diff --git a/lib/theme/cover_palette.dart b/lib/theme/cover_palette.dart index 09dcbb4f..7470427c 100644 --- a/lib/theme/cover_palette.dart +++ b/lib/theme/cover_palette.dart @@ -19,6 +19,7 @@ class CoverPalette { static final Map _cache = {}; static final Map _sourceColors = {}; static final Map> _pending = {}; + static final Map _sourceKeys = {}; static final List _cacheOrder = []; static const int _maxEntries = 32; @@ -27,11 +28,14 @@ class CoverPalette { /// Includes the local file version so replacing artwork at the same path /// cannot reuse a palette derived from the previous image. - static String cacheKeyFor(String source, Brightness brightness) { + static Future cacheKeyFor( + String source, + Brightness brightness, + ) async { var versionedSource = source; if (!_isNetworkSource(source)) { try { - final stat = File(source).statSync(); + final stat = await File(source).stat(); if (stat.type != FileSystemEntityType.notFound) { versionedSource = '$source|${stat.modified.microsecondsSinceEpoch}|${stat.size}'; @@ -45,14 +49,12 @@ class CoverPalette { /// Cached scheme for [source], or null when it has not been resolved yet. static ColorScheme? peek(String source, Brightness brightness) => - _cache[cacheKeyFor(source, brightness)]; + _cache[_sourceKeys['$source|${brightness.name}']]; /// Average cover colour before Material's accent selection or tonal mapping. /// Near-monochrome covers stay neutral instead of acquiring a seed hue. static Color? sourceColor(String source, Brightness brightness) => - _sourceColors[cacheKeyFor(source, brightness)]; - - static ColorScheme? _peekByKey(String key) => _cache[key]; + _sourceColors[_sourceKeys['$source|${brightness.name}']]; /// Resolves the scheme for [source] (a network URL or a local file path). /// Returns null when the image cannot be decoded. @@ -61,17 +63,50 @@ class CoverPalette { Brightness brightness, { String? cacheKey, }) { - final key = cacheKey ?? cacheKeyFor(source, brightness); - final cached = _cache[key]; - if (cached != null) return Future.value(cached); + final sourceKey = '$source|${brightness.name}'; return _pending.putIfAbsent( - key, - () => _resolve(source, brightness, key).whenComplete(() { - _pending.remove(key); - }), + sourceKey, + () => + (() async { + final key = cacheKey ?? await cacheKeyFor(source, brightness); + final previous = _sourceKeys.remove(sourceKey); + // FileImage keys contain the path, not its modification time. A new + // palette key also needs a fresh decode after an in-place cover edit. + if ((!_cache.containsKey(key) || + (previous != null && previous != key)) && + !_isNetworkSource(source)) { + if (previous != null && previous != key) { + await _evictDecodedImage(FileImage(File(source))); + } + await _evictDecodedImage( + ResizeImage( + FileImage(File(source)), + width: 112, + height: 112, + policy: ResizeImagePolicy.fit, + ), + ); + } + _sourceKeys[sourceKey] = key; + while (_sourceKeys.length > _maxEntries) { + _sourceKeys.remove(_sourceKeys.keys.first); + } + return _cache[key] ?? await _resolve(source, brightness, key); + })().whenComplete(() { + _pending.remove(sourceKey); + }), ); } + static Future _evictDecodedImage(ImageProvider provider) async { + final key = await provider.obtainKey(ImageConfiguration.empty); + // A first palette request can overlap the header's initial precache. Do + // not cancel that pending decode while removing an older retained bitmap. + if (!PaintingBinding.instance.imageCache.statusForKey(key).pending) { + await provider.evict(); + } + } + static Future _resolve( String source, Brightness brightness, @@ -82,7 +117,7 @@ class CoverPalette { provider = cachedCoverImageProvider(source); } else { final file = File(source); - if (!file.existsSync()) return null; + if (!await file.exists()) return null; provider = FileImage(file); } @@ -216,7 +251,8 @@ class CoverPaletteBuilder extends StatefulWidget { class _CoverPaletteBuilderState extends State { ColorScheme? _scheme; - String? _resolvedKey; + String? _resolvedSource; + Brightness? _resolvedBrightness; int _resolveGeneration = 0; @override @@ -238,29 +274,24 @@ class _CoverPaletteBuilderState extends State { final brightness = Theme.of(context).brightness; if (source == null || source.isEmpty) { _resolveGeneration++; - _resolvedKey = null; + _resolvedSource = null; + _resolvedBrightness = null; _scheme = null; return; } - final key = CoverPalette.cacheKeyFor(source, brightness); - if (_resolvedKey == key) return; - final requestGeneration = ++_resolveGeneration; - _resolvedKey = key; - - final cached = CoverPalette._peekByKey(key); - if (cached != null) { - _scheme = cached; - return; + if (_resolvedSource != source || _resolvedBrightness != brightness) { + _resolvedSource = source; + _resolvedBrightness = brightness; + _scheme = CoverPalette.peek(source, brightness); } - _scheme = null; - CoverPalette.resolve(source, brightness, cacheKey: key).then((scheme) { + CoverPalette.resolve(source, brightness).then((scheme) { if (!mounted) return; - if (_resolveGeneration != requestGeneration || _resolvedKey != key) { + if (_resolveGeneration != requestGeneration) { return; } - setState(() => _scheme = scheme); + if (_scheme != scheme) setState(() => _scheme = scheme); }); } diff --git a/lib/widgets/mornye_chrome.dart b/lib/widgets/mornye_chrome.dart index 3a6315e8..8e2cb30c 100644 --- a/lib/widgets/mornye_chrome.dart +++ b/lib/widgets/mornye_chrome.dart @@ -103,7 +103,8 @@ class MornyeGlassPanel extends ConsumerWidget { tintColor: tintColor, blurEnabled: blurEnabled && ref.watch(mornyeBlurEnabledProvider), highlightBorder: highlightBorder, - child: child, + // Tile ink must paint above the glass tint rather than beneath it. + child: Material(type: MaterialType.transparency, child: child), ); } diff --git a/test/cover_palette_test.dart b/test/cover_palette_test.dart index df8e3a1a..ff1835ee 100644 --- a/test/cover_palette_test.dart +++ b/test/cover_palette_test.dart @@ -6,6 +6,71 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:spotiflac_android/theme/cover_palette.dart'; void main() { + testWidgets( + 'same-path cover replacement refreshes palette and decoded image', + (tester) async { + await tester.runAsync(() async { + final directory = await Directory.systemTemp.createTemp( + 'palette-replace-', + ); + final file = File('${directory.path}/cover.png'); + final cache = PaintingBinding.instance.imageCache; + Future write(Color color) async { + final recorder = ui.PictureRecorder(); + Canvas( + recorder, + ).drawRect(const Rect.fromLTWH(0, 0, 32, 32), Paint()..color = color); + final picture = recorder.endRecording(); + final image = await picture.toImage(32, 32); + final bytes = await image.toByteData(format: ui.ImageByteFormat.png); + image.dispose(); + picture.dispose(); + await file.writeAsBytes(bytes!.buffer.asUint8List()); + } + + try { + await write(Colors.blue); + final first = await CoverPalette.resolve(file.path, Brightness.light); + expect(first, isNotNull); + final oldKey = await CoverPalette.cacheKeyFor( + file.path, + Brightness.light, + ); + // The small source-key lookup may evict a file while Flutter still + // retains its decoded image. A later edit must stay fresh too. + for (var index = 0; index < 33; index++) { + await CoverPalette.resolve( + '${directory.path}/missing-$index.png', + Brightness.light, + ); + } + expect(CoverPalette.peek(file.path, Brightness.light), isNull); + await write(Colors.red); + await file.setLastModified( + DateTime.now().add(const Duration(seconds: 1)), + ); + expect( + await CoverPalette.cacheKeyFor(file.path, Brightness.light), + isNot(oldKey), + ); + final second = await CoverPalette.resolve( + file.path, + Brightness.light, + ); + expect(second, isNot(same(first))); + expect( + CoverPalette.sourceColor(file.path, Brightness.light)?.toARGB32(), + Colors.red.toARGB32(), + ); + } finally { + cache.clear(); + cache.clearLiveImages(); + await directory.delete(recursive: true); + } + }); + }, + ); + testWidgets('palette requests share a small aspect-preserving decode', ( tester, ) async { diff --git a/test/design_system_test.dart b/test/design_system_test.dart index 4833b6ba..a1faeae5 100644 --- a/test/design_system_test.dart +++ b/test/design_system_test.dart @@ -391,25 +391,34 @@ void main() { }); group('CoverPalette', () { - test('local cache identity changes when artwork is replaced in place', () { - final directory = Directory.systemTemp.createTempSync( - 'spotiflac-cover-palette-', - ); - final file = File('${directory.path}/cover.jpg'); - try { - file.writeAsBytesSync(const [1, 2, 3]); - file.setLastModifiedSync(DateTime.utc(2026, 1, 1)); - final before = CoverPalette.cacheKeyFor(file.path, Brightness.dark); + test( + 'local cache identity changes when artwork is replaced in place', + () async { + final directory = Directory.systemTemp.createTempSync( + 'spotiflac-cover-palette-', + ); + final file = File('${directory.path}/cover.jpg'); + try { + file.writeAsBytesSync(const [1, 2, 3]); + file.setLastModifiedSync(DateTime.utc(2026, 1, 1)); + final before = await CoverPalette.cacheKeyFor( + file.path, + Brightness.dark, + ); - file.writeAsBytesSync(const [4, 5, 6, 7]); - file.setLastModifiedSync(DateTime.utc(2026, 1, 2)); - final after = CoverPalette.cacheKeyFor(file.path, Brightness.dark); + file.writeAsBytesSync(const [4, 5, 6, 7]); + file.setLastModifiedSync(DateTime.utc(2026, 1, 2)); + final after = await CoverPalette.cacheKeyFor( + file.path, + Brightness.dark, + ); - expect(after, isNot(before)); - } finally { - directory.deleteSync(recursive: true); - } - }); + expect(after, isNot(before)); + } finally { + directory.deleteSync(recursive: true); + } + }, + ); }); group('AlbumDetailHeader', () {