From 4ea2cd7818672b5bbc0c4bd2f64aa741d3a264b3 Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Fri, 5 Jun 2026 19:39:02 -0400 Subject: [PATCH] unify hls playback minor simplifications capLevelToPlayerSize custom media element minor tweaks respect playOriginal cleanup --- pnpm-lock.yaml | 34 +-- web/package.json | 4 +- web/src/app.css | 6 + .../asset-viewer/VideoNativeViewer.svelte | 255 +++--------------- .../asset-viewer/VideoQualityMenu.svelte | 57 ++++ .../asset-viewer/immich-video-element.ts | 84 ++++++ .../assets/thumbnail/Thumbnail.svelte | 8 +- .../assets/thumbnail/VideoThumbnail.svelte | 74 +++-- .../managers/video-session-manager.svelte.ts | 47 ++++ web/src/lib/utils/video/controller.svelte.ts | 219 +++++++++++++++ web/src/lib/utils/video/hls.ts | 157 +++++++++++ .../[[assetId=id]]/MemoryVideoViewer.svelte | 33 ++- 12 files changed, 670 insertions(+), 308 deletions(-) create mode 100644 web/src/lib/components/asset-viewer/VideoQualityMenu.svelte create mode 100644 web/src/lib/components/asset-viewer/immich-video-element.ts create mode 100644 web/src/lib/managers/video-session-manager.svelte.ts create mode 100644 web/src/lib/utils/video/controller.svelte.ts create mode 100644 web/src/lib/utils/video/hls.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7bb2289996..8d9d76487e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -807,6 +807,9 @@ importers: '@zoom-image/svelte': specifier: ^0.3.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: specifier: ^2.6.0 version: 2.6.0 @@ -825,12 +828,9 @@ importers: happy-dom: specifier: ^20.0.0 version: 20.10.3 - hls-video-element: - specifier: ^1.5.11 - version: 1.5.11 hls.js: - specifier: ^1.6.16 - version: 1.6.16 + specifier: 1.7.0-beta.1.0.canary.11837 + version: 1.7.0-beta.1.0.canary.11837 intl-messageformat: specifier: ^11.0.0 version: 11.2.8 @@ -8327,11 +8327,8 @@ packages: history@4.10.1: resolution: {integrity: sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==} - hls-video-element@1.5.11: - resolution: {integrity: sha512-tJJ65/52CDxj8XFyIve6zT9nVVdUIc6mqvKR25X0ycPKHk07rpjp4xxVteeCefDUBSf/tFLhlICFmn3KWj37xA==} - - hls.js@1.6.16: - resolution: {integrity: sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==} + hls.js@1.7.0-beta.1.0.canary.11837: + resolution: {integrity: sha512-JXSTYLvxCpJeD8xgYlIYzEA0Ag+1Vnkakl7y8JiS0RtgNPFgtsGjqqxCPFyYCmumi1CON6VtksohqeJoiBAKmw==} hogan.js@3.0.2: resolution: {integrity: sha512-RqGs4wavGYJWE07t35JQccByczmNUXQT0E12ZYV1VKYu5UiAU9lsos/yBAcf840+zrUQQxgVduCR5/B8nNtibg==} @@ -9339,11 +9336,8 @@ packages: mdn-data@2.28.1: resolution: {integrity: sha512-U9w+PzSZ00Z5m9rZ5ARVFL5xOfuCHdKYi/1RRwDCJsboFgJDNT3zT6PIPD7mZQYaQLhsZM3GfDRgSMRHhSmVng==} - media-chrome@4.19.2: - resolution: {integrity: sha512-4ai1ITN8wBhwugQcRgqe3tN0z6OSKGOXqHLNrS04MgKFfsLqu6Dm8MPq02pI9Y9ZKoXtFjIl85jOryIW9es3BA==} - - media-tracks@0.3.5: - resolution: {integrity: sha512-l54rkKXlLBt3ob3zOLWHcnjvwUmX5bNEZ70igyapOZZC9imzqBmq1oz8p2roiV04KhjblFIi2hetLPF1oYVLRA==} + 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==} @@ -21710,13 +21704,7 @@ snapshots: tiny-warning: 1.0.3 value-equal: 1.0.1 - hls-video-element@1.5.11: - dependencies: - custom-media-element: 1.4.6 - hls.js: 1.6.16 - media-tracks: 0.3.5 - - hls.js@1.6.16: {} + hls.js@1.7.0-beta.1.0.canary.11837: {} hogan.js@3.0.2: dependencies: @@ -22825,8 +22813,6 @@ snapshots: transitivePeerDependencies: - react - media-tracks@0.3.5: {} - media-typer@0.3.0: {} media-typer@1.1.0: {} diff --git a/web/package.json b/web/package.json index de74a5d6ed..09cdb2472e 100644 --- a/web/package.json +++ b/web/package.json @@ -40,14 +40,14 @@ "@types/geojson": "^7946.0.16", "@zoom-image/core": "^0.42.0", "@zoom-image/svelte": "^0.3.0", + "custom-media-element": "^1.4.6", "dom-to-image": "^2.6.0", "fabric": "^7.0.0", "geo-coordinates-parser": "^1.7.4", "geojson": "^0.5.0", "handlebars": "^4.7.8", "happy-dom": "^20.0.0", - "hls-video-element": "^1.5.11", - "hls.js": "^1.6.16", + "hls.js": "1.7.0-beta.1.0.canary.11837", "intl-messageformat": "^11.0.0", "justified-layout": "^4.1.0", "lodash-es": "^4.17.21", diff --git a/web/src/app.css b/web/src/app.css index 4435b255d2..9f6672e49f 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -176,3 +176,9 @@ @apply bg-subtle rounded-lg; } } + +immich-video > video { + width: 100%; + height: 100%; + object-fit: var(--media-object-fit, contain); +} diff --git a/web/src/lib/components/asset-viewer/VideoNativeViewer.svelte b/web/src/lib/components/asset-viewer/VideoNativeViewer.svelte index d218b512b6..ef66f96b7c 100644 --- a/web/src/lib/components/asset-viewer/VideoNativeViewer.svelte +++ b/web/src/lib/components/asset-viewer/VideoNativeViewer.svelte @@ -5,9 +5,11 @@ import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; import { castManager } from '$lib/managers/cast-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 { 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 { Icon, LoadingSpinner, shortcuts } from '@immich/ui'; import { @@ -23,9 +25,6 @@ mdiVolumeMedium, mdiVolumeMute, } 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-controller'; import 'media-chrome/media-fullscreen-button'; @@ -35,11 +34,10 @@ import 'media-chrome/media-time-display'; import 'media-chrome/media-volume-range'; 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-button'; 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 { t } from 'svelte-i18n'; import { fade } from 'svelte/transition'; @@ -73,7 +71,6 @@ onClose = () => {}, }: Props = $props(); - let videoPlayer: HTMLVideoElement | undefined = $state(); let isLoading = $state(true); let assetFileUrl = $derived.by(() => { if (featureFlagsManager.value.realtimeTranscoding) { @@ -88,182 +85,29 @@ }); const aspectRatio = $derived(asset.width && asset.height ? `${asset.width} / ${asset.height}` : undefined); let showVideo = $state(false); - let hasFocused = $state(false); - let activeSession: { assetId: string; id: string } | undefined; - let rebuildCount = 0; + let focusedAssetId = $state(); - const MAX_REBUILDS = 1; - 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. - 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 = { - 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)); - }); - }; + const controller = $derived(videoSessionManager.get(assetId)); // self-acquires the controller for the asset + const videoPlayer = $derived(controller?.element); onMount(() => { 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(() => { - // reactive on `assetFileUrl` changes - if (videoPlayer && assetFileUrl) { - hasFocused = false; - rebuildCount = 0; - releaseSession(); - if (isHlsElement(videoPlayer)) { - videoPlayer.config = hlsConfig; - videoPlayer.src = assetFileUrl; - const el = videoPlayer; - queueMicrotask(() => wireHlsListeners(el, assetId)); - } else { - videoPlayer.load(); - } + if (videoPlayer && videoPlayer.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA) { + void handleCanPlay(videoPlayer); } - return releaseSession; }); - const onPagehide = (event: PageTransitionEvent) => { - if (!event.persisted) { - releaseSession(); + const onPlaying = () => { + if (focusedAssetId !== assetId) { + videoPlayer?.focus(); + focusedAssetId = assetId; } }; - $effect(() => { - window.addEventListener('pagehide', onPagehide); - return () => window.removeEventListener('pagehide', onPagehide); - }); - - onDestroy(() => { - if (videoPlayer) { - videoPlayer.src = ''; - } - }); - const handleCanPlay = async (video: HTMLVideoElement) => { try { if (!video.paused) { @@ -352,53 +196,23 @@ class="dark h-full max-w-full" style:aspect-ratio={aspectRatio} defaultduration={asset.duration! / 1000} + {...useSwipe(onSwipe)} > - {#if featureFlagsManager.value.realtimeTranscoding} - handleCanPlay(e.currentTarget as HTMLVideoElement)} - onended={onVideoEnded} - onseeking={onSeeking} - onplaying={(e: Event) => { - if (!hasFocused) { - (e.currentTarget as HTMLElement).focus(); - hasFocused = true; - } - }} - onclose={onClose} - poster={getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Preview, cacheKey })} - > - {:else} - - {/if} + handleCanPlay(event.currentTarget as HTMLVideoElement)} + onended={onVideoEnded} + onseeking={onSeeking} + onplaying={onPlaying} + onclose={onClose} + > {#if extendedControls} diff --git a/web/src/lib/components/asset-viewer/VideoQualityMenu.svelte b/web/src/lib/components/asset-viewer/VideoQualityMenu.svelte new file mode 100644 index 0000000000..97ad3e39a7 --- /dev/null +++ b/web/src/lib/components/asset-viewer/VideoQualityMenu.svelte @@ -0,0 +1,57 @@ + + + + + diff --git a/web/src/lib/components/asset-viewer/immich-video-element.ts b/web/src/lib/components/asset-viewer/immich-video-element.ts new file mode 100644 index 0000000000..ad03844f7c --- /dev/null +++ b/web/src/lib/components/asset-viewer/immich-video-element.ts @@ -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; diff --git a/web/src/lib/components/assets/thumbnail/Thumbnail.svelte b/web/src/lib/components/assets/thumbnail/Thumbnail.svelte index c050ce4cea..ca166b1d0f 100644 --- a/web/src/lib/components/assets/thumbnail/Thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/Thumbnail.svelte @@ -4,7 +4,7 @@ import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import { mediaQueryManager } from '$lib/stores/media-query-manager.svelte'; 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 { currentUrlReplaceAssetId } from '$lib/utils/navigation'; import { getAltText } from '$lib/utils/thumbnail-util'; @@ -264,7 +264,8 @@
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 { mdiAlertCircleOutline, mdiPauseCircleOutline, mdiPlayCircleOutline } from '@mdi/js'; import { Duration } from 'luxon'; import type { ClassValue } from 'svelte/elements'; interface Props { - url: string; + assetId: string; + cacheKey: string | null; durationInSeconds?: number; enablePlayback?: boolean; playbackOnIconHover?: boolean; @@ -18,7 +22,8 @@ } let { - url, + assetId, + cacheKey, durationInSeconds = 0, enablePlayback = $bindable(false), playbackOnIconHover = false, @@ -29,26 +34,27 @@ class: className, }: Props = $props(); - let remainingSeconds = $state(durationInSeconds); - let loading = $state(true); - let error = $state(false); - let player: HTMLVideoElement | undefined = $state(); + const useHls = $derived(featureFlagsManager.value.realtimeTranscoding); + + let active = $state(false); + const controller = $derived(videoSessionManager.get(assetId)); + const remainingSeconds = $derived(controller?.remainingSeconds || durationInSeconds); $effect(() => { if (!enablePlayback) { - remainingSeconds = durationInSeconds; + active = false; return; } - if (!player) { + if (!useHls) { + active = true; return; } - const video = player; - return () => { - video.pause(); - video.removeAttribute('src'); - video.load(); - }; + // Cold-starting a transcode for every thumbnail the pointer brushes over would hammer the server, + // so wait for the hover to settle before opening an HLS session. + const timer = setTimeout(() => (active = true), 200); + return () => clearTimeout(timer); }); + const onMouseEnter = () => { if (playbackOnIconHover) { enablePlayback = true; @@ -62,35 +68,15 @@ }; -{#if enablePlayback} - + autoplay + class={cleanClass('h-full w-full [--media-object-fit:cover]', className, curve && 'rounded-xl overflow-hidden')} + > {/if}
- {#if enablePlayback} - {#if loading} + {#if active} + {#if !controller || controller.loading} - {:else if error} + {:else if controller.error} {:else} diff --git a/web/src/lib/managers/video-session-manager.svelte.ts b/web/src/lib/managers/video-session-manager.svelte.ts new file mode 100644 index 0000000000..ff798ee01f --- /dev/null +++ b/web/src/lib/managers/video-session-manager.svelte.ts @@ -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. `` 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(); + + 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); + }, 1_000); + } + + get(assetId: string): VideoController | undefined { + return this.#sessions.get(assetId)?.controller; + } +} + +export const videoSessionManager = new VideoSessionManager(); diff --git a/web/src/lib/utils/video/controller.svelte.ts b/web/src/lib/utils/video/controller.svelte.ts new file mode 100644 index 0000000000..5163795bd6 --- /dev/null +++ b/web/src/lib/utils/video/controller.svelte.ts @@ -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 `