From c0ba23b208df40fb9e40029b2b1e361c2917d95a Mon Sep 17 00:00:00 2001 From: cottongin Date: Tue, 10 Mar 2026 20:16:43 -0400 Subject: [PATCH] feat: now playing UX overhaul with stream quality and audio improvements Redesign Now Playing screen with blurred album art background, dominant color extraction, bounce marquee for long text, cross-fade artwork transitions, icon-labeled timers, and stream quality badge (bitrate, codec, SSL). Add StreamInfo propagation from connection through to UI. Fix MediaCodec PTS spam by providing incrementing presentation timestamps. Made-with: Cursor --- .../cottongin/radio247/audio/AudioEngine.kt | 8 +- .../radio247/audio/AudioEngineEvent.kt | 1 + .../radio247/audio/StreamConnection.kt | 14 + .../radio247/service/PlaybackState.kt | 7 +- .../radio247/service/RadioController.kt | 3 +- .../radio247/service/RadioPlaybackService.kt | 21 +- .../ui/screens/nowplaying/NowPlayingScreen.kt | 1279 ++++++++++++++--- .../screens/nowplaying/NowPlayingViewModel.kt | 63 +- 8 files changed, 1139 insertions(+), 257 deletions(-) diff --git a/app/src/main/java/xyz/cottongin/radio247/audio/AudioEngine.kt b/app/src/main/java/xyz/cottongin/radio247/audio/AudioEngine.kt index a6189ca..1e1d46c 100644 --- a/app/src/main/java/xyz/cottongin/radio247/audio/AudioEngine.kt +++ b/app/src/main/java/xyz/cottongin/radio247/audio/AudioEngine.kt @@ -36,10 +36,12 @@ class AudioEngine( private var catchingUp = true @Volatile private var timedStream: TimedInputStream? = null + private var presentationTimeUs = 0L fun start() { running = true catchingUp = true + presentationTimeUs = 0L thread = Thread({ try { runPipeline() @@ -109,6 +111,7 @@ class AudioEngine( currentCodec = codec _events.tryEmit(AudioEngineEvent.Started) + connection.streamInfo?.let { _events.tryEmit(AudioEngineEvent.StreamInfoReceived(it)) } try { val bufferFrames = if (bufferMs > 0) (bufferMs / 26).coerceAtLeast(1) else 0 @@ -165,6 +168,7 @@ class AudioEngine( audioTrack.flush() audioTrack.play() drainCodecOutput(codec) + presentationTimeUs = 0L return } @@ -173,7 +177,8 @@ class AudioEngine( val inBuf = codec.getInputBuffer(inIdx)!! inBuf.clear() inBuf.put(mp3Frame) - codec.queueInputBuffer(inIdx, 0, mp3Frame.size, 0, 0) + codec.queueInputBuffer(inIdx, 0, mp3Frame.size, presentationTimeUs, 0) + presentationTimeUs += FRAME_DURATION_US } val bufferInfo = MediaCodec.BufferInfo() @@ -202,6 +207,7 @@ class AudioEngine( companion object { private const val FRAMES_PER_SECOND = 38 private const val CATCHUP_THRESHOLD_MS = 30L + private const val FRAME_DURATION_US = 26_122L // 1152 samples at 44100 Hz } } diff --git a/app/src/main/java/xyz/cottongin/radio247/audio/AudioEngineEvent.kt b/app/src/main/java/xyz/cottongin/radio247/audio/AudioEngineEvent.kt index 99652e0..ac5217b 100644 --- a/app/src/main/java/xyz/cottongin/radio247/audio/AudioEngineEvent.kt +++ b/app/src/main/java/xyz/cottongin/radio247/audio/AudioEngineEvent.kt @@ -2,6 +2,7 @@ package xyz.cottongin.radio247.audio sealed interface AudioEngineEvent { data class MetadataChanged(val metadata: IcyMetadata) : AudioEngineEvent + data class StreamInfoReceived(val streamInfo: StreamInfo) : AudioEngineEvent data class Error(val cause: EngineError) : AudioEngineEvent data object Started : AudioEngineEvent data object Stopped : AudioEngineEvent diff --git a/app/src/main/java/xyz/cottongin/radio247/audio/StreamConnection.kt b/app/src/main/java/xyz/cottongin/radio247/audio/StreamConnection.kt index c5d2086..1eb9354 100644 --- a/app/src/main/java/xyz/cottongin/radio247/audio/StreamConnection.kt +++ b/app/src/main/java/xyz/cottongin/radio247/audio/StreamConnection.kt @@ -38,6 +38,12 @@ private class LowLatencySocketFactory : SocketFactory() { class ConnectionFailed(message: String, cause: Throwable? = null) : Exception(message, cause) +data class StreamInfo( + val bitrate: Int?, + val ssl: Boolean, + val contentType: String? +) + class StreamConnection(private val url: String) { private val client = OkHttpClient.Builder() .socketFactory(LowLatencySocketFactory()) @@ -48,6 +54,8 @@ class StreamConnection(private val url: String) { private set var inputStream: InputStream? = null private set + var streamInfo: StreamInfo? = null + private set private var response: Response? = null fun open() { @@ -65,6 +73,11 @@ class StreamConnection(private val url: String) { } response = resp metaint = resp.header("icy-metaint")?.toIntOrNull() + streamInfo = StreamInfo( + bitrate = resp.header("icy-br")?.toIntOrNull(), + ssl = url.startsWith("https", ignoreCase = true), + contentType = resp.header("Content-Type") + ) inputStream = resp.body?.byteStream() ?: throw ConnectionFailed("Empty response body") } catch (e: IOException) { @@ -82,5 +95,6 @@ class StreamConnection(private val url: String) { response = null inputStream = null metaint = null + streamInfo = null } } diff --git a/app/src/main/java/xyz/cottongin/radio247/service/PlaybackState.kt b/app/src/main/java/xyz/cottongin/radio247/service/PlaybackState.kt index d1c9602..d99215a 100644 --- a/app/src/main/java/xyz/cottongin/radio247/service/PlaybackState.kt +++ b/app/src/main/java/xyz/cottongin/radio247/service/PlaybackState.kt @@ -1,6 +1,7 @@ package xyz.cottongin.radio247.service import xyz.cottongin.radio247.audio.IcyMetadata +import xyz.cottongin.radio247.audio.StreamInfo import xyz.cottongin.radio247.data.model.Station sealed interface PlaybackState { @@ -13,12 +14,14 @@ sealed interface PlaybackState { val station: Station, val metadata: IcyMetadata? = null, val sessionStartedAt: Long = System.currentTimeMillis(), - val connectionStartedAt: Long = System.currentTimeMillis() + val connectionStartedAt: Long = System.currentTimeMillis(), + val streamInfo: StreamInfo? = null ) : PlaybackState data class Paused( val station: Station, val metadata: IcyMetadata? = null, - val sessionStartedAt: Long + val sessionStartedAt: Long, + val streamInfo: StreamInfo? = null ) : PlaybackState data class Reconnecting( val station: Station, diff --git a/app/src/main/java/xyz/cottongin/radio247/service/RadioController.kt b/app/src/main/java/xyz/cottongin/radio247/service/RadioController.kt index ee17c1b..efe6f59 100644 --- a/app/src/main/java/xyz/cottongin/radio247/service/RadioController.kt +++ b/app/src/main/java/xyz/cottongin/radio247/service/RadioController.kt @@ -41,7 +41,8 @@ class RadioController( _state.value = PlaybackState.Paused( station = current.station, metadata = current.metadata, - sessionStartedAt = current.sessionStartedAt + sessionStartedAt = current.sessionStartedAt, + streamInfo = current.streamInfo ) } val intent = Intent(application, RadioPlaybackService::class.java).apply { diff --git a/app/src/main/java/xyz/cottongin/radio247/service/RadioPlaybackService.kt b/app/src/main/java/xyz/cottongin/radio247/service/RadioPlaybackService.kt index 1c40d28..1680d44 100644 --- a/app/src/main/java/xyz/cottongin/radio247/service/RadioPlaybackService.kt +++ b/app/src/main/java/xyz/cottongin/radio247/service/RadioPlaybackService.kt @@ -261,7 +261,8 @@ class RadioPlaybackService : LifecycleService() { reconnectionMutex.withLock { engine?.stop() val bufferMs = app.preferences.bufferMs.first() - engine = AudioEngine(station.url, bufferMs) + val urls = app.streamResolver.resolveUrls(station) + engine = AudioEngine(urls.first(), bufferMs) connectionSpanId = connectionSpanDao.insert( ConnectionSpan( sessionId = listeningSessionId, @@ -297,6 +298,14 @@ class RadioPlaybackService : LifecycleService() { updateNotification(station, event.metadata, false) persistMetadataSnapshot(station.id, event.metadata) } + is AudioEngineEvent.StreamInfoReceived -> { + val playingState = controller.state.value + if (playingState is PlaybackState.Playing) { + controller.updateState( + playingState.copy(streamInfo = event.streamInfo) + ) + } + } is AudioEngineEvent.Started -> { controller.updateLatency(engine!!.estimatedLatencyMs) } @@ -410,6 +419,7 @@ class RadioPlaybackService : LifecycleService() { } private suspend fun persistMetadataSnapshot(stationId: Long, metadata: IcyMetadata) { + val now = System.currentTimeMillis() withContext(Dispatchers.IO) { metadataSnapshotDao.insert( MetadataSnapshot( @@ -417,9 +427,16 @@ class RadioPlaybackService : LifecycleService() { title = metadata.title, artist = metadata.artist, artworkUrl = null, - timestamp = System.currentTimeMillis() + timestamp = now ) ) + val station = stationDao.getStationById(stationId) + app.historyWriter.append( + station = station?.name ?: "Unknown", + artist = metadata.artist, + title = metadata.title, + timestamp = now + ) } } 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 a2ab781..ff2fb1e 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,26 +1,54 @@ package xyz.cottongin.radio247.ui.screens.nowplaying +import android.app.Activity +import android.content.res.Configuration +import android.graphics.BitmapFactory +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.keyframes +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable 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.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight 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.layout.widthIn +import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape 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.Lock import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Stop +import androidx.compose.material.icons.outlined.Schedule +import androidx.compose.material.icons.outlined.Speed +import androidx.compose.material.icons.outlined.Wifi import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator @@ -30,23 +58,81 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Slider import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Surface import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +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.draw.clipToBounds +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.view.WindowInsetsControllerCompat import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.palette.graphics.Palette import coil3.compose.AsyncImage +import coil3.compose.rememberAsyncImagePainter +import coil3.request.ImageRequest +import coil3.size.Size import xyz.cottongin.radio247.R +import xyz.cottongin.radio247.RadioApplication import xyz.cottongin.radio247.audio.IcyMetadata +import xyz.cottongin.radio247.audio.StreamInfo import xyz.cottongin.radio247.service.PlaybackState +import com.skydoves.cloudy.cloudy +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.Request + +private data class DominantColors( + val background: Color, + val onBackground: Color, + val accent: Color +) + +private val DefaultColors = DominantColors( + background = Color.Unspecified, + onBackground = Color.Unspecified, + accent = Color.Unspecified +) + +private fun contrastingTextColor(bg: Color): Color = + if (bg.luminance() > 0.5f) Color(0xFF1A1A1A) else Color(0xFFF5F5F5) + +private fun borderColor(bg: Color): Color = + if (bg.luminance() > 0.5f) Color.Black.copy(alpha = 0.15f) else Color.White.copy(alpha = 0.15f) @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -55,18 +141,130 @@ fun NowPlayingScreen( modifier: Modifier = Modifier, viewModel: NowPlayingViewModel = viewModel( factory = NowPlayingViewModelFactory( - LocalContext.current.applicationContext as xyz.cottongin.radio247.RadioApplication + LocalContext.current.applicationContext as RadioApplication ) ) ) { val playbackState by viewModel.playbackState.collectAsState() - val artworkUrl by viewModel.artworkUrl.collectAsState() + val artworkUrl by viewModel.displayArtworkUrl.collectAsState() + val displayMetadata by viewModel.displayMetadata.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() + val configuration = LocalConfiguration.current + val isLandscape = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE + + val context = LocalContext.current + val fallbackBg = MaterialTheme.colorScheme.surface + val fallbackOnBg = MaterialTheme.colorScheme.onSurface + val fallbackAccent = MaterialTheme.colorScheme.primary + + var dominantColors by remember { mutableStateOf(DefaultColors) } + + LaunchedEffect(artworkUrl) { + val url = artworkUrl + if (url == null) { + dominantColors = DefaultColors + return@LaunchedEffect + } + withContext(Dispatchers.IO) { + try { + val app = context.applicationContext as RadioApplication + val req = Request.Builder().url(url).build() + app.okHttpClient.newCall(req).execute().use { response -> + if (response.isSuccessful) { + val bitmap = response.body?.byteStream()?.use { stream -> + BitmapFactory.decodeStream(stream) + } + if (bitmap != null) { + try { + val palette = Palette.from(bitmap) + .clearFilters() + .generate() + val swatch = palette.dominantSwatch + ?: palette.vibrantSwatch + ?: palette.mutedSwatch + if (swatch != null) { + val bg = Color(swatch.rgb) + val onBg = contrastingTextColor(bg) + val accent = if (bg.luminance() < 0.5f) { + Color( + palette.lightVibrantSwatch?.rgb + ?: palette.lightMutedSwatch?.rgb + ?: 0xFFBB86FC.toInt() + ) + } else { + Color( + palette.darkVibrantSwatch?.rgb + ?: palette.darkMutedSwatch?.rgb + ?: 0xFF6200EE.toInt() + ) + } + dominantColors = DominantColors(bg, onBg, accent) + } + } finally { + bitmap.recycle() + } + } + } + } + } catch (_: Exception) { + dominantColors = DefaultColors + } + } + } + + val rawBg = if (dominantColors.background != Color.Unspecified) dominantColors.background else fallbackBg + val rawText = if (dominantColors.onBackground != Color.Unspecified) dominantColors.onBackground else fallbackOnBg + val rawAccent = if (dominantColors.accent != Color.Unspecified) dominantColors.accent else fallbackAccent + + val bgColor by animateColorAsState(rawBg, tween(600), label = "bg") + val textColor by animateColorAsState(rawText, tween(600), label = "text") + val accentColor by animateColorAsState(rawAccent, tween(600), label = "accent") + + // Dynamic status bar + navigation bar coloring + val view = LocalView.current + val dimmedBg = bgColor.copy(alpha = 0.85f) + val savedStatusBarColor = remember { mutableStateOf(null) } + val savedNavBarColor = remember { mutableStateOf(null) } + val savedLightStatusBar = remember { mutableStateOf(null) } + val savedLightNavBar = remember { mutableStateOf(null) } + + if (!view.isInEditMode) { + val activity = view.context as? Activity + if (activity != null) { + DisposableEffect(Unit) { + val window = activity.window + savedStatusBarColor.value = window.statusBarColor + savedNavBarColor.value = window.navigationBarColor + val controller = WindowInsetsControllerCompat(window, view) + savedLightStatusBar.value = controller.isAppearanceLightStatusBars + savedLightNavBar.value = controller.isAppearanceLightNavigationBars + onDispose { + savedStatusBarColor.value?.let { window.statusBarColor = it } + savedNavBarColor.value?.let { window.navigationBarColor = it } + val c = WindowInsetsControllerCompat(window, view) + savedLightStatusBar.value?.let { c.isAppearanceLightStatusBars = it } + savedLightNavBar.value?.let { c.isAppearanceLightNavigationBars = it } + } + } + + SideEffect { + val window = activity.window + val barColor = dimmedBg.toArgb() + window.statusBarColor = barColor + window.navigationBarColor = barColor + val controller = WindowInsetsControllerCompat(window, view) + val useLightIcons = dimmedBg.luminance() < 0.5f + controller.isAppearanceLightStatusBars = !useLightIcons + controller.isAppearanceLightNavigationBars = !useLightIcons + } + } + } + when (val state = playbackState) { is PlaybackState.Connecting, is PlaybackState.Playing, @@ -79,265 +277,872 @@ fun NowPlayingScreen( 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 + val streamInfo = when (state) { + is PlaybackState.Playing -> state.streamInfo + is PlaybackState.Paused -> state.streamInfo + 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 - ) - ) + BlurredBackground(artworkUrl = artworkUrl, bgColor = bgColor) - 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() - .background(MaterialTheme.colorScheme.surfaceVariant), - contentAlignment = Alignment.Center - ) { - if (artworkUrl != null) { - AsyncImage( - model = artworkUrl, - contentDescription = null, - modifier = Modifier.fillMaxSize() - ) - } else { - Image( - painter = painterResource(R.drawable.ic_radio_placeholder), - contentDescription = null, - modifier = Modifier.size(120.dp) + if (isLandscape) { + Column(modifier = Modifier.fillMaxSize()) { + TopAppBar( + title = { }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back" ) } - } - } - - Text( - text = station.name, - style = MaterialTheme.typography.headlineSmall, - modifier = Modifier.padding(vertical = 8.dp) - ) - Text( - text = when { - isConnecting -> "Connecting..." - isPaused -> "Paused" - else -> formatTrackInfo(metadata) }, - style = MaterialTheme.typography.bodyMedium, - color = if (isPaused || isConnecting) - MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) - else MaterialTheme.colorScheme.onSurfaceVariant - ) - - Spacer(modifier = Modifier.height(24.dp)) - - Text( - text = "Session: ${formatElapsed(sessionElapsed)}", - 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)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically - ) { - 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(16.dp)) - IconButton( - onClick = { viewModel.stop() }, - modifier = Modifier.size(64.dp) - ) { - Icon( - Icons.Filled.Stop, - contentDescription = "Stop", - modifier = Modifier.size(40.dp), - tint = MaterialTheme.colorScheme.error - ) - } - } - - 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 + colors = TopAppBarDefaults.topAppBarColors( + containerColor = Color.Transparent, + navigationIconContentColor = textColor ) ) - - Spacer(modifier = Modifier.height(32.dp)) + LandscapeContent( + artworkUrl = artworkUrl, + station = station, + metadata = displayMetadata, + isPaused = isPaused, + isPlaying = isPlaying, + isConnecting = isConnecting, + sessionElapsed = sessionElapsed, + connectionElapsed = connectionElapsed, + estimatedLatencyMs = estimatedLatencyMs, + streamInfo = streamInfo, + stayConnected = stayConnected, + bufferMs = bufferMs, + viewModel = viewModel, + bgColor = bgColor, + textColor = textColor, + accentColor = accentColor + ) } + } else { + PortraitContent( + artworkUrl = artworkUrl, + station = station, + metadata = displayMetadata, + isPaused = isPaused, + isPlaying = isPlaying, + isConnecting = isConnecting, + sessionElapsed = sessionElapsed, + connectionElapsed = connectionElapsed, + estimatedLatencyMs = estimatedLatencyMs, + streamInfo = streamInfo, + stayConnected = stayConnected, + bufferMs = bufferMs, + viewModel = viewModel, + onBack = onBack, + bgColor = bgColor, + textColor = textColor, + accentColor = accentColor + ) } - 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})") - } - } - } - } - - 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}...") - } - } - } - } + OverlayCards(state, station, isConnecting) } } PlaybackState.Idle -> {} } } +@Composable +private fun BlurredBackground( + artworkUrl: String?, + bgColor: Color, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + Box(modifier = modifier.fillMaxSize()) { + if (artworkUrl != null) { + AsyncImage( + model = ImageRequest.Builder(context) + .data(artworkUrl) + .size(Size(10, 10)) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxSize() + .cloudy(radius = 25) + ) + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.15f)) + ) + } else { + Box(modifier = Modifier.fillMaxSize().background(bgColor)) + } + } +} + +@Composable +private fun ArtworkImage( + artworkUrl: String?, + bgColor: Color, + modifier: Modifier = Modifier +) { + val bColor = borderColor(bgColor) + val artShape = RoundedCornerShape(14.dp) + + AnimatedContent( + targetState = artworkUrl, + modifier = modifier, + transitionSpec = { + fadeIn(tween(600)) togetherWith fadeOut(tween(600)) + }, + label = "artwork-crossfade" + ) { url -> + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + if (url != null) { + val painter = rememberAsyncImagePainter(model = url) + val intrinsic = painter.intrinsicSize + val loaded = intrinsic != androidx.compose.ui.geometry.Size.Unspecified && + intrinsic.width > 0f && intrinsic.height > 0f + + if (loaded) { + val aspect = intrinsic.width / intrinsic.height + Image( + painter = painter, + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier + .aspectRatio(aspect) + .clip(artShape) + .border(1.dp, bColor, artShape) + ) + } else { + Image( + painter = painter, + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier.fillMaxSize() + ) + } + } else { + Image( + painter = painterResource(R.drawable.ic_radio_placeholder), + contentDescription = null, + modifier = Modifier.size(120.dp) + ) + } + } + } +} + +@Composable +private fun BounceMarqueeText( + text: String, + style: TextStyle, + color: Color, + strokeColor: Color? = null, + strokeWidth: Float = 2f, + modifier: Modifier = Modifier +) { + val density = LocalDensity.current + val textMeasurer = rememberTextMeasurer() + + val textWidthPx = remember(text, style) { + textMeasurer.measure(text, style, maxLines = 1, softWrap = false).size.width.toFloat() + } + + var containerWidthPx by remember { mutableStateOf(0f) } + + Box( + modifier = modifier + .fillMaxWidth() + .onSizeChanged { containerWidthPx = it.width.toFloat() } + .clipToBounds() + ) { + val overflowPx = if (containerWidthPx > 0f) + (textWidthPx - containerWidthPx).coerceAtLeast(0f) else 0f + + if (overflowPx > 0f) { + key(text) { + val scrollMs = ((overflowPx / density.density) * 18f).toInt().coerceIn(2000, 8000) + val pauseMs = 1500 + val totalMs = pauseMs + scrollMs + pauseMs + scrollMs + + val transition = rememberInfiniteTransition(label = "marquee") + val offset by transition.animateFloat( + initialValue = 0f, + targetValue = 0f, + animationSpec = infiniteRepeatable( + animation = keyframes { + durationMillis = totalMs + 0f at 0 using LinearEasing + 0f at pauseMs using FastOutSlowInEasing + -overflowPx at (pauseMs + scrollMs) using LinearEasing + -overflowPx at (pauseMs + scrollMs + pauseMs) using FastOutSlowInEasing + }, + repeatMode = RepeatMode.Restart + ), + label = "bounce" + ) + + Box( + modifier = Modifier + .wrapContentWidth(align = Alignment.Start, unbounded = true) + .align(Alignment.CenterStart) + .graphicsLayer { translationX = offset } + ) { + if (strokeColor != null) { + Text( + text = text, + style = style.merge(TextStyle(drawStyle = Stroke(width = strokeWidth))), + color = strokeColor, + maxLines = 1, + softWrap = false + ) + } + Text( + text = text, + style = style, + color = color, + maxLines = 1, + softWrap = false + ) + } + } + } else { + Box(modifier = Modifier.align(Alignment.Center)) { + if (strokeColor != null) { + Text( + text = text, + style = style.merge(TextStyle(drawStyle = Stroke(width = strokeWidth))), + color = strokeColor, + maxLines = 1, + softWrap = false + ) + } + Text( + text = text, + style = style, + color = color, + maxLines = 1, + softWrap = false + ) + } + } + } +} + +@Composable +private fun TrackInfoSection( + station: xyz.cottongin.radio247.data.model.Station, + metadata: IcyMetadata?, + isPaused: Boolean, + isConnecting: Boolean, + textColor: Color, + bgColor: Color, + modifier: Modifier = Modifier +) { + val strokeColor = borderColor(bgColor) + val trackText = when { + isConnecting -> "Connecting\u2026" + isPaused -> "Paused" + else -> formatTrackInfo(metadata) + } + val trackStyle = MaterialTheme.typography.headlineLarge.copy(fontWeight = FontWeight.Bold) + val trackColor = if (isPaused || isConnecting) textColor.copy(alpha = 0.45f) else textColor + + Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = station.name.uppercase(), + style = MaterialTheme.typography.labelLarge.copy( + letterSpacing = 3.sp, + fontWeight = FontWeight.Medium + ), + textAlign = TextAlign.Center, + color = textColor.copy(alpha = 0.5f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Spacer(modifier = Modifier.height(6.dp)) + BounceMarqueeText( + text = trackText, + style = trackStyle, + color = trackColor, + strokeColor = strokeColor + ) + } +} + +@Composable +private fun TimerSection( + sessionElapsed: Long, + connectionElapsed: Long, + estimatedLatencyMs: Long, + isPaused: Boolean, + isConnecting: Boolean, + textColor: Color, + modifier: Modifier = Modifier +) { + val dim = textColor.copy(alpha = 0.45f) + val labelStyle = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Light) + val iconSize = 14.dp + + Row( + modifier = modifier, + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Outlined.Schedule, + contentDescription = "Session time", + modifier = Modifier.size(iconSize), + tint = dim + ) + Spacer(modifier = Modifier.width(3.dp)) + Text(text = formatElapsed(sessionElapsed), style = labelStyle, color = dim) + + if (!isPaused && !isConnecting) { + Spacer(modifier = Modifier.width(12.dp)) + Icon( + Icons.Outlined.Wifi, + contentDescription = "Connection time", + modifier = Modifier.size(iconSize), + tint = dim + ) + Spacer(modifier = Modifier.width(3.dp)) + Text(text = formatElapsed(connectionElapsed), style = labelStyle, color = dim) + + Spacer(modifier = Modifier.width(12.dp)) + Icon( + Icons.Outlined.Speed, + contentDescription = "Latency", + modifier = Modifier.size(iconSize), + tint = dim + ) + Spacer(modifier = Modifier.width(3.dp)) + Text(text = "~${estimatedLatencyMs}ms", style = labelStyle, color = dim) + } + } +} + +@Composable +private fun QualityBadge( + streamInfo: StreamInfo?, + textColor: Color, + modifier: Modifier = Modifier +) { + if (streamInfo == null) return + val dim = textColor.copy(alpha = 0.4f) + val badgeStyle = MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.Medium) + + Row( + modifier = modifier, + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + if (streamInfo.bitrate != null) { + Text(text = "${streamInfo.bitrate} kbps", style = badgeStyle, color = dim) + } + val codec = when { + streamInfo.contentType?.contains("mpeg", ignoreCase = true) == true -> "MP3" + streamInfo.contentType?.contains("aac", ignoreCase = true) == true -> "AAC" + streamInfo.contentType?.contains("ogg", ignoreCase = true) == true -> "OGG" + streamInfo.contentType?.contains("flac", ignoreCase = true) == true -> "FLAC" + else -> null + } + if (codec != null) { + if (streamInfo.bitrate != null) { + Text(text = " \u00B7 ", style = badgeStyle, color = dim) + } + Text(text = codec, style = badgeStyle, color = dim) + } + if (streamInfo.ssl) { + Spacer(modifier = Modifier.width(4.dp)) + Icon( + Icons.Filled.Lock, + contentDescription = "Encrypted", + modifier = Modifier.size(10.dp), + tint = dim + ) + } + } +} + +@Composable +private fun TransportControls( + isPaused: Boolean, + isPlaying: Boolean, + viewModel: NowPlayingViewModel, + accentColor: Color, + textColor: Color, + modifier: Modifier = Modifier +) { + val disabledColor = textColor.copy(alpha = 0.18f) + val heroSize = 96.dp + val heroIconSize = 56.dp + val secondarySize = 64.dp + val secondaryIconSize = 34.dp + val spacing = 36.dp + val heroBorder = borderColor(accentColor) + val secondaryBorder = textColor.copy(alpha = 0.25f) + val secondaryFill = textColor.copy(alpha = 0.06f) + + Row( + modifier = modifier, + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + if (isPaused) { + Box( + modifier = Modifier + .size(heroSize) + .clip(CircleShape) + .background(accentColor) + .border(1.dp, heroBorder, CircleShape) + .clickable { viewModel.resume() }, + contentAlignment = Alignment.Center + ) { + Icon( + Icons.Filled.PlayArrow, + contentDescription = "Resume", + modifier = Modifier.size(heroIconSize), + tint = contrastingTextColor(accentColor) + ) + } + } else { + Box( + modifier = Modifier + .size(secondarySize) + .clip(CircleShape) + .background(if (isPlaying) secondaryFill else Color.Transparent) + .border(1.dp, if (isPlaying) secondaryBorder else disabledColor, CircleShape) + .then(if (isPlaying) Modifier.clickable { viewModel.skipAhead() } else Modifier), + contentAlignment = Alignment.Center + ) { + Icon( + Icons.Filled.FastForward, + contentDescription = "Skip ahead", + modifier = Modifier.size(secondaryIconSize), + tint = if (isPlaying) accentColor else disabledColor + ) + } + Spacer(modifier = Modifier.width(spacing)) + Box( + modifier = Modifier + .size(heroSize) + .clip(CircleShape) + .background(if (isPlaying) accentColor else disabledColor) + .border(1.dp, if (isPlaying) heroBorder else disabledColor, CircleShape) + .then(if (isPlaying) Modifier.clickable { viewModel.pause() } else Modifier), + contentAlignment = Alignment.Center + ) { + Icon( + Icons.Filled.Pause, + contentDescription = "Pause", + modifier = Modifier.size(heroIconSize), + tint = if (isPlaying) contrastingTextColor(accentColor) else textColor.copy(alpha = 0.3f) + ) + } + } + Spacer(modifier = Modifier.width(spacing)) + Box( + modifier = Modifier + .size(secondarySize) + .clip(CircleShape) + .background(secondaryFill) + .border(1.dp, secondaryBorder, CircleShape) + .clickable { viewModel.stop() }, + contentAlignment = Alignment.Center + ) { + Icon( + Icons.Filled.Stop, + contentDescription = "Stop", + modifier = Modifier.size(secondaryIconSize), + tint = textColor.copy(alpha = 0.6f) + ) + } + } +} + +@Composable +private fun SettingsSection( + stayConnected: Boolean, + bufferMs: Int, + viewModel: NowPlayingViewModel, + textColor: Color, + accentColor: Color, + compact: Boolean = false, + modifier: Modifier = Modifier +) { + val cardColor = textColor.copy(alpha = 0.12f) + val labelColor = textColor.copy(alpha = 0.7f) + val sliderColors = SliderDefaults.colors( + thumbColor = accentColor, + activeTrackColor = accentColor, + inactiveTrackColor = textColor.copy(alpha = 0.12f) + ) + val switchColors = SwitchDefaults.colors( + checkedThumbColor = accentColor, + checkedTrackColor = accentColor.copy(alpha = 0.4f), + uncheckedThumbColor = textColor.copy(alpha = 0.35f), + uncheckedTrackColor = textColor.copy(alpha = 0.1f) + ) + + val bColor = borderColor(textColor) + val settingsShape = RoundedCornerShape(12.dp) + Surface( + modifier = modifier + .widthIn(max = 320.dp) + .border(1.dp, bColor, settingsShape), + shape = settingsShape, + color = cardColor + ) { + if (compact) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Buffer ${bufferMs}ms", + style = MaterialTheme.typography.bodyMedium, + color = labelColor + ) + Slider( + value = bufferMs.toFloat(), + onValueChange = { viewModel.setBufferMs(it.toInt()) }, + valueRange = 0f..500f, + steps = 49, + modifier = Modifier.fillMaxWidth(), + colors = sliderColors + ) + } + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = "Stay on", + style = MaterialTheme.typography.bodyMedium, + color = labelColor + ) + Spacer(modifier = Modifier.width(8.dp)) + Switch( + checked = stayConnected, + onCheckedChange = { viewModel.toggleStayConnected() }, + colors = switchColors + ) + } + } + } else { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 16.dp) + ) { + Text( + text = "Buffer ${bufferMs}ms", + style = MaterialTheme.typography.bodyLarge, + color = labelColor + ) + Slider( + value = bufferMs.toFloat(), + onValueChange = { viewModel.setBufferMs(it.toInt()) }, + valueRange = 0f..500f, + steps = 49, + modifier = Modifier.fillMaxWidth(), + colors = sliderColors + ) + Spacer(modifier = Modifier.height(4.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = "Stay Connected", + style = MaterialTheme.typography.bodyLarge, + color = labelColor + ) + Switch( + checked = stayConnected, + onCheckedChange = { viewModel.toggleStayConnected() }, + colors = switchColors + ) + } + } + } + } +} + +@Composable +private fun PortraitContent( + artworkUrl: String?, + station: xyz.cottongin.radio247.data.model.Station, + metadata: IcyMetadata?, + isPaused: Boolean, + isPlaying: Boolean, + isConnecting: Boolean, + sessionElapsed: Long, + connectionElapsed: Long, + estimatedLatencyMs: Long, + streamInfo: StreamInfo?, + stayConnected: Boolean, + bufferMs: Int, + viewModel: NowPlayingViewModel, + onBack: () -> Unit, + bgColor: Color, + textColor: Color, + accentColor: Color +) { + val bColor = borderColor(bgColor) + val cardShape = RoundedCornerShape(14.dp) + val backBtnShape = RoundedCornerShape(10.dp) + + Box(modifier = Modifier.fillMaxSize()) { + Column(modifier = Modifier.fillMaxSize()) { + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .padding(start = 24.dp, end = 24.dp, top = 48.dp, bottom = 8.dp), + contentAlignment = Alignment.Center + ) { + ArtworkImage( + artworkUrl = artworkUrl, + bgColor = bgColor, + modifier = Modifier.fillMaxSize() + ) + } + + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .padding(horizontal = 12.dp), + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier + .widthIn(min = 340.dp) + .clip(cardShape) + .background(bgColor.copy(alpha = 0.78f)) + .border(1.dp, bColor, cardShape) + ) { + Column( + modifier = Modifier + .align(Alignment.Center) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp, vertical = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + TrackInfoSection(station, metadata, isPaused, isConnecting, textColor, bgColor) + + Spacer(modifier = Modifier.height(8.dp)) + + TimerSection( + sessionElapsed, connectionElapsed, estimatedLatencyMs, + isPaused, isConnecting, textColor + ) + + QualityBadge(streamInfo = streamInfo, textColor = textColor) + + Spacer(modifier = Modifier.height(24.dp)) + + TransportControls( + isPaused = isPaused, + isPlaying = isPlaying, + viewModel = viewModel, + accentColor = accentColor, + textColor = textColor + ) + + Spacer(modifier = Modifier.height(24.dp)) + + SettingsSection(stayConnected, bufferMs, viewModel, textColor, accentColor, compact = false) + } + } + } + + Spacer(modifier = Modifier.height(8.dp)) + } + + Box( + modifier = Modifier + .padding(start = 12.dp, top = 12.dp) + .size(40.dp) + .clip(backBtnShape) + .background(bgColor.copy(alpha = 0.5f)) + .border(1.dp, bColor, backBtnShape) + .clickable { onBack() }, + contentAlignment = Alignment.Center + ) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + tint = textColor, + modifier = Modifier.size(20.dp) + ) + } + } +} + +@Composable +private fun LandscapeContent( + artworkUrl: String?, + station: xyz.cottongin.radio247.data.model.Station, + metadata: IcyMetadata?, + isPaused: Boolean, + isPlaying: Boolean, + isConnecting: Boolean, + sessionElapsed: Long, + connectionElapsed: Long, + estimatedLatencyMs: Long, + streamInfo: StreamInfo?, + stayConnected: Boolean, + bufferMs: Int, + viewModel: NowPlayingViewModel, + bgColor: Color, + textColor: Color, + accentColor: Color +) { + val bColor = borderColor(bgColor) + val cardShape = RoundedCornerShape(14.dp) + + Row( + modifier = Modifier.fillMaxSize(), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + ArtworkImage( + artworkUrl = artworkUrl, + bgColor = bgColor, + modifier = Modifier.fillMaxSize() + ) + } + + Box( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .padding(12.dp), + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier + .widthIn(min = 340.dp) + .clip(cardShape) + .background(bgColor.copy(alpha = 0.78f)) + .border(1.dp, bColor, cardShape) + ) { + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp, vertical = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + TrackInfoSection(station, metadata, isPaused, isConnecting, textColor, bgColor) + + Spacer(modifier = Modifier.height(8.dp)) + + TimerSection( + sessionElapsed, connectionElapsed, estimatedLatencyMs, + isPaused, isConnecting, textColor + ) + + QualityBadge(streamInfo = streamInfo, textColor = textColor) + + Spacer(modifier = Modifier.height(16.dp)) + + TransportControls( + isPaused = isPaused, + isPlaying = isPlaying, + viewModel = viewModel, + accentColor = accentColor, + textColor = textColor, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(16.dp)) + + SettingsSection(stayConnected, bufferMs, viewModel, textColor, accentColor, compact = true) + } + } + } + } +} + +@Composable +private fun OverlayCards( + state: PlaybackState, + station: xyz.cottongin.radio247.data.model.Station, + isConnecting: Boolean +) { + 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\u2026 (attempt ${state.attempt})") + } + } + } + } + + 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}\u2026") + } + } + } + } +} + private fun formatTrackInfo(metadata: IcyMetadata?): String { - if (metadata == null) return "No track info" + if (metadata == null) return "\u266A" return when { metadata.artist != null && metadata.title != null -> - "${metadata.artist} - ${metadata.title}" + "${metadata.artist} \u2014 ${metadata.title}" metadata.title != null -> metadata.title metadata.artist != null -> metadata.artist - else -> "No track info" + else -> "\u266A" } } 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 index 74ab416..8719fd5 100644 --- 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 @@ -4,6 +4,7 @@ import android.app.Application import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import xyz.cottongin.radio247.RadioApplication +import xyz.cottongin.radio247.audio.IcyMetadata import xyz.cottongin.radio247.service.PlaybackState import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -32,13 +33,17 @@ class NowPlayingViewModel(application: Application) : AndroidViewModel(applicati 0 ) - private val _artworkUrl = MutableStateFlow(null) - val artworkUrl: StateFlow = _artworkUrl.asStateFlow() + private val _displayArtworkUrl = MutableStateFlow(null) + val displayArtworkUrl: StateFlow = _displayArtworkUrl.asStateFlow() + + private val _displayMetadata = MutableStateFlow(null) + val displayMetadata: StateFlow = _displayMetadata.asStateFlow() val sessionElapsed: StateFlow val connectionElapsed: StateFlow private var artworkResolveJob: Job? = null + private var displayStationId: Long? = null init { val ticker = flow { @@ -75,32 +80,62 @@ class NowPlayingViewModel(application: Application) : AndroidViewModel(applicati PlaybackState.Idle -> null to null } when (state) { - is PlaybackState.Connecting, + is PlaybackState.Connecting -> { + artworkResolveJob?.cancel() + if (station?.id != displayStationId) { + displayStationId = station?.id + _displayMetadata.value = null + _displayArtworkUrl.value = station?.defaultArtworkUrl + } + } is PlaybackState.Playing, is PlaybackState.Paused, is PlaybackState.Reconnecting -> { - artworkResolveJob?.cancel() - artworkResolveJob = viewModelScope.launch { - val url = station?.let { s -> - app.albumArtResolver.resolve( - artist = metadata?.artist, - title = metadata?.title, - icyStreamUrl = metadata?.streamUrl, - stationArtworkUrl = s.defaultArtworkUrl - ) + val stationChanged = station?.id != displayStationId + val trackChanged = stationChanged || isTrackChange(metadata, _displayMetadata.value) + + if (stationChanged) { + displayStationId = station?.id + } + + if (trackChanged && metadata != null) { + artworkResolveJob?.cancel() + val pendingMetadata = metadata + artworkResolveJob = viewModelScope.launch { + val url = station?.let { s -> + app.albumArtResolver.resolve( + artist = pendingMetadata.artist, + title = pendingMetadata.title, + icyStreamUrl = pendingMetadata.streamUrl, + stationArtworkUrl = s.defaultArtworkUrl + ) + } + _displayArtworkUrl.value = url + _displayMetadata.value = pendingMetadata } - _artworkUrl.value = url + } else if (stationChanged) { + artworkResolveJob?.cancel() + _displayMetadata.value = null + _displayArtworkUrl.value = station?.defaultArtworkUrl } } PlaybackState.Idle -> { artworkResolveJob?.cancel() - _artworkUrl.value = null + displayStationId = null + _displayMetadata.value = null + _displayArtworkUrl.value = null } } } } } + private fun isTrackChange(new: IcyMetadata?, old: IcyMetadata?): Boolean { + if (new == null && old == null) return false + if (new == null || old == null) return true + return new.artist != old.artist || new.title != old.title + } + fun stop() { controller.stop() }