mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-22 07:18:38 +02:00
perf(theme): resolve artwork cache identities asynchronously
This commit is contained in:
1 parent
7f7e9c0e39
commit
fe306119fe
4 files changed
+153
-47
No files matched your search
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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', () {
|
||||
|
||||
Reference in new issue
Block a user