feat(android): shuffle + repeat on NowPlaying

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-26 14:45:29 -04:00
parent dc68156d98
commit 03a6dc4e5d
4 changed files with 74 additions and 1 deletions
@@ -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
},
)
}
},
@@ -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,
)
@@ -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,
@@ -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()
}