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:
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user