diff --git a/mobile/lib/extensions/scroll_extensions.dart b/mobile/lib/extensions/scroll_extensions.dart index 169032ff5d..2d812b3a35 100644 --- a/mobile/lib/extensions/scroll_extensions.dart +++ b/mobile/lib/extensions/scroll_extensions.dart @@ -32,3 +32,31 @@ class FastClampingScrollPhysics extends ClampingScrollPhysics { 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; + } +} diff --git a/mobile/lib/infrastructure/repositories/asset_media.repository.dart b/mobile/lib/infrastructure/repositories/asset_media.repository.dart index cdfc894e15..19f164fd73 100644 --- a/mobile/lib/infrastructure/repositories/asset_media.repository.dart +++ b/mobile/lib/infrastructure/repositories/asset_media.repository.dart @@ -29,6 +29,9 @@ abstract class ImageRequest { return; } _isCancelled = true; + if (!kReleaseMode) { + debugPrint('Cancelling image request $requestId'); + } return _onCancelled(); } @@ -37,6 +40,9 @@ abstract class ImageRequest { Future _fromPlatformImage(Map info) async { final address = info['pointer']; if (address == null) { + if (!kReleaseMode) { + debugPrint('Platform image request for $requestId was cancelled'); + } return null; } @@ -84,7 +90,15 @@ class ThumbhashImageRequest extends ImageRequest { return null; } + Stopwatch? stopwatch; + if (!kReleaseMode) { + stopwatch = Stopwatch()..start(); + } final Map info = await thumbnailApi.getThumbhash(thumbhash); + if (!kReleaseMode) { + stopwatch!.stop(); + debugPrint('Thumbhash request $requestId took ${stopwatch.elapsedMilliseconds} ms'); + } final frame = await _fromPlatformImage(info); return frame == null ? null : ImageInfo(image: frame.image, scale: scale); } @@ -108,12 +122,20 @@ class LocalImageRequest extends ImageRequest { return null; } + Stopwatch? stopwatch; + if (!kReleaseMode) { + stopwatch = Stopwatch()..start(); + } final Map info = await thumbnailApi.requestImage( localId, requestId: requestId, width: width, height: height, ); + if (!kReleaseMode) { + stopwatch!.stop(); + debugPrint('Local image request $requestId took ${stopwatch.elapsedMilliseconds} ms'); + } final frame = await _fromPlatformImage(info); return frame == null ? null : ImageInfo(image: frame.image, scale: scale); } @@ -127,7 +149,7 @@ class LocalImageRequest extends ImageRequest { class RemoteImageRequest extends ImageRequest { static final log = Logger('RemoteImageRequest'); static final cacheManager = RemoteImageCacheManager(); - static final client = HttpClient(); + static final client = HttpClient()..maxConnectionsPerHost = 32; String uri; Map headers; HttpClientRequest? _request; @@ -140,29 +162,42 @@ class RemoteImageRequest extends ImageRequest { return null; } - try { - // The DB calls made by the cache manager are a *massive* bottleneck (6+ seconds) with high concurrency. - // Since it isn't possible to cancel these operations, we only prefer the cache when they can be avoided. - // The DB hit is left as a fallback for offline use. - final cachedFileBuffer = await _loadCachedFile(uri, inMemoryOnly: true); - if (cachedFileBuffer != null) { - return _decodeBuffer(cachedFileBuffer, decode, scale); - } + // TODO: the cache manager makes everything sequential with its DB calls and its operations cannot be cancelled, + // so it just makes things slower and more memory hungry. Even just saving files to disk + // for offline use adds too much overhead as these calls add up. We only prefer fetching from it when + // it can skip the DB call. + final cachedFileImage = await _loadCachedFile(uri, decode, scale, inMemoryOnly: true); + if (cachedFileImage != null) { + return cachedFileImage; + } + try { + Stopwatch? stopwatch; + if (!kReleaseMode) { + stopwatch = Stopwatch()..start(); + } final buffer = await _downloadImage(uri); - if (buffer == null || _isCancelled) { + if (buffer == null) { return null; } + if (!kReleaseMode) { + stopwatch!.stop(); + debugPrint('Remote image download request $requestId took ${stopwatch.elapsedMilliseconds} ms'); + } return await _decodeBuffer(buffer, decode, scale); } 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; } - log.severe('Failed to load remote image', e); - final buffer = await _loadCachedFile(uri, inMemoryOnly: false); - if (buffer != null) { - return _decodeBuffer(buffer, decode, scale); + + final cachedFileImage = await _loadCachedFile(uri, decode, scale, inMemoryOnly: false); + if (cachedFileImage != null) { + return cachedFileImage; } + rethrow; } finally { _request = null; @@ -170,45 +205,59 @@ class RemoteImageRequest extends ImageRequest { } Future _downloadImage(String url) async { - final request = _request = await client.getUrl(Uri.parse(url)); if (_isCancelled) { return null; } + final request = _request = await client.getUrl(Uri.parse(url)); + if (_isCancelled) { + request.abort(); + return _request = null; + } + final headers = ApiService.getRequestHeaders(); for (final entry in headers.entries) { request.headers.set(entry.key, entry.value); } final response = await request.close(); - if (_isCancelled) { - return null; - } - final bytes = await consolidateHttpClientResponseBytes(response); - _cacheFile(url, bytes); if (_isCancelled) { return null; } return await ImmutableBuffer.fromUint8List(bytes); } - Future _cacheFile(String url, Uint8List bytes) async { - try { - await cacheManager.putFile(url, bytes); - } catch (e) { - log.severe('Failed to cache image', e); - } - } - - Future _loadCachedFile(String url, {required bool inMemoryOnly}) async { + Future _loadCachedFile( + String url, + ImageDecoderCallback decode, + double scale, { + required bool inMemoryOnly, + }) async { if (_isCancelled) { return null; } + final file = await (inMemoryOnly ? cacheManager.getFileFromMemory(url) : cacheManager.getFileFromCache(url)); if (_isCancelled || file == 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 _evictFile(String url) async { + try { + await cacheManager.removeFile(url); + } catch (e) { + log.severe('Failed to remove cached image', e); + } } Future _decodeBuffer(ImmutableBuffer buffer, ImageDecoderCallback decode, scale) async { diff --git a/mobile/lib/presentation/widgets/images/thumbnail.widget.dart b/mobile/lib/presentation/widgets/images/thumbnail.widget.dart index 9965c1bfd5..04638fc16f 100644 --- a/mobile/lib/presentation/widgets/images/thumbnail.widget.dart +++ b/mobile/lib/presentation/widgets/images/thumbnail.widget.dart @@ -2,13 +2,14 @@ import 'package:flutter/material.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/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/common/fade_in_placeholder_image.dart'; import 'package:logging/logging.dart'; import 'package:octo_image/octo_image.dart'; -class Thumbnail extends StatelessWidget { - const Thumbnail({this.asset, this.remoteId, this.size = const Size.square(256), this.fit = BoxFit.cover, super.key}) +class Thumbnail extends StatefulWidget { + 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'); final BaseAsset? asset; @@ -16,46 +17,79 @@ class Thumbnail extends StatelessWidget { final Size size; final BoxFit fit; + @override + createState() => _ThumbnailState(); +} + +class _ThumbnailState extends State { + 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 Widget build(BuildContext context) { - final thumbHash = asset is RemoteAsset ? (asset as RemoteAsset).thumbHash : null; - final provider = getThumbnailImageProvider(asset: asset, remoteId: remoteId); + final thumbHash = widget.asset is RemoteAsset ? (widget.asset as RemoteAsset).thumbHash : null; return OctoImage.fromSet( - image: provider, + image: provider!, octoSet: OctoSet( - placeholderBuilder: _blurHashPlaceholderBuilder(thumbHash, fit: fit), - errorBuilder: _blurHashErrorBuilder(thumbHash, provider: provider, fit: fit, asset: asset), + placeholderBuilder: _blurHashPlaceholderBuilder(thumbHash), + errorBuilder: _blurHashErrorBuilder(thumbHash, provider: provider, asset: widget.asset), ), fadeOutDuration: const Duration(milliseconds: 100), fadeInDuration: Duration.zero, - width: size.width, - height: size.height, - fit: fit, + width: widget.size.width, + height: widget.size.height, + fit: widget.fit, placeholderFadeInDuration: Duration.zero, ); } -} -OctoPlaceholderBuilder _blurHashPlaceholderBuilder(String? thumbHash, {BoxFit? fit}) { - return (context) => thumbHash == null - ? const ThumbnailPlaceholder() - : FadeInPlaceholderImage( - placeholder: const ThumbnailPlaceholder(), - image: ThumbHashProvider(thumbHash: thumbHash), - fit: fit ?? BoxFit.cover, + @override + void dispose() { + if (provider is CancellableImageProvider) { + (provider as CancellableImageProvider).cancel(); + } + super.dispose(); + } + + 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)), - ], - ); - }; diff --git a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart index c859ae0e80..f41d665599 100644 --- a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart +++ b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart @@ -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/extensions/asyncvalue_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/timeline/scrubber.widget.dart'; import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart'; @@ -106,7 +107,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { bool _dragging = false; TimelineAssetIndex? _dragAnchorIndex; final Set _draggedAssets = HashSet(); - ScrollPhysics? _scrollPhysics; + ScrollPhysics _scrollPhysics = const ScrollUnawareScrollPhysics(); int _perRow = 4; double _scaleFactor = 3.0; @@ -188,7 +189,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { // Drag selection methods void _setDragStartIndex(TimelineAssetIndex index) { setState(() { - _scrollPhysics = const ClampingScrollPhysics(); + _scrollPhysics = const ScrollUnawareClampingScrollPhysics(); _dragAnchorIndex = index; _dragging = true; }); @@ -198,7 +199,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { WidgetsBinding.instance.addPostFrameCallback((_) { // Update the physics post frame to prevent sudden change in physics on iOS. setState(() { - _scrollPhysics = null; + _scrollPhysics = const ScrollUnawareScrollPhysics(); }); }); setState(() { diff --git a/mobile/lib/utils/cache/custom_image_cache.dart b/mobile/lib/utils/cache/custom_image_cache.dart index 194c55f9df..a3905baf9b 100644 --- a/mobile/lib/utils/cache/custom_image_cache.dart +++ b/mobile/lib/utils/cache/custom_image_cache.dart @@ -1,6 +1,7 @@ import 'package:flutter/painting.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/thumb_hash_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_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 /// so that a single large image does not evict all small images final class CustomImageCache implements ImageCache { + final _thumbhash = ImageCache()..maximumSize = 0; final _small = ImageCache(); final _large = ImageCache()..maximumSize = 5; // Maximum 5 images @@ -39,13 +41,16 @@ final class CustomImageCache implements ImageCache { /// Gets the cache for the given key /// [_large] is used for [ImmichLocalImageProvider] and [ImmichRemoteImageProvider] /// [_small] is used for [ImmichLocalThumbnailProvider] and [ImmichRemoteThumbnailProvider] - ImageCache _cacheForKey(Object key) => - (key is ImmichLocalImageProvider || - key is ImmichRemoteImageProvider || - key is LocalFullImageProvider || - key is RemoteFullImageProvider) - ? _large - : _small; + ImageCache _cacheForKey(Object key) { + return switch (key) { + ImmichLocalImageProvider() || + ImmichRemoteImageProvider() || + LocalFullImageProvider() || + RemoteFullImageProvider() => _large, + ThumbHashProvider() => _thumbhash, + _ => _small, + }; + } @override bool containsKey(Object key) { diff --git a/mobile/lib/widgets/common/fade_in_placeholder_image.dart b/mobile/lib/widgets/common/fade_in_placeholder_image.dart index 2461dbe6bf..d7d1d6b048 100644 --- a/mobile/lib/widgets/common/fade_in_placeholder_image.dart +++ b/mobile/lib/widgets/common/fade_in_placeholder_image.dart @@ -1,30 +1,43 @@ import 'package:flutter/material.dart'; -import 'package:immich_mobile/widgets/common/transparent_image.dart'; class FadeInPlaceholderImage extends StatelessWidget { final Widget placeholder; final ImageProvider image; final Duration duration; final BoxFit fit; + final double width; + final double height; const FadeInPlaceholderImage({ super.key, required this.placeholder, required this.image, + required this.width, + required this.height, this.duration = const Duration(milliseconds: 100), this.fit = BoxFit.cover, }); @override Widget build(BuildContext context) { - return SizedBox.expand( - child: Stack( - fit: StackFit.expand, - children: [ - placeholder, - FadeInImage(fadeInDuration: duration, image: image, fit: fit, placeholder: MemoryImage(kTransparentImage)), - ], - ), + final stopwatch = Stopwatch()..start(); + return Image( + image: image, + frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { + if (frame == null) { + 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, ); } } diff --git a/mobile/lib/widgets/common/immich_thumbnail.dart b/mobile/lib/widgets/common/immich_thumbnail.dart index 612a6a4bd0..755565e7cd 100644 --- a/mobile/lib/widgets/common/immich_thumbnail.dart +++ b/mobile/lib/widgets/common/immich_thumbnail.dart @@ -61,7 +61,7 @@ class ImmichThumbnail extends HookConsumerWidget { customErrorBuilder(BuildContext ctx, Object error, StackTrace? stackTrace) { thumbnailProviderInstance.evict(); - final originalErrorWidgetBuilder = blurHashErrorBuilder(blurhash, fit: fit); + final originalErrorWidgetBuilder = blurHashErrorBuilder(blurhash, width: width, height: height, fit: fit); return originalErrorWidgetBuilder(ctx, error, stackTrace); } @@ -72,7 +72,7 @@ class ImmichThumbnail extends HookConsumerWidget { fadeInDuration: Duration.zero, fadeOutDuration: const Duration(milliseconds: 100), octoSet: OctoSet( - placeholderBuilder: blurHashPlaceholderBuilder(blurhash, fit: fit), + placeholderBuilder: blurHashPlaceholderBuilder(blurhash, width: width, height: height, fit: fit), errorBuilder: customErrorBuilder, ), image: thumbnailProviderInstance, diff --git a/mobile/lib/widgets/common/thumbhash_placeholder.dart b/mobile/lib/widgets/common/thumbhash_placeholder.dart index 0cb1222989..703af53fe2 100644 --- a/mobile/lib/widgets/common/thumbhash_placeholder.dart +++ b/mobile/lib/widgets/common/thumbhash_placeholder.dart @@ -6,25 +6,40 @@ import 'package:octo_image/octo_image.dart'; /// Simple set to show [OctoPlaceholder.circularProgressIndicator] as /// 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( - placeholderBuilder: blurHashPlaceholderBuilder(blurhash, fit: fit), - errorBuilder: blurHashErrorBuilder(blurhash, fit: fit, message: errorMessage), + placeholderBuilder: blurHashPlaceholderBuilder(blurhash, width: width, height: height, fit: fit), + 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 ? const ThumbnailPlaceholder() : FadeInPlaceholderImage( placeholder: const ThumbnailPlaceholder(), image: MemoryImage(blurhash), fit: fit ?? BoxFit.cover, + width: width, + height: height, ); } OctoErrorBuilder blurHashErrorBuilder( Uint8List? blurhash, { + required double width, + required double height, BoxFit? fit, Text? message, IconData? icon, @@ -32,7 +47,7 @@ OctoErrorBuilder blurHashErrorBuilder( double? iconSize, }) { return OctoError.placeholderWithErrorIcon( - blurHashPlaceholderBuilder(blurhash, fit: fit), + blurHashPlaceholderBuilder(blurhash, width: width, height: width, fit: fit), message: message, icon: icon, iconColor: iconColor,