UNPKG

vidstack

Version:

Build awesome media experiences on the web.

1,748 lines (1,692 loc) 99.4 kB
import { ComponentController, defineProp, defineElement, Component } from 'maverick.js/element'; import { signal, peek, createContext, useContext, effect, StoreFactory, tick, onDispose, computed } from 'maverick.js'; import { EventsTarget, DOMEvent, listenEvent, isKeyboardClick, setAttribute, isArray, isUndefined, isNumber, isKeyboardEvent, appendTriggerEvent, isString, deferredPromise, isNull, 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" ); var _a$1; class List extends EventsTarget { constructor() { super(...arguments); this._items = []; /* @internal */ this[_a$1] = false; } get length() { return this._items.length; } get readonly() { return this[LIST_READONLY]; } /** * Transform list to an array. */ toArray() { return [...this._items]; } [(_a$1 = 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 })); } } 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 { 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" ); } function canOrientScreen() { return false; } function canPlayHLSNatively(video) { return false; } function canUsePictureInPicture(video) { return false; } function canUseVideoPresentation(video) { return false; } function isHLSSupported() { return false; } 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() { { const query = window.matchMedia("(orientation: landscape)"); query.onchange = this._onOrientationChange.bind(this); return () => query.onchange = null; } } async onDisconnect() { } _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() { throw Error( "[vidstack] screen orientation API is not available" ); } function getScreenOrientation() { return "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()); } 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) { return callback(); } 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; 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) ) ; } _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(); } 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 { 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) { return; } } class MediaPlayerDelegate { constructor(_handle, _media) { this._handle = _handle; this._media = _media; } _dispatch(type, ...init) { return; } async _ready(info, trigger) { return; } 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 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 { constructor() { super(...arguments); this._idleTimer = -2; this._delay = 2e3; this._pausedTracking = false; this._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 { constructor() { this._seeking = false; this._looping = false; this._replaying = false; this._queue = new Queue(); } } class MediaRequestManager extends ComponentController { constructor(instance, _stateMgr, _request, _media) { super(instance); this._stateMgr = _stateMgr; this._request = _request; this._media = _media; this._wasPIPActive = false; this._store = _media.$store; this._provider = _media.$provider; this._user = new MediaUserController(instance); this._fullscreen = new FullscreenController(instance); this._orientation = new ScreenOrientationController(instance); } 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() { return; } async _pause() { return; } _seekToLiveEdge() { return; } async _enterFullscreen(target = "prefer-media") { return; } async _exitFullscreen(target = "prefer-media") { return; } async _enterPictureInPicture() { return; } async _exitPictureInPicture() { return; } _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; } } } 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 == null) { trailing = !leading; //default } if (leading == true) { trailing = false; // forced because there should be invocation per call } 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("ATTACH_VIDEO" ); const TEXT_TRACK_CROSSORIGIN = Symbol("TEXT_TRACK_CROSSORIGIN" ); const TEXT_TRACK_READY_STATE = Symbol("TEXT_TRACK_READY_STATE" ); const TEXT_TRACK_UPDATE_ACTIVE_CUES = Symbol("TEXT_TRACK_UPDATE_ACTIVE_CUES" ); const TEXT_TRACK_CAN_LOAD = Symbol("TEXT_TRACK_CAN_LOAD" ); const TEXT_TRACK_ON_MODE_CHANGE = Symbol("TEXT_TRACK_ON_MODE_CHANGE" ); const TEXT_TRACK_NATIVE = Symbol("TEXT_TRACK_NATIVE" ); const TEXT_TRACK_NATIVE_HLS = Symbol("TEXT_TRACK_NATIVE_HLS" ); class MediaStateManager extends ComponentController { constructor(instance, _request, _media) { super(instance); this._request = _request; this._media = _media; this._trackedEvents = /* @__PURE__ */ new Map(); this._skipInitialSrcChange = true; this._firingWaiting = false; this["seeking"] = functionThrottle( (event) => { const { seeking, currentTime, paused } = this._store; seeking.set(true); currentTime.set(event.detail); this._satisfyRequest("seeking", event); if (paused()) { this._waitingTrigger = event; this._fireWaiting(); } }, 150, { leading: true } ); this._fireWaiting = functionDebounce(() => { if (!this._waitingTrigger) return; this._firingWaiting = true; const { waiting, playing } = this._store; waiting.set(true); playing.set(false); const event = this.createEvent("waiting", { trigger: this._waitingTrigger }); this._trackedEvents.set("waiting", event); this.el.dispatchEvent(event); this._waitingTrigger = void 0; this._firingWaiting = false; }, 300); this._store = _media.$store; } onAttach(el) { el.setAttribute("aria-busy", "true"); } onConnect(el) { this._addTextTrackListeners(); this._addQualityListeners(); this._addAudioTrackListeners(); this.listen("fullscreen-change", this["fullscreen-change"].bind(this)); this.listen("fullscreen-error", this["fullscreen-error"].bind(this)); } _handle(event) { event.type; this[event.type]?.(event); } _resetTracking() { this._stopWaiting(); this._request._replaying = false; this._request._looping = false; this._firingWaiting = false; this._waitingTrigger = void 0; this._trackedEvents.clear(); } _satisfyRequest(request, event) { this._request._queue._serve(request, (requestEvent) => { event.request = requestEvent; appendTriggerEvent(event, requestEvent); }); } _addTextTrackListeners() { this._onTextTracksChange(); this._onTextTrackModeChange(); const textTracks = this._media.textTracks; listenEvent(textTracks, "add", this._onTextTracksChange.bind(this)); listenEvent(textTracks, "remove", this._onTextTracksChange.bind(this)); listenEvent(textTracks, "mode-change", this._onTextTrackModeChange.bind(this)); } _addQualityListeners() { const qualities = this._media.qualities; listenEvent(qualities, "add", this._onQualitiesChange.bind(this)); listenEvent(qualities, "remove", this._onQualitiesChange.bind(this)); listenEvent(qualities, "change", this._onQualityChange.bind(this)); listenEvent(qualities, "auto-change", this._onAutoQualityChange.bind(this)); listenEvent(qualities, "readonly-change", this._onCanSetQualityChange.bind(this)); } _addAudioTrackListeners() { const audioTracks = this._media.audioTracks; listenEvent(audioTracks, "add", this._onAudioTracksChange.bind(this)); listenEvent(audioTracks, "remove", this._onAudioTracksChange.bind(this)); listenEvent(audioTracks, "change", this._onAudioTrackChange.bind(this)); } _onTextTracksChange(event) { const { textTracks } = this._store; textTracks.set(this._media.textTracks.toArray()); this.dispatch("text-tracks-change", { detail: textTracks(), trigger: event }); } _onTextTrackModeChange(event) { if (event) this._satisfyRequest("textTrack", event); const current = this._media.textTracks.selected, { textTrack } = this._store; if (textTrack() !== current) { textTrack.set(current); this.dispatch("text-track-change", { detail: current, trigger: event }); } } _onAudioTracksChange(event) { const { audioTracks } = this._store; audioTracks.set(this._media.audioTracks.toArray()); this.dispatch("audio-tracks-change", { detail: audioTracks(), trigger: event }); } _onAudioTrackChange(event) { const { audioTrack } = this._store; audioTrack.set(this._media.audioT