Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions frontend/src/components/control/CameraFeed.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import React from "react";
import { VideoOff } from "lucide-react";
import { useCameraStream } from "@/hooks/useCameraStream";

interface CameraFeedProps {
/** Browser deviceId to stream. Empty string renders the "no camera" state. */
deviceId: string;
/** Optional caption shown under the feed. */
label?: string;
}

/** Live browser-camera feed bound to a deviceId via getUserMedia. */
const CameraFeed: React.FC<CameraFeedProps> = ({ deviceId, label }) => {
const { videoRef, hasError } = useCameraStream(deviceId, false);
const showVideo = deviceId && !hasError;

return (
<div className="bg-gray-900 rounded-lg border border-gray-700 overflow-hidden">
<div className="aspect-[4/3] bg-gray-800 relative">
{showVideo ? (
<video
ref={videoRef}
autoPlay
muted
playsInline
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex flex-col items-center justify-center">
<VideoOff className="w-8 h-8 text-gray-500 mb-2" />
<span className="text-gray-500 text-sm">
{deviceId ? "Preview failed" : "No camera selected"}
</span>
</div>
)}
</div>
{label && (
<div className="p-2 text-sm text-gray-300 truncate border-t border-gray-800">
{label}
</div>
)}
</div>
);
};

export default CameraFeed;
94 changes: 94 additions & 0 deletions frontend/src/components/control/TeleopCameraPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import React, { useState } from "react";
import { RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { useRobots } from "@/hooks/useRobots";
import CameraFeed from "./CameraFeed";

/**
* Optional live camera panel for the teleoperation page. Off by default so we
* never call getUserMedia just by landing on the page (same consent pattern as
* the calibration camera toggle). Teleoperation opens no cv2 cameras, so the
* browser can stream them directly while the arm runs.
*
* A strict mirror of the selected robot's configured cameras: one live feed per
* camera on the robot record (e.g. "wrist_cam", "webcam"), stacked vertically.
* If the robot has none configured it shows nothing — teleop never surfaces a
* device that wasn't deliberately added to the robot.
*/
const TeleopCameraPanel: React.FC = () => {
const [enabled, setEnabled] = useState(false);
// Bumped by the retry button to remount the feeds (a fresh getUserMedia
// attempt) — useful if a camera was unplugged and reconnected.
const [reloadKey, setReloadKey] = useState(0);
const { selectedRecord, isLoading: robotsLoading } = useRobots();

// Feeds come solely from the robot's configured cameras; each carries a stored
// browser device_id we stream directly. A configured camera whose device is
// currently absent still shows (name + failed-preview placeholder), so the
// user can tell it's expected but not detected.
const configured = selectedRecord?.cameras ?? [];
const feeds = configured.map((c) => ({
key: c.id,
name: c.name,
deviceId: c.device_id,
}));

return (
<div className="bg-gray-900 rounded-lg p-4 flex flex-col gap-4 h-full">
<div className="flex items-center justify-between">
<h2 className="text-xl font-medium text-gray-200">Cameras</h2>
<div className="flex items-center gap-2">
{enabled && feeds.length > 0 && (
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => setReloadKey((k) => k + 1)}
className="h-9 w-9 text-gray-400 hover:text-white flex-shrink-0"
title="Retry camera feeds (e.g. after reconnecting a camera)"
aria-label="Retry camera feeds"
>
<RefreshCw className="w-4 h-4" />
</Button>
)}
<Label htmlFor="teleop-camera-toggle" className="text-sm text-gray-400">
{enabled ? "On" : "Off"}
</Label>
<Switch
id="teleop-camera-toggle"
checked={enabled}
onCheckedChange={setEnabled}
/>
</div>
</div>

{enabled ? (
feeds.length > 0 ? (
<div className="flex flex-col gap-3 overflow-y-auto">
{feeds.map((feed) => (
<CameraFeed
key={`${feed.key}:${reloadKey}`}
deviceId={feed.deviceId}
label={feed.name}
/>
))}
</div>
) : (
<p className="text-sm text-gray-500">
{robotsLoading
? "Loading robot..."
: "No cameras configured for this robot. Add them during calibration to see live feeds here."}
</p>
)
) : (
<p className="text-sm text-gray-500">
Turn on to watch your cameras while you teleoperate.
</p>
)}
</div>
);
};

export default TeleopCameraPanel;
6 changes: 6 additions & 0 deletions frontend/src/components/control/VisualizerPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,14 @@ import Logo from "@/components/Logo";
interface VisualizerPanelProps {
onGoBack: () => void;
className?: string;
/** Optional content rendered as a column beside the 3D viewer (e.g. a camera panel). */
rightSlot?: React.ReactNode;
}

const VisualizerPanel: React.FC<VisualizerPanelProps> = ({
onGoBack,
className,
rightSlot,
}) => {
return (
<div
Expand All @@ -39,6 +42,9 @@ const VisualizerPanel: React.FC<VisualizerPanelProps> = ({
<UrdfViewer />
</div>
</div>
{rightSlot && (
<div className="lg:w-96 flex flex-col">{rightSlot}</div>
)}
</div>
);
};
Expand Down
7 changes: 6 additions & 1 deletion frontend/src/pages/Teleoperation.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React, { useCallback, useEffect, useRef } from "react";
import { useNavigate } from "react-router-dom";
import VisualizerPanel from "@/components/control/VisualizerPanel";
import TeleopCameraPanel from "@/components/control/TeleopCameraPanel";
import { useToast } from "@/hooks/use-toast";
import { useApi } from "@/contexts/ApiContext";

Expand Down Expand Up @@ -69,7 +70,11 @@ const TeleoperationPage = () => {
return (
<div className="min-h-screen bg-black flex items-center justify-center p-2 sm:p-4">
<div className="w-full h-[95vh] flex">
<VisualizerPanel onGoBack={handleGoBack} className="lg:w-full" />
<VisualizerPanel
onGoBack={handleGoBack}
className="lg:w-full"
rightSlot={<TeleopCameraPanel />}
/>
</div>
</div>
);
Expand Down