feat(player): expand MediaSession surface for Wear + lock-screen + Auto
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.
This commit is contained in:
@@ -27,6 +27,32 @@ class AlbumCoverCache {
|
||||
/// same album dedupe to one fetch.
|
||||
final Map<String, Future<String?>> _inflight = {};
|
||||
|
||||
/// Cached covers directory path resolved once on the first
|
||||
/// async cacheDir call, then reused for sync existsSync() checks
|
||||
/// in [peekCached]. Without this every "is the cover already on
|
||||
/// disk?" check would have to await path_provider, blocking the
|
||||
/// MediaItem broadcast on every track change.
|
||||
String? _coversDirPath;
|
||||
|
||||
/// Returns the local file path for [albumId]'s cover if it's
|
||||
/// already cached on disk, or null otherwise. Synchronous — uses
|
||||
/// File.existsSync() against a path computed from the directory
|
||||
/// resolved by an earlier async [getOrFetch] call. Returns null
|
||||
/// until at least one getOrFetch has populated _coversDirPath.
|
||||
///
|
||||
/// The audio handler uses this to seed MediaItem.artUri on the
|
||||
/// initial broadcast so external media controllers (Wear, Android
|
||||
/// Auto, Bluetooth) see the cover on the first frame for warm-cache
|
||||
/// tracks. Cold-cache tracks still fall back to the async
|
||||
/// _loadArtForCurrentItem path.
|
||||
String? peekCached(String albumId) {
|
||||
if (albumId.isEmpty) return null;
|
||||
final dir = _coversDirPath;
|
||||
if (dir == null) return null;
|
||||
final path = '$dir/$albumId.jpg';
|
||||
return File(path).existsSync() ? path : null;
|
||||
}
|
||||
|
||||
/// Returns local file path to the album cover, or null on any
|
||||
/// failure (network, 4xx/5xx, disk full, empty albumId).
|
||||
Future<String?> getOrFetch(String albumId) {
|
||||
@@ -44,6 +70,9 @@ class AlbumCoverCache {
|
||||
final dir = await _cacheDirFactory();
|
||||
final coversDir = Directory('${dir.path}/album_covers');
|
||||
await coversDir.create(recursive: true);
|
||||
// Cache the resolved directory so [peekCached] can do its
|
||||
// existsSync() check without re-awaiting path_provider.
|
||||
_coversDirPath = coversDir.path;
|
||||
final filePath = '${coversDir.path}/$albumId.jpg';
|
||||
final file = File(filePath);
|
||||
if (await file.exists()) return filePath;
|
||||
|
||||
@@ -40,6 +40,7 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
||||
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
|
||||
@@ -94,10 +95,12 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -301,12 +304,30 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
||||
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,
|
||||
);
|
||||
}
|
||||
@@ -391,6 +412,58 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
||||
@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
|
||||
@@ -425,20 +498,33 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
||||
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 — without play/pause/skip in here, taps on lock-screen
|
||||
// controls don't route back to the handler on Android 13+.
|
||||
// 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,
|
||||
@@ -463,3 +549,27 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../api/endpoints/likes.dart' show LikeKind;
|
||||
import '../api/endpoints/radio.dart';
|
||||
import '../auth/auth_provider.dart';
|
||||
import '../cache/audio_cache_manager.dart';
|
||||
import '../library/library_providers.dart' show dioProvider;
|
||||
import '../likes/likes_provider.dart';
|
||||
import '../models/track.dart';
|
||||
import 'album_cover_cache.dart';
|
||||
import 'audio_handler.dart';
|
||||
@@ -49,7 +51,18 @@ final positionProvider = StreamProvider<Duration>(
|
||||
);
|
||||
|
||||
class PlayerActions {
|
||||
PlayerActions(this._ref);
|
||||
PlayerActions(this._ref) {
|
||||
// Keep MediaItem.rating in sync with the drift-backed likedIds
|
||||
// cache so external media surfaces (Wear's heart button, lock-
|
||||
// screen favorite, Auto's like icon) reflect the current state
|
||||
// even when the user toggles a like from somewhere other than
|
||||
// the watch itself — e.g. tapping the heart on a TrackRow, the
|
||||
// kebab menu, or another logged-in device propagating via SSE.
|
||||
_ref.listen<AsyncValue<LikedIds>>(likedIdsProvider, (_, next) {
|
||||
if (next.value == null) return;
|
||||
_ref.read(audioHandlerProvider).refreshCurrentRating();
|
||||
});
|
||||
}
|
||||
final Ref _ref;
|
||||
|
||||
Future<void> playTracks(List<TrackRef> tracks, {int initialIndex = 0}) async {
|
||||
@@ -63,11 +76,28 @@ class PlayerActions {
|
||||
token: token,
|
||||
coverCache: cache,
|
||||
audioCacheManager: audioCache,
|
||||
likeBridge: _buildLikeBridge(),
|
||||
);
|
||||
await h.setQueueFromTracks(tracks, initialIndex: initialIndex);
|
||||
await h.play();
|
||||
}
|
||||
|
||||
/// Builds the adapter the audio handler uses to read + flip the
|
||||
/// current track's like state in response to external media-
|
||||
/// controller events (Wear's heart button, lock-screen favorite).
|
||||
/// Constructed here because the audio handler is initialized in
|
||||
/// main.dart before the ProviderScope exists; passing the bridge
|
||||
/// in via configure() keeps the handler Riverpod-agnostic while
|
||||
/// still letting it call into LikesController + likedIdsProvider.
|
||||
LikeBridge _buildLikeBridge() {
|
||||
return LikeBridge(
|
||||
toggleTrackLike: (id) =>
|
||||
_ref.read(likesControllerProvider).toggle(LikeKind.track, id),
|
||||
isTrackLiked: (id) =>
|
||||
_ref.read(likedIdsProvider).value?.has(LikeKind.track, id) ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> playNext(TrackRef track) async {
|
||||
final h = _ref.read(audioHandlerProvider);
|
||||
await h.playNext(track);
|
||||
|
||||
Reference in New Issue
Block a user