UNPKG

@7sage/vidstack

Version:

UI component library for building high-quality, accessible video and audio experiences on the web.

1,638 lines (1,613 loc) 289 kB
import { DOMEvent, EventsTarget, fscreen, ViewController, EventsController, onDispose, signal, listenEvent, peek, isNumber, isString, useContext, createContext, Component, functionThrottle, isKeyboardClick, isTouchEvent, setStyle, isFunction, setAttribute, isDOMNode, effect, untrack, functionDebounce, isArray, isKeyboardEvent, deferredPromise, isUndefined, prop, method, provideContext, animationFrameThrottle, uppercaseFirstChar, camelToKebabCase, computed, tick, scoped, noop, State, isNull, ariaBool as ariaBool$1, isWriteSignal, hasProvidedContext, isObject, useState, createScope, r, wasEnterKeyPressed, isPointerEvent, isMouseEvent, kebabToCamelCase, createDisposalBin } from './chunks/vidstack-BUJn1if6.js'; export { appendTriggerEvent, findTriggerEvent, hasTriggerEvent, walkTriggerEventChain } from './chunks/vidstack-BUJn1if6.js'; import { canOrientScreen, isTrackCaptionKind, TextTrackSymbol, TextTrack, isAudioSrc, isVideoSrc, isHLSSupported, isHLSSrc, isDASHSupported, isDASHSrc, IS_CHROME, preconnect, boundTime, canChangeVolume, softResetMediaState, getTimeRangesEnd, updateTimeIntervals, TimeRange, mediaState, getRequestCredentials, watchActiveTextTrack, isCueActive } from './chunks/vidstack-CshLUi9f.js'; export { AUDIO_EXTENSIONS, AUDIO_TYPES, DASH_VIDEO_EXTENSIONS, DASH_VIDEO_TYPES, HLS_VIDEO_EXTENSIONS, HLS_VIDEO_TYPES, VIDEO_EXTENSIONS, VIDEO_TYPES, canGoogleCastSrc, canPlayHLSNatively, canRotateScreen, canUsePictureInPicture, canUseVideoPresentation, findActiveCue, getDownloadFile, getTimeRangesStart, isMediaStream, normalizeTimeIntervals, parseJSONCaptionsFile, watchCueTextChange } from './chunks/vidstack-CshLUi9f.js'; import { autoUpdate, computePosition, flip, shift } from '@floating-ui/dom'; const GROUPED_LOG = Symbol(0); class GroupedLog { constructor(logger, level, title, root, parent) { this.logger = logger; this.level = level; this.title = title; this.root = root; this.parent = parent; } [GROUPED_LOG] = true; logs = []; log(...data) { this.logs.push({ data }); return this; } labelledLog(label, ...data) { this.logs.push({ label, data }); return this; } groupStart(title) { return new GroupedLog(this.logger, this.level, title, this.root ?? this, this); } groupEnd() { this.parent?.logs.push(this); return this.parent ?? this; } dispatch() { return this.logger.dispatch(this.level, this.root ?? this); } } class Logger { #target = null; error(...data) { return this.dispatch("error", ...data); } warn(...data) { return this.dispatch("warn", ...data); } info(...data) { return this.dispatch("info", ...data); } debug(...data) { return this.dispatch("debug", ...data); } errorGroup(title) { return new GroupedLog(this, "error", title); } warnGroup(title) { return new GroupedLog(this, "warn", title); } infoGroup(title) { return new GroupedLog(this, "info", title); } debugGroup(title) { return new GroupedLog(this, "debug", title); } setTarget(newTarget) { this.#target = newTarget; } dispatch(level, ...data) { return this.#target?.dispatchEvent( new DOMEvent("vds-log", { bubbles: true, composed: true, detail: { level, data } }) ) || false; } } const ADD = Symbol(0), REMOVE = Symbol(0), RESET = Symbol(0), SELECT = Symbol(0), READONLY = Symbol(0), SET_READONLY = Symbol(0), ON_RESET = Symbol(0), ON_REMOVE = Symbol(0), ON_USER_SELECT = Symbol(0); const ListSymbol = { add: ADD, remove: REMOVE, reset: RESET, select: SELECT, readonly: READONLY, setReadonly: SET_READONLY, onReset: ON_RESET, onRemove: ON_REMOVE, onUserSelect: ON_USER_SELECT }; class List extends EventsTarget { items = []; /** @internal */ [ListSymbol.readonly] = false; get length() { return this.items.length; } get readonly() { return this[ListSymbol.readonly]; } /** * Returns the index of the first occurrence of the given item, or -1 if it is not present. */ indexOf(item) { return this.items.indexOf(item); } /** * Returns an item matching the given `id`, or `null` if not present. */ getById(id) { if (id === "") return null; return this.items.find((item) => item.id === id) ?? null; } /** * Transform list to an array. */ toArray() { return [...this.items]; } [Symbol.iterator]() { return this.items.values(); } /** @internal */ [ListSymbol.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 */ [ListSymbol.remove](item, trigger) { const index = this.items.indexOf(item); if (index >= 0) { this[ListSymbol.onRemove]?.(item, trigger); this.items.splice(index, 1); this.dispatchEvent(new DOMEvent("remove", { detail: item, trigger })); } } /** @internal */ [ListSymbol.reset](trigger) { for (const item of [...this.items]) this[ListSymbol.remove](item, trigger); this.items = []; this[ListSymbol.setReadonly](false, trigger); this[ListSymbol.onReset]?.(); } /** @internal */ [ListSymbol.setReadonly](readonly, trigger) { if (this[ListSymbol.readonly] === readonly) return; this[ListSymbol.readonly] = readonly; this.dispatchEvent(new DOMEvent("readonly-change", { detail: readonly, trigger })); } } const CAN_FULLSCREEN = fscreen.fullscreenEnabled; class FullscreenController extends ViewController { /** * 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() { new EventsController(fscreen).add("fullscreenchange", this.#onChange.bind(this)).add("fullscreenerror", this.#onError.bind(this)); onDispose(this.#onDisconnect.bind(this)); } async #onDisconnect() { if (CAN_FULLSCREEN) await this.exit(); } #onChange(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 }); } #onError(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.requestFullscreen(this.el); } catch (error) { this.#listening = false; throw error; } } async exit() { if (!this.el || !isFullscreen(this.el)) return; assertFullscreenAPI(); return fscreen.exitFullscreen(); } } function canFullscreen() { return CAN_FULLSCREEN; } function isFullscreen(host) { if (fscreen.fullscreenElement === host) return true; try { return host.matches( // @ts-expect-error - `fullscreenPseudoClass` is missing from `@types/fscreen`. fscreen.fullscreenPseudoClass ); } catch (error) { return false; } } function assertFullscreenAPI() { if (CAN_FULLSCREEN) return; throw Error( "[vidstack] no fullscreen API" ); } class ScreenOrientationController extends ViewController { #type = signal(this.#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. */ static supported = canOrientScreen(); /** * Whether the native Screen Orientation API is available. */ get supported() { return ScreenOrientationController.supported; } onConnect() { if (this.supported) { listenEvent(screen.orientation, "change", this.#onOrientationChange.bind(this)); } else { const query = window.matchMedia("(orientation: landscape)"); query.onchange = this.#onOrientationChange.bind(this); onDispose(() => query.onchange = null); } onDispose(this.#onDisconnect.bind(this)); } async #onDisconnect() { if (this.supported && this.#locked()) await this.unlock(); } #onOrientationChange(event) { this.#type.set(this.#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; this.#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; this.#assertScreenOrientationAPI(); this.#currentLock = void 0; await screen.orientation.unlock(); this.#locked.set(false); } #assertScreenOrientationAPI() { if (this.supported) return; throw Error( "[vidstack] no orientation API" ); } #getScreenOrientation() { return "portrait-primary"; } } function isVideoQualitySrc(src) { return !isString(src) && "width" in src && "height" in src && isNumber(src.width) && isNumber(src.height); } const mediaContext = createContext(); function useMediaContext() { return useContext(mediaContext); } class MediaRemoteControl { #target = null; #player = null; #prevTrackIndex = -1; #logger; constructor(logger = void 0) { this.#logger = logger; } /** * Set the target from which to dispatch media requests events from. The events should bubble * up from this target to the player element. * * @example * ```ts * const button = document.querySelector('button'); * remote.setTarget(button); * ``` */ setTarget(target) { this.#target = target; } /** * Returns the current player element. This method will attempt to find the player by * searching up from either the given `target` or default target set via `remote.setTarget`. * * @example * ```ts * const player = remote.getPlayer(); * ``` */ getPlayer(target) { if (this.#player) return this.#player; (target ?? this.#target)?.dispatchEvent( new DOMEvent("find-media-player", { detail: (player) => void (this.#player = player), bubbles: true, composed: true }) ); return this.#player; } /** * Set the current player element so the remote can support toggle methods such as * `togglePaused` as they rely on the current media state. */ setPlayer(player) { this.#player = player; } /** * Dispatch a request to start the media loading process. This will only work if the media * player has been initialized with a custom loading strategy `load="custom">`. * * @docs {@link https://www.vidstack.io/docs/player/core-concepts/loading#load-strategies} */ startLoading(trigger) { this.#dispatchRequest("media-start-loading", trigger); } /** * Dispatch a request to start the poster loading process. This will only work if the media * player has been initialized with a custom poster loading strategy `posterLoad="custom">`. * * @docs {@link https://www.vidstack.io/docs/player/core-concepts/loading#load-strategies} */ startLoadingPoster(trigger) { this.#dispatchRequest("media-poster-start-loading", trigger); } /** * Dispatch a request to connect to AirPlay. * * @see {@link https://www.apple.com/au/airplay} */ requestAirPlay(trigger) { this.#dispatchRequest("media-airplay-request", trigger); } /** * Dispatch a request to connect to Google Cast. * * @see {@link https://developers.google.com/cast/docs/overview} */ requestGoogleCast(trigger) { this.#dispatchRequest("media-google-cast-request", trigger); } /** * Dispatch a request to begin/resume media playback. */ play(trigger) { this.#dispatchRequest("media-play-request", trigger); } /** * Dispatch a request to pause media playback. */ pause(trigger) { this.#dispatchRequest("media-pause-request", trigger); } /** * Dispatch a request to set the media volume to mute (0). */ mute(trigger) { this.#dispatchRequest("media-mute-request", trigger); } /** * Dispatch a request to unmute the media volume and set it back to it's previous state. */ unmute(trigger) { this.#dispatchRequest("media-unmute-request", trigger); } /** * Dispatch a request to enter fullscreen. * * @docs {@link https://www.vidstack.io/docs/player/api/fullscreen#remote-control} */ enterFullscreen(target, trigger) { this.#dispatchRequest("media-enter-fullscreen-request", trigger, target); } /** * Dispatch a request to exit fullscreen. * * @docs {@link https://www.vidstack.io/docs/player/api/fullscreen#remote-control} */ exitFullscreen(target, trigger) { this.#dispatchRequest("media-exit-fullscreen-request", trigger, target); } /** * Dispatch a request to lock the screen orientation. * * @docs {@link https://www.vidstack.io/docs/player/screen-orientation#remote-control} */ lockScreenOrientation(lockType, trigger) { this.#dispatchRequest("media-orientation-lock-request", trigger, lockType); } /** * Dispatch a request to unlock the screen orientation. * * @docs {@link https://www.vidstack.io/docs/player/api/screen-orientation#remote-control} */ unlockScreenOrientation(trigger) { this.#dispatchRequest("media-orientation-unlock-request", trigger); } /** * Dispatch a request to enter picture-in-picture mode. * * @docs {@link https://www.vidstack.io/docs/player/api/picture-in-picture#remote-control} */ enterPictureInPicture(trigger) { this.#dispatchRequest("media-enter-pip-request", trigger); } /** * Dispatch a request to exit picture-in-picture mode. * * @docs {@link https://www.vidstack.io/docs/player/api/picture-in-picture#remote-control} */ exitPictureInPicture(trigger) { this.#dispatchRequest("media-exit-pip-request", trigger); } /** * Notify the media player that a seeking process is happening and to seek to the given `time`. */ seeking(time, trigger) { this.#dispatchRequest("media-seeking-request", trigger, time); } /** * Notify the media player that a seeking operation has completed and to seek to the given `time`. * This is generally called after a series of `remote.seeking()` calls. */ seek(time, trigger) { this.#dispatchRequest("media-seek-request", trigger, time); } seekToLiveEdge(trigger) { this.#dispatchRequest("media-live-edge-request", trigger); } /** * Dispatch a request to update the length of the media in seconds. * * @example * ```ts * remote.changeDuration(100); // 100 seconds * ``` */ changeDuration(duration, trigger) { this.#dispatchRequest("media-duration-change-request", trigger, duration); } /** * Dispatch a request to update the clip start time. This is the time at which media playback * should start at. * * @example * ```ts * remote.changeClipStart(100); // start at 100 seconds * ``` */ changeClipStart(startTime, trigger) { this.#dispatchRequest("media-clip-start-change-request", trigger, startTime); } /** * Dispatch a request to update the clip end time. This is the time at which media playback * should end at. * * @example * ```ts * remote.changeClipEnd(100); // end at 100 seconds * ``` */ changeClipEnd(endTime, trigger) { this.#dispatchRequest("media-clip-end-change-request", trigger, endTime); } /** * Dispatch a request to update the media volume to the given `volume` level which is a value * between 0 and 1. * * @docs {@link https://www.vidstack.io/docs/player/api/audio-gain#remote-control} * @example * ```ts * remote.changeVolume(0); // 0% * remote.changeVolume(0.05); // 5% * remote.changeVolume(0.5); // 50% * remote.changeVolume(0.75); // 70% * remote.changeVolume(1); // 100% * ``` */ changeVolume(volume, trigger) { this.#dispatchRequest("media-volume-change-request", trigger, Math.max(0, Math.min(1, volume))); } /** * Dispatch a request to change the current audio track. * * @example * ```ts * remote.changeAudioTrack(1); // track at index 1 * ``` */ changeAudioTrack(index, trigger) { this.#dispatchRequest("media-audio-track-change-request", trigger, index); } /** * Dispatch a request to change the video quality. The special value `-1` represents auto quality * selection. * * @example * ```ts * remote.changeQuality(-1); // auto * remote.changeQuality(1); // quality at index 1 * ``` */ changeQuality(index, trigger) { this.#dispatchRequest("media-quality-change-request", trigger, index); } /** * Request auto quality selection. */ requestAutoQuality(trigger) { this.changeQuality(-1, trigger); } /** * Dispatch a request to change the mode of the text track at the given index. * * @example * ```ts * remote.changeTextTrackMode(1, 'showing'); // track at index 1 * ``` */ changeTextTrackMode(index, mode, trigger) { this.#dispatchRequest("media-text-track-change-request", trigger, { index, mode }); } /** * Dispatch a request to change the media playback rate. * * @example * ```ts * remote.changePlaybackRate(0.5); // Half the normal speed * remote.changePlaybackRate(1); // Normal speed * remote.changePlaybackRate(1.5); // 50% faster than normal * remote.changePlaybackRate(2); // Double the normal speed * ``` */ changePlaybackRate(rate, trigger) { this.#dispatchRequest("media-rate-change-request", trigger, rate); } /** * Dispatch a request to change the media audio gain. * * @example * ```ts * remote.changeAudioGain(1); // Disable audio gain * remote.changeAudioGain(1.5); // 50% louder * remote.changeAudioGain(2); // 100% louder * ``` */ changeAudioGain(gain, trigger) { this.#dispatchRequest("media-audio-gain-change-request", trigger, gain); } /** * Dispatch a request to resume idle tracking on controls. */ resumeControls(trigger) { this.#dispatchRequest("media-resume-controls-request", trigger); } /** * Dispatch a request to pause controls idle tracking. Pausing tracking will result in the * controls being visible until `remote.resumeControls()` is called. This method * is generally used when building custom controls and you'd like to prevent the UI from * disappearing. * * @example * ```ts * // Prevent controls hiding while menu is being interacted with. * function onSettingsOpen() { * remote.pauseControls(); * } * * function onSettingsClose() { * remote.resumeControls(); * } * ``` */ pauseControls(trigger) { this.#dispatchRequest("media-pause-controls-request", trigger); } /** * Dispatch a request to toggle the media playback state. */ togglePaused(trigger) { const player = this.getPlayer(trigger?.target); if (!player) { return; } if (player.state.paused) this.play(trigger); else this.pause(trigger); } /** * Dispatch a request to toggle the controls visibility. */ toggleControls(trigger) { const player = this.getPlayer(trigger?.target); if (!player) { return; } if (!player.controls.showing) { player.controls.show(0, trigger); } else { player.controls.hide(0, trigger); } } /** * Dispatch a request to toggle the media muted state. */ toggleMuted(trigger) { const player = this.getPlayer(trigger?.target); if (!player) { return; } if (player.state.muted) this.unmute(trigger); else this.mute(trigger); } /** * Dispatch a request to toggle the media fullscreen state. * * @docs {@link https://www.vidstack.io/docs/player/api/fullscreen#remote-control} */ toggleFullscreen(target, trigger) { const player = this.getPlayer(trigger?.target); if (!player) { return; } if (player.state.fullscreen) this.exitFullscreen(target, trigger); else this.enterFullscreen(target, trigger); } /** * Dispatch a request to toggle the media picture-in-picture mode. * * @docs {@link https://www.vidstack.io/docs/player/api/picture-in-picture#remote-control} */ togglePictureInPicture(trigger) { const player = this.getPlayer(trigger?.target); if (!player) { return; } if (player.state.pictureInPicture) this.exitPictureInPicture(trigger); else this.enterPictureInPicture(trigger); } /** * Show captions. */ showCaptions(trigger) { const player = this.getPlayer(trigger?.target); if (!player) { return; } let tracks = player.state.textTracks, index = this.#prevTrackIndex; if (!tracks[index] || !isTrackCaptionKind(tracks[index])) { index = -1; } if (index === -1) { index = tracks.findIndex((track) => isTrackCaptionKind(track) && track.default); } if (index === -1) { index = tracks.findIndex((track) => isTrackCaptionKind(track)); } if (index >= 0) this.changeTextTrackMode(index, "showing", trigger); this.#prevTrackIndex = -1; } /** * Turn captions off. */ disableCaptions(trigger) { const player = this.getPlayer(trigger?.target); if (!player) { return; } const tracks = player.state.textTracks, track = player.state.textTrack; if (track) { const index = tracks.indexOf(track); this.changeTextTrackMode(index, "disabled", trigger); this.#prevTrackIndex = index; } } /** * Dispatch a request to toggle the current captions mode. */ toggleCaptions(trigger) { const player = this.getPlayer(trigger?.target); if (!player) { return; } if (player.state.textTrack) { this.disableCaptions(); } else { this.showCaptions(); } } userPrefersLoopChange(prefersLoop, trigger) { this.#dispatchRequest("media-user-loop-change-request", trigger, prefersLoop); } #dispatchRequest(type, trigger, detail) { const request = new DOMEvent(type, { bubbles: true, composed: true, cancelable: true, detail, trigger }); let target = trigger?.target || null; if (target && target instanceof Component) target = target.el; const shouldUsePlayer = !target || target === document || target === window || target === document.body || this.#player?.el && target instanceof Node && !this.#player.el.contains(target); target = shouldUsePlayer ? this.#target ?? this.getPlayer()?.el : target ?? this.#target; if (this.#player) { if (type === "media-play-request" && !this.#player.state.canLoad) { target?.dispatchEvent(request); } else { this.#player.canPlayQueue.enqueue(type, () => target?.dispatchEvent(request)); } } else { target?.dispatchEvent(request); } } #noPlayerWarning(method) { } } class LocalMediaStorage { playerId = "vds-player"; mediaId = null; #data = { volume: null, muted: null, audioGain: null, time: null, lang: null, captions: null, rate: null, quality: null }; async getVolume() { return this.#data.volume; } async setVolume(volume) { this.#data.volume = volume; this.save(); } async getMuted() { return this.#data.muted; } async setMuted(muted) { this.#data.muted = muted; this.save(); } async getTime() { return this.#data.time; } async setTime(time, ended) { const shouldClear = time < 0; this.#data.time = !shouldClear ? time : null; if (shouldClear || ended) this.saveTime(); else this.saveTimeThrottled(); } async getLang() { return this.#data.lang; } async setLang(lang) { this.#data.lang = lang; this.save(); } async getCaptions() { return this.#data.captions; } async setCaptions(enabled) { this.#data.captions = enabled; this.save(); } async getPlaybackRate() { return this.#data.rate; } async setPlaybackRate(rate) { this.#data.rate = rate; this.save(); } async getAudioGain() { return this.#data.audioGain; } async setAudioGain(gain) { this.#data.audioGain = gain; this.save(); } async getVideoQuality() { return this.#data.quality; } async setVideoQuality(quality) { this.#data.quality = quality; this.save(); } onChange(src, mediaId, playerId = "vds-player") { const savedData = playerId ? localStorage.getItem(playerId) : null, savedTime = mediaId ? localStorage.getItem(mediaId) : null; this.playerId = playerId; this.mediaId = mediaId; this.#data = { volume: null, muted: null, audioGain: null, lang: null, captions: null, rate: null, quality: null, ...savedData ? JSON.parse(savedData) : {}, time: savedTime ? +savedTime : null }; } save() { return; } saveTimeThrottled = functionThrottle(this.saveTime.bind(this), 1e3); saveTime() { return; } } const SELECTED = Symbol(0); class SelectList extends List { get selected() { return this.items.find((item) => item.selected) ?? null; } get selectedIndex() { return this.items.findIndex((item) => item.selected); } /** @internal */ [ListSymbol.onRemove](item, trigger) { this[ListSymbol.select](item, false, trigger); } /** @internal */ [ListSymbol.add](item, trigger) { item[SELECTED] = false; Object.defineProperty(item, "selected", { get() { return this[SELECTED]; }, set: (selected) => { if (this.readonly) return; this[ListSymbol.onUserSelect]?.(); this[ListSymbol.select](item, selected); } }); super[ListSymbol.add](item, trigger); } /** @internal */ [ListSymbol.select](item, selected, trigger) { if (selected === item?.[SELECTED]) return; const prev = this.selected; if (item) item[SELECTED] = selected; const changed = !selected ? prev === item : prev !== item; if (changed) { if (prev) prev[SELECTED] = false; this.dispatchEvent( new DOMEvent("change", { detail: { prev, current: this.selected }, trigger }) ); } } } class AudioTrackList extends SelectList { } class LibASSTextRenderer { constructor(loader, config) { this.loader = loader; this.config = config; } priority = 1; #instance = null; #track = null; #typeRE = /(ssa|ass)$/; canRender(track, video) { return !!video && !!track.src && (isString(track.type) && this.#typeRE.test(track.type) || this.#typeRE.test(track.src)); } attach(video) { if (!video) return; this.loader().then(async (mod) => { this.#instance = new mod.default({ ...this.config, video, subUrl: this.#track?.src || "" }); new EventsController(this.#instance).add("ready", () => { const canvas = this.#instance?._canvas; if (canvas) canvas.style.pointerEvents = "none"; }).add("error", (event) => { if (!this.#track) return; this.#track[TextTrackSymbol.readyState] = 3; this.#track.dispatchEvent( new DOMEvent("error", { trigger: event, detail: event.error }) ); }); }); } changeTrack(track) { if (!track || track.readyState === 3) { this.#freeTrack(); } else if (this.#track !== track) { this.#instance?.setTrackByUrl(track.src); this.#track = track; } } detach() { this.#freeTrack(); } #freeTrack() { this.#instance?.freeTrack(); this.#track = null; } } function round(num, decimalPlaces = 2) { return Number(num.toFixed(decimalPlaces)); } function getNumberOfDecimalPlaces(num) { return String(num).split(".")[1]?.length ?? 0; } function clampNumber(min, value, max) { return Math.max(min, Math.min(max, value)); } function isEventInside(el, event) { const target = event.composedPath()[0]; return isDOMNode(target) && el.contains(target); } const rafJobs = /* @__PURE__ */ new Set(); function scheduleRafJob(job) { rafJobs.add(job); return () => rafJobs.delete(job); } function setAttributeIfEmpty(target, name, value) { if (!target.hasAttribute(name)) target.setAttribute(name, value); } function setARIALabel(target, $label) { if (target.hasAttribute("aria-label") || target.hasAttribute("data-no-label")) return; if (!isFunction($label)) { setAttribute(target, "aria-label", $label); return; } function updateAriaDescription() { setAttribute(target, "aria-label", $label()); } updateAriaDescription(); } function isElementVisible(el) { const style = getComputedStyle(el); return style.display !== "none" && parseInt(style.opacity) > 0; } function checkVisibility(el) { return !!el && ("checkVisibility" in el ? el.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true }) : isElementVisible(el)); } function observeVisibility(el, callback) { return scheduleRafJob(() => callback(checkVisibility(el))); } function isElementParent(owner, node, test) { while (node) { if (node === owner) { return true; } else if (test?.(node)) { break; } else { node = node.parentElement; } } return false; } function onPress(target, handler) { return new EventsController(target).add("pointerup", (event) => { if (event.button === 0 && !event.defaultPrevented) handler(event); }).add("keydown", (event) => { if (isKeyboardClick(event)) handler(event); }); } function isTouchPinchEvent(event) { return isTouchEvent(event) && (event.touches.length > 1 || event.changedTouches.length > 1); } function requestScopedAnimationFrame(callback) { return callback(); } function autoPlacement(el, trigger, placement, { offsetVarName, xOffset, yOffset, ...options }) { if (!el) return; const floatingPlacement = placement.replace(" ", "-").replace("-center", ""); setStyle(el, "visibility", !trigger ? "hidden" : null); if (!trigger) return; let isTop = placement.includes("top"); const negateX = (x) => placement.includes("left") ? `calc(-1 * ${x})` : x, negateY = (y) => isTop ? `calc(-1 * ${y})` : y; return autoUpdate(trigger, el, () => { computePosition(trigger, el, { placement: floatingPlacement, middleware: [ ...options.middleware ?? [], flip({ fallbackAxisSideDirection: "start", crossAxis: false }), shift() ], ...options }).then(({ x, y, middlewareData }) => { const hasFlipped = !!middlewareData.flip?.index; isTop = placement.includes(hasFlipped ? "bottom" : "top"); el.setAttribute( "data-placement", hasFlipped ? placement.startsWith("top") ? placement.replace("top", "bottom") : placement.replace("bottom", "top") : placement ); Object.assign(el.style, { top: `calc(${y + "px"} + ${negateY( yOffset ? yOffset + "px" : `var(--${offsetVarName}-y-offset, 0px)` )})`, left: `calc(${x + "px"} + ${negateX( xOffset ? xOffset + "px" : `var(--${offsetVarName}-x-offset, 0px)` )})` }); }); }); } function hasAnimation(el) { const styles = getComputedStyle(el); return styles.animationName !== "none"; } function isHTMLElement(el) { return el instanceof HTMLElement; } class NativeTextRenderer { priority = 0; #display = true; #video = null; #track = null; #tracks = /* @__PURE__ */ new Set(); canRender(_, video) { return !!video; } attach(video) { this.#video = video; if (video) video.textTracks.onchange = this.#onChange.bind(this); } addTrack(track) { this.#tracks.add(track); this.#attachTrack(track); } removeTrack(track) { track[TextTrackSymbol.native]?.remove?.(); track[TextTrackSymbol.native] = null; this.#tracks.delete(track); } changeTrack(track) { const current = track?.[TextTrackSymbol.native]; if (current && current.track.mode !== "showing") { current.track.mode = "showing"; } this.#track = track; } setDisplay(display) { this.#display = display; this.#onChange(); } detach() { if (this.#video) this.#video.textTracks.onchange = null; for (const track of this.#tracks) this.removeTrack(track); this.#tracks.clear(); this.#video = null; this.#track = null; } #attachTrack(track) { if (!this.#video) return; const el = track[TextTrackSymbol.native] ??= this.#createTrackElement(track); if (isHTMLElement(el)) { this.#video.append(el); el.track.mode = el.default ? "showing" : "disabled"; } } #createTrackElement(track) { const el = document.createElement("track"), isDefault = track.default || track.mode === "showing", isSupported = track.src && track.type === "vtt"; el.id = track.id; el.src = isSupported ? track.src : ""; el.label = track.label; el.kind = track.kind; el.default = isDefault; track.language && (el.srclang = track.language); if (isDefault && !isSupported) { this.#copyCues(track, el.track); } return el; } #copyCues(track, native) { if (track.src && track.type === "vtt" || native.cues?.length) return; for (const cue of track.cues) native.addCue(cue); } #onChange(event) { for (const track of this.#tracks) { const native = track[TextTrackSymbol.native]; if (!native) continue; if (!this.#display) { native.track.mode = native.managed ? "hidden" : "disabled"; continue; } const isShowing = native.track.mode === "showing"; if (isShowing) this.#copyCues(track, native.track); track.setMode(isShowing ? "showing" : "disabled", event); } } } class TextRenderers { #video = null; #textTracks; #renderers = []; #media; #nativeDisplay = false; #nativeRenderer = null; #customRenderer = null; constructor(media) { this.#media = media; const textTracks = media.textTracks; this.#textTracks = textTracks; effect(this.#watchControls.bind(this)); onDispose(this.#detach.bind(this)); new EventsController(textTracks).add("add", this.#onAddTrack.bind(this)).add("remove", this.#onRemoveTrack.bind(this)).add("mode-change", this.#update.bind(this)); } #watchControls() { const { nativeControls } = this.#media.$state; this.#nativeDisplay = nativeControls(); this.#update(); } add(renderer) { this.#renderers.push(renderer); untrack(this.#update.bind(this)); } remove(renderer) { renderer.detach(); this.#renderers.splice(this.#renderers.indexOf(renderer), 1); untrack(this.#update.bind(this)); } /** @internal */ attachVideo(video) { requestAnimationFrame(() => { this.#video = video; if (video) { this.#nativeRenderer = new NativeTextRenderer(); this.#nativeRenderer.attach(video); for (const track of this.#textTracks) this.#addNativeTrack(track); } this.#update(); }); } #addNativeTrack(track) { if (!isTrackCaptionKind(track)) return; this.#nativeRenderer?.addTrack(track); } #removeNativeTrack(track) { if (!isTrackCaptionKind(track)) return; this.#nativeRenderer?.removeTrack(track); } #onAddTrack(event) { this.#addNativeTrack(event.detail); } #onRemoveTrack(event) { this.#removeNativeTrack(event.detail); } #update() { const currentTrack = this.#textTracks.selected; if (this.#video && (this.#nativeDisplay || currentTrack?.[TextTrackSymbol.nativeHLS])) { this.#customRenderer?.changeTrack(null); this.#nativeRenderer?.setDisplay(true); this.#nativeRenderer?.changeTrack(currentTrack); return; } this.#nativeRenderer?.setDisplay(false); this.#nativeRenderer?.changeTrack(null); if (!currentTrack) { this.#customRenderer?.changeTrack(null); return; } const customRenderer = this.#renderers.sort((a, b) => a.priority - b.priority).find((renderer) => renderer.canRender(currentTrack, this.#video)); if (this.#customRenderer !== customRenderer) { this.#customRenderer?.detach(); customRenderer?.attach(this.#video); this.#customRenderer = customRenderer ?? null; } customRenderer?.changeTrack(currentTrack); } #detach() { this.#nativeRenderer?.detach(); this.#nativeRenderer = null; this.#customRenderer?.detach(); this.#customRenderer = null; } } class TextTrackList extends List { #canLoad = false; #defaults = {}; #storage = null; #preferredLang = null; /** @internal */ [TextTrackSymbol.crossOrigin]; constructor() { super(); } get selected() { const track = this.items.find((t) => t.mode === "showing" && isTrackCaptionKind(t)); return track ?? null; } get selectedIndex() { const selected = this.selected; return selected ? this.indexOf(selected) : -1; } get preferredLang() { return this.#preferredLang; } set preferredLang(lang) { this.#preferredLang = lang; this.#saveLang(lang); } add(init, trigger) { const isTrack = init instanceof TextTrack, track = isTrack ? init : new TextTrack(init), kind = init.kind === "captions" || init.kind === "subtitles" ? "captions" : init.kind; if (this.#defaults[kind] && init.default) delete init.default; track.addEventListener("mode-change", this.#onTrackModeChangeBind); this[ListSymbol.add](track, trigger); track[TextTrackSymbol.crossOrigin] = this[TextTrackSymbol.crossOrigin]; if (this.#canLoad) track[TextTrackSymbol.canLoad](); if (init.default) this.#defaults[kind] = track; this.#selectTracks(); return this; } remove(track, trigger) { this.#pendingRemoval = track; if (!this.items.includes(track)) return; if (track === this.#defaults[track.kind]) delete this.#defaults[track.kind]; track.mode = "disabled"; track[TextTrackSymbol.onModeChange] = null; track.removeEventListener("mode-change", this.#onTrackModeChangeBind); this[ListSymbol.remove](track, trigger); this.#pendingRemoval = null; return this; } clear(trigger) { for (const track of [...this.items]) { this.remove(track, trigger); } return this; } getByKind(kind) { const kinds = Array.isArray(kind) ? kind : [kind]; return this.items.filter((track) => kinds.includes(track.kind)); } /** @internal */ [TextTrackSymbol.canLoad]() { if (this.#canLoad) return; for (const track of this.items) track[TextTrackSymbol.canLoad](); this.#canLoad = true; this.#selectTracks(); } #selectTracks = functionDebounce(async () => { if (!this.#canLoad) return; if (!this.#preferredLang && this.#storage) { this.#preferredLang = await this.#storage.getLang(); } const showCaptions = await this.#storage?.getCaptions(), kinds = [ ["captions", "subtitles"], "chapters", "descriptions", "metadata" ]; for (const kind of kinds) { const tracks = this.getByKind(kind); if (tracks.find((t) => t.mode === "showing")) continue; const preferredTrack = this.#preferredLang ? tracks.find((track2) => track2.language === this.#preferredLang) : null; const defaultTrack = isArray(kind) ? this.#defaults[kind.find((kind2) => this.#defaults[kind2]) || ""] : this.#defaults[kind]; const track = preferredTrack ?? defaultTrack, isCaptionsKind = track && isTrackCaptionKind(track); if (track && (!isCaptionsKind || showCaptions !== false)) { track.mode = "showing"; if (isCaptionsKind) this.#saveCaptionsTrack(track); } } }, 300); #pendingRemoval = null; #onTrackModeChangeBind = this.#onTrackModeChange.bind(this); #onTrackModeChange(event) { const track = event.detail; if (this.#storage && isTrackCaptionKind(track) && track !== this.#pendingRemoval) { this.#saveCaptionsTrack(track); } if (track.mode === "showing") { const kinds = isTrackCaptionKind(track) ? ["captions", "subtitles"] : [track.kind]; for (const t of this.items) { if (t.mode === "showing" && t != track && kinds.includes(t.kind)) { t.mode = "disabled"; } } } this.dispatchEvent( new DOMEvent("mode-change", { detail: event.detail, trigger: event }) ); } #saveCaptionsTrack(track) { if (track.mode !== "disabled") { this.#saveLang(track.language); } this.#storage?.setCaptions?.(track.mode === "showing"); } #saveLang(lang) { this.#storage?.setLang?.(this.#preferredLang = lang); } setStorage(storage) { this.#storage = storage; } } const SET_AUTO = Symbol(0), ENABLE_AUTO = Symbol(0); const QualitySymbol = { setAuto: SET_AUTO, enableAuto: ENABLE_AUTO }; 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://www.vidstack.io/docs/player/api/video-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 */ [QualitySymbol.enableAuto]; /** @internal */ [ListSymbol.onUserSelect]() { this[QualitySymbol.setAuto](false); } /** @internal */ [ListSymbol.onReset](trigger) { this[QualitySymbol.enableAuto] = void 0; this[QualitySymbol.setAuto](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[QualitySymbol.enableAuto]) return; this[QualitySymbol.enableAuto]?.(trigger); this[QualitySymbol.setAuto](true, trigger); } getBySrc(src) { return this.items.find((quality) => quality.src === src); } /** @internal */ [QualitySymbol.setAuto](auto, trigger) { if (this.#auto === auto) return; this.#auto = auto; this.dispatchEvent( new DOMEvent("auto-change", { detail: auto, trigger }) ); } } function sortVideoQualities(qualities, desc) { return [...qualities].sort(desc ? compareVideoQualityDesc : compareVideoQualityAsc); } function compareVideoQualityAsc(a, b) { return a.height === b.height ? (a.bitrate ?? 0) - (b.bitrate ?? 0) : a.height - b.height; } function compareVideoQualityDesc(a, b) { return b.height === a.height ? (b.bitrate ?? 0) - (a.bitrate ?? 0) : b.height - a.height; } function isAudioProvider(provider) { return provider?.$$PROVIDER_TYPE === "AUDIO"; } function isVideoProvider(provider) { return provider?.$$PROVIDER_TYPE === "VIDEO"; } function isHLSProvider(provider) { return provider?.$$PROVIDER_TYPE === "HLS"; } function isDASHProvider(provider) { return provider?.$$PROVIDER_TYPE === "DASH"; } function isYouTubeProvider(provider) { return provider?.$$PROVIDER_TYPE === "YOUTUBE"; } function isVimeoProvider(provider) { return provider?.$$PROVIDER_TYPE === "VIMEO"; } function isGoogleCastProvider(provider) { return provider?.$$PROVIDER_TYPE === "GOOGLE_CAST"; } function isHTMLAudioElement(element) { return false; } function isHTMLVideoElement(element) { return false; } function isHTMLMediaElement(element) { return isHTMLVideoElement(); } function isHTMLIFrameElement(element) { return false; } class MediaPlayerController extends ViewController { } const MEDIA_KEY_SHORTCUTS = { togglePaused: "k Space", toggleMuted: "m", toggleFullscreen: "f", togglePictureInPicture: "i", toggleCaptions: "c", seekBackward: "j J ArrowLeft", seekForward: "l L ArrowRight", volumeUp: "ArrowUp", volumeDown: "ArrowDown", speedUp: ">", slowDown: "<" }; const MODIFIER_KEYS = /* @__PURE__ */ new Set(["Shift", "Alt", "Meta", "Ctrl"]), BUTTON_SELECTORS = 'button, [role="button"]', IGNORE_SELECTORS = 'input, textarea, select, [contenteditable], [role^="menuitem"], [role="timer"]'; class MediaKeyboardController extends MediaPlayerController { #media; constructor(media) { super(); 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) { new EventsController(this.el).add("focusin", () => $active.set(true)).add("focusout", (event) => { if (!this.el.contains(event.target)) $active.set(false); }); } else { if (!peek($active)) $active.set(document.querySelector("[data-media-player]") === this.el); } effect(() => { if (!$active()) return; new EventsController(target).add("keyup", this.#onKeyUp.bind(this)).add("keydown", this.#onKeyDown.bind(this)).add("keydown", this.#onPreventVideoKeys.bind(this), { capture: true }); }); } #onKeyUp(event) { const focusedEl = document.activeElement; if (!event.key || !this.$state.canSeek() || focusedEl?.matches(IGNORE_SELECTORS)) { return; } let { method, value } = this.#getMatchingMethod(event); if (!isString(value) && !isArray(value)) { value?.onKeyUp?.({ event, player: this.#media.player, remote: this.#media.remote }); value?.callback?.(event, this.#media.remote); return; } if (method?.startsWith("seek")) { event.preventDefault(); event.stopPropagation(); if (this.#timeSlider) { this.#forwardTimeKeyboardEvent(event, method === "seekForward"); this.#timeSlider = null; } else { this.#media.remote.seek(this.#seekTotal, event); this.#seekTotal = void 0; } } if (method?.startsWith("volume")) { const volumeSlider = this.el.querySelector("[data-media-volume-slider]"); volumeSlider?.dispatchEvent( new KeyboardEvent("keyup", { key: method === "volumeUp" ? "Up" : "Down", shiftKey: event.shiftKey, trigger: event }) ); } } #onKeyDown(event) { if (!event.key || MODIFIER_KEYS.has(event.key)) return; const focusedEl = document.activeElement; if (focusedEl?.matches(IGNORE_SELECTORS) || isKeyboardClick(event) && focusedEl?.matches(BUTTON_SELECTORS)) { return; } let { method, value } = this.#getMatchingMethod(event), isNumberPress = !event.metaKey && /^[0-9]$/.test(event.key); if (!isString(value) && !isArray(value) && !isNumberPress) { value?.onKeyDown?.({ event, player: this.#media.player, remote: this.#media.remote }); value?.callback?.(event, this.#media.remote); return; } if (!method && isNumberPress && !modifierKeyPressed(event)) { event.preventDefault(); event.stopPropagation(); this.#media.remote.seek(this.$state.duration() / 10 * Number(event.key), event); return; } if (!method) return; event.preventDefault(); event.stopPropagation(); switch (method) { case "seekForward": case "seekBackward":