diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/MiniPlayer.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/MiniPlayer.kt index 306d410a..6f3c5a12 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/MiniPlayer.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/MiniPlayer.kt @@ -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, + ) } }