Nicolas Rabault commited on
Commit
42d8fb8
·
1 Parent(s): 40c9336

Add working camera

Browse files
src/App.tsx CHANGED
@@ -18,7 +18,7 @@ import Training from "@/pages/Training";
18
  import ReplayDataset from "@/pages/ReplayDataset";
19
  import EditDataset from "@/pages/EditDataset";
20
  import Upload from "@/pages/Upload";
21
- import PhoneCamera from "@/pages/PhoneCamera";
22
  import NotFound from "@/pages/NotFound";
23
  import "./App.css";
24
  import { TooltipProvider } from "@radix-ui/react-tooltip";
@@ -44,7 +44,7 @@ function App() {
44
  <Route path="/calibration" element={<Calibration />} />
45
  <Route path="/edit-dataset" element={<EditDataset />} />
46
  <Route path="/replay-dataset" element={<ReplayDataset />} />
47
- <Route path="/phone-camera" element={<PhoneCamera />} />
48
  <Route path="*" element={<NotFound />} />
49
  </Routes>
50
  <Toaster />
 
18
  import ReplayDataset from "@/pages/ReplayDataset";
19
  import EditDataset from "@/pages/EditDataset";
20
  import Upload from "@/pages/Upload";
21
+
22
  import NotFound from "@/pages/NotFound";
23
  import "./App.css";
24
  import { TooltipProvider } from "@radix-ui/react-tooltip";
 
44
  <Route path="/calibration" element={<Calibration />} />
45
  <Route path="/edit-dataset" element={<EditDataset />} />
46
  <Route path="/replay-dataset" element={<ReplayDataset />} />
47
+
48
  <Route path="*" element={<NotFound />} />
49
  </Routes>
50
  <Toaster />
src/components/landing/ActionList.tsx CHANGED
@@ -43,7 +43,9 @@ const ActionList: React.FC<ActionListProps> = ({ actions, robotModel }) => {
43
  <div className="flex items-center gap-2">
44
  <div>
45
  <div className="flex items-center gap-2">
46
- <h3 className="font-semibold text-lg">{action.title}</h3>
 
 
47
  {action.isWorkInProgress && (
48
  <div className="flex items-center gap-1">
49
  <Tooltip>
 
43
  <div className="flex items-center gap-2">
44
  <div>
45
  <div className="flex items-center gap-2">
46
+ <h3 className="font-semibold text-lg text-left">
47
+ {action.title}
48
+ </h3>
49
  {action.isWorkInProgress && (
50
  <div className="flex items-center gap-1">
51
  <Tooltip>
src/components/landing/LandingHeader.tsx CHANGED
@@ -1,49 +1,16 @@
1
  import React from "react";
2
  import { Button } from "@/components/ui/button";
3
- import { Info, Globe, Wifi, WifiOff } from "lucide-react";
4
- import { useApi } from "@/contexts/ApiContext";
5
 
6
  interface LandingHeaderProps {
7
  onShowInstructions: () => void;
8
- onShowNgrokConfig: () => void;
9
  }
10
 
11
  const LandingHeader: React.FC<LandingHeaderProps> = ({
12
  onShowInstructions,
13
- onShowNgrokConfig,
14
  }) => {
15
- const { isNgrokEnabled } = useApi();
16
-
17
  return (
18
  <div className="relative w-full">
19
- {/* Ngrok button in top right */}
20
- <div className="absolute top-0 right-0 flex gap-2">
21
- <Button
22
- variant="ghost"
23
- size="sm"
24
- onClick={onShowNgrokConfig}
25
- className={`transition-all duration-200 ${
26
- isNgrokEnabled
27
- ? "bg-green-900/30 border border-green-700 text-green-400 hover:bg-green-900/50"
28
- : "text-gray-400 hover:text-white hover:bg-gray-800"
29
- }`}
30
- title={
31
- isNgrokEnabled
32
- ? "Ngrok enabled - Click to configure"
33
- : "Configure Ngrok for external access"
34
- }
35
- >
36
- {isNgrokEnabled ? (
37
- <Wifi className="h-4 w-4 mr-2" />
38
- ) : (
39
- <Globe className="h-4 w-4 mr-2" />
40
- )}
41
- <span className="hidden sm:inline">
42
- {isNgrokEnabled ? "Ngrok" : "Configure Ngrok"}
43
- </span>
44
- </Button>
45
- </div>
46
-
47
  {/* Main header content */}
48
  <div className="text-center space-y-4 w-full pt-8">
49
  <img
 
1
  import React from "react";
2
  import { Button } from "@/components/ui/button";
3
+ import { Info } from "lucide-react";
 
4
 
5
  interface LandingHeaderProps {
6
  onShowInstructions: () => void;
 
7
  }
8
 
9
  const LandingHeader: React.FC<LandingHeaderProps> = ({
10
  onShowInstructions,
 
11
  }) => {
 
 
12
  return (
13
  <div className="relative w-full">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  {/* Main header content */}
15
  <div className="text-center space-y-4 w-full pt-8">
16
  <img
src/components/landing/NgrokConfigModal.tsx DELETED
@@ -1,212 +0,0 @@
1
- import React, { useState } from "react";
2
- import { Button } from "@/components/ui/button";
3
- import { Input } from "@/components/ui/input";
4
- import { Label } from "@/components/ui/label";
5
- import {
6
- Dialog,
7
- DialogContent,
8
- DialogHeader,
9
- DialogTitle,
10
- DialogDescription,
11
- } from "@/components/ui/dialog";
12
- import { Globe, Wifi, WifiOff, ExternalLink } from "lucide-react";
13
- import { useApi } from "@/contexts/ApiContext";
14
- import { useToast } from "@/hooks/use-toast";
15
-
16
- interface NgrokConfigModalProps {
17
- open: boolean;
18
- onOpenChange: (open: boolean) => void;
19
- }
20
-
21
- const NgrokConfigModal: React.FC<NgrokConfigModalProps> = ({
22
- open,
23
- onOpenChange,
24
- }) => {
25
- const {
26
- ngrokUrl,
27
- isNgrokEnabled,
28
- setNgrokUrl,
29
- resetToLocalhost,
30
- fetchWithHeaders,
31
- } = useApi();
32
- const [inputUrl, setInputUrl] = useState(ngrokUrl);
33
- const [isTestingConnection, setIsTestingConnection] = useState(false);
34
- const { toast } = useToast();
35
-
36
- const handleSave = async () => {
37
- if (!inputUrl.trim()) {
38
- resetToLocalhost();
39
- toast({
40
- title: "Ngrok Disabled",
41
- description: "Switched back to localhost mode.",
42
- });
43
- onOpenChange(false);
44
- return;
45
- }
46
-
47
- setIsTestingConnection(true);
48
-
49
- try {
50
- // Clean the URL
51
- let cleanUrl = inputUrl.trim();
52
- if (!cleanUrl.startsWith("http")) {
53
- cleanUrl = `https://${cleanUrl}`;
54
- }
55
- cleanUrl = cleanUrl.replace(/\/$/, "");
56
-
57
- // Test the connection
58
- const testResponse = await fetchWithHeaders(`${cleanUrl}/health`, {
59
- method: "GET",
60
- headers: {
61
- Accept: "application/json",
62
- },
63
- });
64
-
65
- if (testResponse.ok) {
66
- setNgrokUrl(cleanUrl);
67
- toast({
68
- title: "Ngrok Configured Successfully",
69
- description: `Connected to ${cleanUrl}. All API calls will now use this URL.`,
70
- });
71
- onOpenChange(false);
72
- } else {
73
- throw new Error(`Server responded with status ${testResponse.status}`);
74
- }
75
- } catch (error) {
76
- console.error("Failed to connect to ngrok URL:", error);
77
- toast({
78
- title: "Connection Failed",
79
- description: `Could not connect to ${inputUrl}. Please check the URL and ensure your ngrok tunnel is running.`,
80
- variant: "destructive",
81
- });
82
- } finally {
83
- setIsTestingConnection(false);
84
- }
85
- };
86
-
87
- const handleReset = () => {
88
- resetToLocalhost();
89
- setInputUrl("");
90
- toast({
91
- title: "Reset to Localhost",
92
- description: "All API calls will now use localhost:8000.",
93
- });
94
- onOpenChange(false);
95
- };
96
-
97
- return (
98
- <Dialog open={open} onOpenChange={onOpenChange}>
99
- <DialogContent className="bg-gray-900 border-gray-800 text-white w-[95vw] max-w-[600px] max-h-[90vh] overflow-y-auto p-4 sm:p-6 md:p-8">
100
- <DialogHeader>
101
- <div className="flex justify-center items-center mb-4">
102
- <Globe className="w-8 h-8 text-blue-500" />
103
- </div>
104
- <DialogTitle className="text-white text-center text-xl sm:text-2xl font-bold">
105
- Ngrok Configuration
106
- </DialogTitle>
107
- <DialogDescription className="text-gray-400 text-center text-sm sm:text-base">
108
- Configure ngrok tunnel for external access and phone camera
109
- features.
110
- </DialogDescription>
111
- </DialogHeader>
112
-
113
- <div className="space-y-4 sm:space-y-6 py-4">
114
- {/* Current Status */}
115
- <div className="bg-gray-800 rounded-lg p-3 sm:p-4 border border-gray-700">
116
- <div className="flex items-center gap-3 mb-2">
117
- {isNgrokEnabled ? (
118
- <Wifi className="w-4 h-4 sm:w-5 sm:h-5 text-green-500" />
119
- ) : (
120
- <WifiOff className="w-4 h-4 sm:w-5 sm:h-5 text-gray-500" />
121
- )}
122
- <span className="font-semibold text-sm sm:text-base">
123
- Current Mode: {isNgrokEnabled ? "Ngrok" : "Localhost"}
124
- </span>
125
- </div>
126
- <p className="text-xs sm:text-sm text-gray-400 break-all">
127
- {isNgrokEnabled
128
- ? `Using: ${ngrokUrl}`
129
- : "Using: http://localhost:8000"}
130
- </p>
131
- </div>
132
-
133
- {/* URL Input */}
134
- <div className="space-y-2">
135
- <Label
136
- htmlFor="ngrokUrl"
137
- className="text-sm font-medium text-gray-300"
138
- >
139
- Ngrok URL
140
- </Label>
141
- <Input
142
- id="ngrokUrl"
143
- value={inputUrl}
144
- onChange={(e) => setInputUrl(e.target.value)}
145
- placeholder="https://abc123.ngrok.io"
146
- className="bg-gray-800 border-gray-700 text-white text-sm sm:text-base"
147
- />
148
- <p className="text-xs text-gray-500">
149
- Enter your ngrok tunnel URL. Leave empty to use localhost.
150
- </p>
151
- </div>
152
-
153
- {/* Benefits */}
154
- <div className="bg-green-900/20 border border-green-800 rounded-lg p-3 sm:p-4">
155
- <h3 className="font-semibold text-green-400 mb-2 text-sm sm:text-base">
156
- Why use Ngrok?
157
- </h3>
158
- <ul className="text-xs sm:text-sm text-gray-300 space-y-1">
159
- <li>• Access your robot from anywhere on the internet</li>
160
- <li>• Use phone cameras as secondary recording angles</li>
161
- <li>• Share your robot session with remote collaborators</li>
162
- <li>• Test your setup from different devices</li>
163
- </ul>
164
- </div>
165
-
166
- {/* Action Buttons */}
167
- <div className="flex flex-col gap-3 sm:gap-4 pt-4">
168
- <Button
169
- onClick={handleSave}
170
- disabled={isTestingConnection}
171
- className="w-full bg-blue-500 hover:bg-blue-600 text-white px-6 sm:px-8 py-3 text-sm sm:text-lg transition-all shadow-md shadow-blue-500/30 hover:shadow-lg hover:shadow-blue-500/40"
172
- >
173
- {isTestingConnection ? (
174
- <>
175
- <div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin mr-2" />
176
- Testing Connection...
177
- </>
178
- ) : (
179
- <>
180
- <ExternalLink className="w-4 h-4 sm:w-5 sm:h-5 mr-2" />
181
- Save Configuration
182
- </>
183
- )}
184
- </Button>
185
-
186
- <div className="flex flex-col sm:flex-row gap-3">
187
- {isNgrokEnabled && (
188
- <Button
189
- onClick={handleReset}
190
- variant="outline"
191
- className="w-full border-orange-500 hover:border-orange-400 text-orange-400 hover:text-orange-300 px-6 sm:px-8 py-3 text-sm sm:text-lg"
192
- >
193
- Reset to Localhost
194
- </Button>
195
- )}
196
-
197
- <Button
198
- onClick={() => onOpenChange(false)}
199
- variant="outline"
200
- className="w-full border-gray-500 hover:border-gray-200 px-6 sm:px-8 py-3 text-sm sm:text-lg text-zinc-500 bg-zinc-900 hover:bg-zinc-800"
201
- >
202
- Cancel
203
- </Button>
204
- </div>
205
- </div>
206
- </div>
207
- </DialogContent>
208
- </Dialog>
209
- );
210
- };
211
-
212
- export default NgrokConfigModal;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/components/landing/RecordingModal.tsx CHANGED
@@ -16,10 +16,13 @@ import {
16
  DialogTitle,
17
  DialogDescription,
18
  } from "@/components/ui/dialog";
19
- import { QrCode } from "lucide-react";
20
  import PortDetectionModal from "@/components/ui/PortDetectionModal";
21
  import PortDetectionButton from "@/components/ui/PortDetectionButton";
22
- import QrCodeModal from "@/components/recording/QrCodeModal";
 
 
 
23
  import { useApi } from "@/contexts/ApiContext";
24
  import { useAutoSave } from "@/hooks/useAutoSave";
25
  interface RecordingModalProps {
@@ -41,8 +44,11 @@ interface RecordingModalProps {
41
  setSingleTask: (value: string) => void;
42
  numEpisodes: number;
43
  setNumEpisodes: (value: number) => void;
 
 
44
  isLoadingConfigs: boolean;
45
  onStart: () => void;
 
46
  }
47
  const RecordingModal: React.FC<RecordingModalProps> = ({
48
  open,
@@ -63,8 +69,11 @@ const RecordingModal: React.FC<RecordingModalProps> = ({
63
  setSingleTask,
64
  numEpisodes,
65
  setNumEpisodes,
 
 
66
  isLoadingConfigs,
67
  onStart,
 
68
  }) => {
69
  const { baseUrl, fetchWithHeaders } = useApi();
70
  const { debouncedSavePort, debouncedSaveConfig } = useAutoSave();
@@ -72,8 +81,6 @@ const RecordingModal: React.FC<RecordingModalProps> = ({
72
  const [detectionRobotType, setDetectionRobotType] = useState<
73
  "leader" | "follower"
74
  >("leader");
75
- const [showQrCodeModal, setShowQrCodeModal] = useState(false);
76
- const [sessionId, setSessionId] = useState("");
77
 
78
  const handlePortDetection = (robotType: "leader" | "follower") => {
79
  setDetectionRobotType(robotType);
@@ -136,19 +143,29 @@ const RecordingModal: React.FC<RecordingModalProps> = ({
136
 
137
  // Load leader configuration
138
  const leaderConfigResponse = await fetchWithHeaders(
139
- `${baseUrl}/robot-config/leader?available_configs=${leaderConfigs.join(',')}`
 
 
140
  );
141
  const leaderConfigData = await leaderConfigResponse.json();
142
- if (leaderConfigData.status === "success" && leaderConfigData.default_config) {
 
 
 
143
  setLeaderConfig(leaderConfigData.default_config);
144
  }
145
 
146
  // Load follower configuration
147
  const followerConfigResponse = await fetchWithHeaders(
148
- `${baseUrl}/robot-config/follower?available_configs=${followerConfigs.join(',')}`
 
 
149
  );
150
  const followerConfigData = await followerConfigResponse.json();
151
- if (followerConfigData.status === "success" && followerConfigData.default_config) {
 
 
 
152
  setFollowerConfig(followerConfigData.default_config);
153
  }
154
  } catch (error) {
@@ -159,16 +176,18 @@ const RecordingModal: React.FC<RecordingModalProps> = ({
159
  if (open && leaderConfigs.length > 0 && followerConfigs.length > 0) {
160
  loadSavedData();
161
  }
162
- }, [open, setLeaderPort, setFollowerPort, setLeaderConfig, setFollowerConfig, leaderConfigs, followerConfigs, baseUrl, fetchWithHeaders]);
 
 
 
 
 
 
 
 
 
 
163
 
164
- const handleQrCodeClick = () => {
165
- // Generate a session ID for this recording session
166
- const newSessionId = `recording_${Date.now()}_${Math.random()
167
- .toString(36)
168
- .substr(2, 9)}`;
169
- setSessionId(newSessionId);
170
- setShowQrCodeModal(true);
171
- };
172
  return (
173
  <>
174
  <Dialog open={open} onOpenChange={onOpenChange}>
@@ -189,23 +208,6 @@ const RecordingModal: React.FC<RecordingModalProps> = ({
189
  recording.
190
  </DialogDescription>
191
 
192
- <div className="border-y border-gray-700 py-6 flex flex-col items-center gap-4 bg-gray-800/50 rounded-lg">
193
- <h3 className="text-lg font-semibold text-white">
194
- Need an extra angle?
195
- </h3>
196
- <p className="text-sm text-gray-400 -mt-2">
197
- Add your phone as a secondary camera.
198
- </p>
199
- <Button
200
- onClick={handleQrCodeClick}
201
- title="Add Phone Camera"
202
- className="bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 px-6 flex items-center gap-2 transition-all duration-300 shadow-lg shadow-blue-500/30 hover:shadow-xl hover:shadow-blue-500/40 transform hover:scale-105 rounded-lg"
203
- >
204
- <QrCode className="w-5 h-5" />
205
- <span>Add Phone Camera</span>
206
- </Button>
207
- </div>
208
-
209
  <div className="grid grid-cols-1 gap-6">
210
  <div className="space-y-4">
211
  <h3 className="text-lg font-semibold text-white border-b border-gray-700 pb-2">
@@ -277,7 +279,9 @@ const RecordingModal: React.FC<RecordingModalProps> = ({
277
  <Input
278
  id="recordFollowerPort"
279
  value={followerPort}
280
- onChange={(e) => handleFollowerPortChange(e.target.value)}
 
 
281
  placeholder="/dev/tty.usbmodem5A460816621"
282
  className="bg-gray-800 border-gray-700 text-white flex-1"
283
  />
@@ -377,6 +381,14 @@ const RecordingModal: React.FC<RecordingModalProps> = ({
377
  </div>
378
  </div>
379
  </div>
 
 
 
 
 
 
 
 
380
  </div>
381
 
382
  <div className="flex flex-col sm:flex-row gap-4 justify-center pt-4">
@@ -405,12 +417,6 @@ const RecordingModal: React.FC<RecordingModalProps> = ({
405
  robotType={detectionRobotType}
406
  onPortDetected={handlePortDetected}
407
  />
408
-
409
- <QrCodeModal
410
- open={showQrCodeModal}
411
- onOpenChange={setShowQrCodeModal}
412
- sessionId={sessionId}
413
- />
414
  </>
415
  );
416
  };
 
16
  DialogTitle,
17
  DialogDescription,
18
  } from "@/components/ui/dialog";
19
+
20
  import PortDetectionModal from "@/components/ui/PortDetectionModal";
21
  import PortDetectionButton from "@/components/ui/PortDetectionButton";
22
+
23
+ import CameraConfiguration, {
24
+ CameraConfig,
25
+ } from "@/components/recording/CameraConfiguration";
26
  import { useApi } from "@/contexts/ApiContext";
27
  import { useAutoSave } from "@/hooks/useAutoSave";
28
  interface RecordingModalProps {
 
44
  setSingleTask: (value: string) => void;
45
  numEpisodes: number;
46
  setNumEpisodes: (value: number) => void;
47
+ cameras: CameraConfig[];
48
+ setCameras: (cameras: CameraConfig[]) => void;
49
  isLoadingConfigs: boolean;
50
  onStart: () => void;
51
+ releaseStreamsRef?: React.MutableRefObject<(() => void) | null>;
52
  }
53
  const RecordingModal: React.FC<RecordingModalProps> = ({
54
  open,
 
69
  setSingleTask,
70
  numEpisodes,
71
  setNumEpisodes,
72
+ cameras,
73
+ setCameras,
74
  isLoadingConfigs,
75
  onStart,
76
+ releaseStreamsRef,
77
  }) => {
78
  const { baseUrl, fetchWithHeaders } = useApi();
79
  const { debouncedSavePort, debouncedSaveConfig } = useAutoSave();
 
81
  const [detectionRobotType, setDetectionRobotType] = useState<
82
  "leader" | "follower"
83
  >("leader");
 
 
84
 
85
  const handlePortDetection = (robotType: "leader" | "follower") => {
86
  setDetectionRobotType(robotType);
 
143
 
144
  // Load leader configuration
145
  const leaderConfigResponse = await fetchWithHeaders(
146
+ `${baseUrl}/robot-config/leader?available_configs=${leaderConfigs.join(
147
+ ","
148
+ )}`
149
  );
150
  const leaderConfigData = await leaderConfigResponse.json();
151
+ if (
152
+ leaderConfigData.status === "success" &&
153
+ leaderConfigData.default_config
154
+ ) {
155
  setLeaderConfig(leaderConfigData.default_config);
156
  }
157
 
158
  // Load follower configuration
159
  const followerConfigResponse = await fetchWithHeaders(
160
+ `${baseUrl}/robot-config/follower?available_configs=${followerConfigs.join(
161
+ ","
162
+ )}`
163
  );
164
  const followerConfigData = await followerConfigResponse.json();
165
+ if (
166
+ followerConfigData.status === "success" &&
167
+ followerConfigData.default_config
168
+ ) {
169
  setFollowerConfig(followerConfigData.default_config);
170
  }
171
  } catch (error) {
 
176
  if (open && leaderConfigs.length > 0 && followerConfigs.length > 0) {
177
  loadSavedData();
178
  }
179
+ }, [
180
+ open,
181
+ setLeaderPort,
182
+ setFollowerPort,
183
+ setLeaderConfig,
184
+ setFollowerConfig,
185
+ leaderConfigs,
186
+ followerConfigs,
187
+ baseUrl,
188
+ fetchWithHeaders,
189
+ ]);
190
 
 
 
 
 
 
 
 
 
191
  return (
192
  <>
193
  <Dialog open={open} onOpenChange={onOpenChange}>
 
208
  recording.
209
  </DialogDescription>
210
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
211
  <div className="grid grid-cols-1 gap-6">
212
  <div className="space-y-4">
213
  <h3 className="text-lg font-semibold text-white border-b border-gray-700 pb-2">
 
279
  <Input
280
  id="recordFollowerPort"
281
  value={followerPort}
282
+ onChange={(e) =>
283
+ handleFollowerPortChange(e.target.value)
284
+ }
285
  placeholder="/dev/tty.usbmodem5A460816621"
286
  className="bg-gray-800 border-gray-700 text-white flex-1"
287
  />
 
381
  </div>
382
  </div>
383
  </div>
384
+
385
+ <div className="space-y-4">
386
+ <CameraConfiguration
387
+ cameras={cameras}
388
+ onCamerasChange={setCameras}
389
+ releaseStreamsRef={releaseStreamsRef}
390
+ />
391
+ </div>
392
  </div>
393
 
394
  <div className="flex flex-col sm:flex-row gap-4 justify-center pt-4">
 
417
  robotType={detectionRobotType}
418
  onPortDetected={handlePortDetected}
419
  />
 
 
 
 
 
 
420
  </>
421
  );
422
  };
src/components/recording/CameraConfiguration.tsx ADDED
@@ -0,0 +1,693 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect, useRef, useCallback } from "react";
2
+ import { Button } from "@/components/ui/button";
3
+ import { Label } from "@/components/ui/label";
4
+ import {
5
+ Select,
6
+ SelectContent,
7
+ SelectItem,
8
+ SelectTrigger,
9
+ SelectValue,
10
+ } from "@/components/ui/select";
11
+ import { Input } from "@/components/ui/input";
12
+ import { Camera, Plus, X, Video, VideoOff } from "lucide-react";
13
+ import { useApi } from "@/contexts/ApiContext";
14
+ import { useToast } from "@/hooks/use-toast";
15
+
16
+ export interface CameraConfig {
17
+ id: string;
18
+ name: string;
19
+ type: string;
20
+ camera_index?: number; // Keep for backend compatibility
21
+ device_id: string; // Use this for actual camera selection
22
+ width: number;
23
+ height: number;
24
+ fps?: number;
25
+ }
26
+
27
+ interface CameraConfigurationProps {
28
+ cameras: CameraConfig[];
29
+ onCamerasChange: (cameras: CameraConfig[]) => void;
30
+ releaseStreamsRef?: React.MutableRefObject<(() => void) | null>; // Ref to expose stream release function
31
+ }
32
+
33
+ interface AvailableCamera {
34
+ index: number;
35
+ deviceId: string;
36
+ name: string;
37
+ available: boolean;
38
+ }
39
+
40
+ const CameraConfiguration: React.FC<CameraConfigurationProps> = ({
41
+ cameras,
42
+ onCamerasChange,
43
+ releaseStreamsRef,
44
+ }) => {
45
+ const { baseUrl, fetchWithHeaders } = useApi();
46
+ const { toast } = useToast();
47
+
48
+ const [availableCameras, setAvailableCameras] = useState<AvailableCamera[]>(
49
+ []
50
+ );
51
+ const [selectedCameraIndex, setSelectedCameraIndex] = useState<string>("");
52
+ const [cameraName, setCameraName] = useState("");
53
+ const [isLoadingCameras, setIsLoadingCameras] = useState(false);
54
+ const [cameraStreams, setCameraStreams] = useState<Map<string, MediaStream>>(
55
+ new Map()
56
+ );
57
+
58
+ // Fetch available cameras on component mount
59
+ useEffect(() => {
60
+ fetchAvailableCameras();
61
+ }, []);
62
+
63
+ const fetchAvailableCameras = async () => {
64
+ console.log("🚀 fetchAvailableCameras() called");
65
+ setIsLoadingCameras(true);
66
+ try {
67
+ console.log(
68
+ "📡 Trying backend endpoint:",
69
+ `${baseUrl}/available-cameras`
70
+ );
71
+ const response = await fetchWithHeaders(`${baseUrl}/available-cameras`);
72
+ console.log("📡 Backend response status:", response.status, response.ok);
73
+
74
+ if (response.ok) {
75
+ const data = await response.json();
76
+ console.log("📡 Backend camera data received:", data);
77
+ setAvailableCameras(data.cameras || []);
78
+
79
+ // Always also try browser detection to get device IDs
80
+ console.log("🔄 Also running browser detection for device IDs...");
81
+ await detectBrowserCameras();
82
+ } else {
83
+ console.log("📡 Backend failed, falling back to browser detection");
84
+ // Fallback to browser camera detection
85
+ await detectBrowserCameras();
86
+ }
87
+ } catch (error) {
88
+ console.error("📡 Error fetching cameras from backend:", error);
89
+ console.log("🔄 Falling back to browser detection due to error");
90
+ // Fallback to browser camera detection
91
+ await detectBrowserCameras();
92
+ } finally {
93
+ setIsLoadingCameras(false);
94
+ console.log("✅ fetchAvailableCameras() completed");
95
+ }
96
+ };
97
+
98
+ const detectBrowserCameras = async () => {
99
+ try {
100
+ // First, request camera permissions to get proper device IDs and labels
101
+ console.log("🔐 Requesting camera permissions for device detection...");
102
+ try {
103
+ const tempStream = await navigator.mediaDevices.getUserMedia({
104
+ video: true,
105
+ });
106
+ console.log("✅ Camera permission granted, stopping temp stream");
107
+ tempStream.getTracks().forEach((track) => track.stop());
108
+ } catch (permError) {
109
+ console.warn(
110
+ "⚠️ Camera permission denied, device IDs may be empty:",
111
+ permError
112
+ );
113
+ }
114
+
115
+ const devices = await navigator.mediaDevices.enumerateDevices();
116
+ const videoDevices = devices.filter(
117
+ (device) => device.kind === "videoinput"
118
+ );
119
+
120
+ console.log(
121
+ "🔍 Raw video devices from enumerateDevices:",
122
+ videoDevices.map((d) => ({
123
+ deviceId: d.deviceId,
124
+ label: d.label,
125
+ kind: d.kind,
126
+ }))
127
+ );
128
+
129
+ const detectedCameras = videoDevices.map((device, index) => ({
130
+ index,
131
+ deviceId: device.deviceId || `fallback_${index}`, // Fallback if deviceId is empty
132
+ name: device.label || `Camera ${index + 1}`,
133
+ available: true,
134
+ }));
135
+
136
+ console.log("🎬 Browser cameras with indices mapped:", detectedCameras);
137
+ setAvailableCameras(detectedCameras);
138
+ } catch (error) {
139
+ console.error("Error detecting browser cameras:", error);
140
+ toast({
141
+ title: "Camera Detection Failed",
142
+ description:
143
+ "Could not detect available cameras. Please check permissions.",
144
+ variant: "destructive",
145
+ });
146
+ }
147
+ };
148
+
149
+ const startCameraPreview = async (cameraConfig: CameraConfig) => {
150
+ try {
151
+ console.log(
152
+ "🎥 Starting camera preview for:",
153
+ cameraConfig.name,
154
+ "with device_id:",
155
+ cameraConfig.device_id,
156
+ "camera_index:",
157
+ cameraConfig.camera_index
158
+ );
159
+
160
+ // Create constraints with fallbacks to avoid OverconstrainedError
161
+ const constraints: MediaStreamConstraints = {
162
+ video: {
163
+ width: { ideal: cameraConfig.width, min: 320, max: 1920 },
164
+ height: { ideal: cameraConfig.height, min: 240, max: 1080 },
165
+ frameRate: { ideal: cameraConfig.fps || 30, min: 10, max: 60 },
166
+ },
167
+ };
168
+
169
+ // Only add deviceId if it's not a fallback
170
+ if (
171
+ cameraConfig.device_id &&
172
+ !cameraConfig.device_id.startsWith("fallback_")
173
+ ) {
174
+ (constraints.video as MediaTrackConstraints).deviceId = {
175
+ exact: cameraConfig.device_id, // Changed from 'ideal' to 'exact'
176
+ };
177
+ console.log(
178
+ "🔧 Using EXACT deviceId constraint:",
179
+ cameraConfig.device_id
180
+ );
181
+ } else {
182
+ console.log("⚠️ No valid deviceId, will use default camera");
183
+ }
184
+
185
+ console.log(
186
+ "📋 Final constraints:",
187
+ JSON.stringify(constraints, null, 2)
188
+ );
189
+
190
+ const stream = await navigator.mediaDevices.getUserMedia(constraints);
191
+
192
+ // Get the actual device being used
193
+ const videoTrack = stream.getVideoTracks()[0];
194
+ if (videoTrack) {
195
+ const settings = videoTrack.getSettings();
196
+ console.log("✅ Actual camera settings:", {
197
+ deviceId: settings.deviceId,
198
+ label: videoTrack.label,
199
+ width: settings.width,
200
+ height: settings.height,
201
+ });
202
+
203
+ // Check if we got the camera we requested
204
+ if (
205
+ cameraConfig.device_id &&
206
+ settings.deviceId !== cameraConfig.device_id
207
+ ) {
208
+ console.warn(
209
+ "⚠️ CAMERA MISMATCH! Requested:",
210
+ cameraConfig.device_id,
211
+ "Got:",
212
+ settings.deviceId
213
+ );
214
+ } else {
215
+ console.log("✅ Camera match confirmed!");
216
+ }
217
+ }
218
+
219
+ console.log(
220
+ "Camera stream created successfully for:",
221
+ cameraConfig.name,
222
+ {
223
+ streamId: stream.id,
224
+ tracks: stream.getTracks().length,
225
+ videoTracks: stream.getVideoTracks().length,
226
+ active: stream.active,
227
+ }
228
+ );
229
+
230
+ setCameraStreams((prev) => {
231
+ const newMap = new Map(prev.set(cameraConfig.id, stream));
232
+ console.log("Updated camera streams map:", Array.from(newMap.keys()));
233
+ return newMap;
234
+ });
235
+
236
+ // Force a small delay to ensure state update
237
+ await new Promise((resolve) => setTimeout(resolve, 100));
238
+
239
+ return stream;
240
+ } catch (error: unknown) {
241
+ console.error("Error starting camera preview:", error);
242
+
243
+ const isMediaError = error instanceof Error;
244
+ const errorName = isMediaError ? error.name : "";
245
+ const errorMessage = isMediaError ? error.message : "Unknown error";
246
+
247
+ // If constraints failed, try with basic constraints
248
+ if (
249
+ errorName === "OverconstrainedError" ||
250
+ errorName === "NotReadableError"
251
+ ) {
252
+ try {
253
+ console.log("Retrying with basic constraints...");
254
+ const basicStream = await navigator.mediaDevices.getUserMedia({
255
+ video: { width: 640, height: 480 },
256
+ });
257
+
258
+ setCameraStreams(
259
+ (prev) => new Map(prev.set(cameraConfig.id, basicStream))
260
+ );
261
+ toast({
262
+ title: "Camera Preview Started",
263
+ description: `${cameraConfig.name} started with basic settings due to constraint issues.`,
264
+ });
265
+ return basicStream;
266
+ } catch (basicError) {
267
+ console.error("Error with basic constraints:", basicError);
268
+ }
269
+ }
270
+
271
+ toast({
272
+ title: "Camera Preview Failed",
273
+ description: `Could not start preview for ${cameraConfig.name}: ${errorMessage}`,
274
+ variant: "destructive",
275
+ });
276
+ return null;
277
+ }
278
+ };
279
+
280
+ const stopCameraPreview = (cameraId: string) => {
281
+ const stream = cameraStreams.get(cameraId);
282
+ if (stream) {
283
+ stream.getTracks().forEach((track) => track.stop());
284
+ setCameraStreams((prev) => {
285
+ const newMap = new Map(prev);
286
+ newMap.delete(cameraId);
287
+ return newMap;
288
+ });
289
+ }
290
+ };
291
+
292
+ const addCamera = async () => {
293
+ if (!selectedCameraIndex || !cameraName.trim()) {
294
+ toast({
295
+ title: "Missing Information",
296
+ description: "Please select a camera and provide a name.",
297
+ variant: "destructive",
298
+ });
299
+ return;
300
+ }
301
+
302
+ const cameraIndex = parseInt(selectedCameraIndex);
303
+ const selectedCamera = availableCameras.find(
304
+ (cam) => cam.index === cameraIndex
305
+ );
306
+
307
+ if (!selectedCamera) {
308
+ toast({
309
+ title: "Invalid Camera",
310
+ description: "Selected camera is not available.",
311
+ variant: "destructive",
312
+ });
313
+ return;
314
+ }
315
+
316
+ // Check if camera is already added
317
+ if (cameras.some((cam) => cam.camera_index === cameraIndex)) {
318
+ toast({
319
+ title: "Camera Already Added",
320
+ description: "This camera is already in the configuration.",
321
+ variant: "destructive",
322
+ });
323
+ return;
324
+ }
325
+
326
+ const newCamera: CameraConfig = {
327
+ id: `camera_${Date.now()}`,
328
+ name: cameraName.trim(),
329
+ type: "opencv",
330
+ camera_index: selectedCamera.index,
331
+ device_id: selectedCamera.deviceId,
332
+ width: 640,
333
+ height: 480,
334
+ fps: 30,
335
+ };
336
+
337
+ console.log("🆕 Creating new camera config:", {
338
+ name: newCamera.name,
339
+ camera_index: newCamera.camera_index,
340
+ device_id: newCamera.device_id,
341
+ selectedCamera: selectedCamera,
342
+ });
343
+
344
+ const updatedCameras = [...cameras, newCamera];
345
+ onCamerasChange(updatedCameras);
346
+
347
+ // Start preview for the new camera
348
+ await startCameraPreview(newCamera);
349
+
350
+ // Reset form
351
+ setSelectedCameraIndex("");
352
+ setCameraName("");
353
+
354
+ toast({
355
+ title: "Camera Added",
356
+ description: `${newCamera.name} has been added to the configuration.`,
357
+ });
358
+ };
359
+
360
+ const removeCamera = (cameraId: string) => {
361
+ stopCameraPreview(cameraId);
362
+ const updatedCameras = cameras.filter((cam) => cam.id !== cameraId);
363
+ onCamerasChange(updatedCameras);
364
+
365
+ toast({
366
+ title: "Camera Removed",
367
+ description: "Camera has been removed from the configuration.",
368
+ });
369
+ };
370
+
371
+ const updateCamera = (cameraId: string, updates: Partial<CameraConfig>) => {
372
+ const updatedCameras = cameras.map((cam) =>
373
+ cam.id === cameraId ? { ...cam, ...updates } : cam
374
+ );
375
+ onCamerasChange(updatedCameras);
376
+ };
377
+
378
+ // Function to release all camera streams (for recording start)
379
+ const releaseAllCameraStreams = useCallback(() => {
380
+ console.log("🔓 Releasing all camera streams for recording...");
381
+ cameraStreams.forEach((stream, cameraId) => {
382
+ console.log(`🔓 Stopping stream for camera: ${cameraId}`);
383
+ stream.getTracks().forEach((track) => track.stop());
384
+ });
385
+ setCameraStreams(new Map());
386
+ console.log("✅ All camera streams released");
387
+ }, [cameraStreams]);
388
+
389
+ // Expose the release function to parent component via ref
390
+ useEffect(() => {
391
+ if (releaseStreamsRef) {
392
+ releaseStreamsRef.current = releaseAllCameraStreams;
393
+ }
394
+ }, [releaseStreamsRef, releaseAllCameraStreams]);
395
+
396
+ // Clean up streams on component unmount
397
+ useEffect(() => {
398
+ return () => {
399
+ cameraStreams.forEach((stream) => {
400
+ stream.getTracks().forEach((track) => track.stop());
401
+ });
402
+ };
403
+ }, []);
404
+
405
+ return (
406
+ <div className="space-y-4">
407
+ <h3 className="text-lg font-semibold text-white border-b border-gray-700 pb-2">
408
+ Camera Configuration
409
+ </h3>
410
+
411
+ {/* Add Camera Section */}
412
+ <div className="bg-gray-800/50 rounded-lg p-4 space-y-4">
413
+ <h4 className="text-md font-medium text-gray-300">Add Camera</h4>
414
+
415
+ <div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
416
+ <div className="space-y-2">
417
+ <Label className="text-sm font-medium text-gray-300">
418
+ Available Cameras
419
+ </Label>
420
+ <Select
421
+ value={selectedCameraIndex}
422
+ onValueChange={setSelectedCameraIndex}
423
+ disabled={isLoadingCameras}
424
+ >
425
+ <SelectTrigger className="bg-gray-800 border-gray-700 text-white">
426
+ <SelectValue
427
+ placeholder={
428
+ isLoadingCameras ? "Loading cameras..." : "Select camera"
429
+ }
430
+ />
431
+ </SelectTrigger>
432
+ <SelectContent className="bg-gray-800 border-gray-700">
433
+ {availableCameras.map((camera) => (
434
+ <SelectItem
435
+ key={camera.index}
436
+ value={camera.index.toString()}
437
+ className="text-white hover:bg-gray-700"
438
+ disabled={
439
+ !camera.available ||
440
+ cameras.some((cam) => cam.camera_index === camera.index)
441
+ }
442
+ >
443
+ {camera.name} (Index {camera.index})
444
+ {cameras.some((cam) => cam.camera_index === camera.index) &&
445
+ " (Already added)"}
446
+ </SelectItem>
447
+ ))}
448
+ </SelectContent>
449
+ </Select>
450
+ </div>
451
+
452
+ <div className="space-y-2">
453
+ <Label className="text-sm font-medium text-gray-300">
454
+ Camera Name
455
+ </Label>
456
+ <Input
457
+ value={cameraName}
458
+ onChange={(e) => setCameraName(e.target.value)}
459
+ placeholder="e.g., workspace_cam"
460
+ className="bg-gray-800 border-gray-700 text-white"
461
+ />
462
+ </div>
463
+
464
+ <div className="space-y-2 flex flex-col justify-end">
465
+ <Button
466
+ onClick={addCamera}
467
+ className="bg-blue-500 hover:bg-blue-600 text-white"
468
+ disabled={!selectedCameraIndex || !cameraName.trim()}
469
+ >
470
+ <Plus className="w-4 h-4 mr-2" />
471
+ Add Camera
472
+ </Button>
473
+ </div>
474
+ </div>
475
+ </div>
476
+
477
+ {/* Configured Cameras */}
478
+ {cameras.length > 0 && (
479
+ <div className="space-y-4">
480
+ <h4 className="text-md font-medium text-gray-300">
481
+ Configured Cameras ({cameras.length})
482
+ </h4>
483
+
484
+ <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-2 gap-4">
485
+ {cameras.map((camera) => (
486
+ <CameraPreview
487
+ key={camera.id}
488
+ camera={camera}
489
+ stream={cameraStreams.get(camera.id)}
490
+ onRemove={() => removeCamera(camera.id)}
491
+ onUpdate={(updates) => updateCamera(camera.id, updates)}
492
+ onStartPreview={() => startCameraPreview(camera)}
493
+ />
494
+ ))}
495
+ </div>
496
+ </div>
497
+ )}
498
+
499
+ {cameras.length === 0 && (
500
+ <div className="text-center py-8 text-gray-500">
501
+ <Camera className="w-12 h-12 mx-auto mb-4 text-gray-600" />
502
+ <p>No cameras configured. Add a camera to get started.</p>
503
+ </div>
504
+ )}
505
+ </div>
506
+ );
507
+ };
508
+
509
+ interface CameraPreviewProps {
510
+ camera: CameraConfig;
511
+ stream?: MediaStream;
512
+ onRemove: () => void;
513
+ onUpdate: (updates: Partial<CameraConfig>) => void;
514
+ onStartPreview: () => void;
515
+ }
516
+
517
+ const CameraPreview: React.FC<CameraPreviewProps> = ({
518
+ camera,
519
+ stream,
520
+ onRemove,
521
+ onUpdate,
522
+ onStartPreview,
523
+ }) => {
524
+ const videoRef = useRef<HTMLVideoElement>(null);
525
+ const [isPreviewActive, setIsPreviewActive] = useState(false);
526
+
527
+ // Debug logging for props
528
+ console.log("CameraPreview render for:", camera.name, {
529
+ hasStream: !!stream,
530
+ streamActive: stream?.active,
531
+ isPreviewActive,
532
+ streamId: stream?.id,
533
+ });
534
+
535
+ useEffect(() => {
536
+ const video = videoRef.current;
537
+ if (video && stream) {
538
+ console.log("Setting stream to video element for camera:", camera.name);
539
+ video.srcObject = stream;
540
+
541
+ // Explicitly play the video to ensure it starts
542
+ const playVideo = async () => {
543
+ try {
544
+ await video.play();
545
+ console.log("Video playing successfully for camera:", camera.name);
546
+ setIsPreviewActive(true);
547
+ } catch (error) {
548
+ console.error("Error playing video for camera:", camera.name, error);
549
+ // Try to play without audio in case autoplay is blocked
550
+ video.muted = true;
551
+ try {
552
+ await video.play();
553
+ console.log("Video playing muted for camera:", camera.name);
554
+ setIsPreviewActive(true);
555
+ } catch (mutedError) {
556
+ console.error("Error playing muted video:", mutedError);
557
+ setIsPreviewActive(false);
558
+ }
559
+ }
560
+ };
561
+
562
+ // Wait for metadata to load before playing
563
+ if (video.readyState >= 1) {
564
+ playVideo();
565
+ } else {
566
+ video.addEventListener("loadedmetadata", playVideo, { once: true });
567
+ }
568
+ } else {
569
+ console.log("No stream or video element for camera:", camera.name);
570
+ setIsPreviewActive(false);
571
+ }
572
+ }, [stream, camera.name]);
573
+
574
+ useEffect(() => {
575
+ // Auto-start preview when camera is added
576
+ if (!stream && !isPreviewActive) {
577
+ console.log("Auto-starting preview for camera:", camera.name);
578
+ onStartPreview();
579
+ }
580
+ }, [stream, isPreviewActive, onStartPreview, camera.name]);
581
+
582
+ return (
583
+ <div className="bg-gray-900 rounded-lg border border-gray-700 overflow-hidden">
584
+ {/* Camera Preview */}
585
+ <div className="aspect-[4/3] bg-gray-800 relative">
586
+ {/* Always show the video element if we have a stream, regardless of isPreviewActive */}
587
+ {stream ? (
588
+ <>
589
+ <video
590
+ ref={videoRef}
591
+ autoPlay
592
+ muted
593
+ playsInline
594
+ className="w-full h-full object-cover"
595
+ onLoadedMetadata={() =>
596
+ console.log("Video metadata loaded for:", camera.name)
597
+ }
598
+ onPlay={() =>
599
+ console.log("Video started playing for:", camera.name)
600
+ }
601
+ onError={(e) => console.error("Video error for:", camera.name, e)}
602
+ onCanPlay={() => console.log("Video can play for:", camera.name)}
603
+ />
604
+ <div className="absolute top-2 left-2">
605
+ <div className="flex items-center gap-1 bg-black/50 px-2 py-1 rounded text-xs">
606
+ <div className="w-1.5 h-1.5 bg-green-400 rounded-full animate-pulse"></div>
607
+ <span className="text-green-400">
608
+ {isPreviewActive ? "LIVE" : "LOADING"}
609
+ </span>
610
+ </div>
611
+ </div>
612
+ </>
613
+ ) : (
614
+ <div className="w-full h-full flex flex-col items-center justify-center">
615
+ <VideoOff className="w-8 h-8 text-gray-500 mb-2" />
616
+ <span className="text-gray-500 text-sm">Preview not available</span>
617
+ <Button
618
+ onClick={onStartPreview}
619
+ size="sm"
620
+ className="mt-2 bg-blue-500 hover:bg-blue-600"
621
+ >
622
+ <Video className="w-3 h-3 mr-1" />
623
+ Start Preview
624
+ </Button>
625
+ </div>
626
+ )}
627
+ </div>
628
+
629
+ {/* Camera Info */}
630
+ <div className="p-3 space-y-2">
631
+ <div className="flex items-center justify-between">
632
+ <h5 className="font-medium text-white truncate">{camera.name}</h5>
633
+ <Button
634
+ onClick={onRemove}
635
+ size="sm"
636
+ variant="ghost"
637
+ className="text-red-400 hover:text-red-300 hover:bg-red-900/20 p-1"
638
+ >
639
+ <X className="w-4 h-4" />
640
+ </Button>
641
+ </div>
642
+
643
+ <div className="grid grid-cols-1 gap-2 text-xs text-gray-400">
644
+ <div className="flex items-center gap-2">
645
+ <span className="w-16">Resolution:</span>
646
+ <div className="flex items-center gap-1">
647
+ <Input
648
+ type="number"
649
+ value={camera.width}
650
+ onChange={(e) =>
651
+ onUpdate({ width: parseInt(e.target.value) || 640 })
652
+ }
653
+ className="bg-gray-800 border-gray-700 text-white text-xs h-6 px-2 w-16"
654
+ min="320"
655
+ max="1920"
656
+ />
657
+ <span className="flex items-center">×</span>
658
+ <Input
659
+ type="number"
660
+ value={camera.height}
661
+ onChange={(e) =>
662
+ onUpdate({ height: parseInt(e.target.value) || 480 })
663
+ }
664
+ className="bg-gray-800 border-gray-700 text-white text-xs h-6 px-2 w-16"
665
+ min="240"
666
+ max="1080"
667
+ />
668
+ </div>
669
+ </div>
670
+ <div className="flex items-center gap-2">
671
+ <span className="w-16">FPS:</span>
672
+ <Input
673
+ type="number"
674
+ value={camera.fps || 30}
675
+ onChange={(e) =>
676
+ onUpdate({ fps: parseInt(e.target.value) || 30 })
677
+ }
678
+ className="bg-gray-800 border-gray-700 text-white text-xs h-6 px-2 w-16"
679
+ min="10"
680
+ max="60"
681
+ />
682
+ </div>
683
+ </div>
684
+
685
+ <div className="text-xs text-gray-500">
686
+ Type: {camera.type} | Device: {camera.device_id?.substring(0, 10)}...
687
+ </div>
688
+ </div>
689
+ </div>
690
+ );
691
+ };
692
+
693
+ export default CameraConfiguration;
src/components/recording/PhoneCameraFeed.tsx DELETED
@@ -1,117 +0,0 @@
1
- import React, { useEffect, useRef, useState } from "react";
2
- import { Smartphone, WifiOff } from "lucide-react";
3
- import { useApi } from "@/contexts/ApiContext";
4
-
5
- interface PhoneCameraFeedProps {
6
- sessionId: string;
7
- }
8
-
9
- const PhoneCameraFeed: React.FC<PhoneCameraFeedProps> = ({ sessionId }) => {
10
- const { wsBaseUrl } = useApi();
11
- const videoRef = useRef<HTMLVideoElement>(null);
12
- const [isConnected, setIsConnected] = useState(false);
13
- const [error, setError] = useState<string | null>(null);
14
- const wsRef = useRef<WebSocket | null>(null);
15
- const mediaSourceRef = useRef<MediaSource | null>(null);
16
-
17
- useEffect(() => {
18
- if (!sessionId) return;
19
-
20
- const connectWebSocket = () => {
21
- try {
22
- const ws = new WebSocket(`${wsBaseUrl}/ws/camera/${sessionId}`);
23
- wsRef.current = ws;
24
-
25
- ws.onopen = () => {
26
- console.log("Camera feed WebSocket connected");
27
- setIsConnected(true);
28
- setError(null);
29
- };
30
-
31
- ws.onmessage = (event) => {
32
- // Handle incoming video chunks
33
- if (event.data instanceof Blob) {
34
- handleVideoChunk(event.data);
35
- } else if (event.data === "camera_connected") {
36
- setIsConnected(true);
37
- }
38
- };
39
-
40
- ws.onclose = () => {
41
- console.log("Camera feed WebSocket disconnected");
42
- setIsConnected(false);
43
- };
44
-
45
- ws.onerror = (error) => {
46
- console.error("Camera feed WebSocket error:", error);
47
- setError("Failed to connect to camera feed");
48
- setIsConnected(false);
49
- };
50
- } catch (error) {
51
- console.error("Failed to create WebSocket:", error);
52
- setError("Failed to establish connection");
53
- }
54
- };
55
-
56
- const handleVideoChunk = (chunk: Blob) => {
57
- // For now, we'll just log that we received a chunk
58
- // In a full implementation, this would use MediaSource API
59
- console.log("Received video chunk:", chunk.size, "bytes");
60
- };
61
-
62
- connectWebSocket();
63
-
64
- return () => {
65
- if (wsRef.current) {
66
- wsRef.current.close();
67
- }
68
- if (mediaSourceRef.current) {
69
- mediaSourceRef.current.endOfStream();
70
- }
71
- };
72
- }, [sessionId]);
73
-
74
- if (error) {
75
- return (
76
- <div className="w-full h-32 bg-gray-800 rounded-lg flex flex-col items-center justify-center">
77
- <WifiOff className="w-6 h-6 text-red-400 mb-2" />
78
- <p className="text-xs text-red-400 text-center">{error}</p>
79
- </div>
80
- );
81
- }
82
-
83
- if (!isConnected) {
84
- return (
85
- <div className="w-full h-32 bg-gray-800 rounded-lg flex flex-col items-center justify-center">
86
- <Smartphone className="w-6 h-6 text-gray-500 mb-2" />
87
- <p className="text-xs text-gray-500 text-center">
88
- Waiting for phone camera...
89
- </p>
90
- <div className="w-4 h-4 border-2 border-gray-600 border-t-blue-500 rounded-full animate-spin mt-2"></div>
91
- </div>
92
- );
93
- }
94
-
95
- return (
96
- <div className="w-full h-32 bg-gray-800 rounded-lg overflow-hidden relative">
97
- <video
98
- ref={videoRef}
99
- className="w-full h-full object-cover"
100
- autoPlay
101
- muted
102
- playsInline
103
- />
104
- <div className="absolute top-2 left-2">
105
- <div className="flex items-center gap-1 bg-black/50 px-2 py-1 rounded text-xs">
106
- <div className="w-1.5 h-1.5 bg-green-400 rounded-full animate-pulse"></div>
107
- <span className="text-green-400">LIVE</span>
108
- </div>
109
- </div>
110
- <div className="absolute bottom-2 right-2">
111
- <Smartphone className="w-4 h-4 text-white/70" />
112
- </div>
113
- </div>
114
- );
115
- };
116
-
117
- export default PhoneCameraFeed;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/components/recording/QrCodeModal.tsx DELETED
@@ -1,145 +0,0 @@
1
-
2
- import React, { useEffect, useState } from "react";
3
- import {
4
- Dialog,
5
- DialogContent,
6
- DialogHeader,
7
- DialogTitle,
8
- DialogDescription,
9
- } from "@/components/ui/dialog";
10
- import { Button } from "@/components/ui/button";
11
- import { Copy, Smartphone, QrCode } from "lucide-react";
12
- import { useToast } from "@/hooks/use-toast";
13
-
14
- interface QrCodeModalProps {
15
- open: boolean;
16
- onOpenChange: (open: boolean) => void;
17
- sessionId: string;
18
- }
19
-
20
- const QrCodeModal: React.FC<QrCodeModalProps> = ({
21
- open,
22
- onOpenChange,
23
- sessionId,
24
- }) => {
25
- const { toast } = useToast();
26
- const [qrCodeUrl, setQrCodeUrl] = useState<string>("");
27
- const [phoneUrl, setPhoneUrl] = useState<string>("");
28
-
29
- useEffect(() => {
30
- if (sessionId) {
31
- // Get current host URL - in production this would be the deployed URL
32
- const currentHost = window.location.origin;
33
- const phonePageUrl = `${currentHost}/phone-camera?sessionId=${sessionId}`;
34
- setPhoneUrl(phonePageUrl);
35
-
36
- // Generate QR code using a public QR code API
37
- const qrApiUrl = `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(phonePageUrl)}`;
38
- setQrCodeUrl(qrApiUrl);
39
- }
40
- }, [sessionId]);
41
-
42
- const copyToClipboard = async () => {
43
- try {
44
- await navigator.clipboard.writeText(phoneUrl);
45
- toast({
46
- title: "URL Copied!",
47
- description: "Phone camera URL has been copied to clipboard.",
48
- });
49
- } catch (error) {
50
- toast({
51
- title: "Copy Failed",
52
- description: "Could not copy URL to clipboard.",
53
- variant: "destructive",
54
- });
55
- }
56
- };
57
-
58
- return (
59
- <Dialog open={open} onOpenChange={onOpenChange}>
60
- <DialogContent className="bg-gray-900 border-gray-800 text-white sm:max-w-[500px] p-8">
61
- <DialogHeader>
62
- <div className="flex justify-center items-center gap-4 mb-4">
63
- <div className="w-10 h-10 bg-blue-500 rounded-full flex items-center justify-center">
64
- <Smartphone className="w-5 h-5 text-white" />
65
- </div>
66
- </div>
67
- <DialogTitle className="text-white text-center text-2xl font-bold">
68
- Add Phone Camera
69
- </DialogTitle>
70
- <DialogDescription className="text-gray-400 text-base leading-relaxed text-center">
71
- Scan the QR code with your phone to add a camera feed to your recording session.
72
- </DialogDescription>
73
- </DialogHeader>
74
-
75
- <div className="space-y-6 py-4">
76
- {/* QR Code Display */}
77
- <div className="flex justify-center">
78
- {qrCodeUrl ? (
79
- <div className="bg-white p-4 rounded-lg">
80
- <img
81
- src={qrCodeUrl}
82
- alt="QR Code for phone camera"
83
- className="w-48 h-48"
84
- />
85
- </div>
86
- ) : (
87
- <div className="w-48 h-48 bg-gray-800 rounded-lg flex items-center justify-center">
88
- <QrCode className="w-12 h-12 text-gray-600" />
89
- </div>
90
- )}
91
- </div>
92
-
93
- {/* Instructions */}
94
- <div className="space-y-4">
95
- <h3 className="text-lg font-semibold text-white text-center">
96
- How to connect your phone camera:
97
- </h3>
98
- <ol className="text-sm text-gray-400 space-y-2 list-decimal list-inside">
99
- <li>Open your phone's camera app</li>
100
- <li>Scan the QR code above</li>
101
- <li>Allow camera permissions when prompted</li>
102
- <li>Your phone camera feed will appear in the recording interface</li>
103
- </ol>
104
- </div>
105
-
106
- {/* Manual URL Option */}
107
- <div className="space-y-3">
108
- <h4 className="text-sm font-semibold text-gray-300">
109
- Or open this URL manually on your phone:
110
- </h4>
111
- <div className="flex gap-2">
112
- <input
113
- type="text"
114
- value={phoneUrl}
115
- readOnly
116
- className="flex-1 bg-gray-800 border border-gray-700 text-white px-3 py-2 rounded text-sm"
117
- />
118
- <Button
119
- onClick={copyToClipboard}
120
- variant="outline"
121
- size="sm"
122
- className="border-gray-600 hover:border-blue-500 text-gray-300 hover:text-blue-400"
123
- >
124
- <Copy className="w-4 h-4" />
125
- </Button>
126
- </div>
127
- </div>
128
-
129
- {/* Close Button */}
130
- <div className="flex justify-center pt-4">
131
- <Button
132
- onClick={() => onOpenChange(false)}
133
- variant="outline"
134
- className="border-gray-500 hover:border-gray-200 px-8 py-2 text-gray-300 hover:text-white"
135
- >
136
- Close
137
- </Button>
138
- </div>
139
- </div>
140
- </DialogContent>
141
- </Dialog>
142
- );
143
- };
144
-
145
- export default QrCodeModal;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/contexts/ApiContext.tsx CHANGED
@@ -1,19 +1,8 @@
1
- import React, {
2
- createContext,
3
- useContext,
4
- useState,
5
- useEffect,
6
- ReactNode,
7
- } from "react";
8
 
9
  interface ApiContextType {
10
  baseUrl: string;
11
  wsBaseUrl: string;
12
- isNgrokEnabled: boolean;
13
- setNgrokUrl: (url: string) => void;
14
- resetToLocalhost: () => void;
15
- ngrokUrl: string;
16
- getHeaders: () => Record<string, string>;
17
  fetchWithHeaders: (url: string, options?: RequestInit) => Promise<Response>;
18
  }
19
 
@@ -27,69 +16,8 @@ interface ApiProviderProps {
27
  }
28
 
29
  export const ApiProvider: React.FC<ApiProviderProps> = ({ children }) => {
30
- const [ngrokUrl, setNgrokUrlState] = useState<string>("");
31
- const [isNgrokEnabled, setIsNgrokEnabled] = useState<boolean>(false);
32
-
33
- // Load saved ngrok configuration on mount
34
- useEffect(() => {
35
- const savedNgrokUrl = localStorage.getItem("ngrok-url");
36
- const savedNgrokEnabled = localStorage.getItem("ngrok-enabled") === "true";
37
-
38
- if (savedNgrokUrl && savedNgrokEnabled) {
39
- setNgrokUrlState(savedNgrokUrl);
40
- setIsNgrokEnabled(true);
41
- }
42
- }, []);
43
-
44
- const setNgrokUrl = (url: string) => {
45
- // Clean and validate the URL
46
- let cleanUrl = url.trim();
47
- if (cleanUrl && !cleanUrl.startsWith("http")) {
48
- cleanUrl = `https://${cleanUrl}`;
49
- }
50
-
51
- // Remove trailing slash
52
- cleanUrl = cleanUrl.replace(/\/$/, "");
53
-
54
- setNgrokUrlState(cleanUrl);
55
- setIsNgrokEnabled(!!cleanUrl);
56
-
57
- // Persist to localStorage
58
- if (cleanUrl) {
59
- localStorage.setItem("ngrok-url", cleanUrl);
60
- localStorage.setItem("ngrok-enabled", "true");
61
- } else {
62
- localStorage.removeItem("ngrok-url");
63
- localStorage.removeItem("ngrok-enabled");
64
- }
65
- };
66
-
67
- const resetToLocalhost = () => {
68
- setNgrokUrlState("");
69
- setIsNgrokEnabled(false);
70
- localStorage.removeItem("ngrok-url");
71
- localStorage.removeItem("ngrok-enabled");
72
- };
73
-
74
- const baseUrl = isNgrokEnabled && ngrokUrl ? ngrokUrl : DEFAULT_LOCALHOST;
75
- const wsBaseUrl =
76
- isNgrokEnabled && ngrokUrl
77
- ? ngrokUrl.replace("https://", "wss://").replace("http://", "ws://")
78
- : DEFAULT_WS_LOCALHOST;
79
-
80
- // Helper function to get headers with ngrok skip warning if needed
81
- const getHeaders = (): Record<string, string> => {
82
- const headers: Record<string, string> = {
83
- "Content-Type": "application/json",
84
- };
85
-
86
- // Add ngrok skip warning header when using ngrok
87
- if (isNgrokEnabled && ngrokUrl) {
88
- headers["ngrok-skip-browser-warning"] = "true";
89
- }
90
-
91
- return headers;
92
- };
93
 
94
  // Enhanced fetch function that automatically includes necessary headers
95
  const fetchWithHeaders = async (
@@ -99,7 +27,7 @@ export const ApiProvider: React.FC<ApiProviderProps> = ({ children }) => {
99
  const enhancedOptions: RequestInit = {
100
  ...options,
101
  headers: {
102
- ...getHeaders(),
103
  ...options.headers,
104
  },
105
  };
@@ -112,11 +40,6 @@ export const ApiProvider: React.FC<ApiProviderProps> = ({ children }) => {
112
  value={{
113
  baseUrl,
114
  wsBaseUrl,
115
- isNgrokEnabled,
116
- setNgrokUrl,
117
- resetToLocalhost,
118
- ngrokUrl,
119
- getHeaders,
120
  fetchWithHeaders,
121
  }}
122
  >
 
1
+ import React, { createContext, useContext, ReactNode } from "react";
 
 
 
 
 
 
2
 
3
  interface ApiContextType {
4
  baseUrl: string;
5
  wsBaseUrl: string;
 
 
 
 
 
6
  fetchWithHeaders: (url: string, options?: RequestInit) => Promise<Response>;
7
  }
8
 
 
16
  }
17
 
18
  export const ApiProvider: React.FC<ApiProviderProps> = ({ children }) => {
19
+ const baseUrl = DEFAULT_LOCALHOST;
20
+ const wsBaseUrl = DEFAULT_WS_LOCALHOST;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
 
22
  // Enhanced fetch function that automatically includes necessary headers
23
  const fetchWithHeaders = async (
 
27
  const enhancedOptions: RequestInit = {
28
  ...options,
29
  headers: {
30
+ "Content-Type": "application/json",
31
  ...options.headers,
32
  },
33
  };
 
40
  value={{
41
  baseUrl,
42
  wsBaseUrl,
 
 
 
 
 
43
  fetchWithHeaders,
44
  }}
45
  >
src/pages/Landing.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import React, { useState } from "react";
2
  import { useNavigate } from "react-router-dom";
3
  import { useToast } from "@/hooks/use-toast";
4
  import { ArrowRight } from "lucide-react";
@@ -8,18 +8,19 @@ import ActionList from "@/components/landing/ActionList";
8
  import PermissionModal from "@/components/landing/PermissionModal";
9
  import TeleoperationModal from "@/components/landing/TeleoperationModal";
10
  import RecordingModal from "@/components/landing/RecordingModal";
11
- import NgrokConfigModal from "@/components/landing/NgrokConfigModal";
12
  import { Action } from "@/components/landing/types";
13
  import UsageInstructionsModal from "@/components/landing/UsageInstructionsModal";
14
  import DirectFollowerModal from "@/components/landing/DirectFollowerModal";
15
  import { useApi } from "@/contexts/ApiContext";
 
16
 
17
  const Landing = () => {
18
  const [robotModel, setRobotModel] = useState("SO101");
19
  const [showPermissionModal, setShowPermissionModal] = useState(false);
20
  const [showTeleoperationModal, setShowTeleoperationModal] = useState(false);
21
  const [showUsageModal, setShowUsageModal] = useState(false);
22
- const [showNgrokModal, setShowNgrokModal] = useState(false);
23
  const [leaderPort, setLeaderPort] = useState("/dev/tty.usbmodem5A460816421");
24
  const [followerPort, setFollowerPort] = useState(
25
  "/dev/tty.usbmodem5A460816621"
@@ -44,6 +45,10 @@ const Landing = () => {
44
  const [datasetRepoId, setDatasetRepoId] = useState("");
45
  const [singleTask, setSingleTask] = useState("");
46
  const [numEpisodes, setNumEpisodes] = useState(5);
 
 
 
 
47
 
48
  // Direct follower control state
49
  const [showDirectFollowerModal, setShowDirectFollowerModal] = useState(false);
@@ -55,6 +60,30 @@ const Landing = () => {
55
  const navigate = useNavigate();
56
  const { toast } = useToast();
57
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  const loadConfigs = async () => {
59
  setIsLoadingConfigs(true);
60
  try {
@@ -99,6 +128,15 @@ const Landing = () => {
99
  }
100
  };
101
 
 
 
 
 
 
 
 
 
 
102
  const handleTrainingClick = () => {
103
  if (robotModel) {
104
  navigate("/training");
@@ -182,6 +220,40 @@ const Landing = () => {
182
  return;
183
  }
184
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
185
  const recordingConfig = {
186
  leader_port: recordLeaderPort,
187
  follower_port: recordFollowerPort,
@@ -196,6 +268,7 @@ const Landing = () => {
196
  video: true,
197
  push_to_hub: false,
198
  resume: false,
 
199
  };
200
 
201
  setShowRecordingModal(false);
@@ -330,10 +403,7 @@ const Landing = () => {
330
  return (
331
  <div className="min-h-screen bg-black text-white flex flex-col items-center p-4 pt-12 sm:pt-20">
332
  <div className="w-full max-w-7xl mx-auto px-4 mb-12">
333
- <LandingHeader
334
- onShowInstructions={() => setShowUsageModal(true)}
335
- onShowNgrokConfig={() => setShowNgrokModal(true)}
336
- />
337
  </div>
338
 
339
  <div className="p-8 bg-gray-900 rounded-lg shadow-xl w-full max-w-4xl space-y-6 border border-gray-700">
@@ -374,7 +444,7 @@ const Landing = () => {
374
 
375
  <RecordingModal
376
  open={showRecordingModal}
377
- onOpenChange={setShowRecordingModal}
378
  leaderPort={recordLeaderPort}
379
  setLeaderPort={setRecordLeaderPort}
380
  followerPort={recordFollowerPort}
@@ -391,8 +461,11 @@ const Landing = () => {
391
  setSingleTask={setSingleTask}
392
  numEpisodes={numEpisodes}
393
  setNumEpisodes={setNumEpisodes}
 
 
394
  isLoadingConfigs={isLoadingConfigs}
395
  onStart={handleStartRecording}
 
396
  />
397
 
398
  <DirectFollowerModal
@@ -406,10 +479,6 @@ const Landing = () => {
406
  isLoadingConfigs={isLoadingConfigs}
407
  onStart={handleStartDirectFollower}
408
  />
409
- <NgrokConfigModal
410
- open={showNgrokModal}
411
- onOpenChange={setShowNgrokModal}
412
- />
413
  </div>
414
  );
415
  };
 
1
+ import React, { useState, useRef, useEffect } from "react";
2
  import { useNavigate } from "react-router-dom";
3
  import { useToast } from "@/hooks/use-toast";
4
  import { ArrowRight } from "lucide-react";
 
8
  import PermissionModal from "@/components/landing/PermissionModal";
9
  import TeleoperationModal from "@/components/landing/TeleoperationModal";
10
  import RecordingModal from "@/components/landing/RecordingModal";
11
+
12
  import { Action } from "@/components/landing/types";
13
  import UsageInstructionsModal from "@/components/landing/UsageInstructionsModal";
14
  import DirectFollowerModal from "@/components/landing/DirectFollowerModal";
15
  import { useApi } from "@/contexts/ApiContext";
16
+ import { CameraConfig } from "@/components/recording/CameraConfiguration";
17
 
18
  const Landing = () => {
19
  const [robotModel, setRobotModel] = useState("SO101");
20
  const [showPermissionModal, setShowPermissionModal] = useState(false);
21
  const [showTeleoperationModal, setShowTeleoperationModal] = useState(false);
22
  const [showUsageModal, setShowUsageModal] = useState(false);
23
+
24
  const [leaderPort, setLeaderPort] = useState("/dev/tty.usbmodem5A460816421");
25
  const [followerPort, setFollowerPort] = useState(
26
  "/dev/tty.usbmodem5A460816621"
 
45
  const [datasetRepoId, setDatasetRepoId] = useState("");
46
  const [singleTask, setSingleTask] = useState("");
47
  const [numEpisodes, setNumEpisodes] = useState(5);
48
+ const [cameras, setCameras] = useState<CameraConfig[]>([]);
49
+
50
+ // Camera stream release ref
51
+ const releaseStreamsRef = useRef<(() => void) | null>(null);
52
 
53
  // Direct follower control state
54
  const [showDirectFollowerModal, setShowDirectFollowerModal] = useState(false);
 
60
  const navigate = useNavigate();
61
  const { toast } = useToast();
62
 
63
+ // Clear camera state and release streams when returning to landing page
64
+ useEffect(() => {
65
+ // If we have cameras and returning from a recording session, clear them
66
+ if (cameras.length > 0) {
67
+ console.log(
68
+ "🧹 Landing page: Cleaning up camera state from previous session"
69
+ );
70
+ if (releaseStreamsRef.current) {
71
+ releaseStreamsRef.current();
72
+ }
73
+ setCameras([]); // Clear camera configuration
74
+ }
75
+ }, []); // Only run on mount
76
+
77
+ // Cleanup when leaving landing page
78
+ useEffect(() => {
79
+ return () => {
80
+ if (releaseStreamsRef.current) {
81
+ console.log("🧹 Landing page: Cleaning up camera streams on unmount");
82
+ releaseStreamsRef.current();
83
+ }
84
+ };
85
+ }, []);
86
+
87
  const loadConfigs = async () => {
88
  setIsLoadingConfigs(true);
89
  try {
 
128
  }
129
  };
130
 
131
+ const handleRecordingModalClose = (open: boolean) => {
132
+ setShowRecordingModal(open);
133
+ // Release camera streams when modal is closed
134
+ if (!open && releaseStreamsRef.current) {
135
+ console.log("🧹 Modal closed: Releasing camera streams");
136
+ releaseStreamsRef.current();
137
+ }
138
+ };
139
+
140
  const handleTrainingClick = () => {
141
  if (robotModel) {
142
  navigate("/training");
 
220
  return;
221
  }
222
 
223
+ // 🔓 CRITICAL: Release all camera streams before backend accesses them
224
+ if (cameras.length > 0 && releaseStreamsRef.current) {
225
+ console.log("🔓 Releasing camera streams before starting recording...");
226
+
227
+ toast({
228
+ title: "Preparing Camera Resources",
229
+ description: `Releasing ${cameras.length} camera stream(s) for recording...`,
230
+ });
231
+
232
+ releaseStreamsRef.current();
233
+
234
+ // Wait a moment for camera resources to be fully released
235
+ await new Promise((resolve) => setTimeout(resolve, 500));
236
+ console.log("✅ Camera streams released, proceeding with recording...");
237
+
238
+ toast({
239
+ title: "Camera Resources Ready",
240
+ description:
241
+ "Camera streams released successfully. Starting recording...",
242
+ });
243
+ }
244
+
245
+ // Convert cameras to the LeRobot format
246
+ const cameraDict = cameras.reduce((acc, cam) => {
247
+ acc[cam.name] = {
248
+ type: cam.type,
249
+ camera_index: cam.camera_index,
250
+ width: cam.width,
251
+ height: cam.height,
252
+ fps: cam.fps,
253
+ };
254
+ return acc;
255
+ }, {} as Record<string, { type: string; camera_index?: number; width: number; height: number; fps?: number }>);
256
+
257
  const recordingConfig = {
258
  leader_port: recordLeaderPort,
259
  follower_port: recordFollowerPort,
 
268
  video: true,
269
  push_to_hub: false,
270
  resume: false,
271
+ cameras: cameraDict,
272
  };
273
 
274
  setShowRecordingModal(false);
 
403
  return (
404
  <div className="min-h-screen bg-black text-white flex flex-col items-center p-4 pt-12 sm:pt-20">
405
  <div className="w-full max-w-7xl mx-auto px-4 mb-12">
406
+ <LandingHeader onShowInstructions={() => setShowUsageModal(true)} />
 
 
 
407
  </div>
408
 
409
  <div className="p-8 bg-gray-900 rounded-lg shadow-xl w-full max-w-4xl space-y-6 border border-gray-700">
 
444
 
445
  <RecordingModal
446
  open={showRecordingModal}
447
+ onOpenChange={handleRecordingModalClose}
448
  leaderPort={recordLeaderPort}
449
  setLeaderPort={setRecordLeaderPort}
450
  followerPort={recordFollowerPort}
 
461
  setSingleTask={setSingleTask}
462
  numEpisodes={numEpisodes}
463
  setNumEpisodes={setNumEpisodes}
464
+ cameras={cameras}
465
+ setCameras={setCameras}
466
  isLoadingConfigs={isLoadingConfigs}
467
  onStart={handleStartRecording}
468
+ releaseStreamsRef={releaseStreamsRef}
469
  />
470
 
471
  <DirectFollowerModal
 
479
  isLoadingConfigs={isLoadingConfigs}
480
  onStart={handleStartDirectFollower}
481
  />
 
 
 
 
482
  </div>
483
  );
484
  };
src/pages/PhoneCamera.tsx DELETED
@@ -1,223 +0,0 @@
1
- import React, { useEffect, useRef, useState } from "react";
2
- import { useSearchParams } from "react-router-dom";
3
- import { Camera, WifiOff, Smartphone } from "lucide-react";
4
- import { Button } from "@/components/ui/button";
5
- import { useApi } from "@/contexts/ApiContext";
6
-
7
- const PhoneCamera = () => {
8
- const [searchParams] = useSearchParams();
9
- const sessionId = searchParams.get("sessionId");
10
- const { wsBaseUrl } = useApi();
11
- const videoRef = useRef<HTMLVideoElement>(null);
12
- const canvasRef = useRef<HTMLCanvasElement>(null);
13
- const wsRef = useRef<WebSocket | null>(null);
14
- const streamRef = useRef<MediaStream | null>(null);
15
- const [isConnected, setIsConnected] = useState(false);
16
- const [isStreaming, setIsStreaming] = useState(false);
17
- const [error, setError] = useState<string | null>(null);
18
-
19
- useEffect(() => {
20
- if (!sessionId) {
21
- setError("No session ID provided");
22
- return;
23
- }
24
-
25
- connectWebSocket();
26
- return () => {
27
- cleanup();
28
- };
29
- }, [sessionId]);
30
-
31
- const connectWebSocket = () => {
32
- try {
33
- const ws = new WebSocket(`${wsBaseUrl}/ws/camera/${sessionId}`);
34
- wsRef.current = ws;
35
-
36
- ws.onopen = () => {
37
- console.log("Phone camera WebSocket connected");
38
- setIsConnected(true);
39
- setError(null);
40
- // Notify the recording page that a camera is connected
41
- ws.send("camera_connected");
42
- };
43
-
44
- ws.onclose = () => {
45
- console.log("Phone camera WebSocket disconnected");
46
- setIsConnected(false);
47
- };
48
-
49
- ws.onerror = (error) => {
50
- console.error("Phone camera WebSocket error:", error);
51
- setError("Failed to connect to recording session");
52
- setIsConnected(false);
53
- };
54
- } catch (error) {
55
- console.error("Failed to create WebSocket:", error);
56
- setError("Failed to establish connection");
57
- }
58
- };
59
-
60
- const startCamera = async () => {
61
- try {
62
- const stream = await navigator.mediaDevices.getUserMedia({
63
- video: {
64
- facingMode: "environment", // Use back camera
65
- width: { ideal: 640 },
66
- height: { ideal: 480 },
67
- },
68
- audio: false,
69
- });
70
-
71
- streamRef.current = stream;
72
- if (videoRef.current) {
73
- videoRef.current.srcObject = stream;
74
- }
75
-
76
- setIsStreaming(true);
77
- startVideoStreaming();
78
- } catch (error) {
79
- console.error("Error accessing camera:", error);
80
- setError("Could not access camera. Please check permissions.");
81
- }
82
- };
83
-
84
- const startVideoStreaming = () => {
85
- if (!videoRef.current || !canvasRef.current || !wsRef.current) return;
86
-
87
- const video = videoRef.current;
88
- const canvas = canvasRef.current;
89
- const ctx = canvas.getContext("2d");
90
- const ws = wsRef.current;
91
-
92
- const sendFrame = () => {
93
- if (!ctx || !isConnected || !isStreaming) return;
94
-
95
- // Draw video frame to canvas
96
- canvas.width = video.videoWidth || 640;
97
- canvas.height = video.videoHeight || 480;
98
- ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
99
-
100
- // Convert to blob and send via WebSocket
101
- canvas.toBlob(
102
- (blob) => {
103
- if (blob && ws.readyState === WebSocket.OPEN) {
104
- ws.send(blob);
105
- }
106
- },
107
- "image/jpeg",
108
- 0.7
109
- );
110
- };
111
-
112
- // Send frames at ~10 FPS
113
- const interval = setInterval(sendFrame, 100);
114
-
115
- return () => clearInterval(interval);
116
- };
117
-
118
- const cleanup = () => {
119
- if (streamRef.current) {
120
- streamRef.current.getTracks().forEach((track) => track.stop());
121
- }
122
- if (wsRef.current) {
123
- wsRef.current.close();
124
- }
125
- };
126
-
127
- if (!sessionId) {
128
- return (
129
- <div className="min-h-screen bg-black text-white flex items-center justify-center p-4">
130
- <div className="text-center">
131
- <WifiOff className="w-16 h-16 text-red-400 mx-auto mb-4" />
132
- <h1 className="text-2xl font-bold mb-2">Invalid Session</h1>
133
- <p className="text-gray-400">No session ID provided in the URL.</p>
134
- </div>
135
- </div>
136
- );
137
- }
138
-
139
- return (
140
- <div className="min-h-screen bg-black text-white flex flex-col">
141
- {/* Header */}
142
- <div className="bg-gray-900 p-4 border-b border-gray-700">
143
- <div className="flex items-center justify-center">
144
- <h1 className="text-2xl font-bold">LeLab Camera</h1>
145
- </div>
146
- </div>
147
-
148
- {/* Camera Section */}
149
- <div className="flex-1 flex flex-col p-4">
150
- {error ? (
151
- <div className="flex-1 flex items-center justify-center">
152
- <div className="text-center">
153
- <WifiOff className="w-16 h-16 text-red-400 mx-auto mb-4" />
154
- <h2 className="text-xl font-bold mb-2">Connection Error</h2>
155
- <p className="text-gray-400 mb-4">{error}</p>
156
- <Button
157
- onClick={connectWebSocket}
158
- className="bg-blue-500 hover:bg-blue-600"
159
- >
160
- Retry Connection
161
- </Button>
162
- </div>
163
- </div>
164
- ) : !isConnected ? (
165
- <div className="flex-1 flex items-center justify-center">
166
- <div className="text-center">
167
- <div className="w-16 h-16 border-4 border-gray-600 border-t-blue-500 rounded-full animate-spin mx-auto mb-4"></div>
168
- <h2 className="text-xl font-bold mb-2">Connecting...</h2>
169
- <p className="text-gray-400">Connecting to recording session</p>
170
- </div>
171
- </div>
172
- ) : !isStreaming ? (
173
- <div className="flex-1 flex flex-col items-center justify-center">
174
- <Camera className="w-16 h-16 text-blue-400 mb-4" />
175
- <h2 className="text-xl font-bold mb-2">Ready to Start</h2>
176
- <p className="text-gray-400 mb-6 text-center">
177
- Tap the button below to start your camera and begin streaming to
178
- the recording session.
179
- </p>
180
- <Button
181
- onClick={startCamera}
182
- className="bg-blue-500 hover:bg-blue-600 px-8 py-4 text-lg"
183
- >
184
- <Camera className="w-5 h-5 mr-2" />
185
- Start Camera
186
- </Button>
187
- </div>
188
- ) : (
189
- <div className="flex-1 flex flex-col">
190
- <div className="flex items-center justify-between mb-4">
191
- <div className="flex items-center gap-2">
192
- <div className="w-2 h-2 bg-red-500 rounded-full animate-pulse"></div>
193
- <span className="text-red-400 font-semibold">STREAMING</span>
194
- </div>
195
- <Smartphone className="w-5 h-5 text-blue-400" />
196
- </div>
197
-
198
- <div className="flex-1 bg-gray-900 rounded-lg overflow-hidden">
199
- <video
200
- ref={videoRef}
201
- autoPlay
202
- playsInline
203
- muted
204
- className="w-full h-full object-cover"
205
- />
206
- </div>
207
-
208
- <canvas ref={canvasRef} className="hidden" />
209
-
210
- <div className="mt-4 text-center">
211
- <p className="text-gray-400 text-sm">
212
- Your camera is now streaming to the recording session. Keep this
213
- page open during recording.
214
- </p>
215
- </div>
216
- </div>
217
- )}
218
- </div>
219
- </div>
220
- );
221
- };
222
-
223
- export default PhoneCamera;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/pages/Recording.tsx CHANGED
@@ -5,7 +5,7 @@ import { useToast } from "@/hooks/use-toast";
5
  import { ArrowLeft, Square, SkipForward, RotateCcw, Play } from "lucide-react";
6
  import UrdfViewer from "@/components/UrdfViewer";
7
  import UrdfProcessorInitializer from "@/components/UrdfProcessorInitializer";
8
- import PhoneCameraFeed from "@/components/recording/PhoneCameraFeed";
9
  import { useApi } from "@/contexts/ApiContext";
10
 
11
  interface RecordingConfig {
@@ -56,10 +56,9 @@ const Recording = () => {
56
  );
57
  const [recordingSessionStarted, setRecordingSessionStarted] = useState(false);
58
 
59
- // QR Code and camera states
60
- const [showQrModal, setShowQrModal] = useState(false);
61
- const [sessionId, setSessionId] = useState<string>("");
62
- const [phoneCameraConnected, setPhoneCameraConnected] = useState(false);
63
 
64
  // Redirect if no config provided
65
  useEffect(() => {
@@ -92,8 +91,25 @@ const Recording = () => {
92
  );
93
  if (response.ok) {
94
  const status = await response.json();
 
 
 
95
  setBackendStatus(status);
96
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
  // If backend recording stopped and session ended, navigate to upload
98
  if (
99
  !status.recording_active &&
@@ -126,52 +142,14 @@ const Recording = () => {
126
  return () => {
127
  if (statusInterval) clearInterval(statusInterval);
128
  };
129
- }, [recordingSessionStarted, recordingConfig, navigate, toast]);
130
-
131
- // Generate session ID when component loads
132
- useEffect(() => {
133
- const newSessionId = `session_${Date.now()}_${Math.random()
134
- .toString(36)
135
- .substr(2, 9)}`;
136
- setSessionId(newSessionId);
137
- }, []);
138
-
139
- // Listen for phone camera connections
140
- useEffect(() => {
141
- if (!sessionId) return;
142
-
143
- const connectToPhoneCameraWS = () => {
144
- const ws = new WebSocket(`${wsBaseUrl}/ws/camera/${sessionId}`);
145
-
146
- ws.onopen = () => {
147
- console.log("Phone camera WebSocket connected");
148
- };
149
-
150
- ws.onmessage = (event) => {
151
- if (event.data === "camera_connected" && !phoneCameraConnected) {
152
- setPhoneCameraConnected(true);
153
- toast({
154
- title: "Phone Camera Connected!",
155
- description: "New camera feed detected and connected successfully.",
156
- });
157
- }
158
- };
159
-
160
- ws.onclose = () => {
161
- console.log("Phone camera WebSocket disconnected");
162
- setPhoneCameraConnected(false);
163
- };
164
-
165
- ws.onerror = (error) => {
166
- console.error("Phone camera WebSocket error:", error);
167
- };
168
-
169
- return ws;
170
- };
171
-
172
- const ws = connectToPhoneCameraWS();
173
- return () => ws.close();
174
- }, [sessionId, phoneCameraConnected, toast]);
175
 
176
  const formatTime = (seconds: number): string => {
177
  const mins = Math.floor(seconds / 60);
@@ -218,6 +196,24 @@ const Recording = () => {
218
  const handleExitEarly = async () => {
219
  if (!backendStatus?.available_controls.exit_early) return;
220
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
221
  try {
222
  const response = await fetchWithHeaders(
223
  `${baseUrl}/recording-exit-early`,
@@ -228,19 +224,12 @@ const Recording = () => {
228
  const data = await response.json();
229
 
230
  if (response.ok) {
231
- const currentPhase = backendStatus.current_phase;
232
- if (currentPhase === "recording") {
233
- toast({
234
- title: "Episode Recording Ended",
235
- description: `Episode ${backendStatus.current_episode} recording completed. Moving to reset phase.`,
236
- });
237
- } else if (currentPhase === "resetting") {
238
- toast({
239
- title: "Reset Complete",
240
- description: `Moving to next episode...`,
241
- });
242
- }
243
  } else {
 
 
 
244
  toast({
245
  title: "Error",
246
  description: data.message,
@@ -248,6 +237,9 @@ const Recording = () => {
248
  });
249
  }
250
  } catch (error) {
 
 
 
251
  toast({
252
  title: "Connection Error",
253
  description: "Could not connect to the backend server.",
@@ -359,12 +351,20 @@ const Recording = () => {
359
  const sessionElapsedTime = backendStatus.session_elapsed_seconds || 0;
360
 
361
  const getPhaseTitle = () => {
 
 
 
 
362
  if (currentPhase === "recording") return "Episode Recording Time";
363
  if (currentPhase === "resetting") return "Environment Reset Time";
364
  return "Phase Time";
365
  };
366
 
367
  const getStatusText = () => {
 
 
 
 
368
  if (currentPhase === "recording")
369
  return `RECORDING EPISODE ${currentEpisode}`;
370
  if (currentPhase === "resetting") return "RESET THE ENVIRONMENT";
@@ -373,6 +373,10 @@ const Recording = () => {
373
  };
374
 
375
  const getStatusColor = () => {
 
 
 
 
376
  if (currentPhase === "recording") return "text-red-400";
377
  if (currentPhase === "resetting") return "text-orange-400";
378
  if (currentPhase === "preparing") return "text-yellow-400";
@@ -380,6 +384,10 @@ const Recording = () => {
380
  };
381
 
382
  const getDotColor = () => {
 
 
 
 
383
  if (currentPhase === "recording") return "bg-red-500 animate-pulse";
384
  if (currentPhase === "resetting") return "bg-orange-500 animate-pulse";
385
  if (currentPhase === "preparing") return "bg-yellow-500";
@@ -497,11 +505,23 @@ const Recording = () => {
497
  <div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
498
  <Button
499
  onClick={handleExitEarly}
500
- disabled={!backendStatus.available_controls.exit_early}
 
 
 
501
  className="bg-green-500 hover:bg-green-600 text-white font-semibold py-4 text-lg disabled:opacity-50"
502
  >
503
- <SkipForward className="w-5 h-5 mr-2" />
504
- End Episode
 
 
 
 
 
 
 
 
 
505
  </Button>
506
 
507
  <Button
@@ -529,11 +549,23 @@ const Recording = () => {
529
  <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
530
  <Button
531
  onClick={handleExitEarly}
532
- disabled={!backendStatus.available_controls.exit_early}
 
 
 
533
  className="bg-blue-500 hover:bg-blue-600 text-white font-semibold py-6 text-xl disabled:opacity-50"
534
  >
535
- <Play className="w-6 h-6 mr-2" />
536
- Continue to Next Phase
 
 
 
 
 
 
 
 
 
537
  </Button>
538
 
539
  <Button
@@ -615,16 +647,6 @@ const Recording = () => {
615
  )}
616
  </div>
617
  </div>
618
-
619
- {/* Phone Camera Feed - takes up 1 column */}
620
- {phoneCameraConnected && (
621
- <div className="bg-gray-900 rounded-lg p-4 border border-gray-700">
622
- <h3 className="text-sm font-semibold text-gray-400 mb-3">
623
- Phone Camera
624
- </h3>
625
- <PhoneCameraFeed sessionId={sessionId} />
626
- </div>
627
- )}
628
  </div>
629
 
630
  {/* URDF Viewer Section */}
 
5
  import { ArrowLeft, Square, SkipForward, RotateCcw, Play } from "lucide-react";
6
  import UrdfViewer from "@/components/UrdfViewer";
7
  import UrdfProcessorInitializer from "@/components/UrdfProcessorInitializer";
8
+
9
  import { useApi } from "@/contexts/ApiContext";
10
 
11
  interface RecordingConfig {
 
56
  );
57
  const [recordingSessionStarted, setRecordingSessionStarted] = useState(false);
58
 
59
+ // Local UI state for immediate user feedback
60
+ const [transitioningToReset, setTransitioningToReset] = useState(false);
61
+ const [transitioningToNext, setTransitioningToNext] = useState(false);
 
62
 
63
  // Redirect if no config provided
64
  useEffect(() => {
 
91
  );
92
  if (response.ok) {
93
  const status = await response.json();
94
+ console.log(
95
+ `📊 Backend Status: ${status.current_phase} | Transition States: reset=${transitioningToReset}, next=${transitioningToNext}`
96
+ );
97
  setBackendStatus(status);
98
 
99
+ // 🎯 CLEAR TRANSITION STATES: Only clear when backend actually reaches the expected phase
100
+ if (status.current_phase === "resetting" && transitioningToReset) {
101
+ console.log(
102
+ "✅ Clearing transitioningToReset - backend reached resetting phase"
103
+ );
104
+ setTransitioningToReset(false);
105
+ }
106
+ if (status.current_phase === "recording" && transitioningToNext) {
107
+ console.log(
108
+ "✅ Clearing transitioningToNext - backend reached recording phase"
109
+ );
110
+ setTransitioningToNext(false);
111
+ }
112
+
113
  // If backend recording stopped and session ended, navigate to upload
114
  if (
115
  !status.recording_active &&
 
142
  return () => {
143
  if (statusInterval) clearInterval(statusInterval);
144
  };
145
+ }, [
146
+ recordingSessionStarted,
147
+ recordingConfig,
148
+ navigate,
149
+ toast,
150
+ transitioningToReset,
151
+ transitioningToNext,
152
+ ]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
153
 
154
  const formatTime = (seconds: number): string => {
155
  const mins = Math.floor(seconds / 60);
 
196
  const handleExitEarly = async () => {
197
  if (!backendStatus?.available_controls.exit_early) return;
198
 
199
+ // 🎯 IMMEDIATE UI FEEDBACK: Show transition state before backend response
200
+ const currentPhase = backendStatus.current_phase;
201
+ if (currentPhase === "recording") {
202
+ console.log("🎯 Setting transitioningToReset = true");
203
+ setTransitioningToReset(true);
204
+ toast({
205
+ title: "Ending Episode Recording",
206
+ description: `Moving to reset phase for episode ${backendStatus.current_episode}...`,
207
+ });
208
+ } else if (currentPhase === "resetting") {
209
+ console.log("🎯 Setting transitioningToNext = true");
210
+ setTransitioningToNext(true);
211
+ toast({
212
+ title: "Reset Complete",
213
+ description: `Moving to next episode...`,
214
+ });
215
+ }
216
+
217
  try {
218
  const response = await fetchWithHeaders(
219
  `${baseUrl}/recording-exit-early`,
 
224
  const data = await response.json();
225
 
226
  if (response.ok) {
227
+ // SUCCESS: Don't clear transition states here - let them persist until backend phase changes
228
+ // The transition states will be cleared when the backend status actually updates to the new phase
 
 
 
 
 
 
 
 
 
 
229
  } else {
230
+ // Clear transition states on error
231
+ setTransitioningToReset(false);
232
+ setTransitioningToNext(false);
233
  toast({
234
  title: "Error",
235
  description: data.message,
 
237
  });
238
  }
239
  } catch (error) {
240
+ // Clear transition states on error
241
+ setTransitioningToReset(false);
242
+ setTransitioningToNext(false);
243
  toast({
244
  title: "Connection Error",
245
  description: "Could not connect to the backend server.",
 
351
  const sessionElapsedTime = backendStatus.session_elapsed_seconds || 0;
352
 
353
  const getPhaseTitle = () => {
354
+ // 🎯 IMMEDIATE FEEDBACK: Show transition titles
355
+ if (transitioningToReset) return "Transitioning to Reset";
356
+ if (transitioningToNext) return "Moving to Next Episode";
357
+
358
  if (currentPhase === "recording") return "Episode Recording Time";
359
  if (currentPhase === "resetting") return "Environment Reset Time";
360
  return "Phase Time";
361
  };
362
 
363
  const getStatusText = () => {
364
+ // 🎯 IMMEDIATE FEEDBACK: Show transition states
365
+ if (transitioningToReset) return "MOVING TO RESET PHASE";
366
+ if (transitioningToNext) return "MOVING TO NEXT EPISODE";
367
+
368
  if (currentPhase === "recording")
369
  return `RECORDING EPISODE ${currentEpisode}`;
370
  if (currentPhase === "resetting") return "RESET THE ENVIRONMENT";
 
373
  };
374
 
375
  const getStatusColor = () => {
376
+ // 🎯 IMMEDIATE FEEDBACK: Show transition state colors
377
+ if (transitioningToReset) return "text-blue-400"; // Blue for transition
378
+ if (transitioningToNext) return "text-blue-400"; // Blue for transition
379
+
380
  if (currentPhase === "recording") return "text-red-400";
381
  if (currentPhase === "resetting") return "text-orange-400";
382
  if (currentPhase === "preparing") return "text-yellow-400";
 
384
  };
385
 
386
  const getDotColor = () => {
387
+ // 🎯 IMMEDIATE FEEDBACK: Show transition state dots with animation
388
+ if (transitioningToReset) return "bg-blue-500 animate-pulse"; // Blue pulsing for transition
389
+ if (transitioningToNext) return "bg-blue-500 animate-pulse"; // Blue pulsing for transition
390
+
391
  if (currentPhase === "recording") return "bg-red-500 animate-pulse";
392
  if (currentPhase === "resetting") return "bg-orange-500 animate-pulse";
393
  if (currentPhase === "preparing") return "bg-yellow-500";
 
505
  <div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
506
  <Button
507
  onClick={handleExitEarly}
508
+ disabled={
509
+ !backendStatus.available_controls.exit_early ||
510
+ transitioningToReset
511
+ }
512
  className="bg-green-500 hover:bg-green-600 text-white font-semibold py-4 text-lg disabled:opacity-50"
513
  >
514
+ {transitioningToReset ? (
515
+ <>
516
+ <div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div>
517
+ Moving to Reset...
518
+ </>
519
+ ) : (
520
+ <>
521
+ <SkipForward className="w-5 h-5 mr-2" />
522
+ End Episode
523
+ </>
524
+ )}
525
  </Button>
526
 
527
  <Button
 
549
  <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
550
  <Button
551
  onClick={handleExitEarly}
552
+ disabled={
553
+ !backendStatus.available_controls.exit_early ||
554
+ transitioningToNext
555
+ }
556
  className="bg-blue-500 hover:bg-blue-600 text-white font-semibold py-6 text-xl disabled:opacity-50"
557
  >
558
+ {transitioningToNext ? (
559
+ <>
560
+ <div className="animate-spin rounded-full h-6 w-6 border-b-2 border-white mr-2"></div>
561
+ Moving to Next Episode...
562
+ </>
563
+ ) : (
564
+ <>
565
+ <Play className="w-6 h-6 mr-2" />
566
+ Continue to Next Phase
567
+ </>
568
+ )}
569
  </Button>
570
 
571
  <Button
 
647
  )}
648
  </div>
649
  </div>
 
 
 
 
 
 
 
 
 
 
650
  </div>
651
 
652
  {/* URDF Viewer Section */}