feat: redesign/simplify asset-viewer previous/next links - hide nav button when no photo

pull/24357/head
midzelis 2025-12-06 18:56:14 +07:00
parent e1a6f6ac34
commit b9a7e834ef
17 changed files with 580 additions and 510 deletions

@ -718,7 +718,7 @@ importers:
version: link:../open-api/typescript-sdk
'@immich/ui':
specifier: ^0.50.1
version: 0.50.1(@sveltejs/kit@2.49.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.45.2)
version: 0.50.1(@sveltejs/kit@2.49.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.3)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.43.3)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.43.3)
'@mapbox/mapbox-gl-rtl-text':
specifier: 0.2.3
version: 0.2.3(mapbox-gl@1.13.3)
@ -751,7 +751,7 @@ importers:
version: 0.41.4
'@zoom-image/svelte':
specifier: ^0.3.0
version: 0.3.8(svelte@5.45.2)
version: 0.3.8(svelte@5.43.3)
async-mutex:
specifier: ^0.5.0
version: 0.5.0
@ -805,13 +805,13 @@ importers:
version: 5.2.2
svelte-i18n:
specifier: ^4.0.1
version: 4.0.1(svelte@5.45.2)
version: 4.0.1(svelte@5.43.3)
svelte-maplibre:
specifier: ^1.2.5
version: 1.2.5(svelte@5.45.2)
version: 1.2.5(svelte@5.43.3)
svelte-persisted-store:
specifier: ^0.12.0
version: 0.12.0(svelte@5.45.2)
version: 0.12.0(svelte@5.43.3)
tabbable:
specifier: ^6.2.0
version: 6.3.0
@ -836,16 +836,16 @@ importers:
version: 3.1.2
'@sveltejs/adapter-static':
specifier: ^3.0.8
version: 3.0.10(@sveltejs/kit@2.49.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))
version: 3.0.10(@sveltejs/kit@2.49.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.3)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.43.3)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))
'@sveltejs/enhanced-img':
specifier: ^0.9.0
version: 0.9.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(rollup@4.53.3)(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2))
version: 0.9.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.3)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(rollup@4.53.3)(svelte@5.43.3)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2))
'@sveltejs/kit':
specifier: ^2.27.1
version: 2.49.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2))
version: 2.49.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.3)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.43.3)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2))
'@sveltejs/vite-plugin-svelte':
specifier: 6.2.1
version: 6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2))
version: 6.2.1(svelte@5.43.3)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2))
'@tailwindcss/vite':
specifier: ^4.1.7
version: 4.1.17(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2))
@ -854,7 +854,7 @@ importers:
version: 6.9.1
'@testing-library/svelte':
specifier: ^5.2.8
version: 5.2.9(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2))
version: 5.2.9(svelte@5.43.3)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2))
'@testing-library/user-event':
specifier: ^14.5.2
version: 14.6.1(@testing-library/dom@10.4.1)
@ -893,7 +893,7 @@ importers:
version: 6.0.2(eslint@9.39.1(jiti@2.6.1))
eslint-plugin-svelte:
specifier: ^3.12.4
version: 3.13.0(eslint@9.39.1(jiti@2.6.1))(svelte@5.45.2)
version: 3.13.0(eslint@9.39.1(jiti@2.6.1))(svelte@5.43.3)
eslint-plugin-unicorn:
specifier: ^62.0.0
version: 62.0.0(eslint@9.39.1(jiti@2.6.1))
@ -914,19 +914,19 @@ importers:
version: 4.1.1(prettier@3.7.4)
prettier-plugin-svelte:
specifier: ^3.3.3
version: 3.4.0(prettier@3.7.4)(svelte@5.45.2)
version: 3.4.0(prettier@3.7.4)(svelte@5.43.3)
rollup-plugin-visualizer:
specifier: ^6.0.0
version: 6.0.5(rollup@4.53.3)
svelte:
specifier: 5.45.2
version: 5.45.2
specifier: 5.43.3
version: 5.43.3
svelte-check:
specifier: ^4.1.5
version: 4.3.4(picomatch@4.0.3)(svelte@5.45.2)(typescript@5.9.3)
version: 4.3.4(picomatch@4.0.3)(svelte@5.43.3)(typescript@5.9.3)
svelte-eslint-parser:
specifier: ^1.3.3
version: 1.4.0(svelte@5.45.2)
version: 1.4.0(svelte@5.43.3)
tailwindcss:
specifier: ^4.1.7
version: 4.1.17
@ -10781,8 +10781,8 @@ packages:
peerDependencies:
svelte: ^5.30.2
svelte@5.45.2:
resolution: {integrity: sha512-yyXdW2u3H0H/zxxWoGwJoQlRgaSJLp+Vhktv12iRw2WRDlKqUPT54Fi0K/PkXqrdkcQ98aBazpy0AH4BCBVfoA==}
svelte@5.43.3:
resolution: {integrity: sha512-kjkAjCk41mJfvJZG56XcJNOdJSke94JxtcX8zFzzz2vrt47E0LnoBzU6azIZ1aBxJgUep8qegAkguSf1GjxLXQ==}
engines: {node: '>=18'}
svg-parser@2.0.4:
@ -14697,19 +14697,19 @@ snapshots:
'@immich/justified-layout-wasm@0.4.3': {}
'@immich/svelte-markdown-preprocess@0.1.0(svelte@5.45.2)':
'@immich/svelte-markdown-preprocess@0.1.0(svelte@5.43.3)':
dependencies:
svelte: 5.45.2
svelte: 5.43.3
'@immich/ui@0.50.1(@sveltejs/kit@2.49.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.45.2)':
'@immich/ui@0.50.1(@sveltejs/kit@2.49.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.3)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.43.3)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.43.3)':
dependencies:
'@immich/svelte-markdown-preprocess': 0.1.0(svelte@5.45.2)
'@immich/svelte-markdown-preprocess': 0.1.0(svelte@5.43.3)
'@internationalized/date': 3.10.0
'@mdi/js': 7.4.47
bits-ui: 2.14.4(@internationalized/date@3.10.0)(@sveltejs/kit@2.49.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.45.2)
bits-ui: 2.14.4(@internationalized/date@3.10.0)(@sveltejs/kit@2.49.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.3)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.43.3)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.43.3)
luxon: 3.7.2
simple-icons: 15.22.0
svelte: 5.45.2
svelte: 5.43.3
svelte-highlight: 7.9.0
tailwind-merge: 3.4.0
tailwind-variants: 3.2.2(tailwind-merge@3.4.0)(tailwindcss@4.1.17)
@ -16212,17 +16212,17 @@ snapshots:
dependencies:
acorn: 8.15.0
'@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.49.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))':
'@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.49.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.3)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.43.3)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))':
dependencies:
'@sveltejs/kit': 2.49.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2))
'@sveltejs/kit': 2.49.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.3)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.43.3)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2))
'@sveltejs/enhanced-img@0.9.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(rollup@4.53.3)(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2))':
'@sveltejs/enhanced-img@0.9.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.3)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(rollup@4.53.3)(svelte@5.43.3)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2))':
dependencies:
'@sveltejs/vite-plugin-svelte': 6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2))
'@sveltejs/vite-plugin-svelte': 6.2.1(svelte@5.43.3)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2))
magic-string: 0.30.21
sharp: 0.34.5
svelte: 5.45.2
svelte-parse-markup: 0.1.5(svelte@5.45.2)
svelte: 5.43.3
svelte-parse-markup: 0.1.5(svelte@5.43.3)
vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)
vite-imagetools: 9.0.2(rollup@4.53.3)
zimmerframe: 1.1.4
@ -16230,11 +16230,11 @@ snapshots:
- rollup
- supports-color
'@sveltejs/kit@2.49.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2))':
'@sveltejs/kit@2.49.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.3)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.43.3)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2))':
dependencies:
'@standard-schema/spec': 1.0.0
'@sveltejs/acorn-typescript': 1.0.8(acorn@8.15.0)
'@sveltejs/vite-plugin-svelte': 6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2))
'@sveltejs/vite-plugin-svelte': 6.2.1(svelte@5.43.3)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2))
'@types/cookie': 0.6.0
acorn: 8.15.0
cookie: 0.6.0
@ -16246,27 +16246,27 @@ snapshots:
sade: 1.8.1
set-cookie-parser: 2.7.2
sirv: 3.0.2
svelte: 5.45.2
svelte: 5.43.3
vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)
optionalDependencies:
'@opentelemetry/api': 1.9.0
'@sveltejs/vite-plugin-svelte-inspector@5.0.0(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2))':
'@sveltejs/vite-plugin-svelte-inspector@5.0.0(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.3)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.43.3)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2))':
dependencies:
'@sveltejs/vite-plugin-svelte': 6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2))
'@sveltejs/vite-plugin-svelte': 6.2.1(svelte@5.43.3)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2))
debug: 4.4.3
svelte: 5.45.2
svelte: 5.43.3
vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)
transitivePeerDependencies:
- supports-color
'@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2))':
'@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.3)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2))':
dependencies:
'@sveltejs/vite-plugin-svelte-inspector': 5.0.0(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2))
'@sveltejs/vite-plugin-svelte-inspector': 5.0.0(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.3)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.43.3)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2))
debug: 4.4.3
deepmerge: 4.3.1
magic-string: 0.30.21
svelte: 5.45.2
svelte: 5.43.3
vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)
vitefu: 1.1.1(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2))
transitivePeerDependencies:
@ -16514,10 +16514,10 @@ snapshots:
picocolors: 1.1.1
redent: 3.0.0
'@testing-library/svelte@5.2.9(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2))':
'@testing-library/svelte@5.2.9(svelte@5.43.3)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2))':
dependencies:
'@testing-library/dom': 10.4.1
svelte: 5.45.2
svelte: 5.43.3
optionalDependencies:
vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)
@ -17237,10 +17237,10 @@ snapshots:
dependencies:
'@namnode/store': 0.1.0
'@zoom-image/svelte@0.3.8(svelte@5.45.2)':
'@zoom-image/svelte@0.3.8(svelte@5.43.3)':
dependencies:
'@zoom-image/core': 0.41.4
svelte: 5.45.2
svelte: 5.43.3
abab@2.0.6:
optional: true
@ -17603,15 +17603,15 @@ snapshots:
binary-extensions@2.3.0: {}
bits-ui@2.14.4(@internationalized/date@3.10.0)(@sveltejs/kit@2.49.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.45.2):
bits-ui@2.14.4(@internationalized/date@3.10.0)(@sveltejs/kit@2.49.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.3)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.43.3)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.43.3):
dependencies:
'@floating-ui/core': 1.7.3
'@floating-ui/dom': 1.7.4
'@internationalized/date': 3.10.0
esm-env: 1.2.2
runed: 0.35.1(@sveltejs/kit@2.49.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.45.2)
svelte: 5.45.2
svelte-toolbelt: 0.10.6(@sveltejs/kit@2.49.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.45.2)
runed: 0.35.1(@sveltejs/kit@2.49.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.3)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.43.3)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.43.3)
svelte: 5.43.3
svelte-toolbelt: 0.10.6(@sveltejs/kit@2.49.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.3)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.43.3)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.43.3)
tabbable: 6.3.0
transitivePeerDependencies:
- '@sveltejs/kit'
@ -18918,7 +18918,7 @@ snapshots:
'@types/eslint': 9.6.1
eslint-config-prettier: 10.1.8(eslint@9.39.1(jiti@2.6.1))
eslint-plugin-svelte@3.13.0(eslint@9.39.1(jiti@2.6.1))(svelte@5.45.2):
eslint-plugin-svelte@3.13.0(eslint@9.39.1(jiti@2.6.1))(svelte@5.43.3):
dependencies:
'@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@2.6.1))
'@jridgewell/sourcemap-codec': 1.5.5
@ -18930,9 +18930,9 @@ snapshots:
postcss-load-config: 3.1.4(postcss@8.5.6)
postcss-safe-parser: 7.0.1(postcss@8.5.6)
semver: 7.7.3
svelte-eslint-parser: 1.4.0(svelte@5.45.2)
svelte-eslint-parser: 1.4.0(svelte@5.43.3)
optionalDependencies:
svelte: 5.45.2
svelte: 5.43.3
transitivePeerDependencies:
- ts-node
@ -22646,10 +22646,10 @@ snapshots:
dependencies:
prettier: 3.7.4
prettier-plugin-svelte@3.4.0(prettier@3.7.4)(svelte@5.45.2):
prettier-plugin-svelte@3.4.0(prettier@3.7.4)(svelte@5.43.3):
dependencies:
prettier: 3.7.4
svelte: 5.45.2
svelte: 5.43.3
prettier@3.7.4: {}
@ -23251,14 +23251,14 @@ snapshots:
dependencies:
queue-microtask: 1.2.3
runed@0.35.1(@sveltejs/kit@2.49.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.45.2):
runed@0.35.1(@sveltejs/kit@2.49.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.3)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.43.3)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.43.3):
dependencies:
dequal: 2.0.3
esm-env: 1.2.2
lz-string: 1.5.0
svelte: 5.45.2
svelte: 5.43.3
optionalDependencies:
'@sveltejs/kit': 2.49.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2))
'@sveltejs/kit': 2.49.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.3)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.43.3)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2))
rw@1.3.3: {}
@ -23896,19 +23896,19 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {}
svelte-check@4.3.4(picomatch@4.0.3)(svelte@5.45.2)(typescript@5.9.3):
svelte-check@4.3.4(picomatch@4.0.3)(svelte@5.43.3)(typescript@5.9.3):
dependencies:
'@jridgewell/trace-mapping': 0.3.31
chokidar: 4.0.3
fdir: 6.5.0(picomatch@4.0.3)
picocolors: 1.1.1
sade: 1.8.1
svelte: 5.45.2
svelte: 5.43.3
typescript: 5.9.3
transitivePeerDependencies:
- picomatch
svelte-eslint-parser@1.4.0(svelte@5.45.2):
svelte-eslint-parser@1.4.0(svelte@5.43.3):
dependencies:
eslint-scope: 8.4.0
eslint-visitor-keys: 4.2.1
@ -23917,7 +23917,7 @@ snapshots:
postcss-scss: 4.0.9(postcss@8.5.6)
postcss-selector-parser: 7.1.0
optionalDependencies:
svelte: 5.45.2
svelte: 5.43.3
svelte-gestures@5.2.2: {}
@ -23925,7 +23925,7 @@ snapshots:
dependencies:
highlight.js: 11.11.1
svelte-i18n@4.0.1(svelte@5.45.2):
svelte-i18n@4.0.1(svelte@5.43.3):
dependencies:
cli-color: 2.0.4
deepmerge: 4.3.1
@ -23933,36 +23933,36 @@ snapshots:
estree-walker: 2.0.2
intl-messageformat: 10.7.18
sade: 1.8.1
svelte: 5.45.2
svelte: 5.43.3
tiny-glob: 0.2.9
svelte-maplibre@1.2.5(svelte@5.45.2):
svelte-maplibre@1.2.5(svelte@5.43.3):
dependencies:
d3-geo: 3.1.1
dequal: 2.0.3
just-compare: 2.3.0
maplibre-gl: 5.13.0
pmtiles: 3.2.1
svelte: 5.45.2
svelte: 5.43.3
svelte-parse-markup@0.1.5(svelte@5.45.2):
svelte-parse-markup@0.1.5(svelte@5.43.3):
dependencies:
svelte: 5.45.2
svelte: 5.43.3
svelte-persisted-store@0.12.0(svelte@5.45.2):
svelte-persisted-store@0.12.0(svelte@5.43.3):
dependencies:
svelte: 5.45.2
svelte: 5.43.3
svelte-toolbelt@0.10.6(@sveltejs/kit@2.49.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.45.2):
svelte-toolbelt@0.10.6(@sveltejs/kit@2.49.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.3)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.43.3)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.43.3):
dependencies:
clsx: 2.1.1
runed: 0.35.1(@sveltejs/kit@2.49.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.45.2)
runed: 0.35.1(@sveltejs/kit@2.49.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.3)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.43.3)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.43.3)
style-to-object: 1.0.14
svelte: 5.45.2
svelte: 5.43.3
transitivePeerDependencies:
- '@sveltejs/kit'
svelte@5.45.2:
svelte@5.43.3:
dependencies:
'@jridgewell/remapping': 2.3.5
'@jridgewell/sourcemap-codec': 1.5.5
@ -23972,7 +23972,6 @@ snapshots:
aria-query: 5.3.2
axobject-query: 4.1.0
clsx: 2.1.1
devalue: 5.5.0
esm-env: 1.2.2
esrap: 2.2.0
is-reference: 3.0.3

@ -98,7 +98,7 @@
"prettier-plugin-sort-json": "^4.1.1",
"prettier-plugin-svelte": "^3.3.3",
"rollup-plugin-visualizer": "^6.0.0",
"svelte": "5.45.2",
"svelte": "5.43.3",
"svelte-check": "^4.1.5",
"svelte-eslint-parser": "^1.3.3",
"tailwindcss": "^4.1.7",

@ -10,7 +10,8 @@
import { AppRoute, AssetAction, ProjectionType } from '$lib/constants';
import { activityManager } from '$lib/managers/activity-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { preloadManager } from '$lib/managers/PreloadManager.svelte';
import { closeEditorCofirm } from '$lib/stores/asset-editor.store';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { ocrManager } from '$lib/stores/ocr.svelte';
@ -18,9 +19,11 @@
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
import { user } from '$lib/stores/user.store';
import { websocketEvents } from '$lib/stores/websocket';
import { getAssetJobMessage, getSharedLink, handlePromiseError } from '$lib/utils';
import { getAssetJobMessage, getAssetUrl, getSharedLink, handlePromiseError } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import { InvocationTracker } from '$lib/utils/invocationTracker';
import { SlideshowHistory } from '$lib/utils/slideshow-history';
import { preloadImageUrl } from '$lib/utils/sw-messaging';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import {
AssetJobName,
@ -50,11 +53,10 @@
import SlideshowBar from './slideshow-bar.svelte';
import VideoViewer from './video-wrapper-viewer.svelte';
type HasAsset = boolean;
interface Props {
asset: AssetResponseDto;
preloadAssets?: TimelineAsset[];
nextAsset?: AssetResponseDto;
previousAsset?: AssetResponseDto;
showNavigation?: boolean;
withStacked?: boolean;
isShared?: boolean;
@ -63,16 +65,16 @@
preAction?: PreAction | undefined;
onAction?: OnAction | undefined;
showCloseButton?: boolean;
onClose: (asset: AssetResponseDto) => void;
onNext: () => Promise<HasAsset>;
onPrevious: () => Promise<HasAsset>;
onRandom: () => Promise<{ id: string } | undefined>;
onClose?: (asset: AssetResponseDto) => void;
onNavigateToAsset?: (asset: AssetResponseDto | undefined) => Promise<boolean>;
onRandom?: () => Promise<{ id: string } | undefined>;
copyImage?: () => Promise<void>;
}
let {
asset = $bindable(),
preloadAssets = $bindable([]),
asset,
nextAsset,
previousAsset,
showNavigation = true,
withStacked = false,
isShared = false,
@ -82,8 +84,7 @@
onAction = undefined,
showCloseButton,
onClose,
onNext,
onPrevious,
onNavigateToAsset,
onRandom,
copyImage = $bindable(),
}: Props = $props();
@ -135,7 +136,7 @@
untrack(() => {
if (stack && stack?.assets.length > 1) {
preloadAssets.push(toTimelineAsset(stack.assets[1]));
preloadImageUrl(getAssetUrl({ asset: stack.assets[1] }));
}
});
};
@ -225,7 +226,7 @@
};
const closeViewer = () => {
onClose(asset);
onClose?.(asset);
};
const closeEditor = () => {
@ -234,7 +235,9 @@
});
};
const navigateAsset = async (order?: 'previous' | 'next', e?: Event) => {
const tracker = new InvocationTracker();
const navigateAsset = (order?: 'previous' | 'next', e?: Event) => {
if (!order) {
if ($slideshowState === SlideshowState.PlaySlideshow) {
order = $slideshowNavigation === SlideshowNavigation.AscendingOrder ? 'previous' : 'next';
@ -244,38 +247,39 @@
}
e?.stopPropagation();
preloadManager.cancel(asset);
if (tracker.isActive()) {
return;
}
let hasNext = false;
if ($slideshowState === SlideshowState.PlaySlideshow && $slideshowNavigation === SlideshowNavigation.Shuffle) {
hasNext = order === 'previous' ? slideshowHistory.previous() : slideshowHistory.next();
if (!hasNext) {
const asset = await onRandom();
if (asset) {
slideshowHistory.queue(asset);
hasNext = true;
void tracker.invoke(async () => {
let hasNext = false;
if ($slideshowState === SlideshowState.PlaySlideshow && $slideshowNavigation === SlideshowNavigation.Shuffle) {
hasNext = order === 'previous' ? slideshowHistory.previous() : slideshowHistory.next();
if (!hasNext) {
const asset = await onRandom?.();
if (asset) {
slideshowHistory.queue(asset);
hasNext = true;
}
}
} else if (onNavigateToAsset) {
hasNext = order === 'previous' ? await onNavigateToAsset(previousAsset) : await onNavigateToAsset(nextAsset);
} else {
hasNext = false;
}
} else {
hasNext = order === 'previous' ? await onPrevious() : await onNext();
}
if ($slideshowState === SlideshowState.PlaySlideshow) {
if (hasNext) {
$restartSlideshowProgress = true;
} else {
await handleStopSlideshow();
if ($slideshowState === SlideshowState.PlaySlideshow) {
if (hasNext) {
$restartSlideshowProgress = true;
} else {
await handleStopSlideshow();
}
}
}
});
};
// const showEditorHandler = () => {
// if (isShowActivity) {
// isShowActivity = false;
// }
// isShowEditor = !isShowEditor;
// };
const handleRunJob = async (name: AssetJobName) => {
try {
await runAssetJobs({ assetJobsDto: { assetIds: [asset.id], name } });
@ -378,12 +382,6 @@
let isFullScreen = $derived(fullscreenElement !== null);
$effect(() => {
if (asset) {
previewStackedAsset = undefined;
handlePromiseError(refreshStack());
}
});
$effect(() => {
if (album && !album.isActivityEnabled && activityManager.commentCount === 0) {
isShowActivity = false;
@ -395,13 +393,57 @@
}
});
// primarily, this is reactive on `asset`
let lastAsset: AssetResponseDto | undefined;
$effect(() => {
handlePromiseError(handleGetAllAlbums());
ocrManager.clear();
if (!sharedLink) {
handlePromiseError(ocrManager.getAssetOcr(asset.id));
const refresh = async () => {
await refreshStack();
await handleGetAllAlbums();
ocrManager.clear();
if (!sharedLink) {
if (previewStackedAsset) {
await ocrManager.getAssetOcr(previewStackedAsset.id);
}
await ocrManager.getAssetOcr(asset.id);
}
};
handlePromiseError(refresh());
if (lastAsset?.id !== asset.id) {
preloadManager.cancel(lastAsset);
}
lastAsset = asset;
});
$effect(() => {
preloadManager.preload(nextAsset);
preloadManager.preload(previousAsset);
});
$effect(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
asset.id;
if (viewerKind !== 'PhotoViewer') {
eventManager.emit('AssetViewerFree');
}
});
const viewerKind = $derived.by(() => {
if (previewStackedAsset) {
return asset.type === AssetTypeEnum.Image ? 'StackPhotoViewer' : 'StackVideoViewer';
}
if (asset.type === AssetTypeEnum.Image) {
if (shouldPlayMotionPhoto && asset.livePhotoVideoId) {
return 'LiveVideoViewer';
} else if (
asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR ||
(asset.originalPath && asset.originalPath.toLowerCase().endsWith('.insp'))
) {
return 'ImagePanaramaViewer';
} else if (isShowEditor && selectedEditType === 'crop') {
return 'CropArea';
}
return 'PhotoViewer';
}
return 'VideoViewer';
});
</script>
@ -459,7 +501,7 @@
</div>
{/if}
{#if $slideshowState === SlideshowState.None && showNavigation && !isShowEditor}
{#if $slideshowState === SlideshowState.None && showNavigation && !isShowEditor && previousAsset}
<div class="my-auto column-span-1 col-start-1 row-span-full row-start-1 justify-self-start">
<PreviousAssetAction onPreviousAsset={() => navigateAsset('previous')} />
</div>
@ -467,104 +509,91 @@
<!-- Asset Viewer -->
<div class="z-[-1] relative col-start-1 col-span-4 row-start-1 row-span-full">
{#if previewStackedAsset}
{#key previewStackedAsset.id}
{#if previewStackedAsset.type === AssetTypeEnum.Image}
<PhotoViewer
bind:zoomToggle
bind:copyImage
asset={previewStackedAsset}
{preloadAssets}
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
haveFadeTransition={false}
{sharedLink}
/>
{:else}
<VideoViewer
assetId={previewStackedAsset.id}
cacheKey={previewStackedAsset.thumbhash}
projectionType={previewStackedAsset.exifInfo?.projectionType}
loopVideo={true}
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
onClose={closeViewer}
onVideoEnded={() => navigateAsset()}
onVideoStarted={handleVideoStarted}
{playOriginalVideo}
/>
{/if}
{/key}
{:else}
{#key asset.id}
{#if asset.type === AssetTypeEnum.Image}
{#if shouldPlayMotionPhoto && asset.livePhotoVideoId}
<VideoViewer
assetId={asset.livePhotoVideoId}
cacheKey={asset.thumbhash}
projectionType={asset.exifInfo?.projectionType}
loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
onVideoEnded={() => (shouldPlayMotionPhoto = false)}
{playOriginalVideo}
/>
{:else if asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR || (asset.originalPath && asset.originalPath
.toLowerCase()
.endsWith('.insp'))}
<ImagePanoramaViewer bind:zoomToggle {asset} />
{:else if isShowEditor && selectedEditType === 'crop'}
<CropArea {asset} />
{:else}
<PhotoViewer
bind:zoomToggle
bind:copyImage
{asset}
{preloadAssets}
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
{sharedLink}
haveFadeTransition={$slideshowState !== SlideshowState.None && $slideshowTransition}
/>
{/if}
{:else}
<VideoViewer
assetId={asset.id}
cacheKey={asset.thumbhash}
projectionType={asset.exifInfo?.projectionType}
loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
onClose={closeViewer}
onVideoEnded={() => navigateAsset()}
onVideoStarted={handleVideoStarted}
{playOriginalVideo}
/>
{/if}
{#if $slideshowState === SlideshowState.None && isShared && ((album && album.isActivityEnabled) || activityManager.commentCount > 0) && !activityManager.isLoading}
<div class="absolute bottom-0 end-0 mb-20 me-8">
<ActivityStatus
disabled={!album?.isActivityEnabled}
isLiked={activityManager.isLiked}
numberOfComments={activityManager.commentCount}
numberOfLikes={activityManager.likeCount}
onFavorite={handleFavorite}
onOpenActivityTab={handleOpenActivity}
/>
</div>
{/if}
{#if viewerKind === 'StackPhotoViewer'}
<PhotoViewer
bind:zoomToggle
bind:copyImage
asset={previewStackedAsset!}
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
haveFadeTransition={false}
{sharedLink}
/>
{:else if viewerKind === 'StackVideoViewer'}
<VideoViewer
assetId={previewStackedAsset!.id}
cacheKey={previewStackedAsset!.thumbhash}
projectionType={previewStackedAsset!.exifInfo?.projectionType}
loopVideo={true}
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
onClose={closeViewer}
onVideoEnded={() => navigateAsset()}
onVideoStarted={handleVideoStarted}
{playOriginalVideo}
/>
{:else if viewerKind === 'LiveVideoViewer'}
<VideoViewer
assetId={asset.livePhotoVideoId!}
cacheKey={asset.thumbhash}
projectionType={asset.exifInfo?.projectionType}
loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
onVideoEnded={() => (shouldPlayMotionPhoto = false)}
{playOriginalVideo}
/>
{:else if viewerKind === 'ImagePanaramaViewer'}
<ImagePanoramaViewer bind:zoomToggle {asset} />
{:else if viewerKind === 'CropArea'}
<CropArea {asset} />
{:else if viewerKind === 'PhotoViewer'}
<PhotoViewer
bind:zoomToggle
bind:copyImage
{asset}
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
{sharedLink}
haveFadeTransition={$slideshowState !== SlideshowState.None && $slideshowTransition}
onFree={() => eventManager.emit('AssetViewerFree')}
/>
{:else if viewerKind === 'VideoViewer'}
<VideoViewer
assetId={asset.id}
cacheKey={asset.thumbhash}
projectionType={asset.exifInfo?.projectionType}
loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
onClose={closeViewer}
onVideoEnded={() => navigateAsset()}
onVideoStarted={handleVideoStarted}
{playOriginalVideo}
/>
{/if}
{#if $slideshowState === SlideshowState.None && asset.type === AssetTypeEnum.Image && !isShowEditor && ocrManager.hasOcrData}
<div class="absolute bottom-0 end-0 mb-6 me-6">
<OcrButton />
</div>
{/if}
{/key}
{#if $slideshowState === SlideshowState.None && isShared && ((album && album.isActivityEnabled) || activityManager.commentCount > 0) && !activityManager.isLoading}
<div class="absolute bottom-0 end-0 mb-20 me-8">
<ActivityStatus
disabled={!album?.isActivityEnabled}
isLiked={activityManager.isLiked}
numberOfComments={activityManager.commentCount}
numberOfLikes={activityManager.likeCount}
onFavorite={handleFavorite}
onOpenActivityTab={handleOpenActivity}
/>
</div>
{/if}
{#if $slideshowState === SlideshowState.None && asset.type === AssetTypeEnum.Image && !isShowEditor && ocrManager.hasOcrData}
<div class="absolute bottom-0 end-0 mb-6 me-6">
<OcrButton />
</div>
{/if}
</div>
{#if $slideshowState === SlideshowState.None && showNavigation && !isShowEditor}
{#if $slideshowState === SlideshowState.None && showNavigation && !isShowEditor && nextAsset}
<div class="my-auto col-span-1 col-start-4 row-span-full row-start-1 justify-self-end">
<NextAssetAction onNextAsset={() => navigateAsset('next')} />
</div>

@ -1,7 +1,7 @@
import { getAnimateMock } from '$lib/__mocks__/animate.mock';
import PhotoViewer from '$lib/components/asset-viewer/photo-viewer.svelte';
import * as utils from '$lib/utils';
import { AssetMediaSize, AssetTypeEnum } from '@immich/sdk';
import { AssetTypeEnum } from '@immich/sdk';
import { assetFactory } from '@test-data/factories/asset-factory';
import { sharedLinkFactory } from '@test-data/factories/shared-link-factory';
import { render } from '@testing-library/svelte';
@ -19,18 +19,15 @@ vi.mock('$lib/utils', async (originalImport) => {
const meta = await originalImport<typeof import('$lib/utils')>();
return {
...meta,
getAssetOriginalUrl: vi.fn(),
getAssetThumbnailUrl: vi.fn(),
getAssetUrl: vi.fn(),
};
});
describe('PhotoViewer component', () => {
let getAssetOriginalUrlSpy: MockInstance;
let getAssetThumbnailUrlSpy: MockInstance;
let getAssetUrlSpy: MockInstance;
beforeAll(() => {
getAssetOriginalUrlSpy = vi.spyOn(utils, 'getAssetOriginalUrl');
getAssetThumbnailUrlSpy = vi.spyOn(utils, 'getAssetThumbnailUrl');
getAssetUrlSpy = vi.spyOn(utils, 'getAssetUrl');
vi.stubGlobal('cast', {
framework: {
@ -72,12 +69,11 @@ describe('PhotoViewer component', () => {
});
render(PhotoViewer, { asset });
expect(getAssetThumbnailUrlSpy).toBeCalledWith({
id: asset.id,
size: AssetMediaSize.Preview,
cacheKey: asset.thumbhash,
expect(getAssetUrlSpy).toBeCalledWith({
asset,
sharedLink: undefined,
forceOriginal: false,
});
expect(getAssetOriginalUrlSpy).not.toBeCalled();
});
it('loads the thumbnail image for static gifs', () => {
@ -88,12 +84,11 @@ describe('PhotoViewer component', () => {
});
render(PhotoViewer, { asset });
expect(getAssetThumbnailUrlSpy).toBeCalledWith({
id: asset.id,
size: AssetMediaSize.Preview,
cacheKey: asset.thumbhash,
expect(getAssetUrlSpy).toBeCalledWith({
asset,
sharedLink: undefined,
forceOriginal: false,
});
expect(getAssetOriginalUrlSpy).not.toBeCalled();
});
it('loads the thumbnail image for static webp images', () => {
@ -104,12 +99,11 @@ describe('PhotoViewer component', () => {
});
render(PhotoViewer, { asset });
expect(getAssetThumbnailUrlSpy).toBeCalledWith({
id: asset.id,
size: AssetMediaSize.Preview,
cacheKey: asset.thumbhash,
expect(getAssetUrlSpy).toBeCalledWith({
asset,
sharedLink: undefined,
forceOriginal: false,
});
expect(getAssetOriginalUrlSpy).not.toBeCalled();
});
it('loads the original image for animated gifs', () => {
@ -121,8 +115,11 @@ describe('PhotoViewer component', () => {
});
render(PhotoViewer, { asset });
expect(getAssetThumbnailUrlSpy).not.toBeCalled();
expect(getAssetOriginalUrlSpy).toBeCalledWith({ id: asset.id, cacheKey: asset.thumbhash });
expect(getAssetUrlSpy).toBeCalledWith({
asset,
sharedLink: undefined,
forceOriginal: false,
});
});
it('loads the original image for animated webp images', () => {
@ -134,8 +131,11 @@ describe('PhotoViewer component', () => {
});
render(PhotoViewer, { asset });
expect(getAssetThumbnailUrlSpy).not.toBeCalled();
expect(getAssetOriginalUrlSpy).toBeCalledWith({ id: asset.id, cacheKey: asset.thumbhash });
expect(getAssetUrlSpy).toBeCalledWith({
asset,
sharedLink: undefined,
forceOriginal: false,
});
});
it('not loads original static image in shared link even when download permission is true and showMetadata permission is true', () => {
@ -147,13 +147,11 @@ describe('PhotoViewer component', () => {
const sharedLink = sharedLinkFactory.build({ allowDownload: true, showMetadata: true, assets: [asset] });
render(PhotoViewer, { asset, sharedLink });
expect(getAssetThumbnailUrlSpy).toBeCalledWith({
id: asset.id,
size: AssetMediaSize.Preview,
cacheKey: asset.thumbhash,
expect(getAssetUrlSpy).toBeCalledWith({
asset,
sharedLink,
forceOriginal: false,
});
expect(getAssetOriginalUrlSpy).not.toBeCalled();
});
it('loads original animated image in shared link when download permission is true and showMetadata permission is true', () => {
@ -166,8 +164,11 @@ describe('PhotoViewer component', () => {
const sharedLink = sharedLinkFactory.build({ allowDownload: true, showMetadata: true, assets: [asset] });
render(PhotoViewer, { asset, sharedLink });
expect(getAssetThumbnailUrlSpy).not.toBeCalled();
expect(getAssetOriginalUrlSpy).toBeCalledWith({ id: asset.id, cacheKey: asset.thumbhash });
expect(getAssetUrlSpy).toBeCalledWith({
asset,
sharedLink,
forceOriginal: false,
});
});
it('not loads original animated image when shared link download permission is false', () => {
@ -180,13 +181,11 @@ describe('PhotoViewer component', () => {
const sharedLink = sharedLinkFactory.build({ allowDownload: false, assets: [asset] });
render(PhotoViewer, { asset, sharedLink });
expect(getAssetThumbnailUrlSpy).toBeCalledWith({
id: asset.id,
size: AssetMediaSize.Preview,
cacheKey: asset.thumbhash,
expect(getAssetUrlSpy).toBeCalledWith({
asset,
sharedLink,
forceOriginal: false,
});
expect(getAssetOriginalUrlSpy).not.toBeCalled();
});
it('not loads original animated image when shared link showMetadata permission is false', () => {
@ -199,12 +198,10 @@ describe('PhotoViewer component', () => {
const sharedLink = sharedLinkFactory.build({ showMetadata: false, assets: [asset] });
render(PhotoViewer, { asset, sharedLink });
expect(getAssetThumbnailUrlSpy).toBeCalledWith({
id: asset.id,
size: AssetMediaSize.Preview,
cacheKey: asset.thumbhash,
expect(getAssetUrlSpy).toBeCalledWith({
asset,
sharedLink,
forceOriginal: false,
});
expect(getAssetOriginalUrlSpy).not.toBeCalled();
});
});

@ -6,56 +6,59 @@
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
import { assetViewerFadeDuration } from '$lib/constants';
import { castManager } from '$lib/managers/cast-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { preloadManager } from '$lib/managers/PreloadManager.svelte';
import { photoViewerImgElement } from '$lib/stores/assets-store.svelte';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { ocrManager } from '$lib/stores/ocr.svelte';
import { boundingBoxesArray } from '$lib/stores/people.store';
import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store';
import { SlideshowLook, SlideshowState, slideshowLookCssMapping, slideshowStore } from '$lib/stores/slideshow.store';
import { photoZoomState } from '$lib/stores/zoom-image.store';
import { getAssetOriginalUrl, getAssetThumbnailUrl, handlePromiseError } from '$lib/utils';
import { canCopyImageToClipboard, copyImageToClipboard, isWebCompatibleImage } from '$lib/utils/asset-utils';
import { getAssetUrl, targetImageSize as getTargetImageSize, handlePromiseError } from '$lib/utils';
import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
import { getOcrBoundingBoxes } from '$lib/utils/ocr-utils';
import { getBoundingBox } from '$lib/utils/people-utils';
import { cancelImageUrl } from '$lib/utils/sw-messaging';
import { getAltText } from '$lib/utils/thumbnail-util';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { AssetMediaSize, AssetTypeEnum, type AssetResponseDto, type SharedLinkResponseDto } from '@immich/sdk';
import { AssetMediaSize, type AssetResponseDto, type SharedLinkResponseDto } from '@immich/sdk';
import { LoadingSpinner, toastManager } from '@immich/ui';
import { onDestroy, onMount } from 'svelte';
import { onDestroy, onMount, untrack } from 'svelte';
import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
interface Props {
asset: AssetResponseDto;
preloadAssets?: TimelineAsset[] | undefined;
element?: HTMLDivElement | undefined;
haveFadeTransition?: boolean;
sharedLink?: SharedLinkResponseDto | undefined;
onPreviousAsset?: (() => void) | null;
onNextAsset?: (() => void) | null;
onLoad?: (() => void) | null;
onError?: (() => void) | null;
onBusy?: (() => void) | null;
onFree?: (() => void) | null;
copyImage?: () => Promise<void>;
zoomToggle?: (() => void) | null;
}
let {
asset,
preloadAssets = undefined,
element = $bindable(),
haveFadeTransition = true,
sharedLink = undefined,
onPreviousAsset = null,
onNextAsset = null,
onLoad,
onError,
onBusy,
onFree,
copyImage = $bindable(),
zoomToggle = $bindable(),
}: Props = $props();
const { slideshowState, slideshowLook } = slideshowStore;
let assetFileUrl: string = $state('');
let imageLoaded: boolean = $state(false);
let originalImageLoaded: boolean = $state(false);
let imageError: boolean = $state(false);
@ -82,25 +85,6 @@
let isOcrActive = $derived(ocrManager.showOverlay);
const preload = (targetSize: AssetMediaSize | 'original', preloadAssets?: TimelineAsset[]) => {
for (const preloadAsset of preloadAssets || []) {
if (preloadAsset.isImage) {
let img = new Image();
img.src = getAssetUrl(preloadAsset.id, targetSize, preloadAsset.thumbhash);
}
}
};
const getAssetUrl = (id: string, targetSize: AssetMediaSize | 'original', cacheKey: string | null) => {
if (sharedLink && (!sharedLink.allowDownload || !sharedLink.showMetadata)) {
return getAssetThumbnailUrl({ id, size: AssetMediaSize.Preview, cacheKey });
}
return targetSize === 'original'
? getAssetOriginalUrl({ id, cacheKey })
: getAssetThumbnailUrl({ id, size: targetSize, cacheKey });
};
copyImage = async () => {
if (!canCopyImageToClipboard() || !$photoViewerImgElement) {
return;
@ -155,23 +139,11 @@
}
};
// when true, will force loading of the original image
let forceUseOriginal: boolean = $derived(
(asset.type === AssetTypeEnum.Image && asset.duration && !asset.duration.includes('0:00:00.000')) ||
$photoZoomState.currentZoom > 1,
);
const targetImageSize = $derived.by(() => {
if ($alwaysLoadOriginalFile || forceUseOriginal || originalImageLoaded) {
return isWebCompatibleImage(asset) ? 'original' : AssetMediaSize.Fullsize;
}
return AssetMediaSize.Preview;
});
const targetImageSize = $derived(getTargetImageSize(asset, originalImageLoaded || $photoZoomState.currentZoom > 1));
$effect(() => {
if (assetFileUrl) {
void cast(assetFileUrl);
if (imageLoaderUrl) {
void cast(imageLoaderUrl);
}
});
@ -190,36 +162,50 @@
};
const onload = () => {
onLoad?.();
onFree?.();
imageLoaded = true;
assetFileUrl = imageLoaderUrl;
originalImageLoaded = targetImageSize === AssetMediaSize.Fullsize || targetImageSize === 'original';
};
const onerror = () => {
onError?.();
onFree?.();
imageError = imageLoaded = true;
};
$effect(() => {
preload(targetImageSize, preloadAssets);
});
onMount(() => {
if (loader?.complete) {
onload();
}
loader?.addEventListener('load', onload, { passive: true });
loader?.addEventListener('error', onerror, { passive: true });
return () => {
loader?.removeEventListener('load', onload);
loader?.removeEventListener('error', onerror);
cancelImageUrl(imageLoaderUrl);
if (!imageLoaded && !imageError) {
onFree?.();
}
preloadManager.cancelPreloadUrl(imageLoaderUrl);
};
});
let imageLoaderUrl = $derived(getAssetUrl(asset.id, targetImageSize, asset.thumbhash));
let imageLoaderUrl = $derived(
getAssetUrl({ asset, sharedLink, forceOriginal: originalImageLoaded || $photoZoomState.currentZoom > 1 }),
);
let containerWidth = $state(0);
let containerHeight = $state(0);
let lastUrl: string | undefined;
$effect(() => {
if (!lastUrl) {
untrack(() => onBusy?.());
}
if (lastUrl && lastUrl !== imageLoaderUrl) {
untrack(() => {
imageLoaded = false;
originalImageLoaded = false;
imageError = false;
onBusy?.();
});
}
lastUrl = imageLoaderUrl;
});
</script>
<svelte:document
@ -236,15 +222,13 @@
<BrokenAsset class="text-xl h-full w-full" />
</div>
{/if}
<!-- svelte-ignore a11y_missing_attribute -->
<img bind:this={loader} style="display:none" src={imageLoaderUrl} aria-hidden="true" />
<img bind:this={loader} style="display:none" src={imageLoaderUrl} alt="" aria-hidden="true" {onload} {onerror} />
<div
bind:this={element}
class="relative h-full select-none"
bind:clientWidth={containerWidth}
bind:clientHeight={containerHeight}
>
<img style="display:none" src={imageLoaderUrl} alt="" {onload} {onerror} />
{#if !imageLoaded}
<div id="spinner" class="flex h-full items-center justify-center">
<LoadingSpinner />
@ -258,7 +242,7 @@
>
{#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground}
<img
src={assetFileUrl}
src={imageLoaderUrl}
alt=""
class="-z-1 absolute top-0 start-0 object-cover h-full w-full blur-lg"
draggable="false"
@ -266,7 +250,7 @@
{/if}
<img
bind:this={$photoViewerImgElement}
src={assetFileUrl}
src={imageLoaderUrl}
alt={$getAltText(toTimelineAsset(asset))}
class="h-full w-full {$slideshowState === SlideshowState.None
? 'object-contain'

@ -26,13 +26,13 @@
import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { type MemoryAsset, memoryStore } from '$lib/stores/memory.store.svelte';
import { memoryStore, type MemoryAsset } from '$lib/stores/memory.store.svelte';
import { locale, videoViewerMuted, videoViewerVolume } from '$lib/stores/preferences.store';
import { preferences } from '$lib/stores/user.store';
import { getAssetThumbnailUrl, handlePromiseError, memoryLaneTitle } from '$lib/utils';
import { cancelMultiselect } from '$lib/utils/asset-utils';
import { fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util';
import { AssetMediaSize, getAssetInfo } from '@immich/sdk';
import { AssetMediaSize, AssetTypeEnum, getAssetInfo } from '@immich/sdk';
import { IconButton, toastManager } from '@immich/ui';
import {
mdiCardsOutline,
@ -67,7 +67,7 @@
let currentMemoryAssetFull = $derived.by(async () =>
current?.asset ? await getAssetInfo({ ...authManager.params, id: current.asset.id }) : undefined,
);
let currentTimelineAssets = $derived(current?.memory.assets.map((asset) => toTimelineAsset(asset)) || []);
let currentTimelineAssets = $derived(current?.memory.assets || []);
let isSaved = $derived(current?.memory.isSaved);
let viewerHeight = $state(0);
@ -396,7 +396,7 @@
</p>
</div>
{#if currentTimelineAssets.some(({ isVideo }) => isVideo)}
{#if currentTimelineAssets.some(({ type }) => type === AssetTypeEnum.Video)}
<div class="w-12.5 dark">
<IconButton
shape="round"
@ -651,8 +651,6 @@
bind:this={memoryGallery}
>
<GalleryViewer
onNext={handleNextAsset}
onPrevious={handlePreviousAsset}
assets={currentTimelineAssets}
viewport={galleryViewport}
{assetInteraction}

@ -31,7 +31,7 @@
const viewport: Viewport = $state({ width: 0, height: 0 });
const assetInteraction = new AssetInteraction();
let assets = $derived(sharedLink.assets.map((a) => toTimelineAsset(a)));
let assets = $derived(sharedLink.assets);
dragAndDropFilesStore.subscribe((value) => {
if (value.isDragging && value.files.length > 0) {
@ -67,7 +67,7 @@
};
const handleSelectAll = () => {
assetInteraction.selectAssets(assets);
assetInteraction.selectAssets(assets.map((asset) => toTimelineAsset(asset)));
};
const handleAction = async (action: Action) => {
@ -143,15 +143,7 @@
{:else if assets.length === 1}
{#await getAssetInfo({ ...authManager.params, id: assets[0].id }) then asset}
{#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
<AssetViewer
{asset}
showCloseButton={false}
onAction={handleAction}
onPrevious={() => Promise.resolve(false)}
onNext={() => Promise.resolve(false)}
onRandom={() => Promise.resolve(undefined)}
onClose={() => {}}
/>
<AssetViewer {asset} showCloseButton={false} onAction={handleAction} />
{/await}
{/await}
{/if}

@ -27,7 +27,7 @@
interface Props {
initialAssetId?: string;
assets: TimelineAsset[] | AssetResponseDto[];
assets: AssetResponseDto[];
assetInteraction: AssetInteraction;
disableAssetSelect?: boolean;
showArchiveIcon?: boolean;
@ -35,9 +35,7 @@
onIntersected?: (() => void) | undefined;
showAssetName?: boolean;
isShowDeleteConfirmation?: boolean;
onPrevious?: (() => Promise<{ id: string } | undefined>) | undefined;
onNext?: (() => Promise<{ id: string } | undefined>) | undefined;
onRandom?: (() => Promise<{ id: string } | undefined>) | undefined;
onNavigateToAsset?: (asset: AssetResponseDto | undefined) => Promise<boolean>;
onReload?: (() => void) | undefined;
pageHeaderOffset?: number;
slidingWindowOffset?: number;
@ -54,9 +52,7 @@
onIntersected = undefined,
showAssetName = false,
isShowDeleteConfirmation = $bindable(false),
onPrevious = undefined,
onNext = undefined,
onRandom = undefined,
onNavigateToAsset,
onReload = undefined,
slidingWindowOffset = 0,
pageHeaderOffset = 0,
@ -86,7 +82,7 @@
return top + pageHeaderOffset < window.bottom && top + geo.getHeight(i) > window.top;
};
let currentIndex = 0;
let currentIndex = $state(0);
if (initialAssetId && assets.length > 0) {
const index = assets.findIndex(({ id }) => id === initialAssetId);
if (index !== -1) {
@ -229,7 +225,7 @@
isShowDeleteConfirmation = false;
await deleteAssets(
!(isTrashEnabled && !force),
(assetIds) => (assets = assets.filter((asset) => !assetIds.includes(asset.id)) as TimelineAsset[]),
(assetIds) => (assets = assets.filter((asset) => !assetIds.includes(asset.id))),
assetInteraction.selectedAssets,
onReload,
);
@ -242,7 +238,7 @@
assetInteraction.isAllArchived ? AssetVisibility.Timeline : AssetVisibility.Archive,
);
if (ids) {
assets = assets.filter((asset) => !ids.includes(asset.id)) as TimelineAsset[];
assets = assets.filter((asset) => !ids.includes(asset.id));
deselectAllAssets();
}
};
@ -295,48 +291,29 @@
})(),
);
const handleNext = async (): Promise<boolean> => {
try {
let asset: { id: string } | undefined;
if (onNext) {
asset = await onNext();
} else {
if (currentIndex >= assets.length - 1) {
return false;
}
currentIndex = currentIndex + 1;
asset = currentIndex < assets.length ? assets[currentIndex] : undefined;
}
if (!asset) {
return false;
}
const nextAsset = $derived.by(() => {
if (currentIndex >= assets.length - 1) {
return undefined;
}
return currentIndex + 1 < assets.length ? assets[currentIndex + 1] : undefined;
});
await navigateToAsset(asset);
return true;
} catch (error) {
handleError(error, $t('errors.cannot_navigate_next_asset'));
return false;
const previousAsset = $derived.by(() => {
if (currentIndex <= 0) {
return undefined;
}
};
return currentIndex - 1 >= 0 ? assets[currentIndex - 1] : undefined;
});
const handleRandom = async (): Promise<{ id: string } | undefined> => {
try {
let asset: { id: string } | undefined;
if (onRandom) {
asset = await onRandom();
} else {
if (assets.length > 0) {
const randomIndex = Math.floor(Math.random() * assets.length);
asset = assets[randomIndex];
}
}
if (!asset) {
if (assets.length === 0) {
return;
}
const randomIndex = Math.floor(Math.random() * assets.length);
const asset = assets[randomIndex];
await navigateToAsset(asset);
return asset;
} catch (error) {
@ -345,30 +322,13 @@
}
};
const handlePrevious = async (): Promise<boolean> => {
try {
let asset: { id: string } | undefined;
if (onPrevious) {
asset = await onPrevious();
} else {
if (currentIndex <= 0) {
return false;
}
currentIndex = currentIndex - 1;
asset = currentIndex >= 0 ? assets[currentIndex] : undefined;
}
if (!asset) {
return false;
}
await navigateToAsset(asset);
const handleNavigateToAsset = async (target: AssetResponseDto | undefined) => {
if (target) {
currentIndex = assets.indexOf(target);
await (onNavigateToAsset ? onNavigateToAsset(target) : navigateToAsset(target));
return true;
} catch (error) {
handleError(error, $t('errors.cannot_navigate_previous_asset'));
return false;
}
return false;
};
const navigateToAsset = async (asset?: { id: string }) => {
@ -390,9 +350,9 @@
if (assets.length === 0) {
await goto(AppRoute.PHOTOS);
} else if (currentIndex === assets.length) {
await handlePrevious();
await handleNavigateToAsset(previousAsset);
} else {
await setAssetId(assets[currentIndex].id);
await handleNavigateToAsset(nextAsset);
}
break;
}
@ -489,9 +449,10 @@
{#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
<AssetViewer
asset={$viewingAsset}
{nextAsset}
{previousAsset}
onAction={handleAction}
onPrevious={handlePrevious}
onNext={handleNext}
onNavigateToAsset={handleNavigateToAsset}
onRandom={handleRandom}
onClose={() => {
assetViewingStore.showAssetViewer(false);

@ -2,14 +2,16 @@
import type { Action } from '$lib/components/asset-viewer/actions/action';
import { AssetAction } from '$lib/constants';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { updateStackedAssetInTimeline, updateUnstackedAssetInTimeline } from '$lib/utils/actions';
import { navigate } from '$lib/utils/navigation';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { getAssetInfo, type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk';
import { getAssetInfo, type AlbumResponseDto, type AssetResponseDto, type PersonResponseDto } from '@immich/sdk';
import { untrack } from 'svelte';
let { asset: viewingAsset, gridScrollTarget, mutex, preloadAssets } = assetViewingStore;
let { asset: viewingAsset, gridScrollTarget } = assetViewingStore;
interface Props {
timelineManager: TimelineManager;
@ -38,44 +40,65 @@
person = null,
}: Props = $props();
const handlePrevious = async () => {
const release = await mutex.acquire();
const laterAsset = await timelineManager.getLaterAsset($viewingAsset);
const getNextAsset = async (currentAsset: AssetResponseDto) => {
const earlierTimelineAsset = await timelineManager.getEarlierAsset(currentAsset);
if (earlierTimelineAsset) {
const asset = await getAssetInfo({ ...authManager.params, id: earlierTimelineAsset.id });
return asset;
}
};
if (laterAsset) {
const preloadAsset = await timelineManager.getLaterAsset(laterAsset);
const asset = await getAssetInfo({ ...authManager.params, id: laterAsset.id });
assetViewingStore.setAsset(asset, preloadAsset ? [preloadAsset] : []);
await navigate({ targetRoute: 'current', assetId: laterAsset.id });
const getPreviousAsset = async (currentAsset: AssetResponseDto) => {
const laterTimelineAsset = await timelineManager.getLaterAsset(currentAsset);
if (laterTimelineAsset) {
const asset = await getAssetInfo({ ...authManager.params, id: laterTimelineAsset.id });
return asset;
}
};
release();
return !!laterAsset;
let assetCursor = $state<{
previousAsset: AssetResponseDto | undefined;
current: AssetResponseDto;
nextAsset: AssetResponseDto | undefined;
}>({
current: $viewingAsset,
previousAsset: undefined,
nextAsset: undefined,
});
const loadCloseAssets = async (currentAsset: AssetResponseDto) => {
const [nextAsset, previousAsset] = await Promise.all([getNextAsset(currentAsset), getPreviousAsset(currentAsset)]);
assetCursor = {
current: currentAsset,
nextAsset,
previousAsset,
};
};
const handleNext = async () => {
const release = await mutex.acquire();
const earlierAsset = await timelineManager.getEarlierAsset($viewingAsset);
//TODO: replace this with async derived in svelte 6
$effect(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
$viewingAsset;
untrack(() => void loadCloseAssets($viewingAsset));
});
if (earlierAsset) {
const preloadAsset = await timelineManager.getEarlierAsset(earlierAsset);
const asset = await getAssetInfo({ ...authManager.params, id: earlierAsset.id });
assetViewingStore.setAsset(asset, preloadAsset ? [preloadAsset] : []);
await navigate({ targetRoute: 'current', assetId: earlierAsset.id });
const handleNavigateToAsset = async (targetAsset: AssetResponseDto | undefined) => {
if (!targetAsset) {
return false;
}
release();
return !!earlierAsset;
let waitForAssetViewerFree = new Promise<void>((resolve) => {
eventManager.once('AssetViewerFree', () => resolve());
});
await navigate({ targetRoute: 'current', assetId: targetAsset.id });
await waitForAssetViewerFree;
return true;
};
const handleRandom = async () => {
const randomAsset = await timelineManager.getRandomAsset();
if (randomAsset) {
const asset = await getAssetInfo({ ...authManager.params, id: randomAsset.id });
assetViewingStore.setAsset(asset);
await navigate({ targetRoute: 'current', assetId: randomAsset.id });
return asset;
return { id: randomAsset.id };
}
};
@ -97,7 +120,9 @@
case AssetAction.SET_VISIBILITY_TIMELINE: {
// find the next asset to show or close the viewer
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
(await handleNext()) || (await handlePrevious()) || (await handleClose(action.asset));
(await handleNavigateToAsset(assetCursor?.nextAsset)) ||
(await handleNavigateToAsset(assetCursor?.previousAsset)) ||
(await handleClose(action.asset));
// delete after find the next one
timelineManager.removeAssets([action.asset.id]);
@ -168,15 +193,15 @@
{#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
<AssetViewer
{withStacked}
asset={$viewingAsset}
preloadAssets={$preloadAssets}
asset={assetCursor.current}
nextAsset={assetCursor.nextAsset}
previousAsset={assetCursor.previousAsset}
{isShared}
{album}
{person}
preAction={handlePreAction}
onAction={handleAction}
onPrevious={handlePrevious}
onNext={handleNext}
onNavigateToAsset={handleNavigateToAsset}
onRandom={handleRandom}
onClose={handleClose}
/>

@ -10,7 +10,7 @@
import { getAssetInfo, type AssetResponseDto } from '@immich/sdk';
import { Button } from '@immich/ui';
import { mdiCheck, mdiImageMultipleOutline, mdiTrashCanOutline } from '@mdi/js';
import { onDestroy, onMount } from 'svelte';
import { onDestroy, onMount, untrack } from 'svelte';
import { t } from 'svelte-i18n';
import { SvelteSet } from 'svelte/reactivity';
@ -43,24 +43,55 @@
assetViewingStore.showAssetViewer(false);
});
const onNext = async () => {
const index = getAssetIndex($viewingAsset.id) + 1;
if (index >= assets.length) {
const handleNavigateToAsset = async (asset: AssetResponseDto | undefined) => {
if (!asset) {
return false;
}
await onViewAsset(assets[index]);
await onViewAsset(asset);
return true;
};
const onPrevious = async () => {
const index = getAssetIndex($viewingAsset.id) - 1;
const getPreviousAsset = (currentAsset: AssetResponseDto) => {
const index = getAssetIndex(currentAsset.id) - 1;
if (index < 0) {
return false;
return undefined;
}
await onViewAsset(assets[index]);
return true;
return assets[index];
};
const getNextAsset = (currentAsset: AssetResponseDto) => {
const index = getAssetIndex(currentAsset.id) + 1;
if (index >= assets.length) {
return undefined;
}
return assets[index];
};
let assetCursor = $state<{
previousAsset: AssetResponseDto | undefined;
current: AssetResponseDto;
nextAsset: AssetResponseDto | undefined;
}>({
current: $viewingAsset,
previousAsset: undefined,
nextAsset: undefined,
});
const loadCloseAssets = (currentAsset: AssetResponseDto) => {
assetCursor = {
current: currentAsset,
nextAsset: getNextAsset(currentAsset),
previousAsset: getPreviousAsset(currentAsset),
};
};
//TODO: replace this with async derived in svelte 6
$effect(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
$viewingAsset;
untrack(() => void loadCloseAssets($viewingAsset));
});
const onRandom = async () => {
if (assets.length <= 0) {
return;
@ -182,10 +213,11 @@
{#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
<Portal target="body">
<AssetViewer
asset={$viewingAsset}
asset={assetCursor.current}
nextAsset={assetCursor.nextAsset}
previousAsset={assetCursor.previousAsset}
showNavigation={assets.length > 1}
{onNext}
{onPrevious}
onNavigateToAsset={handleNavigateToAsset}
{onRandom}
onClose={() => {
assetViewingStore.showAssetViewer(false);

@ -43,6 +43,8 @@ export type Events = {
LibraryDelete: [{ id: string }];
ReleaseEvent: [ReleaseEvent];
AssetViewerFree: [];
};
type Listener<EventMap extends Record<string, unknown[]>, K extends keyof EventMap> = (...params: EventMap[K]) => void;

@ -346,7 +346,7 @@ export class TimelineManager extends VirtualScrollManager {
async findMonthGroupForAsset(asset: AssetDescriptor | AssetResponseDto) {
if (!this.isInitialized) {
await this.initTask.waitUntilCompletion();
await this.initTask.waitUntilExecution();
}
const { id } = asset;

@ -1,19 +1,14 @@
import { authManager } from '$lib/managers/auth-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { type AssetGridRouteSearchParams } from '$lib/utils/navigation';
import { getAssetInfo, type AssetResponseDto } from '@immich/sdk';
import { Mutex } from 'async-mutex';
import { readonly, writable } from 'svelte/store';
function createAssetViewingStore() {
const viewingAssetStoreState = writable<AssetResponseDto>();
const preloadAssets = writable<TimelineAsset[]>([]);
const viewState = writable<boolean>(false);
const viewingAssetMutex = new Mutex();
const gridScrollTarget = writable<AssetGridRouteSearchParams | null | undefined>();
const setAsset = (asset: AssetResponseDto, assetsToPreload: TimelineAsset[] = []) => {
preloadAssets.set(assetsToPreload);
const setAsset = (asset: AssetResponseDto) => {
viewingAssetStoreState.set(asset);
viewState.set(true);
};
@ -30,8 +25,6 @@ function createAssetViewingStore() {
return {
asset: readonly(viewingAssetStoreState),
mutex: viewingAssetMutex,
preloadAssets: readonly(preloadAssets),
isViewing: viewState,
gridScrollTarget,
setAsset,

@ -50,4 +50,13 @@ export class InvocationTracker {
isActive() {
return this.invocationsStarted !== this.invocationsEnded;
}
async invoke<T>(invocable: () => Promise<T>) {
const invocation = this.startInvocation();
try {
return await invocable();
} finally {
invocation.endInvocation();
}
}
}

@ -3,13 +3,15 @@
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import { AppRoute, timeToLoadTheMap } from '$lib/constants';
import Portal from '$lib/elements/Portal.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { handlePromiseError } from '$lib/utils';
import { delay } from '$lib/utils/asset-utils';
import { navigate } from '$lib/utils/navigation';
import { getAssetInfo, type AssetResponseDto } from '@immich/sdk';
import { LoadingSpinner } from '@immich/ui';
import { onDestroy } from 'svelte';
import { onDestroy, untrack } from 'svelte';
import type { PageData } from './$types';
interface Props {
@ -21,7 +23,6 @@
let { isViewing: showAssetViewer, asset: viewingAsset, setAssetId } = assetViewingStore;
let viewingAssets: string[] = $state([]);
let viewingAssetCursor = 0;
onDestroy(() => {
assetViewingStore.showAssetViewer(false);
@ -33,27 +34,66 @@
async function onViewAssets(assetIds: string[]) {
viewingAssets = assetIds;
viewingAssetCursor = 0;
await setAssetId(assetIds[0]);
}
async function navigateNext() {
if (viewingAssetCursor < viewingAssets.length - 1) {
await setAssetId(viewingAssets[++viewingAssetCursor]);
await navigate({ targetRoute: 'current', assetId: $viewingAsset.id });
return true;
const handleNavigateToAsset = async (currentAsset: AssetResponseDto | undefined) => {
if (!currentAsset) {
return false;
}
return false;
}
await navigate({ targetRoute: 'current', assetId: currentAsset.id });
return true;
};
async function navigatePrevious() {
if (viewingAssetCursor > 0) {
await setAssetId(viewingAssets[--viewingAssetCursor]);
await navigate({ targetRoute: 'current', assetId: $viewingAsset.id });
return true;
const getNextAsset = async (currentAsset: AssetResponseDto | undefined) => {
if (!currentAsset) {
return;
}
return false;
}
const cursor = viewingAssets.indexOf(currentAsset.id);
if (cursor < viewingAssets.length - 1) {
const id = viewingAssets[cursor + 1];
const asset = await getAssetInfo({ ...authManager.params, id });
return asset;
}
};
const getPreviousAsset = async (currentAsset: AssetResponseDto | undefined) => {
if (!currentAsset) {
return;
}
const cursor = viewingAssets.indexOf(currentAsset.id);
if (cursor > 0) {
const id = viewingAssets[cursor - 1];
const asset = await getAssetInfo({ ...authManager.params, id });
return asset;
}
};
let assetCursor = $state<{
previousAsset: AssetResponseDto | undefined;
current: AssetResponseDto | undefined;
nextAsset: AssetResponseDto | undefined;
}>({
current: $viewingAsset,
previousAsset: undefined,
nextAsset: undefined,
});
const loadCloseAssets = async (currentAsset: AssetResponseDto | undefined) => {
const [nextAsset, previousAsset] = await Promise.all([getNextAsset(currentAsset), getPreviousAsset(currentAsset)]);
assetCursor = {
current: currentAsset,
nextAsset,
previousAsset,
};
};
//TODO: replace this with async derived in svelte 6
$effect(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
$viewingAsset;
untrack(() => void loadCloseAssets($viewingAsset));
});
async function navigateRandom() {
if (viewingAssets.length <= 0) {
@ -82,13 +122,14 @@
</div>
</UserPageLayout>
<Portal target="body">
{#if $showAssetViewer}
{#if $showAssetViewer && assetCursor.current}
{#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
<AssetViewer
asset={$viewingAsset}
asset={assetCursor.current}
nextAsset={assetCursor.nextAsset}
previousAsset={assetCursor.previousAsset}
showNavigation={viewingAssets.length > 1}
onNext={navigateNext}
onPrevious={navigatePrevious}
onNavigateToAsset={handleNavigateToAsset}
onRandom={navigateRandom}
onClose={() => {
assetViewingStore.showAssetViewer(false);

@ -22,7 +22,7 @@
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
import { AppRoute, QueryParameter } from '$lib/constants';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types';
import type { Viewport } from '$lib/managers/timeline-manager/types';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { lang, locale } from '$lib/stores/preferences.store';
@ -35,6 +35,7 @@
import { toTimelineAsset } from '$lib/utils/timeline-util';
import {
type AlbumResponseDto,
type AssetResponseDto,
getPerson,
getTagById,
type MetadataSearchDto,
@ -58,7 +59,7 @@
let nextPage = $state(1);
let searchResultAlbums: AlbumResponseDto[] = $state([]);
let searchResultAssets: TimelineAsset[] = $state([]);
let searchResultAssets: AssetResponseDto[] = $state([]);
let isLoading = $state(true);
let scrollY = $state(0);
let scrollYHistory = 0;
@ -121,7 +122,7 @@
const onAssetDelete = (assetIds: string[]) => {
const assetIdSet = new Set(assetIds);
searchResultAssets = searchResultAssets.filter((asset: TimelineAsset) => !assetIdSet.has(asset.id));
searchResultAssets = searchResultAssets.filter((asset: AssetResponseDto) => !assetIdSet.has(asset.id));
};
const handleSetVisibility = (assetIds: string[]) => {
@ -130,7 +131,7 @@
};
const handleSelectAll = () => {
assetInteraction.selectAssets(searchResultAssets);
assetInteraction.selectAssets(searchResultAssets.map((asset) => toTimelineAsset(asset)));
};
async function onSearchQueryUpdate() {
@ -162,7 +163,7 @@
: await searchAssets({ metadataSearchDto: searchDto });
searchResultAlbums.push(...albums.items);
searchResultAssets.push(...assets.items.map((asset) => toTimelineAsset(asset)));
searchResultAssets.push(...assets.items);
nextPage = Number(assets.nextPage) || 0;
} catch (error) {

@ -6,9 +6,9 @@
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { handlePromiseError } from '$lib/utils';
import { navigate } from '$lib/utils/navigation';
import type { AssetResponseDto } from '@immich/sdk';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
import type { AssetResponseDto } from '@immich/sdk';
interface Props {
data: PageData;
@ -27,21 +27,27 @@
}
});
const onNext = async () => {
const index = getAssetIndex($viewingAsset.id) + 1;
if (index >= assets.length) {
return false;
const nextAsset = $derived.by(() => {
const currentIndex = getAssetIndex($viewingAsset.id);
if (currentIndex >= assets.length - 1) {
return undefined;
}
await onViewAsset(assets[index]);
return true;
};
return currentIndex + 1 < assets.length ? assets[currentIndex + 1] : undefined;
});
const previousAsset = $derived.by(() => {
const currentIndex = getAssetIndex($viewingAsset.id);
if (currentIndex <= 0) {
return undefined;
}
return currentIndex - 1 >= 0 ? assets[currentIndex - 1] : undefined;
});
const onPrevious = async () => {
const index = getAssetIndex($viewingAsset.id) - 1;
if (index < 0) {
const handleNavigateToAsset = async (asset: AssetResponseDto | undefined) => {
if (!asset) {
return false;
}
await onViewAsset(assets[index]);
await onViewAsset(asset);
return true;
};
@ -86,9 +92,10 @@
<Portal target="body">
<AssetViewer
asset={$viewingAsset}
onNavigateToAsset={handleNavigateToAsset}
{nextAsset}
{previousAsset}
showNavigation={assets.length > 1}
{onNext}
{onPrevious}
{onRandom}
{onAction}
onClose={() => {