Files
_hublib-web/packages/video-player/dist/core/player-runtime.js

707 lines
26 KiB
JavaScript
Raw Normal View History

2026-02-27 09:50:13 +03:00
import Hls from "hls.js";
import videojs from "video.js";
import "./plugins/register";
import { selectPlaybackEngine, } from "./engine-selector";
import { formatTime } from "./format-time";
import { resolveVideoPlayerToken } from "./token-provider";
const DEFAULT_SOURCE_TYPE = "application/x-mpegurl";
const detectIOS = () => {
if (typeof navigator === "undefined") {
return false;
}
const userAgent = navigator.userAgent || "";
return /iPad|iPhone|iPod/.test(userAgent);
};
const createAuthPlaylistLoader = ({ debug, }) => {
const BaseLoader = Hls.DefaultConfig.loader;
return class AuthPlaylistLoader extends BaseLoader {
constructor(config) {
super({ ...config, debug: debug ?? false });
}
load(context, config, callbacks) {
const start = async () => {
try {
const token = await resolveVideoPlayerToken();
if (token) {
context.headers = {
...(context.headers ?? {}),
Authorization: `Bearer ${token}`,
};
}
}
catch (error) {
if (debug) {
console.warn("[VideoRuntime:HLS] Failed to append auth header to playlist request", error);
}
}
finally {
super.load(context, config, callbacks);
}
};
void start().catch(error => {
if (debug) {
console.error("[VideoRuntime:HLS] Playlist loader start failed", error);
}
});
}
};
};
const normalizeSource = (source) => ({
src: source?.src ?? "",
type: source?.type ?? DEFAULT_SOURCE_TYPE,
});
const normalizeOptions = (options, previous) => ({
source: normalizeSource(options.source ?? previous?.source),
strategy: options.strategy ?? previous?.strategy ?? "auto",
preload: options.preload ?? previous?.preload ?? "auto",
autoplay: options.autoplay ?? previous?.autoplay ?? false,
controls: options.controls ?? previous?.controls ?? true,
responsive: options.responsive ?? previous?.responsive ?? true,
aspectRatio: options.aspectRatio ?? previous?.aspectRatio,
fluid: options.fluid ?? previous?.fluid ?? true,
muted: options.muted ?? previous?.muted ?? false,
poster: options.poster ?? previous?.poster,
preferHQ: options.preferHQ ?? previous?.preferHQ ?? false,
debug: options.debug ?? previous?.debug ?? false,
initialTime: options.initialTime ?? previous?.initialTime ?? 0,
isIOS: options.isIOS ?? previous?.isIOS,
isMobile: options.isMobile ?? previous?.isMobile,
full: options.full ?? previous?.full ?? false,
withRewind: options.withRewind ?? previous?.withRewind ?? true,
skipSeconds: options.skipSeconds ?? previous?.skipSeconds ?? 10,
classNames: options.classNames ?? previous?.classNames ?? [],
onPlayerReady: options.onPlayerReady ?? previous?.onPlayerReady,
});
export class VideoPlayerRuntime {
constructor() {
this.containerRef = null;
this.videoRef = null;
this.playerRef = null;
this.hlsRef = null;
this.options = null;
this.currentEngine = null;
this.currentSource = null;
this.vhsAuthTokenRef = null;
this.vhsRequestCleanupRef = null;
this.visibilityObserverRef = null;
this.originalPlayRef = null;
this.hlsLoaded = false;
this.eventListeners = new Map();
}
async init(options) {
this.dispose();
this.containerRef = options.container;
this.options = normalizeOptions(options);
this.createVideoElement();
this.createPlayer();
const state = await this.loadCurrentSource(null);
this.emit("ready", {
engine: state.engine,
source: state.source,
player: this.playerRef,
});
this.options.onPlayerReady?.(this.playerRef, state);
return state;
}
async update(options) {
if (!this.options || !this.playerRef) {
return this.getState();
}
const previous = this.options;
const nextOptions = normalizeOptions(options, previous);
this.options = nextOptions;
this.syncPlayerDisplayOptions(previous, nextOptions);
const sourceChanged = nextOptions.source.src !== previous.source.src ||
nextOptions.source.type !== previous.source.type;
const engineDependsOnChanged = nextOptions.strategy !== previous.strategy ||
nextOptions.isIOS !== previous.isIOS;
if (sourceChanged || engineDependsOnChanged) {
if (sourceChanged) {
this.emit("sourcechange", {
previous: previous.source,
next: nextOptions.source,
engine: this.currentEngine,
});
}
return this.loadCurrentSource(this.currentEngine);
}
if (nextOptions.poster !== previous.poster) {
this.syncPoster(nextOptions.poster);
}
return this.getState();
}
on(event, handler) {
if (!this.eventListeners.has(event)) {
this.eventListeners.set(event, new Set());
}
const listeners = this.eventListeners.get(event);
listeners.add(handler);
return () => {
listeners.delete(handler);
};
}
dispose() {
this.emit("dispose", {});
this.resetDeferredHlsLoading();
this.teardownHls();
this.vhsRequestCleanupRef?.();
this.vhsRequestCleanupRef = null;
this.vhsAuthTokenRef = null;
if (this.playerRef) {
this.playerRef.dispose();
}
if (this.containerRef) {
this.containerRef.innerHTML = "";
}
this.containerRef = null;
this.videoRef = null;
this.playerRef = null;
this.options = null;
this.currentEngine = null;
this.currentSource = null;
this.hlsLoaded = false;
}
getState() {
return {
initialized: Boolean(this.playerRef),
engine: this.currentEngine,
source: this.currentSource,
};
}
getPlayer() {
return this.playerRef;
}
emit(event, payload) {
const listeners = this.eventListeners.get(event);
if (!listeners?.size) {
return;
}
listeners.forEach(listener => {
try {
listener(payload);
}
catch (error) {
console.error("[VideoRuntime] Listener failed", {
event,
error,
});
}
});
}
tryPlay(player) {
const playResult = player.play();
if (playResult && typeof playResult.catch === "function") {
void playResult.catch(() => undefined);
}
}
resolveEngine(options) {
return selectPlaybackEngine({
src: options.source.src,
type: options.source.type,
strategy: options.strategy,
hlsSupported: Hls.isSupported(),
isIOS: options.isIOS ?? detectIOS(),
});
}
createVideoElement() {
if (!this.containerRef || !this.options) {
return;
}
const videoElement = document.createElement("video");
videoElement.classList.add("video-js", "vjs-tach-skin");
if (this.options.isMobile) {
videoElement.classList.add("vjs-mobile-ui");
}
if (!this.options.withRewind) {
videoElement.classList.add("vjs-disable-rewind");
}
videoElement.setAttribute("playsinline", "true");
if (this.options.poster) {
videoElement.setAttribute("poster", this.options.poster);
}
this.options.classNames.forEach(className => {
videoElement.classList.add(className);
});
this.containerRef.innerHTML = "";
this.containerRef.appendChild(videoElement);
this.videoRef = videoElement;
}
createPlayer() {
if (!this.videoRef || !this.options) {
throw new Error("[VideoRuntime] Unable to create player without video element");
}
const videoJsAny = videojs;
videoJsAny.formatTime = formatTime;
videoJsAny.setFormatTime?.(formatTime);
const player = (this.playerRef = videojs(this.videoRef, {
autoplay: this.options.autoplay,
controls: this.options.controls,
preload: this.options.preload === "visibility" ? "none" : this.options.preload,
fluid: this.options.fluid,
responsive: this.options.responsive,
aspectRatio: this.options.aspectRatio,
muted: this.options.muted,
}));
this.attachCompatibilityApi(player);
player.on("error", () => {
this.emit("error", {
scope: "player",
error: player.error?.() ?? new Error("Unknown player error"),
});
});
player.bigPlayPauseButton?.();
if (this.options.full) {
player.settingsMenu?.();
player.skipButtons?.({ skip: this.options.skipSeconds });
}
}
syncPoster(poster) {
const videoElement = this.videoRef;
if (!videoElement) {
return;
}
if (poster) {
videoElement.setAttribute("poster", poster);
}
else {
videoElement.removeAttribute("poster");
}
}
syncPlayerDisplayOptions(previous, next) {
const player = this.playerRef;
if (!player) {
return;
}
if (previous.muted !== next.muted) {
player.muted(next.muted);
}
if (previous.controls !== next.controls) {
player.controls(next.controls);
}
if (!previous.full && next.full) {
player.settingsMenu?.();
player.skipButtons?.({ skip: next.skipSeconds });
}
if (previous.initialTime !== next.initialTime && next.initialTime > 0) {
if (this.hlsRef) {
this.hlsRef.startLoad(next.initialTime);
}
player.currentTime(next.initialTime);
}
if (!previous.autoplay && next.autoplay && player.paused()) {
this.tryPlay(player);
}
}
attachCompatibilityApi(player) {
player.subscribeToSegmentChange = callback => {
let lastIndex = -1;
if (this.hlsRef) {
this.hlsRef.on(Hls.Events.FRAG_CHANGED, (_event, data) => {
callback(data.frag);
});
return;
}
player.on("timeupdate", () => {
const seconds = Math.floor(player.currentTime() || 0);
const segmentIndex = Math.floor(seconds / 10);
if (segmentIndex > lastIndex) {
lastIndex = segmentIndex;
callback({ start: segmentIndex * 10 });
}
});
};
player.mediaduration = () => {
if (this.hlsRef) {
const level = this.hlsRef.levels[this.hlsRef.currentLevel];
const details = level?.details;
if (details?.totalduration) {
return details.totalduration;
}
}
return player.duration();
};
player.subscribeToDuration = callback => {
const fire = (duration) => {
if (duration && !Number.isNaN(duration) && duration > 0) {
callback(duration);
}
};
fire(player.duration());
if (this.hlsRef) {
this.hlsRef.on(Hls.Events.MANIFEST_PARSED, () => {
const totalDuration = this.hlsRef?.levels[0]?.details?.totalduration;
if (totalDuration) {
fire(totalDuration);
}
});
}
player.on("loadedmetadata", () => fire(player.mediaduration()));
};
player.subscribeToPlayStart = callback => {
let started = false;
const onPlayStart = () => {
if (!started) {
started = true;
callback();
}
};
if (this.hlsRef) {
this.hlsRef.once(Hls.Events.FRAG_BUFFERED, onPlayStart);
}
player.one("playing", onPlayStart);
};
player.subscribeToPlayStarted = callback => {
let started = false;
const onPlayStarted = () => {
if (!started) {
started = true;
callback();
}
};
player.one("playing", onPlayStarted);
};
player.subscribeToManifestLoaded = callback => {
if (this.hlsRef) {
this.hlsRef.once(Hls.Events.MANIFEST_PARSED, () => callback());
return;
}
player.one("loadedmetadata", () => callback());
};
}
attachDurationAndPlayStartHooks() {
const player = this.playerRef;
if (!player) {
return;
}
player.one("playing", () => {
this.emit("playstart", {
engine: this.currentEngine,
});
});
player.one("loadedmetadata", () => {
const duration = player.duration();
if (typeof duration === "number" && !Number.isNaN(duration)) {
this.emit("duration", { duration });
}
});
}
async loadCurrentSource(previousEngine) {
if (!this.options || !this.playerRef || !this.videoRef) {
return this.getState();
}
const nextEngine = this.resolveEngine(this.options);
const source = this.options.source;
if (previousEngine !== nextEngine || this.currentEngine !== nextEngine) {
this.emit("enginechange", {
previous: this.currentEngine,
next: nextEngine,
source,
});
}
this.currentEngine = nextEngine;
this.currentSource = source;
if (nextEngine === "hls") {
await this.loadHlsSource();
}
else {
await this.loadVideoJsSource();
}
this.attachDurationAndPlayStartHooks();
return this.getState();
}
buildHlsConfig(overrides = {}) {
const options = this.options;
if (!options) {
return overrides;
}
const preferHqSettings = options.preferHQ
? { abrEwmaDefaultEstimate: 10690560 * 1.2 }
: {};
const playlistLoader = createAuthPlaylistLoader({ debug: options.debug });
return {
debug: options.debug,
enableWorker: true,
fragLoadingMaxRetry: 2,
manifestLoadingMaxRetry: 2,
fragLoadingRetryDelay: 2000,
manifestLoadingRetryDelay: 2000,
forceKeyFrameOnDiscontinuity: true,
backBufferLength: 90,
appendErrorMaxRetry: 3,
startPosition: options.initialTime > 0 ? options.initialTime : -1,
testBandwidth: false,
lowLatencyMode: false,
liveSyncDurationCount: 2,
maxBufferHole: 10,
nudgeOffset: 0.1,
nudgeMaxRetry: 5,
highBufferWatchdogPeriod: 2,
...preferHqSettings,
...overrides,
pLoader: playlistLoader,
};
}
async loadHlsSource() {
const options = this.options;
const player = this.playerRef;
const video = this.videoRef;
if (!options || !player || !video) {
return;
}
this.resetDeferredHlsLoading();
this.teardownHls();
player.pause();
this.hlsLoaded = false;
if (!Hls.isSupported()) {
await this.loadVideoJsSource();
return;
}
const setupHls = () => {
if (this.hlsLoaded) {
return;
}
this.hlsLoaded = true;
const hls = new Hls(this.buildHlsConfig());
this.hlsRef = hls;
player.hlsInstance = hls;
let recoveryAttempts = 0;
const MAX_RECOVERY_ATTEMPTS = 10;
let lastErrorTime = 0;
const ERROR_RESET_TIME = 10000;
let lastSegmentIndex = -1;
const isAtLiveEdge = () => {
if (!hls.liveSyncPosition || !video.duration) {
return false;
}
return hls.liveSyncPosition - video.currentTime < 10;
};
hls.on(Hls.Events.MANIFEST_PARSED, (_event, data) => {
const details = data.levels?.[0]?.details;
this.emit("manifestloaded", {
engine: "hls",
duration: details?.totalduration,
live: details?.live,
});
if (details?.live) {
video.parentElement?.classList.add("vjs-hls-live", "vjs-live");
player.duration(Infinity);
if (player.liveTracker) {
player.liveTracker.isLive_ = true;
player.liveTracker.startTracking();
player.liveTracker.trigger("durationchange");
}
}
if (options.initialTime > 0) {
hls.startLoad(options.initialTime);
}
});
hls.on(Hls.Events.FRAG_CHANGED, () => {
if (player.liveTracker) {
player.liveTracker.atLiveEdge = isAtLiveEdge;
player.liveTracker.trigger("liveedgechange");
}
});
hls.on(Hls.Events.ERROR, (_event, data) => {
this.emit("error", {
scope: "hls",
error: data,
fatal: data.fatal,
});
if (!data.fatal) {
return;
}
const now = Date.now();
if (now - lastErrorTime > ERROR_RESET_TIME) {
recoveryAttempts = 0;
lastSegmentIndex = -1;
}
lastErrorTime = now;
if (recoveryAttempts >= MAX_RECOVERY_ATTEMPTS) {
return;
}
recoveryAttempts += 1;
switch (data.type) {
case Hls.ErrorTypes.NETWORK_ERROR:
setTimeout(() => hls.startLoad(), 1000);
break;
case Hls.ErrorTypes.MEDIA_ERROR: {
const currentLevel = hls.currentLevel;
const details = hls.levels[currentLevel]?.details;
if (details?.fragments?.length) {
if (lastSegmentIndex === -1) {
lastSegmentIndex = details.startSN;
}
lastSegmentIndex += 1;
if (lastSegmentIndex < details.endSN) {
const fragment = details.fragments[lastSegmentIndex - details.startSN];
if (fragment) {
const savedTime = fragment.start;
video.currentTime = savedTime;
setTimeout(() => {
if (Math.abs(video.currentTime - savedTime) > 0.1) {
video.currentTime = savedTime;
}
hls.recoverMediaError();
if (!video.paused && options.autoplay) {
void video.play().catch(() => undefined);
}
}, 100);
break;
}
}
else {
recoveryAttempts = MAX_RECOVERY_ATTEMPTS;
break;
}
}
setTimeout(() => hls.recoverMediaError(), 1000);
break;
}
default:
setTimeout(() => {
this.teardownHls();
this.hlsLoaded = false;
setupHls();
}, 2000);
}
});
hls.loadSource(options.source.src);
hls.attachMedia(video);
if (options.autoplay && options.preload !== "none") {
this.tryPlay(player);
}
};
switch (options.preload) {
case "none":
this.originalPlayRef = player.play.bind(player);
player.play = (...args) => {
setupHls();
return this.originalPlayRef(...args);
};
break;
case "metadata": {
const hls = new Hls(this.buildHlsConfig({
autoStartLoad: false,
}));
this.hlsLoaded = true;
this.hlsRef = hls;
player.hlsInstance = hls;
hls.attachMedia(video);
hls.loadSource(options.source.src);
break;
}
case "visibility":
if (typeof IntersectionObserver !== "undefined" && this.containerRef) {
this.visibilityObserverRef = new IntersectionObserver(entries => {
if (entries[0]?.isIntersecting) {
setupHls();
this.visibilityObserverRef?.disconnect();
this.visibilityObserverRef = null;
}
}, { threshold: 0.25 });
this.visibilityObserverRef.observe(this.containerRef);
}
else {
setupHls();
}
break;
default:
setupHls();
}
}
async loadVideoJsSource() {
const options = this.options;
const player = this.playerRef;
if (!options || !player) {
return;
}
player.pause();
this.resetDeferredHlsLoading();
this.teardownHls();
try {
const token = await resolveVideoPlayerToken();
this.vhsAuthTokenRef = token;
this.ensureVhsAuthInterceptor(player);
}
catch (error) {
this.emit("error", {
scope: "runtime",
error,
});
}
player.src([
{
src: options.source.src,
type: options.source.type ?? DEFAULT_SOURCE_TYPE,
},
]);
if (options.initialTime > 0) {
player.one("loadedmetadata", () => {
player.currentTime(options.initialTime);
});
}
if (options.autoplay) {
this.tryPlay(player);
}
}
resetDeferredHlsLoading() {
if (this.visibilityObserverRef) {
this.visibilityObserverRef.disconnect();
this.visibilityObserverRef = null;
}
if (this.originalPlayRef && this.playerRef) {
this.playerRef.play = this.originalPlayRef;
this.originalPlayRef = null;
}
}
ensureVhsAuthInterceptor(player) {
if (this.vhsRequestCleanupRef) {
return;
}
const videojsAny = videojs;
const xhr = videojsAny?.Vhs?.xhr ?? videojsAny?.Hls?.xhr;
if (!xhr) {
return;
}
const originalBeforeRequest = xhr.beforeRequest;
xhr.beforeRequest = (requestOptions) => {
const processedOptions = originalBeforeRequest?.call(xhr, requestOptions) ?? requestOptions;
if (this.vhsAuthTokenRef) {
processedOptions.headers = {
...(processedOptions.headers ?? {}),
Authorization: `Bearer ${this.vhsAuthTokenRef}`,
};
}
return processedOptions;
};
this.vhsRequestCleanupRef = () => {
xhr.beforeRequest = originalBeforeRequest;
};
player.one("dispose", () => {
this.vhsRequestCleanupRef?.();
this.vhsRequestCleanupRef = null;
});
}
teardownHls() {
if (!this.hlsRef) {
return;
}
this.hlsRef.stopLoad();
this.hlsRef.detachMedia();
this.hlsRef.destroy();
this.hlsRef = null;
this.hlsLoaded = false;
if (this.playerRef) {
this.playerRef.hlsInstance = null;
const videoElement = this.playerRef
.el()
?.querySelector("video");
videoElement?.parentElement?.classList.remove("vjs-hls-live", "vjs-live");
if (this.playerRef.liveTracker) {
this.playerRef.liveTracker.isLive_ = false;
this.playerRef.liveTracker.trigger("durationchange");
}
}
}
}
//# sourceMappingURL=player-runtime.js.map