UNPKG

@ktt45678/vidstack

Version:

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

1,736 lines (1,718 loc) 138 kB
import { EventsTarget, DOMEvent, fscreen, ViewController, listenEvent, onDispose, signal, peek, isString, isNumber, State, tick, Component, functionThrottle, effect, untrack, functionDebounce, isArray, isKeyboardClick, isKeyboardEvent, waitIdlePeriod, deferredPromise, isUndefined, provideContext, setAttribute, animationFrameThrottle, uppercaseFirstChar, camelToKebabCase, setStyle, computed, prop, method, scoped, noop } from './vidstack-C6myozhB.js'; import { mediaContext, useMediaContext } from './vidstack-Cq-GdDcp.js'; import { canOrientScreen, IS_IPHONE, isAudioSrc, canPlayAudioType, isVideoSrc, canPlayVideoType, isHLSSupported, isHLSSrc, isDASHSupported, isDASHSrc, IS_CHROME, IS_IOS, canGoogleCastSrc, canChangeVolume } from './vidstack-CTW_LGt6.js'; import { TimeRange, getTimeRangesStart, getTimeRangesEnd, updateTimeIntervals } from './vidstack-Dy-iOvF5.js'; import { isTrackCaptionKind, TextTrackSymbol, TextTrack } from './vidstack-CFEqcMSQ.js'; import { ListSymbol } from './vidstack-BoSiLpaP.js'; import { QualitySymbol } from './vidstack-DH8xaM_3.js'; import { coerceToError } from './vidstack-C9vIqaYT.js'; import { preconnect, getRequestCredentials } from './vidstack-CVbXna2m.js'; import { isHTMLElement, isTouchPinchEvent, setAttributeIfEmpty } from './vidstack-BeyDmEgV.js'; import { clampNumber } from './vidstack-Dihypf8P.js'; import { FocusVisibleController } from './vidstack-D6_zYTXL.js'; var _a$1; const GROUPED_LOG = Symbol(0); _a$1 = GROUPED_LOG; const _GroupedLog = class _GroupedLog2 { constructor(logger, level, title, root, parent) { this.logger = logger; this.level = level; this.title = title; this.root = root; this.parent = parent; this[_a$1] = true; this.logs = []; } log(...data) { this.logs.push({ data }); return this; } labelledLog(label, ...data) { this.logs.push({ label, data }); return this; } groupStart(title) { return new _GroupedLog2(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); } }; let GroupedLog = _GroupedLog; var _a; class List extends EventsTarget { constructor() { super(...arguments); this.A = []; this[_a] = false; } get length() { return this.A.length; } get readonly() { return this[ListSymbol.Yc]; } /** * Returns the index of the first occurrence of the given item, or -1 if it is not present. */ indexOf(item) { return this.A.indexOf(item); } /** * Returns an item matching the given `id`, or `null` if not present. */ getById(id) { if (id === "") return null; return this.A.find((item) => item.id === id) ?? null; } /** * Transform list to an array. */ toArray() { return [...this.A]; } [(_a = ListSymbol.Yc, Symbol.iterator)]() { return this.A.values(); } /** @internal */ [ListSymbol.da](item, trigger) { const index = this.A.length; if (!("" + index in this)) { Object.defineProperty(this, index, { get() { return this.A[index]; } }); } if (this.A.includes(item)) return; this.A.push(item); this.dispatchEvent(new DOMEvent("add", { detail: item, trigger })); } /** @internal */ [ListSymbol.cc](item, trigger) { const index = this.A.indexOf(item); if (index >= 0) { this[ListSymbol.Hf]?.(item, trigger); this.A.splice(index, 1); this.dispatchEvent(new DOMEvent("remove", { detail: item, trigger })); } } /** @internal */ [ListSymbol.z](trigger) { for (const item of [...this.A]) this[ListSymbol.cc](item, trigger); this.A = []; this[ListSymbol.Od](false, trigger); this[ListSymbol.Gf]?.(); } /** @internal */ [ListSymbol.Od](readonly, trigger) { if (this[ListSymbol.Yc] === readonly) return; this[ListSymbol.Yc] = readonly; this.dispatchEvent(new DOMEvent("readonly-change", { detail: readonly, trigger })); } } const CAN_FULLSCREEN = fscreen.fullscreenEnabled; class FullscreenController extends ViewController { constructor() { super(...arguments); this.dc = false; this.Pd = false; } get active() { return this.Pd; } get supported() { return CAN_FULLSCREEN; } onConnect() { listenEvent(fscreen, "fullscreenchange", this.E.bind(this)); listenEvent(fscreen, "fullscreenerror", this.Q.bind(this)); onDispose(this.Fa.bind(this)); } async Fa() { if (CAN_FULLSCREEN) await this.exit(); } E(event) { const active = isFullscreen(this.el); if (active === this.Pd) return; if (!active) this.dc = false; this.Pd = active; this.dispatch("fullscreen-change", { detail: active, trigger: event }); } Q(event) { if (!this.dc) return; this.dispatch("fullscreen-error", { detail: null, trigger: event }); this.dc = false; } async enter() { try { this.dc = true; if (!this.el || isFullscreen(this.el)) return; assertFullscreenAPI(); return fscreen.requestFullscreen(this.el); } catch (error) { this.dc = 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 { constructor() { super(...arguments); this.la = signal(this.Jf()); this.Cb = 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.la(); } /** * 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.Cb(); } /** * Whether the viewport is in a portrait orientation. * * @signal */ get portrait() { return this.la().startsWith("portrait"); } /** * Whether the viewport is in a landscape orientation. * * @signal */ get landscape() { return this.la().startsWith("landscape"); } static { this.supported = canOrientScreen(); } /** * Whether the native Screen Orientation API is available. */ get supported() { return ScreenOrientationController.supported; } onConnect() { if (this.supported) { listenEvent(screen.orientation, "change", this.Kf.bind(this)); } else { const query = window.matchMedia("(orientation: landscape)"); query.onchange = this.Kf.bind(this); onDispose(() => query.onchange = null); } onDispose(this.Fa.bind(this)); } async Fa() { if (this.supported && this.Cb()) await this.unlock(); } Kf(event) { this.la.set(this.Jf()); this.dispatch("orientation-change", { detail: { orientation: peek(this.la), lock: this._c }, 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.Cb) || this._c === lockType) return; this.Lf(); await screen.orientation.lock(lockType); this.Cb.set(true); this._c = 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.Cb)) return; this.Lf(); this._c = void 0; await screen.orientation.unlock(); this.Cb.set(false); } Lf() { if (this.supported) return; throw Error( "[vidstack] no orientation API" ); } Jf() { 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 realDuration() { return this.providedDuration > 0 ? this.providedDuration : this.intrinsicDuration; }, get duration() { return this.clipEndTime > 0 ? this.clipEndTime - this.clipStartTime : Math.max(0, this.realDuration - this.clipStartTime); }, 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(0, start - this.clipStartTime); }, get bufferedEnd() { const end = getTimeRangesEnd(this.buffered) ?? 0; return Math.min(this.duration, Math.max(0, end - this.clipStartTime)); }, get seekableStart() { const start = getTimeRangesStart(this.seekable) ?? 0; return Math.max(0, start - this.clipStartTime); }, get seekableEnd() { const end = this.canPlay ? getTimeRangesEnd(this.seekable) ?? Infinity : 0; return this.clipEndTime > 0 ? Math.max(this.clipEndTime, Math.max(0, end - this.clipStartTime)) : end; }, get seekableWindow() { return Math.max(0, this.seekableEnd - this.seekableStart); }, // ~~ 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.seekableWindow) && (!this.live || /:dvr/.test(this.streamType) && this.seekableWindow >= this.minLiveDVRWindow); }, get live() { return this.streamType.includes("live") || !Number.isFinite(this.realDuration); }, 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, providedTitle: "", inferredTitle: "", providedLoop: false, userPrefersLoop: false, providedPoster: "", inferredPoster: "", inferredViewType: "unknown", providedViewType: "unknown", providedStreamType: "unknown", inferredStreamType: "unknown", liveSyncPosition: null, 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", "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(); } class MediaRemoteControl { constructor(_logger = void 0) { this.bc = _logger; this.G = null; this.f = null; this.Rd = -1; } /** * 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.G = 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.f) return this.f; (target ?? this.G)?.dispatchEvent( new DOMEvent("find-media-player", { detail: (player) => void (this.f = player), bubbles: true, composed: true }) ); return this.f; } /** * 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.f = 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.s("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.s("media-poster-start-loading", trigger); } /** * Dispatch a request to connect to AirPlay. * * @see {@link https://www.apple.com/au/airplay} */ requestAirPlay(trigger) { this.s("media-airplay-request", trigger); } /** * Dispatch a request to connect to Google Cast. * * @see {@link https://developers.google.com/cast/docs/overview} */ requestGoogleCast(trigger) { this.s("media-google-cast-request", trigger); } /** * Dispatch a request to begin/resume media playback. */ play(trigger) { this.s("media-play-request", trigger); } /** * Dispatch a request to pause media playback. */ pause(trigger) { this.s("media-pause-request", trigger); } /** * Dispatch a request to set the media volume to mute (0). */ mute(trigger) { this.s("media-mute-request", trigger); } /** * Dispatch a request to unmute the media volume and set it back to it's previous state. */ unmute(trigger) { this.s("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.s("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.s("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.s("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.s("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.s("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.s("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.s("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.s("media-seek-request", trigger, time); } seekToLiveEdge(trigger) { this.s("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.s("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.s("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.s("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.s("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.s("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.s("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.s("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.s("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.s("media-audio-gain-change-request", trigger, gain); } /** * Dispatch a request to resume idle tracking on controls. */ resumeControls(trigger) { this.s("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.s("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.Rd; 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.Rd = -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.Rd = 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.s("media-user-loop-change-request", trigger, prefersLoop); } s(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.f?.el && target instanceof Node && !this.f.el.contains(target); target = shouldUsePlayer ? this.G ?? this.getPlayer()?.el : target ?? this.G; if (this.f) { if (type === "media-play-request" && !this.f.state.canLoad) { target?.dispatchEvent(request); } else { this.f.canPlayQueue.k(type, () => target?.dispatchEvent(request)); } } else { target?.dispatchEvent(request); } } Va(method) { } } class LocalMediaStorage { constructor() { this.playerId = "vds-player"; this.mediaId = null; this.H = { volume: null, muted: null, audioGain: null, time: null, lang: null, captions: null, rate: null, quality: null }; this.saveTimeThrottled = functionThrottle(this.saveTime.bind(this), 1e3); } async getVolume() { return this.H.volume; } async setVolume(volume) { this.H.volume = volume; this.save(); } async getMuted() { return this.H.muted; } async setMuted(muted) { this.H.muted = muted; this.save(); } async getTime() { return this.H.time; } async setTime(time, ended) { const shouldClear = time < 0; this.H.time = !shouldClear ? time : null; if (shouldClear || ended) this.saveTime(); else this.saveTimeThrottled(); } async getLang() { return this.H.lang; } async setLang(lang) { this.H.lang = lang; this.save(); } async getCaptions() { return this.H.captions; } async setCaptions(enabled) { this.H.captions = enabled; this.save(); } async getPlaybackRate() { return this.H.rate; } async setPlaybackRate(rate) { this.H.rate = rate; this.save(); } async getAudioGain() { return this.H.audioGain; } async setAudioGain(gain) { this.H.audioGain = gain; this.save(); } async getVideoQuality() { return this.H.quality; } async setVideoQuality(quality) { this.H.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.H = { 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.H, time: void 0 }); localStorage.setItem(this.playerId, data); } saveTime() { if (!this.mediaId) return; const data = (this.H.time ?? 0).toString(); localStorage.setItem(this.mediaId, data); } } class NativeTextRenderer { constructor() { this.priority = 0; this.Uf = true; this.m = null; this.J = null; this.va = /* @__PURE__ */ new Set(); } canRender(_, video) { return !!video; } attach(video) { this.m = video; if (video) video.textTracks.onchange = this.E.bind(this); } addTrack(track) { this.va.add(track); this.ci(track); } removeTrack(track) { track[TextTrackSymbol._]?.remove?.(); track[TextTrackSymbol._] = null; this.va.delete(track); } changeTrack(track) { const current = track?.[TextTrackSymbol._]; if (current && current.track.mode !== "showing") { current.track.mode = "showing"; } this.J = track; } setDisplay(display) { this.Uf = display; this.E(); } detach() { if (this.m) this.m.textTracks.onchange = null; for (const track of this.va) this.removeTrack(track); this.va.clear(); this.m = null; this.J = null; } ci(track) { if (!this.m) return; const el = track[TextTrackSymbol._] ??= this.di(track); if (isHTMLElement(el)) { this.m.append(el); el.track.mode = el.default ? "showing" : "disabled"; } } di(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.Vf(track, el.track); } return el; } Vf(track, native) { if (track.src && track.type === "vtt" || native.cues?.length) return; for (const cue of track.cues) native.addCue(cue); } E(event) { for (const track of this.va) { const native = track[TextTrackSymbol._]; if (!native) continue; if (!this.Uf) { native.track.mode = native.managed ? "hidden" : "disabled"; continue; } const isShowing = native.track.mode === "showing"; if (isShowing) this.Vf(track, native.track); track.setMode(isShowing ? "showing" : "disabled", event); } } } class TextRenderers { constructor(_media) { this.a = _media; this.m = null; this.bd = []; this.Wf = false; this.wa = null; this.jb = null; const textTracks = _media.textTracks; this.Wd = textTracks; effect(this.Xd.bind(this)); onDispose(this.ei.bind(this)); listenEvent(textTracks, "add", this.Yd.bind(this)); listenEvent(textTracks, "remove", this.fi.bind(this)); listenEvent(textTracks, "mode-change", this.Ha.bind(this)); } Xd() { const { nativeControls } = this.a.$state; this.Wf = nativeControls(); this.Ha(); } add(renderer) { this.bd.push(renderer); untrack(this.Ha.bind(this)); } remove(renderer) { renderer.detach(); this.bd.splice(this.bd.indexOf(renderer), 1); untrack(this.Ha.bind(this)); } resetCustomRenderer() { if (!this.jb) return; this.jb.changeTrack(null); } /** @internal */ Xf(video) { requestAnimationFrame(() => { this.m = video; if (video) { this.wa = new NativeTextRenderer(); this.wa.attach(video); for (const track of this.Wd) this.Yf(track); } this.Ha(); }); } Yf(track) { if (!isTrackCaptionKind(track)) return; this.wa?.addTrack(track); } gi(track) { if (!isTrackCaptionKind(track)) return; this.wa?.removeTrack(track); } Yd(event) { this.Yf(event.detail); } fi(event) { this.gi(event.detail); } Ha() { const currentTrack = this.Wd.selected; if (currentTrack && currentTrack.subtitleLoader && !currentTrack.contentLoaded) { Promise.resolve(currentTrack.subtitleLoader(currentTrack)).then((content) => { if (content) currentTrack.content = content; currentTrack.contentLoaded = true; this.Wn(currentTrack); }); return; } this.Wn(currentTrack); } Wn(currentTrack) { if (this.m && (this.Wf || currentTrack?.[TextTrackSymbol.Mf])) { this.jb?.changeTrack(null); this.wa?.setDisplay(true); this.wa?.changeTrack(currentTrack); return; } this.wa?.setDisplay(false); this.wa?.changeTrack(null); this.jb?.changeTrack(null); if (!currentTrack) { return; } const customRenderer = this.bd.sort((a, b) => a.priority - b.priority).find((renderer) => renderer.canRender(currentTrack, this.m)); if (this.jb !== customRenderer) { this.jb?.detach(); if (this.m) customRenderer?.attach(this.m); this.jb = customRenderer ?? null; } if (this.m) customRenderer?.changeTrack(currentTrack, this.m); } ei() { this.wa?.detach(); this.wa = null; this.jb?.detach(); this.jb = null; } } class TextTrackList extends List { constructor() { super(); this.Z = false; this.kb = {}; this.lb = null; this.mb = null; this.bg = functionDebounce(async () => { if (!this.Z) return; if (!this.mb && this.lb) { this.mb = await this.lb.getLang(); } const showCaptions = await this.lb?.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.mb ? tracks.find((track2) => track2.language === this.mb) : null; const defaultTrack = isArray(kind) ? this.kb[kind.find((kind2) => this.kb[kind2]) || ""] : this.kb[kind]; const track = preferredTrack ?? defaultTrack, isCaptionsKind = track && isTrackCaptionKind(track); if (track && (!isCaptionsKind || showCaptions !== false)) { track.mode = "showing"; if (isCaptionsKind) this.cg(track); } } }, 300); this.Zd = null; this.ag = this.hi.bind(this); } get selected() { const track = this.A.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.mb; } set preferredLang(lang) { this.mb = lang; this.$f(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.kb[kind] && init.default) delete init.default; track.addEventListener("mode-change", this.ag); this[ListSymbol.da](track, trigger); track[TextTrackSymbol.Db] = this[TextTrackSymbol.Db]; if (this.Z) track[TextTrackSymbol.Z](); if (init.default) this.kb[kind] = track; this.bg(); return this; } remove(track, trigger) { this.Zd = track; if (!this.A.includes(track)) return; if (track === this.kb[track.kind]) delete this.kb[track.kind]; track.mode = "disabled"; track[TextTrackSymbol.hb] = null; track.removeEventListener("mode-change", this.ag); this[ListSymbol.cc](track, trigger); this.Zd = null; return this; } clear(trigger) { for (const track of [...this.A]) { this.remove(track, trigger); } return this; } getByKind(kind) { const kinds = Array.isArray(kind) ? kind : [kind]; return this.A.filter((track) => kinds.includes(track.kind)); } /** @internal */ [(TextTrackSymbol.Z)]() { if (this.Z) return; for (const track of this.A) track[TextTrackSymbol.Z](); this.Z = true; this.bg(); } hi(event) { const track = event.detail; if (this.lb && isTrackCaptionKind(track) && track !== this.Zd) { this.cg(track); } if (track.mode === "showing") { const kinds = isTrackCaptionKind(track) ? ["captions", "subtitles"] : [track.kind]; for (const t of this.A) { if (t.mode === "showing" && t != track && kinds.includes(t.kind)) { t.mode = "disabled"; } } } this.dispatchEvent( new DOMEvent("mode-change", { detail: event.detail, trigger: event }) ); } cg(track) { if (track.mode !== "disabled") { this.$f(track.language); } this.lb?.setCaptions?.(track.mode === "showing"); } $f(lang) { this.lb?.setLang?.(this.mb = lang); } setStorage(storage) { this.lb = storage; } } const SELECTED = Symbol(0); class SelectList extends List { get selected() { return this.A.find((item) => item.selected) ?? null; } get selectedIndex() { return this.A.findIndex((item) => item.selected); } /** @internal */ [ListSymbol.Hf](item, trigger) { this[ListSymbol.ea](item, false, trigger); } /** @internal */ [ListSymbol.da](item, trigger) { item[SELECTED] = false; Object.defineProperty(item, "selected", { get() { return this[SELECTED]; }, set: (selected) => { if (this.readonly) return; this[ListSymbol.If]?.(); this[ListSymbol.ea](item, selected); } }); super[ListSymbol.da](item, trigger); } /** @internal */ [ListSymbol.ea](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 VideoQualityList extends SelectList { constructor() { super(...arguments); this.cd = false; this.switch = "current"; } /** * Whether automatic quality selection is enabled. */ get auto() { return this.cd || this.readonly; } /** @internal */ [(ListSymbol.If)]() { this[QualitySymbol.Wa](false); } /** @internal */ [ListSymbol.Gf](trigger) { this[QualitySymbol.Ia] = void 0; this[QualitySymbol.Wa](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.cd || !this[QualitySymbol.Ia]) return; this[QualitySymbol.Ia]?.(trigger); this[QualitySymbol.Wa](true, trigger); } getBySrc(src) { return this.A.find((quality) => quality.src === src); } /** @internal */ [QualitySymbol.Wa](auto, trigger) { if (this.cd === auto) return; this.cd = 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 { constructor(_media) { super(); this.a = _media; this.Ib = null; } onConnect() { effect(this.ii.bind(this)); } ii() { 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("[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; listenEvent(target, "keyup", this.hc.bind(this)); listenEvent(target, "keydown", this.ic.bind(this)); listenEvent(target, "keydown", this.ji.bind(this), { capture: true }); }); } hc(event) { const focusedEl = document.activeElement; if (!event.key || !this.$state.canSeek() || focusedEl?.matches(IGNORE_SELECTORS)) { return; } let { method, value } = this._d(event); if (!isString(value) && !isArray(value)) { value?.onKeyUp?.({ event, player: this.a.player, remote: this.a.remote }); value?.callback?.(event, this.a.remote); return; } if (method?.startsWith("seek")) { event.preventDefault(); event.stopPropagation(); if (this.Ib) { this.dg(event, method === "seekForward"); this.Ib = null; } else { this.a.remote.seek(this.dd, event); this.dd = 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 }) ); } } ic(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._d(event), isNumberPress = !event.metaKey && /^[0-9]$/.test(event.key); if (!isString(value) && !isArray(value) && !isNumberPress) { value?.onKeyDown?.({ event, player: this.a.player, remote: this.a.remote }); value?.callback?.(event, this.a.remote); return; } if (!method && isNumberPress) { event.preventDefault(); event.stopPropagation(); this.a.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.Ja(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 { const value2 = event.shiftKey ? 0.1 : 0.05; this.a.remote.changeVolume( this.$state.volume() + (method === "volumeUp" ? +value2 : -value2), event ); } break; case "toggleFullscreen": this.a.remote.toggleFullscreen("prefer-media", event); break; case "speedUp": case "slowDown": const playbackRate = this.$state.playbackRate(); this.a.remote.changePlaybackRate( Math.max(0.25, Math.min(2, playbackRate + (method === "speedUp" ? 0.25 : -0.25))), event ); break; default: this.a.remote[method]?.(event); } this.$state.lastKeyboardAction.set({ action: method, event }); } ji(event) { if (isHTMLMediaElement(event.target) && this._d(event).method) { event.preventDefault(); } } _d(event) { const keyShortcuts = { ...this.$props.keyShortcuts(), ...this.a.ariaKeys }; const method = Object.keys(keyShortcuts).find((method2) => { const value = keyShortcuts[method2], keys = isArray(value) ? value.join(" ") : isString(value) ? value : value?.keys; const combinations = (isArray(keys) ? keys : keys?.split(" "))?.map( (key) => replaceSymbolKeys(key).replace(/Control/g, "Ctrl").split("+") ); return combinations?.some((combo) => { const modifierKeys = new Set(combo.filter((key) => MODIFIER_KEYS.has(key))); for (const modKey of MODIFIER_KEYS) { const modKeyProp = modKey.toLowerCase() + "Key"; if (!modifierKeys.has(modKey) && event[modKeyProp]) { return false; } } return combo.every((key) => { return MODIFIER_KEYS.has(key) ? event[key.toLowerCase() + "Key"] : event.key === key.replace("Space", " "); }); }); }); return { method, value: method ? keyShortcuts[method] : null }; } ki(event, type) { const seekBy = event.shiftKey ? 10 : 5; return this.dd = Math.max( 0, Math.min( (this.dd ?? this.$state.currentTime()) + (type === "seekForward" ? +seekBy : -seekBy), this.$state.duration() ) ); } dg(event, forward) { this.Ib?.dispatchEvent( new KeyboardEvent(event.type, { key: !forward ? "Left" : "Right", shiftKey: event.shiftKey, trigger: event }) ); } Ja(event, type, forward) { if (!this.$state.canSeek()) return; if (!this.Ib) { this.Ib = this.el.querySelector("[data-media-time-slider]"); } if (this.Ib) { this.dg(event, forward); } else { this.a.remote.seeking(this.ki(event, type), event); } } } const SYMBOL_KEY_MAP = ["!", "@", "#", "$", "%", "^", "&", "*", "(", ")"]; function replaceSymbolKeys(key) { return key.replace(/Shift\+(\d)/g, (_, num) => SYMBOL_KEY_MAP[num - 1]); } class MediaControls extends MediaPlayerController { constructor() { super(...arguments); this.Sd = -2; this.Gb = false; this.Sf = signal