UNPKG

@7sage/vidstack

Version:

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

1,620 lines (1,604 loc) 151 kB
import { EventsTarget, DOMEvent, fscreen, ViewController, EventsController, onDispose, signal, listenEvent, peek, isNumber, isString, State, tick, Component, functionThrottle, effect, untrack, functionDebounce, isArray, isKeyboardClick, isKeyboardEvent, waitIdlePeriod, deferredPromise, isUndefined, prop, method, provideContext, setAttribute, animationFrameThrottle, uppercaseFirstChar, camelToKebabCase, setStyle, computed, scoped, noop } from './vidstack-BGSTndAW.js'; import { mediaContext, useMediaContext } from './vidstack-DJDnh4xT.js'; import { canOrientScreen, IS_IPHONE, isAudioSrc, canPlayAudioType, isVideoSrc, canPlayVideoType, isHLSSupported, isHLSSrc, isDASHSupported, isDASHSrc, IS_CHROME, IS_IOS, canGoogleCastSrc, canChangeVolume } from './vidstack-xMS8dnYq.js'; import { TimeRange, getTimeRangesEnd, getTimeRangesStart, updateTimeIntervals } from './vidstack-ChQTHmIQ.js'; import { isTrackCaptionKind, TextTrackSymbol, TextTrack } from './vidstack-Ci54COQW.js'; import { ListSymbol } from './vidstack-D5EzK014.js'; import { QualitySymbol } from './vidstack-B01xzxC4.js'; import { coerceToError } from './vidstack-C9vIqaYT.js'; import { preconnect, getRequestCredentials } from './vidstack-CTojmhKq.js'; import { isHTMLElement, isTouchPinchEvent, prefersReducedMotion, setAttributeIfEmpty } from './vidstack-C2US-gSO.js'; import { clampNumber } from './vidstack-Dihypf8P.js'; import { FocusVisibleController } from './vidstack-DsPOyKtl.js'; 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() { if (this.supported) return window.screen.orientation.type; return window.innerWidth >= window.innerHeight ? "landscape-primary" : "portrait-primary"; } } function isVideoQualitySrc(src) { return !isString(src) && "width" in src && "height" in src && isNumber(src.width) && isNumber(src.height); } const mediaState = new State({ artist: "", artwork: null, audioTrack: null, audioTracks: [], autoPlay: false, autoPlayError: null, audioGain: null, buffered: new TimeRange(), canLoad: false, canLoadPoster: false, canFullscreen: false, canOrientScreen: canOrientScreen(), canPictureInPicture: false, canPlay: false, clipStartTime: 0, clipEndTime: 0, controls: false, get iOSControls() { return IS_IPHONE && this.mediaType === "video" && (!this.playsInline || !fscreen.fullscreenEnabled && this.fullscreen); }, get nativeControls() { return this.controls || this.iOSControls; }, controlsVisible: false, get controlsHidden() { return !this.controlsVisible; }, crossOrigin: null, ended: false, error: null, fullscreen: false, get loop() { return this.providedLoop || this.userPrefersLoop; }, logLevel: "silent", mediaType: "unknown", muted: false, paused: true, played: new TimeRange(), playing: false, playsInline: false, pictureInPicture: false, preload: "metadata", playbackRate: 1, qualities: [], quality: null, autoQuality: false, canSetQuality: true, canSetPlaybackRate: true, canSetVolume: false, canSetAudioGain: false, seekable: new TimeRange(), seeking: false, source: { src: "", type: "" }, sources: [], started: false, textTracks: [], textTrack: null, get hasCaptions() { return this.textTracks.filter(isTrackCaptionKind).length > 0; }, volume: 1, waiting: false, realCurrentTime: 0, get currentTime() { return this.ended ? this.duration : this.clipStartTime > 0 ? Math.max(0, Math.min(this.realCurrentTime - this.clipStartTime, this.duration)) : this.realCurrentTime; }, providedDuration: -1, intrinsicDuration: 0, get duration() { return this.seekableWindow; }, get title() { return this.providedTitle || this.inferredTitle; }, get poster() { return this.providedPoster || this.inferredPoster; }, get viewType() { return this.providedViewType !== "unknown" ? this.providedViewType : this.inferredViewType; }, get streamType() { return this.providedStreamType !== "unknown" ? this.providedStreamType : this.inferredStreamType; }, get currentSrc() { return this.source; }, get bufferedStart() { const start = getTimeRangesStart(this.buffered) ?? 0; return Math.max(start, this.clipStartTime); }, get bufferedEnd() { const end = getTimeRangesEnd(this.buffered) ?? 0; return Math.min(this.seekableEnd, Math.max(0, end - this.clipStartTime)); }, get bufferedWindow() { return Math.max(0, this.bufferedEnd - this.bufferedStart); }, get seekableStart() { if (this.isLiveDVR && this.liveDVRWindow > 0) { return Math.max(0, this.seekableEnd - this.liveDVRWindow); } const start = getTimeRangesStart(this.seekable) ?? 0; return Math.max(start, this.clipStartTime); }, get seekableEnd() { if (this.providedDuration > 0) return this.providedDuration; const end = this.liveSyncPosition > 0 ? this.liveSyncPosition : this.canPlay ? getTimeRangesEnd(this.seekable) ?? Infinity : 0; return this.clipEndTime > 0 ? Math.min(this.clipEndTime, end) : end; }, get seekableWindow() { const window = this.seekableEnd - this.seekableStart; return !isNaN(window) ? Math.max(0, window) : Infinity; }, // ~~ remote playback ~~ canAirPlay: false, canGoogleCast: false, remotePlaybackState: "disconnected", remotePlaybackType: "none", remotePlaybackLoader: null, remotePlaybackInfo: null, get isAirPlayConnected() { return this.remotePlaybackType === "airplay" && this.remotePlaybackState === "connected"; }, get isGoogleCastConnected() { return this.remotePlaybackType === "google-cast" && this.remotePlaybackState === "connected"; }, // ~~ responsive design ~~ pointer: "fine", orientation: "landscape", width: 0, height: 0, mediaWidth: 0, mediaHeight: 0, lastKeyboardAction: null, // ~~ user props ~~ userBehindLiveEdge: false, // ~~ live props ~~ liveEdgeTolerance: 10, minLiveDVRWindow: 60, get canSeek() { return /unknown|on-demand|:dvr/.test(this.streamType) && Number.isFinite(this.duration) && (!this.isLiveDVR || this.duration >= this.liveDVRWindow); }, get live() { return this.streamType.includes("live") || !Number.isFinite(this.duration); }, get liveEdgeStart() { return this.live && Number.isFinite(this.seekableEnd) ? Math.max(0, 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; }, get isLiveDVR() { return /:dvr/.test(this.streamType); }, get liveDVRWindow() { return Math.max(this.inferredLiveDVRWindow, this.minLiveDVRWindow); }, // ~~ internal props ~~ autoPlaying: false, providedTitle: "", inferredTitle: "", providedLoop: false, userPrefersLoop: false, providedPoster: "", inferredPoster: "", inferredViewType: "unknown", providedViewType: "unknown", providedStreamType: "unknown", inferredStreamType: "unknown", liveSyncPosition: null, inferredLiveDVRWindow: 0, savedState: null }); const RESET_ON_SRC_QUALITY_CHANGE = /* @__PURE__ */ new Set([ "autoPlayError", "autoPlaying", "buffered", "canPlay", "error", "paused", "played", "playing", "seekable", "seeking", "waiting" ]); const RESET_ON_SRC_CHANGE = /* @__PURE__ */ new Set([ ...RESET_ON_SRC_QUALITY_CHANGE, "ended", "inferredPoster", "inferredStreamType", "inferredTitle", "intrinsicDuration", "inferredLiveDVRWindow", "liveSyncPosition", "realCurrentTime", "savedState", "started", "userBehindLiveEdge" ]); function softResetMediaState($media, isSourceQualityChange = false) { const filter = isSourceQualityChange ? RESET_ON_SRC_QUALITY_CHANGE : RESET_ON_SRC_CHANGE; mediaState.reset($media, (prop) => filter.has(prop)); tick(); } function boundTime(time, store) { const clippedTime = time + store.clipStartTime(), isStart = Math.floor(time) === Math.floor(store.seekableStart()), isEnd = Math.floor(clippedTime) === Math.floor(store.seekableEnd()); if (isStart) { return store.seekableStart(); } if (isEnd) { return store.seekableEnd(); } if (store.isLiveDVR() && store.liveDVRWindow() > 0 && clippedTime < store.seekableEnd() - store.liveDVRWindow()) { return store.bufferedStart(); } return Math.min(Math.max(store.seekableStart() + 0.1, clippedTime), store.seekableEnd() - 0.1); } 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() { if (!this.playerId) return; const data = JSON.stringify({ ...this.#data, time: void 0 }); localStorage.setItem(this.playerId, data); } saveTimeThrottled = functionThrottle(this.saveTime.bind(this), 1e3); saveTime() { if (!this.mediaId) return; const data = (this.#data.time ?? 0).toString(); localStorage.setItem(this.mediaId, data); } } 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 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; } } 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 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 element instanceof HTMLAudioElement; } function isHTMLVideoElement(element) { return element instanceof HTMLVideoElement; } function isHTMLMediaElement(element) { return isHTMLAudioElement(element) || isHTMLVideoElement(element); } function isHTMLIFrameElement(element) { return element instanceof HTMLIFrameElement; } 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); 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; 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": this.#seeking(event, method, method === "seekForward"); break; case "volumeUp": case "volumeDown": const volumeSlider = this.el.querySelector("[data-media-volume-slider]"); if (volumeSlider) { volumeSlider.dispatchEvent( new KeyboardEvent("keydown", { key: method === "volumeUp" ? "Up" : "Down", shiftKey: event.shiftKey, trigger: event }) ); } else { c