feat(player): add Mornye media notification controls

Sync notification favorites with Library, add Mornye transport icons and an output action on legacy Android, and expose the iOS favorite command. Preserve playback position when controls refresh.

Vendor audio_service 0.18.19 with a small Android patch for custom notification actions. Modern Android keeps its system output switcher and transport layout.
This commit is contained in:
zarzet committed 2026-09-26 03:07:38 +07:00
1 parent 728d179d69
commit 5259c86365
80 files changed
+8973 -13

No files matched your search

@@ -0,0 +1,54 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/models/theme_settings.dart';
import 'package:spotiflac_android/providers/library_collections_provider.dart';
import 'package:spotiflac_android/providers/music_player_provider.dart';
import 'package:spotiflac_android/providers/theme_provider.dart';
import 'package:spotiflac_android/services/music_player_service.dart';
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,
) {
final mornye = ref.watch(
themeProvider.select((theme) => theme.style == AppThemeStyle.mornye),
);
final item = ref.watch(currentMediaItemProvider).value;
final track = item == null
? null
: ref.watch(playerCollectionTrackProvider(item)).value;
final loved = ref.watch(
libraryCollectionsProvider.select(
(state) => track != null && state.isLoved(track),
),
);
// Publishing playback state inside a provider build would synchronously
// modify the stream provider this binding is observing.
var active = true;
ref.onDispose(() => active = false);
scheduleMicrotask(() {
if (!active) return;
configurePlaybackNotification(
presentation: PlaybackNotification(
mornye: mornye,
mediaId: item?.id,
source: item?.extras?['source']?.toString(),
loved: loved,
favoriteLabel: loved ? labels.unfavorite : labels.favorite,
outputLabel: labels.output,
),
toggleFavorite: (selected) async {
final collections = ref.read(libraryCollectionsProvider.notifier);
final selectedTrack = await ref.read(
playerCollectionTrackProvider(selected).future,
);
await collections.toggleLoved(selectedTrack);
},
);
});
});
+8
View File
@@ -14,6 +14,7 @@ import 'package:spotiflac_android/screens/upgrade_intro_screen.dart';
import 'package:spotiflac_android/services/upgrade_intro_service.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/playback_notification_provider.dart';
import 'package:spotiflac_android/providers/repo_provider.dart';
import 'package:spotiflac_android/providers/runtime_profile_provider.dart';
import 'package:spotiflac_android/providers/track_provider.dart';
@@ -721,6 +722,13 @@ class _MainShellState extends ConsumerState<MainShell>
@override
Widget build(BuildContext context) {
ref.watch(
playbackNotificationProvider((
favorite: context.l10n.trackOptionAddToLoved,
unfavorite: context.l10n.trackOptionRemoveFromLoved,
output: context.l10n.nowPlayingAudioOutput,
)),
);
ref.listen(settingsProvider.select((s) => s.playbackNormalization), (
_,
enabled,
+91 -6
View File
@@ -3,6 +3,8 @@ import 'dart:io';
import 'dart:math';
import 'package:audio_service/audio_service.dart';
import 'package:flutter/services.dart';
import 'package:spotiflac_android/services/playback_notification.dart';
import 'package:spotiflac_android/services/player_widget_service.dart';
import 'package:audio_session/audio_session.dart'
show AudioSession, AudioSessionConfiguration, AudioInterruptionType;
@@ -38,6 +40,46 @@ void updateMusicPlayerStrings({
bool _playbackNormalizationEnabled = false;
bool _autoMixEnabled = false;
MusicPlayerHandler? _activeMusicPlayerHandler;
PlaybackNotification _notificationPresentation = const PlaybackNotification();
Future<void> Function(MediaItem)? _toggleNotificationFavorite;
const _notificationChannel = MethodChannel(
'com.zarz.spotiflac/playback_notification',
);
void configurePlaybackNotification({
required PlaybackNotification presentation,
required Future<void> Function(MediaItem) toggleFavorite,
}) {
_notificationPresentation = presentation;
_toggleNotificationFavorite = toggleFavorite;
_activeMusicPlayerHandler?._refreshNotificationControls();
if (Platform.isIOS) {
_notificationChannel.setMethodCallHandler((call) async {
if (call.method == 'favorite') {
await _activeMusicPlayerHandler?.customAction(
PlaybackNotification.favoriteAction,
);
}
});
unawaited(_publishIosNotificationFavorite());
}
}
Future<void> _publishIosNotificationFavorite() async {
try {
await _notificationChannel.invokeMethod<void>('update', {
'enabled':
_notificationPresentation.mornye &&
_notificationPresentation.mediaId != null,
'loved': _notificationPresentation.loved,
'label': _notificationPresentation.favoriteLabel,
});
} on MissingPluginException {
// Unit tests do not install the iOS remote-command bridge.
} on PlatformException catch (error) {
_log.w('Could not update the system favorite action: ${error.code}');
}
}
/// Enables/disables ReplayGain volume normalization and re-applies it to the
/// track currently playing.
@@ -559,11 +601,10 @@ class MusicPlayerHandler extends BaseAudioHandler
playbackState.add(
playbackState.value.copyWith(
controls: [
MediaControl.skipToPrevious,
if (playing) MediaControl.pause else MediaControl.play,
MediaControl.skipToNext,
],
controls: _notificationPresentation.controls(
playing: playing,
item: mediaItem.value,
),
systemActions: const {
MediaAction.seek,
MediaAction.seekForward,
@@ -571,7 +612,9 @@ class MusicPlayerHandler extends BaseAudioHandler
MediaAction.skipToPrevious,
MediaAction.skipToNext,
},
androidCompactActionIndices: const [0, 1, 2],
androidCompactActionIndices: _notificationPresentation.mornye
? const [1, 2, 3]
: const [0, 1, 2],
processingState: (loading == true)
? AudioProcessingState.loading
: _mapProcessingState(state),
@@ -586,6 +629,48 @@ class MusicPlayerHandler extends BaseAudioHandler
);
}
void _refreshNotificationControls() {
if (_disposed) return;
final state = playbackState.value;
playbackState.add(
state.copyWith(
// copyWith creates a fresh updateTime; keep the extrapolated position
// so changing a star or theme cannot pull the scrubber backwards.
updatePosition: state.position,
controls: _notificationPresentation.controls(
playing: state.playing,
item: mediaItem.value,
),
androidCompactActionIndices: _notificationPresentation.mornye
? const [1, 2, 3]
: const [0, 1, 2],
),
);
}
bool _savingNotificationFavorite = false;
@override
Future<dynamic> customAction(
String name, [
Map<String, dynamic>? extras,
]) async {
if (name == PlaybackNotification.favoriteAction) {
final item = mediaItem.value;
if (item == null || _savingNotificationFavorite) return;
_savingNotificationFavorite = true;
try {
await _toggleNotificationFavorite?.call(item);
} catch (error) {
_log.w('Notification favorite failed: $error');
} finally {
_savingNotificationFavorite = false;
}
} else {
return super.customAction(name, extras);
}
}
void _broadcastPosition(Duration position, {bool force = false}) {
final now = DateTime.now();
final lastAt = _lastPositionBroadcastAt;
+63
View File
@@ -0,0 +1,63 @@
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';
final bool mornye;
final String? mediaId;
final String? source;
final bool loved;
final String favoriteLabel;
final String outputLabel;
const PlaybackNotification({
this.mornye = false,
this.mediaId,
this.source,
this.loved = false,
this.favoriteLabel = 'Favorite',
this.outputLabel = 'Audio Output',
});
List<MediaControl> controls({
required bool playing,
required MediaItem? item,
}) {
if (!mornye) {
return [
MediaControl.skipToPrevious,
playing ? MediaControl.pause : MediaControl.play,
MediaControl.skipToNext,
];
}
final current = item?.id == mediaId && item?.extras?['source'] == source;
return [
MediaControl.custom(
name: favoriteAction,
androidIcon: current && loved
? 'drawable/ic_notification_star_filled'
: 'drawable/ic_notification_star',
label: favoriteLabel,
),
MediaControl.skipToPrevious.copyWith(
androidIcon: 'drawable/ic_widget_previous',
),
(playing ? MediaControl.pause : MediaControl.play).copyWith(
androidIcon: playing
? 'drawable/ic_widget_pause'
: 'drawable/ic_widget_play',
),
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',
},
),
];
}
}
+6 -2
View File
@@ -122,8 +122,12 @@ class PlayerWidgetService {
'album': item?.album ?? '',
'playing': item != null && state.playing,
'canPlay': item != null,
'canPrevious': state.controls.contains(MediaControl.skipToPrevious),
'canNext': state.controls.contains(MediaControl.skipToNext),
'canPrevious': state.controls.any(
(control) => control.action == MediaAction.skipToPrevious,
),
'canNext': state.controls.any(
(control) => control.action == MediaAction.skipToNext,
),
'artworkKey': _artwork == null ? '' : _artUri.toString(),
'background': _artwork?.background ?? 0xff292433,
};