瀏覽代碼

merge main

martabal 1 年之前
父節點
當前提交
fb9b854bf1
共有 53 個文件被更改,包括 976 次插入396 次删除
  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
  * Immich API
  * 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).
  * 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
  * Immich API
  * 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).
  * 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
  * Immich API
  * 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).
  * 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
  * Immich API
  * 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).
  * 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.
 - Only new files that are added to the gallery will be detected.
 - Deleted and moved files will not 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
 ## 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.
 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'
 ```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
 docker start immich_postgres    # Start Postgres server
 sleep 10    # Wait for Postgres server to start up
 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
 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.).
 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                                             |
 | --server / -s    | Immich's server address                                             |
 | --threads / -t   | Number of threads to use (Default 5)                                |
 | --threads / -t   | Number of threads to use (Default 5)                                |
 | --album/ -al     | Create albums for assets based on the parent folder or a given name |
 | --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
 ## Quick Start
 
 
@@ -108,70 +107,3 @@ npm run build
 ```bash title="Run the command"
 ```bash title="Run the command"
 node bin/index.js upload --key HFEJ38DNSDUEG --server http://192.168.1.216:2283/api --recursive your/asset/directory
 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]
 [tool.poetry]
 name = "machine-learning"
 name = "machine-learning"
-version = "1.83.0"
+version = "1.84.0"
 description = ""
 description = ""
 authors = ["Hau Tran <alex.tran1502@gmail.com>"]
 authors = ["Hau Tran <alex.tran1502@gmail.com>"]
 readme = "README.md"
 readme = "README.md"

+ 2 - 2
mobile/android/fastlane/Fastfile

@@ -35,8 +35,8 @@ platform :android do
       task: 'bundle', 
       task: 'bundle', 
       build_type: 'Release',
       build_type: 'Release',
       properties: {
       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')
     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>
     
     
       
       
-      <testcase classname="fastlane.lanes" name="1: bundleRelease" time="81.160108">
+      <testcase classname="fastlane.lanes" name="1: bundleRelease" time="70.943413">
         
         
       </testcase>
       </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>
       </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_err_title": "Failed to change album title",
   "album_viewer_appbar_share_leave": "Leave album",
   "album_viewer_appbar_share_leave": "Leave album",
   "album_viewer_appbar_share_remove": "Remove from album",
   "album_viewer_appbar_share_remove": "Remove from album",
+  "album_viewer_appbar_share_to": "Share To",
   "album_viewer_page_share_add_users": "Add users",
   "album_viewer_page_share_add_users": "Add users",
   "all_people_page_title": "People",
   "all_people_page_title": "People",
   "all_videos_page_title": "Videos",
   "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_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
 				CODE_SIGN_IDENTITY = "Apple Development";
 				CODE_SIGN_IDENTITY = "Apple Development";
 				CODE_SIGN_STYLE = Automatic;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 118;
+				CURRENT_PROJECT_VERSION = 124;
 				DEVELOPMENT_TEAM = 2F67MQ8R79;
 				DEVELOPMENT_TEAM = 2F67MQ8R79;
 				ENABLE_BITCODE = NO;
 				ENABLE_BITCODE = NO;
 				INFOPLIST_FILE = Runner/Info.plist;
 				INFOPLIST_FILE = Runner/Info.plist;
@@ -515,7 +515,7 @@
 				CLANG_ENABLE_MODULES = YES;
 				CLANG_ENABLE_MODULES = YES;
 				CODE_SIGN_IDENTITY = "Apple Development";
 				CODE_SIGN_IDENTITY = "Apple Development";
 				CODE_SIGN_STYLE = Automatic;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 118;
+				CURRENT_PROJECT_VERSION = 124;
 				DEVELOPMENT_TEAM = 2F67MQ8R79;
 				DEVELOPMENT_TEAM = 2F67MQ8R79;
 				ENABLE_BITCODE = NO;
 				ENABLE_BITCODE = NO;
 				INFOPLIST_FILE = Runner/Info.plist;
 				INFOPLIST_FILE = Runner/Info.plist;
@@ -543,7 +543,7 @@
 				CLANG_ENABLE_MODULES = YES;
 				CLANG_ENABLE_MODULES = YES;
 				CODE_SIGN_IDENTITY = "Apple Development";
 				CODE_SIGN_IDENTITY = "Apple Development";
 				CODE_SIGN_STYLE = Automatic;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 118;
+				CURRENT_PROJECT_VERSION = 124;
 				DEVELOPMENT_TEAM = 2F67MQ8R79;
 				DEVELOPMENT_TEAM = 2F67MQ8R79;
 				ENABLE_BITCODE = NO;
 				ENABLE_BITCODE = NO;
 				INFOPLIST_FILE = Runner/Info.plist;
 				INFOPLIST_FILE = Runner/Info.plist;

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

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

+ 1 - 1
mobile/ios/fastlane/Fastfile

@@ -19,7 +19,7 @@ platform :ios do
   desc "iOS Beta"
   desc "iOS Beta"
   lane :beta do
   lane :beta do
     increment_version_number(
     increment_version_number(
-      version_number: "1.83.0"
+      version_number: "1.84.0"
     )
     )
     increment_build_number(
     increment_build_number(
       build_number: latest_testflight_build_number + 1,
       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>
     
     
       
       
-      <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>
     
     
       
       
-      <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>
     
     
       
       
-      <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>
     
     
       
       
-      <testcase classname="fastlane.lanes" name="4: build_app" time="97.596654">
+      <testcase classname="fastlane.lanes" name="4: build_app" time="145.399278">
         
         
       </testcase>
       </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>
       </testcase>
     
     

+ 3 - 0
mobile/lib/main.dart

@@ -7,6 +7,7 @@ import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
 import 'package:flutter/services.dart';
 import 'package:flutter_displaymode/flutter_displaymode.dart';
 import 'package:flutter_displaymode/flutter_displaymode.dart';
 import 'package:hooks_riverpod/hooks_riverpod.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/constants/locales.dart';
 import 'package:immich_mobile/modules/backup/background_service/background.service.dart';
 import 'package:immich_mobile/modules/backup/background_service/background.service.dart';
 import 'package:immich_mobile/modules/backup/models/backup_album.model.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);
     log.severe('Catch all error: ${error.toString()} - $error', error, stack);
     return true;
     return true;
   };
   };
+
+  initializeTimeZones();
 }
 }
 
 
 Future<Isar> loadDb() async {
 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_detail.provider.dart';
 import 'package:immich_mobile/modules/album/providers/album_viewer.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/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/routing/router.dart';
 import 'package:immich_mobile/shared/models/album.dart';
 import 'package:immich_mobile/shared/models/album.dart';
 import 'package:immich_mobile/shared/models/asset.dart';
 import 'package:immich_mobile/shared/models/asset.dart';
@@ -160,40 +162,77 @@ class AlbumViewerAppbar extends HookConsumerWidget
       ImmichLoadingOverlayController.appLoader.hide();
       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 (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),
             leading: const Icon(Icons.delete_sweep_rounded),
             title: const Text(
             title: const Text(
               'album_viewer_appbar_share_remove',
               'album_viewer_appbar_share_remove',
               style: TextStyle(fontWeight: FontWeight.bold),
               style: TextStyle(fontWeight: FontWeight.bold),
             ).tr(),
             ).tr(),
             onTap: () => onRemoveFromAlbumPressed(),
             onTap: () => onRemoveFromAlbumPressed(),
-          );
-        } else {
-          return const SizedBox();
-        }
+          ) : const SizedBox(),
+        ];
       } else {
       } else {
-        if (album.ownerId == userId) {
-          return ListTile(
+        return [
+          album.ownerId == userId ? ListTile(
             leading: const Icon(Icons.delete_forever_rounded),
             leading: const Icon(Icons.delete_forever_rounded),
             title: const Text(
             title: const Text(
               'album_viewer_appbar_share_delete',
               'album_viewer_appbar_share_delete',
               style: TextStyle(fontWeight: FontWeight.bold),
               style: TextStyle(fontWeight: FontWeight.bold),
             ).tr(),
             ).tr(),
             onTap: () => onDeleteAlbumPressed(),
             onTap: () => onDeleteAlbumPressed(),
-          );
-        } else {
-          return ListTile(
+          ) : ListTile(
             leading: const Icon(Icons.person_remove_rounded),
             leading: const Icon(Icons.person_remove_rounded),
             title: const Text(
             title: const Text(
               'album_viewer_appbar_share_leave',
               'album_viewer_appbar_share_leave',
               style: TextStyle(fontWeight: FontWeight.bold),
               style: TextStyle(fontWeight: FontWeight.bold),
             ).tr(),
             ).tr(),
             onTap: () => onLeaveAlbumPressed(),
             onTap: () => onLeaveAlbumPressed(),
-          );
-        }
+          ),
+        ];
       }
       }
     }
     }
 
 
@@ -257,7 +296,7 @@ class AlbumViewerAppbar extends HookConsumerWidget
               child: Column(
               child: Column(
                 mainAxisSize: MainAxisSize.min,
                 mainAxisSize: MainAxisSize.min,
                 children: [
                 children: [
-                  buildBottomSheetActionButton(),
+                  ...buildBottomSheetActions(),
                   if (selected.isEmpty && onAddPhotos != null) ...commonActions,
                   if (selected.isEmpty && onAddPhotos != null) ...commonActions,
                   if (selected.isEmpty &&
                   if (selected.isEmpty &&
                       onAddPhotos != null &&
                       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/material.dart';
 import 'package:flutter_map/flutter_map.dart';
 import 'package:flutter_map/flutter_map.dart';
 import 'package:hooks_riverpod/hooks_riverpod.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/asset_viewer/ui/description_input.dart';
 import 'package:immich_mobile/modules/map/ui/map_thumbnail.dart';
 import 'package:immich_mobile/modules/map/ui/map_thumbnail.dart';
 import 'package:immich_mobile/shared/models/asset.dart';
 import 'package:immich_mobile/shared/models/asset.dart';
@@ -26,12 +27,36 @@ class ExifBottomSheet extends HookConsumerWidget {
       exifInfo.latitude != 0 &&
       exifInfo.latitude != 0 &&
       exifInfo.longitude != 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 {
   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 {
   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(),
             label: "control_bottom_app_bar_stack".tr(),
             onPressed: enabled ? onStack : null,
             onPressed: enabled ? onStack : null,
           ),
           ),
-        if (!hasRemote)
+        if (hasLocal)
           ControlBoxButton(
           ControlBoxButton(
             iconData: Icons.backup_outlined,
             iconData: Icons.backup_outlined,
             label: "Upload",
             label: "Upload",

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

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

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

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

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

@@ -27,65 +27,75 @@ const ExifInfoSchema = CollectionSchema(
       name: r'country',
       name: r'country',
       type: IsarType.string,
       type: IsarType.string,
     ),
     ),
-    r'description': PropertySchema(
+    r'dateTimeOriginal': PropertySchema(
       id: 2,
       id: 2,
+      name: r'dateTimeOriginal',
+      type: IsarType.dateTime,
+    ),
+    r'description': PropertySchema(
+      id: 3,
       name: r'description',
       name: r'description',
       type: IsarType.string,
       type: IsarType.string,
     ),
     ),
     r'exposureSeconds': PropertySchema(
     r'exposureSeconds': PropertySchema(
-      id: 3,
+      id: 4,
       name: r'exposureSeconds',
       name: r'exposureSeconds',
       type: IsarType.float,
       type: IsarType.float,
     ),
     ),
     r'f': PropertySchema(
     r'f': PropertySchema(
-      id: 4,
+      id: 5,
       name: r'f',
       name: r'f',
       type: IsarType.float,
       type: IsarType.float,
     ),
     ),
     r'fileSize': PropertySchema(
     r'fileSize': PropertySchema(
-      id: 5,
+      id: 6,
       name: r'fileSize',
       name: r'fileSize',
       type: IsarType.long,
       type: IsarType.long,
     ),
     ),
     r'iso': PropertySchema(
     r'iso': PropertySchema(
-      id: 6,
+      id: 7,
       name: r'iso',
       name: r'iso',
       type: IsarType.int,
       type: IsarType.int,
     ),
     ),
     r'lat': PropertySchema(
     r'lat': PropertySchema(
-      id: 7,
+      id: 8,
       name: r'lat',
       name: r'lat',
       type: IsarType.float,
       type: IsarType.float,
     ),
     ),
     r'lens': PropertySchema(
     r'lens': PropertySchema(
-      id: 8,
+      id: 9,
       name: r'lens',
       name: r'lens',
       type: IsarType.string,
       type: IsarType.string,
     ),
     ),
     r'long': PropertySchema(
     r'long': PropertySchema(
-      id: 9,
+      id: 10,
       name: r'long',
       name: r'long',
       type: IsarType.float,
       type: IsarType.float,
     ),
     ),
     r'make': PropertySchema(
     r'make': PropertySchema(
-      id: 10,
+      id: 11,
       name: r'make',
       name: r'make',
       type: IsarType.string,
       type: IsarType.string,
     ),
     ),
     r'mm': PropertySchema(
     r'mm': PropertySchema(
-      id: 11,
+      id: 12,
       name: r'mm',
       name: r'mm',
       type: IsarType.float,
       type: IsarType.float,
     ),
     ),
     r'model': PropertySchema(
     r'model': PropertySchema(
-      id: 12,
+      id: 13,
       name: r'model',
       name: r'model',
       type: IsarType.string,
       type: IsarType.string,
     ),
     ),
     r'state': PropertySchema(
     r'state': PropertySchema(
-      id: 13,
+      id: 14,
       name: r'state',
       name: r'state',
       type: IsarType.string,
       type: IsarType.string,
+    ),
+    r'timeZone': PropertySchema(
+      id: 15,
+      name: r'timeZone',
+      type: IsarType.string,
     )
     )
   },
   },
   estimateSize: _exifInfoEstimateSize,
   estimateSize: _exifInfoEstimateSize,
@@ -150,6 +160,12 @@ int _exifInfoEstimateSize(
       bytesCount += 3 + value.length * 3;
       bytesCount += 3 + value.length * 3;
     }
     }
   }
   }
+  {
+    final value = object.timeZone;
+    if (value != null) {
+      bytesCount += 3 + value.length * 3;
+    }
+  }
   return bytesCount;
   return bytesCount;
 }
 }
 
 
@@ -161,18 +177,20 @@ void _exifInfoSerialize(
 ) {
 ) {
   writer.writeString(offsets[0], object.city);
   writer.writeString(offsets[0], object.city);
   writer.writeString(offsets[1], object.country);
   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(
 ExifInfo _exifInfoDeserialize(
@@ -184,19 +202,21 @@ ExifInfo _exifInfoDeserialize(
   final object = ExifInfo(
   final object = ExifInfo(
     city: reader.readStringOrNull(offsets[0]),
     city: reader.readStringOrNull(offsets[0]),
     country: reader.readStringOrNull(offsets[1]),
     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,
     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;
   return object;
 }
 }
@@ -213,29 +233,33 @@ P _exifInfoDeserializeProp<P>(
     case 1:
     case 1:
       return (reader.readStringOrNull(offset)) as P;
       return (reader.readStringOrNull(offset)) as P;
     case 2:
     case 2:
-      return (reader.readStringOrNull(offset)) as P;
+      return (reader.readDateTimeOrNull(offset)) as P;
     case 3:
     case 3:
-      return (reader.readFloatOrNull(offset)) as P;
+      return (reader.readStringOrNull(offset)) as P;
     case 4:
     case 4:
       return (reader.readFloatOrNull(offset)) as P;
       return (reader.readFloatOrNull(offset)) as P;
     case 5:
     case 5:
-      return (reader.readLongOrNull(offset)) as P;
+      return (reader.readFloatOrNull(offset)) as P;
     case 6:
     case 6:
-      return (reader.readIntOrNull(offset)) as P;
+      return (reader.readLongOrNull(offset)) as P;
     case 7:
     case 7:
-      return (reader.readFloatOrNull(offset)) as P;
+      return (reader.readIntOrNull(offset)) as P;
     case 8:
     case 8:
-      return (reader.readStringOrNull(offset)) as P;
-    case 9:
       return (reader.readFloatOrNull(offset)) as P;
       return (reader.readFloatOrNull(offset)) as P;
-    case 10:
+    case 9:
       return (reader.readStringOrNull(offset)) as P;
       return (reader.readStringOrNull(offset)) as P;
-    case 11:
+    case 10:
       return (reader.readFloatOrNull(offset)) as P;
       return (reader.readFloatOrNull(offset)) as P;
-    case 12:
+    case 11:
       return (reader.readStringOrNull(offset)) as P;
       return (reader.readStringOrNull(offset)) as P;
+    case 12:
+      return (reader.readFloatOrNull(offset)) as P;
     case 13:
     case 13:
       return (reader.readStringOrNull(offset)) as P;
       return (reader.readStringOrNull(offset)) as P;
+    case 14:
+      return (reader.readStringOrNull(offset)) as P;
+    case 15:
+      return (reader.readStringOrNull(offset)) as P;
     default:
     default:
       throw IsarError('Unknown property with id $propertyId');
       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() {
   QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> descriptionIsNull() {
     return QueryBuilder.apply(this, (query) {
     return QueryBuilder.apply(this, (query) {
       return query.addFilterCondition(const FilterCondition.isNull(
       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
 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() {
   QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> sortByDescription() {
     return QueryBuilder.apply(this, (query) {
     return QueryBuilder.apply(this, (query) {
       return query.addSortBy(r'description', Sort.asc);
       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);
       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
 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() {
   QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> thenByDescription() {
     return QueryBuilder.apply(this, (query) {
     return QueryBuilder.apply(this, (query) {
       return query.addSortBy(r'description', Sort.asc);
       return query.addSortBy(r'description', Sort.asc);
@@ -2315,6 +2595,18 @@ extension ExifInfoQuerySortThenBy
       return query.addSortBy(r'state', Sort.desc);
       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
 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(
   QueryBuilder<ExifInfo, ExifInfo, QDistinct> distinctByDescription(
       {bool caseSensitive = true}) {
       {bool caseSensitive = true}) {
     return QueryBuilder.apply(this, (query) {
     return QueryBuilder.apply(this, (query) {
@@ -2409,6 +2707,13 @@ extension ExifInfoQueryWhereDistinct
       return query.addDistinctBy(r'state', caseSensitive: caseSensitive);
       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
 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() {
   QueryBuilder<ExifInfo, String?, QQueryOperations> descriptionProperty() {
     return QueryBuilder.apply(this, (query) {
     return QueryBuilder.apply(this, (query) {
       return query.addPropertyName(r'description');
       return query.addPropertyName(r'description');
@@ -2502,4 +2814,10 @@ extension ExifInfoQueryProperty
       return query.addPropertyName(r'state');
       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();
                 Navigator.of(context).pop();
                 launchUrl(
                 launchUrl(
                   Uri.parse('https://immich.app'),
                   Uri.parse('https://immich.app'),
+                  mode: LaunchMode.externalApplication,
                 );
                 );
               },
               },
               child: Text(
               child: Text(
@@ -213,6 +214,7 @@ class ImmichAppBarDialog extends HookConsumerWidget {
                 Navigator.of(context).pop();
                 Navigator.of(context).pop();
                 launchUrl(
                 launchUrl(
                   Uri.parse('https://github.com/immich-app/immich'),
                   Uri.parse('https://github.com/immich-app/immich'),
+                  mode: LaunchMode.externalApplication,
                 );
                 );
               },
               },
               child: Text(
               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(
                     child: Container(
                       width: 200,
                       width: 200,
                       padding: const EdgeInsets.only(right: 10.0),
                       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,
                           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;
     final isDarkMode = Theme.of(context).brightness == Brightness.dark;
     const widgetSize = 30.0;
     const widgetSize = 30.0;
 
 
-    buildProfilePhoto() {
+    buildProfileIndicator() {
       return InkWell(
       return InkWell(
         onTap: () => showDialog(
         onTap: () => showDialog(
           context: context,
           context: context,
@@ -39,37 +39,33 @@ class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget {
           builder: (ctx) => const ImmichAppBarDialog(),
           builder: (ctx) => const ImmichAppBarDialog(),
         ),
         ),
         borderRadius: BorderRadius.circular(12),
         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)
 [[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**
 # **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 albumId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | 
 final assetId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | 
 final assetId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | 
 final type = ; // ReactionType | 
 final type = ; // ReactionType | 
+final userId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | 
 
 
 try {
 try {
-    final result = api_instance.getActivities(albumId, assetId, type);
+    final result = api_instance.getActivities(albumId, assetId, type, userId);
     print(result);
     print(result);
 } catch (e) {
 } catch (e) {
     print('Exception when calling ActivityApi->getActivities: $e\n');
     print('Exception when calling ActivityApi->getActivities: $e\n');
@@ -167,6 +168,7 @@ Name | Type | Description  | Notes
  **albumId** | **String**|  | 
  **albumId** | **String**|  | 
  **assetId** | **String**|  | [optional] 
  **assetId** | **String**|  | [optional] 
  **type** | [**ReactionType**](.md)|  | [optional] 
  **type** | [**ReactionType**](.md)|  | [optional] 
+ **userId** | **String**|  | [optional] 
 
 
 ### Return type
 ### Return type
 
 

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

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

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

@@ -27,7 +27,7 @@ void main() {
       // TODO
       // 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 {
     test('test getActivities', () async {
       // TODO
       // TODO
     });
     });

+ 1 - 1
mobile/pubspec.lock

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

+ 2 - 1
mobile/pubspec.yaml

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

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

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

+ 2 - 2
server/package-lock.json

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

+ 1 - 1
server/package.json

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

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

@@ -38,6 +38,9 @@ export class ActivitySearchDto extends ActivityDto {
   @Optional()
   @Optional()
   @ApiProperty({ enumName: 'ReactionType', enum: ReactionType })
   @ApiProperty({ enumName: 'ReactionType', enum: ReactionType })
   type?: ReactionType;
   type?: ReactionType;
+
+  @ValidateUUID({ optional: true })
+  userId?: string;
 }
 }
 
 
 const isComment = (dto: ActivityCreateDto) => dto.type === 'comment';
 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[]> {
   async getAll(authUser: AuthUserDto, dto: ActivitySearchDto): Promise<ActivityResponseDto[]> {
     await this.access.requirePermission(authUser, Permission.ALBUM_READ, dto.albumId);
     await this.access.requirePermission(authUser, Permission.ALBUM_READ, dto.albumId);
     const activities = await this.repository.search({
     const activities = await this.repository.search({
+      userId: dto.userId,
       albumId: dto.albumId,
       albumId: dto.albumId,
       assetId: dto.assetId,
       assetId: dto.assetId,
       isLiked: dto.type && dto.type === ReactionType.LIKE,
       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),
     tags: entity.tags?.map(mapTag),
     people: entity.faces
     people: entity.faces
       ?.map(mapFace)
       ?.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'),
     checksum: entity.checksum.toString('base64'),
     stackParentId: entity.stackParentId,
     stackParentId: entity.stackParentId,
     stack: withStack ? entity.stack?.map((a) => mapAsset(a, { stripMetadata })) ?? undefined : undefined,
     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: {
         faces: {
           person: true,
           person: true,
         },
         },
-        stack: true,
+        stack: {
+          exifInfo: true,
+        },
       },
       },
       // We are specifically asking for this asset. Return it even if it is soft deleted
       // We are specifically asking for this asset. Return it even if it is soft deleted
       withDeleted: true,
       withDeleted: true,

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

@@ -134,6 +134,29 @@ describe(`${ActivityController.name} (e2e)`, () => {
       expect(body[0]).toEqual(reaction);
       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 () => {
     it('should filter by assetId', async () => {
       const [reaction] = await Promise.all([
       const [reaction] = await Promise.all([
         api.activityApi.create(server, admin.accessToken, {
         api.activityApi.create(server, admin.accessToken, {

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

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

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

@@ -29,25 +29,24 @@
   import { browser } from '$app/environment';
   import { browser } from '$app/environment';
   import { handleError } from '$lib/utils/handle-error';
   import { handleError } from '$lib/utils/handle-error';
   import type { AssetStore } from '$lib/stores/assets.store';
   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 { 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 { featureFlags } from '$lib/stores/server-config.store';
   import {
   import {
-    mdiChevronLeft,
     mdiHeartOutline,
     mdiHeartOutline,
     mdiHeart,
     mdiHeart,
     mdiCommentOutline,
     mdiCommentOutline,
+    mdiChevronLeft,
     mdiChevronRight,
     mdiChevronRight,
-    mdiClose,
     mdiImageBrokenVariant,
     mdiImageBrokenVariant,
-    mdiPause,
-    mdiPlay,
   } from '@mdi/js';
   } from '@mdi/js';
   import Icon from '$lib/components/elements/icon.svelte';
   import Icon from '$lib/components/elements/icon.svelte';
   import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
   import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
   import { stackAssetsStore } from '$lib/stores/stacked-asset.store';
   import { stackAssetsStore } from '$lib/stores/stacked-asset.store';
   import ActivityViewer from './activity-viewer.svelte';
   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 assetStore: AssetStore | null = null;
   export let asset: AssetResponseDto;
   export let asset: AssetResponseDto;
@@ -62,6 +61,14 @@
 
 
   let reactions: ActivityResponseDto[] = [];
   let reactions: ActivityResponseDto[] = [];
 
 
+  const { setAssetId } = assetViewingStore;
+  const {
+    restartProgress: restartSlideshowProgress,
+    stopProgress: stopSlideshowProgress,
+    slideshowShuffle,
+    slideshowState,
+  } = slideshowStore;
+
   const dispatch = createEventDispatcher<{
   const dispatch = createEventDispatcher<{
     archived: AssetResponseDto;
     archived: AssetResponseDto;
     unarchived: AssetResponseDto;
     unarchived: AssetResponseDto;
@@ -82,6 +89,8 @@
   let shouldShowDownloadButton = sharedLink ? sharedLink.allowDownload : !asset.isOffline;
   let shouldShowDownloadButton = sharedLink ? sharedLink.allowDownload : !asset.isOffline;
   let shouldShowDetailButton = asset.hasMetadata;
   let shouldShowDetailButton = asset.hasMetadata;
   let canCopyImagesToClipboard: boolean;
   let canCopyImagesToClipboard: boolean;
+  let slideshowStateUnsubscribe: () => void;
+  let shuffleSlideshowUnsubscribe: () => void;
   let previewStackedAsset: AssetResponseDto | undefined;
   let previewStackedAsset: AssetResponseDto | undefined;
   let isShowActivity = false;
   let isShowActivity = false;
   let isLiked: ActivityResponseDto | null = null;
   let isLiked: ActivityResponseDto | null = null;
@@ -123,15 +132,18 @@
   };
   };
 
 
   const getFavorite = async () => {
   const getFavorite = async () => {
-    if (album) {
+    if (album && user) {
       try {
       try {
         const { data } = await api.activityApi.getActivities({
         const { data } = await api.activityApi.getActivities({
+          userId: user.id,
           assetId: asset.id,
           assetId: asset.id,
           albumId: album.id,
           albumId: album.id,
           type: ReactionType.Like,
           type: ReactionType.Like,
         });
         });
         if (data.length > 0) {
         if (data.length > 0) {
           isLiked = data[0];
           isLiked = data[0];
+        } else {
+          isLiked = null;
         }
         }
       } catch (error) {
       } catch (error) {
         handleError(error, "Can't get Favorite");
         handleError(error, "Can't get Favorite");
@@ -161,6 +173,23 @@
   onMount(async () => {
   onMount(async () => {
     document.addEventListener('keydown', onKeyboardPress);
     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) {
     if (!sharedLink) {
       await getAllAlbums();
       await getAllAlbums();
     }
     }
@@ -184,6 +213,14 @@
     if (browser) {
     if (browser) {
       document.removeEventListener('keydown', onKeyboardPress);
       document.removeEventListener('keydown', onKeyboardPress);
     }
     }
+
+    if (slideshowStateUnsubscribe) {
+      slideshowStateUnsubscribe();
+    }
+
+    if (shuffleSlideshowUnsubscribe) {
+      shuffleSlideshowUnsubscribe();
+    }
   });
   });
 
 
   $: asset.id && !sharedLink && getAllAlbums(); // Update the album information when the asset ID changes
   $: asset.id && !sharedLink && getAllAlbums(); // Update the album information when the asset ID changes
@@ -262,11 +299,31 @@
 
 
   const closeViewer = () => dispatch('close');
   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) => {
   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);
       const hasNext = await assetStore.getNextAssetId(asset.id);
       if (hasNext) {
       if (hasNext) {
-        progressBar.restart(true);
+        $restartSlideshowProgress = true;
       } else {
       } else {
         await handleStopSlideshow();
         await handleStopSlideshow();
       }
       }
@@ -277,8 +334,13 @@
   };
   };
 
 
   const navigateAssetBackward = (e?: Event) => {
   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();
     e?.stopPropagation();
@@ -426,19 +488,21 @@
    * Slide show mode
    * Slide show mode
    */
    */
 
 
-  let isSlideshowMode = false;
   let assetViewerHtmlElement: HTMLElement;
   let assetViewerHtmlElement: HTMLElement;
-  let progressBar: ProgressBar;
-  let progressBarStatus: ProgressBarStatus;
+
+  const slideshowHistory = new SlideshowHistory((assetId: string) => {
+    setAssetId(assetId);
+    $restartSlideshowProgress = true;
+  });
 
 
   const handleVideoStarted = () => {
   const handleVideoStarted = () => {
-    if (isSlideshowMode) {
-      progressBar.restart(false);
+    if ($slideshowState === SlideshowState.PlaySlideshow) {
+      $stopSlideshowProgress = true;
     }
     }
   };
   };
 
 
   const handleVideoEnded = async () => {
   const handleVideoEnded = async () => {
-    if (isSlideshowMode) {
+    if ($slideshowState === SlideshowState.PlaySlideshow) {
       await navigateAssetForward();
       await navigateAssetForward();
     }
     }
   };
   };
@@ -448,19 +512,20 @@
       await assetViewerHtmlElement.requestFullscreen();
       await assetViewerHtmlElement.requestFullscreen();
     } catch (error) {
     } catch (error) {
       console.error('Error entering fullscreen', error);
       console.error('Error entering fullscreen', error);
-    } finally {
-      isSlideshowMode = true;
+      $slideshowState = SlideshowState.StopSlideshow;
     }
     }
   };
   };
 
 
   const handleStopSlideshow = async () => {
   const handleStopSlideshow = async () => {
     try {
     try {
-      await document.exitFullscreen();
+      if (document.fullscreenElement) {
+        await document.exitFullscreen();
+      }
     } catch (error) {
     } catch (error) {
       console.error('Error exiting fullscreen', error);
       console.error('Error exiting fullscreen', error);
     } finally {
     } finally {
-      isSlideshowMode = false;
-      progressBar.restart(false);
+      $stopSlideshowProgress = true;
+      $slideshowState = SlideshowState.None;
     }
     }
   };
   };
 
 
@@ -484,7 +549,7 @@
       }
       }
       asset.stackCount = 0;
       asset.stackCount = 0;
       asset.stack = [];
       asset.stack = [];
-      assetStore?.updateAsset(asset);
+      assetStore?.updateAsset(asset, true);
 
 
       dispatch('unstack');
       dispatch('unstack');
       notificationController.show({ type: NotificationType.Info, message: 'Un-stacked', timeout: 1500 });
       notificationController.show({ type: NotificationType.Info, message: 'Un-stacked', timeout: 1500 });
@@ -497,31 +562,10 @@
 <section
 <section
   id="immich-asset-viewer"
   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"
   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
       <AssetViewerNavBar
         {asset}
         {asset}
         isMotionPhotoPlaying={shouldPlayMotionPhoto}
         isMotionPhotoPlaying={shouldPlayMotionPhoto}
@@ -531,7 +575,7 @@
         showDownloadButton={shouldShowDownloadButton}
         showDownloadButton={shouldShowDownloadButton}
         showDetailButton={shouldShowDetailButton}
         showDetailButton={shouldShowDetailButton}
         showSlideshow={!!assetStore}
         showSlideshow={!!assetStore}
-        hasStackChildern={$stackAssetsStore.length > 0}
+        hasStackChildren={$stackAssetsStore.length > 0}
         on:goBack={closeViewer}
         on:goBack={closeViewer}
         on:showDetail={showDetailInfoHandler}
         on:showDetail={showDetailInfoHandler}
         on:download={() => downloadFile(asset)}
         on:download={() => downloadFile(asset)}
@@ -544,19 +588,30 @@
         on:toggleArchive={toggleArchive}
         on:toggleArchive={toggleArchive}
         on:asProfileImage={() => (isShowProfileImageCrop = true)}
         on:asProfileImage={() => (isShowProfileImageCrop = true)}
         on:runJob={({ detail: job }) => handleRunJob(job)}
         on:runJob={({ detail: job }) => handleRunJob(job)}
-        on:playSlideShow={handlePlaySlideshow}
+        on:playSlideShow={() => ($slideshowState = SlideshowState.PlaySlideshow)}
         on:unstack={handleUnstack}
         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>
       <NavigationArea on:click={navigateAssetBackward}><Icon path={mdiChevronLeft} size="36" /></NavigationArea>
     </div>
     </div>
   {/if}
   {/if}
+
   <!-- Asset Viewer -->
   <!-- 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}
     {#if previewStackedAsset}
       {#key previewStackedAsset.id}
       {#key previewStackedAsset.id}
         {#if previewStackedAsset.type === AssetTypeEnum.Image}
         {#if previewStackedAsset.type === AssetTypeEnum.Image}
@@ -602,7 +657,7 @@
             on:onVideoStarted={handleVideoStarted}
             on:onVideoStarted={handleVideoStarted}
           />
           />
         {/if}
         {/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="z-[9999] absolute bottom-0 right-0 mb-6 mr-6 justify-self-end">
             <div
             <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"
               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}
     {/if}
   </div>
   </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>
       <NavigationArea on:click={navigateAssetForward}><Icon path={mdiChevronRight} size="36" /></NavigationArea>
     </div>
     </div>
   {/if}
   {/if}
 
 
-  {#if !isSlideshowMode && $isShowDetail}
+  {#if $slideshowState === SlideshowState.None && $isShowDetail}
     <div
     <div
       transition:fly={{ duration: 150 }}
       transition:fly={{ duration: 150 }}
       id="detail-panel"
       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"
       translate="yes"
     >
     >
       <DetailPanel
       <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 scrollbar = true;
   export let admin = false;
   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';
   $: hasTitleClass = title ? 'top-16 h-[calc(100%-theme(spacing.16))]' : 'top-0 h-full';
 </script>
 </script>
 
 

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

@@ -21,11 +21,27 @@
 
 
   $: hoverY = height - windowHeight + clientY;
   $: hoverY = height - windowHeight + clientY;
   $: scrollY = toScrollY(timelineY);
   $: 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 dispatch = createEventDispatcher<{ scrollTimeline: number }>();
   const scrollTimeline = () => dispatch('scrollTimeline', toTimelineY(hoverY));
   const scrollTimeline = () => dispatch('scrollTimeline', toTimelineY(hoverY));
@@ -51,6 +67,23 @@
       isAnimating = false;
       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>
 </script>
 
 
 <svelte:window bind:innerHeight={windowHeight} />
 <svelte:window bind:innerHeight={windowHeight} />
@@ -96,6 +129,7 @@
       {@const date = fromLocalDateTime(segment.timeGroup)}
       {@const date = fromLocalDateTime(segment.timeGroup)}
       {@const year = date.year}
       {@const year = date.year}
       {@const label = `${date.toLocaleString({ month: 'short' })} ${year}`}
       {@const label = `${date.toLocaleString({ month: 'short' })} ${year}`}
+      {@const lastGroupYear = fromLocalDateTime(segments[index - 1]?.timeGroup).year}
 
 
       <!-- svelte-ignore a11y-no-static-element-interactions -->
       <!-- svelte-ignore a11y-no-static-element-interactions -->
       <div
       <div
@@ -105,10 +139,10 @@
         aria-label={segment.timeGroup + ' ' + segment.count}
         aria-label={segment.timeGroup + ' ' + segment.count}
         on:mousemove={() => (hoverLabel = label)}
         on:mousemove={() => (hoverLabel = label)}
       >
       >
-        {#if new Date(segments[index - 1]?.timeGroup).getFullYear() !== year}
+        {#if lastGroupYear !== year && (index == 0 || prevYearSegmentHeight(segments, index - 1) > 16)}
           <div
           <div
             aria-label={segment.timeGroup + ' ' + segment.count}
             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}
             {year}
           </div>
           </div>

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

@@ -304,7 +304,20 @@ export class AssetStore {
     return this.assetToBucket[assetId]?.bucketIndex ?? null;
     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);
     const asset = this.assets.find((asset) => asset.id === _asset.id);
     if (!asset) {
     if (!asset) {
       return;
       return;
@@ -312,7 +325,7 @@ export class AssetStore {
 
 
     Object.assign(asset, _asset);
     Object.assign(asset, _asset);
 
 
-    this.emit(false);
+    this.emit(recalculate);
   }
   }
 
 
   removeAssets(ids: string[]) {
   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 { AppRoute, dateFormats } from '$lib/constants';
   import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store';
   import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store';
   import { assetViewingStore } from '$lib/stores/asset-viewing.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 { AssetStore } from '$lib/stores/assets.store';
   import { locale } from '$lib/stores/preferences.store';
   import { locale } from '$lib/stores/preferences.store';
   import { downloadArchive } from '$lib/utils/asset-utils';
   import { downloadArchive } from '$lib/utils/asset-utils';
@@ -52,7 +53,8 @@
 
 
   export let data: PageData;
   export let data: PageData;
 
 
-  let { isViewing: showAssetViewer } = assetViewingStore;
+  let { isViewing: showAssetViewer, setAssetId } = assetViewingStore;
+  let { slideshowState, slideshowShuffle } = slideshowStore;
 
 
   let album = data.album;
   let album = data.album;
   $: 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 = () => {
   const handleEscape = () => {
     if (viewMode === ViewMode.SELECT_USERS) {
     if (viewMode === ViewMode.SELECT_USERS) {
       viewMode = ViewMode.VIEW;
       viewMode = ViewMode.VIEW;
@@ -365,6 +375,9 @@
                 <CircleIconButton title="Album options" on:click={handleOpenAlbumOptions} icon={mdiDotsVertical}>
                 <CircleIconButton title="Album options" on:click={handleOpenAlbumOptions} icon={mdiDotsVertical}>
                   {#if viewMode === ViewMode.ALBUM_OPTIONS}
                   {#if viewMode === ViewMode.ALBUM_OPTIONS}
                     <ContextMenu {...contextMenuPosition}>
                     <ContextMenu {...contextMenuPosition}>
+                      {#if album.assetCount !== 0}
+                        <MenuOption on:click={handleStartSlideshow} text="Slideshow" />
+                      {/if}
                       <MenuOption on:click={() => (viewMode = ViewMode.SELECT_THUMBNAIL)} text="Set album cover" />
                       <MenuOption on:click={() => (viewMode = ViewMode.SELECT_THUMBNAIL)} text="Set album cover" />
                     </ContextMenu>
                     </ContextMenu>
                   {/if}
                   {/if}

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

@@ -87,7 +87,7 @@
     </div>
     </div>
 
 
     <AssetGrid forceDelete {assetStore} {assetInteractionStore}>
     <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.
         Trashed items will be permanently deleted after {$serverConfig.trashDays} days.
       </p>
       </p>
       <EmptyPlaceholder
       <EmptyPlaceholder