@7sage/vidstack
Version:
UI component library for building high-quality, accessible video and audio experiences on the web.
502 lines (496 loc) • 16.6 kB
JavaScript
import { loadScript, preconnect } from '../chunks/vidstack-CTojmhKq.js';
import { canPlayVideoType, canPlayAudioType, IS_CHROME, isDASHSupported } from '../chunks/vidstack-xMS8dnYq.js';
import { VideoProvider } from './vidstack-video.js';
import { listenEvent, effect, DOMEvent, camelToKebabCase, isNumber, isString, isUndefined, isFunction, peek } from '../chunks/vidstack-BGSTndAW.js';
import { QualitySymbol } from '../chunks/vidstack-B01xzxC4.js';
import { TextTrackSymbol, TextTrack } 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';
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 {
#video;
#ctx;
#instance = null;
#callbacks = /* @__PURE__ */ new Set();
#stopLiveSync = null;
config = {};
get instance() {
return this.#instance;
}
constructor(video, ctx) {
this.#video = video;
this.#ctx = ctx;
}
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));
}
#currentTrack = null;
#cueTracker = {};
#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.newQuality];
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.notify("stream-type-change", type !== "static" ? "live" : "on-demand", trigger);
this.#ctx.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: 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;
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));
}
}
#retryLoadingTimer = -1;
#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.notify("error", {
message: error.message ?? "",
code: 1,
error
});
}
#enableAutoQuality() {
this.#switchAutoBitrate("video", true);
const { qualities } = this.#ctx;
this.#instance?.setQualityFor("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.setQualityFor("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 = {};
}
onInstance(callback) {
this.#callbacks.add(callback);
return () => this.#callbacks.delete(callback);
}
loadSource(src) {
this.#reset();
if (!isString(src.src)) return;
this.#instance?.attachSource(src.src);
}
destroy() {
this.#reset();
this.#instance?.destroy();
this.#instance = null;
this.#stopLiveSync?.();
this.#stopLiveSync = null;
}
}
class DASHLibLoader {
#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 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.player.dispatch(new DOMEvent("dash-unsupported"));
this.#ctx.notify("error", { message, code: 4 });
return null;
}
return ctor;
}
#onLoadStart() {
this.#ctx.player.dispatch(new DOMEvent("dash-lib-load-start"));
}
#onLoaded(ctor) {
this.#ctx.player.dispatch(
new DOMEvent("dash-lib-loaded", {
detail: ctor
})
);
this.#callback(ctor);
}
#onLoadError(e) {
const error = coerceToError(e);
this.#ctx.player.dispatch(
new DOMEvent("dash-lib-load-error", {
detail: error
})
);
this.#ctx.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(
false ? "[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(
false ? "[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 {
$$PROVIDER_TYPE = "DASH";
#ctor = null;
#controller = new DASHController(this.video, this.ctx);
/**
* The `dash.js` constructor.
*/
get ctor() {
return this.#ctor;
}
/**
* The current `dash.js` instance.
*/
get instance() {
return this.#controller.instance;
}
/**
* Whether `dash.js` is supported in this environment.
*/
static supported = isDASHSupported();
get type() {
return "dash";
}
get canLiveSync() {
return true;
}
#library = `${JS_DELIVR_CDN}/npm/dashjs@4.7.4/dist/dash${".all.min.js"}`;
/**
* 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.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 `dash.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 { DASHProvider };