UNPKG

@ktt45678/vidstack

Version:

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

521 lines (515 loc) 17.8 kB
import { canPlayVideoType, canPlayAudioType, IS_CHROME, isParsedManifest, isDASHSupported } from '../chunks/vidstack-BpOkecTJ.js'; import { loadScript, preconnect } from '../chunks/vidstack-BnCZ4oyK.js'; import { VideoProvider } from './vidstack-video.js'; import { listenEvent, effect, DOMEvent, isNumber, peek, isString, camelToKebabCase, isUndefined, isFunction } from '../chunks/vidstack-fG_Sx3Q9.js'; import { QualitySymbol } from '../chunks/vidstack-BYmCj-36.js'; import { TextTrackSymbol, TextTrack } 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'; function getLangName(langCode) { try { const displayNames = new Intl.DisplayNames(navigator.languages, { type: "language" }); const languageName = displayNames.of(langCode); return languageName ?? null; } catch (err) { return null; } } const toDOMEventType = (type) => `dash-${camelToKebabCase(type)}`; class DASHController { constructor(_video, _ctx) { this._video = _video; this._ctx = _ctx; this._instance = null; this._stopLiveSync = null; this._config = {}; this._callbacks = /* @__PURE__ */ new Set(); this._currentTrack = null; this._cueTracker = {}; this._retryLoadingTimer = -1; } get instance() { return this._instance; } setup(ctor) { this._instance = ctor().create(); const dispatcher = this._dispatchDASHEvent.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("dash-instance", { detail: this._instance }); this._instance.initialize(this._video, void 0, false); this._instance.updateSettings({ streaming: { text: { // Disabling text rendering by dash. defaultEnabled: false, dispatchForManualRendering: true }, buffer: { /// Enables buffer replacement when switching bitrates for faster switching. fastSwitchEnabled: true } }, ...this._config }); this._instance.on(ctor.events.FRAGMENT_LOADING_STARTED, this._onFragmentLoadStart.bind(this)); this._instance.on( ctor.events.FRAGMENT_LOADING_COMPLETED, this._onFragmentLoadComplete.bind(this) ); this._instance.on(ctor.events.MANIFEST_LOADED, this._onManifestLoaded.bind(this)); this._instance.on(ctor.events.QUALITY_CHANGE_RENDERED, this._onQualityChange.bind(this)); this._instance.on(ctor.events.TEXT_TRACKS_ADDED, this._onTextTracksAdded.bind(this)); this._instance.on(ctor.events.TRACK_CHANGE_RENDERED, this._onTrackChange.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(event) { return new DOMEvent(toDOMEventType(event.type), { detail: event }); } _liveSync() { if (!this._ctx.$state.live()) return; const raf = new RAFLoop(this._liveSyncPosition.bind(this)); raf._start(); return raf._stop.bind(raf); } _liveSyncPosition() { if (!this._instance) return; const position = this._instance.duration() - this._instance.time(); this._ctx.$state.liveSyncPosition.set(!isNaN(position) ? position : Infinity); } _dispatchDASHEvent(event) { this._ctx.player?.dispatch(this._createDOMEvent(event)); } _onTextFragmentLoaded(event) { const native = this._currentTrack?.[TextTrackSymbol._native], cues = (native?.track).cues; if (!native || !cues) return; const id = this._currentTrack.id, startIndex = this._cueTracker[id] ?? 0, trigger = this._createDOMEvent(event); for (let i = startIndex; i < cues.length; i++) { const cue = cues[i]; if (!cue.positionAlign) cue.positionAlign = "auto"; this._currentTrack.addCue(cue, trigger); } this._cueTracker[id] = cues.length; } _onTextTracksAdded(event) { if (!this._instance) return; const data = event.tracks, nativeTextTracks = [...this._video.textTracks].filter((track) => "manualMode" in track), trigger = this._createDOMEvent(event); for (let i = 0; i < nativeTextTracks.length; i++) { const textTrackInfo = data[i], nativeTextTrack = nativeTextTracks[i]; const id = `dash-${textTrackInfo.kind}-${i}`, track = new TextTrack({ id, label: textTrackInfo?.label ?? textTrackInfo.labels.find((t) => t.text)?.text ?? (textTrackInfo?.lang && getLangName(textTrackInfo.lang)) ?? textTrackInfo?.lang ?? void 0, language: textTrackInfo.lang ?? void 0, kind: textTrackInfo.kind, default: textTrackInfo.defaultTrack }); track[TextTrackSymbol._native] = { managed: true, track: nativeTextTrack }; track[TextTrackSymbol._readyState] = 2; track[TextTrackSymbol._onModeChange] = () => { if (!this._instance) return; if (track.mode === "showing") { this._instance.setTextTrack(i); this._currentTrack = track; } else { this._instance.setTextTrack(-1); this._currentTrack = null; } }; this._ctx.textTracks.add(track, trigger); } } _onTrackChange(event) { const { mediaType, newMediaInfo } = event; if (mediaType === "audio") { const track = this._ctx.audioTracks.getById(`dash-audio-${newMediaInfo.index}`); if (track) { const trigger = this._createDOMEvent(event); this._ctx.audioTracks[ListSymbol._select](track, true, trigger); } } } _onQualityChange(event) { if (event.mediaType !== "video") return; const quality = this._ctx.qualities[event.newRepresentation.index]; if (quality) { const trigger = this._createDOMEvent(event); this._ctx.qualities[ListSymbol._select](quality, true, trigger); } } _onManifestLoaded(event) { if (this._ctx.$state.canPlay() || !this._instance) return; const { type, mediaPresentationDuration } = event.data, trigger = this._createDOMEvent(event); this._ctx.delegate._notify( "stream-type-change", type !== "static" ? "live" : "on-demand", trigger ); this._ctx.delegate._notify("duration-change", mediaPresentationDuration, trigger); this._ctx.qualities[QualitySymbol._setAuto](true, trigger); const media = this._instance.getVideoElement(); const videoQualities = this._instance.getTracksForTypeFromManifest( "video", event.data ); const supportedVideoMimeType = [...new Set(videoQualities.map((e) => e.mimeType))].find( (type2) => type2 && canPlayVideoType(media, type2) ); const videoQuality = videoQualities.filter( (track) => supportedVideoMimeType === track.mimeType )[0]; let audioTracks = this._instance.getTracksForTypeFromManifest( "audio", event.data ); const supportedAudioMimeType = [...new Set(audioTracks.map((e) => e.mimeType))].find( (type2) => type2 && canPlayAudioType(media, type2) ); audioTracks = audioTracks.filter((track) => supportedAudioMimeType === track.mimeType); videoQuality.bitrateList.forEach((bitrate, index) => { const quality = { id: bitrate.id?.toString() ?? `dash-bitrate-${index}`, width: bitrate.width ?? 0, height: bitrate.height ?? 0, bitrate: bitrate.bandwidth ?? 0, codec: videoQuality.codec, index }; this._ctx.qualities[ListSymbol._add](quality, trigger); }); if (isNumber(videoQuality.index)) { const quality = this._ctx.qualities[videoQuality.index]; if (quality) this._ctx.qualities[ListSymbol._select](quality, true, trigger); } audioTracks.forEach((audioTrack, index) => { const matchingLabel = audioTrack.labels.find((label2) => { return navigator.languages.some((language) => { return label2.lang && language.toLowerCase().startsWith(label2.lang.toLowerCase()); }); }); const label = matchingLabel || audioTrack.labels[0]; const localTrack = { id: `dash-audio-${audioTrack?.index}`, label: audioTrack.bitrateList[0]?.id ?? label?.text ?? (audioTrack.lang && getLangName(audioTrack.lang)) ?? audioTrack.lang ?? "", language: audioTrack.lang ?? "", kind: "main", mimeType: audioTrack.mimeType, codec: audioTrack.codec, index }; this._ctx.audioTracks[ListSymbol._add](localTrack, trigger); }); media.dispatchEvent(new DOMEvent("canplay", { trigger })); } _onError(event) { const { type: eventType, error: data } = event; { this._ctx.logger?.errorGroup(`[vidstack] DASH error \`${data.message}\``).labelledLog("Media Element", this._video).labelledLog("DASH Instance", this._instance).labelledLog("Event Type", eventType).labelledLog("Data", data).labelledLog("Src", peek(this._ctx.$state.source)).labelledLog("Media Store", { ...this._ctx.$state }).dispatch(); } switch (data.code) { case 27: this._onNetworkError(data); break; default: this._onFatalError(data); break; } } _onFragmentLoadStart() { if (this._retryLoadingTimer >= 0) this._clearRetryTimer(); } _onFragmentLoadComplete(event) { const mediaType = event.mediaType; if (mediaType === "text") { requestAnimationFrame(this._onTextFragmentLoaded.bind(this, event)); } } _onNetworkError(error) { this._clearRetryTimer(); this._instance?.play(); this._retryLoadingTimer = window.setTimeout(() => { this._retryLoadingTimer = -1; this._onFatalError(error); }, 5e3); } _clearRetryTimer() { clearTimeout(this._retryLoadingTimer); this._retryLoadingTimer = -1; } _onFatalError(error) { this._ctx.delegate._notify("error", { message: error.message ?? "", code: 1, error }); } _enableAutoQuality() { this._switchAutoBitrate("video", true); const { qualities } = this._ctx; this._instance?.setRepresentationForTypeByIndex("video", qualities.selectedIndex, true); } _switchAutoBitrate(type, auto) { this._instance?.updateSettings({ streaming: { abr: { autoSwitchBitrate: { [type]: auto } } } }); } _onUserQualityChange() { const { qualities } = this._ctx; if (!this._instance || qualities.auto || !qualities.selected) return; this._switchAutoBitrate("video", false); this._instance.setRepresentationForTypeByIndex( "video", qualities.selectedIndex, qualities.switch === "current" ); if (IS_CHROME) { this._video.currentTime = this._video.currentTime; } } _onUserAudioChange() { if (!this._instance) return; const { audioTracks } = this._ctx, selectedTrack = this._instance.getTracksFor("audio").find( (track) => audioTracks.selected && audioTracks.selected.id === `dash-audio-${track.index}` ); if (selectedTrack) this._instance.setCurrentTrack(selectedTrack); } _reset() { this._clearRetryTimer(); this._currentTrack = null; this._cueTracker = {}; } loadSource(src) { this._reset(); if (!isString(src.src) && !isParsedManifest(src.src)) return; this._instance?.attachSource(src.src); } destroy() { this._reset(); this._instance?.destroy(); this._instance = null; this._stopLiveSync?.(); this._stopLiveSync = null; this._ctx?.logger?.info("\u{1F3D7}\uFE0F Destroyed DASH instance"); } } class DASHLibLoader { 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 DASH Library"); const callbacks = { onLoadStart: this._onLoadStart.bind(this), onLoaded: this._onLoaded.bind(this), onLoadError: this._onLoadError.bind(this) }; let ctor = await loadDASHScript(this._lib, callbacks); if (isUndefined(ctor) && !isString(this._lib)) ctor = await importDASH(this._lib, callbacks); if (!ctor) return null; if (!window.dashjs.supportsMediaSource()) { const message = "[vidstack] `dash.js` is not supported in this environment"; this._ctx.logger?.error(message); this._ctx.player.dispatch(new DOMEvent("dash-unsupported")); this._ctx.delegate._notify("error", { message, code: 4 }); return null; } return ctor; } _onLoadStart() { { this._ctx.logger?.infoGroup("Starting to load `dash.js`").labelledLog("URL", this._lib).dispatch(); } this._ctx.player.dispatch(new DOMEvent("dash-lib-load-start")); } _onLoaded(ctor) { { this._ctx.logger?.infoGroup("Loaded `dash.js`").labelledLog("Library", this._lib).labelledLog("Constructor", ctor).dispatch(); } this._ctx.player.dispatch( new DOMEvent("dash-lib-loaded", { detail: ctor }) ); this._callback(ctor); } _onLoadError(e) { const error = coerceToError(e); { this._ctx.logger?.errorGroup("[vidstack] Failed to load `dash.js`").labelledLog("Library", this._lib).labelledLog("Error", e).dispatch(); } this._ctx.player.dispatch( new DOMEvent("dash-lib-load-error", { detail: error }) ); this._ctx.delegate._notify("error", { message: error.message, code: 4, error }); } } async function importDASH(loader, callbacks = {}) { if (isUndefined(loader)) return void 0; callbacks.onLoadStart?.(); if (isDASHConstructor(loader)) { callbacks.onLoaded?.(loader); return loader; } if (isDASHNamespace(loader)) { const ctor = loader.MediaPlayer; callbacks.onLoaded?.(ctor); return ctor; } try { const ctor = (await loader())?.default; if (isDASHNamespace(ctor)) { callbacks.onLoaded?.(ctor.MediaPlayer); return ctor.MediaPlayer; } if (ctor) { callbacks.onLoaded?.(ctor); } else { throw Error( true ? "[vidstack] failed importing `dash.js`. Dynamic import returned invalid object." : "" ); } return ctor; } catch (err) { callbacks.onLoadError?.(err); } return void 0; } async function loadDASHScript(src, callbacks = {}) { if (!isString(src)) return void 0; callbacks.onLoadStart?.(); try { await loadScript(src); if (!isFunction(window.dashjs.MediaPlayer)) { throw Error( true ? "[vidstack] failed loading `dash.js`. Could not find a valid `Dash` constructor on window" : "" ); } const ctor = window.dashjs.MediaPlayer; callbacks.onLoaded?.(ctor); return ctor; } catch (err) { callbacks.onLoadError?.(err); } return void 0; } function isDASHConstructor(value) { return value && value.prototype && value.prototype !== Function; } function isDASHNamespace(value) { return value && "MediaPlayer" in value; } const JS_DELIVR_CDN = "https://cdn.jsdelivr.net"; class DASHProvider extends VideoProvider { constructor() { super(...arguments); this.$$PROVIDER_TYPE = "DASH"; this._ctor = null; this._controller = new DASHController(this.video, this._ctx); this._library = `${JS_DELIVR_CDN}/npm/dashjs@4.7.4/dist/dash${".all.debug.js" }`; } /** * The `dash.js` constructor. */ get ctor() { return this._ctor; } /** * The current `dash.js` instance. */ get instance() { return this._controller.instance; } static { /** * Whether `dash.js` is supported in this environment. */ this.supported = isDASHSupported(); } get type() { return "dash"; } get canLiveSync() { return true; } /** * The `dash.js` configuration object. * * @see {@link https://cdn.dashjs.org/latest/jsdoc/module-Settings.html} */ get config() { return this._controller._config; } set config(config) { this._controller._config = config; } /** * The `dash.js` constructor (supports dynamic imports) or a URL of where it can be found. * * @defaultValue `https://cdn.jsdelivr.net/npm/dashjs@4.7.4/dist/dash.all.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 DASHLibLoader(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) && !isParsedManifest(src.src)) { this._removeSource(); return; } this._media.preload = preload || ""; this._controller.loadSource(src); this._currentSrc = src; } /** * The given callback is invoked when a new `dash.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 { DASHProvider };