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