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>
This commit is contained in:
@@ -0,0 +1,65 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:minstrel/player/album_cover_cache.dart';
|
||||||
|
|
||||||
|
class _RecordingAdapter implements HttpClientAdapter {
|
||||||
|
_RecordingAdapter(this.body);
|
||||||
|
final List<int> body;
|
||||||
|
int callCount = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ResponseBody> fetch(
|
||||||
|
RequestOptions options,
|
||||||
|
Stream<Uint8List>? requestStream,
|
||||||
|
Future<void>? cancelFuture,
|
||||||
|
) async {
|
||||||
|
callCount++;
|
||||||
|
return ResponseBody.fromBytes(body, 200, headers: {
|
||||||
|
Headers.contentTypeHeader: ['image/jpeg'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void close({bool force = false}) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FailingAdapter implements HttpClientAdapter {
|
||||||
|
@override
|
||||||
|
Future<ResponseBody> fetch(
|
||||||
|
RequestOptions options,
|
||||||
|
Stream<Uint8List>? requestStream,
|
||||||
|
Future<void>? cancelFuture,
|
||||||
|
) async {
|
||||||
|
throw DioException(
|
||||||
|
requestOptions: options,
|
||||||
|
type: DioExceptionType.connectionError,
|
||||||
|
message: 'simulated network failure',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void close({bool force = false}) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Directory> _tmpDirFactory() async =>
|
||||||
|
Directory.systemTemp.createTempSync('cover_cache_test_');
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('cache miss writes file and returns path', () async {
|
||||||
|
final adapter = _RecordingAdapter([1, 2, 3, 4]);
|
||||||
|
final dio = Dio(BaseOptions(baseUrl: 'http://test'))..httpClientAdapter = adapter;
|
||||||
|
final cache = AlbumCoverCache(
|
||||||
|
dioFactory: () async => dio,
|
||||||
|
cacheDirFactory: _tmpDirFactory,
|
||||||
|
);
|
||||||
|
final path = await cache.getOrFetch('alb-1');
|
||||||
|
expect(path, isNotNull);
|
||||||
|
expect(File(path!).readAsBytesSync(), [1, 2, 3, 4]);
|
||||||
|
expect(adapter.callCount, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cache hit returns same path without re-fetching', () async {
|
||||||
|
final adapter = _RecordingAdapter([9, 9, 9]);
|
||||||
|
final dio = Dio(BaseOptions(baseUrl: 'http://test'))..httpClientAdapter = adapter;
|
||||||
|
final tmp = await _tmpDirFactory();
|
||||||
|
final cache = AlbumCoverCache(
|
||||||
|
dioFactory: () async => dio,
|
||||||
|
cacheDirFactory: () async => tmp,
|
||||||
|
);
|
||||||
|
final p1 = await cache.getOrFetch('alb-2');
|
||||||
|
final p2 = await cache.getOrFetch('alb-2');
|
||||||
|
expect(p1, p2);
|
||||||
|
expect(adapter.callCount, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('concurrent calls for same albumId dedupe', () async {
|
||||||
|
final adapter = _RecordingAdapter([1, 2, 3]);
|
||||||
|
final dio = Dio(BaseOptions(baseUrl: 'http://test'))..httpClientAdapter = adapter;
|
||||||
|
final cache = AlbumCoverCache(
|
||||||
|
dioFactory: () async => dio,
|
||||||
|
cacheDirFactory: _tmpDirFactory,
|
||||||
|
);
|
||||||
|
final results = await Future.wait([
|
||||||
|
cache.getOrFetch('alb-3'),
|
||||||
|
cache.getOrFetch('alb-3'),
|
||||||
|
cache.getOrFetch('alb-3'),
|
||||||
|
]);
|
||||||
|
expect(results[0], isNotNull);
|
||||||
|
expect(results[1], results[0]);
|
||||||
|
expect(results[2], results[0]);
|
||||||
|
expect(adapter.callCount, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failure returns null without writing file', () async {
|
||||||
|
final adapter = _FailingAdapter();
|
||||||
|
final dio = Dio(BaseOptions(baseUrl: 'http://test'))..httpClientAdapter = adapter;
|
||||||
|
final tmp = await _tmpDirFactory();
|
||||||
|
final cache = AlbumCoverCache(
|
||||||
|
dioFactory: () async => dio,
|
||||||
|
cacheDirFactory: () async => tmp,
|
||||||
|
);
|
||||||
|
final path = await cache.getOrFetch('alb-4');
|
||||||
|
expect(path, isNull);
|
||||||
|
expect(File('${tmp.path}/album_covers/alb-4.jpg').existsSync(), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty albumId returns null without dio call', () async {
|
||||||
|
final adapter = _RecordingAdapter([1, 2]);
|
||||||
|
final dio = Dio(BaseOptions(baseUrl: 'http://test'))..httpClientAdapter = adapter;
|
||||||
|
final cache = AlbumCoverCache(
|
||||||
|
dioFactory: () async => dio,
|
||||||
|
cacheDirFactory: _tmpDirFactory,
|
||||||
|
);
|
||||||
|
final path = await cache.getOrFetch('');
|
||||||
|
expect(path, isNull);
|
||||||
|
expect(adapter.callCount, 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user