fix(playback): refresh notification favorite and shuffle controls

This commit is contained in:
zarzet committed 2026-10-03 02:14:01 +07:00
1 parent cd3fb40f2e
commit b767bd16d8
7 files changed
+215 -26

No files matched your search

@@ -0,0 +1,8 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp"
android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#00000000" android:strokeColor="#FFFFFF"
android:strokeWidth="1.8" android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M3,5 H5 C10,5 14,17 19,17 H21 M18,14 L21,17 L18,20 M3,17 H5 C7,17 8.5,15 10,13 M14,9 C15.5,7 17,5 19,5 H21 M18,2 L21,5 L18,8" />
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp"
android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#00000000" android:strokeColor="#FFFFFF"
android:strokeWidth="1.8" android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M3,5 H5 C10,5 14,17 19,17 H21 M18,14 L21,17 L18,20 M3,17 H5 C7,17 8.5,15 10,13 M14,9 C15.5,7 17,5 19,5 H21 M18,2 L21,5 L18,8" />
<path android:fillColor="#FFFFFF"
android:pathData="M12,20.5 A1.25,1.25 0,1 1,12,23 A1.25,1.25 0,1 1,12,20.5" />
</vector>
@@ -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<void, ({String favorite, String unfavorite, String output})>((
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);
},
);
});
+2 -1
View File
@@ -772,7 +772,8 @@ class _MainShellState extends ConsumerState<MainShell>
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), (
+33 -4
View File
@@ -51,14 +51,14 @@ bool _usbAllowFixedVolume = false;
bool _dapExclusiveEnabled = false;
MusicPlayerHandler? _activeMusicPlayerHandler;
PlaybackNotification _notificationPresentation = const PlaybackNotification();
Future<void> Function(MediaItem)? _toggleNotificationFavorite;
Future<bool> Function(MediaItem)? _toggleNotificationFavorite;
const _notificationChannel = MethodChannel(
'com.zarz.spotiflac/playback_notification',
);
void configurePlaybackNotification({
required PlaybackNotification presentation,
required Future<void> Function(MediaItem) toggleFavorite,
required Future<bool> Function(MediaItem) toggleFavorite,
}) {
_notificationPresentation = presentation;
_toggleNotificationFavorite = toggleFavorite;
@@ -82,7 +82,7 @@ Future<void> _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<dynamic> 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);
}
+28 -10
View File
@@ -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<MediaControl> 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,
),
];
}
+120 -4
View File
@@ -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<void> prepare() async {
setAutoMixEnabled(true);
await handler.setQueueAndPlay(_tracks);