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:
cottongin
2026-03-10 05:15:31 -04:00
parent 49bbb54bb9
commit 5dd7a411ed
10 changed files with 380 additions and 120 deletions

View File

@@ -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

View File

@@ -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,

View File

@@ -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
} }

View File

@@ -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

View File

@@ -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
) )
} }
} }

View File

@@ -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"

View File

@@ -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()
} }

View File

@@ -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,

View 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.

View 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`