diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index b0a78ab795..84b83690e4 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -739,7 +739,7 @@ importers:
version: 0.4.3
'@immich/sdk':
specifier: file:../open-api/typescript-sdk
- version: link:../open-api/typescript-sdk
+ version: file:open-api/typescript-sdk
'@immich/ui':
specifier: ^0.61.4
version: 0.61.4(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)
@@ -781,7 +781,7 @@ importers:
version: 2.6.0
fabric:
specifier: ^6.5.4
- version: 6.9.1
+ version: 6.9.1(encoding@0.1.13)
geo-coordinates-parser:
specifier: ^1.7.4
version: 1.7.4
@@ -809,6 +809,9 @@ importers:
maplibre-gl:
specifier: ^5.6.2
version: 5.16.0
+ media-chrome:
+ specifier: ^4.17.2
+ version: 4.17.2(react@19.2.3)
pmtiles:
specifier: ^4.3.0
version: 4.3.2
@@ -881,7 +884,7 @@ importers:
version: 6.9.1
'@testing-library/svelte':
specifier: ^5.2.8
- version: 5.3.1(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.9)(happy-dom@20.3.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
+ version: 5.3.1(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.9)(happy-dom@20.3.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
'@testing-library/user-event':
specifier: ^14.5.2
version: 14.6.1(@testing-library/dom@10.4.1)
@@ -905,7 +908,7 @@ importers:
version: 1.5.6
'@vitest/coverage-v8':
specifier: ^3.0.0
- version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.9)(happy-dom@20.3.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
+ version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.9)(happy-dom@20.3.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
dotenv:
specifier: ^17.0.0
version: 17.2.3
@@ -968,7 +971,7 @@ importers:
version: 7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
vitest:
specifier: ^3.0.0
- version: 3.2.4(@types/debug@4.1.12)(@types/node@25.0.9)(happy-dom@20.3.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
+ version: 3.2.4(@types/debug@4.1.12)(@types/node@25.0.9)(happy-dom@20.3.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
packages:
@@ -3126,6 +3129,9 @@ packages:
'@immich/justified-layout-wasm@0.4.3':
resolution: {integrity: sha512-fpcQ7zPhP3Cp1bEXhONVYSUeIANa2uzaQFGKufUZQo5FO7aFT77szTVChhlCy4XaVy5R4ZvgSkA/1TJmeORz7Q==}
+ '@immich/sdk@file:open-api/typescript-sdk':
+ resolution: {directory: open-api/typescript-sdk, type: directory}
+
'@immich/svelte-markdown-preprocess@0.2.1':
resolution: {integrity: sha512-mbr/g75lO8Zh+ELCuYrZP0XB4gf2UbK8rJcGYMYxFJJzMMunV+sm9FqtV1dbwW2dpXzCZGz1XPCEZ6oo526TbA==}
peerDependencies:
@@ -6229,6 +6235,11 @@ packages:
ccount@2.0.1:
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
+ ce-la-react@0.3.2:
+ resolution: {integrity: sha512-QJ6k4lOD/btI08xG8jBPxRCGXvCnusGGkTsiXk0u3NqUu/W+BXRnFD4PYjwtqh8AWmGa5LDbGk0fLQsqr0nSMA==}
+ peerDependencies:
+ react: '>=17.0.0'
+
chai@5.3.3:
resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
engines: {node: '>=18'}
@@ -9153,6 +9164,9 @@ packages:
mdn-data@2.0.30:
resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==}
+ media-chrome@4.17.2:
+ resolution: {integrity: sha512-o/IgiHx0tdSVwRxxqF5H12FK31A/A8T71sv3KdAvh7b6XeBS9dXwqvIFwlR9kdEuqg3n7xpmRIuL83rmYq8FTg==}
+
media-typer@0.3.0:
resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
engines: {node: '>= 0.6'}
@@ -15755,6 +15769,10 @@ snapshots:
'@immich/justified-layout-wasm@0.4.3': {}
+ '@immich/sdk@file:open-api/typescript-sdk':
+ dependencies:
+ '@oazapfts/runtime': 1.1.0
+
'@immich/svelte-markdown-preprocess@0.2.1(svelte@5.48.0)':
dependencies:
front-matter: 4.0.2
@@ -17804,14 +17822,14 @@ snapshots:
dependencies:
svelte: 5.48.0
- '@testing-library/svelte@5.3.1(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.9)(happy-dom@20.3.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
+ '@testing-library/svelte@5.3.1(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.9)(happy-dom@20.3.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
dependencies:
'@testing-library/dom': 10.4.1
'@testing-library/svelte-core': 1.0.0(svelte@5.48.0)
svelte: 5.48.0
optionalDependencies:
vite: 7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
- vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.0.9)(happy-dom@20.3.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
+ vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.0.9)(happy-dom@20.3.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
'@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)':
dependencies:
@@ -18537,7 +18555,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.9)(happy-dom@20.3.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
+ '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.9)(happy-dom@20.3.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
dependencies:
'@ampproject/remapping': 2.3.0
'@bcoe/v8-coverage': 1.0.2
@@ -18552,7 +18570,7 @@ snapshots:
std-env: 3.10.0
test-exclude: 7.0.1
tinyrainbow: 2.0.0
- vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.0.9)(happy-dom@20.3.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
+ vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.0.9)(happy-dom@20.3.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
transitivePeerDependencies:
- supports-color
@@ -19296,6 +19314,10 @@ snapshots:
ccount@2.0.1: {}
+ ce-la-react@0.3.2(react@19.2.3):
+ dependencies:
+ react: 19.2.3
+
chai@5.3.3:
dependencies:
assertion-error: 2.0.1
@@ -20904,10 +20926,10 @@ snapshots:
extend@3.0.2: {}
- fabric@6.9.1:
+ fabric@6.9.1(encoding@0.1.13):
optionalDependencies:
- canvas: 2.11.2
- jsdom: 20.0.3(canvas@2.11.2)
+ canvas: 2.11.2(encoding@0.1.13)
+ jsdom: 20.0.3(canvas@2.11.2(encoding@0.1.13))
transitivePeerDependencies:
- bufferutil
- encoding
@@ -22059,7 +22081,7 @@ snapshots:
dependencies:
argparse: 2.0.1
- jsdom@20.0.3(canvas@2.11.2):
+ jsdom@20.0.3(canvas@2.11.2(encoding@0.1.13)):
dependencies:
abab: 2.0.6
acorn: 8.15.0
@@ -22088,7 +22110,7 @@ snapshots:
ws: 8.19.0
xml-name-validator: 4.0.0
optionalDependencies:
- canvas: 2.11.2
+ canvas: 2.11.2(encoding@0.1.13)
transitivePeerDependencies:
- bufferutil
- supports-color
@@ -22781,6 +22803,12 @@ snapshots:
mdn-data@2.0.30: {}
+ media-chrome@4.17.2(react@19.2.3):
+ dependencies:
+ ce-la-react: 0.3.2(react@19.2.3)
+ transitivePeerDependencies:
+ - react
+
media-typer@0.3.0: {}
media-typer@1.1.0: {}
@@ -26668,7 +26696,7 @@ snapshots:
- tsx
- yaml
- vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.9)(happy-dom@20.3.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2):
+ vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.9)(happy-dom@20.3.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2):
dependencies:
'@types/chai': 5.2.3
'@vitest/expect': 3.2.4
@@ -26697,7 +26725,7 @@ snapshots:
'@types/debug': 4.1.12
'@types/node': 25.0.9
happy-dom: 20.3.0
- jsdom: 26.1.0(canvas@2.11.2)
+ jsdom: 26.1.0(canvas@2.11.2(encoding@0.1.13))
transitivePeerDependencies:
- jiti
- less
diff --git a/web/package.json b/web/package.json
index 7af0474ba1..b8a5f1d494 100644
--- a/web/package.json
+++ b/web/package.json
@@ -50,6 +50,7 @@
"lodash-es": "^4.17.21",
"luxon": "^3.4.4",
"maplibre-gl": "^5.6.2",
+ "media-chrome": "^4.17.2",
"pmtiles": "^4.3.0",
"qrcode": "^1.5.4",
"simple-icons": "^15.15.0",
diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte
index 848870b654..5bb31be7fd 100644
--- a/web/src/lib/components/asset-viewer/asset-viewer.svelte
+++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte
@@ -513,6 +513,7 @@
cacheKey={asset.thumbhash}
projectionType={asset.exifInfo?.projectionType}
loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}
+ showFullscreen={false}
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
onVideoEnded={() => (assetViewerManager.isPlayingMotionPhoto = false)}
diff --git a/web/src/lib/components/asset-viewer/video-native-viewer.svelte b/web/src/lib/components/asset-viewer/video-native-viewer.svelte
index 78fdc3a1ba..287d833e0d 100644
--- a/web/src/lib/components/asset-viewer/video-native-viewer.svelte
+++ b/web/src/lib/components/asset-viewer/video-native-viewer.svelte
@@ -4,24 +4,41 @@
import { assetViewerFadeDuration } from '$lib/constants';
import { castManager } from '$lib/managers/cast-manager.svelte';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
- import {
- autoPlayVideo,
- loopVideo as loopVideoPreference,
- videoViewerMuted,
- videoViewerVolume,
- } from '$lib/stores/preferences.store';
+ import { autoPlayVideo, loopVideo as loopVideoPreference } from '$lib/stores/preferences.store';
import { getAssetMediaUrl, getAssetPlaybackUrl } from '$lib/utils';
- import { AssetMediaSize } from '@immich/sdk';
- import { LoadingSpinner } from '@immich/ui';
+ import { timeToSeconds } from '$lib/utils/date-time';
+ import { AssetMediaSize, type AssetResponseDto } from '@immich/sdk';
+ import { Icon, LoadingSpinner } from '@immich/ui';
+ import {
+ mdiFullscreen,
+ mdiFullscreenExit,
+ mdiPause,
+ mdiPlay,
+ mdiVolumeHigh,
+ mdiVolumeLow,
+ mdiVolumeMedium,
+ mdiVolumeMute,
+ } from '@mdi/js';
+ import 'media-chrome/media-control-bar';
+ import 'media-chrome/media-controller';
+ import 'media-chrome/media-fullscreen-button';
+ import 'media-chrome/media-mute-button';
+ import 'media-chrome/media-play-button';
+ import 'media-chrome/media-playback-rate-button';
+ import 'media-chrome/media-time-display';
+ import 'media-chrome/media-time-range';
+ import 'media-chrome/media-volume-range';
import { onDestroy, onMount } from 'svelte';
import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures';
import { fade } from 'svelte/transition';
interface Props {
+ asset: AssetResponseDto;
assetId: string;
loopVideo: boolean;
cacheKey: string | null;
playOriginalVideo: boolean;
+ showFullscreen?: boolean;
onPreviousAsset?: () => void;
onNextAsset?: () => void;
onVideoEnded?: () => void;
@@ -30,10 +47,12 @@
}
let {
+ asset,
assetId,
loopVideo,
cacheKey,
playOriginalVideo,
+ showFullscreen = true,
onPreviousAsset = () => {},
onNextAsset = () => {},
onVideoEnded = () => {},
@@ -48,7 +67,6 @@
? getAssetMediaUrl({ id: assetId, size: AssetMediaSize.Original, cacheKey })
: getAssetPlaybackUrl({ id: assetId, cacheKey }),
);
- let isScrubbing = $state(false);
let showVideo = $state(false);
onMount(() => {
@@ -71,7 +89,7 @@
const handleCanPlay = async (video: HTMLVideoElement) => {
try {
- if (!video.paused && !isScrubbing) {
+ if (!video.paused) {
await video.play();
onVideoStarted();
}
@@ -136,30 +154,56 @@
/>
{:else}
-
+