use Glide for a lower priority pass of higher quality thumbnails

This commit is contained in:
Thibault Deckers 2020-06-04 12:37:29 +09:00
parent f7ef4c0d01
commit 65fffdd21a
13 changed files with 189 additions and 122 deletions

View file

@ -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<ImageDecodeTask.Params, Void, Ima
static class Params {
ImageEntry entry;
int width, height;
Integer width, height, defaultSize;
MethodChannel.Result result;
Params(ImageEntry entry, int width, int height, MethodChannel.Result result) {
Params(ImageEntry entry, @Nullable Integer width, @Nullable Integer height, Integer defaultSize, MethodChannel.Result result) {
this.entry = entry;
this.width = width;
this.height = height;
this.result = result;
this.defaultSize = defaultSize;
}
}
@ -69,6 +71,12 @@ public class ImageDecodeTask extends AsyncTask<ImageDecodeTask.Params, Void, Ima
Bitmap bitmap = null;
if (!this.isCancelled()) {
Exception exception = null;
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);
@ -78,8 +86,9 @@ public class ImageDecodeTask extends AsyncTask<ImageDecodeTask.Params, Void, Ima
} 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<ImageDecodeTask.Params, Void, Ima
@RequiresApi(api = Build.VERSION_CODES.Q)
private Bitmap getThumbnailBytesByResolver(Params params) throws IOException {
ImageEntry entry = params.entry;
int width = params.width;
int height = params.height;
Integer width = params.width;
Integer height = params.height;
// Log.d(LOG_TAG, "getThumbnailBytesByResolver width=" + width + ", path=" + entry.path);
ContentResolver resolver = activity.getContentResolver();
return resolver.loadThumbnail(entry.uri, new Size(width, height), null);
@ -141,6 +151,7 @@ public class ImageDecodeTask extends AsyncTask<ImageDecodeTask.Params, Void, Ima
ImageEntry entry = params.entry;
int width = params.width;
int height = params.height;
// Log.d(LOG_TAG, "getThumbnailByGlide width=" + width + ", path=" + entry.path);
// add signature to ignore cache for images which got modified but kept the same URI
Key signature = new ObjectKey("" + entry.dateModifiedSecs + entry.width + entry.orientationDegrees);

View file

@ -7,6 +7,8 @@ import android.os.Looper;
import androidx.annotation.NonNull;
import com.bumptech.glide.Glide;
import java.util.Map;
import deckers.thibault.aves.model.ImageEntry;
@ -39,6 +41,10 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler {
case "getThumbnail":
new Thread(() -> 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) {

View file

@ -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;

View file

@ -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<void> getImageEntries() async {
try {
await platform.invokeMethod('getImageEntries');
@ -64,28 +62,36 @@ class ImageFileService {
static Future<Uint8List> 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', <String, dynamic>{
'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<void> 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<T> resumeThumbnail<T>(Object taskKey) => servicePolicy.resume<T>(taskKey, thumbnailPriority);
static bool cancelThumbnail(Object taskKey) => servicePolicy.pause(taskKey, [ServiceCallPriority.getFastThumbnail, ServiceCallPriority.getSizedThumbnail]);
static Future<T> resumeThumbnail<T>(Object taskKey) => servicePolicy.resume<T>(taskKey);
static Stream<ImageOpEvent> delete(Iterable<ImageEntry> entries) {
try {

View file

@ -50,7 +50,7 @@ class MetadataService {
}
return null;
},
priority: ServiceCallPriority.background,
priority: ServiceCallPriority.getMetadata,
);
}

View file

@ -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<Object, _Task> _paused = {};
final Queue<_Task> _asapQueue = Queue(), _normalQueue = Queue(), _backgroundQueue = Queue();
List<Queue<_Task>> _queues;
final Map<Object, Tuple2<int, _Task>> _paused = {};
final SplayTreeMap<int, Queue<_Task>> _queues = SplayTreeMap();
_Task _running;
ServicePolicy._private() {
_queues = [_asapQueue, _normalQueue, _backgroundQueue];
}
ServicePolicy._private();
Future<T> call<T>(
Future<T> 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<T>();
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<T> resume<T>(Object key, ServiceCallPriority priority) {
var task = _paused.remove(key);
if (task == null) return null;
Future<T> resume<T>(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;
bool _takeOut(Object key, Iterable<int> 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)) {
cancelled = true;
task.completer.completeError(CancelledException());
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);
bool cancel(Object key, Iterable<int> priorities) {
return _takeOut(key, priorities, (priority, task) => task.completer.completeError(CancelledException()));
}
});
return paused;
bool pause(Object key, Iterable<int> 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;
}

View file

@ -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);

View file

@ -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<ThumbnailRasterImage> {
ThumbnailProvider _imageProvider;
ThumbnailProvider _fastThumbnailProvider, _sizedThumbnailProvider;
ImageEntry get entry => widget.entry;
@ -32,6 +33,11 @@ class _ThumbnailRasterImageState extends State<ThumbnailRasterImage> {
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<ThumbnailRasterImage> {
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,14 +72,26 @@ class _ThumbnailRasterImageState extends State<ThumbnailRasterImage> {
// 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,
@ -78,7 +101,7 @@ class _ThumbnailRasterImageState extends State<ThumbnailRasterImage> {
: 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,

View file

@ -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<CollectionLens>
// 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<double> appBarHeightNotifier;
final ValueNotifier<bool> 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<CollectionScrollView> {
// 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

View file

@ -9,8 +9,8 @@ import 'package:flutter/material.dart';
class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
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) {

View file

@ -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<DebugPage> {
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<DebugPage> {
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<DebugPage> {
),
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<DebugPage> {
),
],
),
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<DebugPage> {
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<DebugPage> {
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<DebugPage> {
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<DebugPage> {
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<DebugPage> {
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<DebugPage> {
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'),

View file

@ -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<FullscreenVerticalPageView>
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(() {});

View file

@ -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,