diff --git a/flutter_client/lib/player/album_cover_cache.dart b/flutter_client/lib/player/album_cover_cache.dart index 23443807..ac2534c4 100644 --- a/flutter_client/lib/player/album_cover_cache.dart +++ b/flutter_client/lib/player/album_cover_cache.dart @@ -27,6 +27,32 @@ class AlbumCoverCache { /// same album dedupe to one fetch. final Map> _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 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; diff --git a/flutter_client/lib/player/audio_handler.dart b/flutter_client/lib/player/audio_handler.dart index 9d48ae89..64caefd5 100644 --- a/flutter_client/lib/player/audio_handler.dart +++ b/flutter_client/lib/player/audio_handler.dart @@ -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 = {}; 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 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 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 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))); + } + @override Future 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 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; +} diff --git a/flutter_client/lib/player/player_provider.dart b/flutter_client/lib/player/player_provider.dart index baca1af3..14f4afe5 100644 --- a/flutter_client/lib/player/player_provider.dart +++ b/flutter_client/lib/player/player_provider.dart @@ -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( ); 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>(likedIdsProvider, (_, next) { + if (next.value == null) return; + _ref.read(audioHandlerProvider).refreshCurrentRating(); + }); + } final Ref _ref; Future playTracks(List 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 playNext(TrackRef track) async { final h = _ref.read(audioHandlerProvider); await h.playNext(track);