refactor(playback): own configuration and dependencies per runtime

This commit is contained in:
zarzet committed 2026-10-05 02:31:47 +07:00
1 parent 5194344268
commit a363f5a459
12 files changed
+1195 -488

No files matched your search

+1
View File
@@ -79,6 +79,7 @@ void main() {
final prefs = await SharedPreferences.getInstance();
await _prepareAndroidInstallationState(prefs);
final bootstrapSettings = loadBootstrapSettings(prefs);
musicPlayerRuntime.configure(bootstrapSettings);
final bootstrapTheme = loadBootstrapThemeSettings(prefs);
final initialSafAccessLost = await _detectInitialSafAccessLoss(
bootstrapSettings,
+19 -8
View File
@@ -6,8 +6,12 @@ import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/services/music_player_service.dart';
final musicPlayerRuntimeProvider = Provider<MusicPlayerRuntime>(
(ref) => musicPlayerRuntime,
);
final currentMediaItemProvider = StreamProvider<MediaItem?>((ref) {
return musicPlayerMediaItemEvents();
return ref.watch(musicPlayerRuntimeProvider).mediaItemEvents();
});
/// Use the Library's identity for favorites, including restored queues and
@@ -23,7 +27,10 @@ final playerCollectionTrackProvider = FutureProvider.autoDispose
return UnifiedLibraryItem.fromDownloadHistory(history).toTrack();
}
}
final database = LibraryDatabase.instance;
final database = ref
.watch(musicPlayerRuntimeProvider)
.dependencies
.libraryDatabase;
final row = await database.getById(item.id);
if (row != null) {
return UnifiedLibraryItem.fromLocalLibrary(
@@ -54,7 +61,7 @@ final playerCollectionTrackProvider = FutureProvider.autoDispose
});
final playbackStateProvider = StreamProvider<PlaybackState>((ref) {
return musicPlayerPlaybackStateEvents();
return ref.watch(musicPlayerRuntimeProvider).playbackStateEvents();
});
/// Small derived providers keep position ticks from rebuilding widgets that
@@ -106,19 +113,22 @@ final mediaItemPlaybackUiProvider = Provider.autoDispose
});
final playQueueProvider = StreamProvider<List<MediaItem>>((ref) {
return musicPlayerQueueEvents();
return ref.watch(musicPlayerRuntimeProvider).queueEvents();
});
class MusicPlayerController {
const MusicPlayerController();
MusicPlayerController({MusicPlayerRuntime? runtime})
: _runtime = runtime ?? musicPlayerRuntime;
MusicPlayerHandler? get _handler => musicPlayerHandler;
final MusicPlayerRuntime _runtime;
MusicPlayerHandler? get _handler => _runtime.handler;
DateTime? get sleepTimerEndsAt => _handler?.sleepTimerEndsAt;
Future<MusicPlayerHandler?> ensureInitialized() async {
try {
return await initMusicPlayer();
return await _runtime.initialize();
} catch (_) {
return null;
}
@@ -235,7 +245,8 @@ class MusicPlayerController {
}
final musicPlayerControllerProvider = Provider<MusicPlayerController>(
(ref) => const MusicPlayerController(),
(ref) =>
MusicPlayerController(runtime: ref.watch(musicPlayerRuntimeProvider)),
);
PlayableMedia playableFromHistory(DownloadHistoryItem item) {
@@ -5,7 +5,6 @@ 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
@@ -38,7 +37,8 @@ final playbackNotificationProvider = Provider.autoDispose
ref.onDispose(() => active = false);
scheduleMicrotask(() {
if (!active) return;
configurePlaybackNotification(
final runtime = ref.read(musicPlayerRuntimeProvider);
runtime.configureNotification(
presentation: PlaybackNotification(
mornye: mornye,
mediaId: item?.id,
+10 -5
View File
@@ -3,7 +3,7 @@ import 'dart:async';
import 'package:audioplayers/audioplayers.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/services/music_player_service.dart';
import 'package:spotiflac_android/providers/music_player_provider.dart';
import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('PreviewPlayer');
@@ -60,11 +60,16 @@ class PreviewPlayerController extends Notifier<PreviewPlayerState> {
_lifecycleListener = AppLifecycleListener(
onStateChange: _handleAppLifecycleState,
);
musicPlayerExclusiveAudioHook = () async {
final runtime = ref.read(musicPlayerRuntimeProvider);
Future<void> stopPreview() async {
if (state.isActive) await stop();
};
}
runtime.exclusiveAudioHook = stopPreview;
ref.onDispose(() {
musicPlayerExclusiveAudioHook = null;
if (identical(runtime.exclusiveAudioHook, stopPreview)) {
runtime.exclusiveAudioHook = null;
}
_disposePlayer();
});
return const PreviewPlayerState();
@@ -168,7 +173,7 @@ class PreviewPlayerController extends Notifier<PreviewPlayerState> {
if (trimmed.isEmpty) return;
try {
await musicPlayerHandler?.pause();
await ref.read(musicPlayerRuntimeProvider).handler?.pause();
} catch (_) {}
state = PreviewPlayerState(
+13 -75
View File
@@ -13,6 +13,7 @@ import 'package:spotiflac_android/constants/app_info.dart';
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/music_player_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';
@@ -27,7 +28,6 @@ import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/services/shell_navigation_service.dart';
import 'package:spotiflac_android/services/share_intent_service.dart';
import 'package:spotiflac_android/services/music_playback_deck.dart';
import 'package:spotiflac_android/services/music_player_service.dart';
import 'package:spotiflac_android/services/listening_statistics.dart';
import 'package:spotiflac_android/services/discord_presence_service.dart';
import 'package:spotiflac_android/services/notification_service.dart';
@@ -103,41 +103,19 @@ class _MainShellState extends ConsumerState<MainShell>
super.didChangeDependencies();
final l10n = context.l10n;
NotificationService().updateStrings(l10n);
updateMusicPlayerStrings(
final playback = ref.read(musicPlayerRuntimeProvider);
playback.updateStrings(
unknownTitle: l10n.unknownTitle,
unknownArtist: l10n.unknownArtist,
);
setPlaybackNormalizationEnabled(
ref.read(settingsProvider).playbackNormalization,
);
setPlaybackAutomationOptions(
enabled: ref.read(settingsProvider).playerMode == 'internal',
pauseOnMute: ref.read(settingsProvider).pauseOnMute,
playOnHeadphonesConnected: ref
.read(settingsProvider)
.playOnHeadphonesConnected,
);
setAutoMixEnabled(ref.read(settingsProvider).autoMix);
setAutoMixOptions(ref.read(settingsProvider).autoMixOptions);
final settings = ref.read(settingsProvider);
playback.configure(settings);
unawaited(
DiscordPresenceService.instance.setEnabled(
ref.read(settingsProvider).discordRichPresenceEnabled,
settings.discordRichPresenceEnabled,
),
);
listeningRecorder.setEnabled(
ref.read(settingsProvider).listeningStatisticsEnabled,
);
setUsbBitPerfectEnabled(ref.read(settingsProvider).usbBitPerfect);
setUsbOutputOptions(
direct: ref.read(settingsProvider).usbDirect,
allowDop: ref.read(settingsProvider).usbDsdOverPcm,
allowFixedVolume: ref.read(settingsProvider).usbAllowFixedVolume,
dapExclusive: ref.read(settingsProvider).dapExclusive,
);
setAutoplayEnabled(
ref.read(settingsProvider).autoplay,
includeLocal: ref.read(settingsProvider).localLibraryEnabled,
);
listeningRecorder.setEnabled(settings.listeningStatisticsEnabled);
// Deezer & co. localize artist/genre names by IP unless told the app's
// language (issue #480).
unawaited(
@@ -184,7 +162,7 @@ class _MainShellState extends ConsumerState<MainShell>
);
_initialSafRepairComplete = true;
if (!mounted) return;
unawaited(restorePersistedPlaybackSession());
unawaited(ref.read(musicPlayerRuntimeProvider).restoreSession());
await _checkUpgradeIntro();
if (!mounted) return;
_setupShareListener();
@@ -223,7 +201,9 @@ class _MainShellState extends ConsumerState<MainShell>
if (state == AppLifecycleState.resumed && _initialSafRepairComplete) {
unawaited(_repairSafAccessIfNeeded());
} else if (state == AppLifecycleState.paused) {
unawaited(persistCurrentPlaybackSession());
unawaited(
ref.read(musicPlayerRuntimeProvider).handler?.persistCurrentSession(),
);
}
}
@@ -792,27 +772,8 @@ class _MainShellState extends ConsumerState<MainShell>
shuffleOff: context.l10n.nowPlayingPlayInOrder,
)),
);
ref.listen(settingsProvider.select((s) => s.playbackNormalization), (
_,
enabled,
) {
setPlaybackNormalizationEnabled(enabled);
});
ref.listen(settingsProvider.select((s) => s.autoMix), (_, enabled) {
setAutoMixEnabled(enabled);
});
ref.listen(
settingsProvider.select(
(s) => (s.playerMode, s.pauseOnMute, s.playOnHeadphonesConnected),
),
(_, options) => setPlaybackAutomationOptions(
enabled: options.$1 == 'internal',
pauseOnMute: options.$2,
playOnHeadphonesConnected: options.$3,
),
);
ref.listen(settingsProvider.select((s) => s.autoMixOptions), (_, options) {
setAutoMixOptions(options);
ref.listen(settingsProvider, (_, settings) {
ref.read(musicPlayerRuntimeProvider).configure(settings);
});
ref.listen(settingsProvider.select((s) => s.discordRichPresenceEnabled), (
_,
@@ -824,29 +785,6 @@ class _MainShellState extends ConsumerState<MainShell>
settingsProvider.select((s) => s.listeningStatisticsEnabled),
(_, enabled) => listeningRecorder.setEnabled(enabled),
);
ref.listen(settingsProvider.select((s) => s.usbBitPerfect), (_, enabled) {
setUsbBitPerfectEnabled(enabled);
});
ref.listen(
settingsProvider.select(
(s) => (
s.usbDirect,
s.usbDsdOverPcm,
s.usbAllowFixedVolume,
s.dapExclusive,
),
),
(_, value) => setUsbOutputOptions(
direct: value.$1,
allowDop: value.$2,
allowFixedVolume: value.$3,
dapExclusive: value.$4,
),
);
ref.listen(
settingsProvider.select((s) => (s.autoplay, s.localLibraryEnabled)),
(_, value) => setAutoplayEnabled(value.$1, includeLocal: value.$2),
);
final queueState = ref.watch(
downloadQueueProvider.select((s) => s.queuedCount),
);
+13 -12
View File
@@ -34,7 +34,7 @@ class _MusicAutoMix {
bool _writing = false;
void _setStatus(AutoMixStatus status) {
if (identical(_activeMusicPlayerHandler, handler)) {
if (identical(musicPlayerRuntime.handler, handler)) {
autoMixStatus.value = status;
}
}
@@ -49,8 +49,8 @@ class _MusicAutoMix {
};
bool get _canMix =>
_autoMixEnabled &&
!_usbBitPerfectEnabled &&
handler._settings.autoMix &&
!handler._settings.usbBitPerfect &&
!handler._player.isDirect &&
!handler._disposed &&
handler._sourceReady &&
@@ -109,7 +109,7 @@ class _MusicAutoMix {
MusicPlaybackDeck? deck;
MusicPlaybackDeck? effectDeck;
RenderedMixTail? effectTail;
final options = _autoMixOptions;
final options = handler._settings.autoMixOptions;
try {
if (nextIndex < 0 || nextIndex == index || index < 0) return;
final current = handler._media[index];
@@ -121,9 +121,8 @@ class _MusicAutoMix {
final nextPath = await handler._resolveSource(next);
if (nextPath == null || !_current(generation)) return;
pins.add(nextPath);
deck = MusicPlaybackDeck(
playerId:
'music-mix-$generation-${DateTime.now().microsecondsSinceEpoch}',
deck = handler._dependencies.createDeck(
'music-mix-$generation-${DateTime.now().microsecondsSinceEpoch}',
);
handler._listenToPlayer(deck);
await deck.setReleaseMode(ReleaseMode.stop);
@@ -167,9 +166,8 @@ class _MusicAutoMix {
);
if (!_current(generation)) return;
if (effectTail != null) {
effectDeck = MusicPlaybackDeck(
playerId:
'music-effect-$generation-${DateTime.now().microsecondsSinceEpoch}',
effectDeck = handler._dependencies.createDeck(
'music-effect-$generation-${DateTime.now().microsecondsSinceEpoch}',
);
handler._listenToPlayer(effectDeck);
try {
@@ -290,8 +288,11 @@ class _MusicAutoMix {
handler._pendingRestorePosition = null;
rate = plan.rate;
handler.mediaItem.add(
next
.toMediaItem(resolvedSource: next.isContentUri ? _nextPath : null)
handler
._toMediaItem(
next,
resolvedSource: next.isContentUri ? _nextPath : null,
)
.copyWith(duration: _nextDuration),
);
handler._broadcastPosition(plan.incomingStart, force: true);
+19 -18
View File
@@ -3,37 +3,37 @@ part of 'music_player_service.dart';
typedef AutoplayLibraryLoader =
Future<List<PlayableMedia>> Function(PlayableMedia seed);
bool _autoplayEnabled = false;
bool _autoplayIncludeLocal = true;
void setAutoplayEnabled(bool enabled, {bool includeLocal = true}) {
if (_autoplayEnabled == enabled && _autoplayIncludeLocal == includeLocal) {
return;
}
_autoplayEnabled = enabled;
_autoplayIncludeLocal = includeLocal;
_activeMusicPlayerHandler?._autoplay.updateMode();
}
void setAutoplayEnabled(bool enabled, {bool includeLocal = true}) =>
musicPlayerRuntime.configure(
musicPlayerRuntime.settings.copyWith(
autoplay: enabled,
localLibraryEnabled: includeLocal,
),
);
/// Bounded database pages keep recommendation work independent of Library
/// size. Both downloaded tracks and enabled scanned sources are local files.
Future<List<PlayableMedia>> _loadAutoplayLibrary(PlayableMedia seed) async {
final database = LibraryDatabase.instance;
final query = QueueLibraryDbQuery(includeLocal: _autoplayIncludeLocal);
Future<List<PlayableMedia>> _loadAutoplayLibrary(
PlayableMedia seed,
MusicPlayerRuntime runtime,
) async {
final database = runtime.dependencies.libraryDatabase;
final includeLocal = runtime.settings.localLibraryEnabled;
final query = QueueLibraryDbQuery(includeLocal: includeLocal);
final count = (await database.getQueueCounts(query)).allTrackCount;
final offset = count > 256 ? Random().nextInt(count - 255) : 0;
final rows = [
if (seed.artist.trim().isNotEmpty)
...await database.getQueueTrackPage(
QueueLibraryDbQuery(
includeLocal: _autoplayIncludeLocal,
includeLocal: includeLocal,
searchQuery: seed.artist,
limit: 128,
),
),
...await database.getQueueTrackPage(
QueueLibraryDbQuery(
includeLocal: _autoplayIncludeLocal,
includeLocal: includeLocal,
offset: offset,
limit: 256,
),
@@ -153,7 +153,8 @@ class _MusicAutoplay {
(String, int)? _attempt;
bool get enabled =>
_autoplayEnabled && handler._repeatMode == AudioServiceRepeatMode.none;
handler._settings.autoplay &&
handler._repeatMode == AudioServiceRepeatMode.none;
int get manualInsertIndex {
for (var i = handler._index + 1; i < handler._media.length; i++) {
@@ -238,7 +239,7 @@ class _MusicAutoplay {
for (final item in selected)
PlayableMedia.fromJson({...item.toJson(), 'autoplay': true})!,
];
final queueItems = additions.map((item) => item.toMediaItem()).toList();
final queueItems = additions.map(handler._toMediaItem).toList();
handler._media.addAll(additions);
handler._queueItems.addAll(queueItems);
handler._originalQueueOrder?.addAll(queueItems);
+299
View File
@@ -0,0 +1,299 @@
import 'dart:async';
import 'dart:io';
import 'package:audio_service/audio_service.dart';
import 'package:audio_session/audio_session.dart';
import 'package:flutter/services.dart';
import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/services/app_state_database.dart';
import 'package:spotiflac_android/services/discord_presence_service.dart';
import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/services/listening_statistics.dart';
import 'package:spotiflac_android/services/music_playback_deck.dart';
import 'package:spotiflac_android/services/music_player_service.dart';
import 'package:spotiflac_android/services/network_metadata_service.dart';
import 'package:spotiflac_android/services/network_storage_service.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/services/playback_notification.dart';
import 'package:spotiflac_android/services/player_widget_service.dart';
import 'package:spotiflac_android/services/system_volume_service.dart';
import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('MusicPlayerRuntime');
/// The process services used by a player. Tests can replace effects without
/// changing the queue, transport, interruption or automation algorithms.
class PlaybackDependencies {
const PlaybackDependencies({
this.createDeck = _createDeck,
this.audioSession = _getAudioSession,
this.readMetadata = PlatformBridge.readFileMetadata,
this.readNetworkMetadata = _readNetworkMetadata,
this.resolveNetworkSource = _resolveNetworkSource,
this.initializeHandler = _initializeAudioHandler,
AppStateDatabase? sessionDatabase,
LibraryDatabase? libraryDatabase,
ListeningRecorder? recorder,
Stream<double>? volumeChanges,
bool? isIOS,
bool? isAndroid,
this.notificationChannel = const MethodChannel(
'com.zarz.spotiflac/playback_notification',
),
}) : _sessionDatabase = sessionDatabase,
_libraryDatabase = libraryDatabase,
_recorder = recorder,
_volumeChanges = volumeChanges,
_isIOS = isIOS,
_isAndroid = isAndroid;
final MusicPlaybackDeck Function(String playerId) createDeck;
final Future<AudioSession> Function() audioSession;
final Future<Map<String, dynamic>> Function(String, {String? displayName})
readMetadata;
final Future<Map<String, dynamic>> Function(String) readNetworkMetadata;
final Future<String> Function(String) resolveNetworkSource;
final Future<MusicPlayerHandler> Function(MusicPlayerHandler Function())
initializeHandler;
final MethodChannel notificationChannel;
final AppStateDatabase? _sessionDatabase;
final LibraryDatabase? _libraryDatabase;
final ListeningRecorder? _recorder;
final Stream<double>? _volumeChanges;
final bool? _isIOS, _isAndroid;
AppStateDatabase get sessionDatabase =>
_sessionDatabase ?? AppStateDatabase.instance;
LibraryDatabase get libraryDatabase =>
_libraryDatabase ?? LibraryDatabase.instance;
ListeningRecorder get recorder => _recorder ?? listeningRecorder;
Stream<double> get volumeChanges =>
_volumeChanges ?? SystemVolumeService.instance.changes;
bool get isIOS => _isIOS ?? Platform.isIOS;
bool get isAndroid => _isAndroid ?? Platform.isAndroid;
static MusicPlaybackDeck _createDeck(String playerId) =>
MusicPlaybackDeck(playerId: playerId);
static Future<AudioSession> _getAudioSession() => AudioSession.instance;
static Future<Map<String, dynamic>> _readNetworkMetadata(String source) =>
NetworkMetadataService.instance.read(source);
static Future<String> _resolveNetworkSource(String source) =>
NetworkStorageService.instance.resolve(source);
static Future<MusicPlayerHandler> _initializeAudioHandler(
MusicPlayerHandler Function() builder,
) => AudioService.init(
builder: builder,
config: const AudioServiceConfig(
androidNotificationChannelId: 'com.zarz.spotiflac.playback',
androidNotificationChannelName: 'Playback',
androidNotificationOngoing: true,
androidStopForegroundOnPause: true,
),
);
}
/// Owns a player lifetime and its latest immutable settings. Configuration is
/// available before lazy initialization and remains live while it is pending.
class MusicPlayerRuntime {
MusicPlayerRuntime({
AppSettings settings = const AppSettings(),
this.dependencies = const PlaybackDependencies(),
}) : _settings = settings;
final PlaybackDependencies dependencies;
AppSettings _settings;
MusicPlayerHandler? _handler;
Future<MusicPlayerHandler>? _initFuture;
Future<void>? _restoreFuture;
final _handlers = StreamController<MusicPlayerHandler?>.broadcast(sync: true);
bool _initializing = false;
bool _disposed = false;
Future<void>? _disposeFuture;
String _unknownTitle = 'Unknown title', _unknownArtist = 'Unknown artist';
PlaybackNotification _notification = const PlaybackNotification();
Future<bool> Function(MediaItem)? _toggleFavorite;
Future<void> Function()? exclusiveAudioHook;
AppSettings get settings => _settings;
MusicPlayerHandler? get handler => _initializing ? null : _handler;
String get unknownTitle => _unknownTitle;
String get unknownArtist => _unknownArtist;
PlaybackNotification get notification => _notification;
void configure(AppSettings settings) {
final previous = _settings;
_settings = settings;
_handler?.applySettings(previous);
}
void updateStrings({
required String unknownTitle,
required String unknownArtist,
}) {
_unknownTitle = unknownTitle;
_unknownArtist = unknownArtist;
}
void configureNotification({
required PlaybackNotification presentation,
required Future<bool> Function(MediaItem) toggleFavorite,
}) {
_notification = presentation;
_toggleFavorite = toggleFavorite;
_handler?.refreshNotificationControls();
if (dependencies.isIOS) {
dependencies.notificationChannel.setMethodCallHandler((call) async {
if (call.method == 'favorite') {
await _handler?.customAction(PlaybackNotification.favoriteAction);
}
});
unawaited(_publishNotificationFavorite());
}
}
Future<bool?> toggleFavorite(MediaItem item) async =>
_toggleFavorite?.call(item);
void updateFavorite(MediaItem item, bool loved) {
_notification = _notification.withFavorite(item, loved);
_handler?.refreshNotificationControls();
if (dependencies.isIOS) unawaited(_publishNotificationFavorite());
}
Future<void> _publishNotificationFavorite() async {
try {
await dependencies.notificationChannel.invokeMethod<void>('update', {
'enabled': _notification.mornye && _notification.mediaId != null,
'loved': _notification.loved,
'label': _notification.favoriteActionLabel,
});
} on MissingPluginException {
// Tests do not install the iOS remote-command bridge.
} on PlatformException catch (error) {
_log.w('Could not update the system favorite action: ${error.code}');
}
}
void attach(MusicPlayerHandler handler) {
if (_disposed) throw StateError('This playback runtime was disposed');
final existing = _handler;
if (existing != null && !identical(existing, handler)) {
throw StateError('This playback runtime already owns a handler');
}
_handler = handler;
if (!_initializing && !identical(existing, handler)) _handlers.add(handler);
}
void detach(MusicPlayerHandler handler) {
if (!identical(_handler, handler)) return;
_handler = null;
_restoreFuture = null;
if (!_handlers.isClosed) _handlers.add(null);
}
Future<MusicPlayerHandler> initialize() {
if (_disposed) {
return Future.error(StateError('This playback runtime was disposed'));
}
final pending = _initFuture;
if (pending != null) return pending;
final existing = _handler;
if (existing != null) return Future.value(existing);
return _initFuture = _initialize();
}
Future<MusicPlayerHandler> _initialize() async {
_initializing = true;
MusicPlayerHandler? created;
try {
// Defer even a synchronous test initializer until _initFuture is stored.
await Future<void>.value();
final handler = await dependencies.initializeHandler(() {
if (_disposed) throw StateError('This playback runtime was disposed');
return created = MusicPlayerHandler(runtime: this);
});
if (_disposed || handler.isDisposed || !identical(_handler, handler)) {
throw StateError(
'Playback initialization returned an inactive handler',
);
}
_initializing = false;
_handlers.add(handler);
if (dependencies.isAndroid || dependencies.isIOS) {
PlayerWidgetService.instance.bind(handler);
DiscordPresenceService.instance.bind(handler);
}
return handler;
} catch (_) {
await created?.dispose();
rethrow;
} finally {
_initializing = false;
_initFuture = null;
}
}
Future<void> restoreSession() =>
_restoreFuture ??= restorePlaybackSession(this);
/// The default runtime lives for the app lifetime. Scoped runtimes close
/// subscriptions and reject new initialization when their owner disposes.
Future<void> dispose() => _disposeFuture ??= _dispose();
Future<void> _dispose() async {
_disposed = true;
_toggleFavorite = null;
exclusiveAudioHook = null;
await _handler?.dispose();
try {
await _initFuture;
} catch (_) {
// Initialization may have failed because its pending handler was closed.
}
await _handlers.close();
}
Stream<T> _events<T>(
Stream<T> Function(MusicPlayerHandler) select, {
List<T> initial = const [],
}) => Stream<T>.multi((listener) {
StreamSubscription<T>? events;
var revision = 0;
void selectHandler(MusicPlayerHandler? handler) {
final selectedRevision = ++revision;
unawaited(events?.cancel());
events = null;
if (handler == null) {
for (final value in initial) {
listener.addSync(value);
}
} else {
events = select(handler).listen((value) {
if (selectedRevision == revision) listener.addSync(value);
}, onError: listener.addErrorSync);
}
}
// Register first: UI listeners can cancel immediately while initialization
// is pending, and can follow a replacement handler after disposal.
final ready = _handlers.stream.listen(
selectHandler,
onDone: listener.closeSync,
);
selectHandler(handler);
listener.onCancel = () {
revision++;
unawaited(ready.cancel());
unawaited(events?.cancel());
};
});
Stream<MediaItem?> mediaItemEvents() =>
_events((handler) => handler.mediaItem, initial: [null]);
Stream<PlaybackState> playbackStateEvents() =>
_events((handler) => handler.playbackState);
Stream<List<MediaItem>> queueEvents() =>
_events((handler) => handler.queue, initial: [const []]);
}
final musicPlayerRuntime = MusicPlayerRuntime();
+196 -368
View File
@@ -3,12 +3,8 @@ import 'dart:io';
import 'dart:math';
import 'package:audio_service/audio_service.dart';
import 'package:spotiflac_android/services/network_storage_service.dart';
import 'package:spotiflac_android/services/network_metadata_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:spotiflac_android/services/discord_presence_service.dart';
import 'package:audio_session/audio_session.dart'
show
AudioDevice,
@@ -17,19 +13,20 @@ import 'package:audio_session/audio_session.dart'
AudioInterruptionType;
import 'package:audioplayers/audioplayers.dart';
import 'package:path_provider/path_provider.dart';
import 'package:spotiflac_android/services/app_state_database.dart';
import 'package:spotiflac_android/services/listening_statistics.dart';
import 'package:spotiflac_android/services/listening_statistics.dart'
show ListeningTrack;
import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/services/sqlite_helpers.dart'
show normalizeLookupText;
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/services/playback_normalization.dart';
import 'package:spotiflac_android/services/playback_automation.dart';
import 'package:spotiflac_android/services/system_volume_service.dart';
import 'package:spotiflac_android/services/music_player_runtime.dart';
import 'package:spotiflac_android/services/music_playback_deck.dart';
import 'package:spotiflac_android/services/automix_analysis.dart';
import 'package:spotiflac_android/services/automix_analyzer.dart';
import 'package:spotiflac_android/models/automix_options.dart';
import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/services/automix_effect_renderer.dart';
import 'package:spotiflac_android/services/automix_status.dart';
import 'package:spotiflac_android/utils/int_utils.dart';
@@ -39,84 +36,31 @@ import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.da
import 'package:spotiflac_android/utils/logger.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
export 'music_player_runtime.dart';
export 'playback_metadata.dart';
part 'music_player_automix.dart';
part 'music_player_autoplay.dart';
final _log = AppLogger('MusicPlayer');
String _playbackUnknownTitle = 'Unknown title';
String _playbackUnknownArtist = 'Unknown artist';
void updateMusicPlayerStrings({
required String unknownTitle,
required String unknownArtist,
}) {
_playbackUnknownTitle = unknownTitle;
_playbackUnknownArtist = unknownArtist;
}
bool _playbackNormalizationEnabled = false;
bool _playbackAutomationEnabled = true;
bool _pauseOnMute = false;
bool _playOnHeadphonesConnected = false;
bool _autoMixEnabled = false;
AutoMixOptions _autoMixOptions = const AutoMixOptions();
bool _usbBitPerfectEnabled = false;
bool _usbDirectEnabled = false;
bool _usbDopEnabled = false;
bool _usbAllowFixedVolume = false;
bool _dapExclusiveEnabled = false;
MusicPlayerHandler? _activeMusicPlayerHandler;
PlaybackNotification _notificationPresentation = const PlaybackNotification();
Future<bool> Function(MediaItem)? _toggleNotificationFavorite;
const _notificationChannel = MethodChannel(
'com.zarz.spotiflac/playback_notification',
);
void configurePlaybackNotification({
required PlaybackNotification presentation,
required Future<bool> 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.favoriteActionLabel,
});
} 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}');
}
}
}) => musicPlayerRuntime.configureNotification(
presentation: presentation,
toggleFavorite: toggleFavorite,
);
/// Decorative video plugins share AVAudioSession with the music player and
/// may enable mixing while creating a muted cover. Restore music ownership
/// without activating a session when this app is not playing music.
Future<void> restoreMusicAudioSessionAfterVideo() async {
if (!Platform.isIOS) return;
final handler = _activeMusicPlayerHandler;
if (!musicPlayerRuntime.dependencies.isIOS) return;
final handler = musicPlayerRuntime.handler;
if (handler == null || handler.mediaItem.value == null) return;
try {
final session = await AudioSession.instance;
final session = await musicPlayerRuntime.dependencies.audioSession();
await session.configure(const AudioSessionConfiguration.music());
} catch (error) {
_log.w('Could not restore music audio session after video: $error');
@@ -125,61 +69,59 @@ Future<void> restoreMusicAudioSessionAfterVideo() async {
/// Enables/disables ReplayGain volume normalization and re-applies it to the
/// track currently playing.
void setPlaybackNormalizationEnabled(bool enabled) {
if (_playbackNormalizationEnabled == enabled) return;
_playbackNormalizationEnabled = enabled;
_activeMusicPlayerHandler?.reapplyNormalization();
}
void setPlaybackNormalizationEnabled(bool enabled) =>
musicPlayerRuntime.configure(
musicPlayerRuntime.settings.copyWith(playbackNormalization: enabled),
);
void setPlaybackAutomationOptions({
required bool enabled,
required bool pauseOnMute,
required bool playOnHeadphonesConnected,
}) {
_playbackAutomationEnabled = enabled;
_pauseOnMute = pauseOnMute;
_playOnHeadphonesConnected = playOnHeadphonesConnected;
_activeMusicPlayerHandler?._refreshPlaybackAutomation();
}
}) => musicPlayerRuntime.configure(
musicPlayerRuntime.settings.copyWith(
playerMode: enabled ? 'internal' : 'external',
pauseOnMute: pauseOnMute,
playOnHeadphonesConnected: playOnHeadphonesConnected,
),
);
void setAutoMixEnabled(bool enabled) {
if (_autoMixEnabled == enabled) return;
_autoMixEnabled = enabled;
final handler = _activeMusicPlayerHandler;
if (handler != null) unawaited(handler._autoMix.cancel());
}
void setAutoMixEnabled(bool enabled) => musicPlayerRuntime.configure(
musicPlayerRuntime.settings.copyWith(autoMix: enabled),
);
void setAutoMixOptions(AutoMixOptions options) {
if (_autoMixOptions == options) return;
_autoMixOptions = options;
final handler = _activeMusicPlayerHandler;
if (handler != null) unawaited(handler._autoMix.cancel());
}
void setAutoMixOptions(AutoMixOptions options) => musicPlayerRuntime.configure(
musicPlayerRuntime.settings.copyWith(
autoMixDuration: options.durationSeconds,
autoMixEffect: options.effect,
autoMixSpeed: options.speed,
autoMixPitch: options.pitchSemitones,
autoMixEcho: options.echo,
autoMixLowPass: options.lowPass,
),
);
void setUsbBitPerfectEnabled(bool enabled) {
if (_usbBitPerfectEnabled == enabled) return;
_usbBitPerfectEnabled = enabled;
final handler = _activeMusicPlayerHandler;
if (handler != null) unawaited(handler._autoMix.cancel());
// Apply at the next source boundary; never raise a playing track's volume.
handler?._refreshPlaybackAutomation();
}
void setUsbBitPerfectEnabled(bool enabled) => musicPlayerRuntime.configure(
musicPlayerRuntime.settings.copyWith(usbBitPerfect: enabled),
);
void setUsbOutputOptions({
required bool direct,
required bool allowDop,
bool allowFixedVolume = false,
bool dapExclusive = false,
}) {
_usbDirectEnabled = direct;
_usbDopEnabled = allowDop;
_usbAllowFixedVolume = allowFixedVolume;
_dapExclusiveEnabled = dapExclusive;
}
}) => musicPlayerRuntime.configure(
musicPlayerRuntime.settings.copyWith(
usbDirect: direct,
usbDsdOverPcm: allowDop,
usbAllowFixedVolume: allowFixedVolume,
dapExclusive: dapExclusive,
),
);
/// Refreshes gain tags after a successful file update, including SAF copies.
void refreshPlaybackNormalization(String source) {
final handler = _activeMusicPlayerHandler;
final handler = musicPlayerRuntime.handler;
if (handler != null) unawaited(handler._refreshNormalizationSource(source));
}
@@ -310,12 +252,21 @@ class PlayableMedia {
);
}
MediaItem toMediaItem({String? resolvedSource, String? resolvedArtwork}) {
MediaItem toMediaItem({
String? resolvedSource,
String? resolvedArtwork,
String? unknownTitle,
String? unknownArtist,
}) {
final artwork = resolvedArtwork ?? artUri;
return MediaItem(
id: id,
title: title.isEmpty ? _playbackUnknownTitle : title,
artist: artist.isEmpty ? _playbackUnknownArtist : artist,
title: title.isEmpty
? unknownTitle ?? musicPlayerRuntime.unknownTitle
: title,
artist: artist.isEmpty
? unknownArtist ?? musicPlayerRuntime.unknownArtist
: artist,
album: album.isEmpty ? null : album,
duration: duration,
artUri: (artwork != null && artwork.isNotEmpty)
@@ -337,96 +288,6 @@ class PlayableMedia {
}
}
/// Technical audio metadata carried with a queue item. This is available
/// immediately when tracks change, before a fresh file probe completes.
Map<String, dynamic> playbackAudioMetadataFromMediaItem(MediaItem item) {
final extras = item.extras;
if (extras == null || extras.isEmpty) return const {};
final metadata = <String, dynamic>{};
final bitDepth = readPositiveInt(extras['bit_depth']);
final sampleRate = readPositiveInt(extras['sample_rate']);
final bitrate = readPositiveInt(extras['bitrate']);
final format = extras['format']?.toString().trim();
final explicit = parseExplicitFlag(extras['explicit']);
if (bitDepth != null) metadata['bit_depth'] = bitDepth;
if (sampleRate != null) metadata['sample_rate'] = sampleRate;
if (bitrate != null) metadata['bitrate'] = bitrate;
if (format != null && format.isNotEmpty) metadata['format'] = format;
if (explicit != null) metadata['explicit'] = explicit;
return metadata;
}
/// Combines the immediate queue metadata with the richer file probe while
/// retaining known quality fields when a decoder omits or reports them as 0.
Map<String, dynamic> mergePlaybackFileMetadata(
Map<String, dynamic> fallback,
Map<String, dynamic> probed,
) {
final merged = <String, dynamic>{...fallback, ...probed};
for (final key in const ['bit_depth', 'sample_rate', 'bitrate']) {
if (readPositiveInt(probed[key]) == null &&
readPositiveInt(fallback[key]) != null) {
merged[key] = fallback[key];
}
}
final probedFormat = probed['format']?.toString().trim();
final fallbackFormat = fallback['format']?.toString().trim();
if ((probedFormat == null || probedFormat.isEmpty) &&
fallbackFormat != null &&
fallbackFormat.isNotEmpty) {
merged['format'] = fallbackFormat;
}
return merged;
}
typedef PlaybackMetadataReader =
Future<Map<String, dynamic>> Function(String path);
/// Reads playback metadata with a small bounded retry window for transient
/// cold-start/native bridge failures.
///
/// The native bridge reports some read failures as an `error` field instead
/// of throwing. Treat both forms identically so Now Playing does not cache an
/// empty Lyrics view until the route is reopened. A successful response with
/// no lyrics is still final and is never retried.
Future<Map<String, dynamic>> readPlaybackFileMetadataWithRetry(
String path, {
PlaybackMetadataReader? reader,
List<Duration> retryDelays = const [
Duration.zero,
Duration(milliseconds: 250),
Duration(milliseconds: 750),
],
}) async {
if (reader == null && path.startsWith('network://')) {
return NetworkMetadataService.instance.read(path);
}
final read = reader ?? PlatformBridge.readFileMetadata;
final delays = retryDelays.isEmpty ? const [Duration.zero] : retryDelays;
Object? lastError;
var lastStack = StackTrace.current;
for (final delay in delays) {
if (delay > Duration.zero) await Future<void>.delayed(delay);
try {
final metadata = await read(path);
final reportedError = metadata['error']?.toString().trim() ?? '';
if (reportedError.isEmpty) return metadata;
lastError = StateError(reportedError);
lastStack = StackTrace.current;
} catch (error, stack) {
lastError = error;
lastStack = stack;
}
}
Error.throwWithStackTrace(
lastError ?? StateError('Metadata reader returned no result'),
lastStack,
);
}
/// Returns a safe source-start position for restored playback. A completed
/// snapshot starts over instead of immediately completing again on resume.
Duration normalizedPlaybackResumePosition(
@@ -442,7 +303,10 @@ Duration normalizedPlaybackResumePosition(
class MusicPlayerHandler extends BaseAudioHandler
with QueueHandler, SeekHandler {
MusicPlaybackDeck _player = MusicPlaybackDeck(playerId: 'music-player');
final MusicPlayerRuntime _runtime;
PlaybackDependencies get _dependencies => _runtime.dependencies;
AppSettings get _settings => _runtime.settings;
late MusicPlaybackDeck _player;
late final _MusicAutoMix _autoMix;
late final _MusicAutoplay _autoplay;
bool _completing = false;
@@ -467,6 +331,7 @@ class MusicPlayerHandler extends BaseAudioHandler
int _playRequestGeneration = 0;
String? _activeResolvedPath;
bool _disposed = false;
Future<void>? _disposeFuture;
bool _initialized = false;
bool _sourceReady = false;
Future<void>? _activePlayOperation;
@@ -505,12 +370,46 @@ class MusicPlayerHandler extends BaseAudioHandler
static const int _maxResolvedPathCacheBytes = 256 * 1024 * 1024;
DateTime? get sleepTimerEndsAt => _sleepTimerEndsAt;
bool get isDisposed => _disposed;
/// Reconcile only the playback fields affected by the new settings snapshot.
void applySettings(AppSettings previous) {
final settings = _settings;
if (previous.playbackNormalization != settings.playbackNormalization) {
reapplyNormalization();
}
if (previous.autoMix != settings.autoMix ||
previous.autoMixOptions != settings.autoMixOptions ||
previous.usbBitPerfect != settings.usbBitPerfect) {
unawaited(_autoMix.cancel());
}
// USB changes apply at the next source boundary, without raising volume.
if ((previous.playerMode == 'internal') !=
(settings.playerMode == 'internal') ||
previous.pauseOnMute != settings.pauseOnMute ||
previous.playOnHeadphonesConnected !=
settings.playOnHeadphonesConnected ||
previous.usbBitPerfect != settings.usbBitPerfect) {
_refreshPlaybackAutomation();
}
if (previous.autoplay != settings.autoplay ||
previous.localLibraryEnabled != settings.localLibraryEnabled) {
_autoplay.updateMode();
}
}
MusicPlayerHandler({
MusicPlayerRuntime? runtime,
AutoMixAnalyzer? autoMixAnalyzer,
AutoMixEffectRenderer? autoMixEffectRenderer,
AutoplayLibraryLoader? autoplayLibraryLoader,
}) {
}) : _runtime = runtime ?? musicPlayerRuntime {
_player = _dependencies.createDeck('music-player');
_normalizationCache = PlaybackNormalizationCache(
readMetadata: _dependencies.readMetadata,
onReadError: (error) =>
_log.w('Failed to read gain tags for normalization: $error'),
);
_playbackAutomation = PlaybackAutomation(
isPlaying: () =>
_player.state == PlayerState.playing || playbackState.value.playing,
@@ -530,19 +429,35 @@ class MusicPlayerHandler extends BaseAudioHandler
);
_autoplay = _MusicAutoplay(
this,
autoplayLibraryLoader ?? _loadAutoplayLibrary,
autoplayLibraryLoader ?? (seed) => _loadAutoplayLibrary(seed, _runtime),
);
_activeMusicPlayerHandler = this;
_init();
try {
_init();
_runtime.attach(this);
} catch (_) {
unawaited(dispose());
rethrow;
}
}
MediaItem _toMediaItem(
PlayableMedia media, {
String? resolvedSource,
String? resolvedArtwork,
}) => media.toMediaItem(
resolvedSource: resolvedSource,
resolvedArtwork: resolvedArtwork,
unknownTitle: _runtime.unknownTitle,
unknownArtist: _runtime.unknownArtist,
);
void _init() {
if (_initialized) return;
_initialized = true;
void recordListening() {
final item = mediaItem.value;
final state = playbackState.value;
listeningRecorder.update(
_dependencies.recorder.update(
item == null
? null
: ListeningTrack(
@@ -569,7 +484,7 @@ class MusicPlayerHandler extends BaseAudioHandler
}),
);
_listeningTimer = Timer.periodic(const Duration(seconds: 10), (_) {
unawaited(listeningRecorder.flush());
unawaited(_dependencies.recorder.flush());
});
_player.setReleaseMode(ReleaseMode.stop);
unawaited(_player.setAudioContext(_musicAudioContext));
@@ -640,7 +555,7 @@ class MusicPlayerHandler extends BaseAudioHandler
/// stuck on "playing".
Future<void> _configureAudioSession() async {
try {
final session = await AudioSession.instance;
final session = await _dependencies.audioSession();
_audioSession = session;
await session.configure(const AudioSessionConfiguration.music());
if (_disposed) return;
@@ -699,9 +614,12 @@ class MusicPlayerHandler extends BaseAudioHandler
void _refreshPlaybackAutomation() {
if (_disposed) return;
final pauseOnMute =
_playbackAutomationEnabled && _pauseOnMute && !_usbBitPerfectEnabled;
_settings.playerMode == 'internal' &&
_settings.pauseOnMute &&
!_settings.usbBitPerfect;
final playOnHeadphonesConnected =
_playbackAutomationEnabled && _playOnHeadphonesConnected;
_settings.playerMode == 'internal' &&
_settings.playOnHeadphonesConnected;
unawaited(
_playbackAutomation.configure(
pauseOnMute: pauseOnMute,
@@ -709,12 +627,11 @@ class MusicPlayerHandler extends BaseAudioHandler
),
);
if (pauseOnMute && _automationVolumeSubscription == null) {
_automationVolumeSubscription = SystemVolumeService.instance.changes
.listen(
(volume) => unawaited(_playbackAutomation.volumeChanged(volume)),
onError: (Object error) =>
_log.w('Could not monitor media volume: $error'),
);
_automationVolumeSubscription = _dependencies.volumeChanges.listen(
(volume) => unawaited(_playbackAutomation.volumeChanged(volume)),
onError: (Object error) =>
_log.w('Could not monitor media volume: $error'),
);
} else if (!pauseOnMute) {
final subscription = _automationVolumeSubscription;
_automationVolumeSubscription = null;
@@ -794,11 +711,11 @@ class MusicPlayerHandler extends BaseAudioHandler
Future<void> _activateAudioSession() async {
try {
final session = _audioSession ?? await AudioSession.instance;
final session = _audioSession ?? await _dependencies.audioSession();
_audioSession = session;
// Other media plugins can change the process-wide iOS category/options
// after initialization. Mixing makes us ineligible for Now Playing.
if (Platform.isIOS) {
if (_dependencies.isIOS) {
await session.configure(const AudioSessionConfiguration.music());
}
final granted = await session.setActive(true);
@@ -812,7 +729,7 @@ class MusicPlayerHandler extends BaseAudioHandler
// Required on some Android builds because audio_session manages focus.
Future<void> _claimHardwareMediaButtons() async {
if (!Platform.isAndroid) return;
if (!_dependencies.isAndroid) return;
try {
await AudioService.androidForceEnableMediaButtons();
} catch (e) {
@@ -839,7 +756,7 @@ class MusicPlayerHandler extends BaseAudioHandler
playbackState.add(
playbackState.value.copyWith(
controls: _notificationPresentation.controls(
controls: _runtime.notification.controls(
playing: playing,
item: mediaItem.value,
shuffle: _shuffle,
@@ -851,7 +768,7 @@ class MusicPlayerHandler extends BaseAudioHandler
MediaAction.skipToPrevious,
MediaAction.skipToNext,
},
androidCompactActionIndices: _notificationPresentation.mornye
androidCompactActionIndices: _runtime.notification.mornye
? const [1, 2, 3]
: const [0, 1, 2],
processingState: (loading == true)
@@ -868,7 +785,7 @@ class MusicPlayerHandler extends BaseAudioHandler
);
}
void _refreshNotificationControls() {
void refreshNotificationControls() {
if (_disposed) return;
final state = playbackState.value;
playbackState.add(
@@ -876,12 +793,12 @@ class MusicPlayerHandler extends BaseAudioHandler
// 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(
controls: _runtime.notification.controls(
playing: state.playing,
item: mediaItem.value,
shuffle: _shuffle,
),
androidCompactActionIndices: _notificationPresentation.mornye
androidCompactActionIndices: _runtime.notification.mornye
? const [1, 2, 3]
: const [0, 1, 2],
),
@@ -901,7 +818,7 @@ class MusicPlayerHandler extends BaseAudioHandler
if (item == null || _savingNotificationFavorite) return;
_savingNotificationFavorite = true;
try {
final loved = await _toggleNotificationFavorite?.call(item);
final loved = await _runtime.toggleFavorite(item);
final current = mediaItem.value;
if (!_disposed &&
loved != null &&
@@ -909,12 +826,7 @@ class MusicPlayerHandler extends BaseAudioHandler
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());
_runtime.updateFavorite(item, loved);
}
} catch (error) {
_log.w('Notification favorite failed: $error');
@@ -982,11 +894,7 @@ class MusicPlayerHandler extends BaseAudioHandler
unawaited(_persistSession(position: position));
}
final _normalizationCache = PlaybackNormalizationCache(
readMetadata: PlatformBridge.readFileMetadata,
onReadError: (error) =>
_log.w('Failed to read gain tags for normalization: $error'),
);
late final PlaybackNormalizationCache _normalizationCache;
int _normalizationGeneration = 0;
Future<void> _refreshNormalizationSource(String source) async {
@@ -1016,8 +924,8 @@ class MusicPlayerHandler extends BaseAudioHandler
'http',
'https',
}.contains(Uri.tryParse(path)?.scheme) ||
!_playbackNormalizationEnabled ||
_usbBitPerfectEnabled ||
!_settings.playbackNormalization ||
_settings.usbBitPerfect ||
_player.isDirect) {
return 1.0;
}
@@ -1082,7 +990,7 @@ class MusicPlayerHandler extends BaseAudioHandler
Future<String?> _resolveSource(PlayableMedia media) async {
if (media.source.startsWith('network://')) {
try {
return await NetworkStorageService.instance.resolve(media.source);
return await _dependencies.resolveNetworkSource(media.source);
} catch (_) {
_log.w('Network source is unavailable');
return null;
@@ -1273,7 +1181,7 @@ class MusicPlayerHandler extends BaseAudioHandler
_scheduledSessionQueueRevision = -1;
_persistedSessionQueueRevision = -1;
return _enqueueSessionWrite(
AppStateDatabase.instance.clearPlaybackSession,
_dependencies.sessionDatabase.clearPlaybackSession,
);
}
final queueRevision = _sessionQueueRevision;
@@ -1287,7 +1195,7 @@ class MusicPlayerHandler extends BaseAudioHandler
_scheduledSessionQueueRevision = queueRevision;
return _enqueueSessionWrite(() async {
try {
await AppStateDatabase.instance.savePlaybackSession({
await _dependencies.sessionDatabase.savePlaybackSession({
'version': 2,
'media': media,
'index': index,
@@ -1306,7 +1214,7 @@ class MusicPlayerHandler extends BaseAudioHandler
}
return _enqueueSessionWrite(() async {
final updated = await AppStateDatabase.instance
final updated = await _dependencies.sessionDatabase
.updatePlaybackSessionState(
index: index,
positionMs: positionMs,
@@ -1321,7 +1229,7 @@ class MusicPlayerHandler extends BaseAudioHandler
// Defensive recovery for an externally cleared/corrupted row. This is
// intentionally the only state-only path that serializes the queue.
await AppStateDatabase.instance.savePlaybackSession({
await _dependencies.sessionDatabase.savePlaybackSession({
'version': 2,
'media': _sessionMedia(),
'index': index,
@@ -1377,7 +1285,7 @@ class MusicPlayerHandler extends BaseAudioHandler
..addAll(items);
_queueItems
..clear()
..addAll(items.map((m) => m.toMediaItem()));
..addAll(items.map(_toMediaItem));
_index = index.clamp(0, items.length - 1);
_shuffle = shuffle;
if (shuffle) {
@@ -1400,7 +1308,7 @@ class MusicPlayerHandler extends BaseAudioHandler
_persistedSessionQueueRevision = _sessionQueueRevision;
}
queue.add(List<MediaItem>.unmodifiable(_queueItems));
mediaItem.add(_media[_index].toMediaItem());
mediaItem.add(_toMediaItem(_media[_index]));
if (position > Duration.zero) {
playbackState.add(
playbackState.value.copyWith(updatePosition: position),
@@ -1437,7 +1345,7 @@ class MusicPlayerHandler extends BaseAudioHandler
..addAll(items);
_queueItems
..clear()
..addAll(items.map((m) => m.toMediaItem()));
..addAll(items.map(_toMediaItem));
_originalQueueOrder = null;
_index = initialIndex.clamp(0, items.length - 1);
if (_shuffle) _shuffleQueue();
@@ -1456,7 +1364,7 @@ class MusicPlayerHandler extends BaseAudioHandler
? (_index + 1).clamp(0, _media.length)
: _autoplay.manualInsertIndex;
_media.insert(insertAt, item);
final queueItem = item.toMediaItem();
final queueItem = _toMediaItem(item);
_queueItems.insert(insertAt, queueItem);
_rememberEnqueued([queueItem], playNext: playNext);
_markSessionQueueChanged();
@@ -1485,7 +1393,7 @@ class MusicPlayerHandler extends BaseAudioHandler
final queued = <MediaItem>[];
for (final item in items) {
_media.insert(at, item);
final queueItem = item.toMediaItem();
final queueItem = _toMediaItem(item);
_queueItems.insert(at, queueItem);
queued.add(queueItem);
for (var i = 0; i < _playHistory.length; i++) {
@@ -1618,11 +1526,11 @@ class MusicPlayerHandler extends BaseAudioHandler
} else {
await _player.setSource(
DeviceFileSource(path),
preferBitPerfect: _usbBitPerfectEnabled,
directUsb: _usbDirectEnabled,
allowFixedVolume: _usbAllowFixedVolume,
dapExclusive: _dapExclusiveEnabled,
allowDop: _usbDopEnabled,
preferBitPerfect: _settings.usbBitPerfect,
directUsb: _settings.usbDirect,
allowFixedVolume: _settings.usbAllowFixedVolume,
dapExclusive: _settings.dapExclusive,
allowDop: _settings.usbDsdOverPcm,
requiresDsd:
media.bitDepth == 1 ||
const ['dsf', 'dff'].contains(media.format?.toLowerCase()) ||
@@ -1655,7 +1563,7 @@ class MusicPlayerHandler extends BaseAudioHandler
startPosition,
duration: media.duration,
);
mediaItem.add(media.toMediaItem());
mediaItem.add(_toMediaItem(media));
_lastBroadcastPosition = Duration.zero;
_lastPositionBroadcastAt = null;
_broadcastPosition(effectiveStartPosition, force: true);
@@ -1670,11 +1578,11 @@ class MusicPlayerHandler extends BaseAudioHandler
}
if (media.networkArtworkSource != null) {
try {
final artwork = await NetworkStorageService.instance.resolve(
final artwork = await _dependencies.resolveNetworkSource(
media.networkArtworkSource!,
);
if (!_isCurrentPlayRequest(generation, media)) return;
mediaItem.add(media.toMediaItem(resolvedArtwork: artwork));
mediaItem.add(_toMediaItem(media, resolvedArtwork: artwork));
} catch (_) {
// Cover failure must not prevent audio playback.
}
@@ -1684,7 +1592,7 @@ class MusicPlayerHandler extends BaseAudioHandler
// MediaPlayer duplicates that descriptor, so the original can be closed
// immediately after play() without retaining a full-file cache copy.
ContentUriPlaybackLease? playbackLease;
if (Platform.isAndroid && media.isContentUri) {
if (_dependencies.isAndroid && media.isContentUri) {
try {
playbackLease = await PlatformBridge.openContentUriPlaybackLease(
media.source,
@@ -1708,7 +1616,7 @@ class MusicPlayerHandler extends BaseAudioHandler
}
try {
await musicPlayerExclusiveAudioHook?.call();
await _runtime.exclusiveAudioHook?.call();
} catch (_) {}
if (!_isCurrentPlayRequest(generation, media)) {
if (playbackLease != null) {
@@ -1791,7 +1699,7 @@ class MusicPlayerHandler extends BaseAudioHandler
// the same metadata twice on every Next. SAF needs this second event so
// the UI can inspect its temporary local copy.
if (usingLocalSafCopy) {
mediaItem.add(_media[_index].toMediaItem(resolvedSource: resolved));
mediaItem.add(_toMediaItem(_media[_index], resolvedSource: resolved));
}
_broadcastPosition(effectiveStartPosition, force: true);
_broadcastState();
@@ -1847,7 +1755,7 @@ class MusicPlayerHandler extends BaseAudioHandler
Future<void> _loadNetworkMetadata(PlayableMedia media, int generation) async {
try {
final metadata = await NetworkMetadataService.instance.read(media.source);
final metadata = await _dependencies.readNetworkMetadata(media.source);
if (!_isCurrentPlayRequest(generation, media)) return;
final current = mediaItem.value;
if (current == null) return;
@@ -2153,7 +2061,9 @@ class MusicPlayerHandler extends BaseAudioHandler
_scheduledSessionQueueRevision = -1;
_persistedSessionQueueRevision = -1;
// An explicit stop ends the session for good; nothing to restore later.
await _enqueueSessionWrite(AppStateDatabase.instance.clearPlaybackSession);
await _enqueueSessionWrite(
_dependencies.sessionDatabase.clearPlaybackSession,
);
if (generation != _playRequestGeneration || _disposed) return;
// A stopped session has no current item; this also hides the mini player.
mediaItem.add(null);
@@ -2287,17 +2197,19 @@ class MusicPlayerHandler extends BaseAudioHandler
}
}
Future<void> dispose() async {
Future<void> dispose() => _disposeFuture ??= _dispose();
Future<void> _dispose() async {
_autoplay.reset();
_disposed = true;
if (identical(_activeMusicPlayerHandler, this)) {
if (identical(musicPlayerRuntime.handler, this)) {
autoMixStatus.value = AutoMixStatus.idle;
_activeMusicPlayerHandler = null;
}
_runtime.detach(this);
cancelSleepTimer();
_listeningTimer?.cancel();
listeningRecorder.update(null, playing: false, ended: true);
await listeningRecorder.flush();
_dependencies.recorder.update(null, playing: false, ended: true);
await _dependencies.recorder.flush();
_playRequestGeneration++;
_headphoneMonitoringRevision++;
await _automationVolumeSubscription?.cancel();
@@ -2328,78 +2240,31 @@ class MusicPlayerHandler extends BaseAudioHandler
}
}
MusicPlayerHandler? _handler;
Future<MusicPlayerHandler>? _initFuture;
final StreamController<MusicPlayerHandler> _handlerReadyController =
StreamController<MusicPlayerHandler>.broadcast();
MusicPlayerHandler? get musicPlayerHandler => musicPlayerRuntime.handler;
MusicPlayerHandler? get musicPlayerHandler => _handler;
Future<void> Function()? musicPlayerExclusiveAudioHook;
/// Flushes the current playback position if the player has been initialized.
Future<void> persistCurrentPlaybackSession() async {
final handler = _handler;
if (handler == null) return;
await handler.persistCurrentSession();
}
Future<MusicPlayerHandler> initMusicPlayer() async {
if (_handler != null) return _handler!;
final existingFuture = _initFuture;
if (existingFuture != null) return existingFuture;
final future = _doInitMusicPlayer();
_initFuture = future;
return future;
}
Future<MusicPlayerHandler> _doInitMusicPlayer() async {
try {
final handler = await AudioService.init(
builder: () => MusicPlayerHandler(),
config: const AudioServiceConfig(
androidNotificationChannelId: 'com.zarz.spotiflac.playback',
androidNotificationChannelName: 'Playback',
androidNotificationOngoing: true,
androidStopForegroundOnPause: true,
),
);
_handler = handler;
if (Platform.isAndroid || Platform.isIOS) {
PlayerWidgetService.instance.bind(handler);
DiscordPresenceService.instance.bind(handler);
}
_handlerReadyController.add(handler);
return handler;
} catch (_) {
_initFuture = null;
rethrow;
}
}
Future<MusicPlayerHandler> initMusicPlayer() => musicPlayerRuntime.initialize();
/// Restores the last persisted playback session (if any) into a freshly
/// initialized handler, paused. Entries whose plain file paths no longer
/// exist are dropped; content URIs are kept and fail gracefully at play time.
Future<void>? _restoreSessionFuture;
Future<void> restorePersistedPlaybackSession() =>
_restoreSessionFuture ??= _restorePersistedPlaybackSession();
musicPlayerRuntime.restoreSession();
Future<void> _restorePersistedPlaybackSession() async {
Future<void> restorePlaybackSession(MusicPlayerRuntime runtime) async {
try {
final session = await AppStateDatabase.instance.getPlaybackSession();
final database = runtime.dependencies.sessionDatabase;
final session = await database.getPlaybackSession();
if (session == null) return;
final rawMedia = session['media'];
if (rawMedia is! List) {
await AppStateDatabase.instance.clearPlaybackSession();
await database.clearPlaybackSession();
return;
}
final items = <PlayableMedia>[];
final keptOriginalIndices = <int>[];
final iosDocumentsPath = Platform.isIOS
final iosDocumentsPath = runtime.dependencies.isIOS
? (await getApplicationDocumentsDirectory()).path
: null;
var artworkRelocated = false;
@@ -2419,7 +2284,7 @@ Future<void> _restorePersistedPlaybackSession() async {
final artwork = await resolveRestoredArtworkUri(
media.artUri,
loadLibraryCoverPath: () async {
final database = LibraryDatabase.instance;
final database = runtime.dependencies.libraryDatabase;
final row = await database.getById(media.id);
if (row != null) return row['coverPath'] as String?;
final matches = await database.findByTrackAndArtist(
@@ -2443,7 +2308,7 @@ Future<void> _restorePersistedPlaybackSession() async {
keptOriginalIndices.add(i);
}
if (items.isEmpty) {
await AppStateDatabase.instance.clearPlaybackSession();
await database.clearPlaybackSession();
return;
}
@@ -2461,7 +2326,7 @@ Future<void> _restorePersistedPlaybackSession() async {
position = Duration.zero;
}
final handler = await initMusicPlayer();
final handler = await runtime.initialize();
await handler.restoreSession(
items: items,
index: index,
@@ -2486,46 +2351,9 @@ Future<void> _restorePersistedPlaybackSession() async {
}
}
Stream<MediaItem?> musicPlayerMediaItemEvents() async* {
final existing = _handler;
if (existing != null) {
yield existing.mediaItem.value;
yield* existing.mediaItem;
return;
}
yield null;
await for (final handler in _handlerReadyController.stream) {
yield handler.mediaItem.value;
yield* handler.mediaItem;
return;
}
}
Stream<PlaybackState> musicPlayerPlaybackStateEvents() async* {
final existing = _handler;
if (existing != null) {
yield existing.playbackState.value;
yield* existing.playbackState;
return;
}
await for (final handler in _handlerReadyController.stream) {
yield handler.playbackState.value;
yield* handler.playbackState;
return;
}
}
Stream<List<MediaItem>> musicPlayerQueueEvents() async* {
final existing = _handler;
if (existing != null) {
yield existing.queue.value;
yield* existing.queue;
return;
}
yield const [];
await for (final handler in _handlerReadyController.stream) {
yield handler.queue.value;
yield* handler.queue;
return;
}
}
Stream<MediaItem?> musicPlayerMediaItemEvents() =>
musicPlayerRuntime.mediaItemEvents();
Stream<PlaybackState> musicPlayerPlaybackStateEvents() =>
musicPlayerRuntime.playbackStateEvents();
Stream<List<MediaItem>> musicPlayerQueueEvents() =>
musicPlayerRuntime.queueEvents();
+95
View File
@@ -0,0 +1,95 @@
import 'package:audio_service/audio_service.dart';
import 'package:spotiflac_android/services/network_metadata_service.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/int_utils.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
/// Technical audio metadata carried with a queue item. This is available
/// immediately when tracks change, before a fresh file probe completes.
Map<String, dynamic> playbackAudioMetadataFromMediaItem(MediaItem item) {
final extras = item.extras;
if (extras == null || extras.isEmpty) return const {};
final metadata = <String, dynamic>{};
final bitDepth = readPositiveInt(extras['bit_depth']);
final sampleRate = readPositiveInt(extras['sample_rate']);
final bitrate = readPositiveInt(extras['bitrate']);
final format = extras['format']?.toString().trim();
final explicit = parseExplicitFlag(extras['explicit']);
if (bitDepth != null) metadata['bit_depth'] = bitDepth;
if (sampleRate != null) metadata['sample_rate'] = sampleRate;
if (bitrate != null) metadata['bitrate'] = bitrate;
if (format != null && format.isNotEmpty) metadata['format'] = format;
if (explicit != null) metadata['explicit'] = explicit;
return metadata;
}
/// Combines the immediate queue metadata with the richer file probe while
/// retaining known quality fields when a decoder omits or reports them as 0.
Map<String, dynamic> mergePlaybackFileMetadata(
Map<String, dynamic> fallback,
Map<String, dynamic> probed,
) {
final merged = <String, dynamic>{...fallback, ...probed};
for (final key in const ['bit_depth', 'sample_rate', 'bitrate']) {
if (readPositiveInt(probed[key]) == null &&
readPositiveInt(fallback[key]) != null) {
merged[key] = fallback[key];
}
}
final probedFormat = probed['format']?.toString().trim();
final fallbackFormat = fallback['format']?.toString().trim();
if ((probedFormat == null || probedFormat.isEmpty) &&
fallbackFormat != null &&
fallbackFormat.isNotEmpty) {
merged['format'] = fallbackFormat;
}
return merged;
}
typedef PlaybackMetadataReader =
Future<Map<String, dynamic>> Function(String path);
/// Reads playback metadata with a small bounded retry window for transient
/// cold-start/native bridge failures.
///
/// The native bridge reports some read failures as an `error` field instead
/// of throwing. Treat both forms identically so Now Playing does not cache an
/// empty Lyrics view until the route is reopened. A successful response with
/// no lyrics is still final and is never retried.
Future<Map<String, dynamic>> readPlaybackFileMetadataWithRetry(
String path, {
PlaybackMetadataReader? reader,
List<Duration> retryDelays = const [
Duration.zero,
Duration(milliseconds: 250),
Duration(milliseconds: 750),
],
}) async {
if (reader == null && path.startsWith('network://')) {
return NetworkMetadataService.instance.read(path);
}
final read = reader ?? PlatformBridge.readFileMetadata;
final delays = retryDelays.isEmpty ? const [Duration.zero] : retryDelays;
Object? lastError;
var lastStack = StackTrace.current;
for (final delay in delays) {
if (delay > Duration.zero) await Future<void>.delayed(delay);
try {
final metadata = await read(path);
final reportedError = metadata['error']?.toString().trim() ?? '';
if (reportedError.isEmpty) return metadata;
lastError = StateError(reportedError);
lastStack = StackTrace.current;
} catch (error, stack) {
lastError = error;
lastStack = stack;
}
}
Error.throwWithStackTrace(
lastError ?? StateError('Metadata reader returned no result'),
lastStack,
);
}
+477
View File
@@ -0,0 +1,477 @@
import 'dart:async';
import 'package:audio_service/audio_service.dart';
import 'package:audio_session/audio_session.dart';
import 'package:audioplayers/audioplayers.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/providers/music_player_provider.dart';
import 'package:spotiflac_android/services/app_state_database.dart';
import 'package:spotiflac_android/services/listening_statistics.dart';
import 'package:spotiflac_android/services/music_playback_deck.dart';
import 'package:spotiflac_android/services/music_player_service.dart';
import 'package:spotiflac_android/services/playback_notification.dart';
class _Deck implements MusicPlaybackDeck {
@override
PlayerState state = PlayerState.stopped;
final volumes = <double>[];
Map<String, bool>? outputOptions;
Duration position = Duration.zero;
int disposals = 0;
@override
bool get isDirect => false;
@override
bool get needsSourceReload => false;
@override
Stream<PlayerState> get onPlayerStateChanged => const Stream.empty();
@override
Stream<Duration> get onPositionChanged => const Stream.empty();
@override
Stream<Duration> get onDurationChanged => const Stream.empty();
@override
Stream<void> get onPlayerComplete => const Stream.empty();
@override
set positionUpdater(PositionUpdater updater) {}
@override
Future<void> setReleaseMode(ReleaseMode mode) async {}
@override
Future<void> setAudioContext(AudioContext context) async {}
@override
Future<void> setVolume(double volume) async => volumes.add(volume);
@override
Future<void> cancelPreparation() async {}
@override
Future<void> stop() async => state = PlayerState.stopped;
@override
Future<void> pause() async => state = PlayerState.paused;
@override
Future<void> resume() async => state = PlayerState.playing;
@override
Future<void> seek(Duration value) async => position = value;
@override
Future<Duration?> getDuration() async => const Duration(seconds: 60);
@override
Future<Duration?> getCurrentPosition() async => position;
@override
Future<void> dispose() async => disposals++;
@override
Future<void> setSource(
DeviceFileSource source, {
bool preferBitPerfect = false,
bool directUsb = false,
bool allowDop = false,
bool requiresDsd = false,
bool allowFixedVolume = false,
bool dapExclusive = false,
}) async {
outputOptions = {
'bitPerfect': preferBitPerfect,
'direct': directUsb,
'dop': allowDop,
'fixedVolume': allowFixedVolume,
'exclusive': dapExclusive,
};
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _Session implements AudioSession {
@override
Future<void> configure(AudioSessionConfiguration configuration) async {}
@override
Future<bool> setActive(
bool active, {
AVAudioSessionSetActiveOptions? avAudioSessionSetActiveOptions,
AndroidAudioFocusGainType? androidAudioFocusGainType,
AndroidAudioAttributes? androidAudioAttributes,
bool? androidWillPauseWhenDucked,
AudioSessionConfiguration fallbackConfiguration =
const AudioSessionConfiguration.music(),
}) async => true;
@override
Stream<AudioInterruptionEvent> get interruptionEventStream =>
const Stream.empty();
@override
Stream<void> get becomingNoisyEventStream => const Stream.empty();
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _SessionStore implements AppStateDatabase {
@override
Future<void> savePlaybackSession(Map<String, dynamic> session) async {}
@override
Future<bool> updatePlaybackSessionState({
required int index,
required int positionMs,
required bool shuffle,
required String repeatMode,
}) async => true;
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
MusicPlayerRuntime _runtime({
AppSettings settings = const AppSettings(pauseOnMute: false),
Future<MusicPlayerHandler> Function(MusicPlayerHandler Function())?
initialize,
List<_Deck>? decks,
MusicPlaybackDeck Function(String)? createDeck,
Stream<double> volumeChanges = const Stream.empty(),
}) {
final runtime = MusicPlayerRuntime(
settings: settings,
dependencies: PlaybackDependencies(
createDeck:
createDeck ??
(_) {
final deck = _Deck();
decks?.add(deck);
return deck;
},
audioSession: () async => _Session(),
initializeHandler: initialize ?? (builder) async => builder(),
readMetadata: (_, {displayName}) async => {
'replaygain_track_gain': '-6 dB',
},
sessionDatabase: _SessionStore(),
recorder: ListeningRecorder(
write: (_) async {},
elapsed: () => Duration.zero,
now: () => DateTime(2026),
),
volumeChanges: volumeChanges,
isAndroid: false,
isIOS: false,
),
);
addTearDown(runtime.dispose);
return runtime;
}
Future<void> _settle() => Future<void>.delayed(Duration.zero);
const _item = MediaItem(id: 'one', title: 'One');
const _track = PlayableMedia(
id: 'one',
source: '/one.flac',
title: 'One',
artist: 'Artist',
duration: Duration(seconds: 60),
);
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test(
'pre-init events have empty values and cancellation never waits for init',
() async {
var calls = 0;
final runtime = _runtime(
initialize: (builder) async {
calls++;
return builder();
},
);
final items = <MediaItem?>[];
final queues = <List<MediaItem>>[];
final states = <PlaybackState>[];
final itemEvents = runtime.mediaItemEvents().listen(items.add);
final queueEvents = runtime.queueEvents().listen(queues.add);
final stateEvents = runtime.playbackStateEvents().listen(states.add);
await _settle();
expect(items, [null]);
expect(queues, [isEmpty]);
expect(states, isEmpty);
await Future.wait([
itemEvents.cancel(),
queueEvents.cancel(),
stateEvents.cancel(),
]).timeout(const Duration(seconds: 1));
expect(calls, 0);
await runtime.initialize();
await _settle();
expect(items, [null]);
expect(queues, [isEmpty]);
expect(states, isEmpty);
},
);
test(
'pending init is deduplicated and uses the latest live configuration',
() async {
final ready = Completer<MusicPlayerHandler>();
final volume = StreamController<double>.broadcast();
addTearDown(volume.close);
var calls = 0;
late MusicPlayerHandler created;
final runtime = _runtime(
volumeChanges: volume.stream,
initialize: (builder) {
calls++;
created = builder();
return ready.future;
},
);
final items = <MediaItem?>[];
final events = runtime.mediaItemEvents().listen(items.add);
addTearDown(events.cancel);
runtime.configure(runtime.settings.copyWith(autoMix: true));
final first = runtime.initialize();
expect(runtime.initialize(), same(first));
await _settle();
expect(calls, 1);
expect(runtime.handler, isNull);
expect(volume.hasListener, isFalse);
runtime.configure(runtime.settings.copyWith(pauseOnMute: true));
created.mediaItem.add(_item);
await _settle();
expect(volume.hasListener, isTrue);
expect(items, [null]);
ready.complete(created);
expect(await first, same(created));
expect(await runtime.initialize(), same(created));
await _settle();
expect(items, [null, _item]);
expect(calls, 1);
expect(runtime.settings.autoMix, isTrue);
await runtime.dispose();
expect(volume.hasListener, isFalse);
},
);
test('failed initialization releases its handler and can retry', () async {
var calls = 0;
final decks = <_Deck>[];
final runtime = _runtime(
decks: decks,
initialize: (builder) async {
final handler = builder();
if (++calls == 1) throw StateError('Initialization failed');
return handler;
},
);
await expectLater(runtime.initialize(), throwsStateError);
expect(runtime.handler, isNull);
expect(decks.single.disposals, 1);
final retried = await runtime.initialize();
expect(runtime.handler, same(retried));
expect(calls, 2);
await runtime.dispose();
expect(decks.map((deck) => deck.disposals), [1, 1]);
});
test(
'deck construction failure leaves no partial handler and can retry',
() async {
var calls = 0;
final deck = _Deck();
final runtime = _runtime(
createDeck: (_) {
if (++calls == 1) throw StateError('Deck creation failed');
return deck;
},
);
await expectLater(runtime.initialize(), throwsStateError);
expect(runtime.handler, isNull);
final handler = await runtime.initialize();
expect(runtime.handler, same(handler));
expect(calls, 2);
await runtime.dispose();
expect(deck.disposals, 1);
},
);
test('an initializer returning a disposed handler can retry', () async {
var calls = 0;
final runtime = _runtime(
initialize: (builder) async {
final handler = builder();
if (++calls == 1) await handler.dispose();
return handler;
},
);
await expectLater(runtime.initialize(), throwsStateError);
final handler = await runtime.initialize();
expect(runtime.handler, same(handler));
expect(handler.isDisposed, isFalse);
expect(calls, 2);
});
test(
'runtime disposal retires a pending handler and closes its events',
() async {
final ready = Completer<MusicPlayerHandler>();
final decks = <_Deck>[];
late MusicPlayerHandler created;
final runtime = _runtime(
decks: decks,
initialize: (builder) {
created = builder();
return ready.future;
},
);
final closed = Completer<void>();
final events = runtime.mediaItemEvents().listen(
(_) {},
onDone: closed.complete,
);
addTearDown(events.cancel);
final pending = runtime.initialize();
final failed = expectLater(pending, throwsStateError);
await _settle();
final disposed = runtime.dispose();
expect(runtime.dispose(), same(disposed));
expect(created.isDisposed, isTrue);
expect(runtime.handler, isNull);
ready.complete(created);
await failed;
await disposed;
await closed.future.timeout(const Duration(seconds: 1));
expect(decks.single.disposals, 1);
await expectLater(runtime.initialize(), throwsStateError);
},
);
test(
'a delayed builder cannot create playback resources after disposal',
() async {
final build = Completer<void>();
final decks = <_Deck>[];
final runtime = _runtime(
decks: decks,
initialize: (builder) async {
await build.future;
return builder();
},
);
final failed = expectLater(runtime.initialize(), throwsStateError);
await _settle();
final disposed = runtime.dispose();
build.complete();
await failed;
await disposed;
expect(runtime.handler, isNull);
expect(decks, isEmpty);
},
);
test(
'event subscribers follow a replacement and ignore the retired handler',
() async {
final runtime = _runtime();
final items = <MediaItem?>[];
final queues = <List<MediaItem>>[];
final itemEvents = runtime.mediaItemEvents().listen(items.add);
final queueEvents = runtime.queueEvents().listen(queues.add);
addTearDown(itemEvents.cancel);
addTearDown(queueEvents.cancel);
final first = await runtime.initialize();
first.mediaItem.add(_item);
first.queue.add([_item]);
await _settle();
expect(items.last, _item);
expect(queues.last, [_item]);
await first.dispose();
await _settle();
expect(items.last, isNull);
expect(queues.last, isEmpty);
first.mediaItem.add(const MediaItem(id: 'stale', title: 'Stale'));
first.queue.add([_item]);
final second = await runtime.initialize();
const replacement = MediaItem(id: 'two', title: 'Two');
second.mediaItem.add(replacement);
second.queue.add([replacement]);
await _settle();
expect(items.last, replacement);
expect(queues.last, [replacement]);
expect(items.whereType<MediaItem>().map((item) => item.id), [
'one',
'two',
]);
},
);
test(
'provider scopes isolate transport settings and notification favorites',
() async {
final firstDecks = <_Deck>[];
final secondDecks = <_Deck>[];
final first = _runtime(
decks: firstDecks,
settings: const AppSettings(
pauseOnMute: false,
playbackNormalization: true,
),
);
final second = _runtime(
decks: secondDecks,
settings: const AppSettings(
pauseOnMute: false,
usbBitPerfect: true,
usbDirect: true,
usbDsdOverPcm: true,
usbAllowFixedVolume: true,
dapExclusive: true,
),
);
final containers = [
for (final runtime in [first, second])
ProviderContainer(
overrides: [musicPlayerRuntimeProvider.overrideWithValue(runtime)],
),
];
for (final container in containers) {
addTearDown(container.dispose);
await container.read(musicPlayerControllerProvider).playSingle(_track);
}
expect(firstDecks.single.state, PlayerState.playing);
expect(secondDecks.single.state, PlayerState.playing);
expect(firstDecks.single.volumes.last, closeTo(0.501, 0.001));
expect(secondDecks.single.volumes.last, 1);
expect(firstDecks.single.outputOptions!.values, everyElement(isFalse));
expect(secondDecks.single.outputOptions!.values, everyElement(isTrue));
final toggled = <String>[];
first.configureNotification(
presentation: const PlaybackNotification(mornye: true),
toggleFavorite: (item) async {
toggled.add('first:${item.id}');
return true;
},
);
second.configureNotification(
presentation: const PlaybackNotification(mornye: true),
toggleFavorite: (item) async {
toggled.add('second:${item.id}');
return false;
},
);
await first.handler!.customAction(PlaybackNotification.favoriteAction);
expect(first.notification.loved, isTrue);
expect(second.notification.loved, isFalse);
await second.handler!.customAction(PlaybackNotification.favoriteAction);
expect(toggled, ['first:one', 'second:one']);
expect(
first.handler!.playbackState.value.controls.first.label,
'Remove from favorites',
);
expect(
second.handler!.playbackState.value.controls.first.label,
'Favorite',
);
first.configure(first.settings.copyWith(playbackNormalization: false));
await _settle();
expect(firstDecks.single.volumes.last, 1);
expect(second.settings.usbBitPerfect, isTrue);
expect(secondDecks.single.volumes, [1]);
},
);
}
+51
View File
@@ -0,0 +1,51 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/providers/music_player_provider.dart';
import 'package:spotiflac_android/providers/preview_player_provider.dart';
import 'package:spotiflac_android/services/music_player_service.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
ProviderContainer previews(MusicPlayerRuntime runtime) => ProviderContainer(
overrides: [musicPlayerRuntimeProvider.overrideWithValue(runtime)],
);
test('preview ownership stays within its playback runtime', () async {
final firstRuntime = MusicPlayerRuntime();
final secondRuntime = MusicPlayerRuntime();
final first = previews(firstRuntime);
final second = previews(secondRuntime);
first.read(previewPlayerProvider);
second.read(previewPlayerProvider);
final secondHook = secondRuntime.exclusiveAudioHook;
expect(firstRuntime.exclusiveAudioHook, isNotNull);
expect(secondHook, isNotNull);
expect(firstRuntime.exclusiveAudioHook, isNot(same(secondHook)));
first.dispose();
expect(firstRuntime.exclusiveAudioHook, isNull);
expect(secondRuntime.exclusiveAudioHook, same(secondHook));
second.dispose();
expect(secondRuntime.exclusiveAudioHook, isNull);
await firstRuntime.dispose();
await secondRuntime.dispose();
});
test('disposing an old preview preserves the new audio owner', () async {
final runtime = MusicPlayerRuntime();
final first = previews(runtime);
final second = previews(runtime);
first.read(previewPlayerProvider);
final firstHook = runtime.exclusiveAudioHook;
second.read(previewPlayerProvider);
final secondHook = runtime.exclusiveAudioHook;
expect(firstHook, isNot(same(secondHook)));
first.dispose();
expect(runtime.exclusiveAudioHook, same(secondHook));
second.dispose();
expect(runtime.exclusiveAudioHook, isNull);
await runtime.dispose();
});
}