import 'dart:async'; import 'dart:io'; import 'package:audio_service/audio_service.dart'; import 'package:audio_session/audio_session.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())); unawaited(_configureAudioSession()); } 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; /// How long the session may sit not-actively-playing (paused, or a /// finished queue) before we tear it down so the Wear tile / lock /// screen / notification don't linger on a stale track. The /// notification is configured ongoing (main.dart), so nothing else /// ever drives the MediaSession to a terminal state. static const _idleStopTimeout = Duration(minutes: 5); /// Single-shot cleanup timer, armed while not actively playing and /// cancelled the moment playback resumes or a new queue is set. Timer? _idleStopTimer; /// Periodic PlaybackState re-broadcast while actively playing so /// external surfaces (lock screen, Wear, Android Auto) interpolate the /// scrubber smoothly. The in-app bar uses positionStream and doesn't /// need this. Active only while playing; cancelled otherwise. Timer? _positionBroadcastTimer; /// Buffering-stall watchdog. On poor coverage a stream hangs in /// ProcessingState.buffering with NO error event, so the onError /// recovery never fires. Armed while playing+buffering; on fire, if /// buffered position hasn't advanced it's a dead stream → recover. /// Re-arms a fresh window while buffering-but-progressing (slow-but- /// downloading is not a stall). static const _stallTimeout = Duration(seconds: 15); Timer? _stallTimer; Duration _stallMarkBuffered = Duration.zero; /// Per-track recovery budget. Reset when a track reaches /// ready+playing. One retry of the same source (rebuilt fresh), then /// skip to the next cached track (or pause) instead of thrashing /// through unreachable streams. static const _maxRecoverRetries = 1; String? _recoveringTrackId; int _recoverAttempts = 0; /// Player volume captured when an OS duck interruption begins, /// restored when it ends. Null when not currently ducked. double? _volumeBeforeDuck; /// True when an interruption (call / other media) paused playback that /// was actively going, so a transient (pause-type) interruption end /// auto-resumes. Cleared on resume or a non-resumable end. bool _interruptedWhilePlaying = false; /// 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; /// The full TrackRef list backing the current queue (even when only /// part is built as just_audio sources). Empty when nothing is /// queued. Exposed for resume-state persistence (#54). List get queuedTracks => _lastTracks; /// 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; /// Broadcasts the title of a track that just failed to play (404, /// decoder failure, premature EOS, network drop) and was auto-skipped /// or paused by _handlePlaybackError. The app listens and surfaces a /// debounced/coalesced SnackBar so a silent skip isn't mysterious. /// App-lifetime singleton handler — intentionally never closed. final _playbackErrors = StreamController.broadcast(); Stream get playbackErrorStream => _playbackErrors.stream; 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; } /// Invoked by play() when nothing is loaded (mediaItem == null) so a /// media-button press after the #52 teardown resumes the last /// persisted session (#448). Registered by ResumeController.start(); /// null until then (then it's a no-op fall-through to _player.play()). Future Function()? _resumeHook; void setResumeHook(Future Function() hook) => _resumeHook = hook; Future setQueueFromTracks( List tracks, { int initialIndex = 0, String? source, }) async { if (tracks.isEmpty) return; // New playback supersedes any pending idle cleanup. play() below // re-broadcasts and _reconcileIdleTimer would cancel anyway; doing // it up front avoids a race during the pause→swap window. _idleStopTimer?.cancel(); _idleStopTimer = null; _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. /// onError entrypoint (404 / decoder / premature EOS / network drop). /// Routes into the unified recovery path (retry the track once, then /// skip to the next cached track or pause) instead of immediately /// skipping the literal next — possibly also-unreachable — source. Future _handlePlaybackError() async { await _recoverPlayback(); } 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))); } /// Display-state-only teardown. Stops playback, clears the queue and /// current track, and broadcasts idle so external surfaces (Wear tile, /// notification, in-app mini bar) drop their visible state — but does /// NOT call super.stop(), so the FGS and MediaSessionCompat remain /// alive and addressable. /// /// Why this matters (Fable #472): the paired Wear OS companion app /// caches a MediaController bound to our MediaSession's token. If /// the system destroys our service (super.stop() → stopSelf() makes /// it eligible under memory pressure), the next play() spins up a /// new MediaSession with a fresh token; the companion's cached /// controller still points at the dead one and transport taps from /// notification + watch silently no-op. Keeping the service alive /// across idle periods preserves the binding. /// /// Used by the #52 idle timeout. Full termination (super.stop) is /// reserved for the explicit-close path (onTaskRemoved while idle). Future _softTeardown() async { _idleStopTimer?.cancel(); _idleStopTimer = null; _positionBroadcastTimer?.cancel(); _positionBroadcastTimer = null; _stallTimer?.cancel(); _stallTimer = null; try { await _player.stop(); } catch (_) {} playbackState.add(playbackState.value.copyWith( playing: false, processingState: AudioProcessingState.idle, )); mediaItem.add(null); queue.add([]); } /// Full termination — soft teardown plus super.stop(), which calls /// stopSelf() on the AudioService and lets the OS reclaim it. Reserved /// for the explicit-close path (onTaskRemoved when not playing). On the /// idle path we use _softTeardown instead to preserve the Wear OS /// MediaController binding (Fable #472). @override Future stop() async { await _softTeardown(); await super.stop(); } /// App swiped away from recents. Keep playing if audio is active /// (standard media behaviour — music shouldn't die because the app /// left recents); otherwise stop so the watch tile / notification /// don't linger on a stale paused track. @override Future onTaskRemoved() async { if (_player.playing && _player.processingState != ProcessingState.completed) { await super.onTaskRemoved(); return; } await stop(); } @override Future play() async { // Nothing loaded (fresh, or torn down by the #52 idle/dismiss // teardown): a media-button press resumes the last persisted // session instead of no-op'ing (#448). The hook restores the queue // and starts playback itself, so we return without calling // _player.play() on an empty player. if (mediaItem.value == null && _resumeHook != null) { await _resumeHook!(); return; } await _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))); } /// Re-broadcasts PlaybackState so the notification favorite control's /// icon/label reflects a like toggled from elsewhere (TrackRow, kebab, /// another device via SSE). Sibling to refreshCurrentRating, which /// updates the Wear/lock heart via MediaItem.rating. void refreshFavoriteControl() => _broadcastState(null); @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)); } /// Handles the notification favorite control (and any future custom /// actions). Toggles the current track's like via the LikeBridge, /// then re-broadcasts so the heart icon/label flips. Signature /// matches `AudioHandler.customAction` (`Future`). @override Future customAction(String name, [Map? extras]) async { if (name == 'minstrel.favorite') { final media = mediaItem.value; final bridge = _likeBridge; if (media == null || bridge == null) return null; try { await bridge.toggleTrackLike(media.id); } catch (_) {} _broadcastState(null); return null; } return super.customAction(name, extras); } // _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; // No custom favorite MediaControl here. audio_service builds a // PlaybackStateCompat.CustomAction for it and throws // "You must specify an icon resource id to build a CustomAction" // on real builds (the androidIcon doesn't resolve to a usable id), // and that exception aborts the ENTIRE media notification — no // tray or Wear controls at all. Removed; like/favorite remains // available in-app and via the standard lock-screen surface. 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, }, )); _reconcileIdleTimer(); _reconcilePositionBroadcast(); _reconcileStallWatchdog(); } /// Arms (or cancels) the idle-cleanup timer based on the current /// player state. Driven from _broadcastState, which fires on every /// play/pause/buffer/complete transition, so paused AND finished-queue /// both arm it. Idle (already stopped) never re-arms — otherwise stop() /// would loop every _idleStopTimeout. void _reconcileIdleTimer() { final ps = _player.processingState; if (ps == ProcessingState.idle) { _idleStopTimer?.cancel(); _idleStopTimer = null; return; } final activelyPlaying = _player.playing && ps != ProcessingState.completed; if (activelyPlaying) { _idleStopTimer?.cancel(); _idleStopTimer = null; return; } _idleStopTimer ??= Timer(_idleStopTimeout, _onIdleTimeout); } void _onIdleTimeout() { _idleStopTimer = null; final ps = _player.processingState; if (ps == ProcessingState.idle) return; // already torn down if (_player.playing && ps != ProcessingState.completed) { return; // resumed between arm and fire } // _softTeardown — not full stop() — to preserve the MediaSession // binding for the paired Wear OS companion (Fable #472). unawaited(_softTeardown()); } /// While actively playing, keeps a 1s periodic PlaybackState re- /// broadcast running so updateTime/updatePosition stay fresh and /// external surfaces interpolate the scrubber smoothly. Idempotent and /// driven from _broadcastState — which the periodic tick itself calls, /// so the "already running" guard prevents pile-up. Cancels the moment /// playback is no longer active. void _reconcilePositionBroadcast() { final ps = _player.processingState; final active = _player.playing && ps != ProcessingState.idle && ps != ProcessingState.completed; if (!active) { _positionBroadcastTimer?.cancel(); _positionBroadcastTimer = null; return; } _positionBroadcastTimer ??= Timer.periodic( const Duration(seconds: 1), (_) => _broadcastState(null), ); } /// Buffering-stall watchdog. Armed while playing+buffering/loading. /// On fire: still stuck with no >=1s buffered progress → dead stream, /// recover; progressing → re-arm a fresh window (slow-but-downloading /// is fine). When the track is happily ready+playing, clears the /// per-track recovery budget. Driven from _broadcastState; mirrors /// the other reconcile helpers. void _reconcileStallWatchdog() { final ps = _player.processingState; if (ps == ProcessingState.ready && _player.playing) { _recoveringTrackId = null; _recoverAttempts = 0; } final buffering = _player.playing && (ps == ProcessingState.buffering || ps == ProcessingState.loading); if (!buffering) { _stallTimer?.cancel(); _stallTimer = null; return; } if (_stallTimer != null) return; // window already running _stallMarkBuffered = _player.bufferedPosition; _stallTimer = Timer(_stallTimeout, () { _stallTimer = null; final ps2 = _player.processingState; final stillBuffering = _player.playing && (ps2 == ProcessingState.buffering || ps2 == ProcessingState.loading); if (!stillBuffering) return; final progressed = (_player.bufferedPosition - _stallMarkBuffered) >= const Duration(seconds: 1); if (progressed) { _reconcileStallWatchdog(); // slow but downloading — re-arm } else { unawaited(_recoverPlayback()); } }); } /// Unified recovery for a failed OR stalled stream. Retries the /// current track once (rebuilt from scratch via skipToQueueItem so a /// transient blip doesn't lose it); on exhaustion, surfaces the /// failure (#58 SnackBar) and skips to the next cached track — or /// pauses — instead of blindly walking into more unreachable streams. Future _recoverPlayback() async { _stallTimer?.cancel(); _stallTimer = null; final media = mediaItem.value; if (media == null) return; final trackId = media.id; final curIdx = _lastTracks.indexWhere((t) => t.id == trackId); if (curIdx < 0) { try { await _player.pause(); } catch (_) {} return; } if (_recoveringTrackId != trackId) { _recoveringTrackId = trackId; _recoverAttempts = 0; } if (_recoverAttempts < _maxRecoverRetries) { _recoverAttempts++; // Rebuild + restart the SAME track (fresh source / HTTP). await skipToQueueItem(curIdx); return; } if (media.title.isNotEmpty) _playbackErrors.add(media.title); _recoveringTrackId = null; _recoverAttempts = 0; await _skipToNextCachedOrPause(curIdx); } /// Skips to the first track after [fromIdx] that is fully cached on /// disk (plays with zero network). If none, pauses rather than /// thrashing through unreachable streams on a dead connection. Future _skipToNextCachedOrPause(int fromIdx) async { final mgr = _audioCacheManager; if (mgr != null) { for (var i = fromIdx + 1; i < _lastTracks.length; i++) { if (await mgr.pathFor(_lastTracks[i].id) != null) { await skipToQueueItem(i); return; } } } try { await _player.pause(); } catch (_) {} } /// Configures the OS audio session for music playback and wires /// interruption + becoming-noisy handling. just_audio auto-activates / /// deactivates the session around playback once it's configured, but /// does NOT handle interruptions or the headphones-unplugged event /// itself — that's done here. Best effort: a failure must not break /// playback. Future _configureAudioSession() async { try { final session = await AudioSession.instance; await session.configure(const AudioSessionConfiguration.music()); session.interruptionEventStream.listen(_onInterruption); session.becomingNoisyEventStream.listen((_) { // Headphones unplugged / Bluetooth disconnected — never blast the // phone speaker; pause like every other media app. unawaited(_player.pause()); }); } catch (e, st) { debugPrint('audio_handler: audio session configure failed: $e\n$st'); } } void _onInterruption(AudioInterruptionEvent event) { if (event.begin) { switch (event.type) { case AudioInterruptionType.duck: // Transient: another app wants the foreground briefly. Lower // our volume rather than stopping (OS may also auto-duck). _volumeBeforeDuck = _player.volume; unawaited(_player.setVolume(0.3)); case AudioInterruptionType.pause: case AudioInterruptionType.unknown: // Call / other media took focus. Remember whether we were // actively playing so a transient end can resume us. _interruptedWhilePlaying = _player.playing && _player.processingState != ProcessingState.completed; unawaited(_player.pause()); } } else { switch (event.type) { case AudioInterruptionType.duck: unawaited(_player.setVolume(_volumeBeforeDuck ?? 1.0)); _volumeBeforeDuck = null; case AudioInterruptionType.pause: // Transient interruption ended — resume only if WE paused it // and the session is still alive (a long call may have let the // #52 idle timer tear it down; re-init is resume-last-session // territory, out of scope here). if (_interruptedWhilePlaying && _player.processingState != ProcessingState.idle) { unawaited(_player.play()); } _interruptedWhilePlaying = false; case AudioInterruptionType.unknown: // Unknown end — do not auto-resume (could surprise the user). _interruptedWhilePlaying = false; } } } } /// 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; }