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. Logging alone leaves the UI // claiming "now playing X" while audio is silent — the user- // observable mismatch between visual and audio state. Skip // forward so the queue advances past the failed track and // mediaItem updates to whatever's actually playing. If the // failure is at the queue tail, just_audio will go idle on its // own and _broadcastState reflects that. onError: (Object e, StackTrace st) { debugPrint('audio_handler: playbackEventStream error: $e\n$st'); unawaited(_handlePlaybackError()); }, ); _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; LikeBridge? _likeBridge; /// 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; /// Logical-queue index that just_audio player-index 0 currently maps /// to. setQueueFromTracks fast-starts with a SINGLE source at player /// index 0 while queue.value already holds the full list, so the /// playing track's logical index is clampedInitial, not 0. The /// transient currentIndexStream→0 emission that arrives just after /// setAudioSources resolves (and after _suppressIndexUpdates is back /// to false) would otherwise make _onCurrentIndexChanged broadcast /// queue.value[0] — the FIRST track — over the correct item, pinning /// the mini bar / playlist marker to the wrong track until a later /// index event. Decremented in lockstep with the backward fill's /// front inserts so player-index → logical-index stays correct and /// lands at 0 once the player list fully matches queue.value. int _logicalIndexBase = 0; /// Tracks the most recent setQueueFromTracks() input so /// skipToQueueItem can reconstruct the source list. just_audio /// requires every source to be built before it can be a skip /// target, but setQueueFromTracks only builds the initial source /// and fills the rest in the background — so a skip to an index /// past the fill front needs to rebuild from the stored tracks. List _lastTracks = const []; /// #415: which system playlist this queue was seeded from /// ('for_you' | 'discover'), or null for library / user-playlist / /// radio. The play-events reporter reads this so play_started /// carries `source` and the server advances that rotation. A fresh /// setQueueFromTracks from a non-system surface clears it; internal /// rebuilds (skipToQueueItem) preserve it. String? _queueSource; String? get queueSource => _queueSource; /// 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, LikeBridge? likeBridge, }) { _baseUrl = baseUrl; _token = token; if (coverCache != null) _coverCache = coverCache; if (likeBridge != null) _likeBridge = likeBridge; if (audioCacheManager != null) _audioCacheManager = audioCacheManager; } Future setQueueFromTracks( List tracks, { int initialIndex = 0, String? source, }) async { if (tracks.isEmpty) return; _queueSource = source; 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; _lastTracks = tracks; // Pause the old source immediately so the previous track stops // audibly the moment the user taps, instead of bleeding through // until the new source finishes building. setAudioSources below // will swap the source list cleanly; pause is the simplest way // to silence the player during the (possibly multi-100ms) build. if (_player.playing) { await _player.pause(); } // Build MediaItems up front (pure — no side effects); we'll // broadcast queue/mediaItem only AFTER setAudioSources resolves // so the audio engine and the UI flip together. If the build // throws, the UI stays on the previous track (correct: audio // also stays on the previous track since setAudioSources never // ran). final items = tracks.map(_toMediaItem).toList(); // Build only the initial source for fast start. Remaining // sources stream in via _fillRemainingSources() — addAudioSource // for next/auto-advance tracks, insertAudioSource for skipPrev. final AudioSource initial; try { initial = await _buildAudioSource(tracks[clampedInitial]); } catch (e, st) { // Source build failed (bad URL, missing baseUrl, etc.). Don't // broadcast the new state — leaving queue/mediaItem on the // previous track keeps UI in sync with what the player is // actually doing (which is "still on the previous track, // paused"). debugPrint('audio_handler: _buildAudioSource failed: $e\n$st'); return; } if (myGen != _queueGeneration) { debugPrint('audio_handler: superseded before setAudioSources (gen=$myGen)'); return; } // Suppress _onCurrentIndexChanged side effects while the source // list is being swapped — without this, a transient currentIndex // emission during setAudioSources could broadcast the OLD queue's // entry at the NEW index. Re-enabled after the broadcasts land. _suppressIndexUpdates = true; try { await _player.setAudioSources([initial], initialIndex: 0); // Player-index 0 holds the single fast-start source, which is // logical-queue index clampedInitial. Record the offset before // broadcasting so the post-resolve currentIndexStream→0 emission // maps back to the correct item instead of queue.value[0]. _logicalIndexBase = clampedInitial; // Broadcast in this order: queue first (so any consumer that // reacts to mediaItem and reads queue.value sees the consistent // pair), then mediaItem. queue.add(items); mediaItem.add(items[clampedInitial]); } finally { _suppressIndexUpdates = false; } unawaited(_loadArtForCurrentItem()); unawaited(_fillRemainingSources(tracks, clampedInitial, myGen)); } /// Switches playback to the [index]th queue item. The full /// just_audio source list isn't necessarily built yet /// (_fillRemainingSources runs in the background), so we /// reconstruct from the stored TrackRef list rather than calling /// _player.seek(index: ...) on a possibly-missing source. Calling /// setQueueFromTracks again is the safe path: it bumps the /// generation, cancels any in-flight fill, rebuilds source[0] as /// the target, and re-fills around. play() restarts playback so /// queue taps feel like "jump to this song" rather than "set the /// pointer and wait for me to press play." @override Future skipToQueueItem(int index) async { if (index < 0 || index >= _lastTracks.length) return; // Preserve the system-playlist source across an internal rebuild // — a queue-item skip is still playing from the same playlist. await setQueueFromTracks(_lastTracks, initialIndex: index, source: _queueSource); await play(); } /// 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); // Each front insert shifts the playing source's player index // up by one; drop the base in lockstep so player-index → // logical-index stays correct (and reaches 0 once the player // list fully matches queue.value). _logicalIndexBase -= 1; } } 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; // Sync-peek the album cover cache so warm-cache tracks broadcast // with artUri populated on the first frame. External media // controllers (Android Wear, Bluetooth dashes, Auto, lock screen) // can only render the cover bytes that audio_service hands them // at MediaItem broadcast time; the later async _loadArtForCurrent // Item path repopulates for cold-cache tracks. Without this seed, // every track change starts with a generic icon on the watch and // only gets the real cover after one or two seconds. final coverPath = (t.albumId.isNotEmpty && _coverCache != null) ? _coverCache!.peekCached(t.albumId) : null; // MediaItem.rating intentionally NOT set: audio_service propagates // it to MediaSession.setRating(), but the Android session also // needs setRatingType(RATING_HEART) configured to expose that to // controllers — audio_service doesn't surface that config knob, // and broadcasting an unanchored rating made Wear OS reject the // session entirely. The LikeBridge wiring stays in place so // setRating can still fire from any surface that DOES route it, // we just don't advertise it. return MediaItem( id: t.id, title: t.title, artist: t.artistName, album: t.albumTitle, duration: Duration(seconds: t.durationSec), artUri: coverPath != null ? Uri.file(coverPath) : null, 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); } /// Called when playbackEventStream emits an error. Skips past the /// failing track so the UI and audio re-converge — without this, /// _player goes silent on a 404 / decoder failure / EOS but /// mediaItem stays on the failed track and the user sees a "now /// playing" header for something that isn't. /// /// If we're at the last track, seekToNext is a no-op; the state /// drops to idle and _broadcastState reflects that. Future _handlePlaybackError() async { final currentIdx = _player.currentIndex; final queueLen = queue.value.length; if (currentIdx == null || currentIdx + 1 >= queueLen) { // Last track or no queue context — just pause; _broadcastState // already reflects the idle/error processingState. try { await _player.pause(); } catch (_) {} return; } try { await _player.seekToNext(); } catch (_) { // If seekToNext fails too (next source not built yet, etc.), // there's nothing safe to do — pause and let the user recover // manually. try { await _player.pause(); } catch (_) {} } } 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; // Map the just_audio player index back to the logical queue index. // During the fast-start/fill window the player list is a moving // window offset from queue.value by _logicalIndexBase; mapping the // raw player index straight in here is what previously broadcast // queue.value[0] (the first track) over the correct item on every // fast-start with initialIndex > 0. final logical = idx + _logicalIndexBase; if (logical >= 0 && logical < items.length) { mediaItem.add(items[logical]); } 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(); /// Heart rating from external surfaces (Wear's favorite button, /// lock-screen like) → LikesController.toggle(track). We only /// route through the bridge when the rating actually flips relative /// to the current state, so repeated taps from a flaky controller /// don't ping-pong the like. @override Future setRating(Rating rating, [Map? extras]) async { final media = mediaItem.value; final bridge = _likeBridge; if (media == null || bridge == null) return; final currentlyLiked = bridge.isTrackLiked(media.id); final wantLiked = rating.hasHeart(); if (currentlyLiked == wantLiked) return; try { await bridge.toggleTrackLike(media.id); } catch (_) { // LikesController already rolls back on REST failure; nothing // to do here beyond letting the broadcast skip. return; } // Re-emit so the watch's heart icon updates immediately. mediaItem.add(media.copyWith(rating: Rating.newHeartRating(wantLiked))); } /// Re-emits the current mediaItem with a fresh rating pulled from /// the LikeBridge. Called by PlayerActions on likedIdsProvider /// changes so the watch / lock-screen heart icon updates when the /// user toggles a like from TrackRow, the kebab menu, or another /// device's playback (SSE-routed). No-op if no track is playing /// or the like state didn't change. void refreshCurrentRating() { final media = mediaItem.value; final bridge = _likeBridge; if (media == null || bridge == null) return; final liked = bridge.isTrackLiked(media.id); if (media.rating?.hasHeart() == liked) return; mediaItem.add(media.copyWith(rating: Rating.newHeartRating(liked))); } @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+. // // v2026.05.13.3 added stop, skipToQueueItem, setShuffleMode, // setRepeatMode, and setRating to this set; reverted because // Pixel Watch 2 stopped showing controls entirely after that // change. The MediaSession contract on Wear OS requires more // setup than just advertising the action (e.g. setRatingType // for setRating) and audio_service doesn't expose those knobs. // Keep the override methods themselves (skipToQueueItem is // still routed via QueueScreen's direct handler call; // setRating is harmless if never invoked) so we don't lose // the underlying functionality — just don't tell the system // we support them. 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, }, )); } } /// Adapter that lets the audio handler call into the app's /// LikesController without depending on Riverpod directly. Constructed /// in PlayerActions where ref is available; consumed inside the audio /// handler's setRating override and MediaItem builder to keep external /// media controllers (Wear, lock screen, Auto) in sync with the /// likedIds drift cache. class LikeBridge { const LikeBridge({ required this.toggleTrackLike, required this.isTrackLiked, }); /// Flip the like state for [trackId]. Returns a Future that resolves /// once the underlying LikesController has both updated drift /// optimistically and rolled the change through the REST API /// (errors result in drift rollback inside LikesController). final Future Function(String trackId) toggleTrackLike; /// Read the current like state for [trackId] from the drift-backed /// likedIdsProvider. Synchronous because the audio handler needs /// to populate MediaItem.rating on the broadcast hot path. final bool Function(String trackId) isTrackLiked; }