From 7064f1a53a037e0f3469af266c7f29c81a014473 Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Thu, 17 Sep 2026 02:59:14 +0700 Subject: [PATCH] perf(theme): bound cover palette decoding and coalesce requests --- lib/theme/cover_palette.dart | 26 ++++++++++++++++--- test/cover_palette_test.dart | 50 ++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 3 deletions(-) create mode 100644 test/cover_palette_test.dart 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); + } + }); + }); +}