vidstack
Version:
Build awesome media experiences on the web.
1,866 lines (1,839 loc) • 93.1 kB
JavaScript
import { ComponentController, defineProp, Component, defineElement } from 'maverick.js/element';
import { signal, peek, effect, getScope, scoped, createContext, useContext, StoreFactory, tick, onDispose, computed } from 'maverick.js';
import { EventsTarget, DOMEvent, listenEvent, isUndefined, isFunction, waitTimeout, isKeyboardClick, setAttribute, isArray, isKeyboardEvent, appendTriggerEvent, isNull, deferredPromise, isString, isNumber, noop, animationFrameThrottle, setStyle } from 'maverick.js/std';
import { i as isHTMLMediaElement } from './providers/type-check.js';
import { A as AudioProviderLoader } from './providers/audio/loader.js';
import { H as HLSProviderLoader } from './providers/hls/loader.js';
import { V as VideoProviderLoader } from './providers/video/loader.js';
const LIST_ADD = Symbol(0);
const LIST_REMOVE = Symbol(0);
const LIST_RESET = Symbol(0);
const LIST_SELECT = Symbol(0);
const LIST_READONLY = Symbol(0);
const LIST_SET_READONLY = Symbol(0);
const LIST_ON_RESET = Symbol(0);
const LIST_ON_REMOVE = Symbol(0);
const LIST_ON_USER_SELECT = Symbol(0);
class List extends EventsTarget {
a = [];
/* @internal */
[LIST_READONLY] = false;
get length() {
return this.a.length;
}
get readonly() {
return this[LIST_READONLY];
}
/**
* Transform list to an array.
*/
toArray() {
return [...this.a];
}
[Symbol.iterator]() {
return this.a.values();
}
/* @internal */
[LIST_ADD](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 */
[LIST_REMOVE](item, trigger) {
const index = this.a.indexOf(item);
if (index >= 0) {
this[LIST_ON_REMOVE]?.(item, trigger);
this.a.splice(index, 1);
this.dispatchEvent(new DOMEvent("remove", { detail: item, trigger }));
}
}
/* @internal */
[LIST_RESET](trigger) {
for (const item of [...this.a])
this[LIST_REMOVE](item, trigger);
this.a = [];
this[LIST_SET_READONLY](false, trigger);
this[LIST_ON_RESET]?.();
}
/* @internal */
[LIST_SET_READONLY](readonly, trigger) {
if (this[LIST_READONLY] === readonly)
return;
this[LIST_READONLY] = readonly;
this.dispatchEvent(new DOMEvent("readonly-change", { detail: readonly, trigger }));
}
}
var key = {
fullscreenEnabled: 0,
fullscreenElement: 1,
requestFullscreen: 2,
exitFullscreen: 3,
fullscreenchange: 4,
fullscreenerror: 5,
fullscreen: 6
};
var webkit = [
"webkitFullscreenEnabled",
"webkitFullscreenElement",
"webkitRequestFullscreen",
"webkitExitFullscreen",
"webkitfullscreenchange",
"webkitfullscreenerror",
"-webkit-full-screen"
];
var moz = [
"mozFullScreenEnabled",
"mozFullScreenElement",
"mozRequestFullScreen",
"mozCancelFullScreen",
"mozfullscreenchange",
"mozfullscreenerror",
"-moz-full-screen"
];
var ms = [
"msFullscreenEnabled",
"msFullscreenElement",
"msRequestFullscreen",
"msExitFullscreen",
"MSFullscreenChange",
"MSFullscreenError",
"-ms-fullscreen"
];
var document$1 = typeof window !== "undefined" && typeof window.document !== "undefined" ? window.document : {};
var vendor = "fullscreenEnabled" in document$1 && Object.keys(key) || webkit[0] in document$1 && webkit || moz[0] in document$1 && moz || ms[0] in document$1 && ms || [];
var fscreen = {
requestFullscreen: function(element) {
return element[vendor[key.requestFullscreen]]();
},
requestFullscreenFunction: function(element) {
return element[vendor[key.requestFullscreen]];
},
get exitFullscreen() {
return document$1[vendor[key.exitFullscreen]].bind(document$1);
},
get fullscreenPseudoClass() {
return ":" + vendor[key.fullscreen];
},
addEventListener: function(type, handler, options) {
return document$1.addEventListener(vendor[key[type]], handler, options);
},
removeEventListener: function(type, handler, options) {
return document$1.removeEventListener(vendor[key[type]], handler, options);
},
get fullscreenEnabled() {
return Boolean(document$1[vendor[key.fullscreenEnabled]]);
},
set fullscreenEnabled(val) {
},
get fullscreenElement() {
return document$1[vendor[key.fullscreenElement]];
},
set fullscreenElement(val) {
},
get onfullscreenchange() {
return document$1[("on" + vendor[key.fullscreenchange]).toLowerCase()];
},
set onfullscreenchange(handler) {
return document$1[("on" + vendor[key.fullscreenchange]).toLowerCase()] = handler;
},
get onfullscreenerror() {
return document$1[("on" + vendor[key.fullscreenerror]).toLowerCase()];
},
set onfullscreenerror(handler) {
return document$1[("on" + vendor[key.fullscreenerror]).toLowerCase()] = handler;
}
};
var fscreen$1 = fscreen;
const CAN_FULLSCREEN = fscreen$1.fullscreenEnabled;
class FullscreenController extends ComponentController {
/**
* 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.
*/
b = false;
c = false;
get active() {
return this.c;
}
get supported() {
return CAN_FULLSCREEN;
}
onConnect() {
listenEvent(fscreen$1, "fullscreenchange", this.d.bind(this));
listenEvent(fscreen$1, "fullscreenerror", this.e.bind(this));
}
async onDisconnect() {
if (CAN_FULLSCREEN)
await this.exit();
}
d(event) {
const active = isFullscreen(this.el);
if (active === this.c)
return;
if (!active)
this.b = false;
this.c = active;
this.dispatch("fullscreen-change", { detail: active, trigger: event });
}
e(event) {
if (!this.b)
return;
this.dispatch("fullscreen-error", { detail: null, trigger: event });
this.b = false;
}
async enter() {
try {
this.b = true;
if (!this.el || isFullscreen(this.el))
return;
assertFullscreenAPI();
return fscreen$1.requestFullscreen(this.el);
} catch (error) {
this.b = false;
throw error;
}
}
async exit() {
if (!this.el || !isFullscreen(this.el))
return;
assertFullscreenAPI();
return fscreen$1.exitFullscreen();
}
}
function canFullscreen() {
return CAN_FULLSCREEN;
}
function isFullscreen(host) {
if (fscreen$1.fullscreenElement === host)
return true;
try {
return host.matches(
// @ts-expect-error - `fullscreenPseudoClass` is missing from `@types/fscreen`.
fscreen$1.fullscreenPseudoClass
);
} catch (error) {
return false;
}
}
function assertFullscreenAPI() {
if (CAN_FULLSCREEN)
return;
throw Error(
"[vidstack] no fullscreen API"
);
}
const UA = navigator?.userAgent.toLowerCase();
const IS_IOS = /iphone|ipad|ipod|ios|crios|fxios/i.test(UA);
const IS_IPHONE = /(iphone|ipod)/gi.test(navigator?.platform);
const IS_CHROME = !!window.chrome;
const IS_SAFARI = !!window.safari || IS_IOS;
function canOrientScreen() {
return !isUndefined(screen.orientation) && isFunction(screen.orientation.lock) && isFunction(screen.orientation.unlock);
}
function canPlayHLSNatively(video) {
if (!video)
video = document.createElement("video");
return video.canPlayType("application/vnd.apple.mpegurl").length > 0;
}
function canUsePictureInPicture(video) {
return !!document.pictureInPictureEnabled && !video.disablePictureInPicture;
}
function canUseVideoPresentation(video) {
return isFunction(video.webkitSupportsPresentationMode) && isFunction(video.webkitSetPresentationMode);
}
async function canChangeVolume() {
const video = document.createElement("video");
video.volume = 0.5;
await waitTimeout(0);
return video.volume === 0.5;
}
function getMediaSource() {
return window?.MediaSource ?? window?.WebKitMediaSource;
}
function getSourceBuffer() {
return window?.SourceBuffer ?? window?.WebKitSourceBuffer;
}
function isHLSSupported() {
const MediaSource = getMediaSource();
if (isUndefined(MediaSource))
return false;
const isTypeSupported = MediaSource && isFunction(MediaSource.isTypeSupported) && MediaSource.isTypeSupported('video/mp4; codecs="avc1.42E01E,mp4a.40.2"');
const SourceBuffer = getSourceBuffer();
const isSourceBufferValid = isUndefined(SourceBuffer) || !isUndefined(SourceBuffer.prototype) && isFunction(SourceBuffer.prototype.appendBuffer) && isFunction(SourceBuffer.prototype.remove);
return !!isTypeSupported && !!isSourceBufferValid;
}
const CAN_USE_SCREEN_ORIENTATION_API = canOrientScreen();
class ScreenOrientationController extends ComponentController {
g = signal(getScreenOrientation());
f = signal(false);
h;
/**
* 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.g();
}
/**
* 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.f();
}
/**
* Whether the viewport is in a portrait orientation.
*
* @signal
*/
get portrait() {
return this.g().startsWith("portrait");
}
/**
* Whether the viewport is in a landscape orientation.
*
* @signal
*/
get landscape() {
return this.g().startsWith("landscape");
}
/**
* Whether the native Screen Orientation API is available.
*/
get supported() {
return CAN_USE_SCREEN_ORIENTATION_API;
}
onConnect() {
if (CAN_USE_SCREEN_ORIENTATION_API) {
listenEvent(screen.orientation, "change", this.i.bind(this));
} else {
const query = window.matchMedia("(orientation: landscape)");
query.onchange = this.i.bind(this);
return () => query.onchange = null;
}
}
async onDisconnect() {
if (CAN_USE_SCREEN_ORIENTATION_API && this.f())
await this.unlock();
}
i(event) {
this.g.set(getScreenOrientation());
this.dispatch("orientation-change", {
detail: {
orientation: peek(this.g),
lock: this.h
},
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.f) || this.h === lockType)
return;
assertScreenOrientationAPI();
await screen.orientation.lock(lockType);
this.f.set(true);
this.h = 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.f))
return;
assertScreenOrientationAPI();
this.h = void 0;
await screen.orientation.unlock();
this.f.set(false);
}
}
function assertScreenOrientationAPI() {
if (CAN_USE_SCREEN_ORIENTATION_API)
return;
throw Error(
"[vidstack] no orientation API"
);
}
function getScreenOrientation() {
if (CAN_USE_SCREEN_ORIENTATION_API)
return window.screen.orientation.type;
return window.innerWidth >= window.innerHeight ? "landscape-primary" : "portrait-primary";
}
function setAttributeIfEmpty(target, name, value) {
if (!target.hasAttribute(name))
target.setAttribute(name, value);
}
function setARIALabel(target, label) {
if (target.hasAttribute("aria-label") || target.hasAttribute("aria-describedby"))
return;
function updateAriaDescription() {
setAttribute(target, "aria-label", label());
}
effect(updateAriaDescription);
}
function isElementParent(owner, node, test) {
while (node) {
if (node === owner) {
return true;
} else if (node.localName === owner.localName || test?.(node)) {
break;
} else {
node = node.parentElement;
}
}
return false;
}
function onPress(target, handler) {
listenEvent(target, "pointerup", (event) => {
if (event.button === 0)
handler(event);
});
listenEvent(target, "keydown", (event) => {
if (isKeyboardClick(event))
handler(event);
});
}
function scopedRaf(callback) {
const scope = getScope();
requestAnimationFrame(() => scoped(callback, scope));
}
const mediaContext = createContext();
function useMedia() {
return useContext(mediaContext);
}
const MEDIA_ATTRIBUTES = [
"autoplay",
"autoplayError",
"canFullscreen",
"canPictureInPicture",
"canLoad",
"canPlay",
"canSeek",
"ended",
"error",
"fullscreen",
"loop",
"live",
"liveEdge",
"mediaType",
"muted",
"paused",
"pictureInPicture",
"playing",
"playsinline",
"seeking",
"started",
"streamType",
"userIdle",
"viewType",
"waiting"
];
const MEDIA_KEY_SHORTCUTS = {
togglePaused: "k Space",
toggleMuted: "m",
toggleFullscreen: "f",
togglePictureInPicture: "i",
toggleCaptions: "c",
seekBackward: "ArrowLeft",
seekForward: "ArrowRight",
volumeUp: "ArrowUp",
volumeDown: "ArrowDown"
};
const MODIFIER_KEYS = /* @__PURE__ */ new Set(["Shift", "Alt", "Meta", "Control"]), BUTTON_SELECTORS = 'button, [role="button"]', IGNORE_SELECTORS = 'input, textarea, select, [contenteditable], [role^="menuitem"]';
class MediaKeyboardController extends ComponentController {
constructor(instance, _media) {
super(instance);
this.j = _media;
}
onConnect() {
effect(this.Xa.bind(this));
}
Xa() {
const { keyDisabled, keyTarget } = this.$props;
if (keyDisabled())
return;
const target = keyTarget() === "player" ? this.el : document, $active = signal(false);
if (target === this.el) {
this.listen("focusin", () => $active.set(true));
this.listen("focusout", (event) => {
if (!this.el.contains(event.target))
$active.set(false);
});
} else {
if (!peek($active))
$active.set(document.querySelector("media-player") === this.el);
listenEvent(document, "focusin", (event) => {
const activePlayer = event.composedPath().find((el) => el instanceof Element && el.localName === "media-player");
if (activePlayer !== void 0)
$active.set(this.el === activePlayer);
});
}
effect(() => {
if (!$active())
return;
listenEvent(target, "keyup", this.Ya.bind(this));
listenEvent(target, "keydown", this.Za.bind(this));
listenEvent(target, "keydown", this._a.bind(this), { capture: true });
});
}
Ya(event) {
const focused = document.activeElement, sliderFocused = focused?.hasAttribute("data-media-slider");
if (!event.key || !this.$store.canSeek() || sliderFocused || focused?.matches(IGNORE_SELECTORS)) {
return;
}
const method = this.Va(event);
if (method?.startsWith("seek")) {
event.preventDefault();
event.stopPropagation();
if (this.Ta) {
this.Wa(event);
this.Ta = null;
} else {
this.j.remote.seek(this.Ua, event);
this.Ua = void 0;
}
}
if (method?.startsWith("volume")) {
const volumeSlider = this.el.querySelector("media-volume-slider");
volumeSlider?.dispatchEvent(new DOMEvent("keyup", { trigger: event }));
}
}
Za(event) {
if (!event.key || MODIFIER_KEYS.has(event.key))
return;
const focused = document.activeElement;
if (focused?.matches(IGNORE_SELECTORS) || isKeyboardClick(event) && focused?.matches(BUTTON_SELECTORS)) {
return;
}
const sliderFocused = focused?.hasAttribute("data-media-slider"), method = this.Va(event);
if (!method && !event.metaKey && /[0-9]/.test(event.key) && !sliderFocused) {
event.preventDefault();
event.stopPropagation();
this.j.remote.seek(this.$store.duration() / 10 * Number(event.key), event);
return;
}
if (!method || /volume|seek/.test(method) && sliderFocused)
return;
event.preventDefault();
event.stopPropagation();
switch (method) {
case "seekForward":
case "seekBackward":
this.$a(event, method);
break;
case "volumeUp":
case "volumeDown":
const volumeSlider = this.el.querySelector("media-volume-slider");
if (volumeSlider) {
volumeSlider.dispatchEvent(new DOMEvent("keydown", { trigger: event }));
} else {
const value = event.shiftKey ? 0.1 : 0.05;
this.j.remote.changeVolume(
this.$store.volume() + (method === "volumeUp" ? +value : -value),
event
);
}
break;
case "toggleFullscreen":
this.j.remote.toggleFullscreen("prefer-media", event);
break;
default:
this.j.remote[method]?.(event);
}
}
_a(event) {
if (isHTMLMediaElement(event.target) && this.Va(event)) {
event.preventDefault();
}
}
Va(event) {
const keyShortcuts = {
...this.$props.keyShortcuts(),
...this.j.ariaKeys
};
return Object.keys(keyShortcuts).find(
(method) => keyShortcuts[method].split(" ").some(
(keys) => replaceSymbolKeys(keys).replace(/Control/g, "Ctrl").split("+").every(
(key) => MODIFIER_KEYS.has(key) ? event[key.toLowerCase() + "Key"] : event.key === key.replace("Space", " ")
)
)
);
}
Ua;
ab(event, type) {
const seekBy = event.shiftKey ? 10 : 5;
return this.Ua = Math.max(
0,
Math.min(
(this.Ua ?? this.$store.currentTime()) + (type === "seekForward" ? +seekBy : -seekBy),
this.$store.duration()
)
);
}
Ta = null;
Wa(event) {
this.Ta?.dispatchEvent(new DOMEvent(event.type, { trigger: event }));
}
$a(event, type) {
if (!this.$store.canSeek())
return;
if (!this.Ta)
this.Ta = this.el.querySelector("media-time-slider");
if (this.Ta) {
this.Wa(event);
} else {
this.j.remote.seeking(this.ab(event, type), event);
}
}
}
const SYMBOL_KEY_MAP = ["!", "@", "#", "$", "%", "^", "&", "*", "(", ")"];
function replaceSymbolKeys(key) {
return key.replace(/Shift\+(\d)/g, (_, num) => SYMBOL_KEY_MAP[num - 1]);
}
const mediaPlayerProps = {
autoplay: false,
aspectRatio: defineProp({
value: null,
type: {
from(value) {
if (!value)
return null;
if (!value.includes("/"))
return +value;
const [width, height] = value.split("/").map(Number);
return +(width / height).toFixed(4);
}
}
}),
controls: false,
currentTime: 0,
crossorigin: null,
fullscreenOrientation: "landscape",
load: "visible",
logLevel: "silent",
loop: false,
muted: false,
paused: true,
playsinline: false,
playbackRate: 1,
poster: "",
preload: "metadata",
preferNativeHLS: defineProp({
value: false,
attribute: "prefer-native-hls"
}),
src: "",
userIdleDelay: 2e3,
viewType: "unknown",
streamType: "unknown",
volume: 1,
liveEdgeTolerance: 10,
minLiveDVRWindow: 60,
keyDisabled: false,
keyTarget: "player",
keyShortcuts: MEDIA_KEY_SHORTCUTS,
title: "",
thumbnails: null,
textTracks: defineProp({
value: [],
attribute: false
}),
smallBreakpointX: 600,
largeBreakpointX: 980,
smallBreakpointY: 380,
largeBreakpointY: 600
};
class TimeRange {
W;
get length() {
return this.W.length;
}
constructor(start, end) {
if (isArray(start)) {
this.W = start;
} else if (!isUndefined(start) && !isUndefined(end)) {
this.W = [[start, end]];
} else {
this.W = [];
}
}
start(index) {
return this.W[index][0] ?? Infinity;
}
end(index) {
return this.W[index][1] ?? Infinity;
}
}
function getTimeRangesStart(range) {
if (!range.length)
return null;
let min = range.start(0);
for (let i = 1; i < range.length; i++) {
const value = range.start(i);
if (value < min)
min = value;
}
return min;
}
function getTimeRangesEnd(range) {
if (!range.length)
return null;
let max = range.end(0);
for (let i = 1; i < range.length; i++) {
const value = range.end(i);
if (value > max)
max = value;
}
return max;
}
const MediaStoreFactory = new StoreFactory({
audioTracks: [],
audioTrack: null,
autoplay: false,
autoplayError: void 0,
buffered: new TimeRange(),
duration: 0,
canLoad: false,
canFullscreen: false,
canPictureInPicture: false,
canPlay: false,
controls: false,
crossorigin: null,
poster: "",
currentTime: 0,
ended: false,
error: void 0,
fullscreen: false,
loop: false,
logLevel: "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,
seekable: new TimeRange(),
seeking: false,
source: { src: "", type: "" },
sources: [],
started: false,
title: "",
textTracks: [],
textTrack: null,
thumbnails: null,
thumbnailCues: [],
volume: 1,
waiting: false,
get viewType() {
return this.providedViewType !== "unknown" ? this.providedViewType : this.mediaType;
},
get streamType() {
return this.providedStreamType !== "unknown" ? this.providedStreamType : this.inferredStreamType;
},
get currentSrc() {
return this.source;
},
get bufferedStart() {
return getTimeRangesStart(this.buffered) ?? 0;
},
get bufferedEnd() {
return getTimeRangesEnd(this.buffered) ?? 0;
},
get seekableStart() {
return getTimeRangesStart(this.seekable) ?? 0;
},
get seekableEnd() {
return this.canPlay ? getTimeRangesEnd(this.seekable) ?? Infinity : 0;
},
get seekableWindow() {
return Math.max(0, this.seekableEnd - this.seekableStart);
},
// ~~ responsive design ~~
touchPointer: false,
orientation: "landscape",
mediaWidth: 0,
mediaHeight: 0,
breakpointX: "sm",
breakpointY: "sm",
// ~~ user props ~~
userIdle: false,
userBehindLiveEdge: false,
// ~~ live props ~~
liveEdgeTolerance: 10,
minLiveDVRWindow: 60,
get canSeek() {
return /unknown|on-demand|:dvr/.test(this.streamType) && Number.isFinite(this.seekableWindow) && (!this.live || /:dvr/.test(this.streamType) && this.seekableWindow >= this.minLiveDVRWindow);
},
get live() {
return this.streamType.includes("live") || !Number.isFinite(this.duration);
},
get liveEdgeStart() {
return this.live && Number.isFinite(this.seekableEnd) ? Math.max(0, (this.liveSyncPosition ?? this.seekableEnd) - this.liveEdgeTolerance) : 0;
},
get liveEdge() {
return this.live && (!this.canSeek || !this.userBehindLiveEdge && this.currentTime >= this.liveEdgeStart);
},
get liveEdgeWindow() {
return this.live && Number.isFinite(this.seekableEnd) ? this.seekableEnd - this.liveEdgeStart : 0;
},
// ~~ internal props ~~
autoplaying: false,
providedViewType: "unknown",
providedStreamType: "unknown",
inferredStreamType: "unknown",
liveSyncPosition: null
});
const DO_NOT_RESET_ON_SRC_CHANGE = /* @__PURE__ */ new Set([
"autoplay",
"breakpointX",
"breakpointY",
"canFullscreen",
"canLoad",
"canPictureInPicture",
"controls",
"fullscreen",
"logLevel",
"loop",
"mediaHeight",
"mediaWidth",
"muted",
"orientation",
"pictureInPicture",
"playsinline",
"poster",
"preload",
"providedStreamType",
"providedViewType",
"source",
"sources",
"textTrack",
"textTracks",
"thumbnailCues",
"thumbnails",
"title",
"touchPointer",
"volume"
]);
function softResetMediaStore($media) {
MediaStoreFactory.reset($media, (prop) => !DO_NOT_RESET_ON_SRC_CHANGE.has(prop));
tick();
}
const SELECTED = Symbol(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 */
[LIST_ON_REMOVE](item, trigger) {
this[LIST_SELECT](item, false, trigger);
}
/* @internal */
[LIST_ADD](item, trigger) {
item[SELECTED] = false;
Object.defineProperty(item, "selected", {
get() {
return this[SELECTED];
},
set: (selected) => {
if (this.readonly)
return;
this[LIST_ON_USER_SELECT]?.();
this[LIST_SELECT](item, selected);
}
});
super[LIST_ADD](item, trigger);
}
/* @internal */
[LIST_SELECT](item, selected, trigger) {
if (selected === item[SELECTED])
return;
const prev = this.selected;
item[SELECTED] = selected;
const changed = !selected ? prev === item : prev !== item;
if (changed) {
if (prev)
prev[SELECTED] = false;
this.dispatchEvent(
new DOMEvent("change", {
detail: { prev, current: this.selected },
trigger
})
);
}
}
}
const SET_AUTO_QUALITY = Symbol(0);
const ENABLE_AUTO_QUALITY = Symbol(0);
class VideoQualityList extends SelectList {
Sa = false;
/**
* Configures quality switching:
*
* - `current`: Trigger an immediate quality level switch. This will abort the current fragment
* request if any, flush the whole buffer, and fetch fragment matching with current position
* and requested quality level.
*
* - `next`: Trigger a quality level switch for next fragment. This could eventually flush
* already buffered next fragment.
*
* - `load`: Set quality level for next loaded fragment.
*
* @see {@link https://vidstack.io/docs/player/core-concepts/quality#switch}
* @see {@link https://github.com/video-dev/hls.js/blob/master/docs/API.md#quality-switch-control-api}
*/
switch = "current";
/**
* Whether automatic quality selection is enabled.
*/
get auto() {
return this.Sa || this.readonly;
}
/* @internal */
[ENABLE_AUTO_QUALITY];
/* @internal */
[LIST_ON_USER_SELECT]() {
this[SET_AUTO_QUALITY](false);
}
/* @internal */
[LIST_ON_RESET](trigger) {
this[SET_AUTO_QUALITY](false, trigger);
}
/**
* Request automatic quality selection (if supported). This will be a no-op if the list is
* `readonly` as that already implies auto-selection.
*/
autoSelect(trigger) {
if (this.readonly || this.Sa || !this[ENABLE_AUTO_QUALITY])
return;
this[ENABLE_AUTO_QUALITY]();
this[SET_AUTO_QUALITY](true, trigger);
}
/* @internal */
[SET_AUTO_QUALITY](auto, trigger) {
if (this.Sa === auto)
return;
this.Sa = auto;
this.dispatchEvent(
new DOMEvent("auto-change", {
detail: auto,
trigger
})
);
}
}
class MediaLoadController extends ComponentController {
constructor(instance, _callback) {
super(instance);
this.jf = _callback;
}
async onAttach(el) {
const load = this.$props.load();
if (load === "eager") {
requestAnimationFrame(this.jf);
} else if (load === "idle") {
const { waitIdlePeriod } = await import('maverick.js/std');
waitIdlePeriod(this.jf);
} else if (load === "visible") {
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
observer.disconnect();
this.jf();
}
});
observer.observe(el);
return observer.disconnect.bind(observer);
}
}
}
class MediaPlayerDelegate {
constructor(_handle, _media) {
this.N = _handle;
this.j = _media;
}
p(type, ...init) {
this.N(new DOMEvent(type, init?.[0]));
}
async lf(info, trigger) {
const { $store, logger } = this.j;
if (peek($store.canPlay))
return;
this.p("can-play", { detail: info, trigger });
tick();
if ($store.canPlay() && $store.autoplay() && !$store.started()) {
await this.kf();
}
}
async kf() {
const { player, $store } = this.j;
$store.autoplaying.set(true);
try {
await player.play();
this.p("autoplay", { detail: { muted: $store.muted() } });
} catch (error) {
this.p("autoplay-fail", {
detail: {
muted: $store.muted(),
error
}
});
} finally {
$store.autoplaying.set(false);
}
}
}
class Queue {
Ze = /* @__PURE__ */ new Map();
/**
* Queue the given `item` under the given `key` to be processed at a later time by calling
* `serve(key)`.
*/
t(key, item) {
if (!this.Ze.has(key))
this.Ze.set(key, /* @__PURE__ */ new Set());
this.Ze.get(key).add(item);
}
/**
* Process all items in queue for the given `key`.
*/
cf(key, callback) {
const items = this.Ze.get(key);
if (items)
for (const item of items)
callback(item);
this.Ze.delete(key);
}
/**
* Removes all queued items under the given `key`.
*/
yf(key) {
this.Ze.delete(key);
}
/**
* The number of items currently queued under the given `key`.
*/
df(key) {
return this.Ze.get(key)?.size ?? 0;
}
/**
* Clear all items in the queue.
*/
gf() {
this.Ze.clear();
}
}
function coerceToError(error) {
return error instanceof Error ? error : Error(JSON.stringify(error));
}
class MediaUserController extends ComponentController {
da = -2;
ba = 2e3;
ea = false;
ca = null;
/**
* Whether the media user is currently idle.
*/
get idling() {
return this.$store.userIdle();
}
/**
* The amount of delay in milliseconds while media playback is progressing without user
* activity to indicate an idle state.
*
* @defaultValue 2000
*/
get idleDelay() {
return this.ba;
}
set idleDelay(newDelay) {
this.ba = newDelay;
}
/**
* Change the user idle state.
*/
idle(idle, delay = this.ba, trigger) {
this.fa();
if (!this.ea)
this.ga(idle, delay, trigger);
}
/**
* Whether all idle tracking should be paused until resumed again.
*/
pauseIdleTracking(paused, trigger) {
this.ea = paused;
if (paused) {
this.fa();
this.ga(false, 0, trigger);
}
}
onConnect() {
effect(this.C.bind(this));
listenEvent(this.el, "play", this.ia.bind(this));
listenEvent(this.el, "pause", this.ja.bind(this));
}
C() {
if (this.$store.paused())
return;
const onStopIdle = this.ka.bind(this);
for (const eventType of ["pointerup", "keydown"]) {
listenEvent(this.el, eventType, onStopIdle);
}
effect(() => {
if (!this.$store.touchPointer())
listenEvent(this.el, "pointermove", onStopIdle);
});
}
ia(event) {
this.idle(true, this.ba, event);
}
ja(event) {
this.idle(false, 0, event);
}
fa() {
window.clearTimeout(this.da);
this.da = -1;
}
ka(event) {
if (event.MEDIA_GESTURE)
return;
if (isKeyboardEvent(event)) {
if (event.key === "Escape") {
this.el?.focus();
this.ca = null;
} else if (this.ca) {
event.preventDefault();
requestAnimationFrame(() => {
this.ca?.focus();
this.ca = null;
});
}
}
this.idle(false, 0, event);
this.idle(true, this.ba, event);
}
ga(idle, delay, trigger) {
if (delay === 0) {
this.ha(idle, trigger);
return;
}
this.da = window.setTimeout(() => {
this.ha(idle && !this.ea, trigger);
}, delay);
}
ha(idle, trigger) {
if (this.$store.userIdle() === idle)
return;
this.$store.userIdle.set(idle);
if (idle && document.activeElement && this.el?.contains(document.activeElement)) {
this.ca = document.activeElement;
requestAnimationFrame(() => this.el?.focus());
}
this.dispatch("user-idle-change", {
detail: idle,
trigger
});
}
}
class MediaRequestContext {
$a = false;
rf = false;
pf = false;
Ze = new Queue();
}
class MediaRequestManager extends ComponentController {
constructor(instance, _stateMgr, _request, _media) {
super(instance);
this.u = _stateMgr;
this.mf = _request;
this.j = _media;
this.nf = _media.$store;
this.q = _media.$provider;
this.Q = new MediaUserController(instance);
this.of = new FullscreenController(instance);
this.nb = new ScreenOrientationController(instance);
}
Q;
of;
nb;
nf;
q;
onConnect() {
effect(this.uf.bind(this));
effect(this.vf.bind(this));
effect(this.wf.bind(this));
const names = Object.getOwnPropertyNames(Object.getPrototypeOf(this)), handle = this.xf.bind(this);
for (const name of names) {
if (name.startsWith("media-")) {
this.listen(name, handle);
}
}
this.listen("fullscreen-change", this.d.bind(this));
}
xf(event) {
event.stopPropagation();
if (peek(this.q))
this[event.type]?.(event);
}
async M() {
const { canPlay, paused, ended, autoplaying, seekableStart } = this.nf;
if (!peek(paused))
return;
try {
const provider = peek(this.q);
throwIfNotReadyForPlayback(provider, peek(canPlay));
if (peek(ended)) {
provider.currentTime = seekableStart() + 0.1;
}
return provider.play();
} catch (error) {
const errorEvent = this.createEvent("play-fail", { detail: coerceToError(error) });
errorEvent.autoplay = autoplaying();
this.u.N(errorEvent);
throw error;
}
}
async L() {
const { canPlay, paused } = this.nf;
if (peek(paused))
return;
const provider = peek(this.q);
throwIfNotReadyForPlayback(provider, peek(canPlay));
return provider.pause();
}
V() {
const { canPlay, live, liveEdge, canSeek, liveSyncPosition, seekableEnd, userBehindLiveEdge } = this.nf;
userBehindLiveEdge.set(false);
if (peek(() => !live() || liveEdge() || !canSeek()))
return;
const provider = peek(this.q);
throwIfNotReadyForPlayback(provider, peek(canPlay));
provider.currentTime = liveSyncPosition() ?? seekableEnd() - 2;
}
qf = false;
async R(target = "prefer-media") {
const provider = peek(this.q);
const adapter = target === "prefer-media" && this.of.supported || target === "media" ? this.of : provider?.fullscreen;
throwIfFullscreenNotSupported(target, adapter);
if (adapter.active)
return;
if (peek(this.nf.pictureInPicture)) {
this.qf = true;
await this.U();
}
return adapter.enter();
}
async S(target = "prefer-media") {
const provider = peek(this.q);
const adapter = target === "prefer-media" && this.of.supported || target === "media" ? this.of : provider?.fullscreen;
throwIfFullscreenNotSupported(target, adapter);
if (!adapter.active)
return;
if (this.nb.locked)
await this.nb.unlock();
try {
const result = await adapter.exit();
if (this.qf && peek(this.nf.canPictureInPicture)) {
await this.T();
}
return result;
} finally {
this.qf = false;
}
}
async T() {
this.sf();
if (this.nf.pictureInPicture())
return;
return await this.q().pictureInPicture.enter();
}
async U() {
this.sf();
if (!this.nf.pictureInPicture())
return;
return await this.q().pictureInPicture.exit();
}
sf() {
if (this.nf.canPictureInPicture())
return;
throw Error(
"[vidstack] no pip support"
);
}
uf() {
this.Q.idleDelay = this.$props.userIdleDelay();
}
vf() {
const { canLoad, canFullscreen } = this.nf, supported = this.of.supported || this.q()?.fullscreen?.supported || false;
if (canLoad() && peek(canFullscreen) === supported)
return;
canFullscreen.set(supported);
}
wf() {
const { canLoad, canPictureInPicture } = this.nf, supported = this.q()?.pictureInPicture?.supported || false;
if (canLoad() && peek(canPictureInPicture) === supported)
return;
canPictureInPicture.set(supported);
}
["media-audio-track-change-request"](event) {
if (this.j.audioTracks.readonly) {
return;
}
const index = event.detail, track = this.j.audioTracks[index];
if (track) {
this.mf.Ze.t("audioTrack", event);
track.selected = true;
}
}
async ["media-enter-fullscreen-request"](event) {
try {
this.mf.Ze.t("fullscreen", event);
await this.R(event.detail);
} catch (error) {
this.e(error);
}
}
async ["media-exit-fullscreen-request"](event) {
try {
this.mf.Ze.t("fullscreen", event);
await this.S(event.detail);
} catch (error) {
this.e(error);
}
}
async d(event) {
if (!event.detail)
return;
try {
const lockType = peek(this.$props.fullscreenOrientation);
if (this.nb.supported && !isUndefined(lockType)) {
await this.nb.lock(lockType);
}
} catch (e) {
}
}
e(error) {
this.u.N(
this.createEvent("fullscreen-error", {
detail: coerceToError(error)
})
);
}
async ["media-enter-pip-request"](event) {
try {
this.mf.Ze.t("pip", event);
await this.T();
} catch (error) {
this.tf(error);
}
}
async ["media-exit-pip-request"](event) {
try {
this.mf.Ze.t("pip", event);
await this.U();
} catch (error) {
this.tf(error);
}
}
tf(error) {
this.u.N(
this.createEvent("picture-in-picture-error", {
detail: coerceToError(error)
})
);
}
["media-live-edge-request"](event) {
const { live, liveEdge, canSeek } = this.nf;
if (!live() || liveEdge() || !canSeek())
return;
this.mf.Ze.t("seeked", event);
try {
this.V();
} catch (e) {
}
}
["media-loop-request"]() {
window.requestAnimationFrame(async () => {
try {
this.mf.rf = true;
this.mf.pf = true;
await this.M();
} catch (e) {
this.mf.rf = false;
this.mf.pf = false;
}
});
}
async ["media-pause-request"](event) {
if (this.nf.paused())
return;
try {
this.mf.Ze.t("pause", event);
await this.q().pause();
} catch (e) {
this.mf.Ze.yf("pause");
}
}
async ["media-play-request"](event) {
if (!this.nf.paused())
return;
try {
this.mf.Ze.t("play", event);
await this.q().play();
} catch (e) {
const errorEvent = this.createEvent("play-fail", { detail: coerceToError(e) });
this.u.N(errorEvent);
}
}
["media-rate-change-request"](event) {
if (this.nf.playbackRate() === event.detail)
return;
this.mf.Ze.t("rate", event);
this.q().playbackRate = event.detail;
}
["media-quality-change-request"](event) {
if (this.j.qualities.readonly) {
return;
}
this.mf.Ze.t("quality", event);
const index = event.detail;
if (index < 0) {
this.j.qualities.autoSelect(event);
} else {
const quality = this.j.qualities[index];
if (quality) {
quality.selected = true;
}
}
}
["media-resume-user-idle-request"](event) {
this.mf.Ze.t("userIdle", event);
this.Q.pauseIdleTracking(false, event);
}
["media-pause-user-idle-request"](event) {
this.mf.Ze.t("userIdle", event);
this.Q.pauseIdleTracking(true, event);
}
["media-seek-request"](event) {
const { seekableStart, seekableEnd, ended, canSeek, live, userBehindLiveEdge } = this.nf;
if (ended())
this.mf.pf = true;
this.mf.$a = false;
this.mf.Ze.yf("seeking");
const boundTime = Math.min(Math.max(seekableStart() + 0.1, event.detail), seekableEnd() - 0.1);
if (!Number.isFinite(boundTime) || !canSeek())
return;
this.mf.Ze.t("seeked", event);
this.q().currentTime = boundTime;
if (live() && event.isOriginTrusted && Math.abs(seekableEnd() - boundTime) >= 2) {
userBehindLiveEdge.set(true);
}
}
["media-seeking-request"](event) {
this.mf.Ze.t("seeking", event);
this.nf.seeking.set(true);
this.mf.$a = true;
}
["media-start-loading"](event) {
if (this.nf.canLoad())
return;
this.mf.Ze.t("load", event);
this.u.N(this.createEvent("can-load"));
}
["media-text-track-change-request"](event) {
const { index, mode } = event.detail, track = this.j.textTracks[index];
if (track) {
this.mf.Ze.t("textTrack", event);
track.setMode(mode, event);
}
}
["media-mute-request"](event) {
if (this.nf.muted())
return;
this.mf.Ze.t("volume", event);
this.q().muted = true;
}
["media-unmute-request"](event) {
const { muted, volume } = this.nf;
if (!muted())
return;
this.mf.Ze.t("volume", event);
this.j.$provider().muted = false;
if (volume() === 0) {
this.mf.Ze.t("volume", event);
this.q().volume = 0.25;
}
}
["media-volume-change-request"](event) {
const { muted, volume } = this.nf;
const newVolume = event.detail;
if (volume() === newVolume)
return;
this.mf.Ze.t("volume", event);
this.q().volume = newVolume;
if (newVolume > 0 && muted()) {
this.mf.Ze.t("volume", event);
this.q().muted = false;
}
}
}
function throwIfNotReadyForPlayback(provider, canPlay) {
if (provider && canPlay)
return;
throw Error(
"[vidstack] media not ready"
);
}
function throwIfFullscreenNotSupported(target, fullscreen) {
if (fullscreen?.supported)
return;
throw Error(
"[vidstack] no fullscreen support"
);
}
var functionDebounce = debounce;
function debounce(fn, wait, callFirst) {
var timeout = null;
var debouncedFn = null;
var clear = function() {
if (timeout) {
clearTimeout(timeout);
debouncedFn = null;
timeout = null;
}
};
var flush = function() {
var call = debouncedFn;
clear();
if (call) {
call();
}
};
var debounceWrapper = function() {
if (!wait) {
return fn.apply(this, arguments);
}
var context = this;
var args = arguments;
var callNow = callFirst && !timeout;
clear();
debouncedFn = function() {
fn.apply(context, args);
};
timeout = setTimeout(function() {
timeout = null;
if (!callNow) {
var call = debouncedFn;
debouncedFn = null;
return call();
}
}, wait);
if (callNow) {
return debouncedFn();
}
};
debounceWrapper.cancel = clear;
debounceWrapper.flush = flush;
return debounceWrapper;
}
var functionThrottle = throttle;
function throttle(fn, interval, options) {
var timeoutId = null;
var throttledFn = null;
var leading = options && options.leading;
var trailing = options && options.trailing;
if (leading == null) {
leading = true;
}
if (trailing == null) {
trailing = !leading;
}
if (leading == true) {
trailing = false;
}
var cancel = function() {
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
};
var flush = function() {
var call = throttledFn;
cancel();
if (call) {
call();
}
};
var throttleWrapper = function() {
var callNow = leading && !timeoutId;
var context = this;
var args = arguments;
throttledFn = function() {
return fn.apply(context, args);
};
if (!timeoutId) {
timeoutId = setTimeout(function() {
timeoutId = null;
if (trailing) {
return throttledFn();
}
}, interval);
}
if (callNow) {
callNow = false;
return throttledFn();
}
};
throttleWrapper.cancel = cancel;
throttleWrapper.flush = flush;
return throttleWrapper;
}
const ATTACH_VIDEO = Symbol(0);
const TEXT_TRACK_CROSSORIGIN = Symbol(0);
const TEXT_TRACK_READY_STATE = Symbol(0);
const TEXT_TRACK_UPDATE_ACTIVE_CUES = Symbol(0);
const TEXT_TRACK_CAN_LOAD = Symbol(0);
const TEXT_TRACK_ON_MODE_CHANGE = Symbol(0);
const TEXT_TRACK_NATIVE = Symbol(0);
const TEXT_TRACK_NATIVE_HLS = Symbol(0);
const TRACKED_EVENT = /* @__PURE__ */ new Set([
"autoplay",
"autoplay-fail",
"can-load",
"sources-change",
"source-change",
"load-start",
"abort",
"error",
"loaded-metadata",
"loaded-data",
"can-play",
"play",
"play-fail",
"pause",
"playing",
"seeking",
"seeked",
"waiting"
]);
class MediaStateManager extends ComponentController {
constructor(instance, _request, _media) {
super(instance);
this.mf = _request;
this.j = _media;
this.nf = _media.$store;
}
nf;
zf = /* @__PURE__ */ new Map();
Gf = true;
Df = false;
Bf;
onAttach(el) {
el.setAttribute("aria-busy", "true");
}
onConnect(el) {
this.Mf();
this.Nf();
this.Of();
this.listen("fullscreen-change", this["fullscreen-change"].bind(this));
this.listen("fullscreen-error", this["fullscreen-error"].bind(this));
}
N(event) {
const type = event.type;
this[event.type]?.(event);
{
if (TRACKED_EVENT.has(type))
this.zf.set(type, event);
this.el?.dispatchEvent(event);
}
}
Cf() {
this.Hf();
this.mf.pf = false;
this.mf.rf = false;
this.Df = false;
this.Bf = void 0;
this.zf.clear();
}
Af(request, event) {
this.mf.Ze.cf(request, (requestEvent) => {
event.request = requestEvent;
appendTriggerEvent(event, requestEvent);
});
}
Mf() {
this.Ef();
this.If();
const textTracks = this.j.textTracks;
listenEvent(textTracks, "add", this.Ef.bind(this));
listenEvent(textTracks, "remove", this.Ef.bind(this));
listenEvent(textTracks, "mode-change", this.If.bind(this));
}
Nf() {
const qualities = this.j.qualities;
listenEvent(qualities, "add", this.Jf.bind(this));
listenEvent(qualities, "remove", this.Jf.bind(this));
listenEvent(qualities, "change", this.Pf.bind(this));
listenEvent(qualities, "auto-change", this.Qf.bind(this));
listenEvent(qualities, "readonly-change", this.Rf.bind(this));
}
Of() {
const audioTracks = this.j.audioTracks;
listenEvent(audioTracks, "add", this.Kf.bind(this));
listenEvent(audioTracks, "remove", this.Kf.bind(this));
listenEvent(audioTracks, "change", this.Sf.bind(this));
}
Ef(event) {
const { textTracks } = this.nf;
textTracks.set(this.j.textTracks.toArray());
this.dispatch("text-tracks-change", {
detail: textTracks(),
trigger: event
});
}
If(event) {
if (event)
this.Af("textTrack", event);
const current = this.j.textTracks.selected, { textTrack } = this.nf;
if (textTrack() !== current) {
textTrack.set(current);
this.dispatch("text-track-change", {
detail: current,
trigger: event
});
}
}
Kf(event) {
const { audioTracks } = this.nf;
audioTracks.set(this.j.audioTracks.toArray());
this.dispatch("audio-tracks-change", {
detail: audioTracks(),
trigger: event
});
}
Sf(event) {
const { audioTrack } = this.nf;
audioTrack.set(this.j.audioTracks.selected);
this.Af("audioTrack", event);
this.dispatch("audio-track-change", {
detail: audioTrack(),
trigger: event
});
}
Jf(event) {
const { qualities } = this.nf;
qualities.set(this.j.qualities.toArray());
this.dispatch("qualities-change", {
detail: qualities(),
trigger: event
});
}
Pf(event) {
const { quality } = this.nf;
quality.set(this.j.qualities.selected);
this.Af("quality", event);
this.dispatch("quality-change", {
detail: quality(),
trigger: event
});
}
Qf() {
this.nf.autoQuality.set(this.j.qualities.auto);
}
Rf() {
this.nf.canSetQuality.set(!this.j.qualities.readonly);
}
["provider-change"](event) {
this.j.$provider.set(event.detail);
}
["autoplay"](event) {
appendTriggerEvent(event, this.zf.get("play"));
appendTriggerEvent(event, this.zf.get("can-play"));
this.nf.autoplayError.set(void 0);
}
["autoplay-fail"](event) {
appendTriggerEvent(event, this.zf.get("play-fail"));
appendTriggerEvent(event, this.zf.get("can-play"));
this.nf.autoplayError.set(event.detail);
this.Cf();
}
["can-load"](event) {
this.nf.canLoad.set(true);
this.zf.set("can-load", event);
this.Af("load", event);
this.j.textTracks[TEXT_TRACK_CAN_LOAD]();
}
["media-type-change"](event) {
appendTriggerEvent(event, this.zf.get("source-change"));
const viewType = this.nf.viewType();
this.nf.mediaType.set(event.detail);
if (viewType !== this.nf.viewType()) {
setTimeout(
() => this.dispatch("view-type-change", {
detail: this.nf.viewType(),
trigger: event
}),
0
);
}
}
["stream-type-change"](event) {
const { streamType, inferredStreamType } = this.nf;
appendTriggerEvent(event, this.zf.get("source-change"));
inferredStreamType.set(event.detail);
event.detail = streamType();
}
["rate-change"](event) {
this.nf.playbackRate.set(event.detail);
this.Af("rate", event);
}
["sources-change"](event) {
this.nf.sources.set(event.detail);
}
["source-change"](event) {
appendTriggerEvent(event, this.zf.get("sources-change"));
this.nf.source.set(event.detail);
this.el?.setAttribute("aria-busy", "true");
if (this.Gf) {
this.Gf = false;
return;
}
this.j.audioTracks[LIST