@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
JavaScript
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