From b46e8342202c546efdec6b037acd1fc6afdd87f3 Mon Sep 17 00:00:00 2001
From: Matthias Rupp <matthias.rupp@posteo.de>
Date: Mon, 8 Aug 2022 02:43:09 +0200
Subject: [PATCH] Mobile performance improvements (#417)

* First performance tweaks (caching and rendering improvemetns)

* Revert asset response caching

* 3-step image loading in asset viewer

* Prevent panning and zooming until full-scale version is loaded

* Loading indicator

* Adapt to gallery PR

* Cleanup

* Dart format

* Fix exif sheet

* Disable three stage loading until settings are available
---
 mobile/lib/main.dart                          |  6 ++
 .../album/ui/album_viewer_thumbnail.dart      |  5 +-
 .../ui/shared_album_thumbnail_image.dart      |  5 +-
 .../asset_viewer/ui/remote_photo_view.dart    | 78 ++++++++++++++-----
 .../asset_viewer/ui/top_control_app_bar.dart  | 10 +++
 .../asset_viewer/views/gallery_viewer.dart    | 13 ++--
 .../asset_viewer/views/image_viewer_page.dart | 31 +++++---
 .../lib/modules/home/ui/thumbnail_image.dart  |  5 +-
 mobile/lib/modules/home/views/home_page.dart  |  1 +
 mobile/lib/routing/router.gr.dart             | 62 +++++++--------
 mobile/lib/utils/image_url_builder.dart       | 16 ++++
 mobile/pubspec.lock                           |  7 ++
 mobile/pubspec.yaml                           |  1 +
 13 files changed, 159 insertions(+), 81 deletions(-)
 create mode 100644 mobile/lib/utils/image_url_builder.dart

diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart
index fdc1a43b8e..94d581c57a 100644
--- a/mobile/lib/main.dart
+++ b/mobile/lib/main.dart
@@ -1,6 +1,8 @@
 import 'package:easy_localization/easy_localization.dart';
+import 'package:flutter/foundation.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
+import 'package:flutter_displaymode/flutter_displaymode.dart';
 import 'package:hive_flutter/hive_flutter.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/constants/immich_colors.dart';
@@ -49,6 +51,10 @@ void main() async {
     Locale('it', 'IT'),
   ];
 
+  if (kReleaseMode) {
+    await FlutterDisplayMode.setHighRefreshRate();
+  }
+
   runApp(
     EasyLocalization(
       supportedLocales: locales,
diff --git a/mobile/lib/modules/album/ui/album_viewer_thumbnail.dart b/mobile/lib/modules/album/ui/album_viewer_thumbnail.dart
index 17686afeb2..c5441fe9d7 100644
--- a/mobile/lib/modules/album/ui/album_viewer_thumbnail.dart
+++ b/mobile/lib/modules/album/ui/album_viewer_thumbnail.dart
@@ -8,6 +8,7 @@ import 'package:immich_mobile/constants/hive_box.dart';
 import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
 import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart';
 import 'package:immich_mobile/routing/router.dart';
+import 'package:immich_mobile/utils/image_url_builder.dart';
 import 'package:openapi/api.dart';
 
 class AlbumViewerThumbnail extends HookConsumerWidget {
@@ -24,8 +25,7 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
   Widget build(BuildContext context, WidgetRef ref) {
     final cacheKey = useState(1);
     var box = Hive.box(userInfoBox);
-    var thumbnailRequestUrl =
-        '${box.get(serverEndpointKey)}/asset/thumbnail/${asset.id}';
+    var thumbnailRequestUrl = getThumbnailUrl(asset);
     var deviceId = ref.watch(authenticationProvider).deviceId;
     final selectedAssetsInAlbumViewer =
         ref.watch(assetSelectionProvider).selectedAssetsInAlbumViewer;
@@ -37,7 +37,6 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
         GalleryViewerRoute(
           asset: asset,
           assetList: assetList,
-          thumbnailRequestUrl: thumbnailRequestUrl,
         ),
       );
     }
diff --git a/mobile/lib/modules/album/ui/shared_album_thumbnail_image.dart b/mobile/lib/modules/album/ui/shared_album_thumbnail_image.dart
index d62f1e097b..4f90a8cf36 100644
--- a/mobile/lib/modules/album/ui/shared_album_thumbnail_image.dart
+++ b/mobile/lib/modules/album/ui/shared_album_thumbnail_image.dart
@@ -4,6 +4,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
 import 'package:hive_flutter/hive_flutter.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/constants/hive_box.dart';
+import 'package:immich_mobile/utils/image_url_builder.dart';
 import 'package:openapi/api.dart';
 
 class SharedAlbumThumbnailImage extends HookConsumerWidget {
@@ -17,8 +18,6 @@ class SharedAlbumThumbnailImage extends HookConsumerWidget {
     final cacheKey = useState(1);
 
     var box = Hive.box(userInfoBox);
-    var thumbnailRequestUrl =
-        '${box.get(serverEndpointKey)}/asset/thumbnail/${asset.id}';
 
     return GestureDetector(
       onTap: () {
@@ -32,7 +31,7 @@ class SharedAlbumThumbnailImage extends HookConsumerWidget {
             height: 500,
             memCacheHeight: 500,
             fit: BoxFit.cover,
-            imageUrl: thumbnailRequestUrl,
+            imageUrl: getThumbnailUrl(asset),
             httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
             fadeInDuration: const Duration(milliseconds: 250),
             progressIndicatorBuilder: (context, url, downloadProgress) =>
diff --git a/mobile/lib/modules/asset_viewer/ui/remote_photo_view.dart b/mobile/lib/modules/asset_viewer/ui/remote_photo_view.dart
index cb930bc621..862de5b501 100644
--- a/mobile/lib/modules/asset_viewer/ui/remote_photo_view.dart
+++ b/mobile/lib/modules/asset_viewer/ui/remote_photo_view.dart
@@ -3,7 +3,7 @@ import 'package:flutter/cupertino.dart';
 import 'package:flutter/material.dart';
 import 'package:photo_view/photo_view.dart';
 
-enum _RemoteImageStatus { empty, thumbnail, full }
+enum _RemoteImageStatus { empty, thumbnail, preview, full }
 
 class _RemotePhotoViewState extends State<RemotePhotoView> {
   late CachedNetworkImageProvider _imageProvider;
@@ -15,13 +15,16 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
   @override
   Widget build(BuildContext context) {
     bool allowMoving = _status == _RemoteImageStatus.full;
-    return PhotoView(
-      imageProvider: _imageProvider,
-      minScale: PhotoViewComputedScale.contained,
-      maxScale: allowMoving ? 1.0 : PhotoViewComputedScale.contained,
-      enablePanAlways: true,
-      scaleStateChangedCallback: _scaleStateChanged,
-      onScaleEnd: _onScaleListener,
+
+    return IgnorePointer(
+      ignoring: !allowMoving,
+      child: PhotoView(
+        imageProvider: _imageProvider,
+        minScale: PhotoViewComputedScale.contained,
+        enablePanAlways: true,
+        scaleStateChangedCallback: _scaleStateChanged,
+        onScaleEnd: _onScaleListener,
+      ),
     );
   }
 
@@ -52,6 +55,14 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
     widget.isZoomedFunction();
   }
 
+  void _fireStartLoadingEvent() {
+    if (widget.onLoadingStart != null) widget.onLoadingStart!();
+  }
+
+  void _fireFinishedLoadingEvent() {
+    if (widget.onLoadingCompleted != null) widget.onLoadingCompleted!();
+  }
+
   CachedNetworkImageProvider _authorizedImageProvider(String url) {
     return CachedNetworkImageProvider(
       url,
@@ -64,14 +75,25 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
     _RemoteImageStatus newStatus,
     CachedNetworkImageProvider provider,
   ) {
-    // Transition to same status is forbidden
     if (_status == newStatus) return;
-    // Transition full -> thumbnail is forbidden
+
     if (_status == _RemoteImageStatus.full &&
         newStatus == _RemoteImageStatus.thumbnail) return;
 
+    if (_status == _RemoteImageStatus.preview &&
+        newStatus == _RemoteImageStatus.thumbnail) return;
+
+    if (_status == _RemoteImageStatus.full &&
+        newStatus == _RemoteImageStatus.preview) return;
+
     if (!mounted) return;
 
+    if (newStatus != _RemoteImageStatus.full) {
+      _fireStartLoadingEvent();
+    } else {
+      _fireFinishedLoadingEvent();
+    }
+
     setState(() {
       _status = newStatus;
       _imageProvider = provider;
@@ -92,6 +114,16 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
       }),
     );
 
+    if (widget.previewUrl != null) {
+      CachedNetworkImageProvider previewProvider =
+          _authorizedImageProvider(widget.previewUrl!);
+      previewProvider.resolve(const ImageConfiguration()).addListener(
+        ImageStreamListener((ImageInfo imageInfo, _) {
+          _performStateTransition(_RemoteImageStatus.preview, previewProvider);
+        }),
+      );
+    }
+
     CachedNetworkImageProvider fullProvider =
         _authorizedImageProvider(widget.imageUrl);
     fullProvider.resolve(const ImageConfiguration()).addListener(
@@ -109,20 +141,26 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
 }
 
 class RemotePhotoView extends StatefulWidget {
-  const RemotePhotoView({
-    Key? key,
-    required this.thumbnailUrl,
-    required this.imageUrl,
-    required this.authToken,
-    required this.isZoomedFunction,
-    required this.isZoomedListener,
-    required this.onSwipeDown,
-    required this.onSwipeUp,
-  }) : super(key: key);
+  const RemotePhotoView(
+      {Key? key,
+      required this.thumbnailUrl,
+      required this.imageUrl,
+      required this.authToken,
+      required this.isZoomedFunction,
+      required this.isZoomedListener,
+      required this.onSwipeDown,
+      required this.onSwipeUp,
+      this.previewUrl,
+      this.onLoadingCompleted,
+      this.onLoadingStart})
+      : super(key: key);
 
   final String thumbnailUrl;
   final String imageUrl;
   final String authToken;
+  final String? previewUrl;
+  final Function? onLoadingCompleted;
+  final Function? onLoadingStart;
 
   final void Function() onSwipeDown;
   final void Function() onSwipeUp;
diff --git a/mobile/lib/modules/asset_viewer/ui/top_control_app_bar.dart b/mobile/lib/modules/asset_viewer/ui/top_control_app_bar.dart
index eba66a2691..3c298568b2 100644
--- a/mobile/lib/modules/asset_viewer/ui/top_control_app_bar.dart
+++ b/mobile/lib/modules/asset_viewer/ui/top_control_app_bar.dart
@@ -11,11 +11,13 @@ class TopControlAppBar extends ConsumerWidget with PreferredSizeWidget {
     required this.asset,
     required this.onMoreInfoPressed,
     required this.onDownloadPressed,
+    this.loading = false
   }) : super(key: key);
 
   final AssetResponseDto asset;
   final Function onMoreInfoPressed;
   final Function onDownloadPressed;
+  final bool loading;
 
   @override
   Widget build(BuildContext context, WidgetRef ref) {
@@ -35,6 +37,14 @@ class TopControlAppBar extends ConsumerWidget with PreferredSizeWidget {
         ),
       ),
       actions: [
+        if (loading) Center(
+          child: Container(
+            margin: const EdgeInsets.symmetric(horizontal: 15.0),
+            width: iconSize,
+            height: iconSize,
+            child: const CircularProgressIndicator(strokeWidth: 2.0),
+          ),
+        ) ,
         IconButton(
           iconSize: iconSize,
           splashRadius: iconSize,
diff --git a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart
index 7e0ecc9357..c9d46e6c61 100644
--- a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart
+++ b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart
@@ -17,13 +17,13 @@ import 'package:openapi/api.dart';
 class GalleryViewerPage extends HookConsumerWidget {
   late List<AssetResponseDto> assetList;
   final AssetResponseDto asset;
-  final String thumbnailRequestUrl;
+
+  static const _threeStageLoading = false;
 
   GalleryViewerPage({
     Key? key,
     required this.assetList,
     required this.asset,
-    required this.thumbnailRequestUrl,
   }) : super(key: key);
 
   AssetResponseDto? assetDetail;
@@ -32,6 +32,7 @@ class GalleryViewerPage extends HookConsumerWidget {
     final Box<dynamic> box = Hive.box(userInfoBox);
 
     int indexOfAsset = assetList.indexOf(asset);
+    final loading = useState(false);
 
     @override
     void initState(int index) {
@@ -74,6 +75,7 @@ class GalleryViewerPage extends HookConsumerWidget {
     return Scaffold(
       backgroundColor: Colors.black,
       appBar: TopControlAppBar(
+        loading: loading.value,
         asset: assetList[indexOfAsset],
         onMoreInfoPressed: () {
           showInfo();
@@ -98,15 +100,14 @@ class GalleryViewerPage extends HookConsumerWidget {
             getAssetExif();
             if (assetList[index].type == AssetTypeEnum.IMAGE) {
               return ImageViewerPage(
-                thumbnailUrl:
-                    '${box.get(serverEndpointKey)}/asset/thumbnail/${assetList[index].id}',
-                imageUrl:
-                    '${box.get(serverEndpointKey)}/asset/file?aid=${assetList[index].deviceAssetId}&did=${assetList[index].deviceId}&isThumb=false',
                 authToken: 'Bearer ${box.get(accessTokenKey)}',
                 isZoomedFunction: isZoomedMethod,
                 isZoomedListener: isZoomedListener,
+                onLoadingCompleted: () => loading.value = false,
+                onLoadingStart: () => loading.value = _threeStageLoading,
                 asset: assetList[index],
                 heroTag: assetList[index].id,
+                threeStageLoading: _threeStageLoading
               );
             } else {
               return SwipeDetector(
diff --git a/mobile/lib/modules/asset_viewer/views/image_viewer_page.dart b/mobile/lib/modules/asset_viewer/views/image_viewer_page.dart
index f1130cdf6c..a4817ee9dc 100644
--- a/mobile/lib/modules/asset_viewer/views/image_viewer_page.dart
+++ b/mobile/lib/modules/asset_viewer/views/image_viewer_page.dart
@@ -8,27 +8,30 @@ import 'package:immich_mobile/modules/asset_viewer/ui/download_loading_indicator
 import 'package:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart';
 import 'package:immich_mobile/modules/asset_viewer/ui/remote_photo_view.dart';
 import 'package:immich_mobile/modules/home/services/asset.service.dart';
+import 'package:immich_mobile/utils/image_url_builder.dart';
 import 'package:openapi/api.dart';
 
 // ignore: must_be_immutable
 class ImageViewerPage extends HookConsumerWidget {
-  final String imageUrl;
   final String heroTag;
-  final String thumbnailUrl;
   final AssetResponseDto asset;
   final String authToken;
   final ValueNotifier<bool> isZoomedListener;
   final void Function() isZoomedFunction;
+  final void Function() onLoadingCompleted;
+  final void Function() onLoadingStart;
+  final bool threeStageLoading;
 
   ImageViewerPage({
     Key? key,
-    required this.imageUrl,
     required this.heroTag,
-    required this.thumbnailUrl,
     required this.asset,
     required this.authToken,
     required this.isZoomedFunction,
     required this.isZoomedListener,
+    required this.onLoadingCompleted,
+    required this.onLoadingStart,
+    required this.threeStageLoading,
   }) : super(key: key);
 
   AssetResponseDto? assetDetail;
@@ -68,14 +71,18 @@ class ImageViewerPage extends HookConsumerWidget {
           child: Hero(
             tag: heroTag,
             child: RemotePhotoView(
-              thumbnailUrl: thumbnailUrl,
-              imageUrl: imageUrl,
-              authToken: authToken,
-              isZoomedFunction: isZoomedFunction,
-              isZoomedListener: isZoomedListener,
-              onSwipeDown: () => AutoRouter.of(context).pop(),
-              onSwipeUp: () => showInfo(),
-            ),
+                thumbnailUrl: getThumbnailUrl(asset),
+                imageUrl: getImageUrl(asset),
+                previewUrl: threeStageLoading
+                    ? getThumbnailUrl(asset, type: ThumbnailFormat.JPEG)
+                    : null,
+                authToken: authToken,
+                isZoomedFunction: isZoomedFunction,
+                isZoomedListener: isZoomedListener,
+                onSwipeDown: () => AutoRouter.of(context).pop(),
+                onSwipeUp: () => showInfo(),
+                onLoadingCompleted: onLoadingCompleted,
+                onLoadingStart: onLoadingStart),
           ),
         ),
         if (downloadAssetStatus == DownloadAssetStatus.loading)
diff --git a/mobile/lib/modules/home/ui/thumbnail_image.dart b/mobile/lib/modules/home/ui/thumbnail_image.dart
index c2b07c7621..683ec2d470 100644
--- a/mobile/lib/modules/home/ui/thumbnail_image.dart
+++ b/mobile/lib/modules/home/ui/thumbnail_image.dart
@@ -9,6 +9,7 @@ import 'package:immich_mobile/constants/hive_box.dart';
 import 'package:immich_mobile/modules/home/providers/home_page_state.provider.dart';
 import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
 import 'package:immich_mobile/routing/router.dart';
+import 'package:immich_mobile/utils/image_url_builder.dart';
 import 'package:openapi/api.dart';
 
 class ThumbnailImage extends HookConsumerWidget {
@@ -23,8 +24,7 @@ class ThumbnailImage extends HookConsumerWidget {
     final cacheKey = useState(1);
 
     var box = Hive.box(userInfoBox);
-    var thumbnailRequestUrl =
-        '${box.get(serverEndpointKey)}/asset/thumbnail/${asset.id}';
+    var thumbnailRequestUrl = getThumbnailUrl(asset);
     var selectedAsset = ref.watch(homePageStateProvider).selectedItems;
     var isMultiSelectEnable =
         ref.watch(homePageStateProvider).isMultiSelectEnable;
@@ -65,7 +65,6 @@ class ThumbnailImage extends HookConsumerWidget {
           AutoRouter.of(context).push(
             GalleryViewerRoute(
               assetList: assetList,
-              thumbnailRequestUrl: thumbnailRequestUrl,
               asset: asset,
             ),
           );
diff --git a/mobile/lib/modules/home/views/home_page.dart b/mobile/lib/modules/home/views/home_page.dart
index e63acf1abd..ff259a2fec 100644
--- a/mobile/lib/modules/home/views/home_page.dart
+++ b/mobile/lib/modules/home/views/home_page.dart
@@ -76,6 +76,7 @@ class HomePage extends HookConsumerWidget {
 
           imageGridGroup.add(
             DailyTitleText(
+              key: Key('${dateGroup.toString()}title'),
               isoDate: dateGroup,
               assetGroup: immichAssetList,
             ),
diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart
index a367930af9..c047be73fc 100644
--- a/mobile/lib/routing/router.gr.dart
+++ b/mobile/lib/routing/router.gr.dart
@@ -46,10 +46,7 @@ class _$AppRouter extends RootStackRouter {
       return MaterialPageX<dynamic>(
           routeData: routeData,
           child: GalleryViewerPage(
-              key: args.key,
-              assetList: args.assetList,
-              asset: args.asset,
-              thumbnailRequestUrl: args.thumbnailRequestUrl));
+              key: args.key, assetList: args.assetList, asset: args.asset));
     },
     ImageViewerRoute.name: (routeData) {
       final args = routeData.argsAs<ImageViewerRouteArgs>();
@@ -57,13 +54,14 @@ class _$AppRouter extends RootStackRouter {
           routeData: routeData,
           child: ImageViewerPage(
               key: args.key,
-              imageUrl: args.imageUrl,
               heroTag: args.heroTag,
-              thumbnailUrl: args.thumbnailUrl,
               asset: args.asset,
               authToken: args.authToken,
               isZoomedFunction: args.isZoomedFunction,
-              isZoomedListener: args.isZoomedListener));
+              isZoomedListener: args.isZoomedListener,
+              onLoadingCompleted: args.onLoadingCompleted,
+              onLoadingStart: args.onLoadingStart,
+              threeStageLoading: args.threeStageLoading));
     },
     VideoViewerRoute.name: (routeData) {
       final args = routeData.argsAs<VideoViewerRouteArgs>();
@@ -258,25 +256,18 @@ class GalleryViewerRoute extends PageRouteInfo<GalleryViewerRouteArgs> {
   GalleryViewerRoute(
       {Key? key,
       required List<AssetResponseDto> assetList,
-      required AssetResponseDto asset,
-      required String thumbnailRequestUrl})
+      required AssetResponseDto asset})
       : super(GalleryViewerRoute.name,
             path: '/gallery-viewer-page',
             args: GalleryViewerRouteArgs(
-                key: key,
-                assetList: assetList,
-                asset: asset,
-                thumbnailRequestUrl: thumbnailRequestUrl));
+                key: key, assetList: assetList, asset: asset));
 
   static const String name = 'GalleryViewerRoute';
 }
 
 class GalleryViewerRouteArgs {
   const GalleryViewerRouteArgs(
-      {this.key,
-      required this.assetList,
-      required this.asset,
-      required this.thumbnailRequestUrl});
+      {this.key, required this.assetList, required this.asset});
 
   final Key? key;
 
@@ -284,11 +275,9 @@ class GalleryViewerRouteArgs {
 
   final AssetResponseDto asset;
 
-  final String thumbnailRequestUrl;
-
   @override
   String toString() {
-    return 'GalleryViewerRouteArgs{key: $key, assetList: $assetList, asset: $asset, thumbnailRequestUrl: $thumbnailRequestUrl}';
+    return 'GalleryViewerRouteArgs{key: $key, assetList: $assetList, asset: $asset}';
   }
 }
 
@@ -297,24 +286,26 @@ class GalleryViewerRouteArgs {
 class ImageViewerRoute extends PageRouteInfo<ImageViewerRouteArgs> {
   ImageViewerRoute(
       {Key? key,
-      required String imageUrl,
       required String heroTag,
-      required String thumbnailUrl,
       required AssetResponseDto asset,
       required String authToken,
       required void Function() isZoomedFunction,
-      required ValueNotifier<bool> isZoomedListener})
+      required ValueNotifier<bool> isZoomedListener,
+      required void Function() onLoadingCompleted,
+      required void Function() onLoadingStart,
+      required bool threeStageLoading})
       : super(ImageViewerRoute.name,
             path: '/image-viewer-page',
             args: ImageViewerRouteArgs(
                 key: key,
-                imageUrl: imageUrl,
                 heroTag: heroTag,
-                thumbnailUrl: thumbnailUrl,
                 asset: asset,
                 authToken: authToken,
                 isZoomedFunction: isZoomedFunction,
-                isZoomedListener: isZoomedListener));
+                isZoomedListener: isZoomedListener,
+                onLoadingCompleted: onLoadingCompleted,
+                onLoadingStart: onLoadingStart,
+                threeStageLoading: threeStageLoading));
 
   static const String name = 'ImageViewerRoute';
 }
@@ -322,22 +313,19 @@ class ImageViewerRoute extends PageRouteInfo<ImageViewerRouteArgs> {
 class ImageViewerRouteArgs {
   const ImageViewerRouteArgs(
       {this.key,
-      required this.imageUrl,
       required this.heroTag,
-      required this.thumbnailUrl,
       required this.asset,
       required this.authToken,
       required this.isZoomedFunction,
-      required this.isZoomedListener});
+      required this.isZoomedListener,
+      required this.onLoadingCompleted,
+      required this.onLoadingStart,
+      required this.threeStageLoading});
 
   final Key? key;
 
-  final String imageUrl;
-
   final String heroTag;
 
-  final String thumbnailUrl;
-
   final AssetResponseDto asset;
 
   final String authToken;
@@ -346,9 +334,15 @@ class ImageViewerRouteArgs {
 
   final ValueNotifier<bool> isZoomedListener;
 
+  final void Function() onLoadingCompleted;
+
+  final void Function() onLoadingStart;
+
+  final bool threeStageLoading;
+
   @override
   String toString() {
-    return 'ImageViewerRouteArgs{key: $key, imageUrl: $imageUrl, heroTag: $heroTag, thumbnailUrl: $thumbnailUrl, asset: $asset, authToken: $authToken, isZoomedFunction: $isZoomedFunction, isZoomedListener: $isZoomedListener}';
+    return 'ImageViewerRouteArgs{key: $key, heroTag: $heroTag, asset: $asset, authToken: $authToken, isZoomedFunction: $isZoomedFunction, isZoomedListener: $isZoomedListener, onLoadingCompleted: $onLoadingCompleted, onLoadingStart: $onLoadingStart, threeStageLoading: $threeStageLoading}';
   }
 }
 
diff --git a/mobile/lib/utils/image_url_builder.dart b/mobile/lib/utils/image_url_builder.dart
new file mode 100644
index 0000000000..0a22b54dd8
--- /dev/null
+++ b/mobile/lib/utils/image_url_builder.dart
@@ -0,0 +1,16 @@
+import 'package:hive/hive.dart';
+import 'package:openapi/api.dart';
+
+import '../constants/hive_box.dart';
+
+String getThumbnailUrl(final AssetResponseDto asset,
+    {ThumbnailFormat type = ThumbnailFormat.WEBP}) {
+  final box = Hive.box(userInfoBox);
+
+  return '${box.get(serverEndpointKey)}/asset/thumbnail/${asset.id}?format=${type.value}';
+}
+
+String getImageUrl(final AssetResponseDto asset) {
+  final box = Hive.box(userInfoBox);
+  return '${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}&isThumb=false';
+}
diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock
index ec0701fafe..6a756d003f 100644
--- a/mobile/pubspec.lock
+++ b/mobile/pubspec.lock
@@ -328,6 +328,13 @@ packages:
       url: "https://pub.dartlang.org"
     source: hosted
     version: "3.3.0"
+  flutter_displaymode:
+    dependency: "direct main"
+    description:
+      name: flutter_displaymode
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "0.4.0"
   flutter_hooks:
     dependency: "direct main"
     description:
diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml
index dbe84f23af..e7d495a761 100644
--- a/mobile/pubspec.yaml
+++ b/mobile/pubspec.yaml
@@ -41,6 +41,7 @@ dependencies:
   http: 0.13.4
   cancellation_token_http: ^1.1.0
   easy_localization: ^3.0.1
+  flutter_displaymode: ^0.4.0
 
   path: ^1.8.1
   path_provider: ^2.0.11