"use client"; import type { RiveParameters } from "@rive-app/react-webgl2"; import { useRive, useStateMachineInput, useViewModel, useViewModelInstance, useViewModelInstanceColor } from "@rive-app/react-webgl2"; import type { FC, ReactNode } from "react"; import { memo, useEffect, useMemo, useRef, useState } from "react"; import { cn } from "@/lib/utils"; const useStrictModeSafeInit = () => { const [ready, setReady] = useState(false); useEffect(() => { const id = requestAnimationFrame(() => setReady(true)); return () => { cancelAnimationFrame(id); setReady(false); }; }, []); return ready; }; export type PersonaState = | "idle" | "listening" | "thinking" | "speaking" | "asleep"; const sources = { command: { dynamicColor: true, hasModel: true, source: "https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/command-2.0.riv" }, glint: { dynamicColor: true, hasModel: true, source: "https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/glint-2.0.riv" }, halo: { dynamicColor: true, hasModel: true, source: "https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/halo-2.0.riv" }, mana: { dynamicColor: false, hasModel: true, source: "https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/mana-2.0.riv" }, obsidian: { dynamicColor: true, hasModel: true, source: "https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/obsidian-2.0.riv" }, opal: { dynamicColor: false, hasModel: false, source: "https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/orb-1.2.riv" } }; export type PersonaVariant = keyof typeof sources; export type PersonaProps = { state?: PersonaState; onLoad?: RiveParameters["onLoad"]; onLoadError?: RiveParameters["onLoadError"]; onReady?: () => void; onPause?: RiveParameters["onPause"]; onPlay?: RiveParameters["onPlay"]; onStop?: RiveParameters["onStop"]; className?: string; variant?: PersonaVariant; }; const stateMachine = "default"; const getCurrentTheme = (): "light" | "dark" => { if (typeof window !== "undefined") { if (document.documentElement.classList.contains("dark")) { return "dark"; } if (window.matchMedia?.("(prefers-color-scheme: dark)").matches) { return "dark"; } } return "light"; }; const useTheme = (enabled: boolean) => { const [theme, setTheme] = useState<"light" | "dark">("light"); useEffect(() => { if (!enabled) { return; } setTheme(getCurrentTheme()); const observer = new MutationObserver(() => { setTheme(getCurrentTheme()); }); observer.observe(document.documentElement, { attributeFilter: ["class"], attributes: true }); let mql: MediaQueryList | null = null; const handleMediaChange = () => { setTheme(getCurrentTheme()); }; if (window.matchMedia) { mql = window.matchMedia("(prefers-color-scheme: dark)"); mql.addEventListener("change", handleMediaChange); } return () => { observer.disconnect(); if (mql) { mql.removeEventListener("change", handleMediaChange); } }; }, [enabled]); return theme; }; type PersonaSource = (typeof sources)[PersonaVariant]; type PersonaWithModelProps = { rive: ReturnType["rive"]; source: PersonaSource; children: ReactNode; }; const PersonaWithModel = memo(({ rive, source, children }: PersonaWithModelProps) => { const theme = useTheme(source.dynamicColor); const viewModel = useViewModel(rive, { useDefault: true }); const viewModelInstance = useViewModelInstance(viewModel, { rive, useDefault: true }); const viewModelInstanceColor = useViewModelInstanceColor( "color", viewModelInstance ); useEffect(() => { if (!(viewModelInstanceColor && source.dynamicColor)) { return; } const [r, g, b] = theme === "dark" ? [255, 255, 255] : [0, 0, 0]; viewModelInstanceColor.setRgb(r, g, b); }, [viewModelInstanceColor, theme, source.dynamicColor]); return children; }); PersonaWithModel.displayName = "PersonaWithModel"; const PersonaWithoutModel = memo(({ children }: { children: ReactNode }) => children); PersonaWithoutModel.displayName = "PersonaWithoutModel"; export const Persona: FC = memo( ({ variant = "obsidian", state = "idle", onLoad, onLoadError, onReady, onPause, onPlay, onStop, className }) => { const source = sources[variant]; const callbacksRef = useRef({ onLoad, onLoadError, onPause, onPlay, onReady, onStop }); useEffect(() => { callbacksRef.current = { onLoad, onLoadError, onPause, onPlay, onReady, onStop }; }, [onLoad, onLoadError, onPause, onPlay, onReady, onStop]); const stableCallbacks = useMemo( () => ({ onLoad: ((loadedRive) => callbacksRef.current.onLoad?.(loadedRive)) as RiveParameters["onLoad"], onLoadError: ((error) => callbacksRef.current.onLoadError?.(error)) as RiveParameters["onLoadError"], onPause: ((event) => callbacksRef.current.onPause?.(event)) as RiveParameters["onPause"], onPlay: ((event) => callbacksRef.current.onPlay?.(event)) as RiveParameters["onPlay"], onReady: () => callbacksRef.current.onReady?.(), onStop: ((event) => callbacksRef.current.onStop?.(event)) as RiveParameters["onStop"] }), [] ); const ready = useStrictModeSafeInit(); const { rive, RiveComponent } = useRive( ready ? { autoplay: true, onLoad: stableCallbacks.onLoad, onLoadError: stableCallbacks.onLoadError, onPause: stableCallbacks.onPause, onPlay: stableCallbacks.onPlay, onRiveReady: stableCallbacks.onReady, onStop: stableCallbacks.onStop, src: source.source, stateMachines: stateMachine } : null ); const listeningInput = useStateMachineInput( rive, stateMachine, "listening" ); const thinkingInput = useStateMachineInput(rive, stateMachine, "thinking"); const speakingInput = useStateMachineInput(rive, stateMachine, "speaking"); const asleepInput = useStateMachineInput(rive, stateMachine, "asleep"); useEffect(() => { if (listeningInput) { listeningInput.value = state === "listening"; } if (thinkingInput) { thinkingInput.value = state === "thinking"; } if (speakingInput) { speakingInput.value = state === "speaking"; } if (asleepInput) { asleepInput.value = state === "asleep"; } }, [state, listeningInput, thinkingInput, speakingInput, asleepInput]); const visual = ; if (!source.hasModel) { return {visual}; } return ( {visual} ); } ); Persona.displayName = "Persona";