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:
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
+41
-34
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user