diff --git a/lib/providers/music_player_provider.dart b/lib/providers/music_player_provider.dart index 1a56ee1c..593f4ca5 100644 --- a/lib/providers/music_player_provider.dart +++ b/lib/providers/music_player_provider.dart @@ -102,6 +102,9 @@ class MusicPlayerController { ); } + Future setRepeatMode(AudioServiceRepeatMode mode) async => + _handler?.setRepeatMode(mode); + Future playNext(PlayableMedia item) async => (await ensureInitialized())?.enqueue(item, playNext: true); diff --git a/lib/screens/now_playing_screen.dart b/lib/screens/now_playing_screen.dart index 6f57a5ff..7525c0dd 100644 --- a/lib/screens/now_playing_screen.dart +++ b/lib/screens/now_playing_screen.dart @@ -662,6 +662,11 @@ class _NowPlayingScreenState extends ConsumerState { (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 { ), ), 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, + }), + ), ], ), ], diff --git a/lib/services/music_player_service.dart b/lib/services/music_player_service.dart index 12607946..1fe2c73d 100644 --- a/lib/services/music_player_service.dart +++ b/lib/services/music_player_service.dart @@ -131,6 +131,7 @@ class MusicPlayerHandler extends BaseAudioHandler bool _initialized = false; bool _shuffle = false; + AudioServiceRepeatMode _repeatMode = AudioServiceRepeatMode.none; final Random _random = Random(); final List _recent = []; final List _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.unmodifiable(_queueItems)); mediaItem.add(_media[_index].toMediaItem()); @@ -774,9 +779,18 @@ class MusicPlayerHandler extends BaseAudioHandler } Future _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 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 stop() async { _playRequestGeneration++; @@ -1114,6 +1143,10 @@ Future 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 '