This commit is contained in:
legop3
2026-07-11 13:39:08 -04:00
parent 026e9de476
commit 65b54f01d2
10 changed files with 119 additions and 66 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -78,8 +78,8 @@
<script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script>
<script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script>
<title>Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-CYuyI8Zg.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CoyqmROJ.css">
<script type="module" crossorigin src="/assets/index-Dp_4HZ7O.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-LTXnNyH3.css">
</head>
<body>
<div id="root"></div>
+1 -1
View File
@@ -206,7 +206,7 @@ function MobileFeatureTabs({
</div>
</TabPanel>
<TabPanel id="vip" keepMounted>
<VipPanel isActive={activeTab === 'vip'} />
<VipPanel isActive={activeTab === 'vip'} layout={layout} />
</TabPanel>
<TabPanel id="roomcontrols">
<div className={themeStackClass}>
+1 -1
View File
@@ -425,7 +425,7 @@ export default function RightPaneTabs({ layout, onOpenHelpOverlay }) {
{/* VIP tab */}
<TabPanel id="vip" keepMounted>
<VipPanel isActive={activeTab === 'vip'} />
<VipPanel isActive={activeTab === 'vip'} layout={layout} />
</TabPanel>
{/* help tab */}
+2 -2
View File
@@ -13,7 +13,7 @@ import VipPrivateRoverAccessCard from '../vip/VipPrivateRoverAccessCard.jsx';
import VipProfileImageCard from '../vip/VipProfileImageCard.jsx';
import VipPtzCameraCard from '../vip/VipPtzCameraCard.jsx';
export default function VipPanel() {
export default function VipPanel({ layout = 'desktop' }) {
const session = useSessionSelector((state) => state.session);
const {
identifySession,
@@ -70,7 +70,7 @@ export default function VipPanel() {
<div className="lg:col-span-2">
{isVerified ? (
<div className="space-y-2">
<VipPtzCameraCard onMessage={setMessage} fullWidth />
<VipPtzCameraCard onMessage={setMessage} fullWidth layout={layout} />
<VipMidiBeeperCard />
<VipAudioUploadCard
ownRoverId={ownRoverId}
+101 -48
View File
@@ -2,9 +2,11 @@
// Purpose: Provides the verified-user entry point and fullscreen controller for the single Reolink PTZ camera.
// Scope: Owns PTZ UI state only; server-side PTZ ownership, rover handoff, and command authorization remain authoritative.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import CardFrame from '../CardFrame/index.jsx';
import GPIOToggleControl from '../GPIOToggleControl/index.jsx';
import ReplaySourcesPanel from '../ReplaySourcesPanel/index.jsx';
import KeyPill from './VipAudioUploadCard/KeyPill.jsx';
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
import { useControlSelector } from '../../controls/index.js';
import { formatKeyLabel } from '../../controls/keymapUtils.js';
@@ -356,31 +358,34 @@ function keyLabelFor(keymap, actionId) {
function PtzControlReference() {
const keymap = useControlSelector((control) => control.state.keymap);
const rows = [
['Tilt up', keyLabelFor(keymap, 'driveForward')],
['Tilt down', keyLabelFor(keymap, 'driveBackward')],
['Pan left', keyLabelFor(keymap, 'driveLeft')],
['Pan right', keyLabelFor(keymap, 'driveRight')],
['Zoom in', keyLabelFor(keymap, 'cameraUp')],
['Zoom out', keyLabelFor(keymap, 'cameraDown')],
['Spotlight', keyLabelFor(keymap, 'headlightToggle')],
['Infrared mode', keyLabelFor(keymap, 'laserToggle')],
['Tilt up', 'driveForward'],
['Tilt down', 'driveBackward'],
['Pan left', 'driveLeft'],
['Pan right', 'driveRight'],
['Zoom in', 'cameraUp'],
['Zoom out', 'cameraDown'],
['Spotlight', 'headlightToggle'],
['Infrared mode', 'laserToggle'],
];
return (
<CardFrame title="Controls" bodyClassName="space-y-0.5 p-1 text-xs">
{rows.map(([label, value]) => (
{rows.map(([label, actionId]) => (
<div key={label} className="surface flex items-center justify-between gap-1">
<span className="text-slate-400">{label}</span>
<span className="truncate text-slate-100">{value}</span>
{/* Use the same key display component as the rest of the UI so PTZ
controls read as normal mapped controls instead of custom labels. */}
<KeyPill label={keyLabelFor(keymap, actionId)} />
</div>
))}
</CardFrame>
);
}
function PtzController({ open, onClose }) {
function PtzController({ open, onClose, layout = 'desktop' }) {
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
const isOperator = Boolean(ptz?.isOperator);
const isMobile = layout === 'mobile-portrait' || layout === 'mobile-landscape';
const { ptzRelease } = useSessionActions();
const snapshot = usePtzCameraSnapshot({ enabled: open && !isOperator });
const [releasePending, setReleasePending] = useState(false);
@@ -397,61 +402,109 @@ function PtzController({ open, onClose }) {
}
};
return (
const desktopSidebar = (
<>
<div className="shrink-0">
<PtzStatePanel
ptz={ptz}
onClose={onClose}
onRelease={isOperator ? releaseAndClose : null}
releaseDisabled={releasePending}
/>
</div>
<div className="shrink-0">
<PtzQueueList queue={ptz?.queue} operatorLabel={ptz?.operatorLabel} />
</div>
{isOperator ? (
<div className="shrink-0">
<PtzLightingControls ptz={ptz} />
</div>
) : (
<div className="shrink-0">
<CardFrame title="Controls" bodyClassName="p-1 text-xs text-slate-400">
Live PTZ controls unlock when your camera turn is active.
</CardFrame>
</div>
)}
<div className="shrink-0">
<PtzControlReference />
</div>
<div className="shrink-0">
<ReplaySourcesPanel panelId="ptz-controller-replay" />
</div>
</>
);
const mobileSidebar = (
<>
<div className="shrink-0">
<PtzStatePanel
ptz={ptz}
onClose={onClose}
onRelease={isOperator ? releaseAndClose : null}
releaseDisabled={releasePending}
/>
</div>
{isOperator ? (
<>
<div className="shrink-0">
<PtzLightingControls ptz={ptz} />
</div>
<div className="shrink-0">
<PtzMobileZoomButtons />
</div>
</>
) : (
<div className="shrink-0">
<CardFrame title="Controls" bodyClassName="p-1 text-xs text-slate-400">
Live PTZ controls unlock when your camera turn is active.
</CardFrame>
</div>
)}
<div className="shrink-0">
<PtzQueueList queue={ptz?.queue} operatorLabel={ptz?.operatorLabel} />
</div>
<div className="shrink-0">
<ReplaySourcesPanel panelId="ptz-controller-replay-mobile" />
</div>
</>
);
const sidebarWidthClass = isMobile
? 'grid-cols-[minmax(0,1fr)_12rem]'
: 'grid-cols-[minmax(0,1fr)_20rem]';
const controller = (
<div className="fixed inset-0 z-[110] h-[100dvh] w-[100vw] overflow-hidden bg-black text-slate-100">
<CardFrame
hideHeader
fillHeight
clipOverflow={false}
className="h-[100dvh] w-[100vw] rounded-none border-0 !bg-black"
bodyClassName="grid h-full min-h-0 overflow-hidden grid-cols-[minmax(0,1fr)_12rem] md:grid-cols-[minmax(0,1fr)_20rem]"
bodyClassName={`grid h-full min-h-0 overflow-hidden ${sidebarWidthClass}`}
>
<main className="relative min-h-0 min-w-0 bg-black">
{isOperator ? <PtzLiveVideo enabled /> : <PtzSnapshotPreview feed={snapshot} label={ptz?.name || 'PTZ Camera'} />}
</main>
<aside className="flex h-full min-h-0 items-stretch overflow-hidden border-l border-neutral-600 bg-neutral-950 text-sm">
<div className="flex min-h-0 w-full flex-col gap-0.5 overflow-y-auto p-0.5">
<div className="shrink-0">
<PtzStatePanel
ptz={ptz}
onClose={onClose}
onRelease={isOperator ? releaseAndClose : null}
releaseDisabled={releasePending}
/>
</div>
<div className="shrink-0">
<PtzQueueList queue={ptz?.queue} operatorLabel={ptz?.operatorLabel} />
</div>
{isOperator ? (
<>
<div className="shrink-0">
<PtzLightingControls ptz={ptz} />
</div>
<div className="shrink-0">
<PtzMobileZoomButtons />
</div>
</>
) : (
<div className="shrink-0">
<CardFrame title="Controls" bodyClassName="p-1 text-xs text-slate-400">
Live PTZ controls unlock when your camera turn is active.
</CardFrame>
</div>
)}
<div className="shrink-0">
<PtzControlReference />
</div>
<div className="shrink-0">
<ReplaySourcesPanel panelId="ptz-controller-replay" />
</div>
{isMobile ? mobileSidebar : desktopSidebar}
</div>
</aside>
</CardFrame>
</div>
);
/*
The controller is a true fullscreen surface, so mount it directly under
document.body instead of inside the VIP tab/card tree. That keeps tab panel
spacing, mobile banners, and parent overflow rules from creating visible
gaps around a fixed-position camera interface.
*/
return createPortal(controller, document.body);
}
export default function VipPtzCameraCard({ onMessage, fullWidth = false }) {
export default function VipPtzCameraCard({ onMessage, fullWidth = false, layout = 'desktop' }) {
const featureEnabled = useSessionSelector((state) => isFeatureEnabled(state, 'ptzCamera'));
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
const isVerified = useSessionSelector((state) => Boolean(state.session?.isVerified));
@@ -556,7 +609,7 @@ export default function VipPtzCameraCard({ onMessage, fullWidth = false }) {
</div>
</div>
</CardFrame>
<PtzController open={controllerOpen} onClose={() => setControllerOpen(false)} />
<PtzController open={controllerOpen} onClose={() => setControllerOpen(false)} layout={layout} />
</>
);
}