File size: 6,584 Bytes
af9cde9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
"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&apos;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>
  );
}