From df87f1e95052db108693b2dd59fa515c529cca1b Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Thu, 17 Sep 2026 18:25:24 +0700 Subject: [PATCH] perf(images): decode local covers to their fitted display size --- lib/widgets/cached_cover_image.dart | 163 ++++++++++++---- test/cached_cover_decode_test.dart | 283 ++++++++++++++++++++++++++++ 2 files changed, 413 insertions(+), 33 deletions(-) diff --git a/lib/widgets/cached_cover_image.dart b/lib/widgets/cached_cover_image.dart index 19992976..b530db90 100644 --- a/lib/widgets/cached_cover_image.dart +++ b/lib/widgets/cached_cover_image.dart @@ -1,4 +1,6 @@ import 'dart:io'; +import 'dart:math' as math; +import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:cached_network_image/cached_network_image.dart'; @@ -151,39 +153,10 @@ class LocalOrNetworkCoverImage extends StatelessWidget { @override Widget build(BuildContext context) { if (_isLocal) { - final image = Image.file( - File(url), - width: width, - height: height, - fit: fit, - cacheWidth: localCacheWidth, - gaplessPlayback: true, - filterQuality: FilterQuality.low, - frameBuilder: fadeInDuration == null - ? null - : (context, child, frame, wasSynchronouslyLoaded) { - final ready = wasSynchronouslyLoaded || frame != null; - if (fadeInDuration == Duration.zero) { - return ready ? child : placeholder(context); - } - return Stack( - fit: StackFit.expand, - children: [ - placeholder(context), - AnimatedOpacity( - opacity: ready ? 1.0 : 0.0, - duration: fadeInDuration!, - curve: Curves.easeOutCubic, - child: child, - ), - ], - ); - }, - errorBuilder: (_, _, _) => placeholder(context), - ); - return borderRadius == null - ? image - : ClipRRect(borderRadius: borderRadius!, child: image); + if (localCacheWidth != null) { + return _buildLocalImage(context, const BoxConstraints()); + } + return LayoutBuilder(builder: _buildLocalImage); } return CachedCoverImage( @@ -199,6 +172,130 @@ class LocalOrNetworkCoverImage extends StatelessWidget { errorWidget: (_, _, _) => placeholder(context), ); } + + Widget _buildLocalImage(BuildContext context, BoxConstraints constraints) { + final file = File(url); + final ImageProvider provider; + if (localCacheWidth != null) { + provider = ResizeImage(FileImage(file), width: localCacheWidth); + } else { + final decodeWidth = _localDecodeExtent( + context, + constraints.constrainWidth(width ?? double.infinity), + ); + final decodeHeight = _localDecodeExtent( + context, + constraints.constrainHeight(height ?? double.infinity), + ); + provider = decodeWidth == null && decodeHeight == null + ? FileImage(file) + : _FittedLocalFileImage( + file, + width: decodeWidth, + height: decodeHeight, + fit: fit, + ); + } + final image = Image( + image: provider, + width: width, + height: height, + fit: fit, + gaplessPlayback: true, + filterQuality: FilterQuality.low, + frameBuilder: fadeInDuration == null + ? null + : (context, child, frame, wasSynchronouslyLoaded) { + final ready = wasSynchronouslyLoaded || frame != null; + if (fadeInDuration == Duration.zero) { + return ready ? child : placeholder(context); + } + return Stack( + fit: StackFit.expand, + children: [ + placeholder(context), + AnimatedOpacity( + opacity: ready ? 1.0 : 0.0, + duration: fadeInDuration!, + curve: Curves.easeOutCubic, + child: child, + ), + ], + ); + }, + errorBuilder: (_, _, _) => placeholder(context), + ); + return borderRadius == null + ? image + : ClipRRect(borderRadius: borderRadius!, child: image); + } + + static int? _localDecodeExtent(BuildContext context, double? size) { + if (size == null || !size.isFinite || size <= 0) return null; + return (size * MediaQuery.devicePixelRatioOf(context)).ceil(); + } +} + +/// Lets Flutter read intrinsic dimensions before choosing the decode size. +/// FileImage still owns file loading, codec creation and image lifecycle. +class _FittedLocalFileImage extends FileImage { + final int? width; + final int? height; + final BoxFit fit; + + const _FittedLocalFileImage( + super.file, { + required this.width, + required this.height, + required this.fit, + }); + + @override + ImageStreamCompleter loadImage(FileImage key, ImageDecoderCallback decode) { + return super.loadImage(key, (buffer, {getTargetSize}) { + return decode( + buffer, + getTargetSize: (intrinsicWidth, intrinsicHeight) { + final double ratio; + if (width != null && height != null) { + final fitted = applyBoxFit( + fit, + Size(intrinsicWidth.toDouble(), intrinsicHeight.toDouble()), + Size(width!.toDouble(), height!.toDouble()), + ); + ratio = math.max( + fitted.destination.width / fitted.source.width, + fitted.destination.height / fitted.source.height, + ); + } else if (fit == BoxFit.none) { + ratio = 1; + } else { + ratio = width != null + ? width! / intrinsicWidth + : height! / intrinsicHeight; + } + // Never enlarge the decoded source. Cover uses the cropped source + // dimensions above, retaining enough pixels along both display axes. + final scale = ratio.clamp(0.0, 1.0); + return ui.TargetImageSize( + width: (intrinsicWidth * scale).ceil().clamp(1, intrinsicWidth), + height: (intrinsicHeight * scale).ceil().clamp(1, intrinsicHeight), + ); + }, + ); + }); + } + + @override + bool operator ==(Object other) => + other is _FittedLocalFileImage && + super == other && + width == other.width && + height == other.height && + fit == other.fit; + + @override + int get hashCode => Object.hash(super.hashCode, width, height, fit); } CachedNetworkImageProvider cachedCoverImageProvider(String url) { diff --git a/test/cached_cover_decode_test.dart b/test/cached_cover_decode_test.dart index a0f883b4..1127d8af 100644 --- a/test/cached_cover_decode_test.dart +++ b/test/cached_cover_decode_test.dart @@ -1,3 +1,7 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:ui' as ui; + import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:spotiflac_android/widgets/cached_cover_image.dart'; @@ -40,4 +44,283 @@ void main() { }, ); } + + for (final scenario in [ + ( + name: 'explicit row size', + width: 64.0, + height: 64.0, + bound: 64.0, + dpr: 2.0, + override: null, + fit: BoxFit.cover, + source: const Size(2048, 1024), + expected: const Size(256, 128), + ), + ( + name: 'grid constraints', + width: null, + height: null, + bound: 96.0, + dpr: 3.0, + override: null, + fit: BoxFit.cover, + source: const Size(2048, 1024), + expected: const Size(576, 288), + ), + ( + name: 'explicit decode override', + width: 64.0, + height: 64.0, + bound: 64.0, + dpr: 2.0, + override: 640, + fit: BoxFit.cover, + source: const Size(2048, 1024), + expected: const Size(640, 320), + ), + ( + name: 'tight constraints override requested height', + width: null, + height: 48.0, + bound: 96.0, + dpr: 2.0, + override: null, + fit: BoxFit.cover, + source: const Size(2048, 1024), + expected: const Size(384, 192), + ), + ( + name: 'unbounded artwork', + width: null, + height: null, + bound: null, + dpr: 2.0, + override: null, + fit: BoxFit.cover, + source: const Size(2048, 1024), + expected: const Size(2048, 1024), + ), + for (final fit in [BoxFit.contain, BoxFit.fitWidth, BoxFit.scaleDown]) + ( + name: '$fit uses visible source detail', + width: 64.0, + height: 64.0, + bound: 64.0, + dpr: 2.0, + override: null, + fit: fit, + source: const Size(2048, 1024), + expected: const Size(128, 64), + ), + for (final fit in [BoxFit.fill, BoxFit.fitHeight]) + ( + name: '$fit retains detail along both axes', + width: 64.0, + height: 64.0, + bound: 64.0, + dpr: 2.0, + override: null, + fit: fit, + source: const Size(2048, 1024), + expected: const Size(256, 128), + ), + ( + name: 'none keeps original pixel scale', + width: 64.0, + height: 64.0, + bound: 64.0, + dpr: 2.0, + override: null, + fit: BoxFit.none, + source: const Size(2048, 1024), + expected: const Size(2048, 1024), + ), + ( + name: 'portrait cover', + width: 64.0, + height: 64.0, + bound: 64.0, + dpr: 2.0, + override: null, + fit: BoxFit.cover, + source: const Size(1024, 2048), + expected: const Size(128, 256), + ), + ( + name: 'small source is never upscaled during decode', + width: 64.0, + height: 64.0, + bound: 64.0, + dpr: 2.0, + override: null, + fit: BoxFit.cover, + source: const Size(32, 16), + expected: const Size(32, 16), + ), + ]) { + testWidgets( + 'local ${scenario.name} retains only the expected decoded bitmap', + (tester) async { + final cache = PaintingBinding.instance.imageCache; + cache.clear(); + cache.clearLiveImages(); + final directory = await tester.runAsync(() async { + final directory = await Directory.systemTemp.createTemp( + 'local-cover-decode-', + ); + await _writeCover(directory, scenario.source); + return directory; + }); + try { + final cover = LocalOrNetworkCoverImage( + url: '${directory!.path}/cover.png', + width: scenario.width, + height: scenario.height, + fit: scenario.fit, + localCacheWidth: scenario.override, + placeholder: (_) => const SizedBox(), + ); + final decoded = await tester.runAsync(() async { + await tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: MediaQueryData(devicePixelRatio: scenario.dpr), + child: Center( + child: scenario.bound == null + ? OverflowBox( + maxWidth: double.infinity, + maxHeight: double.infinity, + child: cover, + ) + : SizedBox.square( + dimension: scenario.bound, + child: cover, + ), + ), + ), + ), + ); + final imageWidget = tester.widget(find.byType(Image)); + return _decodedImageSize(imageWidget.image); + }); + expect(decoded, scenario.expected); + expect( + cache.currentSizeBytes, + scenario.expected.width.toInt() * + scenario.expected.height.toInt() * + 4, + ); + expect( + decoded!.width / decoded.height, + scenario.source.width / scenario.source.height, + ); + await tester.pump(); + expect(tester.takeException(), isNull); + } finally { + await tester.pumpWidget(const SizedBox()); + cache.clear(); + cache.clearLiveImages(); + await tester.runAsync(() => directory!.delete(recursive: true)); + } + }, + ); + } + + testWidgets('local cache keys distinguish bounds and fit symmetrically', ( + tester, + ) async { + final cache = PaintingBinding.instance.imageCache; + cache.clear(); + cache.clearLiveImages(); + final directory = await tester.runAsync(() async { + final directory = await Directory.systemTemp.createTemp( + 'local-cover-key-', + ); + await _writeCover(directory, const Size(2048, 1024)); + return directory; + }); + try { + final keys = []; + for (final scenario in [ + (bound: 64.0, fit: BoxFit.cover, expected: const Size(256, 128)), + (bound: 64.0, fit: BoxFit.cover, expected: const Size(256, 128)), + (bound: 96.0, fit: BoxFit.cover, expected: const Size(384, 192)), + (bound: 64.0, fit: BoxFit.contain, expected: const Size(128, 64)), + ]) { + final decoded = await tester.runAsync(() async { + await tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: const MediaQueryData(devicePixelRatio: 2), + child: Center( + child: SizedBox.square( + dimension: scenario.bound, + child: LocalOrNetworkCoverImage( + url: '${directory!.path}/cover.png', + fit: scenario.fit, + placeholder: (_) => const SizedBox(), + ), + ), + ), + ), + ), + ); + final provider = tester.widget(find.byType(Image)).image; + keys.add(await provider.obtainKey(ImageConfiguration.empty)); + return _decodedImageSize(provider); + }); + expect(decoded, scenario.expected); + } + expect(keys[0], keys[1]); + expect(keys[0].hashCode, keys[1].hashCode); + expect(keys.toSet(), hasLength(3)); + final original = FileImage(File('${directory!.path}/cover.png')); + for (final key in keys) { + expect(key == original, isFalse); + expect(original == key, isFalse); + } + expect(cache.currentSize, 3); + expect(cache.currentSizeBytes, (256 * 128 + 384 * 192 + 128 * 64) * 4); + await tester.pump(); + expect(tester.takeException(), isNull); + } finally { + await tester.pumpWidget(const SizedBox()); + cache.clear(); + cache.clearLiveImages(); + await tester.runAsync(() => directory!.delete(recursive: true)); + } + }); +} + +Future _writeCover(Directory directory, Size size) async { + final recorder = ui.PictureRecorder(); + Canvas(recorder).drawRect(Offset.zero & size, Paint()..color = Colors.blue); + final picture = recorder.endRecording(); + final image = await picture.toImage(size.width.toInt(), size.height.toInt()); + final bytes = await image.toByteData(format: ui.ImageByteFormat.png); + image.dispose(); + picture.dispose(); + await File( + '${directory.path}/cover.png', + ).writeAsBytes(bytes!.buffer.asUint8List()); +} + +Future _decodedImageSize(ImageProvider provider) async { + final result = Completer(); + final stream = provider.resolve(ImageConfiguration.empty); + final listener = ImageStreamListener((info, _) { + if (!result.isCompleted) { + result.complete( + Size(info.image.width.toDouble(), info.image.height.toDouble()), + ); + } + info.dispose(); + }, onError: result.completeError); + stream.addListener(listener); + try { + return await result.future.timeout(const Duration(seconds: 10)); + } finally { + stream.removeListener(listener); + } }