diff --git a/i18n/en.json b/i18n/en.json
index add755c05d..cc30d9e350 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -1761,6 +1761,7 @@
"play_original_video": "Play original video",
"play_original_video_setting_description": "Prefer playback of original videos rather than transcoded videos. If original asset is not compatible it may not playback correctly.",
"play_transcoded_video": "Play transcoded video",
+ "playback_speed": "Playback speed",
"please_auth_to_access": "Please authenticate to access",
"port": "Port",
"preferences_settings_subtitle": "Manage the app's preferences",
@@ -2436,6 +2437,7 @@
"workflows": "Workflows",
"workflows_help_text": "Workflows automate actions on your assets based on triggers and filters",
"wrong_pin_code": "Wrong PIN code",
+ "x_of_total": "{x}/{total}",
"year": "Year",
"years_ago": "{years, plural, one {# year} other {# years}} ago",
"yes": "Yes",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f7aa8f33f9..c3f970ca51 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -804,6 +804,9 @@ importers:
maplibre-gl:
specifier: ^5.6.2
version: 5.24.0
+ media-chrome:
+ specifier: ^4.19.0
+ version: 4.19.0(react@19.2.5)
pmtiles:
specifier: ^4.3.0
version: 4.4.1
@@ -6175,6 +6178,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'}
@@ -9080,6 +9088,9 @@ packages:
mdn-data@2.27.1:
resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==}
+ media-chrome@4.19.0:
+ resolution: {integrity: sha512-HWhDTwts+BSbdPkkB1VsJXp5kvL0IxY7xFT5tBwliM2+89kTPVTnHnev+9it2f9PweANjT/C8/C/S0PW9oyZbA==}
+
media-typer@0.3.0:
resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
engines: {node: '>= 0.6'}
@@ -18763,6 +18774,10 @@ snapshots:
ccount@2.0.1: {}
+ ce-la-react@0.3.2(react@19.2.5):
+ dependencies:
+ react: 19.2.5
+
chai@5.3.3:
dependencies:
assertion-error: 2.0.1
@@ -22167,6 +22182,12 @@ snapshots:
mdn-data@2.27.1: {}
+ media-chrome@4.19.0(react@19.2.5):
+ dependencies:
+ ce-la-react: 0.3.2(react@19.2.5)
+ transitivePeerDependencies:
+ - react
+
media-typer@0.3.0: {}
media-typer@1.1.0: {}
diff --git a/web/package.json b/web/package.json
index 1b4f1c46ed..daaa74d9e9 100644
--- a/web/package.json
+++ b/web/package.json
@@ -51,6 +51,7 @@
"lodash-es": "^4.17.21",
"luxon": "^3.4.4",
"maplibre-gl": "^5.6.2",
+ "media-chrome": "^4.19.0",
"pmtiles": "^4.3.0",
"qrcode": "^1.5.4",
"simple-icons": "^16.0.0",
diff --git a/web/src/lib/components/asset-viewer/AssetViewer.svelte b/web/src/lib/components/asset-viewer/AssetViewer.svelte
index 656d67dea8..cb49828f0d 100644
--- a/web/src/lib/components/asset-viewer/AssetViewer.svelte
+++ b/web/src/lib/components/asset-viewer/AssetViewer.svelte
@@ -540,6 +540,7 @@
cacheKey={asset.thumbhash}
projectionType={asset.exifInfo?.projectionType}
loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}
+ extendedControls
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
onClose={closeViewer}
diff --git a/web/src/lib/components/asset-viewer/VideoNativeViewer.svelte b/web/src/lib/components/asset-viewer/VideoNativeViewer.svelte
index 5ae768d4d4..58e6fdd1e1 100644
--- a/web/src/lib/components/asset-viewer/VideoNativeViewer.svelte
+++ b/web/src/lib/components/asset-viewer/VideoNativeViewer.svelte
@@ -2,26 +2,50 @@
import FaceEditor from '$lib/components/asset-viewer/face-editor/FaceEditor.svelte';
import VideoRemoteViewer from '$lib/components/asset-viewer/VideoRemoteViewer.svelte';
import { assetViewerFadeDuration } from '$lib/constants';
- import { castManager } from '$lib/managers/cast-manager.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
- import {
- autoPlayVideo,
- loopVideo as loopVideoPreference,
- videoViewerMuted,
- videoViewerVolume,
- } from '$lib/stores/preferences.store';
+ import { castManager } from '$lib/managers/cast-manager.svelte';
+ 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 { AssetMediaSize, type AssetResponseDto } from '@immich/sdk';
+ import { Icon, LoadingSpinner } from '@immich/ui';
+ import {
+ mdiCheck,
+ mdiChevronLeft,
+ mdiChevronRight,
+ 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 'media-chrome/menu/media-playback-rate-menu';
+ import 'media-chrome/menu/media-settings-menu';
+ import 'media-chrome/menu/media-settings-menu-button';
+ import 'media-chrome/menu/media-settings-menu-item';
import { onDestroy, onMount } from 'svelte';
import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures';
+ import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
interface Props {
+ asset: AssetResponseDto;
assetId: string;
loopVideo: boolean;
cacheKey: string | null;
playOriginalVideo: boolean;
+ extendedControls?: boolean;
onPreviousAsset?: () => void;
onNextAsset?: () => void;
onVideoEnded?: () => void;
@@ -30,10 +54,12 @@
}
let {
+ asset,
assetId,
loopVideo,
cacheKey,
playOriginalVideo,
+ extendedControls = false,
onPreviousAsset = () => {},
onNextAsset = () => {},
onVideoEnded = () => {},
@@ -48,12 +74,11 @@
? getAssetMediaUrl({ id: assetId, size: AssetMediaSize.Original, cacheKey })
: getAssetPlaybackUrl({ id: assetId, cacheKey }),
);
- let isScrubbing = $state(false);
+ const aspectRatio = $derived(asset.width && asset.height ? `${asset.width} / ${asset.height}` : undefined);
let showVideo = $state(false);
let hasFocused = $state(false);
onMount(() => {
- // Show video after mount to ensure fading in.
showVideo = true;
});
@@ -73,7 +98,7 @@
const handleCanPlay = async (video: HTMLVideoElement) => {
try {
- if (!video.paused && !isScrubbing) {
+ if (!video.paused) {
await video.play();
onVideoStarted();
}
@@ -138,33 +163,83 @@
/>
{:else}
-
+
+
+ {#if extendedControls}
+
- {(current.assetIndex + 1).toLocaleString($locale)}/{current.memory.assets.length.toLocaleString($locale)} -
-