UNPKG

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