From b767bd16d894eacf241f919a8a58c79e3aaa302b Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Sat, 3 Oct 2026 02:14:01 +0700 Subject: [PATCH] fix(playback): refresh notification favorite and shuffle controls --- .../res/drawable/ic_notification_shuffle.xml | 8 ++ .../drawable/ic_notification_shuffle_on.xml | 10 ++ .../playback_notification_provider.dart | 21 ++- lib/screens/main_shell.dart | 3 +- lib/services/music_player_service.dart | 37 +++++- lib/services/playback_notification.dart | 38 ++++-- test/music_player_automix_test.dart | 124 +++++++++++++++++- 7 files changed, 215 insertions(+), 26 deletions(-) create mode 100644 android/app/src/main/res/drawable/ic_notification_shuffle.xml create mode 100644 android/app/src/main/res/drawable/ic_notification_shuffle_on.xml diff --git a/android/app/src/main/res/drawable/ic_notification_shuffle.xml b/android/app/src/main/res/drawable/ic_notification_shuffle.xml new file mode 100644 index 00000000..9006193f --- /dev/null +++ b/android/app/src/main/res/drawable/ic_notification_shuffle.xml @@ -0,0 +1,8 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_notification_shuffle_on.xml b/android/app/src/main/res/drawable/ic_notification_shuffle_on.xml new file mode 100644 index 00000000..c0993a9a --- /dev/null +++ b/android/app/src/main/res/drawable/ic_notification_shuffle_on.xml @@ -0,0 +1,10 @@ + + + + diff --git a/lib/providers/playback_notification_provider.dart b/lib/providers/playback_notification_provider.dart index d1ec50e0..f7f8d9e4 100644 --- a/lib/providers/playback_notification_provider.dart +++ b/lib/providers/playback_notification_provider.dart @@ -11,10 +11,15 @@ import 'package:spotiflac_android/services/playback_notification.dart'; /// Kept alive by the app shell so notification favorites share the exact /// Library identity and refresh when the same track is starred inside the app. final playbackNotificationProvider = Provider.autoDispose - .family(( - ref, - labels, - ) { + .family< + void, + ({ + String favorite, + String unfavorite, + String shuffleOn, + String shuffleOff, + }) + >((ref, labels) { final mornye = ref.watch( themeProvider.select((theme) => theme.style == AppThemeStyle.mornye), ); @@ -39,15 +44,17 @@ final playbackNotificationProvider = Provider.autoDispose mediaId: item?.id, source: item?.extras?['source']?.toString(), loved: loved, - favoriteLabel: loved ? labels.unfavorite : labels.favorite, - outputLabel: labels.output, + favoriteLabel: labels.favorite, + unfavoriteLabel: labels.unfavorite, + shuffleOnLabel: labels.shuffleOn, + shuffleOffLabel: labels.shuffleOff, ), toggleFavorite: (selected) async { final collections = ref.read(libraryCollectionsProvider.notifier); final selectedTrack = await ref.read( playerCollectionTrackProvider(selected).future, ); - await collections.toggleLoved(selectedTrack); + return collections.toggleLoved(selectedTrack); }, ); }); diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index 679354ab..86086732 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -772,7 +772,8 @@ class _MainShellState extends ConsumerState playbackNotificationProvider(( favorite: context.l10n.trackOptionAddToLoved, unfavorite: context.l10n.trackOptionRemoveFromLoved, - output: context.l10n.nowPlayingAudioOutput, + shuffleOn: context.l10n.nowPlayingShuffleOn, + shuffleOff: context.l10n.nowPlayingPlayInOrder, )), ); ref.listen(settingsProvider.select((s) => s.playbackNormalization), ( diff --git a/lib/services/music_player_service.dart b/lib/services/music_player_service.dart index 3afeb86c..fa48d15e 100644 --- a/lib/services/music_player_service.dart +++ b/lib/services/music_player_service.dart @@ -51,14 +51,14 @@ bool _usbAllowFixedVolume = false; bool _dapExclusiveEnabled = false; MusicPlayerHandler? _activeMusicPlayerHandler; PlaybackNotification _notificationPresentation = const PlaybackNotification(); -Future Function(MediaItem)? _toggleNotificationFavorite; +Future Function(MediaItem)? _toggleNotificationFavorite; const _notificationChannel = MethodChannel( 'com.zarz.spotiflac/playback_notification', ); void configurePlaybackNotification({ required PlaybackNotification presentation, - required Future Function(MediaItem) toggleFavorite, + required Future Function(MediaItem) toggleFavorite, }) { _notificationPresentation = presentation; _toggleNotificationFavorite = toggleFavorite; @@ -82,7 +82,7 @@ Future _publishIosNotificationFavorite() async { _notificationPresentation.mornye && _notificationPresentation.mediaId != null, 'loved': _notificationPresentation.loved, - 'label': _notificationPresentation.favoriteLabel, + 'label': _notificationPresentation.favoriteActionLabel, }); } on MissingPluginException { // Unit tests do not install the iOS remote-command bridge. @@ -703,6 +703,7 @@ class MusicPlayerHandler extends BaseAudioHandler controls: _notificationPresentation.controls( playing: playing, item: mediaItem.value, + shuffle: _shuffle, ), systemActions: const { MediaAction.seek, @@ -739,6 +740,7 @@ class MusicPlayerHandler extends BaseAudioHandler controls: _notificationPresentation.controls( playing: state.playing, item: mediaItem.value, + shuffle: _shuffle, ), androidCompactActionIndices: _notificationPresentation.mornye ? const [1, 2, 3] @@ -748,6 +750,7 @@ class MusicPlayerHandler extends BaseAudioHandler } bool _savingNotificationFavorite = false; + bool _changingNotificationShuffle = false; @override Future customAction( @@ -759,12 +762,38 @@ class MusicPlayerHandler extends BaseAudioHandler if (item == null || _savingNotificationFavorite) return; _savingNotificationFavorite = true; try { - await _toggleNotificationFavorite?.call(item); + final loved = await _toggleNotificationFavorite?.call(item); + final current = mediaItem.value; + if (!_disposed && + loved != null && + current?.id == item.id && + current?.extras?['source'] == item.extras?['source']) { + // Widget/provider rebuilds may wait for a frame while the app is + // backgrounded. Publish the persisted result directly to the OS. + _notificationPresentation = _notificationPresentation.withFavorite( + item, + loved, + ); + _refreshNotificationControls(); + if (Platform.isIOS) unawaited(_publishIosNotificationFavorite()); + } } catch (error) { _log.w('Notification favorite failed: $error'); } finally { _savingNotificationFavorite = false; } + } else if (name == PlaybackNotification.shuffleAction) { + if (mediaItem.value == null || _changingNotificationShuffle) return; + _changingNotificationShuffle = true; + try { + await setShuffleMode( + _shuffle ? AudioServiceShuffleMode.none : AudioServiceShuffleMode.all, + ); + } catch (error) { + _log.w('Notification shuffle failed: $error'); + } finally { + _changingNotificationShuffle = false; + } } else { return super.customAction(name, extras); } diff --git a/lib/services/playback_notification.dart b/lib/services/playback_notification.dart index bca36752..5427c24b 100644 --- a/lib/services/playback_notification.dart +++ b/lib/services/playback_notification.dart @@ -3,14 +3,16 @@ import 'package:audio_service/audio_service.dart'; /// Presentation only; every transport action still uses the audio handler. class PlaybackNotification { static const favoriteAction = 'spotiflac.favorite'; - static const outputAction = 'spotiflac.audioOutput'; + static const shuffleAction = 'spotiflac.shuffle'; final bool mornye; final String? mediaId; final String? source; final bool loved; final String favoriteLabel; - final String outputLabel; + final String unfavoriteLabel; + final String shuffleOnLabel; + final String shuffleOffLabel; const PlaybackNotification({ this.mornye = false, @@ -18,12 +20,29 @@ class PlaybackNotification { this.source, this.loved = false, this.favoriteLabel = 'Favorite', - this.outputLabel = 'Audio Output', + this.unfavoriteLabel = 'Remove from favorites', + this.shuffleOnLabel = 'Shuffle on', + this.shuffleOffLabel = 'Shuffle off', }); + String get favoriteActionLabel => loved ? unfavoriteLabel : favoriteLabel; + + PlaybackNotification withFavorite(MediaItem item, bool loved) => + PlaybackNotification( + mornye: mornye, + mediaId: item.id, + source: item.extras?['source']?.toString(), + loved: loved, + favoriteLabel: favoriteLabel, + unfavoriteLabel: unfavoriteLabel, + shuffleOnLabel: shuffleOnLabel, + shuffleOffLabel: shuffleOffLabel, + ); + List controls({ required bool playing, required MediaItem? item, + bool shuffle = false, }) { if (!mornye) { return [ @@ -39,7 +58,7 @@ class PlaybackNotification { androidIcon: current && loved ? 'drawable/ic_notification_star_filled' : 'drawable/ic_notification_star', - label: favoriteLabel, + label: current && loved ? unfavoriteLabel : favoriteLabel, ), MediaControl.skipToPrevious.copyWith( androidIcon: 'drawable/ic_widget_previous', @@ -51,12 +70,11 @@ class PlaybackNotification { ), MediaControl.skipToNext.copyWith(androidIcon: 'drawable/ic_widget_next'), MediaControl.custom( - name: outputAction, - androidIcon: 'drawable/ic_notification_airplay', - label: outputLabel, - extras: const { - 'spotiflac.activity': 'com.zarz.spotiflac.AudioOutputActivity', - }, + name: shuffleAction, + androidIcon: shuffle + ? 'drawable/ic_notification_shuffle_on' + : 'drawable/ic_notification_shuffle', + label: shuffle ? shuffleOnLabel : shuffleOffLabel, ), ]; } diff --git a/test/music_player_automix_test.dart b/test/music_player_automix_test.dart index bdcf3a62..0657aa17 100644 --- a/test/music_player_automix_test.dart +++ b/test/music_player_automix_test.dart @@ -162,7 +162,7 @@ void main() { await handler.dispose(); configurePlaybackNotification( presentation: const PlaybackNotification(), - toggleFavorite: (_) async {}, + toggleFavorite: (_) async => false, ); analyzer.pending?.complete(null); setAutoMixEnabled(false); @@ -806,7 +806,7 @@ void main() { source: '/one.flac', loved: true, ), - toggleFavorite: (_) async {}, + toggleFavorite: (_) async => false, ); final state = handler.playbackState.value; expect(state.playing, isFalse); @@ -819,11 +819,15 @@ void main() { MediaAction.custom, ]); expect(state.controls.first.androidIcon, contains('star_filled')); + expect( + state.controls.last.customAction?.name, + PlaybackNotification.shuffleAction, + ); expect(state.androidCompactActionIndices, [1, 2, 3]); configurePlaybackNotification( presentation: const PlaybackNotification(), - toggleFavorite: (_) async {}, + toggleFavorite: (_) async => false, ); expect(handler.playbackState.value.controls, [ MediaControl.skipToPrevious, @@ -842,7 +846,7 @@ void main() { ); configurePlaybackNotification( presentation: const PlaybackNotification(mornye: true), - toggleFavorite: (_) async {}, + toggleFavorite: (_) async => false, ); expect( handler.playbackState.value.updatePosition.inMilliseconds, @@ -851,6 +855,55 @@ void main() { ); }); + test( + 'notification shuffle toggles queue order without restarting audio', + () async { + configurePlaybackNotification( + presentation: const PlaybackNotification(mornye: true), + toggleFavorite: (_) async => false, + ); + await handler.setQueueAndPlay(_tracks, initialIndex: 1); + final resumed = List.of(native.resumedSources); + + await handler.customAction(PlaybackNotification.shuffleAction); + expect( + handler.playbackState.value.shuffleMode, + AudioServiceShuffleMode.all, + ); + expect(handler.queue.value.first.id, 'two'); + expect(handler.queue.value.map((item) => item.id).toSet(), { + 'one', + 'two', + 'three', + }); + expect(handler.mediaItem.value?.id, 'two'); + expect( + handler.playbackState.value.controls.last.androidIcon, + endsWith('shuffle_on'), + ); + expect(handler.playbackState.value.controls.last.label, 'Shuffle on'); + + await handler.customAction(PlaybackNotification.shuffleAction); + expect( + handler.playbackState.value.shuffleMode, + AudioServiceShuffleMode.none, + ); + expect(handler.queue.value.map((item) => item.id), [ + 'one', + 'two', + 'three', + ]); + expect(handler.mediaItem.value?.id, 'two'); + expect( + handler.playbackState.value.controls.last.androidIcon, + endsWith('shuffle'), + ); + expect(handler.playbackState.value.controls.last.label, 'Shuffle off'); + expect(handler.playbackState.value.playing, isTrue); + expect(native.resumedSources, resumed); + }, + ); + test( 'shuffle plays the published queue and off restores the original order', () async { @@ -1034,6 +1087,7 @@ void main() { toggleFavorite: (item) async { selected.add(item.id); await save.future; + return true; }, ); handler.mediaItem.add(_tracks.first.toMediaItem()); @@ -1043,11 +1097,73 @@ void main() { expect(selected, ['one']); save.complete(); await first; + expect( + handler.playbackState.value.controls.first.androidIcon, + endsWith('ic_notification_star'), + reason: + 'Completing a favorite for the old track must not star the next one', + ); await handler.customAction(PlaybackNotification.favoriteAction); expect(selected, ['one', 'three']); + expect( + handler.playbackState.value.controls.first.androidIcon, + endsWith('ic_notification_star_filled'), + ); }, ); + test('notification favorite refreshes without a UI rebuild', () async { + var loved = false; + configurePlaybackNotification( + presentation: const PlaybackNotification( + mornye: true, + favoriteLabel: 'Add to Loved', + unfavoriteLabel: 'Remove from Loved', + ), + toggleFavorite: (_) async => loved = !loved, + ); + await handler.restoreSession( + items: _tracks, + index: 0, + position: const Duration(seconds: 12), + shuffle: false, + ); + await handler.customAction(PlaybackNotification.favoriteAction); + expect( + handler.playbackState.value.controls.first.androidIcon, + endsWith('ic_notification_star_filled'), + ); + expect( + handler.playbackState.value.controls.first.label, + 'Remove from Loved', + ); + expect( + handler.playbackState.value.updatePosition, + const Duration(seconds: 12), + ); + + await handler.customAction(PlaybackNotification.favoriteAction); + expect( + handler.playbackState.value.controls.first.androidIcon, + endsWith('ic_notification_star'), + ); + expect(handler.playbackState.value.controls.first.label, 'Add to Loved'); + expect(native.resumedSources, isEmpty); + }); + + test('failed favorite save leaves the notification icon unchanged', () async { + configurePlaybackNotification( + presentation: const PlaybackNotification(mornye: true), + toggleFavorite: (_) async => throw StateError('Save failed'), + ); + handler.mediaItem.add(_tracks.first.toMediaItem()); + await handler.customAction(PlaybackNotification.favoriteAction); + expect( + handler.playbackState.value.controls.first.androidIcon, + endsWith('ic_notification_star'), + ); + }); + Future prepare() async { setAutoMixEnabled(true); await handler.setQueueAndPlay(_tracks);