feat(ui): Spotify-style playback signatures

- EqualizerBars: animated 3-bar 'now playing' indicator (out-of-phase
  pulses; frozen mid-height when paused; static under Reduce Motion)
- Now-playing bar: two-line layout with live metadata (title + artist
  for streams and local songs) and rise transitions; equalizer glyph
  replaces the icon chip while playing
- Playing state in every list: Stations card, Streams card, and
  Library song rows show the animated equalizer instead of a static
  icon on the active source
- NowPlayingViewModel exposes stream/local metadata flows
This commit is contained in:
avi 2026-09-08 09:35:53 -05:00
commit 4f8d3e9299
6 changed files with 188 additions and 32 deletions

View file

@ -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
}
}
}

View file

@ -78,6 +78,7 @@ import com.rada.data.local.entity.SavedSongEntity
import com.rada.data.repository.SortOrder import com.rada.data.repository.SortOrder
import com.rada.ui.theme.AppearanceDialog import com.rada.ui.theme.AppearanceDialog
import com.rada.ui.components.AppHomeButton import com.rada.ui.components.AppHomeButton
import com.rada.ui.components.EqualizerBars
import com.rada.ui.theme.LocalTokens import com.rada.ui.theme.LocalTokens
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@ -469,12 +470,20 @@ private fun SongListItem(
.padding(horizontal = 16.dp, vertical = 12.dp), .padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
if (isPlaying) {
EqualizerBars(
active = true,
color = LocalTokens.current.accentFill,
modifier = Modifier.size(20.dp),
)
} else {
Icon( Icon(
Icons.Default.MusicNote, Icons.Default.MusicNote,
contentDescription = null, contentDescription = null,
tint = if (isPlaying) LocalTokens.current.accentFill else LocalTokens.current.textSecondary, tint = LocalTokens.current.textSecondary,
modifier = Modifier.size(24.dp), modifier = Modifier.size(24.dp),
) )
}
Spacer(modifier = Modifier.width(16.dp)) Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(

View file

@ -1,6 +1,11 @@
package com.rada.ui.player package com.rada.ui.player
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility 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.LocalReduceMotion
import com.rada.ui.theme.barEnter import com.rada.ui.theme.barEnter
import com.rada.ui.theme.barExit import com.rada.ui.theme.barExit
@ -55,8 +60,23 @@ fun NowPlayingBar(
val isPlaying by viewModel.isPlaying.collectAsState() val isPlaying by viewModel.isPlaying.collectAsState()
val bufferState by viewModel.bufferState.collectAsState() val bufferState by viewModel.bufferState.collectAsState()
val isLocal by viewModel.isLocal.collectAsState() val isLocal by viewModel.isLocal.collectAsState()
val streamMeta by viewModel.streamMeta.collectAsState()
val localMedia by viewModel.localMedia.collectAsState()
val isVisible = stationName.isNotEmpty() 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( AnimatedVisibility(
visible = isVisible, visible = isVisible,
enter = barEnter(reduce), enter = barEnter(reduce),
@ -105,13 +125,22 @@ fun NowPlayingBar(
.background(LocalTokens.current.accentFill), .background(LocalTokens.current.accentFill),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
if (isPlaying) {
EqualizerBars(
active = true,
color = LocalTokens.current.accentOnFill,
barWidth = 3.dp,
modifier = Modifier.size(22.dp),
)
} else {
Icon( Icon(
if (isLocal) Icons.Default.MusicNote else Icons.Default.Radio, if (isLocal) Icons.Default.MusicNote else Icons.Default.Radio,
contentDescription = if (isLocal) "Now playing" else "Now playing station", contentDescription = "Paused",
tint = LocalTokens.current.accentOnFill, tint = LocalTokens.current.accentOnFill,
modifier = Modifier.size(24.dp), modifier = Modifier.size(24.dp),
) )
} }
}
Spacer(modifier = Modifier.width(12.dp)) Spacer(modifier = Modifier.width(12.dp))
Column( Column(
modifier = Modifier modifier = Modifier
@ -122,8 +151,14 @@ fun NowPlayingBar(
onClick = onNavigateToPlayer, onClick = onNavigateToPlayer,
), ),
) { ) {
Column {
AnimatedContent(
targetState = title,
transitionSpec = { metadataEnter(reduce) togetherWith metadataExit(reduce) },
label = "npTitle",
) { t ->
Text( Text(
text = stationName, text = t,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
maxLines = 1, maxLines = 1,
@ -131,6 +166,23 @@ fun NowPlayingBar(
color = LocalTokens.current.text, 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( IconButton(
onClick = { viewModel.togglePlayPause() }, onClick = { viewModel.togglePlayPause() },
modifier = Modifier.size(44.dp), modifier = Modifier.size(44.dp),

View file

@ -2,6 +2,7 @@ package com.rada.ui.player
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import com.rada.player.PlaybackManager import com.rada.player.PlaybackManager
import com.rada.recording.StreamMetadata
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject import javax.inject.Inject
@ -16,6 +17,8 @@ class NowPlayingViewModel @Inject constructor(
val isPlaying: StateFlow<Boolean> = playbackManager.isPlayingFlow val isPlaying: StateFlow<Boolean> = playbackManager.isPlayingFlow
val bufferState: StateFlow<PlaybackManager.BufferState> = playbackManager.bufferStateFlow val bufferState: StateFlow<PlaybackManager.BufferState> = playbackManager.bufferStateFlow
val isLocal: StateFlow<Boolean> = playbackManager.isLocalFlow val isLocal: StateFlow<Boolean> = playbackManager.isLocalFlow
val streamMeta: StateFlow<StreamMetadata?> = playbackManager.lastStreamMetaFlow
val localMedia: StateFlow<Pair<String, String>?> = playbackManager.localMediaFlow
fun togglePlayPause() { fun togglePlayPause() {
playbackManager.togglePlayPause() playbackManager.togglePlayPause()

View file

@ -60,6 +60,7 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import com.rada.domain.model.Station import com.rada.domain.model.Station
import com.rada.ui.components.AppHomeButton import com.rada.ui.components.AppHomeButton
import com.rada.ui.components.EqualizerBars
import com.rada.ui.theme.LocalTokens import com.rada.ui.theme.LocalTokens
private val genreColors = mapOf( private val genreColors = mapOf(
@ -503,13 +504,17 @@ private fun StationCard(
.background(tokens.accentFill.copy(alpha = 0.15f)), .background(tokens.accentFill.copy(alpha = 0.15f)),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
if (isPlaying) {
EqualizerBars(active = true, color = tokens.accentText)
} else {
Icon( Icon(
if (isPlaying) Icons.Default.PlayArrow else Icons.Default.Radio, Icons.Default.Radio,
contentDescription = null, contentDescription = null,
tint = tokens.accentText, tint = tokens.accentText,
modifier = Modifier.size(20.dp), modifier = Modifier.size(20.dp),
) )
} }
}
Spacer(modifier = Modifier.width(14.dp)) Spacer(modifier = Modifier.width(14.dp))
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(

View file

@ -56,6 +56,7 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import com.rada.domain.model.StreamEntry import com.rada.domain.model.StreamEntry
import com.rada.ui.components.AppHomeButton import com.rada.ui.components.AppHomeButton
import com.rada.ui.components.EqualizerBars
import com.rada.ui.theme.LocalTokens import com.rada.ui.theme.LocalTokens
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@ -415,13 +416,21 @@ private fun StreamCard(
.background(if (isPlaying) LocalTokens.current.accentFill else LocalTokens.current.accentFill.copy(alpha = 0.15f)), .background(if (isPlaying) LocalTokens.current.accentFill else LocalTokens.current.accentFill.copy(alpha = 0.15f)),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
if (isPlaying) {
EqualizerBars(
active = true,
color = LocalTokens.current.accentOnFill,
modifier = Modifier.size(16.dp),
)
} else {
Icon( Icon(
if (isPlaying) Icons.Default.Stop else Icons.Default.PlayArrow, Icons.Default.PlayArrow,
contentDescription = null, contentDescription = null,
tint = if (isPlaying) Color.White else LocalTokens.current.accentText, tint = LocalTokens.current.accentText,
modifier = Modifier.size(22.dp), modifier = Modifier.size(22.dp),
) )
} }
}
Spacer(modifier = Modifier.width(14.dp)) Spacer(modifier = Modifier.width(14.dp))
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(