From 534dafb0445e8757992ad115f528408e48a29e54 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 9 May 2026 12:54:47 -0400 Subject: [PATCH] feat(flutter/player): set MediaItem.artUri from local cover cache Audio handler accepts an AlbumCoverCache via configure() and uses it to populate MediaItem.artUri with a file:// URI to a locally cached album cover. Lock screen, Bluetooth car displays, Wear OS, and CarPlay now show the album cover for the currently playing track instead of the system's generic music icon. Flow: - _toMediaItem stashes album_id in MediaItem.extras - After setQueueFromTracks pushes the queue + initial mediaItem, fires _loadArtForCurrentItem async (doesn't block playback) - Subscribes to _player.currentIndexStream so track advances trigger the same loader for the new current item - _loadArtForCurrentItem early-returns if cache is null, no current item, no album_id, or artUri already set; otherwise calls cache.getOrFetch and pushes an updated MediaItem with artUri set - Race guard: if the user skipped to another track while the fetch was in flight, the result is discarded Closes the visible "generic music icon on lock screen" gap operator flagged when first-testing on a real device. Co-Authored-By: Claude Opus 4.7 (1M context) --- flutter_client/lib/player/audio_handler.dart | 47 +++++++++++++++++++- 1 file changed, 45 insertions(+), 2 deletions(-) diff --git a/flutter_client/lib/player/audio_handler.dart b/flutter_client/lib/player/audio_handler.dart index 13886545..f9e2095b 100644 --- a/flutter_client/lib/player/audio_handler.dart +++ b/flutter_client/lib/player/audio_handler.dart @@ -1,23 +1,34 @@ +import 'dart:async'; + import 'package:audio_service/audio_service.dart'; import 'package:flutter/foundation.dart'; import 'package:just_audio/just_audio.dart'; import '../models/track.dart'; +import 'album_cover_cache.dart'; class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { MinstrelAudioHandler() { _player.playbackEventStream.listen(_broadcastState); + _player.currentIndexStream.listen(_onCurrentIndexChanged); } final AudioPlayer _player = AudioPlayer(); String _baseUrl = ''; String? _token; + AlbumCoverCache? _coverCache; - void configure({required String baseUrl, required String? token}) { + void configure({ + required String baseUrl, + required String? token, + AlbumCoverCache? coverCache, + }) { _baseUrl = baseUrl; _token = token; + if (coverCache != null) _coverCache = coverCache; debugPrint('audio_handler.configure: baseUrl="$baseUrl" ' - 'tokenPresent=${token != null && token.isNotEmpty}'); + 'tokenPresent=${token != null && token.isNotEmpty} ' + 'cachePresent=${_coverCache != null}'); } Future setQueueFromTracks(List tracks, {int initialIndex = 0}) async { @@ -54,6 +65,11 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl sources, initialIndex: initialIndex, ); + + // Kick the cover fetch for the initial item — async, doesn't block + // playback. Subsequent track changes are handled by the + // currentIndexStream listener. + unawaited(_loadArtForCurrentItem()); } String _resolveStreamUrl(TrackRef t) { @@ -73,8 +89,35 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl artist: t.artistName, album: t.albumTitle, duration: Duration(seconds: t.durationSec), + // Stash album_id in extras so _loadArtForCurrentItem can pull it + // back without re-walking the track list. + extras: t.albumId.isEmpty ? null : {'album_id': t.albumId}, ); + void _onCurrentIndexChanged(int? idx) { + if (idx == null) return; + 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 _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 play() => _player.play();