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
+3
View File
@@ -102,6 +102,9 @@ class MusicPlayerController {
);
}
Future<void> setRepeatMode(AudioServiceRepeatMode mode) async =>
_handler?.setRepeatMode(mode);
Future<void> playNext(PlayableMedia item) async =>
(await ensureInitialized())?.enqueue(item, playNext: true);
+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,
}),
),
],
),
],
+33
View File
@@ -131,6 +131,7 @@ class MusicPlayerHandler extends BaseAudioHandler
bool _initialized = false;
bool _shuffle = false;
AudioServiceRepeatMode _repeatMode = AudioServiceRepeatMode.none;
final Random _random = Random();
final List<int> _recent = [];
final List<int> _playHistory = [];
@@ -320,6 +321,7 @@ class MusicPlayerHandler extends BaseAudioHandler
shuffleMode: _shuffle
? AudioServiceShuffleMode.all
: AudioServiceShuffleMode.none,
repeatMode: _repeatMode,
),
);
}
@@ -489,6 +491,7 @@ class MusicPlayerHandler extends BaseAudioHandler
'index': _index,
'positionMs': (position ?? Duration.zero).inMilliseconds,
'shuffle': _shuffle,
'repeat': _repeatMode.name,
};
unawaited(
AppStateDatabase.instance.savePlaybackSession(session).catchError((
@@ -515,6 +518,7 @@ class MusicPlayerHandler extends BaseAudioHandler
required int index,
required Duration position,
required bool shuffle,
AudioServiceRepeatMode repeatMode = AudioServiceRepeatMode.none,
}) async {
if (items.isEmpty) return;
// Never clobber a session the user already started this launch.
@@ -529,6 +533,7 @@ class MusicPlayerHandler extends BaseAudioHandler
..addAll(items.map((m) => m.toMediaItem()));
_index = index.clamp(0, items.length - 1);
_shuffle = shuffle;
_repeatMode = repeatMode;
_pendingRestorePosition = position > Duration.zero ? position : null;
queue.add(List<MediaItem>.unmodifiable(_queueItems));
mediaItem.add(_media[_index].toMediaItem());
@@ -774,9 +779,18 @@ class MusicPlayerHandler extends BaseAudioHandler
}
Future<void> _onComplete() async {
if (_repeatMode == AudioServiceRepeatMode.one &&
_index >= 0 &&
_index < _media.length) {
await _playIndex(_index, recordHistory: false);
return;
}
if (_shuffle) {
if (_media.length > 1) {
await _playIndex(_pickNextShuffle());
} else if (_repeatMode == AudioServiceRepeatMode.all &&
_media.isNotEmpty) {
await _playIndex(_index, recordHistory: false);
} else {
_broadcastState(playerState: PlayerState.completed);
}
@@ -784,6 +798,9 @@ class MusicPlayerHandler extends BaseAudioHandler
}
if (_index >= 0 && _index < _media.length - 1) {
await _playIndex(_index + 1);
} else if (_repeatMode == AudioServiceRepeatMode.all &&
_media.isNotEmpty) {
await _playIndex(0);
} else {
_broadcastState(playerState: PlayerState.completed);
}
@@ -854,6 +871,18 @@ class MusicPlayerHandler extends BaseAudioHandler
}
}
@override
Future<void> setRepeatMode(AudioServiceRepeatMode repeatMode) async {
// Group repeat has no meaning for a flat queue; treat it as all.
_repeatMode = repeatMode == AudioServiceRepeatMode.group
? AudioServiceRepeatMode.all
: repeatMode;
_broadcastState();
if (_media.isNotEmpty && _index >= 0) {
_persistSession(position: playbackState.value.position);
}
}
@override
Future<void> stop() async {
_playRequestGeneration++;
@@ -1114,6 +1143,10 @@ Future<void> restorePersistedPlaybackSession() async {
index: index,
position: position,
shuffle: session['shuffle'] == true,
repeatMode: AudioServiceRepeatMode.values.firstWhere(
(mode) => mode.name == session['repeat'],
orElse: () => AudioServiceRepeatMode.none,
),
);
_log.i(
'Restored playback session: ${items.length} track(s), paused at '