d1e276204e
External media controllers (Android Wear, Auto, Bluetooth dashes, lock-screen widgets) consume the audio_service MediaSession and silently no-op on any action that isn't in the handler's systemActions set. Several handler methods were already implemented but never advertised, plus stop() defaulted to a no-op — which matched user reports of "media controller on the watch sometimes works but doesn't play nice with Minstrel." This patch lines the advertised surface up with what the handler actually implements + wires a native heart-rating button. **Expanded controls + systemActions:** - Added MediaControl.stop to the expanded controls list. - systemActions now also enumerates stop, skipToQueueItem (override shipped in v2026.05.13.1), setShuffleMode, setRepeatMode, and setRating. Without these in the set, Android 13+ drops the corresponding callbacks from external surfaces. **stop() override:** halts _player and dismisses the foreground notification via super.stop(). Default just flipped processingState to idle without releasing the audio session — external surfaces treated that as "paused forever". **setRating wiring (native heart-button protocol):** new LikeBridge adapter passes through configure() carrying toggleTrackLike + isTrackLiked closures over LikesController and likedIdsProvider. - setRating override flips the like through the bridge and re-emits mediaItem so the watch's heart icon updates immediately. - _toMediaItem populates MediaItem.rating on every track change so the right filled/outlined heart shows on track-A → track-B. - PlayerActions ref.listen on likedIdsProvider calls refreshCurrentRating so toggling a like from TrackRow / kebab / another device (SSE) also keeps the watch icon in sync. **artUri seed on first broadcast:** AlbumCoverCache.peekCached returns the file path synchronously when the cover is already on disk. _toMediaItem uses this so warm-cache tracks broadcast with artUri populated from the first frame — external controllers see the cover immediately instead of waiting for the later async _loadArtForCurrentItem path. Cold-cache tracks fall through to that path unchanged.
576 lines
24 KiB
Dart
576 lines
24 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. Without an error sink, the
|
|
// player just goes quiet — exactly the "starts then stops"
|
|
// symptom we hit.
|
|
onError: (Object e, StackTrace st) {
|
|
debugPrint('audio_handler: playbackEventStream error: $e\n$st');
|
|
},
|
|
);
|
|
_player.currentIndexStream.listen(_onCurrentIndexChanged);
|
|
// Re-broadcast on shuffle/repeat changes so the PlaybackState's
|
|
// shuffleMode + repeatMode fields stay current for UI subscribers.
|
|
_player.shuffleModeEnabledStream.listen((_) => _broadcastState(null));
|
|
_player.loopModeStream.listen((_) => _broadcastState(null));
|
|
// Watch buffered-position so we can register stream-cached files
|
|
// in the audio cache index once they're fully downloaded. Without
|
|
// this, files written by LockCachingAudioSource never appear in
|
|
// the index and the eviction loop can't reclaim them.
|
|
_player.bufferedPositionStream
|
|
.listen((_) => unawaited(_maybeRegisterStreamCache()));
|
|
}
|
|
|
|
final AudioPlayer _player = AudioPlayer();
|
|
String _baseUrl = '';
|
|
String? _token;
|
|
AlbumCoverCache? _coverCache;
|
|
AudioCacheManager? _audioCacheManager;
|
|
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;
|
|
|
|
/// 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 [];
|
|
|
|
/// 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}) async {
|
|
if (tracks.isEmpty) return;
|
|
final clampedInitial = initialIndex.clamp(0, tracks.length - 1);
|
|
|
|
// Bump the generation FIRST. Any in-flight _fillRemainingSources
|
|
// from a previous play will see the mismatch on its next gen
|
|
// check and stop calling player mutations — important so a stale
|
|
// fill doesn't append old-playlist tracks into the new queue.
|
|
final myGen = ++_queueGeneration;
|
|
// Reset suppress flag in case a prior backward-fill bailed on
|
|
// gen check before reaching its `finally`.
|
|
_suppressIndexUpdates = false;
|
|
_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();
|
|
}
|
|
|
|
// Populate the visible queue + current mediaItem immediately so
|
|
// the player UI reflects the user's tap before any source has
|
|
// been built. Source list at the just_audio layer fills in
|
|
// asynchronously below.
|
|
final items = tracks.map(_toMediaItem).toList();
|
|
queue.add(items);
|
|
mediaItem.add(items[clampedInitial]);
|
|
|
|
// Fast path: build only the initial source so the player can
|
|
// start. Remaining sources stream in via _fillRemainingSources()
|
|
// in the background — addAudioSource for next/auto-advance
|
|
// tracks, insertAudioSource for skipPrev tracks.
|
|
final initial = await _buildAudioSource(tracks[clampedInitial]);
|
|
if (myGen != _queueGeneration) {
|
|
debugPrint('audio_handler: superseded before setAudioSources (gen=$myGen)');
|
|
return;
|
|
}
|
|
|
|
await _player.setAudioSources([initial], initialIndex: 0);
|
|
|
|
unawaited(_loadArtForCurrentItem());
|
|
unawaited(_fillRemainingSources(tracks, clampedInitial, myGen));
|
|
}
|
|
|
|
/// 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;
|
|
await setQueueFromTracks(_lastTracks, initialIndex: index);
|
|
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);
|
|
}
|
|
} 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 reflects the user's like state on this track
|
|
// so external surfaces (Wear's heart button, lock-screen
|
|
// favorites) render the right filled/outlined icon. Updated on
|
|
// every track change via _likeBridge.isTrackLiked.
|
|
final liked = _likeBridge?.isTrackLiked(t.id) ?? false;
|
|
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,
|
|
rating: Rating.newHeartRating(liked),
|
|
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);
|
|
}
|
|
|
|
void _onCurrentIndexChanged(int? idx) {
|
|
if (idx == null) return;
|
|
if (_suppressIndexUpdates) return;
|
|
// Push the new track's MediaItem onto the mediaItem stream so
|
|
// the player UI rebuilds with the new title/artist/album/cover.
|
|
// Without this, the bar and full player stayed pinned to whichever
|
|
// track was passed via setQueueFromTracks(initialIndex:) regardless
|
|
// of skip/auto-advance.
|
|
final items = queue.value;
|
|
if (idx >= 0 && idx < items.length) {
|
|
mediaItem.add(items[idx]);
|
|
}
|
|
unawaited(_loadArtForCurrentItem());
|
|
}
|
|
|
|
/// Async-fetches the cover for whichever item is currently active and
|
|
/// pushes a MediaItem update with artUri set. No-op if no cache is
|
|
/// configured, no current item, the item has no album_id in extras,
|
|
/// or the fetch returns null.
|
|
Future<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();
|
|
|
|
/// Stops playback and dismisses the system notification. BaseAudio
|
|
/// Handler's default just sets the processing state to idle without
|
|
/// touching the player, so the just_audio engine kept its audio
|
|
/// session and external surfaces (Wear, Auto, BT) saw a "paused
|
|
/// forever" rather than a clean stop. Halting the player releases
|
|
/// the session and lets super.stop() tear down the foreground
|
|
/// notification.
|
|
@override
|
|
Future<void> stop() async {
|
|
await _player.stop();
|
|
await super.stop();
|
|
}
|
|
|
|
/// 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,
|
|
MediaControl.stop,
|
|
],
|
|
// androidCompactActionIndices tells the system which controls
|
|
// appear in the collapsed/lock-screen view. Without this, some
|
|
// Android versions render the player without working buttons.
|
|
// Keep this at 3 actions (prev/play-pause/next) — stop lives
|
|
// in the expanded view only.
|
|
androidCompactActionIndices: const [0, 1, 2],
|
|
// systemActions enumerates which actions the system can invoke
|
|
// on us. Anything not listed here doesn't route back to the
|
|
// handler from external surfaces (Wear, Auto, Bluetooth, lock
|
|
// screen) — Android 13+ silently drops those events. Keep this
|
|
// in sync with the override methods so each implemented action
|
|
// is actually advertised.
|
|
systemActions: const {
|
|
MediaAction.play,
|
|
MediaAction.pause,
|
|
MediaAction.stop,
|
|
MediaAction.skipToNext,
|
|
MediaAction.skipToPrevious,
|
|
MediaAction.skipToQueueItem,
|
|
MediaAction.seek,
|
|
MediaAction.setShuffleMode,
|
|
MediaAction.setRepeatMode,
|
|
// Heart rating — Wear and lock screen render this as a like
|
|
// button. Routed to LikesController via _likeBridge.
|
|
MediaAction.setRating,
|
|
},
|
|
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;
|
|
}
|