mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-19 22:17:29 +02:00
refactor(playback): own configuration and dependencies per runtime
This commit is contained in:
1 parent
5194344268
commit
a363f5a459
12 files changed
+1195
-488
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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]);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user