// ClickRippleOverlay - Renders expanding ripple animations at click positions during video playback import React, { useState, useEffect, useRef } from "react"; import type { ClickEvent } from "../lib/api"; interface ClickRippleOverlayProps { clicks: ClickEvent[]; currentTimeMs: number; enabled?: boolean; } interface ActiveRipple { id: string; x: number; y: number; button: "left" | "right"; startTime: number; } const RIPPLE_DURATION_MS = 400; const RIPPLE_MAX_SIZE = 60; // pixels const ClickRippleOverlay: React.FC = ({ clicks, currentTimeMs, enabled = true, }) => { const [activeRipples, setActiveRipples] = useState([]); const lastTimeRef = useRef(0); const processedClicksRef = useRef>(new Set()); useEffect(() => { if (!enabled) { setActiveRipples([]); processedClicksRef.current.clear(); return; } // Detect if we're seeking backwards or restarting if (currentTimeMs < lastTimeRef.current - 100) { // Reset processed clicks when seeking backwards processedClicksRef.current.clear(); setActiveRipples([]); } lastTimeRef.current = currentTimeMs; // Find clicks that should trigger now // A click triggers if we just passed its timestamp const newRipples: ActiveRipple[] = []; for (const click of clicks) { const clickId = `${click.timestamp_ms}-${click.x}-${click.y}`; // Check if this click is within the trigger window // We trigger if currentTime is within 50ms after the click timestamp const timeDiff = currentTimeMs - click.timestamp_ms; if (timeDiff >= 0 && timeDiff < 50 && !processedClicksRef.current.has(clickId)) { processedClicksRef.current.add(clickId); newRipples.push({ id: `${clickId}-${Date.now()}`, x: click.x, y: click.y, button: click.button, startTime: Date.now(), }); } } if (newRipples.length > 0) { setActiveRipples((prev) => [...prev, ...newRipples]); } }, [currentTimeMs, clicks, enabled]); // Cleanup expired ripples useEffect(() => { const cleanup = setInterval(() => { const now = Date.now(); setActiveRipples((prev) => prev.filter((ripple) => now - ripple.startTime < RIPPLE_DURATION_MS) ); }, 50); return () => clearInterval(cleanup); }, []); if (!enabled || activeRipples.length === 0) { return null; } return (
{activeRipples.map((ripple) => ( ))}
); }; interface RippleCircleProps { x: number; y: number; button: "left" | "right"; startTime: number; } const RippleCircle: React.FC = ({ x, y, button, startTime }) => { const [progress, setProgress] = useState(0); useEffect(() => { const animate = () => { const elapsed = Date.now() - startTime; const newProgress = Math.min(elapsed / RIPPLE_DURATION_MS, 1); setProgress(newProgress); if (newProgress < 1) { requestAnimationFrame(animate); } }; requestAnimationFrame(animate); }, [startTime]); // Easing function for smooth animation const easeOutCubic = (t: number) => 1 - Math.pow(1 - t, 3); const easedProgress = easeOutCubic(progress); const size = RIPPLE_MAX_SIZE * easedProgress; const opacity = 1 - progress; // Vercel-style monochrome ripples // Subtle white for left click, slightly dimmer for right click const color = button === "left" ? "rgba(255, 255, 255, 0.15)" : "rgba(255, 255, 255, 0.1)"; const borderColor = "rgba(255, 255, 255, 0.4)"; return (
); }; export default ClickRippleOverlay;