Compare commits

...

7 Commits

Author SHA1 Message Date
Mert 2f1de18d4f Update web/src/lib/components/asset-viewer/VideoNativeViewer.svelte
Co-authored-by: Mees Frensel <33722705+meesfrensel@users.noreply.github.com>
2026-05-14 10:46:45 -04:00
mertalev f93c167f68 seek on release 2026-05-14 10:46:45 -04:00
mertalev aa3ca57511 linting false positives 2026-05-14 10:46:45 -04:00
mertalev 596e8ea397 filter by hw decode 2026-05-14 10:46:45 -04:00
mertalev 24ad0fed53 video quality selector 2026-05-14 10:46:45 -04:00
mertalev 0c0fac9fb0 hls player 2026-05-14 10:46:45 -04:00
mertalev 9341037b0a feature flag 2026-05-14 10:46:45 -04:00
13 changed files with 377 additions and 33 deletions
+1
View File
@@ -2393,6 +2393,7 @@
"video": "Video",
"video_hover_setting": "Play video thumbnail on hover",
"video_hover_setting_description": "Play video thumbnail when mouse is hovering over item. Even when disabled, playback can be started by hovering over the play icon.",
"video_quality": "Video quality",
"videos": "Videos",
"videos_count": "{count, plural, one {# Video} other {# Videos}}",
"videos_only": "Videos only",
+10 -1
View File
@@ -23,6 +23,7 @@ class ServerFeaturesDto {
required this.oauthAutoLaunch,
required this.ocr,
required this.passwordLogin,
required this.realtimeTranscoding,
required this.reverseGeocoding,
required this.search,
required this.sidecar,
@@ -60,6 +61,9 @@ class ServerFeaturesDto {
/// Whether password login is enabled
bool passwordLogin;
/// Whether real-time transcoding is enabled
bool realtimeTranscoding;
/// Whether reverse geocoding is enabled
bool reverseGeocoding;
@@ -87,6 +91,7 @@ class ServerFeaturesDto {
other.oauthAutoLaunch == oauthAutoLaunch &&
other.ocr == ocr &&
other.passwordLogin == passwordLogin &&
other.realtimeTranscoding == realtimeTranscoding &&
other.reverseGeocoding == reverseGeocoding &&
other.search == search &&
other.sidecar == sidecar &&
@@ -106,6 +111,7 @@ class ServerFeaturesDto {
(oauthAutoLaunch.hashCode) +
(ocr.hashCode) +
(passwordLogin.hashCode) +
(realtimeTranscoding.hashCode) +
(reverseGeocoding.hashCode) +
(search.hashCode) +
(sidecar.hashCode) +
@@ -113,7 +119,7 @@ class ServerFeaturesDto {
(trash.hashCode);
@override
String toString() => 'ServerFeaturesDto[configFile=$configFile, duplicateDetection=$duplicateDetection, email=$email, facialRecognition=$facialRecognition, importFaces=$importFaces, map=$map, oauth=$oauth, oauthAutoLaunch=$oauthAutoLaunch, ocr=$ocr, passwordLogin=$passwordLogin, reverseGeocoding=$reverseGeocoding, search=$search, sidecar=$sidecar, smartSearch=$smartSearch, trash=$trash]';
String toString() => 'ServerFeaturesDto[configFile=$configFile, duplicateDetection=$duplicateDetection, email=$email, facialRecognition=$facialRecognition, importFaces=$importFaces, map=$map, oauth=$oauth, oauthAutoLaunch=$oauthAutoLaunch, ocr=$ocr, passwordLogin=$passwordLogin, realtimeTranscoding=$realtimeTranscoding, reverseGeocoding=$reverseGeocoding, search=$search, sidecar=$sidecar, smartSearch=$smartSearch, trash=$trash]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@@ -127,6 +133,7 @@ class ServerFeaturesDto {
json[r'oauthAutoLaunch'] = this.oauthAutoLaunch;
json[r'ocr'] = this.ocr;
json[r'passwordLogin'] = this.passwordLogin;
json[r'realtimeTranscoding'] = this.realtimeTranscoding;
json[r'reverseGeocoding'] = this.reverseGeocoding;
json[r'search'] = this.search;
json[r'sidecar'] = this.sidecar;
@@ -154,6 +161,7 @@ class ServerFeaturesDto {
oauthAutoLaunch: mapValueOfType<bool>(json, r'oauthAutoLaunch')!,
ocr: mapValueOfType<bool>(json, r'ocr')!,
passwordLogin: mapValueOfType<bool>(json, r'passwordLogin')!,
realtimeTranscoding: mapValueOfType<bool>(json, r'realtimeTranscoding')!,
reverseGeocoding: mapValueOfType<bool>(json, r'reverseGeocoding')!,
search: mapValueOfType<bool>(json, r'search')!,
sidecar: mapValueOfType<bool>(json, r'sidecar')!,
@@ -216,6 +224,7 @@ class ServerFeaturesDto {
'oauthAutoLaunch',
'ocr',
'passwordLogin',
'realtimeTranscoding',
'reverseGeocoding',
'search',
'sidecar',
+5
View File
@@ -21567,6 +21567,10 @@
"description": "Whether password login is enabled",
"type": "boolean"
},
"realtimeTranscoding": {
"description": "Whether real-time transcoding is enabled",
"type": "boolean"
},
"reverseGeocoding": {
"description": "Whether reverse geocoding is enabled",
"type": "boolean"
@@ -21599,6 +21603,7 @@
"oauthAutoLaunch",
"ocr",
"passwordLogin",
"realtimeTranscoding",
"reverseGeocoding",
"search",
"sidecar",
@@ -2049,6 +2049,8 @@ export type ServerFeaturesDto = {
ocr: boolean;
/** Whether password login is enabled */
passwordLogin: boolean;
/** Whether real-time transcoding is enabled */
realtimeTranscoding: boolean;
/** Whether reverse geocoding is enabled */
reverseGeocoding: boolean;
/** Whether search is enabled */
+30
View File
@@ -789,6 +789,12 @@ importers:
happy-dom:
specifier: ^20.0.0
version: 20.9.0
hls-video-element:
specifier: ^1.5.11
version: 1.5.11
hls.js:
specifier: ^1.6.16
version: 1.6.16
intl-messageformat:
specifier: ^11.0.0
version: 11.2.1
@@ -6739,6 +6745,9 @@ packages:
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
custom-media-element@1.4.6:
resolution: {integrity: sha512-/HRYqJOa1ob5ik4q7FIJVYxTJCFs/FL3+cQPAJjUf2uiqrDEzbTgB315gQ2rG8oK3w094W9m5tcB8S5Qah+caA==}
cytoscape-cose-bilkent@4.1.0:
resolution: {integrity: sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==}
peerDependencies:
@@ -8060,6 +8069,12 @@ 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==}
hogan.js@3.0.2:
resolution: {integrity: sha512-RqGs4wavGYJWE07t35JQccByczmNUXQT0E12ZYV1VKYu5UiAU9lsos/yBAcf840+zrUQQxgVduCR5/B8nNtibg==}
hasBin: true
@@ -9091,6 +9106,9 @@ packages:
media-chrome@4.19.0:
resolution: {integrity: sha512-HWhDTwts+BSbdPkkB1VsJXp5kvL0IxY7xFT5tBwliM2+89kTPVTnHnev+9it2f9PweANjT/C8/C/S0PW9oyZbA==}
media-tracks@0.3.5:
resolution: {integrity: sha512-l54rkKXlLBt3ob3zOLWHcnjvwUmX5bNEZ70igyapOZZC9imzqBmq1oz8p2roiV04KhjblFIi2hetLPF1oYVLRA==}
media-typer@0.3.0:
resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
engines: {node: '>= 0.6'}
@@ -19347,6 +19365,8 @@ snapshots:
csstype@3.2.3: {}
custom-media-element@1.4.6: {}
cytoscape-cose-bilkent@4.1.0(cytoscape@3.33.1):
dependencies:
cose-base: 1.0.3
@@ -21018,6 +21038,14 @@ 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: {}
hogan.js@3.0.2:
dependencies:
mkdirp: 0.3.0
@@ -22188,6 +22216,8 @@ snapshots:
transitivePeerDependencies:
- react
media-tracks@0.3.5: {}
media-typer@0.3.0: {}
media-typer@1.1.0: {}
+1
View File
@@ -137,6 +137,7 @@ const ServerFeaturesSchema = z
search: z.boolean().describe('Whether search is enabled'),
email: z.boolean().describe('Whether email notifications are enabled'),
ocr: z.boolean().describe('Whether OCR is enabled'),
realtimeTranscoding: z.boolean().describe('Whether real-time transcoding is enabled'),
})
.meta({ id: 'ServerFeaturesDto' });
@@ -148,6 +148,7 @@ describe(ServerService.name, () => {
configFile: false,
trash: true,
email: false,
realtimeTranscoding: false,
});
expect(mocks.systemMetadata.get).toHaveBeenCalled();
});
+2 -1
View File
@@ -86,7 +86,7 @@ export class ServerService extends BaseService {
}
async getFeatures(): Promise<ServerFeaturesDto> {
const { reverseGeocoding, metadata, map, machineLearning, trash, oauth, passwordLogin, notifications } =
const { reverseGeocoding, metadata, map, machineLearning, trash, oauth, passwordLogin, notifications, ffmpeg } =
await this.getConfig({ withCache: false });
const { configFile } = this.configRepository.getEnv();
@@ -106,6 +106,7 @@ export class ServerService extends BaseService {
passwordLogin: passwordLogin.enabled,
configFile: !!configFile,
email: notifications.smtp.enabled,
realtimeTranscoding: ffmpeg.realtime.enabled,
};
}
+2
View File
@@ -46,6 +46,8 @@
"geojson": "^0.5.0",
"handlebars": "^4.7.8",
"happy-dom": "^20.0.0",
"hls-video-element": "^1.5.11",
"hls.js": "^1.6.16",
"intl-messageformat": "^11.0.0",
"justified-layout": "^4.1.0",
"lodash-es": "^4.17.21",
@@ -5,7 +5,7 @@
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
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 { getAssetHlsSessionUrl, getAssetHlsUrl, getAssetMediaUrl, getAssetPlaybackUrl } from '$lib/utils';
import { AssetMediaSize, type AssetResponseDto } from '@immich/sdk';
import { Icon, LoadingSpinner } from '@immich/ui';
import {
@@ -21,6 +21,9 @@
mdiVolumeMedium,
mdiVolumeMute,
} from '@mdi/js';
import Hls, { AbrController, type HlsConfig } from 'hls.js';
import 'hls-video-element';
import type HlsVideoElement from 'hls-video-element';
import 'media-chrome/media-control-bar';
import 'media-chrome/media-controller';
import 'media-chrome/media-fullscreen-button';
@@ -31,6 +34,7 @@
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-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';
@@ -38,6 +42,8 @@
import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { mediaCapabilitiesManager } from '$lib/managers/media-capabilities-manager.svelte';
interface Props {
asset: AssetResponseDto;
@@ -69,14 +75,127 @@
let videoPlayer: HTMLVideoElement | undefined = $state();
let isLoading = $state(true);
let assetFileUrl = $derived(
playOriginalVideo
? getAssetMediaUrl({ id: assetId, size: AssetMediaSize.Original, cacheKey })
: getAssetPlaybackUrl({ id: assetId, cacheKey }),
);
let assetFileUrl = $derived.by(() => {
if (featureFlagsManager.value.realtimeTranscoding) {
return getAssetHlsUrl(assetId);
}
if (playOriginalVideo) {
return getAssetMediaUrl({ id: assetId, size: AssetMediaSize.Original, cacheKey });
}
return getAssetPlaybackUrl({ id: assetId, cacheKey });
});
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;
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.
class NoAbandonAbrController extends AbrController {
protected override onFragLoading() {}
}
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;
}
// 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 decodingInfo = await Promise.all(api.levels.map((level) => mediaCapabilitiesManager.decodingInfo(level)));
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const lowestBitrateByHeight = new Map<number, number>();
for (let i = 0; i < api.levels.length; i++) {
if (!decodingInfo[i].powerEfficient) {
continue;
}
const { bitrate, height } = api.levels[i];
const cur = lowestBitrateByHeight.get(height);
if (cur === undefined || bitrate < api.levels[cur].bitrate) {
lowestBitrateByHeight.set(height, i);
}
}
const keep = new Set(lowestBitrateByHeight.values());
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(() => {
showVideo = true;
@@ -84,10 +203,31 @@
$effect(() => {
// reactive on `assetFileUrl` changes
if (assetFileUrl) {
if (videoPlayer && assetFileUrl) {
hasFocused = false;
videoPlayer?.load();
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) => {
if (!event.persisted) {
releaseSession();
}
};
$effect(() => {
window.addEventListener('pagehide', onPagehide);
return () => window.removeEventListener('pagehide', onPagehide);
});
onDestroy(() => {
@@ -144,6 +284,44 @@
videoPlayer?.pause();
}
});
// Suppress mediaseekrequest events while the user is dragging the time range and
// replay only the last one on pointerup.
const commitOnRelease = (node: HTMLElement) => {
let dragging = false;
let pending: number | undefined;
const onPointerDown = () => (dragging = true);
const onPointerUp = () => {
if (!dragging) {
return;
}
dragging = false;
if (pending !== undefined) {
node.dispatchEvent(new CustomEvent('mediaseekrequest', { composed: true, bubbles: true, detail: pending }));
// Update time display immediately without waiting for the new fragment to load
videoPlayer?.dispatchEvent(new Event('timeupdate'));
pending = undefined;
}
};
const onSeekRequest = (event: Event) => {
if (dragging) {
pending = (event as CustomEvent<number>).detail;
event.stopImmediatePropagation();
}
};
node.addEventListener('pointerdown', onPointerDown);
node.addEventListener('pointerup', onPointerUp);
node.addEventListener('pointercancel', onPointerUp);
node.addEventListener('mediaseekrequest', onSeekRequest, { capture: true });
return {
destroy() {
node.removeEventListener('pointerdown', onPointerDown);
node.removeEventListener('pointerup', onPointerUp);
node.removeEventListener('pointercancel', onPointerUp);
node.removeEventListener('mediaseekrequest', onSeekRequest, { capture: true });
},
};
};
</script>
{#if showVideo}
@@ -171,27 +349,49 @@
style:aspect-ratio={aspectRatio}
defaultduration={asset.duration! / 1000}
>
<video
bind:this={videoPlayer}
slot="media"
loop={$loopVideoPreference && loopVideo}
autoplay={$autoPlayVideo}
disablePictureInPicture
playsinline
{...useSwipe(onSwipe)}
class="h-full object-contain"
oncanplay={(e) => handleCanPlay(e.currentTarget)}
onended={onVideoEnded}
onplaying={(e) => {
if (!hasFocused) {
e.currentTarget.focus();
hasFocused = true;
}
}}
onclose={onClose}
poster={getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Preview, cacheKey })}
src={assetFileUrl}
></video>
{#if featureFlagsManager.value.realtimeTranscoding}
<hls-video
bind:this={videoPlayer}
slot="media"
loop={$loopVideoPreference && loopVideo}
autoplay={$autoPlayVideo}
disablePictureInPicture
playsinline
{...useSwipe(onSwipe)}
class="h-full object-contain"
oncanplay={(e: Event) => handleCanPlay(e.currentTarget as HTMLVideoElement)}
onended={onVideoEnded}
onplaying={(e: Event) => {
if (!hasFocused) {
(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"
loop={$loopVideoPreference && loopVideo}
autoplay={$autoPlayVideo}
disablePictureInPicture
playsinline
{...useSwipe(onSwipe)}
class="h-full object-contain"
oncanplay={(e) => handleCanPlay(e.currentTarget)}
onended={onVideoEnded}
onplaying={(e) => {
if (!hasFocused) {
e.currentTarget.focus();
hasFocused = true;
}
}}
onclose={onClose}
poster={getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Preview, cacheKey })}
></video>
{/if}
{#if extendedControls}
<media-settings-menu hidden anchor="auto" class="w-3xs rounded-xl border border-light-300 shadow-sm">
@@ -204,6 +404,16 @@
<span slot="title">{$t('playback_speed')}</span>
</media-playback-rate-menu>
</media-settings-menu-item>
{#if featureFlagsManager.value.realtimeTranscoding}
<media-settings-menu-item class="mx-1 rounded-lg p-1 ps-2">
{$t('video_quality')}
<Icon slot="suffix" icon={mdiChevronRight} class="m-2" />
<media-rendition-menu slot="submenu" hidden>
<Icon slot="back-icon" icon={mdiChevronLeft} class="m-2" />
<span slot="title">{$t('video_quality')}</span>
</media-rendition-menu>
</media-settings-menu-item>
{/if}
</media-settings-menu>
{/if}
@@ -237,7 +447,7 @@
<media-settings-menu-button class="shrink-0 rounded-full p-2 outline-none"></media-settings-menu-button>
{/if}
</media-control-bar>
<media-time-range class="h-8 w-full rounded-lg px-2 pb-3 outline-none"></media-time-range>
<media-time-range use:commitOnRelease class="h-8 w-full rounded-lg px-2 pb-3 outline-none"></media-time-range>
</div>
</media-controller>
@@ -247,7 +457,7 @@
</div>
{/if}
{#if assetViewerManager.isFaceEditMode}
{#if assetViewerManager.isFaceEditMode && videoPlayer}
<FaceEditor htmlElement={videoPlayer} {containerWidth} {containerHeight} {assetId} />
{/if}
{/if}
@@ -0,0 +1,72 @@
export type Level = { videoCodec?: string; width: number; height: number; bitrate: number; frameRate: number };
export const DEFAULT_DECODING_INFO: MediaCapabilitiesDecodingInfo = {
powerEfficient: true,
smooth: true,
supported: true,
keySystemAccess: null,
};
class MediaCapabilitiesManager {
private cache = new Map<string, Promise<MediaCapabilitiesDecodingInfo>>();
init() {
for (const level of [
{ videoCodec: 'av01.0.04M.08', width: 854, height: 480, bitrate: 1_000_000, frameRate: 60 },
{ videoCodec: 'hvc1.1.6.L90.B0', width: 854, height: 480, bitrate: 1_200_000, frameRate: 60 },
{ videoCodec: 'av01.0.08M.08', width: 1280, height: 720, bitrate: 2_000_000, frameRate: 60 },
{ videoCodec: 'hvc1.1.6.L93.B0', width: 1280, height: 720, bitrate: 2_500_000, frameRate: 60 },
{ videoCodec: 'av01.0.09M.08', width: 1920, height: 1080, bitrate: 4_000_000, frameRate: 60 },
{ videoCodec: 'hvc1.1.6.L120.B0', width: 1920, height: 1080, bitrate: 4_500_000, frameRate: 60 },
{ videoCodec: 'av01.0.12M.08', width: 2560, height: 1440, bitrate: 7_000_000, frameRate: 60 },
{ videoCodec: 'hvc1.2.4.L150.B0', width: 2560, height: 1440, bitrate: 8_000_000, frameRate: 60 },
]) {
this.cache.set(this.cacheKey(level), this.queryDecodingInfo(level));
}
for (const level of [
{ videoCodec: 'avc1.64001e', width: 854, height: 480, bitrate: 2_500_000, frameRate: 60 },
{ videoCodec: 'avc1.64001f', width: 1280, height: 720, bitrate: 5_000_000, frameRate: 60 },
{ videoCodec: 'avc1.640028', width: 1920, height: 1080, bitrate: 8_000_000, frameRate: 60 },
{ videoCodec: 'avc1.640032', width: 2560, height: 1440, bitrate: 16_000_000, frameRate: 60 },
]) {
this.cache.set(this.cacheKey(level), Promise.resolve(DEFAULT_DECODING_INFO));
}
}
decodingInfo(level: Level) {
const key = this.cacheKey(level);
const existing = this.cache.get(key);
if (existing) {
return existing;
}
const promise = this.queryDecodingInfo(level);
this.cache.set(key, promise);
return promise;
}
private async queryDecodingInfo(level: Level) {
try {
return await navigator.mediaCapabilities.decodingInfo({
type: 'media-source',
video: {
contentType: `video/mp4; codecs="${level.videoCodec}"`,
width: level.width,
height: level.height,
bitrate: level.bitrate,
framerate: level.frameRate,
},
});
} catch {
return DEFAULT_DECODING_INFO;
}
}
private cacheKey({ videoCodec, width, height, frameRate }: Level) {
const resolution = Math.min(width, height);
const fpsBucket = Math.trunc(frameRate / 61) * 60;
return `${videoCodec}|${resolution}|${fpsBucket}`;
}
}
export const mediaCapabilitiesManager = new MediaCapabilitiesManager();
+8
View File
@@ -243,6 +243,14 @@ export const getAssetPlaybackUrl = (options: AssetUrlOptions) => {
return createUrl(getAssetPlaybackPath(id), { ...authManager.params, c });
};
export const getAssetHlsUrl = (id: string) => {
return createUrl(`/assets/${id}/video/stream/main.m3u8`, authManager.params);
};
export const getAssetHlsSessionUrl = (id: string, sessionId: string) => {
return createUrl(`/assets/${id}/video/stream/${sessionId}`, authManager.params);
};
export const getProfileImageUrl = (user: UserResponseDto) =>
createUrl(getUserProfileImagePath(user.id), { updatedAt: user.profileChangedAt });
+2
View File
@@ -2,6 +2,7 @@ import { defaults } from '@immich/sdk';
import { memoize } from 'lodash-es';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { mediaCapabilitiesManager } from '$lib/managers/media-capabilities-manager.svelte';
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
import { initLanguage } from '$lib/utils';
@@ -12,6 +13,7 @@ async function _init(fetch: Fetch) {
// https://kit.svelte.dev/docs/load#making-fetch-requests
// https://github.com/oazapfts/oazapfts/blob/main/README.md#fetch-options
defaults.fetch = fetch;
mediaCapabilitiesManager.init();
await initLanguage();
await serverConfigManager.init();
await authManager.load();