feat: add Now Playing screen with dual timers and latency indicator

Made-with: Cursor
This commit is contained in:
cottongin
2026-03-10 02:48:26 -04:00
parent 30b4bc9814
commit 7678b2b12a
3 changed files with 348 additions and 8 deletions

View File

@@ -1,23 +1,270 @@
package xyz.cottongin.radio247.ui.screens.nowplaying
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
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.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import xyz.cottongin.radio247.R
import xyz.cottongin.radio247.audio.IcyMetadata
import xyz.cottongin.radio247.service.PlaybackState
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NowPlayingScreen(
onBack: () -> Unit,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
viewModel: NowPlayingViewModel = viewModel(
factory = NowPlayingViewModelFactory(
LocalContext.current.applicationContext as xyz.cottongin.radio247.RadioApplication
)
)
) {
Column(modifier) {
val playbackState by viewModel.playbackState.collectAsState()
val sessionElapsed by viewModel.sessionElapsed.collectAsState()
val connectionElapsed by viewModel.connectionElapsed.collectAsState()
val estimatedLatencyMs by viewModel.estimatedLatencyMs.collectAsState()
val stayConnected by viewModel.stayConnected.collectAsState()
val bufferMs by viewModel.bufferMs.collectAsState()
when (val state = playbackState) {
is PlaybackState.Playing,
is PlaybackState.Reconnecting -> {
val station = when (state) {
is PlaybackState.Playing -> state.station
is PlaybackState.Reconnecting -> state.station
else -> return
}
val metadata = when (state) {
is PlaybackState.Playing -> state.metadata
is PlaybackState.Reconnecting -> state.metadata
else -> null
}
Box(modifier = modifier.fillMaxSize()) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
) {
TopAppBar(
title = { },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back"
)
}
Text("Now Playing")
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
navigationIconContentColor = MaterialTheme.colorScheme.onSurface
)
)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Card(
modifier = Modifier
.size(220.dp)
.padding(vertical = 16.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
shape = MaterialTheme.shapes.medium
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Image(
painter = painterResource(R.drawable.ic_radio_placeholder),
contentDescription = null,
modifier = Modifier.size(120.dp)
)
}
}
Text(
text = station.name,
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.padding(vertical = 8.dp)
)
Text(
text = formatTrackInfo(metadata),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(24.dp))
Text(
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
)
Spacer(modifier = Modifier.height(24.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(text = "Stay Connected")
Switch(
checked = stayConnected,
onCheckedChange = { viewModel.toggleStayConnected() }
)
}
Text(
text = "Buffer: ${bufferMs}ms",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(top = 8.dp)
)
Slider(
value = bufferMs.toFloat(),
onValueChange = { viewModel.setBufferMs(it.toInt()) },
valueRange = 0f..500f,
steps = 49,
modifier = Modifier.fillMaxWidth(),
colors = SliderDefaults.colors(
thumbColor = MaterialTheme.colorScheme.primary,
activeTrackColor = MaterialTheme.colorScheme.primary
)
)
Spacer(modifier = Modifier.height(32.dp))
Button(
onClick = { viewModel.stop() },
modifier = Modifier
.fillMaxWidth()
.height(56.dp)
) {
Text("STOP")
}
Spacer(modifier = Modifier.height(32.dp))
}
}
if (state is PlaybackState.Reconnecting) {
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("Reconnecting... (attempt ${state.attempt})")
}
}
}
}
}
}
PlaybackState.Idle -> {
Column(modifier = modifier.fillMaxSize()) {
TopAppBar(
title = { },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back"
)
}
}
)
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = "Nothing playing",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
private fun formatTrackInfo(metadata: IcyMetadata?): String {
if (metadata == null) return "No track info"
return when {
metadata.artist != null && metadata.title != null ->
"${metadata.artist} - ${metadata.title}"
metadata.title != null -> metadata.title
metadata.artist != null -> metadata.artist
else -> "No track info"
}
}
private fun formatElapsed(millis: Long): String {
if (millis <= 0) return "0s"
val totalSeconds = millis / 1000
val hours = totalSeconds / 3600
val minutes = (totalSeconds % 3600) / 60
val seconds = totalSeconds % 60
return buildString {
if (hours > 0) append("${hours}h ")
if (minutes > 0 || hours > 0) append("${minutes}m ")
append("${seconds}s")
}.trim()
}

View File

@@ -0,0 +1,75 @@
package xyz.cottongin.radio247.ui.screens.nowplaying
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import xyz.cottongin.radio247.RadioApplication
import xyz.cottongin.radio247.service.PlaybackState
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
class NowPlayingViewModel(application: Application) : AndroidViewModel(application) {
private val app = application as RadioApplication
val controller = app.controller
val playbackState = controller.state
val estimatedLatencyMs = controller.estimatedLatencyMs
val stayConnected = app.preferences.stayConnected.stateIn(
viewModelScope,
SharingStarted.Lazily,
false
)
val bufferMs = app.preferences.bufferMs.stateIn(
viewModelScope,
SharingStarted.Lazily,
0
)
val sessionElapsed: StateFlow<Long>
val connectionElapsed: StateFlow<Long>
init {
val ticker = flow {
while (true) {
emit(System.currentTimeMillis())
delay(1000)
}
}
sessionElapsed = combine(ticker, playbackState) { now, state ->
when (state) {
is PlaybackState.Playing -> now - state.sessionStartedAt
is PlaybackState.Reconnecting -> now - state.sessionStartedAt
else -> 0L
}
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0L)
connectionElapsed = combine(ticker, playbackState) { now, state ->
when (state) {
is PlaybackState.Playing -> now - state.connectionStartedAt
else -> 0L
}
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0L)
}
fun stop() {
controller.stop()
}
fun toggleStayConnected() {
viewModelScope.launch {
val current = stayConnected.value
app.preferences.setStayConnected(!current)
}
}
fun setBufferMs(ms: Int) {
viewModelScope.launch {
app.preferences.setBufferMs(ms)
}
}
}

View File

@@ -0,0 +1,18 @@
package xyz.cottongin.radio247.ui.screens.nowplaying
import android.app.Application
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import xyz.cottongin.radio247.RadioApplication
class NowPlayingViewModelFactory(
private val application: RadioApplication
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(NowPlayingViewModel::class.java)) {
return NowPlayingViewModel(application) as T
}
throw IllegalArgumentException("Unknown ViewModel class")
}
}