vidstack
Version:
Build awesome media experiences on the web.
1,691 lines (1,659 loc) • 269 kB
JavaScript
import { lazyPaths } from 'https://cdn.jsdelivr.net/npm/media-icons/dist/lazy.js';
import { r as defineElement, C as Component, t as signal, l as effect, p as peek, $ as $$_clone, e as $$_effect, h as $$_create_template, m as listenEvent, v as isKeyboardClick, x as scoped, y as getScope, s as setAttribute, z as useContext, A as createContext, j as computed, c as isString, B as tick, F as isArray, G as animationFrameThrottle, H as setStyle, o as onDispose, I as ComponentController, J as isObject, D as DOMEvent, i as isUndefined, d as deferredPromise, K as defineProp, k as isNumber, S as StoreFactory, E as EventsTarget, L as isKeyboardEvent, M as appendTriggerEvent, N as noop, O as provideContext, P as uppercaseFirstChar, q as camelToKebabCase, Q as prop, R as method, f as $$_attr, T as isWriteSignal, U as ariaBool$1, V as $$_create_component, W as createDisposalBin, X as isPointerEvent, Y as isMouseEvent, Z as isTouchEvent, _ as kebabToCamelCase, a0 as $$_insert_lite, a1 as isDOMElement, a2 as hasProvidedContext, a3 as $$_listen, a4 as $$_scoped, a5 as $$_setup_custom_element, g as $$_ref, a6 as useStore, b as isNull, a7 as isDOMEvent, a8 as $$_peek, a9 as registerLiteCustomElement } from './dev/maverick.js';
import { g as getRequestCredentials, p as preconnect, H as HLSProviderLoader, V as VideoProviderLoader, A as AudioProviderLoader, T as TextTrack, I as IS_SAFARI, r as round, c as canOrientScreen, i as isHTMLMediaElement, L as LIST_READONLY, a as LIST_ADD, b as LIST_REMOVE, d as LIST_ON_REMOVE, e as LIST_RESET, f as LIST_SET_READONLY, h as LIST_ON_RESET, j as LIST_SELECT, k as LIST_ON_USER_SELECT, S as SET_AUTO_QUALITY, E as ENABLE_AUTO_QUALITY, l as coerceToError, m as TEXT_TRACK_CAN_LOAD, n as TEXT_TRACK_UPDATE_ACTIVE_CUES, o as isTrackCaptionKind, q as TEXT_TRACK_NATIVE, s as ATTACH_VIDEO, t as TEXT_TRACK_NATIVE_HLS, u as TEXT_TRACK_CROSSORIGIN, v as TEXT_TRACK_ON_MODE_CHANGE, w as IS_IPHONE, x as clampNumber, y as findActiveCue, z as isCueActive, B as onTrackChapterChange, C as getNumberOfDecimalPlaces, D as canChangeVolume } from './dev/providers.js';
import { C as CaptionsRenderer, r as renderVTTCueString, u as updateTimedVTTCueNodes } from './dev/captions/index.js';
const $$_templ$m = /* @__PURE__ */ $$_create_template(`<svg viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" data-media-icon="true"></svg>`);
let Icon$1 = class Icon extends Component {
constructor() {
super(...arguments);
this._hydrate = false;
this._paths = signal("");
}
onAttach(el) {
this._hydrate = el.hasAttribute("mk-h");
effect(this._loadIcon.bind(this));
}
_loadIcon() {
const type = this.$props.type();
if (this._hydrate) {
this._hydrate = false;
return;
}
if (type && lazyPaths[type]) {
lazyPaths[type]().then(({ default: paths2 }) => {
if (type === peek(this.$props.type))
this._paths.set(paths2);
});
} else {
this._paths.set("");
}
}
render() {
return (() => {
const $$_root = $$_clone($$_templ$m);
$$_effect(() => void ($$_root.innerHTML = this._paths()));
return $$_root;
})();
}
};
Icon$1.el = defineElement({
tagName: "media-icon",
props: { type: void 0 }
});
function setAttributeIfEmpty(target, name, value) {
if (!target.hasAttribute(name))
target.setAttribute(name, value);
}
function setARIALabel(target, label) {
if (target.hasAttribute("aria-label") || target.hasAttribute("aria-describedby"))
return;
function updateAriaDescription() {
setAttribute(target, "aria-label", label());
}
effect(updateAriaDescription);
}
function isElementParent(owner, node, test) {
while (node) {
if (node === owner) {
return true;
} else if (node.localName === owner.localName || test?.(node)) {
break;
} else {
node = node.parentElement;
}
}
return false;
}
function onPress(target, handler) {
listenEvent(target, "pointerup", (event) => {
if (event.button === 0)
handler(event);
});
listenEvent(target, "keydown", (event) => {
if (isKeyboardClick(event))
handler(event);
});
}
function scopedRaf(callback) {
const scope = getScope();
requestAnimationFrame(() => scoped(callback, scope));
}
const mediaContext = createContext();
function useMedia() {
return useContext(mediaContext);
}
let warned = /* @__PURE__ */ new Set() ;
class SourceSelection {
constructor(_domSources, _media, _loader) {
this._domSources = _domSources;
this._media = _media;
this._loader = _loader;
const HLS_LOADER = new HLSProviderLoader(), VIDEO_LOADER = new VideoProviderLoader(), AUDIO_LOADER = new AudioProviderLoader();
this._loaders = computed(() => {
return _media.$props.preferNativeHLS() ? [VIDEO_LOADER, AUDIO_LOADER, HLS_LOADER] : [HLS_LOADER, VIDEO_LOADER, AUDIO_LOADER];
});
effect(this._onSourcesChange.bind(this));
effect(this._onSourceChange.bind(this));
effect(this._onPreconnect.bind(this));
effect(this._onLoadSource.bind(this));
}
_onSourcesChange() {
this._media.delegate._dispatch("sources-change", {
detail: [...normalizeSrc(this._media.$props.src()), ...this._domSources()]
});
}
_onSourceChange() {
const { $store } = this._media;
const sources = $store.sources(), currentSource = peek($store.source), newSource = this._findNewSource(currentSource, sources), noMatch = sources[0]?.src && !newSource.src && !newSource.type;
if (noMatch && !warned.has(newSource.src) && !peek(this._loader)) {
const source = sources[0];
console.warn(
`[vidstack] could not find a loader for any of the given media sources, consider providing \`type\`:
<media-outlet>
<source src="${source.src}" type="video/mp4" />
</media-outlet>"
Falling back to fetching source headers...`
);
warned.add(newSource.src);
}
if (noMatch) {
const { crossorigin } = $store, credentials = getRequestCredentials(crossorigin()), abort = new AbortController();
Promise.all(
sources.map(
(source) => isString(source.src) && source.type === "?" ? fetch(source.src, {
method: "HEAD",
credentials,
signal: abort.signal
}).then((res) => {
source.type = res.headers.get("content-type") || "??";
return source;
}).catch(() => source) : source
)
).then((sources2) => {
if (abort.signal.aborted)
return;
this._findNewSource(peek($store.source), sources2);
tick();
});
return () => abort.abort();
}
tick();
}
_findNewSource(currentSource, sources) {
let newSource = { src: "", type: "" }, newLoader = null;
for (const src of sources) {
const loader = peek(this._loaders).find((loader2) => loader2.canPlay(src));
if (loader) {
newSource = src;
newLoader = loader;
}
}
this._notifySourceChange(currentSource, newSource, newLoader);
this._notifyLoaderChange(peek(this._loader), newLoader);
return newSource;
}
_notifySourceChange(currentSource, newSource, newLoader) {
if (newSource.src === currentSource.src && newSource.type === currentSource.type)
return;
this._media.delegate._dispatch("source-change", { detail: newSource });
this._media.delegate._dispatch("media-type-change", {
detail: newLoader?.mediaType(newSource) || "unknown"
});
}
_notifyLoaderChange(currentLoader, newLoader) {
if (newLoader === currentLoader)
return;
this._media.delegate._dispatch("provider-change", { detail: null });
newLoader && peek(() => newLoader.preconnect?.(this._media));
this._loader.set(newLoader);
this._media.delegate._dispatch("provider-loader-change", { detail: newLoader });
}
_onPreconnect() {
const provider = this._media.$provider();
if (!provider)
return;
if (this._media.$store.canLoad()) {
peek(
() => provider.setup({
...this._media,
player: this._media.player
})
);
return;
}
peek(() => provider.preconnect?.(this._media));
}
_onLoadSource() {
const provider = this._media.$provider(), source = this._media.$store.source();
if (this._media.$store.canLoad()) {
peek(() => provider?.loadSource(source, peek(this._media.$store.preload)));
return;
}
try {
isString(source.src) && preconnect(new URL(source.src).origin, "preconnect");
} catch (e) {
{
this._media.logger?.infoGroup(`Failed to preconnect to source: ${source.src}`).labelledLog("Error", e).dispatch();
}
}
}
}
function normalizeSrc(src) {
return (isArray(src) ? src : [!isString(src) && "src" in src ? src : { src }]).map(
({ src: src2, type }) => ({
src: src2,
type: type ?? (!isString(src2) || src2.startsWith("blob:") ? "video/object" : "?")
})
);
}
class Tracks {
constructor(_domTracks, _media) {
this._domTracks = _domTracks;
this._media = _media;
this._prevTracks = [];
effect(this._onTracksChange.bind(this));
}
_onTracksChange() {
const newTracks = [...this._media.$props.textTracks(), ...this._domTracks()];
for (const oldTrack of this._prevTracks) {
if (!newTracks.some((t) => t.id === oldTrack.id)) {
const track = oldTrack.id && this._media.textTracks.getById(oldTrack.id);
if (track)
this._media.textTracks.remove(track);
}
}
for (const newTrack of newTracks) {
const id = newTrack.id || TextTrack.createId(newTrack);
if (!this._media.textTracks.getById(id)) {
newTrack.id = id;
this._media.textTracks.add(newTrack);
}
}
this._prevTracks = newTracks;
}
}
class Outlet extends Component {
constructor(instance) {
super(instance);
this._domSources = signal([]);
this._domTracks = signal([]);
this._loader = signal(null);
this._media = useMedia();
new SourceSelection(this._domSources, this._media, this._loader);
new Tracks(this._domTracks, this._media);
}
onAttach(el) {
el.setAttribute("keep-alive", "");
}
onConnect(el) {
const resize = new ResizeObserver(animationFrameThrottle(this._onResize.bind(this)));
resize.observe(el);
const mutation = new MutationObserver(this._onMutation.bind(this));
mutation.observe(el, { attributes: true, childList: true });
if (IS_SAFARI) {
listenEvent(el, "touchstart", (e) => e.preventDefault(), { passive: false });
}
scopedRaf(() => {
this._onResize();
this._onMutation();
});
return () => {
resize.disconnect();
mutation.disconnect();
};
}
onDestroy() {
this._media.$store.currentTime.set(0);
}
_onResize() {
const player = this._media.player, width = this.el.offsetWidth, height = this.el.offsetHeight;
if (!player)
return;
player.$store.mediaWidth.set(width);
player.$store.mediaHeight.set(height);
setStyle(player, "--media-width", width + "px");
setStyle(player, "--media-height", height + "px");
}
_onMutation() {
const sources = [], tracks = [], children = this.el.children;
for (const el of children) {
if (el instanceof HTMLSourceElement) {
sources.push({
src: el.src,
type: el.type
});
} else if (el instanceof HTMLTrackElement) {
tracks.push({
id: el.id,
src: el.src,
kind: el.track.kind,
language: el.srclang,
label: el.label,
default: el.default,
type: el.getAttribute("data-type")
});
}
}
this._domSources.set(sources);
this._domTracks.set(tracks);
tick();
}
render() {
let currentProvider;
onDispose(() => currentProvider?.destroy?.());
return () => {
currentProvider?.destroy();
const loader = this._loader();
if (!loader)
return null;
const el = loader.render(this._media.$store);
{
peek(() => {
loader.load(this._media).then((provider) => {
if (peek(this._loader) !== loader)
return;
this._media.delegate._dispatch("provider-change", {
detail: provider
});
currentProvider = provider;
});
});
}
return el;
};
}
}
Outlet.el = defineElement({
tagName: "media-outlet"
});
var key = {
fullscreenEnabled: 0,
fullscreenElement: 1,
requestFullscreen: 2,
exitFullscreen: 3,
fullscreenchange: 4,
fullscreenerror: 5,
fullscreen: 6
};
var webkit = [
'webkitFullscreenEnabled',
'webkitFullscreenElement',
'webkitRequestFullscreen',
'webkitExitFullscreen',
'webkitfullscreenchange',
'webkitfullscreenerror',
'-webkit-full-screen',
];
var moz = [
'mozFullScreenEnabled',
'mozFullScreenElement',
'mozRequestFullScreen',
'mozCancelFullScreen',
'mozfullscreenchange',
'mozfullscreenerror',
'-moz-full-screen',
];
var ms$1 = [
'msFullscreenEnabled',
'msFullscreenElement',
'msRequestFullscreen',
'msExitFullscreen',
'MSFullscreenChange',
'MSFullscreenError',
'-ms-fullscreen',
];
// so it doesn't throw if no window or document
var document$1 = typeof window !== 'undefined' && typeof window.document !== 'undefined' ? window.document : {};
var vendor = (('fullscreenEnabled' in document$1 && Object.keys(key)) ||
(webkit[0] in document$1 && webkit) ||
(moz[0] in document$1 && moz) ||
(ms$1[0] in document$1 && ms$1) ||
[]);
var fscreen = {
requestFullscreen: function (element) { return element[vendor[key.requestFullscreen]](); },
requestFullscreenFunction: function (element) { return element[vendor[key.requestFullscreen]]; },
get exitFullscreen() { return document$1[vendor[key.exitFullscreen]].bind(document$1); },
get fullscreenPseudoClass() { return ":" + vendor[key.fullscreen]; },
addEventListener: function (type, handler, options) { return document$1.addEventListener(vendor[key[type]], handler, options); },
removeEventListener: function (type, handler, options) { return document$1.removeEventListener(vendor[key[type]], handler, options); },
get fullscreenEnabled() { return Boolean(document$1[vendor[key.fullscreenEnabled]]); },
set fullscreenEnabled(val) { },
get fullscreenElement() { return document$1[vendor[key.fullscreenElement]]; },
set fullscreenElement(val) { },
get onfullscreenchange() { return document$1[("on" + vendor[key.fullscreenchange]).toLowerCase()]; },
set onfullscreenchange(handler) { return document$1[("on" + vendor[key.fullscreenchange]).toLowerCase()] = handler; },
get onfullscreenerror() { return document$1[("on" + vendor[key.fullscreenerror]).toLowerCase()]; },
set onfullscreenerror(handler) { return document$1[("on" + vendor[key.fullscreenerror]).toLowerCase()] = handler; },
};
var fscreen$1 = fscreen;
const CAN_FULLSCREEN = fscreen$1.fullscreenEnabled;
class FullscreenController extends ComponentController {
constructor() {
super(...arguments);
/**
* Tracks whether we're the active fullscreen event listener. Fullscreen events can only be
* listened to globally on the document so we need to know if they relate to the current host
* element or not.
*/
this._listening = false;
this._active = false;
}
get active() {
return this._active;
}
get supported() {
return CAN_FULLSCREEN;
}
onConnect() {
listenEvent(fscreen$1, "fullscreenchange", this._onFullscreenChange.bind(this));
listenEvent(fscreen$1, "fullscreenerror", this._onFullscreenError.bind(this));
}
async onDisconnect() {
if (CAN_FULLSCREEN)
await this.exit();
}
_onFullscreenChange(event) {
const active = isFullscreen(this.el);
if (active === this._active)
return;
if (!active)
this._listening = false;
this._active = active;
this.dispatch("fullscreen-change", { detail: active, trigger: event });
}
_onFullscreenError(event) {
if (!this._listening)
return;
this.dispatch("fullscreen-error", { detail: null, trigger: event });
this._listening = false;
}
async enter() {
try {
this._listening = true;
if (!this.el || isFullscreen(this.el))
return;
assertFullscreenAPI();
return fscreen$1.requestFullscreen(this.el);
} catch (error) {
this._listening = false;
throw error;
}
}
async exit() {
if (!this.el || !isFullscreen(this.el))
return;
assertFullscreenAPI();
return fscreen$1.exitFullscreen();
}
}
function canFullscreen() {
return CAN_FULLSCREEN;
}
function isFullscreen(host) {
if (fscreen$1.fullscreenElement === host)
return true;
try {
return host.matches(
// @ts-expect-error - `fullscreenPseudoClass` is missing from `@types/fscreen`.
fscreen$1.fullscreenPseudoClass
);
} catch (error) {
return false;
}
}
function assertFullscreenAPI() {
if (CAN_FULLSCREEN)
return;
throw Error(
"[vidstack] fullscreen API is not enabled or supported in this environment"
);
}
var _a$1;
const GROUPED_LOG = Symbol("GROUPED_LOG" );
const _GroupedLog = class {
constructor(logger, level, title, root, parent) {
this.logger = logger;
this.level = level;
this.title = title;
this.root = root;
this.parent = parent;
this[_a$1] = true;
this.logs = [];
}
log(...data) {
this.logs.push({ data });
return this;
}
labelledLog(label, ...data) {
this.logs.push({ label, data });
return this;
}
groupStart(title) {
return new _GroupedLog(this.logger, this.level, title, this.root ?? this, this);
}
groupEnd() {
this.parent?.logs.push(this);
return this.parent ?? this;
}
dispatch() {
return this.logger.dispatch(this.level, this.root ?? this);
}
};
let GroupedLog = _GroupedLog;
_a$1 = GROUPED_LOG;
function isGroupedLog(data) {
return isObject(data) && data[GROUPED_LOG];
}
class Logger {
constructor() {
this._target = null;
}
error(...data) {
return this.dispatch("error", ...data);
}
warn(...data) {
return this.dispatch("warn", ...data);
}
info(...data) {
return this.dispatch("info", ...data);
}
debug(...data) {
return this.dispatch("debug", ...data);
}
errorGroup(title) {
return new GroupedLog(this, "error", title);
}
warnGroup(title) {
return new GroupedLog(this, "warn", title);
}
infoGroup(title) {
return new GroupedLog(this, "info", title);
}
debugGroup(title) {
return new GroupedLog(this, "debug", title);
}
setTarget(newTarget) {
this._target = newTarget;
}
dispatch(level, ...data) {
return this._target?.dispatchEvent(
new DOMEvent("vds-log", {
bubbles: true,
composed: true,
detail: { level, data }
})
) || false;
}
}
const LOCAL_STORAGE_KEY = "@vidstack/log-colors";
const savedColors = init();
function getLogColor(key) {
return savedColors.get(key);
}
function saveLogColor(key, { color = generateColor(), overwrite = false } = {}) {
if (!savedColors.has(key) || overwrite) {
savedColors.set(key, color);
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(Object.entries(savedColors)));
}
}
function generateColor() {
return `hsl(${Math.random() * 360}, 55%, 70%)`;
}
function init() {
let colors;
try {
colors = JSON.parse(localStorage.getItem(LOCAL_STORAGE_KEY));
} catch {
}
return new Map(Object.entries(colors ?? {}));
}
const LogLevelValue = Object.freeze({
silent: 0,
error: 1,
warn: 2,
info: 3,
debug: 4
});
const LogLevelColor = Object.freeze({
silent: "white",
error: "hsl(6, 58%, 50%)",
warn: "hsl(51, 58%, 50%)",
info: "hsl(219, 58%, 50%)",
debug: "hsl(280, 58%, 50%)"
});
const s = 1e3;
const m = s * 60;
const h = m * 60;
const d = h * 24;
function ms(val) {
const msAbs = Math.abs(val);
if (msAbs >= d) {
return Math.round(val / d) + "d";
}
if (msAbs >= h) {
return Math.round(val / h) + "h";
}
if (msAbs >= m) {
return Math.round(val / m) + "m";
}
if (msAbs >= s) {
return Math.round(val / s) + "s";
}
return round(val, 2) + "ms";
}
class LogPrinter extends ComponentController {
constructor() {
super(...arguments);
this._level = "warn" ;
}
/**
* The current log level.
*/
get logLevel() {
return this._level ;
}
set logLevel(level) {
this._level = level;
}
onConnect() {
this.listen("vds-log", (event) => {
event.stopPropagation();
const eventTargetName = (event.path?.[0] ?? event.target).tagName.toLowerCase();
const { level = "warn", data } = event.detail ?? {};
if (LogLevelValue[this._level] < LogLevelValue[level]) {
return;
}
saveLogColor(eventTargetName);
const hint = data?.length === 1 && isGroupedLog(data[0]) ? data[0].title : isString(data?.[0]) ? data[0] : "";
console.groupCollapsed(
`%c${level.toUpperCase()}%c ${eventTargetName}%c ${hint.slice(0, 50)}${hint.length > 50 ? "..." : ""}`,
`background: ${LogLevelColor[level]}; color: white; padding: 1.5px 2.2px; border-radius: 2px; font-size: 11px;`,
`color: ${getLogColor(eventTargetName)}; padding: 4px 0px; font-size: 11px;`,
"color: gray; font-size: 11px; padding-left: 4px;"
);
if (data?.length === 1 && isGroupedLog(data[0])) {
printGroup(level, data[0]);
} else if (data) {
print(level, ...data);
}
this._printTimeDiff();
printStackTrace();
console.groupEnd();
});
return () => {
this._lastLogged = void 0;
};
}
_printTimeDiff() {
labelledPrint("Time since last log", this._calcLastLogTimeDiff());
}
_calcLastLogTimeDiff() {
const time = performance.now();
const diff = time - (this._lastLogged ?? (this._lastLogged = performance.now()));
this._lastLogged = time;
return ms(diff);
}
}
function print(level, ...data) {
console[level](...data);
}
function labelledPrint(label, ...data) {
console.log(`%c${label}:`, "color: gray", ...data);
}
function printStackTrace() {
console.groupCollapsed("%cStack Trace", "color: gray");
console.trace();
console.groupEnd();
}
function printGroup(level, groupedLog) {
console.groupCollapsed(groupedLog.title);
for (const log of groupedLog.logs) {
if (isGroupedLog(log)) {
printGroup(level, log);
} else if ("label" in log && !isUndefined(log.label)) {
labelledPrint(log.label, ...log.data);
} else {
print(level, ...log.data);
}
}
console.groupEnd();
}
let $keyboard = signal(false);
{
listenEvent(document, "pointerdown", () => {
$keyboard.set(false);
});
listenEvent(document, "keydown", (e) => {
if (e.metaKey || e.altKey || e.ctrlKey)
return;
$keyboard.set(true);
});
}
class FocusVisibleController extends ComponentController {
constructor() {
super(...arguments);
this._focused = signal(false);
}
onConnect(el) {
effect(() => {
if (!$keyboard()) {
this._focused.set(false);
updateFocusAttr(el, false);
this.listen("pointerenter", this._onPointerEnter.bind(this));
this.listen("pointerleave", this._onPointerLeave.bind(this));
return;
}
const active = document.activeElement === el;
this._focused.set(active);
updateFocusAttr(el, active);
this.listen("focus", this._onFocus.bind(this));
this.listen("blur", this._onBlur.bind(this));
});
}
focused() {
return this._focused();
}
_onFocus() {
this._focused.set(true);
updateFocusAttr(this.el, true);
}
_onBlur() {
this._focused.set(false);
updateFocusAttr(this.el, false);
}
_onPointerEnter() {
updateHoverAttr(this.el, true);
}
_onPointerLeave() {
updateHoverAttr(this.el, false);
}
}
function updateFocusAttr(el, isFocused) {
setAttribute(el, "data-focus", isFocused);
setAttribute(el, "data-hocus", isFocused);
}
function updateHoverAttr(el, isHovering) {
setAttribute(el, "data-hocus", isHovering);
setAttribute(el, "data-hover", isHovering);
}
const CAN_USE_SCREEN_ORIENTATION_API = canOrientScreen();
class ScreenOrientationController extends ComponentController {
constructor() {
super(...arguments);
this._type = signal(getScreenOrientation());
this._locked = signal(false);
}
/**
* The current screen orientation type.
*
* @signal
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/ScreenOrientation}
* @see https://w3c.github.io/screen-orientation/#screen-orientation-types-and-locks
*/
get type() {
return this._type();
}
/**
* Whether the screen orientation is currently locked.
*
* @signal
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/ScreenOrientation}
* @see https://w3c.github.io/screen-orientation/#screen-orientation-types-and-locks
*/
get locked() {
return this._locked();
}
/**
* Whether the viewport is in a portrait orientation.
*
* @signal
*/
get portrait() {
return this._type().startsWith("portrait");
}
/**
* Whether the viewport is in a landscape orientation.
*
* @signal
*/
get landscape() {
return this._type().startsWith("landscape");
}
/**
* Whether the native Screen Orientation API is available.
*/
get supported() {
return CAN_USE_SCREEN_ORIENTATION_API;
}
onConnect() {
if (CAN_USE_SCREEN_ORIENTATION_API) {
listenEvent(screen.orientation, "change", this._onOrientationChange.bind(this));
} else {
const query = window.matchMedia("(orientation: landscape)");
query.onchange = this._onOrientationChange.bind(this);
return () => query.onchange = null;
}
}
async onDisconnect() {
if (CAN_USE_SCREEN_ORIENTATION_API && this._locked())
await this.unlock();
}
_onOrientationChange(event) {
this._type.set(getScreenOrientation());
this.dispatch("orientation-change", {
detail: {
orientation: peek(this._type),
lock: this._currentLock
},
trigger: event
});
}
/**
* Locks the orientation of the screen to the desired orientation type using the
* Screen Orientation API.
*
* @param lockType - The screen lock orientation type.
* @throws Error - If screen orientation API is unavailable.
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Screen/orientation}
* @see {@link https://w3c.github.io/screen-orientation}
*/
async lock(lockType) {
if (peek(this._locked) || this._currentLock === lockType)
return;
assertScreenOrientationAPI();
await screen.orientation.lock(lockType);
this._locked.set(true);
this._currentLock = lockType;
}
/**
* Unlocks the orientation of the screen to it's default state using the Screen Orientation
* API. This method will throw an error if the API is unavailable.
*
* @throws Error - If screen orientation API is unavailable.
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Screen/orientation}
* @see {@link https://w3c.github.io/screen-orientation}
*/
async unlock() {
if (!peek(this._locked))
return;
assertScreenOrientationAPI();
this._currentLock = void 0;
await screen.orientation.unlock();
this._locked.set(false);
}
}
function assertScreenOrientationAPI() {
if (CAN_USE_SCREEN_ORIENTATION_API)
return;
throw Error(
"[vidstack] screen orientation API is not available"
);
}
function getScreenOrientation() {
if (CAN_USE_SCREEN_ORIENTATION_API)
return window.screen.orientation.type;
return window.innerWidth >= window.innerHeight ? "landscape-primary" : "portrait-primary";
}
class RequestQueue {
constructor() {
this._serving = false;
this._pending = deferredPromise();
this._queue = /* @__PURE__ */ new Map();
}
/**
* The number of callbacks that are currently in queue.
*/
get _size() {
return this._queue.size;
}
/**
* Whether items in the queue are being served immediately, otherwise they're queued to
* be processed later.
*/
get _isServing() {
return this._serving;
}
/**
* Waits for the queue to be flushed (ie: start serving).
*/
async _waitForFlush() {
if (this._serving)
return;
await this._pending.promise;
}
/**
* Queue the given `callback` to be invoked at a later time by either calling the `serve()` or
* `start()` methods. If the queue has started serving (i.e., `start()` was already called),
* then the callback will be invoked immediately.
*
* @param key - Uniquely identifies this callback so duplicates are ignored.
* @param callback - The function to call when this item in the queue is being served.
*/
_enqueue(key, callback) {
if (this._serving) {
callback();
return;
}
this._queue.delete(key);
this._queue.set(key, callback);
}
/**
* Invokes the callback with the given `key` in the queue (if it exists).
*/
_serve(key) {
this._queue.get(key)?.();
this._queue.delete(key);
}
/**
* Flush all queued items and start serving future requests immediately until `stop()` is called.
*/
_start() {
this._flush();
this._serving = true;
if (this._queue.size > 0)
this._flush();
}
/**
* Stop serving requests, they'll be queued until you begin processing again by calling `start()`.
*/
_stop() {
this._serving = false;
}
/**
* Stop serving requests, empty the request queue, and release any promises waiting for the
* queue to flush.
*/
_reset() {
this._stop();
this._queue.clear();
this._release();
}
_flush() {
for (const key of this._queue.keys())
this._serve(key);
this._release();
}
_release() {
this._pending.resolve();
this._pending = deferredPromise();
}
}
const MEDIA_ATTRIBUTES = [
"autoplay",
"autoplayError",
"canFullscreen",
"canPictureInPicture",
"canLoad",
"canPlay",
"canSeek",
"ended",
"error",
"fullscreen",
"loop",
"live",
"liveEdge",
"mediaType",
"muted",
"paused",
"pictureInPicture",
"playing",
"playsinline",
"seeking",
"started",
"streamType",
"userIdle",
"viewType",
"waiting"
];
const MEDIA_KEY_SHORTCUTS = {
togglePaused: "k Space",
toggleMuted: "m",
toggleFullscreen: "f",
togglePictureInPicture: "i",
toggleCaptions: "c",
seekBackward: "ArrowLeft",
seekForward: "ArrowRight",
volumeUp: "ArrowUp",
volumeDown: "ArrowDown"
};
const MODIFIER_KEYS = /* @__PURE__ */ new Set(["Shift", "Alt", "Meta", "Control"]), BUTTON_SELECTORS = 'button, [role="button"]', IGNORE_SELECTORS = 'input, textarea, select, [contenteditable], [role^="menuitem"]';
class MediaKeyboardController extends ComponentController {
constructor(instance, _media) {
super(instance);
this._media = _media;
this._timeSlider = null;
}
onConnect() {
effect(this._onTargetChange.bind(this));
}
_onTargetChange() {
const { keyDisabled, keyTarget } = this.$props;
if (keyDisabled())
return;
const target = keyTarget() === "player" ? this.el : document, $active = signal(false);
if (target === this.el) {
this.listen("focusin", () => $active.set(true));
this.listen("focusout", (event) => {
if (!this.el.contains(event.target))
$active.set(false);
});
} else {
if (!peek($active))
$active.set(document.querySelector("media-player") === this.el);
listenEvent(document, "focusin", (event) => {
const activePlayer = event.composedPath().find((el) => el instanceof Element && el.localName === "media-player");
if (activePlayer !== void 0)
$active.set(this.el === activePlayer);
});
}
effect(() => {
if (!$active())
return;
listenEvent(target, "keyup", this._onKeyUp.bind(this));
listenEvent(target, "keydown", this._onKeyDown.bind(this));
listenEvent(target, "keydown", this._onPreventVideoKeys.bind(this), { capture: true });
});
}
_onKeyUp(event) {
const focused = document.activeElement, sliderFocused = focused?.hasAttribute("data-media-slider");
if (!event.key || !this.$store.canSeek() || sliderFocused || focused?.matches(IGNORE_SELECTORS)) {
return;
}
const method = this._getMatchingMethod(event);
if (method?.startsWith("seek")) {
event.preventDefault();
event.stopPropagation();
if (this._timeSlider) {
this._forwardTimeKeyboardEvent(event);
this._timeSlider = null;
} else {
this._media.remote.seek(this._seekTotal, event);
this._seekTotal = void 0;
}
}
if (method?.startsWith("volume")) {
const volumeSlider = this.el.querySelector("media-volume-slider");
volumeSlider?.dispatchEvent(new DOMEvent("keyup", { trigger: event }));
}
}
_onKeyDown(event) {
if (!event.key || MODIFIER_KEYS.has(event.key))
return;
const focused = document.activeElement;
if (focused?.matches(IGNORE_SELECTORS) || isKeyboardClick(event) && focused?.matches(BUTTON_SELECTORS)) {
return;
}
const sliderFocused = focused?.hasAttribute("data-media-slider"), method = this._getMatchingMethod(event);
if (!method && !event.metaKey && /[0-9]/.test(event.key) && !sliderFocused) {
event.preventDefault();
event.stopPropagation();
this._media.remote.seek(this.$store.duration() / 10 * Number(event.key), event);
return;
}
if (!method || /volume|seek/.test(method) && sliderFocused)
return;
event.preventDefault();
event.stopPropagation();
switch (method) {
case "seekForward":
case "seekBackward":
this._seeking(event, method);
break;
case "volumeUp":
case "volumeDown":
const volumeSlider = this.el.querySelector("media-volume-slider");
if (volumeSlider) {
volumeSlider.dispatchEvent(new DOMEvent("keydown", { trigger: event }));
} else {
const value = event.shiftKey ? 0.1 : 0.05;
this._media.remote.changeVolume(
this.$store.volume() + (method === "volumeUp" ? +value : -value),
event
);
}
break;
case "toggleFullscreen":
this._media.remote.toggleFullscreen("prefer-media", event);
break;
default:
this._media.remote[method]?.(event);
}
}
_onPreventVideoKeys(event) {
if (isHTMLMediaElement(event.target) && this._getMatchingMethod(event)) {
event.preventDefault();
}
}
_getMatchingMethod(event) {
const keyShortcuts = {
...this.$props.keyShortcuts(),
...this._media.ariaKeys
};
return Object.keys(keyShortcuts).find(
(method) => keyShortcuts[method].split(" ").some(
(keys) => replaceSymbolKeys(keys).replace(/Control/g, "Ctrl").split("+").every(
(key) => MODIFIER_KEYS.has(key) ? event[key.toLowerCase() + "Key"] : event.key === key.replace("Space", " ")
)
)
);
}
_calcSeekAmount(event, type) {
const seekBy = event.shiftKey ? 10 : 5;
return this._seekTotal = Math.max(
0,
Math.min(
(this._seekTotal ?? this.$store.currentTime()) + (type === "seekForward" ? +seekBy : -seekBy),
this.$store.duration()
)
);
}
_forwardTimeKeyboardEvent(event) {
this._timeSlider?.dispatchEvent(new DOMEvent(event.type, { trigger: event }));
}
_seeking(event, type) {
if (!this.$store.canSeek())
return;
if (!this._timeSlider)
this._timeSlider = this.el.querySelector("media-time-slider");
if (this._timeSlider) {
this._forwardTimeKeyboardEvent(event);
} else {
this._media.remote.seeking(this._calcSeekAmount(event, type), event);
}
}
}
const SYMBOL_KEY_MAP = ["!", "@", "#", "$", "%", "^", "&", "*", "(", ")"];
function replaceSymbolKeys(key) {
return key.replace(/Shift\+(\d)/g, (_, num) => SYMBOL_KEY_MAP[num - 1]);
}
const mediaPlayerProps = {
autoplay: false,
aspectRatio: defineProp({
value: null,
type: {
from(value) {
if (!value)
return null;
if (!value.includes("/"))
return +value;
const [width, height] = value.split("/").map(Number);
return +(width / height).toFixed(4);
}
}
}),
controls: false,
currentTime: 0,
crossorigin: null,
fullscreenOrientation: "landscape",
load: "visible",
logLevel: "silent",
loop: false,
muted: false,
paused: true,
playsinline: false,
playbackRate: 1,
poster: "",
preload: "metadata",
preferNativeHLS: defineProp({
value: false,
attribute: "prefer-native-hls"
}),
src: "",
userIdleDelay: 2e3,
viewType: "unknown",
streamType: "unknown",
volume: 1,
liveEdgeTolerance: 10,
minLiveDVRWindow: 60,
keyDisabled: false,
keyTarget: "player",
keyShortcuts: MEDIA_KEY_SHORTCUTS,
title: "",
thumbnails: null,
textTracks: defineProp({
value: [],
attribute: false
}),
smallBreakpointX: 600,
largeBreakpointX: 980,
smallBreakpointY: 380,
largeBreakpointY: 600
};
class TimeRange {
get length() {
return this._ranges.length;
}
constructor(start, end) {
if (isArray(start)) {
this._ranges = start;
} else if (!isUndefined(start) && !isUndefined(end)) {
this._ranges = [[start, end]];
} else {
this._ranges = [];
}
}
start(index) {
throwIfEmpty(this._ranges.length);
throwIfOutOfRange("start", index, this._ranges.length - 1);
return this._ranges[index][0] ?? Infinity;
}
end(index) {
throwIfEmpty(this._ranges.length);
throwIfOutOfRange("end", index, this._ranges.length - 1);
return this._ranges[index][1] ?? Infinity;
}
}
function getTimeRangesStart(range) {
if (!range.length)
return null;
let min = range.start(0);
for (let i = 1; i < range.length; i++) {
const value = range.start(i);
if (value < min)
min = value;
}
return min;
}
function getTimeRangesEnd(range) {
if (!range.length)
return null;
let max = range.end(0);
for (let i = 1; i < range.length; i++) {
const value = range.end(i);
if (value > max)
max = value;
}
return max;
}
function throwIfEmpty(length) {
if (!length)
throw new Error("`TimeRanges` object is empty." );
}
function throwIfOutOfRange(fnName, index, end) {
if (!isNumber(index) || index < 0 || index > end) {
throw new Error(
`Failed to execute '${fnName}' on 'TimeRanges': The index provided (${index}) is non-numeric or out of bounds (0-${end}).`
);
}
}
const MediaStoreFactory = new StoreFactory({
audioTracks: [],
audioTrack: null,
autoplay: false,
autoplayError: void 0,
buffered: new TimeRange(),
duration: 0,
canLoad: false,
canFullscreen: false,
canPictureInPicture: false,
canPlay: false,
controls: false,
crossorigin: null,
poster: "",
currentTime: 0,
ended: false,
error: void 0,
fullscreen: false,
loop: false,
logLevel: "warn" ,
mediaType: "unknown",
muted: false,
paused: true,
played: new TimeRange(),
playing: false,
playsinline: false,
pictureInPicture: false,
preload: "metadata",
playbackRate: 1,
qualities: [],
quality: null,
autoQuality: false,
canSetQuality: true,
seekable: new TimeRange(),
seeking: false,
source: { src: "", type: "" },
sources: [],
started: false,
title: "",
textTracks: [],
textTrack: null,
thumbnails: null,
thumbnailCues: [],
volume: 1,
waiting: false,
get viewType() {
return this.providedViewType !== "unknown" ? this.providedViewType : this.mediaType;
},
get streamType() {
return this.providedStreamType !== "unknown" ? this.providedStreamType : this.inferredStreamType;
},
get currentSrc() {
return this.source;
},
get bufferedStart() {
return getTimeRangesStart(this.buffered) ?? 0;
},
get bufferedEnd() {
return getTimeRangesEnd(this.buffered) ?? 0;
},
get seekableStart() {
return getTimeRangesStart(this.seekable) ?? 0;
},
get seekableEnd() {
return this.canPlay ? getTimeRangesEnd(this.seekable) ?? Infinity : 0;
},
get seekableWindow() {
return Math.max(0, this.seekableEnd - this.seekableStart);
},
// ~~ responsive design ~~
touchPointer: false,
orientation: "landscape",
mediaWidth: 0,
mediaHeight: 0,
breakpointX: "sm",
breakpointY: "sm",
// ~~ user props ~~
userIdle: false,
userBehindLiveEdge: false,
// ~~ live props ~~
liveEdgeTolerance: 10,
minLiveDVRWindow: 60,
get canSeek() {
return /unknown|on-demand|:dvr/.test(this.streamType) && Number.isFinite(this.seekableWindow) && (!this.live || /:dvr/.test(this.streamType) && this.seekableWindow >= this.minLiveDVRWindow);
},
get live() {
return this.streamType.includes("live") || !Number.isFinite(this.duration);
},
get liveEdgeStart() {
return this.live && Number.isFinite(this.seekableEnd) ? Math.max(0, (this.liveSyncPosition ?? this.seekableEnd) - this.liveEdgeTolerance) : 0;
},
get liveEdge() {
return this.live && (!this.canSeek || !this.userBehindLiveEdge && this.currentTime >= this.liveEdgeStart);
},
get liveEdgeWindow() {
return this.live && Number.isFinite(this.seekableEnd) ? this.seekableEnd - this.liveEdgeStart : 0;
},
// ~~ internal props ~~
autoplaying: false,
providedViewType: "unknown",
providedStreamType: "unknown",
inferredStreamType: "unknown",
liveSyncPosition: null
});
const DO_NOT_RESET_ON_SRC_CHANGE = /* @__PURE__ */ new Set([
"autoplay",
"breakpointX",
"breakpointY",
"canFullscreen",
"canLoad",
"canPictureInPicture",
"controls",
"fullscreen",
"logLevel",
"loop",
"mediaHeight",
"mediaWidth",
"muted",
"orientation",
"pictureInPicture",
"playsinline",
"poster",
"preload",
"providedStreamType",
"providedViewType",
"source",
"sources",
"textTrack",
"textTracks",
"thumbnailCues",
"thumbnails",
"title",
"touchPointer",
"volume"
]);
function softResetMediaStore($media) {
MediaStoreFactory.reset($media, (prop) => !DO_NOT_RESET_ON_SRC_CHANGE.has(prop));
tick();
}
var _a;
class List extends EventsTarget {
constructor() {
super(...arguments);
this._items = [];
/* @internal */
this[_a] = false;
}
get length() {
return this._items.length;
}
get readonly() {
return this[LIST_READONLY];
}
/**
* Transform list to an array.
*/
toArray() {
return [...this._items];
}
[(_a = LIST_READONLY, Symbol.iterator)]() {
return this._items.values();
}
/* @internal */
[LIST_ADD](item, trigger) {
const index = this._items.length;
if (!("" + index in this)) {
Object.defineProperty(this, index, {
get() {
return this._items[index];
}
});
}
if (this._items.includes(item))
return;
this._items.push(item);
this.dispatchEvent(new DOMEvent("add", { detail: item, trigger }));
}
/* @internal */
[LIST_REMOVE](item, trigger) {
const index = this._items.indexOf(item);
if (index >= 0) {
this[LIST_ON_REMOVE]?.(item, trigger);
this._items.splice(index, 1);
this.dispatchEvent(new DOMEvent("remove", { detail: item, trigger }));
}
}
/* @internal */
[LIST_RESET](trigger) {
for (const item of [...this._items])
this[LIST_REMOVE](item, trigger);
this._items = [];
this[LIST_SET_READONLY](false, trigger);
this[LIST_ON_RESET]?.();
}
/* @internal */
[LIST_SET_READONLY](readonly, trigger) {
if (this[LIST_READONLY] === readonly)
return;
this[LIST_READONLY] = readonly;
this.dispatchEvent(new DOMEvent("readonly-change", { detail: readonly, trigger }));
}
}
const SELECTED = Symbol("SELECTED" );
class SelectList extends List {
get selected() {
return this._items.find((item) => item.selected) ?? null;
}
get selectedIndex() {
return this._items.findIndex((item) => item.selected);
}
/* @internal */
[LIST_ON_REMOVE](item, trigger) {
this[LIST_SELECT](item, false, trigger);
}
/* @internal */
[LIST_ADD](item, trigger) {
item[SELECTED] = false;
Object.defineProperty(item, "selected", {
get() {
return this[SELECTED];
},
set: (selected) => {
if (this.readonly)
return;
this[LIST_ON_USER_SELECT]?.();
this[LIST_SELECT](item, selected);
}
});
super[LIST_ADD](item, trigger);
}
/* @internal */
[LIST_SELECT](item, selected, trigger) {
if (selected === item[SELECTED])
return;
const prev = this.selected;
item[SELECTED] = selected;
const changed = !selected ? prev === item : prev !== item;
if (changed) {
if (prev)
prev[SELECTED] = false;
this.dispatchEvent(
new DOMEvent("change", {
detail: { prev, current: this.selected },
trigger
})
);
}
}
}
class VideoQualityList extends SelectList {
constructor() {
super(...arguments);
this._auto = false;
/**
* Configures quality switching:
*
* - `current`: Trigger an immediate quality level switch. This will abort the current fragment
* request if any, flush the whole buffer, and fetch fragment matching with current position
* and requested quality level.
*
* - `next`: Trigger a quality level switch for next fragment. This could eventually flush
* already buffered next fragment.
*
* - `load`: Set quality level for next loaded fragment.
*
* @see {@link https://vidstack.io/docs/player/core-concepts/quality#switch}
* @see {@link https://github.com/video-dev/hls.js/blob/master/docs/API.md#quality-switch-control-api}
*/
this.switch = "current";
}
/**
* Whether automatic quality selection is enabled.
*/
get auto() {
return this._auto || this.readonly;
}
/* @internal */
[(LIST_ON_USER_SELECT)]() {
this[SET_AUTO_QUALITY](false);
}
/* @internal */
[LIST_ON_RESET](trigger) {
this[SET_AUTO_QUALITY](false, trigger);
}
/**
* Request automatic quality selection (if supported). This will be a no-op if the list is
* `readonly` as that already implies auto-selection.
*/
autoSelect(trigger) {
if (this.readonly || this._auto || !this[ENABLE_AUTO_QUALITY])
return;
this[ENABLE_AUTO_QUALITY]();
this[SET_AUTO_QUALITY](true, trigger);
}
/* @internal */
[SET_AUTO_QUALITY](auto, trigger) {
if (this._auto === auto)
return;
this._auto = auto;
this.dispatchEvent(
new DOMEvent("auto-change", {
detail: auto,
trigger
})
);
}
}
const MEDIA_EVENTS = [
"abort",
"can-play",
"can-play-through",
"duration-change",
"emptied",
"ended",
"error",
"fullscreen-change",
"loaded-data",
"loaded-metadata",
"load-start",
"media-type-change",
"pause",
"play",
"playing",
"progress",
"seeked",
"seeking",
"source-change",
"sources-change",
"stalled",
"started",
"suspend",
"stream-type-change",
"replay",
// 'time-update',
"view-type-change",
"volume-change",
"waiting"
] ;
class MediaEventsLogger extends ComponentController {
constructor(instance, _media) {
super(instance);
this._media = _media;
}
onConnect() {
const handler = this._onMediaEvent.bind(this);
for (const eventType of MEDIA_EVENTS)
this.listen(eventType, handler);
}
_onMediaEvent(event) {
this._media.logger?.infoGroup(`\u{1F4E1} dispatching \`${event.type}\``).labelledLog("Media Store", { ...this.$store }).labelledLog("Event", event).dispatch();
}
}
class MediaLoadController extends ComponentController {
constructor(instance, _callback) {
super(instance);
this._callback = _callback;
}
async onAttach(el) {
const load = this.$props.load();
if (load === "eager") {
requestAnimationFrame(this._callback);
} else if (load === "idle") {
const { waitIdlePeriod } = await import('./dev/maverick.js').then(function (n) { return n.aa; });
waitIdlePeriod(this._callback);
} else if (load === "visible") {
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
observer.disconnect();
this._callback();
}
});
observer.observe(el);
return observer.disconnect.bind(observer);
}
}
}
class MediaPlayerDelegate {
constructor(_handle, _media) {
this._handle = _handle;
this._media = _media;
}
_dispatch(type, ...init) {
this._handle(new DOMEvent(type, init?.[0]));
}
async _ready(info, trigger) {
const { $store, logger } = this._media;
if (peek($store.canPlay))
return;
this._dispatch("can-play", { detail: info, trigger });
tick();
{
logger?.infoGroup("-~-~-~-~-~-~-~-~- \u2705 MEDIA READY -~-~-~-~-~-~-~-~-").labelledLog("Media Store", { ...$store }).labelledLog("Trigger Event", trigger).dispatch();
}
if ($store.canPlay() && $store.autoplay() && !$store.started()) {
await this._attemptAutoplay();
}
}
async _attemptAutoplay() {
const { player, $store } = this._media;
$store.autoplaying.set(true);
try {
await player.play();
this._dispatch("autoplay", { detail: { muted: $store.muted() } });
} catch (error) {
this._dispatch("autoplay-fail", {
detail: {
muted: $store.muted(),
error
}
});
} finally {
$store.autoplaying.set(false);
}
}
}
class Queue {
constructor() {
this._queue = /* @__PURE__ */ new Map();
}
/**
* Queue the given `item` under the given `key` to be processed at a l