@aidenlx/vidstack-react
Version:
UI component library for building high-quality, accessible video and audio experiences on the web.
1,649 lines (1,625 loc) • 371 kB
JavaScript
"use client"
import * as React from 'react';
import { composeRefs, useStateContext, useSignal, useSignalRecord } from 'maverick.js/react';
import { getScope, scoped, State, tick, createContext, useContext, Component, effect, onDispose, untrack, ViewController, signal, peek, createScope, provideContext, computed, prop, method, isWriteSignal, hasProvidedContext, useState } from 'maverick.js';
import { isNumber, isString, isFunction, waitTimeout, isUndefined, isArray, isNull, deferredPromise, isBoolean, listenEvent, EventsTarget, DOMEvent, isTouchEvent, setStyle, EventsController, isKeyboardClick, setAttribute, isDOMNode, isKeyboardEvent, isNil, camelToKebabCase, waitIdlePeriod, animationFrameThrottle, uppercaseFirstChar, noop, ariaBool as ariaBool$1, isObject, wasEnterKeyPressed, isPointerEvent, isMouseEvent, kebabToCamelCase } from 'maverick.js/std';
import { fscreen, functionThrottle, functionDebounce, r } from './vidstack-CPShcCv0.js';
import { autoUpdate, computePosition, flip, shift } from '@floating-ui/dom';
function isVideoQualitySrc(src) {
return !isString(src) && "width" in src && "height" in src && isNumber(src.width) && isNumber(src.height);
}
const IS_SERVER = typeof document === "undefined";
const UA = IS_SERVER ? "" : navigator?.userAgent.toLowerCase() || "";
const IS_IOS = !IS_SERVER && /iphone|ipad|ipod|ios|crios|fxios/i.test(UA);
const IS_IPHONE = !IS_SERVER && /(iphone|ipod)/gi.test(navigator?.platform || "");
const IS_CHROME = !IS_SERVER && !!window.chrome;
const IS_SAFARI = !IS_SERVER && (!!window.safari || IS_IOS);
function canOrientScreen() {
return canRotateScreen() && isFunction(screen.orientation.unlock);
}
function canRotateScreen() {
return !IS_SERVER && !isUndefined(window.screen.orientation) && !isUndefined(window.screen.orientation.lock);
}
function canPlayAudioType(audio, type) {
if (IS_SERVER) return false;
if (!audio) audio = document.createElement("audio");
return audio.canPlayType(type).length > 0;
}
function canPlayVideoType(video, type) {
if (IS_SERVER) return false;
if (!video) video = document.createElement("video");
return video.canPlayType(type).length > 0;
}
function canPlayHLSNatively(video) {
if (IS_SERVER) return false;
if (!video) video = document.createElement("video");
return video.canPlayType("application/vnd.apple.mpegurl").length > 0;
}
function canUsePictureInPicture(video) {
if (IS_SERVER) return false;
return !!document.pictureInPictureEnabled && !video?.disablePictureInPicture;
}
function canUseVideoPresentation(video) {
if (IS_SERVER) return false;
return isFunction(video?.webkitSupportsPresentationMode) && isFunction(video?.webkitSetPresentationMode);
}
async function canChangeVolume() {
const video = document.createElement("video");
video.volume = 0.5;
await waitTimeout(0);
return video.volume === 0.5;
}
function getMediaSource() {
return IS_SERVER ? void 0 : window?.ManagedMediaSource ?? window?.MediaSource ?? window?.WebKitMediaSource;
}
function getSourceBuffer() {
return IS_SERVER ? void 0 : window?.SourceBuffer ?? window?.WebKitSourceBuffer;
}
function isHLSSupported() {
if (IS_SERVER) return false;
const MediaSource = getMediaSource();
if (isUndefined(MediaSource)) return false;
const isTypeSupported = MediaSource && isFunction(MediaSource.isTypeSupported) && MediaSource.isTypeSupported('video/mp4; codecs="avc1.42E01E,mp4a.40.2"');
const SourceBuffer = getSourceBuffer();
const isSourceBufferValid = isUndefined(SourceBuffer) || !isUndefined(SourceBuffer.prototype) && isFunction(SourceBuffer.prototype.appendBuffer) && isFunction(SourceBuffer.prototype.remove);
return !!isTypeSupported && !!isSourceBufferValid;
}
function isDASHSupported() {
return isHLSSupported();
}
class TimeRange {
#ranges;
get length() {
return this.#ranges.length;
}
constructor(start, end) {
if (isArray(start)) {
this.#ranges = start;
} else if (!isUndefined(start) && !isUndefined(end)) {
this.#ranges = [[start, end]];
} else {
this.#ranges = [];
}
}
start(index) {
throwIfEmpty(this.#ranges.length);
throwIfOutOfRange("start", index, this.#ranges.length - 1);
return this.#ranges[index][0] ?? Infinity;
}
end(index) {
throwIfEmpty(this.#ranges.length);
throwIfOutOfRange("end", index, this.#ranges.length - 1);
return this.#ranges[index][1] ?? Infinity;
}
}
function getTimeRangesStart(range) {
if (!range.length) return null;
let min = range.start(0);
for (let i = 1; i < range.length; i++) {
const value = range.start(i);
if (value < min) min = value;
}
return min;
}
function getTimeRangesEnd(range) {
if (!range.length) return null;
let max = range.end(0);
for (let i = 1; i < range.length; i++) {
const value = range.end(i);
if (value > max) max = value;
}
return max;
}
function throwIfEmpty(length) {
if (!length) throw new Error("`TimeRanges` object is empty." );
}
function throwIfOutOfRange(fnName, index, end) {
if (!isNumber(index) || index < 0 || index > end) {
throw new Error(
`Failed to execute '${fnName}' on 'TimeRanges': The index provided (${index}) is non-numeric or out of bounds (0-${end}).`
);
}
}
function normalizeTimeIntervals(intervals) {
if (intervals.length <= 1) {
return intervals;
}
intervals.sort((a, b) => a[0] - b[0]);
let normalized = [], current = intervals[0];
for (let i = 1; i < intervals.length; i++) {
const next = intervals[i];
if (current[1] >= next[0] - 1) {
current = [current[0], Math.max(current[1], next[1])];
} else {
normalized.push(current);
current = next;
}
}
normalized.push(current);
return normalized;
}
function updateTimeIntervals(intervals, interval, value) {
let start = interval[0], end = interval[1];
if (value < start) {
return [value, -1];
} else if (value === start) {
return interval;
} else if (start === -1) {
interval[0] = value;
return interval;
} else if (value > start) {
interval[1] = value;
if (end === -1) intervals.push(interval);
}
normalizeTimeIntervals(intervals);
return interval;
}
const AUDIO_EXTENSIONS = /\.(m4a|m4b|mp4a|mpga|mp2|mp2a|mp3|m2a|m3a|wav|weba|aac|oga|spx|flac)($|\?)/i;
const AUDIO_TYPES = /* @__PURE__ */ new Set([
"audio/mpeg",
"audio/ogg",
"audio/3gp",
"audio/mp3",
"audio/webm",
"audio/flac",
"audio/m4a",
"audio/m4b",
"audio/mp4a",
"audio/mp4"
]);
const VIDEO_EXTENSIONS = /\.(mp4|og[gv]|webm|mov|m4v)(#t=[,\d+]+)?($|\?)/i;
const VIDEO_TYPES = /* @__PURE__ */ new Set([
"video/mp4",
"video/webm",
"video/3gp",
"video/ogg",
"video/avi",
"video/mpeg"
]);
const HLS_VIDEO_EXTENSIONS = /\.(m3u8)($|\?)/i;
const DASH_VIDEO_EXTENSIONS = /\.(mpd)($|\?)/i;
const HLS_VIDEO_TYPES = /* @__PURE__ */ new Set([
// Apple sanctioned
"application/vnd.apple.mpegurl",
// Apple sanctioned for backwards compatibility
"audio/mpegurl",
// Very common
"audio/x-mpegurl",
// Very common
"application/x-mpegurl",
// Included for completeness
"video/x-mpegurl",
"video/mpegurl",
"application/mpegurl"
]);
const DASH_VIDEO_TYPES = /* @__PURE__ */ new Set(["application/dash+xml"]);
function isAudioSrc({ src, type }) {
return isString(src) ? AUDIO_EXTENSIONS.test(src) || AUDIO_TYPES.has(type) || src.startsWith("blob:") && type === "audio/object" : type === "audio/object";
}
function isVideoSrc(src) {
return isString(src.src) ? VIDEO_EXTENSIONS.test(src.src) || VIDEO_TYPES.has(src.type) || src.src.startsWith("blob:") && src.type === "video/object" || isHLSSrc(src) && (IS_SERVER || canPlayHLSNatively()) : src.type === "video/object";
}
function isHLSSrc({ src, type }) {
return isString(src) && HLS_VIDEO_EXTENSIONS.test(src) || HLS_VIDEO_TYPES.has(type);
}
function isDASHSrc({ src, type }) {
return isString(src) && DASH_VIDEO_EXTENSIONS.test(src) || DASH_VIDEO_TYPES.has(type);
}
function canGoogleCastSrc(src) {
return isString(src.src) && (isAudioSrc(src) || isVideoSrc(src) || isHLSSrc(src));
}
function isMediaStream(src) {
return !IS_SERVER && typeof window.MediaStream !== "undefined" && src instanceof window.MediaStream;
}
function appendParamsToURL(baseUrl, params) {
const url = new URL(baseUrl);
for (const key of Object.keys(params)) {
url.searchParams.set(key, params[key] + "");
}
return url.toString();
}
function preconnect(url, rel = "preconnect") {
if (IS_SERVER) return false;
const exists = document.querySelector(`link[href="${url}"]`);
if (!isNull(exists)) return true;
const link = document.createElement("link");
link.rel = rel;
link.href = url;
link.crossOrigin = "true";
document.head.append(link);
return true;
}
const pendingRequests = {};
function loadScript(src) {
if (pendingRequests[src]) return pendingRequests[src].promise;
const promise = deferredPromise(), exists = document.querySelector(`script[src="${src}"]`);
if (!isNull(exists)) {
promise.resolve();
return promise.promise;
}
pendingRequests[src] = promise;
const script = document.createElement("script");
script.src = src;
script.onload = () => {
promise.resolve();
delete pendingRequests[src];
};
script.onerror = () => {
promise.reject();
delete pendingRequests[src];
};
setTimeout(() => document.head.append(script), 0);
return promise.promise;
}
function getRequestCredentials(crossOrigin) {
return crossOrigin === "use-credentials" ? "include" : isString(crossOrigin) ? "same-origin" : void 0;
}
function getDownloadFile({
title,
src,
download
}) {
const url = isBoolean(download) || download === "" ? src.src : isString(download) ? download : download?.url;
if (!isValidFileDownload({ url, src, download })) return null;
return {
url,
name: !isBoolean(download) && !isString(download) && download?.filename || title.toLowerCase() || "media"
};
}
function isValidFileDownload({
url,
src,
download
}) {
return isString(url) && (download && download !== true || isAudioSrc(src) || isVideoSrc(src));
}
const CROSS_ORIGIN = Symbol("TEXT_TRACK_CROSS_ORIGIN" ), READY_STATE = Symbol("TEXT_TRACK_READY_STATE" ), UPDATE_ACTIVE_CUES = Symbol("TEXT_TRACK_UPDATE_ACTIVE_CUES" ), CAN_LOAD = Symbol("TEXT_TRACK_CAN_LOAD" ), ON_MODE_CHANGE = Symbol("TEXT_TRACK_ON_MODE_CHANGE" ), NATIVE = Symbol("TEXT_TRACK_NATIVE" ), NATIVE_HLS = Symbol("TEXT_TRACK_NATIVE_HLS" );
const TextTrackSymbol = {
crossOrigin: CROSS_ORIGIN,
readyState: READY_STATE,
updateActiveCues: UPDATE_ACTIVE_CUES,
canLoad: CAN_LOAD,
onModeChange: ON_MODE_CHANGE,
native: NATIVE,
nativeHLS: NATIVE_HLS
};
function findActiveCue(cues, time) {
for (let i = 0, len = cues.length; i < len; i++) {
if (isCueActive(cues[i], time)) return cues[i];
}
return null;
}
function isCueActive(cue, time) {
return time >= cue.startTime && time < cue.endTime;
}
function watchActiveTextTrack(tracks, kind, onChange) {
let currentTrack = null, scope = getScope();
function onModeChange() {
const kinds = isString(kind) ? [kind] : kind, track = tracks.toArray().find((track2) => kinds.includes(track2.kind) && track2.mode === "showing");
if (track === currentTrack) return;
if (!track) {
onChange(null);
currentTrack = null;
return;
}
if (track.readyState == 2) {
onChange(track);
} else {
onChange(null);
scoped(() => {
const off = listenEvent(
track,
"load",
() => {
onChange(track);
off();
},
{ once: true }
);
}, scope);
}
currentTrack = track;
}
onModeChange();
return listenEvent(tracks, "mode-change", onModeChange);
}
function watchCueTextChange(tracks, kind, callback) {
watchActiveTextTrack(tracks, kind, (track) => {
if (!track) {
callback("");
return;
}
const onCueChange = () => {
const activeCue = track?.activeCues[0];
callback(activeCue?.text || "");
};
onCueChange();
listenEvent(track, "cue-change", onCueChange);
});
}
class TextTrack extends EventsTarget {
static createId(track) {
return `vds-${track.type}-${track.kind}-${track.src ?? track.label ?? "?"}`;
}
src;
content;
type;
encoding;
id = "";
label = "";
language = "";
kind;
default = false;
#canLoad = false;
#currentTime = 0;
#mode = "disabled";
#metadata = {};
#regions = [];
#cues = [];
#activeCues = [];
/** @internal */
[TextTrackSymbol.readyState] = 0;
/** @internal */
[TextTrackSymbol.crossOrigin];
/** @internal */
[TextTrackSymbol.onModeChange] = null;
/** @internal */
[TextTrackSymbol.native] = null;
get metadata() {
return this.#metadata;
}
get regions() {
return this.#regions;
}
get cues() {
return this.#cues;
}
get activeCues() {
return this.#activeCues;
}
/**
* - 0: Not Loading
* - 1: Loading
* - 2: Ready
* - 3: Error
*/
get readyState() {
return this[TextTrackSymbol.readyState];
}
get mode() {
return this.#mode;
}
set mode(mode) {
this.setMode(mode);
}
#fetch;
constructor(init) {
super();
this.#fetch = init.fetch ?? fetch;
for (const prop of Object.keys(init)) this[prop] = init[prop];
if (!this.type) this.type = "vtt";
if (!IS_SERVER && init.content) {
this.#parseContent(init);
} else if (!init.src) {
this[TextTrackSymbol.readyState] = 2;
}
if (isTrackCaptionKind(this) && !this.label) {
console.warn(`[vidstack] captions text track created without label: \`${this.src}\``);
}
}
addCue(cue, trigger) {
let i = 0, length = this.#cues.length;
for (i = 0; i < length; i++) if (cue.endTime <= this.#cues[i].startTime) break;
if (i === length) this.#cues.push(cue);
else this.#cues.splice(i, 0, cue);
if (!(cue instanceof TextTrackCue)) {
this[TextTrackSymbol.native]?.track.addCue(cue);
}
this.dispatchEvent(new DOMEvent("add-cue", { detail: cue, trigger }));
if (isCueActive(cue, this.#currentTime)) {
this[TextTrackSymbol.updateActiveCues](this.#currentTime, trigger);
}
}
removeCue(cue, trigger) {
const index = this.#cues.indexOf(cue);
if (index >= 0) {
const isActive = this.#activeCues.includes(cue);
this.#cues.splice(index, 1);
this[TextTrackSymbol.native]?.track.removeCue(cue);
this.dispatchEvent(new DOMEvent("remove-cue", { detail: cue, trigger }));
if (isActive) {
this[TextTrackSymbol.updateActiveCues](this.#currentTime, trigger);
}
}
}
setMode(mode, trigger) {
if (this.#mode === mode) return;
this.#mode = mode;
if (mode === "disabled") {
this.#activeCues = [];
this.#activeCuesChanged();
} else if (this.readyState === 2) {
this[TextTrackSymbol.updateActiveCues](this.#currentTime, trigger);
} else {
this.#load();
}
this.dispatchEvent(new DOMEvent("mode-change", { detail: this, trigger }));
this[TextTrackSymbol.onModeChange]?.();
}
/** @internal */
[TextTrackSymbol.updateActiveCues](currentTime, trigger) {
this.#currentTime = currentTime;
if (this.mode === "disabled" || !this.#cues.length) return;
const activeCues = [];
for (let i = 0, length = this.#cues.length; i < length; i++) {
const cue = this.#cues[i];
if (isCueActive(cue, currentTime)) activeCues.push(cue);
}
let changed = activeCues.length !== this.#activeCues.length;
if (!changed) {
for (let i = 0; i < activeCues.length; i++) {
if (!this.#activeCues.includes(activeCues[i])) {
changed = true;
break;
}
}
}
this.#activeCues = activeCues;
if (changed) this.#activeCuesChanged(trigger);
}
/** @internal */
[TextTrackSymbol.canLoad]() {
this.#canLoad = true;
if (this.#mode !== "disabled") this.#load();
}
#parseContent(init) {
import('media-captions').then(({ parseText, VTTCue, VTTRegion }) => {
if (!isString(init.content) || init.type === "json") {
this.#parseJSON(init.content, VTTCue, VTTRegion);
if (this.readyState !== 3) this.#ready();
} else {
parseText(init.content, { type: init.type }).then(({ cues, regions }) => {
this.#cues = cues;
this.#regions = regions;
this.#ready();
});
}
});
}
async #load() {
if (!this.#canLoad || this[TextTrackSymbol.readyState] > 0) return;
this[TextTrackSymbol.readyState] = 1;
this.dispatchEvent(new DOMEvent("load-start"));
if (!this.src) {
this.#ready();
return;
}
try {
const { parseResponse, VTTCue, VTTRegion } = await import('media-captions'), crossOrigin = this[TextTrackSymbol.crossOrigin]?.();
const response = this.#fetch(this.src, {
headers: this.type === "json" ? { "Content-Type": "application/json" } : void 0,
credentials: getRequestCredentials(crossOrigin)
});
if (this.type === "json") {
this.#parseJSON(await (await response).text(), VTTCue, VTTRegion);
} else {
const { errors, metadata, regions, cues } = await parseResponse(response, {
type: this.type,
encoding: this.encoding
});
if (errors[0]?.code === 0) {
throw errors[0];
} else {
this.#metadata = metadata;
this.#regions = regions;
this.#cues = cues;
}
}
this.#ready();
} catch (error) {
this.#error(error);
}
}
#ready() {
this[TextTrackSymbol.readyState] = 2;
if (!this.src || this.type !== "vtt") {
const native = this[TextTrackSymbol.native];
if (native && !native.managed) {
for (const cue of this.#cues) native.track.addCue(cue);
}
}
const loadEvent = new DOMEvent("load");
this[TextTrackSymbol.updateActiveCues](this.#currentTime, loadEvent);
this.dispatchEvent(loadEvent);
}
#error(error) {
this[TextTrackSymbol.readyState] = 3;
this.dispatchEvent(new DOMEvent("error", { detail: error }));
}
#parseJSON(json, VTTCue, VTTRegion) {
try {
const { regions, cues } = parseJSONCaptionsFile(json, VTTCue, VTTRegion);
this.#regions = regions;
this.#cues = cues;
} catch (error) {
{
console.error(`[vidstack] failed to parse JSON captions at: \`${this.src}\`
`, error);
}
this.#error(error);
}
}
#activeCuesChanged(trigger) {
this.dispatchEvent(new DOMEvent("cue-change", { trigger }));
}
}
const captionRE = /captions|subtitles/;
function isTrackCaptionKind(track) {
return captionRE.test(track.kind);
}
function parseJSONCaptionsFile(json, Cue, Region) {
const content = isString(json) ? JSON.parse(json) : json;
let regions = [], cues = [];
if (content.regions && Region) {
regions = content.regions.map((region) => Object.assign(new Region(), region));
}
if (content.cues || isArray(content)) {
cues = (isArray(content) ? content : content.cues).filter((content2) => isNumber(content2.startTime) && isNumber(content2.endTime)).map((cue) => Object.assign(new Cue(0, 0, ""), cue));
}
return { regions, cues };
}
const mediaState = new State({
artist: "",
artwork: null,
audioTrack: null,
audioTracks: [],
autoPlay: false,
autoPlayError: null,
audioGain: null,
buffered: new TimeRange(),
canLoad: false,
canLoadPoster: false,
canFullscreen: false,
canOrientScreen: canOrientScreen(),
canPictureInPicture: false,
canPlay: false,
clipStartTime: 0,
clipEndTime: 0,
controls: false,
get iOSControls() {
return IS_IPHONE && this.mediaType === "video" && (!this.playsInline || !fscreen.fullscreenEnabled && this.fullscreen);
},
get nativeControls() {
return this.controls || this.iOSControls;
},
controlsVisible: false,
get controlsHidden() {
return !this.controlsVisible;
},
crossOrigin: null,
ended: false,
error: null,
fullscreen: false,
get loop() {
return this.providedLoop || this.userPrefersLoop;
},
logLevel: "warn" ,
mediaType: "unknown",
muted: false,
paused: true,
played: new TimeRange(),
playing: false,
playsInline: false,
pictureInPicture: false,
preload: "metadata",
playbackRate: 1,
qualities: [],
quality: null,
autoQuality: false,
canSetQuality: true,
canSetPlaybackRate: true,
canSetVolume: false,
canSetAudioGain: false,
seekable: new TimeRange(),
seeking: false,
source: { src: "", type: "" },
sources: [],
started: false,
textTracks: [],
textTrack: null,
get hasCaptions() {
return this.textTracks.filter(isTrackCaptionKind).length > 0;
},
volume: 1,
waiting: false,
realCurrentTime: 0,
get currentTime() {
return this.ended ? this.duration : this.clipStartTime > 0 ? Math.max(0, Math.min(this.realCurrentTime - this.clipStartTime, this.duration)) : this.realCurrentTime;
},
providedDuration: -1,
intrinsicDuration: 0,
get duration() {
return this.seekableWindow;
},
get title() {
return this.providedTitle || this.inferredTitle;
},
get poster() {
return this.providedPoster || this.inferredPoster;
},
get viewType() {
return this.providedViewType !== "unknown" ? this.providedViewType : this.inferredViewType;
},
get streamType() {
return this.providedStreamType !== "unknown" ? this.providedStreamType : this.inferredStreamType;
},
get currentSrc() {
return this.source;
},
get bufferedStart() {
const start = getTimeRangesStart(this.buffered) ?? 0;
return Math.max(start, this.clipStartTime);
},
get bufferedEnd() {
const end = getTimeRangesEnd(this.buffered) ?? 0;
return Math.min(this.seekableEnd, Math.max(0, end - this.clipStartTime));
},
get bufferedWindow() {
return Math.max(0, this.bufferedEnd - this.bufferedStart);
},
get seekableStart() {
if (this.isLiveDVR && this.liveDVRWindow > 0) {
return Math.max(0, this.seekableEnd - this.liveDVRWindow);
}
const start = getTimeRangesStart(this.seekable) ?? 0;
return Math.max(start, this.clipStartTime);
},
get seekableEnd() {
if (this.providedDuration > 0) return this.providedDuration;
const end = this.liveSyncPosition > 0 ? this.liveSyncPosition : this.canPlay ? getTimeRangesEnd(this.seekable) ?? Infinity : 0;
return this.clipEndTime > 0 ? Math.min(this.clipEndTime, end) : end;
},
get seekableWindow() {
const window = this.seekableEnd - this.seekableStart;
return !isNaN(window) ? Math.max(0, window) : Infinity;
},
// ~~ remote playback ~~
canAirPlay: false,
canGoogleCast: false,
remotePlaybackState: "disconnected",
remotePlaybackType: "none",
remotePlaybackLoader: null,
remotePlaybackInfo: null,
get isAirPlayConnected() {
return this.remotePlaybackType === "airplay" && this.remotePlaybackState === "connected";
},
get isGoogleCastConnected() {
return this.remotePlaybackType === "google-cast" && this.remotePlaybackState === "connected";
},
// ~~ responsive design ~~
pointer: "fine",
orientation: "landscape",
width: 0,
height: 0,
mediaWidth: 0,
mediaHeight: 0,
lastKeyboardAction: null,
// ~~ user props ~~
userBehindLiveEdge: false,
// ~~ live props ~~
liveEdgeTolerance: 10,
minLiveDVRWindow: 60,
get canSeek() {
return /unknown|on-demand|:dvr/.test(this.streamType) && Number.isFinite(this.duration) && (!this.isLiveDVR || this.duration >= this.liveDVRWindow);
},
get live() {
return this.streamType.includes("live") || !Number.isFinite(this.duration);
},
get liveEdgeStart() {
return this.live && Number.isFinite(this.seekableEnd) ? Math.max(0, this.seekableEnd - this.liveEdgeTolerance) : 0;
},
get liveEdge() {
return this.live && (!this.canSeek || !this.userBehindLiveEdge && this.currentTime >= this.liveEdgeStart);
},
get liveEdgeWindow() {
return this.live && Number.isFinite(this.seekableEnd) ? this.seekableEnd - this.liveEdgeStart : 0;
},
get isLiveDVR() {
return /:dvr/.test(this.streamType);
},
get liveDVRWindow() {
return Math.max(this.inferredLiveDVRWindow, this.minLiveDVRWindow);
},
// ~~ internal props ~~
autoPlaying: false,
providedTitle: "",
inferredTitle: "",
providedLoop: false,
userPrefersLoop: false,
providedPoster: "",
inferredPoster: "",
inferredViewType: "unknown",
providedViewType: "unknown",
providedStreamType: "unknown",
inferredStreamType: "unknown",
liveSyncPosition: null,
inferredLiveDVRWindow: 0,
savedState: null
});
const RESET_ON_SRC_QUALITY_CHANGE = /* @__PURE__ */ new Set([
"autoPlayError",
"autoPlaying",
"buffered",
"canPlay",
"error",
"paused",
"played",
"playing",
"seekable",
"seeking",
"waiting"
]);
const RESET_ON_SRC_CHANGE = /* @__PURE__ */ new Set([
...RESET_ON_SRC_QUALITY_CHANGE,
"ended",
"inferredPoster",
"inferredStreamType",
"inferredTitle",
"intrinsicDuration",
"inferredLiveDVRWindow",
"liveSyncPosition",
"realCurrentTime",
"savedState",
"started",
"userBehindLiveEdge"
]);
function softResetMediaState($media, isSourceQualityChange = false) {
const filter = isSourceQualityChange ? RESET_ON_SRC_QUALITY_CHANGE : RESET_ON_SRC_CHANGE;
mediaState.reset($media, (prop) => filter.has(prop));
tick();
}
function boundTime(time, store) {
const clippedTime = time + store.clipStartTime(), isStart = Math.floor(time) === Math.floor(store.seekableStart()), isEnd = Math.floor(clippedTime) === Math.floor(store.seekableEnd());
if (isStart) {
return store.seekableStart();
}
if (isEnd) {
return store.seekableEnd();
}
if (store.isLiveDVR() && store.liveDVRWindow() > 0 && clippedTime < store.seekableEnd() - store.liveDVRWindow()) {
return store.bufferedStart();
}
return Math.min(Math.max(store.seekableStart() + 0.1, clippedTime), store.seekableEnd() - 0.1);
}
const mediaContext = createContext();
function useMediaContext() {
return useContext(mediaContext);
}
const GROUPED_LOG = Symbol("GROUPED_LOG" );
class GroupedLog {
constructor(logger, level, title, root, parent) {
this.logger = logger;
this.level = level;
this.title = title;
this.root = root;
this.parent = parent;
}
[GROUPED_LOG] = true;
logs = [];
log(...data) {
this.logs.push({ data });
return this;
}
labelledLog(label, ...data) {
this.logs.push({ label, data });
return this;
}
groupStart(title) {
return new GroupedLog(this.logger, this.level, title, this.root ?? this, this);
}
groupEnd() {
this.parent?.logs.push(this);
return this.parent ?? this;
}
dispatch() {
return this.logger.dispatch(this.level, this.root ?? this);
}
}
function isGroupedLog(data) {
return !!data?.[GROUPED_LOG];
}
class Logger {
#target = null;
error(...data) {
return this.dispatch("error", ...data);
}
warn(...data) {
return this.dispatch("warn", ...data);
}
info(...data) {
return this.dispatch("info", ...data);
}
debug(...data) {
return this.dispatch("debug", ...data);
}
errorGroup(title) {
return new GroupedLog(this, "error", title);
}
warnGroup(title) {
return new GroupedLog(this, "warn", title);
}
infoGroup(title) {
return new GroupedLog(this, "info", title);
}
debugGroup(title) {
return new GroupedLog(this, "debug", title);
}
setTarget(newTarget) {
this.#target = newTarget;
}
dispatch(level, ...data) {
return this.#target?.dispatchEvent(
new DOMEvent("vds-log", {
bubbles: true,
composed: true,
detail: { level, data }
})
) || false;
}
}
class MediaRemoteControl {
#target = null;
#player = null;
#prevTrackIndex = -1;
#logger;
constructor(logger = new Logger() ) {
this.#logger = logger;
}
/**
* Set the target from which to dispatch media requests events from. The events should bubble
* up from this target to the player element.
*
* @example
* ```ts
* const button = document.querySelector('button');
* remote.setTarget(button);
* ```
*/
setTarget(target) {
this.#target = target;
this.#logger?.setTarget(target);
}
/**
* Returns the current player element. This method will attempt to find the player by
* searching up from either the given `target` or default target set via `remote.setTarget`.
*
* @example
* ```ts
* const player = remote.getPlayer();
* ```
*/
getPlayer(target) {
if (this.#player) return this.#player;
(target ?? this.#target)?.dispatchEvent(
new DOMEvent("find-media-player", {
detail: (player) => void (this.#player = player),
bubbles: true,
composed: true
})
);
return this.#player;
}
/**
* Set the current player element so the remote can support toggle methods such as
* `togglePaused` as they rely on the current media state.
*/
setPlayer(player) {
this.#player = player;
}
/**
* Dispatch a request to start the media loading process. This will only work if the media
* player has been initialized with a custom loading strategy `load="custom">`.
*
* @docs {@link https://www.vidstack.io/docs/player/core-concepts/loading#load-strategies}
*/
startLoading(trigger) {
this.#dispatchRequest("media-start-loading", trigger);
}
/**
* Dispatch a request to start the poster loading process. This will only work if the media
* player has been initialized with a custom poster loading strategy `posterLoad="custom">`.
*
* @docs {@link https://www.vidstack.io/docs/player/core-concepts/loading#load-strategies}
*/
startLoadingPoster(trigger) {
this.#dispatchRequest("media-poster-start-loading", trigger);
}
/**
* Dispatch a request to connect to AirPlay.
*
* @see {@link https://www.apple.com/au/airplay}
*/
requestAirPlay(trigger) {
this.#dispatchRequest("media-airplay-request", trigger);
}
/**
* Dispatch a request to connect to Google Cast.
*
* @see {@link https://developers.google.com/cast/docs/overview}
*/
requestGoogleCast(trigger) {
this.#dispatchRequest("media-google-cast-request", trigger);
}
/**
* Dispatch a request to begin/resume media playback.
*/
play(trigger) {
this.#dispatchRequest("media-play-request", trigger);
}
/**
* Dispatch a request to pause media playback.
*/
pause(trigger) {
this.#dispatchRequest("media-pause-request", trigger);
}
/**
* Dispatch a request to set the media volume to mute (0).
*/
mute(trigger) {
this.#dispatchRequest("media-mute-request", trigger);
}
/**
* Dispatch a request to unmute the media volume and set it back to it's previous state.
*/
unmute(trigger) {
this.#dispatchRequest("media-unmute-request", trigger);
}
/**
* Dispatch a request to enter fullscreen.
*
* @docs {@link https://www.vidstack.io/docs/player/api/fullscreen#remote-control}
*/
enterFullscreen(target, trigger) {
this.#dispatchRequest("media-enter-fullscreen-request", trigger, target);
}
/**
* Dispatch a request to exit fullscreen.
*
* @docs {@link https://www.vidstack.io/docs/player/api/fullscreen#remote-control}
*/
exitFullscreen(target, trigger) {
this.#dispatchRequest("media-exit-fullscreen-request", trigger, target);
}
/**
* Dispatch a request to lock the screen orientation.
*
* @docs {@link https://www.vidstack.io/docs/player/screen-orientation#remote-control}
*/
lockScreenOrientation(lockType, trigger) {
this.#dispatchRequest("media-orientation-lock-request", trigger, lockType);
}
/**
* Dispatch a request to unlock the screen orientation.
*
* @docs {@link https://www.vidstack.io/docs/player/api/screen-orientation#remote-control}
*/
unlockScreenOrientation(trigger) {
this.#dispatchRequest("media-orientation-unlock-request", trigger);
}
/**
* Dispatch a request to enter picture-in-picture mode.
*
* @docs {@link https://www.vidstack.io/docs/player/api/picture-in-picture#remote-control}
*/
enterPictureInPicture(trigger) {
this.#dispatchRequest("media-enter-pip-request", trigger);
}
/**
* Dispatch a request to exit picture-in-picture mode.
*
* @docs {@link https://www.vidstack.io/docs/player/api/picture-in-picture#remote-control}
*/
exitPictureInPicture(trigger) {
this.#dispatchRequest("media-exit-pip-request", trigger);
}
/**
* Notify the media player that a seeking process is happening and to seek to the given `time`.
*/
seeking(time, trigger) {
this.#dispatchRequest("media-seeking-request", trigger, time);
}
/**
* Notify the media player that a seeking operation has completed and to seek to the given `time`.
* This is generally called after a series of `remote.seeking()` calls.
*/
seek(time, trigger) {
this.#dispatchRequest("media-seek-request", trigger, time);
}
seekToLiveEdge(trigger) {
this.#dispatchRequest("media-live-edge-request", trigger);
}
/**
* Dispatch a request to update the length of the media in seconds.
*
* @example
* ```ts
* remote.changeDuration(100); // 100 seconds
* ```
*/
changeDuration(duration, trigger) {
this.#dispatchRequest("media-duration-change-request", trigger, duration);
}
/**
* Dispatch a request to update the clip start time. This is the time at which media playback
* should start at.
*
* @example
* ```ts
* remote.changeClipStart(100); // start at 100 seconds
* ```
*/
changeClipStart(startTime, trigger) {
this.#dispatchRequest("media-clip-start-change-request", trigger, startTime);
}
/**
* Dispatch a request to update the clip end time. This is the time at which media playback
* should end at.
*
* @example
* ```ts
* remote.changeClipEnd(100); // end at 100 seconds
* ```
*/
changeClipEnd(endTime, trigger) {
this.#dispatchRequest("media-clip-end-change-request", trigger, endTime);
}
/**
* Dispatch a request to update the media volume to the given `volume` level which is a value
* between 0 and 1.
*
* @docs {@link https://www.vidstack.io/docs/player/api/audio-gain#remote-control}
* @example
* ```ts
* remote.changeVolume(0); // 0%
* remote.changeVolume(0.05); // 5%
* remote.changeVolume(0.5); // 50%
* remote.changeVolume(0.75); // 70%
* remote.changeVolume(1); // 100%
* ```
*/
changeVolume(volume, trigger) {
this.#dispatchRequest("media-volume-change-request", trigger, Math.max(0, Math.min(1, volume)));
}
/**
* Dispatch a request to change the current audio track.
*
* @example
* ```ts
* remote.changeAudioTrack(1); // track at index 1
* ```
*/
changeAudioTrack(index, trigger) {
this.#dispatchRequest("media-audio-track-change-request", trigger, index);
}
/**
* Dispatch a request to change the video quality. The special value `-1` represents auto quality
* selection.
*
* @example
* ```ts
* remote.changeQuality(-1); // auto
* remote.changeQuality(1); // quality at index 1
* ```
*/
changeQuality(index, trigger) {
this.#dispatchRequest("media-quality-change-request", trigger, index);
}
/**
* Request auto quality selection.
*/
requestAutoQuality(trigger) {
this.changeQuality(-1, trigger);
}
/**
* Dispatch a request to change the mode of the text track at the given index.
*
* @example
* ```ts
* remote.changeTextTrackMode(1, 'showing'); // track at index 1
* ```
*/
changeTextTrackMode(index, mode, trigger) {
this.#dispatchRequest("media-text-track-change-request", trigger, {
index,
mode
});
}
/**
* Dispatch a request to change the media playback rate.
*
* @example
* ```ts
* remote.changePlaybackRate(0.5); // Half the normal speed
* remote.changePlaybackRate(1); // Normal speed
* remote.changePlaybackRate(1.5); // 50% faster than normal
* remote.changePlaybackRate(2); // Double the normal speed
* ```
*/
changePlaybackRate(rate, trigger) {
this.#dispatchRequest("media-rate-change-request", trigger, rate);
}
/**
* Dispatch a request to change the media audio gain.
*
* @example
* ```ts
* remote.changeAudioGain(1); // Disable audio gain
* remote.changeAudioGain(1.5); // 50% louder
* remote.changeAudioGain(2); // 100% louder
* ```
*/
changeAudioGain(gain, trigger) {
this.#dispatchRequest("media-audio-gain-change-request", trigger, gain);
}
/**
* Dispatch a request to resume idle tracking on controls.
*/
resumeControls(trigger) {
this.#dispatchRequest("media-resume-controls-request", trigger);
}
/**
* Dispatch a request to pause controls idle tracking. Pausing tracking will result in the
* controls being visible until `remote.resumeControls()` is called. This method
* is generally used when building custom controls and you'd like to prevent the UI from
* disappearing.
*
* @example
* ```ts
* // Prevent controls hiding while menu is being interacted with.
* function onSettingsOpen() {
* remote.pauseControls();
* }
*
* function onSettingsClose() {
* remote.resumeControls();
* }
* ```
*/
pauseControls(trigger) {
this.#dispatchRequest("media-pause-controls-request", trigger);
}
/**
* Dispatch a request to toggle the media playback state.
*/
togglePaused(trigger) {
const player = this.getPlayer(trigger?.target);
if (!player) {
this.#noPlayerWarning(this.togglePaused.name);
return;
}
if (player.state.paused) this.play(trigger);
else this.pause(trigger);
}
/**
* Dispatch a request to toggle the controls visibility.
*/
toggleControls(trigger) {
const player = this.getPlayer(trigger?.target);
if (!player) {
this.#noPlayerWarning(this.toggleControls.name);
return;
}
if (!player.controls.showing) {
player.controls.show(0, trigger);
} else {
player.controls.hide(0, trigger);
}
}
/**
* Dispatch a request to toggle the media muted state.
*/
toggleMuted(trigger) {
const player = this.getPlayer(trigger?.target);
if (!player) {
this.#noPlayerWarning(this.toggleMuted.name);
return;
}
if (player.state.muted) this.unmute(trigger);
else this.mute(trigger);
}
/**
* Dispatch a request to toggle the media fullscreen state.
*
* @docs {@link https://www.vidstack.io/docs/player/api/fullscreen#remote-control}
*/
toggleFullscreen(target, trigger) {
const player = this.getPlayer(trigger?.target);
if (!player) {
this.#noPlayerWarning(this.toggleFullscreen.name);
return;
}
if (player.state.fullscreen) this.exitFullscreen(target, trigger);
else this.enterFullscreen(target, trigger);
}
/**
* Dispatch a request to toggle the media picture-in-picture mode.
*
* @docs {@link https://www.vidstack.io/docs/player/api/picture-in-picture#remote-control}
*/
togglePictureInPicture(trigger) {
const player = this.getPlayer(trigger?.target);
if (!player) {
this.#noPlayerWarning(this.togglePictureInPicture.name);
return;
}
if (player.state.pictureInPicture) this.exitPictureInPicture(trigger);
else this.enterPictureInPicture(trigger);
}
/**
* Show captions.
*/
showCaptions(trigger) {
const player = this.getPlayer(trigger?.target);
if (!player) {
this.#noPlayerWarning(this.showCaptions.name);
return;
}
let tracks = player.state.textTracks, index = this.#prevTrackIndex;
if (!tracks[index] || !isTrackCaptionKind(tracks[index])) {
index = -1;
}
if (index === -1) {
index = tracks.findIndex((track) => isTrackCaptionKind(track) && track.default);
}
if (index === -1) {
index = tracks.findIndex((track) => isTrackCaptionKind(track));
}
if (index >= 0) this.changeTextTrackMode(index, "showing", trigger);
this.#prevTrackIndex = -1;
}
/**
* Turn captions off.
*/
disableCaptions(trigger) {
const player = this.getPlayer(trigger?.target);
if (!player) {
this.#noPlayerWarning(this.disableCaptions.name);
return;
}
const tracks = player.state.textTracks, track = player.state.textTrack;
if (track) {
const index = tracks.indexOf(track);
this.changeTextTrackMode(index, "disabled", trigger);
this.#prevTrackIndex = index;
}
}
/**
* Dispatch a request to toggle the current captions mode.
*/
toggleCaptions(trigger) {
const player = this.getPlayer(trigger?.target);
if (!player) {
this.#noPlayerWarning(this.toggleCaptions.name);
return;
}
if (player.state.textTrack) {
this.disableCaptions();
} else {
this.showCaptions();
}
}
userPrefersLoopChange(prefersLoop, trigger) {
this.#dispatchRequest("media-user-loop-change-request", trigger, prefersLoop);
}
#dispatchRequest(type, trigger, detail) {
const request = new DOMEvent(type, {
bubbles: true,
composed: true,
cancelable: true,
detail,
trigger
});
let target = trigger?.target || null;
if (target && target instanceof Component) target = target.el;
const shouldUsePlayer = !target || target === document || target === window || target === document.body || this.#player?.el && target instanceof Node && !this.#player.el.contains(target);
target = shouldUsePlayer ? this.#target ?? this.getPlayer()?.el : target ?? this.#target;
{
this.#logger?.debugGroup(`\u{1F4E8} dispatching \`${type}\``).labelledLog("Target", target).labelledLog("Player", this.#player).labelledLog("Request Event", request).labelledLog("Trigger Event", trigger).dispatch();
}
if (this.#player) {
if (type === "media-play-request" && !this.#player.state.canLoad) {
target?.dispatchEvent(request);
} else {
this.#player.canPlayQueue.enqueue(type, () => target?.dispatchEvent(request));
}
} else {
target?.dispatchEvent(request);
}
}
#noPlayerWarning(method) {
{
console.warn(
`[vidstack] attempted to call \`MediaRemoteControl.${method}\`() that requires player but failed because remote could not find a parent player element from target`
);
}
}
}
class LocalMediaStorage {
playerId = "vds-player";
mediaId = null;
#data = {
volume: null,
muted: null,
audioGain: null,
time: null,
lang: null,
captions: null,
rate: null,
quality: null
};
async getVolume() {
return this.#data.volume;
}
async setVolume(volume) {
this.#data.volume = volume;
this.save();
}
async getMuted() {
return this.#data.muted;
}
async setMuted(muted) {
this.#data.muted = muted;
this.save();
}
async getTime() {
return this.#data.time;
}
async setTime(time, ended) {
const shouldClear = time < 0;
this.#data.time = !shouldClear ? time : null;
if (shouldClear || ended) this.saveTime();
else this.saveTimeThrottled();
}
async getLang() {
return this.#data.lang;
}
async setLang(lang) {
this.#data.lang = lang;
this.save();
}
async getCaptions() {
return this.#data.captions;
}
async setCaptions(enabled) {
this.#data.captions = enabled;
this.save();
}
async getPlaybackRate() {
return this.#data.rate;
}
async setPlaybackRate(rate) {
this.#data.rate = rate;
this.save();
}
async getAudioGain() {
return this.#data.audioGain;
}
async setAudioGain(gain) {
this.#data.audioGain = gain;
this.save();
}
async getVideoQuality() {
return this.#data.quality;
}
async setVideoQuality(quality) {
this.#data.quality = quality;
this.save();
}
onChange(src, mediaId, playerId = "vds-player") {
const savedData = playerId ? localStorage.getItem(playerId) : null, savedTime = mediaId ? localStorage.getItem(mediaId) : null;
this.playerId = playerId;
this.mediaId = mediaId;
this.#data = {
volume: null,
muted: null,
audioGain: null,
lang: null,
captions: null,
rate: null,
quality: null,
...savedData ? JSON.parse(savedData) : {},
time: savedTime ? +savedTime : null
};
}
save() {
if (IS_SERVER || !this.playerId) return;
const data = JSON.stringify({ ...this.#data, time: void 0 });
localStorage.setItem(this.playerId, data);
}
saveTimeThrottled = functionThrottle(this.saveTime.bind(this), 1e3);
saveTime() {
if (IS_SERVER || !this.mediaId) return;
const data = (this.#data.time ?? 0).toString();
localStorage.setItem(this.mediaId, data);
}
}
const ADD = Symbol("LIST_ADD" ), REMOVE = Symbol("LIST_REMOVE" ), RESET = Symbol("LIST_RESET" ), SELECT = Symbol("LIST_SELECT" ), READONLY = Symbol("LIST_READONLY" ), SET_READONLY = Symbol("LIST_SET_READONLY" ), ON_RESET = Symbol("LIST_ON_RESET" ), ON_REMOVE = Symbol("LIST_ON_REMOVE" ), ON_USER_SELECT = Symbol("LIST_ON_USER_SELECT" );
const ListSymbol = {
add: ADD,
remove: REMOVE,
reset: RESET,
select: SELECT,
readonly: READONLY,
setReadonly: SET_READONLY,
onReset: ON_RESET,
onRemove: ON_REMOVE,
onUserSelect: ON_USER_SELECT
};
class List extends EventsTarget {
items = [];
/** @internal */
[ListSymbol.readonly] = false;
get length() {
return this.items.length;
}
get readonly() {
return this[ListSymbol.readonly];
}
/**
* Returns the index of the first occurrence of the given item, or -1 if it is not present.
*/
indexOf(item) {
return this.items.indexOf(item);
}
/**
* Returns an item matching the given `id`, or `null` if not present.
*/
getById(id) {
if (id === "") return null;
return this.items.find((item) => item.id === id) ?? null;
}
/**
* Transform list to an array.
*/
toArray() {
return [...this.items];
}
[Symbol.iterator]() {
return this.items.values();
}
/** @internal */
[ListSymbol.add](item, trigger) {
const index = this.items.length;
if (!("" + index in this)) {
Object.defineProperty(this, index, {
get() {
return this.items[index];
}
});
}
if (this.items.includes(item)) return;
this.items.push(item);
this.dispatchEvent(new DOMEvent("add", { detail: item, trigger }));
}
/** @internal */
[ListSymbol.remove](item, trigger) {
const index = this.items.indexOf(item);
if (index >= 0) {
this[ListSymbol.onRemove]?.(item, trigger);
this.items.splice(index, 1);
this.dispatchEvent(new DOMEvent("remove", { detail: item, trigger }));
}
}
/** @internal */
[ListSymbol.reset](trigger) {
for (const item of [...this.items]) this[ListSymbol.remove](item, trigger);
this.items = [];
this[ListSymbol.setReadonly](false, trigger);
this[ListSymbol.onReset]?.();
}
/** @internal */
[ListSymbol.setReadonly](readonly, trigger) {
if (this[ListSymbol.readonly] === readonly) return;
this[ListSymbol.readonly] = readonly;
this.dispatchEvent(new DOMEvent("readonly-change", { detail: readonly, trigger }));
}
}
const SELECTED = Symbol("SELECTED" );
class SelectList extends List {
get selected() {
return this.items.find((item) => item.selected) ?? null;
}
get selectedIndex() {
return this.items.findIndex((item) => item.selected);
}
/** @internal */
[ListSymbol.onRemove](item, trigger) {
this[ListSymbol.select](item, false, trigger);
}
/** @internal */
[ListSymbol.add](item, trigger) {
item[SELECTED] = false;
Object.defineProperty(item, "selected", {
get() {
return this[SELECTED];
},
set: (selected) => {
if (this.readonly) return;
this[ListSymbol.onUserSelect]?.();
this[ListSymbol.select](item, selected);
}
});
super[ListSymbol.add](item, trigger);
}
/** @internal */
[ListSymbol.select](item, selected, trigger) {
if (selected === item?.[SELECTED]) return;
const prev = this.selected;
if (item) item[SELECTED] = selected;
const changed = !selected ? prev === item : prev !== item;
if (changed) {
if (prev) prev[SELECTED] = false;
this.dispatchEvent(
new DOMEvent("change", {
detail: {
prev,
current: this.selected
},
trigger
})
);
}
}
}
class AudioTrackList extends SelectList {
}
function round(num, decimalPlaces = 2) {
return Number(num.toFixed(decimalPlaces));
}
function getNumberOfDecimalPlaces(num) {
return String(num).split(".")[1]?.length ?? 0;
}
function clampNumber(min, value, max) {
return Math.max(min, Math.min(max, value));
}
function isEventInside(el, event) {
const target = event.composedPath()[0];
return isDOMNode(target) && el.contains(target);
}
const rafJobs = /* @__PURE__ */ new Set();
if (!IS_SERVER) {
let processJobs = function() {
for (const job of rafJobs) {
try {
job();
} catch (e) {
console.error(`[vidstack] failed job:
${e}`);
}
}
window.requestAnimationFrame(processJobs);
};
processJobs();
}
function scheduleRafJob(job) {
rafJobs.add(job);
return () => rafJobs.delete(job);
}
function setAttributeIfEmpty(target, name, value) {
if (!target.hasAttribute(name)) target.setAttribute(name, value);
}
function setARIALabel(target, $label) {
if (target.hasAttribute("aria-label") || target.hasAttribute("data-no-label")) return;
if (!isFunction($label)) {
setAttribute(target, "aria-label", $label);
return;
}
function updateAriaDescription() {
setAttribute(target, "aria-label", $label());
}
if (IS_SERVER) updateAriaDescription();
else effect(updateAriaDescription);
}
function isElementVisible(el) {
const style = getComputedStyle(el);
return style.display