UNPKG

vidstack

Version:

Build awesome media experiences on the web.

1,691 lines (1,659 loc) 269 kB
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