fix(mobile): load more than one image on Android with Remove animations (#31587)

* fix(mobile): load more than one image on Android with Remove animations

* Wrap album Mesmerizing Slivers with ProgressiveImage
This commit is contained in:
Adam Gastineau
2026-09-16 13:47:23 +00:00
committed by GitHub
parent c5cdbef26f
commit 2e98d745bf
6 changed files with 305 additions and 79 deletions
@@ -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<AssetPage> {
);
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),
),
),
);
}
@@ -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);
},
),
);
}
}
@@ -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<ProgressiveImage> createState() => _ProgressiveImageState();
}
class _ProgressiveImageState extends State<ProgressiveImage> {
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));
}
}
@@ -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),
),
),
);
}
@@ -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<RandomAssetBackgroundImage>
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<RandomAssetBackgroundImage>
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]),
);
},
),
),
),
),
@@ -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<ImageInfo> 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<ImageInfo>();
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<RawImage>(find.byType(RawImage)).image?.width, 8);
frames.add(ImageInfo(image: preview));
await tester.pump();
await tester.pump();
expect(tester.widget<RawImage>(find.byType(RawImage)).image?.width, 64);
});
}
}