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.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,
|
||||||
) {
|
) {
|
||||||
Icon(
|
if (isPlaying) {
|
||||||
Icons.Default.MusicNote,
|
EqualizerBars(
|
||||||
contentDescription = null,
|
active = true,
|
||||||
tint = if (isPlaying) LocalTokens.current.accentFill else LocalTokens.current.textSecondary,
|
color = LocalTokens.current.accentFill,
|
||||||
modifier = Modifier.size(24.dp),
|
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))
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Text(
|
Text(
|
||||||
|
|
|
||||||
|
|
@ -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,12 +125,21 @@ fun NowPlayingBar(
|
||||||
.background(LocalTokens.current.accentFill),
|
.background(LocalTokens.current.accentFill),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Icon(
|
if (isPlaying) {
|
||||||
if (isLocal) Icons.Default.MusicNote else Icons.Default.Radio,
|
EqualizerBars(
|
||||||
contentDescription = if (isLocal) "Now playing" else "Now playing station",
|
active = true,
|
||||||
tint = LocalTokens.current.accentOnFill,
|
color = LocalTokens.current.accentOnFill,
|
||||||
modifier = Modifier.size(24.dp),
|
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))
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
Column(
|
Column(
|
||||||
|
|
@ -122,14 +151,37 @@ fun NowPlayingBar(
|
||||||
onClick = onNavigateToPlayer,
|
onClick = onNavigateToPlayer,
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
Text(
|
Column {
|
||||||
text = stationName,
|
AnimatedContent(
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
targetState = title,
|
||||||
fontWeight = FontWeight.SemiBold,
|
transitionSpec = { metadataEnter(reduce) togetherWith metadataExit(reduce) },
|
||||||
maxLines = 1,
|
label = "npTitle",
|
||||||
overflow = TextOverflow.Ellipsis,
|
) { t ->
|
||||||
color = LocalTokens.current.text,
|
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(
|
IconButton(
|
||||||
onClick = { viewModel.togglePlayPause() },
|
onClick = { viewModel.togglePlayPause() },
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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,12 +504,16 @@ private fun StationCard(
|
||||||
.background(tokens.accentFill.copy(alpha = 0.15f)),
|
.background(tokens.accentFill.copy(alpha = 0.15f)),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Icon(
|
if (isPlaying) {
|
||||||
if (isPlaying) Icons.Default.PlayArrow else Icons.Default.Radio,
|
EqualizerBars(active = true, color = tokens.accentText)
|
||||||
contentDescription = null,
|
} else {
|
||||||
tint = tokens.accentText,
|
Icon(
|
||||||
modifier = Modifier.size(20.dp),
|
Icons.Default.Radio,
|
||||||
)
|
contentDescription = null,
|
||||||
|
tint = tokens.accentText,
|
||||||
|
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)) {
|
||||||
|
|
|
||||||
|
|
@ -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,12 +416,20 @@ 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,
|
||||||
) {
|
) {
|
||||||
Icon(
|
if (isPlaying) {
|
||||||
if (isPlaying) Icons.Default.Stop else Icons.Default.PlayArrow,
|
EqualizerBars(
|
||||||
contentDescription = null,
|
active = true,
|
||||||
tint = if (isPlaying) Color.White else LocalTokens.current.accentText,
|
color = LocalTokens.current.accentOnFill,
|
||||||
modifier = Modifier.size(22.dp),
|
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))
|
Spacer(modifier = Modifier.width(14.dp))
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue