Spaces:
Running
Running
| "use client"; | |
| import { useState, useCallback } from "react"; | |
| import { ChevronUp, ChevronDown, ChevronLeft, ChevronRight, Circle, X, Video } from "lucide-react"; | |
| type Direction = "up" | "down" | "left" | "right" | "front"; | |
| // --- Shared D-Pad + Camera Image --- | |
| interface CameraFeedProps { | |
| base64: string; | |
| } | |
| /** | |
| * Pure camera feed with D-pad controls — no positioning wrapper. | |
| * Used by DashboardGrid's bento flip and can be embedded anywhere. | |
| */ | |
| export function CameraFeed({ base64 }: CameraFeedProps) { | |
| const [isMoving, setIsMoving] = useState(false); | |
| const [lastDirection, setLastDirection] = useState<Direction | null>(null); | |
| const moveHead = useCallback(async (direction: Direction) => { | |
| if (isMoving) return; | |
| setIsMoving(true); | |
| setLastDirection(direction); | |
| try { | |
| const response = await fetch("http://localhost:7860/api/move-head", { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ direction }), | |
| }); | |
| if (!response.ok) { | |
| console.error("Move head failed:", await response.text()); | |
| } | |
| } catch (error) { | |
| console.error("Move head error:", error); | |
| } finally { | |
| setTimeout(() => { | |
| setIsMoving(false); | |
| setLastDirection(null); | |
| }, 300); | |
| } | |
| }, [isMoving]); | |
| return ( | |
| <div className="relative w-full h-full flex flex-col" style={{ | |
| background: 'linear-gradient(135deg, rgba(54,54,54,0.8) 0%, rgba(36,36,36,0.9) 100%)', | |
| }}> | |
| {/* Header bar — muted, consistent with bento cards */} | |
| <div className="flex items-center gap-2 px-4 py-2.5" style={{ | |
| borderBottom: '1px solid rgba(255,255,255,0.1)', | |
| }}> | |
| <Video className="w-3.5 h-3.5 text-gray-400" /> | |
| <span className="text-[10px] font-semibold text-gray-400 tracking-widest uppercase">Live View</span> | |
| <span className="w-1.5 h-1.5 rounded-full bg-red-400 animate-pulse ml-auto" /> | |
| </div> | |
| {/* Video feed — fills remaining space */} | |
| <div className="relative flex-1 min-h-0"> | |
| <img | |
| src={`data:image/jpeg;base64,${base64}`} | |
| alt="Robot camera view" | |
| className="w-full h-full object-cover" | |
| /> | |
| {/* D-pad overlay */} | |
| <div className="absolute inset-0 flex items-center justify-center pointer-events-none"> | |
| <div className="relative w-28 h-28 pointer-events-auto"> | |
| <DPadButton | |
| direction="up" | |
| icon={<ChevronUp className="w-4 h-4" />} | |
| position="top-0 left-1/2 -translate-x-1/2" | |
| onClick={() => moveHead("up")} | |
| isActive={lastDirection === "up"} | |
| disabled={isMoving && lastDirection !== "up"} | |
| /> | |
| <DPadButton | |
| direction="down" | |
| icon={<ChevronDown className="w-4 h-4" />} | |
| position="bottom-0 left-1/2 -translate-x-1/2" | |
| onClick={() => moveHead("down")} | |
| isActive={lastDirection === "down"} | |
| disabled={isMoving && lastDirection !== "down"} | |
| /> | |
| <DPadButton | |
| direction="left" | |
| icon={<ChevronLeft className="w-4 h-4" />} | |
| position="left-0 top-1/2 -translate-y-1/2" | |
| onClick={() => moveHead("left")} | |
| isActive={lastDirection === "left"} | |
| disabled={isMoving && lastDirection !== "left"} | |
| /> | |
| <DPadButton | |
| direction="right" | |
| icon={<ChevronRight className="w-4 h-4" />} | |
| position="right-0 top-1/2 -translate-y-1/2" | |
| onClick={() => moveHead("right")} | |
| isActive={lastDirection === "right"} | |
| disabled={isMoving && lastDirection !== "right"} | |
| /> | |
| {/* Center button (front) */} | |
| <button | |
| onClick={() => moveHead("front")} | |
| disabled={isMoving && lastDirection !== "front"} | |
| className={`absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-8 h-8 rounded-full | |
| ${lastDirection === "front" | |
| ? "bg-white/30 ring-2 ring-white/40" | |
| : "bg-white/10 hover:bg-white/20" | |
| } | |
| backdrop-blur transition-all duration-150 flex items-center justify-center | |
| disabled:opacity-50`} | |
| aria-label="Center robot view" | |
| > | |
| <Circle className="w-3 h-3 text-white/70" /> | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| {/* Footer hint */} | |
| <div className="px-4 py-2 text-center" style={{ | |
| borderTop: '1px solid rgba(255,255,255,0.06)', | |
| }}> | |
| <span className="text-[10px] text-gray-500">Tap arrows to move robot's view</span> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| // --- Legacy floating overlay (kept for backwards compat) --- | |
| interface CameraViewProps { | |
| base64: string; | |
| onClose: () => void; | |
| } | |
| export function CameraView({ base64, onClose }: CameraViewProps) { | |
| return ( | |
| <div className="absolute bottom-20 right-6 z-50 animate-in slide-in-from-right-4 duration-300"> | |
| <div className="relative w-64 rounded-2xl overflow-hidden border border-white/20 shadow-2xl bg-black/80 backdrop-blur-xl"> | |
| {/* Close button on the old overlay */} | |
| <div className="absolute top-2 right-2 z-10"> | |
| <button | |
| onClick={onClose} | |
| className="p-1 rounded-full hover:bg-white/20 transition-colors bg-black/40" | |
| aria-label="Close camera view" | |
| > | |
| <X className="w-4 h-4 text-white" /> | |
| </button> | |
| </div> | |
| <CameraFeed base64={base64} /> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| // --- D-Pad Button --- | |
| interface DPadButtonProps { | |
| direction: Direction; | |
| icon: React.ReactNode; | |
| position: string; | |
| onClick: () => void; | |
| isActive: boolean; | |
| disabled: boolean; | |
| } | |
| function DPadButton({ direction, icon, position, onClick, isActive, disabled }: DPadButtonProps) { | |
| return ( | |
| <button | |
| onClick={onClick} | |
| disabled={disabled} | |
| className={`absolute ${position} w-7 h-7 rounded-lg | |
| ${isActive | |
| ? "bg-white/25 ring-2 ring-white/30 scale-110" | |
| : "bg-white/10 hover:bg-white/20" | |
| } | |
| backdrop-blur transition-all duration-150 flex items-center justify-center | |
| disabled:opacity-50 focus:outline-none focus:ring-2 focus:ring-white/30`} | |
| aria-label={`Look ${direction}`} | |
| > | |
| <span className="text-white">{icon}</span> | |
| </button> | |
| ); | |
| } | |