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:
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user