From b6d6f225986ba7c116c84c1ba2e96e51431eecfb Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 9 May 2026 12:53:20 -0400 Subject: [PATCH] feat(flutter/player): AlbumCoverCache for lock-screen artwork MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fetches album cover bytes via the authenticated dio and writes to /album_covers/.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) --- .../lib/player/album_cover_cache.dart | 65 ++++++++++ .../test/player/album_cover_cache_test.dart | 121 ++++++++++++++++++ 2 files changed, 186 insertions(+) create mode 100644 flutter_client/lib/player/album_cover_cache.dart create mode 100644 flutter_client/test/player/album_cover_cache_test.dart diff --git a/flutter_client/lib/player/album_cover_cache.dart b/flutter_client/lib/player/album_cover_cache.dart new file mode 100644 index 00000000..4ddd1270 --- /dev/null +++ b/flutter_client/lib/player/album_cover_cache.dart @@ -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: /album_covers/.jpg. +/// No explicit eviction — covers are tiny and OS clears app cache when +/// space is tight. +class AlbumCoverCache { + AlbumCoverCache({ + required Future Function() dioFactory, + Future Function()? cacheDirFactory, + }) : _dioFactory = dioFactory, + _cacheDirFactory = cacheDirFactory ?? getApplicationCacheDirectory; + + final Future Function() _dioFactory; + final Future Function() _cacheDirFactory; + + /// In-flight requests keyed by albumId so concurrent callers for the + /// same album dedupe to one fetch. + final Map> _inflight = {}; + + /// Returns local file path to the album cover, or null on any + /// failure (network, 4xx/5xx, disk full, empty albumId). + Future 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 _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>( + '/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; + } + } +} diff --git a/flutter_client/test/player/album_cover_cache_test.dart b/flutter_client/test/player/album_cover_cache_test.dart new file mode 100644 index 00000000..79940f89 --- /dev/null +++ b/flutter_client/test/player/album_cover_cache_test.dart @@ -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 body; + int callCount = 0; + + @override + Future fetch( + RequestOptions options, + Stream? requestStream, + Future? 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 fetch( + RequestOptions options, + Stream? requestStream, + Future? cancelFuture, + ) async { + throw DioException( + requestOptions: options, + type: DioExceptionType.connectionError, + message: 'simulated network failure', + ); + } + + @override + void close({bool force = false}) {} +} + +Future _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); + }); +}