import { useEffect, useRef, useCallback } from 'react'; // Config-driven. Loaded from /morphy/headphones.json. // Clips map to states: // idle (hold) → 'idle' // enter (forward) → 'activating' // active (pingpong)→ 'recording' // exit (forward) → 'deactivating' const HP_CONFIG_URL = '/morphy/headphones.json'; const HP_ASSETS_DIR = '/morphy/'; interface HpConfig { spritesheet: string; frame: { w: number; h: number }; grid: { cols: number; rows: number }; fps: number; clips: { idle: { from: number; to: number; mode: string; fps?: number }; enter: { from: number; to: number; mode: string; fps?: number }; active: { from: number; to: number; mode: string; fps?: number }; exit: { from: number; to: number; mode: string; fps?: number }; }; } let cachedSprite: HTMLImageElement | null = null; let cachedConfig: HpConfig | null = null; function loadConfigAndSprite(): Promise<{ img: HTMLImageElement; cfg: HpConfig }> { if (cachedSprite && cachedConfig) return Promise.resolve({ img: cachedSprite, cfg: cachedConfig }); return fetch(HP_CONFIG_URL) .then((r) => { if (!r.ok) throw new Error('hp config ' + r.status); return r.json() as Promise; }) .then((cfg) => new Promise<{ img: HTMLImageElement; cfg: HpConfig }>((resolve, reject) => { const img = new Image(); img.onload = () => { cachedSprite = img; cachedConfig = cfg; resolve({ img, cfg }); }; img.onerror = reject; img.src = HP_ASSETS_DIR + cfg.spritesheet; })); } interface Props { recording: boolean; height?: number; onDone?: () => void; } type HpState = 'idle' | 'activating' | 'activating_then_deactivate' | 'recording' | 'deactivating'; export default function HeadphonesAnimation({ recording, height = 36, onDone }: Props) { const canvasRef = useRef(null); const spriteRef = useRef(cachedSprite); const configRef = useRef(cachedConfig); const stateRef = useRef('idle'); const frameRef = useRef(0); const pingPongRef = useRef(1); const lastRef = useRef(0); const rafRef = useRef(0); const prevRecording = useRef(false); const onDoneRef = useRef(onDone); onDoneRef.current = onDone; // Aspect ratio from config (defaults to 1:1 — matches new sprite). const cfg = configRef.current; const aspectW = cfg ? cfg.frame.w : 180; const aspectH = cfg ? cfg.frame.h : 180; const displayH = height; const displayW = Math.round(displayH * (aspectW / aspectH)); useEffect(() => { loadConfigAndSprite().then(({ img, cfg }) => { spriteRef.current = img; configRef.current = cfg; if (stateRef.current === 'idle') frameRef.current = cfg.clips.idle.from; }); }, []); // React to recording prop useEffect(() => { const cfg = configRef.current; if (!cfg) return; if (recording && !prevRecording.current) { stateRef.current = 'activating'; frameRef.current = cfg.clips.enter.from; pingPongRef.current = 1; lastRef.current = performance.now(); } else if (!recording && prevRecording.current) { if (stateRef.current === 'activating') { stateRef.current = 'activating_then_deactivate'; } else if (stateRef.current === 'recording') { stateRef.current = 'deactivating'; frameRef.current = cfg.clips.exit.from; lastRef.current = performance.now(); } } prevRecording.current = recording; }, [recording]); const tick = useCallback((now: number) => { const canvas = canvasRef.current; const sprite = spriteRef.current; const cfg = configRef.current; if (!canvas) { rafRef.current = requestAnimationFrame(tick); return; } const ctx = canvas.getContext('2d')!; const state = stateRef.current; if (!sprite || !cfg) { ctx.clearRect(0, 0, displayW, displayH); rafRef.current = requestAnimationFrame(tick); return; } if (state === 'idle') { // Hold the configured idle frame so the chat header shows a real visual at rest. const cols = cfg.grid.cols; const fw = cfg.frame.w; const fh = cfg.frame.h; const idleFrame = cfg.clips.idle.from; const col = idleFrame % cols; const row = Math.floor(idleFrame / cols); ctx.clearRect(0, 0, displayW, displayH); ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = 'high'; ctx.drawImage(sprite, col * fw, row * fh, fw, fh, 0, 0, displayW, displayH); rafRef.current = requestAnimationFrame(tick); return; } const baseFps = cfg.fps; const fps = state === 'activating' || state === 'activating_then_deactivate' ? (cfg.clips.enter.fps ?? baseFps) : state === 'deactivating' ? (cfg.clips.exit.fps ?? baseFps) : (cfg.clips.active.fps ?? baseFps); const frameMs = 1000 / fps; const delta = now - lastRef.current; if (delta >= frameMs) { lastRef.current = now - (delta % frameMs); if (state === 'activating' || state === 'activating_then_deactivate') { frameRef.current++; if (frameRef.current > cfg.clips.enter.to) { if (state === 'activating_then_deactivate') { stateRef.current = 'deactivating'; frameRef.current = cfg.clips.exit.from; } else { stateRef.current = 'recording'; frameRef.current = cfg.clips.active.from; pingPongRef.current = 1; } } } else if (state === 'recording') { frameRef.current += pingPongRef.current; if (frameRef.current >= cfg.clips.active.to) { frameRef.current = cfg.clips.active.to; pingPongRef.current = -1; } else if (frameRef.current <= cfg.clips.active.from) { frameRef.current = cfg.clips.active.from; pingPongRef.current = 1; } } else if (state === 'deactivating') { frameRef.current++; if (frameRef.current > cfg.clips.exit.to) { stateRef.current = 'idle'; frameRef.current = cfg.clips.idle.from; ctx.clearRect(0, 0, displayW, displayH); onDoneRef.current?.(); rafRef.current = requestAnimationFrame(tick); return; } } } const cols = cfg.grid.cols; const fw = cfg.frame.w; const fh = cfg.frame.h; const col = frameRef.current % cols; const row = Math.floor(frameRef.current / cols); ctx.clearRect(0, 0, displayW, displayH); ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = 'high'; ctx.drawImage(sprite, col * fw, row * fh, fw, fh, 0, 0, displayW, displayH); rafRef.current = requestAnimationFrame(tick); }, [displayW, displayH]); useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; const dpr = window.devicePixelRatio || 1; canvas.width = displayW * dpr; canvas.height = displayH * dpr; const ctx = canvas.getContext('2d')!; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); rafRef.current = requestAnimationFrame(tick); return () => cancelAnimationFrame(rafRef.current); }, [tick, displayW, displayH]); return ; }