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) <noreply@anthropic.com>
This commit is contained in:
2026-05-09 12:54:47 -04:00
parent d70075c426
commit 534dafb044
+45 -2
View File
@@ -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<void> setQueueFromTracks(List<TrackRef> 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<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();