mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-19 14:07:26 +02:00
fix(playback): refresh notification favorite and shuffle controls
This commit is contained in:
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);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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), (
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user