Files
minstrel/flutter_client/lib/player/album_cover_cache.dart
T
bvandeusen b6d6f22598 feat(flutter/player): AlbumCoverCache for lock-screen artwork
Fetches album cover bytes via the authenticated dio and writes to
<applicationCacheDirectory>/album_covers/<albumId>.jpg. Returns the
local path so MediaItem.artUri can point at a file:// URI — Android's
MediaSession framework fetches artUri itself without our Bearer
header, so a pre-fetched local file is the workaround.

Concurrent callers for the same albumId dedupe to one fetch via an
in-flight Future map. Any failure (network / 4xx / 5xx / disk full /
empty id) returns null without throwing — playback continues with
the system's generic music icon, same UX as today.

No eviction — covers are tiny, OS clears cache when space is tight.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 12:53:20 -04:00

66 lines
2.3 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 = {};
/// 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);
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;
}
}
}