mirror of
https://github.com/immich-app/immich.git
synced 2026-07-17 13:44:36 +03:00
Compare commits
4 Commits
fix-29727
...
fix/slides
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f29f884f0b | ||
|
|
52562c4972 | ||
|
|
fc65603699 | ||
|
|
557189d7a8 |
@@ -4,4 +4,4 @@
|
||||
/web/ @danieldietzler
|
||||
/machine-learning/ @mertalev
|
||||
/e2e/ @danieldietzler
|
||||
/mobile/ @shenlong-tanwen @santoshakil
|
||||
/mobile/ @shenlong-tanwen @santoshakil @agg23
|
||||
|
||||
@@ -51,6 +51,7 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> with Si
|
||||
int? _crossfadeFromIndex;
|
||||
int? _crossfadeToIndex;
|
||||
int _zoomCycle = 0;
|
||||
bool _disableAnimations = false;
|
||||
|
||||
@override
|
||||
initState() {
|
||||
@@ -70,6 +71,12 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> with Si
|
||||
unawaited(WakelockPlus.enable());
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
_disableAnimations = MediaQuery.disableAnimationsOf(context);
|
||||
}
|
||||
|
||||
@override
|
||||
dispose() {
|
||||
_timer.cancel();
|
||||
@@ -166,6 +173,11 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> with Si
|
||||
}
|
||||
|
||||
void _crossFadeToPage(int page) {
|
||||
if (_disableAnimations) {
|
||||
_pageController.jumpToPage(page);
|
||||
return;
|
||||
}
|
||||
|
||||
final previousIndex = _index;
|
||||
_pageController.jumpToPage(page);
|
||||
setState(() {
|
||||
@@ -273,19 +285,12 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> with Si
|
||||
}
|
||||
|
||||
if (asset.isImage) {
|
||||
final elapsed = _stopwatch.elapsedMilliseconds;
|
||||
final duration = _config.duration * 1000;
|
||||
|
||||
return TweenAnimationBuilder(
|
||||
return _SlideshowProgressBar(
|
||||
key: Key(_index.toString()),
|
||||
tween: Tween<double>(begin: elapsed / duration.toDouble(), end: _paused ? elapsed / duration.toDouble() : 1.0),
|
||||
duration: Duration(milliseconds: _paused ? 1 : max(duration - elapsed, 1)),
|
||||
builder: (context, value, _) => LinearProgressIndicator(
|
||||
color: context.colorScheme.primary,
|
||||
borderRadius: const BorderRadius.all(Radius.zero),
|
||||
minHeight: 5,
|
||||
value: value,
|
||||
),
|
||||
durationMs: _config.duration * 1000,
|
||||
elapsedMs: _stopwatch.elapsedMilliseconds,
|
||||
paused: _paused,
|
||||
color: context.colorScheme.primary,
|
||||
);
|
||||
} else {
|
||||
return LinearProgressIndicator(
|
||||
@@ -334,6 +339,21 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> with Si
|
||||
final imageProvider = getFullImageProvider(asset, size: context.sizeData);
|
||||
|
||||
if (asset.isImage) {
|
||||
PhotoView buildPhotoView(PhotoViewComputedScale initialScale) => PhotoView(
|
||||
imageProvider: imageProvider,
|
||||
index: index,
|
||||
disableScaleGestures: true,
|
||||
gaplessPlayback: true,
|
||||
filterQuality: FilterQuality.high,
|
||||
initialScale: initialScale,
|
||||
controller: PhotoViewController(),
|
||||
onTapUp: (_, _, _) => _onTapUp(),
|
||||
);
|
||||
|
||||
if (_disableAnimations) {
|
||||
return buildPhotoView(scale);
|
||||
}
|
||||
|
||||
final zoomOut = _zoomCycle.isOdd;
|
||||
final elapsed = _stopwatch.elapsedMilliseconds;
|
||||
final duration = _config.duration * 1000;
|
||||
@@ -349,16 +369,7 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> with Si
|
||||
: 1.0,
|
||||
),
|
||||
duration: Duration(milliseconds: _paused ? 1 : max(duration - elapsed, 1)),
|
||||
builder: (context, value, _) => PhotoView(
|
||||
imageProvider: imageProvider,
|
||||
index: index,
|
||||
disableScaleGestures: true,
|
||||
gaplessPlayback: true,
|
||||
filterQuality: FilterQuality.high,
|
||||
initialScale: scale * (1.0 + value * _kenBurnsZoom),
|
||||
controller: PhotoViewController(),
|
||||
onTapUp: (_, _, _) => _onTapUp(),
|
||||
),
|
||||
builder: (context, value, _) => buildPhotoView(scale * (1.0 + value * _kenBurnsZoom)),
|
||||
);
|
||||
} else {
|
||||
final status = ref.watch(videoPlayerProvider(asset.heroTag).select((s) => s.status));
|
||||
@@ -463,3 +474,75 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> with Si
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Progress bar for image slides, driven by an explicit [AnimationController].
|
||||
///
|
||||
/// [TweenAnimationBuilder] creates its controller internally with the default
|
||||
/// [AnimationBehavior.normal], which makes it run ~20x too fast while the system
|
||||
/// "reduce motion" setting is on (flutter/flutter#164287). This owns its
|
||||
/// controller so it can use [AnimationBehavior.preserve] and animate at the real
|
||||
/// slide duration regardless of that setting.
|
||||
class _SlideshowProgressBar extends StatefulWidget {
|
||||
final int durationMs;
|
||||
final int elapsedMs;
|
||||
final bool paused;
|
||||
final Color color;
|
||||
|
||||
const _SlideshowProgressBar({
|
||||
super.key,
|
||||
required this.durationMs,
|
||||
required this.elapsedMs,
|
||||
required this.paused,
|
||||
required this.color,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_SlideshowProgressBar> createState() => _SlideshowProgressBarState();
|
||||
}
|
||||
|
||||
class _SlideshowProgressBarState extends State<_SlideshowProgressBar> with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: Duration(milliseconds: widget.durationMs),
|
||||
animationBehavior: AnimationBehavior.preserve,
|
||||
)..value = (widget.elapsedMs / widget.durationMs).clamp(0.0, 1.0);
|
||||
if (!widget.paused) {
|
||||
_controller.forward();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(_SlideshowProgressBar oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.durationMs != oldWidget.durationMs) {
|
||||
_controller.duration = Duration(milliseconds: widget.durationMs);
|
||||
}
|
||||
if (widget.paused != oldWidget.paused) {
|
||||
widget.paused ? _controller.stop() : _controller.forward();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (context, _) => LinearProgressIndicator(
|
||||
color: widget.color,
|
||||
borderRadius: const BorderRadius.all(Radius.zero),
|
||||
minHeight: 5,
|
||||
value: _controller.value,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import 'package:immich_mobile/constants/aspect_ratios.dart';
|
||||
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/pages/edit/editor.provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/progressive_image_guard.dart';
|
||||
import 'package:immich_mobile/providers/theme.provider.dart';
|
||||
import 'package:immich_mobile/theme/theme_data.dart';
|
||||
import 'package:immich_mobile/utils/editor.utils.dart';
|
||||
@@ -118,9 +117,7 @@ class _DriftEditImagePageState extends ConsumerState<DriftEditImagePage> with Ti
|
||||
bottom: false,
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ProgressiveImageGuard(child: _EditorPreview(image: widget.image)),
|
||||
),
|
||||
Expanded(child: _EditorPreview(image: widget.image)),
|
||||
AnimatedSize(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeInOut,
|
||||
@@ -430,9 +427,7 @@ class _EditorPreviewState extends ConsumerState<_EditorPreview> with TickerProvi
|
||||
padding: const EdgeInsets.all(10),
|
||||
width: (editorState.rotationAngle % 180 == 0) ? baseWidth : baseHeight,
|
||||
height: (editorState.rotationAngle % 180 == 0) ? baseHeight : baseWidth,
|
||||
child: ProgressiveImageGuard(
|
||||
child: CropImage(controller: cropController, image: widget.image, gridColor: Colors.white),
|
||||
),
|
||||
child: CropImage(controller: cropController, image: widget.image, gridColor: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -10,7 +10,6 @@ import 'package:image_picker/image_picker.dart';
|
||||
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/images/image_provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/progressive_image_guard.dart';
|
||||
import 'package:immich_mobile/providers/auth.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/backup.provider.dart';
|
||||
import 'package:immich_mobile/providers/upload_profile_image.provider.dart';
|
||||
@@ -171,9 +170,7 @@ class _ProfilePictureCropPageState extends ConsumerState<ProfilePictureCropPage>
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
child: ProgressiveImageGuard(
|
||||
child: CropImage(controller: _cropController, image: image, gridColor: Colors.white),
|
||||
),
|
||||
child: CropImage(controller: _cropController, image: image, gridColor: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -8,7 +8,6 @@ import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/progressive_image_guard.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';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
|
||||
@@ -302,8 +301,7 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
|
||||
return IgnorePointer(
|
||||
child: Stack(
|
||||
children: [
|
||||
if (!_isVideoReady || widget.asset.isMotionPhoto || isCasting)
|
||||
Center(child: ProgressiveImageGuard(child: widget.image)),
|
||||
if (!_isVideoReady || widget.asset.isMotionPhoto || isCasting) Center(child: widget.image),
|
||||
if (!isCasting) ...[
|
||||
Visibility.maintain(
|
||||
visible: _isVideoReady,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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_guard.dart';
|
||||
import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart';
|
||||
import 'package:octo_image/octo_image.dart';
|
||||
|
||||
@@ -22,20 +21,18 @@ class FullImage extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final provider = getFullImageProvider(asset, size: size);
|
||||
return ProgressiveImageGuard(
|
||||
child: OctoImage(
|
||||
fadeInDuration: const Duration(milliseconds: 0),
|
||||
fadeOutDuration: const Duration(milliseconds: 100),
|
||||
placeholderBuilder: placeholder != null ? (_) => placeholder! : null,
|
||||
image: provider,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
fit: fit,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
provider.evict();
|
||||
return const Icon(Icons.image_not_supported_outlined, size: 32);
|
||||
},
|
||||
),
|
||||
return OctoImage(
|
||||
fadeInDuration: const Duration(milliseconds: 0),
|
||||
fadeOutDuration: const Duration(milliseconds: 100),
|
||||
placeholderBuilder: placeholder != null ? (_) => placeholder! : null,
|
||||
image: provider,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
fit: fit,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
provider.evict();
|
||||
return const Icon(Icons.image_not_supported_outlined, size: 32);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Keeps progressive image streams delivering frames when the platform
|
||||
/// requests reduced animations.
|
||||
///
|
||||
/// The full-image providers emit multiple, increasingly higher-quality images
|
||||
/// (thumbnail -> preview -> original) as successive frames of a single image
|
||||
/// stream. Since Flutter 3.44, [Image] stops listening to its stream after the
|
||||
/// first frame when [MediaQueryData.disableAnimations] is set (e.g. Android's
|
||||
/// "Remove animations" accessibility setting or an animator duration scale of
|
||||
/// zero), which would freeze these images at their low-res first frame.
|
||||
/// Photos are not animations, so clear the flag for this subtree.
|
||||
class ProgressiveImageGuard extends StatelessWidget {
|
||||
const ProgressiveImageGuard({required this.child, super.key});
|
||||
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final disableAnimations = MediaQuery.maybeDisableAnimationsOf(context) ?? false;
|
||||
if (!disableAnimations) {
|
||||
return child;
|
||||
}
|
||||
return MediaQuery(data: MediaQuery.of(context).copyWith(disableAnimations: false), child: child);
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,6 @@ import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/progressive_image_guard.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
|
||||
@@ -405,28 +404,26 @@ class _RandomAssetBackgroundState extends State<_RandomAssetBackground> with Tic
|
||||
if (_currentAsset != null)
|
||||
Opacity(
|
||||
opacity: _crossFadeAnimation.value,
|
||||
child: ProgressiveImageGuard(
|
||||
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: 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]),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -434,28 +431,26 @@ class _RandomAssetBackgroundState extends State<_RandomAssetBackground> with Tic
|
||||
if (_nextAsset != null)
|
||||
Opacity(
|
||||
opacity: 1.0 - _crossFadeAnimation.value,
|
||||
child: ProgressiveImageGuard(
|
||||
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: 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]),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -13,7 +13,6 @@ import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/progressive_image_guard.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
@@ -508,28 +507,26 @@ class _RandomAssetBackgroundState extends State<_RandomAssetBackground> with Tic
|
||||
if (_currentAsset != null)
|
||||
Opacity(
|
||||
opacity: _crossFadeAnimation.value,
|
||||
child: ProgressiveImageGuard(
|
||||
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: 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]),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -537,28 +534,26 @@ class _RandomAssetBackgroundState extends State<_RandomAssetBackground> with Tic
|
||||
if (_nextAsset != null)
|
||||
Opacity(
|
||||
opacity: 1.0 - _crossFadeAnimation.value,
|
||||
child: ProgressiveImageGuard(
|
||||
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: 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]),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -14,7 +14,6 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/datetime_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/progressive_image_guard.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/remote_album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
@@ -484,28 +483,26 @@ class _RandomAssetBackgroundState extends State<_RandomAssetBackground> with Tic
|
||||
if (_currentAsset != null)
|
||||
Opacity(
|
||||
opacity: _crossFadeAnimation.value,
|
||||
child: ProgressiveImageGuard(
|
||||
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: 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]),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -513,28 +510,26 @@ class _RandomAssetBackgroundState extends State<_RandomAssetBackground> with Tic
|
||||
if (_nextAsset != null)
|
||||
Opacity(
|
||||
opacity: 1.0 - _crossFadeAnimation.value,
|
||||
child: ProgressiveImageGuard(
|
||||
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: 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]),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/progressive_image_guard.dart';
|
||||
import 'package:immich_mobile/widgets/photo_view/photo_view.dart'
|
||||
show
|
||||
PhotoViewScaleState,
|
||||
@@ -439,17 +438,15 @@ class PhotoViewCoreState extends State<PhotoViewCore>
|
||||
height: scaleBoundaries.childSize.height * scale,
|
||||
child: widget.customChild!,
|
||||
)
|
||||
: ProgressiveImageGuard(
|
||||
child: Image(
|
||||
key: widget.heroAttributes?.tag != null ? ObjectKey(widget.heroAttributes!.tag) : null,
|
||||
image: widget.imageProvider!,
|
||||
semanticLabel: widget.semanticLabel,
|
||||
gaplessPlayback: widget.gaplessPlayback ?? false,
|
||||
filterQuality: widget.filterQuality,
|
||||
width: scaleBoundaries.childSize.width * scale,
|
||||
fit: BoxFit.contain,
|
||||
isAntiAlias: widget.filterQuality == FilterQuality.high,
|
||||
),
|
||||
: Image(
|
||||
key: widget.heroAttributes?.tag != null ? ObjectKey(widget.heroAttributes!.tag) : null,
|
||||
image: widget.imageProvider!,
|
||||
semanticLabel: widget.semanticLabel,
|
||||
gaplessPlayback: widget.gaplessPlayback ?? false,
|
||||
filterQuality: widget.filterQuality,
|
||||
width: scaleBoundaries.childSize.width * scale,
|
||||
fit: BoxFit.contain,
|
||||
isAntiAlias: widget.filterQuality == FilterQuality.high,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,296 +0,0 @@
|
||||
// End-to-end tests for progressive image loading (thumbnail -> preview)
|
||||
// through the real provider/completer pipeline with a mocked platform
|
||||
// image API.
|
||||
//
|
||||
// The "animations are disabled" cases are regression tests for
|
||||
// https://github.com/immich-app/immich/issues/29727: since Flutter 3.44,
|
||||
// a paused Image widget stops listening to its stream after the first
|
||||
// frame, freezing progressive images at the low-res thumbnail.
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:ffi' hide Size;
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:drift/drift.dart' hide isNull;
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:ffi/ffi.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/services/store.service.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
|
||||
import 'package:immich_mobile/platform/remote_image_api.g.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/full_image.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
||||
import 'package:immich_mobile/utils/cache/custom_image_cache.dart';
|
||||
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
|
||||
|
||||
import '../../../test_utils.dart';
|
||||
|
||||
class _CustomCacheBinding extends AutomatedTestWidgetsFlutterBinding {
|
||||
@override
|
||||
ImageCache createImageCache() => CustomImageCache();
|
||||
}
|
||||
|
||||
const kThumbSize = 16;
|
||||
const kPreviewSize = 64;
|
||||
const kOriginalSize = 128;
|
||||
|
||||
late Uint8List thumbPng;
|
||||
late Uint8List previewPng;
|
||||
late Uint8List originalPng;
|
||||
|
||||
final requestedUrls = <String>[];
|
||||
|
||||
Future<Uint8List> _pngBytes(int size) async {
|
||||
final image = await createTestImage(width: size, height: size);
|
||||
final data = await image.toByteData(format: ui.ImageByteFormat.png);
|
||||
return data!.buffer.asUint8List();
|
||||
}
|
||||
|
||||
void _installRemoteImageApiMock() {
|
||||
const channel = BasicMessageChannel<Object?>(
|
||||
'dev.flutter.pigeon.immich_mobile.RemoteImageApi.requestImage',
|
||||
RemoteImageApi.pigeonChannelCodec,
|
||||
);
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockDecodedMessageHandler<Object?>(channel, (
|
||||
message,
|
||||
) async {
|
||||
final args = message as List<Object?>;
|
||||
final url = args[0] as String;
|
||||
requestedUrls.add(url);
|
||||
|
||||
final Uint8List bytes;
|
||||
if (url.contains('size=thumbnail')) {
|
||||
bytes = thumbPng;
|
||||
} else if (url.contains('size=preview')) {
|
||||
bytes = previewPng;
|
||||
} else {
|
||||
bytes = originalPng;
|
||||
}
|
||||
|
||||
final pointer = malloc<Uint8>(bytes.length);
|
||||
pointer.asTypedList(bytes.length).setAll(0, bytes);
|
||||
return <Object?>[
|
||||
{'pointer': pointer.address, 'length': bytes.length},
|
||||
];
|
||||
});
|
||||
|
||||
const cancelChannel = BasicMessageChannel<Object?>(
|
||||
'dev.flutter.pigeon.immich_mobile.RemoteImageApi.cancelRequest',
|
||||
RemoteImageApi.pigeonChannelCodec,
|
||||
);
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockDecodedMessageHandler<Object?>(
|
||||
cancelChannel,
|
||||
(message) async => <Object?>[null],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _precacheThumbnail(WidgetTester tester, dynamic asset) async {
|
||||
await tester.runAsync(() async {
|
||||
final provider = getThumbnailImageProvider(asset)!;
|
||||
final completer = Completer<void>();
|
||||
final stream = provider.resolve(ImageConfiguration.empty);
|
||||
final listener = ImageStreamListener((info, _) {
|
||||
info.dispose();
|
||||
if (!completer.isCompleted) {
|
||||
completer.complete();
|
||||
}
|
||||
}, onError: (e, s) => completer.completeError(e, s));
|
||||
stream.addListener(listener);
|
||||
await completer.future;
|
||||
stream.removeListener(listener);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _settle(WidgetTester tester) async {
|
||||
for (int i = 0; i < 10; i++) {
|
||||
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 50)));
|
||||
await tester.pump();
|
||||
}
|
||||
}
|
||||
|
||||
int? _renderedImageWidth(WidgetTester tester) {
|
||||
final rawImages = tester.widgetList<RawImage>(find.byType(RawImage)).toList();
|
||||
return rawImages.isEmpty ? null : rawImages.first.image?.width;
|
||||
}
|
||||
|
||||
void main() {
|
||||
_CustomCacheBinding();
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUpAll(() async {
|
||||
TestUtils.init();
|
||||
final db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
|
||||
await StoreService.init(storeRepository: DriftStoreRepository(db), listenUpdates: false);
|
||||
await StoreService.I.put(StoreKey.serverEndpoint, 'http://localhost:3000');
|
||||
await SettingsRepository.ensureInitialized(db);
|
||||
});
|
||||
|
||||
setUp(() {
|
||||
requestedUrls.clear();
|
||||
_installRemoteImageApiMock();
|
||||
imageCache.clear();
|
||||
imageCache.clearLiveImages();
|
||||
});
|
||||
|
||||
int assetCounter = 0;
|
||||
|
||||
Future<dynamic> setUpAsset(WidgetTester tester) async {
|
||||
await tester.runAsync(() async {
|
||||
thumbPng = await _pngBytes(kThumbSize);
|
||||
previewPng = await _pngBytes(kPreviewSize);
|
||||
originalPng = await _pngBytes(kOriginalSize);
|
||||
});
|
||||
return TestUtils.createRemoteAsset(id: 'asset-${++assetCounter}', width: 3000, height: 4000);
|
||||
}
|
||||
|
||||
testWidgets('FullImage shows preview (thumbnail pre-cached by timeline)', (tester) async {
|
||||
final asset = await setUpAsset(tester);
|
||||
await _precacheThumbnail(tester, asset);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Center(
|
||||
child: SizedBox(width: 400, height: 800, child: FullImage(asset, size: const Size(400, 800))),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await _settle(tester);
|
||||
expect(_renderedImageWidth(tester), kPreviewSize, reason: 'stuck on thumbnail. URLs: $requestedUrls');
|
||||
});
|
||||
|
||||
testWidgets('FullImage shows preview (thumbnail not cached)', (tester) async {
|
||||
final asset = await setUpAsset(tester);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Center(
|
||||
child: SizedBox(width: 400, height: 800, child: FullImage(asset, size: const Size(400, 800))),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await _settle(tester);
|
||||
expect(_renderedImageWidth(tester), kPreviewSize, reason: 'stuck on thumbnail. URLs: $requestedUrls');
|
||||
});
|
||||
|
||||
testWidgets('PhotoView shows preview (thumbnail pre-cached by timeline)', (tester) async {
|
||||
final asset = await setUpAsset(tester);
|
||||
await _precacheThumbnail(tester, asset);
|
||||
|
||||
final provider = getFullImageProvider(asset, size: const Size(400, 800));
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: PhotoView(
|
||||
imageProvider: provider,
|
||||
index: 0,
|
||||
gaplessPlayback: true,
|
||||
filterQuality: FilterQuality.high,
|
||||
tightMode: true,
|
||||
enablePanAlways: true,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await _settle(tester);
|
||||
expect(_renderedImageWidth(tester), kPreviewSize, reason: 'stuck on thumbnail. URLs: $requestedUrls');
|
||||
});
|
||||
|
||||
testWidgets('PhotoView shows preview (thumbnail not cached)', (tester) async {
|
||||
final asset = await setUpAsset(tester);
|
||||
|
||||
final provider = getFullImageProvider(asset, size: const Size(400, 800));
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: PhotoView(
|
||||
imageProvider: provider,
|
||||
index: 0,
|
||||
gaplessPlayback: true,
|
||||
filterQuality: FilterQuality.high,
|
||||
tightMode: true,
|
||||
enablePanAlways: true,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await _settle(tester);
|
||||
expect(_renderedImageWidth(tester), kPreviewSize, reason: 'stuck on thumbnail. URLs: $requestedUrls');
|
||||
});
|
||||
|
||||
testWidgets('FullImage shows preview when animations are disabled (issue #29727)', (tester) async {
|
||||
final asset = await setUpAsset(tester);
|
||||
await _precacheThumbnail(tester, asset);
|
||||
|
||||
// Android "remove animations" / animator duration scale 0 sets
|
||||
// MediaQuery.disableAnimations, which pauses Image stream listening
|
||||
// after the first frame on Flutter 3.44+.
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: MediaQuery(
|
||||
data: const MediaQueryData(disableAnimations: true),
|
||||
child: Center(
|
||||
child: SizedBox(width: 400, height: 800, child: FullImage(asset, size: const Size(400, 800))),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await _settle(tester);
|
||||
expect(_renderedImageWidth(tester), kPreviewSize, reason: 'stuck on thumbnail. URLs: $requestedUrls');
|
||||
});
|
||||
|
||||
testWidgets('PhotoView shows preview when animations are disabled (issue #29727)', (tester) async {
|
||||
final asset = await setUpAsset(tester);
|
||||
await _precacheThumbnail(tester, asset);
|
||||
|
||||
final provider = getFullImageProvider(asset, size: const Size(400, 800));
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: MediaQuery(
|
||||
data: const MediaQueryData(disableAnimations: true),
|
||||
child: PhotoView(
|
||||
imageProvider: provider,
|
||||
index: 0,
|
||||
gaplessPlayback: true,
|
||||
filterQuality: FilterQuality.high,
|
||||
tightMode: true,
|
||||
enablePanAlways: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await _settle(tester);
|
||||
expect(_renderedImageWidth(tester), kPreviewSize, reason: 'stuck on thumbnail. URLs: $requestedUrls');
|
||||
});
|
||||
|
||||
testWidgets('memory page pattern: precacheImage then FullImage', (tester) async {
|
||||
final asset = await setUpAsset(tester);
|
||||
await _precacheThumbnail(tester, asset);
|
||||
|
||||
// The memory page precaches the full image provider before showing the card.
|
||||
await tester.pumpWidget(MaterialApp(home: Builder(builder: (context) => const SizedBox())));
|
||||
final context = tester.element(find.byType(SizedBox));
|
||||
final precacheFuture = precacheImage(getFullImageProvider(asset, size: const Size(400, 800)), context);
|
||||
await tester.pump();
|
||||
await tester.runAsync(() => precacheFuture);
|
||||
// Post-frame callback removes the precache listener.
|
||||
await tester.pump();
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Center(
|
||||
child: SizedBox(width: 400, height: 800, child: FullImage(asset, size: const Size(400, 800))),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await _settle(tester);
|
||||
expect(_renderedImageWidth(tester), kPreviewSize, reason: 'stuck on thumbnail. URLs: $requestedUrls');
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user