UNPKG

vidstack

Version:

Build awesome media experiences on the web.

1,688 lines (1,645 loc) 107 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, isNumber, isKeyboardEvent, appendTriggerEvent, isNull, deferredPromise, isString, 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("LIST_ADD" ); const LIST_REMOVE = Symbol("LIST_REMOVE" ); const LIST_RESET = Symbol("LIST_RESET" ); const LIST_SELECT = Symbol("LIST_SELECT" ); const LIST_READONLY = Symbol("LIST_READONLY" ); const LIST_SET_READONLY = Symbol("LIST_SET_READONLY" ); const LIST_ON_RESET = Symbol("LIST_ON_RESET" ); const LIST_ON_REMOVE = Symbol("LIST_ON_REMOVE" ); const LIST_ON_USER_SELECT = Symbol("LIST_ON_USER_SELECT" ); class List extends EventsTarget { _items = []; /* @internal */ [LIST_READONLY] = false; get length() { return this._items.length; } get readonly() { return this[LIST_READONLY]; } /** * Transform list to an array. */ toArray() { return [...this._items]; } [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 })); } } 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', ]; // 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[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. */ _listening = false; _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" ); } 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 { _type = signal(getScreenOrientation()); _locked = signal(false); _currentLock; /** * 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"; } 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._media = _media; } 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", " ") ) ) ); } _seekTotal; _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() ) ); } _timeSlider = null; _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 { _ranges; 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(); } 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 }) ); } } } const SET_AUTO_QUALITY = Symbol("SET_AUTO_QUALITY" ); const ENABLE_AUTO_QUALITY = Symbol("ENABLE_AUTO_QUALITY" ); class VideoQualityList extends SelectList { _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} */ switch = "current"; /** * Whether automatic quality selection is enabled. */ get auto() { return this._auto || 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._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('maverick.js/std'); 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 { _queue = /* @__PURE__ */ new Map(); /** * Queue the given `item` under the given `key` to be processed at a later time by calling * `serve(key)`. */ _enqueue(key, item) { if (!this._queue.has(key)) this._queue.set(key, /* @__PURE__ */ new Set()); this._queue.get(key).add(item); } /** * Process all items in queue for the given `key`. */ _serve(key, callback) { const items = this._queue.get(key); if (items) for (const item of items) callback(item); this._queue.delete(key); } /** * Removes all queued items under the given `key`. */ _delete(key) { this._queue.delete(key); } /** * The number of items currently queued under the given `key`. */ _size(key) { return this._queue.get(key)?.size ?? 0; } /** * Clear all items in the queue. */ _reset() { this._queue.clear(); } } function coerceToError(error) { return error instanceof Error ? error : Error(JSON.stringify(error)); } class MediaUserController extends ComponentController { _idleTimer = -2; _delay = 2e3; _pausedTracking = false; _focusedItem = 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._delay; } set idleDelay(newDelay) { this._delay = newDelay; } /** * Change the user idle state. */ idle(idle, delay = this._delay, trigger) { this._clearIdleTimer(); if (!this._pausedTracking) this._requestIdleChange(idle, delay, trigger); } /** * Whether all idle tracking should be paused until resumed again. */ pauseIdleTracking(paused, trigger) { this._pausedTracking = paused; if (paused) { this._clearIdleTimer(); this._requestIdleChange(false, 0, trigger); } } onConnect() { effect(this._watchPaused.bind(this)); listenEvent(this.el, "play", this._onMediaPlay.bind(this)); listenEvent(this.el, "pause", this._onMediaPause.bind(this)); } _watchPaused() { if (this.$store.paused()) return; const onStopIdle = this._onStopIdle.bind(this); for (const eventType of ["pointerup", "keydown"]) { listenEvent(this.el, eventType, onStopIdle); } effect(() => { if (!this.$store.touchPointer()) listenEvent(this.el, "pointermove", onStopIdle); }); } _onMediaPlay(event) { this.idle(true, this._delay, event); } _onMediaPause(event) { this.idle(false, 0, event); } _clearIdleTimer() { window.clearTimeout(this._idleTimer); this._idleTimer = -1; } _onStopIdle(event) { if (event.MEDIA_GESTURE) return; if (isKeyboardEvent(event)) { if (event.key === "Escape") { this.el?.focus(); this._focusedItem = null; } else if (this._focusedItem) { event.preventDefault(); requestAnimationFrame(() => { this._focusedItem?.focus(); this._focusedItem = null; }); } } this.idle(false, 0, event); this.idle(true, this._delay, event); } _requestIdleChange(idle, delay, trigger) { if (delay === 0) { this._onIdleChange(idle, trigger); return; } this._idleTimer = window.setTimeout(() => { this._onIdleChange(idle && !this._pausedTracking, trigger); }, delay); } _onIdleChange(idle, trigger) { if (this.$store.userIdle() === idle) return; this.$store.userIdle.set(idle); if (idle && document.activeElement && this.el?.contains(document.activeElement)) { this._focusedItem = document.activeElement; requestAnimationFrame(() => this.el?.focus()); } this.dispatch("user-idle-change", { detail: idle, trigger }); } } class MediaRequestContext { _seeking = false; _looping = false; _replaying = false; _queue = new Queue(); } class MediaRequestManager extends ComponentController { constructor(instance, _stateMgr, _request, _media) { super(instance); this._stateMgr = _stateMgr; this._request = _request; this._media = _media; this._store = _media.$store; this._provider = _media.$provider; this._user = new MediaUserController(instance); this._fullscreen = new FullscreenController(instance); this._orientation = new ScreenOrientationController(instance); } _user; _fullscreen; _orientation; _store; _provider; onConnect() { effect(this._onIdleDelayChange.bind(this)); effect(this._onFullscreenSupportChange.bind(this)); effect(this._onPiPSupportChange.bind(this)); const names = Object.getOwnPropertyNames(Object.getPrototypeOf(this)), handle = this._handleRequest.bind(this); for (const name of names) { if (name.startsWith("media-")) { this.listen(name, handle); } } this.listen("fullscreen-change", this._onFullscreenChange.bind(this)); } _handleRequest(event) { event.stopPropagation(); { this._media.logger?.infoGroup(`\u{1F4EC} received \`${event.type}\``).labelledLog("Request", event).dispatch(); } if (peek(this._provider)) this[event.type]?.(event); } async _play() { const { canPlay, paused, ended, autoplaying, seekableStart } = this._store; if (!peek(paused)) return; try { const provider = peek(this._provider); 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._stateMgr._handle(errorEvent); throw error; } } async _pause() { const { canPlay, paused } = this._store; if (peek(paused)) return; const provider = peek(this._provider); throwIfNotReadyForPlayback(provider, peek(canPlay)); return provider.pause(); } _seekToLiveEdge() { const { canPlay, live, liveEdge, canSeek, liveSyncPosition, seekableEnd, userBehindLiveEdge } = this._store; userBehindLiveEdge.set(false); if (peek(() => !live() || liveEdge() || !canSeek())) return; const provider = peek(this._provider); throwIfNotReadyForPlayback(provider, peek(canPlay)); provider.currentTime = liveSyncPosition() ?? seekableEnd() - 2; } _wasPIPActive = false; async _enterFullscreen(target = "prefer-media") { const provider = peek(this._provider); const adapter = target === "prefer-media" && this._fullscreen.supported || target === "media" ? this._fullscreen : provider?.fullscreen; throwIfFullscreenNotSupported(target, adapter); if (adapter.active) return; if (peek(this._store.pictureInPicture)) { this._wasPIPActive = true; await this._exitPictureInPicture(); } return adapter.enter(); } async _exitFullscreen(target = "prefer-media") { const provider = peek(this._provider); const adapter = target === "prefer-media" && this._fullscreen.supported || target === "media" ? this._fullscreen : provider?.fullscreen; throwIfFullscreenNotSupported(target, adapter); if (!adapter.active) return; if (this._orientation.locked) await this._orientation.unlock(); try { const result = await adapter.exit(); if (this._wasPIPActive && peek(this._store.canPictureInPicture)) { await this._enterPictureInPicture(); } return result; } finally { this._wasPIPActive = false; } } async _enterPictureInPicture() { this._throwIfPIPNotSupported(); if (this._store.pictureInPicture()) return; return await this._provider().pictureInPicture.enter(); } async _exitPictureInPicture() { this._throwIfPIPNotSupported(); if (!this._store.pictureInPicture()) return; return await this._provider().pictureInPicture.exit(); } _throwIfPIPNotSupported() { if (this._store.canPictureInPicture()) return; throw Error( `[vidstack] picture-in-picture is not currently available` ); } _onIdleDelayChange() { this._user.idleDelay = this.$props.userIdleDelay(); } _onFullscreenSupportChange() { const { canLoad, canFullscreen } = this._store, supported = this._fullscreen.supported || this._provider()?.fullscreen?.supported || false; if (canLoad() && peek(canFullscreen) === supported) return; canFullscreen.set(supported); } _onPiPSupportChange() { const { canLoad, canPictureInPicture } = this._store, supported = this._provider()?.pictureInPicture?.supported || false; if (canLoad() && peek(canPictureInPicture) === supported) return; canPictureInPicture.set(supported); } ["media-audio-track-change-request"](event) { if (this._media.audioTracks.readonly) { { this._media.logger?.warnGroup(`[vidstack] attempted to change audio track but it is currently read-only`).labelledLog("Event", event).dispatch(); } return; } const index = event.detail, track = this._media.audioTracks[index]; if (track) { this._request._queue._enqueue("audioTrack", event); track.selected = true; } else { this._media.logger?.warnGroup("[vidstack] failed audio track change request (invalid index)").labelledLog("Audio Tracks", this._media.audioTracks.toArray()).labelledLog("Index", index).labelledLog("Event", event).dispatch(); } } async ["media-enter-fullscreen-request"](event) { try { this._request._queue._enqueue("fullscreen", event); await this._enterFullscreen(event.detail); } catch (error) { this._onFullscreenError(error); } } async ["media-exit-fullscreen-request"](event) { try { this._request._queue._enqueue("fullscreen", event); await this._exitFullscreen(event.detail); } catch (error) { this._onFullscreenError(error); } } async _onFullscreenChange(event) { if (!event.detail) return; try { const lockType = peek(this.$props.fullscreenOrientation); if (this._orientation.supported && !isUndefined(lockType)) { await this._orientation.lock(lockType); } } catch (e) { } } _onFullscreenError(error) { this._stateMgr._handle( this.createEvent("fullscreen-error", { detail: coerceToError(error) }) ); } async ["media-enter-pip-request"](event) { try { this._request._queue._enqueue("pip", event); await this._enterPictureInPicture(); } catch (error) { this._onPictureInPictureError(error); } } async ["media-exit-pip-request"](event) { try { this._request._queue._enqueue("pip", event); await this._exitPictureInPicture(); } catch (error) { this._onPictureInPictureError(error); } } _onPictureInPictureError(error) { this._stateMgr._handle( this.createEvent("picture-in-picture-error", { detail: coerceToError(error) }) ); } ["media-live-edge-request"](event) { const { live, liveEdge, canSeek } = this._store; if (!live() || liveEdge() || !canSeek()) return; this._request._queue._enqueue("seeked", event); try { this._seekToLiveEdge(); } catch (e) { this._media.logger?.error("seek to live edge fail", e); } } ["media-loop-request"]() { window.requestAnimationFrame(async () => { try { this._request._looping = true; this._request._replaying = true; await this._play(); } catch (e) { this._request._looping = false; this._request._replaying = false; } }); } async ["media-pause-request"](event) { if (this._store.paused()) return; try { this._request._queue._enqueue("pause", event); await this._provider().pause(); } catch (e) { this._request._queue._delete("pause"); this._media.logger?.error("pause-fail", e); } } async ["media-play-request"](event) { if (!this._store.paused()) return; try { this._request._queue._enqueue("play", event); await this._provider().play(); } catch (e) { const errorEvent = this.createEvent("play-fail", { detail: coerceToError(e) }); this._stateMgr._handle(errorEvent); } } ["media-rate-change-request"](event) { if (this._store.playbackRate() === event.detail) return; this._request._queue._enqueue("rate", event); this._provider().playbackRate = event.detail; } ["media-quality-change-request"](event) { if (this._media.qualities.readonly) { { this._media.logger?.warnGroup(`[vidstack] attempted to change video quality but it is currently read-only`).labelledLog("Event", event).dispatch(); } return; } this._request._queue._enqueue("quality", event); const index = event.detail; if (index < 0) { this._media.qualities.autoSelect(event); } else { const quality = this._media.qualities[index]; if (quality) { quality.selected = true; } else { this._media.logger?.warnGroup("[vidstack] failed quality change request (invalid index)").labelledLog("Qualities", this._media.qualities.toArray()).labelledLog("Index", index).labelledLog("Event", event).dispatch(); } } } ["media-resume-user-idle-request"](event) { this._request._queue._enqueue("userIdle", event); this._user.pauseIdleTracking(false, event); } ["media-pause-user-idle-request"](event) { this._request._queue._enqueue("userIdle", event); this._user.pauseIdleTracking(true, event); } ["media-seek-request"](event) { const { seekableStart, seekableEnd, ended, canSeek, live, userBehindLiveEdge } = this._store; if (ended()) this._request._replaying = true; this._request._seeking = false; this._request._queue._delete("seeking"); const boundTime = Math.min(Math.max(seekableStart() + 0.1, event.detail), seekableEnd() - 0.1); if (!Number.isFinite(boundTime) || !canSeek()) return; this._request._queue._enqueue("seeked", event); this._provider().currentTime = boundTime; if (live() && event.isOriginTrusted && Math.abs(seekableEnd() - boundTime) >= 2) { userBehindLiveEdge.set(true); } } ["media-seeking-request"](event) { this._request._queue._enqueue("seeking", event); this._store.seeking.set(true); this._request._seeking = true; } ["media-start-loading"](event) { if (this._store.canLoad()) return; this._request._queue._enqueue("load", event); this._stateMgr._handle(this.createEvent("can-load")); } ["media-text-track-change-request"](event) { const { index, mode } = event.detail, track = this._media.textTracks[index]; if (track) { this._request._queue._enqueue("textTrack", event); track.setMode(mode, event); } else { this._media.logger?.warnGroup("[vidstack] failed text track change request (invalid index)").labelledLog("Text Tracks", this._media.textTracks.toArray()).labelledLog("Index", index).labelledLog("Event", event).dispatch(); } } ["media-mute-request"](event) { if (this._store.muted()) return; this._request._queue._enqueue("volume", event); this._provider().muted = true; } ["media-unmute-request"](event) { const { muted, volume } = this._store; if (!muted()) return; this._request._queue._enqueue("volume", event); this._media.$provider().muted = false; if (volume() === 0) { this._request._queue._enqueue("volume", event); this._provider().volume = 0.25; } } ["media-volume-change-request"](event) { const { muted, volume } = this._store; const newVolume = event.detail; if (volume() === newVolume) return; this._request._queue._enqueue("volume", event); this._provider().volume = newVolume; if (newVolume > 0 && muted()) { this._request._queue._enqueue("volume", event); this._provider().muted = false; } } } function throwIfNotReadyForPlayback(provider, canPlay) { if (provider && canPlay) return; throw Error( `[vidstack] media is not ready - wait for \`can-play\` event.` ); } function throwIfFullscreenNotSupported(target, fullscreen) { if (fullscreen?.supported) return; throw Error( `[vidstack] fullscreen is not currently available on target \`${target}\`` ); } 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; // default } if (trailing