better scrolling

This commit is contained in:
mertalev 2025-08-13 18:01:47 -04:00
parent c988342de1
commit 3100702e93
No known key found for this signature in database
GPG key ID: DF6ABC77AAD98C95
8 changed files with 233 additions and 88 deletions

View file

@ -32,3 +32,31 @@ class FastClampingScrollPhysics extends ClampingScrollPhysics {
damping: 80, damping: 80,
); );
} }
class ScrollUnawareScrollPhysics extends ScrollPhysics {
const ScrollUnawareScrollPhysics({super.parent});
@override
ScrollUnawareScrollPhysics applyTo(ScrollPhysics? ancestor) {
return ScrollUnawareScrollPhysics(parent: buildParent(ancestor));
}
@override
bool recommendDeferredLoading(double velocity, ScrollMetrics metrics, BuildContext context) {
return false;
}
}
class ScrollUnawareClampingScrollPhysics extends ClampingScrollPhysics {
const ScrollUnawareClampingScrollPhysics({super.parent});
@override
ScrollUnawareClampingScrollPhysics applyTo(ScrollPhysics? ancestor) {
return ScrollUnawareClampingScrollPhysics(parent: buildParent(ancestor));
}
@override
bool recommendDeferredLoading(double velocity, ScrollMetrics metrics, BuildContext context) {
return false;
}
}

View file

@ -29,6 +29,9 @@ abstract class ImageRequest {
return; return;
} }
_isCancelled = true; _isCancelled = true;
if (!kReleaseMode) {
debugPrint('Cancelling image request $requestId');
}
return _onCancelled(); return _onCancelled();
} }
@ -37,6 +40,9 @@ abstract class ImageRequest {
Future<ui.FrameInfo?> _fromPlatformImage(Map<String, int> info) async { Future<ui.FrameInfo?> _fromPlatformImage(Map<String, int> info) async {
final address = info['pointer']; final address = info['pointer'];
if (address == null) { if (address == null) {
if (!kReleaseMode) {
debugPrint('Platform image request for $requestId was cancelled');
}
return null; return null;
} }
@ -84,7 +90,15 @@ class ThumbhashImageRequest extends ImageRequest {
return null; return null;
} }
Stopwatch? stopwatch;
if (!kReleaseMode) {
stopwatch = Stopwatch()..start();
}
final Map<String, int> info = await thumbnailApi.getThumbhash(thumbhash); final Map<String, int> info = await thumbnailApi.getThumbhash(thumbhash);
if (!kReleaseMode) {
stopwatch!.stop();
debugPrint('Thumbhash request $requestId took ${stopwatch.elapsedMilliseconds} ms');
}
final frame = await _fromPlatformImage(info); final frame = await _fromPlatformImage(info);
return frame == null ? null : ImageInfo(image: frame.image, scale: scale); return frame == null ? null : ImageInfo(image: frame.image, scale: scale);
} }
@ -108,12 +122,20 @@ class LocalImageRequest extends ImageRequest {
return null; return null;
} }
Stopwatch? stopwatch;
if (!kReleaseMode) {
stopwatch = Stopwatch()..start();
}
final Map<String, int> info = await thumbnailApi.requestImage( final Map<String, int> info = await thumbnailApi.requestImage(
localId, localId,
requestId: requestId, requestId: requestId,
width: width, width: width,
height: height, height: height,
); );
if (!kReleaseMode) {
stopwatch!.stop();
debugPrint('Local image request $requestId took ${stopwatch.elapsedMilliseconds} ms');
}
final frame = await _fromPlatformImage(info); final frame = await _fromPlatformImage(info);
return frame == null ? null : ImageInfo(image: frame.image, scale: scale); return frame == null ? null : ImageInfo(image: frame.image, scale: scale);
} }
@ -127,7 +149,7 @@ class LocalImageRequest extends ImageRequest {
class RemoteImageRequest extends ImageRequest { class RemoteImageRequest extends ImageRequest {
static final log = Logger('RemoteImageRequest'); static final log = Logger('RemoteImageRequest');
static final cacheManager = RemoteImageCacheManager(); static final cacheManager = RemoteImageCacheManager();
static final client = HttpClient(); static final client = HttpClient()..maxConnectionsPerHost = 32;
String uri; String uri;
Map<String, String> headers; Map<String, String> headers;
HttpClientRequest? _request; HttpClientRequest? _request;
@ -140,29 +162,42 @@ class RemoteImageRequest extends ImageRequest {
return null; return null;
} }
try { // TODO: the cache manager makes everything sequential with its DB calls and its operations cannot be cancelled,
// The DB calls made by the cache manager are a *massive* bottleneck (6+ seconds) with high concurrency. // so it just makes things slower and more memory hungry. Even just saving files to disk
// Since it isn't possible to cancel these operations, we only prefer the cache when they can be avoided. // for offline use adds too much overhead as these calls add up. We only prefer fetching from it when
// The DB hit is left as a fallback for offline use. // it can skip the DB call.
final cachedFileBuffer = await _loadCachedFile(uri, inMemoryOnly: true); final cachedFileImage = await _loadCachedFile(uri, decode, scale, inMemoryOnly: true);
if (cachedFileBuffer != null) { if (cachedFileImage != null) {
return _decodeBuffer(cachedFileBuffer, decode, scale); return cachedFileImage;
} }
try {
Stopwatch? stopwatch;
if (!kReleaseMode) {
stopwatch = Stopwatch()..start();
}
final buffer = await _downloadImage(uri); final buffer = await _downloadImage(uri);
if (buffer == null || _isCancelled) { if (buffer == null) {
return null; return null;
} }
if (!kReleaseMode) {
stopwatch!.stop();
debugPrint('Remote image download request $requestId took ${stopwatch.elapsedMilliseconds} ms');
}
return await _decodeBuffer(buffer, decode, scale); return await _decodeBuffer(buffer, decode, scale);
} catch (e) { } catch (e) {
if (e is HttpException && (e.message.endsWith('aborted') || e.message.startsWith('Connection closed'))) { if (_isCancelled) {
if (!kReleaseMode) {
debugPrint('Remote image download request for $requestId was cancelled');
}
return null; return null;
} }
log.severe('Failed to load remote image', e);
final buffer = await _loadCachedFile(uri, inMemoryOnly: false); final cachedFileImage = await _loadCachedFile(uri, decode, scale, inMemoryOnly: false);
if (buffer != null) { if (cachedFileImage != null) {
return _decodeBuffer(buffer, decode, scale); return cachedFileImage;
} }
rethrow; rethrow;
} finally { } finally {
_request = null; _request = null;
@ -170,45 +205,59 @@ class RemoteImageRequest extends ImageRequest {
} }
Future<ImmutableBuffer?> _downloadImage(String url) async { Future<ImmutableBuffer?> _downloadImage(String url) async {
final request = _request = await client.getUrl(Uri.parse(url));
if (_isCancelled) { if (_isCancelled) {
return null; return null;
} }
final request = _request = await client.getUrl(Uri.parse(url));
if (_isCancelled) {
request.abort();
return _request = null;
}
final headers = ApiService.getRequestHeaders(); final headers = ApiService.getRequestHeaders();
for (final entry in headers.entries) { for (final entry in headers.entries) {
request.headers.set(entry.key, entry.value); request.headers.set(entry.key, entry.value);
} }
final response = await request.close(); final response = await request.close();
if (_isCancelled) {
return null;
}
final bytes = await consolidateHttpClientResponseBytes(response); final bytes = await consolidateHttpClientResponseBytes(response);
_cacheFile(url, bytes);
if (_isCancelled) { if (_isCancelled) {
return null; return null;
} }
return await ImmutableBuffer.fromUint8List(bytes); return await ImmutableBuffer.fromUint8List(bytes);
} }
Future<void> _cacheFile(String url, Uint8List bytes) async { Future<ImageInfo?> _loadCachedFile(
try { String url,
await cacheManager.putFile(url, bytes); ImageDecoderCallback decode,
} catch (e) { double scale, {
log.severe('Failed to cache image', e); required bool inMemoryOnly,
} }) async {
}
Future<ImmutableBuffer?> _loadCachedFile(String url, {required bool inMemoryOnly}) async {
if (_isCancelled) { if (_isCancelled) {
return null; return null;
} }
final file = await (inMemoryOnly ? cacheManager.getFileFromMemory(url) : cacheManager.getFileFromCache(url)); final file = await (inMemoryOnly ? cacheManager.getFileFromMemory(url) : cacheManager.getFileFromCache(url));
if (_isCancelled || file == null) { if (_isCancelled || file == null) {
return null; return null;
} }
return await ImmutableBuffer.fromFilePath(file.file.path);
try {
final buffer = await ImmutableBuffer.fromFilePath(file.file.path);
return await _decodeBuffer(buffer, decode, scale);
} catch (e) {
log.severe('Failed to decode cached image', e);
_evictFile(url);
return null;
}
}
Future<void> _evictFile(String url) async {
try {
await cacheManager.removeFile(url);
} catch (e) {
log.severe('Failed to remove cached image', e);
}
} }
Future<ImageInfo?> _decodeBuffer(ImmutableBuffer buffer, ImageDecoderCallback decode, scale) async { Future<ImageInfo?> _decodeBuffer(ImmutableBuffer buffer, ImageDecoderCallback decode, scale) async {

View file

@ -2,13 +2,14 @@ import 'package:flutter/material.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/thumb_hash_provider.dart'; import 'package:immich_mobile/presentation/widgets/images/thumb_hash_provider.dart';
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart'; import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart';
import 'package:immich_mobile/widgets/common/fade_in_placeholder_image.dart'; import 'package:immich_mobile/widgets/common/fade_in_placeholder_image.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:octo_image/octo_image.dart'; import 'package:octo_image/octo_image.dart';
class Thumbnail extends StatelessWidget { class Thumbnail extends StatefulWidget {
const Thumbnail({this.asset, this.remoteId, this.size = const Size.square(256), this.fit = BoxFit.cover, super.key}) const Thumbnail({this.asset, this.remoteId, this.size = kTimelineFixedTileExtent, this.fit = BoxFit.cover, super.key})
: assert(asset != null || remoteId != null, 'Either asset or remoteId must be provided'); : assert(asset != null || remoteId != null, 'Either asset or remoteId must be provided');
final BaseAsset? asset; final BaseAsset? asset;
@ -16,46 +17,79 @@ class Thumbnail extends StatelessWidget {
final Size size; final Size size;
final BoxFit fit; final BoxFit fit;
@override
createState() => _ThumbnailState();
}
class _ThumbnailState extends State<Thumbnail> {
ImageProvider? provider;
@override
void initState() {
provider = getThumbnailImageProvider(asset: widget.asset, remoteId: widget.remoteId);
super.initState();
}
@override
void didUpdateWidget(covariant Thumbnail oldWidget) {
if (oldWidget.asset == widget.asset && oldWidget.remoteId == widget.remoteId) {
return;
}
if (provider is CancellableImageProvider) {
(provider as CancellableImageProvider).cancel();
}
provider = getThumbnailImageProvider(asset: widget.asset, remoteId: widget.remoteId);
super.didUpdateWidget(oldWidget);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final thumbHash = asset is RemoteAsset ? (asset as RemoteAsset).thumbHash : null; final thumbHash = widget.asset is RemoteAsset ? (widget.asset as RemoteAsset).thumbHash : null;
final provider = getThumbnailImageProvider(asset: asset, remoteId: remoteId);
return OctoImage.fromSet( return OctoImage.fromSet(
image: provider, image: provider!,
octoSet: OctoSet( octoSet: OctoSet(
placeholderBuilder: _blurHashPlaceholderBuilder(thumbHash, fit: fit), placeholderBuilder: _blurHashPlaceholderBuilder(thumbHash),
errorBuilder: _blurHashErrorBuilder(thumbHash, provider: provider, fit: fit, asset: asset), errorBuilder: _blurHashErrorBuilder(thumbHash, provider: provider, asset: widget.asset),
), ),
fadeOutDuration: const Duration(milliseconds: 100), fadeOutDuration: const Duration(milliseconds: 100),
fadeInDuration: Duration.zero, fadeInDuration: Duration.zero,
width: size.width, width: widget.size.width,
height: size.height, height: widget.size.height,
fit: fit, fit: widget.fit,
placeholderFadeInDuration: Duration.zero, placeholderFadeInDuration: Duration.zero,
); );
} }
}
OctoPlaceholderBuilder _blurHashPlaceholderBuilder(String? thumbHash, {BoxFit? fit}) { @override
return (context) => thumbHash == null void dispose() {
? const ThumbnailPlaceholder() if (provider is CancellableImageProvider) {
: FadeInPlaceholderImage( (provider as CancellableImageProvider).cancel();
placeholder: const ThumbnailPlaceholder(), }
image: ThumbHashProvider(thumbHash: thumbHash), super.dispose();
fit: fit ?? BoxFit.cover, }
OctoPlaceholderBuilder _blurHashPlaceholderBuilder(String? thumbHash) {
return (context) => thumbHash == null
? const ThumbnailPlaceholder()
: FadeInPlaceholderImage(
placeholder: const ThumbnailPlaceholder(),
image: ThumbHashProvider(thumbHash: thumbHash),
fit: widget.fit,
width: widget.size.width,
height: widget.size.height,
);
}
OctoErrorBuilder _blurHashErrorBuilder(String? blurhash, {BaseAsset? asset, ImageProvider? provider}) =>
(context, e, s) {
Logger("ImThumbnail").warning("Error loading thumbnail for ${asset?.name}", e, s);
return Stack(
alignment: Alignment.center,
children: [
_blurHashPlaceholderBuilder(blurhash)(context),
const Opacity(opacity: 0.75, child: Icon(Icons.error_outline_rounded)),
],
); );
};
} }
OctoErrorBuilder _blurHashErrorBuilder(String? blurhash, {BaseAsset? asset, ImageProvider? provider, BoxFit? fit}) =>
(context, e, s) {
Logger("ImThumbnail").warning("Error loading thumbnail for ${asset?.name}", e, s);
provider?.evict();
return Stack(
alignment: Alignment.center,
children: [
_blurHashPlaceholderBuilder(blurhash, fit: fit)(context),
const Opacity(opacity: 0.75, child: Icon(Icons.error_outline_rounded)),
],
);
};

View file

@ -14,6 +14,7 @@ import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/scroll_extensions.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart'; import 'package:immich_mobile/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/scrubber.widget.dart'; import 'package:immich_mobile/presentation/widgets/timeline/scrubber.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart'; import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
@ -106,7 +107,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
bool _dragging = false; bool _dragging = false;
TimelineAssetIndex? _dragAnchorIndex; TimelineAssetIndex? _dragAnchorIndex;
final Set<BaseAsset> _draggedAssets = HashSet(); final Set<BaseAsset> _draggedAssets = HashSet();
ScrollPhysics? _scrollPhysics; ScrollPhysics _scrollPhysics = const ScrollUnawareScrollPhysics();
int _perRow = 4; int _perRow = 4;
double _scaleFactor = 3.0; double _scaleFactor = 3.0;
@ -188,7 +189,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
// Drag selection methods // Drag selection methods
void _setDragStartIndex(TimelineAssetIndex index) { void _setDragStartIndex(TimelineAssetIndex index) {
setState(() { setState(() {
_scrollPhysics = const ClampingScrollPhysics(); _scrollPhysics = const ScrollUnawareClampingScrollPhysics();
_dragAnchorIndex = index; _dragAnchorIndex = index;
_dragging = true; _dragging = true;
}); });
@ -198,7 +199,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
// Update the physics post frame to prevent sudden change in physics on iOS. // Update the physics post frame to prevent sudden change in physics on iOS.
setState(() { setState(() {
_scrollPhysics = null; _scrollPhysics = const ScrollUnawareScrollPhysics();
}); });
}); });
setState(() { setState(() {

View file

@ -1,6 +1,7 @@
import 'package:flutter/painting.dart'; import 'package:flutter/painting.dart';
import 'package:immich_mobile/presentation/widgets/images/local_image_provider.dart'; import 'package:immich_mobile/presentation/widgets/images/local_image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/thumb_hash_provider.dart';
import 'package:immich_mobile/providers/image/immich_local_image_provider.dart'; import 'package:immich_mobile/providers/image/immich_local_image_provider.dart';
import 'package:immich_mobile/providers/image/immich_local_thumbnail_provider.dart'; import 'package:immich_mobile/providers/image/immich_local_thumbnail_provider.dart';
import 'package:immich_mobile/providers/image/immich_remote_image_provider.dart'; import 'package:immich_mobile/providers/image/immich_remote_image_provider.dart';
@ -9,6 +10,7 @@ import 'package:immich_mobile/providers/image/immich_remote_thumbnail_provider.d
/// [ImageCache] that uses two caches for small and large images /// [ImageCache] that uses two caches for small and large images
/// so that a single large image does not evict all small images /// so that a single large image does not evict all small images
final class CustomImageCache implements ImageCache { final class CustomImageCache implements ImageCache {
final _thumbhash = ImageCache()..maximumSize = 0;
final _small = ImageCache(); final _small = ImageCache();
final _large = ImageCache()..maximumSize = 5; // Maximum 5 images final _large = ImageCache()..maximumSize = 5; // Maximum 5 images
@ -39,13 +41,16 @@ final class CustomImageCache implements ImageCache {
/// Gets the cache for the given key /// Gets the cache for the given key
/// [_large] is used for [ImmichLocalImageProvider] and [ImmichRemoteImageProvider] /// [_large] is used for [ImmichLocalImageProvider] and [ImmichRemoteImageProvider]
/// [_small] is used for [ImmichLocalThumbnailProvider] and [ImmichRemoteThumbnailProvider] /// [_small] is used for [ImmichLocalThumbnailProvider] and [ImmichRemoteThumbnailProvider]
ImageCache _cacheForKey(Object key) => ImageCache _cacheForKey(Object key) {
(key is ImmichLocalImageProvider || return switch (key) {
key is ImmichRemoteImageProvider || ImmichLocalImageProvider() ||
key is LocalFullImageProvider || ImmichRemoteImageProvider() ||
key is RemoteFullImageProvider) LocalFullImageProvider() ||
? _large RemoteFullImageProvider() => _large,
: _small; ThumbHashProvider() => _thumbhash,
_ => _small,
};
}
@override @override
bool containsKey(Object key) { bool containsKey(Object key) {

View file

@ -1,30 +1,43 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:immich_mobile/widgets/common/transparent_image.dart';
class FadeInPlaceholderImage extends StatelessWidget { class FadeInPlaceholderImage extends StatelessWidget {
final Widget placeholder; final Widget placeholder;
final ImageProvider image; final ImageProvider image;
final Duration duration; final Duration duration;
final BoxFit fit; final BoxFit fit;
final double width;
final double height;
const FadeInPlaceholderImage({ const FadeInPlaceholderImage({
super.key, super.key,
required this.placeholder, required this.placeholder,
required this.image, required this.image,
required this.width,
required this.height,
this.duration = const Duration(milliseconds: 100), this.duration = const Duration(milliseconds: 100),
this.fit = BoxFit.cover, this.fit = BoxFit.cover,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SizedBox.expand( final stopwatch = Stopwatch()..start();
child: Stack( return Image(
fit: StackFit.expand, image: image,
children: [ frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
placeholder, if (frame == null) {
FadeInImage(fadeInDuration: duration, image: image, fit: fit, placeholder: MemoryImage(kTransparentImage)), return AnimatedSwitcher(duration: duration, child: placeholder);
], }
),
stopwatch.stop();
if (stopwatch.elapsedMilliseconds < 32) {
return child;
}
return AnimatedSwitcher(duration: duration, child: child);
},
filterQuality: FilterQuality.low,
fit: fit,
width: width,
height: height,
); );
} }
} }

View file

@ -61,7 +61,7 @@ class ImmichThumbnail extends HookConsumerWidget {
customErrorBuilder(BuildContext ctx, Object error, StackTrace? stackTrace) { customErrorBuilder(BuildContext ctx, Object error, StackTrace? stackTrace) {
thumbnailProviderInstance.evict(); thumbnailProviderInstance.evict();
final originalErrorWidgetBuilder = blurHashErrorBuilder(blurhash, fit: fit); final originalErrorWidgetBuilder = blurHashErrorBuilder(blurhash, width: width, height: height, fit: fit);
return originalErrorWidgetBuilder(ctx, error, stackTrace); return originalErrorWidgetBuilder(ctx, error, stackTrace);
} }
@ -72,7 +72,7 @@ class ImmichThumbnail extends HookConsumerWidget {
fadeInDuration: Duration.zero, fadeInDuration: Duration.zero,
fadeOutDuration: const Duration(milliseconds: 100), fadeOutDuration: const Duration(milliseconds: 100),
octoSet: OctoSet( octoSet: OctoSet(
placeholderBuilder: blurHashPlaceholderBuilder(blurhash, fit: fit), placeholderBuilder: blurHashPlaceholderBuilder(blurhash, width: width, height: height, fit: fit),
errorBuilder: customErrorBuilder, errorBuilder: customErrorBuilder,
), ),
image: thumbnailProviderInstance, image: thumbnailProviderInstance,

View file

@ -6,25 +6,40 @@ import 'package:octo_image/octo_image.dart';
/// Simple set to show [OctoPlaceholder.circularProgressIndicator] as /// Simple set to show [OctoPlaceholder.circularProgressIndicator] as
/// placeholder and [OctoError.icon] as error. /// placeholder and [OctoError.icon] as error.
OctoSet blurHashOrPlaceholder(Uint8List? blurhash, {BoxFit? fit, Text? errorMessage}) { OctoSet blurHashOrPlaceholder(
Uint8List? blurhash, {
required double width,
required double height,
BoxFit? fit,
Text? errorMessage,
}) {
return OctoSet( return OctoSet(
placeholderBuilder: blurHashPlaceholderBuilder(blurhash, fit: fit), placeholderBuilder: blurHashPlaceholderBuilder(blurhash, width: width, height: height, fit: fit),
errorBuilder: blurHashErrorBuilder(blurhash, fit: fit, message: errorMessage), errorBuilder: blurHashErrorBuilder(blurhash, width: width, height: height, fit: fit, message: errorMessage),
); );
} }
OctoPlaceholderBuilder blurHashPlaceholderBuilder(Uint8List? blurhash, {BoxFit? fit}) { OctoPlaceholderBuilder blurHashPlaceholderBuilder(
Uint8List? blurhash, {
required double width,
required double height,
BoxFit? fit,
}) {
return (context) => blurhash == null return (context) => blurhash == null
? const ThumbnailPlaceholder() ? const ThumbnailPlaceholder()
: FadeInPlaceholderImage( : FadeInPlaceholderImage(
placeholder: const ThumbnailPlaceholder(), placeholder: const ThumbnailPlaceholder(),
image: MemoryImage(blurhash), image: MemoryImage(blurhash),
fit: fit ?? BoxFit.cover, fit: fit ?? BoxFit.cover,
width: width,
height: height,
); );
} }
OctoErrorBuilder blurHashErrorBuilder( OctoErrorBuilder blurHashErrorBuilder(
Uint8List? blurhash, { Uint8List? blurhash, {
required double width,
required double height,
BoxFit? fit, BoxFit? fit,
Text? message, Text? message,
IconData? icon, IconData? icon,
@ -32,7 +47,7 @@ OctoErrorBuilder blurHashErrorBuilder(
double? iconSize, double? iconSize,
}) { }) {
return OctoError.placeholderWithErrorIcon( return OctoError.placeholderWithErrorIcon(
blurHashPlaceholderBuilder(blurhash, fit: fit), blurHashPlaceholderBuilder(blurhash, width: width, height: width, fit: fit),
message: message, message: message,
icon: icon, icon: icon,
iconColor: iconColor, iconColor: iconColor,