import 'dart:async'; import 'dart:io'; import 'dart:math'; import 'package:audio_service/audio_service.dart'; import 'package:audio_session/audio_session.dart' show AudioSession, AudioSessionConfiguration, AudioInterruptionType; import 'package:audioplayers/audioplayers.dart'; import 'package:spotiflac_android/services/app_state_database.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/playback_normalization.dart'; import 'package:spotiflac_android/utils/int_utils.dart'; import 'package:spotiflac_android/utils/logger.dart'; import 'package:spotiflac_android/utils/string_utils.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; MusicPlayerHandler? _activeMusicPlayerHandler; /// 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(); } List buildShuffleCandidatePool({ required int mediaCount, required int currentIndex, required Iterable recentIndices, }) { final recent = recentIndices.toSet(); final pool = []; for (var index = 0; index < mediaCount; index++) { if (index != currentIndex && !recent.contains(index)) pool.add(index); } if (pool.isEmpty) { for (var index = 0; index < mediaCount; index++) { if (index != currentIndex) pool.add(index); } } return pool; } final AudioContext _musicAudioContext = AudioContext( android: const AudioContextAndroid( audioFocus: AndroidAudioFocus.none, contentType: AndroidContentType.music, usageType: AndroidUsageType.media, stayAwake: true, ), ); class PlayableMedia { final String id; final String source; final String title; final String artist; final String album; final String? artUri; final Duration? duration; final int? bitDepth; final int? sampleRate; final int? bitrate; final String? format; final bool explicit; const PlayableMedia({ required this.id, required this.source, required this.title, required this.artist, this.album = '', this.artUri, this.duration, this.bitDepth, this.sampleRate, this.bitrate, this.format, this.explicit = false, }); bool get isContentUri => source.startsWith('content://'); Map toJson() => { 'id': id, 'source': source, 'title': title, 'artist': artist, 'album': album, if (artUri != null && artUri!.isNotEmpty) 'artUri': artUri, if (duration != null) 'durationMs': duration!.inMilliseconds, if (bitDepth != null && bitDepth! > 0) 'bitDepth': bitDepth, if (sampleRate != null && sampleRate! > 0) 'sampleRate': sampleRate, if (bitrate != null && bitrate! > 0) 'bitrate': bitrate, if (format != null && format!.trim().isNotEmpty) 'format': format, if (explicit) 'explicit': true, }; static PlayableMedia? fromJson(Map json) { final id = json['id'] as String?; final source = json['source'] as String?; if (id == null || id.isEmpty || source == null || source.isEmpty) { return null; } final durationMs = (json['durationMs'] as num?)?.toInt(); return PlayableMedia( id: id, source: source, title: json['title'] as String? ?? '', artist: json['artist'] as String? ?? '', album: json['album'] as String? ?? '', artUri: json['artUri'] as String?, duration: (durationMs != null && durationMs > 0) ? Duration(milliseconds: durationMs) : null, bitDepth: readPositiveInt(json['bitDepth']), sampleRate: readPositiveInt(json['sampleRate']), bitrate: readPositiveInt(json['bitrate']), format: json['format']?.toString(), explicit: parseExplicitFlag(json['explicit']) == true, ); } MediaItem toMediaItem({String? resolvedSource}) { return MediaItem( id: id, title: title.isEmpty ? _playbackUnknownTitle : title, artist: artist.isEmpty ? _playbackUnknownArtist : artist, album: album.isEmpty ? null : album, duration: duration, artUri: (artUri != null && artUri!.isNotEmpty) ? Uri.tryParse(artUri!) : null, extras: { 'source': source, if (resolvedSource != null && resolvedSource.isNotEmpty) 'resolvedSource': resolvedSource, if (bitDepth != null && bitDepth! > 0) 'bit_depth': bitDepth, if (sampleRate != null && sampleRate! > 0) 'sample_rate': sampleRate, if (bitrate != null && bitrate! > 0) 'bitrate': bitrate, if (format != null && format!.trim().isNotEmpty) 'format': format!.trim(), if (explicit) 'explicit': true, }, ); } } /// Technical audio metadata carried with a queue item. This is available /// immediately when tracks change, before a fresh file probe completes. Map playbackAudioMetadataFromMediaItem(MediaItem item) { final extras = item.extras; if (extras == null || extras.isEmpty) return const {}; final metadata = {}; final bitDepth = readPositiveInt(extras['bit_depth']); final sampleRate = readPositiveInt(extras['sample_rate']); final bitrate = readPositiveInt(extras['bitrate']); final format = extras['format']?.toString().trim(); final explicit = parseExplicitFlag(extras['explicit']); if (bitDepth != null) metadata['bit_depth'] = bitDepth; if (sampleRate != null) metadata['sample_rate'] = sampleRate; if (bitrate != null) metadata['bitrate'] = bitrate; if (format != null && format.isNotEmpty) metadata['format'] = format; if (explicit != null) metadata['explicit'] = explicit; return metadata; } /// Combines the immediate queue metadata with the richer file probe while /// retaining known quality fields when a decoder omits or reports them as 0. Map mergePlaybackFileMetadata( Map fallback, Map probed, ) { final merged = {...fallback, ...probed}; for (final key in const ['bit_depth', 'sample_rate', 'bitrate']) { if (readPositiveInt(probed[key]) == null && readPositiveInt(fallback[key]) != null) { merged[key] = fallback[key]; } } final probedFormat = probed['format']?.toString().trim(); final fallbackFormat = fallback['format']?.toString().trim(); if ((probedFormat == null || probedFormat.isEmpty) && fallbackFormat != null && fallbackFormat.isNotEmpty) { merged['format'] = fallbackFormat; } return merged; } typedef PlaybackMetadataReader = Future> Function(String path); /// Reads playback metadata with a small bounded retry window for transient /// cold-start/native bridge failures. /// /// The native bridge reports some read failures as an `error` field instead /// of throwing. Treat both forms identically so Now Playing does not cache an /// empty Lyrics view until the route is reopened. A successful response with /// no lyrics is still final and is never retried. Future> readPlaybackFileMetadataWithRetry( String path, { PlaybackMetadataReader? reader, List retryDelays = const [ Duration.zero, Duration(milliseconds: 250), Duration(milliseconds: 750), ], }) async { final read = reader ?? PlatformBridge.readFileMetadata; final delays = retryDelays.isEmpty ? const [Duration.zero] : retryDelays; Object? lastError; var lastStack = StackTrace.current; for (final delay in delays) { if (delay > Duration.zero) await Future.delayed(delay); try { final metadata = await read(path); final reportedError = metadata['error']?.toString().trim() ?? ''; if (reportedError.isEmpty) return metadata; lastError = StateError(reportedError); lastStack = StackTrace.current; } catch (error, stack) { lastError = error; lastStack = stack; } } Error.throwWithStackTrace( lastError ?? StateError('Metadata reader returned no result'), lastStack, ); } /// Returns a safe source-start position for restored playback. A completed /// snapshot starts over instead of immediately completing again on resume. Duration normalizedPlaybackResumePosition( Duration position, { Duration? duration, }) { if (position <= Duration.zero) return Duration.zero; if (duration != null && duration > Duration.zero && position >= duration) { return Duration.zero; } return position; } class MusicPlayerHandler extends BaseAudioHandler with QueueHandler, SeekHandler { final AudioPlayer _player = AudioPlayer(playerId: 'music-player'); AudioSession? _audioSession; final List _media = []; final List _queueItems = []; final Map _resolvedPathCache = {}; final Map _resolvedPathSizes = {}; final Map> _pendingSourceResolutions = {}; final List _resolvedPathOrder = []; final Set _pendingResolvedPathDeletes = {}; final List> _subscriptions = []; int _index = -1; int _playRequestGeneration = 0; String? _activeResolvedPath; bool _disposed = false; bool _initialized = false; bool _sourceReady = false; Future? _activePlayOperation; Timer? _sleepTimer; DateTime? _sleepTimerEndsAt; bool _shuffle = false; AudioServiceRepeatMode _repeatMode = AudioServiceRepeatMode.none; final Random _random = Random(); final List _recent = []; final List _playHistory = []; // True when playback was paused because another app took audio focus. bool _pausedByInterruption = false; bool _interruptionActive = false; bool _userPaused = false; // Position saved with the restored session; applied on the first play(). Duration? _pendingRestorePosition; bool _restoringSession = false; int _switchingGeneration = 0; Duration _lastBroadcastPosition = Duration.zero; DateTime? _lastPositionBroadcastAt; DateTime? _lastPeriodicPersistAt; Future _sessionWriteTail = Future.value(); int _sessionQueueRevision = 0; int _scheduledSessionQueueRevision = -1; int _persistedSessionQueueRevision = -1; static const Duration _positionBroadcastInterval = Duration( milliseconds: 500, ); static const Duration _positionPersistInterval = Duration(seconds: 10); static const int _maxResolvedPathCacheEntries = 3; static const int _maxResolvedPathCacheBytes = 256 * 1024 * 1024; DateTime? get sleepTimerEndsAt => _sleepTimerEndsAt; MusicPlayerHandler() { _activeMusicPlayerHandler = this; _init(); } void _init() { if (_initialized) return; _initialized = true; _player.setReleaseMode(ReleaseMode.stop); unawaited(_player.setAudioContext(_musicAudioContext)); unawaited(_configureAudioSession()); _subscriptions.addAll([ _player.onPlayerStateChanged.listen((state) { if (_switchingGeneration != 0 && (state == PlayerState.stopped || state == PlayerState.completed || state == PlayerState.disposed)) { return; } if (state == PlayerState.completed && _shouldIgnoreComplete) { if (_userPaused || _interruptionActive) { _broadcastState(playerState: PlayerState.paused); } return; } _broadcastState(playerState: state); }), _player.onPositionChanged.listen(_handlePositionChanged), _player.onDurationChanged.listen((duration) { final current = mediaItem.value; if (current != null && duration > Duration.zero) { mediaItem.add(current.copyWith(duration: duration)); } }), _player.onPlayerComplete.listen((_) { unawaited(_handlePlayerComplete()); }), ]); } /// Configures the OS audio session and reacts to interruptions (e.g. another /// app like PowerAmp taking audio focus, or headphones unplugged) so playback /// pauses and the UI/notification reflect the real state instead of staying /// stuck on "playing". Future _configureAudioSession() async { try { final session = await AudioSession.instance; _audioSession = session; await session.configure(const AudioSessionConfiguration.music()); _subscriptions.add( session.interruptionEventStream.listen((event) { _log.d( 'Audio interruption ${event.begin ? 'began' : 'ended'} ' 'type=${event.type} player=${_player.state} ' 'playing=${playbackState.value.playing}', ); if (event.begin) { if (event.type == AudioInterruptionType.duck) { return; } // Another app took focus or a transient interruption began. _interruptionActive = true; _pausedByInterruption = _player.state == PlayerState.playing || playbackState.value.playing; unawaited(_pauseForFocusLoss(reason: 'audio interruption')); } else { if (event.type == AudioInterruptionType.duck) { return; } // Focus returned; resume only if we paused due to a transient // (duck/pause) interruption. _interruptionActive = false; if (_pausedByInterruption && event.type == AudioInterruptionType.pause) { _pausedByInterruption = false; unawaited(play()); } else { _pausedByInterruption = false; } } }), ); _subscriptions.add( session.becomingNoisyEventStream.listen((_) { // Headphones unplugged / output route lost. unawaited(_pauseForFocusLoss(reason: 'becoming noisy')); }), ); } catch (e) { _log.w('Failed to configure audio session: $e'); } } bool get _shouldIgnoreComplete => _switchingGeneration != 0 || _interruptionActive || _userPaused; Future _pauseForFocusLoss({required String reason}) async { _log.i('Pausing internal player because of $reason'); _playRequestGeneration++; _switchingGeneration = 0; try { await _player.pause(); } catch (e) { _log.w('Failed to pause after audio focus loss: $e'); } // Force the UI/notification to reflect the pause even if the engine does // not emit a state-change event on focus loss. _broadcastState(playerState: PlayerState.paused); await _persistSession(position: await _currentPositionForPersist()); } void setSleepTimer(Duration duration) { if (duration <= Duration.zero) return; _sleepTimer?.cancel(); _sleepTimerEndsAt = DateTime.now().add(duration); _sleepTimer = Timer(duration, () { _sleepTimer = null; _sleepTimerEndsAt = null; if (_disposed) return; _log.i('Sleep timer elapsed; pausing playback'); unawaited(pause()); }); } void cancelSleepTimer() { _sleepTimer?.cancel(); _sleepTimer = null; _sleepTimerEndsAt = null; } Future _activateAudioSession() async { try { final session = _audioSession ?? await AudioSession.instance; _audioSession = session; final granted = await session.setActive(true); if (!granted) { _log.w('Audio focus request was not granted'); } } catch (e) { _log.w('Failed to activate audio session: $e'); } } // Required on some Android builds because audio_session manages focus. Future _claimHardwareMediaButtons() async { if (!Platform.isAndroid) return; try { await AudioService.androidForceEnableMediaButtons(); } catch (e) { _log.w('Failed to claim Android media-button routing: $e'); } } AudioProcessingState _mapProcessingState(PlayerState state) { switch (state) { case PlayerState.playing: case PlayerState.paused: return AudioProcessingState.ready; case PlayerState.completed: return AudioProcessingState.completed; case PlayerState.stopped: case PlayerState.disposed: return AudioProcessingState.idle; } } void _broadcastState({PlayerState? playerState, bool? loading}) { final state = playerState ?? _player.state; final playing = state == PlayerState.playing; playbackState.add( playbackState.value.copyWith( controls: [ MediaControl.skipToPrevious, if (playing) MediaControl.pause else MediaControl.play, MediaControl.skipToNext, ], systemActions: const { MediaAction.seek, MediaAction.seekForward, MediaAction.seekBackward, MediaAction.skipToPrevious, MediaAction.skipToNext, }, androidCompactActionIndices: const [0, 1, 2], processingState: (loading == true) ? AudioProcessingState.loading : _mapProcessingState(state), playing: playing, shuffleMode: _shuffle ? AudioServiceShuffleMode.all : AudioServiceShuffleMode.none, repeatMode: _repeatMode, ), ); } void _broadcastPosition(Duration position, {bool force = false}) { final now = DateTime.now(); final lastAt = _lastPositionBroadcastAt; final elapsed = lastAt == null ? null : now.difference(lastAt); final moved = (position - _lastBroadcastPosition).abs(); if (!force && elapsed != null && elapsed < _positionBroadcastInterval && moved < _positionBroadcastInterval) { return; } _lastPositionBroadcastAt = now; _lastBroadcastPosition = position; playbackState.add(playbackState.value.copyWith(updatePosition: position)); } void _handlePositionChanged(Duration position) { _broadcastPosition(position); if (_restoringSession || _player.state != PlayerState.playing || _media.isEmpty || _index < 0 || _index >= _media.length) { return; } final now = DateTime.now(); final lastPersistAt = _lastPeriodicPersistAt; if (lastPersistAt != null && now.difference(lastPersistAt) < _positionPersistInterval) { return; } _lastPeriodicPersistAt = now; unawaited(_persistSession(position: position)); } final _normalizationCache = PlaybackNormalizationCache( readMetadata: PlatformBridge.readFileMetadata, onReadError: (error) => _log.w('Failed to read gain tags for normalization: $error'), ); Future _normalizationVolumeFor( String path, { String? cacheKey, String? displayName, }) async { if (!_playbackNormalizationEnabled) return 1.0; return _normalizationCache.volumeFor( path, cacheKey: cacheKey, displayName: displayName, ); } /// Re-applies normalization to the playing track when the setting flips. void reapplyNormalization() { final index = _index; final generation = _playRequestGeneration; if (index < 0 || index >= _media.length) return; unawaited(() async { final media = _media[index]; var resolved = media.isContentUri ? _resolvedPathCache[media.source] : media.source; ContentUriPlaybackLease? playbackLease; if (resolved == null && media.isContentUri) { try { playbackLease = await PlatformBridge.openContentUriPlaybackLease( media.source, ); resolved = playbackLease?.path; } catch (_) {} resolved ??= await _resolveSource(media); } if (resolved == null) return; final volume = await _normalizationVolumeFor( resolved, cacheKey: media.isContentUri ? media.source : null, displayName: playbackLease?.displayName, ); if (playbackLease != null) { await PlatformBridge.closeContentUriPlaybackLease(playbackLease.token); } if (_index != index || generation != _playRequestGeneration) return; try { await _player.setVolume(volume); } catch (e) { _log.w('Failed to apply normalization volume: $e'); } }()); } Future _resolveSource(PlayableMedia media) async { if (!media.isContentUri) return media.source; final cached = _resolvedPathCache[media.source]; if (cached != null) { if (await File(cached).exists()) return cached; _resolvedPathCache.remove(media.source); _resolvedPathSizes.remove(media.source); _resolvedPathOrder.remove(media.source); } final inFlight = _pendingSourceResolutions[media.source]; if (inFlight != null) return inFlight; final resolution = () async { try { final tempPath = await PlatformBridge.copyContentUriToTemp( media.source, ); if (tempPath == null || tempPath.isEmpty) return null; if (_disposed) { await _discardResolvedPath(tempPath); return null; } _resolvedPathCache[media.source] = tempPath; _resolvedPathSizes[media.source] = await File(tempPath).length(); _resolvedPathOrder ..remove(media.source) ..add(media.source); while (_resolvedPathOrder.length > _maxResolvedPathCacheEntries || (_resolvedPathOrder.length > 1 && _resolvedPathSizes.values.fold( 0, (sum, size) => sum + size, ) > _maxResolvedPathCacheBytes)) { final evictedSource = _resolvedPathOrder.removeAt(0); final evictedPath = _resolvedPathCache.remove(evictedSource); _resolvedPathSizes.remove(evictedSource); if (evictedPath != null) { unawaited(_discardResolvedPath(evictedPath)); } } return tempPath; } catch (e) { _log.e('Failed to resolve content URI for playback: $e'); return null; } }(); _pendingSourceResolutions[media.source] = resolution; try { return await resolution; } finally { if (identical(_pendingSourceResolutions[media.source], resolution)) { _pendingSourceResolutions.remove(media.source); } } } Future _discardResolvedPath(String path) async { if (path == _activeResolvedPath) { _pendingResolvedPathDeletes.add(path); return; } try { final file = File(path); if (await file.exists()) await file.delete(); } catch (e) { _log.w('Failed to delete SAF playback temp file: $e'); } } Future _cleanupPendingResolvedPaths() async { final deletable = _pendingResolvedPathDeletes .where((path) => path != _activeResolvedPath) .toList(growable: false); for (final path in deletable) { _pendingResolvedPathDeletes.remove(path); await _discardResolvedPath(path); } } Future _enqueueSessionWrite(Future Function() operation) { final queued = _sessionWriteTail.then((_) async { try { await operation(); } catch (e) { _log.w('Failed to update persisted playback session: $e'); } }); _sessionWriteTail = queued; return queued; } void _markSessionQueueChanged() { _sessionQueueRevision++; } /// Persists the queue only when it changed. Periodic position updates write /// fixed-size scalar columns, avoiding full queue JSON serialization every /// ten seconds for large playback sessions. Future _persistSession({Duration? position}) { if (_restoringSession) return Future.value(); if (_media.isEmpty || _index < 0 || _index >= _media.length) { _scheduledSessionQueueRevision = -1; _persistedSessionQueueRevision = -1; return _enqueueSessionWrite( AppStateDatabase.instance.clearPlaybackSession, ); } final queueRevision = _sessionQueueRevision; final index = _index; final positionMs = (position ?? Duration.zero).inMilliseconds; final shuffle = _shuffle; final repeatMode = _repeatMode.name; if (_scheduledSessionQueueRevision != queueRevision) { final media = _media.map((item) => item.toJson()).toList(growable: false); _scheduledSessionQueueRevision = queueRevision; return _enqueueSessionWrite(() async { try { await AppStateDatabase.instance.savePlaybackSession({ 'version': 2, 'media': media, 'index': index, 'positionMs': positionMs, 'shuffle': shuffle, 'repeat': repeatMode, }); _persistedSessionQueueRevision = queueRevision; } catch (_) { if (_scheduledSessionQueueRevision == queueRevision) { _scheduledSessionQueueRevision = _persistedSessionQueueRevision; } rethrow; } }); } return _enqueueSessionWrite(() async { final updated = await AppStateDatabase.instance .updatePlaybackSessionState( index: index, positionMs: positionMs, shuffle: shuffle, repeatMode: repeatMode, ); if (updated) return; // A newer queue snapshot is already (or is about to be) scheduled. Do // not recreate a missing row from this older scalar snapshot with a // mismatched index; the newer full write will restore it consistently. if (_sessionQueueRevision != queueRevision) return; // Defensive recovery for an externally cleared/corrupted row. This is // intentionally the only state-only path that serializes the queue. await AppStateDatabase.instance.savePlaybackSession({ 'version': 2, 'media': _media.map((item) => item.toJson()).toList(growable: false), 'index': index, 'positionMs': positionMs, 'shuffle': shuffle, 'repeat': repeatMode, }); _persistedSessionQueueRevision = queueRevision; }); } Future _currentPositionForPersist() async { // During restore/source preparation the engine may report a stale zero (or // the previous source). The broadcast position already represents the // intended start point for the current queue item. if (!_sourceReady) return playbackState.value.position; try { final position = await _player.getCurrentPosition(); if (position != null) return position; } catch (_) {} return playbackState.value.position; } /// Flushes the live engine position and every older queued session write. /// Called when the app leaves the foreground so a normal restart resumes at /// the latest audible point rather than the beginning of the track. Future persistCurrentSession() async { if (_media.isEmpty || _index < 0 || _index >= _media.length) return; final position = await _currentPositionForPersist(); _lastPeriodicPersistAt = DateTime.now(); await _persistSession(position: position); } /// Restores a persisted session paused: queue and current track become /// visible (mini player included) without loading any source; the first /// play() prepares the source directly at the saved position. Future restoreSession({ required List items, required int index, required Duration position, required bool shuffle, bool queueNeedsRewrite = false, AudioServiceRepeatMode repeatMode = AudioServiceRepeatMode.none, }) async { if (items.isEmpty) return; // Never clobber a session the user already started this launch. if (_media.isNotEmpty || _index >= 0) return; _restoringSession = true; try { _media ..clear() ..addAll(items); _queueItems ..clear() ..addAll(items.map((m) => m.toMediaItem())); _index = index.clamp(0, items.length - 1); _shuffle = shuffle; _repeatMode = repeatMode; _pendingRestorePosition = position > Duration.zero ? position : null; _sourceReady = false; _lastPeriodicPersistAt = null; _sessionQueueRevision++; if (queueNeedsRewrite) { _scheduledSessionQueueRevision = -1; _persistedSessionQueueRevision = -1; } else { _scheduledSessionQueueRevision = _sessionQueueRevision; _persistedSessionQueueRevision = _sessionQueueRevision; } queue.add(List.unmodifiable(_queueItems)); mediaItem.add(_media[_index].toMediaItem()); if (position > Duration.zero) { playbackState.add( playbackState.value.copyWith(updatePosition: position), ); } _broadcastState(playerState: PlayerState.paused); } finally { _restoringSession = false; } if (queueNeedsRewrite) { await _persistSession(position: position); } } bool _isCurrentPlayRequest(int generation, PlayableMedia media) { return generation == _playRequestGeneration && _index >= 0 && _index < _media.length && _media[_index].id == media.id && _media[_index].source == media.source; } Future setQueueAndPlay( List items, { int initialIndex = 0, }) async { if (items.isEmpty) return; _playRequestGeneration++; _media ..clear() ..addAll(items); _queueItems ..clear() ..addAll(items.map((m) => m.toMediaItem())); _markSessionQueueChanged(); _recent.clear(); _playHistory.clear(); queue.add(List.unmodifiable(_queueItems)); await _playIndex(initialIndex.clamp(0, items.length - 1)); } Future enqueue(PlayableMedia item, {bool playNext = false}) async { if (_media.isEmpty || _index < 0) { await setQueueAndPlay([item]); return; } final insertAt = playNext ? (_index + 1).clamp(0, _media.length) : _media.length; _media.insert(insertAt, item); _queueItems.insert(insertAt, item.toMediaItem()); _markSessionQueueChanged(); for (var i = 0; i < _recent.length; i++) { if (_recent[i] >= insertAt) _recent[i]++; } for (var i = 0; i < _playHistory.length; i++) { if (_playHistory[i] >= insertAt) _playHistory[i]++; } queue.add(List.unmodifiable(_queueItems)); _broadcastState(); unawaited(_persistSession(position: playbackState.value.position)); } Future enqueueAll( List items, { bool playNext = false, }) async { if (items.isEmpty) return; if (_media.isEmpty || _index < 0) { await setQueueAndPlay(items); return; } var at = playNext ? (_index + 1).clamp(0, _media.length) : _media.length; for (final item in items) { _media.insert(at, item); _queueItems.insert(at, item.toMediaItem()); for (var i = 0; i < _recent.length; i++) { if (_recent[i] >= at) _recent[i]++; } for (var i = 0; i < _playHistory.length; i++) { if (_playHistory[i] >= at) _playHistory[i]++; } at++; } _markSessionQueueChanged(); queue.add(List.unmodifiable(_queueItems)); _broadcastState(); unawaited(_persistSession(position: playbackState.value.position)); } void moveQueueItem(int oldIndex, int newIndex) { if (oldIndex < 0 || oldIndex >= _media.length || newIndex < 0 || newIndex >= _media.length || oldIndex == newIndex) { return; } final media = _media.removeAt(oldIndex); final qi = _queueItems.removeAt(oldIndex); _media.insert(newIndex, media); _queueItems.insert(newIndex, qi); _markSessionQueueChanged(); if (_index == oldIndex) { _index = newIndex; } else { if (oldIndex < _index && newIndex >= _index) { _index--; } else if (oldIndex > _index && newIndex <= _index) { _index++; } } _recent.clear(); _playHistory.clear(); queue.add(List.unmodifiable(_queueItems)); _broadcastState(); unawaited(_persistSession(position: playbackState.value.position)); } Future _playIndex( int index, { bool recordHistory = true, Duration startPosition = Duration.zero, }) async { if (index < 0 || index >= _media.length) return; // A normal track change supersedes a pending restore. A restored start // keeps it until the source is actually ready so a transient failure can // be retried from the same position. if (startPosition <= Duration.zero) { _pendingRestorePosition = null; } final generation = ++_playRequestGeneration; _index = index; _pausedByInterruption = false; _interruptionActive = false; _userPaused = false; if (recordHistory) { _playHistory.add(index); if (_playHistory.length > 200) _playHistory.removeAt(0); _recent.add(index); final maxRecent = ((_media.length - 1) * 0.6).floor().clamp( 1, _media.length > 1 ? _media.length - 1 : 1, ); while (_recent.length > maxRecent) { _recent.removeAt(0); } } final media = _media[index]; final effectiveStartPosition = normalizedPlaybackResumePosition( startPosition, duration: media.duration, ); mediaItem.add(media.toMediaItem()); _lastBroadcastPosition = Duration.zero; _lastPositionBroadcastAt = null; _broadcastPosition(effectiveStartPosition, force: true); // Claim the playing state up front (while the app is still in the // foreground window) so audio_service can start its foreground service // before the async source resolve below. _broadcastState(playerState: PlayerState.playing, loading: true); await _claimHardwareMediaButtons(); if (!_isCurrentPlayRequest(generation, media)) return; // Android opens SAF files through a short-lived file-descriptor lease. // 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) { try { playbackLease = await PlatformBridge.openContentUriPlaybackLease( media.source, ); } catch (e) { _log.w('Failed to open direct SAF playback lease: $e'); } } var resolved = playbackLease?.path ?? await _resolveSource(media); var usingLocalSafCopy = media.isContentUri && playbackLease == null; if (!_isCurrentPlayRequest(generation, media)) { if (playbackLease != null) { await PlatformBridge.closeContentUriPlaybackLease(playbackLease.token); } return; } if (resolved == null) { _log.e('No playable source for ${media.title}'); _broadcastState(playerState: PlayerState.stopped); return; } try { await musicPlayerExclusiveAudioHook?.call(); } catch (_) {} if (!_isCurrentPlayRequest(generation, media)) { if (playbackLease != null) { await PlatformBridge.closeContentUriPlaybackLease(playbackLease.token); } return; } _switchingGeneration = generation; try { // Set before play() so the track never starts at the wrong loudness; // always set (1.0 when disabled/untagged) so a previous track's // attenuation can't leak into the next one. var normalizationVolume = await _normalizationVolumeFor( resolved, cacheKey: media.isContentUri ? media.source : null, displayName: playbackLease?.displayName, ); if (!_isCurrentPlayRequest(generation, media)) return; await _player.setAudioContext(_musicAudioContext); if (!_isCurrentPlayRequest(generation, media)) return; await _activateAudioSession(); if (!_isCurrentPlayRequest(generation, media)) return; await _player.stop(); _sourceReady = false; if (!_isCurrentPlayRequest(generation, media)) return; await _player.setVolume(normalizationVolume); if (!_isCurrentPlayRequest(generation, media)) return; final startAt = effectiveStartPosition > Duration.zero ? effectiveStartPosition : null; try { await _player.play(DeviceFileSource(resolved), position: startAt); if (playbackLease != null) { await PlatformBridge.closeContentUriPlaybackLease( playbackLease.token, ); playbackLease = null; } } catch (directError) { if (playbackLease == null || !_isCurrentPlayRequest(generation, media)) { rethrow; } await PlatformBridge.closeContentUriPlaybackLease(playbackLease.token); playbackLease = null; _log.w( 'Direct SAF playback unavailable; using bounded local fallback: ' '$directError', ); final fallback = await _resolveSource(media); if (fallback == null || !_isCurrentPlayRequest(generation, media)) { rethrow; } resolved = fallback; usingLocalSafCopy = true; normalizationVolume = await _normalizationVolumeFor( fallback, cacheKey: media.source, ); await _player.setVolume(normalizationVolume); await _player.play(DeviceFileSource(fallback), position: startAt); } if (!_isCurrentPlayRequest(generation, media)) return; _sourceReady = true; _pendingRestorePosition = null; _activeResolvedPath = usingLocalSafCopy ? resolved : null; await _cleanupPendingResolvedPaths(); // Plain file paths were already published before loading. Re-publishing // them with an identical resolved path made Now Playing clear and probe // 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.toMediaItem(resolvedSource: resolved)); } _broadcastPosition(effectiveStartPosition, force: true); _broadcastState(playerState: PlayerState.playing); _lastPeriodicPersistAt = DateTime.now(); unawaited(_persistSession(position: effectiveStartPosition)); // Some files do not emit onDurationChanged reliably (stuck at 0:00); // poll the engine for the real duration as a fallback. unawaited(_ensureDurationKnown(index, generation)); } catch (e) { if (!_isCurrentPlayRequest(generation, media)) return; _sourceReady = false; _log.e('Playback failed for ${media.title}: $e'); _broadcastState(playerState: PlayerState.stopped); } finally { if (playbackLease != null) { await PlatformBridge.closeContentUriPlaybackLease(playbackLease.token); } if (_switchingGeneration == generation) { _switchingGeneration = 0; } } } /// Resolves the real track duration when the initial metadata had none and /// the duration-changed event did not fire, so the seek bar and total time /// do not get stuck at 0:00. Future _ensureDurationKnown(int index, int generation) async { for (var attempt = 0; attempt < 15; attempt++) { if (_index != index || generation != _playRequestGeneration) return; final current = mediaItem.value; final existing = current?.duration; if (existing != null && existing > Duration.zero) return; try { final d = await _player.getDuration(); if (_index != index || generation != _playRequestGeneration) return; if (d != null && d > Duration.zero) { final item = mediaItem.value; if (item != null) { mediaItem.add(item.copyWith(duration: d)); } return; } } catch (_) { // Duration probing is best-effort; retry until the bounded loop ends. } await Future.delayed(const Duration(milliseconds: 300)); } } int _pickNextShuffle() { if (_media.length <= 1) return _index; final pool = buildShuffleCandidatePool( mediaCount: _media.length, currentIndex: _index, recentIndices: _recent, ); return pool[_random.nextInt(pool.length)]; } Future _onComplete() async { if (_repeatMode == AudioServiceRepeatMode.one && _index >= 0 && _index < _media.length) { await _playIndex(_index, recordHistory: false); return; } if (_shuffle) { if (_media.length > 1) { await _playIndex(_pickNextShuffle()); } else if (_repeatMode == AudioServiceRepeatMode.all && _media.isNotEmpty) { await _playIndex(_index, recordHistory: false); } else { _broadcastState(playerState: PlayerState.completed); } return; } if (_index >= 0 && _index < _media.length - 1) { await _playIndex(_index + 1); } else if (_repeatMode == AudioServiceRepeatMode.all && _media.isNotEmpty) { await _playIndex(0); } else { _broadcastState(playerState: PlayerState.completed); } } Future _handlePlayerComplete() async { if (_shouldIgnoreComplete) { if (_userPaused || _interruptionActive) { _broadcastState(playerState: PlayerState.paused); } return; } await _onComplete(); } @override Future play() async { final activeOperation = _activePlayOperation; if (activeOperation != null) { await activeOperation; return; } final operation = _playInternal(); _activePlayOperation = operation; try { await operation; } finally { if (identical(_activePlayOperation, operation)) { _activePlayOperation = null; } } } Future _playInternal() async { _pausedByInterruption = false; _interruptionActive = false; _userPaused = false; if ((!_sourceReady || _player.state == PlayerState.stopped || _player.state == PlayerState.completed) && _index >= 0 && _index < _media.length) { await _playIndex( _index, recordHistory: false, startPosition: _pendingRestorePosition ?? Duration.zero, ); return; } await _activateAudioSession(); await _claimHardwareMediaButtons(); await _player.resume(); _broadcastState(playerState: PlayerState.playing); } @override Future click([MediaButton button = MediaButton.media]) async { switch (button) { case MediaButton.media: if (playbackState.value.playing) { await pause(); } else { await play(); } break; case MediaButton.next: await skipToNext(); break; case MediaButton.previous: await skipToPrevious(); break; } } @override Future pause() async { _playRequestGeneration++; _switchingGeneration = 0; _userPaused = true; _pausedByInterruption = false; await _player.pause(); _broadcastState(playerState: PlayerState.paused); await _persistSession(position: await _currentPositionForPersist()); } @override Future seek(Duration position) async { await _player.seek(position); _broadcastPosition(position, force: true); } @override Future setShuffleMode(AudioServiceShuffleMode shuffleMode) async { _shuffle = shuffleMode == AudioServiceShuffleMode.all; _broadcastState(); if (_media.isNotEmpty && _index >= 0) { unawaited(_persistSession(position: playbackState.value.position)); } } @override Future setRepeatMode(AudioServiceRepeatMode repeatMode) async { // Group repeat has no meaning for a flat queue; treat it as all. _repeatMode = repeatMode == AudioServiceRepeatMode.group ? AudioServiceRepeatMode.all : repeatMode; _broadcastState(); if (_media.isNotEmpty && _index >= 0) { unawaited(_persistSession(position: playbackState.value.position)); } } @override Future stop() async { cancelSleepTimer(); _playRequestGeneration++; _switchingGeneration = 0; _userPaused = true; await _player.stop(); _sourceReady = false; _activeResolvedPath = null; await _cleanupPendingResolvedPaths(); _index = -1; _pausedByInterruption = false; _interruptionActive = false; _userPaused = false; _recent.clear(); _playHistory.clear(); _pendingRestorePosition = null; _scheduledSessionQueueRevision = -1; _persistedSessionQueueRevision = -1; // An explicit stop ends the session for good; nothing to restore later. await _enqueueSessionWrite(AppStateDatabase.instance.clearPlaybackSession); // A stopped session has no current item; this also hides the mini player. mediaItem.add(null); _broadcastState(playerState: PlayerState.stopped); await super.stop(); } @override Future skipToNext() async { if (_shuffle) { if (_media.length > 1) await _playIndex(_pickNextShuffle()); return; } if (_index < _media.length - 1) await _playIndex(_index + 1); } @override Future skipToPrevious() async { if (playbackState.value.position > const Duration(seconds: 3)) { await _player.seek(Duration.zero); _broadcastPosition(Duration.zero, force: true); return; } if (_shuffle) { if (_playHistory.length >= 2) { _playHistory.removeLast(); final prev = _playHistory.last; await _playIndex(prev, recordHistory: false); } else { await _player.seek(Duration.zero); _broadcastPosition(Duration.zero, force: true); } return; } if (_index > 0) await _playIndex(_index - 1); } @override Future skipToQueueItem(int index) => _playIndex(index); @override Future> getChildren( String parentMediaId, [ Map? options, ]) async { if (parentMediaId == AudioService.browsableRootId || parentMediaId == AudioService.recentRootId) { return List.unmodifiable(_queueItems); } return const []; } @override Future getMediaItem(String mediaId) async { final index = _media.indexWhere((m) => m.id == mediaId); if (index < 0) return null; return _queueItems[index]; } @override Future playFromMediaId( String mediaId, [ Map? extras, ]) async { final index = _media.indexWhere((m) => m.id == mediaId); if (index >= 0) await _playIndex(index); } @override Future playMediaItem(MediaItem mediaItem) => playFromMediaId(mediaItem.id); /// Called when a file is deleted from disk. Removes it from the queue and, if /// it is the track currently playing, stops or advances so a deleted song can /// no longer be played. Future onSourceDeleted(String source) async { final target = source.trim(); if (target.isEmpty || _media.isEmpty) return; final discardedPath = _resolvedPathCache.remove(target); _resolvedPathOrder.remove(target); if (discardedPath != null) { await _discardResolvedPath(discardedPath); } final wasCurrent = _index >= 0 && _index < _media.length && _media[_index].source == target; var removedBeforeCurrent = 0; final kept = []; for (var i = 0; i < _media.length; i++) { if (_media[i].source == target) { if (i < _index) removedBeforeCurrent++; continue; } kept.add(_media[i]); } if (kept.length == _media.length) return; _media ..clear() ..addAll(kept); _queueItems ..clear() ..addAll(kept.map((m) => m.toMediaItem())); _markSessionQueueChanged(); _recent.clear(); _playHistory.clear(); queue.add(List.unmodifiable(_queueItems)); if (_media.isEmpty) { await stop(); return; } if (wasCurrent) { final nextIndex = _index.clamp(0, _media.length - 1); await _playIndex(nextIndex); } else { _index = (_index - removedBeforeCurrent).clamp(0, _media.length - 1); _broadcastState(); unawaited(_persistSession(position: playbackState.value.position)); } } Future dispose() async { _disposed = true; cancelSleepTimer(); _playRequestGeneration++; for (final sub in _subscriptions) { await sub.cancel(); } _subscriptions.clear(); _sourceReady = false; await _player.dispose(); _activeResolvedPath = null; final tempPaths = { ..._resolvedPathCache.values, ..._pendingResolvedPathDeletes, }; _resolvedPathCache.clear(); _resolvedPathSizes.clear(); _resolvedPathOrder.clear(); _pendingResolvedPathDeletes.clear(); for (final path in tempPaths) { await _discardResolvedPath(path); } } } MusicPlayerHandler? _handler; Future? _initFuture; final StreamController _handlerReadyController = StreamController.broadcast(); MusicPlayerHandler? get musicPlayerHandler => _handler; Future Function()? musicPlayerExclusiveAudioHook; /// Flushes the current playback position if the player has been initialized. Future persistCurrentPlaybackSession() async { final handler = _handler; if (handler == null) return; await handler.persistCurrentSession(); } Future initMusicPlayer() async { if (_handler != null) return _handler!; final existingFuture = _initFuture; if (existingFuture != null) return existingFuture; final future = _doInitMusicPlayer(); _initFuture = future; return future; } Future _doInitMusicPlayer() async { try { final handler = await AudioService.init( builder: () => MusicPlayerHandler(), config: const AudioServiceConfig( androidNotificationChannelId: 'com.zarz.spotiflac.playback', androidNotificationChannelName: 'Playback', androidNotificationOngoing: true, androidStopForegroundOnPause: true, ), ); _handler = handler; _handlerReadyController.add(handler); return handler; } catch (_) { _initFuture = null; rethrow; } } /// 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 restorePersistedPlaybackSession() async { try { final session = await AppStateDatabase.instance.getPlaybackSession(); if (session == null) return; final rawMedia = session['media']; if (rawMedia is! List) { await AppStateDatabase.instance.clearPlaybackSession(); return; } final items = []; final keptOriginalIndices = []; for (var i = 0; i < rawMedia.length; i++) { final entry = rawMedia[i]; if (entry is! Map) continue; final media = PlayableMedia.fromJson(Map.from(entry)); if (media == null) continue; if (!media.isContentUri && !await File(media.source).exists()) { continue; } items.add(media); keptOriginalIndices.add(i); } if (items.isEmpty) { await AppStateDatabase.instance.clearPlaybackSession(); return; } // Remap the saved index onto the surviving list; if the current track // itself was dropped, land on the nearest earlier survivor at 0:00. final savedIndex = (session['index'] as num?)?.toInt() ?? 0; var index = 0; for (var i = 0; i < keptOriginalIndices.length; i++) { if (keptOriginalIndices[i] <= savedIndex) index = i; } var position = Duration( milliseconds: (session['positionMs'] as num?)?.toInt() ?? 0, ); if (keptOriginalIndices[index] != savedIndex) { position = Duration.zero; } final handler = await initMusicPlayer(); await handler.restoreSession( items: items, index: index, position: position, shuffle: session['shuffle'] == true, queueNeedsRewrite: items.length != rawMedia.length, repeatMode: AudioServiceRepeatMode.values.firstWhere( (mode) => mode.name == session['repeat'], orElse: () => AudioServiceRepeatMode.none, ), ); _log.i( 'Restored playback session: ${items.length} track(s), paused at ' '${position.inSeconds}s', ); } catch (e) { _log.w('Failed to restore playback session: $e'); } } Stream musicPlayerMediaItemEvents() async* { final existing = _handler; if (existing != null) { yield existing.mediaItem.value; yield* existing.mediaItem; return; } yield null; await for (final handler in _handlerReadyController.stream) { yield handler.mediaItem.value; yield* handler.mediaItem; return; } } Stream musicPlayerPlaybackStateEvents() async* { final existing = _handler; if (existing != null) { yield existing.playbackState.value; yield* existing.playbackState; return; } await for (final handler in _handlerReadyController.stream) { yield handler.playbackState.value; yield* handler.playbackState; return; } } Stream> musicPlayerQueueEvents() async* { final existing = _handler; if (existing != null) { yield existing.queue.value; yield* existing.queue; return; } yield const []; await for (final handler in _handlerReadyController.stream) { yield handler.queue.value; yield* handler.queue; return; } }