mirror of
https://github.com/immich-app/immich.git
synced 2026-07-10 14:14:00 -07:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 37c68ae947 |
+2
-2
@@ -431,8 +431,8 @@
|
||||
"transcoding_realtime_description": "Permet au transcodage d'être réalisé en temps réel durant la diffusion du flux de la vidéo. Active la bascule automatique entre résolutions, mais peut entraîner une latence importante de lecture et des microcoupures en fonction des capacités du serveur.",
|
||||
"transcoding_realtime_enabled": "Activer la transcodification en temps réel",
|
||||
"transcoding_realtime_enabled_description": "Si désactivé, le serveur refusera de démarrer de nouvelles sessions de transcodifications en temps réel.",
|
||||
"transcoding_realtime_resolutions": "Résolutions",
|
||||
"transcoding_realtime_resolutions_description": "Les résolutions proposées pour le transcodage en temps réel. Des résolutions supérieures peuvent causer des soucis pendant la lecture si le serveur ne peut pas les transcoder suffisamment rapidement.",
|
||||
"transcoding_realtime_resolutions": "Définitions",
|
||||
"transcoding_realtime_resolutions_description": "Les définitions proposées pour le transcodage en temps réel. Des définitions supérieures peuvent causer des soucis pendant la lecture si le serveur ne peut pas les transcoder suffisamment rapidement.",
|
||||
"transcoding_realtime_video_codecs": "Codecs vidéo",
|
||||
"transcoding_realtime_video_codecs_description": "Les codecs vidéo proposés pour le transcodage en temps réel. Les clients choisissent la meilleure option qu'ils supportent durant la lecture. AV1 est plus efficace que HEVC, qui est plus efficace que H.264. En utilisant l'accélération matérielle, sélectionnez seulement les codecs que l'accélérateur peut encoder. En utilisant le transcodage logiciel, notez que H.264 est plus rapide que AV1, qui est plus rapide que HEVC.",
|
||||
"transcoding_reference_frames": "Trames de référence",
|
||||
|
||||
@@ -1,71 +1,9 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:immich_mobile/models/map/map_marker.model.dart';
|
||||
import 'package:immich_mobile/utils/map_utils.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
|
||||
extension MapMarkers on MapLibreMapController {
|
||||
static var _completer = Completer()..complete();
|
||||
|
||||
Future<void> addGeoJSONSourceForMarkers(List<MapMarker> markers) async {
|
||||
return addSource(
|
||||
MapUtils.defaultSourceId,
|
||||
GeojsonSourceProperties(data: MapUtils.generateGeoJsonForMarkers(markers.toList())),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> reloadAllLayersForMarkers(List<MapMarker> markers) async {
|
||||
// Wait for previous reload to complete
|
||||
if (!_completer.isCompleted) {
|
||||
return _completer.future;
|
||||
}
|
||||
_completer = Completer();
|
||||
|
||||
// !! Make sure to remove layers before sources else the native
|
||||
// maplibre library would crash when removing the source saying that
|
||||
// the source is still in use
|
||||
final existingLayers = await getLayerIds();
|
||||
if (existingLayers.contains(MapUtils.defaultHeatMapLayerId)) {
|
||||
await removeLayer(MapUtils.defaultHeatMapLayerId);
|
||||
}
|
||||
|
||||
final existingSources = await getSourceIds();
|
||||
if (existingSources.contains(MapUtils.defaultSourceId)) {
|
||||
await removeSource(MapUtils.defaultSourceId);
|
||||
}
|
||||
|
||||
await addGeoJSONSourceForMarkers(markers);
|
||||
|
||||
if (Platform.isAndroid) {
|
||||
await addCircleLayer(
|
||||
MapUtils.defaultSourceId,
|
||||
MapUtils.defaultHeatMapLayerId,
|
||||
const CircleLayerProperties(
|
||||
circleRadius: 10,
|
||||
circleColor: "rgba(150,86,34,0.7)",
|
||||
circleBlur: 1.0,
|
||||
circleOpacity: 0.7,
|
||||
circleStrokeWidth: 0.1,
|
||||
circleStrokeColor: "rgba(203,46,19,0.5)",
|
||||
circleStrokeOpacity: 0.7,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (Platform.isIOS) {
|
||||
await addHeatmapLayer(
|
||||
MapUtils.defaultSourceId,
|
||||
MapUtils.defaultHeatMapLayerId,
|
||||
MapUtils.defaultHeatMapLayerProperties,
|
||||
);
|
||||
}
|
||||
|
||||
_completer.complete();
|
||||
}
|
||||
|
||||
Future<Symbol?> addMarkerAtLatLng(LatLng centre) async {
|
||||
// no marker is displayed if asset-path is incorrect
|
||||
try {
|
||||
@@ -76,14 +14,4 @@ extension MapMarkers on MapLibreMapController {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
|
||||
Future<LatLngBounds> getBoundsFromPoint(Point<double> point, double distance) async {
|
||||
final southWestPx = Point(point.x - distance, point.y + distance);
|
||||
final northEastPx = Point(point.x + distance, point.y - distance);
|
||||
|
||||
final southWest = await toLatLng(southWestPx);
|
||||
final northEast = await toLatLng(northEastPx);
|
||||
|
||||
return LatLngBounds(southwest: southWest, northeast: northEast);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -52,7 +52,12 @@ class DriftMap extends ConsumerStatefulWidget {
|
||||
class _DriftMapState extends ConsumerState<DriftMap> {
|
||||
MapLibreMapController? mapController;
|
||||
final _reloadMutex = AsyncMutex();
|
||||
final _debouncer = Debouncer(interval: const Duration(milliseconds: 500), maxWaitTime: const Duration(seconds: 2));
|
||||
|
||||
final _debouncer = Debouncer(
|
||||
interval: const Duration(milliseconds: 150),
|
||||
maxWaitTime: const Duration(milliseconds: 500),
|
||||
);
|
||||
|
||||
final ValueNotifier<double> bottomSheetOffset = ValueNotifier(0.25);
|
||||
final GlobalKey _bottomSheetKey = GlobalKey();
|
||||
StreamSubscription? _eventSubscription;
|
||||
@@ -117,7 +122,7 @@ class _DriftMapState extends ConsumerState<DriftMap> {
|
||||
}
|
||||
|
||||
void onMapMoved() {
|
||||
if (mapController!.isCameraMoving || !mounted) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -213,6 +218,8 @@ class _Map extends StatelessWidget {
|
||||
: CameraPosition(target: initialLocation, zoom: MapUtils.mapZoomToAssetLevel),
|
||||
compassEnabled: false,
|
||||
rotateGesturesEnabled: false,
|
||||
// Get continuous movement events for bounds tracking
|
||||
trackCameraPosition: true,
|
||||
styleString: style,
|
||||
onMapCreated: onMapCreated,
|
||||
onStyleLoadedCallback: onMapReady,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
@@ -7,16 +7,6 @@ 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: () => {},
|
||||
@@ -34,6 +24,15 @@ 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,14 +4,16 @@ 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,11 +4,6 @@ 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,
|
||||
@@ -31,6 +26,10 @@ 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).toHaveBeenCalledOnce();
|
||||
expect(sdkMock.getAllAlbums).toBeCalledTimes(1);
|
||||
|
||||
// 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 })).toThrow();
|
||||
expect(() => render(FormatMessage, { key: '' as Translations })).toThrowError();
|
||||
await locale.set('en');
|
||||
});
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ describe('TimelineManager', () => {
|
||||
});
|
||||
|
||||
it('should load months in viewport', () => {
|
||||
expect(sdkMock.getTimeBuckets).toHaveBeenCalledOnce();
|
||||
expect(sdkMock.getTimeBuckets).toBeCalledTimes(1);
|
||||
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).toHaveBeenCalledOnce();
|
||||
expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
|
||||
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).not.toHaveBeenCalled();
|
||||
expect(sdkMock.getTimeBucket).toBeCalledTimes(0);
|
||||
});
|
||||
|
||||
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).toHaveBeenCalledOnce();
|
||||
expect(abortSpy).toBeCalledTimes(1);
|
||||
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).toHaveBeenCalledOnce();
|
||||
expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
|
||||
|
||||
await timelineManager.loadTimelineMonth({ year: 2024, month: 1 });
|
||||
expect(sdkMock.getTimeBucket).toHaveBeenCalledOnce();
|
||||
expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('allows loading a canceled month', async () => {
|
||||
@@ -283,7 +283,7 @@ describe('TimelineManager', () => {
|
||||
const asset = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build());
|
||||
timelineManager.upsertAssets([asset]);
|
||||
|
||||
expect(updateAssetsSpy).toHaveBeenCalledWith([asset]);
|
||||
expect(updateAssetsSpy).toBeCalledWith([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).not.toHaveBeenCalled();
|
||||
expect(previousMonthSpy).not.toHaveBeenCalled();
|
||||
expect(loadTimelineMonthSpy).toBeCalledTimes(0);
|
||||
expect(previousMonthSpy).toBeCalledTimes(0);
|
||||
});
|
||||
|
||||
it('skips removed assets', async () => {
|
||||
|
||||
@@ -2,15 +2,17 @@ import type { ServerConfigDto } from '@immich/sdk';
|
||||
import { asUrl } from '$lib/services/shared-link.service';
|
||||
import { sharedLinkFactory } from '@test-data/factories/shared-link-factory';
|
||||
|
||||
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', () => {
|
||||
beforeAll(() => {
|
||||
vi.mock(import('$lib/managers/server-config-manager.svelte'), () => ({
|
||||
serverConfigManager: {
|
||||
value: { externalDomain: 'http://localhost:2283' } as ServerConfigDto,
|
||||
init: vi.fn(),
|
||||
loadServerConfig: vi.fn(),
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
describe('asUrl', () => {
|
||||
it('should properly encode characters in slug', () => {
|
||||
expect(asUrl(sharedLinkFactory.build({ slug: 'foo/bar' }))).toBe('http://localhost:2283/s/foo%2Fbar');
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
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');
|
||||
@@ -45,6 +41,10 @@ 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', () => {
|
||||
|
||||
@@ -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.toHaveBeenCalled();
|
||||
expect(finished).not.toBeCalled();
|
||||
expect(started).toHaveBeenCalledTimes(3);
|
||||
|
||||
vi.advanceTimersByTime(100);
|
||||
|
||||
Reference in New Issue
Block a user