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.setContent
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import xyz.cottongin.radio247.service.PlaybackState
import xyz.cottongin.radio247.ui.navigation.Screen
import xyz.cottongin.radio247.ui.screens.nowplaying.NowPlayingScreen
import xyz.cottongin.radio247.ui.screens.settings.SettingsScreen
@@ -38,7 +40,20 @@ class MainActivity : ComponentActivity() {
}
}
Radio247Theme {
val app = application as RadioApplication
val playbackState by app.controller.state.collectAsState()
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) {
currentScreen = Screen.StationList

View File

@@ -5,12 +5,21 @@ import xyz.cottongin.radio247.data.model.Station
sealed interface PlaybackState {
data object Idle : PlaybackState
data class Connecting(
val station: Station,
val sessionStartedAt: Long = System.currentTimeMillis()
) : PlaybackState
data class Playing(
val station: Station,
val metadata: IcyMetadata? = null,
val sessionStartedAt: Long = System.currentTimeMillis(),
val connectionStartedAt: Long = System.currentTimeMillis()
) : PlaybackState
data class Paused(
val station: Station,
val metadata: IcyMetadata? = null,
val sessionStartedAt: Long
) : PlaybackState
data class Reconnecting(
val station: Station,
val metadata: IcyMetadata? = null,

View File

@@ -17,6 +17,9 @@ class RadioController(
val estimatedLatencyMs: StateFlow<Long> = _estimatedLatencyMs.asStateFlow()
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 {
action = RadioPlaybackService.ACTION_PLAY
putExtra(RadioPlaybackService.EXTRA_STATION_ID, station.id)
@@ -25,12 +28,28 @@ class RadioController(
}
fun stop() {
_state.value = PlaybackState.Idle
val intent = Intent(application, RadioPlaybackService::class.java).apply {
action = RadioPlaybackService.ACTION_STOP
}
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() {
val intent = Intent(application, RadioPlaybackService::class.java).apply {
action = RadioPlaybackService.ACTION_SEEK_LIVE
@@ -38,7 +57,6 @@ class RadioController(
application.startService(intent)
}
// Called by the service to update state
internal fun updateState(state: PlaybackState) {
_state.value = state
}

View File

@@ -42,6 +42,7 @@ class RadioPlaybackService : LifecycleService() {
companion object {
const val ACTION_PLAY = "xyz.cottongin.radio247.PLAY"
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 EXTRA_STATION_ID = "station_id"
}
@@ -105,6 +106,7 @@ class RadioPlaybackService : LifecycleService() {
stopSelf()
}
}
ACTION_PAUSE -> handlePause()
ACTION_SEEK_LIVE -> handleSeekLive()
ACTION_STOP -> handleStop()
else -> stopSelf()
@@ -114,17 +116,22 @@ class RadioPlaybackService : LifecycleService() {
private fun launchPlay(stationId: Long) {
val oldJob = playJob
val currentState = controller.state.value
val isResume = currentState is PlaybackState.Paused && currentState.station.id == stationId
playJob = serviceScope.launch {
oldJob?.let {
stayConnected = false
if (!isResume) {
stayConnected = false
}
engine?.stop()
it.join()
}
stayConnected = app.preferences.stayConnected.first()
val station = stationDao.getStationById(stationId)
if (station != null) {
handlePlay(station)
handlePlay(station, reuseSession = isResume)
} else {
controller.updateState(PlaybackState.Idle)
stopSelf()
}
}
@@ -133,31 +140,31 @@ class RadioPlaybackService : LifecycleService() {
override fun onBind(intent: Intent): IBinder? = null
override fun onDestroy() {
cleanup()
cleanupResources()
serviceScope.cancel()
super.onDestroy()
}
private fun cleanup() {
private fun cleanupResources() {
engine?.stop()
engine = null
releaseLocks()
mediaSession?.release()
mediaSession = null
unregisterNetworkCallback()
controller.updateState(PlaybackState.Idle)
controller.updateLatency(0)
}
private suspend fun handlePlay(station: Station) {
sessionStartedAt = System.currentTimeMillis()
listeningSessionId = listeningSessionDao.insert(
ListeningSession(
stationId = station.id,
startedAt = sessionStartedAt
private suspend fun handlePlay(station: Station, reuseSession: Boolean = false) {
if (!reuseSession) {
sessionStartedAt = System.currentTimeMillis()
listeningSessionId = listeningSessionDao.insert(
ListeningSession(
stationId = station.id,
startedAt = sessionStartedAt
)
)
)
}
acquireLocks()
ensureMediaSession()
@@ -174,16 +181,32 @@ class RadioPlaybackService : LifecycleService() {
}
} finally {
endConnectionSpan()
endListeningSession()
val isActiveJob = playJob == coroutineContext[Job]
if (isActiveJob) {
cleanup()
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
when (controller.state.value) {
is PlaybackState.Paused -> {
updateNotification(station, currentMetadata, isReconnecting = false, isPaused = true)
}
is PlaybackState.Idle -> {
endListeningSession()
val isActiveJob = playJob == coroutineContext[Job]
if (isActiveJob) {
cleanupResources()
stopForeground(STOP_FOREGROUND_REMOVE)
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() {
engine?.skipAhead()
}
@@ -260,7 +283,7 @@ class RadioPlaybackService : LifecycleService() {
engine!!.start()
val collectorJob = serviceScope.launch collector@ {
serviceScope.launch collector@ {
engine!!.events.collect { event ->
when (event) {
is AudioEngineEvent.MetadataChanged -> {
@@ -367,7 +390,7 @@ class RadioPlaybackService : LifecycleService() {
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 stopIntent = Intent(this, RadioPlaybackService::class.java).apply {
action = ACTION_STOP

View File

@@ -1,13 +1,18 @@
package xyz.cottongin.radio247.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
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.material3.Icon
import androidx.compose.material3.IconButton
@@ -17,9 +22,10 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
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 xyz.cottongin.radio247.audio.IcyMetadata
import xyz.cottongin.radio247.data.model.Station
import coil3.compose.AsyncImage
import xyz.cottongin.radio247.service.PlaybackState
@Composable
@@ -29,12 +35,17 @@ fun MiniPlayer(
onStop: () -> Unit,
modifier: Modifier = Modifier
) {
val (station, metadata) = when (state) {
is PlaybackState.Playing -> state.station to state.metadata
is PlaybackState.Reconnecting -> state.station to state.metadata
val (station, metadata, isPaused) = when (state) {
is PlaybackState.Connecting -> Triple(state.station, null, false)
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
}
val artUrl = metadata?.streamUrl?.takeIf { it.endsWith(".jpg", true) || it.endsWith(".png", true) }
?: station.defaultArtworkUrl
Surface(
modifier = modifier.fillMaxWidth(),
tonalElevation = 2.dp
@@ -42,31 +53,54 @@ fun MiniPlayer(
Row(
modifier = Modifier
.clickable(onClick = onTap)
.padding(horizontal = 16.dp, vertical = 12.dp)
.padding(horizontal = 12.dp, vertical = 8.dp)
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = station.name,
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.weight(1f)
)
metadata?.title?.let { title ->
Spacer(modifier = Modifier.width(8.dp))
Text(
text = title,
style = MaterialTheme.typography.bodySmall,
maxLines = 1
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))
}
Spacer(modifier = Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = station.name,
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
val subtitle = when {
state is PlaybackState.Connecting -> "Connecting..."
isPaused -> "Paused"
state is PlaybackState.Reconnecting -> "Reconnecting..."
metadata?.title != null -> metadata.title
else -> null
}
subtitle?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Spacer(modifier = Modifier.width(4.dp))
IconButton(
onClick = onStop,
modifier = Modifier.size(40.dp)
) {
Icon(
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.automirrored.filled.ArrowBack
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.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -69,18 +68,26 @@ fun NowPlayingScreen(
val bufferMs by viewModel.bufferMs.collectAsState()
when (val state = playbackState) {
is PlaybackState.Connecting,
is PlaybackState.Playing,
is PlaybackState.Paused,
is PlaybackState.Reconnecting -> {
val station = when (state) {
is PlaybackState.Connecting -> state.station
is PlaybackState.Playing -> state.station
is PlaybackState.Paused -> state.station
is PlaybackState.Reconnecting -> state.station
else -> return
}
val metadata = when (state) {
is PlaybackState.Playing -> state.metadata
is PlaybackState.Paused -> state.metadata
is PlaybackState.Reconnecting -> state.metadata
else -> null
}
val isPaused = state is PlaybackState.Paused
val isPlaying = state is PlaybackState.Playing
val isConnecting = state is PlaybackState.Connecting
Box(modifier = modifier.fillMaxSize()) {
Column(
@@ -145,9 +152,15 @@ fun NowPlayingScreen(
modifier = Modifier.padding(vertical = 8.dp)
)
Text(
text = formatTrackInfo(metadata),
text = when {
isConnecting -> "Connecting..."
isPaused -> "Paused"
else -> formatTrackInfo(metadata)
},
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))
@@ -156,14 +169,16 @@ fun NowPlayingScreen(
text = "Session: ${formatElapsed(sessionElapsed)}",
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "Connected: ${formatElapsed(connectionElapsed)}",
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "Latency: ~${estimatedLatencyMs}ms",
style = MaterialTheme.typography.bodyMedium
)
if (!isPaused && !isConnecting) {
Text(
text = "Connected: ${formatElapsed(connectionElapsed)}",
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "Latency: ~${estimatedLatencyMs}ms",
style = MaterialTheme.typography.bodyMedium
)
}
Spacer(modifier = Modifier.height(24.dp))
@@ -172,21 +187,50 @@ fun NowPlayingScreen(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
IconButton(
onClick = { viewModel.skipAhead() },
modifier = Modifier.size(64.dp),
enabled = state is PlaybackState.Playing
) {
Icon(
Icons.Filled.FastForward,
contentDescription = "Skip ahead ~1s",
modifier = Modifier.size(40.dp),
tint = if (state is PlaybackState.Playing)
MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)
)
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(
onClick = { viewModel.skipAhead() },
modifier = Modifier.size(64.dp),
enabled = isPlaying
) {
Icon(
Icons.Filled.FastForward,
contentDescription = "Skip ahead ~1s",
modifier = Modifier.size(40.dp),
tint = if (isPlaying)
MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)
)
}
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(24.dp))
Spacer(modifier = Modifier.width(16.dp))
IconButton(
onClick = { viewModel.stop() },
modifier = Modifier.size(64.dp)
@@ -257,33 +301,32 @@ fun NowPlayingScreen(
}
}
}
}
}
PlaybackState.Idle -> {
Column(modifier = modifier.fillMaxSize()) {
TopAppBar(
title = { },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back"
)
if (isConnecting) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
contentAlignment = Alignment.Center
) {
Card(
modifier = Modifier.padding(32.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
) {
Column(
modifier = Modifier.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
CircularProgressIndicator()
Spacer(modifier = Modifier.height(16.dp))
Text("Connecting to ${station.name}...")
}
}
}
)
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = "Nothing playing",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
PlaybackState.Idle -> {}
}
}

View File

@@ -50,7 +50,9 @@ class NowPlayingViewModel(application: Application) : AndroidViewModel(applicati
sessionElapsed = combine(ticker, playbackState) { now, state ->
when (state) {
is PlaybackState.Connecting -> now - state.sessionStartedAt
is PlaybackState.Playing -> now - state.sessionStartedAt
is PlaybackState.Paused -> now - state.sessionStartedAt
is PlaybackState.Reconnecting -> now - state.sessionStartedAt
else -> 0L
}
@@ -66,12 +68,16 @@ class NowPlayingViewModel(application: Application) : AndroidViewModel(applicati
viewModelScope.launch {
playbackState.collect { state ->
val (station, metadata) = when (state) {
is PlaybackState.Connecting -> state.station to null
is PlaybackState.Playing -> state.station to state.metadata
is PlaybackState.Paused -> state.station to state.metadata
is PlaybackState.Reconnecting -> state.station to state.metadata
PlaybackState.Idle -> null to null
}
when (state) {
is PlaybackState.Connecting,
is PlaybackState.Playing,
is PlaybackState.Paused,
is PlaybackState.Reconnecting -> {
artworkResolveJob?.cancel()
artworkResolveJob = viewModelScope.launch {
@@ -99,6 +105,17 @@ class NowPlayingViewModel(application: Application) : AndroidViewModel(applicati
controller.stop()
}
fun pause() {
controller.pause()
}
fun resume() {
val state = playbackState.value
if (state is PlaybackState.Paused) {
controller.play(state.station)
}
}
fun skipAhead() {
controller.seekToLive()
}

View File

@@ -5,20 +5,20 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
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.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
@@ -46,9 +45,11 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.viewmodel.compose.viewModel
import coil3.compose.AsyncImage
import xyz.cottongin.radio247.data.model.Playlist
import xyz.cottongin.radio247.data.model.Station
import xyz.cottongin.radio247.service.PlaybackState
@@ -115,7 +116,9 @@ fun StationListScreen(
},
bottomBar = {
when (playbackState) {
is PlaybackState.Connecting,
is PlaybackState.Playing,
is PlaybackState.Paused,
is PlaybackState.Reconnecting -> MiniPlayer(
state = playbackState,
onTap = onNavigateToNowPlaying,
@@ -126,7 +129,9 @@ fun StationListScreen(
}
) { paddingValues ->
val currentPlayingStationId = when (val state = playbackState) {
is PlaybackState.Connecting -> state.station.id
is PlaybackState.Playing -> state.station.id
is PlaybackState.Paused -> state.station.id
is PlaybackState.Reconnecting -> state.station.id
PlaybackState.Idle -> null
}
@@ -256,7 +261,7 @@ private fun PlaylistSectionHeader(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = if (isExpanded) Icons.Default.Folder else Icons.Default.Folder,
imageVector = Icons.Default.Folder,
contentDescription = null,
modifier = Modifier.size(24.dp)
)
@@ -266,17 +271,17 @@ private fun PlaylistSectionHeader(
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.weight(1f)
)
IconButton(
onClick = { onToggleStar() },
modifier = Modifier.size(32.dp)
) {
Icon(
imageVector = if (playlist.starred) Icons.Filled.Star else Icons.Outlined.Star,
contentDescription = if (playlist.starred) "Unstar" else "Star",
tint = if (playlist.starred) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)
)
}
IconButton(
onClick = { onToggleStar() },
modifier = Modifier.size(32.dp)
) {
Icon(
imageVector = if (playlist.starred) Icons.Filled.Star else Icons.Outlined.Star,
contentDescription = if (playlist.starred) "Unstar" else "Star",
tint = if (playlist.starred) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)
)
}
}
}
@@ -300,7 +305,8 @@ private fun StationRow(
.combinedClickable(
onClick = onPlay,
onLongClick = { showMenu = true }
),
)
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(
@@ -315,6 +321,17 @@ private fun StationRow(
)
}
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)) {
Text(
text = station.name,
@@ -342,20 +359,20 @@ private fun StationRow(
expanded = showMenu,
onDismissRequest = { showMenu = false }
) {
DropdownMenuItem(
text = { Text("Edit") },
onClick = {
showMenu = false
onEdit()
}
)
DropdownMenuItem(
text = { Text("Delete") },
onClick = {
showMenu = false
onDelete()
}
)
DropdownMenuItem(
text = { Text("Edit") },
onClick = {
showMenu = false
onEdit()
}
)
DropdownMenuItem(
text = { Text("Delete") },
onClick = {
showMenu = false
onDelete()
}
)
}
}
}

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`