UNPKG

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