Files
minstrel/flutter_client/lib/player/audio_handler.dart
T
bvandeusen e68e1b10a6 fix(player+lidarr): mini-player sync race; durable approve + dedup
player: setQueueFromTracks fast-starts a single source at player-index 0
while the full queue is broadcast, so the transient currentIndexStream→0
emission clobbered the correct mediaItem with queue.value[0] (the first
track). Mini bar / playlist marker pinned to the wrong track until a
later index event (~the "passive ~30s recovery"). Track a logical-index
base so the player→queue mapping stays correct during the fill window;
also fixes the latent forward-fill auto-advance off-by-base.

lidarr #50: approving no longer fails when Lidarr is down. Approve
records the decision durably first, then best-effort adds; the
reconciler idempotently (re)sends unconfirmed adds every tick until they
stick (new additive lidarr_add_confirmed_at; AddArtist/AddAlbum map
Lidarr's "already exists" 400 → ErrAlreadyExists). No failed-state or
expiry by design — Lidarr keeps trying, operator monitors.

lidarr #51: Create() is now idempotent — a non-terminal request for the
same MBID returns the existing row instead of inserting a duplicate.

Rewrites the obsolete LidarrUnreachable_503 test to assert the durable-
approve contract; threads a client factory into NewReconciler.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 12:24:20 -04:00

668 lines
29 KiB
Dart

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<String> _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<TrackRef> _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<double> 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<Duration> 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<void> setQueueFromTracks(
List<TrackRef> 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<void> 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<void> _fillRemainingSources(
List<TrackRef> 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<AudioSource> _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<void> 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<void> 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 = <String, dynamic>{};
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<void> _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<void> _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<void> _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<void> play() => _player.play();
@override
Future<void> pause() => _player.pause();
@override
Future<void> seek(Duration position) => _player.seek(position);
@override
Future<void> skipToNext() => _player.seekToNext();
@override
Future<void> 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<void> setRating(Rating rating, [Map<String, dynamic>? 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<void> setShuffleMode(AudioServiceShuffleMode shuffleMode) async {
await _player
.setShuffleModeEnabled(shuffleMode != AudioServiceShuffleMode.none);
// _broadcastState picks up the change via shuffleModeEnabledStream.
}
@override
Future<void> 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<void> 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<void> 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;
}