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.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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 -> {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user