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.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,
) {
if (isPlaying) {
EqualizerBars(
active = true,
color = LocalTokens.current.accentFill,
modifier = Modifier.size(20.dp),
)
} else {
Icon(
Icons.Default.MusicNote,
contentDescription = null,
tint = if (isPlaying) LocalTokens.current.accentFill else LocalTokens.current.textSecondary,
tint = LocalTokens.current.textSecondary,
modifier = Modifier.size(24.dp),
)
}
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(

View file

@ -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,13 +125,22 @@ fun NowPlayingBar(
.background(LocalTokens.current.accentFill),
contentAlignment = Alignment.Center,
) {
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 = if (isLocal) "Now playing" else "Now playing station",
contentDescription = "Paused",
tint = LocalTokens.current.accentOnFill,
modifier = Modifier.size(24.dp),
)
}
}
Spacer(modifier = Modifier.width(12.dp))
Column(
modifier = Modifier
@ -122,8 +151,14 @@ fun NowPlayingBar(
onClick = onNavigateToPlayer,
),
) {
Column {
AnimatedContent(
targetState = title,
transitionSpec = { metadataEnter(reduce) togetherWith metadataExit(reduce) },
label = "npTitle",
) { t ->
Text(
text = stationName,
text = t,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
@ -131,6 +166,23 @@ fun NowPlayingBar(
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() },
modifier = Modifier.size(44.dp),

View file

@ -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<Boolean> = playbackManager.isPlayingFlow
val bufferState: StateFlow<PlaybackManager.BufferState> = playbackManager.bufferStateFlow
val isLocal: StateFlow<Boolean> = playbackManager.isLocalFlow
val streamMeta: StateFlow<StreamMetadata?> = playbackManager.lastStreamMetaFlow
val localMedia: StateFlow<Pair<String, String>?> = playbackManager.localMediaFlow
fun togglePlayPause() {
playbackManager.togglePlayPause()

View file

@ -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,13 +504,17 @@ private fun StationCard(
.background(tokens.accentFill.copy(alpha = 0.15f)),
contentAlignment = Alignment.Center,
) {
if (isPlaying) {
EqualizerBars(active = true, color = tokens.accentText)
} else {
Icon(
if (isPlaying) Icons.Default.PlayArrow else Icons.Default.Radio,
Icons.Default.Radio,
contentDescription = null,
tint = tokens.accentText,
modifier = Modifier.size(20.dp),
)
}
}
Spacer(modifier = Modifier.width(14.dp))
Column(modifier = Modifier.weight(1f)) {
Text(

View file

@ -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,13 +416,21 @@ private fun StreamCard(
.background(if (isPlaying) LocalTokens.current.accentFill else LocalTokens.current.accentFill.copy(alpha = 0.15f)),
contentAlignment = Alignment.Center,
) {
if (isPlaying) {
EqualizerBars(
active = true,
color = LocalTokens.current.accentOnFill,
modifier = Modifier.size(16.dp),
)
} else {
Icon(
if (isPlaying) Icons.Default.Stop else Icons.Default.PlayArrow,
Icons.Default.PlayArrow,
contentDescription = null,
tint = if (isPlaying) Color.White else LocalTokens.current.accentText,
tint = LocalTokens.current.accentText,
modifier = Modifier.size(22.dp),
)
}
}
Spacer(modifier = Modifier.width(14.dp))
Column(modifier = Modifier.weight(1f)) {
Text(