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 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 = {}; /// 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 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); // 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>( '/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; } } }