From 03a6dc4e5da05c0c91a16f1d859d2493b9408506 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 26 May 2026 14:45:29 -0400 Subject: [PATCH] feat(android): shuffle + repeat on NowPlaying MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes audit #3 — PlayerController gains toggleShuffle / cycleRepeat methods backed by Media3's Player.shuffleModeEnabled + Player.repeatMode. PlayerUiState surfaces both via a new shuffleEnabled flag and a RepeatMode enum (OFF/ALL/ONE) mapped from Media3's int constants. NowPlaying's BottomActionsRow grows two IconButtons: shuffle toggles (accent when on, muted when off) and repeat cycles off → all → one → off (Lucide.Repeat ↔ Lucide.Repeat1 swap; accent when not-off). PlayerViewModel exposes the two new methods as thin pass-throughs matching the existing transport pattern. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../minstrel/player/PlayerController.kt | 27 ++++++++++++++ .../minstrel/player/PlayerUiState.kt | 9 +++++ .../minstrel/player/ui/NowPlayingScreen.kt | 37 ++++++++++++++++++- .../minstrel/player/ui/PlayerViewModel.kt | 2 + 4 files changed, 74 insertions(+), 1 deletion(-) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt index 0801e899..0c5febe1 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt @@ -78,6 +78,27 @@ class PlayerController @Inject constructor( fun skipToNext() { mediaController?.seekToNextMediaItem() } fun skipToPrevious() { mediaController?.seekToPreviousMediaItem() } + /** Flip shuffle on/off. Media3 emits onEvents → uiState reflects. */ + fun toggleShuffle() { + val controller = mediaController ?: return + controller.shuffleModeEnabled = !controller.shuffleModeEnabled + } + + /** + * Cycles the player's repeat mode: off → all → one → off. Mirrors + * Flutter's `cycleRepeat`. The wire order (none/all/one) matches + * what the Now Playing icon swap expects — Lucide.Repeat for off + * and all, Lucide.Repeat1 for one. + */ + fun cycleRepeat() { + val controller = mediaController ?: return + controller.repeatMode = when (controller.repeatMode) { + Player.REPEAT_MODE_OFF -> Player.REPEAT_MODE_ALL + Player.REPEAT_MODE_ALL -> Player.REPEAT_MODE_ONE + else -> Player.REPEAT_MODE_OFF + } + } + /** Jump to [index] in the current queue. No-op if index is out of range. */ fun seekToIndex(index: Int) { val controller = mediaController ?: return @@ -179,6 +200,12 @@ class PlayerController @Inject constructor( bufferedPositionMs = player.bufferedPosition.coerceAtLeast(0), playbackError = player.playerError?.message, currentSource = source, + shuffleEnabled = player.shuffleModeEnabled, + repeatMode = when (player.repeatMode) { + Player.REPEAT_MODE_ALL -> RepeatMode.ALL + Player.REPEAT_MODE_ONE -> RepeatMode.ONE + else -> RepeatMode.OFF + }, ) } }, diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerUiState.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerUiState.kt index 1a40daf8..fedaf877 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerUiState.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerUiState.kt @@ -2,6 +2,13 @@ package com.fabledsword.minstrel.player import com.fabledsword.minstrel.models.TrackRef +/** + * Cycle on the repeat button: off → all → one → off. Mirrors + * `AudioServiceRepeatMode` in flutter_client and maps directly to + * the three Media3 `Player.REPEAT_MODE_*` int constants. + */ +enum class RepeatMode { OFF, ALL, ONE } + /** * Snapshot of what the player is doing, for the UI to read via * [PlayerController.uiState]. Replaces the audio_service-style @@ -19,4 +26,6 @@ data class PlayerUiState( val bufferedPositionMs: Long = 0, val playbackError: String? = null, val currentSource: String? = null, + val shuffleEnabled: Boolean = false, + val repeatMode: RepeatMode = RepeatMode.OFF, ) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt index 34dc22eb..8e95eb0f 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt @@ -43,8 +43,12 @@ 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.Repeat +import com.composables.icons.lucide.Repeat1 +import com.composables.icons.lucide.Shuffle import com.composables.icons.lucide.SkipBack import com.composables.icons.lucide.SkipForward +import com.fabledsword.minstrel.player.RepeatMode import com.fabledsword.minstrel.nav.AlbumDetail import com.fabledsword.minstrel.nav.ArtistDetail import com.fabledsword.minstrel.nav.Queue @@ -121,7 +125,14 @@ fun NowPlayingScreen( onNext = viewModel::skipToNext, ) Spacer(Modifier.height(16.dp)) - BottomActionsRow(navController = navController, track = track) + BottomActionsRow( + navController = navController, + track = track, + shuffleEnabled = state.shuffleEnabled, + repeatMode = state.repeatMode, + onToggleShuffle = viewModel::toggleShuffle, + onCycleRepeat = viewModel::cycleRepeat, + ) } } } @@ -130,11 +141,35 @@ fun NowPlayingScreen( private fun BottomActionsRow( navController: NavHostController, track: com.fabledsword.minstrel.models.TrackRef, + shuffleEnabled: Boolean, + repeatMode: RepeatMode, + onToggleShuffle: () -> Unit, + onCycleRepeat: () -> Unit, ) { + val accent = MaterialTheme.colorScheme.primary + val muted = MaterialTheme.colorScheme.onSurfaceVariant Row( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { + IconButton(onClick = onToggleShuffle) { + Icon( + imageVector = Lucide.Shuffle, + contentDescription = if (shuffleEnabled) "Shuffle on" else "Shuffle off", + tint = if (shuffleEnabled) accent else muted, + ) + } + IconButton(onClick = onCycleRepeat) { + Icon( + imageVector = if (repeatMode == RepeatMode.ONE) Lucide.Repeat1 else Lucide.Repeat, + contentDescription = when (repeatMode) { + RepeatMode.OFF -> "Repeat off" + RepeatMode.ALL -> "Repeat all" + RepeatMode.ONE -> "Repeat one" + }, + tint = if (repeatMode == RepeatMode.OFF) muted else accent, + ) + } ViewQueueButton(onClick = { navController.navigate(Queue) }) TrackActionsButton( track = track, diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/PlayerViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/PlayerViewModel.kt index 9e927433..b9a32e3f 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/PlayerViewModel.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/PlayerViewModel.kt @@ -31,4 +31,6 @@ class PlayerViewModel @Inject constructor( fun skipToNext() = controller.skipToNext() fun skipToPrevious() = controller.skipToPrevious() fun seekToIndex(index: Int) = controller.seekToIndex(index) + fun toggleShuffle() = controller.toggleShuffle() + fun cycleRepeat() = controller.cycleRepeat() }