diff --git a/lib/theme/cover_palette.dart b/lib/theme/cover_palette.dart index 9c4781aa..ccb33cb1 100644 --- a/lib/theme/cover_palette.dart +++ b/lib/theme/cover_palette.dart @@ -14,6 +14,7 @@ class CoverPalette { /// URL+brightness. Bounded because a long library-browsing session would /// otherwise keep every visited album's scheme alive. static final Map _cache = {}; + static final Map> _pending = {}; static final List _cacheOrder = []; static const int _maxEntries = 32; @@ -50,11 +51,23 @@ class CoverPalette { String source, Brightness brightness, { String? cacheKey, - }) async { + }) { final key = cacheKey ?? cacheKeyFor(source, brightness); final cached = _cache[key]; - if (cached != null) return cached; + if (cached != null) return Future.value(cached); + return _pending.putIfAbsent( + key, + () => _resolve(source, brightness, key).whenComplete(() { + _pending.remove(key); + }), + ); + } + static Future _resolve( + String source, + Brightness brightness, + String key, + ) async { final ImageProvider provider; if (_isNetworkSource(source)) { provider = cachedCoverImageProvider(source); @@ -66,7 +79,14 @@ class CoverPalette { try { final scheme = await ColorScheme.fromImageProvider( - provider: provider, + // Palette extraction only samples a small image. Bound decoding too, + // instead of decoding the original before Flutter downsamples it. + provider: ResizeImage( + provider, + width: 112, + height: 112, + policy: ResizeImagePolicy.fit, + ), brightness: brightness, ); _cache[key] = scheme; diff --git a/test/cover_palette_test.dart b/test/cover_palette_test.dart new file mode 100644 index 00000000..934fba89 --- /dev/null +++ b/test/cover_palette_test.dart @@ -0,0 +1,50 @@ +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/theme/cover_palette.dart'; + +void main() { + testWidgets('palette requests share a small aspect-preserving decode', ( + tester, + ) async { + await tester.runAsync(() async { + final directory = await Directory.systemTemp.createTemp('palette-test-'); + final cache = PaintingBinding.instance.imageCache; + cache.clear(); + cache.clearLiveImages(); + try { + final recorder = ui.PictureRecorder(); + Canvas(recorder).drawRect( + const Rect.fromLTWH(0, 0, 512, 256), + Paint()..color = Colors.blue, + ); + final picture = recorder.endRecording(); + final image = await picture.toImage(512, 256); + final bytes = await image.toByteData(format: ui.ImageByteFormat.png); + image.dispose(); + picture.dispose(); + final file = File('${directory.path}/cover.png'); + await file.writeAsBytes(bytes!.buffer.asUint8List()); + + final first = CoverPalette.resolve(file.path, Brightness.light); + final second = CoverPalette.resolve(file.path, Brightness.light); + expect(identical(first, second), isTrue); + final scheme = await first; + expect(scheme, isNotNull); + // The original is 512 x 256. Its cached decode fits inside 112 x 112 + // while preserving the 2:1 aspect ratio, instead of retaining 512 KiB. + expect(cache.currentSizeBytes, 112 * 56 * 4); + expect( + await CoverPalette.resolve(file.path, Brightness.light), + same(scheme), + ); + } finally { + cache.clear(); + cache.clearLiveImages(); + await directory.delete(recursive: true); + } + }); + }); +}