mirror of
https://github.com/immich-app/immich
synced 2026-08-15 13:03:57 +00:00
feat(web): hls player (#28312)
* update e2e * hls player * fix transcoding restart on explicit quality selection * move level filtering to manager * move init to manager declaration * refactor commit on release * these lints... * fix seek sometimes being ignored * fix panic downswitch
This commit is contained in:
parent
7eabac6702
commit
138e2d9158
14 changed files with 449 additions and 36 deletions
|
|
@ -5,7 +5,7 @@
|
|||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { castManager } from '$lib/managers/cast-manager.svelte';
|
||||
import { autoPlayVideo, lang, 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, Events, type FragLoadedData, type FragLoadingData, 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';
|
||||
|
|
@ -28,9 +31,10 @@
|
|||
import 'media-chrome/media-play-button';
|
||||
import 'media-chrome/media-playback-rate-button';
|
||||
import 'media-chrome/media-time-display';
|
||||
import 'media-chrome/media-time-range';
|
||||
import './immich-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,155 @@
|
|||
|
||||
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.
|
||||
//
|
||||
// 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(() => {
|
||||
showVideo = true;
|
||||
|
|
@ -84,10 +231,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 +312,10 @@
|
|||
videoPlayer?.pause();
|
||||
}
|
||||
});
|
||||
|
||||
// The time is only refreshed on HLS fragment decode by default,
|
||||
// so manually emit events on seek to update it immediately.
|
||||
const onSeeking = (event: Event) => event.currentTarget?.dispatchEvent(new Event('timeupdate'));
|
||||
</script>
|
||||
|
||||
{#if showVideo}
|
||||
|
|
@ -172,27 +344,51 @@
|
|||
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}
|
||||
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 })}
|
||||
></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}
|
||||
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}
|
||||
<media-settings-menu hidden anchor="auto" class="min-w-3xs rounded-xl border border-light-300 shadow-sm">
|
||||
|
|
@ -205,6 +401,16 @@
|
|||
<span slot="title">{$t('media_chrome.playback_rate')}</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}
|
||||
|
||||
|
|
@ -238,7 +444,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>
|
||||
<immich-time-range class="h-8 w-full rounded-lg px-2 pb-3 outline-none"></immich-time-range>
|
||||
</div>
|
||||
</media-controller>
|
||||
|
||||
|
|
@ -248,7 +454,7 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
{#if assetViewerManager.isFaceEditMode}
|
||||
{#if assetViewerManager.isFaceEditMode && videoPlayer}
|
||||
<FaceEditor htmlElement={videoPlayer} {containerWidth} {containerHeight} {assetId} />
|
||||
{/if}
|
||||
{/if}
|
||||
|
|
@ -291,12 +497,12 @@
|
|||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
media-time-range,
|
||||
immich-time-range,
|
||||
media-volume-range {
|
||||
--media-control-hover-background: none;
|
||||
}
|
||||
|
||||
media-time-range:hover,
|
||||
immich-time-range:hover,
|
||||
media-volume-range:hover {
|
||||
--media-range-thumb-opacity: 1;
|
||||
}
|
||||
|
|
|
|||
54
web/src/lib/components/asset-viewer/immich-time-range.ts
Normal file
54
web/src/lib/components/asset-viewer/immich-time-range.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { MediaUIEvents } from 'media-chrome/constants';
|
||||
import MediaTimeRange from 'media-chrome/media-time-range';
|
||||
|
||||
const COMMIT_DELAY_MS = 750;
|
||||
|
||||
/** Custom MediaTimeRange that only seeks after pointer release to avoid hammering the server.
|
||||
* Keyboard input uses timed debouncing instead since there's no release event. */
|
||||
class ImmichTimeRange extends MediaTimeRange {
|
||||
private seeking = false;
|
||||
private pending: number | undefined;
|
||||
private idleTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.addEventListener('pointerdown', this.hold);
|
||||
this.addEventListener('keydown', this.hold);
|
||||
this.addEventListener('pointerup', this.release);
|
||||
this.addEventListener('pointercancel', this.release);
|
||||
this.addEventListener(MediaUIEvents.MEDIA_SEEK_REQUEST, this.intercept, { capture: true });
|
||||
}
|
||||
|
||||
private hold(event: Event) {
|
||||
if (event instanceof KeyboardEvent) {
|
||||
if (!this.keysUsed.includes(event.key)) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(this.idleTimer);
|
||||
this.idleTimer = setTimeout(this.release, COMMIT_DELAY_MS);
|
||||
}
|
||||
this.seeking = true;
|
||||
}
|
||||
|
||||
private intercept(event: Event) {
|
||||
if (!this.seeking) {
|
||||
return; // not mid-scrub, or this is the request we replay in release()
|
||||
}
|
||||
this.pending = (event as CustomEvent<number>).detail;
|
||||
event.stopImmediatePropagation();
|
||||
}
|
||||
|
||||
private release() {
|
||||
clearTimeout(this.idleTimer);
|
||||
this.seeking = false;
|
||||
if (this.pending !== undefined) {
|
||||
const detail = this.pending;
|
||||
this.pending = undefined;
|
||||
this.dispatchEvent(new CustomEvent(MediaUIEvents.MEDIA_SEEK_REQUEST, { bubbles: true, composed: true, detail }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!globalThis.customElements.get('immich-time-range')) {
|
||||
globalThis.customElements.define('immich-time-range', ImmichTimeRange);
|
||||
}
|
||||
92
web/src/lib/managers/media-capabilities-manager.svelte.ts
Normal file
92
web/src/lib/managers/media-capabilities-manager.svelte.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
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));
|
||||
}
|
||||
}
|
||||
|
||||
async efficientLevels(levels: Level[]) {
|
||||
const decodingInfo = await Promise.all(levels.map((level) => this.decodingInfo(level)));
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
const lowestBitrateByHeight = new Map<number, number>();
|
||||
for (let i = 0; i < levels.length; i++) {
|
||||
if (!decodingInfo[i].powerEfficient) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const { bitrate, height } = levels[i];
|
||||
const cur = lowestBitrateByHeight.get(height);
|
||||
if (cur === undefined || bitrate < levels[cur].bitrate) {
|
||||
lowestBitrateByHeight.set(height, i);
|
||||
}
|
||||
}
|
||||
|
||||
return new Set(lowestBitrateByHeight.values());
|
||||
}
|
||||
|
||||
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();
|
||||
mediaCapabilitiesManager.init();
|
||||
|
|
@ -244,6 +244,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 });
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue