diff --git a/app/src/main/java/com/rada/ui/components/EqualizerBars.kt b/app/src/main/java/com/rada/ui/components/EqualizerBars.kt new file mode 100644 index 0000000..0990484 --- /dev/null +++ b/app/src/main/java/com/rada/ui/components/EqualizerBars.kt @@ -0,0 +1,78 @@ +package com.rada.ui.components + +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.rada.ui.theme.LocalReduceMotion + +/** + * The three-bar "now playing" indicator — the compact signal that a row is + * the active source (Spotify's equalizer). Bars pulse out of phase; paused + * freezes them mid-height so the shape still reads as "the equalizer", and + * reduce-motion renders a static, even-bar version. + */ +@Composable +fun EqualizerBars( + active: Boolean, + color: Color, + modifier: Modifier = Modifier, + barWidth: Dp = 2.5.dp, +) { + val reduce = LocalReduceMotion.current + val transition = rememberInfiniteTransition(label = "eq") + // Out-of-phase sine-ish pulses; different durations keep bars unsynced. + val b1 by transition.animateFloat( + initialValue = 0.25f, targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(520), repeatMode = RepeatMode.Reverse, + ), label = "b1", + ) + val b2 by transition.animateFloat( + initialValue = 1f, targetValue = 0.3f, + animationSpec = infiniteRepeatable( + animation = tween(660), repeatMode = RepeatMode.Reverse, + ), label = "b2", + ) + val b3 by transition.animateFloat( + initialValue = 0.45f, targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(440), repeatMode = RepeatMode.Reverse, + ), label = "b3", + ) + // Frozen heights for the paused pose (mid-range, uneven = still "eq"). + val h1 = if (reduce || !active) 0.6f else b1 + val h2 = if (reduce || !active) 1f else b2 + val h3 = if (reduce || !active) 0.45f else b3 + + Canvas(modifier = modifier.size(14.dp)) { + val gap = this.size.width / 8f + val w = barWidth.toPx() + val totalW = w * 3 + gap * 2 + var x = (this.size.width - totalW) / 2f + val r = CornerRadius(w / 2f, w / 2f) + for (h in floatArrayOf(h1, h2, h3)) { + val barH = this.size.height * h + drawRoundRect( + color = color, + topLeft = Offset(x, this.size.height - barH), + size = Size(w, barH), + cornerRadius = r, + ) + x += w + gap + } + } +} diff --git a/app/src/main/java/com/rada/ui/library/LibraryScreen.kt b/app/src/main/java/com/rada/ui/library/LibraryScreen.kt index edf25e3..d9ee346 100644 --- a/app/src/main/java/com/rada/ui/library/LibraryScreen.kt +++ b/app/src/main/java/com/rada/ui/library/LibraryScreen.kt @@ -78,6 +78,7 @@ import com.rada.data.local.entity.SavedSongEntity import com.rada.data.repository.SortOrder import com.rada.ui.theme.AppearanceDialog import com.rada.ui.components.AppHomeButton +import com.rada.ui.components.EqualizerBars import com.rada.ui.theme.LocalTokens @OptIn(ExperimentalMaterial3Api::class) @@ -469,12 +470,20 @@ private fun SongListItem( .padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, ) { - Icon( - Icons.Default.MusicNote, - contentDescription = null, - tint = if (isPlaying) LocalTokens.current.accentFill else LocalTokens.current.textSecondary, - modifier = Modifier.size(24.dp), - ) + if (isPlaying) { + EqualizerBars( + active = true, + color = LocalTokens.current.accentFill, + modifier = Modifier.size(20.dp), + ) + } else { + Icon( + Icons.Default.MusicNote, + contentDescription = null, + tint = LocalTokens.current.textSecondary, + modifier = Modifier.size(24.dp), + ) + } Spacer(modifier = Modifier.width(16.dp)) Column(modifier = Modifier.weight(1f)) { Text( diff --git a/app/src/main/java/com/rada/ui/player/NowPlayingBar.kt b/app/src/main/java/com/rada/ui/player/NowPlayingBar.kt index b2a9d7a..a3cbde2 100644 --- a/app/src/main/java/com/rada/ui/player/NowPlayingBar.kt +++ b/app/src/main/java/com/rada/ui/player/NowPlayingBar.kt @@ -1,6 +1,11 @@ package com.rada.ui.player +import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.togetherWith +import com.rada.ui.components.EqualizerBars +import com.rada.ui.theme.metadataEnter +import com.rada.ui.theme.metadataExit import com.rada.ui.theme.LocalReduceMotion import com.rada.ui.theme.barEnter import com.rada.ui.theme.barExit @@ -55,8 +60,23 @@ fun NowPlayingBar( val isPlaying by viewModel.isPlaying.collectAsState() val bufferState by viewModel.bufferState.collectAsState() val isLocal by viewModel.isLocal.collectAsState() + val streamMeta by viewModel.streamMeta.collectAsState() + val localMedia by viewModel.localMedia.collectAsState() val isVisible = stationName.isNotEmpty() + // Spotify-style two-line bar: track (or station) + artist (or station). + val title: String + val subtitle: String + if (isLocal) { + title = localMedia?.first?.takeIf { it.isNotEmpty() } ?: stationName + subtitle = localMedia?.second.orEmpty() + } else { + val meta = streamMeta + title = meta?.displayTitle?.takeIf { it.isNotEmpty() } ?: stationName + subtitle = if (meta != null && meta.displayTitle.isNotEmpty()) + meta.displayArtist.ifEmpty { stationName } else "" + } + AnimatedVisibility( visible = isVisible, enter = barEnter(reduce), @@ -105,12 +125,21 @@ fun NowPlayingBar( .background(LocalTokens.current.accentFill), contentAlignment = Alignment.Center, ) { - Icon( - if (isLocal) Icons.Default.MusicNote else Icons.Default.Radio, - contentDescription = if (isLocal) "Now playing" else "Now playing station", - tint = LocalTokens.current.accentOnFill, - modifier = Modifier.size(24.dp), - ) + if (isPlaying) { + EqualizerBars( + active = true, + color = LocalTokens.current.accentOnFill, + barWidth = 3.dp, + modifier = Modifier.size(22.dp), + ) + } else { + Icon( + if (isLocal) Icons.Default.MusicNote else Icons.Default.Radio, + contentDescription = "Paused", + tint = LocalTokens.current.accentOnFill, + modifier = Modifier.size(24.dp), + ) + } } Spacer(modifier = Modifier.width(12.dp)) Column( @@ -122,14 +151,37 @@ fun NowPlayingBar( onClick = onNavigateToPlayer, ), ) { - Text( - text = stationName, - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = LocalTokens.current.text, - ) + Column { + AnimatedContent( + targetState = title, + transitionSpec = { metadataEnter(reduce) togetherWith metadataExit(reduce) }, + label = "npTitle", + ) { t -> + Text( + text = t, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = LocalTokens.current.text, + ) + } + if (subtitle.isNotEmpty()) { + AnimatedContent( + targetState = subtitle, + transitionSpec = { metadataEnter(reduce) togetherWith metadataExit(reduce) }, + label = "npSubtitle", + ) { st -> + Text( + text = st, + style = MaterialTheme.typography.bodySmall, + color = LocalTokens.current.textSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } } IconButton( onClick = { viewModel.togglePlayPause() }, diff --git a/app/src/main/java/com/rada/ui/player/NowPlayingViewModel.kt b/app/src/main/java/com/rada/ui/player/NowPlayingViewModel.kt index cdc996b..9105fc3 100644 --- a/app/src/main/java/com/rada/ui/player/NowPlayingViewModel.kt +++ b/app/src/main/java/com/rada/ui/player/NowPlayingViewModel.kt @@ -2,6 +2,7 @@ package com.rada.ui.player import androidx.lifecycle.ViewModel import com.rada.player.PlaybackManager +import com.rada.recording.StreamMetadata import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.StateFlow import javax.inject.Inject @@ -16,6 +17,8 @@ class NowPlayingViewModel @Inject constructor( val isPlaying: StateFlow = playbackManager.isPlayingFlow val bufferState: StateFlow = playbackManager.bufferStateFlow val isLocal: StateFlow = playbackManager.isLocalFlow + val streamMeta: StateFlow = playbackManager.lastStreamMetaFlow + val localMedia: StateFlow?> = playbackManager.localMediaFlow fun togglePlayPause() { playbackManager.togglePlayPause() diff --git a/app/src/main/java/com/rada/ui/stations/StationBrowserScreen.kt b/app/src/main/java/com/rada/ui/stations/StationBrowserScreen.kt index 6160f92..abf34d7 100644 --- a/app/src/main/java/com/rada/ui/stations/StationBrowserScreen.kt +++ b/app/src/main/java/com/rada/ui/stations/StationBrowserScreen.kt @@ -60,6 +60,7 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.rada.domain.model.Station import com.rada.ui.components.AppHomeButton +import com.rada.ui.components.EqualizerBars import com.rada.ui.theme.LocalTokens private val genreColors = mapOf( @@ -503,12 +504,16 @@ private fun StationCard( .background(tokens.accentFill.copy(alpha = 0.15f)), contentAlignment = Alignment.Center, ) { - Icon( - if (isPlaying) Icons.Default.PlayArrow else Icons.Default.Radio, - contentDescription = null, - tint = tokens.accentText, - modifier = Modifier.size(20.dp), - ) + if (isPlaying) { + EqualizerBars(active = true, color = tokens.accentText) + } else { + Icon( + Icons.Default.Radio, + contentDescription = null, + tint = tokens.accentText, + modifier = Modifier.size(20.dp), + ) + } } Spacer(modifier = Modifier.width(14.dp)) Column(modifier = Modifier.weight(1f)) { diff --git a/app/src/main/java/com/rada/ui/streams/StreamsScreen.kt b/app/src/main/java/com/rada/ui/streams/StreamsScreen.kt index c9112f0..bf27ca1 100644 --- a/app/src/main/java/com/rada/ui/streams/StreamsScreen.kt +++ b/app/src/main/java/com/rada/ui/streams/StreamsScreen.kt @@ -56,6 +56,7 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.rada.domain.model.StreamEntry import com.rada.ui.components.AppHomeButton +import com.rada.ui.components.EqualizerBars import com.rada.ui.theme.LocalTokens @OptIn(ExperimentalMaterial3Api::class) @@ -415,12 +416,20 @@ private fun StreamCard( .background(if (isPlaying) LocalTokens.current.accentFill else LocalTokens.current.accentFill.copy(alpha = 0.15f)), contentAlignment = Alignment.Center, ) { - Icon( - if (isPlaying) Icons.Default.Stop else Icons.Default.PlayArrow, - contentDescription = null, - tint = if (isPlaying) Color.White else LocalTokens.current.accentText, - modifier = Modifier.size(22.dp), - ) + if (isPlaying) { + EqualizerBars( + active = true, + color = LocalTokens.current.accentOnFill, + modifier = Modifier.size(16.dp), + ) + } else { + Icon( + Icons.Default.PlayArrow, + contentDescription = null, + tint = LocalTokens.current.accentText, + modifier = Modifier.size(22.dp), + ) + } } Spacer(modifier = Modifier.width(14.dp)) Column(modifier = Modifier.weight(1f)) {