Merge pull request 'release v2026.05.13.3: full-player seed + MediaSession expansion (Wear)' (#46) from dev into main

This commit was merged in pull request #46.
This commit is contained in:
2026-05-14 18:19:39 +00:00
5 changed files with 206 additions and 4 deletions
@@ -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;
+112 -2
View File
@@ -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;
}
@@ -62,6 +62,39 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
/// a track whose cover hadn't finished decoding yet.
String? _pendingPreloadId;
@override
void initState() {
super.initState();
// Seed displayed state from the current mediaItem if a track is
// already playing when the full player is opened. ref.listen
// below only fires on CHANGES after subscription, so without
// this seed the screen sits on "Nothing playing." even though
// the mini bar shows a live track — the listener never gets a
// mediaItem emission because the stream's current value hasn't
// changed.
final current = ref.read(mediaItemProvider).value;
if (current == null) return;
_displayedMedia = current;
// Best-effort synchronous color seed: if albumColorProvider has
// already extracted this album's dominant color earlier in the
// session, pull it now so the backdrop renders correctly on
// first frame. Otherwise the post-frame _scheduleSwap below
// resolves it asynchronously and AnimatedContainer tweens.
final albumId = current.extras?['album_id'] as String?;
if (albumId != null && albumId.isNotEmpty) {
_displayedDominant =
ref.read(albumColorProvider(albumId)).asData?.value;
}
// Kick the preload pipeline post-frame so the cover bytes are
// decoded + the dominant color is resolved even when the user
// opens the player to a track that hasn't yet flowed through
// ref.listen.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_scheduleSwap(current);
});
}
/// Preload the new track's cover bytes + dominant color, then
/// atomically flip _displayedMedia / _displayedDominant. Until both
/// resolve, the screen continues to render the previous track —
+31 -1
View File
@@ -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);
+1 -1
View File
@@ -1,7 +1,7 @@
name: minstrel
description: Minstrel mobile client
publish_to: 'none'
version: 2026.5.13+3
version: 2026.5.13+4
environment:
sdk: '>=3.5.0 <4.0.0'