import 'dart:async'; import 'dart:io'; import 'package:audio_service/audio_service.dart'; import 'package:flutter/foundation.dart'; import 'package:just_audio/just_audio.dart'; import 'package:path_provider/path_provider.dart'; import '../cache/audio_cache_manager.dart'; import '../models/track.dart'; import 'album_cover_cache.dart'; class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { MinstrelAudioHandler() { _player.playbackEventStream.listen( _broadcastState, // ExoPlayer surfaces stream errors (404, premature EOS, decoder // failure, network drop) here. Without an error sink, the // player just goes quiet — exactly the "starts then stops" // symptom we hit. onError: (Object e, StackTrace st) { debugPrint('audio_handler: playbackEventStream error: $e\n$st'); }, ); _player.currentIndexStream.listen(_onCurrentIndexChanged); // Re-broadcast on shuffle/repeat changes so the PlaybackState's // shuffleMode + repeatMode fields stay current for UI subscribers. _player.shuffleModeEnabledStream.listen((_) => _broadcastState(null)); _player.loopModeStream.listen((_) => _broadcastState(null)); // Watch buffered-position so we can register stream-cached files // in the audio cache index once they're fully downloaded. Without // this, files written by LockCachingAudioSource never appear in // the index and the eviction loop can't reclaim them. _player.bufferedPositionStream .listen((_) => unawaited(_maybeRegisterStreamCache())); } final AudioPlayer _player = AudioPlayer(); String _baseUrl = ''; String? _token; AlbumCoverCache? _coverCache; AudioCacheManager? _audioCacheManager; /// Trackers to dedupe registration — once we've inserted an index row /// for a trackId, don't repeat the work on every buffered-position /// emit. Cleared on dispose only; surviving across queue rebuilds is /// fine because the index is itself the source of truth. final Set _streamCacheRegistered = {}; /// Cached on first use so we don't hit the platform channel every /// time the buffered-position stream emits (~200ms cadence). String? _cacheDirPath; /// True while _fillRemainingSources is doing backward-fill inserts /// at index 0..initialIndex-1. Each insert shifts the player's /// currentIndex (it tracks the actively-playing source through /// list mutations), and the resulting _onCurrentIndexChanged /// callbacks would push the wrong MediaItem onto the stream /// (queue.value[shifted_idx] != actively-playing track). When /// the fill completes, currentIndex == initialIndex, mediaItem /// is already correct, and we re-enable normal listener behavior. bool _suppressIndexUpdates = false; /// Increments on every setQueueFromTracks call. The background /// fill task captures the value at start and bails if the live /// counter has moved on — without this, a stale fill will keep /// addAudioSource'ing tracks from the previous playlist into the /// new queue, leaving the player "locked" to a corrupted state. int _queueGeneration = 0; /// Volume stream for UI subscribers. Mirrors the just_audio player's /// volume directly; set via setVolume(double). Stream get volumeStream => _player.volumeStream; double get volume => _player.volume; /// Position stream for UI subscribers. just_audio emits roughly every /// 200ms while playing, which is what makes the seek bar advance /// smoothly. PlaybackState.updatePosition only changes on event /// transitions (play/pause/buffer), so it's too coarse for live /// scrubbing UI. Stream get positionStream => _player.positionStream; Duration get position => _player.position; void configure({ required String baseUrl, required String? token, AlbumCoverCache? coverCache, AudioCacheManager? audioCacheManager, }) { _baseUrl = baseUrl; _token = token; if (coverCache != null) _coverCache = coverCache; if (audioCacheManager != null) _audioCacheManager = audioCacheManager; } Future setQueueFromTracks(List tracks, {int initialIndex = 0}) async { if (tracks.isEmpty) return; final clampedInitial = initialIndex.clamp(0, tracks.length - 1); // Bump the generation FIRST. Any in-flight _fillRemainingSources // from a previous play will see the mismatch on its next gen // check and stop calling player mutations — important so a stale // fill doesn't append old-playlist tracks into the new queue. final myGen = ++_queueGeneration; // Reset suppress flag in case a prior backward-fill bailed on // gen check before reaching its `finally`. _suppressIndexUpdates = false; // Populate the visible queue + current mediaItem immediately so // the player UI reflects the user's tap before any source has // been built. Source list at the just_audio layer fills in // asynchronously below. final items = tracks.map(_toMediaItem).toList(); queue.add(items); mediaItem.add(items[clampedInitial]); // Fast path: build only the initial source so the player can // start. Remaining sources stream in via _fillRemainingSources() // in the background — addAudioSource for next/auto-advance // tracks, insertAudioSource for skipPrev tracks. final initial = await _buildAudioSource(tracks[clampedInitial]); if (myGen != _queueGeneration) { debugPrint('audio_handler: superseded before setAudioSources (gen=$myGen)'); return; } await _player.setAudioSources([initial], initialIndex: 0); unawaited(_loadArtForCurrentItem()); unawaited(_fillRemainingSources(tracks, clampedInitial, myGen)); } /// Background fill of the rest of the just_audio source list after /// the initial source is playing. Forward direction first (most /// common skipNext target). Backward inserts shift the player's /// currentIndex; we suppress _onCurrentIndexChanged side effects /// for those so the mediaItem stream doesn't bounce to the wrong /// queue entry. /// /// `gen` is the queue generation captured when this fill started. /// Every loop iteration checks against _queueGeneration; if a /// newer setQueueFromTracks has run (user tapped play on something /// else), bail immediately so we don't pollute the new queue with /// addAudioSource calls from this stale fill. Future _fillRemainingSources( List tracks, int initialIndex, int gen) async { for (var i = initialIndex + 1; i < tracks.length; i++) { if (gen != _queueGeneration) return; try { final src = await _buildAudioSource(tracks[i]); if (gen != _queueGeneration) return; await _player.addAudioSource(src); } catch (e) { debugPrint('audio_handler: forward fill failed for ${tracks[i].id}: $e'); } } if (initialIndex > 0) { _suppressIndexUpdates = true; try { for (var i = 0; i < initialIndex; i++) { if (gen != _queueGeneration) return; final src = await _buildAudioSource(tracks[i]); if (gen != _queueGeneration) return; await _player.insertAudioSource(i, src); } } catch (e) { debugPrint('audio_handler: backward fill failed: $e'); } finally { // Only release the flag if we're still the active gen — a // newer setQueueFromTracks already reset it for itself. if (gen == _queueGeneration) _suppressIndexUpdates = false; } } } String _resolveStreamUrl(TrackRef t) { if (t.streamUrl.isEmpty) { return '$_baseUrl/api/tracks/${t.id}/stream'; } final parsed = Uri.tryParse(t.streamUrl); if (parsed != null && parsed.hasScheme) { return t.streamUrl; } return '$_baseUrl${t.streamUrl}'; } /// Builds an AudioSource for a track. Cache-aware: /// 1. If the track is fully cached on disk, returns a file:// source. /// 2. Else returns a LockCachingAudioSource that streams + caches as /// it plays (subsequent plays will hit the cache). /// /// Without an audio cache manager configured (e.g. in older code paths /// that pre-date #357), falls back to a plain network AudioSource.uri. Future _buildAudioSource(TrackRef t) async { final headers = _token == null ? null : {'Authorization': 'Bearer $_token'}; final mgr = _audioCacheManager; // 1. Cache hit: play from disk, no headers needed. if (mgr != null) { final path = await mgr.pathFor(t.id); if (path != null) { return AudioSource.uri(Uri.file(path)); } } final url = _resolveStreamUrl(t); final parsed = Uri.parse(url); if (!parsed.hasScheme || parsed.host.isEmpty) { throw StateError( 'audio_handler: refused to play scheme-less URL "$url" ' '(baseUrl="$_baseUrl", track.streamUrl="${t.streamUrl}", ' 'track.id="${t.id}"). configure() must be called with a ' 'non-empty baseUrl before setQueueFromTracks().', ); } // 2. Cache miss WITH manager: stream + cache-as-you-play. Future // plays of this track will hit the cache. If the cache write // completes, we don't currently register an index row — that would // require a download-complete hook from just_audio that's not // exposed cleanly. Acceptable for v1: prefetcher / explicit // pin / Download buttons cover the index path; LockCaching handles // the network optimization. if (mgr != null) { _cacheDirPath ??= (await getApplicationCacheDirectory()).path; final cacheFile = File('${_cacheDirPath!}/audio_cache/${t.id}.mp3'); // ignore: experimental_member_use return LockCachingAudioSource(parsed, headers: headers, cacheFile: cacheFile); } // 3. No manager configured: plain network source (legacy path). return AudioSource.uri(parsed, headers: headers); } /// Inserts [track] right after the currently-playing item so it plays /// next. If nothing is playing, appends to the end. Future playNext(TrackRef track) async { final source = await _buildAudioSource(track); final item = _toMediaItem(track); final currentIdx = _player.currentIndex; final insertAt = currentIdx == null ? queue.value.length : currentIdx + 1; await _player.insertAudioSource(insertAt, source); final current = queue.value; queue.add([ ...current.sublist(0, insertAt), item, ...current.sublist(insertAt), ]); } /// Appends [track] to the end of the queue. Future enqueue(TrackRef track) async { final source = await _buildAudioSource(track); final item = _toMediaItem(track); await _player.addAudioSource(source); queue.add([...queue.value, item]); } MediaItem _toMediaItem(TrackRef t) { // Stash album_id + artist_id in extras so widgets reconstructing // a TrackRef from the MediaItem (player kebab → "Go to artist", // "Go to album") have the IDs they need to navigate. Earlier code // only carried album_id which left "Go to artist" pushing // /artists/ (empty id, route 404). final extras = {}; if (t.albumId.isNotEmpty) extras['album_id'] = t.albumId; if (t.artistId.isNotEmpty) extras['artist_id'] = t.artistId; return MediaItem( id: t.id, title: t.title, artist: t.artistName, album: t.albumTitle, duration: Duration(seconds: t.durationSec), extras: extras.isEmpty ? null : extras, ); } /// Once a track is fully buffered (LockCaching has written the whole /// file to disk), insert an audio_cache_index row so the file shows /// up to AudioCacheManager.evict() and clearAll(). No-op if the /// cache manager isn't configured, no current track, the file isn't /// fully buffered yet, the on-disk file is missing, or we already /// registered this trackId during this subscription. Future _maybeRegisterStreamCache() async { final mgr = _audioCacheManager; if (mgr == null) return; final current = mediaItem.value; if (current == null) return; final trackId = current.id; if (_streamCacheRegistered.contains(trackId)) return; final dur = _player.duration; if (dur == null) return; final buf = _player.bufferedPosition; // 200ms slack for header bytes / encoding rounding. if (buf < dur - const Duration(milliseconds: 200)) return; _cacheDirPath ??= (await getApplicationCacheDirectory()).path; final path = '${_cacheDirPath!}/audio_cache/$trackId.mp3'; final file = File(path); if (!await file.exists()) return; final size = await file.length(); if (size <= 0) return; _streamCacheRegistered.add(trackId); await mgr.registerStreamCache(trackId, path, size); } void _onCurrentIndexChanged(int? idx) { if (idx == null) return; if (_suppressIndexUpdates) return; // Push the new track's MediaItem onto the mediaItem stream so // the player UI rebuilds with the new title/artist/album/cover. // Without this, the bar and full player stayed pinned to whichever // track was passed via setQueueFromTracks(initialIndex:) regardless // of skip/auto-advance. final items = queue.value; if (idx >= 0 && idx < items.length) { mediaItem.add(items[idx]); } unawaited(_loadArtForCurrentItem()); } /// Async-fetches the cover for whichever item is currently active and /// pushes a MediaItem update with artUri set. No-op if no cache is /// configured, no current item, the item has no album_id in extras, /// or the fetch returns null. Future _loadArtForCurrentItem() async { final cache = _coverCache; if (cache == null) return; final current = mediaItem.value; if (current == null) return; final albumId = current.extras?['album_id'] as String?; if (albumId == null || albumId.isEmpty) return; if (current.artUri != null) return; // already set final path = await cache.getOrFetch(albumId); if (path == null) return; // Discard if the user advanced to another track while we waited. if (mediaItem.value?.id != current.id) return; mediaItem.add(current.copyWith(artUri: Uri.file(path))); } @override Future play() => _player.play(); @override Future pause() => _player.pause(); @override Future seek(Duration position) => _player.seek(position); @override Future skipToNext() => _player.seekToNext(); @override Future skipToPrevious() => _player.seekToPrevious(); @override Future setShuffleMode(AudioServiceShuffleMode shuffleMode) async { await _player .setShuffleModeEnabled(shuffleMode != AudioServiceShuffleMode.none); // _broadcastState picks up the change via shuffleModeEnabledStream. } @override Future setRepeatMode(AudioServiceRepeatMode repeatMode) async { final loop = switch (repeatMode) { AudioServiceRepeatMode.none => LoopMode.off, AudioServiceRepeatMode.one => LoopMode.one, AudioServiceRepeatMode.all || AudioServiceRepeatMode.group => LoopMode.all, }; await _player.setLoopMode(loop); } /// Sets player volume in [0.0, 1.0]. Note: most mobile browsers tie /// page audio to system volume — this is the in-app slider for parity /// with desktop/web; on mobile the system volume is the real control. Future setVolume(double v) async { await _player.setVolume(v.clamp(0.0, 1.0)); } // _broadcastState accepts a nullable event because the shuffle/repeat // listeners don't have one — we just want to re-emit PlaybackState // with up-to-date shuffleMode/repeatMode fields. void _broadcastState(PlaybackEvent? event) { final playing = _player.playing; playbackState.add(PlaybackState( controls: [ MediaControl.skipToPrevious, if (playing) MediaControl.pause else MediaControl.play, MediaControl.skipToNext, ], // androidCompactActionIndices tells the system which controls // appear in the collapsed/lock-screen view. Without this, some // Android versions render the player without working buttons. androidCompactActionIndices: const [0, 1, 2], // systemActions enumerates which actions the system can invoke // on us — without play/pause/skip in here, taps on lock-screen // controls don't route back to the handler on Android 13+. systemActions: const { MediaAction.play, MediaAction.pause, MediaAction.skipToNext, MediaAction.skipToPrevious, MediaAction.seek, }, processingState: switch (_player.processingState) { ProcessingState.idle => AudioProcessingState.idle, ProcessingState.loading => AudioProcessingState.loading, ProcessingState.buffering => AudioProcessingState.buffering, ProcessingState.ready => AudioProcessingState.ready, ProcessingState.completed => AudioProcessingState.completed, }, playing: playing, updatePosition: _player.position, bufferedPosition: _player.bufferedPosition, speed: _player.speed, queueIndex: event?.currentIndex ?? _player.currentIndex, shuffleMode: _player.shuffleModeEnabled ? AudioServiceShuffleMode.all : AudioServiceShuffleMode.none, repeatMode: switch (_player.loopMode) { LoopMode.off => AudioServiceRepeatMode.none, LoopMode.one => AudioServiceRepeatMode.one, LoopMode.all => AudioServiceRepeatMode.all, }, )); } }