Spaces:
Build error
Build error
| "use client"; | |
| import { useEffect, useRef, useState, useCallback } from "react"; | |
| export default function LivePreviewFrame({ | |
| sessionId, | |
| onScrapeComplete, | |
| children, | |
| }: { | |
| sessionId: string; | |
| children: React.ReactNode; | |
| onScrapeComplete?: () => void; | |
| }) { | |
| const imgRef = useRef<HTMLImageElement>(null); | |
| const containerRef = useRef<HTMLDivElement>(null); | |
| const wsRef = useRef<WebSocket | null>(null); | |
| const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null); | |
| const initialPositionSetRef = useRef(false); | |
| const idleStartTimerRef = useRef<NodeJS.Timeout | null>(null); | |
| const idleMoveTimerRef = useRef<NodeJS.Timeout | null>(null); | |
| const [imageLoaded, setImageLoaded] = useState(false); | |
| const [imageSrc, setImageSrc] = useState<string | null>(null); | |
| const [isConnecting, setIsConnecting] = useState(true); | |
| const [cursorPosition, setCursorPosition] = useState<{ | |
| x: number; | |
| y: number; | |
| }>({ x: 980, y: 54 }); | |
| const [targetPosition, setTargetPosition] = useState<{ | |
| x: number; | |
| y: number; | |
| }>({ x: 980, y: 54 }); | |
| const [isIdle, setIsIdle] = useState(false); | |
| // Function to start the random idle movement sequence | |
| const scheduleNextIdleMove = useCallback(() => { | |
| if (idleMoveTimerRef.current) { | |
| clearTimeout(idleMoveTimerRef.current); | |
| } | |
| const randomDelay = Math.random() * 500 + 500; // 500ms to 1000ms | |
| idleMoveTimerRef.current = setTimeout(() => { | |
| if (isIdle) { | |
| // Check if still idle | |
| const randomOffsetX = (Math.random() - 0.5) * 10; // -5 to +5 pixels | |
| const randomOffsetY = (Math.random() - 0.5) * 10; | |
| // Update target slightly - the main animation loop will handle the movement | |
| setTargetPosition((prevTarget) => ({ | |
| x: prevTarget.x + randomOffsetX, | |
| y: prevTarget.y + randomOffsetY, | |
| })); | |
| scheduleNextIdleMove(); // Schedule the next one | |
| } | |
| }, randomDelay); | |
| }, [isIdle]); | |
| // Effect to handle starting/stopping idle movement sequence | |
| useEffect(() => { | |
| if (isIdle) { | |
| scheduleNextIdleMove(); | |
| } else { | |
| if (idleMoveTimerRef.current) { | |
| clearTimeout(idleMoveTimerRef.current); | |
| } | |
| } | |
| // Cleanup function for this effect | |
| return () => { | |
| if (idleMoveTimerRef.current) { | |
| clearTimeout(idleMoveTimerRef.current); | |
| } | |
| }; | |
| }, [isIdle, scheduleNextIdleMove]); | |
| // Main Animation effect (runs continuously) | |
| useEffect(() => { | |
| let animationFrameId: number | null = null; | |
| const step = () => { | |
| setCursorPosition((currentPos) => { | |
| const dx = targetPosition.x - currentPos.x; | |
| const dy = targetPosition.y - currentPos.y; | |
| const isClose = Math.abs(dx) < 0.1 && Math.abs(dy) < 0.1; | |
| if (isClose) { | |
| // Reached target | |
| if (!isIdle && !idleStartTimerRef.current) { | |
| // Only start the idle timer if not already idle and no timer is running | |
| idleStartTimerRef.current = setTimeout(() => { | |
| setIsIdle(true); | |
| idleStartTimerRef.current = null; // Clear ref after timer runs | |
| }, 5000); | |
| } | |
| if (animationFrameId) { | |
| cancelAnimationFrame(animationFrameId); | |
| animationFrameId = null; | |
| } | |
| return targetPosition; // Snap to final position | |
| } else { | |
| // Moving towards target | |
| // If we were waiting to go idle, cancel it because we're moving again | |
| if (idleStartTimerRef.current) { | |
| clearTimeout(idleStartTimerRef.current); | |
| idleStartTimerRef.current = null; | |
| } | |
| // Ensure idle state is false if we are moving significantly | |
| if (isIdle) setIsIdle(false); | |
| const nextX = currentPos.x + dx * 0.05; // Keep slow easing for now | |
| const nextY = currentPos.y + dy * 0.05; | |
| animationFrameId = requestAnimationFrame(step); | |
| return { x: nextX, y: nextY }; | |
| } | |
| }); | |
| }; | |
| // Start animation frame loop | |
| animationFrameId = requestAnimationFrame(step); | |
| // Cleanup function for main animation loop | |
| return () => { | |
| if (animationFrameId) { | |
| cancelAnimationFrame(animationFrameId); | |
| } | |
| // Also clear idle start timer on unmount or if target changes causing effect re-run | |
| if (idleStartTimerRef.current) { | |
| clearTimeout(idleStartTimerRef.current); | |
| } | |
| }; | |
| }, [targetPosition, isIdle]); // Re-run main loop logic if targetPosition changes | |
| const cleanupConnection = () => { | |
| if (reconnectTimeoutRef.current) { | |
| clearTimeout(reconnectTimeoutRef.current); | |
| reconnectTimeoutRef.current = null; | |
| } | |
| if (wsRef.current) { | |
| wsRef.current.close(); | |
| wsRef.current = null; | |
| } | |
| // Cancel animation frame (handled in effect cleanup, but good practice here too) | |
| // Clear timers | |
| if (idleStartTimerRef.current) clearTimeout(idleStartTimerRef.current); | |
| if (idleMoveTimerRef.current) clearTimeout(idleMoveTimerRef.current); | |
| // Reset state | |
| setCursorPosition({ x: 0, y: 0 }); | |
| setTargetPosition({ x: 0, y: 0 }); | |
| setIsIdle(false); | |
| initialPositionSetRef.current = false; | |
| }; | |
| useEffect(() => { | |
| if (onScrapeComplete) { | |
| cleanupConnection(); | |
| } | |
| }, [onScrapeComplete]); | |
| const connect = useCallback(() => { | |
| setIsConnecting(true); | |
| // Clear any existing connection | |
| if (wsRef.current) { | |
| wsRef.current.close(); | |
| wsRef.current = null; | |
| } | |
| // Create new WebSocket connection | |
| const wsUrl = `wss://api.firecrawl.dev/agent-livecast?userProvidedId=${sessionId}`; | |
| try { | |
| const ws = new WebSocket(wsUrl); | |
| wsRef.current = ws; | |
| ws.addEventListener("open", () => { | |
| console.log("Connected - Streaming frames..."); | |
| setIsConnecting(false); | |
| // Clear any pending reconnection attempts | |
| if (reconnectTimeoutRef.current) { | |
| clearTimeout(reconnectTimeoutRef.current); | |
| reconnectTimeoutRef.current = null; | |
| } | |
| }); | |
| ws.addEventListener("message", (event) => { | |
| try { | |
| // Try to handle as raw base64 first | |
| if ( | |
| typeof event.data === "string" && | |
| event.data.startsWith("data:image") | |
| ) { | |
| setImageSrc(event.data); | |
| return; | |
| } | |
| // If not direct image data, try parsing as JSON | |
| const data = JSON.parse(event.data); | |
| if (data.mouseCoordinates) { | |
| let { x, y } = data.mouseCoordinates; | |
| // --- Interrupt Idle State --- | |
| if (idleStartTimerRef.current) { | |
| clearTimeout(idleStartTimerRef.current); | |
| idleStartTimerRef.current = null; | |
| } | |
| if (isIdle) { | |
| setIsIdle(false); | |
| // idleMoveTimerRef is cleared by the isIdle effect cleanup | |
| } | |
| // --- End Interrupt Idle State --- | |
| if (imgRef.current && containerRef.current) { | |
| const rect = containerRef.current.getBoundingClientRect(); | |
| const imageRect = imgRef.current.getBoundingClientRect(); | |
| // Calculate the scale factor between the original coordinates and our container | |
| const scaleX = imageRect.width / 1920; | |
| const scaleY = | |
| imageRect.height > 2000 ? 0 : imageRect.height / 1080; | |
| if (x === 0 && y === 0) { | |
| x = 1800; | |
| y = 100; | |
| } | |
| // Scale the coordinates to match our container size | |
| const scaledX = x * scaleX; | |
| const scaledY = y * scaleY; | |
| setTargetPosition({ x: scaledX, y: scaledY }); | |
| if (!initialPositionSetRef.current) { | |
| setCursorPosition({ x: scaledX, y: scaledY }); | |
| initialPositionSetRef.current = true; | |
| } | |
| } | |
| } | |
| if (data.frame) { | |
| const img = "data:image/jpeg;base64," + data.frame; | |
| localStorage.setItem("browserImageData", img); | |
| setImageSrc(img); | |
| } | |
| } catch (e) { | |
| // Try to use raw data as fallback if JSON parsing fails | |
| if (typeof event.data === "string") { | |
| setImageSrc(event.data); | |
| } | |
| } | |
| }); | |
| ws.addEventListener("close", (event) => { | |
| console.log(`Disconnected (Code: ${event.code})`); | |
| wsRef.current = null; | |
| // Attempt to reconnect after a delay for any unexpected closure | |
| if (event.code !== 1000) { | |
| reconnectTimeoutRef.current = setTimeout(() => { | |
| if (sessionId) { | |
| connect(); | |
| } | |
| }, 3000); // Wait 3 seconds before reconnecting | |
| } | |
| }); | |
| ws.addEventListener("error", (error) => { | |
| console.error("Connection error - Will attempt to reconnect"); | |
| }); | |
| } catch (error) { | |
| console.error("Failed to create connection"); | |
| setIsConnecting(false); | |
| } | |
| }, [sessionId, isIdle]); | |
| useEffect(() => { | |
| // Only connect if we have a sessionId | |
| if (sessionId) { | |
| connect(); | |
| return () => { | |
| cleanupConnection(); | |
| }; | |
| } else { | |
| // Clean up any existing connection | |
| cleanupConnection(); | |
| console.log("Waiting for session ID..."); | |
| return () => { | |
| cleanupConnection(); | |
| }; | |
| } | |
| }, [sessionId, connect]); // Re-run effect when sessionId changes | |
| return ( | |
| <div | |
| ref={containerRef} | |
| className="relative w-full h-full flex items-center justify-center" | |
| > | |
| {/* Cursor */} | |
| {cursorPosition && cursorPosition.x !== 0 && cursorPosition.y !== 0 && ( | |
| <div | |
| className="absolute pointer-events-none transform-gpu" | |
| style={{ | |
| left: `${cursorPosition.x}px`, | |
| top: `${cursorPosition.y}px`, | |
| width: "50px", | |
| height: "50px", | |
| backgroundImage: `url("/images/agent-cursor.svg")`, | |
| backgroundSize: "contain", | |
| backgroundRepeat: "no-repeat", | |
| transform: "translate(-20%, -20%)", | |
| zIndex: 10, | |
| transition: isIdle ? "all 0.5s ease-out" : "all 0.1s linear", | |
| }} | |
| /> | |
| )} | |
| {/* Children fallback */} | |
| {children && !imgRef.current?.src ? ( | |
| <div className="h-full w-full flex items-center justify-center"> | |
| {children} | |
| </div> | |
| ) : null} | |
| {/* Preview image - Using regular img tag for dynamic WebSocket stream */} | |
| {imageSrc && ( | |
| <img | |
| ref={imgRef} | |
| id="live-frame" | |
| src={imageSrc} | |
| alt="Live preview" | |
| onLoad={() => { | |
| setImageLoaded(true); | |
| if (onScrapeComplete) onScrapeComplete(); | |
| }} | |
| className={`w-auto h-auto max-w-full max-h-full object-contain transform-gpu ${ | |
| !imageLoaded ? "opacity-0 scale-95" : "opacity-100 scale-100" | |
| } transition-all duration-300 ease-out`} | |
| style={{ | |
| backgroundColor: "#f0f0f0", | |
| }} | |
| /> | |
| )} | |
| </div> | |
| ); | |
| } | |