UNPKG

@ktt45678/vidstack

Version:

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

400 lines (395 loc) 13.4 kB
import { loadScript, preconnect } from '../chunks/vidstack-BnCZ4oyK.js'; import { IS_CHROME, isHLSSupported } from '../chunks/vidstack-BpOkecTJ.js'; import { VideoProvider } from './vidstack-video.js'; import { peek, listenEvent, effect, DOMEvent, isString, camelToKebabCase, isUndefined, isFunction } from '../chunks/vidstack-fG_Sx3Q9.js'; import { QualitySymbol } from '../chunks/vidstack-BYmCj-36.js'; import { TextTrack, TextTrackSymbol } from '../chunks/vidstack-DSRs3D8P.js'; import { RAFLoop, ListSymbol } from '../chunks/vidstack-BXMqlVv4.js'; import { coerceToError } from '../chunks/vidstack-DbBJlz7I.js'; import './vidstack-html.js'; import '../chunks/vidstack-Dihypf8P.js'; import '../chunks/vidstack-B9iqnZP1.js'; import 'media-captions'; import '../chunks/vidstack-C_9SlM6s.js'; const toDOMEventType = (type) => camelToKebabCase(type); class HLSController { constructor(_video, _ctx) { this._video = _video; this._ctx = _ctx; this._instance = null; this._stopLiveSync = null; this._config = {}; this._callbacks = /* @__PURE__ */ new Set(); } get instance() { return this._instance; } 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.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); } } _onLevelLoaded(eventType, data) { if (this._ctx.$state.canPlay()) return; const { type, live, totalduration: duration, targetduration } = data.details, trigger = this._createDOMEvent(eventType, data); this._ctx.delegate._notify( "stream-type-change", live ? type === "EVENT" && Number.isFinite(duration) && targetduration >= 10 ? "live:dvr" : "live" : "on-demand", trigger ); this._ctx.delegate._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) { { this._ctx.logger?.errorGroup(`[vidstack] HLS error \`${eventType}\``).labelledLog("Media Element", this._instance?.media).labelledLog("HLS Instance", this._instance).labelledLog("Event Type", eventType).labelledLog("Data", data).labelledLog("Src", peek(this._ctx.$state.source)).labelledLog("Media Store", { ...this._ctx.$state }).dispatch(); } if (data.fatal) { switch (data.type) { case "mediaError": this._instance?.recoverMediaError(); break; default: this._onFatalError(data.error); break; } } } _onFatalError(error) { this._ctx.delegate._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; } } _loadSource(src) { if (!isString(src.src)) return; this._instance?.loadSource(src.src); } _destroy() { this._instance?.destroy(); this._instance = null; this._stopLiveSync?.(); this._stopLiveSync = null; this._ctx?.logger?.info("\u{1F3D7}\uFE0F Destroyed HLS instance"); } } class HLSLibLoader { constructor(_lib, _ctx, _callback) { this._lib = _lib; this._ctx = _ctx; this._callback = _callback; this._startLoading(); } async _startLoading() { this._ctx.logger?.info("\u{1F3D7}\uFE0F Loading HLS Library"); 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.logger?.error(message); this._ctx.player.dispatch(new DOMEvent("hls-unsupported")); this._ctx.delegate._notify("error", { message, code: 4 }); return null; } return ctor; } _onLoadStart() { { this._ctx.logger?.infoGroup("Starting to load `hls.js`").labelledLog("URL", this._lib).dispatch(); } this._ctx.player.dispatch(new DOMEvent("hls-lib-load-start")); } _onLoaded(ctor) { { this._ctx.logger?.infoGroup("Loaded `hls.js`").labelledLog("Library", this._lib).labelledLog("Constructor", ctor).dispatch(); } this._ctx.player.dispatch( new DOMEvent("hls-lib-loaded", { detail: ctor }) ); this._callback(ctor); } _onLoadError(e) { const error = coerceToError(e); { this._ctx.logger?.errorGroup("[vidstack] Failed to load `hls.js`").labelledLog("Library", this._lib).labelledLog("Error", e).dispatch(); } this._ctx.player.dispatch( new DOMEvent("hls-lib-load-error", { detail: error }) ); this._ctx.delegate._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( true ? "[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( true ? "[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 { constructor() { super(...arguments); this.$$PROVIDER_TYPE = "HLS"; this._ctor = null; this._controller = new HLSController(this.video, this._ctx); this._library = `${JS_DELIVR_CDN}/npm/hls.js@^1.5.0/dist/hls${".js" }`; } /** * The `hls.js` constructor. */ get ctor() { return this._ctor; } /** * The current `hls.js` instance. */ get instance() { return this._controller.instance; } static { /** * Whether `hls.js` is supported in this environment. */ this.supported = isHLSSupported(); } get type() { return "hls"; } get canLiveSync() { return true; } /** * 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.delegate._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); this._controller._callbacks.add(callback); return () => this._controller._callbacks.delete(callback); } destroy() { this._controller._destroy(); } } export { HLSProvider };