fix: refactor player state machine to eliminate race conditions
Introduce a Connecting state so the UI reflects user intent immediately, centralize navigation in MainActivity via state transitions, and replace the pauseRequested volatile flag with controller state as single source of truth. Made-with: Cursor
This commit is contained in:
@@ -11,11 +11,13 @@ import androidx.core.content.ContextCompat
|
|||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import xyz.cottongin.radio247.service.PlaybackState
|
||||||
import xyz.cottongin.radio247.ui.navigation.Screen
|
import xyz.cottongin.radio247.ui.navigation.Screen
|
||||||
import xyz.cottongin.radio247.ui.screens.nowplaying.NowPlayingScreen
|
import xyz.cottongin.radio247.ui.screens.nowplaying.NowPlayingScreen
|
||||||
import xyz.cottongin.radio247.ui.screens.settings.SettingsScreen
|
import xyz.cottongin.radio247.ui.screens.settings.SettingsScreen
|
||||||
@@ -38,7 +40,20 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Radio247Theme {
|
Radio247Theme {
|
||||||
|
val app = application as RadioApplication
|
||||||
|
val playbackState by app.controller.state.collectAsState()
|
||||||
var currentScreen by remember { mutableStateOf<Screen>(Screen.StationList) }
|
var currentScreen by remember { mutableStateOf<Screen>(Screen.StationList) }
|
||||||
|
var wasActive by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
LaunchedEffect(playbackState) {
|
||||||
|
val isActive = playbackState !is PlaybackState.Idle
|
||||||
|
if (isActive && !wasActive) {
|
||||||
|
currentScreen = Screen.NowPlaying
|
||||||
|
} else if (!isActive && wasActive && currentScreen == Screen.NowPlaying) {
|
||||||
|
currentScreen = Screen.StationList
|
||||||
|
}
|
||||||
|
wasActive = isActive
|
||||||
|
}
|
||||||
|
|
||||||
BackHandler(enabled = currentScreen != Screen.StationList) {
|
BackHandler(enabled = currentScreen != Screen.StationList) {
|
||||||
currentScreen = Screen.StationList
|
currentScreen = Screen.StationList
|
||||||
|
|||||||
@@ -5,12 +5,21 @@ import xyz.cottongin.radio247.data.model.Station
|
|||||||
|
|
||||||
sealed interface PlaybackState {
|
sealed interface PlaybackState {
|
||||||
data object Idle : PlaybackState
|
data object Idle : PlaybackState
|
||||||
|
data class Connecting(
|
||||||
|
val station: Station,
|
||||||
|
val sessionStartedAt: Long = System.currentTimeMillis()
|
||||||
|
) : PlaybackState
|
||||||
data class Playing(
|
data class Playing(
|
||||||
val station: Station,
|
val station: Station,
|
||||||
val metadata: IcyMetadata? = null,
|
val metadata: IcyMetadata? = null,
|
||||||
val sessionStartedAt: Long = System.currentTimeMillis(),
|
val sessionStartedAt: Long = System.currentTimeMillis(),
|
||||||
val connectionStartedAt: Long = System.currentTimeMillis()
|
val connectionStartedAt: Long = System.currentTimeMillis()
|
||||||
) : PlaybackState
|
) : PlaybackState
|
||||||
|
data class Paused(
|
||||||
|
val station: Station,
|
||||||
|
val metadata: IcyMetadata? = null,
|
||||||
|
val sessionStartedAt: Long
|
||||||
|
) : PlaybackState
|
||||||
data class Reconnecting(
|
data class Reconnecting(
|
||||||
val station: Station,
|
val station: Station,
|
||||||
val metadata: IcyMetadata? = null,
|
val metadata: IcyMetadata? = null,
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ class RadioController(
|
|||||||
val estimatedLatencyMs: StateFlow<Long> = _estimatedLatencyMs.asStateFlow()
|
val estimatedLatencyMs: StateFlow<Long> = _estimatedLatencyMs.asStateFlow()
|
||||||
|
|
||||||
fun play(station: Station) {
|
fun play(station: Station) {
|
||||||
|
val current = _state.value
|
||||||
|
if (current is PlaybackState.Connecting && current.station.id == station.id) return
|
||||||
|
_state.value = PlaybackState.Connecting(station)
|
||||||
val intent = Intent(application, RadioPlaybackService::class.java).apply {
|
val intent = Intent(application, RadioPlaybackService::class.java).apply {
|
||||||
action = RadioPlaybackService.ACTION_PLAY
|
action = RadioPlaybackService.ACTION_PLAY
|
||||||
putExtra(RadioPlaybackService.EXTRA_STATION_ID, station.id)
|
putExtra(RadioPlaybackService.EXTRA_STATION_ID, station.id)
|
||||||
@@ -25,12 +28,28 @@ class RadioController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun stop() {
|
fun stop() {
|
||||||
|
_state.value = PlaybackState.Idle
|
||||||
val intent = Intent(application, RadioPlaybackService::class.java).apply {
|
val intent = Intent(application, RadioPlaybackService::class.java).apply {
|
||||||
action = RadioPlaybackService.ACTION_STOP
|
action = RadioPlaybackService.ACTION_STOP
|
||||||
}
|
}
|
||||||
application.startService(intent)
|
application.startService(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun pause() {
|
||||||
|
val current = _state.value
|
||||||
|
if (current is PlaybackState.Playing) {
|
||||||
|
_state.value = PlaybackState.Paused(
|
||||||
|
station = current.station,
|
||||||
|
metadata = current.metadata,
|
||||||
|
sessionStartedAt = current.sessionStartedAt
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val intent = Intent(application, RadioPlaybackService::class.java).apply {
|
||||||
|
action = RadioPlaybackService.ACTION_PAUSE
|
||||||
|
}
|
||||||
|
application.startService(intent)
|
||||||
|
}
|
||||||
|
|
||||||
fun seekToLive() {
|
fun seekToLive() {
|
||||||
val intent = Intent(application, RadioPlaybackService::class.java).apply {
|
val intent = Intent(application, RadioPlaybackService::class.java).apply {
|
||||||
action = RadioPlaybackService.ACTION_SEEK_LIVE
|
action = RadioPlaybackService.ACTION_SEEK_LIVE
|
||||||
@@ -38,7 +57,6 @@ class RadioController(
|
|||||||
application.startService(intent)
|
application.startService(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Called by the service to update state
|
|
||||||
internal fun updateState(state: PlaybackState) {
|
internal fun updateState(state: PlaybackState) {
|
||||||
_state.value = state
|
_state.value = state
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ class RadioPlaybackService : LifecycleService() {
|
|||||||
companion object {
|
companion object {
|
||||||
const val ACTION_PLAY = "xyz.cottongin.radio247.PLAY"
|
const val ACTION_PLAY = "xyz.cottongin.radio247.PLAY"
|
||||||
const val ACTION_STOP = "xyz.cottongin.radio247.STOP"
|
const val ACTION_STOP = "xyz.cottongin.radio247.STOP"
|
||||||
|
const val ACTION_PAUSE = "xyz.cottongin.radio247.PAUSE"
|
||||||
const val ACTION_SEEK_LIVE = "xyz.cottongin.radio247.SEEK_LIVE"
|
const val ACTION_SEEK_LIVE = "xyz.cottongin.radio247.SEEK_LIVE"
|
||||||
const val EXTRA_STATION_ID = "station_id"
|
const val EXTRA_STATION_ID = "station_id"
|
||||||
}
|
}
|
||||||
@@ -105,6 +106,7 @@ class RadioPlaybackService : LifecycleService() {
|
|||||||
stopSelf()
|
stopSelf()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
ACTION_PAUSE -> handlePause()
|
||||||
ACTION_SEEK_LIVE -> handleSeekLive()
|
ACTION_SEEK_LIVE -> handleSeekLive()
|
||||||
ACTION_STOP -> handleStop()
|
ACTION_STOP -> handleStop()
|
||||||
else -> stopSelf()
|
else -> stopSelf()
|
||||||
@@ -114,17 +116,22 @@ class RadioPlaybackService : LifecycleService() {
|
|||||||
|
|
||||||
private fun launchPlay(stationId: Long) {
|
private fun launchPlay(stationId: Long) {
|
||||||
val oldJob = playJob
|
val oldJob = playJob
|
||||||
|
val currentState = controller.state.value
|
||||||
|
val isResume = currentState is PlaybackState.Paused && currentState.station.id == stationId
|
||||||
playJob = serviceScope.launch {
|
playJob = serviceScope.launch {
|
||||||
oldJob?.let {
|
oldJob?.let {
|
||||||
|
if (!isResume) {
|
||||||
stayConnected = false
|
stayConnected = false
|
||||||
|
}
|
||||||
engine?.stop()
|
engine?.stop()
|
||||||
it.join()
|
it.join()
|
||||||
}
|
}
|
||||||
stayConnected = app.preferences.stayConnected.first()
|
stayConnected = app.preferences.stayConnected.first()
|
||||||
val station = stationDao.getStationById(stationId)
|
val station = stationDao.getStationById(stationId)
|
||||||
if (station != null) {
|
if (station != null) {
|
||||||
handlePlay(station)
|
handlePlay(station, reuseSession = isResume)
|
||||||
} else {
|
} else {
|
||||||
|
controller.updateState(PlaybackState.Idle)
|
||||||
stopSelf()
|
stopSelf()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -133,31 +140,31 @@ class RadioPlaybackService : LifecycleService() {
|
|||||||
override fun onBind(intent: Intent): IBinder? = null
|
override fun onBind(intent: Intent): IBinder? = null
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
cleanup()
|
cleanupResources()
|
||||||
serviceScope.cancel()
|
serviceScope.cancel()
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun cleanup() {
|
private fun cleanupResources() {
|
||||||
engine?.stop()
|
engine?.stop()
|
||||||
engine = null
|
engine = null
|
||||||
releaseLocks()
|
releaseLocks()
|
||||||
mediaSession?.release()
|
mediaSession?.release()
|
||||||
mediaSession = null
|
mediaSession = null
|
||||||
unregisterNetworkCallback()
|
unregisterNetworkCallback()
|
||||||
controller.updateState(PlaybackState.Idle)
|
|
||||||
controller.updateLatency(0)
|
controller.updateLatency(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun handlePlay(station: Station) {
|
private suspend fun handlePlay(station: Station, reuseSession: Boolean = false) {
|
||||||
|
if (!reuseSession) {
|
||||||
sessionStartedAt = System.currentTimeMillis()
|
sessionStartedAt = System.currentTimeMillis()
|
||||||
|
|
||||||
listeningSessionId = listeningSessionDao.insert(
|
listeningSessionId = listeningSessionDao.insert(
|
||||||
ListeningSession(
|
ListeningSession(
|
||||||
stationId = station.id,
|
stationId = station.id,
|
||||||
startedAt = sessionStartedAt
|
startedAt = sessionStartedAt
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
acquireLocks()
|
acquireLocks()
|
||||||
ensureMediaSession()
|
ensureMediaSession()
|
||||||
@@ -174,14 +181,30 @@ class RadioPlaybackService : LifecycleService() {
|
|||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
endConnectionSpan()
|
endConnectionSpan()
|
||||||
|
when (controller.state.value) {
|
||||||
|
is PlaybackState.Paused -> {
|
||||||
|
updateNotification(station, currentMetadata, isReconnecting = false, isPaused = true)
|
||||||
|
}
|
||||||
|
is PlaybackState.Idle -> {
|
||||||
endListeningSession()
|
endListeningSession()
|
||||||
val isActiveJob = playJob == coroutineContext[Job]
|
val isActiveJob = playJob == coroutineContext[Job]
|
||||||
if (isActiveJob) {
|
if (isActiveJob) {
|
||||||
cleanup()
|
cleanupResources()
|
||||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
stopSelf()
|
stopSelf()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else -> {
|
||||||
|
// Connecting to a new station or other transition -- another job takes over
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handlePause() {
|
||||||
|
stayConnected = false
|
||||||
|
retryImmediatelyOnNetwork = false
|
||||||
|
engine?.stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleSeekLive() {
|
private fun handleSeekLive() {
|
||||||
@@ -260,7 +283,7 @@ class RadioPlaybackService : LifecycleService() {
|
|||||||
|
|
||||||
engine!!.start()
|
engine!!.start()
|
||||||
|
|
||||||
val collectorJob = serviceScope.launch collector@ {
|
serviceScope.launch collector@ {
|
||||||
engine!!.events.collect { event ->
|
engine!!.events.collect { event ->
|
||||||
when (event) {
|
when (event) {
|
||||||
is AudioEngineEvent.MetadataChanged -> {
|
is AudioEngineEvent.MetadataChanged -> {
|
||||||
@@ -367,7 +390,7 @@ class RadioPlaybackService : LifecycleService() {
|
|||||||
updateNotification(station, null, isReconnecting = false)
|
updateNotification(station, null, isReconnecting = false)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateNotification(station: Station, metadata: IcyMetadata?, isReconnecting: Boolean) {
|
private fun updateNotification(station: Station, metadata: IcyMetadata?, isReconnecting: Boolean, isPaused: Boolean = false) {
|
||||||
val session = mediaSession ?: return
|
val session = mediaSession ?: return
|
||||||
val stopIntent = Intent(this, RadioPlaybackService::class.java).apply {
|
val stopIntent = Intent(this, RadioPlaybackService::class.java).apply {
|
||||||
action = ACTION_STOP
|
action = ACTION_STOP
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
package xyz.cottongin.radio247.ui.components
|
package xyz.cottongin.radio247.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Pause
|
||||||
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
import androidx.compose.material.icons.filled.Stop
|
import androidx.compose.material.icons.filled.Stop
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
@@ -17,9 +22,10 @@ import androidx.compose.material3.Text
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import xyz.cottongin.radio247.audio.IcyMetadata
|
import coil3.compose.AsyncImage
|
||||||
import xyz.cottongin.radio247.data.model.Station
|
|
||||||
import xyz.cottongin.radio247.service.PlaybackState
|
import xyz.cottongin.radio247.service.PlaybackState
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -29,12 +35,17 @@ fun MiniPlayer(
|
|||||||
onStop: () -> Unit,
|
onStop: () -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val (station, metadata) = when (state) {
|
val (station, metadata, isPaused) = when (state) {
|
||||||
is PlaybackState.Playing -> state.station to state.metadata
|
is PlaybackState.Connecting -> Triple(state.station, null, false)
|
||||||
is PlaybackState.Reconnecting -> state.station to state.metadata
|
is PlaybackState.Playing -> Triple(state.station, state.metadata, false)
|
||||||
|
is PlaybackState.Paused -> Triple(state.station, state.metadata, true)
|
||||||
|
is PlaybackState.Reconnecting -> Triple(state.station, state.metadata, false)
|
||||||
PlaybackState.Idle -> return
|
PlaybackState.Idle -> return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val artUrl = metadata?.streamUrl?.takeIf { it.endsWith(".jpg", true) || it.endsWith(".png", true) }
|
||||||
|
?: station.defaultArtworkUrl
|
||||||
|
|
||||||
Surface(
|
Surface(
|
||||||
modifier = modifier.fillMaxWidth(),
|
modifier = modifier.fillMaxWidth(),
|
||||||
tonalElevation = 2.dp
|
tonalElevation = 2.dp
|
||||||
@@ -42,31 +53,54 @@ fun MiniPlayer(
|
|||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.clickable(onClick = onTap)
|
.clickable(onClick = onTap)
|
||||||
.padding(horizontal = 16.dp, vertical = 12.dp)
|
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||||
.fillMaxWidth(),
|
.fillMaxWidth(),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
|
if (artUrl != null) {
|
||||||
|
AsyncImage(
|
||||||
|
model = artUrl,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(40.dp)
|
||||||
|
.clip(RoundedCornerShape(6.dp))
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(6.dp))
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(10.dp))
|
||||||
|
}
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Text(
|
Text(
|
||||||
text = station.name,
|
text = station.name,
|
||||||
style = MaterialTheme.typography.titleSmall,
|
style = MaterialTheme.typography.titleSmall,
|
||||||
modifier = Modifier.weight(1f)
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
metadata?.title?.let { title ->
|
val subtitle = when {
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
state is PlaybackState.Connecting -> "Connecting..."
|
||||||
|
isPaused -> "Paused"
|
||||||
|
state is PlaybackState.Reconnecting -> "Reconnecting..."
|
||||||
|
metadata?.title != null -> metadata.title
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
subtitle?.let {
|
||||||
Text(
|
Text(
|
||||||
text = title,
|
text = it,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
maxLines = 1
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
}
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = onStop,
|
onClick = onStop,
|
||||||
modifier = Modifier.size(40.dp)
|
modifier = Modifier.size(40.dp)
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Stop,
|
Icons.Default.Stop,
|
||||||
contentDescription = "Stop"
|
contentDescription = "Stop",
|
||||||
|
tint = MaterialTheme.colorScheme.error
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,14 +18,13 @@ import androidx.compose.foundation.verticalScroll
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.FastForward
|
import androidx.compose.material.icons.filled.FastForward
|
||||||
|
import androidx.compose.material.icons.filled.Pause
|
||||||
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
import androidx.compose.material.icons.filled.Stop
|
import androidx.compose.material.icons.filled.Stop
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.ButtonDefaults
|
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.FilledTonalButton
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -69,18 +68,26 @@ fun NowPlayingScreen(
|
|||||||
val bufferMs by viewModel.bufferMs.collectAsState()
|
val bufferMs by viewModel.bufferMs.collectAsState()
|
||||||
|
|
||||||
when (val state = playbackState) {
|
when (val state = playbackState) {
|
||||||
|
is PlaybackState.Connecting,
|
||||||
is PlaybackState.Playing,
|
is PlaybackState.Playing,
|
||||||
|
is PlaybackState.Paused,
|
||||||
is PlaybackState.Reconnecting -> {
|
is PlaybackState.Reconnecting -> {
|
||||||
val station = when (state) {
|
val station = when (state) {
|
||||||
|
is PlaybackState.Connecting -> state.station
|
||||||
is PlaybackState.Playing -> state.station
|
is PlaybackState.Playing -> state.station
|
||||||
|
is PlaybackState.Paused -> state.station
|
||||||
is PlaybackState.Reconnecting -> state.station
|
is PlaybackState.Reconnecting -> state.station
|
||||||
else -> return
|
else -> return
|
||||||
}
|
}
|
||||||
val metadata = when (state) {
|
val metadata = when (state) {
|
||||||
is PlaybackState.Playing -> state.metadata
|
is PlaybackState.Playing -> state.metadata
|
||||||
|
is PlaybackState.Paused -> state.metadata
|
||||||
is PlaybackState.Reconnecting -> state.metadata
|
is PlaybackState.Reconnecting -> state.metadata
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
|
val isPaused = state is PlaybackState.Paused
|
||||||
|
val isPlaying = state is PlaybackState.Playing
|
||||||
|
val isConnecting = state is PlaybackState.Connecting
|
||||||
|
|
||||||
Box(modifier = modifier.fillMaxSize()) {
|
Box(modifier = modifier.fillMaxSize()) {
|
||||||
Column(
|
Column(
|
||||||
@@ -145,9 +152,15 @@ fun NowPlayingScreen(
|
|||||||
modifier = Modifier.padding(vertical = 8.dp)
|
modifier = Modifier.padding(vertical = 8.dp)
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = formatTrackInfo(metadata),
|
text = when {
|
||||||
|
isConnecting -> "Connecting..."
|
||||||
|
isPaused -> "Paused"
|
||||||
|
else -> formatTrackInfo(metadata)
|
||||||
|
},
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = if (isPaused || isConnecting)
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
|
||||||
|
else MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
@@ -156,6 +169,7 @@ fun NowPlayingScreen(
|
|||||||
text = "Session: ${formatElapsed(sessionElapsed)}",
|
text = "Session: ${formatElapsed(sessionElapsed)}",
|
||||||
style = MaterialTheme.typography.bodyMedium
|
style = MaterialTheme.typography.bodyMedium
|
||||||
)
|
)
|
||||||
|
if (!isPaused && !isConnecting) {
|
||||||
Text(
|
Text(
|
||||||
text = "Connected: ${formatElapsed(connectionElapsed)}",
|
text = "Connected: ${formatElapsed(connectionElapsed)}",
|
||||||
style = MaterialTheme.typography.bodyMedium
|
style = MaterialTheme.typography.bodyMedium
|
||||||
@@ -164,6 +178,7 @@ fun NowPlayingScreen(
|
|||||||
text = "Latency: ~${estimatedLatencyMs}ms",
|
text = "Latency: ~${estimatedLatencyMs}ms",
|
||||||
style = MaterialTheme.typography.bodyMedium
|
style = MaterialTheme.typography.bodyMedium
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
@@ -172,21 +187,50 @@ fun NowPlayingScreen(
|
|||||||
horizontalArrangement = Arrangement.Center,
|
horizontalArrangement = Arrangement.Center,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
|
if (isPaused) {
|
||||||
|
IconButton(
|
||||||
|
onClick = { viewModel.resume() },
|
||||||
|
modifier = Modifier.size(64.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.PlayArrow,
|
||||||
|
contentDescription = "Resume",
|
||||||
|
modifier = Modifier.size(48.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = { viewModel.skipAhead() },
|
onClick = { viewModel.skipAhead() },
|
||||||
modifier = Modifier.size(64.dp),
|
modifier = Modifier.size(64.dp),
|
||||||
enabled = state is PlaybackState.Playing
|
enabled = isPlaying
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Filled.FastForward,
|
Icons.Filled.FastForward,
|
||||||
contentDescription = "Skip ahead ~1s",
|
contentDescription = "Skip ahead ~1s",
|
||||||
modifier = Modifier.size(40.dp),
|
modifier = Modifier.size(40.dp),
|
||||||
tint = if (state is PlaybackState.Playing)
|
tint = if (isPlaying)
|
||||||
MaterialTheme.colorScheme.primary
|
MaterialTheme.colorScheme.primary
|
||||||
else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)
|
else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.width(24.dp))
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
|
IconButton(
|
||||||
|
onClick = { viewModel.pause() },
|
||||||
|
modifier = Modifier.size(64.dp),
|
||||||
|
enabled = isPlaying
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Pause,
|
||||||
|
contentDescription = "Pause",
|
||||||
|
modifier = Modifier.size(40.dp),
|
||||||
|
tint = if (isPlaying)
|
||||||
|
MaterialTheme.colorScheme.onSurface
|
||||||
|
else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = { viewModel.stop() },
|
onClick = { viewModel.stop() },
|
||||||
modifier = Modifier.size(64.dp)
|
modifier = Modifier.size(64.dp)
|
||||||
@@ -257,35 +301,34 @@ fun NowPlayingScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
if (isConnecting) {
|
||||||
PlaybackState.Idle -> {
|
|
||||||
Column(modifier = modifier.fillMaxSize()) {
|
|
||||||
TopAppBar(
|
|
||||||
title = { },
|
|
||||||
navigationIcon = {
|
|
||||||
IconButton(onClick = onBack) {
|
|
||||||
Icon(
|
|
||||||
Icons.AutoMirrored.Filled.ArrowBack,
|
|
||||||
contentDescription = "Back"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(24.dp),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Text(
|
Card(
|
||||||
text = "Nothing playing",
|
modifier = Modifier.padding(32.dp),
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
) {
|
||||||
)
|
Column(
|
||||||
|
modifier = Modifier.padding(32.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Text("Connecting to ${station.name}...")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
PlaybackState.Idle -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun formatTrackInfo(metadata: IcyMetadata?): String {
|
private fun formatTrackInfo(metadata: IcyMetadata?): String {
|
||||||
if (metadata == null) return "No track info"
|
if (metadata == null) return "No track info"
|
||||||
|
|||||||
@@ -50,7 +50,9 @@ class NowPlayingViewModel(application: Application) : AndroidViewModel(applicati
|
|||||||
|
|
||||||
sessionElapsed = combine(ticker, playbackState) { now, state ->
|
sessionElapsed = combine(ticker, playbackState) { now, state ->
|
||||||
when (state) {
|
when (state) {
|
||||||
|
is PlaybackState.Connecting -> now - state.sessionStartedAt
|
||||||
is PlaybackState.Playing -> now - state.sessionStartedAt
|
is PlaybackState.Playing -> now - state.sessionStartedAt
|
||||||
|
is PlaybackState.Paused -> now - state.sessionStartedAt
|
||||||
is PlaybackState.Reconnecting -> now - state.sessionStartedAt
|
is PlaybackState.Reconnecting -> now - state.sessionStartedAt
|
||||||
else -> 0L
|
else -> 0L
|
||||||
}
|
}
|
||||||
@@ -66,12 +68,16 @@ class NowPlayingViewModel(application: Application) : AndroidViewModel(applicati
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
playbackState.collect { state ->
|
playbackState.collect { state ->
|
||||||
val (station, metadata) = when (state) {
|
val (station, metadata) = when (state) {
|
||||||
|
is PlaybackState.Connecting -> state.station to null
|
||||||
is PlaybackState.Playing -> state.station to state.metadata
|
is PlaybackState.Playing -> state.station to state.metadata
|
||||||
|
is PlaybackState.Paused -> state.station to state.metadata
|
||||||
is PlaybackState.Reconnecting -> state.station to state.metadata
|
is PlaybackState.Reconnecting -> state.station to state.metadata
|
||||||
PlaybackState.Idle -> null to null
|
PlaybackState.Idle -> null to null
|
||||||
}
|
}
|
||||||
when (state) {
|
when (state) {
|
||||||
|
is PlaybackState.Connecting,
|
||||||
is PlaybackState.Playing,
|
is PlaybackState.Playing,
|
||||||
|
is PlaybackState.Paused,
|
||||||
is PlaybackState.Reconnecting -> {
|
is PlaybackState.Reconnecting -> {
|
||||||
artworkResolveJob?.cancel()
|
artworkResolveJob?.cancel()
|
||||||
artworkResolveJob = viewModelScope.launch {
|
artworkResolveJob = viewModelScope.launch {
|
||||||
@@ -99,6 +105,17 @@ class NowPlayingViewModel(application: Application) : AndroidViewModel(applicati
|
|||||||
controller.stop()
|
controller.stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun pause() {
|
||||||
|
controller.pause()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resume() {
|
||||||
|
val state = playbackState.value
|
||||||
|
if (state is PlaybackState.Paused) {
|
||||||
|
controller.play(state.station)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun skipAhead() {
|
fun skipAhead() {
|
||||||
controller.seekToLive()
|
controller.seekToLive()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,20 +5,20 @@ import androidx.activity.compose.rememberLauncherForActivityResult
|
|||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.combinedClickable
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.Folder
|
import androidx.compose.material.icons.filled.Folder
|
||||||
@@ -32,7 +32,6 @@ import androidx.compose.material3.DropdownMenuItem
|
|||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.ListItem
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -46,9 +45,11 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import coil3.compose.AsyncImage
|
||||||
import xyz.cottongin.radio247.data.model.Playlist
|
import xyz.cottongin.radio247.data.model.Playlist
|
||||||
import xyz.cottongin.radio247.data.model.Station
|
import xyz.cottongin.radio247.data.model.Station
|
||||||
import xyz.cottongin.radio247.service.PlaybackState
|
import xyz.cottongin.radio247.service.PlaybackState
|
||||||
@@ -115,7 +116,9 @@ fun StationListScreen(
|
|||||||
},
|
},
|
||||||
bottomBar = {
|
bottomBar = {
|
||||||
when (playbackState) {
|
when (playbackState) {
|
||||||
|
is PlaybackState.Connecting,
|
||||||
is PlaybackState.Playing,
|
is PlaybackState.Playing,
|
||||||
|
is PlaybackState.Paused,
|
||||||
is PlaybackState.Reconnecting -> MiniPlayer(
|
is PlaybackState.Reconnecting -> MiniPlayer(
|
||||||
state = playbackState,
|
state = playbackState,
|
||||||
onTap = onNavigateToNowPlaying,
|
onTap = onNavigateToNowPlaying,
|
||||||
@@ -126,7 +129,9 @@ fun StationListScreen(
|
|||||||
}
|
}
|
||||||
) { paddingValues ->
|
) { paddingValues ->
|
||||||
val currentPlayingStationId = when (val state = playbackState) {
|
val currentPlayingStationId = when (val state = playbackState) {
|
||||||
|
is PlaybackState.Connecting -> state.station.id
|
||||||
is PlaybackState.Playing -> state.station.id
|
is PlaybackState.Playing -> state.station.id
|
||||||
|
is PlaybackState.Paused -> state.station.id
|
||||||
is PlaybackState.Reconnecting -> state.station.id
|
is PlaybackState.Reconnecting -> state.station.id
|
||||||
PlaybackState.Idle -> null
|
PlaybackState.Idle -> null
|
||||||
}
|
}
|
||||||
@@ -256,7 +261,7 @@ private fun PlaylistSectionHeader(
|
|||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = if (isExpanded) Icons.Default.Folder else Icons.Default.Folder,
|
imageVector = Icons.Default.Folder,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier.size(24.dp)
|
modifier = Modifier.size(24.dp)
|
||||||
)
|
)
|
||||||
@@ -300,7 +305,8 @@ private fun StationRow(
|
|||||||
.combinedClickable(
|
.combinedClickable(
|
||||||
onClick = onPlay,
|
onClick = onPlay,
|
||||||
onLongClick = { showMenu = true }
|
onLongClick = { showMenu = true }
|
||||||
),
|
)
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
IconButton(
|
IconButton(
|
||||||
@@ -315,6 +321,17 @@ private fun StationRow(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
if (station.defaultArtworkUrl != null) {
|
||||||
|
AsyncImage(
|
||||||
|
model = station.defaultArtworkUrl,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(36.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant, CircleShape)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
}
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Text(
|
Text(
|
||||||
text = station.name,
|
text = station.name,
|
||||||
|
|||||||
46
chat-summaries/2026-03-09_player-state-machine-refactor.md
Normal file
46
chat-summaries/2026-03-09_player-state-machine-refactor.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# Player State Machine Refactor
|
||||||
|
|
||||||
|
## Task
|
||||||
|
Refactored the player state management to eliminate race conditions during rapid station tapping, stop/play transitions, and the NowPlayingScreen bounce-back bug.
|
||||||
|
|
||||||
|
## Changes Made
|
||||||
|
|
||||||
|
### PlaybackState.kt
|
||||||
|
- Added `Connecting` state variant to represent the period between user tap and service processing the intent.
|
||||||
|
|
||||||
|
### RadioController.kt
|
||||||
|
- `play()` now sets state to `Connecting` synchronously before sending the intent, eliminating the async gap.
|
||||||
|
- `stop()` now sets state to `Idle` synchronously before sending the intent.
|
||||||
|
- `pause()` now sets state to `Paused` synchronously before sending the intent.
|
||||||
|
- Added redundancy guard: double-tapping a station that's already `Connecting` is a no-op.
|
||||||
|
|
||||||
|
### RadioPlaybackService.kt
|
||||||
|
- Removed the `pauseRequested` volatile flag entirely.
|
||||||
|
- The `handlePlay()` finally block now checks `controller.state.value` (the single source of truth) instead of the volatile flag to determine cleanup behavior.
|
||||||
|
- Renamed `cleanup()` to `cleanupResources()` since it no longer sets `Idle` state (that's now the controller's responsibility).
|
||||||
|
- `handlePause()` and `handleStop()` no longer set `pauseRequested`.
|
||||||
|
|
||||||
|
### MainActivity.kt
|
||||||
|
- Centralized navigation logic via a `LaunchedEffect` that observes `RadioController.state`.
|
||||||
|
- Navigates to NowPlaying on `Idle -> Active` transitions.
|
||||||
|
- Navigates back to StationList on `Active -> Idle` transitions while on NowPlaying.
|
||||||
|
- Station switching (`Playing(A) -> Connecting(B)`) stays on NowPlaying.
|
||||||
|
|
||||||
|
### NowPlayingScreen.kt
|
||||||
|
- Removed the `LaunchedEffect(playbackState)` that called `onBack()` on `Idle` (was the source of the bounce-back bug).
|
||||||
|
- Added `Connecting` to the `when` branches, showing a loading spinner overlay.
|
||||||
|
- Disabled pause/skip-ahead buttons while connecting.
|
||||||
|
|
||||||
|
### NowPlayingViewModel.kt
|
||||||
|
- Added `Connecting` handling in session timer (uses `sessionStartedAt`), connection timer (shows 0), and artwork resolver (uses station default artwork).
|
||||||
|
|
||||||
|
### StationListScreen.kt
|
||||||
|
- Removed `onNavigateToNowPlaying()` from `onPlay` callbacks (navigation is now centralized in MainActivity).
|
||||||
|
- Added `Connecting` to mini player visibility and "now playing" station highlight.
|
||||||
|
|
||||||
|
### MiniPlayer.kt
|
||||||
|
- Added `Connecting` to the destructuring `when` block.
|
||||||
|
- Shows "Connecting..." subtitle for the `Connecting` state.
|
||||||
|
|
||||||
|
## Follow-up Items
|
||||||
|
- The `stayConnected` and `retryImmediatelyOnNetwork` volatile flags in RadioPlaybackService could be further consolidated into the state machine in a future pass.
|
||||||
38
chat-summaries/2026-03-09_ui-polish-and-pause.md
Normal file
38
chat-summaries/2026-03-09_ui-polish-and-pause.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# UI polish: navigation, pause, artwork, scrollability
|
||||||
|
|
||||||
|
**Date:** 2026-03-09
|
||||||
|
|
||||||
|
## Task description
|
||||||
|
Five UI/UX improvements after manual testing.
|
||||||
|
|
||||||
|
## Changes made
|
||||||
|
|
||||||
|
### 1. Auto-navigate to Now Playing on station tap (StationListScreen.kt)
|
||||||
|
- `onPlay` callback now calls `onNavigateToNowPlaying()` immediately after `viewModel.playStation()`.
|
||||||
|
|
||||||
|
### 2. Auto-navigate back to station list on stop (NowPlayingScreen.kt)
|
||||||
|
- `LaunchedEffect(playbackState)` watches for `PlaybackState.Idle` and calls `onBack()`.
|
||||||
|
- Removed the "Nothing playing" dead-end screen.
|
||||||
|
|
||||||
|
### 3. Pause/resume support
|
||||||
|
- **PlaybackState.kt**: Added `Paused(station, metadata, sessionStartedAt)` state.
|
||||||
|
- **RadioController.kt**: Added `pause()` method sending `ACTION_PAUSE`.
|
||||||
|
- **RadioPlaybackService.kt**: `handlePause()` sets `pauseRequested = true` and stops engine. In `handlePlay`'s finally block, if paused: updates state to `Paused`, keeps listening session alive, doesn't cleanup service. On resume (ACTION_PLAY with same station while paused), reuses the session.
|
||||||
|
- **NowPlayingViewModel.kt**: Added `pause()` and `resume()`. Session timer keeps ticking during pause.
|
||||||
|
- **NowPlayingScreen.kt**: Transport controls adapt to state — paused shows large play/resume button + stop; playing shows skip-ahead + pause + stop.
|
||||||
|
|
||||||
|
### 4. Artwork in mini player and station list
|
||||||
|
- **MiniPlayer.kt**: Shows 40dp rounded artwork thumbnail from ICY StreamUrl or station's `defaultArtworkUrl`. Shows "Paused"/"Reconnecting..." subtitle as appropriate.
|
||||||
|
- **StationListScreen.kt**: Station rows show a 36dp circular artwork thumbnail from `defaultArtworkUrl` (EXTIMG) when available.
|
||||||
|
|
||||||
|
### 5. Scrollability / drag-n-drop readiness
|
||||||
|
- LazyColumn was already scrollable. Added `padding(vertical = 4.dp)` to station rows for better touch targets. All items have stable keys (`station.id`, `"playlist_header_${playlist.id}"`) ready for reorderable integration.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
- `app/src/main/java/.../service/PlaybackState.kt`
|
||||||
|
- `app/src/main/java/.../service/RadioController.kt`
|
||||||
|
- `app/src/main/java/.../service/RadioPlaybackService.kt`
|
||||||
|
- `app/src/main/java/.../ui/screens/nowplaying/NowPlayingScreen.kt`
|
||||||
|
- `app/src/main/java/.../ui/screens/nowplaying/NowPlayingViewModel.kt`
|
||||||
|
- `app/src/main/java/.../ui/screens/stationlist/StationListScreen.kt`
|
||||||
|
- `app/src/main/java/.../ui/components/MiniPlayer.kt`
|
||||||
Reference in New Issue
Block a user