Browse Source

merge main

martabal 1 year ago
parent
commit
fb9b854bf1
53 changed files with 976 additions and 396 deletions
  1. 1 1
      cli/src/api/open-api/base.ts
  2. 1 1
      cli/src/api/open-api/common.ts
  3. 1 1
      cli/src/api/open-api/configuration.ts
  4. 1 1
      cli/src/api/open-api/index.ts
  5. 0 2
      docs/blog/2023/06-24/update.mdx
  6. 4 4
      docs/docs/administration/backup-and-restore.md
  7. 0 68
      docs/docs/features/bulk-upload.md
  8. 0 97
      docs/docs/features/read-only-gallery.md
  9. 1 1
      machine-learning/pyproject.toml
  10. 2 2
      mobile/android/fastlane/Fastfile
  11. 3 3
      mobile/android/fastlane/report.xml
  12. 1 0
      mobile/assets/i18n/en-US.json
  13. 3 3
      mobile/ios/Runner.xcodeproj/project.pbxproj
  14. 2 2
      mobile/ios/Runner/Info.plist
  15. 1 1
      mobile/ios/fastlane/Fastfile
  16. 6 6
      mobile/ios/fastlane/report.xml
  17. 3 0
      mobile/lib/main.dart
  18. 54 15
      mobile/lib/modules/album/ui/album_viewer_appbar.dart
  19. 29 4
      mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart
  20. 1 1
      mobile/lib/modules/home/ui/control_bottom_app_bar.dart
  21. 4 3
      mobile/lib/modules/home/views/home_page.dart
  22. 14 0
      mobile/lib/shared/models/exif_info.dart
  23. 364 46
      mobile/lib/shared/models/exif_info.g.dart
  24. 2 0
      mobile/lib/shared/ui/app_bar_dialog/app_bar_dialog.dart
  25. 28 11
      mobile/lib/shared/ui/app_bar_dialog/app_bar_server_info.dart
  26. 26 30
      mobile/lib/shared/ui/immich_app_bar.dart
  27. 4 2
      mobile/openapi/doc/ActivityApi.md
  28. 10 3
      mobile/openapi/lib/api/activity_api.dart
  29. 1 1
      mobile/openapi/test/activity_api_test.dart
  30. 1 1
      mobile/pubspec.lock
  31. 2 1
      mobile/pubspec.yaml
  32. 10 1
      server/immich-openapi-specs.json
  33. 2 2
      server/package-lock.json
  34. 1 1
      server/package.json
  35. 3 0
      server/src/domain/activity/activity.dto.ts
  36. 1 0
      server/src/domain/activity/activity.service.ts
  37. 8 1
      server/src/domain/asset/response-dto/asset-response.dto.ts
  38. 3 1
      server/src/immich/api-v1/asset/asset-repository.ts
  39. 23 0
      server/test/e2e/activity.e2e-spec.ts
  40. 1 1
      web/src/api/open-api/base.ts
  41. 1 1
      web/src/api/open-api/common.ts
  42. 1 1
      web/src/api/open-api/configuration.ts
  43. 1 1
      web/src/api/open-api/index.ts
  44. 2 2
      web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte
  45. 114 61
      web/src/lib/components/asset-viewer/asset-viewer.svelte
  46. 78 0
      web/src/lib/components/asset-viewer/slideshow-bar.svelte
  47. 1 1
      web/src/lib/components/layouts/user-page-layout.svelte
  48. 41 7
      web/src/lib/components/shared-components/scrollbar/scrollbar.svelte
  49. 15 2
      web/src/lib/stores/assets.store.ts
  50. 45 0
      web/src/lib/stores/slideshow.store.ts
  51. 40 0
      web/src/lib/utils/slideshow-history.ts
  52. 14 1
      web/src/routes/(user)/albums/[albumId]/+page.svelte
  53. 1 1
      web/src/routes/(user)/trash/+page.svelte

+ 1 - 1
cli/src/api/open-api/base.ts

@@ -4,7 +4,7 @@
  * Immich
  * Immich API
  *
- * The version of the OpenAPI document: 1.83.0
+ * The version of the OpenAPI document: 1.84.0
  * 
  *
  * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

+ 1 - 1
cli/src/api/open-api/common.ts

@@ -4,7 +4,7 @@
  * Immich
  * Immich API
  *
- * The version of the OpenAPI document: 1.83.0
+ * The version of the OpenAPI document: 1.84.0
  * 
  *
  * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

+ 1 - 1
cli/src/api/open-api/configuration.ts

@@ -4,7 +4,7 @@
  * Immich
  * Immich API
  *
- * The version of the OpenAPI document: 1.83.0
+ * The version of the OpenAPI document: 1.84.0
  * 
  *
  * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

+ 1 - 1
cli/src/api/open-api/index.ts

@@ -4,7 +4,7 @@
  * Immich
  * Immich API
  *
- * The version of the OpenAPI document: 1.83.0
+ * The version of the OpenAPI document: 1.84.0
  * 
  *
  * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

+ 0 - 2
docs/blog/2023/06-24/update.mdx

@@ -33,8 +33,6 @@ To be concise, Immich can now read in the gallery files, register the path into
 - Only new files that are added to the gallery will be detected.
 - Deleted and moved files will not be detected.
 
-You can find more information on how to use the feature by reading the documentation [here](/docs/features/read-only-gallery).
-
 ## Memory feature
 
 This is considered a fun feature that the team and I wanted to build for so long, but we had to put it off because of the refactoring of the code base. The code base is now in a good enough form to circle back and add more exciting features.

+ 4 - 4
docs/docs/administration/backup-and-restore.md

@@ -17,13 +17,13 @@ docker exec -t immich_postgres pg_dumpall -c -U postgres | gzip > "/path/to/back
 ```
 
 ```bash title='Restore'
-docker-compose down -v  # CAUTION! Deletes all Immich data to start from scratch.
-docker-compose pull     # Update to latest version of Immich (if desired)
-docker-compose create   # Create Docker containers for Immich apps without running them.
+docker compose down -v  # CAUTION! Deletes all Immich data to start from scratch.
+docker compose pull     # Update to latest version of Immich (if desired)
+docker compose create   # Create Docker containers for Immich apps without running them.
 docker start immich_postgres    # Start Postgres server
 sleep 10    # Wait for Postgres server to start up
 gunzip < "/path/to/backup/dump.sql.gz" | docker exec -i immich_postgres psql -U postgres -d immich    # Restore Backup
-docker-compose up -d    # Start remainder of Immich apps
+docker compose up -d    # Start remainder of Immich apps
 ```
 
 Note that for the database restore to proceed properly, it requires a completely fresh install (i.e. the Immich server has never run since creating the Docker containers). If the Immich app has run, Postgres conflicts may be encountered upon database restoration (relation already exists, violated foreign key constraints, multiple primary keys, etc.).

+ 0 - 68
docs/docs/features/bulk-upload.md

@@ -32,7 +32,6 @@ immich
 | --server / -s    | Immich's server address                                             |
 | --threads / -t   | Number of threads to use (Default 5)                                |
 | --album/ -al     | Create albums for assets based on the parent folder or a given name |
-| --import/ -i     | Import gallery (assets are not uploaded)                            |
 
 ## Quick Start
 
@@ -108,70 +107,3 @@ npm run build
 ```bash title="Run the command"
 node bin/index.js upload --key HFEJ38DNSDUEG --server http://192.168.1.216:2283/api --recursive your/asset/directory
 ```
-
----
-
-## Importing existing libraries
-
-If you do not wish to upload files into the server, existing files can be imported into the immich gallery through the use of the `--import` flag.
-
-```
-immich upload --key HFEJ38DNSDUEG --server http://192.168.1.216:2283/api --recursive directory/ --import
-```
-
-```
-immich upload --key HFEJ38DNSDUEG --server http://192.168.1.216:2283/api file1.jpg file2.jpg --import
-```
-
-The `immich-server` and `immich-microservices` containers must be able to access the files, or directories at the path referenced in the command. The directories referenced must be set under a user's `External Path` setting. More detailed instructions can be found [here](/docs/features/read-only-gallery).
-
-:::tip Matching volume references
-The import command is most easily run on the machine running the immich service, as the path to the files on the machine running the command and the server much match identically.
-
-If you are running immich within docker, the volume pointing to your existing library should be identical with your host machine.
-
-```diff title="docker-compose.yml"
-  immich-server:
-    container_name: immich_server
-    image: ghcr.io/immich-app/immich-server:${IMMICH_VERSION:-release}
-    command: [ "start.sh", "immich" ]
-    volumes:
-      - ${UPLOAD_LOCATION}:/usr/src/app/upload
-+     - /path/to/media:/path/to/media
-    env_file:
-      - .env
-    depends_on:
-      - redis
-      - database
-      - typesense
-    restart: always
-
-  immich-microservices:
-    container_name: immich_microservices
-    image: ghcr.io/immich-app/immich-server:${IMMICH_VERSION:-release}
-    command: [ "start.sh", "microservices" ]
-    volumes:
-      - ${UPLOAD_LOCATION}:/usr/src/app/upload
-+     - /path/to/media:/path/to/media
-    env_file:
-      - .env
-    depends_on:
-      - redis
-      - database
-      - typesense
-    restart: always
-```
-
-The proper command for above would be as shown below. You should have access to `/path/to/media` exactly on the environment the CLI command is being run on
-
-```
-immich upload --key HFEJ38DNSDUEG --server http://192.168.1.216:2283/api --recursive /path/to/media --import
-```
-
-If you are running the import using the docker command, please note that the volumes should point to the `/path/to/media` exactly on the environment the CLI command is being run on
-
-```
-docker run -it --rm -v "/path/to/media:/path/to/media" ghcr.io/immich-app/immich-cli:latest upload --key HFEJ38DNSDUEG --server http://192.168.1.216:2283/api --recursive /path/to/media --import
-```
-
-:::

+ 0 - 97
docs/docs/features/read-only-gallery.md

@@ -1,97 +0,0 @@
-# Read-only Gallery [Deprecated]
-
-:::caution
-
-This feature is being deprecated in favor of [Libraries](/docs/features/libraries.md).
-
-:::
-
-## Overview
-
-This feature enables users to use an existing gallery without uploading the assets to Immich.
-
-Upon syncing the file information, it will be read by Immich to generate supported files.
-
-## Usage
-
-:::tip Example scenario
-
-On the VM/system that Immich is running, I have 2 galleries that I want to use with Immich.
-
-- My gallery is stored at `/mnt/media/precious-memory`
-- My wife's gallery is stored at `/mnt/media/childhood-memory`
-
-We will use those values in the steps below.
-
-:::
-
-### Mount the gallery to the containers.
-
-`immich-server` and `immich-microservices` containers will need access to the gallery. Mount the directory path as in the example below
-
-```diff title="docker-compose.yml"
-  immich-server:
-    container_name: immich_server
-    image: ghcr.io/immich-app/immich-server:${IMMICH_VERSION:-release}
-    command: [ "start.sh", "immich" ]
-    volumes:
-      - ${UPLOAD_LOCATION}:/usr/src/app/upload
-+     - /mnt/media/precious-memory:/mnt/media/precious-memory:ro
-+     - /mnt/media/childhood-memory:/mnt/media/childhood-memory:ro
-    env_file:
-      - .env
-    depends_on:
-      - redis
-      - database
-      - typesense
-    restart: always
-
-  immich-microservices:
-    container_name: immich_microservices
-    image: ghcr.io/immich-app/immich-server:${IMMICH_VERSION:-release}
-    command: [ "start.sh", "microservices" ]
-    volumes:
-      - ${UPLOAD_LOCATION}:/usr/src/app/upload
-+     - /mnt/media/precious-memory:/mnt/media/precious-memory:ro
-+     - /mnt/media/childhood-memory:/mnt/media/childhood-memory:ro
-    env_file:
-      - .env
-    depends_on:
-      - redis
-      - database
-      - typesense
-    restart: always
-```
-
-:::tip
-Internal and external path have to be identical.
-:::
-
-_Remember to bring the container down/up to register the changes. Make sure you can see the mounted path in the container._
-
-### Register the path for the user.
-
-This action is done by the admin of the instance.
-
-- Navigate to `Administration > Users` page on the web.
-- Click on the user edit button.
-- Add the gallery path to the `External Path` field for the corresponding user and confirm the changes.
-
-<img src={require('./img/me.png').default} width='33%' title='My Account Storage Path' />
-
-<img src={require('./img/my-wife.png').default} width='33%' title='My Wifes Account Storage Path' />
-
-### Sync with the CLI tool.
-
-- Install or update the [CLI Tool](/docs/features/bulk-upload.md). The import feature is supported from version `v0.39.0` of the CLI
-- Run the command below to sync the gallery with Immich.
-
-```bash title="Import my gallery"
-immich upload --key <my-api-key> --server http://my-server-ip:2283/api /mnt/media/precious-memory --recursive --import
-```
-
-```bash title="Import my wife gallery"
-immich upload --key <my-wife-api-key> --server http://my-server-ip:2283/api /mnt/media/childhood-memory --recursive --import
-```
-
-The `--import` flag will tell Immich to import the files by path instead of uploading them.

+ 1 - 1
machine-learning/pyproject.toml

@@ -1,6 +1,6 @@
 [tool.poetry]
 name = "machine-learning"
-version = "1.83.0"
+version = "1.84.0"
 description = ""
 authors = ["Hau Tran <alex.tran1502@gmail.com>"]
 readme = "README.md"

+ 2 - 2
mobile/android/fastlane/Fastfile

@@ -35,8 +35,8 @@ platform :android do
       task: 'bundle', 
       build_type: 'Release',
       properties: {
-        "android.injected.version.code" => 107,
-        "android.injected.version.name" => "1.83.0",
+        "android.injected.version.code" => 108,
+        "android.injected.version.name" => "1.84.0",
       }
     )
     upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')

+ 3 - 3
mobile/android/fastlane/report.xml

@@ -5,17 +5,17 @@
     
     
       
-      <testcase classname="fastlane.lanes" name="0: default_platform" time="0.000269">
+      <testcase classname="fastlane.lanes" name="0: default_platform" time="0.000625">
         
       </testcase>
     
       
-      <testcase classname="fastlane.lanes" name="1: bundleRelease" time="81.160108">
+      <testcase classname="fastlane.lanes" name="1: bundleRelease" time="70.943413">
         
       </testcase>
     
       
-      <testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="39.176668">
+      <testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="30.374484">
         
       </testcase>
     

+ 1 - 0
mobile/assets/i18n/en-US.json

@@ -23,6 +23,7 @@
   "album_viewer_appbar_share_err_title": "Failed to change album title",
   "album_viewer_appbar_share_leave": "Leave album",
   "album_viewer_appbar_share_remove": "Remove from album",
+  "album_viewer_appbar_share_to": "Share To",
   "album_viewer_page_share_add_users": "Add users",
   "all_people_page_title": "People",
   "all_videos_page_title": "Videos",

+ 3 - 3
mobile/ios/Runner.xcodeproj/project.pbxproj

@@ -379,7 +379,7 @@
 				CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
 				CODE_SIGN_IDENTITY = "Apple Development";
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 118;
+				CURRENT_PROJECT_VERSION = 124;
 				DEVELOPMENT_TEAM = 2F67MQ8R79;
 				ENABLE_BITCODE = NO;
 				INFOPLIST_FILE = Runner/Info.plist;
@@ -515,7 +515,7 @@
 				CLANG_ENABLE_MODULES = YES;
 				CODE_SIGN_IDENTITY = "Apple Development";
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 118;
+				CURRENT_PROJECT_VERSION = 124;
 				DEVELOPMENT_TEAM = 2F67MQ8R79;
 				ENABLE_BITCODE = NO;
 				INFOPLIST_FILE = Runner/Info.plist;
@@ -543,7 +543,7 @@
 				CLANG_ENABLE_MODULES = YES;
 				CODE_SIGN_IDENTITY = "Apple Development";
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 118;
+				CURRENT_PROJECT_VERSION = 124;
 				DEVELOPMENT_TEAM = 2F67MQ8R79;
 				ENABLE_BITCODE = NO;
 				INFOPLIST_FILE = Runner/Info.plist;

+ 2 - 2
mobile/ios/Runner/Info.plist

@@ -59,11 +59,11 @@
     <key>CFBundlePackageType</key>
     <string>APPL</string>
     <key>CFBundleShortVersionString</key>
-    <string>1.78.1</string>
+    <string>1.84.0</string>
     <key>CFBundleSignature</key>
     <string>????</string>
     <key>CFBundleVersion</key>
-    <string>118</string>
+    <string>124</string>
     <key>FLTEnableImpeller</key>
     <true />
     <key>ITSAppUsesNonExemptEncryption</key>

+ 1 - 1
mobile/ios/fastlane/Fastfile

@@ -19,7 +19,7 @@ platform :ios do
   desc "iOS Beta"
   lane :beta do
     increment_version_number(
-      version_number: "1.83.0"
+      version_number: "1.84.0"
     )
     increment_build_number(
       build_number: latest_testflight_build_number + 1,

+ 6 - 6
mobile/ios/fastlane/report.xml

@@ -5,32 +5,32 @@
     
     
       
-      <testcase classname="fastlane.lanes" name="0: default_platform" time="0.000256">
+      <testcase classname="fastlane.lanes" name="0: default_platform" time="0.000253">
         
       </testcase>
     
       
-      <testcase classname="fastlane.lanes" name="1: increment_version_number" time="7.645306">
+      <testcase classname="fastlane.lanes" name="1: increment_version_number" time="0.181977">
         
       </testcase>
     
       
-      <testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="4.669798">
+      <testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="16.12614">
         
       </testcase>
     
       
-      <testcase classname="fastlane.lanes" name="3: increment_build_number" time="2.218788">
+      <testcase classname="fastlane.lanes" name="3: increment_build_number" time="0.162663">
         
       </testcase>
     
       
-      <testcase classname="fastlane.lanes" name="4: build_app" time="97.596654">
+      <testcase classname="fastlane.lanes" name="4: build_app" time="145.399278">
         
       </testcase>
     
       
-      <testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="89.490906">
+      <testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="61.317235">
         
       </testcase>
     

+ 3 - 0
mobile/lib/main.dart

@@ -7,6 +7,7 @@ import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
 import 'package:flutter_displaymode/flutter_displaymode.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:timezone/data/latest.dart';
 import 'package:immich_mobile/constants/locales.dart';
 import 'package:immich_mobile/modules/backup/background_service/background.service.dart';
 import 'package:immich_mobile/modules/backup/models/backup_album.model.dart';
@@ -77,6 +78,8 @@ Future<void> initApp() async {
     log.severe('Catch all error: ${error.toString()} - $error', error, stack);
     return true;
   };
+
+  initializeTimeZones();
 }
 
 Future<Isar> loadDb() async {

+ 54 - 15
mobile/lib/modules/album/ui/album_viewer_appbar.dart

@@ -7,6 +7,8 @@ import 'package:immich_mobile/modules/album/providers/album.provider.dart';
 import 'package:immich_mobile/modules/album/providers/album_detail.provider.dart';
 import 'package:immich_mobile/modules/album/providers/album_viewer.provider.dart';
 import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
+import 'package:immich_mobile/shared/ui/share_dialog.dart';
+import 'package:immich_mobile/shared/services/share.service.dart';
 import 'package:immich_mobile/routing/router.dart';
 import 'package:immich_mobile/shared/models/album.dart';
 import 'package:immich_mobile/shared/models/asset.dart';
@@ -160,40 +162,77 @@ class AlbumViewerAppbar extends HookConsumerWidget
       ImmichLoadingOverlayController.appLoader.hide();
     }
 
-    buildBottomSheetActionButton() {
+    void handleShareAssets(
+      WidgetRef ref,
+      BuildContext context,
+      Set<Asset> selection,
+    ) {
+      showDialog(
+        context: context,
+        builder: (BuildContext buildContext) {
+          ref.watch(shareServiceProvider).shareAssets(selection.toList()).then(
+            (bool status) {
+              if (!status) {
+                ImmichToast.show(
+                  context: context,
+                  msg: 'image_viewer_page_state_provider_share_error'.tr(),
+                  toastType: ToastType.error,
+                  gravity: ToastGravity.BOTTOM,
+                );
+              }
+              Navigator.of(buildContext).pop();
+            },
+          );
+          return const ShareDialog();
+        },
+        barrierDismissible: false,
+      );
+    }
+
+    void onShareAssetsTo() async {
+      ImmichLoadingOverlayController.appLoader.show();
+      handleShareAssets(ref, context, selected);
+      ImmichLoadingOverlayController.appLoader.hide();
+    }
+
+    buildBottomSheetActions() {
       if (selected.isNotEmpty) {
-        if (album.ownerId == userId) {
-          return ListTile(
+        return [
+          ListTile(
+            leading: const Icon(Icons.ios_share_rounded),
+            title: const Text(
+              'album_viewer_appbar_share_to',
+              style: TextStyle(fontWeight: FontWeight.bold),
+            ).tr(),
+            onTap: () => onShareAssetsTo(),
+          ),
+          album.ownerId == userId ? ListTile(
             leading: const Icon(Icons.delete_sweep_rounded),
             title: const Text(
               'album_viewer_appbar_share_remove',
               style: TextStyle(fontWeight: FontWeight.bold),
             ).tr(),
             onTap: () => onRemoveFromAlbumPressed(),
-          );
-        } else {
-          return const SizedBox();
-        }
+          ) : const SizedBox(),
+        ];
       } else {
-        if (album.ownerId == userId) {
-          return ListTile(
+        return [
+          album.ownerId == userId ? ListTile(
             leading: const Icon(Icons.delete_forever_rounded),
             title: const Text(
               'album_viewer_appbar_share_delete',
               style: TextStyle(fontWeight: FontWeight.bold),
             ).tr(),
             onTap: () => onDeleteAlbumPressed(),
-          );
-        } else {
-          return ListTile(
+          ) : ListTile(
             leading: const Icon(Icons.person_remove_rounded),
             title: const Text(
               'album_viewer_appbar_share_leave',
               style: TextStyle(fontWeight: FontWeight.bold),
             ).tr(),
             onTap: () => onLeaveAlbumPressed(),
-          );
-        }
+          ),
+        ];
       }
     }
 
@@ -257,7 +296,7 @@ class AlbumViewerAppbar extends HookConsumerWidget
               child: Column(
                 mainAxisSize: MainAxisSize.min,
                 children: [
-                  buildBottomSheetActionButton(),
+                  ...buildBottomSheetActions(),
                   if (selected.isEmpty && onAddPhotos != null) ...commonActions,
                   if (selected.isEmpty &&
                       onAddPhotos != null &&

+ 29 - 4
mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart

@@ -4,6 +4,7 @@ import 'package:easy_localization/easy_localization.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_map/flutter_map.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:timezone/timezone.dart';
 import 'package:immich_mobile/modules/asset_viewer/ui/description_input.dart';
 import 'package:immich_mobile/modules/map/ui/map_thumbnail.dart';
 import 'package:immich_mobile/shared/models/asset.dart';
@@ -26,12 +27,36 @@ class ExifBottomSheet extends HookConsumerWidget {
       exifInfo.latitude != 0 &&
       exifInfo.longitude != 0;
 
+  String formatTimeZone(Duration d) =>
+      "GMT${d.isNegative ? '-': '+'}${d.inHours.abs().toString().padLeft(2, '0')}:${d.inMinutes.abs().remainder(60).toString().padLeft(2, '0')}";
+
   String get formattedDateTime {
-    final fileCreatedAt = asset.fileCreatedAt.toLocal();
-    final date = DateFormat.yMMMEd().format(fileCreatedAt);
-    final time = DateFormat.jm().format(fileCreatedAt);
+    DateTime dt = asset.fileCreatedAt.toLocal();
+    String? timeZone;
+    if (asset.exifInfo?.dateTimeOriginal != null) {
+      dt = asset.exifInfo!.dateTimeOriginal!;
+      if (asset.exifInfo?.timeZone != null) {
+        dt = dt.toUtc();
+        try {
+          final location = getLocation(asset.exifInfo!.timeZone!);
+          dt = TZDateTime.from(dt, location);
+        } on LocationNotFoundException {
+          RegExp re = RegExp(r'^utc(?:([+-]\d{1,2})(?::(\d{2}))?)?$', caseSensitive: false);
+          final m = re.firstMatch(asset.exifInfo!.timeZone!);
+          if (m != null) {
+            final duration = Duration(hours: int.parse(m.group(1) ?? '0'), minutes: int.parse(m.group(2) ?? '0'));
+            dt = dt.add(duration);
+            timeZone = formatTimeZone(duration);
+          }
+        }
+      }
+    }
+
+    final date = DateFormat.yMMMEd().format(dt);
+    final time = DateFormat.jm().format(dt);
+    timeZone ??= formatTimeZone(dt.timeZoneOffset);
 
-    return '$date • $time';
+    return '$date • $time $timeZone';
   }
 
   Future<Uri?> _createCoordinatesUri(ExifInfo? exifInfo) async {

+ 1 - 1
mobile/lib/modules/home/ui/control_bottom_app_bar.dart

@@ -100,7 +100,7 @@ class ControlBottomAppBar extends ConsumerWidget {
             label: "control_bottom_app_bar_stack".tr(),
             onPressed: enabled ? onStack : null,
           ),
-        if (!hasRemote)
+        if (hasLocal)
           ControlBoxButton(
             iconData: Icons.backup_outlined,
             label: "Upload",

+ 4 - 3
mobile/lib/modules/home/views/home_page.dart

@@ -169,9 +169,10 @@ class HomePage extends HookConsumerWidget {
         processing.value = true;
         selectionEnabledHook.value = false;
         try {
-          ref
-              .read(manualUploadProvider.notifier)
-              .uploadAssets(context, selection.value);
+          ref.read(manualUploadProvider.notifier).uploadAssets(
+                context,
+                selection.value.where((a) => a.storage == AssetState.local),
+              );
         } finally {
           processing.value = false;
         }

+ 14 - 0
mobile/lib/shared/models/exif_info.dart

@@ -8,6 +8,8 @@ part 'exif_info.g.dart';
 class ExifInfo {
   Id? id;
   int? fileSize;
+  DateTime? dateTimeOriginal;
+  String? timeZone;
   String? make;
   String? model;
   String? lens;
@@ -47,6 +49,8 @@ class ExifInfo {
 
   ExifInfo.fromDto(ExifResponseDto dto)
       : fileSize = dto.fileSizeInByte,
+        dateTimeOriginal = dto.dateTimeOriginal,
+        timeZone = dto.timeZone,
         make = dto.make,
         model = dto.model,
         lens = dto.lensModel,
@@ -64,6 +68,8 @@ class ExifInfo {
   ExifInfo({
     this.id,
     this.fileSize,
+    this.dateTimeOriginal,
+    this.timeZone,
     this.make,
     this.model,
     this.lens,
@@ -82,6 +88,8 @@ class ExifInfo {
   ExifInfo copyWith({
     Id? id,
     int? fileSize,
+    DateTime? dateTimeOriginal,
+    String? timeZone,
     String? make,
     String? model,
     String? lens,
@@ -99,6 +107,8 @@ class ExifInfo {
       ExifInfo(
         id: id ?? this.id,
         fileSize: fileSize ?? this.fileSize,
+        dateTimeOriginal: dateTimeOriginal ?? this.dateTimeOriginal,
+        timeZone: timeZone ?? this.timeZone,
         make: make ?? this.make,
         model: model ?? this.model,
         lens: lens ?? this.lens,
@@ -119,6 +129,8 @@ class ExifInfo {
     if (other is! ExifInfo) return false;
     return id == other.id &&
         fileSize == other.fileSize &&
+        dateTimeOriginal == other.dateTimeOriginal &&
+        timeZone == other.timeZone &&
         make == other.make &&
         model == other.model &&
         lens == other.lens &&
@@ -139,6 +151,8 @@ class ExifInfo {
   int get hashCode =>
       id.hashCode ^
       fileSize.hashCode ^
+      dateTimeOriginal.hashCode ^
+      timeZone.hashCode ^
       make.hashCode ^
       model.hashCode ^
       lens.hashCode ^

+ 364 - 46
mobile/lib/shared/models/exif_info.g.dart

@@ -27,65 +27,75 @@ const ExifInfoSchema = CollectionSchema(
       name: r'country',
       type: IsarType.string,
     ),
-    r'description': PropertySchema(
+    r'dateTimeOriginal': PropertySchema(
       id: 2,
+      name: r'dateTimeOriginal',
+      type: IsarType.dateTime,
+    ),
+    r'description': PropertySchema(
+      id: 3,
       name: r'description',
       type: IsarType.string,
     ),
     r'exposureSeconds': PropertySchema(
-      id: 3,
+      id: 4,
       name: r'exposureSeconds',
       type: IsarType.float,
     ),
     r'f': PropertySchema(
-      id: 4,
+      id: 5,
       name: r'f',
       type: IsarType.float,
     ),
     r'fileSize': PropertySchema(
-      id: 5,
+      id: 6,
       name: r'fileSize',
       type: IsarType.long,
     ),
     r'iso': PropertySchema(
-      id: 6,
+      id: 7,
       name: r'iso',
       type: IsarType.int,
     ),
     r'lat': PropertySchema(
-      id: 7,
+      id: 8,
       name: r'lat',
       type: IsarType.float,
     ),
     r'lens': PropertySchema(
-      id: 8,
+      id: 9,
       name: r'lens',
       type: IsarType.string,
     ),
     r'long': PropertySchema(
-      id: 9,
+      id: 10,
       name: r'long',
       type: IsarType.float,
     ),
     r'make': PropertySchema(
-      id: 10,
+      id: 11,
       name: r'make',
       type: IsarType.string,
     ),
     r'mm': PropertySchema(
-      id: 11,
+      id: 12,
       name: r'mm',
       type: IsarType.float,
     ),
     r'model': PropertySchema(
-      id: 12,
+      id: 13,
       name: r'model',
       type: IsarType.string,
     ),
     r'state': PropertySchema(
-      id: 13,
+      id: 14,
       name: r'state',
       type: IsarType.string,
+    ),
+    r'timeZone': PropertySchema(
+      id: 15,
+      name: r'timeZone',
+      type: IsarType.string,
     )
   },
   estimateSize: _exifInfoEstimateSize,
@@ -150,6 +160,12 @@ int _exifInfoEstimateSize(
       bytesCount += 3 + value.length * 3;
     }
   }
+  {
+    final value = object.timeZone;
+    if (value != null) {
+      bytesCount += 3 + value.length * 3;
+    }
+  }
   return bytesCount;
 }
 
@@ -161,18 +177,20 @@ void _exifInfoSerialize(
 ) {
   writer.writeString(offsets[0], object.city);
   writer.writeString(offsets[1], object.country);
-  writer.writeString(offsets[2], object.description);
-  writer.writeFloat(offsets[3], object.exposureSeconds);
-  writer.writeFloat(offsets[4], object.f);
-  writer.writeLong(offsets[5], object.fileSize);
-  writer.writeInt(offsets[6], object.iso);
-  writer.writeFloat(offsets[7], object.lat);
-  writer.writeString(offsets[8], object.lens);
-  writer.writeFloat(offsets[9], object.long);
-  writer.writeString(offsets[10], object.make);
-  writer.writeFloat(offsets[11], object.mm);
-  writer.writeString(offsets[12], object.model);
-  writer.writeString(offsets[13], object.state);
+  writer.writeDateTime(offsets[2], object.dateTimeOriginal);
+  writer.writeString(offsets[3], object.description);
+  writer.writeFloat(offsets[4], object.exposureSeconds);
+  writer.writeFloat(offsets[5], object.f);
+  writer.writeLong(offsets[6], object.fileSize);
+  writer.writeInt(offsets[7], object.iso);
+  writer.writeFloat(offsets[8], object.lat);
+  writer.writeString(offsets[9], object.lens);
+  writer.writeFloat(offsets[10], object.long);
+  writer.writeString(offsets[11], object.make);
+  writer.writeFloat(offsets[12], object.mm);
+  writer.writeString(offsets[13], object.model);
+  writer.writeString(offsets[14], object.state);
+  writer.writeString(offsets[15], object.timeZone);
 }
 
 ExifInfo _exifInfoDeserialize(
@@ -184,19 +202,21 @@ ExifInfo _exifInfoDeserialize(
   final object = ExifInfo(
     city: reader.readStringOrNull(offsets[0]),
     country: reader.readStringOrNull(offsets[1]),
-    description: reader.readStringOrNull(offsets[2]),
-    exposureSeconds: reader.readFloatOrNull(offsets[3]),
-    f: reader.readFloatOrNull(offsets[4]),
-    fileSize: reader.readLongOrNull(offsets[5]),
+    dateTimeOriginal: reader.readDateTimeOrNull(offsets[2]),
+    description: reader.readStringOrNull(offsets[3]),
+    exposureSeconds: reader.readFloatOrNull(offsets[4]),
+    f: reader.readFloatOrNull(offsets[5]),
+    fileSize: reader.readLongOrNull(offsets[6]),
     id: id,
-    iso: reader.readIntOrNull(offsets[6]),
-    lat: reader.readFloatOrNull(offsets[7]),
-    lens: reader.readStringOrNull(offsets[8]),
-    long: reader.readFloatOrNull(offsets[9]),
-    make: reader.readStringOrNull(offsets[10]),
-    mm: reader.readFloatOrNull(offsets[11]),
-    model: reader.readStringOrNull(offsets[12]),
-    state: reader.readStringOrNull(offsets[13]),
+    iso: reader.readIntOrNull(offsets[7]),
+    lat: reader.readFloatOrNull(offsets[8]),
+    lens: reader.readStringOrNull(offsets[9]),
+    long: reader.readFloatOrNull(offsets[10]),
+    make: reader.readStringOrNull(offsets[11]),
+    mm: reader.readFloatOrNull(offsets[12]),
+    model: reader.readStringOrNull(offsets[13]),
+    state: reader.readStringOrNull(offsets[14]),
+    timeZone: reader.readStringOrNull(offsets[15]),
   );
   return object;
 }
@@ -213,29 +233,33 @@ P _exifInfoDeserializeProp<P>(
     case 1:
       return (reader.readStringOrNull(offset)) as P;
     case 2:
-      return (reader.readStringOrNull(offset)) as P;
+      return (reader.readDateTimeOrNull(offset)) as P;
     case 3:
-      return (reader.readFloatOrNull(offset)) as P;
+      return (reader.readStringOrNull(offset)) as P;
     case 4:
       return (reader.readFloatOrNull(offset)) as P;
     case 5:
-      return (reader.readLongOrNull(offset)) as P;
+      return (reader.readFloatOrNull(offset)) as P;
     case 6:
-      return (reader.readIntOrNull(offset)) as P;
+      return (reader.readLongOrNull(offset)) as P;
     case 7:
-      return (reader.readFloatOrNull(offset)) as P;
+      return (reader.readIntOrNull(offset)) as P;
     case 8:
-      return (reader.readStringOrNull(offset)) as P;
-    case 9:
       return (reader.readFloatOrNull(offset)) as P;
-    case 10:
+    case 9:
       return (reader.readStringOrNull(offset)) as P;
-    case 11:
+    case 10:
       return (reader.readFloatOrNull(offset)) as P;
-    case 12:
+    case 11:
       return (reader.readStringOrNull(offset)) as P;
+    case 12:
+      return (reader.readFloatOrNull(offset)) as P;
     case 13:
       return (reader.readStringOrNull(offset)) as P;
+    case 14:
+      return (reader.readStringOrNull(offset)) as P;
+    case 15:
+      return (reader.readStringOrNull(offset)) as P;
     default:
       throw IsarError('Unknown property with id $propertyId');
   }
@@ -622,6 +646,80 @@ extension ExifInfoQueryFilter
     });
   }
 
+  QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition>
+      dateTimeOriginalIsNull() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(const FilterCondition.isNull(
+        property: r'dateTimeOriginal',
+      ));
+    });
+  }
+
+  QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition>
+      dateTimeOriginalIsNotNull() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(const FilterCondition.isNotNull(
+        property: r'dateTimeOriginal',
+      ));
+    });
+  }
+
+  QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition>
+      dateTimeOriginalEqualTo(DateTime? value) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.equalTo(
+        property: r'dateTimeOriginal',
+        value: value,
+      ));
+    });
+  }
+
+  QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition>
+      dateTimeOriginalGreaterThan(
+    DateTime? value, {
+    bool include = false,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.greaterThan(
+        include: include,
+        property: r'dateTimeOriginal',
+        value: value,
+      ));
+    });
+  }
+
+  QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition>
+      dateTimeOriginalLessThan(
+    DateTime? value, {
+    bool include = false,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.lessThan(
+        include: include,
+        property: r'dateTimeOriginal',
+        value: value,
+      ));
+    });
+  }
+
+  QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition>
+      dateTimeOriginalBetween(
+    DateTime? lower,
+    DateTime? upper, {
+    bool includeLower = true,
+    bool includeUpper = true,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.between(
+        property: r'dateTimeOriginal',
+        lower: lower,
+        includeLower: includeLower,
+        upper: upper,
+        includeUpper: includeUpper,
+      ));
+    });
+  }
+
   QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> descriptionIsNull() {
     return QueryBuilder.apply(this, (query) {
       return query.addFilterCondition(const FilterCondition.isNull(
@@ -1956,6 +2054,152 @@ extension ExifInfoQueryFilter
       ));
     });
   }
+
+  QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> timeZoneIsNull() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(const FilterCondition.isNull(
+        property: r'timeZone',
+      ));
+    });
+  }
+
+  QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> timeZoneIsNotNull() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(const FilterCondition.isNotNull(
+        property: r'timeZone',
+      ));
+    });
+  }
+
+  QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> timeZoneEqualTo(
+    String? value, {
+    bool caseSensitive = true,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.equalTo(
+        property: r'timeZone',
+        value: value,
+        caseSensitive: caseSensitive,
+      ));
+    });
+  }
+
+  QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> timeZoneGreaterThan(
+    String? value, {
+    bool include = false,
+    bool caseSensitive = true,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.greaterThan(
+        include: include,
+        property: r'timeZone',
+        value: value,
+        caseSensitive: caseSensitive,
+      ));
+    });
+  }
+
+  QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> timeZoneLessThan(
+    String? value, {
+    bool include = false,
+    bool caseSensitive = true,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.lessThan(
+        include: include,
+        property: r'timeZone',
+        value: value,
+        caseSensitive: caseSensitive,
+      ));
+    });
+  }
+
+  QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> timeZoneBetween(
+    String? lower,
+    String? upper, {
+    bool includeLower = true,
+    bool includeUpper = true,
+    bool caseSensitive = true,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.between(
+        property: r'timeZone',
+        lower: lower,
+        includeLower: includeLower,
+        upper: upper,
+        includeUpper: includeUpper,
+        caseSensitive: caseSensitive,
+      ));
+    });
+  }
+
+  QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> timeZoneStartsWith(
+    String value, {
+    bool caseSensitive = true,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.startsWith(
+        property: r'timeZone',
+        value: value,
+        caseSensitive: caseSensitive,
+      ));
+    });
+  }
+
+  QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> timeZoneEndsWith(
+    String value, {
+    bool caseSensitive = true,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.endsWith(
+        property: r'timeZone',
+        value: value,
+        caseSensitive: caseSensitive,
+      ));
+    });
+  }
+
+  QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> timeZoneContains(
+      String value,
+      {bool caseSensitive = true}) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.contains(
+        property: r'timeZone',
+        value: value,
+        caseSensitive: caseSensitive,
+      ));
+    });
+  }
+
+  QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> timeZoneMatches(
+      String pattern,
+      {bool caseSensitive = true}) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.matches(
+        property: r'timeZone',
+        wildcard: pattern,
+        caseSensitive: caseSensitive,
+      ));
+    });
+  }
+
+  QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> timeZoneIsEmpty() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.equalTo(
+        property: r'timeZone',
+        value: '',
+      ));
+    });
+  }
+
+  QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> timeZoneIsNotEmpty() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.greaterThan(
+        property: r'timeZone',
+        value: '',
+      ));
+    });
+  }
 }
 
 extension ExifInfoQueryObject
@@ -1989,6 +2233,18 @@ extension ExifInfoQuerySortBy on QueryBuilder<ExifInfo, ExifInfo, QSortBy> {
     });
   }
 
+  QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> sortByDateTimeOriginal() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'dateTimeOriginal', Sort.asc);
+    });
+  }
+
+  QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> sortByDateTimeOriginalDesc() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'dateTimeOriginal', Sort.desc);
+    });
+  }
+
   QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> sortByDescription() {
     return QueryBuilder.apply(this, (query) {
       return query.addSortBy(r'description', Sort.asc);
@@ -2132,6 +2388,18 @@ extension ExifInfoQuerySortBy on QueryBuilder<ExifInfo, ExifInfo, QSortBy> {
       return query.addSortBy(r'state', Sort.desc);
     });
   }
+
+  QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> sortByTimeZone() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'timeZone', Sort.asc);
+    });
+  }
+
+  QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> sortByTimeZoneDesc() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'timeZone', Sort.desc);
+    });
+  }
 }
 
 extension ExifInfoQuerySortThenBy
@@ -2160,6 +2428,18 @@ extension ExifInfoQuerySortThenBy
     });
   }
 
+  QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> thenByDateTimeOriginal() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'dateTimeOriginal', Sort.asc);
+    });
+  }
+
+  QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> thenByDateTimeOriginalDesc() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'dateTimeOriginal', Sort.desc);
+    });
+  }
+
   QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> thenByDescription() {
     return QueryBuilder.apply(this, (query) {
       return query.addSortBy(r'description', Sort.asc);
@@ -2315,6 +2595,18 @@ extension ExifInfoQuerySortThenBy
       return query.addSortBy(r'state', Sort.desc);
     });
   }
+
+  QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> thenByTimeZone() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'timeZone', Sort.asc);
+    });
+  }
+
+  QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> thenByTimeZoneDesc() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'timeZone', Sort.desc);
+    });
+  }
 }
 
 extension ExifInfoQueryWhereDistinct
@@ -2333,6 +2625,12 @@ extension ExifInfoQueryWhereDistinct
     });
   }
 
+  QueryBuilder<ExifInfo, ExifInfo, QDistinct> distinctByDateTimeOriginal() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addDistinctBy(r'dateTimeOriginal');
+    });
+  }
+
   QueryBuilder<ExifInfo, ExifInfo, QDistinct> distinctByDescription(
       {bool caseSensitive = true}) {
     return QueryBuilder.apply(this, (query) {
@@ -2409,6 +2707,13 @@ extension ExifInfoQueryWhereDistinct
       return query.addDistinctBy(r'state', caseSensitive: caseSensitive);
     });
   }
+
+  QueryBuilder<ExifInfo, ExifInfo, QDistinct> distinctByTimeZone(
+      {bool caseSensitive = true}) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addDistinctBy(r'timeZone', caseSensitive: caseSensitive);
+    });
+  }
 }
 
 extension ExifInfoQueryProperty
@@ -2431,6 +2736,13 @@ extension ExifInfoQueryProperty
     });
   }
 
+  QueryBuilder<ExifInfo, DateTime?, QQueryOperations>
+      dateTimeOriginalProperty() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addPropertyName(r'dateTimeOriginal');
+    });
+  }
+
   QueryBuilder<ExifInfo, String?, QQueryOperations> descriptionProperty() {
     return QueryBuilder.apply(this, (query) {
       return query.addPropertyName(r'description');
@@ -2502,4 +2814,10 @@ extension ExifInfoQueryProperty
       return query.addPropertyName(r'state');
     });
   }
+
+  QueryBuilder<ExifInfo, String?, QQueryOperations> timeZoneProperty() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addPropertyName(r'timeZone');
+    });
+  }
 }

+ 2 - 0
mobile/lib/shared/ui/app_bar_dialog/app_bar_dialog.dart

@@ -194,6 +194,7 @@ class ImmichAppBarDialog extends HookConsumerWidget {
                 Navigator.of(context).pop();
                 launchUrl(
                   Uri.parse('https://immich.app'),
+                  mode: LaunchMode.externalApplication,
                 );
               },
               child: Text(
@@ -213,6 +214,7 @@ class ImmichAppBarDialog extends HookConsumerWidget {
                 Navigator.of(context).pop();
                 launchUrl(
                   Uri.parse('https://github.com/immich-app/immich'),
+                  mode: LaunchMode.externalApplication,
                 );
               },
               child: Text(

+ 28 - 11
mobile/lib/shared/ui/app_bar_dialog/app_bar_server_info.dart

@@ -182,19 +182,36 @@ class AppBarServerInfo extends HookConsumerWidget {
                     child: Container(
                       width: 200,
                       padding: const EdgeInsets.only(right: 10.0),
-                      child: Text(
-                        getServerUrl() ?? '--',
-                        style: TextStyle(
-                          fontSize: 11,
-                          color: Theme.of(context)
-                              .textTheme
-                              .labelSmall
-                              ?.color
-                              ?.withOpacity(0.5),
+                      child: Tooltip(
+                        verticalOffset: 0,
+                        decoration: BoxDecoration(
+                          color:
+                              Theme.of(context).primaryColor.withOpacity(0.9),
+                          borderRadius: BorderRadius.circular(10),
+                        ),
+                        textStyle: TextStyle(
+                          color: Theme.of(context).brightness == Brightness.dark
+                              ? Colors.black
+                              : Colors.white,
                           fontWeight: FontWeight.bold,
-                          overflow: TextOverflow.ellipsis,
                         ),
-                        textAlign: TextAlign.end,
+                        message: getServerUrl() ?? '--',
+                        preferBelow: false,
+                        triggerMode: TooltipTriggerMode.tap,
+                        child: Text(
+                          getServerUrl() ?? '--',
+                          style: TextStyle(
+                            fontSize: 11,
+                            color: Theme.of(context)
+                                .textTheme
+                                .labelSmall
+                                ?.color
+                                ?.withOpacity(0.5),
+                            fontWeight: FontWeight.bold,
+                            overflow: TextOverflow.ellipsis,
+                          ),
+                          textAlign: TextAlign.end,
+                        ),
                       ),
                     ),
                   ),

+ 26 - 30
mobile/lib/shared/ui/immich_app_bar.dart

@@ -31,7 +31,7 @@ class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget {
     final isDarkMode = Theme.of(context).brightness == Brightness.dark;
     const widgetSize = 30.0;
 
-    buildProfilePhoto() {
+    buildProfileIndicator() {
       return InkWell(
         onTap: () => showDialog(
           context: context,
@@ -39,37 +39,33 @@ class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget {
           builder: (ctx) => const ImmichAppBarDialog(),
         ),
         borderRadius: BorderRadius.circular(12),
-        child: authState.profileImagePath.isEmpty || user == null
-            ? const Icon(
-                Icons.face_outlined,
-                size: widgetSize,
-              )
-            : UserCircleAvatar(
-                radius: 15,
-                size: 27,
-                user: user,
-              ),
-      );
-    }
-
-    buildProfileIndicator() {
-      return Badge(
-        label: Container(
-          decoration: BoxDecoration(
-            color: Colors.black,
-            borderRadius: BorderRadius.circular(widgetSize / 2),
-          ),
-          child: const Icon(
-            Icons.info,
-            color: Color.fromARGB(255, 243, 188, 106),
-            size: widgetSize / 2,
+        child: Badge(
+          label: Container(
+            decoration: BoxDecoration(
+              color: Colors.black,
+              borderRadius: BorderRadius.circular(widgetSize / 2),
+            ),
+            child: const Icon(
+              Icons.info,
+              color: Color.fromARGB(255, 243, 188, 106),
+              size: widgetSize / 2,
+            ),
           ),
+          backgroundColor: Colors.transparent,
+          alignment: Alignment.bottomRight,
+          isLabelVisible: serverInfoState.isVersionMismatch,
+          offset: const Offset(2, 2),
+          child: authState.profileImagePath.isEmpty || user == null
+              ? const Icon(
+                  Icons.face_outlined,
+                  size: widgetSize,
+                )
+              : UserCircleAvatar(
+                  radius: 15,
+                  size: 27,
+                  user: user,
+                ),
         ),
-        backgroundColor: Colors.transparent,
-        alignment: Alignment.bottomRight,
-        isLabelVisible: serverInfoState.isVersionMismatch,
-        offset: const Offset(2, 2),
-        child: buildProfilePhoto(),
       );
     }
 

+ 4 - 2
mobile/openapi/doc/ActivityApi.md

@@ -125,7 +125,7 @@ void (empty response body)
 [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
 
 # **getActivities**
-> List<ActivityResponseDto> getActivities(albumId, assetId, type)
+> List<ActivityResponseDto> getActivities(albumId, assetId, type, userId)
 
 
 
@@ -151,9 +151,10 @@ final api_instance = ActivityApi();
 final albumId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | 
 final assetId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | 
 final type = ; // ReactionType | 
+final userId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | 
 
 try {
-    final result = api_instance.getActivities(albumId, assetId, type);
+    final result = api_instance.getActivities(albumId, assetId, type, userId);
     print(result);
 } catch (e) {
     print('Exception when calling ActivityApi->getActivities: $e\n');
@@ -167,6 +168,7 @@ Name | Type | Description  | Notes
  **albumId** | **String**|  | 
  **assetId** | **String**|  | [optional] 
  **type** | [**ReactionType**](.md)|  | [optional] 
+ **userId** | **String**|  | [optional] 
 
 ### Return type
 

+ 10 - 3
mobile/openapi/lib/api/activity_api.dart

@@ -111,7 +111,9 @@ class ActivityApi {
   /// * [String] assetId:
   ///
   /// * [ReactionType] type:
-  Future<Response> getActivitiesWithHttpInfo(String albumId, { String? assetId, ReactionType? type, }) async {
+  ///
+  /// * [String] userId:
+  Future<Response> getActivitiesWithHttpInfo(String albumId, { String? assetId, ReactionType? type, String? userId, }) async {
     // ignore: prefer_const_declarations
     final path = r'/activity';
 
@@ -129,6 +131,9 @@ class ActivityApi {
     if (type != null) {
       queryParams.addAll(_queryParams('', 'type', type));
     }
+    if (userId != null) {
+      queryParams.addAll(_queryParams('', 'userId', userId));
+    }
 
     const contentTypes = <String>[];
 
@@ -151,8 +156,10 @@ class ActivityApi {
   /// * [String] assetId:
   ///
   /// * [ReactionType] type:
-  Future<List<ActivityResponseDto>?> getActivities(String albumId, { String? assetId, ReactionType? type, }) async {
-    final response = await getActivitiesWithHttpInfo(albumId,  assetId: assetId, type: type, );
+  ///
+  /// * [String] userId:
+  Future<List<ActivityResponseDto>?> getActivities(String albumId, { String? assetId, ReactionType? type, String? userId, }) async {
+    final response = await getActivitiesWithHttpInfo(albumId,  assetId: assetId, type: type, userId: userId, );
     if (response.statusCode >= HttpStatus.badRequest) {
       throw ApiException(response.statusCode, await _decodeBodyBytes(response));
     }

+ 1 - 1
mobile/openapi/test/activity_api_test.dart

@@ -27,7 +27,7 @@ void main() {
       // TODO
     });
 
-    //Future<List<ActivityResponseDto>> getActivities(String albumId, { String assetId, ReactionType type }) async
+    //Future<List<ActivityResponseDto>> getActivities(String albumId, { String assetId, ReactionType type, String userId }) async
     test('test getActivities', () async {
       // TODO
     });

+ 1 - 1
mobile/pubspec.lock

@@ -1401,7 +1401,7 @@ packages:
     source: hosted
     version: "2.1.3"
   timezone:
-    dependency: transitive
+    dependency: "direct main"
     description:
       name: timezone
       sha256: "1cfd8ddc2d1cfd836bc93e67b9be88c3adaeca6f40a00ca999104c30693cdca0"

+ 2 - 1
mobile/pubspec.yaml

@@ -2,7 +2,7 @@ name: immich_mobile
 description: Immich - selfhosted backup media file on mobile phone
 
 publish_to: "none"
-version: 1.83.0+107
+version: 1.84.0+108
 isar_version: &isar_version 3.1.0+1
 
 environment:
@@ -52,6 +52,7 @@ dependencies:
   crypto: ^3.0.3 # TODO remove once native crypto is used on iOS
   wakelock_plus: ^1.1.1
   flutter_local_notifications: ^15.1.0+1
+  timezone: ^0.9.2
 
   openapi:
     path: openapi

+ 10 - 1
server/immich-openapi-specs.json

@@ -30,6 +30,15 @@
             "schema": {
               "$ref": "#/components/schemas/ReactionType"
             }
+          },
+          {
+            "name": "userId",
+            "required": false,
+            "in": "query",
+            "schema": {
+              "format": "uuid",
+              "type": "string"
+            }
           }
         ],
         "responses": {
@@ -5635,7 +5644,7 @@
   "info": {
     "title": "Immich",
     "description": "Immich API",
-    "version": "1.83.0",
+    "version": "1.84.0",
     "contact": {}
   },
   "tags": [],

+ 2 - 2
server/package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "immich",
-  "version": "1.83.0",
+  "version": "1.84.0",
   "lockfileVersion": 2,
   "requires": true,
   "packages": {
     "": {
       "name": "immich",
-      "version": "1.83.0",
+      "version": "1.84.0",
       "license": "UNLICENSED",
       "dependencies": {
         "@babel/runtime": "^7.22.11",

+ 1 - 1
server/package.json

@@ -1,6 +1,6 @@
 {
   "name": "immich",
-  "version": "1.83.0",
+  "version": "1.84.0",
   "description": "",
   "author": "",
   "private": true,

+ 3 - 0
server/src/domain/activity/activity.dto.ts

@@ -38,6 +38,9 @@ export class ActivitySearchDto extends ActivityDto {
   @Optional()
   @ApiProperty({ enumName: 'ReactionType', enum: ReactionType })
   type?: ReactionType;
+
+  @ValidateUUID({ optional: true })
+  userId?: string;
 }
 
 const isComment = (dto: ActivityCreateDto) => dto.type === 'comment';

+ 1 - 0
server/src/domain/activity/activity.service.ts

@@ -28,6 +28,7 @@ export class ActivityService {
   async getAll(authUser: AuthUserDto, dto: ActivitySearchDto): Promise<ActivityResponseDto[]> {
     await this.access.requirePermission(authUser, Permission.ALBUM_READ, dto.albumId);
     const activities = await this.repository.search({
+      userId: dto.userId,
       albumId: dto.albumId,
       assetId: dto.assetId,
       isLiked: dto.type && dto.type === ReactionType.LIKE,

+ 8 - 1
server/src/domain/asset/response-dto/asset-response.dto.ts

@@ -98,7 +98,14 @@ export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): As
     tags: entity.tags?.map(mapTag),
     people: entity.faces
       ?.map(mapFace)
-      .filter((person): person is PersonResponseDto => person !== null && !person.isHidden),
+      .filter((person): person is PersonResponseDto => person !== null && !person.isHidden)
+      .reduce((people, person) => {
+        const existingPerson = people.find((p) => p.id === person.id);
+        if (!existingPerson) {
+          people.push(person);
+        }
+        return people;
+      }, [] as PersonResponseDto[]),
     checksum: entity.checksum.toString('base64'),
     stackParentId: entity.stackParentId,
     stack: withStack ? entity.stack?.map((a) => mapAsset(a, { stripMetadata })) ?? undefined : undefined,

+ 3 - 1
server/src/immich/api-v1/asset/asset-repository.ts

@@ -109,7 +109,9 @@ export class AssetRepository implements IAssetRepository {
         faces: {
           person: true,
         },
-        stack: true,
+        stack: {
+          exifInfo: true,
+        },
       },
       // We are specifically asking for this asset. Return it even if it is soft deleted
       withDeleted: true,

+ 23 - 0
server/test/e2e/activity.e2e-spec.ts

@@ -134,6 +134,29 @@ describe(`${ActivityController.name} (e2e)`, () => {
       expect(body[0]).toEqual(reaction);
     });
 
+    it('should filter by userId', async () => {
+      const [reaction] = await Promise.all([
+        api.activityApi.create(server, admin.accessToken, { albumId: album.id, type: ReactionType.LIKE }),
+      ]);
+
+      const response1 = await request(server)
+        .get('/activity')
+        .query({ albumId: album.id, userId: uuidStub.notFound })
+        .set('Authorization', `Bearer ${admin.accessToken}`);
+
+      expect(response1.status).toEqual(200);
+      expect(response1.body.length).toBe(0);
+
+      const response2 = await request(server)
+        .get('/activity')
+        .query({ albumId: album.id, userId: admin.userId })
+        .set('Authorization', `Bearer ${admin.accessToken}`);
+
+      expect(response2.status).toEqual(200);
+      expect(response2.body.length).toBe(1);
+      expect(response2.body[0]).toEqual(reaction);
+    });
+
     it('should filter by assetId', async () => {
       const [reaction] = await Promise.all([
         api.activityApi.create(server, admin.accessToken, {

+ 1 - 1
web/src/api/open-api/base.ts

@@ -4,7 +4,7 @@
  * Immich
  * Immich API
  *
- * The version of the OpenAPI document: 1.83.0
+ * The version of the OpenAPI document: 1.84.0
  * 
  *
  * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

+ 1 - 1
web/src/api/open-api/common.ts

@@ -4,7 +4,7 @@
  * Immich
  * Immich API
  *
- * The version of the OpenAPI document: 1.83.0
+ * The version of the OpenAPI document: 1.84.0
  * 
  *
  * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

+ 1 - 1
web/src/api/open-api/configuration.ts

@@ -4,7 +4,7 @@
  * Immich
  * Immich API
  *
- * The version of the OpenAPI document: 1.83.0
+ * The version of the OpenAPI document: 1.84.0
  * 
  *
  * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

+ 1 - 1
web/src/api/open-api/index.ts

@@ -4,7 +4,7 @@
  * Immich
  * Immich API
  *
- * The version of the OpenAPI document: 1.83.0
+ * The version of the OpenAPI document: 1.84.0
  * 
  *
  * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

+ 2 - 2
web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte

@@ -32,7 +32,7 @@
   export let showDownloadButton: boolean;
   export let showDetailButton: boolean;
   export let showSlideshow = false;
-  export let hasStackChildern = false;
+  export let hasStackChildren = false;
 
   $: isOwner = asset.ownerId === $page.data.user?.id;
 
@@ -176,7 +176,7 @@
               />
               <MenuOption on:click={() => onMenuClick('asProfileImage')} text="As profile picture" />
 
-              {#if hasStackChildern}
+              {#if hasStackChildren}
                 <MenuOption on:click={() => onMenuClick('unstack')} text="Un-Stack" />
               {/if}
 

+ 114 - 61
web/src/lib/components/asset-viewer/asset-viewer.svelte

@@ -29,25 +29,24 @@
   import { browser } from '$app/environment';
   import { handleError } from '$lib/utils/handle-error';
   import type { AssetStore } from '$lib/stores/assets.store';
-  import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
-  import ProgressBar, { ProgressBarStatus } from '../shared-components/progress-bar/progress-bar.svelte';
   import { shouldIgnoreShortcut } from '$lib/utils/shortcut';
+  import { assetViewingStore } from '$lib/stores/asset-viewing.store';
+  import { SlideshowHistory } from '$lib/utils/slideshow-history';
   import { featureFlags } from '$lib/stores/server-config.store';
   import {
-    mdiChevronLeft,
     mdiHeartOutline,
     mdiHeart,
     mdiCommentOutline,
+    mdiChevronLeft,
     mdiChevronRight,
-    mdiClose,
     mdiImageBrokenVariant,
-    mdiPause,
-    mdiPlay,
   } from '@mdi/js';
   import Icon from '$lib/components/elements/icon.svelte';
   import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
   import { stackAssetsStore } from '$lib/stores/stacked-asset.store';
   import ActivityViewer from './activity-viewer.svelte';
+  import { SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
+  import SlideshowBar from './slideshow-bar.svelte';
 
   export let assetStore: AssetStore | null = null;
   export let asset: AssetResponseDto;
@@ -62,6 +61,14 @@
 
   let reactions: ActivityResponseDto[] = [];
 
+  const { setAssetId } = assetViewingStore;
+  const {
+    restartProgress: restartSlideshowProgress,
+    stopProgress: stopSlideshowProgress,
+    slideshowShuffle,
+    slideshowState,
+  } = slideshowStore;
+
   const dispatch = createEventDispatcher<{
     archived: AssetResponseDto;
     unarchived: AssetResponseDto;
@@ -82,6 +89,8 @@
   let shouldShowDownloadButton = sharedLink ? sharedLink.allowDownload : !asset.isOffline;
   let shouldShowDetailButton = asset.hasMetadata;
   let canCopyImagesToClipboard: boolean;
+  let slideshowStateUnsubscribe: () => void;
+  let shuffleSlideshowUnsubscribe: () => void;
   let previewStackedAsset: AssetResponseDto | undefined;
   let isShowActivity = false;
   let isLiked: ActivityResponseDto | null = null;
@@ -123,15 +132,18 @@
   };
 
   const getFavorite = async () => {
-    if (album) {
+    if (album && user) {
       try {
         const { data } = await api.activityApi.getActivities({
+          userId: user.id,
           assetId: asset.id,
           albumId: album.id,
           type: ReactionType.Like,
         });
         if (data.length > 0) {
           isLiked = data[0];
+        } else {
+          isLiked = null;
         }
       } catch (error) {
         handleError(error, "Can't get Favorite");
@@ -161,6 +173,23 @@
   onMount(async () => {
     document.addEventListener('keydown', onKeyboardPress);
 
+    slideshowStateUnsubscribe = slideshowState.subscribe((value) => {
+      if (value === SlideshowState.PlaySlideshow) {
+        slideshowHistory.reset();
+        slideshowHistory.queue(asset.id);
+        handlePlaySlideshow();
+      } else if (value === SlideshowState.StopSlideshow) {
+        handleStopSlideshow();
+      }
+    });
+
+    shuffleSlideshowUnsubscribe = slideshowShuffle.subscribe((value) => {
+      if (value) {
+        slideshowHistory.reset();
+        slideshowHistory.queue(asset.id);
+      }
+    });
+
     if (!sharedLink) {
       await getAllAlbums();
     }
@@ -184,6 +213,14 @@
     if (browser) {
       document.removeEventListener('keydown', onKeyboardPress);
     }
+
+    if (slideshowStateUnsubscribe) {
+      slideshowStateUnsubscribe();
+    }
+
+    if (shuffleSlideshowUnsubscribe) {
+      shuffleSlideshowUnsubscribe();
+    }
   });
 
   $: asset.id && !sharedLink && getAllAlbums(); // Update the album information when the asset ID changes
@@ -262,11 +299,31 @@
 
   const closeViewer = () => dispatch('close');
 
+  const navigateAssetRandom = async () => {
+    if (!assetStore) {
+      return;
+    }
+
+    const asset = await assetStore.getRandomAsset();
+    if (!asset) {
+      return;
+    }
+
+    slideshowHistory.queue(asset.id);
+
+    setAssetId(asset.id);
+    $restartSlideshowProgress = true;
+  };
+
   const navigateAssetForward = async (e?: Event) => {
-    if (isSlideshowMode && assetStore && progressBar) {
+    if ($slideshowState === SlideshowState.PlaySlideshow && $slideshowShuffle) {
+      return slideshowHistory.next() || navigateAssetRandom();
+    }
+
+    if ($slideshowState === SlideshowState.PlaySlideshow && assetStore) {
       const hasNext = await assetStore.getNextAssetId(asset.id);
       if (hasNext) {
-        progressBar.restart(true);
+        $restartSlideshowProgress = true;
       } else {
         await handleStopSlideshow();
       }
@@ -277,8 +334,13 @@
   };
 
   const navigateAssetBackward = (e?: Event) => {
-    if (isSlideshowMode && progressBar) {
-      progressBar.restart(true);
+    if ($slideshowState === SlideshowState.PlaySlideshow && $slideshowShuffle) {
+      slideshowHistory.previous();
+      return;
+    }
+
+    if ($slideshowState === SlideshowState.PlaySlideshow) {
+      $restartSlideshowProgress = true;
     }
 
     e?.stopPropagation();
@@ -426,19 +488,21 @@
    * Slide show mode
    */
 
-  let isSlideshowMode = false;
   let assetViewerHtmlElement: HTMLElement;
-  let progressBar: ProgressBar;
-  let progressBarStatus: ProgressBarStatus;
+
+  const slideshowHistory = new SlideshowHistory((assetId: string) => {
+    setAssetId(assetId);
+    $restartSlideshowProgress = true;
+  });
 
   const handleVideoStarted = () => {
-    if (isSlideshowMode) {
-      progressBar.restart(false);
+    if ($slideshowState === SlideshowState.PlaySlideshow) {
+      $stopSlideshowProgress = true;
     }
   };
 
   const handleVideoEnded = async () => {
-    if (isSlideshowMode) {
+    if ($slideshowState === SlideshowState.PlaySlideshow) {
       await navigateAssetForward();
     }
   };
@@ -448,19 +512,20 @@
       await assetViewerHtmlElement.requestFullscreen();
     } catch (error) {
       console.error('Error entering fullscreen', error);
-    } finally {
-      isSlideshowMode = true;
+      $slideshowState = SlideshowState.StopSlideshow;
     }
   };
 
   const handleStopSlideshow = async () => {
     try {
-      await document.exitFullscreen();
+      if (document.fullscreenElement) {
+        await document.exitFullscreen();
+      }
     } catch (error) {
       console.error('Error exiting fullscreen', error);
     } finally {
-      isSlideshowMode = false;
-      progressBar.restart(false);
+      $stopSlideshowProgress = true;
+      $slideshowState = SlideshowState.None;
     }
   };
 
@@ -484,7 +549,7 @@
       }
       asset.stackCount = 0;
       asset.stack = [];
-      assetStore?.updateAsset(asset);
+      assetStore?.updateAsset(asset, true);
 
       dispatch('unstack');
       notificationController.show({ type: NotificationType.Info, message: 'Un-stacked', timeout: 1500 });
@@ -497,31 +562,10 @@
 <section
   id="immich-asset-viewer"
   class="fixed left-0 top-0 z-[1001] grid h-screen w-screen grid-cols-4 grid-rows-[64px_1fr] overflow-y-hidden bg-black"
-  bind:this={assetViewerHtmlElement}
 >
-  <div class="z-[1000] col-span-4 col-start-1 row-span-1 row-start-1 transition-transform">
-    {#if isSlideshowMode}
-      <!-- SlideShowController -->
-      <div class="flex">
-        <div class="m-4 flex gap-2">
-          <CircleIconButton icon={mdiClose} on:click={handleStopSlideshow} title="Exit Slideshow" />
-          <CircleIconButton
-            icon={progressBarStatus === ProgressBarStatus.Paused ? mdiPlay : mdiPause}
-            on:click={() => (progressBarStatus === ProgressBarStatus.Paused ? progressBar.play() : progressBar.pause())}
-            title={progressBarStatus === ProgressBarStatus.Paused ? 'Play' : 'Pause'}
-          />
-          <CircleIconButton icon={mdiChevronLeft} on:click={navigateAssetBackward} title="Previous" />
-          <CircleIconButton icon={mdiChevronRight} on:click={navigateAssetForward} title="Next" />
-        </div>
-        <ProgressBar
-          autoplay
-          bind:this={progressBar}
-          bind:status={progressBarStatus}
-          on:done={navigateAssetForward}
-          duration={5000}
-        />
-      </div>
-    {:else}
+  <!-- Top navigation bar -->
+  {#if $slideshowState === SlideshowState.None}
+    <div class="z-[1002] col-span-4 col-start-1 row-span-1 row-start-1 transition-transform">
       <AssetViewerNavBar
         {asset}
         isMotionPhotoPlaying={shouldPlayMotionPhoto}
@@ -531,7 +575,7 @@
         showDownloadButton={shouldShowDownloadButton}
         showDetailButton={shouldShowDetailButton}
         showSlideshow={!!assetStore}
-        hasStackChildern={$stackAssetsStore.length > 0}
+        hasStackChildren={$stackAssetsStore.length > 0}
         on:goBack={closeViewer}
         on:showDetail={showDetailInfoHandler}
         on:download={() => downloadFile(asset)}
@@ -544,19 +588,30 @@
         on:toggleArchive={toggleArchive}
         on:asProfileImage={() => (isShowProfileImageCrop = true)}
         on:runJob={({ detail: job }) => handleRunJob(job)}
-        on:playSlideShow={handlePlaySlideshow}
+        on:playSlideShow={() => ($slideshowState = SlideshowState.PlaySlideshow)}
         on:unstack={handleUnstack}
       />
-    {/if}
-  </div>
+    </div>
+  {/if}
 
-  {#if !isSlideshowMode && showNavigation}
-    <div class="column-span-1 z-[999] col-start-1 row-span-1 row-start-2 mb-[60px] justify-self-start">
+  {#if $slideshowState === SlideshowState.None && showNavigation}
+    <div class="z-[1001] column-span-1 col-start-1 row-span-1 row-start-2 mb-[60px] justify-self-start">
       <NavigationArea on:click={navigateAssetBackward}><Icon path={mdiChevronLeft} size="36" /></NavigationArea>
     </div>
   {/if}
+
   <!-- Asset Viewer -->
-  <div class="relative col-span-4 col-start-1 row-span-full row-start-1">
+  <div class="z-[1000] relative col-start-1 col-span-4 row-start-1 row-span-full" bind:this={assetViewerHtmlElement}>
+    {#if $slideshowState != SlideshowState.None}
+      <div class="z-[1000] absolute w-full flex">
+        <SlideshowBar
+          on:prev={navigateAssetBackward}
+          on:next={navigateAssetForward}
+          on:close={() => ($slideshowState = SlideshowState.StopSlideshow)}
+        />
+      </div>
+    {/if}
+
     {#if previewStackedAsset}
       {#key previewStackedAsset.id}
         {#if previewStackedAsset.type === AssetTypeEnum.Image}
@@ -602,7 +657,7 @@
             on:onVideoStarted={handleVideoStarted}
           />
         {/if}
-        {#if isShared}
+        {#if $slideshowState === SlideshowState.None && isShared}
           <div class="z-[9999] absolute bottom-0 right-0 mb-6 mr-6 justify-self-end">
             <div
               class="w-full h-14 flex p-4 text-white items-center justify-center rounded-full gap-4 bg-immich-dark-bg bg-opacity-60"
@@ -664,19 +719,17 @@
     {/if}
   </div>
 
-  <!-- Stack & Stack Controller -->
-
-  {#if !isSlideshowMode && showNavigation}
-    <div class="z-[999] col-span-1 col-start-4 row-span-1 row-start-2 mb-[60px] justify-self-end">
+  {#if $slideshowState === SlideshowState.None && showNavigation}
+    <div class="z-[1001] col-span-1 col-start-4 row-span-1 row-start-2 mb-[60px] justify-self-end">
       <NavigationArea on:click={navigateAssetForward}><Icon path={mdiChevronRight} size="36" /></NavigationArea>
     </div>
   {/if}
 
-  {#if !isSlideshowMode && $isShowDetail}
+  {#if $slideshowState === SlideshowState.None && $isShowDetail}
     <div
       transition:fly={{ duration: 150 }}
       id="detail-panel"
-      class="z-[1002] row-start-1 row-span-5 w-[360px] overflow-y-auto bg-immich-bg transition-all dark:border-l dark:border-l-immich-dark-gray dark:bg-immich-dark-bg"
+      class="z-[1002] row-start-1 row-span-4 w-[360px] overflow-y-auto bg-immich-bg transition-all dark:border-l dark:border-l-immich-dark-gray dark:bg-immich-dark-bg"
       translate="yes"
     >
       <DetailPanel

+ 78 - 0
web/src/lib/components/asset-viewer/slideshow-bar.svelte

@@ -0,0 +1,78 @@
+<script lang="ts">
+  import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
+  import ProgressBar, { ProgressBarStatus } from '../shared-components/progress-bar/progress-bar.svelte';
+  import { slideshowStore } from '$lib/stores/slideshow.store';
+  import { createEventDispatcher, onDestroy, onMount } from 'svelte';
+  import {
+    mdiChevronLeft,
+    mdiChevronRight,
+    mdiClose,
+    mdiPause,
+    mdiPlay,
+    mdiShuffle,
+    mdiShuffleDisabled,
+  } from '@mdi/js';
+
+  const { slideshowShuffle } = slideshowStore;
+  const { restartProgress, stopProgress } = slideshowStore;
+
+  let progressBarStatus: ProgressBarStatus;
+  let progressBar: ProgressBar;
+
+  let unsubscribeRestart: () => void;
+  let unsubscribeStop: () => void;
+
+  const dispatch = createEventDispatcher<{
+    next: void;
+    prev: void;
+    close: void;
+  }>();
+
+  onMount(() => {
+    unsubscribeRestart = restartProgress.subscribe((value) => {
+      if (value) {
+        progressBar.restart(value);
+      }
+    });
+
+    unsubscribeStop = stopProgress.subscribe((value) => {
+      if (value) {
+        progressBar.restart(false);
+      }
+    });
+  });
+
+  onDestroy(() => {
+    if (unsubscribeRestart) {
+      unsubscribeRestart();
+    }
+
+    if (unsubscribeStop) {
+      unsubscribeStop();
+    }
+  });
+</script>
+
+<div class="m-4 flex gap-2">
+  <CircleIconButton icon={mdiClose} on:click={() => dispatch('close')} title="Exit Slideshow" />
+  {#if $slideshowShuffle}
+    <CircleIconButton icon={mdiShuffle} on:click={() => ($slideshowShuffle = false)} title="Shuffle" />
+  {:else}
+    <CircleIconButton icon={mdiShuffleDisabled} on:click={() => ($slideshowShuffle = true)} title="No shuffle" />
+  {/if}
+  <CircleIconButton
+    icon={progressBarStatus === ProgressBarStatus.Paused ? mdiPlay : mdiPause}
+    on:click={() => (progressBarStatus === ProgressBarStatus.Paused ? progressBar.play() : progressBar.pause())}
+    title={progressBarStatus === ProgressBarStatus.Paused ? 'Play' : 'Pause'}
+  />
+  <CircleIconButton icon={mdiChevronLeft} on:click={() => dispatch('prev')} title="Previous" />
+  <CircleIconButton icon={mdiChevronRight} on:click={() => dispatch('next')} title="Next" />
+</div>
+
+<ProgressBar
+  autoplay
+  bind:this={progressBar}
+  bind:status={progressBarStatus}
+  on:done={() => dispatch('next')}
+  duration={5000}
+/>

+ 1 - 1
web/src/lib/components/layouts/user-page-layout.svelte

@@ -12,7 +12,7 @@
   export let scrollbar = true;
   export let admin = false;
 
-  $: scrollbarClass = scrollbar ? 'immich-scrollbar p-4 pb-8' : 'scrollbar-hidden pl-4';
+  $: scrollbarClass = scrollbar ? 'immich-scrollbar p-4 pb-8' : 'scrollbar-hidden';
   $: hasTitleClass = title ? 'top-16 h-[calc(100%-theme(spacing.16))]' : 'top-0 h-full';
 </script>
 

+ 41 - 7
web/src/lib/components/shared-components/scrollbar/scrollbar.svelte

@@ -21,11 +21,27 @@
 
   $: hoverY = height - windowHeight + clientY;
   $: scrollY = toScrollY(timelineY);
-  $: segments = $assetStore.buckets.map((bucket) => ({
-    count: bucket.assets.length,
-    height: toScrollY(bucket.bucketHeight),
-    timeGroup: bucket.bucketDate,
-  }));
+
+  class Segment {
+    public count;
+    public height;
+    public timeGroup;
+
+    constructor({ count = 0, height = 0, timeGroup = '' }) {
+      this.count = count;
+      this.height = height;
+      this.timeGroup = timeGroup;
+    }
+  }
+
+  $: segments = $assetStore.buckets.map(
+    (bucket) =>
+      new Segment({
+        count: bucket.assets.length,
+        height: toScrollY(bucket.bucketHeight),
+        timeGroup: bucket.bucketDate,
+      }),
+  );
 
   const dispatch = createEventDispatcher<{ scrollTimeline: number }>();
   const scrollTimeline = () => dispatch('scrollTimeline', toTimelineY(hoverY));
@@ -51,6 +67,23 @@
       isAnimating = false;
     });
   };
+
+  const prevYearSegmentHeight = (segments: Segment[], index: number) => {
+    var prevYear = null;
+    var height = 0;
+    for (var i = index; i >= 0; i--) {
+      const curr = segments[i];
+      const currYear = fromLocalDateTime(curr.timeGroup).year;
+      if (prevYear && prevYear != currYear) {
+        break;
+      }
+
+      height += curr.height;
+      prevYear = currYear;
+    }
+
+    return height;
+  };
 </script>
 
 <svelte:window bind:innerHeight={windowHeight} />
@@ -96,6 +129,7 @@
       {@const date = fromLocalDateTime(segment.timeGroup)}
       {@const year = date.year}
       {@const label = `${date.toLocaleString({ month: 'short' })} ${year}`}
+      {@const lastGroupYear = fromLocalDateTime(segments[index - 1]?.timeGroup).year}
 
       <!-- svelte-ignore a11y-no-static-element-interactions -->
       <div
@@ -105,10 +139,10 @@
         aria-label={segment.timeGroup + ' ' + segment.count}
         on:mousemove={() => (hoverLabel = label)}
       >
-        {#if new Date(segments[index - 1]?.timeGroup).getFullYear() !== year}
+        {#if lastGroupYear !== year && (index == 0 || prevYearSegmentHeight(segments, index - 1) > 16)}
           <div
             aria-label={segment.timeGroup + ' ' + segment.count}
-            class="absolute right-0 z-10 pr-5 text-xs font-medium dark:text-immich-dark-fg"
+            class="absolute right-0 z-10 pr-5 text-xs font-medium dark:text-immich-dark-fg font-mono"
           >
             {year}
           </div>

+ 15 - 2
web/src/lib/stores/assets.store.ts

@@ -304,7 +304,20 @@ export class AssetStore {
     return this.assetToBucket[assetId]?.bucketIndex ?? null;
   }
 
-  updateAsset(_asset: AssetResponseDto) {
+  async getRandomAsset(): Promise<AssetResponseDto | null> {
+    const bucket = this.buckets[Math.floor(Math.random() * this.buckets.length)] || null;
+    if (!bucket) {
+      return null;
+    }
+
+    if (bucket.assets.length === 0) {
+      await this.loadBucket(bucket.bucketDate, BucketPosition.Unknown);
+    }
+
+    return bucket.assets[Math.floor(Math.random() * bucket.assets.length)] || null;
+  }
+
+  updateAsset(_asset: AssetResponseDto, recalculate = false) {
     const asset = this.assets.find((asset) => asset.id === _asset.id);
     if (!asset) {
       return;
@@ -312,7 +325,7 @@ export class AssetStore {
 
     Object.assign(asset, _asset);
 
-    this.emit(false);
+    this.emit(recalculate);
   }
 
   removeAssets(ids: string[]) {

+ 45 - 0
web/src/lib/stores/slideshow.store.ts

@@ -0,0 +1,45 @@
+import { persisted } from 'svelte-local-storage-store';
+import { writable } from 'svelte/store';
+
+export enum SlideshowState {
+  PlaySlideshow = 'play-slideshow',
+  StopSlideshow = 'stop-slideshow',
+  None = 'none',
+}
+
+function createSlideshowStore() {
+  const restartState = writable<boolean>(false);
+  const stopState = writable<boolean>(false);
+
+  const slideshowShuffle = persisted<boolean>('slideshow-shuffle', true);
+  const slideshowState = writable<SlideshowState>(SlideshowState.None);
+
+  return {
+    restartProgress: {
+      subscribe: restartState.subscribe,
+      set: (value: boolean) => {
+        // Trigger an action whenever the restartProgress is set to true. Automatically
+        // reset the restart state after that
+        if (value) {
+          restartState.set(true);
+          restartState.set(false);
+        }
+      },
+    },
+    stopProgress: {
+      subscribe: stopState.subscribe,
+      set: (value: boolean) => {
+        // Trigger an action whenever the stopProgress is set to true. Automatically
+        // reset the stop state after that
+        if (value) {
+          stopState.set(true);
+          stopState.set(false);
+        }
+      },
+    },
+    slideshowShuffle,
+    slideshowState,
+  };
+}
+
+export const slideshowStore = createSlideshowStore();

+ 40 - 0
web/src/lib/utils/slideshow-history.ts

@@ -0,0 +1,40 @@
+export class SlideshowHistory {
+  private history: string[] = [];
+  private index = 0;
+
+  constructor(private onChange: (assetId: string) => void) {}
+
+  reset() {
+    this.history = [];
+    this.index = 0;
+  }
+
+  queue(assetId: string) {
+    this.history.push(assetId);
+
+    // If we were at the end of the slideshow history, move the index to the new end
+    if (this.index === this.history.length - 2) {
+      this.index++;
+    }
+  }
+
+  next(): boolean {
+    if (this.index === this.history.length - 1) {
+      return false;
+    }
+
+    this.index++;
+    this.onChange(this.history[this.index]);
+    return true;
+  }
+
+  previous(): boolean {
+    if (this.index === 0) {
+      return false;
+    }
+
+    this.index--;
+    this.onChange(this.history[this.index]);
+    return true;
+  }
+}

+ 14 - 1
web/src/routes/(user)/albums/[albumId]/+page.svelte

@@ -29,6 +29,7 @@
   import { AppRoute, dateFormats } from '$lib/constants';
   import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store';
   import { assetViewingStore } from '$lib/stores/asset-viewing.store';
+  import { SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
   import { AssetStore } from '$lib/stores/assets.store';
   import { locale } from '$lib/stores/preferences.store';
   import { downloadArchive } from '$lib/utils/asset-utils';
@@ -52,7 +53,8 @@
 
   export let data: PageData;
 
-  let { isViewing: showAssetViewer } = assetViewingStore;
+  let { isViewing: showAssetViewer, setAssetId } = assetViewingStore;
+  let { slideshowState, slideshowShuffle } = slideshowStore;
 
   let album = data.album;
   $: album = data.album;
@@ -108,6 +110,14 @@
     }
   });
 
+  const handleStartSlideshow = async () => {
+    const asset = $slideshowShuffle ? await assetStore.getRandomAsset() : assetStore.assets[0];
+    if (asset) {
+      setAssetId(asset.id);
+      $slideshowState = SlideshowState.PlaySlideshow;
+    }
+  };
+
   const handleEscape = () => {
     if (viewMode === ViewMode.SELECT_USERS) {
       viewMode = ViewMode.VIEW;
@@ -365,6 +375,9 @@
                 <CircleIconButton title="Album options" on:click={handleOpenAlbumOptions} icon={mdiDotsVertical}>
                   {#if viewMode === ViewMode.ALBUM_OPTIONS}
                     <ContextMenu {...contextMenuPosition}>
+                      {#if album.assetCount !== 0}
+                        <MenuOption on:click={handleStartSlideshow} text="Slideshow" />
+                      {/if}
                       <MenuOption on:click={() => (viewMode = ViewMode.SELECT_THUMBNAIL)} text="Set album cover" />
                     </ContextMenu>
                   {/if}

+ 1 - 1
web/src/routes/(user)/trash/+page.svelte

@@ -87,7 +87,7 @@
     </div>
 
     <AssetGrid forceDelete {assetStore} {assetInteractionStore}>
-      <p class="font-medium text-gray-500/60 dark:text-gray-300/60 py-4">
+      <p class="font-medium text-gray-500/60 dark:text-gray-300/60 p-4">
         Trashed items will be permanently deleted after {$serverConfig.trashDays} days.
       </p>
       <EmptyPlaceholder