This commit is contained in:
legop3
2026-07-12 01:58:46 -04:00
parent 76318e6b36
commit 2cef95e6e3
11 changed files with 63 additions and 32 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-CSIsAzyE.js"></script> <script type="module" crossorigin src="/assets/index-Bt8dATWQ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-17XZRVBQ.css"> <link rel="stylesheet" crossorigin href="/assets/index-CNy3e6VY.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
@@ -42,6 +42,12 @@ function resolveRoverName(roverId) {
function isPrivateClosedRoverId(roverId) { function isPrivateClosedRoverId(roverId) {
if (!roverId) return false; if (!roverId) return false;
/*
PTZ uses the existing rover badge fields so chat rows can reuse RoverLabel,
but it is not a private rover. Let PTZ-badged messages broadcast normally
instead of falling into the closed-private rover path for unknown ids.
*/
if (String(roverId) === ptzCameraService.PTZ_CAMERA_ID) return false;
return roverManager.canReplayRoverId(roverId) !== true; return roverManager.canReplayRoverId(roverId) !== true;
} }
@@ -664,7 +664,6 @@ function revokeOperator(reason = 'release') {
function activateOperator(socket) { function activateOperator(socket) {
revokeOperator('handoff'); revokeOperator('handoff');
removeFromQueue(socket.id); removeFromQueue(socket.id);
releaseRoverOwnershipForPtz(socket);
state.operatorSocketId = socket.id; state.operatorSocketId = socket.id;
state.deadline = Date.now() + getTurnDurationMs(); state.deadline = Date.now() + getTurnDurationMs();
turnTimer = setTimeout(handleTurnDeadline, getTurnDurationMs()); turnTimer = setTimeout(handleTurnDeadline, getTurnDurationMs());
@@ -731,6 +730,15 @@ async function claim(socket) {
socket.emit('ptzCamera:dockRequired', payload); socket.emit('ptzCamera:dockRequired', payload);
throw new Error(leave.message); throw new Error(leave.message);
} }
/*
Joining PTZ is the point where rover ownership must end, even when another
user is currently operating the camera and this socket only enters the
waiting queue. PTZ queue membership is still a camera session: the user is
waiting for a camera turn, not continuing as a rover driver until their turn
arrives. Releasing here also keeps chat badges, assignment UI, and command
routing from presenting a "rover plus camera queue" hybrid state.
*/
releaseRoverOwnershipForPtz(socket);
if (state.operatorSocketId) { if (state.operatorSocketId) {
if (!state.queue.includes(socket.id)) state.queue.push(socket.id); if (!state.queue.includes(socket.id)) state.queue.push(socket.id);
emitChange('queue-join'); emitChange('queue-join');
+4 -1
View File
@@ -214,6 +214,7 @@ function TtsControls({
function ChatComposer({ function ChatComposer({
allowSpectatorInput = false, allowSpectatorInput = false,
hideSpectatorNotice = false, hideSpectatorNotice = false,
inputTarget = 'panel',
}) { }) {
const { const {
sendMessage, sendMessage,
@@ -310,7 +311,7 @@ function ChatComposer({
setTypingActive(false); setTypingActive(false);
} }
}} }}
ref={(el) => registerInputRef(el, { target: 'panel' })} ref={(el) => registerInputRef(el, { target: inputTarget })}
placeholder={canChat ? 'Type a message…' : hideSpectatorNotice ? '' : 'Spectators cannot chat'} placeholder={canChat ? 'Type a message…' : hideSpectatorNotice ? '' : 'Spectators cannot chat'}
disabled={!canChat} disabled={!canChat}
/> />
@@ -346,6 +347,7 @@ export default function ChatPanel({
allowSpectatorInput = false, allowSpectatorInput = false,
title = 'Chat and speech', title = 'Chat and speech',
minimal = false, minimal = false,
inputTarget = 'panel',
}) { }) {
const effectiveHideInput = minimal || hideInput; const effectiveHideInput = minimal || hideInput;
const effectiveTitle = minimal ? '' : title; const effectiveTitle = minimal ? '' : title;
@@ -362,6 +364,7 @@ export default function ChatPanel({
<MemoizedChatComposer <MemoizedChatComposer
allowSpectatorInput={allowSpectatorInput} allowSpectatorInput={allowSpectatorInput}
hideSpectatorNotice={hideSpectatorNotice} hideSpectatorNotice={hideSpectatorNotice}
inputTarget={inputTarget}
/> />
)} )}
</CardFrame> </CardFrame>
+15 -13
View File
@@ -347,13 +347,15 @@ function PtzMediaPane({ ptz, open }) {
const turnModel = useMemo(() => buildPtzTurnModel(ptz, selfId), [ptz, selfId]); const turnModel = useMemo(() => buildPtzTurnModel(ptz, selfId), [ptz, selfId]);
return ( return (
<div className="relative h-full min-h-0 w-full overflow-hidden bg-black"> <div className="flex h-full min-h-0 w-full items-center justify-center overflow-hidden bg-black">
{isOperator ? ( <div className="relative aspect-video max-h-full w-full max-w-full overflow-hidden bg-black">
<PtzLiveVideo enabled={open} startMuted={false} label={ptz?.name || 'PTZ Camera'} /> {isOperator ? (
) : ( <PtzLiveVideo enabled={open} startMuted={false} label={ptz?.name || 'PTZ Camera'} />
<PtzSnapshotPreview feed={snapshot} label={ptz?.name || 'PTZ Camera'} /> ) : (
)} <PtzSnapshotPreview feed={snapshot} label={ptz?.name || 'PTZ Camera'} />
<TurnsOverlay turnModel={turnModel} /> )}
<TurnsOverlay turnModel={turnModel} />
</div>
</div> </div>
); );
} }
@@ -366,6 +368,7 @@ function PtzDesktopFullscreen({ ptz, releasePending }) {
<PtzMediaPane ptz={ptz} open /> <PtzMediaPane ptz={ptz} open />
</main> </main>
<aside className="flex min-h-0 flex-col gap-0.5 overflow-y-auto bg-neutral-950 text-sm"> <aside className="flex min-h-0 flex-col gap-0.5 overflow-y-auto bg-neutral-950 text-sm">
<PtzQueueSummary ptz={ptz} />
{ptz?.isOperator ? ( {ptz?.isOperator ? (
<PtzLightingControls ptz={ptz} /> <PtzLightingControls ptz={ptz} />
) : ( ) : (
@@ -376,14 +379,13 @@ function PtzDesktopFullscreen({ ptz, releasePending }) {
<PtzControlReference /> <PtzControlReference />
<PtzStatePanel ptz={ptz} /> <PtzStatePanel ptz={ptz} />
<ReplaySourcesPanel panelId="ptz-controller-replay" /> <ReplaySourcesPanel panelId="ptz-controller-replay" />
<CardFrame title="Position presets" bodyClassName="p-1 text-xs text-slate-500">
Presets will live here.
</CardFrame>
</aside> </aside>
</div> </div>
<div className="grid min-h-0 grid-cols-[minmax(0,1.6fr)_minmax(16rem,0.7fr)] gap-0.5 overflow-hidden"> <div className="grid min-h-0 grid-cols-[minmax(0,1.6fr)_minmax(16rem,0.7fr)] gap-0.5 overflow-hidden">
<ChatPanel fillHeight title="Chat" /> <ChatPanel fillHeight title="Chat" allowSpectatorInput inputTarget="overlay" />
<PtzQueueSummary ptz={ptz} /> <CardFrame title="Position presets" fillHeight bodyClassName="p-1 text-xs text-slate-500">
Presets will live here.
</CardFrame>
</div> </div>
{releasePending ? ( {releasePending ? (
<div className="pointer-events-none absolute bottom-1 right-1 rounded bg-black/80 px-2 py-1 text-xs text-slate-200"> <div className="pointer-events-none absolute bottom-1 right-1 rounded bg-black/80 px-2 py-1 text-xs text-slate-200">
@@ -407,7 +409,7 @@ function PtzMobileFullscreen({ ptz, layout }) {
<PtzMobileControlsPanel ptz={ptz} disabled={!ptz?.isOperator} /> <PtzMobileControlsPanel ptz={ptz} disabled={!ptz?.isOperator} />
</section> </section>
<section className="grid gap-0.5 md:grid-cols-[minmax(0,1fr)_minmax(0,0.7fr)]"> <section className="grid gap-0.5 md:grid-cols-[minmax(0,1fr)_minmax(0,0.7fr)]">
<ChatPanel title="Chat" /> <ChatPanel title="Chat" allowSpectatorInput inputTarget="overlay" />
<div className="space-y-0.5"> <div className="space-y-0.5">
<PtzQueueSummary ptz={ptz} /> <PtzQueueSummary ptz={ptz} />
<PtzStatePanel ptz={ptz} compact /> <PtzStatePanel ptz={ptz} compact />
+14 -2
View File
@@ -61,6 +61,7 @@ export function ChatProvider({ children }) {
const [isChatFocused, setIsChatFocused] = useState(false); const [isChatFocused, setIsChatFocused] = useState(false);
const panelInputRef = useRef(null); const panelInputRef = useRef(null);
const hudInputRef = useRef(null); const hudInputRef = useRef(null);
const overlayInputRef = useRef(null);
const audioRef = useRef(null); const audioRef = useRef(null);
const typingRef = useRef(new Map()); const typingRef = useRef(new Map());
const typingAlertRef = useRef(new Map()); const typingAlertRef = useRef(new Map());
@@ -236,7 +237,17 @@ export function ChatProvider({ children }) {
); );
const registerInputRef = useCallback((el, options = {}) => { const registerInputRef = useCallback((el, options = {}) => {
const target = options?.target === 'hud' ? 'hud' : 'panel'; const target = options?.target === 'overlay' ? 'overlay' : options?.target === 'hud' ? 'hud' : 'panel';
/*
Fullscreen surfaces such as PTZ need chat-key focus while they are mounted
without stealing the normal HUD ref permanently. Keep overlay chat as a
separate highest-priority slot so unmounting the portal only clears the
overlay target and leaves the driver HUD/panel refs intact.
*/
if (target === 'overlay') {
overlayInputRef.current = el;
return;
}
if (target === 'hud') { if (target === 'hud') {
hudInputRef.current = el; hudInputRef.current = el;
} else { } else {
@@ -246,11 +257,12 @@ export function ChatProvider({ children }) {
const focusChat = useCallback(() => { const focusChat = useCallback(() => {
setIsChatFocused(true); setIsChatFocused(true);
(hudInputRef.current || panelInputRef.current)?.focus(); (overlayInputRef.current || hudInputRef.current || panelInputRef.current)?.focus();
}, []); }, []);
const blurChat = useCallback(() => { const blurChat = useCallback(() => {
setIsChatFocused(false); setIsChatFocused(false);
overlayInputRef.current?.blur();
hudInputRef.current?.blur(); hudInputRef.current?.blur();
panelInputRef.current?.blur(); panelInputRef.current?.blur();
}, []); }, []);