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/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> <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> <title>Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-CYuyI8Zg.js"></script> <script type="module" crossorigin src="/assets/index-Dp_4HZ7O.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CoyqmROJ.css"> <link rel="stylesheet" crossorigin href="/assets/index-LTXnNyH3.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+1 -1
View File
@@ -206,7 +206,7 @@ function MobileFeatureTabs({
</div> </div>
</TabPanel> </TabPanel>
<TabPanel id="vip" keepMounted> <TabPanel id="vip" keepMounted>
<VipPanel isActive={activeTab === 'vip'} /> <VipPanel isActive={activeTab === 'vip'} layout={layout} />
</TabPanel> </TabPanel>
<TabPanel id="roomcontrols"> <TabPanel id="roomcontrols">
<div className={themeStackClass}> <div className={themeStackClass}>
+1 -1
View File
@@ -425,7 +425,7 @@ export default function RightPaneTabs({ layout, onOpenHelpOverlay }) {
{/* VIP tab */} {/* VIP tab */}
<TabPanel id="vip" keepMounted> <TabPanel id="vip" keepMounted>
<VipPanel isActive={activeTab === 'vip'} /> <VipPanel isActive={activeTab === 'vip'} layout={layout} />
</TabPanel> </TabPanel>
{/* help tab */} {/* help tab */}
+2 -2
View File
@@ -13,7 +13,7 @@ import VipPrivateRoverAccessCard from '../vip/VipPrivateRoverAccessCard.jsx';
import VipProfileImageCard from '../vip/VipProfileImageCard.jsx'; import VipProfileImageCard from '../vip/VipProfileImageCard.jsx';
import VipPtzCameraCard from '../vip/VipPtzCameraCard.jsx'; import VipPtzCameraCard from '../vip/VipPtzCameraCard.jsx';
export default function VipPanel() { export default function VipPanel({ layout = 'desktop' }) {
const session = useSessionSelector((state) => state.session); const session = useSessionSelector((state) => state.session);
const { const {
identifySession, identifySession,
@@ -70,7 +70,7 @@ export default function VipPanel() {
<div className="lg:col-span-2"> <div className="lg:col-span-2">
{isVerified ? ( {isVerified ? (
<div className="space-y-2"> <div className="space-y-2">
<VipPtzCameraCard onMessage={setMessage} fullWidth /> <VipPtzCameraCard onMessage={setMessage} fullWidth layout={layout} />
<VipMidiBeeperCard /> <VipMidiBeeperCard />
<VipAudioUploadCard <VipAudioUploadCard
ownRoverId={ownRoverId} 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. // 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. // 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 { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import CardFrame from '../CardFrame/index.jsx'; import CardFrame from '../CardFrame/index.jsx';
import GPIOToggleControl from '../GPIOToggleControl/index.jsx'; import GPIOToggleControl from '../GPIOToggleControl/index.jsx';
import ReplaySourcesPanel from '../ReplaySourcesPanel/index.jsx'; import ReplaySourcesPanel from '../ReplaySourcesPanel/index.jsx';
import KeyPill from './VipAudioUploadCard/KeyPill.jsx';
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx'; import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
import { useControlSelector } from '../../controls/index.js'; import { useControlSelector } from '../../controls/index.js';
import { formatKeyLabel } from '../../controls/keymapUtils.js'; import { formatKeyLabel } from '../../controls/keymapUtils.js';
@@ -356,31 +358,34 @@ function keyLabelFor(keymap, actionId) {
function PtzControlReference() { function PtzControlReference() {
const keymap = useControlSelector((control) => control.state.keymap); const keymap = useControlSelector((control) => control.state.keymap);
const rows = [ const rows = [
['Tilt up', keyLabelFor(keymap, 'driveForward')], ['Tilt up', 'driveForward'],
['Tilt down', keyLabelFor(keymap, 'driveBackward')], ['Tilt down', 'driveBackward'],
['Pan left', keyLabelFor(keymap, 'driveLeft')], ['Pan left', 'driveLeft'],
['Pan right', keyLabelFor(keymap, 'driveRight')], ['Pan right', 'driveRight'],
['Zoom in', keyLabelFor(keymap, 'cameraUp')], ['Zoom in', 'cameraUp'],
['Zoom out', keyLabelFor(keymap, 'cameraDown')], ['Zoom out', 'cameraDown'],
['Spotlight', keyLabelFor(keymap, 'headlightToggle')], ['Spotlight', 'headlightToggle'],
['Infrared mode', keyLabelFor(keymap, 'laserToggle')], ['Infrared mode', 'laserToggle'],
]; ];
return ( return (
<CardFrame title="Controls" bodyClassName="space-y-0.5 p-1 text-xs"> <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"> <div key={label} className="surface flex items-center justify-between gap-1">
<span className="text-slate-400">{label}</span> <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> </div>
))} ))}
</CardFrame> </CardFrame>
); );
} }
function PtzController({ open, onClose }) { function PtzController({ open, onClose, layout = 'desktop' }) {
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null); const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
const isOperator = Boolean(ptz?.isOperator); const isOperator = Boolean(ptz?.isOperator);
const isMobile = layout === 'mobile-portrait' || layout === 'mobile-landscape';
const { ptzRelease } = useSessionActions(); const { ptzRelease } = useSessionActions();
const snapshot = usePtzCameraSnapshot({ enabled: open && !isOperator }); const snapshot = usePtzCameraSnapshot({ enabled: open && !isOperator });
const [releasePending, setReleasePending] = useState(false); 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"> <div className="fixed inset-0 z-[110] h-[100dvh] w-[100vw] overflow-hidden bg-black text-slate-100">
<CardFrame <CardFrame
hideHeader hideHeader
fillHeight fillHeight
clipOverflow={false} clipOverflow={false}
className="h-[100dvh] w-[100vw] rounded-none border-0 !bg-black" 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"> <main className="relative min-h-0 min-w-0 bg-black">
{isOperator ? <PtzLiveVideo enabled /> : <PtzSnapshotPreview feed={snapshot} label={ptz?.name || 'PTZ Camera'} />} {isOperator ? <PtzLiveVideo enabled /> : <PtzSnapshotPreview feed={snapshot} label={ptz?.name || 'PTZ Camera'} />}
</main> </main>
<aside className="flex h-full min-h-0 items-stretch overflow-hidden border-l border-neutral-600 bg-neutral-950 text-sm"> <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="flex min-h-0 w-full flex-col gap-0.5 overflow-y-auto p-0.5">
<div className="shrink-0"> {isMobile ? mobileSidebar : desktopSidebar}
<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>
</div> </div>
</aside> </aside>
</CardFrame> </CardFrame>
</div> </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 featureEnabled = useSessionSelector((state) => isFeatureEnabled(state, 'ptzCamera'));
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null); const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
const isVerified = useSessionSelector((state) => Boolean(state.session?.isVerified)); const isVerified = useSessionSelector((state) => Boolean(state.session?.isVerified));
@@ -556,7 +609,7 @@ export default function VipPtzCameraCard({ onMessage, fullWidth = false }) {
</div> </div>
</div> </div>
</CardFrame> </CardFrame>
<PtzController open={controllerOpen} onClose={() => setControllerOpen(false)} /> <PtzController open={controllerOpen} onClose={() => setControllerOpen(false)} layout={layout} />
</> </>
); );
} }