diff --git a/lib/main.dart b/lib/main.dart index a774737e..c60c3812 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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, diff --git a/lib/providers/music_player_provider.dart b/lib/providers/music_player_provider.dart index 00f8bfaa..ed806fe6 100644 --- a/lib/providers/music_player_provider.dart +++ b/lib/providers/music_player_provider.dart @@ -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( + (ref) => musicPlayerRuntime, +); + final currentMediaItemProvider = StreamProvider((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((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>((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 ensureInitialized() async { try { - return await initMusicPlayer(); + return await _runtime.initialize(); } catch (_) { return null; } @@ -235,7 +245,8 @@ class MusicPlayerController { } final musicPlayerControllerProvider = Provider( - (ref) => const MusicPlayerController(), + (ref) => + MusicPlayerController(runtime: ref.watch(musicPlayerRuntimeProvider)), ); PlayableMedia playableFromHistory(DownloadHistoryItem item) { diff --git a/lib/providers/playback_notification_provider.dart b/lib/providers/playback_notification_provider.dart index f7f8d9e4..07f9d975 100644 --- a/lib/providers/playback_notification_provider.dart +++ b/lib/providers/playback_notification_provider.dart @@ -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, diff --git a/lib/providers/preview_player_provider.dart b/lib/providers/preview_player_provider.dart index d5110953..0d34c447 100644 --- a/lib/providers/preview_player_provider.dart +++ b/lib/providers/preview_player_provider.dart @@ -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 { _lifecycleListener = AppLifecycleListener( onStateChange: _handleAppLifecycleState, ); - musicPlayerExclusiveAudioHook = () async { + final runtime = ref.read(musicPlayerRuntimeProvider); + Future 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 { if (trimmed.isEmpty) return; try { - await musicPlayerHandler?.pause(); + await ref.read(musicPlayerRuntimeProvider).handler?.pause(); } catch (_) {} state = PreviewPlayerState( diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index 0c9de284..8c1d9d7d 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -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 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 ); _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 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 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 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), ); diff --git a/lib/services/music_player_automix.dart b/lib/services/music_player_automix.dart index 73d29544..3aaac83a 100644 --- a/lib/services/music_player_automix.dart +++ b/lib/services/music_player_automix.dart @@ -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); diff --git a/lib/services/music_player_autoplay.dart b/lib/services/music_player_autoplay.dart index 39f3e3af..efc627d1 100644 --- a/lib/services/music_player_autoplay.dart +++ b/lib/services/music_player_autoplay.dart @@ -3,37 +3,37 @@ part of 'music_player_service.dart'; typedef AutoplayLibraryLoader = Future> 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> _loadAutoplayLibrary(PlayableMedia seed) async { - final database = LibraryDatabase.instance; - final query = QueueLibraryDbQuery(includeLocal: _autoplayIncludeLocal); +Future> _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); diff --git a/lib/services/music_player_runtime.dart b/lib/services/music_player_runtime.dart new file mode 100644 index 00000000..2fc938ec --- /dev/null +++ b/lib/services/music_player_runtime.dart @@ -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? 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 Function() audioSession; + final Future> Function(String, {String? displayName}) + readMetadata; + final Future> Function(String) readNetworkMetadata; + final Future Function(String) resolveNetworkSource; + final Future Function(MusicPlayerHandler Function()) + initializeHandler; + final MethodChannel notificationChannel; + final AppStateDatabase? _sessionDatabase; + final LibraryDatabase? _libraryDatabase; + final ListeningRecorder? _recorder; + final Stream? _volumeChanges; + final bool? _isIOS, _isAndroid; + + AppStateDatabase get sessionDatabase => + _sessionDatabase ?? AppStateDatabase.instance; + LibraryDatabase get libraryDatabase => + _libraryDatabase ?? LibraryDatabase.instance; + ListeningRecorder get recorder => _recorder ?? listeningRecorder; + Stream 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 _getAudioSession() => AudioSession.instance; + static Future> _readNetworkMetadata(String source) => + NetworkMetadataService.instance.read(source); + static Future _resolveNetworkSource(String source) => + NetworkStorageService.instance.resolve(source); + static Future _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? _initFuture; + Future? _restoreFuture; + final _handlers = StreamController.broadcast(sync: true); + bool _initializing = false; + bool _disposed = false; + Future? _disposeFuture; + String _unknownTitle = 'Unknown title', _unknownArtist = 'Unknown artist'; + PlaybackNotification _notification = const PlaybackNotification(); + Future Function(MediaItem)? _toggleFavorite; + Future 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 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 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 _publishNotificationFavorite() async { + try { + await dependencies.notificationChannel.invokeMethod('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 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 _initialize() async { + _initializing = true; + MusicPlayerHandler? created; + try { + // Defer even a synchronous test initializer until _initFuture is stored. + await Future.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 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 dispose() => _disposeFuture ??= _dispose(); + + Future _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 _events( + Stream Function(MusicPlayerHandler) select, { + List initial = const [], + }) => Stream.multi((listener) { + StreamSubscription? 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 mediaItemEvents() => + _events((handler) => handler.mediaItem, initial: [null]); + Stream playbackStateEvents() => + _events((handler) => handler.playbackState); + Stream> queueEvents() => + _events((handler) => handler.queue, initial: [const []]); +} + +final musicPlayerRuntime = MusicPlayerRuntime(); diff --git a/lib/services/music_player_service.dart b/lib/services/music_player_service.dart index 33e80803..e1469447 100644 --- a/lib/services/music_player_service.dart +++ b/lib/services/music_player_service.dart @@ -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 Function(MediaItem)? _toggleNotificationFavorite; -const _notificationChannel = MethodChannel( - 'com.zarz.spotiflac/playback_notification', -); - void configurePlaybackNotification({ required PlaybackNotification presentation, required Future 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 _publishIosNotificationFavorite() async { - try { - await _notificationChannel.invokeMethod('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 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 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 playbackAudioMetadataFromMediaItem(MediaItem item) { - final extras = item.extras; - if (extras == null || extras.isEmpty) return const {}; - - final metadata = {}; - 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 mergePlaybackFileMetadata( - Map fallback, - Map probed, -) { - final merged = {...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> 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> readPlaybackFileMetadataWithRetry( - String path, { - PlaybackMetadataReader? reader, - List 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.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? _disposeFuture; bool _initialized = false; bool _sourceReady = false; Future? _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 _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 _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 _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 _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 _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.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 = []; 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 _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 dispose() async { + Future dispose() => _disposeFuture ??= _dispose(); + + Future _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? _initFuture; -final StreamController _handlerReadyController = - StreamController.broadcast(); +MusicPlayerHandler? get musicPlayerHandler => musicPlayerRuntime.handler; -MusicPlayerHandler? get musicPlayerHandler => _handler; - -Future Function()? musicPlayerExclusiveAudioHook; - -/// Flushes the current playback position if the player has been initialized. -Future persistCurrentPlaybackSession() async { - final handler = _handler; - if (handler == null) return; - await handler.persistCurrentSession(); -} - -Future initMusicPlayer() async { - if (_handler != null) return _handler!; - final existingFuture = _initFuture; - if (existingFuture != null) return existingFuture; - - final future = _doInitMusicPlayer(); - _initFuture = future; - return future; -} - -Future _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 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? _restoreSessionFuture; - Future restorePersistedPlaybackSession() => - _restoreSessionFuture ??= _restorePersistedPlaybackSession(); + musicPlayerRuntime.restoreSession(); -Future _restorePersistedPlaybackSession() async { +Future 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 = []; final keptOriginalIndices = []; - final iosDocumentsPath = Platform.isIOS + final iosDocumentsPath = runtime.dependencies.isIOS ? (await getApplicationDocumentsDirectory()).path : null; var artworkRelocated = false; @@ -2419,7 +2284,7 @@ Future _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 _restorePersistedPlaybackSession() async { keptOriginalIndices.add(i); } if (items.isEmpty) { - await AppStateDatabase.instance.clearPlaybackSession(); + await database.clearPlaybackSession(); return; } @@ -2461,7 +2326,7 @@ Future _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 _restorePersistedPlaybackSession() async { } } -Stream 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 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> 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 musicPlayerMediaItemEvents() => + musicPlayerRuntime.mediaItemEvents(); +Stream musicPlayerPlaybackStateEvents() => + musicPlayerRuntime.playbackStateEvents(); +Stream> musicPlayerQueueEvents() => + musicPlayerRuntime.queueEvents(); diff --git a/lib/services/playback_metadata.dart b/lib/services/playback_metadata.dart new file mode 100644 index 00000000..e0f534f6 --- /dev/null +++ b/lib/services/playback_metadata.dart @@ -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 playbackAudioMetadataFromMediaItem(MediaItem item) { + final extras = item.extras; + if (extras == null || extras.isEmpty) return const {}; + + final metadata = {}; + 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 mergePlaybackFileMetadata( + Map fallback, + Map probed, +) { + final merged = {...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> 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> readPlaybackFileMetadataWithRetry( + String path, { + PlaybackMetadataReader? reader, + List 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.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, + ); +} diff --git a/test/music_player_runtime_test.dart b/test/music_player_runtime_test.dart new file mode 100644 index 00000000..ed9ec4cd --- /dev/null +++ b/test/music_player_runtime_test.dart @@ -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 = []; + Map? outputOptions; + Duration position = Duration.zero; + int disposals = 0; + + @override + bool get isDirect => false; + @override + bool get needsSourceReload => false; + @override + Stream get onPlayerStateChanged => const Stream.empty(); + @override + Stream get onPositionChanged => const Stream.empty(); + @override + Stream get onDurationChanged => const Stream.empty(); + @override + Stream get onPlayerComplete => const Stream.empty(); + @override + set positionUpdater(PositionUpdater updater) {} + @override + Future setReleaseMode(ReleaseMode mode) async {} + @override + Future setAudioContext(AudioContext context) async {} + @override + Future setVolume(double volume) async => volumes.add(volume); + @override + Future cancelPreparation() async {} + @override + Future stop() async => state = PlayerState.stopped; + @override + Future pause() async => state = PlayerState.paused; + @override + Future resume() async => state = PlayerState.playing; + @override + Future seek(Duration value) async => position = value; + @override + Future getDuration() async => const Duration(seconds: 60); + @override + Future getCurrentPosition() async => position; + @override + Future dispose() async => disposals++; + @override + Future 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 configure(AudioSessionConfiguration configuration) async {} + @override + Future setActive( + bool active, { + AVAudioSessionSetActiveOptions? avAudioSessionSetActiveOptions, + AndroidAudioFocusGainType? androidAudioFocusGainType, + AndroidAudioAttributes? androidAudioAttributes, + bool? androidWillPauseWhenDucked, + AudioSessionConfiguration fallbackConfiguration = + const AudioSessionConfiguration.music(), + }) async => true; + @override + Stream get interruptionEventStream => + const Stream.empty(); + @override + Stream get becomingNoisyEventStream => const Stream.empty(); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _SessionStore implements AppStateDatabase { + @override + Future savePlaybackSession(Map session) async {} + @override + Future 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 Function(MusicPlayerHandler Function())? + initialize, + List<_Deck>? decks, + MusicPlaybackDeck Function(String)? createDeck, + Stream 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 _settle() => Future.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 = []; + final queues = >[]; + final states = []; + 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(); + final volume = StreamController.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 = []; + 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(); + final decks = <_Deck>[]; + late MusicPlayerHandler created; + final runtime = _runtime( + decks: decks, + initialize: (builder) { + created = builder(); + return ready.future; + }, + ); + final closed = Completer(); + 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(); + 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 = []; + final queues = >[]; + 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().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 = []; + 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]); + }, + ); +} diff --git a/test/preview_player_runtime_test.dart b/test/preview_player_runtime_test.dart new file mode 100644 index 00000000..0ff9427f --- /dev/null +++ b/test/preview_player_runtime_test.dart @@ -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(); + }); +}