This commit is contained in:
legop3
2026-08-19 21:35:02 -04:00
parent fab0673d9e
commit 5ab62c3633
14 changed files with 146 additions and 112 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
+1 -1
View File
@@ -12,7 +12,7 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<!-- site-metadata:inject -->
<!-- analytics:inject -->
<script type="module" crossorigin src="/assets/index-BUo1IlRS.js"></script>
<script type="module" crossorigin src="/assets/index-C0lpCbci.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BITzjVQo.css">
</head>
<body>
@@ -0,0 +1,48 @@
// Empty Driver Video Notice
// Purpose: Explains either an ordinary unassigned state or the recent reason a rover assignment was removed.
// Scope: Owns only the centered notice content; each video layout remains responsible for its own stage dimensions and framing.
import { useSessionSelector } from '../../context/SessionContext.jsx';
import { useSharedClock } from '../../hooks/useSharedClock.js';
const REMOVAL_NOTICE_VISIBLE_MS = 2 * 60 * 1000;
export default function EmptyDriverVideoNotice() {
const removalNotice = useSessionSelector((state) => state.roverRemovalNotice || null);
const now = useSharedClock(1000, Boolean(removalNotice?.receivedAt));
const noticeAgeMs = removalNotice?.receivedAt ? now - removalNotice.receivedAt : Infinity;
/*
A forced removal needs to explain itself where the video was, because the
following session sync can only report that no rover is assigned. Keeping
the message for a bounded period preserves that context without allowing an
old moderation or safety notice to describe an unrelated later wait.
*/
const showRemovalNotice = Boolean(
removalNotice?.message && noticeAgeMs <= REMOVAL_NOTICE_VISIBLE_MS,
);
const title = showRemovalNotice
? removalNotice.title || 'Removed from rover'
: 'No rover assigned';
const message = showRemovalNotice
? removalNotice.message
: 'You are not currently assigned to a rover.';
return (
<div className="flex h-full w-full items-center justify-center p-4 text-center">
<div
className={`mx-auto flex max-w-md flex-col gap-1 rounded border px-4 py-3 ${
showRemovalNotice
? 'border-amber-300/60 bg-amber-950/35 text-amber-50'
: 'border-slate-700/70 bg-slate-950/35 text-slate-300'
}`}
>
<div className={showRemovalNotice ? 'text-sm font-semibold text-amber-100' : 'text-sm font-semibold text-slate-200'}>
{title}
</div>
<div className={showRemovalNotice ? 'text-sm text-amber-50/90' : 'text-sm text-slate-400'}>
{message}
</div>
</div>
</div>
);
}
+10 -41
View File
@@ -11,46 +11,7 @@ import LowBatteryOverlay from '../HudOverlays/LowBatteryOverlay/index.jsx';
import DriverBottomStrip from '../HudOverlays/DriverBottomStrip/index.jsx';
import HudChatInput from '../HudOverlays/HudChatInput/index.jsx';
import CardFrame from '../CardFrame/index.jsx';
import { useSharedClock } from '../../hooks/useSharedClock.js';
const REMOVAL_NOTICE_VISIBLE_MS = 2 * 60 * 1000;
function EmptyDriverVideoNotice() {
const removalNotice = useSessionSelector((state) => state.roverRemovalNotice || null);
const now = useSharedClock(1000, Boolean(removalNotice?.receivedAt));
const noticeAgeMs = removalNotice?.receivedAt ? now - removalNotice.receivedAt : Infinity;
/*
Removal explanations should feel immediate and contextual. After a short
window, falling back to the neutral no-rover state avoids showing an old
moderation/safety message during unrelated later waiting periods.
*/
const showRemovalNotice = Boolean(removalNotice?.message && noticeAgeMs <= REMOVAL_NOTICE_VISIBLE_MS);
const title = showRemovalNotice ? removalNotice.title || 'Removed from rover' : 'No rover assigned';
const message = showRemovalNotice
? removalNotice.message
: 'You are not currently assigned to a rover.';
return (
<CardFrame hideHeader className="shrink-0">
<div className="panel-muted flex aspect-[4/3] items-center justify-center p-4 text-center">
<div
className={`mx-auto flex max-w-md flex-col gap-1 rounded border px-4 py-3 ${
showRemovalNotice
? 'border-amber-300/60 bg-amber-950/35 text-amber-50'
: 'border-slate-700/70 bg-slate-950/35 text-slate-300'
}`}
>
<div className={showRemovalNotice ? 'text-sm font-semibold text-amber-100' : 'text-sm font-semibold text-slate-200'}>
{title}
</div>
<div className={showRemovalNotice ? 'text-sm text-amber-50/90' : 'text-sm text-slate-400'}>
{message}
</div>
</div>
</div>
</CardFrame>
);
}
import EmptyDriverVideoNotice from './EmptyDriverVideoNotice.jsx';
export default function DriverVideo({ layoutFormat = 'desktop' }) {
const roverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
@@ -58,7 +19,15 @@ export default function DriverVideo({ layoutFormat = 'desktop' }) {
const lastControlIntentAt = useControlSelector((control) => control.state.lastControlIntentAt);
if (!roverId) {
return <EmptyDriverVideoNotice />;
return (
<CardFrame hideHeader className="shrink-0">
{/* The legacy page keeps its existing muted 4:3 frame while sharing
the removal-message behavior with the current video surface. */}
<div className="panel-muted aspect-[4/3]">
<EmptyDriverVideoNotice />
</div>
</CardFrame>
);
}
const mobileHud = layoutFormat !== 'desktop';
@@ -4,6 +4,7 @@ import { useRef } from 'react';
import { FaBullhorn, FaCrosshairs, FaLightbulb } from 'react-icons/fa';
import { useControlActions, useControlSelector } from '../../../../controls/index.js';
import { formatKeyLabel } from '../../../../controls/keymapUtils.js';
import { useSessionSelector } from '../../../../context/SessionContext.jsx';
import useCanControlRover from '../../../../hooks/useCanControlRover.js';
import KeyPill from '../../../vip/VipAudioUploadCard/KeyPill.jsx';
import HornSettingsExpansion from './HornSettingsExpansion.jsx';
@@ -35,6 +36,9 @@ function RoundControl({ label, icon, keyLabel, active, tone, disabled = false, o
export default function BottomLeftPod({ roverId }) {
const [open, setOpen] = usePodVisibility('peripherals', true);
const [hornSettingsOpen, setHornSettingsOpen] = usePodVisibility('hornSettings', false);
const roomLightsLockedOn = useSessionSelector(
(state) => Boolean(state.session?.homeAssistant?.lightPolicy?.lockedOn),
);
const headlight = useControlSelector((control) => control.pipeline?.headlight);
const laser = useControlSelector((control) => control.pipeline?.laser);
const hornDevice = useControlSelector((control) => control.pipeline?.horn);
@@ -78,7 +82,11 @@ export default function BottomLeftPod({ roverId }) {
remain interactive because they do not mutate rover hardware. */}
{hornDevice ? <RoundControl label="Horn" icon={FaBullhorn} keyLabel={formatKeyLabel(keymap?.hornHonk?.[0])} active={hornActive} tone="horn" disabled={!canControl} large onPointerDown={startHornPointer} onPointerUp={stopHornPointer} className="absolute bottom-1 left-1" /> : null}
{headlight ? <RoundControl label="Headlight" icon={FaLightbulb} keyLabel={formatKeyLabel(keymap?.headlightToggle?.[0])} active={headlightOn} disabled={!canControl} onClick={() => setHeadlight(!headlightOn)} className="absolute left-[1.979rem] top-[0.662rem]" /> : null}
{laser ? <RoundControl label="Laser" icon={FaCrosshairs} keyLabel={formatKeyLabel(keymap?.laserToggle?.[0])} active={laserOn} disabled={!canControl} onClick={() => setLaser(!laserOn)} className="absolute left-[5.338rem] top-[4.021rem]" /> : null}
{/* The room-light lock deliberately blocks laser activation because
the laser is only intended for use while the room is dark. This
mirrors the old desktop control's visible disabled state; turn
ownership remains the other independent control restriction. */}
{laser ? <RoundControl label="Laser" icon={FaCrosshairs} keyLabel={formatKeyLabel(keymap?.laserToggle?.[0])} active={laserOn} disabled={!canControl || roomLightsLockedOn} onClick={() => setLaser(!laserOn)} className="absolute left-[5.338rem] top-[4.021rem]" /> : null}
<CornerPodToggle corner="bottom-left" expanded label="Hide rover controls" onClick={() => setOpen(false)} />
</div>
) : (
+9 -2
View File
@@ -12,6 +12,7 @@ import { useSessionSelector } from '../../context/SessionContext.jsx';
import { useControlSelector } from '../../controls/index.js';
import { useDriverVideoModePolicy } from '../../hooks/useDriverVideoModePolicy.js';
import { useDriverLayout } from '../../layouts/driver/DriverLayoutContext.jsx';
import EmptyDriverVideoNotice from '../DriverVideo/EmptyDriverVideoNotice.jsx';
// Mobile keeps the same HUD composition and coordinate system as desktop, but its
// physically smaller video stage needs the complete interface reduced as one unit.
@@ -28,8 +29,14 @@ export default function NewDriveVideo() {
if (!roverId) {
return (
<div className={`flex min-h-0 w-full items-center justify-center overflow-hidden text-[0.8rem] text-neutral-300 ${mobileHud ? 'aspect-[4/3] shrink-0' : 'h-screen'}`}>
No rover assigned
<div
className={`min-h-0 w-full overflow-hidden bg-black ${mobileHud ? 'aspect-[4/3] shrink-0' : 'h-screen'}`}
aria-label="No rover assigned"
>
{/* Keep the unassigned state inside the same solid video-shaped stage
so removing a rover does not expose the page background or leave a
visually empty hole where the live picture had been. */}
<EmptyDriverVideoNotice />
</div>
);
}
@@ -16,6 +16,7 @@ import { LinkButtonsPanel } from '../../../components/UserListPanel/index.jsx';
import Tabs, { Tab, TabList, TabPanel, TabPanels } from '../../../components/Tabs/index.jsx';
import ActivitiesTab from '../tabs/shared/ActivitiesTab/index.jsx';
import VipTab from '../tabs/shared/VipTab/index.jsx';
import VipTabButton from '../tabs/shared/VipTabButton/index.jsx';
import HelpTab from '../tabs/shared/HelpTab/index.jsx';
import SettingsTab from '../tabs/shared/SettingsTab/index.jsx';
import { themeGapClass } from '../../../themes/index.js';
@@ -26,7 +27,7 @@ function LeftColumnTabs() {
<TabList className="sticky top-0 z-30 shrink-0 bg-neutral-950">
<Tab id="room">Room</Tab>
<Tab id="activities">Activities</Tab>
<Tab id="vip">Vip</Tab>
<VipTabButton />
</TabList>
<TabPanels>
<TabPanel id="room">
@@ -19,7 +19,6 @@ import GPIOToggleControl from '../../../components/GPIOToggleControl/index.jsx';
import HornControl from '../../../components/HornControl/index.jsx';
import CameraTiltControl from '../../../components/CameraTiltControl/index.jsx';
import { useSessionSelector } from '../../../context/SessionContext.jsx';
import { useSettingsNamespace } from '../../../settings/index.js';
import OverseerPreferencePanel from '../../../components/OverseerPreferencePanel/index.jsx';
import CardFrame from '../../../components/CardFrame/index.jsx';
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
@@ -27,6 +26,7 @@ import { useManualDockAssist } from '../../../features/manualDockAssist/useManua
import { themeGapClass, themeStackClass } from '../../../themes/index.js';
import ActivitiesTab from '../tabs/shared/ActivitiesTab/index.jsx';
import VipTab from '../tabs/shared/VipTab/index.jsx';
import VipTabButton from '../tabs/shared/VipTabButton/index.jsx';
import HelpTab from '../tabs/shared/HelpTab/index.jsx';
import SettingsTab from '../tabs/shared/SettingsTab/index.jsx';
@@ -214,29 +214,6 @@ export default function RightPaneTabs() {
const [activeTab, setActiveTab] = useState('telemetry');
const chatDockRef = useRef(null);
const [chatDockHeight, setChatDockHeight] = useState(CHAT_DOCK_INITIAL_HEIGHT);
const isVerified = useSessionSelector((state) => Boolean(state.session?.isVerified));
const ownRoverId = useSessionSelector((state) => String(state.session?.assignment?.roverId || '').trim());
const ownAudioForward = useSessionSelector((state) => {
const roverId = String(state.session?.assignment?.roverId || '').trim();
return roverId ? state.session?.audioForward?.[roverId] || null : null;
});
const pttActive = useControlSelector((control) => Boolean(control.state.mic?.pttActive));
const { value: vipAudio } = useSettingsNamespace('vipAudio', { openMicEnabled: false, pttMode: 'live' });
const vipDotClass = isVerified ? 'bg-emerald-400' : 'bg-red-600';
const openMicEnabled = Boolean(vipAudio?.openMicEnabled);
const pttMode = vipAudio?.pttMode === 'clip' ? 'clip' : 'live';
const vipMicActive = Boolean(
ownRoverId &&
isVerified &&
(pttMode === 'clip' ? pttActive : (openMicEnabled || pttActive)),
);
const vipClipPlaying = Boolean(
ownRoverId &&
isVerified &&
pttMode === 'clip' &&
ownAudioForward?.source === 'upload' &&
ownAudioForward?.state === 'playing',
);
const showOverseerPreferencePanel = useSessionSelector((state) => {
const vote = state.session?.overseerVote;
@@ -345,16 +322,7 @@ export default function RightPaneTabs() {
<TabList>
<Tab id="telemetry">Controls</Tab>
<Tab id="activities">Activities</Tab>
<Tab id="vip" highlight={vipClipPlaying ? 'green' : vipMicActive ? 'pink' : 'none'}>
<span className="inline-flex items-center gap-2">
<span>VIP</span>
<span
className={`inline-block h-3 w-3 rounded-full ${vipDotClass}`}
aria-hidden="true"
title={isVerified ? 'Verified' : 'Not verified'}
/>
</span>
</Tab>
<VipTabButton />
<Tab id="help">Help</Tab>
<Tab id="settings">Settings</Tab>
</TabList>
+2 -17
View File
@@ -2,26 +2,16 @@
// Purpose: Owns the shared mobile tab selector, state, and per-tab modules.
import { useCallback, useState } from 'react';
import Tabs, { Tab, TabList, TabPanels } from '../../../components/Tabs/index.jsx';
import { useSessionSelector } from '../../../context/SessionContext.jsx';
import { useControlSelector } from '../../../controls/index.js';
import { useSettingsNamespace } from '../../../settings/index.js';
import MobileChatTab from '../tabs/mobile/ChatTab/index.jsx';
import RoomControlsTab from '../tabs/mobile/RoomControlsTab/index.jsx';
import ActivitiesTab from '../tabs/shared/ActivitiesTab/index.jsx';
import VipTab from '../tabs/shared/VipTab/index.jsx';
import VipTabButton from '../tabs/shared/VipTabButton/index.jsx';
import HelpTab from '../tabs/shared/HelpTab/index.jsx';
import SettingsTab from '../tabs/shared/SettingsTab/index.jsx';
export default function MobileTabs() {
const [activeTab, setActiveTab] = useState('chat');
const isVerified = useSessionSelector((state) => Boolean(state.session?.isVerified));
const ownRoverId = useSessionSelector((state) => String(state.session?.assignment?.roverId || '').trim());
const ownAudioForward = useSessionSelector((state) => ownRoverId ? state.session?.audioForward?.[ownRoverId] || null : null);
const pttActive = useControlSelector((control) => Boolean(control.state.mic?.pttActive));
const { value: vipAudio } = useSettingsNamespace('vipAudio', { openMicEnabled: false, pttMode: 'live' });
const pttMode = vipAudio?.pttMode === 'clip' ? 'clip' : 'live';
const vipMicActive = Boolean(ownRoverId && isVerified && (pttMode === 'clip' ? pttActive : (vipAudio?.openMicEnabled || pttActive)));
const vipClipPlaying = Boolean(ownRoverId && isVerified && pttMode === 'clip' && ownAudioForward?.source === 'upload' && ownAudioForward?.state === 'playing');
const handleTabChange = useCallback((tab) => setActiveTab(tab), []);
return (
@@ -30,12 +20,7 @@ export default function MobileTabs() {
<TabList>
<Tab id="chat">Chat</Tab>
<Tab id="activities">Activities</Tab>
<Tab id="vip" highlight={vipClipPlaying ? 'green' : vipMicActive ? 'pink' : 'none'}>
<span className="inline-flex items-center gap-0.5">
<span>VIP</span>
<span className={`inline-block h-1.5 w-1.5 rounded-full ${isVerified ? 'bg-emerald-400' : 'bg-amber-400'}`} aria-hidden="true" title={isVerified ? 'Verified' : 'Not verified'} />
</span>
</Tab>
<VipTabButton compact />
<Tab id="roomcontrols">Room Controls</Tab>
<Tab id="help">Help</Tab>
<Tab id="settings">Settings</Tab>
@@ -0,0 +1,48 @@
// Driver VIP Tab Button
// Purpose: Keeps verification and active-audio status identical across every driver layout.
import { Tab } from '../../../../../components/Tabs/index.jsx';
import { useControlSelector } from '../../../../../controls/index.js';
import { useSessionSelector } from '../../../../../context/SessionContext.jsx';
import { useSettingsNamespace } from '../../../../../settings/index.js';
export default function VipTabButton({ compact = false }) {
const isVerified = useSessionSelector((state) => Boolean(state.session?.isVerified));
const ownRoverId = useSessionSelector((state) => String(state.session?.assignment?.roverId || '').trim());
const ownAudioForward = useSessionSelector((state) => (
ownRoverId ? state.session?.audioForward?.[ownRoverId] || null : null
));
const pttActive = useControlSelector((control) => Boolean(control.state.mic?.pttActive));
const { value: vipAudio } = useSettingsNamespace('vipAudio', { openMicEnabled: false, pttMode: 'live' });
const clipMode = vipAudio?.pttMode === 'clip';
// Pink represents a live microphone path, while green means an uploaded clip
// is actively forwarding. Keeping these conditions here prevents desktop and
// mobile selectors from drifting away from the mounted VIP audio lifecycle.
const micActive = Boolean(
ownRoverId
&& isVerified
&& (clipMode ? pttActive : (vipAudio?.openMicEnabled || pttActive)),
);
const clipPlaying = Boolean(
ownRoverId
&& isVerified
&& clipMode
&& ownAudioForward?.source === 'upload'
&& ownAudioForward?.state === 'playing',
);
const dotSizeClass = compact ? 'h-1.5 w-1.5' : 'h-3 w-3';
const gapClass = compact ? 'gap-0.5' : 'gap-2';
return (
<Tab id="vip" highlight={clipPlaying ? 'green' : micActive ? 'pink' : 'none'}>
<span className={`inline-flex items-center ${gapClass}`}>
<span>VIP</span>
<span
className={`inline-block rounded-full ${dotSizeClass} ${isVerified ? 'bg-emerald-400' : 'bg-red-600'}`}
aria-hidden="true"
title={isVerified ? 'Verified' : 'Not verified'}
/>
</span>
</Tab>
);
}