@ktt45678/vidstack
Version:
UI component library for building high-quality, accessible video and audio experiences on the web.
1,544 lines (1,518 loc) • 97.5 kB
JavaScript
import { ViewController, isArray, isString, isNull, effect, peek, listenEvent, ariaBool, isWriteSignal, Component, State, onDispose, createContext, functionThrottle, hasProvidedContext, useContext, isNumber, signal, animationFrameThrottle, provideContext, isObject, useState, computed, method, setAttribute, r, wasEnterKeyPressed, isKeyboardEvent, tick, prop, setStyle } from './vidstack-41uXLVgN.js';
import { useMediaContext, hasAnimation, setAttributeIfEmpty, onPress, setARIALabel, isTouchPinchEvent, observeVisibility, getRequestCredentials, watchActiveTextTrack, isHTMLElement, isElementParent, isEventInside, requestScopedAnimationFrame, autoPlacement, preconnect } from './vidstack-DPeH8lGJ.js';
import { $ariaBool, sortVideoQualities } from './vidstack-BOTZD4tC.js';
import { FocusVisibleController, isTrackCaptionKind, clampNumber, round, getNumberOfDecimalPlaces, assert } from './vidstack-BI9udY6A.js';
class ARIAKeyShortcuts extends ViewController {
constructor(_shortcut) {
super();
this._shortcut = _shortcut;
}
onAttach(el) {
const { $props, ariaKeys } = useMediaContext(), keys = el.getAttribute("aria-keyshortcuts");
if (keys) {
ariaKeys[this._shortcut] = keys;
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 {
constructor(_delegate) {
super();
this._delegate = _delegate;
this._showTimerId = -1;
this._hideRafId = -1;
this._stopAnimationEndListener = null;
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);
}
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();
this._stopAnimationEndListener = stop;
} else {
onHide();
}
}
});
}
_cancelShowing() {
window.clearTimeout(this._showTimerId);
this._showTimerId = -1;
}
}
class ToggleButtonController extends ViewController {
constructor(_delegate) {
super();
this._delegate = _delegate;
new FocusVisibleController();
if (_delegate._keyShortcut) {
new ARIAKeyShortcuts(_delegate._keyShortcut);
}
}
static {
this.props = {
disabled: false
};
}
onSetup() {
const { disabled } = this.$props;
this.setAttributes({
"data-pressed": this._delegate._isPressed,
"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) {
onPress(el, this._onMaybePress.bind(this));
for (const type of ["click", "touchstart"]) {
this.listen(type, this._onInteraction.bind(this), {
passive: true
});
}
}
_isARIAPressed() {
return ariaBool(this._delegate._isPressed());
}
_onPressed(event) {
if (isWriteSignal(this._delegate._isPressed)) {
this._delegate._isPressed.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 {
this.props = ToggleButtonController.props;
}
constructor() {
super();
new ToggleButtonController({
_isPressed: 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 {
this.props = ToggleButtonController.props;
}
constructor() {
super();
new ToggleButtonController({
_isPressed: 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 {
this.props = ToggleButtonController.props;
}
constructor() {
super();
new ToggleButtonController({
_isPressed: 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 {
this.props = {
...ToggleButtonController.props,
target: "prefer-media"
};
}
constructor() {
super();
new ToggleButtonController({
_isPressed: 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 {
this.props = ToggleButtonController.props;
}
constructor() {
super();
new ToggleButtonController({
_isPressed: 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 {
this.props = ToggleButtonController.props;
}
constructor() {
super();
new ToggleButtonController({
_isPressed: 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 {
this.props = {
disabled: false,
seconds: 30
};
}
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 {
this.props = {
disabled: false
};
}
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 {
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 {
constructor(_delegate, _media) {
super();
this._delegate = _delegate;
this._media = _media;
this._provider = null;
this._touch = null;
this._touchStartValue = null;
this._repeatedKeys = false;
this._onDocumentPointerMove = functionThrottle(
(event) => {
this._updatePointerValue(this._getPointerValue(event), event);
},
20,
{ leading: true }
);
}
onSetup() {
if (hasProvidedContext(sliderObserverContext)) {
this._observer = useContext(sliderObserverContext);
}
}
onConnect() {
effect(this._attachEventListeners.bind(this));
effect(this._attachPointerListeners.bind(this));
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;
listenEvent(this._provider, "touchstart", this._onTouchStart.bind(this));
listenEvent(this._provider, "touchmove", this._onTouchMove.bind(this));
}
_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() {
const { hidden } = this.$props;
this.listen("focus", this._onFocus.bind(this));
this.listen("keydown", this._onKeyDown.bind(this));
this.listen("keyup", this._onKeyUp.bind(this));
if (hidden() || this._delegate._isDisabled()) return;
this.listen("pointerenter", this._onPointerEnter.bind(this));
this.listen("pointermove", this._onPointerMove.bind(this));
this.listen("pointerleave", this._onPointerLeave.bind(this));
this.listen("pointerdown", this._onPointerDown.bind(this));
}
_attachPointerListeners() {
if (this._delegate._isDisabled() || !this.$state.dragging()) return;
listenEvent(document, "pointerup", this._onDocumentPointerUp.bind(this));
listenEvent(document, "pointermove", this._onDocumentPointerMove.bind(this));
listenEvent(document, "touchmove", this._onDocumentTouchMove.bind(this));
}
_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?.();
}
_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();
}
}
const sliderValueFormatContext = createContext(() => ({}));
class SliderController extends ViewController {
constructor(_delegate) {
super();
this._delegate = _delegate;
this._isVisible = signal(true);
this._isIntersecting = signal(true);
this._updateSliderVars = animationFrameThrottle();
}
static {
this.props = {
hidden: false,
disabled: false,
step: 1,
keyStep: 1,
orientation: "horizontal",
shiftKeyMultiplier: 5
};
}
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");
this._watchCSSVars();
}
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._getARIAValueMin ?? this.$state.min,
"aria-valuemax": this._delegate._getARIAValueMax ?? this.$state.max,
"aria-valuenow": this._delegate._getARIAValueNow,
"aria-valuetext": this._delegate._getARIAValueText,
"aria-orientation": orientation
});
}
_watchCSSVars() {
const { fillPercent, pointerPercent } = this.$state;
this._updateSliderVars(round(fillPercent(), 3), round(pointerPercent(), 3));
}
}
class Slider extends Component {
static {
this.props = {
...SliderController.props,
min: 0,
max: 100,
value: 0
};
}
static {
this.state = sliderState;
}
constructor() {
super();
new SliderController({
_getStep: this.$props.step,
_getKeyStep: this.$props.keyStep,
_roundValue: Math.round,
_isDisabled: this.$props.disabled,
_getARIAValueNow: this._getARIAValueNow.bind(this),
_getARIAValueText: 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();
class ThumbnailsLoader {
constructor($src, $crossOrigin, _media) {
this.$src = $src;
this.$crossOrigin = $crossOrigin;
this._media = _media;
this.$images = signal([]);
this.$preloadedThumbs = signal([]);
effect(this._onLoadCues.bind(this));
}
static create($src, $crossOrigin) {
const media = useMediaContext();
return new ThumbnailsLoader($src, $crossOrigin, media);
}
_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), false);
assert(
"url" in image && isString(image.url),
false
);
assert(
"startTime" in image && isNumber(image.startTime),
false
);
}
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 if ("baseURL" in src) {
this.$images.set(this._processCDNSrc(src));
} 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));
assert(
"startTime" in img && isNumber(img.startTime));
return {
...img,
url: isString(img.url) ? this._resolveURL(img.url, baseURL) : img.url
};
});
}
_processStoryboard(board) {
assert(isString(board.url));
assert(isArray(board.tiles) && board.tiles?.length);
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;
}
_processCDNSrc(src) {
const baseURL = src.baseURL;
this.$preloadedThumbs.set([]);
const images = src.thumbs.map((img) => {
return {
...img,
url: isString(img.url) ? this._resolveURL(img.url, baseURL) : img.url
};
});
return images;
}
_processVTTCues(cues) {
for (let i = 0; i < cues.length; i++) {
const cue = cues[i];
assert(
"startTime" in cue && isNumber(cue.startTime));
assert(
"text" in cue && isString(cue.text));
}
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) {
let splitBaseURL = "";
if (!src.startsWith("/") && !src.startsWith("http://") && !src.startsWith("https://"))
splitBaseURL = baseURL.substring(0, baseURL.lastIndexOf("/") + 1);
const srcURL = splitBaseURL + src;
return new URL(srcURL);
}
_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) {
return;
}
}
class Thumbnail extends Component {
constructor() {
super(...arguments);
this._styleResets = [];
}
static {
this.props = {
src: null,
time: 0,
crossOrigin: null,
cdnURL: null,
cdnPreload: false
};
}
static {
this.state = new State({
src: "",
img: null,
thumbnails: [],
activeThumbnail: null,
crossOrigin: null,
loading: false,
error: null,
hidden: false
});
}
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;
listenEvent(img, "load", this._onLoaded.bind(this));
listenEvent(img, "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);
let srcURL = activeImage?.url.href || "";
const cdnURL = this.$props.cdnURL();
if (cdnURL) {
if (this.$props.cdnPreload()) {
const preloadedThumbs = this._loader.$preloadedThumbs();
if (!preloadedThumbs.includes(srcURL)) {
const preloadSrcURL = cdnURL.replace("{url}", encodeURIComponent(srcURL));
fetch(preloadSrcURL, { method: "HEAD" }).then(() => {
if (!this._loader.$preloadedThumbs().includes(srcURL))
this._loader.$preloadedThumbs.set([...preloadedThumbs, srcURL]);
}).catch((error) => this._onError(error));
} else {
srcURL = cdnURL.replace("{url}", encodeURIComponent(srcURL));
}
}
}
src.set(srcURL);
}
_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`, false);
this._style(rootEl, "--thumbnail-height", `${height * scale}px`, false);
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, reset = true) {
el.style.setProperty(name, value);
if (reset) this._styleResets.push(() => el.style.removeProperty(name));
}
_resetStyles() {
for (const reset of this._styleResets) reset();
this._styleResets = [];
}
}
var __defProp$6 = Object.defineProperty;
var __getOwnPropDesc$6 = Object.getOwnPropertyDescriptor;
var __decorateClass$6 = (decorators, target, key, kind) => {
var result = __getOwnPropDesc$6(target, key) ;
for (var i = decorators.length - 1, decorator; i >= 0; i--)
if (decorator = decorators[i])
result = (decorator(target, key, result) ) || result;
if (result) __defProp$6(target, key, result);
return result;
};
class SliderValue extends Component {
static {
this.props = {
type: "pointer",
format: null,
showHours: false,
showMs: false,
padHours: null,
padMinutes: null,
decimalPlaces: 2
};
}
onSetup() {
this._slider = useState(Slider.state);
this._format = useContext(sliderValueFormatContext);
this._text = computed(this.getValueText.bind(this));
}
getValueText() {
const { type, 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)) + "";
}
}
}
__decorateClass$6([
method
], SliderValue.prototype, "getValueText");
class SliderPreview extends Component {
constructor() {
super(...arguments);
this._updatePlacement = animationFrameThrottle();
}
static {
this.props = {
offset: 0,
noClamp: false
};
}
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());
}
}
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 {
constructor() {
super(...arguments);
this._throttleVolumeChange = functionThrottle(this._onVolumeChange.bind(this), 25);
}
static {
this.props = {
...SliderController.props,
keyStep: 5,
shiftKeyMultiplier: 2
};
}
static {
this.state = sliderState;
}
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),
_getARIAValueMax: this._getARIAValueMax.bind(this),
_getARIAValueNow: this._getARIAValueNow.bind(this),
_getARIAValueText: 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 });
}
_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 {
constructor() {
super();
this._chapter = signal(null);
this._playingBeforeDragStart = false;
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._roundValue,
_isDisabled: this._isDisabled.bind(this),
_getARIAValueNow: this._getARIAValueNow.bind(th