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 skipToNext() { mediaController?.seekToNextMediaItem() }
fun skipToPrevious() { mediaController?.seekToPreviousMediaItem() } 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. */ /** Jump to [index] in the current queue. No-op if index is out of range. */
fun seekToIndex(index: Int) { fun seekToIndex(index: Int) {
val controller = mediaController ?: return val controller = mediaController ?: return
@@ -179,6 +200,12 @@ class PlayerController @Inject constructor(
bufferedPositionMs = player.bufferedPosition.coerceAtLeast(0), bufferedPositionMs = player.bufferedPosition.coerceAtLeast(0),
playbackError = player.playerError?.message, playbackError = player.playerError?.message,
currentSource = source, 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 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 * Snapshot of what the player is doing, for the UI to read via
* [PlayerController.uiState]. Replaces the audio_service-style * [PlayerController.uiState]. Replaces the audio_service-style
@@ -19,4 +26,6 @@ data class PlayerUiState(
val bufferedPositionMs: Long = 0, val bufferedPositionMs: Long = 0,
val playbackError: String? = null, val playbackError: String? = null,
val currentSource: 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.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.Repeat
import com.composables.icons.lucide.Repeat1
import com.composables.icons.lucide.Shuffle
import com.composables.icons.lucide.SkipBack import com.composables.icons.lucide.SkipBack
import com.composables.icons.lucide.SkipForward import com.composables.icons.lucide.SkipForward
import com.fabledsword.minstrel.player.RepeatMode
import com.fabledsword.minstrel.nav.AlbumDetail import com.fabledsword.minstrel.nav.AlbumDetail
import com.fabledsword.minstrel.nav.ArtistDetail import com.fabledsword.minstrel.nav.ArtistDetail
import com.fabledsword.minstrel.nav.Queue import com.fabledsword.minstrel.nav.Queue
@@ -121,7 +125,14 @@ fun NowPlayingScreen(
onNext = viewModel::skipToNext, onNext = viewModel::skipToNext,
) )
Spacer(Modifier.height(16.dp)) 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( private fun BottomActionsRow(
navController: NavHostController, navController: NavHostController,
track: com.fabledsword.minstrel.models.TrackRef, 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( Row(
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically, 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) }) ViewQueueButton(onClick = { navController.navigate(Queue) })
TrackActionsButton( TrackActionsButton(
track = track, track = track,
@@ -31,4 +31,6 @@ class PlayerViewModel @Inject constructor(
fun skipToNext() = controller.skipToNext() fun skipToNext() = controller.skipToNext()
fun skipToPrevious() = controller.skipToPrevious() fun skipToPrevious() = controller.skipToPrevious()
fun seekToIndex(index: Int) = controller.seekToIndex(index) fun seekToIndex(index: Int) = controller.seekToIndex(index)
fun toggleShuffle() = controller.toggleShuffle()
fun cycleRepeat() = controller.cycleRepeat()
} }