'use client' import { useCallback, useEffect, useRef, useState } from 'react' import { Pause, Play, RotateCcw, StepForward } from 'lucide-react' import { PageMd } from '@/components/interactive-page/page-md' import type { AnimBeat, SimI18n } from '@/lib/simulators' import { cn } from '@/lib/utils' const WPM = 220 const STEP_FLOOR_MS = 1500 const STEP_CEIL_MS = 6000 const SPEEDS = [0.5, 1, 2, 4] function stepDurationMs(speak: string, speed: number): number { const words = speak.trim().split(/\s+/).filter(Boolean).length const raw = words * (60_000 / WPM) return Math.min(STEP_CEIL_MS, Math.max(STEP_FLOOR_MS, raw)) / speed } 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 } export type AnimPlayerShellProps = { beats: AnimBeat[] lang: string disclaimer?: SimI18n /** Scene renderer — receives the current beat index (0-based). */ children: (stepIndex: number) => React.ReactNode } /** * Shared chrome for curated animation plugins: Play/Pause/Step/Reset/Speed * + narration panel, same pacing rules as InteractiveDemoPlayer. */ export function AnimPlayerShell({ beats, lang, disclaimer, children, }: AnimPlayerShellProps) { const fr = lang.startsWith('fr') const reducedMotion = usePrefersReducedMotion() const [stepIndex, setStepIndex] = useState(0) const [playing, setPlaying] = useState(false) const [speed, setSpeed] = useState(1) const timerRef = useRef | null>(null) /** Keyboard shortcuts only fire for the hovered/focused player. */ const activeRef = useRef(false) const clearTimer = useCallback(() => { if (timerRef.current) { clearTimeout(timerRef.current) timerRef.current = null } }, []) const atLast = stepIndex >= beats.length - 1 useEffect(() => { clearTimer() if (!playing) return const beat = beats[stepIndex] if (!beat) return const speak = fr ? beat.speak.fr : beat.speak.en timerRef.current = setTimeout( () => { if (stepIndex >= beats.length - 1) { setPlaying(false) return } setStepIndex((s) => s + 1) }, reducedMotion ? 250 : stepDurationMs(speak, speed) ) return clearTimer }, [playing, stepIndex, beats, speed, fr, reducedMotion, clearTimer]) useEffect(() => { const onKey = (e: KeyboardEvent) => { const tag = (e.target as HTMLElement)?.tagName if (tag === 'INPUT' || tag === 'TEXTAREA') 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.preventDefault() setPlaying(false) setStepIndex((s) => Math.min(beats.length - 1, s + 1)) } else if (e.code === 'ArrowLeft') { e.preventDefault() setPlaying(false) setStepIndex((s) => Math.max(0, s - 1)) } else if (e.code === 'KeyR') { e.preventDefault() setPlaying(false) setStepIndex(0) } } window.addEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey) }, [beats.length]) const beat = beats[stepIndex] const speak = beat ? (fr ? beat.speak.fr : beat.speak.en) : '' return (
{ activeRef.current = true }} onPointerLeave={() => { activeRef.current = false }} onFocusCapture={() => { activeRef.current = true }} onBlurCapture={() => { activeRef.current = false }} >
{children(stepIndex)}
{fr ? 'étape' : 'step'} {stepIndex + 1} / {beats.length}
{SPEEDS.map((s) => ( ))}
{disclaimer ? (

{fr ? disclaimer.fr : disclaimer.en}

) : null}
) }