UNPKG

@7sage/vidstack

Version:

UI component library for building high-quality, accessible video and audio experiences on the web.

394 lines (389 loc) 12.6 kB
import { loadScript, preconnect } from '../chunks/vidstack-CTojmhKq.js'; import { IS_CHROME, isHLSSupported } from '../chunks/vidstack-xMS8dnYq.js'; import { VideoProvider } from './vidstack-video.js'; import { peek, listenEvent, effect, DOMEvent, camelToKebabCase, isString, isUndefined, isFunction } from '../chunks/vidstack-BGSTndAW.js'; import { QualitySymbol } from '../chunks/vidstack-B01xzxC4.js'; import { TextTrack, TextTrackSymbol } from '../chunks/vidstack-Ci54COQW.js'; import { ListSymbol } from '../chunks/vidstack-D5EzK014.js'; import { RAFLoop } from '../chunks/vidstack-DqAw8m9J.js'; import { coerceToError } from '../chunks/vidstack-C9vIqaYT.js'; import './vidstack-html.js'; import '../chunks/vidstack-Dihypf8P.js'; import '../chunks/vidstack-Bq6c3Bam.js'; import '../chunks/vidstack-DYbwIVLq.js'; const toDOMEventType = (type) => camelToKebabCase(type); class HLSController { #video; #ctx; #instance = null; #stopLiveSync = null; config = {}; #callbacks = /* @__PURE__ */ new Set(); get instance() { return this.#instance; } constructor(video, ctx) { this.#video = video; this.#ctx = ctx; } setup(ctor) { const { streamType } = this.#ctx.$state; const isLive = peek(streamType).includes("live"), isLiveLowLatency = peek(streamType).includes("ll-"); this.#instance = new ctor({ lowLatencyMode: isLiveLowLatency, backBufferLength: isLiveLowLatency ? 4 : isLive ? 8 : void 0, renderTextTracksNatively: false, ...this.config }); const dispatcher = this.#dispatchHLSEvent.bind(this); for (const event of Object.values(ctor.Events)) this.#instance.on(event, dispatcher); this.#instance.on(ctor.Events.ERROR, this.#onError.bind(this)); for (const callback of this.#callbacks) callback(this.#instance); this.#ctx.player.dispatch("hls-instance", { detail: this.#instance }); this.#instance.attachMedia(this.#video); this.#instance.on(ctor.Events.AUDIO_TRACK_SWITCHED, this.#onAudioSwitch.bind(this)); this.#instance.on(ctor.Events.LEVEL_SWITCHED, this.#onLevelSwitched.bind(this)); this.#instance.on(ctor.Events.LEVEL_LOADED, this.#onLevelLoaded.bind(this)); this.#instance.on(ctor.Events.LEVEL_UPDATED, this.#onLevelUpdated.bind(this)); this.#instance.on(ctor.Events.NON_NATIVE_TEXT_TRACKS_FOUND, this.#onTracksFound.bind(this)); this.#instance.on(ctor.Events.CUES_PARSED, this.#onCuesParsed.bind(this)); this.#ctx.qualities[QualitySymbol.enableAuto] = this.#enableAutoQuality.bind(this); listenEvent(this.#ctx.qualities, "change", this.#onUserQualityChange.bind(this)); listenEvent(this.#ctx.audioTracks, "change", this.#onUserAudioChange.bind(this)); this.#stopLiveSync = effect(this.#liveSync.bind(this)); } #createDOMEvent(type, data) { return new DOMEvent(toDOMEventType(type), { detail: data }); } #liveSync() { if (!this.#ctx.$state.live()) return; const raf = new RAFLoop(this.#liveSyncPosition.bind(this)); raf.start(); return raf.stop.bind(raf); } #liveSyncPosition() { this.#ctx.$state.liveSyncPosition.set(this.#instance?.liveSyncPosition ?? Infinity); } #dispatchHLSEvent(type, data) { this.#ctx.player?.dispatch(this.#createDOMEvent(type, data)); } #onTracksFound(eventType, data) { const event = this.#createDOMEvent(eventType, data); let currentTrack = -1; for (let i = 0; i < data.tracks.length; i++) { const nonNativeTrack = data.tracks[i], init = nonNativeTrack.subtitleTrack ?? nonNativeTrack.closedCaptions, track = new TextTrack({ id: `hls-${nonNativeTrack.kind}-${i}`, src: init?.url, label: nonNativeTrack.label, language: init?.lang, kind: nonNativeTrack.kind, default: nonNativeTrack.default }); track[TextTrackSymbol.readyState] = 2; track[TextTrackSymbol.onModeChange] = () => { if (track.mode === "showing") { this.#instance.subtitleTrack = i; currentTrack = i; } else if (currentTrack === i) { this.#instance.subtitleTrack = -1; currentTrack = -1; } }; this.#ctx.textTracks.add(track, event); } } #onCuesParsed(eventType, data) { const index = this.#instance?.subtitleTrack, track = this.#ctx.textTracks.getById(`hls-${data.type}-${index}`); if (!track) return; const event = this.#createDOMEvent(eventType, data); for (const cue of data.cues) { cue.positionAlign = "auto"; track.addCue(cue, event); } } #onAudioSwitch(eventType, data) { const track = this.#ctx.audioTracks[data.id]; if (track) { const trigger = this.#createDOMEvent(eventType, data); this.#ctx.audioTracks[ListSymbol.select](track, true, trigger); } } #onLevelSwitched(eventType, data) { const quality = this.#ctx.qualities[data.level]; if (quality) { const trigger = this.#createDOMEvent(eventType, data); this.#ctx.qualities[ListSymbol.select](quality, true, trigger); } } #onLevelUpdated(eventType, data) { if (data.details.totalduration > 0) { this.#ctx.$state.inferredLiveDVRWindow.set(data.details.totalduration); } } #onLevelLoaded(eventType, data) { if (this.#ctx.$state.canPlay()) return; const { type, live, totalduration: duration, targetduration } = data.details, trigger = this.#createDOMEvent(eventType, data); this.#ctx.notify( "stream-type-change", live ? type === "EVENT" && Number.isFinite(duration) && targetduration >= 10 ? "live:dvr" : "live" : "on-demand", trigger ); this.#ctx.notify("duration-change", duration, trigger); const media = this.#instance.media; if (this.#instance.currentLevel === -1) { this.#ctx.qualities[QualitySymbol.setAuto](true, trigger); } for (const remoteTrack of this.#instance.audioTracks) { const localTrack = { id: remoteTrack.id.toString(), label: remoteTrack.name, language: remoteTrack.lang || "", kind: "main" }; this.#ctx.audioTracks[ListSymbol.add](localTrack, trigger); } for (const level of this.#instance.levels) { const videoQuality = { id: level.id?.toString() ?? level.height + "p", width: level.width, height: level.height, codec: level.codecSet, bitrate: level.bitrate }; this.#ctx.qualities[ListSymbol.add](videoQuality, trigger); } media.dispatchEvent(new DOMEvent("canplay", { trigger })); } #onError(eventType, data) { if (data.fatal) { switch (data.type) { case "mediaError": this.#instance?.recoverMediaError(); break; default: this.#onFatalError(data.error); break; } } } #onFatalError(error) { this.#ctx.notify("error", { message: error.message, code: 1, error }); } #enableAutoQuality() { if (this.#instance) this.#instance.currentLevel = -1; } #onUserQualityChange() { const { qualities } = this.#ctx; if (!this.#instance || qualities.auto) return; this.#instance[qualities.switch + "Level"] = qualities.selectedIndex; if (IS_CHROME) { this.#video.currentTime = this.#video.currentTime; } } #onUserAudioChange() { const { audioTracks } = this.#ctx; if (this.#instance && this.#instance.audioTrack !== audioTracks.selectedIndex) { this.#instance.audioTrack = audioTracks.selectedIndex; } } onInstance(callback) { this.#callbacks.add(callback); return () => this.#callbacks.delete(callback); } loadSource(src) { if (!isString(src.src)) return; this.#instance?.loadSource(src.src); } destroy() { this.#instance?.destroy(); this.#instance = null; this.#stopLiveSync?.(); this.#stopLiveSync = null; } } class HLSLibLoader { #lib; #ctx; #callback; constructor(lib, ctx, callback) { this.#lib = lib; this.#ctx = ctx; this.#callback = callback; this.#startLoading(); } async #startLoading() { const callbacks = { onLoadStart: this.#onLoadStart.bind(this), onLoaded: this.#onLoaded.bind(this), onLoadError: this.#onLoadError.bind(this) }; let ctor = await loadHLSScript(this.#lib, callbacks); if (isUndefined(ctor) && !isString(this.#lib)) ctor = await importHLS(this.#lib, callbacks); if (!ctor) return null; if (!ctor.isSupported()) { const message = "[vidstack] `hls.js` is not supported in this environment"; this.#ctx.player.dispatch(new DOMEvent("hls-unsupported")); this.#ctx.notify("error", { message, code: 4 }); return null; } return ctor; } #onLoadStart() { this.#ctx.player.dispatch(new DOMEvent("hls-lib-load-start")); } #onLoaded(ctor) { this.#ctx.player.dispatch( new DOMEvent("hls-lib-loaded", { detail: ctor }) ); this.#callback(ctor); } #onLoadError(e) { const error = coerceToError(e); this.#ctx.player.dispatch( new DOMEvent("hls-lib-load-error", { detail: error }) ); this.#ctx.notify("error", { message: error.message, code: 4, error }); } } async function importHLS(loader, callbacks = {}) { if (isUndefined(loader)) return void 0; callbacks.onLoadStart?.(); if (loader.prototype && loader.prototype !== Function) { callbacks.onLoaded?.(loader); return loader; } try { const ctor = (await loader())?.default; if (ctor && !!ctor.isSupported) { callbacks.onLoaded?.(ctor); } else { throw Error( false ? "[vidstack] failed importing `hls.js`. Dynamic import returned invalid constructor." : "" ); } return ctor; } catch (err) { callbacks.onLoadError?.(err); } return void 0; } async function loadHLSScript(src, callbacks = {}) { if (!isString(src)) return void 0; callbacks.onLoadStart?.(); try { await loadScript(src); if (!isFunction(window.Hls)) { throw Error( false ? "[vidstack] failed loading `hls.js`. Could not find a valid `Hls` constructor on window" : "" ); } const ctor = window.Hls; callbacks.onLoaded?.(ctor); return ctor; } catch (err) { callbacks.onLoadError?.(err); } return void 0; } const JS_DELIVR_CDN = "https://cdn.jsdelivr.net"; class HLSProvider extends VideoProvider { $$PROVIDER_TYPE = "HLS"; #ctor = null; #controller = new HLSController(this.video, this.ctx); /** * The `hls.js` constructor. */ get ctor() { return this.#ctor; } /** * The current `hls.js` instance. */ get instance() { return this.#controller.instance; } /** * Whether `hls.js` is supported in this environment. */ static supported = isHLSSupported(); get type() { return "hls"; } get canLiveSync() { return true; } #library = `${JS_DELIVR_CDN}/npm/hls.js@^1.5.0/dist/hls${".min.js"}`; /** * The `hls.js` configuration object. * * @see {@link https://github.com/video-dev/hls.js/blob/master/docs/API.md#fine-tuning} */ get config() { return this.#controller.config; } set config(config) { this.#controller.config = config; } /** * The `hls.js` constructor (supports dynamic imports) or a URL of where it can be found. * * @defaultValue `https://cdn.jsdelivr.net/npm/hls.js@^1.0.0/dist/hls.min.js` */ get library() { return this.#library; } set library(library) { this.#library = library; } preconnect() { if (!isString(this.#library)) return; preconnect(this.#library); } setup() { super.setup(); new HLSLibLoader(this.#library, this.ctx, (ctor) => { this.#ctor = ctor; this.#controller.setup(ctor); this.ctx.notify("provider-setup", this); const src = peek(this.ctx.$state.source); if (src) this.loadSource(src); }); } async loadSource(src, preload) { if (!isString(src.src)) { this.removeSource(); return; } this.media.preload = preload || ""; this.appendSource(src, "application/x-mpegurl"); this.#controller.loadSource(src); this.currentSrc = src; } /** * The given callback is invoked when a new `hls.js` instance is created and right before it's * attached to media. */ onInstance(callback) { const instance = this.#controller.instance; if (instance) callback(instance); return this.#controller.onInstance(callback); } destroy() { this.#controller.destroy(); } } export { HLSProvider };