diff --git a/android/app/src/main/java/deckers/thibault/aves/channelhandlers/ImageDecodeTask.java b/android/app/src/main/java/deckers/thibault/aves/channelhandlers/ImageDecodeTask.java index 00694b3a8..005570fea 100644 --- a/android/app/src/main/java/deckers/thibault/aves/channelhandlers/ImageDecodeTask.java +++ b/android/app/src/main/java/deckers/thibault/aves/channelhandlers/ImageDecodeTask.java @@ -12,6 +12,7 @@ import android.provider.MediaStore; import android.util.Log; import android.util.Size; +import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; import com.bumptech.glide.Glide; @@ -35,14 +36,15 @@ public class ImageDecodeTask extends AsyncTask= Build.VERSION_CODES.Q) { - bitmap = getThumbnailBytesByResolver(p); - } else { - bitmap = getThumbnailBytesByMediaStore(p); + Integer w = p.width; + Integer h = p.height; + // fetch low quality thumbnails when size is not specified + if (w == null || h == null || w == 0 || h == 0) { + p.width = p.defaultSize; + p.height = p.defaultSize; + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + bitmap = getThumbnailBytesByResolver(p); + } else { + bitmap = getThumbnailBytesByMediaStore(p); + } + } catch (Exception e) { + exception = e; } - } catch (Exception e) { - exception = e; } - // fallback if the native methods failed + // fallback if the native methods failed or for higher quality thumbnails try { if (bitmap == null) { bitmap = getThumbnailByGlide(p); @@ -108,8 +117,9 @@ public class ImageDecodeTask extends AsyncTask getThumbnail(call, new MethodResultWrapper(result))).start(); break; + case "clearSizedThumbnailDiskCache": + new Thread(() -> Glide.get(activity).clearDiskCache()).start(); + result.success(null); + break; case "rename": new Thread(() -> rename(call, new MethodResultWrapper(result))).start(); break; @@ -55,12 +61,13 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler { Map entryMap = call.argument("entry"); Integer width = call.argument("width"); Integer height = call.argument("height"); - if (entryMap == null || width == null || height == null) { + Integer defaultSize = call.argument("defaultSize"); + if (entryMap == null || defaultSize == null) { result.error("getThumbnail-args", "failed because of missing arguments", null); return; } ImageEntry entry = new ImageEntry(entryMap); - new ImageDecodeTask(activity).execute(new ImageDecodeTask.Params(entry, width, height, result)); + new ImageDecodeTask(activity).execute(new ImageDecodeTask.Params(entry, width, height, defaultSize, result)); } private void getImageEntry(@NonNull MethodCall call, @NonNull MethodChannel.Result result) { diff --git a/lib/model/image_entry.dart b/lib/model/image_entry.dart index 23c45491d..cb7911923 100644 --- a/lib/model/image_entry.dart +++ b/lib/model/image_entry.dart @@ -266,7 +266,7 @@ class ImageEntry { try { final addresses = await servicePolicy.call( () => Geocoder.local.findAddressesFromCoordinates(coordinates), - priority: ServiceCallPriority.background, + priority: ServiceCallPriority.getLocation, ); if (addresses != null && addresses.isNotEmpty) { final address = addresses.first; diff --git a/lib/services/image_file_service.dart b/lib/services/image_file_service.dart index 7e2b093e7..0e381a2fb 100644 --- a/lib/services/image_file_service.dart +++ b/lib/services/image_file_service.dart @@ -14,8 +14,6 @@ class ImageFileService { static final StreamsChannel byteChannel = StreamsChannel('deckers.thibault/aves/imagebytestream'); static final StreamsChannel opChannel = StreamsChannel('deckers.thibault/aves/imageopstream'); - static const thumbnailPriority = ServiceCallPriority.asap; - static Future getImageEntries() async { try { await platform.invokeMethod('getImageEntries'); @@ -64,28 +62,36 @@ class ImageFileService { static Future getThumbnail(ImageEntry entry, int width, int height, {Object taskKey}) { return servicePolicy.call( () async { - if (width > 0 && height > 0) { - try { - final result = await platform.invokeMethod('getThumbnail', { - 'entry': entry.toMap(), - 'width': width, - 'height': height, - }); - return result as Uint8List; - } on PlatformException catch (e) { - debugPrint('getThumbnail failed with code=${e.code}, exception=${e.message}, details=${e.details}'); - } + try { + final result = await platform.invokeMethod('getThumbnail', { + 'entry': entry.toMap(), + 'width': width, + 'height': height, + 'defaultSize': 256, + }); + return result as Uint8List; + } on PlatformException catch (e) { + debugPrint('getThumbnail failed with code=${e.code}, exception=${e.message}, details=${e.details}'); } return Uint8List(0); }, - priority: thumbnailPriority, +// debugLabel: 'getThumbnail width=$width, height=$height entry=${entry.filenameWithoutExtension}', + priority: width == 0 || height == 0 ? ServiceCallPriority.getFastThumbnail : ServiceCallPriority.getSizedThumbnail, key: taskKey, ); } - static bool cancelThumbnail(Object taskKey) => servicePolicy.pause(taskKey, thumbnailPriority); + static Future clearSizedThumbnailDiskCache() async { + try { + return platform.invokeMethod('clearSizedThumbnailDiskCache'); + } on PlatformException catch (e) { + debugPrint('clearSizedThumbnailDiskCache failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + } + } - static Future resumeThumbnail(Object taskKey) => servicePolicy.resume(taskKey, thumbnailPriority); + static bool cancelThumbnail(Object taskKey) => servicePolicy.pause(taskKey, [ServiceCallPriority.getFastThumbnail, ServiceCallPriority.getSizedThumbnail]); + + static Future resumeThumbnail(Object taskKey) => servicePolicy.resume(taskKey); static Stream delete(Iterable entries) { try { diff --git a/lib/services/metadata_service.dart b/lib/services/metadata_service.dart index b6d9c9802..1ae5a431b 100644 --- a/lib/services/metadata_service.dart +++ b/lib/services/metadata_service.dart @@ -50,7 +50,7 @@ class MetadataService { } return null; }, - priority: ServiceCallPriority.background, + priority: ServiceCallPriority.getMetadata, ); } diff --git a/lib/services/service_policy.dart b/lib/services/service_policy.dart index 3585fd2ae..0fb47929c 100644 --- a/lib/services/service_policy.dart +++ b/lib/services/service_policy.dart @@ -2,36 +2,37 @@ import 'dart:async'; import 'dart:collection'; import 'package:flutter/foundation.dart'; +import 'package:tuple/tuple.dart'; final ServicePolicy servicePolicy = ServicePolicy._private(); class ServicePolicy { - final Map _paused = {}; - final Queue<_Task> _asapQueue = Queue(), _normalQueue = Queue(), _backgroundQueue = Queue(); - List> _queues; + final Map> _paused = {}; + final SplayTreeMap> _queues = SplayTreeMap(); _Task _running; - ServicePolicy._private() { - _queues = [_asapQueue, _normalQueue, _backgroundQueue]; - } + ServicePolicy._private(); Future call( Future Function() platformCall, { - ServiceCallPriority priority = ServiceCallPriority.normal, + int priority = ServiceCallPriority.normal, String debugLabel, Object key, }) { - var task = _paused.remove(key); - if (task != null) { + _Task task; + final priorityTask = _paused.remove(key); + if (priorityTask != null) { debugPrint('resume task with key=$key'); + priority = priorityTask.item1; + task = priorityTask.item2; } var completer = task?.completer ?? Completer(); task ??= _Task( () async { -// if (debugLabel != null) debugPrint('$runtimeType $debugLabel start'); + if (debugLabel != null) debugPrint('$runtimeType $debugLabel start'); final result = await platformCall(); completer.complete(result); -// if (debugLabel != null) debugPrint('$runtimeType $debugLabel completed'); + if (debugLabel != null) debugPrint('$runtimeType $debugLabel completed'); _running = null; _pickNext(); }, @@ -43,62 +44,46 @@ class ServicePolicy { return completer.future; } - Future resume(Object key, ServiceCallPriority priority) { - var task = _paused.remove(key); - if (task == null) return null; + Future resume(Object key) { + final priorityTask = _paused.remove(key); + if (priorityTask == null) return null; + final priority = priorityTask.item1; + final task = priorityTask.item2; _getQueue(priority).addLast(task); _pickNext(); return task.completer.future; } - Queue<_Task> _getQueue(ServiceCallPriority priority) { - Queue<_Task> queue; - switch (priority) { - case ServiceCallPriority.asap: - queue = _asapQueue; - break; - case ServiceCallPriority.background: - queue = _backgroundQueue; - break; - case ServiceCallPriority.normal: - default: - queue = _normalQueue; - break; - } - return queue; - } + Queue<_Task> _getQueue(int priority) => _queues.putIfAbsent(priority, () => Queue<_Task>()); void _pickNext() { if (_running != null) return; - final queue = _queues.firstWhere((q) => q.isNotEmpty, orElse: () => null); + final queue = _queues.entries.firstWhere((kv) => kv.value.isNotEmpty, orElse: () => null)?.value; _running = queue?.removeFirst(); _running?.callback?.call(); } - bool cancel(Object key, ServiceCallPriority priority) { - var cancelled = false; - final queue = _getQueue(priority); - final tasks = queue.where((task) => task.key == key).toList(); - tasks.forEach((task) { - if (queue.remove(task)) { - cancelled = true; - task.completer.completeError(CancelledException()); - } + bool _takeOut(Object key, Iterable priorities, void Function(int priority, _Task task) action) { + var out = false; + priorities.forEach((priority) { + final queue = _getQueue(priority); + final tasks = queue.where((task) => task.key == key).toList(); + tasks.forEach((task) { + if (queue.remove(task)) { + out = true; + action(priority, task); + } + }); }); - return cancelled; + return out; } - bool pause(Object key, ServiceCallPriority priority) { - var paused = false; - final queue = _getQueue(priority); - final tasks = queue.where((task) => task.key == key).toList(); - tasks.forEach((task) { - if (queue.remove(task)) { - paused = true; - _paused.putIfAbsent(key, () => task); - } - }); - return paused; + bool cancel(Object key, Iterable priorities) { + return _takeOut(key, priorities, (priority, task) => task.completer.completeError(CancelledException())); + } + + bool pause(Object key, Iterable priorities) { + return _takeOut(key, priorities, (priority, task) => _paused.putIfAbsent(key, () => Tuple2(priority, task))); } bool isPaused(Object key) => _paused.containsKey(key); @@ -114,4 +99,10 @@ class _Task { class CancelledException {} -enum ServiceCallPriority { asap, normal, background } +class ServiceCallPriority { + static const int getFastThumbnail = 100; + static const int getSizedThumbnail = 200; + static const int normal = 500; + static const int getMetadata = 1000; + static const int getLocation = 1000; +} diff --git a/lib/utils/constants.dart b/lib/utils/constants.dart index f7ca961ba..ec7ec04eb 100644 --- a/lib/utils/constants.dart +++ b/lib/utils/constants.dart @@ -22,9 +22,6 @@ class Constants { // ref _PopupMenuRoute._kMenuDuration static const popupMenuTransitionDuration = Duration(milliseconds: 300); - // TODO TLAD smarter sizing, but shouldn't only depend on `extent` so that it doesn't reload during gridview scaling - static const double thumbnailCacheExtent = 50; - static const svgBackground = Colors.white; static const svgColorFilter = ColorFilter.mode(svgBackground, BlendMode.dstOver); diff --git a/lib/widgets/album/thumbnail/raster.dart b/lib/widgets/album/thumbnail/raster.dart index ad8e36631..78ee030e9 100644 --- a/lib/widgets/album/thumbnail/raster.dart +++ b/lib/widgets/album/thumbnail/raster.dart @@ -1,5 +1,6 @@ +import 'dart:math'; + import 'package:aves/model/image_entry.dart'; -import 'package:aves/utils/constants.dart'; import 'package:aves/widgets/common/image_providers/thumbnail_provider.dart'; import 'package:aves/widgets/common/image_providers/uri_image_provider.dart'; import 'package:aves/widgets/common/transition_image.dart'; @@ -24,7 +25,7 @@ class ThumbnailRasterImage extends StatefulWidget { } class _ThumbnailRasterImageState extends State { - ThumbnailProvider _imageProvider; + ThumbnailProvider _fastThumbnailProvider, _sizedThumbnailProvider; ImageEntry get entry => widget.entry; @@ -32,6 +33,11 @@ class _ThumbnailRasterImageState extends State { Object get heroTag => widget.heroTag; + // we standardize the thumbnail loading dimension by taking the nearest larger power of 2 + // so that there are less variants of the thumbnails to load and cache + // it increases the chance of cache hit when loading similarly sized columns (e.g. on orientation change) + double get requestExtent => pow(2, (log(extent) / log(2)).ceil()).toDouble(); + @override void initState() { super.initState(); @@ -53,7 +59,12 @@ class _ThumbnailRasterImageState extends State { super.dispose(); } - void _initProvider() => _imageProvider = ThumbnailProvider(entry: entry, extent: Constants.thumbnailCacheExtent); + void _initProvider() { + _fastThumbnailProvider = ThumbnailProvider(entry: entry); + if (!entry.isVideo) { + _sizedThumbnailProvider = ThumbnailProvider(entry: entry, extent: requestExtent); + } + } void _pauseProvider() { final isScrolling = widget.isScrollingNotifier?.value ?? false; @@ -61,24 +72,36 @@ class _ThumbnailRasterImageState extends State { // the retrieval task queue can pile up for thumbnails that got disposed // in this case we pause the image retrieval task to get it out of the queue if (isScrolling) { - _imageProvider?.pause(); + _fastThumbnailProvider?.pause(); + _sizedThumbnailProvider?.pause(); } } @override Widget build(BuildContext context) { - final image = Image( - image: _imageProvider, + final fastImage = Image( + image: _fastThumbnailProvider, width: extent, height: extent, fit: BoxFit.cover, ); + final image = _sizedThumbnailProvider == null + ? fastImage + : Image( + frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { + return frame == null ? fastImage : child; + }, + image: _sizedThumbnailProvider, + width: extent, + height: extent, + fit: BoxFit.cover, + ); return heroTag == null ? image : Hero( tag: heroTag, flightShuttleBuilder: (flight, animation, direction, fromHero, toHero) { - ImageProvider heroImageProvider = _imageProvider; + ImageProvider heroImageProvider = _fastThumbnailProvider; if (!entry.isVideo && !entry.isSvg) { final imageProvider = UriImage( uri: entry.uri, diff --git a/lib/widgets/album/thumbnail_collection.dart b/lib/widgets/album/thumbnail_collection.dart index a631cfa37..72814abbc 100644 --- a/lib/widgets/album/thumbnail_collection.dart +++ b/lib/widgets/album/thumbnail_collection.dart @@ -14,6 +14,7 @@ import 'package:aves/widgets/common/icons.dart'; import 'package:aves/widgets/common/scroll_thumb.dart'; import 'package:draggable_scrollbar/draggable_scrollbar.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:provider/provider.dart'; import 'package:tuple/tuple.dart'; @@ -32,6 +33,7 @@ class ThumbnailCollection extends StatelessWidget { final mqSize = mq.item1; final mqHorizontalPadding = mq.item2; TileExtentManager.applyTileExtent(mqSize, mqHorizontalPadding, _tileExtentNotifier); + final cacheExtent = TileExtentManager.extentMaxForSize(mqSize); // do not replace by Provider.of // so that view updates on collection filter changes @@ -47,6 +49,7 @@ class ThumbnailCollection extends StatelessWidget { appBarHeightNotifier: _appBarHeightNotifier, isScrollingNotifier: _isScrollingNotifier, scrollController: PrimaryScrollController.of(context), + cacheExtent: cacheExtent, ); final scaler = GridScaleGestureDetector( @@ -91,6 +94,7 @@ class CollectionScrollView extends StatefulWidget { final ValueNotifier appBarHeightNotifier; final ValueNotifier isScrollingNotifier; final ScrollController scrollController; + final double cacheExtent; const CollectionScrollView({ @required this.scrollableKey, @@ -99,6 +103,7 @@ class CollectionScrollView extends StatefulWidget { @required this.appBarHeightNotifier, @required this.isScrollingNotifier, @required this.scrollController, + @required this.cacheExtent, }); @override @@ -149,6 +154,7 @@ class _CollectionScrollViewState extends State { // workaround to prevent scrolling the app bar away // when there is no content and we use `SliverFillRemaining` physics: collection.isEmpty ? const NeverScrollableScrollPhysics() : null, + cacheExtent: widget.cacheExtent, slivers: [ appBar, collection.isEmpty diff --git a/lib/widgets/common/image_providers/thumbnail_provider.dart b/lib/widgets/common/image_providers/thumbnail_provider.dart index e09d6430d..065922755 100644 --- a/lib/widgets/common/image_providers/thumbnail_provider.dart +++ b/lib/widgets/common/image_providers/thumbnail_provider.dart @@ -9,8 +9,8 @@ import 'package:flutter/material.dart'; class ThumbnailProvider extends ImageProvider { ThumbnailProvider({ @required this.entry, - @required this.extent, - this.scale = 1.0, + this.extent = 0, + this.scale = 1, }) : assert(entry != null), assert(extent != null), assert(scale != null) { diff --git a/lib/widgets/debug_page.dart b/lib/widgets/debug_page.dart index 5d52a1009..aae7bba1b 100644 --- a/lib/widgets/debug_page.dart +++ b/lib/widgets/debug_page.dart @@ -8,6 +8,7 @@ import 'package:aves/model/metadata_db.dart'; import 'package:aves/model/settings.dart'; import 'package:aves/services/android_app_service.dart'; import 'package:aves/services/android_file_service.dart'; +import 'package:aves/services/image_file_service.dart'; import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/utils/file_utils.dart'; import 'package:aves/widgets/common/data_providers/media_query_data_provider.dart'; @@ -65,7 +66,7 @@ class DebugPageState extends State { Tab(icon: Icon(OMIcons.whatshot)), Tab(icon: Icon(OMIcons.settings)), Tab(icon: Icon(OMIcons.sdStorage)), - Tab(text: 'Env'), + Tab(icon: Icon(OMIcons.android)), ], ), ), @@ -108,8 +109,10 @@ class DebugPageState extends State { const Divider(), Row( children: [ - Text('Image cache: ${imageCache.currentSize} items, ${formatFilesize(imageCache.currentSizeBytes)}'), - const Spacer(), + Expanded( + child: Text('Image cache:\n\t${imageCache.currentSize}/${imageCache.maximumSize} items\n\t${formatFilesize(imageCache.currentSizeBytes)}/${formatFilesize(imageCache.maximumSizeBytes)}'), + ), + const SizedBox(width: 8), RaisedButton( onPressed: () { imageCache.clear(); @@ -121,8 +124,10 @@ class DebugPageState extends State { ), Row( children: [ - Text('SVG cache: ${PictureProvider.cacheCount} items'), - const Spacer(), + Expanded( + child: Text('SVG cache: ${PictureProvider.cacheCount} items'), + ), + const SizedBox(width: 8), RaisedButton( onPressed: () { PictureProvider.clearCache(); @@ -132,6 +137,18 @@ class DebugPageState extends State { ), ], ), + Row( + children: [ + const Expanded( + child: Text('Glide disk cache: ?'), + ), + const SizedBox(width: 8), + RaisedButton( + onPressed: () => ImageFileService.clearSizedThumbnailDiskCache(), + child: const Text('Clear'), + ), + ], + ), const Divider(), FutureBuilder( future: _dbFileSizeLoader, @@ -140,8 +157,10 @@ class DebugPageState extends State { if (snapshot.connectionState != ConnectionState.done) return const SizedBox.shrink(); return Row( children: [ - Text('DB file size: ${formatFilesize(snapshot.data)}'), - const Spacer(), + Expanded( + child: Text('DB file size: ${formatFilesize(snapshot.data)}'), + ), + const SizedBox(width: 8), RaisedButton( onPressed: () => metadataDb.reset().then((_) => _startDbReport()), child: const Text('Reset'), @@ -157,8 +176,10 @@ class DebugPageState extends State { if (snapshot.connectionState != ConnectionState.done) return const SizedBox.shrink(); return Row( children: [ - Text('DB date rows: ${snapshot.data.length}'), - const Spacer(), + Expanded( + child: Text('DB date rows: ${snapshot.data.length}'), + ), + const SizedBox(width: 8), RaisedButton( onPressed: () => metadataDb.clearDates().then((_) => _startDbReport()), child: const Text('Clear'), @@ -174,8 +195,10 @@ class DebugPageState extends State { if (snapshot.connectionState != ConnectionState.done) return const SizedBox.shrink(); return Row( children: [ - Text('DB metadata rows: ${snapshot.data.length}'), - const Spacer(), + Expanded( + child: Text('DB metadata rows: ${snapshot.data.length}'), + ), + const SizedBox(width: 8), RaisedButton( onPressed: () => metadataDb.clearMetadataEntries().then((_) => _startDbReport()), child: const Text('Clear'), @@ -191,8 +214,10 @@ class DebugPageState extends State { if (snapshot.connectionState != ConnectionState.done) return const SizedBox.shrink(); return Row( children: [ - Text('DB address rows: ${snapshot.data.length}'), - const Spacer(), + Expanded( + child: Text('DB address rows: ${snapshot.data.length}'), + ), + const SizedBox(width: 8), RaisedButton( onPressed: () => metadataDb.clearAddresses().then((_) => _startDbReport()), child: const Text('Clear'), @@ -208,8 +233,10 @@ class DebugPageState extends State { if (snapshot.connectionState != ConnectionState.done) return const SizedBox.shrink(); return Row( children: [ - Text('DB favourite rows: ${snapshot.data.length} (${favourites.count} in memory)'), - const Spacer(), + Expanded( + child: Text('DB favourite rows: ${snapshot.data.length} (${favourites.count} in memory)'), + ), + const SizedBox(width: 8), RaisedButton( onPressed: () => favourites.clear().then((_) => _startDbReport()), child: const Text('Clear'), @@ -228,8 +255,10 @@ class DebugPageState extends State { children: [ Row( children: [ - const Text('Settings'), - const Spacer(), + const Expanded( + child: Text('Settings'), + ), + const SizedBox(width: 8), RaisedButton( onPressed: () => settings.reset().then((_) => setState(() {})), child: const Text('Reset'), diff --git a/lib/widgets/fullscreen/fullscreen_body.dart b/lib/widgets/fullscreen/fullscreen_body.dart index b813247a5..445143a61 100644 --- a/lib/widgets/fullscreen/fullscreen_body.dart +++ b/lib/widgets/fullscreen/fullscreen_body.dart @@ -5,7 +5,6 @@ import 'package:aves/model/collection_lens.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/image_entry.dart'; import 'package:aves/utils/change_notifier.dart'; -import 'package:aves/utils/constants.dart'; import 'package:aves/widgets/album/collection_page.dart'; import 'package:aves/widgets/common/action_delegates/entry_action_delegate.dart'; import 'package:aves/widgets/common/image_providers/thumbnail_provider.dart'; @@ -477,7 +476,8 @@ class _FullscreenVerticalPageViewState extends State void _onImageChanged() async { await UriImage(uri: entry.uri, mimeType: entry.mimeType).evict(); - await ThumbnailProvider(entry: entry, extent: Constants.thumbnailCacheExtent).evict(); + // TODO TLAD also evict `ThumbnailProvider` with specified extents + await ThumbnailProvider(entry: entry).evict(); if (entry.path != null) await FileImage(File(entry.path)).evict(); // rebuild to refresh the Image inside ImagePage setState(() {}); diff --git a/lib/widgets/fullscreen/image_view.dart b/lib/widgets/fullscreen/image_view.dart index 9b31e7290..1af06bde7 100644 --- a/lib/widgets/fullscreen/image_view.dart +++ b/lib/widgets/fullscreen/image_view.dart @@ -56,10 +56,7 @@ class ImageView extends StatelessWidget { // if the hero tag wraps the whole `PhotoView` and the `loadingBuilder` is not provided, // there's a black frame between the hero animation and the final image, even when it's cached. - final thumbnailProvider = ThumbnailProvider( - entry: entry, - extent: Constants.thumbnailCacheExtent, - ); + final fastThumbnailProvider = ThumbnailProvider(entry: entry); // this loading builder shows a transition image until the final image is ready // if the image is already in the cache it will show the final image, otherwise the thumbnail // in any case, we should use `Center` + `AspectRatio` + `Fill` so that the transition image @@ -86,7 +83,7 @@ class ImageView extends StatelessWidget { mimeType: entry.mimeType, colorFilter: Constants.svgColorFilter, ), - placeholderBuilder: (context) => loadingBuilder(context, thumbnailProvider), + placeholderBuilder: (context) => loadingBuilder(context, fastThumbnailProvider), ), backgroundDecoration: backgroundDecoration, scaleStateChangedCallback: onScaleChanged, @@ -107,7 +104,7 @@ class ImageView extends StatelessWidget { // we still provide a `loadingBuilder` in that case to avoid a black frame after hero animation loadingBuilder: (context, event) => loadingBuilder( context, - imageCache.statusForKey(uriImage).keepAlive ? uriImage : thumbnailProvider, + imageCache.statusForKey(uriImage).keepAlive ? uriImage : fastThumbnailProvider, ), loadFailedChild: const EmptyContent( icon: AIcons.error,