From 7678b2b12a91638e7735546a6e6cf66feb944418 Mon Sep 17 00:00:00 2001 From: cottongin Date: Tue, 10 Mar 2026 02:48:26 -0400 Subject: [PATCH] feat: add Now Playing screen with dual timers and latency indicator Made-with: Cursor --- .../ui/screens/nowplaying/NowPlayingScreen.kt | 263 +++++++++++++++++- .../screens/nowplaying/NowPlayingViewModel.kt | 75 +++++ .../nowplaying/NowPlayingViewModelFactory.kt | 18 ++ 3 files changed, 348 insertions(+), 8 deletions(-) create mode 100644 app/src/main/java/xyz/cottongin/radio247/ui/screens/nowplaying/NowPlayingViewModel.kt create mode 100644 app/src/main/java/xyz/cottongin/radio247/ui/screens/nowplaying/NowPlayingViewModelFactory.kt diff --git a/app/src/main/java/xyz/cottongin/radio247/ui/screens/nowplaying/NowPlayingScreen.kt b/app/src/main/java/xyz/cottongin/radio247/ui/screens/nowplaying/NowPlayingScreen.kt index 42ab819..b55e80b 100644 --- a/app/src/main/java/xyz/cottongin/radio247/ui/screens/nowplaying/NowPlayingScreen.kt +++ b/app/src/main/java/xyz/cottongin/radio247/ui/screens/nowplaying/NowPlayingScreen.kt @@ -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) { - IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + 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" + ) + } + }, + 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 + ) + } + } } - Text("Now Playing") } } + +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() +} diff --git a/app/src/main/java/xyz/cottongin/radio247/ui/screens/nowplaying/NowPlayingViewModel.kt b/app/src/main/java/xyz/cottongin/radio247/ui/screens/nowplaying/NowPlayingViewModel.kt new file mode 100644 index 0000000..288ddbb --- /dev/null +++ b/app/src/main/java/xyz/cottongin/radio247/ui/screens/nowplaying/NowPlayingViewModel.kt @@ -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 + val connectionElapsed: StateFlow + + 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) + } + } +} diff --git a/app/src/main/java/xyz/cottongin/radio247/ui/screens/nowplaying/NowPlayingViewModelFactory.kt b/app/src/main/java/xyz/cottongin/radio247/ui/screens/nowplaying/NowPlayingViewModelFactory.kt new file mode 100644 index 0000000..7e22def --- /dev/null +++ b/app/src/main/java/xyz/cottongin/radio247/ui/screens/nowplaying/NowPlayingViewModelFactory.kt @@ -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 create(modelClass: Class): T { + if (modelClass.isAssignableFrom(NowPlayingViewModel::class.java)) { + return NowPlayingViewModel(application) as T + } + throw IllegalArgumentException("Unknown ViewModel class") + } +}