UNPKG

@aidenlx/vidstack-react

Version:

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

591 lines (572 loc) 22.8 kB
"use client" import { TextTrackSymbol, RadioGroupController, useMediaContext, menuContext, MediaPlayerInstance, Primitive, MediaProviderInstance, mediaState, isRemotionProvider, TextTrack, ToggleButtonInstance, PosterInstance, useMediaState, ThumbnailsLoader, updateSliderPreviewPlacement } from './chunks/vidstack-B1ySk2FQ.js'; export { ARIAKeyShortcuts, AUDIO_EXTENSIONS, AUDIO_TYPES, AirPlayButtonInstance, AudioGainSliderInstance, AudioProviderLoader, AudioTrackList, CaptionButtonInstance, CaptionsInstance, ControlsGroupInstance, ControlsInstance, DASHProviderLoader, DASH_VIDEO_EXTENSIONS, DASH_VIDEO_TYPES, FullscreenButtonInstance, FullscreenController, GestureInstance, GoogleCastButtonInstance, HLSProviderLoader, HLS_VIDEO_EXTENSIONS, HLS_VIDEO_TYPES, List, LiveButtonInstance, LocalMediaStorage, Logger, MEDIA_KEY_SHORTCUTS, MediaAnnouncerInstance, MediaControls, MediaRemoteControl, MenuButtonInstance, MenuInstance, MenuItemInstance, MenuItemsInstance, MenuPortalInstance, MuteButtonInstance, PIPButtonInstance, PlayButtonInstance, QualitySliderInstance, RadioGroupInstance, RadioInstance, ScreenOrientationController, SeekButtonInstance, SliderChaptersInstance, SliderInstance, SliderPreviewInstance, SliderThumbnailInstance, SliderValueInstance, SliderVideoInstance, SpeedSliderInstance, TextRenderers, TextTrackList, ThumbnailInstance, TimeInstance, TimeRange, TimeSliderInstance, TooltipContentInstance, TooltipInstance, TooltipTriggerInstance, VIDEO_EXTENSIONS, VIDEO_TYPES, VideoProviderLoader, VideoQualityList, VimeoProviderLoader, VolumeSliderInstance, YouTubeProviderLoader, boundTime, canChangeVolume, canFullscreen, canGoogleCastSrc, canOrientScreen, canPlayHLSNatively, canRotateScreen, canUsePictureInPicture, canUseVideoPresentation, findActiveCue, formatSpokenTime, formatTime, getDownloadFile, getTimeRangesEnd, getTimeRangesStart, isAudioProvider, isAudioSrc, isCueActive, isDASHProvider, isDASHSrc, isGoogleCastProvider, isHLSProvider, isHLSSrc, isHTMLAudioElement, isHTMLIFrameElement, isHTMLMediaElement, isHTMLVideoElement, isMediaStream, isTrackCaptionKind, isVideoProvider, isVideoQualitySrc, isVideoSrc, isVimeoProvider, isYouTubeProvider, mediaContext, normalizeTimeIntervals, parseJSONCaptionsFile, sliderState, softResetMediaState, sortVideoQualities, updateTimeIntervals, useMediaStore, useSliderState, useSliderStore, watchActiveTextTrack, watchCueTextChange } from './chunks/vidstack-B1ySk2FQ.js'; import * as React from 'react'; import { createReactComponent, useStateContext, useSignal, composeRefs, useSignalRecord, useReactScope } from 'maverick.js/react'; import { createSignal, useScoped } from './chunks/vidstack-DRMlDrT_.js'; export { audioGainSlider as AudioGainSlider, Captions, ChapterTitle, controls as Controls, GoogleCastButton, MediaAnnouncer, qualitySlider as QualitySlider, speedSlider as SpeedSlider, spinner as Spinner, Title, tooltip as Tooltip, useActiveTextCues, useActiveTextTrack, useChapterOptions, useChapterTitle, useTextCues } from './chunks/vidstack-DRMlDrT_.js'; import { isString, EventsController, DOMEvent } from 'maverick.js/std'; export { appendTriggerEvent, findTriggerEvent, hasTriggerEvent, isKeyboardClick, isKeyboardEvent, isPointerEvent, walkTriggerEventChain } from 'maverick.js/std'; import { useMediaContext as useMediaContext$1 } from './chunks/vidstack-DZHxgbQz.js'; export { AirPlayButton, CaptionButton, FullscreenButton, Gesture, LiveButton, menu as Menu, MuteButton, PIPButton, PlayButton, radioGroup as RadioGroup, SeekButton, slider as Slider, thumbnail as Thumbnail, Time, timeSlider as TimeSlider, volumeSlider as VolumeSlider, useAudioOptions, useCaptionOptions, useMediaPlayer } from './chunks/vidstack-DZHxgbQz.js'; import { Icon } from './chunks/vidstack-CBF7iUqu.js'; import { prop, method, Component, hasProvidedContext, useContext, effect, signal } from 'maverick.js'; export { DEFAULT_PLAYBACK_RATES, useMediaRemote, usePlaybackRateOptions, useVideoQualityOptions } from './chunks/vidstack-BFI47c9T.js'; import './chunks/vidstack-CPShcCv0.js'; import '@floating-ui/dom'; import 'react-dom'; class LibASSTextRenderer { constructor(loader, config) { this.loader = loader; this.config = config; } priority = 1; #instance = null; #track = null; #typeRE = /(ssa|ass)$/; canRender(track, video) { return !!video && !!track.src && (isString(track.type) && this.#typeRE.test(track.type) || this.#typeRE.test(track.src)); } attach(video) { if (!video) return; this.loader().then(async (mod) => { this.#instance = new mod.default({ ...this.config, video, subUrl: this.#track?.src || "" }); new EventsController(this.#instance).add("ready", () => { const canvas = this.#instance?._canvas; if (canvas) canvas.style.pointerEvents = "none"; }).add("error", (event) => { if (!this.#track) return; this.#track[TextTrackSymbol.readyState] = 3; this.#track.dispatchEvent( new DOMEvent("error", { trigger: event, detail: event.error }) ); }); }); } changeTrack(track) { if (!track || track.readyState === 3) { this.#freeTrack(); } else if (this.#track !== track) { this.#instance?.setTrackByUrl(track.src); this.#track = track; } } detach() { this.#freeTrack(); } #freeTrack() { this.#instance?.freeTrack(); this.#track = null; } } const DEFAULT_AUDIO_GAINS = [1, 1.25, 1.5, 1.75, 2, 2.5, 3, 4]; class AudioGainRadioGroup extends Component { static props = { normalLabel: "Disabled", gains: DEFAULT_AUDIO_GAINS }; #media; #menu; #controller; get value() { return this.#controller.value; } get disabled() { const { gains } = this.$props, { canSetAudioGain } = this.#media.$state; return !canSetAudioGain() || gains().length === 0; } constructor() { super(); this.#controller = new RadioGroupController(); this.#controller.onValueChange = this.#onValueChange.bind(this); } onSetup() { this.#media = useMediaContext(); if (hasProvidedContext(menuContext)) { this.#menu = useContext(menuContext); } } onConnect(el) { effect(this.#watchValue.bind(this)); effect(this.#watchHintText.bind(this)); effect(this.#watchControllerDisabled.bind(this)); } getOptions() { const { gains, normalLabel } = this.$props; return gains().map((gain) => ({ label: gain === 1 || gain === null ? normalLabel : String(gain * 100) + "%", value: gain.toString() })); } #watchValue() { this.#controller.value = this.#getValue(); } #watchHintText() { const { normalLabel } = this.$props, { audioGain } = this.#media.$state, gain = audioGain(); this.#menu?.hint.set(gain === 1 || gain == null ? normalLabel() : String(gain * 100) + "%"); } #watchControllerDisabled() { this.#menu?.disable(this.disabled); } #getValue() { const { audioGain } = this.#media.$state; return audioGain()?.toString() ?? "1"; } #onValueChange(value, trigger) { if (this.disabled) return; const gain = +value; this.#media.remote.changeAudioGain(gain, trigger); this.dispatch("change", { detail: gain, trigger }); } } const audiogainradiogroup__proto = AudioGainRadioGroup.prototype; prop(audiogainradiogroup__proto, "value"); prop(audiogainradiogroup__proto, "disabled"); method(audiogainradiogroup__proto, "getOptions"); const playerCallbacks = [ "onAbort", "onAudioTrackChange", "onAudioTracksChange", "onAutoPlay", "onAutoPlayChange", "onAutoPlayFail", "onCanLoad", "onCanPlay", "onCanPlayThrough", "onControlsChange", "onDestroy", "onDurationChange", "onEmptied", "onEnd", "onEnded", "onError", "onFindMediaPlayer", "onFullscreenChange", "onFullscreenError", "onLiveChange", "onLiveEdgeChange", "onLoadedData", "onLoadedMetadata", "onLoadStart", "onLoopChange", "onOrientationChange", "onPause", "onPictureInPictureChange", "onPictureInPictureError", "onPlay", "onPlayFail", "onPlaying", "onPlaysInlineChange", "onPosterChange", "onProgress", "onProviderChange", "onProviderLoaderChange", "onProviderSetup", "onQualitiesChange", "onQualityChange", "onRateChange", "onReplay", "onSeeked", "onSeeking", "onSourceChange", "onSourceChange", "onStalled", "onStarted", "onStreamTypeChange", "onSuspend", "onTextTrackChange", "onTextTracksChange", "onTimeUpdate", "onTitleChange", "onVdsLog", "onVideoPresentationChange", "onVolumeChange", "onWaiting" ]; const MediaPlayerBridge = createReactComponent(MediaPlayerInstance, { events: playerCallbacks, eventsRegex: /^onHls/, domEventsRegex: /^onMedia/ }); const MediaPlayer = React.forwardRef( ({ aspectRatio, children, ...props }, forwardRef) => { return /* @__PURE__ */ React.createElement( MediaPlayerBridge, { ...props, src: props.src, ref: forwardRef, style: { aspectRatio, ...props.style } }, (props2) => /* @__PURE__ */ React.createElement(Primitive.div, { ...props2 }, children) ); } ); MediaPlayer.displayName = "MediaPlayer"; const MediaProviderBridge = createReactComponent(MediaProviderInstance); const MediaProvider = React.forwardRef( ({ loaders = [], children, iframeProps, mediaProps, ...props }, forwardRef) => { const reactLoaders = React.useMemo(() => loaders.map((Loader) => new Loader()), loaders); return /* @__PURE__ */ React.createElement(MediaProviderBridge, { ...props, loaders: reactLoaders, ref: forwardRef }, (props2, instance) => /* @__PURE__ */ React.createElement("div", { ...props2 }, /* @__PURE__ */ React.createElement(MediaOutlet, { provider: instance, mediaProps, iframeProps }), children)); } ); MediaProvider.displayName = "MediaProvider"; function MediaOutlet({ provider, mediaProps, iframeProps }) { const { sources, crossOrigin, poster, remotePlaybackInfo, nativeControls, viewType } = useStateContext(mediaState), { loader } = provider.$state, { $provider: $$provider, $providerSetup: $$providerSetup } = useMediaContext$1(), $sources = useSignal(sources), $nativeControls = useSignal(nativeControls), $crossOrigin = useSignal(crossOrigin), $poster = useSignal(poster), $loader = useSignal(loader), $provider = useSignal($$provider), $providerSetup = useSignal($$providerSetup), $remoteInfo = useSignal(remotePlaybackInfo), $mediaType = $loader?.mediaType(), $viewType = useSignal(viewType), isAudioView = $viewType === "audio", isYouTubeEmbed = $loader?.name === "youtube", isVimeoEmbed = $loader?.name === "vimeo", isEmbed = isYouTubeEmbed || isVimeoEmbed, isRemotion = $loader?.name === "remotion", isGoogleCast = $loader?.name === "google-cast", [googleCastIconPaths, setGoogleCastIconPaths] = React.useState(""), [hasMounted, setHasMounted] = React.useState(false); React.useEffect(() => { if (!isGoogleCast || googleCastIconPaths) return; import('./chunks/vidstack-CPShcCv0.js').then(function (n) { return n.chromecast; }).then((mod) => { setGoogleCastIconPaths(mod.default); }); }, [isGoogleCast]); React.useEffect(() => { setHasMounted(true); }, []); if (isGoogleCast) { return /* @__PURE__ */ React.createElement( "div", { className: "vds-google-cast", ref: (el) => { provider.load(el); } }, /* @__PURE__ */ React.createElement(Icon, { paths: googleCastIconPaths }), $remoteInfo?.deviceName ? /* @__PURE__ */ React.createElement("span", { className: "vds-google-cast-info" }, "Google Cast on", " ", /* @__PURE__ */ React.createElement("span", { className: "vds-google-cast-device-name" }, $remoteInfo.deviceName)) : null ); } if (isRemotion) { return /* @__PURE__ */ React.createElement("div", { "data-remotion-canvas": true }, /* @__PURE__ */ React.createElement( "div", { "data-remotion-container": true, ref: (el) => { provider.load(el); } }, isRemotionProvider($provider) && $providerSetup ? React.createElement($provider.render) : null )); } return isEmbed ? React.createElement( React.Fragment, null, React.createElement("iframe", { ...iframeProps, className: (iframeProps?.className ? `${iframeProps.className} ` : "") + isYouTubeEmbed ? "vds-youtube" : "vds-vimeo", suppressHydrationWarning: true, tabIndex: !$nativeControls ? -1 : void 0, "aria-hidden": "true", "data-no-controls": !$nativeControls ? "" : void 0, ref(el) { provider.load(el); } }), !$nativeControls && !isAudioView ? React.createElement("div", { className: "vds-blocker" }) : null ) : $mediaType ? React.createElement($mediaType === "audio" ? "audio" : "video", { ...mediaProps, controls: $nativeControls ? true : null, crossOrigin: typeof $crossOrigin === "boolean" ? "" : $crossOrigin, poster: $mediaType === "video" && $nativeControls && $poster ? $poster : null, suppressHydrationWarning: true, children: !hasMounted ? $sources.map( ({ src, type }) => isString(src) ? /* @__PURE__ */ React.createElement("source", { src, type: type !== "?" ? type : void 0, key: src }) : null ) : null, ref(el) { provider.load(el); } }) : null; } MediaOutlet.displayName = "MediaOutlet"; function createTextTrack(init) { const media = useMediaContext$1(), track = React.useMemo(() => new TextTrack(init), Object.values(init)); React.useEffect(() => { media.textTracks.add(track); return () => void media.textTracks.remove(track); }, [track]); return track; } function Track({ lang, ...props }) { createTextTrack({ language: lang, ...props }); return null; } Track.displayName = "Track"; const ToggleButtonBridge = createReactComponent(ToggleButtonInstance); const ToggleButton = React.forwardRef( ({ children, ...props }, forwardRef) => { return /* @__PURE__ */ React.createElement(ToggleButtonBridge, { ...props }, (props2) => /* @__PURE__ */ React.createElement( Primitive.button, { ...props2, ref: composeRefs(props2.ref, forwardRef) }, children )); } ); ToggleButton.displayName = "ToggleButton"; const PosterBridge = createReactComponent(PosterInstance); const Poster = React.forwardRef( ({ children, ...props }, forwardRef) => { return /* @__PURE__ */ React.createElement( PosterBridge, { src: props.asChild && React.isValidElement(children) ? children.props.src : void 0, ...props }, (props2, instance) => /* @__PURE__ */ React.createElement( PosterImg, { ...props2, instance, ref: composeRefs(props2.ref, forwardRef) }, children ) ); } ); Poster.displayName = "Poster"; const PosterImg = React.forwardRef( ({ instance, children, ...props }, forwardRef) => { const { src, img, alt, crossOrigin, hidden } = instance.$state, $src = useSignal(src), $alt = useSignal(alt), $crossOrigin = useSignal(crossOrigin), $hidden = useSignal(hidden); return /* @__PURE__ */ React.createElement( Primitive.img, { ...props, src: $src || void 0, alt: $alt || void 0, crossOrigin: $crossOrigin || void 0, ref: composeRefs(img.set, forwardRef), style: { display: $hidden ? "none" : void 0 } }, children ); } ); PosterImg.displayName = "PosterImg"; const Root = React.forwardRef(({ children, ...props }, forwardRef) => { return /* @__PURE__ */ React.createElement( Primitive.div, { translate: "yes", "aria-live": "off", "aria-atomic": "true", ...props, ref: forwardRef }, children ); }); Root.displayName = "Caption"; const Text = React.forwardRef((props, forwardRef) => { const textTrack = useMediaState("textTrack"), [activeCue, setActiveCue] = React.useState(); React.useEffect(() => { if (!textTrack) return; function onCueChange() { setActiveCue(textTrack?.activeCues[0]); } textTrack.addEventListener("cue-change", onCueChange); return () => { textTrack.removeEventListener("cue-change", onCueChange); setActiveCue(void 0); }; }, [textTrack]); return /* @__PURE__ */ React.createElement( Primitive.span, { ...props, "data-part": "cue", dangerouslySetInnerHTML: { __html: activeCue?.text || "" }, ref: forwardRef } ); }); Text.displayName = "CaptionText"; var caption = /*#__PURE__*/Object.freeze({ __proto__: null, Root: Root, Text: Text }); function useState(ctor, prop, ref) { const initialValue = React.useMemo(() => ctor.state.record[prop], [ctor, prop]); return useSignal(ref.current ? ref.current.$state[prop] : initialValue); } const storesCache = /* @__PURE__ */ new Map(); function useStore(ctor, ref) { const initialStore = React.useMemo(() => { let store = storesCache.get(ctor); if (!store) { store = new Proxy(ctor.state.record, { get: (_, prop) => () => ctor.state.record[prop] }); storesCache.set(ctor, store); } return store; }, [ctor]); return useSignalRecord(ref.current ? ref.current.$state : initialStore); } function useMediaProvider() { const [provider, setProvider] = React.useState(null), context = useMediaContext$1(); if (!context) { throw Error( "[vidstack] no media context was found - was this called outside of `<MediaPlayer>`?" ); } React.useEffect(() => { if (!context) return; return effect(() => { setProvider(context.$provider()); }); }, []); return provider; } function useThumbnails(src, crossOrigin = null) { const scope = useReactScope(), $src = createSignal(src), $crossOrigin = createSignal(crossOrigin), loader = useScoped(() => ThumbnailsLoader.create($src, $crossOrigin)); if (!scope) { console.warn( `[vidstack] \`useThumbnails\` must be called inside a child component of \`<MediaPlayer>\`` ); } React.useEffect(() => { $src.set(src); }, [src]); React.useEffect(() => { $crossOrigin.set(crossOrigin); }, [crossOrigin]); return useSignal(loader.$images); } function useActiveThumbnail(thumbnails, time) { return React.useMemo(() => { let activeIndex = -1; for (let i = thumbnails.length - 1; i >= 0; i--) { const image = thumbnails[i]; if (time >= image.startTime && (!image.endTime || time < image.endTime)) { activeIndex = i; break; } } return thumbnails[activeIndex] || null; }, [thumbnails, time]); } function useSliderPreview({ clamp = false, offset = 0, orientation = "horizontal" } = {}) { const [rootRef, setRootRef] = React.useState(null), [previewRef, setPreviewRef] = React.useState(null), [pointerValue, setPointerValue] = React.useState(0), [isVisible, setIsVisible] = React.useState(false); React.useEffect(() => { if (!rootRef) return; const dragging = signal(false); function updatePointerValue(event) { if (!rootRef) return; setPointerValue(getPointerValue(rootRef, event, orientation)); } return effect(() => { if (!dragging()) { new EventsController(rootRef).add("pointerenter", () => { setIsVisible(true); previewRef?.setAttribute("data-visible", ""); }).add("pointerdown", (event) => { dragging.set(true); updatePointerValue(event); }).add("pointerleave", () => { setIsVisible(false); previewRef?.removeAttribute("data-visible"); }).add("pointermove", updatePointerValue); } previewRef?.setAttribute("data-dragging", ""); new EventsController(document).add("pointerup", (event) => { dragging.set(false); previewRef?.removeAttribute("data-dragging"); updatePointerValue(event); }).add("pointermove", updatePointerValue).add("touchmove", (e) => e.preventDefault(), { passive: false }); }); }, [rootRef]); React.useEffect(() => { if (previewRef) { previewRef.style.setProperty("--slider-pointer", pointerValue + "%"); } }, [previewRef, pointerValue]); React.useEffect(() => { if (!previewRef) return; const update = () => { updateSliderPreviewPlacement(previewRef, { offset, clamp, orientation }); }; update(); const resize = new ResizeObserver(update); resize.observe(previewRef); return () => resize.disconnect(); }, [previewRef, clamp, offset, orientation]); return { previewRootRef: setRootRef, previewRef: setPreviewRef, previewValue: pointerValue, isPreviewVisible: isVisible }; } function getPointerValue(root, event, orientation) { let thumbPositionRate, rect = root.getBoundingClientRect(); if (orientation === "vertical") { const { bottom: trackBottom, height: trackHeight } = rect; thumbPositionRate = (trackBottom - event.clientY) / trackHeight; } else { const { left: trackLeft, width: trackWidth } = rect; thumbPositionRate = (event.clientX - trackLeft) / trackWidth; } return round(Math.max(0, Math.min(100, 100 * thumbPositionRate))); } function round(num) { return Number(num.toFixed(3)); } function useAudioGainOptions({ gains = DEFAULT_AUDIO_GAINS, disabledLabel = "disabled" } = {}) { const media = useMediaContext$1(), { audioGain, canSetAudioGain } = media.$state; useSignal(audioGain); useSignal(canSetAudioGain); return React.useMemo(() => { const options = gains.map((opt) => { const label = typeof opt === "number" ? opt === 1 && disabledLabel ? disabledLabel : opt * 100 + "%" : opt.label, gain = typeof opt === "number" ? opt : opt.gain; return { label, value: gain.toString(), gain, get selected() { return audioGain() === gain; }, select(trigger) { media.remote.changeAudioGain(gain, trigger); } }; }); Object.defineProperty(options, "disabled", { get() { return !canSetAudioGain() || !options.length; } }); Object.defineProperty(options, "selectedValue", { get() { return audioGain()?.toString(); } }); return options; }, [gains]); } export { caption as Caption, DEFAULT_AUDIO_GAINS, Icon, LibASSTextRenderer, MediaPlayer, MediaPlayerInstance, MediaProvider, MediaProviderInstance, Poster, PosterInstance, TextTrack, ToggleButton, ToggleButtonInstance, Track, createTextTrack, mediaState, useActiveThumbnail, useAudioGainOptions, useMediaContext$1 as useMediaContext, useMediaProvider, useMediaState, useSliderPreview, useState, useStore, useThumbnails };