mirror of
https://github.com/immich-app/immich.git
synced 2026-07-01 10:35:13 -07:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 068da45454 | |||
| 266671c697 | |||
| 3b2009eb58 | |||
| 4ea2cd7818 |
Generated
+8
-22
@@ -807,6 +807,9 @@ importers:
|
|||||||
'@zoom-image/svelte':
|
'@zoom-image/svelte':
|
||||||
specifier: ^0.3.0
|
specifier: ^0.3.0
|
||||||
version: 0.3.9(svelte@5.56.2(@typescript-eslint/types@8.61.0))
|
version: 0.3.9(svelte@5.56.2(@typescript-eslint/types@8.61.0))
|
||||||
|
custom-media-element:
|
||||||
|
specifier: ^1.4.6
|
||||||
|
version: 1.4.6
|
||||||
dom-to-image:
|
dom-to-image:
|
||||||
specifier: ^2.6.0
|
specifier: ^2.6.0
|
||||||
version: 2.6.0
|
version: 2.6.0
|
||||||
@@ -825,12 +828,9 @@ importers:
|
|||||||
happy-dom:
|
happy-dom:
|
||||||
specifier: ^20.0.0
|
specifier: ^20.0.0
|
||||||
version: 20.10.3
|
version: 20.10.3
|
||||||
hls-video-element:
|
|
||||||
specifier: ^1.5.11
|
|
||||||
version: 1.5.11
|
|
||||||
hls.js:
|
hls.js:
|
||||||
specifier: ^1.6.16
|
specifier: 1.7.0-beta.1.0.canary.11837
|
||||||
version: 1.6.16
|
version: 1.7.0-beta.1.0.canary.11837
|
||||||
intl-messageformat:
|
intl-messageformat:
|
||||||
specifier: ^11.0.0
|
specifier: ^11.0.0
|
||||||
version: 11.2.8
|
version: 11.2.8
|
||||||
@@ -8327,11 +8327,8 @@ packages:
|
|||||||
history@4.10.1:
|
history@4.10.1:
|
||||||
resolution: {integrity: sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==}
|
resolution: {integrity: sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==}
|
||||||
|
|
||||||
hls-video-element@1.5.11:
|
hls.js@1.7.0-beta.1.0.canary.11837:
|
||||||
resolution: {integrity: sha512-tJJ65/52CDxj8XFyIve6zT9nVVdUIc6mqvKR25X0ycPKHk07rpjp4xxVteeCefDUBSf/tFLhlICFmn3KWj37xA==}
|
resolution: {integrity: sha512-JXSTYLvxCpJeD8xgYlIYzEA0Ag+1Vnkakl7y8JiS0RtgNPFgtsGjqqxCPFyYCmumi1CON6VtksohqeJoiBAKmw==}
|
||||||
|
|
||||||
hls.js@1.6.16:
|
|
||||||
resolution: {integrity: sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==}
|
|
||||||
|
|
||||||
hogan.js@3.0.2:
|
hogan.js@3.0.2:
|
||||||
resolution: {integrity: sha512-RqGs4wavGYJWE07t35JQccByczmNUXQT0E12ZYV1VKYu5UiAU9lsos/yBAcf840+zrUQQxgVduCR5/B8nNtibg==}
|
resolution: {integrity: sha512-RqGs4wavGYJWE07t35JQccByczmNUXQT0E12ZYV1VKYu5UiAU9lsos/yBAcf840+zrUQQxgVduCR5/B8nNtibg==}
|
||||||
@@ -9342,9 +9339,6 @@ packages:
|
|||||||
media-chrome@4.19.2:
|
media-chrome@4.19.2:
|
||||||
resolution: {integrity: sha512-4ai1ITN8wBhwugQcRgqe3tN0z6OSKGOXqHLNrS04MgKFfsLqu6Dm8MPq02pI9Y9ZKoXtFjIl85jOryIW9es3BA==}
|
resolution: {integrity: sha512-4ai1ITN8wBhwugQcRgqe3tN0z6OSKGOXqHLNrS04MgKFfsLqu6Dm8MPq02pI9Y9ZKoXtFjIl85jOryIW9es3BA==}
|
||||||
|
|
||||||
media-tracks@0.3.5:
|
|
||||||
resolution: {integrity: sha512-l54rkKXlLBt3ob3zOLWHcnjvwUmX5bNEZ70igyapOZZC9imzqBmq1oz8p2roiV04KhjblFIi2hetLPF1oYVLRA==}
|
|
||||||
|
|
||||||
media-typer@0.3.0:
|
media-typer@0.3.0:
|
||||||
resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
|
resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
@@ -21710,13 +21704,7 @@ snapshots:
|
|||||||
tiny-warning: 1.0.3
|
tiny-warning: 1.0.3
|
||||||
value-equal: 1.0.1
|
value-equal: 1.0.1
|
||||||
|
|
||||||
hls-video-element@1.5.11:
|
hls.js@1.7.0-beta.1.0.canary.11837: {}
|
||||||
dependencies:
|
|
||||||
custom-media-element: 1.4.6
|
|
||||||
hls.js: 1.6.16
|
|
||||||
media-tracks: 0.3.5
|
|
||||||
|
|
||||||
hls.js@1.6.16: {}
|
|
||||||
|
|
||||||
hogan.js@3.0.2:
|
hogan.js@3.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -22825,8 +22813,6 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- react
|
- react
|
||||||
|
|
||||||
media-tracks@0.3.5: {}
|
|
||||||
|
|
||||||
media-typer@0.3.0: {}
|
media-typer@0.3.0: {}
|
||||||
|
|
||||||
media-typer@1.1.0: {}
|
media-typer@1.1.0: {}
|
||||||
|
|||||||
+2
-2
@@ -40,14 +40,14 @@
|
|||||||
"@types/geojson": "^7946.0.16",
|
"@types/geojson": "^7946.0.16",
|
||||||
"@zoom-image/core": "^0.42.0",
|
"@zoom-image/core": "^0.42.0",
|
||||||
"@zoom-image/svelte": "^0.3.0",
|
"@zoom-image/svelte": "^0.3.0",
|
||||||
|
"custom-media-element": "^1.4.6",
|
||||||
"dom-to-image": "^2.6.0",
|
"dom-to-image": "^2.6.0",
|
||||||
"fabric": "^7.0.0",
|
"fabric": "^7.0.0",
|
||||||
"geo-coordinates-parser": "^1.7.4",
|
"geo-coordinates-parser": "^1.7.4",
|
||||||
"geojson": "^0.5.0",
|
"geojson": "^0.5.0",
|
||||||
"handlebars": "^4.7.8",
|
"handlebars": "^4.7.8",
|
||||||
"happy-dom": "^20.0.0",
|
"happy-dom": "^20.0.0",
|
||||||
"hls-video-element": "^1.5.11",
|
"hls.js": "1.7.0-beta.1.0.canary.11837",
|
||||||
"hls.js": "^1.6.16",
|
|
||||||
"intl-messageformat": "^11.0.0",
|
"intl-messageformat": "^11.0.0",
|
||||||
"justified-layout": "^4.1.0",
|
"justified-layout": "^4.1.0",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
|
|||||||
@@ -176,3 +176,9 @@
|
|||||||
@apply bg-subtle rounded-lg;
|
@apply bg-subtle rounded-lg;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
immich-video > video {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: var(--media-object-fit, contain);
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,9 +5,11 @@
|
|||||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||||
import { castManager } from '$lib/managers/cast-manager.svelte';
|
import { castManager } from '$lib/managers/cast-manager.svelte';
|
||||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||||
import { mediaCapabilitiesManager } from '$lib/managers/media-capabilities-manager.svelte';
|
|
||||||
import { autoPlayVideo, lang, loopVideo as loopVideoPreference } from '$lib/stores/preferences.store';
|
import { autoPlayVideo, lang, loopVideo as loopVideoPreference } from '$lib/stores/preferences.store';
|
||||||
import { getAssetHlsSessionUrl, getAssetHlsUrl, getAssetMediaUrl, getAssetPlaybackUrl } from '$lib/utils';
|
import { getAssetHlsUrl, getAssetMediaUrl, getAssetPlaybackUrl } from '$lib/utils';
|
||||||
|
import '$lib/components/asset-viewer/immich-video-element';
|
||||||
|
import { videoSessionManager } from '$lib/managers/video-session-manager.svelte';
|
||||||
|
import VideoQualityMenu from '$lib/components/asset-viewer/VideoQualityMenu.svelte';
|
||||||
import { AssetMediaSize, type AssetResponseDto } from '@immich/sdk';
|
import { AssetMediaSize, type AssetResponseDto } from '@immich/sdk';
|
||||||
import { Icon, LoadingSpinner, shortcuts } from '@immich/ui';
|
import { Icon, LoadingSpinner, shortcuts } from '@immich/ui';
|
||||||
import {
|
import {
|
||||||
@@ -23,9 +25,6 @@
|
|||||||
mdiVolumeMedium,
|
mdiVolumeMedium,
|
||||||
mdiVolumeMute,
|
mdiVolumeMute,
|
||||||
} from '@mdi/js';
|
} from '@mdi/js';
|
||||||
import 'hls-video-element';
|
|
||||||
import type HlsVideoElement from 'hls-video-element';
|
|
||||||
import Hls, { AbrController, Events, type FragLoadedData, type FragLoadingData, type HlsConfig } from 'hls.js';
|
|
||||||
import 'media-chrome/media-control-bar';
|
import 'media-chrome/media-control-bar';
|
||||||
import 'media-chrome/media-controller';
|
import 'media-chrome/media-controller';
|
||||||
import 'media-chrome/media-fullscreen-button';
|
import 'media-chrome/media-fullscreen-button';
|
||||||
@@ -35,11 +34,10 @@
|
|||||||
import 'media-chrome/media-time-display';
|
import 'media-chrome/media-time-display';
|
||||||
import 'media-chrome/media-volume-range';
|
import 'media-chrome/media-volume-range';
|
||||||
import 'media-chrome/menu/media-playback-rate-menu';
|
import 'media-chrome/menu/media-playback-rate-menu';
|
||||||
import 'media-chrome/menu/media-rendition-menu';
|
|
||||||
import 'media-chrome/menu/media-settings-menu';
|
import 'media-chrome/menu/media-settings-menu';
|
||||||
import 'media-chrome/menu/media-settings-menu-button';
|
import 'media-chrome/menu/media-settings-menu-button';
|
||||||
import 'media-chrome/menu/media-settings-menu-item';
|
import 'media-chrome/menu/media-settings-menu-item';
|
||||||
import { onDestroy, onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures';
|
import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
@@ -73,7 +71,6 @@
|
|||||||
onClose = () => {},
|
onClose = () => {},
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let videoPlayer: HTMLVideoElement | undefined = $state();
|
|
||||||
let isLoading = $state(true);
|
let isLoading = $state(true);
|
||||||
let assetFileUrl = $derived.by(() => {
|
let assetFileUrl = $derived.by(() => {
|
||||||
if (featureFlagsManager.value.realtimeTranscoding) {
|
if (featureFlagsManager.value.realtimeTranscoding) {
|
||||||
@@ -88,182 +85,29 @@
|
|||||||
});
|
});
|
||||||
const aspectRatio = $derived(asset.width && asset.height ? `${asset.width} / ${asset.height}` : undefined);
|
const aspectRatio = $derived(asset.width && asset.height ? `${asset.width} / ${asset.height}` : undefined);
|
||||||
let showVideo = $state(false);
|
let showVideo = $state(false);
|
||||||
let hasFocused = $state(false);
|
let focusedAssetId = $state<string>();
|
||||||
let activeSession: { assetId: string; id: string } | undefined;
|
|
||||||
let rebuildCount = 0;
|
|
||||||
|
|
||||||
const MAX_REBUILDS = 1;
|
const controller = $derived(videoSessionManager.get(assetId)); // <immich-video> self-acquires the controller for the asset
|
||||||
const SESSION_ID_REGEX = /\/video\/stream\/([0-9a-f-]{36})\//;
|
const videoPlayer = $derived(controller?.element);
|
||||||
|
|
||||||
// hls.js can abandon fetching an in-flight fragment if it thinks it'll take too long, in which case
|
|
||||||
// it emergency switches to a different variant. This extends the delay even further due to
|
|
||||||
// cold starting another transcode, so let the fragment finish and have steady ABR decide the next level.
|
|
||||||
//
|
|
||||||
// It can also emergency switch between fragments: while a switch's first segment is still loading,
|
|
||||||
// it can run out of buffer and drop to a lower level for just one segment before continuing at the switched quality.
|
|
||||||
// This can cause multiple redundant transcoding restarts when it occurs.
|
|
||||||
// Hold the committed level until its first fragment lands, then resume normal ABR.
|
|
||||||
class NoAbandonAbrController extends AbrController {
|
|
||||||
private switchTarget = -1;
|
|
||||||
|
|
||||||
protected override onFragLoading(_event: Events.FRAG_LOADING, data: FragLoadingData) {
|
|
||||||
if (data.frag.sn === 'initSegment') {
|
|
||||||
this.switchTarget = data.frag.level;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override onFragLoaded(event: Events.FRAG_LOADED, data: FragLoadedData) {
|
|
||||||
if (data.frag.sn !== 'initSegment') {
|
|
||||||
this.switchTarget = -1;
|
|
||||||
}
|
|
||||||
super.onFragLoaded(event, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
override get nextAutoLevel(): number {
|
|
||||||
const level = super.nextAutoLevel;
|
|
||||||
const target = this.hls.levels[this.switchTarget];
|
|
||||||
// Hold the committed level, but only while hls.js still considers it healthy.
|
|
||||||
if (target && level < this.switchTarget && target.loadError === 0 && target.fragmentError === 0) {
|
|
||||||
return this.switchTarget;
|
|
||||||
}
|
|
||||||
return level;
|
|
||||||
}
|
|
||||||
|
|
||||||
override set nextAutoLevel(level: number) {
|
|
||||||
super.nextAutoLevel = level;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const hlsConfig: Partial<HlsConfig> = {
|
|
||||||
abrController: NoAbandonAbrController,
|
|
||||||
highBufferWatchdogPeriod: 10,
|
|
||||||
detectStallWithCurrentTimeMs: 10_000,
|
|
||||||
maxBufferHole: 0.5,
|
|
||||||
maxBufferLength: 30,
|
|
||||||
maxMaxBufferLength: 60,
|
|
||||||
fragLoadPolicy: {
|
|
||||||
default: {
|
|
||||||
maxTimeToFirstByteMs: 30_000,
|
|
||||||
maxLoadTimeMs: 60_000,
|
|
||||||
timeoutRetry: { maxNumRetry: 5, retryDelayMs: 100, maxRetryDelayMs: 0 },
|
|
||||||
errorRetry: { maxNumRetry: 3, retryDelayMs: 1000, maxRetryDelayMs: 8000 },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
useMediaCapabilities: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const releaseSession = () => {
|
|
||||||
const session = activeSession;
|
|
||||||
if (!session) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
activeSession = undefined;
|
|
||||||
const url = getAssetHlsSessionUrl(session.assetId, session.id);
|
|
||||||
void fetch(url, { method: 'DELETE' }).catch(() => console.warn('Failed to release HLS session', session));
|
|
||||||
};
|
|
||||||
|
|
||||||
const isHlsElement = (el: HTMLVideoElement | undefined): el is HlsVideoElement => {
|
|
||||||
return el?.tagName === 'HLS-VIDEO';
|
|
||||||
};
|
|
||||||
|
|
||||||
const wireHlsListeners = (el: HlsVideoElement, assetId: string, resumeTime?: number) => {
|
|
||||||
const api = el.api;
|
|
||||||
if (!api) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// This is a hack to make the rendition menu use `api.currentLevel` instead of `api.nextLevel`.
|
|
||||||
// `api.nextLevel` makes the player request the next segment followed by the current segment.
|
|
||||||
// That backward request causes the server to restart transcoding for no reason.
|
|
||||||
Object.defineProperty(api, 'nextLevel', {
|
|
||||||
configurable: true,
|
|
||||||
get: () => api.currentLevel,
|
|
||||||
set: (level: number) => {
|
|
||||||
api.currentLevel = level;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
|
||||||
api.on(Hls.Events.MANIFEST_PARSED, async () => {
|
|
||||||
// Defer hls.js's first fragment load until we filter out suboptimal variants
|
|
||||||
api.stopLoad();
|
|
||||||
const id = api.levels[0]?.url[0]?.match(SESSION_ID_REGEX)?.[1];
|
|
||||||
if (id) {
|
|
||||||
activeSession = { assetId, id };
|
|
||||||
}
|
|
||||||
|
|
||||||
const keep = await mediaCapabilitiesManager.efficientLevels(api.levels);
|
|
||||||
for (let i = api.levels.length - 1; i >= 0; i--) {
|
|
||||||
if (!keep.has(i)) {
|
|
||||||
api.removeLevel(i);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
api.startLoad(resumeTime);
|
|
||||||
});
|
|
||||||
|
|
||||||
api.on(Hls.Events.FRAG_LOADED, () => (rebuildCount = 0));
|
|
||||||
|
|
||||||
api.on(Hls.Events.ERROR, (_, data) => {
|
|
||||||
// 404 on a fragment can mean the server-side session has expired. Refetch
|
|
||||||
// master for a new session, but give up if it still 404s.
|
|
||||||
if (
|
|
||||||
!data.fatal ||
|
|
||||||
data.details !== Hls.ErrorDetails.FRAG_LOAD_ERROR ||
|
|
||||||
data.response?.code !== 404 ||
|
|
||||||
rebuildCount++ >= MAX_REBUILDS
|
|
||||||
) {
|
|
||||||
console.error('HLS error', JSON.stringify(data));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
console.warn('Error loading segment, starting new session');
|
|
||||||
activeSession = undefined;
|
|
||||||
resumeTime = el.currentTime;
|
|
||||||
el.load();
|
|
||||||
// wireHlsListeners must run after el.api is repopulated.
|
|
||||||
queueMicrotask(() => wireHlsListeners(el, assetId, resumeTime));
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
showVideo = true;
|
showVideo = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A hover-warmed element is already past `canplay` and won't fire it again, so kick playback ourselves once we adopt it
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
// reactive on `assetFileUrl` changes
|
if (videoPlayer && videoPlayer.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA) {
|
||||||
if (videoPlayer && assetFileUrl) {
|
void handleCanPlay(videoPlayer);
|
||||||
hasFocused = false;
|
|
||||||
rebuildCount = 0;
|
|
||||||
releaseSession();
|
|
||||||
if (isHlsElement(videoPlayer)) {
|
|
||||||
videoPlayer.config = hlsConfig;
|
|
||||||
videoPlayer.src = assetFileUrl;
|
|
||||||
const el = videoPlayer;
|
|
||||||
queueMicrotask(() => wireHlsListeners(el, assetId));
|
|
||||||
} else {
|
|
||||||
videoPlayer.load();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return releaseSession;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const onPagehide = (event: PageTransitionEvent) => {
|
const onPlaying = () => {
|
||||||
if (!event.persisted) {
|
if (focusedAssetId !== assetId) {
|
||||||
releaseSession();
|
videoPlayer?.focus();
|
||||||
|
focusedAssetId = assetId;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
window.addEventListener('pagehide', onPagehide);
|
|
||||||
return () => window.removeEventListener('pagehide', onPagehide);
|
|
||||||
});
|
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
if (videoPlayer) {
|
|
||||||
videoPlayer.src = '';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleCanPlay = async (video: HTMLVideoElement) => {
|
const handleCanPlay = async (video: HTMLVideoElement) => {
|
||||||
try {
|
try {
|
||||||
if (!video.paused) {
|
if (!video.paused) {
|
||||||
@@ -352,53 +196,23 @@
|
|||||||
class="dark h-full max-w-full"
|
class="dark h-full max-w-full"
|
||||||
style:aspect-ratio={aspectRatio}
|
style:aspect-ratio={aspectRatio}
|
||||||
defaultduration={asset.duration! / 1000}
|
defaultduration={asset.duration! / 1000}
|
||||||
|
{...useSwipe(onSwipe)}
|
||||||
>
|
>
|
||||||
{#if featureFlagsManager.value.realtimeTranscoding}
|
<immich-video
|
||||||
<hls-video
|
slot="media"
|
||||||
bind:this={videoPlayer}
|
asset-id={assetId}
|
||||||
slot="media"
|
cache-key={cacheKey ?? ''}
|
||||||
loop={$loopVideoPreference && loopVideo}
|
play-original={playOriginalVideo}
|
||||||
autoplay={$autoPlayVideo}
|
class="h-full"
|
||||||
disablePictureInPicture
|
loop={$loopVideoPreference && loopVideo}
|
||||||
playsinline
|
autoplay={$autoPlayVideo}
|
||||||
{...useSwipe(onSwipe)}
|
poster={getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Preview, cacheKey })}
|
||||||
class="h-full object-contain"
|
oncanplay={(event: Event) => handleCanPlay(event.currentTarget as HTMLVideoElement)}
|
||||||
oncanplay={(e: Event) => handleCanPlay(e.currentTarget as HTMLVideoElement)}
|
onended={onVideoEnded}
|
||||||
onended={onVideoEnded}
|
onseeking={onSeeking}
|
||||||
onseeking={onSeeking}
|
onplaying={onPlaying}
|
||||||
onplaying={(e: Event) => {
|
onclose={onClose}
|
||||||
if (!hasFocused) {
|
></immich-video>
|
||||||
(e.currentTarget as HTMLElement).focus();
|
|
||||||
hasFocused = true;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onclose={onClose}
|
|
||||||
poster={getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Preview, cacheKey })}
|
|
||||||
></hls-video>
|
|
||||||
{:else}
|
|
||||||
<video
|
|
||||||
bind:this={videoPlayer}
|
|
||||||
slot="media"
|
|
||||||
src={assetFileUrl}
|
|
||||||
loop={$loopVideoPreference && loopVideo}
|
|
||||||
autoplay={$autoPlayVideo}
|
|
||||||
disablePictureInPicture
|
|
||||||
playsinline
|
|
||||||
{...useSwipe(onSwipe)}
|
|
||||||
class="h-full object-contain"
|
|
||||||
oncanplay={(e) => handleCanPlay(e.currentTarget)}
|
|
||||||
onended={onVideoEnded}
|
|
||||||
onseeking={onSeeking}
|
|
||||||
onplaying={(e) => {
|
|
||||||
if (!hasFocused) {
|
|
||||||
e.currentTarget.focus();
|
|
||||||
hasFocused = true;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onclose={onClose}
|
|
||||||
poster={getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Preview, cacheKey })}
|
|
||||||
></video>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if extendedControls}
|
{#if extendedControls}
|
||||||
<media-settings-menu hidden anchor="auto" class="min-w-3xs rounded-xl border border-light-300 shadow-sm">
|
<media-settings-menu hidden anchor="auto" class="min-w-3xs rounded-xl border border-light-300 shadow-sm">
|
||||||
@@ -411,14 +225,11 @@
|
|||||||
<span slot="title">{$t('media_chrome.playback_rate')}</span>
|
<span slot="title">{$t('media_chrome.playback_rate')}</span>
|
||||||
</media-playback-rate-menu>
|
</media-playback-rate-menu>
|
||||||
</media-settings-menu-item>
|
</media-settings-menu-item>
|
||||||
{#if featureFlagsManager.value.realtimeTranscoding}
|
{#if featureFlagsManager.value.realtimeTranscoding && controller}
|
||||||
<media-settings-menu-item class="mx-1 rounded-lg p-1 ps-2">
|
<media-settings-menu-item class="mx-1 rounded-lg p-1 ps-2">
|
||||||
{$t('video_quality')}
|
{$t('video_quality')}
|
||||||
<Icon slot="suffix" icon={mdiChevronRight} class="m-2" />
|
<Icon slot="suffix" icon={mdiChevronRight} class="m-2" />
|
||||||
<media-rendition-menu slot="submenu" hidden>
|
<VideoQualityMenu video={controller} slot="submenu" />
|
||||||
<Icon slot="back-icon" icon={mdiChevronLeft} class="m-2" />
|
|
||||||
<span slot="title">{$t('video_quality')}</span>
|
|
||||||
</media-rendition-menu>
|
|
||||||
</media-settings-menu-item>
|
</media-settings-menu-item>
|
||||||
{/if}
|
{/if}
|
||||||
</media-settings-menu>
|
</media-settings-menu>
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { VideoController } from '$lib/utils/video/controller.svelte';
|
||||||
|
import { Icon } from '@immich/ui';
|
||||||
|
import { mdiChevronLeft } from '@mdi/js';
|
||||||
|
import 'media-chrome/menu/media-chrome-menu';
|
||||||
|
import 'media-chrome/menu/media-chrome-menu-item';
|
||||||
|
import { t } from 'svelte-i18n';
|
||||||
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
|
|
||||||
|
interface Props extends HTMLAttributes<HTMLElement> {
|
||||||
|
video: VideoController;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { video, ...rest }: Props = $props();
|
||||||
|
|
||||||
|
const options = $derived(
|
||||||
|
video.levels
|
||||||
|
.map((level, idx) => ({ idx, label: Math.min(level.width, level.height) }))
|
||||||
|
.sort((a, b) => b.label - a.label),
|
||||||
|
);
|
||||||
|
|
||||||
|
const autoLevel = $derived(video.selectedLevel === -1 ? options.find(({ idx }) => idx === video.level) : undefined);
|
||||||
|
const autoLabel = $derived(autoLevel ? `${$t('media_chrome.auto')} (${autoLevel.label}p)` : $t('media_chrome.auto'));
|
||||||
|
|
||||||
|
let menu = $state<HTMLElement>();
|
||||||
|
$effect(() => {
|
||||||
|
menu?.dispatchEvent(new CustomEvent('addmenuitem', { detail: autoLabel }));
|
||||||
|
});
|
||||||
|
|
||||||
|
const onChange = (event: Event) => {
|
||||||
|
video.level = Number((event.currentTarget as HTMLElement & { value: string }).value);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<media-chrome-menu bind:this={menu} {...rest} hidden onchange={onChange}>
|
||||||
|
<Icon slot="back-icon" icon={mdiChevronLeft} class="m-2" />
|
||||||
|
<span slot="title">{$t('video_quality')}</span>
|
||||||
|
<media-chrome-menu-item part="menu-item radio" type="radio" value="-1" checked={video.selectedLevel === -1}>
|
||||||
|
<span>{autoLabel}</span>
|
||||||
|
</media-chrome-menu-item>
|
||||||
|
{#each options as option (option.idx)}
|
||||||
|
<media-chrome-menu-item
|
||||||
|
part="menu-item radio"
|
||||||
|
type="radio"
|
||||||
|
value={`${option.idx}`}
|
||||||
|
checked={video.selectedLevel === option.idx}
|
||||||
|
>
|
||||||
|
<span>{option.label}p</span>
|
||||||
|
</media-chrome-menu-item>
|
||||||
|
{/each}
|
||||||
|
</media-chrome-menu>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
media-chrome-menu-item {
|
||||||
|
padding: 0.4em 0.8em 0.4em 1em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { CustomVideoElement } from 'custom-media-element';
|
||||||
|
import { videoSessionManager } from '$lib/managers/video-session-manager.svelte';
|
||||||
|
import type { VideoController } from '$lib/utils/video/controller.svelte';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Video backed by either HLS or a progressive stream based on feature flags and user preferences. Can be managed with
|
||||||
|
* `videoSessionManager.get(assetId)`, this manager being what allows it to reparent the underlying video element.
|
||||||
|
*/
|
||||||
|
class ImmichVideoElement extends CustomVideoElement {
|
||||||
|
static override get observedAttributes() {
|
||||||
|
return [...super.observedAttributes, 'asset-id', 'play-original'];
|
||||||
|
}
|
||||||
|
|
||||||
|
#controller: VideoController | undefined;
|
||||||
|
#mountedAssetId: string | undefined;
|
||||||
|
#remountScheduled = false;
|
||||||
|
|
||||||
|
override connectedCallback() {
|
||||||
|
super.connectedCallback();
|
||||||
|
this.#mount();
|
||||||
|
}
|
||||||
|
|
||||||
|
override disconnectedCallback() {
|
||||||
|
super.disconnectedCallback();
|
||||||
|
this.#unmount();
|
||||||
|
}
|
||||||
|
|
||||||
|
override attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null) {
|
||||||
|
super.attributeChangedCallback(name, oldValue, newValue);
|
||||||
|
if (!this.isConnected || !this.#controller || oldValue === newValue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (name === 'play-original') {
|
||||||
|
this.#controller.playOriginal = newValue === 'true';
|
||||||
|
} else if (name === 'asset-id' && !this.#remountScheduled) {
|
||||||
|
this.#remountScheduled = true;
|
||||||
|
queueMicrotask(() => {
|
||||||
|
this.#remountScheduled = false;
|
||||||
|
if (this.isConnected) {
|
||||||
|
this.#unmount();
|
||||||
|
this.#mount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#mount() {
|
||||||
|
const assetId = this.getAttribute('asset-id');
|
||||||
|
if (!assetId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const controller = videoSessionManager.acquire({
|
||||||
|
assetId,
|
||||||
|
cacheKey: this.getAttribute('cache-key') || null,
|
||||||
|
playOriginal: this.getAttribute('play-original') === 'true',
|
||||||
|
});
|
||||||
|
this.#controller = controller;
|
||||||
|
this.#mountedAssetId = assetId;
|
||||||
|
|
||||||
|
const video = controller.element;
|
||||||
|
video.slot = 'media';
|
||||||
|
video.loop = this.loop;
|
||||||
|
video.autoplay = this.autoplay;
|
||||||
|
video.muted = this.muted || this.hasAttribute('muted');
|
||||||
|
video.poster = this.getAttribute('poster') ?? '';
|
||||||
|
controller.mount(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
#unmount() {
|
||||||
|
if (!this.#mountedAssetId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.#controller?.unmount(this);
|
||||||
|
videoSessionManager.release(this.#mountedAssetId);
|
||||||
|
this.#controller = undefined;
|
||||||
|
this.#mountedAssetId = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (globalThis.customElements && !customElements.get('immich-video')) {
|
||||||
|
customElements.define('immich-video', ImmichVideoElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ImmichVideoElement;
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||||
import { mediaQueryManager } from '$lib/stores/media-query-manager.svelte';
|
import { mediaQueryManager } from '$lib/stores/media-query-manager.svelte';
|
||||||
import { locale, playVideoThumbnailOnHover } from '$lib/stores/preferences.store';
|
import { locale, playVideoThumbnailOnHover } from '$lib/stores/preferences.store';
|
||||||
import { getAssetMediaUrl, getAssetPlaybackUrl } from '$lib/utils';
|
import { getAssetMediaUrl } from '$lib/utils';
|
||||||
import { moveFocus } from '$lib/utils/focus-util';
|
import { moveFocus } from '$lib/utils/focus-util';
|
||||||
import { currentUrlReplaceAssetId } from '$lib/utils/navigation';
|
import { currentUrlReplaceAssetId } from '$lib/utils/navigation';
|
||||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||||
@@ -264,7 +264,8 @@
|
|||||||
<div class="pointer-events-none absolute size-full group-focus-visible:rounded-lg">
|
<div class="pointer-events-none absolute size-full group-focus-visible:rounded-lg">
|
||||||
<VideoThumbnail
|
<VideoThumbnail
|
||||||
class="group-focus-visible:rounded-lg"
|
class="group-focus-visible:rounded-lg"
|
||||||
url={getAssetPlaybackUrl({ id: asset.id, cacheKey: asset.thumbhash })}
|
assetId={asset.id}
|
||||||
|
cacheKey={asset.thumbhash}
|
||||||
enablePlayback={mouseOver && $playVideoThumbnailOnHover}
|
enablePlayback={mouseOver && $playVideoThumbnailOnHover}
|
||||||
curve={selected}
|
curve={selected}
|
||||||
durationInSeconds={asset.duration ? asset.duration / 1000 : 0}
|
durationInSeconds={asset.duration ? asset.duration / 1000 : 0}
|
||||||
@@ -275,7 +276,8 @@
|
|||||||
<div class="pointer-events-none absolute size-full group-focus-visible:rounded-lg">
|
<div class="pointer-events-none absolute size-full group-focus-visible:rounded-lg">
|
||||||
<VideoThumbnail
|
<VideoThumbnail
|
||||||
class="group-focus-visible:rounded-lg"
|
class="group-focus-visible:rounded-lg"
|
||||||
url={getAssetPlaybackUrl({ id: asset.livePhotoVideoId, cacheKey: asset.thumbhash })}
|
assetId={asset.livePhotoVideoId}
|
||||||
|
cacheKey={asset.thumbhash}
|
||||||
enablePlayback={mouseOver && $playVideoThumbnailOnHover}
|
enablePlayback={mouseOver && $playVideoThumbnailOnHover}
|
||||||
pauseIcon={mdiMotionPauseOutline}
|
pauseIcon={mdiMotionPauseOutline}
|
||||||
playIcon={mdiMotionPlayOutline}
|
playIcon={mdiMotionPlayOutline}
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { cleanClass } from '$lib';
|
import { cleanClass } from '$lib';
|
||||||
|
import '$lib/components/asset-viewer/immich-video-element';
|
||||||
|
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||||
|
import { videoSessionManager } from '$lib/managers/video-session-manager.svelte';
|
||||||
import { Icon, LoadingSpinner } from '@immich/ui';
|
import { Icon, LoadingSpinner } from '@immich/ui';
|
||||||
import { mdiAlertCircleOutline, mdiPauseCircleOutline, mdiPlayCircleOutline } from '@mdi/js';
|
import { mdiAlertCircleOutline, mdiPauseCircleOutline, mdiPlayCircleOutline } from '@mdi/js';
|
||||||
import { Duration } from 'luxon';
|
import { Duration } from 'luxon';
|
||||||
import type { ClassValue } from 'svelte/elements';
|
import type { ClassValue } from 'svelte/elements';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
url: string;
|
assetId: string;
|
||||||
|
cacheKey: string | null;
|
||||||
durationInSeconds?: number;
|
durationInSeconds?: number;
|
||||||
enablePlayback?: boolean;
|
enablePlayback?: boolean;
|
||||||
playbackOnIconHover?: boolean;
|
playbackOnIconHover?: boolean;
|
||||||
@@ -18,7 +22,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
url,
|
assetId,
|
||||||
|
cacheKey,
|
||||||
durationInSeconds = 0,
|
durationInSeconds = 0,
|
||||||
enablePlayback = $bindable(false),
|
enablePlayback = $bindable(false),
|
||||||
playbackOnIconHover = false,
|
playbackOnIconHover = false,
|
||||||
@@ -29,26 +34,27 @@
|
|||||||
class: className,
|
class: className,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let remainingSeconds = $state(durationInSeconds);
|
const useHls = $derived(featureFlagsManager.value.realtimeTranscoding);
|
||||||
let loading = $state(true);
|
|
||||||
let error = $state(false);
|
let active = $state(false);
|
||||||
let player: HTMLVideoElement | undefined = $state();
|
const controller = $derived(videoSessionManager.get(assetId));
|
||||||
|
const remainingSeconds = $derived(controller?.remainingSeconds || durationInSeconds);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!enablePlayback) {
|
if (!enablePlayback) {
|
||||||
remainingSeconds = durationInSeconds;
|
active = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!player) {
|
if (!useHls) {
|
||||||
|
active = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const video = player;
|
// Cold-starting a transcode for every thumbnail the pointer brushes over would hammer the server,
|
||||||
return () => {
|
// so wait for the hover to settle before opening an HLS session.
|
||||||
video.pause();
|
const timer = setTimeout(() => (active = true), 200);
|
||||||
video.removeAttribute('src');
|
return () => clearTimeout(timer);
|
||||||
video.load();
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const onMouseEnter = () => {
|
const onMouseEnter = () => {
|
||||||
if (playbackOnIconHover) {
|
if (playbackOnIconHover) {
|
||||||
enablePlayback = true;
|
enablePlayback = true;
|
||||||
@@ -62,35 +68,15 @@
|
|||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if enablePlayback}
|
{#if active}
|
||||||
<video
|
<immich-video
|
||||||
bind:this={player}
|
asset-id={assetId}
|
||||||
class={cleanClass('h-full w-full object-cover', className)}
|
cache-key={cacheKey ?? ''}
|
||||||
class:rounded-xl={curve}
|
|
||||||
muted
|
muted
|
||||||
autoplay
|
|
||||||
loop
|
loop
|
||||||
src={url}
|
autoplay
|
||||||
onplay={() => {
|
class={cleanClass('h-full w-full [--media-object-fit:cover]', className, curve && 'rounded-xl overflow-hidden')}
|
||||||
loading = false;
|
></immich-video>
|
||||||
error = false;
|
|
||||||
}}
|
|
||||||
onerror={() => {
|
|
||||||
if (!player?.src) {
|
|
||||||
// Do not show error when the URL is empty.
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
error = true;
|
|
||||||
loading = false;
|
|
||||||
}}
|
|
||||||
ontimeupdate={({ currentTarget }) => {
|
|
||||||
const remaining = currentTarget.duration - currentTarget.currentTime;
|
|
||||||
remainingSeconds = Math.min(
|
|
||||||
Math.ceil(Number.isNaN(remaining) ? Number.POSITIVE_INFINITY : remaining),
|
|
||||||
durationInSeconds,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
></video>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -114,10 +100,10 @@
|
|||||||
onmouseenter={onMouseEnter}
|
onmouseenter={onMouseEnter}
|
||||||
onmouseleave={onMouseLeave}
|
onmouseleave={onMouseLeave}
|
||||||
>
|
>
|
||||||
{#if enablePlayback}
|
{#if active}
|
||||||
{#if loading}
|
{#if !controller || controller.loading}
|
||||||
<LoadingSpinner size="large" />
|
<LoadingSpinner size="large" />
|
||||||
{:else if error}
|
{:else if controller.error}
|
||||||
<Icon icon={mdiAlertCircleOutline} size="24" class="text-red-600" />
|
<Icon icon={mdiAlertCircleOutline} size="24" class="text-red-600" />
|
||||||
{:else}
|
{:else}
|
||||||
<Icon icon={pauseIcon} size="24" />
|
<Icon icon={pauseIcon} size="24" />
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { SvelteMap } from 'svelte/reactivity';
|
||||||
|
import { VideoController, type VideoControllerOptions } from '$lib/utils/video/controller.svelte';
|
||||||
|
|
||||||
|
interface Session {
|
||||||
|
controller: VideoController;
|
||||||
|
refs: number;
|
||||||
|
timer?: NodeJS.Timeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registry of controllers keyed by asset, ref-counted with a grace period. `<immich-video>` acquires and
|
||||||
|
* releases as it connects and disconnects, with controllers kept briefly before being disposed. This enables
|
||||||
|
* reuse of bandwidth estimation, downloaded segments, HLS session, etc. for seamless handoff.
|
||||||
|
*/
|
||||||
|
class VideoSessionManager {
|
||||||
|
#sessions = new SvelteMap<string, Session>();
|
||||||
|
|
||||||
|
acquire(options: VideoControllerOptions): VideoController {
|
||||||
|
const existing = this.#sessions.get(options.assetId);
|
||||||
|
if (existing) {
|
||||||
|
clearTimeout(existing.timer);
|
||||||
|
existing.timer = undefined;
|
||||||
|
existing.refs++;
|
||||||
|
return existing.controller;
|
||||||
|
}
|
||||||
|
const controller = new VideoController(options);
|
||||||
|
this.#sessions.set(options.assetId, { controller, refs: 1, timer: undefined });
|
||||||
|
return controller;
|
||||||
|
}
|
||||||
|
|
||||||
|
release(assetId: string) {
|
||||||
|
const session = this.#sessions.get(assetId);
|
||||||
|
if (!session || --session.refs > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
session.timer = setTimeout(() => {
|
||||||
|
session.controller.release();
|
||||||
|
this.#sessions.delete(assetId);
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
get(assetId: string): VideoController | undefined {
|
||||||
|
return this.#sessions.get(assetId)?.controller;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const videoSessionManager = new VideoSessionManager();
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import { AssetMediaSize } from '@immich/sdk';
|
||||||
|
import Hls, { type ErrorData, type Level } from 'hls.js';
|
||||||
|
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||||
|
import { getAssetHlsUrl, getAssetMediaUrl, getAssetPlaybackUrl } from '$lib/utils';
|
||||||
|
import { createHls, filterEfficientLevels, getHlsSessionId, releaseHlsSession } from '$lib/utils/video/hls';
|
||||||
|
|
||||||
|
export interface VideoControllerOptions {
|
||||||
|
assetId: string;
|
||||||
|
cacheKey: string | null;
|
||||||
|
playOriginal: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HLS_MIME = 'application/x-mpegURL';
|
||||||
|
const MAX_REBUILDS = 1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns a single, long-lived `<video>` for an asset and all of its playback wiring.
|
||||||
|
* Because the controller owns the element, hosts can {@link mount} it and hand it off by re-parenting, enabling
|
||||||
|
* bandwidth estimation, buffer, HLS session, playback position, etc. to survive.
|
||||||
|
*/
|
||||||
|
export class VideoController {
|
||||||
|
readonly element: HTMLVideoElement;
|
||||||
|
|
||||||
|
loading = $state(true);
|
||||||
|
error = $state(false);
|
||||||
|
currentTime = $state(0);
|
||||||
|
duration = $state(0);
|
||||||
|
levels = $state<Level[]>([]);
|
||||||
|
selectedLevel = $state(-1);
|
||||||
|
|
||||||
|
private assetId: string;
|
||||||
|
private cacheKey: string | null;
|
||||||
|
private api: Hls | undefined;
|
||||||
|
private sourceTeardown: (() => void) | undefined;
|
||||||
|
private started = false;
|
||||||
|
private wasPlaying = false;
|
||||||
|
private rebuilds = 0;
|
||||||
|
|
||||||
|
#level = $state(-1);
|
||||||
|
#playOriginal: boolean;
|
||||||
|
|
||||||
|
constructor({ assetId, cacheKey, playOriginal }: VideoControllerOptions) {
|
||||||
|
this.assetId = assetId;
|
||||||
|
this.cacheKey = cacheKey;
|
||||||
|
this.#playOriginal = playOriginal;
|
||||||
|
|
||||||
|
const element = document.createElement('video');
|
||||||
|
element.setAttribute('playsinline', '');
|
||||||
|
element.setAttribute('disablepictureinpicture', '');
|
||||||
|
element.addEventListener('play', () => {
|
||||||
|
this.loading = false;
|
||||||
|
this.error = false;
|
||||||
|
});
|
||||||
|
element.addEventListener('error', () => {
|
||||||
|
this.error = true;
|
||||||
|
this.loading = false;
|
||||||
|
});
|
||||||
|
element.addEventListener('timeupdate', () => {
|
||||||
|
this.currentTime = element.currentTime;
|
||||||
|
this.duration = element.duration;
|
||||||
|
});
|
||||||
|
this.element = element;
|
||||||
|
}
|
||||||
|
|
||||||
|
mount(container: HTMLElement) {
|
||||||
|
container.append(this.element);
|
||||||
|
if (!this.started) {
|
||||||
|
this.started = true;
|
||||||
|
const useHls = featureFlagsManager.value.realtimeTranscoding && !this.#playOriginal;
|
||||||
|
this.sourceTeardown = useHls ? this.attachHls() : this.attachProgressive();
|
||||||
|
} else if (this.wasPlaying) {
|
||||||
|
void this.element.play().catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unmount(container: HTMLElement) {
|
||||||
|
if (this.element.parentElement === container) {
|
||||||
|
this.wasPlaying = !this.element.paused;
|
||||||
|
this.element.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
release() {
|
||||||
|
this.sourceTeardown?.();
|
||||||
|
this.sourceTeardown = undefined;
|
||||||
|
this.element.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
get remainingSeconds() {
|
||||||
|
return Math.max(0, this.duration - this.currentTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
set playOriginal(playOriginal: boolean) {
|
||||||
|
if (this.#playOriginal === playOriginal) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.#playOriginal = playOriginal;
|
||||||
|
if (!this.started) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.sourceTeardown?.();
|
||||||
|
const useHls = featureFlagsManager.value.realtimeTranscoding && !playOriginal;
|
||||||
|
this.sourceTeardown = useHls ? this.attachHls() : this.attachProgressive();
|
||||||
|
}
|
||||||
|
|
||||||
|
get level() {
|
||||||
|
return this.#level;
|
||||||
|
}
|
||||||
|
|
||||||
|
set level(index: number) {
|
||||||
|
if (!this.api) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// -1 re-enables ABR without flushing
|
||||||
|
if (index === -1) {
|
||||||
|
this.api.loadLevel = -1;
|
||||||
|
} else {
|
||||||
|
this.api.currentLevel = index;
|
||||||
|
}
|
||||||
|
this.selectedLevel = index;
|
||||||
|
}
|
||||||
|
|
||||||
|
private attachProgressive() {
|
||||||
|
this.element.src = this.#playOriginal
|
||||||
|
? getAssetMediaUrl({ id: this.assetId, size: AssetMediaSize.Original, cacheKey: this.cacheKey })
|
||||||
|
: getAssetPlaybackUrl({ id: this.assetId, cacheKey: this.cacheKey });
|
||||||
|
return () => this.detachSource();
|
||||||
|
}
|
||||||
|
|
||||||
|
private detachSource() {
|
||||||
|
this.element.pause();
|
||||||
|
this.element.removeAttribute('src');
|
||||||
|
this.element.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
private attachHls(startPosition = -1): () => void {
|
||||||
|
const video = this.element;
|
||||||
|
// Old iOS versions don't support Media Source Extensions
|
||||||
|
if (!Hls.isSupported()) {
|
||||||
|
return this.attachNativeHls();
|
||||||
|
}
|
||||||
|
|
||||||
|
const hls = createHls({ autoStartLoad: false });
|
||||||
|
this.api = hls;
|
||||||
|
let sessionId: string | undefined;
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||||
|
hls.on(Hls.Events.MANIFEST_PARSED, async () => {
|
||||||
|
sessionId = getHlsSessionId(hls);
|
||||||
|
await filterEfficientLevels(hls);
|
||||||
|
this.levels = hls.levels;
|
||||||
|
hls.attachMedia(video); // Need to attach after filtering for the auto size cap to work
|
||||||
|
hls.startLoad(startPosition);
|
||||||
|
// autoStartLoad defers the first fragment, so the `autoplay` attribute may have already fired and done nothing
|
||||||
|
if (video.autoplay && video.paused) {
|
||||||
|
void video.play().catch(() => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
hls.on(Hls.Events.LEVELS_UPDATED, (_, data) => (this.levels = data.levels));
|
||||||
|
hls.on(Hls.Events.LEVEL_SWITCHED, (_, data) => (this.#level = data.level));
|
||||||
|
hls.on(Hls.Events.FRAG_LOADED, () => (this.rebuilds = 0));
|
||||||
|
hls.on(Hls.Events.ERROR, (_, data) => this.onHlsError(data));
|
||||||
|
|
||||||
|
const onPageHide = (event: PageTransitionEvent) => {
|
||||||
|
if (!event.persisted && sessionId) {
|
||||||
|
releaseHlsSession(this.assetId, sessionId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('pagehide', onPageHide);
|
||||||
|
|
||||||
|
hls.loadSource(getAssetHlsUrl(this.assetId));
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('pagehide', onPageHide);
|
||||||
|
if (sessionId) {
|
||||||
|
releaseHlsSession(this.assetId, sessionId);
|
||||||
|
}
|
||||||
|
hls.destroy();
|
||||||
|
this.api = undefined;
|
||||||
|
this.levels = [];
|
||||||
|
this.#level = -1;
|
||||||
|
this.selectedLevel = -1;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private attachNativeHls() {
|
||||||
|
if (this.element.canPlayType(HLS_MIME)) {
|
||||||
|
this.element.src = getAssetHlsUrl(this.assetId);
|
||||||
|
} else {
|
||||||
|
this.error = true;
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
return () => this.detachSource();
|
||||||
|
}
|
||||||
|
|
||||||
|
private onHlsError(data: ErrorData) {
|
||||||
|
// A fragment 404 usually means the server session expired (e.g. after a long pause). Rebuild it
|
||||||
|
// once, resuming where we left off, before giving up.
|
||||||
|
if (
|
||||||
|
data.fatal &&
|
||||||
|
data.details === Hls.ErrorDetails.FRAG_LOAD_ERROR &&
|
||||||
|
data.response?.code === 404 &&
|
||||||
|
this.rebuilds < MAX_REBUILDS
|
||||||
|
) {
|
||||||
|
this.rebuilds++;
|
||||||
|
this.loading = true;
|
||||||
|
this.error = false;
|
||||||
|
const resume = this.element.currentTime;
|
||||||
|
this.sourceTeardown?.();
|
||||||
|
this.sourceTeardown = this.attachHls(resume);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data.fatal) {
|
||||||
|
console.error('Fatal HLS error', data.details, data.response?.code);
|
||||||
|
this.error = true;
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import Hls, {
|
||||||
|
AbrController,
|
||||||
|
Events,
|
||||||
|
FetchLoader,
|
||||||
|
type FragLoadedData,
|
||||||
|
type FragLoadingData,
|
||||||
|
type HlsConfig,
|
||||||
|
} from 'hls.js';
|
||||||
|
import { debounce } from 'lodash-es';
|
||||||
|
import { mediaCapabilitiesManager } from '$lib/managers/media-capabilities-manager.svelte';
|
||||||
|
import { getAssetHlsSessionUrl } from '$lib/utils';
|
||||||
|
|
||||||
|
const HLS_TARGET_SEGMENT_HEADER = 'x-immich-hls-msn';
|
||||||
|
const RESIZE_FLUSH_DEBOUNCE_MS = 150;
|
||||||
|
const SESSION_ID_REGEX = /\/video\/stream\/([0-9a-f-]{36})\//;
|
||||||
|
|
||||||
|
// hls.js can abandon fetching an in-flight fragment if it thinks it'll take too long, in which case
|
||||||
|
// it emergency switches to a different variant. This extends the delay even further due to
|
||||||
|
// cold starting another transcode, so let the fragment finish and have steady ABR decide the next level.
|
||||||
|
//
|
||||||
|
// It can also emergency switch between fragments: while a switch's first segment is still loading,
|
||||||
|
// it can run out of buffer and drop to a lower level for just one segment before continuing at the switched quality.
|
||||||
|
// This can cause multiple redundant transcoding restarts when it occurs.
|
||||||
|
// Hold the committed level until its first fragment lands, then resume normal ABR.
|
||||||
|
export class NoAbandonAbrController extends AbrController {
|
||||||
|
private switchTarget = -1;
|
||||||
|
|
||||||
|
protected override onFragLoading(_event: Events.FRAG_LOADING, data: FragLoadingData) {
|
||||||
|
if (data.frag.sn === 'initSegment') {
|
||||||
|
this.switchTarget = data.frag.level;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override onFragLoaded(event: Events.FRAG_LOADED, data: FragLoadedData) {
|
||||||
|
if (data.frag.sn !== 'initSegment') {
|
||||||
|
this.switchTarget = -1;
|
||||||
|
}
|
||||||
|
super.onFragLoaded(event, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
override get nextAutoLevel(): number {
|
||||||
|
const level = super.nextAutoLevel;
|
||||||
|
const target = this.hls.levels[this.switchTarget];
|
||||||
|
// Hold the committed level, but only while hls.js still considers it healthy.
|
||||||
|
if (target && level < this.switchTarget && target.loadError === 0 && target.fragmentError === 0) {
|
||||||
|
return this.switchTarget;
|
||||||
|
}
|
||||||
|
return level;
|
||||||
|
}
|
||||||
|
|
||||||
|
override set nextAutoLevel(level: number) {
|
||||||
|
super.nextAutoLevel = level;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// hls.js flushes the forward buffer on a level switch so the new variant surfaces quickly, but requests can happen
|
||||||
|
// out of order and cause unnecessary transcodes since it leaves the loader running during the flush.
|
||||||
|
// This version stops the loader until the buffer is flushed so segment requests stay monotonic.
|
||||||
|
class FlushAheadStreamController extends Hls.DefaultConfig.streamController {
|
||||||
|
#flushPending = false;
|
||||||
|
#flushAhead = debounce(() => this.#switchAhead(), RESIZE_FLUSH_DEBOUNCE_MS);
|
||||||
|
|
||||||
|
override nextLevelSwitch() {
|
||||||
|
this.#flushAhead();
|
||||||
|
}
|
||||||
|
|
||||||
|
#switchAhead() {
|
||||||
|
const { media, hls, levels, playlistType } = this;
|
||||||
|
if (!media?.readyState || !levels || this.#flushPending) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const bufferInfo = this.getFwdBufferInfo(this.getBufferOutput(), playlistType);
|
||||||
|
const nextLevel = levels[hls.nextLoadLevel];
|
||||||
|
if (!bufferInfo || !nextLevel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { fetchdelay, okToFlushForwardBuffer } = this.calculateOptimalSwitchPoint(nextLevel, bufferInfo);
|
||||||
|
const flushFrom = this.playhead + fetchdelay;
|
||||||
|
if (!okToFlushForwardBuffer || bufferInfo.end <= flushFrom) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.#flushPending = true;
|
||||||
|
hls.stopLoad();
|
||||||
|
hls.once(Events.BUFFER_FLUSHED, () => {
|
||||||
|
this.#flushPending = false;
|
||||||
|
hls.startLoad();
|
||||||
|
});
|
||||||
|
hls.trigger(Events.BUFFER_FLUSHING, {
|
||||||
|
startOffset: flushFrom,
|
||||||
|
endOffset: Number.POSITIVE_INFINITY,
|
||||||
|
type: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createHls = (overrides?: Partial<HlsConfig>): Hls => {
|
||||||
|
const hls = new Hls({
|
||||||
|
abrController: NoAbandonAbrController,
|
||||||
|
loader: FetchLoader,
|
||||||
|
capLevelToPlayerSize: true,
|
||||||
|
streamController: FlushAheadStreamController,
|
||||||
|
testBandwidth: false,
|
||||||
|
highBufferWatchdogPeriod: 10,
|
||||||
|
detectStallWithCurrentTimeMs: 10_000,
|
||||||
|
maxBufferHole: 0.5,
|
||||||
|
maxBufferLength: 30,
|
||||||
|
maxMaxBufferLength: 60,
|
||||||
|
fragLoadPolicy: {
|
||||||
|
default: {
|
||||||
|
maxTimeToFirstByteMs: 30_000,
|
||||||
|
maxLoadTimeMs: 60_000,
|
||||||
|
timeoutRetry: { maxNumRetry: 5, retryDelayMs: 100, maxRetryDelayMs: 0 },
|
||||||
|
errorRetry: { maxNumRetry: 3, retryDelayMs: 1000, maxRetryDelayMs: 8000 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
useMediaCapabilities: false,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
// init.mp4 carries no segment number, but the server needs to know which segment an init.mp4
|
||||||
|
// is for so it can start the transcode there. It sometimes can't infer this since segments can
|
||||||
|
// be loaded from browser cache and the server might not know where the client really is as a result.
|
||||||
|
// Let the client hint the target segment it's switching to with a custom header.
|
||||||
|
hls.config.fetchSetup = (context, initParams) => {
|
||||||
|
const frag = (context as { frag?: { sn: number | 'initSegment' } }).frag;
|
||||||
|
if (frag?.sn === 'initSegment') {
|
||||||
|
const sn = hls.inFlightFragments.main.frag?.sn;
|
||||||
|
if (typeof sn === 'number') {
|
||||||
|
(initParams.headers as Headers).set(HLS_TARGET_SEGMENT_HEADER, String(sn));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new Request(context.url, initParams);
|
||||||
|
};
|
||||||
|
|
||||||
|
return hls;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getHlsSessionId = (api: Hls): string | undefined => {
|
||||||
|
return api.levels[0]?.url[0]?.match(SESSION_ID_REGEX)?.[1];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const releaseHlsSession = (assetId: string, sessionId: string) => {
|
||||||
|
const url = getAssetHlsSessionUrl(assetId, sessionId);
|
||||||
|
void fetch(url, { method: 'DELETE' }).catch(() =>
|
||||||
|
console.warn('Failed to release HLS session', { assetId, sessionId }),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Drop every variant the browser can't hardware-decode efficiently, keeping one per resolution. */
|
||||||
|
export const filterEfficientLevels = async (api: Hls) => {
|
||||||
|
const keep = await mediaCapabilitiesManager.efficientLevels(api.levels);
|
||||||
|
for (let i = api.levels.length - 1; i >= 0; i--) {
|
||||||
|
if (!keep.has(i)) {
|
||||||
|
api.removeLevel(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
+11
-15
@@ -1,8 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import '$lib/components/asset-viewer/immich-video-element';
|
||||||
import { assetViewerFadeDuration } from '$lib/constants';
|
import { assetViewerFadeDuration } from '$lib/constants';
|
||||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||||
import { autoPlayVideo } from '$lib/stores/preferences.store';
|
import { autoPlayVideo } from '$lib/stores/preferences.store';
|
||||||
import { getAssetMediaUrl, getAssetPlaybackUrl } from '$lib/utils';
|
import { getAssetMediaUrl } from '$lib/utils';
|
||||||
import { AssetMediaSize } from '@immich/sdk';
|
import { AssetMediaSize } from '@immich/sdk';
|
||||||
import 'media-chrome/media-controller';
|
import 'media-chrome/media-controller';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
@@ -10,13 +11,11 @@
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
asset: TimelineAsset;
|
asset: TimelineAsset;
|
||||||
videoPlayer: HTMLVideoElement | undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let { asset, videoPlayer = $bindable() }: Props = $props();
|
let { asset }: Props = $props();
|
||||||
|
|
||||||
let showVideo: boolean = $state(false);
|
|
||||||
|
|
||||||
|
let showVideo = $state(false);
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
// Show video after mount to ensure fading in.
|
// Show video after mount to ensure fading in.
|
||||||
showVideo = true;
|
showVideo = true;
|
||||||
@@ -24,20 +23,17 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if showVideo}
|
{#if showVideo}
|
||||||
<div class="bg-pink-9000 size-full" transition:fade={{ duration: assetViewerFadeDuration }}>
|
<div class="size-full" transition:fade={{ duration: assetViewerFadeDuration }}>
|
||||||
<media-controller id="memory-video" nohotkeys class="size-full rounded-2xl object-contain transition-all">
|
<media-controller id="memory-video" nohotkeys class="size-full rounded-2xl object-contain transition-all">
|
||||||
<!-- svelte-ignore a11y_media_has_caption -->
|
<immich-video
|
||||||
<video
|
|
||||||
bind:this={videoPlayer}
|
|
||||||
slot="media"
|
slot="media"
|
||||||
autoplay={$autoPlayVideo}
|
asset-id={asset.id}
|
||||||
playsinline
|
cache-key={asset.thumbhash ?? ''}
|
||||||
disablepictureinpicture
|
|
||||||
class="size-full"
|
class="size-full"
|
||||||
src={getAssetPlaybackUrl({ id: asset.id })}
|
|
||||||
poster={getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Preview })}
|
|
||||||
draggable="false"
|
draggable="false"
|
||||||
></video>
|
autoplay={$autoPlayVideo}
|
||||||
|
poster={getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Preview })}
|
||||||
|
></immich-video>
|
||||||
</media-controller>
|
</media-controller>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||||
import { memoryManager, type MemoryAsset } from '$lib/managers/memory-manager.svelte';
|
import { memoryManager, type MemoryAsset } from '$lib/managers/memory-manager.svelte';
|
||||||
import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types';
|
import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types';
|
||||||
|
import { videoSessionManager } from '$lib/managers/video-session-manager.svelte';
|
||||||
import { Route } from '$lib/route';
|
import { Route } from '$lib/route';
|
||||||
import { getAssetBulkActions } from '$lib/services/asset.service';
|
import { getAssetBulkActions } from '$lib/services/asset.service';
|
||||||
import { locale } from '$lib/stores/preferences.store';
|
import { locale } from '$lib/stores/preferences.store';
|
||||||
@@ -80,7 +81,7 @@
|
|||||||
// need to include padding in the viewport for gallery
|
// need to include padding in the viewport for gallery
|
||||||
const galleryViewport: Viewport = $derived({ height: viewport.height, width: viewport.width - 32 });
|
const galleryViewport: Viewport = $derived({ height: viewport.height, width: viewport.width - 32 });
|
||||||
let progressBarController: Tween<number> | undefined = $state(undefined);
|
let progressBarController: Tween<number> | undefined = $state(undefined);
|
||||||
let videoPlayer: HTMLVideoElement | undefined = $state();
|
const videoPlayer = $derived(currentAssetId ? videoSessionManager.get(currentAssetId)?.element : undefined);
|
||||||
const asHref = (asset: { id: string }) => `?${QueryParameter.ID}=${asset.id}`;
|
const asHref = (asset: { id: string }) => `?${QueryParameter.ID}=${asset.id}`;
|
||||||
|
|
||||||
const handleNavigate = async (asset?: { id: string }) => {
|
const handleNavigate = async (asset?: { id: string }) => {
|
||||||
@@ -516,7 +517,7 @@
|
|||||||
<div class="relative size-full rounded-2xl bg-black">
|
<div class="relative size-full rounded-2xl bg-black">
|
||||||
{#key current.asset.id}
|
{#key current.asset.id}
|
||||||
{#if current.asset.isVideo}
|
{#if current.asset.isVideo}
|
||||||
<MemoryVideoViewer asset={current.asset} bind:videoPlayer />
|
<MemoryVideoViewer asset={current.asset} />
|
||||||
{:else}
|
{:else}
|
||||||
<MemoryPhotoViewer asset={current.asset} onImageLoad={resetAndPlay} />
|
<MemoryPhotoViewer asset={current.asset} onImageLoad={resetAndPlay} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
Reference in New Issue
Block a user