Compare commits

..

2 Commits

Author SHA1 Message Date
Alex Tran e4926a2039 fix: reduced animation causes asset viewer to not render high res version 2026-07-10 14:56:11 -05:00
Mees Frensel 19313e75fd chore(web): fix testing deprecations (#29799) 2026-07-10 14:30:12 +02:00
25 changed files with 552 additions and 220 deletions
+2 -3
View File
@@ -814,7 +814,7 @@
"custom_date": "Custom date",
"custom_locale": "Custom locale",
"custom_locale_description": "Format dates, times, and numbers based on the selected language and region",
"custom_url_name": "Custom URL name",
"custom_url": "Custom URL",
"cutoff_date_description": "Keep photos from the last…",
"cutoff_day": "{count, plural, one {day} other {days}}",
"cutoff_year": "{count, plural, one {year} other {years}}",
@@ -1948,8 +1948,7 @@
"shared_link_app_bar_title": "Shared Links",
"shared_link_clipboard_copied_massage": "Copied to clipboard",
"shared_link_create_error": "Error while creating shared link",
"shared_link_custom_url_description": "Access this shared link with a custom URL name",
"shared_link_custom_url_warning": "Warning: a custom URL name with a forward slash may not behave as you expect.",
"shared_link_custom_url_description": "Access this shared link with a custom URL",
"shared_link_edit_description_hint": "Enter the share description",
"shared_link_edit_expire_after_option_day": "1 day",
"shared_link_edit_expire_after_option_days": "{count} days",
@@ -10,6 +10,7 @@ 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';
@@ -117,7 +118,9 @@ class _DriftEditImagePageState extends ConsumerState<DriftEditImagePage> with Ti
bottom: false,
child: Column(
children: [
Expanded(child: _EditorPreview(image: widget.image)),
Expanded(
child: ProgressiveImageGuard(child: _EditorPreview(image: widget.image)),
),
AnimatedSize(
duration: const Duration(milliseconds: 250),
curve: Curves.easeInOut,
@@ -427,7 +430,9 @@ 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: CropImage(controller: cropController, image: widget.image, gridColor: Colors.white),
child: ProgressiveImageGuard(
child: CropImage(controller: cropController, image: widget.image, gridColor: Colors.white),
),
),
),
),
@@ -10,6 +10,7 @@ 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';
@@ -170,7 +171,9 @@ class _ProfilePictureCropPageState extends ConsumerState<ProfilePictureCropPage>
],
),
child: ClipRRect(
child: CropImage(controller: _cropController, image: image, gridColor: Colors.white),
child: ProgressiveImageGuard(
child: CropImage(controller: _cropController, image: image, gridColor: Colors.white),
),
),
),
),
@@ -8,6 +8,7 @@ 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';
@@ -301,7 +302,8 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
return IgnorePointer(
child: Stack(
children: [
if (!_isVideoReady || widget.asset.isMotionPhoto || isCasting) Center(child: widget.image),
if (!_isVideoReady || widget.asset.isMotionPhoto || isCasting)
Center(child: ProgressiveImageGuard(child: widget.image)),
if (!isCasting) ...[
Visibility.maintain(
visible: _isVideoReady,
@@ -1,6 +1,7 @@
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';
@@ -21,18 +22,20 @@ class FullImage extends StatelessWidget {
@override
Widget build(BuildContext context) {
final provider = getFullImageProvider(asset, size: size);
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);
},
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);
},
),
);
}
}
@@ -0,0 +1,26 @@
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,6 +10,7 @@ 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';
@@ -404,26 +405,28 @@ class _RandomAssetBackgroundState extends State<_RandomAssetBackground> with Tic
if (_currentAsset != null)
Opacity(
opacity: _crossFadeAnimation.value,
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: 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]),
);
},
),
),
),
),
@@ -431,26 +434,28 @@ class _RandomAssetBackgroundState extends State<_RandomAssetBackground> with Tic
if (_nextAsset != null)
Opacity(
opacity: 1.0 - _crossFadeAnimation.value,
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: 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]),
);
},
),
),
),
),
@@ -13,6 +13,7 @@ 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';
@@ -507,26 +508,28 @@ class _RandomAssetBackgroundState extends State<_RandomAssetBackground> with Tic
if (_currentAsset != null)
Opacity(
opacity: _crossFadeAnimation.value,
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: 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]),
);
},
),
),
),
),
@@ -534,26 +537,28 @@ class _RandomAssetBackgroundState extends State<_RandomAssetBackground> with Tic
if (_nextAsset != null)
Opacity(
opacity: 1.0 - _crossFadeAnimation.value,
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: 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]),
);
},
),
),
),
),
@@ -14,6 +14,7 @@ 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';
@@ -483,26 +484,28 @@ class _RandomAssetBackgroundState extends State<_RandomAssetBackground> with Tic
if (_currentAsset != null)
Opacity(
opacity: _crossFadeAnimation.value,
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: 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]),
);
},
),
),
),
),
@@ -510,26 +513,28 @@ class _RandomAssetBackgroundState extends State<_RandomAssetBackground> with Tic
if (_nextAsset != null)
Opacity(
opacity: 1.0 - _crossFadeAnimation.value,
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: 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]),
);
},
),
),
),
),
@@ -1,4 +1,5 @@
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,
@@ -438,15 +439,17 @@ class PhotoViewCoreState extends State<PhotoViewCore>
height: scaleBoundaries.childSize.height * scale,
child: widget.customChild!,
)
: 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,
: 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,
),
);
}
}
@@ -0,0 +1,296 @@
// 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');
});
}
@@ -1,6 +1,6 @@
<script lang="ts">
import SharedLinkExpiration from '$lib/components/SharedLinkExpiration.svelte';
import { Field, HelperText, Input, PasswordInput, Switch, Text } from '@immich/ui';
import { Field, Input, PasswordInput, Switch, Text } from '@immich/ui';
import { t } from 'svelte-i18n';
type Props = {
@@ -32,11 +32,8 @@
<div class="mt-4 flex flex-col gap-4">
<div>
<Field label={$t('custom_url_name')} description={$t('shared_link_custom_url_description')}>
<Field label={$t('custom_url')} description={$t('shared_link_custom_url_description')}>
<Input bind:value={slug} autocomplete="off" />
{#if slug.includes('/')}
<HelperText class="text-warning">{$t('shared_link_custom_url_warning')}</HelperText>
{/if}
</Field>
{#if slug}
<Text size="tiny" color="muted" class="pt-2 break-all">/s/{encodeURIComponent(slug)}</Text>
@@ -7,6 +7,16 @@ import { preferencesFactory } from '@test-data/factories/preferences-factory';
import { userAdminFactory } from '@test-data/factories/user-factory';
import AssetViewerNavBar from './AssetViewerNavBar.svelte';
vi.mock(import('$lib/managers/feature-flags-manager.svelte'), function () {
return {
featureFlagsManager: {
init: vi.fn(),
loadFeatureFlags: vi.fn(),
value: { smartSearch: true, trash: true },
} as never,
};
});
describe('AssetViewerNavBar component', () => {
const additionalProps = {
preAction: () => {},
@@ -24,15 +34,6 @@ describe('AssetViewerNavBar component', () => {
};
});
vi.stubGlobal('ResizeObserver', getResizeObserverMock());
vi.mock(import('$lib/managers/feature-flags-manager.svelte'), function () {
return {
featureFlagsManager: {
init: vi.fn(),
loadFeatureFlags: vi.fn(),
value: { smartSearch: true, trash: true },
} as never,
};
});
});
afterEach(() => {
@@ -4,16 +4,14 @@ import { renderWithTooltips } from '$tests/helpers';
import { assetFactory } from '@test-data/factories/asset-factory';
import DeleteAction from './DeleteAction.svelte';
vi.mock(import('$lib/managers/feature-flags-manager.svelte'), () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return { featureFlagsManager: { init: vi.fn(), loadFeatureFlags: vi.fn(), value: { trash: true } } as any };
});
let asset: AssetResponseDto;
describe('DeleteAction component', () => {
beforeEach(() => {
vi.mock(import('$lib/managers/feature-flags-manager.svelte'), () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return { featureFlagsManager: { init: vi.fn(), loadFeatureFlags: vi.fn(), value: { trash: true } } as any };
});
});
describe('given an asset which is not trashed yet', () => {
beforeEach(() => {
asset = assetFactory.build({ isTrashed: false });
@@ -4,6 +4,11 @@ import Thumbnail from '$lib/components/assets/thumbnail/Thumbnail.svelte';
import { getTabbable } from '$lib/utils/focus-util';
import { assetFactory } from '@test-data/factories/asset-factory';
vi.mock('$lib/utils/navigation', () => ({
currentUrlReplaceAssetId: vi.fn(),
isSharedLinkRoute: vi.fn().mockReturnValue(false),
}));
vi.hoisted(() => {
Object.defineProperty(globalThis, 'matchMedia', {
writable: true,
@@ -26,10 +31,6 @@ vi.hoisted(() => {
describe('Thumbnail component', () => {
beforeAll(() => {
vi.stubGlobal('IntersectionObserver', getIntersectionObserverMock());
vi.mock('$lib/utils/navigation', () => ({
currentUrlReplaceAssetId: vi.fn(),
isSharedLinkRoute: vi.fn().mockReturnValue(false),
}));
});
it('should only contain a single tabbable element (the container)', () => {
@@ -16,7 +16,7 @@ describe('RecentAlbums component', () => {
sdkMock.getAllAlbums.mockResolvedValueOnce([...albums]);
render(RecentAlbums);
expect(sdkMock.getAllAlbums).toBeCalledTimes(1);
expect(sdkMock.getAllAlbums).toHaveBeenCalledOnce();
// wtf
await tick();
@@ -35,7 +35,7 @@ describe('FormatMessage component', () => {
it('throws an error when locale is empty', async () => {
await locale.set(undefined);
expect(() => render(FormatMessage, { key: '' as Translations })).toThrowError();
expect(() => render(FormatMessage, { key: '' as Translations })).toThrow();
await locale.set('en');
});
@@ -70,7 +70,7 @@ describe('TimelineManager', () => {
});
it('should load months in viewport', () => {
expect(sdkMock.getTimeBuckets).toBeCalledTimes(1);
expect(sdkMock.getTimeBuckets).toHaveBeenCalledOnce();
expect(sdkMock.getTimeBucket).toHaveBeenCalledTimes(2);
});
@@ -133,13 +133,13 @@ describe('TimelineManager', () => {
it('loads a month', async () => {
expect(getTimelineMonthByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(0);
await timelineManager.loadTimelineMonth({ year: 2024, month: 1 });
expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
expect(sdkMock.getTimeBucket).toHaveBeenCalledOnce();
expect(getTimelineMonthByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(3);
});
it('ignores invalid months', async () => {
await timelineManager.loadTimelineMonth({ year: 2023, month: 1 });
expect(sdkMock.getTimeBucket).toBeCalledTimes(0);
expect(sdkMock.getTimeBucket).not.toHaveBeenCalled();
});
it('cancels month loading', async () => {
@@ -147,7 +147,7 @@ describe('TimelineManager', () => {
void timelineManager.loadTimelineMonth({ year: 2024, month: 1 });
const abortSpy = vi.spyOn(month!.loader!.cancelToken!, 'abort');
month?.cancel();
expect(abortSpy).toBeCalledTimes(1);
expect(abortSpy).toHaveBeenCalledOnce();
await timelineManager.loadTimelineMonth({ year: 2024, month: 1 });
expect(getTimelineMonthByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(3);
});
@@ -157,10 +157,10 @@ describe('TimelineManager', () => {
timelineManager.loadTimelineMonth({ year: 2024, month: 1 }),
timelineManager.loadTimelineMonth({ year: 2024, month: 1 }),
]);
expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
expect(sdkMock.getTimeBucket).toHaveBeenCalledOnce();
await timelineManager.loadTimelineMonth({ year: 2024, month: 1 });
expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
expect(sdkMock.getTimeBucket).toHaveBeenCalledOnce();
});
it('allows loading a canceled month', async () => {
@@ -283,7 +283,7 @@ describe('TimelineManager', () => {
const asset = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build());
timelineManager.upsertAssets([asset]);
expect(updateAssetsSpy).toBeCalledWith([asset]);
expect(updateAssetsSpy).toHaveBeenCalledWith([asset]);
expect(timelineManager.assetCount).toEqual(1);
});
@@ -642,8 +642,8 @@ describe('TimelineManager', () => {
const previousMonthSpy = vi.spyOn(previousMonth!.loader!, 'execute');
const previous = await timelineManager.getLaterAsset(a);
expect(previous).toEqual(b);
expect(loadTimelineMonthSpy).toBeCalledTimes(0);
expect(previousMonthSpy).toBeCalledTimes(0);
expect(loadTimelineMonthSpy).not.toHaveBeenCalled();
expect(previousMonthSpy).not.toHaveBeenCalled();
});
it('skips removed assets', async () => {
-14
View File
@@ -24,20 +24,6 @@ describe('Route', () => {
});
});
describe(Route.viewSharedLink.name, () => {
it('should work with key', () => {
expect(Route.viewSharedLink({ key: 'uuid-key' })).toBe('/share/uuid-key');
});
it('should work with key and slug', () => {
expect(Route.viewSharedLink({ key: 'uuid-key', slug: 'custom-slug' })).toBe('/s/custom-slug');
});
it('should URI encode slug', () => {
expect(Route.viewSharedLink({ key: 'uuid-key', slug: 'albums/the-moon?' })).toBe('/s/albums%2Fthe-moon%3F');
});
});
describe(Route.tags.name, () => {
it('should work', () => {
expect(Route.tags()).toBe('/tags');
+1 -2
View File
@@ -120,8 +120,7 @@ export const Route = {
// shared links
sharedLinks: (params?: { filter?: SharedLinkTab }) => '/shared-links' + asQueryString(params),
editSharedLink: ({ id }: { id: string }) => `/shared-links/${id}/edit`,
viewSharedLink: ({ slug, key }: { slug?: string | null; key: string }) =>
slug ? `/s/${encodeURIComponent(slug)}` : `/share/${key}`,
viewSharedLink: ({ slug, key }: { slug?: string | null; key: string }) => (slug ? `/s/${slug}` : `/share/${key}`),
// settings
userSettings: (params?: { isOpen?: OpenQueryParam }) => '/user-settings' + asQueryString(params),
@@ -2,17 +2,15 @@ import type { ServerConfigDto } from '@immich/sdk';
import { asUrl } from '$lib/services/shared-link.service';
import { sharedLinkFactory } from '@test-data/factories/shared-link-factory';
describe('SharedLinkService', () => {
beforeAll(() => {
vi.mock(import('$lib/managers/server-config-manager.svelte'), () => ({
serverConfigManager: {
value: { externalDomain: 'http://localhost:2283' } as ServerConfigDto,
init: vi.fn(),
loadServerConfig: vi.fn(),
},
}));
});
vi.mock(import('$lib/managers/server-config-manager.svelte'), () => ({
serverConfigManager: {
value: { externalDomain: 'http://localhost:2283' } as ServerConfigDto,
init: vi.fn(),
loadServerConfig: vi.fn(),
},
}));
describe('SharedLinkService', () => {
describe('asUrl', () => {
it('should properly encode characters in slug', () => {
expect(asUrl(sharedLinkFactory.build({ slug: 'foo/bar' }))).toBe('http://localhost:2283/s/foo%2Fbar');
+3 -1
View File
@@ -60,7 +60,9 @@ export const getSharedLinkActions = ($t: MessageFormatter, sharedLink: SharedLin
};
export const asUrl = (sharedLink: SharedLinkResponseDto) => {
const path = Route.viewSharedLink(sharedLink);
const path = sharedLink.slug
? `s/${encodeURIComponent(sharedLink.slug)}`
: `share/${encodeURIComponent(sharedLink.key)}`;
return new URL(path, serverConfigManager.value.externalDomain || globalThis.location.origin).href;
};
+4 -4
View File
@@ -1,6 +1,10 @@
import { writable } from 'svelte/store';
import { getAlbumDateRange, getShortDateRange } from './date-time';
vitest.mock('$lib/stores/preferences.store', () => ({
locale: writable('en'),
}));
describe('getShortDateRange', () => {
beforeEach(() => {
vi.stubEnv('TZ', 'UTC');
@@ -41,10 +45,6 @@ describe('getShortDateRange', () => {
describe('getAlbumDate', () => {
beforeAll(() => {
process.env.TZ = 'UTC';
vitest.mock('$lib/stores/preferences.store', () => ({
locale: writable('en'),
}));
});
it('should work with only a start date', () => {
+1 -1
View File
@@ -34,7 +34,7 @@ describe('Executor Queue test', function () {
// The last task will be executed after 200ms and will finish at 400ms
void eq.addTask(() => timeoutPromiseBuilder(200, 'T4'));
expect(finished).not.toBeCalled();
expect(finished).not.toHaveBeenCalled();
expect(started).toHaveBeenCalledTimes(3);
vi.advanceTimersByTime(100);
@@ -56,10 +56,8 @@
{#if shareType === SharedLinkType.Individual}
<div class="text-sm">
{$t('individual_share')}
{#if description !== ''}
| <span class="text-primary">{description}</span>
{/if}
{$t('individual_share')} |
<span class="text-primary">{sharedLink.description || ''}</span>
</div>
{/if}