diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart index 9c17d9a03..6afc2d422 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart @@ -17,6 +17,7 @@ import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.widg import 'package:immich_mobile/presentation/widgets/asset_viewer/ocr_overlay.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer.widget.dart'; import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; +import 'package:immich_mobile/presentation/widgets/images/progressive_image.widget.dart'; import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; @@ -344,29 +345,32 @@ class _AssetPageState extends ConsumerState { ); if (asset.isImage && !isPlayingMotionVideo) { - return PhotoView( - key: Key(asset.heroTag), - index: widget.index, - imageProvider: imageProvider, - heroAttributes: heroAttributes, - loadingBuilder: (context, progress, index) => const Center(child: ImmichLoadingIndicator()), - gaplessPlayback: true, - filterQuality: FilterQuality.high, - tightMode: true, - enablePanAlways: true, - disableScaleGestures: _showingDetails, - scaleStateChangedCallback: _onScaleStateChanged, - onPageBuild: _onPageBuild, - onDragStart: _onDragStart, - onDragUpdate: _onDragUpdate, - onDragEnd: _onDragEnd, - onDragCancel: _onDragCancel, - onTapUp: _onTapUp, - onLongPressStart: asset.isMotionPhoto ? _onLongPress : null, - errorBuilder: (_, _, _) => SizedBox( - width: size.width, - height: size.height, - child: Thumbnail.fromAsset(asset: asset, fit: BoxFit.contain), + return ProgressiveImage( + provider: imageProvider, + builder: (context, provider) => PhotoView( + key: Key(asset.heroTag), + index: widget.index, + imageProvider: provider, + heroAttributes: heroAttributes, + loadingBuilder: (context, progress, index) => const Center(child: ImmichLoadingIndicator()), + gaplessPlayback: true, + filterQuality: FilterQuality.high, + tightMode: true, + enablePanAlways: true, + disableScaleGestures: _showingDetails, + scaleStateChangedCallback: _onScaleStateChanged, + onPageBuild: _onPageBuild, + onDragStart: _onDragStart, + onDragUpdate: _onDragUpdate, + onDragEnd: _onDragEnd, + onDragCancel: _onDragCancel, + onTapUp: _onTapUp, + onLongPressStart: asset.isMotionPhoto ? _onLongPress : null, + errorBuilder: (_, _, _) => SizedBox( + width: size.width, + height: size.height, + child: Thumbnail.fromAsset(asset: asset, fit: BoxFit.contain), + ), ), ); } diff --git a/mobile/lib/presentation/widgets/images/full_image.widget.dart b/mobile/lib/presentation/widgets/images/full_image.widget.dart index 8c92ac281..f6ee82e7b 100644 --- a/mobile/lib/presentation/widgets/images/full_image.widget.dart +++ b/mobile/lib/presentation/widgets/images/full_image.widget.dart @@ -3,6 +3,7 @@ import 'dart:async'; 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/progressive_image.widget.dart'; import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart'; import 'package:octo_image/octo_image.dart'; @@ -23,18 +24,22 @@ class FullImage extends StatelessWidget { @override Widget build(BuildContext context) { final provider = getFullImageProvider(asset, size: size); - return OctoImage( - fadeInDuration: Duration.zero, - fadeOutDuration: const Duration(milliseconds: 100), - placeholderBuilder: placeholder != null ? (_) => placeholder! : null, - image: provider, - width: size.width, - height: size.height, - fit: fit, - errorBuilder: (context, error, stackTrace) { - unawaited(provider.evict()); - return const Icon(Icons.image_not_supported_outlined, size: 32); - }, + return ProgressiveImage( + provider: provider, + builder: (context, imageProvider) => OctoImage( + fadeInDuration: Duration.zero, + fadeOutDuration: const Duration(milliseconds: 100), + gaplessPlayback: true, + placeholderBuilder: placeholder != null ? (_) => placeholder! : null, + image: imageProvider, + width: size.width, + height: size.height, + fit: fit, + errorBuilder: (context, error, stackTrace) { + unawaited(provider.evict()); + return const Icon(Icons.image_not_supported_outlined, size: 32); + }, + ), ); } } diff --git a/mobile/lib/presentation/widgets/images/progressive_image.widget.dart b/mobile/lib/presentation/widgets/images/progressive_image.widget.dart new file mode 100644 index 000000000..8424f14e0 --- /dev/null +++ b/mobile/lib/presentation/widgets/images/progressive_image.widget.dart @@ -0,0 +1,144 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +/// An [Image] that incrementally follows the content of an [ImageProvider] +/// +/// This is required for quality upgrades on Android when "Remove animations" is enabled. As of Flutter 3.44, using the normal [ImageProvider] would not progress past the initial image +class ProgressiveImage extends StatefulWidget { + const ProgressiveImage({required this.provider, required this.builder, super.key}); + + final ImageProvider provider; + + /// Builds the image widget with the [provider] to display. This is invoked incrementally to produce new widget trees as the image loads + final Widget Function(BuildContext context, ImageProvider provider) builder; + + @override + State createState() => _ProgressiveImageState(); +} + +class _ProgressiveImageState extends State { + ImageInfo? _info; + + ImageStream? _activeStream; + ImageStreamListener? _listener; + + _SingleFrameImageProvider? _frame; + + bool _isFirstFrame = true; + + @override + void initState() { + super.initState(); + _resolve(); + } + + @override + void didUpdateWidget(ProgressiveImage oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.provider != oldWidget.provider) { + _resolve(); + } + } + + @override + void dispose() { + _removeListener(); + _setImageInfo(null); + + super.dispose(); + } + + void _resolve() { + final stream = widget.provider.resolve(ImageConfiguration.empty); + if (_activeStream?.key == stream.key) { + return; + } + + _removeListener(); + _isFirstFrame = true; + _frame = null; + + // The upstream image resolver handles the errors + _listener = ImageStreamListener(_onFrame, onError: (error, stackTrace) => {}); + _activeStream = stream; + + _activeStream?.addListener(_listener!); + } + + void _removeListener() { + if (_listener == null) { + return; + } + + _activeStream?.removeListener(_listener!); + _listener = null; + } + + void _onFrame(ImageInfo info, bool synchronousCall) { + if (_isFirstFrame) { + // [Image] has already resolved the very first frame, so we don't have to do anything special + _isFirstFrame = false; + info.dispose(); + + return; + } + + // A multi frame stream is a real animation, which [Image] is right to pause. + if (_activeStream?.completer is MultiFrameImageStreamCompleter) { + // A multi-frame stream (like an animated gif) should be paused. Do nothing + info.dispose(); + + return; + } + + void setFrame() { + _setImageInfo(info); + + _frame = _SingleFrameImageProvider(info); + } + + if (synchronousCall) { + setFrame(); + } else { + setState(setFrame); + } + } + + void _setImageInfo(ImageInfo? info) { + final oldInfo = _info; + _info = info; + + if (oldInfo != null) { + // An old image may still be rendered, so wait to dispose it + WidgetsBinding.instance.addPostFrameCallback((_) => oldInfo.dispose()); + } + } + + @override + Widget build(BuildContext context) => widget.builder(context, _frame ?? widget.provider); +} + +/// Directly displays a single image +class _SingleFrameImageProvider extends ImageProvider<_SingleFrameImageProvider> { + const _SingleFrameImageProvider(this.info); + + final ImageInfo info; + + @override + Future<_SingleFrameImageProvider> obtainKey(ImageConfiguration configuration) => SynchronousFuture(this); + + @override + ImageStreamCompleter loadImage(_SingleFrameImageProvider key, ImageDecoderCallback decode) => + OneFrameImageStreamCompleter(SynchronousFuture(info.clone())); + + @override + void resolveStreamForKey( + ImageConfiguration configuration, + ImageStream stream, + _SingleFrameImageProvider key, + ImageErrorListener handleError, + ) { + // We explicitly do not want to cache anything, as our parent [ProgressiveImage] already retains it + stream.setCompleter(loadImage(key, PaintingBinding.instance.instantiateImageCodecWithSize)); + } +} diff --git a/mobile/lib/presentation/widgets/slideshow/slideshow_slide.widget.dart b/mobile/lib/presentation/widgets/slideshow/slideshow_slide.widget.dart index de92be81f..e9a4e7073 100644 --- a/mobile/lib/presentation/widgets/slideshow/slideshow_slide.widget.dart +++ b/mobile/lib/presentation/widgets/slideshow/slideshow_slide.widget.dart @@ -7,6 +7,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer.widget.dart'; import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; +import 'package:immich_mobile/presentation/widgets/images/progressive_image.widget.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart'; import 'package:immich_mobile/widgets/photo_view/photo_view.dart'; @@ -55,15 +56,18 @@ class SlideshowSlide extends StatelessWidget { final Widget content = asset.isImage || frozen ? ScaleTransition( scale: zoom.drive(Tween(begin: 1.0, end: 1.0 + _kenBurnsZoomMultiplier)), - child: PhotoView( - imageProvider: getFullImageProvider(asset, size: context.sizeData), - index: index, - disableScaleGestures: true, - gaplessPlayback: true, - filterQuality: FilterQuality.high, - initialScale: _scale, - controller: PhotoViewController(), - onTapUp: (_, _, _) => onTapUp(), + child: ProgressiveImage( + provider: getFullImageProvider(asset, size: context.sizeData), + builder: (context, provider) => PhotoView( + imageProvider: provider, + index: index, + disableScaleGestures: true, + gaplessPlayback: true, + filterQuality: FilterQuality.high, + initialScale: _scale, + controller: PhotoViewController(), + onTapUp: (_, _, _) => onTapUp(), + ), ), ) : _SlideshowVideo( @@ -147,7 +151,10 @@ class _SlideshowVideo extends ConsumerWidget { isCurrent: isCurrent, // Disable video looping loopOverride: false, - image: Image(image: imageProvider, fit: BoxFit.contain, alignment: Alignment.center), + image: ProgressiveImage( + provider: imageProvider, + builder: (context, provider) => Image(image: provider, fit: BoxFit.contain, alignment: Alignment.center), + ), ), ); } diff --git a/mobile/lib/presentation/widgets/sliver_app_bar/random_asset_background_image.widget.dart b/mobile/lib/presentation/widgets/sliver_app_bar/random_asset_background_image.widget.dart index fa5ccd3f1..4ab8da291 100644 --- a/mobile/lib/presentation/widgets/sliver_app_bar/random_asset_background_image.widget.dart +++ b/mobile/lib/presentation/widgets/sliver_app_bar/random_asset_background_image.widget.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; +import 'package:immich_mobile/presentation/widgets/images/progressive_image.widget.dart'; /// Randomly selects an asset from the provided [TimelineService] for a persistent Ken Burns zoom class RandomAssetBackgroundImage extends StatefulWidget { @@ -166,23 +167,26 @@ class _RandomAssetBackgroundImageState extends State child: SizedBox( width: double.infinity, height: double.infinity, - child: Image( - alignment: Alignment.topRight, - image: getFullImageProvider(_currentAsset!), - fit: BoxFit.cover, - frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { - if (wasSynchronouslyLoaded || frame != null) { - return child; - } - return Container(); - }, - errorBuilder: (context, error, stackTrace) { - return SizedBox( - width: double.infinity, - height: double.infinity, - child: Icon(Icons.error_outline_rounded, size: 24, color: Colors.red[300]), - ); - }, + child: ProgressiveImage( + provider: getFullImageProvider(_currentAsset!), + builder: (context, provider) => Image( + alignment: Alignment.topRight, + image: provider, + fit: BoxFit.cover, + frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { + if (wasSynchronouslyLoaded || frame != null) { + return child; + } + return Container(); + }, + errorBuilder: (context, error, stackTrace) { + return SizedBox( + width: double.infinity, + height: double.infinity, + child: Icon(Icons.error_outline_rounded, size: 24, color: Colors.red[300]), + ); + }, + ), ), ), ), @@ -193,23 +197,26 @@ class _RandomAssetBackgroundImageState extends State child: SizedBox( width: double.infinity, height: double.infinity, - child: Image( - alignment: Alignment.topRight, - image: getFullImageProvider(_nextAsset!), - fit: BoxFit.cover, - frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { - if (wasSynchronouslyLoaded || frame != null) { - return child; - } - return const SizedBox.shrink(); - }, - errorBuilder: (context, error, stackTrace) { - return SizedBox( - width: double.infinity, - height: double.infinity, - child: Icon(Icons.error_outline_rounded, size: 24, color: Colors.red[300]), - ); - }, + child: ProgressiveImage( + provider: getFullImageProvider(_nextAsset!), + builder: (context, provider) => Image( + alignment: Alignment.topRight, + image: provider, + fit: BoxFit.cover, + frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { + if (wasSynchronouslyLoaded || frame != null) { + return child; + } + return const SizedBox.shrink(); + }, + errorBuilder: (context, error, stackTrace) { + return SizedBox( + width: double.infinity, + height: double.infinity, + child: Icon(Icons.error_outline_rounded, size: 24, color: Colors.red[300]), + ); + }, + ), ), ), ), diff --git a/mobile/test/presentation/widgets/images/progressive_image_test.dart b/mobile/test/presentation/widgets/images/progressive_image_test.dart new file mode 100644 index 000000000..bc76cdb91 --- /dev/null +++ b/mobile/test/presentation/widgets/images/progressive_image_test.dart @@ -0,0 +1,59 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart'; +import 'package:immich_mobile/presentation/widgets/images/progressive_image.widget.dart'; + +class _ProgressiveProvider extends ImageProvider<_ProgressiveProvider> { + const _ProgressiveProvider(this.frames); + + final Stream frames; + + @override + Future<_ProgressiveProvider> obtainKey(ImageConfiguration configuration) => SynchronousFuture(this); + + @override + ImageStreamCompleter loadImage(_ProgressiveProvider key, ImageDecoderCallback decode) => + OneFramePlaceholderImageStreamCompleter(frames); +} + +void main() { + // Issue https://github.com/immich-app/immich/issues/29727: [Image] stops iterating through [ImageProvider] while Android's "Remove animations" is enabled + // This caused us to get stuck rendering a low quality image + for (final disableAnimations in [false, true]) { + final state = disableAnimations ? 'disabled' : 'enabled'; + + testWidgets('should keep refining a progressive image while animations are $state', (tester) async { + final frames = StreamController(); + addTearDown(frames.close); + + final (thumbnail, preview) = (await tester.runAsync( + () async => (await createTestImage(width: 8, height: 8), await createTestImage(width: 64, height: 64)), + ))!; + + await tester.pumpWidget( + MediaQuery( + data: MediaQueryData(disableAnimations: disableAnimations), + child: MaterialApp( + home: ProgressiveImage( + provider: _ProgressiveProvider(frames.stream), + builder: (context, provider) => Image(image: provider, gaplessPlayback: true), + ), + ), + ), + ); + + frames.add(ImageInfo(image: thumbnail)); + await tester.pump(); + await tester.pump(); + expect(tester.widget(find.byType(RawImage)).image?.width, 8); + + frames.add(ImageInfo(image: preview)); + await tester.pump(); + await tester.pump(); + expect(tester.widget(find.byType(RawImage)).image?.width, 64); + }); + } +}