Files
minstrel/flutter_client/lib/player/album_cover_cache.dart
T
bvandeusen d1e276204e 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.
2026-05-14 13:43:18 -04:00

95 lines
3.7 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
/// Caches album cover bytes to disk so MediaItem.artUri can point at a
/// file:// URI. Android's MediaSession framework fetches artUri itself
/// and doesn't carry our Bearer header, so we pre-fetch via the
/// authenticated dio and hand the system a local file path instead.
///
/// Cache layout: `{applicationCacheDirectory}/album_covers/{albumId}.jpg`.
/// No explicit eviction — covers are tiny and OS clears app cache when
/// space is tight.
class AlbumCoverCache {
AlbumCoverCache({
required Future<Dio> Function() dioFactory,
Future<Directory> Function()? cacheDirFactory,
}) : _dioFactory = dioFactory,
_cacheDirFactory = cacheDirFactory ?? getApplicationCacheDirectory;
final Future<Dio> Function() _dioFactory;
final Future<Directory> Function() _cacheDirFactory;
/// In-flight requests keyed by albumId so concurrent callers for the
/// 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) {
if (albumId.isEmpty) return Future.value(null);
final existing = _inflight[albumId];
if (existing != null) return existing;
final fut = _doFetch(albumId);
_inflight[albumId] = fut;
fut.whenComplete(() => _inflight.remove(albumId));
return fut;
}
Future<String?> _doFetch(String albumId) async {
try {
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;
final dio = await _dioFactory();
final r = await dio.get<List<int>>(
'/api/albums/$albumId/cover',
options: Options(responseType: ResponseType.bytes),
);
final bytes = r.data;
if (bytes == null || bytes.isEmpty) return null;
await file.writeAsBytes(bytes, flush: true);
return filePath;
} catch (e) {
debugPrint('AlbumCoverCache: fetch failed for $albumId: $e');
return null;
}
}
}