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.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Slider
|
||||||
|
import androidx.compose.material3.SliderDefaults
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
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.Music
|
||||||
import com.composables.icons.lucide.Pause
|
import com.composables.icons.lucide.Pause
|
||||||
import com.composables.icons.lucide.Play
|
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.TrackActionsButton
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsViewModel
|
||||||
import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
|
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 MINI_BAR_TONAL_ELEVATION_DP = 4
|
||||||
private const val COVER_SIZE_DP = 48
|
private const val COVER_SIZE_DP = 48
|
||||||
|
private const val SCRUBBER_ROW_HEIGHT_DP = 4
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun MiniCover(coverUrl: String, contentDescription: String) {
|
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
|
* fresh install. Tapping the body navigates to the full
|
||||||
* NowPlayingScreen via [onExpandClick].
|
* NowPlayingScreen via [onExpandClick].
|
||||||
*
|
*
|
||||||
* Cover comes from TrackRef.coverUrl (derived from albumId via the
|
* Layout (Column):
|
||||||
* BaseUrlInterceptor placeholder); falls back to the Lucide.Music
|
* - Slim seek slider at the top (4dp track)
|
||||||
* icon when the album binding is missing.
|
* - 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
|
@Composable
|
||||||
fun MiniPlayer(
|
fun MiniPlayer(
|
||||||
@@ -82,20 +95,92 @@ fun MiniPlayer(
|
|||||||
onNavigateToArtist: (String) -> Unit,
|
onNavigateToArtist: (String) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: PlayerViewModel = hiltViewModel(),
|
viewModel: PlayerViewModel = hiltViewModel(),
|
||||||
|
trackActionsViewModel: TrackActionsViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val track = state.currentTrack ?: return
|
val track = state.currentTrack ?: return
|
||||||
|
val isLiked by trackActionsViewModel.isLikedFlow(track.id)
|
||||||
|
.collectAsStateWithLifecycle(initialValue = false)
|
||||||
|
|
||||||
Surface(
|
Surface(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(MINI_BAR_HEIGHT_DP.dp)
|
.height(MINI_BAR_HEIGHT_DP.dp),
|
||||||
.clickable(onClick = onExpandClick),
|
|
||||||
color = MaterialTheme.colorScheme.surface,
|
color = MaterialTheme.colorScheme.surface,
|
||||||
tonalElevation = MINI_BAR_TONAL_ELEVATION_DP.dp,
|
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(
|
Row(
|
||||||
modifier = Modifier.padding(horizontal = 12.dp),
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.clickable(onClick = onExpandClick),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
MiniCover(coverUrl = track.coverUrl, contentDescription = track.title)
|
MiniCover(coverUrl = track.coverUrl, contentDescription = track.title)
|
||||||
@@ -116,23 +201,36 @@ fun MiniPlayer(
|
|||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
IconButton(
|
}
|
||||||
onClick = { if (state.isPlaying) viewModel.pause() else viewModel.play() },
|
LikeButton(liked = isLiked, onToggle = onToggleLike)
|
||||||
) {
|
IconButton(onClick = onPrev) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = if (state.isPlaying) Lucide.Pause else Lucide.Play,
|
imageVector = Lucide.SkipBack,
|
||||||
contentDescription = if (state.isPlaying) "Pause" else "Play",
|
contentDescription = "Previous",
|
||||||
tint = MaterialTheme.colorScheme.onSurface,
|
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,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
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