UNPKG

@aidenlx/vidstack-react

Version:

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

647 lines (641 loc) 20 kB
"use client" import * as React from 'react'; import { createScope, signal, peek, effect, tick } from 'maverick.js'; import { useSignal } from 'maverick.js/react'; import { createDisposalBin, listenEvent, isNumber, isUndefined, isNull, isNil, isFunction, deferredPromise } from 'maverick.js/std'; import { Composition, Internals } from 'remotion'; import { REMOTION_PROVIDER_ID, RemotionLayoutEngine, RemotionContextProvider, ErrorBoundary } from '../player/vidstack-remotion.js'; import { isRemotionSrc, TimeRange } from './vidstack-B1ySk2FQ.js'; import { NoReactInternals } from 'remotion/no-react'; import './vidstack-DcGAdum5.js'; import './vidstack-CPShcCv0.js'; import '@floating-ui/dom'; class RemotionPlaybackEngine { #src; #onFrameChange; #onEnd; #disposal = createDisposalBin(); #frame = 0; #framesAdvanced = 0; #playbackRate = 1; #playing = false; #rafId = -1; #timerId = -1; #startedAt = 0; #isRunningInBackground = false; get frame() { return this.#frame; } set frame(frame) { this.#frame = frame; this.#onFrameChange(frame); } constructor(src, onFrameChange, onEnd) { this.#src = src; this.#onFrameChange = onFrameChange; this.#onEnd = onEnd; this.#frame = src.initialFrame ?? 0; this.#disposal.add( listenEvent(document, "visibilitychange", this.#onVisibilityChange.bind(this)) ); } play() { this.#framesAdvanced = 0; this.#playing = true; this.#startedAt = performance.now(); this.#tick(); } stop() { this.#playing = false; if (this.#rafId >= 0) { cancelAnimationFrame(this.#rafId); this.#rafId = -1; } if (this.#timerId >= 0) { clearTimeout(this.#timerId); this.#timerId = -1; } } setPlaybackRate(rate) { this.#playbackRate = rate; } destroy() { this.#disposal.empty(); this.stop(); } #update() { const { nextFrame, framesToAdvance, ended } = this.#calculateNextFrame(); this.#framesAdvanced += framesToAdvance; if (nextFrame !== this.#frame) { this.#onFrameChange(nextFrame); this.#frame = nextFrame; } if (ended) { this.#frame = this.#src.outFrame; this.stop(); this.#onEnd(); } } #tick = () => { this.#update(); if (this.#playing) { this.#queueNextFrame(this.#tick); } }; #queueNextFrame(callback) { if (this.#isRunningInBackground) { this.#timerId = window.setTimeout(callback, 1e3 / this.#src.fps); } else { this.#rafId = requestAnimationFrame(callback); } } #calculateNextFrame() { const round = this.#playbackRate < 0 ? Math.ceil : Math.floor, time = performance.now() - this.#startedAt, framesToAdvance = round(time * this.#playbackRate / (1e3 / this.#src.fps)) - this.#framesAdvanced, nextFrame = framesToAdvance + this.#frame, isCurrentFrameOutOfBounds = this.#frame > this.#src.outFrame || this.#frame < this.#src.inFrame, isNextFrameOutOfBounds = nextFrame > this.#src.outFrame || nextFrame < this.#src.inFrame, ended = isNextFrameOutOfBounds && !isCurrentFrameOutOfBounds; if (this.#playbackRate > 0 && !ended) { if (isNextFrameOutOfBounds) { return { nextFrame: this.#src.inFrame, framesToAdvance, ended }; } return { nextFrame, framesToAdvance, ended }; } if (isNextFrameOutOfBounds) { return { nextFrame: this.#src.outFrame, framesToAdvance, ended }; } return { nextFrame, framesToAdvance, ended }; } #onVisibilityChange() { this.#isRunningInBackground = document.visibilityState === "hidden"; if (this.#playing) { this.stop(); this.play(); } } } function validateRemotionResource({ src, compositionWidth: width, compositionHeight: height, fps, durationInFrames, initialFrame, inFrame, outFrame, numberOfSharedAudioTags }) { validateComponent(src); validateInitialFrame(initialFrame, durationInFrames); validateDimension(width, "compositionWidth", "of the remotion source"); validateDimension(height, "compositionHeight", "of the remotion source"); validateDurationInFrames(durationInFrames, { component: "of the remotion source", allowFloats: false }); validateFps(fps, "of the remotion source", false); validateInOutFrames(inFrame, outFrame, durationInFrames); validateSharedNumberOfAudioTags(numberOfSharedAudioTags); } const validateFps = NoReactInternals.validateFps; const validateDimension = NoReactInternals.validateDimension; const validateDurationInFrames = NoReactInternals.validateDurationInFrames; function validateInitialFrame(initialFrame, frames) { if (!isNumber(frames)) { throw new Error( `[vidstack] \`durationInFrames\` must be a number, but is ${JSON.stringify(frames)}` ); } if (isUndefined(initialFrame)) { return; } if (!isNumber(initialFrame)) { throw new Error( `[vidstack] \`initialFrame\` must be a number, but is ${JSON.stringify(initialFrame)}` ); } if (Number.isNaN(initialFrame)) { throw new Error(`[vidstack] \`initialFrame\` must be a number, but is NaN`); } if (!Number.isFinite(initialFrame)) { throw new Error(`[vidstack] \`initialFrame\` must be a number, but is Infinity`); } if (initialFrame % 1 !== 0) { throw new Error( `[vidstack] \`initialFrame\` must be an integer, but is ${JSON.stringify(initialFrame)}` ); } if (initialFrame > frames - 1) { throw new Error( `[vidstack] \`initialFrame\` must be less or equal than \`durationInFrames - 1\`, but is ${JSON.stringify( initialFrame )}` ); } } function validateSingleFrame(frame, variableName) { if (isNil(frame)) { return frame ?? null; } if (!isNumber(frame)) { throw new TypeError( `[vidstack] \`${variableName}\` must be a number, but is ${JSON.stringify(frame)}` ); } if (Number.isNaN(frame)) { throw new TypeError( `[vidstack] \`${variableName}\` must not be NaN, but is ${JSON.stringify(frame)}` ); } if (!Number.isFinite(frame)) { throw new TypeError( `[vidstack] \`${variableName}\` must be finite, but is ${JSON.stringify(frame)}` ); } if (frame % 1 !== 0) { throw new TypeError( `[vidstack] \`${variableName}\` must be an integer, but is ${JSON.stringify(frame)}` ); } return frame; } function validateInOutFrames(inFrame, outFrame, frames) { const validatedInFrame = validateSingleFrame(inFrame, "inFrame"), validatedOutFrame = validateSingleFrame(outFrame, "outFrame"); if (isNull(validatedInFrame) && isNull(validatedOutFrame)) { return; } if (!isNull(validatedInFrame) && validatedInFrame > frames - 1) { throw new Error( `[vidstack] \`inFrame\` must be less than (durationInFrames - 1), but is \`${validatedInFrame}\`` ); } if (!isNull(validatedOutFrame) && validatedOutFrame > frames) { throw new Error( `[vidstack] \`outFrame\` must be less than (durationInFrames), but is \`${validatedOutFrame}\`` ); } if (!isNull(validatedInFrame) && validatedInFrame < 0) { throw new Error( `[vidstack] \`inFrame\` must be greater than 0, but is \`${validatedInFrame}\`` ); } if (!isNull(validatedOutFrame) && validatedOutFrame <= 0) { throw new Error( `[vidstack] \`outFrame\` must be greater than 0, but is \`${validatedOutFrame}\`. If you want to render a single frame, use \`<RemotionThumbnail />\` instead.` ); } if (!isNull(validatedOutFrame) && !isNull(validatedInFrame) && validatedOutFrame <= validatedInFrame) { throw new Error( "[vidstack] `outFrame` must be greater than `inFrame`, but is " + validatedOutFrame + " <= " + validatedInFrame ); } } function validateSharedNumberOfAudioTags(tags) { if (isUndefined(tags)) return; if (tags % 1 !== 0 || !Number.isFinite(tags) || Number.isNaN(tags) || tags < 0) { throw new TypeError( `[vidstack] \`numberOfSharedAudioTags\` must be an integer but got \`${tags}\` instead` ); } } function validatePlaybackRate(playbackRate) { if (playbackRate > 4) { throw new Error( `[vidstack] The highest possible playback rate with Remotion is 4. You passed: ${playbackRate}` ); } if (playbackRate < -4) { throw new Error( `[vidstack] The lowest possible playback rate with Remotion is -4. You passed: ${playbackRate}` ); } if (playbackRate === 0) { throw new Error(`[vidstack] A playback rate of 0 is not supported.`); } } function validateComponent(src) { if (src.type === Composition) { throw new TypeError( `[vidstack] \`src\` should not be an instance of \`<Composition/>\`. Pass the React component directly, and set the duration, fps and dimensions as source props.` ); } if (src === Composition) { throw new TypeError( `[vidstack] \`src\` must not be the \`Composition\` component. Pass your own React component directly, and set the duration, fps and dimensions as source props.` ); } } class RemotionProvider { $$PROVIDER_TYPE = "REMOTION"; scope = createScope(); #src = signal(null); #setup = false; #loadStart = false; #audio = null; #waiting = signal(false); #waitingPromise = null; #mediaTags = signal([]); #mediaElements = signal([]); #bufferingElements = /* @__PURE__ */ new Set(); #timeline = null; #frame = signal({ [REMOTION_PROVIDER_ID]: 0 }); #layoutEngine = new RemotionLayoutEngine(); #playbackEngine = null; #container; #ctx; #setTimeline; #setMediaVolume = { setMediaMuted: this.setMuted.bind(this), setMediaVolume: this.setVolume.bind(this) }; get type() { return "remotion"; } get currentSrc() { return peek(this.#src); } get frame() { return this.#frame(); } constructor(container, ctx) { this.#container = container; this.#ctx = ctx; this.#setTimeline = { setFrame: this.#setFrame.bind(this), setPlaying: this.#setPlaying.bind(this) }; this.#layoutEngine.setContainer(container); } setup() { effect(this.#watchWaiting.bind(this)); effect(this.#watchMediaTags.bind(this)); effect(this.#watchMediaElements.bind(this)); } #watchMediaTags() { this.#mediaTags(); this.#discoverMediaElements(); } #discoverMediaElements() { const elements = [...this.#container.querySelectorAll("audio,video")]; this.#mediaElements.set(elements); } #watchMediaElements() { const elements = this.#mediaElements(); for (const tag of elements) { const onWait = this.#onWaitFor.bind(this, tag), onStopWaiting = this.#onStopWaitingFor.bind(this, tag); if (tag.currentSrc && tag.readyState < 4) { this.#onWaitFor(tag); listenEvent(tag, "canplay", onStopWaiting); } listenEvent(tag, "waiting", onWait); listenEvent(tag, "playing", onStopWaiting); } for (const el of this.#bufferingElements) { if (!elements.includes(el)) this.#onStopWaitingFor(el); } } #onFrameChange(frame) { const { inFrame, fps } = this.#src(), { seeking } = this.#ctx.$state, time = Math.max(0, frame - inFrame) / fps; this.#frame.set((record) => ({ ...record, [REMOTION_PROVIDER_ID]: frame })); this.#ctx.notify("time-change", time); if (seeking()) { tick(); this.#ctx.notify("seeked", time); } } #onFrameEnd() { this.pause(); this.#ctx.notify("end"); } async play() { const { ended } = this.#ctx.$state; if (peek(ended)) { this.#setFrame({ [REMOTION_PROVIDER_ID]: 0 }); } try { const mediaElements = peek(this.#mediaElements); if (mediaElements.length) { await Promise.all(mediaElements.map((media) => media.play())); } this.#ctx.notify("play"); tick(); if (this.#waitingPromise) { this.#ctx.notify("waiting"); return this.#waitingPromise.promise; } else { this.#playbackEngine?.play(); this.#ctx.notify("playing"); } } catch (error) { throw error; } } async pause() { const { paused } = this.#ctx.$state; this.#playbackEngine?.stop(); this.#ctx.notify("pause"); } setMuted(value) { if (!this.#ctx) return; const { muted, volume } = this.#ctx.$state; if (isFunction(value)) { this.setMuted(value(muted())); return; } this.#ctx.notify("volume-change", { volume: peek(volume), muted: value }); } setCurrentTime(time) { const { fps } = this.#src(), frame = time * fps; this.#ctx.notify("seeking", time); this.#setFrame({ [REMOTION_PROVIDER_ID]: frame }); } setVolume(value) { if (!this.#ctx) return; const { volume, muted } = this.#ctx.$state; if (isFunction(value)) { this.setVolume(value(volume())); return; } this.#ctx.notify("volume-change", { volume: value, muted: peek(muted) }); } setPlaybackRate(rate) { if (isFunction(rate)) { const { playbackRate } = this.#ctx.$state; this.setPlaybackRate(rate(peek(playbackRate))); return; } validatePlaybackRate(rate); this.#playbackEngine?.setPlaybackRate(rate); this.#ctx.notify("rate-change", rate); } async loadSource(src) { if (!isRemotionSrc(src)) return; const onUserError = src.onError, resolvedSrc = { compositionWidth: 1920, compositionHeight: 1080, fps: 30, initialFrame: 0, inFrame: 0, outFrame: src.durationInFrames, numberOfSharedAudioTags: 5, inputProps: {}, ...src, onError: (error) => { { this.#ctx.logger?.errorGroup(`[vidstack] ${error.message}`).labelledLog("Source", peek(this.#src)).labelledLog("Error", error).dispatch(); } this.pause(); this.#ctx.notify("error", { message: error.message, code: 1 }); onUserError?.(error); } }; this.#src.set(resolvedSrc); for (const prop of Object.keys(resolvedSrc)) { src[prop] = resolvedSrc[prop]; } this.changeSrc(resolvedSrc); } destroy() { this.changeSrc(null); } changeSrc(src) { this.#playbackEngine?.destroy(); this.#waiting.set(false); this.#waitingPromise?.reject("src changed"); this.#waitingPromise = null; this.#audio = null; this.#timeline = null; this.#playbackEngine = null; this.#mediaTags.set([]); this.#bufferingElements.clear(); this.#frame.set({ [REMOTION_PROVIDER_ID]: 0 }); this.#layoutEngine.setSrc(src); if (src) { this.#timeline = this.#createTimelineContextValue(); this.#playbackEngine = new RemotionPlaybackEngine( src, this.#onFrameChange.bind(this), this.#onFrameEnd.bind(this) ); } } render = () => { const $src = useSignal(this.#src); if (!$src) { throw Error( "[vidstack] attempting to render remotion provider without src" ); } React.useEffect(() => { if (!isRemotionSrc($src)) return; validateRemotionResource($src); const rafId = requestAnimationFrame(() => { if (!this.#setup) { this.#ctx.notify("provider-setup", this); this.#setup = true; } if (!this.#loadStart) { this.#ctx.notify("load-start"); this.#loadStart = true; } this.#discoverMediaElements(); tick(); if (!this.#waiting()) this.#ready($src); }); return () => { cancelAnimationFrame(rafId); this.#loadStart = false; }; }, [$src]); const Component = Internals.useLazyComponent({ component: $src.src }); const { $state } = this.#ctx, $volume = useSignal($state.volume), $isMuted = useSignal($state.muted); const mediaVolume = React.useMemo(() => { const { muted, volume } = this.#ctx.$state; return { mediaMuted: muted(), mediaVolume: volume() }; }, [$isMuted, $volume]); return /* @__PURE__ */ React.createElement( RemotionContextProvider, { src: $src, component: Component, timeline: this.#timeline, mediaVolume, setMediaVolume: this.#setMediaVolume }, /* @__PURE__ */ React.createElement(Internals.Timeline.SetTimelineContext.Provider, { value: this.#setTimeline }, React.createElement(this.renderVideo, { src: $src })) ); }; renderVideo = ({ src }) => { const video = Internals.useVideo(), Video = video ? video.component : null, audioContext = React.useContext(Internals.SharedAudioContext); const { $state } = this.#ctx; useSignal(this.#frame); useSignal($state.playing); useSignal($state.playbackRate); React.useEffect(() => { this.#audio = audioContext; return () => { this.#audio = null; }; }, [audioContext]); const LoadingContent = React.useMemo(() => src.renderLoading?.(), [src]); const Content = Video ? /* @__PURE__ */ React.createElement(ErrorBoundary, { fallback: src.errorFallback, onError: src.onError }, /* @__PURE__ */ React.createElement(Internals.ClipComposition, null, /* @__PURE__ */ React.createElement(Video, { ...video?.props, ...src.inputProps }))) : null; return /* @__PURE__ */ React.createElement(React.Suspense, { fallback: LoadingContent }, Content); }; #ready(src) { if (!src) return; const { outFrame, inFrame, fps } = src, duration = (outFrame - inFrame) / fps; this.#ctx.notify("loaded-metadata"); this.#ctx.notify("loaded-data"); this.#ctx.delegate.ready({ duration, seekable: new TimeRange(0, duration), buffered: new TimeRange(0, duration) }); if (src.initialFrame) { this.#setFrame({ [REMOTION_PROVIDER_ID]: src.initialFrame }); } } #onWaitFor(el) { this.#bufferingElements.add(el); this.#waiting.set(true); if (!this.#waitingPromise) { this.#waitingPromise = deferredPromise(); } } #onStopWaitingFor(el) { this.#bufferingElements.delete(el); if (this.#bufferingElements.size) return; this.#waiting.set(false); this.#waitingPromise?.resolve(); this.#waitingPromise = null; const { canPlay } = this.#ctx.$state; if (!peek(canPlay)) { this.#ready(peek(this.#src)); } } #watchWaiting() { this.#waiting(); const { paused } = this.#ctx.$state; if (peek(paused)) return; if (this.#waiting()) { this.#playbackEngine?.stop(); this.#ctx.notify("waiting"); } else { this.#playbackEngine?.play(); this.#ctx.notify("playing"); } } #setFrame(value) { if (isFunction(value)) { this.#setFrame(value(this.#frame())); return; } this.#frame.set((record) => ({ ...record, ...value })); const nextFrame = value[REMOTION_PROVIDER_ID]; if (this.#playbackEngine && this.#playbackEngine.frame !== nextFrame) { this.#playbackEngine.frame = nextFrame; } } #setPlaying(value) { const { playing } = this.#ctx.$state; if (isFunction(value)) { this.#setPlaying(value(playing())); return; } if (value) { this.play(); } else if (!value) { this.pause(); } } #createTimelineContextValue() { const { playing, playbackRate } = this.#ctx.$state, frame = this.#frame, mediaTags = this.#mediaTags, setPlaybackRate = this.setPlaybackRate.bind(this); return { rootId: REMOTION_PROVIDER_ID, get frame() { return frame(); }, get playing() { return playing(); }, get playbackRate() { return playbackRate(); }, imperativePlaying: { get current() { return playing(); } }, setPlaybackRate, audioAndVideoTags: { get current() { return mediaTags(); }, set current(tags) { mediaTags.set(tags); } } }; } } export { RemotionProvider };