perf(theme): resolve artwork cache identities asynchronously

This commit is contained in:
zarzet committed 2026-10-04 01:41:39 +07:00
1 parent 7f7e9c0e39
commit fe306119fe
4 files changed
+153 -47

No files matched your search

+60 -29
View File
@@ -19,6 +19,7 @@ class CoverPalette {
static final Map<String, ColorScheme> _cache = <String, ColorScheme>{};
static final Map<String, Color> _sourceColors = {};
static final Map<String, Future<ColorScheme?>> _pending = {};
static final Map<String, String> _sourceKeys = {};
static final List<String> _cacheOrder = <String>[];
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<String> 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<void> _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<ColorScheme?> _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<CoverPaletteBuilder> {
ColorScheme? _scheme;
String? _resolvedKey;
String? _resolvedSource;
Brightness? _resolvedBrightness;
int _resolveGeneration = 0;
@override
@@ -238,29 +274,24 @@ class _CoverPaletteBuilderState extends State<CoverPaletteBuilder> {
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);
});
}
+2 -1
View File
@@ -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),
);
}
+65
View File
@@ -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<void> 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 {
+26 -17
View File
@@ -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', () {