mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
bebahba
This commit is contained in:
+2
-2
File diff suppressed because one or more lines are too long
+1
-1
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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user