UNPKG

@ktt45678/vidstack

Version:

UI component library for building high-quality, accessible video and audio experiences on the web.

1,534 lines (1,509 loc) 97.1 kB
import { ViewController, onDispose, isArray, isString, isNull, effect, peek, listenEvent, ariaBool, isWriteSignal, Component, State, createContext, functionThrottle, hasProvidedContext, useContext, isNumber, signal, animationFrameThrottle, provideContext, isObject, useState, computed, method, setAttribute, r, wasEnterKeyPressed, isKeyboardEvent, tick, prop, setStyle } from './vidstack-fG_Sx3Q9.js'; import { useMediaContext } from './vidstack-DQ4Fz5gz.js'; import { $ariaBool, sortVideoQualities } from './vidstack-BOTZD4tC.js'; import { hasAnimation, setAttributeIfEmpty, onPress, setARIALabel, isTouchPinchEvent, observeVisibility, isHTMLElement, isElementParent, isEventInside, isElementVisible, requestScopedAnimationFrame, autoPlacement } from './vidstack-DdUZGy1h.js'; import { isTrackCaptionKind } from './vidstack-DSRs3D8P.js'; import { FocusVisibleController } from './vidstack-DvBAQUpx.js'; import { assert } from './vidstack-DbBJlz7I.js'; import { getRequestCredentials } from './vidstack-BnCZ4oyK.js'; import { clampNumber, round, getNumberOfDecimalPlaces } from './vidstack-Dihypf8P.js'; import { watchActiveTextTrack } from './vidstack-C_9SlM6s.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; { 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 { 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(content, "animationend", onHide, { once: true }); 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), { passive: true }); listenEvent(this._provider, "touchmove", this._onTouchMove.bind(this), { passive: false }); } _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), { capture: true }); listenEvent(document, "pointermove", this._onDocumentPointerMove.bind(this)); listenEvent(document, "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?.(); } _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( (fillPercent, pointerPercent) => { this.el?.style.setProperty("--slider-fill", fillPercent + "%"); this.el?.style.setProperty("--slider-pointer", pointerPercent + "%"); } ); } 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"); 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._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(), warned = /* @__PURE__ */ new Set() ; 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), `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 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), `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; } _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), `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) { 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) { 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 { 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(() => { 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() }); }); } 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-suppo