feat: add Now Playing screen with dual timers and latency indicator
Made-with: Cursor
This commit is contained in:
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user