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, getToken, refreshToken, }) => { const BaseLoader = Hls.DefaultConfig.loader; return class AuthPlaylistLoader extends BaseLoader { constructor(config) { super({ ...config, debug: debug ?? false }); } load(context, config, callbacks) { try { const token = getToken(); 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); } } // Critical path must stay sync for hls.js loader lifecycle. super.load(context, config, callbacks); refreshToken(); } }; }; 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.hlsAuthTokenRef = null; this.hlsTokenResolvePromise = 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; this.hlsAuthTokenRef = null; this.hlsTokenResolvePromise = 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; } async ensureHlsAuthToken() { if (this.hlsTokenResolvePromise !== null) { await this.hlsTokenResolvePromise; return; } this.hlsTokenResolvePromise = resolveVideoPlayerToken() .then(token => { this.hlsAuthTokenRef = token ?? null; }) .catch(() => { this.hlsAuthTokenRef = null; }) .finally(() => { this.hlsTokenResolvePromise = null; }); await this.hlsTokenResolvePromise; } refreshHlsAuthTokenInBackground() { if (this.hlsTokenResolvePromise !== null) { return; } void this.ensureHlsAuthToken(); } 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, getToken: () => this.hlsAuthTokenRef, refreshToken: () => this.refreshHlsAuthTokenInBackground(), }); 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, }; } syncLiveUi(player, video, isLive) { const wrapper = video.parentElement; if (isLive) { wrapper?.classList.add("vjs-hls-live", "vjs-live"); player.duration(Infinity); if (player.liveTracker) { player.liveTracker.isLive_ = true; player.liveTracker.startTracking(); player.liveTracker.trigger("durationchange"); } return; } wrapper?.classList.remove("vjs-hls-live", "vjs-live"); if (player.liveTracker) { player.liveTracker.isLive_ = false; player.liveTracker.trigger("durationchange"); } } 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; } // Resolve async token before starting HLS manifest load. await this.ensureHlsAuthToken(); 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 (typeof details?.live === "boolean") { this.syncLiveUi(player, video, details.live); } if (options.initialTime > 0) { hls.startLoad(options.initialTime); } }); hls.on(Hls.Events.LEVEL_LOADED, (_event, data) => { const details = data?.details; if (!details) { return; } this.emit("manifestloaded", { engine: "hls", duration: details.totalduration, live: details.live, }); if (typeof details.live === "boolean") { this.syncLiveUi(player, video, details.live); } }); 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"); if (videoElement) { this.syncLiveUi(this.playerRef, videoElement, false); } } } } //# sourceMappingURL=player-runtime.js.map