feat(player): add repeat off/all/one modes

The internal player handles setRepeatMode: repeat-one replays the
current track, repeat-all wraps the queue (and keeps a single-track
shuffle queue alive). The mode is broadcast in playback state,
persisted with the playback session, and toggleable from the Now
Playing transport row and the up-next sheet; a small shuffle toggle
joins the transport row for symmetry.
This commit is contained in:
zarzet
2026-07-26 19:00:50 +07:00
parent 11fb4365a2
commit 8d077ae2da
3 changed files with 112 additions and 0 deletions
+76
View File
@@ -662,6 +662,11 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
(s) => s.value?.shuffleMode == AudioServiceShuffleMode.all,
),
);
final repeatMode = ref.watch(
playbackStateProvider.select(
(s) => s.value?.repeatMode ?? AudioServiceRepeatMode.none,
),
);
final textTheme = Theme.of(context).textTheme;
return Column(
@@ -678,6 +683,33 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
),
),
const Spacer(),
IconButton(
tooltip: switch (repeatMode) {
AudioServiceRepeatMode.one =>
context.l10n.nowPlayingRepeatOne,
AudioServiceRepeatMode.none =>
context.l10n.nowPlayingRepeatOff,
_ => context.l10n.nowPlayingRepeatAll,
},
isSelected:
repeatMode != AudioServiceRepeatMode.none,
icon: Icon(
repeatMode == AudioServiceRepeatMode.one
? Icons.repeat_one
: Icons.repeat,
),
color: repeatMode != AudioServiceRepeatMode.none
? colorScheme.primary
: null,
onPressed: () =>
controller.setRepeatMode(switch (repeatMode) {
AudioServiceRepeatMode.none =>
AudioServiceRepeatMode.all,
AudioServiceRepeatMode.all =>
AudioServiceRepeatMode.one,
_ => AudioServiceRepeatMode.none,
}),
),
IconButton(
tooltip: shuffleOn
? context.l10n.nowPlayingShuffleOn
@@ -846,6 +878,16 @@ class _PlaybackControls extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final position = ref.watch(playbackPositionProvider);
final isPlaying = ref.watch(playbackPlayingProvider);
final shuffleOn = ref.watch(
playbackStateProvider.select(
(s) => s.value?.shuffleMode == AudioServiceShuffleMode.all,
),
);
final repeatMode = ref.watch(
playbackStateProvider.select(
(s) => s.value?.repeatMode ?? AudioServiceRepeatMode.none,
),
);
final maxMs = duration.inMilliseconds > 0
? duration.inMilliseconds.toDouble()
: 1.0;
@@ -918,6 +960,18 @@ class _PlaybackControls extends ConsumerWidget {
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
iconSize: 24,
tooltip: shuffleOn
? context.l10n.nowPlayingShuffleOn
: context.l10n.nowPlayingPlayInOrder,
color: shuffleOn
? colorScheme.primary
: colorScheme.onSurfaceVariant,
icon: const Icon(Icons.shuffle),
onPressed: () => controller.setShuffle(!shuffleOn),
),
const SizedBox(width: 8),
IconButton(
iconSize: 44,
icon: const Icon(Icons.skip_previous),
@@ -943,6 +997,28 @@ class _PlaybackControls extends ConsumerWidget {
icon: const Icon(Icons.skip_next),
onPressed: controller.next,
),
const SizedBox(width: 8),
IconButton(
iconSize: 24,
tooltip: switch (repeatMode) {
AudioServiceRepeatMode.one => context.l10n.nowPlayingRepeatOne,
AudioServiceRepeatMode.none => context.l10n.nowPlayingRepeatOff,
_ => context.l10n.nowPlayingRepeatAll,
},
color: repeatMode == AudioServiceRepeatMode.none
? colorScheme.onSurfaceVariant
: colorScheme.primary,
icon: Icon(
repeatMode == AudioServiceRepeatMode.one
? Icons.repeat_one
: Icons.repeat,
),
onPressed: () => controller.setRepeatMode(switch (repeatMode) {
AudioServiceRepeatMode.none => AudioServiceRepeatMode.all,
AudioServiceRepeatMode.all => AudioServiceRepeatMode.one,
_ => AudioServiceRepeatMode.none,
}),
),
],
),
],