UNPKG

vidstack

Version:

Build awesome media experiences on the web.

1,866 lines (1,839 loc) 93.1 kB
import { ComponentController, defineProp, Component, defineElement } from 'maverick.js/element'; import { signal, peek, effect, getScope, scoped, createContext, useContext, StoreFactory, tick, onDispose, computed } from 'maverick.js'; import { EventsTarget, DOMEvent, listenEvent, isUndefined, isFunction, waitTimeout, isKeyboardClick, setAttribute, isArray, isKeyboardEvent, appendTriggerEvent, isNull, deferredPromise, isString, isNumber, noop, animationFrameThrottle, setStyle } from 'maverick.js/std'; import { i as isHTMLMediaElement } from './providers/type-check.js'; import { A as AudioProviderLoader } from './providers/audio/loader.js'; import { H as HLSProviderLoader } from './providers/hls/loader.js'; import { V as VideoProviderLoader } from './providers/video/loader.js'; const LIST_ADD = Symbol(0); const LIST_REMOVE = Symbol(0); const LIST_RESET = Symbol(0); const LIST_SELECT = Symbol(0); const LIST_READONLY = Symbol(0); const LIST_SET_READONLY = Symbol(0); const LIST_ON_RESET = Symbol(0); const LIST_ON_REMOVE = Symbol(0); const LIST_ON_USER_SELECT = Symbol(0); class List extends EventsTarget { a = []; /* @internal */ [LIST_READONLY] = false; get length() { return this.a.length; } get readonly() { return this[LIST_READONLY]; } /** * Transform list to an array. */ toArray() { return [...this.a]; } [Symbol.iterator]() { return this.a.values(); } /* @internal */ [LIST_ADD](item, trigger) { const index = this.a.length; if (!("" + index in this)) { Object.defineProperty(this, index, { get() { return this.a[index]; } }); } if (this.a.includes(item)) return; this.a.push(item); this.dispatchEvent(new DOMEvent("add", { detail: item, trigger })); } /* @internal */ [LIST_REMOVE](item, trigger) { const index = this.a.indexOf(item); if (index >= 0) { this[LIST_ON_REMOVE]?.(item, trigger); this.a.splice(index, 1); this.dispatchEvent(new DOMEvent("remove", { detail: item, trigger })); } } /* @internal */ [LIST_RESET](trigger) { for (const item of [...this.a]) this[LIST_REMOVE](item, trigger); this.a = []; 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 })); } } 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 = [ "msFullscreenEnabled", "msFullscreenElement", "msRequestFullscreen", "msExitFullscreen", "MSFullscreenChange", "MSFullscreenError", "-ms-fullscreen" ]; 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[0] in document$1 && ms || []; 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 { /** * 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. */ b = false; c = false; get active() { return this.c; } get supported() { return CAN_FULLSCREEN; } onConnect() { listenEvent(fscreen$1, "fullscreenchange", this.d.bind(this)); listenEvent(fscreen$1, "fullscreenerror", this.e.bind(this)); } async onDisconnect() { if (CAN_FULLSCREEN) await this.exit(); } d(event) { const active = isFullscreen(this.el); if (active === this.c) return; if (!active) this.b = false; this.c = active; this.dispatch("fullscreen-change", { detail: active, trigger: event }); } e(event) { if (!this.b) return; this.dispatch("fullscreen-error", { detail: null, trigger: event }); this.b = false; } async enter() { try { this.b = true; if (!this.el || isFullscreen(this.el)) return; assertFullscreenAPI(); return fscreen$1.requestFullscreen(this.el); } catch (error) { this.b = 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] no fullscreen API" ); } const UA = navigator?.userAgent.toLowerCase(); const IS_IOS = /iphone|ipad|ipod|ios|crios|fxios/i.test(UA); const IS_IPHONE = /(iphone|ipod)/gi.test(navigator?.platform); const IS_CHROME = !!window.chrome; const IS_SAFARI = !!window.safari || IS_IOS; function canOrientScreen() { return !isUndefined(screen.orientation) && isFunction(screen.orientation.lock) && isFunction(screen.orientation.unlock); } function canPlayHLSNatively(video) { if (!video) video = document.createElement("video"); return video.canPlayType("application/vnd.apple.mpegurl").length > 0; } function canUsePictureInPicture(video) { return !!document.pictureInPictureEnabled && !video.disablePictureInPicture; } function canUseVideoPresentation(video) { return isFunction(video.webkitSupportsPresentationMode) && isFunction(video.webkitSetPresentationMode); } async function canChangeVolume() { const video = document.createElement("video"); video.volume = 0.5; await waitTimeout(0); return video.volume === 0.5; } function getMediaSource() { return window?.MediaSource ?? window?.WebKitMediaSource; } function getSourceBuffer() { return window?.SourceBuffer ?? window?.WebKitSourceBuffer; } function isHLSSupported() { const MediaSource = getMediaSource(); if (isUndefined(MediaSource)) return false; const isTypeSupported = MediaSource && isFunction(MediaSource.isTypeSupported) && MediaSource.isTypeSupported('video/mp4; codecs="avc1.42E01E,mp4a.40.2"'); const SourceBuffer = getSourceBuffer(); const isSourceBufferValid = isUndefined(SourceBuffer) || !isUndefined(SourceBuffer.prototype) && isFunction(SourceBuffer.prototype.appendBuffer) && isFunction(SourceBuffer.prototype.remove); return !!isTypeSupported && !!isSourceBufferValid; } const CAN_USE_SCREEN_ORIENTATION_API = canOrientScreen(); class ScreenOrientationController extends ComponentController { g = signal(getScreenOrientation()); f = signal(false); h; /** * 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.g(); } /** * 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.f(); } /** * Whether the viewport is in a portrait orientation. * * @signal */ get portrait() { return this.g().startsWith("portrait"); } /** * Whether the viewport is in a landscape orientation. * * @signal */ get landscape() { return this.g().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.i.bind(this)); } else { const query = window.matchMedia("(orientation: landscape)"); query.onchange = this.i.bind(this); return () => query.onchange = null; } } async onDisconnect() { if (CAN_USE_SCREEN_ORIENTATION_API && this.f()) await this.unlock(); } i(event) { this.g.set(getScreenOrientation()); this.dispatch("orientation-change", { detail: { orientation: peek(this.g), lock: this.h }, 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.f) || this.h === lockType) return; assertScreenOrientationAPI(); await screen.orientation.lock(lockType); this.f.set(true); this.h = 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.f)) return; assertScreenOrientationAPI(); this.h = void 0; await screen.orientation.unlock(); this.f.set(false); } } function assertScreenOrientationAPI() { if (CAN_USE_SCREEN_ORIENTATION_API) return; throw Error( "[vidstack] no orientation API" ); } function getScreenOrientation() { if (CAN_USE_SCREEN_ORIENTATION_API) return window.screen.orientation.type; return window.innerWidth >= window.innerHeight ? "landscape-primary" : "portrait-primary"; } 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); } 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.j = _media; } onConnect() { effect(this.Xa.bind(this)); } Xa() { 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.Ya.bind(this)); listenEvent(target, "keydown", this.Za.bind(this)); listenEvent(target, "keydown", this._a.bind(this), { capture: true }); }); } Ya(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.Va(event); if (method?.startsWith("seek")) { event.preventDefault(); event.stopPropagation(); if (this.Ta) { this.Wa(event); this.Ta = null; } else { this.j.remote.seek(this.Ua, event); this.Ua = void 0; } } if (method?.startsWith("volume")) { const volumeSlider = this.el.querySelector("media-volume-slider"); volumeSlider?.dispatchEvent(new DOMEvent("keyup", { trigger: event })); } } Za(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.Va(event); if (!method && !event.metaKey && /[0-9]/.test(event.key) && !sliderFocused) { event.preventDefault(); event.stopPropagation(); this.j.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.$a(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.j.remote.changeVolume( this.$store.volume() + (method === "volumeUp" ? +value : -value), event ); } break; case "toggleFullscreen": this.j.remote.toggleFullscreen("prefer-media", event); break; default: this.j.remote[method]?.(event); } } _a(event) { if (isHTMLMediaElement(event.target) && this.Va(event)) { event.preventDefault(); } } Va(event) { const keyShortcuts = { ...this.$props.keyShortcuts(), ...this.j.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", " ") ) ) ); } Ua; ab(event, type) { const seekBy = event.shiftKey ? 10 : 5; return this.Ua = Math.max( 0, Math.min( (this.Ua ?? this.$store.currentTime()) + (type === "seekForward" ? +seekBy : -seekBy), this.$store.duration() ) ); } Ta = null; Wa(event) { this.Ta?.dispatchEvent(new DOMEvent(event.type, { trigger: event })); } $a(event, type) { if (!this.$store.canSeek()) return; if (!this.Ta) this.Ta = this.el.querySelector("media-time-slider"); if (this.Ta) { this.Wa(event); } else { this.j.remote.seeking(this.ab(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 { W; get length() { return this.W.length; } constructor(start, end) { if (isArray(start)) { this.W = start; } else if (!isUndefined(start) && !isUndefined(end)) { this.W = [[start, end]]; } else { this.W = []; } } start(index) { return this.W[index][0] ?? Infinity; } end(index) { return this.W[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; } 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: "silent", 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(); } const SELECTED = Symbol(0); class SelectList extends List { get selected() { return this.a.find((item) => item.selected) ?? null; } get selectedIndex() { return this.a.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 }) ); } } } const SET_AUTO_QUALITY = Symbol(0); const ENABLE_AUTO_QUALITY = Symbol(0); class VideoQualityList extends SelectList { Sa = 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} */ switch = "current"; /** * Whether automatic quality selection is enabled. */ get auto() { return this.Sa || this.readonly; } /* @internal */ [ENABLE_AUTO_QUALITY]; /* @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.Sa || !this[ENABLE_AUTO_QUALITY]) return; this[ENABLE_AUTO_QUALITY](); this[SET_AUTO_QUALITY](true, trigger); } /* @internal */ [SET_AUTO_QUALITY](auto, trigger) { if (this.Sa === auto) return; this.Sa = auto; this.dispatchEvent( new DOMEvent("auto-change", { detail: auto, trigger }) ); } } class MediaLoadController extends ComponentController { constructor(instance, _callback) { super(instance); this.jf = _callback; } async onAttach(el) { const load = this.$props.load(); if (load === "eager") { requestAnimationFrame(this.jf); } else if (load === "idle") { const { waitIdlePeriod } = await import('maverick.js/std'); waitIdlePeriod(this.jf); } else if (load === "visible") { const observer = new IntersectionObserver((entries) => { if (entries[0].isIntersecting) { observer.disconnect(); this.jf(); } }); observer.observe(el); return observer.disconnect.bind(observer); } } } class MediaPlayerDelegate { constructor(_handle, _media) { this.N = _handle; this.j = _media; } p(type, ...init) { this.N(new DOMEvent(type, init?.[0])); } async lf(info, trigger) { const { $store, logger } = this.j; if (peek($store.canPlay)) return; this.p("can-play", { detail: info, trigger }); tick(); if ($store.canPlay() && $store.autoplay() && !$store.started()) { await this.kf(); } } async kf() { const { player, $store } = this.j; $store.autoplaying.set(true); try { await player.play(); this.p("autoplay", { detail: { muted: $store.muted() } }); } catch (error) { this.p("autoplay-fail", { detail: { muted: $store.muted(), error } }); } finally { $store.autoplaying.set(false); } } } class Queue { Ze = /* @__PURE__ */ new Map(); /** * Queue the given `item` under the given `key` to be processed at a later time by calling * `serve(key)`. */ t(key, item) { if (!this.Ze.has(key)) this.Ze.set(key, /* @__PURE__ */ new Set()); this.Ze.get(key).add(item); } /** * Process all items in queue for the given `key`. */ cf(key, callback) { const items = this.Ze.get(key); if (items) for (const item of items) callback(item); this.Ze.delete(key); } /** * Removes all queued items under the given `key`. */ yf(key) { this.Ze.delete(key); } /** * The number of items currently queued under the given `key`. */ df(key) { return this.Ze.get(key)?.size ?? 0; } /** * Clear all items in the queue. */ gf() { this.Ze.clear(); } } function coerceToError(error) { return error instanceof Error ? error : Error(JSON.stringify(error)); } class MediaUserController extends ComponentController { da = -2; ba = 2e3; ea = false; ca = null; /** * Whether the media user is currently idle. */ get idling() { return this.$store.userIdle(); } /** * The amount of delay in milliseconds while media playback is progressing without user * activity to indicate an idle state. * * @defaultValue 2000 */ get idleDelay() { return this.ba; } set idleDelay(newDelay) { this.ba = newDelay; } /** * Change the user idle state. */ idle(idle, delay = this.ba, trigger) { this.fa(); if (!this.ea) this.ga(idle, delay, trigger); } /** * Whether all idle tracking should be paused until resumed again. */ pauseIdleTracking(paused, trigger) { this.ea = paused; if (paused) { this.fa(); this.ga(false, 0, trigger); } } onConnect() { effect(this.C.bind(this)); listenEvent(this.el, "play", this.ia.bind(this)); listenEvent(this.el, "pause", this.ja.bind(this)); } C() { if (this.$store.paused()) return; const onStopIdle = this.ka.bind(this); for (const eventType of ["pointerup", "keydown"]) { listenEvent(this.el, eventType, onStopIdle); } effect(() => { if (!this.$store.touchPointer()) listenEvent(this.el, "pointermove", onStopIdle); }); } ia(event) { this.idle(true, this.ba, event); } ja(event) { this.idle(false, 0, event); } fa() { window.clearTimeout(this.da); this.da = -1; } ka(event) { if (event.MEDIA_GESTURE) return; if (isKeyboardEvent(event)) { if (event.key === "Escape") { this.el?.focus(); this.ca = null; } else if (this.ca) { event.preventDefault(); requestAnimationFrame(() => { this.ca?.focus(); this.ca = null; }); } } this.idle(false, 0, event); this.idle(true, this.ba, event); } ga(idle, delay, trigger) { if (delay === 0) { this.ha(idle, trigger); return; } this.da = window.setTimeout(() => { this.ha(idle && !this.ea, trigger); }, delay); } ha(idle, trigger) { if (this.$store.userIdle() === idle) return; this.$store.userIdle.set(idle); if (idle && document.activeElement && this.el?.contains(document.activeElement)) { this.ca = document.activeElement; requestAnimationFrame(() => this.el?.focus()); } this.dispatch("user-idle-change", { detail: idle, trigger }); } } class MediaRequestContext { $a = false; rf = false; pf = false; Ze = new Queue(); } class MediaRequestManager extends ComponentController { constructor(instance, _stateMgr, _request, _media) { super(instance); this.u = _stateMgr; this.mf = _request; this.j = _media; this.nf = _media.$store; this.q = _media.$provider; this.Q = new MediaUserController(instance); this.of = new FullscreenController(instance); this.nb = new ScreenOrientationController(instance); } Q; of; nb; nf; q; onConnect() { effect(this.uf.bind(this)); effect(this.vf.bind(this)); effect(this.wf.bind(this)); const names = Object.getOwnPropertyNames(Object.getPrototypeOf(this)), handle = this.xf.bind(this); for (const name of names) { if (name.startsWith("media-")) { this.listen(name, handle); } } this.listen("fullscreen-change", this.d.bind(this)); } xf(event) { event.stopPropagation(); if (peek(this.q)) this[event.type]?.(event); } async M() { const { canPlay, paused, ended, autoplaying, seekableStart } = this.nf; if (!peek(paused)) return; try { const provider = peek(this.q); throwIfNotReadyForPlayback(provider, peek(canPlay)); if (peek(ended)) { provider.currentTime = seekableStart() + 0.1; } return provider.play(); } catch (error) { const errorEvent = this.createEvent("play-fail", { detail: coerceToError(error) }); errorEvent.autoplay = autoplaying(); this.u.N(errorEvent); throw error; } } async L() { const { canPlay, paused } = this.nf; if (peek(paused)) return; const provider = peek(this.q); throwIfNotReadyForPlayback(provider, peek(canPlay)); return provider.pause(); } V() { const { canPlay, live, liveEdge, canSeek, liveSyncPosition, seekableEnd, userBehindLiveEdge } = this.nf; userBehindLiveEdge.set(false); if (peek(() => !live() || liveEdge() || !canSeek())) return; const provider = peek(this.q); throwIfNotReadyForPlayback(provider, peek(canPlay)); provider.currentTime = liveSyncPosition() ?? seekableEnd() - 2; } qf = false; async R(target = "prefer-media") { const provider = peek(this.q); const adapter = target === "prefer-media" && this.of.supported || target === "media" ? this.of : provider?.fullscreen; throwIfFullscreenNotSupported(target, adapter); if (adapter.active) return; if (peek(this.nf.pictureInPicture)) { this.qf = true; await this.U(); } return adapter.enter(); } async S(target = "prefer-media") { const provider = peek(this.q); const adapter = target === "prefer-media" && this.of.supported || target === "media" ? this.of : provider?.fullscreen; throwIfFullscreenNotSupported(target, adapter); if (!adapter.active) return; if (this.nb.locked) await this.nb.unlock(); try { const result = await adapter.exit(); if (this.qf && peek(this.nf.canPictureInPicture)) { await this.T(); } return result; } finally { this.qf = false; } } async T() { this.sf(); if (this.nf.pictureInPicture()) return; return await this.q().pictureInPicture.enter(); } async U() { this.sf(); if (!this.nf.pictureInPicture()) return; return await this.q().pictureInPicture.exit(); } sf() { if (this.nf.canPictureInPicture()) return; throw Error( "[vidstack] no pip support" ); } uf() { this.Q.idleDelay = this.$props.userIdleDelay(); } vf() { const { canLoad, canFullscreen } = this.nf, supported = this.of.supported || this.q()?.fullscreen?.supported || false; if (canLoad() && peek(canFullscreen) === supported) return; canFullscreen.set(supported); } wf() { const { canLoad, canPictureInPicture } = this.nf, supported = this.q()?.pictureInPicture?.supported || false; if (canLoad() && peek(canPictureInPicture) === supported) return; canPictureInPicture.set(supported); } ["media-audio-track-change-request"](event) { if (this.j.audioTracks.readonly) { return; } const index = event.detail, track = this.j.audioTracks[index]; if (track) { this.mf.Ze.t("audioTrack", event); track.selected = true; } } async ["media-enter-fullscreen-request"](event) { try { this.mf.Ze.t("fullscreen", event); await this.R(event.detail); } catch (error) { this.e(error); } } async ["media-exit-fullscreen-request"](event) { try { this.mf.Ze.t("fullscreen", event); await this.S(event.detail); } catch (error) { this.e(error); } } async d(event) { if (!event.detail) return; try { const lockType = peek(this.$props.fullscreenOrientation); if (this.nb.supported && !isUndefined(lockType)) { await this.nb.lock(lockType); } } catch (e) { } } e(error) { this.u.N( this.createEvent("fullscreen-error", { detail: coerceToError(error) }) ); } async ["media-enter-pip-request"](event) { try { this.mf.Ze.t("pip", event); await this.T(); } catch (error) { this.tf(error); } } async ["media-exit-pip-request"](event) { try { this.mf.Ze.t("pip", event); await this.U(); } catch (error) { this.tf(error); } } tf(error) { this.u.N( this.createEvent("picture-in-picture-error", { detail: coerceToError(error) }) ); } ["media-live-edge-request"](event) { const { live, liveEdge, canSeek } = this.nf; if (!live() || liveEdge() || !canSeek()) return; this.mf.Ze.t("seeked", event); try { this.V(); } catch (e) { } } ["media-loop-request"]() { window.requestAnimationFrame(async () => { try { this.mf.rf = true; this.mf.pf = true; await this.M(); } catch (e) { this.mf.rf = false; this.mf.pf = false; } }); } async ["media-pause-request"](event) { if (this.nf.paused()) return; try { this.mf.Ze.t("pause", event); await this.q().pause(); } catch (e) { this.mf.Ze.yf("pause"); } } async ["media-play-request"](event) { if (!this.nf.paused()) return; try { this.mf.Ze.t("play", event); await this.q().play(); } catch (e) { const errorEvent = this.createEvent("play-fail", { detail: coerceToError(e) }); this.u.N(errorEvent); } } ["media-rate-change-request"](event) { if (this.nf.playbackRate() === event.detail) return; this.mf.Ze.t("rate", event); this.q().playbackRate = event.detail; } ["media-quality-change-request"](event) { if (this.j.qualities.readonly) { return; } this.mf.Ze.t("quality", event); const index = event.detail; if (index < 0) { this.j.qualities.autoSelect(event); } else { const quality = this.j.qualities[index]; if (quality) { quality.selected = true; } } } ["media-resume-user-idle-request"](event) { this.mf.Ze.t("userIdle", event); this.Q.pauseIdleTracking(false, event); } ["media-pause-user-idle-request"](event) { this.mf.Ze.t("userIdle", event); this.Q.pauseIdleTracking(true, event); } ["media-seek-request"](event) { const { seekableStart, seekableEnd, ended, canSeek, live, userBehindLiveEdge } = this.nf; if (ended()) this.mf.pf = true; this.mf.$a = false; this.mf.Ze.yf("seeking"); const boundTime = Math.min(Math.max(seekableStart() + 0.1, event.detail), seekableEnd() - 0.1); if (!Number.isFinite(boundTime) || !canSeek()) return; this.mf.Ze.t("seeked", event); this.q().currentTime = boundTime; if (live() && event.isOriginTrusted && Math.abs(seekableEnd() - boundTime) >= 2) { userBehindLiveEdge.set(true); } } ["media-seeking-request"](event) { this.mf.Ze.t("seeking", event); this.nf.seeking.set(true); this.mf.$a = true; } ["media-start-loading"](event) { if (this.nf.canLoad()) return; this.mf.Ze.t("load", event); this.u.N(this.createEvent("can-load")); } ["media-text-track-change-request"](event) { const { index, mode } = event.detail, track = this.j.textTracks[index]; if (track) { this.mf.Ze.t("textTrack", event); track.setMode(mode, event); } } ["media-mute-request"](event) { if (this.nf.muted()) return; this.mf.Ze.t("volume", event); this.q().muted = true; } ["media-unmute-request"](event) { const { muted, volume } = this.nf; if (!muted()) return; this.mf.Ze.t("volume", event); this.j.$provider().muted = false; if (volume() === 0) { this.mf.Ze.t("volume", event); this.q().volume = 0.25; } } ["media-volume-change-request"](event) { const { muted, volume } = this.nf; const newVolume = event.detail; if (volume() === newVolume) return; this.mf.Ze.t("volume", event); this.q().volume = newVolume; if (newVolume > 0 && muted()) { this.mf.Ze.t("volume", event); this.q().muted = false; } } } function throwIfNotReadyForPlayback(provider, canPlay) { if (provider && canPlay) return; throw Error( "[vidstack] media not ready" ); } function throwIfFullscreenNotSupported(target, fullscreen) { if (fullscreen?.supported) return; throw Error( "[vidstack] no fullscreen support" ); } var functionDebounce = debounce; function debounce(fn, wait, callFirst) { var timeout = null; var debouncedFn = null; var clear = function() { if (timeout) { clearTimeout(timeout); debouncedFn = null; timeout = null; } }; var flush = function() { var call = debouncedFn; clear(); if (call) { call(); } }; var debounceWrapper = function() { if (!wait) { return fn.apply(this, arguments); } var context = this; var args = arguments; var callNow = callFirst && !timeout; clear(); debouncedFn = function() { fn.apply(context, args); }; timeout = setTimeout(function() { timeout = null; if (!callNow) { var call = debouncedFn; debouncedFn = null; return call(); } }, wait); if (callNow) { return debouncedFn(); } }; debounceWrapper.cancel = clear; debounceWrapper.flush = flush; return debounceWrapper; } var functionThrottle = throttle; function throttle(fn, interval, options) { var timeoutId = null; var throttledFn = null; var leading = options && options.leading; var trailing = options && options.trailing; if (leading == null) { leading = true; } if (trailing == null) { trailing = !leading; } if (leading == true) { trailing = false; } var cancel = function() { if (timeoutId) { clearTimeout(timeoutId); timeoutId = null; } }; var flush = function() { var call = throttledFn; cancel(); if (call) { call(); } }; var throttleWrapper = function() { var callNow = leading && !timeoutId; var context = this; var args = arguments; throttledFn = function() { return fn.apply(context, args); }; if (!timeoutId) { timeoutId = setTimeout(function() { timeoutId = null; if (trailing) { return throttledFn(); } }, interval); } if (callNow) { callNow = false; return throttledFn(); } }; throttleWrapper.cancel = cancel; throttleWrapper.flush = flush; return throttleWrapper; } const ATTACH_VIDEO = Symbol(0); const TEXT_TRACK_CROSSORIGIN = Symbol(0); const TEXT_TRACK_READY_STATE = Symbol(0); const TEXT_TRACK_UPDATE_ACTIVE_CUES = Symbol(0); const TEXT_TRACK_CAN_LOAD = Symbol(0); const TEXT_TRACK_ON_MODE_CHANGE = Symbol(0); const TEXT_TRACK_NATIVE = Symbol(0); const TEXT_TRACK_NATIVE_HLS = Symbol(0); const TRACKED_EVENT = /* @__PURE__ */ new Set([ "autoplay", "autoplay-fail", "can-load", "sources-change", "source-change", "load-start", "abort", "error", "loaded-metadata", "loaded-data", "can-play", "play", "play-fail", "pause", "playing", "seeking", "seeked", "waiting" ]); class MediaStateManager extends ComponentController { constructor(instance, _request, _media) { super(instance); this.mf = _request; this.j = _media; this.nf = _media.$store; } nf; zf = /* @__PURE__ */ new Map(); Gf = true; Df = false; Bf; onAttach(el) { el.setAttribute("aria-busy", "true"); } onConnect(el) { this.Mf(); this.Nf(); this.Of(); this.listen("fullscreen-change", this["fullscreen-change"].bind(this)); this.listen("fullscreen-error", this["fullscreen-error"].bind(this)); } N(event) { const type = event.type; this[event.type]?.(event); { if (TRACKED_EVENT.has(type)) this.zf.set(type, event); this.el?.dispatchEvent(event); } } Cf() { this.Hf(); this.mf.pf = false; this.mf.rf = false; this.Df = false; this.Bf = void 0; this.zf.clear(); } Af(request, event) { this.mf.Ze.cf(request, (requestEvent) => { event.request = requestEvent; appendTriggerEvent(event, requestEvent); }); } Mf() { this.Ef(); this.If(); const textTracks = this.j.textTracks; listenEvent(textTracks, "add", this.Ef.bind(this)); listenEvent(textTracks, "remove", this.Ef.bind(this)); listenEvent(textTracks, "mode-change", this.If.bind(this)); } Nf() { const qualities = this.j.qualities; listenEvent(qualities, "add", this.Jf.bind(this)); listenEvent(qualities, "remove", this.Jf.bind(this)); listenEvent(qualities, "change", this.Pf.bind(this)); listenEvent(qualities, "auto-change", this.Qf.bind(this)); listenEvent(qualities, "readonly-change", this.Rf.bind(this)); } Of() { const audioTracks = this.j.audioTracks; listenEvent(audioTracks, "add", this.Kf.bind(this)); listenEvent(audioTracks, "remove", this.Kf.bind(this)); listenEvent(audioTracks, "change", this.Sf.bind(this)); } Ef(event) { const { textTracks } = this.nf; textTracks.set(this.j.textTracks.toArray()); this.dispatch("text-tracks-change", { detail: textTracks(), trigger: event }); } If(event) { if (event) this.Af("textTrack", event); const current = this.j.textTracks.selected, { textTrack } = this.nf; if (textTrack() !== current) { textTrack.set(current); this.dispatch("text-track-change", { detail: current, trigger: event }); } } Kf(event) { const { audioTracks } = this.nf; audioTracks.set(this.j.audioTracks.toArray()); this.dispatch("audio-tracks-change", { detail: audioTracks(), trigger: event }); } Sf(event) { const { audioTrack } = this.nf; audioTrack.set(this.j.audioTracks.selected); this.Af("audioTrack", event); this.dispatch("audio-track-change", { detail: audioTrack(), trigger: event }); } Jf(event) { const { qualities } = this.nf; qualities.set(this.j.qualities.toArray()); this.dispatch("qualities-change", { detail: qualities(), trigger: event }); } Pf(event) { const { quality } = this.nf; quality.set(this.j.qualities.selected); this.Af("quality", event); this.dispatch("quality-change", { detail: quality(), trigger: event }); } Qf() { this.nf.autoQuality.set(this.j.qualities.auto); } Rf() { this.nf.canSetQuality.set(!this.j.qualities.readonly); } ["provider-change"](event) { this.j.$provider.set(event.detail); } ["autoplay"](event) { appendTriggerEvent(event, this.zf.get("play")); appendTriggerEvent(event, this.zf.get("can-play")); this.nf.autoplayError.set(void 0); } ["autoplay-fail"](event) { appendTriggerEvent(event, this.zf.get("play-fail")); appendTriggerEvent(event, this.zf.get("can-play")); this.nf.autoplayError.set(event.detail); this.Cf(); } ["can-load"](event) { this.nf.canLoad.set(true); this.zf.set("can-load", event); this.Af("load", event); this.j.textTracks[TEXT_TRACK_CAN_LOAD](); } ["media-type-change"](event) { appendTriggerEvent(event, this.zf.get("source-change")); const viewType = this.nf.viewType(); this.nf.mediaType.set(event.detail); if (viewType !== this.nf.viewType()) { setTimeout( () => this.dispatch("view-type-change", { detail: this.nf.viewType(), trigger: event }), 0 ); } } ["stream-type-change"](event) { const { streamType, inferredStreamType } = this.nf; appendTriggerEvent(event, this.zf.get("source-change")); inferredStreamType.set(event.detail); event.detail = streamType(); } ["rate-change"](event) { this.nf.playbackRate.set(event.detail); this.Af("rate", event); } ["sources-change"](event) { this.nf.sources.set(event.detail); } ["source-change"](event) { appendTriggerEvent(event, this.zf.get("sources-change")); this.nf.source.set(event.detail); this.el?.setAttribute("aria-busy", "true"); if (this.Gf) { this.Gf = false; return; } this.j.audioTracks[LIST