feat(android): MiniPlayer prev/next + inline scrubber + like button

Audit v2 priority #8 — daily-touch player polish. MiniPlayer was
just cover + title + play-pause + kebab; now matches Flutter:

* Slim 4dp Slider at the top of the bar (Material3 thumb + active
  primary, no labels — those live on NowPlaying).
* Row: cover | title/artist | LikeButton | Prev | Play/Pause | Next | kebab.
* The cover-and-title region is the only part that expands to
  NowPlaying on tap; each IconButton handles its own click so a
  prev/next tap doesn't accidentally open the full player.

Bar height bumped 64 → 80dp to fit the slider above the row.
LikeButton sources its state from the shell-level TrackActionsViewModel
already plumbed through ShellScaffold (same hiltViewModel() instance).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-26 21:35:51 -04:00
parent e82710dc0b
commit a606267c1e
@@ -15,6 +15,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -32,12 +34,18 @@ import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Music
import com.composables.icons.lucide.Pause
import com.composables.icons.lucide.Play
import com.composables.icons.lucide.SkipBack
import com.composables.icons.lucide.SkipForward
import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.shared.widgets.LikeButton
import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsButton
import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsViewModel
import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
private const val MINI_BAR_HEIGHT_DP = 64
private const val MINI_BAR_HEIGHT_DP = 80
private const val MINI_BAR_TONAL_ELEVATION_DP = 4
private const val COVER_SIZE_DP = 48
private const val SCRUBBER_ROW_HEIGHT_DP = 4
@Composable
private fun MiniCover(coverUrl: String, contentDescription: String) {
@@ -71,9 +79,14 @@ private fun MiniCover(coverUrl: String, contentDescription: String) {
* fresh install. Tapping the body navigates to the full
* NowPlayingScreen via [onExpandClick].
*
* Cover comes from TrackRef.coverUrl (derived from albumId via the
* BaseUrlInterceptor placeholder); falls back to the Lucide.Music
* icon when the album binding is missing.
* Layout (Column):
* - Slim seek slider at the top (4dp track)
* - Row: cover | title/artist column | like | prev | play/pause | next | kebab
*
* The slider participates in pointer-events so tap/scrub works
* without the surface's onClick eating the gesture; the row's
* surface-level clickable still expands to NowPlaying on tap of
* the cover/title area.
*/
@Composable
fun MiniPlayer(
@@ -82,20 +95,92 @@ fun MiniPlayer(
onNavigateToArtist: (String) -> Unit,
modifier: Modifier = Modifier,
viewModel: PlayerViewModel = hiltViewModel(),
trackActionsViewModel: TrackActionsViewModel = hiltViewModel(),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val track = state.currentTrack ?: return
val isLiked by trackActionsViewModel.isLikedFlow(track.id)
.collectAsStateWithLifecycle(initialValue = false)
Surface(
modifier = modifier
.fillMaxWidth()
.height(MINI_BAR_HEIGHT_DP.dp)
.clickable(onClick = onExpandClick),
.height(MINI_BAR_HEIGHT_DP.dp),
color = MaterialTheme.colorScheme.surface,
tonalElevation = MINI_BAR_TONAL_ELEVATION_DP.dp,
) {
Column {
MiniSeekBar(
positionMs = state.positionMs,
durationMs = state.durationMs,
onSeek = viewModel::seekTo,
)
MiniRow(
track = track,
isPlaying = state.isPlaying,
isLiked = isLiked,
onExpandClick = onExpandClick,
onPlayPause = { if (state.isPlaying) viewModel.pause() else viewModel.play() },
onPrev = viewModel::skipToPrevious,
onNext = viewModel::skipToNext,
onToggleLike = {
trackActionsViewModel.toggleLike(track.id, isLiked)
},
onNavigateToAlbum = onNavigateToAlbum,
onNavigateToArtist = onNavigateToArtist,
)
}
}
}
@Composable
private fun MiniSeekBar(positionMs: Long, durationMs: Long, onSeek: (Long) -> Unit) {
val fraction = if (durationMs > 0) {
(positionMs.toFloat() / durationMs.toFloat()).coerceIn(0f, 1f)
} else {
0f
}
Slider(
value = fraction,
onValueChange = { v -> if (durationMs > 0) onSeek((v * durationMs).toLong()) },
modifier = Modifier
.fillMaxWidth()
.height(SCRUBBER_ROW_HEIGHT_DP.dp),
colors = SliderDefaults.colors(
thumbColor = MaterialTheme.colorScheme.primary,
activeTrackColor = MaterialTheme.colorScheme.primary,
),
)
}
@Composable
@Suppress("LongParameterList")
private fun MiniRow(
track: TrackRef,
isPlaying: Boolean,
isLiked: Boolean,
onExpandClick: () -> Unit,
onPlayPause: () -> Unit,
onPrev: () -> Unit,
onNext: () -> Unit,
onToggleLike: () -> Unit,
onNavigateToAlbum: (String) -> Unit,
onNavigateToArtist: (String) -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// Cover + title area expands to NowPlaying on tap, but the
// transport buttons + kebab don't (each IconButton handles
// its own clicks). Wrap only the cover-and-title region with
// the clickable modifier rather than the whole row.
Row(
modifier = Modifier.padding(horizontal = 12.dp),
modifier = Modifier
.weight(1f)
.clickable(onClick = onExpandClick),
verticalAlignment = Alignment.CenterVertically,
) {
MiniCover(coverUrl = track.coverUrl, contentDescription = track.title)
@@ -116,23 +201,36 @@ fun MiniPlayer(
overflow = TextOverflow.Ellipsis,
)
}
IconButton(
onClick = { if (state.isPlaying) viewModel.pause() else viewModel.play() },
) {
Icon(
imageVector = if (state.isPlaying) Lucide.Pause else Lucide.Play,
contentDescription = if (state.isPlaying) "Pause" else "Play",
tint = MaterialTheme.colorScheme.onSurface,
)
}
// hideQueueActions=true: the playing track is itself the
// queue entry, so Play next / Add to queue would be silly.
TrackActionsButton(
track = track,
hideQueueActions = true,
onNavigateToAlbum = onNavigateToAlbum,
onNavigateToArtist = onNavigateToArtist,
}
LikeButton(liked = isLiked, onToggle = onToggleLike)
IconButton(onClick = onPrev) {
Icon(
imageVector = Lucide.SkipBack,
contentDescription = "Previous",
tint = MaterialTheme.colorScheme.onSurface,
)
}
IconButton(onClick = onPlayPause) {
Icon(
imageVector = if (isPlaying) Lucide.Pause else Lucide.Play,
contentDescription = if (isPlaying) "Pause" else "Play",
tint = MaterialTheme.colorScheme.onSurface,
)
}
IconButton(onClick = onNext) {
Icon(
imageVector = Lucide.SkipForward,
contentDescription = "Next",
tint = MaterialTheme.colorScheme.onSurface,
)
}
// hideQueueActions=true: the playing track is itself the
// queue entry, so Play next / Add to queue would be silly.
TrackActionsButton(
track = track,
hideQueueActions = true,
onNavigateToAlbum = onNavigateToAlbum,
onNavigateToArtist = onNavigateToArtist,
)
}
}