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:
parent
5b524f2153
commit
4f8d3e9299
6 changed files with 188 additions and 32 deletions
78
app/src/main/java/com/rada/ui/components/EqualizerBars.kt
Normal file
78
app/src/main/java/com/rada/ui/components/EqualizerBars.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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() },
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue