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

+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,
};