@7sage/vidstack
Version:
UI component library for building high-quality, accessible video and audio experiences on the web.
1,538 lines (1,512 loc) • 94.6 kB
JavaScript
import { ViewController, onDispose, isArray, isString, isNull, effect, peek, listenEvent, ariaBool, isWriteSignal, Component, State, createContext, hasProvidedContext, useContext, EventsController, isNumber, functionThrottle, signal, provideContext, animationFrameThrottle, isObject, method, useState, computed, setAttribute, r, wasEnterKeyPressed, setStyle, isKeyboardEvent, tick, prop, scoped } from './vidstack-Bu2kfzUd.js';
import { useMediaContext } from './vidstack-DFImIcIL.js';
import { hasAnimation, setAttributeIfEmpty, onPress, $ariaBool, setARIALabel, isTouchPinchEvent, observeVisibility, isHTMLElement, isElementParent, isEventInside, isElementVisible, requestScopedAnimationFrame, autoPlacement } from './vidstack-C_l97D5j.js';
import { isTrackCaptionKind } from './vidstack-BYNmVJLa.js';
import { FocusVisibleController } from './vidstack-CofXIJAy.js';
import { clampNumber, round, getNumberOfDecimalPlaces } from './vidstack-Dihypf8P.js';
import { assert } from './vidstack-DbBJlz7I.js';
import { getRequestCredentials } from './vidstack-zG6PIeGg.js';
import { watchActiveTextTrack } from './vidstack-28cU2iGK.js';
import { sortVideoQualities } from './vidstack-BTM4ERc7.js';
class ARIAKeyShortcuts extends ViewController {
#shortcut;
constructor(shortcut) {
super();
this.#shortcut = shortcut;
}
onAttach(el) {
const { $props, ariaKeys } = useMediaContext(), keys = el.getAttribute("aria-keyshortcuts");
if (keys) {
ariaKeys[this.#shortcut] = keys;
{
onDispose(() => {
delete ariaKeys[this.#shortcut];
});
}
return;
}
const shortcuts = $props.keyShortcuts()[this.#shortcut];
if (shortcuts) {
const keys2 = isArray(shortcuts) ? shortcuts.join(" ") : isString(shortcuts) ? shortcuts : shortcuts?.keys;
el.setAttribute("aria-keyshortcuts", isArray(keys2) ? keys2.join(" ") : keys2);
}
}
}
function padNumberWithZeroes(num, expectedLength) {
const str = String(num);
const actualLength = str.length;
const shouldPad = actualLength < expectedLength;
if (shouldPad) {
const padLength = expectedLength - actualLength;
const padding = `0`.repeat(padLength);
return `${padding}${num}`;
}
return str;
}
function parseTime(duration) {
const hours = Math.trunc(duration / 3600);
const minutes = Math.trunc(duration % 3600 / 60);
const seconds = Math.trunc(duration % 60);
const fraction = Number((duration - Math.trunc(duration)).toPrecision(3));
return {
hours,
minutes,
seconds,
fraction
};
}
function formatTime(duration, { padHrs = null, padMins = null, showHrs = false, showMs = false } = {}) {
const { hours, minutes, seconds, fraction } = parseTime(duration), paddedHours = padHrs ? padNumberWithZeroes(hours, 2) : hours, paddedMinutes = padMins || isNull(padMins) && duration >= 3600 ? padNumberWithZeroes(minutes, 2) : minutes, paddedSeconds = padNumberWithZeroes(seconds, 2), paddedMs = showMs && fraction > 0 ? `.${String(fraction).replace(/^0?\./, "")}` : "", time = `${paddedMinutes}:${paddedSeconds}${paddedMs}`;
return hours > 0 || showHrs ? `${paddedHours}:${time}` : time;
}
function formatSpokenTime(duration) {
const spokenParts = [];
const { hours, minutes, seconds } = parseTime(duration);
if (hours > 0) {
spokenParts.push(`${hours} hour`);
}
if (minutes > 0) {
spokenParts.push(`${minutes} min`);
}
if (seconds > 0 || spokenParts.length === 0) {
spokenParts.push(`${seconds} sec`);
}
return spokenParts.join(" ");
}
class Popper extends ViewController {
#delegate;
constructor(delegate) {
super();
this.#delegate = delegate;
effect(this.#watchTrigger.bind(this));
}
onDestroy() {
this.#stopAnimationEndListener?.();
this.#stopAnimationEndListener = null;
}
#watchTrigger() {
const trigger = this.#delegate.trigger();
if (!trigger) {
this.hide();
return;
}
const show = this.show.bind(this), hide = this.hide.bind(this);
this.#delegate.listen(trigger, show, hide);
}
#showTimerId = -1;
#hideRafId = -1;
#stopAnimationEndListener = null;
show(trigger) {
this.#cancelShowing();
window.cancelAnimationFrame(this.#hideRafId);
this.#hideRafId = -1;
this.#stopAnimationEndListener?.();
this.#stopAnimationEndListener = null;
this.#showTimerId = window.setTimeout(() => {
this.#showTimerId = -1;
const content = this.#delegate.content();
if (content) content.style.removeProperty("display");
peek(() => this.#delegate.onChange(true, trigger));
}, this.#delegate.showDelay?.() ?? 0);
}
hide(trigger) {
this.#cancelShowing();
peek(() => this.#delegate.onChange(false, trigger));
this.#hideRafId = requestAnimationFrame(() => {
this.#cancelShowing();
this.#hideRafId = -1;
const content = this.#delegate.content();
if (content) {
const onHide = () => {
content.style.display = "none";
this.#stopAnimationEndListener = null;
};
const isAnimated = hasAnimation(content);
if (isAnimated) {
this.#stopAnimationEndListener?.();
const stop = listenEvent(content, "animationend", onHide, { once: true });
this.#stopAnimationEndListener = stop;
} else {
onHide();
}
}
});
}
#cancelShowing() {
window.clearTimeout(this.#showTimerId);
this.#showTimerId = -1;
}
}
class ToggleButtonController extends ViewController {
static props = {
disabled: false
};
#delegate;
constructor(delegate) {
super();
this.#delegate = delegate;
new FocusVisibleController();
if (delegate.keyShortcut) {
new ARIAKeyShortcuts(delegate.keyShortcut);
}
}
onSetup() {
const { disabled } = this.$props;
this.setAttributes({
"data-pressed": this.#delegate.isPresssed,
"aria-pressed": this.#isARIAPressed.bind(this),
"aria-disabled": () => disabled() ? "true" : null
});
}
onAttach(el) {
setAttributeIfEmpty(el, "tabindex", "0");
setAttributeIfEmpty(el, "role", "button");
setAttributeIfEmpty(el, "type", "button");
}
onConnect(el) {
const events = onPress(el, this.#onMaybePress.bind(this));
for (const type of ["click", "touchstart"]) {
events.add(type, this.#onInteraction.bind(this), {
passive: true
});
}
}
#isARIAPressed() {
return ariaBool(this.#delegate.isPresssed());
}
#onPressed(event) {
if (isWriteSignal(this.#delegate.isPresssed)) {
this.#delegate.isPresssed.set((p) => !p);
}
}
#onMaybePress(event) {
const disabled = this.$props.disabled() || this.el.hasAttribute("data-disabled");
if (disabled) {
event.preventDefault();
event.stopImmediatePropagation();
return;
}
event.preventDefault();
(this.#delegate.onPress ?? this.#onPressed).call(this, event);
}
#onInteraction(event) {
if (this.$props.disabled()) {
event.preventDefault();
event.stopImmediatePropagation();
}
}
}
class AirPlayButton extends Component {
static props = ToggleButtonController.props;
#media;
constructor() {
super();
new ToggleButtonController({
isPresssed: this.#isPressed.bind(this),
onPress: this.#onPress.bind(this)
});
}
onSetup() {
this.#media = useMediaContext();
const { canAirPlay, isAirPlayConnected } = this.#media.$state;
this.setAttributes({
"data-active": isAirPlayConnected,
"data-supported": canAirPlay,
"data-state": this.#getState.bind(this),
"aria-hidden": $ariaBool(() => !canAirPlay())
});
}
onAttach(el) {
el.setAttribute("data-media-tooltip", "airplay");
setARIALabel(el, this.#getDefaultLabel.bind(this));
}
#onPress(event) {
const remote = this.#media.remote;
remote.requestAirPlay(event);
}
#isPressed() {
const { remotePlaybackType, remotePlaybackState } = this.#media.$state;
return remotePlaybackType() === "airplay" && remotePlaybackState() !== "disconnected";
}
#getState() {
const { remotePlaybackType, remotePlaybackState } = this.#media.$state;
return remotePlaybackType() === "airplay" && remotePlaybackState();
}
#getDefaultLabel() {
const { remotePlaybackState } = this.#media.$state;
return `AirPlay ${remotePlaybackState()}`;
}
}
class PlayButton extends Component {
static props = ToggleButtonController.props;
#media;
constructor() {
super();
new ToggleButtonController({
isPresssed: this.#isPressed.bind(this),
keyShortcut: "togglePaused",
onPress: this.#onPress.bind(this)
});
}
onSetup() {
this.#media = useMediaContext();
const { paused, ended } = this.#media.$state;
this.setAttributes({
"data-paused": paused,
"data-ended": ended
});
}
onAttach(el) {
el.setAttribute("data-media-tooltip", "play");
setARIALabel(el, "Play");
}
#onPress(event) {
const remote = this.#media.remote;
this.#isPressed() ? remote.pause(event) : remote.play(event);
}
#isPressed() {
const { paused } = this.#media.$state;
return !paused();
}
}
class CaptionButton extends Component {
static props = ToggleButtonController.props;
#media;
constructor() {
super();
new ToggleButtonController({
isPresssed: this.#isPressed.bind(this),
keyShortcut: "toggleCaptions",
onPress: this.#onPress.bind(this)
});
}
onSetup() {
this.#media = useMediaContext();
this.setAttributes({
"data-active": this.#isPressed.bind(this),
"data-supported": () => !this.#isHidden(),
"aria-hidden": $ariaBool(this.#isHidden.bind(this))
});
}
onAttach(el) {
el.setAttribute("data-media-tooltip", "caption");
setARIALabel(el, "Captions");
}
#onPress(event) {
this.#media.remote.toggleCaptions(event);
}
#isPressed() {
const { textTrack } = this.#media.$state, track = textTrack();
return !!track && isTrackCaptionKind(track);
}
#isHidden() {
const { hasCaptions } = this.#media.$state;
return !hasCaptions();
}
}
class FullscreenButton extends Component {
static props = {
...ToggleButtonController.props,
target: "prefer-media"
};
#media;
constructor() {
super();
new ToggleButtonController({
isPresssed: this.#isPressed.bind(this),
keyShortcut: "toggleFullscreen",
onPress: this.#onPress.bind(this)
});
}
onSetup() {
this.#media = useMediaContext();
const { fullscreen } = this.#media.$state, isSupported = this.#isSupported.bind(this);
this.setAttributes({
"data-active": fullscreen,
"data-supported": isSupported,
"aria-hidden": $ariaBool(() => !isSupported())
});
}
onAttach(el) {
el.setAttribute("data-media-tooltip", "fullscreen");
setARIALabel(el, "Fullscreen");
}
#onPress(event) {
const remote = this.#media.remote, target = this.$props.target();
this.#isPressed() ? remote.exitFullscreen(target, event) : remote.enterFullscreen(target, event);
}
#isPressed() {
const { fullscreen } = this.#media.$state;
return fullscreen();
}
#isSupported() {
const { canFullscreen } = this.#media.$state;
return canFullscreen();
}
}
class MuteButton extends Component {
static props = ToggleButtonController.props;
#media;
constructor() {
super();
new ToggleButtonController({
isPresssed: this.#isPressed.bind(this),
keyShortcut: "toggleMuted",
onPress: this.#onPress.bind(this)
});
}
onSetup() {
this.#media = useMediaContext();
this.setAttributes({
"data-muted": this.#isPressed.bind(this),
"data-state": this.#getState.bind(this)
});
}
onAttach(el) {
el.setAttribute("data-media-mute-button", "");
el.setAttribute("data-media-tooltip", "mute");
setARIALabel(el, "Mute");
}
#onPress(event) {
const remote = this.#media.remote;
this.#isPressed() ? remote.unmute(event) : remote.mute(event);
}
#isPressed() {
const { muted, volume } = this.#media.$state;
return muted() || volume() === 0;
}
#getState() {
const { muted, volume } = this.#media.$state, $volume = volume();
if (muted() || $volume === 0) return "muted";
else if ($volume >= 0.5) return "high";
else if ($volume < 0.5) return "low";
}
}
class PIPButton extends Component {
static props = ToggleButtonController.props;
#media;
constructor() {
super();
new ToggleButtonController({
isPresssed: this.#isPressed.bind(this),
keyShortcut: "togglePictureInPicture",
onPress: this.#onPress.bind(this)
});
}
onSetup() {
this.#media = useMediaContext();
const { pictureInPicture } = this.#media.$state, isSupported = this.#isSupported.bind(this);
this.setAttributes({
"data-active": pictureInPicture,
"data-supported": isSupported,
"aria-hidden": $ariaBool(() => !isSupported())
});
}
onAttach(el) {
el.setAttribute("data-media-tooltip", "pip");
setARIALabel(el, "PiP");
}
#onPress(event) {
const remote = this.#media.remote;
this.#isPressed() ? remote.exitPictureInPicture(event) : remote.enterPictureInPicture(event);
}
#isPressed() {
const { pictureInPicture } = this.#media.$state;
return pictureInPicture();
}
#isSupported() {
const { canPictureInPicture } = this.#media.$state;
return canPictureInPicture();
}
}
class SeekButton extends Component {
static props = {
disabled: false,
seconds: 30
};
#media;
constructor() {
super();
new FocusVisibleController();
}
onSetup() {
this.#media = useMediaContext();
const { seeking } = this.#media.$state, { seconds } = this.$props, isSupported = this.#isSupported.bind(this);
this.setAttributes({
seconds,
"data-seeking": seeking,
"data-supported": isSupported,
"aria-hidden": $ariaBool(() => !isSupported())
});
}
onAttach(el) {
setAttributeIfEmpty(el, "tabindex", "0");
setAttributeIfEmpty(el, "role", "button");
setAttributeIfEmpty(el, "type", "button");
el.setAttribute("data-media-tooltip", "seek");
setARIALabel(el, this.#getDefaultLabel.bind(this));
}
onConnect(el) {
onPress(el, this.#onPress.bind(this));
}
#isSupported() {
const { canSeek } = this.#media.$state;
return canSeek();
}
#getDefaultLabel() {
const { seconds } = this.$props;
return `Seek ${seconds() > 0 ? "forward" : "backward"} ${seconds()} seconds`;
}
#onPress(event) {
const { seconds, disabled } = this.$props;
if (disabled()) return;
const { currentTime } = this.#media.$state, seekTo = currentTime() + seconds();
this.#media.remote.seek(seekTo, event);
}
}
class LiveButton extends Component {
static props = {
disabled: false
};
#media;
constructor() {
super();
new FocusVisibleController();
}
onSetup() {
this.#media = useMediaContext();
const { disabled } = this.$props, { live, liveEdge } = this.#media.$state, isHidden = () => !live();
this.setAttributes({
"data-edge": liveEdge,
"data-hidden": isHidden,
"aria-disabled": $ariaBool(() => disabled() || liveEdge()),
"aria-hidden": $ariaBool(isHidden)
});
}
onAttach(el) {
setAttributeIfEmpty(el, "tabindex", "0");
setAttributeIfEmpty(el, "role", "button");
setAttributeIfEmpty(el, "type", "button");
el.setAttribute("data-media-tooltip", "live");
}
onConnect(el) {
onPress(el, this.#onPress.bind(this));
}
#onPress(event) {
const { disabled } = this.$props, { liveEdge } = this.#media.$state;
if (disabled() || liveEdge()) return;
this.#media.remote.seekToLiveEdge(event);
}
}
const sliderState = new State({
min: 0,
max: 100,
value: 0,
step: 1,
pointerValue: 0,
focused: false,
dragging: false,
pointing: false,
hidden: false,
get active() {
return this.dragging || this.focused || this.pointing;
},
get fillRate() {
return calcRate(this.min, this.max, this.value);
},
get fillPercent() {
return this.fillRate * 100;
},
get pointerRate() {
return calcRate(this.min, this.max, this.pointerValue);
},
get pointerPercent() {
return this.pointerRate * 100;
}
});
function calcRate(min, max, value) {
const range = max - min, offset = value - min;
return range > 0 ? offset / range : 0;
}
class IntersectionObserverController extends ViewController {
#init;
#observer;
constructor(init) {
super();
this.#init = init;
}
onConnect(el) {
this.#observer = new IntersectionObserver((entries) => {
this.#init.callback?.(entries, this.#observer);
}, this.#init);
this.#observer.observe(el);
onDispose(this.#onDisconnect.bind(this));
}
/**
* Disconnect any active intersection observers.
*/
#onDisconnect() {
this.#observer?.disconnect();
this.#observer = void 0;
}
}
const sliderContext = createContext();
const sliderObserverContext = createContext();
function getClampedValue(min, max, value, step) {
return clampNumber(min, round(value, getNumberOfDecimalPlaces(step)), max);
}
function getValueFromRate(min, max, rate, step) {
const boundRate = clampNumber(0, rate, 1), range = max - min, fill = range * boundRate, stepRatio = fill / step, steps = step * Math.round(stepRatio);
return min + steps;
}
const SliderKeyDirection = {
Left: -1,
ArrowLeft: -1,
Up: 1,
ArrowUp: 1,
Right: 1,
ArrowRight: 1,
Down: -1,
ArrowDown: -1
};
class SliderEventsController extends ViewController {
#delegate;
#media;
#observer;
constructor(delegate, media) {
super();
this.#delegate = delegate;
this.#media = media;
}
onSetup() {
if (hasProvidedContext(sliderObserverContext)) {
this.#observer = useContext(sliderObserverContext);
}
}
onConnect(el) {
effect(this.#attachEventListeners.bind(this, el));
effect(this.#attachPointerListeners.bind(this, el));
if (this.#delegate.swipeGesture) effect(this.#watchSwipeGesture.bind(this));
}
#watchSwipeGesture() {
const { pointer } = this.#media.$state;
if (pointer() !== "coarse" || !this.#delegate.swipeGesture()) {
this.#provider = null;
return;
}
this.#provider = this.#media.player.el?.querySelector(
"media-provider,[data-media-provider]"
);
if (!this.#provider) return;
new EventsController(this.#provider).add("touchstart", this.#onTouchStart.bind(this), {
passive: true
}).add("touchmove", this.#onTouchMove.bind(this), { passive: false });
}
#provider = null;
#touch = null;
#touchStartValue = null;
#onTouchStart(event) {
this.#touch = event.touches[0];
}
#onTouchMove(event) {
if (isNull(this.#touch) || isTouchPinchEvent(event)) return;
const touch = event.touches[0], xDiff = touch.clientX - this.#touch.clientX, yDiff = touch.clientY - this.#touch.clientY, isDragging = this.$state.dragging();
if (!isDragging && Math.abs(yDiff) > 5) {
return;
}
if (isDragging) return;
event.preventDefault();
if (Math.abs(xDiff) > 20) {
this.#touch = touch;
this.#touchStartValue = this.$state.value();
this.#onStartDragging(this.#touchStartValue, event);
}
}
#attachEventListeners(el) {
const { hidden } = this.$props;
listenEvent(el, "focus", this.#onFocus.bind(this));
if (hidden() || this.#delegate.isDisabled()) return;
new EventsController(el).add("keyup", this.#onKeyUp.bind(this)).add("keydown", this.#onKeyDown.bind(this)).add("pointerenter", this.#onPointerEnter.bind(this)).add("pointermove", this.#onPointerMove.bind(this)).add("pointerleave", this.#onPointerLeave.bind(this)).add("pointerdown", this.#onPointerDown.bind(this));
}
#attachPointerListeners(el) {
if (this.#delegate.isDisabled() || !this.$state.dragging()) return;
new EventsController(document).add("pointerup", this.#onDocumentPointerUp.bind(this), { capture: true }).add("pointermove", this.#onDocumentPointerMove.bind(this)).add("touchmove", this.#onDocumentTouchMove.bind(this), {
passive: false
});
}
#onFocus() {
this.#updatePointerValue(this.$state.value());
}
#updateValue(newValue, trigger) {
const { value, min, max, dragging } = this.$state;
const clampedValue = Math.max(min(), Math.min(newValue, max()));
value.set(clampedValue);
const event = this.createEvent("value-change", { detail: clampedValue, trigger });
this.dispatch(event);
this.#delegate.onValueChange?.(event);
if (dragging()) {
const event2 = this.createEvent("drag-value-change", { detail: clampedValue, trigger });
this.dispatch(event2);
this.#delegate.onDragValueChange?.(event2);
}
}
#updatePointerValue(value, trigger) {
const { pointerValue, dragging } = this.$state;
pointerValue.set(value);
this.dispatch("pointer-value-change", { detail: value, trigger });
if (dragging()) {
this.#updateValue(value, trigger);
}
}
#getPointerValue(event) {
let thumbPositionRate, rect = this.el.getBoundingClientRect(), { min, max } = this.$state;
if (this.$props.orientation() === "vertical") {
const { bottom: trackBottom, height: trackHeight } = rect;
thumbPositionRate = (trackBottom - event.clientY) / trackHeight;
} else {
if (this.#touch && isNumber(this.#touchStartValue)) {
const { width } = this.#provider.getBoundingClientRect(), rate = (event.clientX - this.#touch.clientX) / width, range = max() - min(), diff = range * Math.abs(rate);
thumbPositionRate = (rate < 0 ? this.#touchStartValue - diff : this.#touchStartValue + diff) / range;
} else {
const { left: trackLeft, width: trackWidth } = rect;
thumbPositionRate = (event.clientX - trackLeft) / trackWidth;
}
}
return Math.max(
min(),
Math.min(
max(),
this.#delegate.roundValue(
getValueFromRate(min(), max(), thumbPositionRate, this.#delegate.getStep())
)
)
);
}
#onPointerEnter(event) {
this.$state.pointing.set(true);
}
#onPointerMove(event) {
const { dragging } = this.$state;
if (dragging()) return;
this.#updatePointerValue(this.#getPointerValue(event), event);
}
#onPointerLeave(event) {
this.$state.pointing.set(false);
}
#onPointerDown(event) {
if (event.button !== 0) return;
const value = this.#getPointerValue(event);
this.#onStartDragging(value, event);
this.#updatePointerValue(value, event);
}
#onStartDragging(value, trigger) {
const { dragging } = this.$state;
if (dragging()) return;
dragging.set(true);
this.#media.remote.pauseControls(trigger);
const event = this.createEvent("drag-start", { detail: value, trigger });
this.dispatch(event);
this.#delegate.onDragStart?.(event);
this.#observer?.onDragStart?.();
}
#onStopDragging(value, trigger) {
const { dragging } = this.$state;
if (!dragging()) return;
dragging.set(false);
this.#media.remote.resumeControls(trigger);
const event = this.createEvent("drag-end", { detail: value, trigger });
this.dispatch(event);
this.#delegate.onDragEnd?.(event);
this.#touch = null;
this.#touchStartValue = null;
this.#observer?.onDragEnd?.();
}
// -------------------------------------------------------------------------------------------
// Keyboard Events
// -------------------------------------------------------------------------------------------
#lastDownKey;
#repeatedKeys = false;
#onKeyDown(event) {
const isValidKey = Object.keys(SliderKeyDirection).includes(event.key);
if (!isValidKey) return;
const { key } = event, jumpValue = this.#calcJumpValue(event);
if (!isNull(jumpValue)) {
this.#updatePointerValue(jumpValue, event);
this.#updateValue(jumpValue, event);
return;
}
const newValue = this.#calcNewKeyValue(event);
if (!this.#repeatedKeys) {
this.#repeatedKeys = key === this.#lastDownKey;
if (!this.$state.dragging() && this.#repeatedKeys) {
this.#onStartDragging(newValue, event);
}
}
this.#updatePointerValue(newValue, event);
this.#lastDownKey = key;
}
#onKeyUp(event) {
const isValidKey = Object.keys(SliderKeyDirection).includes(event.key);
if (!isValidKey || !isNull(this.#calcJumpValue(event))) return;
const newValue = this.#repeatedKeys ? this.$state.pointerValue() : this.#calcNewKeyValue(event);
this.#updateValue(newValue, event);
this.#onStopDragging(newValue, event);
this.#lastDownKey = "";
this.#repeatedKeys = false;
}
#calcJumpValue(event) {
let key = event.key, { min, max } = this.$state;
if (key === "Home" || key === "PageUp") {
return min();
} else if (key === "End" || key === "PageDown") {
return max();
} else if (!event.metaKey && /^[0-9]$/.test(key)) {
return (max() - min()) / 10 * Number(key);
}
return null;
}
#calcNewKeyValue(event) {
const { key, shiftKey } = event;
event.preventDefault();
event.stopPropagation();
const { shiftKeyMultiplier } = this.$props;
const { min, max, value, pointerValue } = this.$state, step = this.#delegate.getStep(), keyStep = this.#delegate.getKeyStep();
const modifiedStep = !shiftKey ? keyStep : keyStep * shiftKeyMultiplier(), direction = Number(SliderKeyDirection[key]), diff = modifiedStep * direction, currentValue = this.#repeatedKeys ? pointerValue() : this.#delegate.getValue?.() ?? value(), steps = (currentValue + diff) / step;
return Math.max(min(), Math.min(max(), Number((step * steps).toFixed(3))));
}
// -------------------------------------------------------------------------------------------
// Document (Pointer Events)
// -------------------------------------------------------------------------------------------
#onDocumentPointerUp(event) {
if (event.button !== 0) return;
event.preventDefault();
event.stopImmediatePropagation();
const value = this.#getPointerValue(event);
this.#updatePointerValue(value, event);
this.#onStopDragging(value, event);
}
#onDocumentTouchMove(event) {
event.preventDefault();
}
#onDocumentPointerMove = functionThrottle(
(event) => {
this.#updatePointerValue(this.#getPointerValue(event), event);
},
20,
{ leading: true }
);
}
const sliderValueFormatContext = createContext(() => ({}));
class SliderController extends ViewController {
static props = {
hidden: false,
disabled: false,
step: 1,
keyStep: 1,
orientation: "horizontal",
shiftKeyMultiplier: 5
};
#media;
#delegate;
#isVisible = signal(true);
#isIntersecting = signal(true);
constructor(delegate) {
super();
this.#delegate = delegate;
}
onSetup() {
this.#media = useMediaContext();
const focus = new FocusVisibleController();
focus.attach(this);
this.$state.focused = focus.focused.bind(focus);
if (!hasProvidedContext(sliderValueFormatContext)) {
provideContext(sliderValueFormatContext, {
default: "value"
});
}
provideContext(sliderContext, {
orientation: this.$props.orientation,
disabled: this.#delegate.isDisabled,
preview: signal(null)
});
effect(this.#watchValue.bind(this));
effect(this.#watchStep.bind(this));
effect(this.#watchDisabled.bind(this));
this.#setupAttrs();
new SliderEventsController(this.#delegate, this.#media).attach(this);
new IntersectionObserverController({
callback: this.#onIntersectionChange.bind(this)
}).attach(this);
}
onAttach(el) {
setAttributeIfEmpty(el, "role", "slider");
setAttributeIfEmpty(el, "tabindex", "0");
setAttributeIfEmpty(el, "autocomplete", "off");
effect(this.#watchCSSVars.bind(this));
}
onConnect(el) {
onDispose(observeVisibility(el, this.#isVisible.set));
effect(this.#watchHidden.bind(this));
}
#onIntersectionChange(entries) {
this.#isIntersecting.set(entries[0].isIntersecting);
}
// -------------------------------------------------------------------------------------------
// Watch
// -------------------------------------------------------------------------------------------
#watchHidden() {
const { hidden } = this.$props;
this.$state.hidden.set(hidden() || !this.#isVisible() || !this.#isIntersecting.bind(this));
}
#watchValue() {
const { dragging, value, min, max } = this.$state;
if (peek(dragging)) return;
value.set(getClampedValue(min(), max(), value(), this.#delegate.getStep()));
}
#watchStep() {
this.$state.step.set(this.#delegate.getStep());
}
#watchDisabled() {
if (!this.#delegate.isDisabled()) return;
const { dragging, pointing } = this.$state;
dragging.set(false);
pointing.set(false);
}
// -------------------------------------------------------------------------------------------
// ARIA
// -------------------------------------------------------------------------------------------
#getARIADisabled() {
return ariaBool(this.#delegate.isDisabled());
}
// -------------------------------------------------------------------------------------------
// Attributes
// -------------------------------------------------------------------------------------------
#setupAttrs() {
const { orientation } = this.$props, { dragging, active, pointing } = this.$state;
this.setAttributes({
"data-dragging": dragging,
"data-pointing": pointing,
"data-active": active,
"aria-disabled": this.#getARIADisabled.bind(this),
"aria-valuemin": this.#delegate.aria.valueMin ?? this.$state.min,
"aria-valuemax": this.#delegate.aria.valueMax ?? this.$state.max,
"aria-valuenow": this.#delegate.aria.valueNow,
"aria-valuetext": this.#delegate.aria.valueText,
"aria-orientation": orientation
});
}
#watchCSSVars() {
const { fillPercent, pointerPercent } = this.$state;
this.#updateSliderVars(round(fillPercent(), 3), round(pointerPercent(), 3));
}
#updateSliderVars = animationFrameThrottle((fillPercent, pointerPercent) => {
this.el?.style.setProperty("--slider-fill", fillPercent + "%");
this.el?.style.setProperty("--slider-pointer", pointerPercent + "%");
});
}
class Slider extends Component {
static props = {
...SliderController.props,
min: 0,
max: 100,
value: 0
};
static state = sliderState;
constructor() {
super();
new SliderController({
getStep: this.$props.step,
getKeyStep: this.$props.keyStep,
roundValue: Math.round,
isDisabled: this.$props.disabled,
aria: {
valueNow: this.#getARIAValueNow.bind(this),
valueText: this.#getARIAValueText.bind(this)
}
});
}
onSetup() {
effect(this.#watchValue.bind(this));
effect(this.#watchMinMax.bind(this));
}
// -------------------------------------------------------------------------------------------
// Props
// -------------------------------------------------------------------------------------------
#getARIAValueNow() {
const { value } = this.$state;
return Math.round(value());
}
#getARIAValueText() {
const { value, max } = this.$state;
return round(value() / max() * 100, 2) + "%";
}
// -------------------------------------------------------------------------------------------
// Watch
// -------------------------------------------------------------------------------------------
#watchValue() {
const { value } = this.$props;
this.$state.value.set(value());
}
#watchMinMax() {
const { min, max } = this.$props;
this.$state.min.set(min());
this.$state.max.set(max());
}
}
const cache = /* @__PURE__ */ new Map(), pending = /* @__PURE__ */ new Map(), warned = /* @__PURE__ */ new Set() ;
class ThumbnailsLoader {
#media;
#src;
#crossOrigin;
$images = signal([]);
static create(src, crossOrigin) {
const media = useMediaContext();
return new ThumbnailsLoader(src, crossOrigin, media);
}
constructor(src, crossOrigin, media) {
this.#src = src;
this.#crossOrigin = crossOrigin;
this.#media = media;
effect(this.#onLoadCues.bind(this));
}
#onLoadCues() {
const { canLoad } = this.#media.$state;
if (!canLoad()) return;
const src = this.#src();
if (!src) return;
if (isString(src) && cache.has(src)) {
const cues = cache.get(src);
cache.delete(src);
cache.set(src, cues);
if (cache.size > 99) {
const firstKey = cache.keys().next().value;
cache.delete(firstKey);
}
this.$images.set(cache.get(src));
} else if (isString(src)) {
const crossOrigin = this.#crossOrigin(), currentKey = src + "::" + crossOrigin;
if (!pending.has(currentKey)) {
const promise = new Promise(async (resolve, reject) => {
try {
const response = await fetch(src, {
credentials: getRequestCredentials(crossOrigin)
}), isJSON = response.headers.get("content-type") === "application/json";
if (isJSON) {
const json = await response.json();
if (isArray(json)) {
if (json[0] && "text" in json[0]) {
resolve(this.#processVTTCues(json));
} else {
for (let i = 0; i < json.length; i++) {
const image = json[i];
assert(isObject(image), `Item not an object at index ${i}`);
assert(
"url" in image && isString(image.url),
`Invalid or missing \`url\` property at index ${i}`
);
assert(
"startTime" in image && isNumber(image.startTime),
`Invalid or missing \`startTime\` property at index ${i}`
);
}
resolve(json);
}
} else {
resolve(this.#processStoryboard(json));
}
return;
}
import('media-captions').then(async ({ parseResponse }) => {
try {
const { cues } = await parseResponse(response);
resolve(this.#processVTTCues(cues));
} catch (e) {
reject(e);
}
});
} catch (e) {
reject(e);
}
}).then((images) => {
cache.set(currentKey, images);
return images;
}).catch((error) => {
this.#onError(src, error);
}).finally(() => {
if (isString(currentKey)) pending.delete(currentKey);
});
pending.set(currentKey, promise);
}
pending.get(currentKey)?.then((images) => {
this.$images.set(images || []);
});
} else if (isArray(src)) {
try {
this.$images.set(this.#processImages(src));
} catch (error) {
this.#onError(src, error);
}
} else {
try {
this.$images.set(this.#processStoryboard(src));
} catch (error) {
this.#onError(src, error);
}
}
return () => {
this.$images.set([]);
};
}
#processImages(images) {
const baseURL = this.#resolveBaseUrl();
return images.map((img, i) => {
assert(
img.url && isString(img.url),
`Invalid or missing \`url\` property at index ${i}`
);
assert(
"startTime" in img && isNumber(img.startTime),
`Invalid or missing \`startTime\` property at index ${i}`
);
return {
...img,
url: isString(img.url) ? this.#resolveURL(img.url, baseURL) : img.url
};
});
}
#processStoryboard(board) {
assert(isString(board.url), "Missing `url` in storyboard object");
assert(isArray(board.tiles) && board.tiles?.length, `Empty tiles in storyboard`);
const url = new URL(board.url), images = [];
const tileWidth = "tile_width" in board ? board.tile_width : board.tileWidth, tileHeight = "tile_height" in board ? board.tile_height : board.tileHeight;
for (const tile of board.tiles) {
images.push({
url,
startTime: "start" in tile ? tile.start : tile.startTime,
width: tileWidth,
height: tileHeight,
coords: { x: tile.x, y: tile.y }
});
}
return images;
}
#processVTTCues(cues) {
for (let i = 0; i < cues.length; i++) {
const cue = cues[i];
assert(
"startTime" in cue && isNumber(cue.startTime),
`Invalid or missing \`startTime\` property at index ${i}`
);
assert(
"text" in cue && isString(cue.text),
`Invalid or missing \`text\` property at index ${i}`
);
}
const images = [], baseURL = this.#resolveBaseUrl();
for (const cue of cues) {
const [url, hash] = cue.text.split("#"), data = this.#resolveData(hash);
images.push({
url: this.#resolveURL(url, baseURL),
startTime: cue.startTime,
endTime: cue.endTime,
width: data?.w,
height: data?.h,
coords: data && isNumber(data.x) && isNumber(data.y) ? { x: data.x, y: data.y } : void 0
});
}
return images;
}
#resolveBaseUrl() {
let baseURL = peek(this.#src);
if (!isString(baseURL) || !/^https?:/.test(baseURL)) {
return location.href;
}
return baseURL;
}
#resolveURL(src, baseURL) {
return /^https?:/.test(src) ? new URL(src) : new URL(src, baseURL);
}
#resolveData(hash) {
if (!hash) return {};
const [hashProps, values] = hash.split("="), hashValues = values?.split(","), data = {};
if (!hashProps || !hashValues) {
return null;
}
for (let i = 0; i < hashProps.length; i++) {
const value = +hashValues[i];
if (!isNaN(value)) data[hashProps[i]] = value;
}
return data;
}
#onError(src, error) {
if (warned?.has(src)) return;
this.#media.logger?.errorGroup("[vidstack] failed to load thumbnails").labelledLog("Src", src).labelledLog("Error", error).dispatch();
warned?.add(src);
}
}
class Thumbnail extends Component {
static props = {
src: null,
time: 0,
crossOrigin: null
};
static state = new State({
src: "",
img: null,
thumbnails: [],
activeThumbnail: null,
crossOrigin: null,
loading: false,
error: null,
hidden: false
});
media;
#loader;
#styleResets = [];
onSetup() {
this.media = useMediaContext();
this.#loader = ThumbnailsLoader.create(this.$props.src, this.$state.crossOrigin);
this.#watchCrossOrigin();
this.setAttributes({
"data-loading": this.#isLoading.bind(this),
"data-error": this.#hasError.bind(this),
"data-hidden": this.$state.hidden,
"aria-hidden": $ariaBool(this.$state.hidden)
});
}
onConnect(el) {
effect(this.#watchImg.bind(this));
effect(this.#watchHidden.bind(this));
effect(this.#watchCrossOrigin.bind(this));
effect(this.#onLoadStart.bind(this));
effect(this.#onFindActiveThumbnail.bind(this));
effect(this.#resize.bind(this));
}
#watchImg() {
const img = this.$state.img();
if (!img) return;
new EventsController(img).add("load", this.#onLoaded.bind(this)).add("error", this.#onError.bind(this));
}
#watchCrossOrigin() {
const { crossOrigin: crossOriginProp } = this.$props, { crossOrigin: crossOriginState } = this.$state, { crossOrigin: mediaCrossOrigin } = this.media.$state, crossOrigin = crossOriginProp() !== null ? crossOriginProp() : mediaCrossOrigin();
crossOriginState.set(crossOrigin === true ? "anonymous" : crossOrigin);
}
#onLoadStart() {
const { src, loading, error } = this.$state;
if (src()) {
loading.set(true);
error.set(null);
}
return () => {
this.#resetStyles();
loading.set(false);
error.set(null);
};
}
#onLoaded() {
const { loading, error } = this.$state;
this.#resize();
loading.set(false);
error.set(null);
}
#onError(event) {
const { loading, error } = this.$state;
loading.set(false);
error.set(event);
}
#isLoading() {
const { loading, hidden } = this.$state;
return !hidden() && loading();
}
#hasError() {
const { error } = this.$state;
return !isNull(error());
}
#watchHidden() {
const { hidden } = this.$state, { duration } = this.media.$state, images = this.#loader.$images();
hidden.set(this.#hasError() || !Number.isFinite(duration()) || images.length === 0);
}
getTime() {
return this.$props.time();
}
#onFindActiveThumbnail() {
let images = this.#loader.$images();
if (!images.length) return;
let time = this.getTime(), { src, activeThumbnail } = this.$state, activeIndex = -1, activeImage = null;
for (let i = images.length - 1; i >= 0; i--) {
const image = images[i];
if (time >= image.startTime && (!image.endTime || time < image.endTime)) {
activeIndex = i;
break;
}
}
if (images[activeIndex]) {
activeImage = images[activeIndex];
}
activeThumbnail.set(activeImage);
src.set(activeImage?.url.href || "");
}
#resize() {
if (!this.scope || this.$state.hidden()) return;
const rootEl = this.el, imgEl = this.$state.img(), thumbnail = this.$state.activeThumbnail();
if (!imgEl || !thumbnail || !rootEl) return;
let width = thumbnail.width ?? imgEl.naturalWidth, height = thumbnail?.height ?? imgEl.naturalHeight, {
maxWidth,
maxHeight,
minWidth,
minHeight,
width: elWidth,
height: elHeight
} = getComputedStyle(this.el);
if (minWidth === "100%") minWidth = parseFloat(elWidth) + "";
if (minHeight === "100%") minHeight = parseFloat(elHeight) + "";
let minRatio = Math.max(parseInt(minWidth) / width, parseInt(minHeight) / height), maxRatio = Math.min(
Math.max(parseInt(minWidth), parseInt(maxWidth)) / width,
Math.max(parseInt(minHeight), parseInt(maxHeight)) / height
), scale = !isNaN(maxRatio) && maxRatio < 1 ? maxRatio : minRatio > 1 ? minRatio : 1;
this.#style(rootEl, "--thumbnail-width", `${width * scale}px`);
this.#style(rootEl, "--thumbnail-height", `${height * scale}px`);
this.#style(rootEl, "--thumbnail-aspect-ratio", String(round(width / height, 5)));
this.#style(imgEl, "width", `${imgEl.naturalWidth * scale}px`);
this.#style(imgEl, "height", `${imgEl.naturalHeight * scale}px`);
this.#style(
imgEl,
"transform",
thumbnail.coords ? `translate(-${thumbnail.coords.x * scale}px, -${thumbnail.coords.y * scale}px)` : ""
);
this.#style(imgEl, "max-width", "none");
}
#style(el, name, value) {
el.style.setProperty(name, value);
this.#styleResets.push(() => el.style.removeProperty(name));
}
#resetStyles() {
for (const reset of this.#styleResets) reset();
this.#styleResets = [];
}
}
class SliderValue extends Component {
static props = {
type: "pointer",
format: null,
showHours: false,
showMs: false,
padHours: null,
padMinutes: null,
decimalPlaces: 2
};
#format;
#text;
#slider;
onSetup() {
this.#slider = useState(Slider.state);
this.#format = useContext(sliderValueFormatContext);
this.#text = computed(this.getValueText.bind(this));
}
/**
* Returns the current value formatted as text based on prop settings.
*/
getValueText() {
const {
type,
format: $format,
decimalPlaces,
padHours,
padMinutes,
showHours,
showMs
} = this.$props, { value: sliderValue, pointerValue, min, max } = this.#slider, format = $format?.() ?? this.#format.default;
const value = type() === "current" ? sliderValue() : pointerValue();
if (format === "percent") {
const range = max() - min();
const percent = value / range * 100;
return (this.#format.percent ?? round)(percent, decimalPlaces()) + "%";
} else if (format === "time") {
return (this.#format.time ?? formatTime)(value, {
padHrs: padHours(),
padMins: padMinutes(),
showHrs: showHours(),
showMs: showMs()
});
} else {
return (this.#format.value?.(value) ?? value.toFixed(2)) + "";
}
}
}
const slidervalue__proto = SliderValue.prototype;
method(slidervalue__proto, "getValueText");
class SliderPreview extends Component {
static props = {
offset: 0,
noClamp: false
};
#slider;
onSetup() {
this.#slider = useContext(sliderContext);
const { active } = useState(Slider.state);
this.setAttributes({
"data-visible": active
});
}
onAttach(el) {
Object.assign(el.style, {
position: "absolute",
top: 0,
left: 0,
width: "max-content"
});
}
onConnect(el) {
const { preview } = this.#slider;
preview.set(el);
onDispose(() => preview.set(null));
effect(this.#updatePlacement.bind(this));
const resize = new ResizeObserver(this.#updatePlacement.bind(this));
resize.observe(el);
onDispose(() => resize.disconnect());
}
#updatePlacement = animationFrameThrottle(() => {
const { disabled, orientation } = this.#slider;
if (disabled()) return;
const el = this.el, { offset, noClamp } = this.$props;
if (!el) return;
updateSliderPreviewPlacement(el, {
clamp: !noClamp(),
offset: offset(),
orientation: orientation()
});
});
}
function updateSliderPreviewPlacement(el, {
clamp,
offset,
orientation
}) {
const computedStyle = getComputedStyle(el), width = parseFloat(computedStyle.width), height = parseFloat(computedStyle.height), styles = {
top: null,
right: null,
bottom: null,
left: null
};
styles[orientation === "horizontal" ? "bottom" : "left"] = `calc(100% + var(--media-slider-preview-offset, ${offset}px))`;
if (orientation === "horizontal") {
const widthHalf = width / 2;
if (!clamp) {
styles.left = `calc(var(--slider-pointer) - ${widthHalf}px)`;
} else {
const leftClamp = `max(0px, calc(var(--slider-pointer) - ${widthHalf}px))`, rightClamp = `calc(100% - ${width}px)`;
styles.left = `min(${leftClamp}, ${rightClamp})`;
}
} else {
const heightHalf = height / 2;
if (!clamp) {
styles.bottom = `calc(var(--slider-pointer) - ${heightHalf}px)`;
} else {
const topClamp = `max(${heightHalf}px, calc(var(--slider-pointer) - ${heightHalf}px))`, bottomClamp = `calc(100% - ${height}px)`;
styles.bottom = `min(${topClamp}, ${bottomClamp})`;
}
}
Object.assign(el.style, styles);
}
class VolumeSlider extends Component {
static props = {
...SliderController.props,
keyStep: 5,
shiftKeyMultiplier: 2
};
static state = sliderState;
#media;
onSetup() {
this.#media = useMediaContext();
const { audioGain } = this.#media.$state;
provideContext(sliderValueFormatContext, {
default: "percent",
value(value) {
return (value * (audioGain() ?? 1)).toFixed(2);
},
percent(value) {
return Math.round(value * (audioGain() ?? 1));
}
});
new SliderController({
getStep: this.$props.step,
getKeyStep: this.$props.keyStep,
roundValue: Math.round,
isDisabled: this.#isDisabled.bind(this),
aria: {
valueMax: this.#getARIAValueMax.bind(this),
valueNow: this.#getARIAValueNow.bind(this),
valueText: this.#getARIAValueText.bind(this)
},
onDragValueChange: this.#onDragValueChange.bind(this),
onValueChange: this.#onValueChange.bind(this)
}).attach(this);
effect(this.#watchVolume.bind(this));
}
onAttach(el) {
el.setAttribute("data-media-volume-slider", "");
setAttributeIfEmpty(el, "aria-label", "Volume");
const { canSetVolume } = this.#media.$state;
this.setAttributes({
"data-supported": canSetVolume,
"aria-hidden": $ariaBool(() => !canSetVolume())
});
}
#getARIAValueNow() {
const { value } = this.$state, { audioGain } = this.#media.$state;
return Math.round(value() * (audioGain() ?? 1));
}
#getARIAValueText() {
const { value, max } = this.$state, { audioGain } = this.#media.$state;
return round(value() / max() * (audioGain() ?? 1) * 100, 2) + "%";
}
#getARIAValueMax() {
const { audioGain } = this.#media.$state;
return this.$state.max() * (audioGain() ?? 1);
}
#isDisabled() {
const { disabled } = this.$props, { canSetVolume } = this.#media.$state;
return disabled() || !canSetVolume();
}
#watchVolume() {
const { muted, volume } = this.#media.$state;
const newValue = muted() ? 0 : volume() * 100;
this.$state.value.set(newValue);
this.dispatch("value-change", { detail: newValue });
}
#throttleVolumeChange = functionThrottle(this.#onVolumeChange.bind(this), 25);
#onVolumeChange(event) {
if (!event.trigger) return;
const mediaVolume = round(event.detail / 100, 3);
this.#media.remote.changeVolume(mediaVolume, event);
}
#onValueChange(event) {
this.#throttleVolumeChange(event);
}
#onDragValueChange(event) {
this.#throttleVolumeChange(event);
}
}
class TimeSlider extends Component {
static props = {
...SliderController.props,
step: 0.1,
keyStep: 5,
shiftKeyMultiplier: 2,
pauseWhileDragging: false,
noSwipeGesture: false,
seekingRequestThrottle: 100
};
static state = sliderState;
#media;
#dispatchSeeking;
#chapter = signal(null);
constructor() {
super();
const { noSwipeGesture } = this.$props;
new SliderController({
swipeGesture: () => !noSwipeGesture(),
getValue: this.#getValue.bind(this),
getStep: this.#getStep.bind(this),
getKeyStep: this.#getKeyStep.bind(this),
roundValue: this