'use client' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Pause, Play, RotateCcw, SkipBack, SkipForward, StepBack, StepForward, } from 'lucide-react' import { DemoSceneView } from '@/components/interactive-demo/demo-scene-view' import { DemoSpeak } from '@/components/interactive-demo/demo-speak' import { resolveInteractiveDemo, type InteractiveDemoV1, } from '@/lib/interactive-demo' import { cn } from '@/lib/utils' import { useLanguage } from '@/lib/i18n' const SPEEDS = [0.5, 1, 2, 4] as const const WPM = 220 const STEP_FLOOR_MS = 1500 const STEP_CEIL_MS = 6000 const ACT_CHANGE_PAUSE_MS = 800 function usePrefersReducedMotion(): boolean { const [reduced, setReduced] = useState(false) useEffect(() => { const mq = window.matchMedia('(prefers-reduced-motion: reduce)') setReduced(mq.matches) const onChange = () => setReduced(mq.matches) mq.addEventListener('change', onChange) return () => mq.removeEventListener('change', onChange) }, []) return reduced } function stepDurationMs(speak: string, speed: number): number { const words = speak.trim().split(/\s+/).filter(Boolean).length const raw = words * (60_000 / WPM) const clamped = Math.min(STEP_CEIL_MS, Math.max(STEP_FLOOR_MS, raw)) return clamped / speed } export type InteractiveDemoPlayerProps = { demo: InteractiveDemoV1 mode?: 'interactive' | 'static' className?: string } export function InteractiveDemoPlayer({ demo, mode = 'interactive', className, }: InteractiveDemoPlayerProps) { const { t } = useLanguage() const reducedMotion = usePrefersReducedMotion() const resolved = useMemo(() => resolveInteractiveDemo(demo), [demo]) const [actIndex, setActIndex] = useState(0) const [stepIndex, setStepIndex] = useState(0) const [playing, setPlaying] = useState(false) const [speedIdx, setSpeedIdx] = useState(1) const speed = SPEEDS[speedIdx] ?? 1 const timerRef = useRef | null>(null) const actPauseRef = useRef(false) /** Keyboard shortcuts only fire for the hovered/focused player. */ const activeRef = useRef(false) const act = resolved.acts[actIndex] ?? resolved.acts[0] const scenes = useMemo(() => { let scene = demo.scene const map: (typeof demo.scene)[] = [] for (const a of demo.acts) { if (a.scene) scene = a.scene map.push(scene) } return map }, [demo]) const scene = scenes[actIndex] ?? demo.scene const state = mode === 'static' ? act?.final : act?.steps[stepIndex] ?? act?.final const clearTimer = useCallback(() => { if (timerRef.current) { clearTimeout(timerRef.current) timerRef.current = null } }, []) const goStep = useCallback( (next: number) => { if (!act) return if (next < 0) { setStepIndex(0) return } if (next >= act.steps.length) { setPlaying(false) setStepIndex(act.steps.length - 1) return } setStepIndex(next) }, [act] ) const resetAct = useCallback(() => { clearTimer() actPauseRef.current = false setPlaying(false) setStepIndex(0) }, [clearTimer]) const resetDemo = useCallback(() => { clearTimer() actPauseRef.current = false setPlaying(false) setActIndex(0) setStepIndex(0) }, [clearTimer]) const skipAct = useCallback(() => { clearTimer() actPauseRef.current = false setPlaying(false) if (actIndex >= resolved.acts.length - 1) { setStepIndex((act?.steps.length ?? 1) - 1) return } setActIndex((i) => i + 1) setStepIndex(0) }, [actIndex, act, resolved.acts.length, clearTimer]) const prevAct = useCallback(() => { clearTimer() actPauseRef.current = false setPlaying(false) if (actIndex <= 0) { setStepIndex(0) return } setActIndex((i) => i - 1) setStepIndex(0) }, [actIndex, clearTimer]) // Auto-play: duration recalculates immediately when speed/step changes useEffect(() => { clearTimer() if (!playing || mode !== 'interactive' || !act || !state) return const atLastStep = stepIndex >= act.steps.length - 1 const ms = stepDurationMs(state.speak, speed) timerRef.current = setTimeout(() => { if (atLastStep) { // End of act while playing if (actIndex < resolved.acts.length - 1) { actPauseRef.current = true timerRef.current = setTimeout(() => { actPauseRef.current = false setActIndex((i) => i + 1) setStepIndex(0) }, ACT_CHANGE_PAUSE_MS) } else { // End of demo — pause on final state setPlaying(false) } return } setStepIndex((s) => s + 1) }, ms) return clearTimer }, [ playing, stepIndex, speed, act, state, mode, actIndex, resolved.acts.length, clearTimer, ]) useEffect(() => { if (mode !== 'interactive') return const onKey = (e: KeyboardEvent) => { const tag = (e.target as HTMLElement)?.tagName if ( tag === 'INPUT' || tag === 'TEXTAREA' || (e.target as HTMLElement)?.isContentEditable ) { return } // Multi-player pages: only the hovered/focused player answers the keyboard if (!activeRef.current) return if (e.code === 'Space') { e.preventDefault() setPlaying((p) => !p) } else if (e.code === 'ArrowRight' && e.shiftKey) { e.preventDefault() skipAct() } else if (e.code === 'ArrowLeft' && e.shiftKey) { e.preventDefault() prevAct() } else if (e.code === 'ArrowRight') { e.preventDefault() setPlaying(false) goStep(stepIndex + 1) } else if (e.code === 'ArrowLeft') { e.preventDefault() setPlaying(false) goStep(stepIndex - 1) } else if (e.code === 'KeyR' && e.shiftKey) { e.preventDefault() resetDemo() } else if (e.code === 'KeyR') { e.preventDefault() resetAct() } } window.addEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey) }, [mode, stepIndex, goStep, resetAct, resetDemo, skipAct, prevAct]) if (!act || !state || !scene) { return (
{t('interactiveDemo.empty') || 'Interactive demo unavailable'}
) } const progressLabel = `${act.title} · ${t('interactiveDemo.step') || 'étape'} ${stepIndex + 1} / ${act.steps.length}` return (
{ activeRef.current = true }} onPointerLeave={() => { activeRef.current = false }} onFocusCapture={() => { activeRef.current = true }} onBlurCapture={() => { activeRef.current = false }} > {demo.disclaimer && (

{demo.disclaimer}

)}
{progressLabel}
{mode === 'interactive' && (
{SPEEDS.map((s, i) => ( ))}
)}
) }