This commit is contained in:
legop3
2026-03-21 00:06:22 -04:00
parent d6da071fde
commit 36a6c07f95
8 changed files with 128 additions and 107 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
+2 -2
View File
@@ -11,8 +11,8 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" /> <meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
<title>Multi Roomba Rover</title> <title>Multi Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-BY461TBg.js"></script> <script type="module" crossorigin src="/assets/index-Bg3-HsWj.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DzG-Bp9_.css"> <link rel="stylesheet" crossorigin href="/assets/index-B89HtNlO.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+11 -4
View File
@@ -6,7 +6,12 @@ import MobileControls, {
MobileLeftColumn, MobileLeftColumn,
MobileRightColumn, MobileRightColumn,
} from './components/MobileControls.jsx'; } from './components/MobileControls.jsx';
import { ControlSystemProvider, KeyboardInputManager, GamepadInputManager } from './controls/index.js'; import {
ControlSystemProvider,
KeyboardInputManager,
GamepadInputManager,
useControlSystem,
} from './controls/index.js';
import RoomCameraPanel from './components/RoomCameraPanel.jsx'; import RoomCameraPanel from './components/RoomCameraPanel.jsx';
import LogPanel from './components/LogPanel.jsx'; import LogPanel from './components/LogPanel.jsx';
import DriverVideoPanel from './components/DriverVideoPanel.jsx'; import DriverVideoPanel from './components/DriverVideoPanel.jsx';
@@ -84,12 +89,14 @@ function MobileFeatureTabs({
showTelemetry = true, showTelemetry = true,
}) { }) {
const { session } = useSession(); const { session } = useSession();
const { state: controlState } = useControlSystem();
const { value: vipAudio } = useSettingsNamespace('vipAudio', { openMicEnabled: false });
const vipDotClass = session?.isVerified ? 'bg-emerald-400' : 'bg-amber-400'; const vipDotClass = session?.isVerified ? 'bg-emerald-400' : 'bg-amber-400';
const ownRoverId = String(session?.assignment?.roverId || '').trim(); const ownRoverId = String(session?.assignment?.roverId || '').trim();
const ownAudioForward = ownRoverId ? session?.audioForward?.[ownRoverId] : null; const pttActive = Boolean(controlState?.mic?.pttActive);
const openMicEnabled = Boolean(vipAudio?.openMicEnabled);
const vipMicActive = Boolean( const vipMicActive = Boolean(
ownAudioForward?.source === 'mic-whip' && ownRoverId && session?.isVerified && (openMicEnabled || pttActive),
(ownAudioForward?.state === 'starting' || ownAudioForward?.state === 'playing'),
); );
return ( return (
<section className="panel text-base"> <section className="panel text-base">
+6 -3
View File
@@ -18,6 +18,7 @@ import HornControl from './HornControl.jsx';
import CameraTiltControl from './CameraTiltControl.jsx'; import CameraTiltControl from './CameraTiltControl.jsx';
import VipPanel from './VipPanel.jsx'; import VipPanel from './VipPanel.jsx';
import { useSession } from '../context/SessionContext.jsx'; import { useSession } from '../context/SessionContext.jsx';
import { useSettingsNamespace } from '../settings/index.js';
function TopDownMapPanel() { function TopDownMapPanel() {
const { const {
@@ -111,12 +112,14 @@ function DriveDockPanel() {
export default function RightPaneTabs({ layout, onOpenHelpOverlay }) { export default function RightPaneTabs({ layout, onOpenHelpOverlay }) {
const { session } = useSession(); const { session } = useSession();
const { state: controlState } = useControlSystem();
const { value: vipAudio } = useSettingsNamespace('vipAudio', { openMicEnabled: false });
const vipDotClass = session?.isVerified ? 'bg-emerald-400' : 'bg-red-600'; const vipDotClass = session?.isVerified ? 'bg-emerald-400' : 'bg-red-600';
const ownRoverId = String(session?.assignment?.roverId || '').trim(); const ownRoverId = String(session?.assignment?.roverId || '').trim();
const ownAudioForward = ownRoverId ? session?.audioForward?.[ownRoverId] : null; const pttActive = Boolean(controlState?.mic?.pttActive);
const openMicEnabled = Boolean(vipAudio?.openMicEnabled);
const vipMicActive = Boolean( const vipMicActive = Boolean(
ownAudioForward?.source === 'mic-whip' && ownRoverId && session?.isVerified && (openMicEnabled || pttActive),
(ownAudioForward?.state === 'starting' || ownAudioForward?.state === 'playing'),
); );
return ( return (
<section className="panel text-base"> <section className="panel text-base">
+20 -29
View File
@@ -48,7 +48,7 @@ export default function VipPanel() {
<section className="surface w-full"> <section className="surface w-full">
<div className="mx-auto flex w-full max-w-md flex-col items-center space-y-0.5 text-center"> <div className="mx-auto flex w-full max-w-md flex-col items-center space-y-0.5 text-center">
<p className="text-sm text-emerald-300">Verification complete</p> <p className="text-sm text-emerald-300">Verification complete</p>
<p className="text-xs text-slate-400">VIP audio controls are unlocked for your assigned rover.</p> <p className="text-xs text-slate-400">VIP controls are unlocked for your assigned rover.</p>
</div> </div>
</section> </section>
) : ( ) : (
@@ -64,34 +64,25 @@ export default function VipPanel() {
)} )}
</div> </div>
{isVerified ? ( <div className="lg:col-span-2">
<VipAudioUploadCard {isVerified ? (
ownRoverId={ownRoverId} <VipAudioUploadCard
audioForwardByRover={session?.audioForward || {}} ownRoverId={ownRoverId}
playUploadedAudio={playUploadedAudio} audioForwardByRover={session?.audioForward || {}}
stopUploadedAudio={stopUploadedAudio} playUploadedAudio={playUploadedAudio}
startMicWhip={startMicWhip} stopUploadedAudio={stopUploadedAudio}
readyMicWhip={readyMicWhip} startMicWhip={startMicWhip}
stopMicWhip={stopMicWhip} readyMicWhip={readyMicWhip}
/> stopMicWhip={stopMicWhip}
) : ( />
<section className="surface h-full"> ) : (
<div className="flex h-full flex-col items-center justify-center text-center text-xs text-slate-400"> <section className="surface h-full">
Verify your account to unlock audio upload and microphone forwarding. <div className="flex h-full flex-col items-center justify-center text-center text-xs text-slate-400">
</div> Verify your account to unlock VIP controls.
</section> </div>
)} </section>
)}
<section className="surface h-full"> </div>
<div className="space-y-0.5">
<p className="text-sm text-slate-200">VIP Notes</p>
<ul className="space-y-0.25 text-xs text-slate-400">
<li>Audio forwarding is locked to your currently assigned rover.</li>
<li>Losing control or turn ownership stops active forwarding.</li>
<li>PTT works page-wide using your keybind (default: m).</li>
</ul>
</div>
</section>
<div className="lg:col-span-2"> <div className="lg:col-span-2">
<VipIdentityCard <VipIdentityCard
+77 -57
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { fieldClass } from './constants.js'; import { fieldClass } from './constants.js';
import { useControlSystem } from '../../controls/index.js'; import { useControlSystem } from '../../controls/index.js';
import { useSettingsNamespace } from '../../settings/index.js';
const MAX_UPLOAD_BYTES = 8 * 1024 * 1024; const MAX_UPLOAD_BYTES = 8 * 1024 * 1024;
const TARGET_SAMPLE_RATE = 16000; const TARGET_SAMPLE_RATE = 16000;
@@ -154,14 +155,17 @@ function waitForOutboundAudioFlow(pc, timeoutMs = 6000) {
}); });
} }
function StatusLight({ label, active, detail = '' }) { function StatusIndicator({ label, active, detail = '' }) {
return ( return (
<div className="surface-muted flex items-center justify-between px-0.5 py-0.25 text-xs"> <div
<span className="text-slate-300">{label}</span> className={`rounded-md border px-0.5 py-0.5 text-xs transition-colors ${
<span className="inline-flex items-center gap-0.5"> active
<span className={`h-2 w-2 rounded-full ${active ? 'bg-emerald-400' : 'bg-slate-600'}`} aria-hidden="true" /> ? 'border-emerald-300/80 bg-emerald-500/20 text-emerald-200'
<span className={active ? 'text-emerald-300' : 'text-slate-500'}>{detail || (active ? 'on' : 'off')}</span> : 'border-slate-700 bg-slate-900/50 text-slate-400'
</span> }`}
>
<div className="font-medium">{label}</div>
<div className="text-[0.72rem] opacity-90">{detail || (active ? 'active' : 'idle')}</div>
</div> </div>
); );
} }
@@ -176,10 +180,12 @@ export default function VipAudioUploadCard({
stopMicWhip, stopMicWhip,
}) { }) {
const { state: controlState } = useControlSystem(); const { state: controlState } = useControlSystem();
const { value: vipAudio, save: saveVipAudio } = useSettingsNamespace('vipAudio', { openMicEnabled: false });
const roverId = String(ownRoverId || '').trim(); const roverId = String(ownRoverId || '').trim();
const [selectedUpload, setSelectedUpload] = useState(null); const [selectedUpload, setSelectedUpload] = useState(null);
const [working, setWorking] = useState(false); const [working, setWorking] = useState(false);
const [openMicEnabled, setOpenMicEnabled] = useState(false); const openMicEnabled = Boolean(vipAudio?.openMicEnabled);
const [micState, setMicState] = useState('idle'); const [micState, setMicState] = useState('idle');
const [message, setMessage] = useState(''); const [message, setMessage] = useState('');
const streamRef = useRef(null); const streamRef = useRef(null);
@@ -204,7 +210,8 @@ export default function VipAudioUploadCard({
selectedForwardState?.source === 'mic-whip' && selectedForwardState?.source === 'mic-whip' &&
(selectedForwardState?.state === 'starting' || selectedForwardState?.state === 'playing'), (selectedForwardState?.state === 'starting' || selectedForwardState?.state === 'playing'),
); );
const micTalking = Boolean(audioTrackRef.current && (openMicEnabled || pttActive)); const micHot = Boolean(audioTrackRef.current && (openMicEnabled || pttActive));
const whipLinkActive = micState === 'live' || micState === 'starting';
const handleUploadPlay = async () => { const handleUploadPlay = async () => {
if (!roverId) { if (!roverId) {
@@ -427,63 +434,76 @@ export default function VipAudioUploadCard({
); );
return ( return (
<section className="surface h-full"> <div className="grid gap-0.5 grid-cols-1 lg:grid-cols-2">
<div className="grid h-full gap-0.5 grid-rows-[auto_auto_auto_1fr_auto]"> <section className="surface h-full">
<p className="text-sm text-slate-200 text-center">VIP Audio Forwarding</p> <div className="grid h-full gap-0.5 grid-rows-[auto_auto_1fr_auto]">
<p className="text-sm text-slate-200 text-center">Audio Upload</p>
<label className="mx-auto grid w-full max-w-sm gap-0.5 text-xs text-slate-300 text-center"> <label className="mx-auto grid w-full max-w-sm gap-0.5 text-xs text-slate-300 text-center">
<span>Audio file (mp3 / wav / ogg)</span> <span>Audio file (mp3 / wav / ogg)</span>
<input <input
className={`${fieldClass} text-center`} className={`${fieldClass} text-center`}
type="file" type="file"
accept=".mp3,.wav,.ogg,audio/mpeg,audio/wav,audio/ogg" accept=".mp3,.wav,.ogg,audio/mpeg,audio/wav,audio/ogg"
disabled={working || !roverId} disabled={working || !roverId}
onChange={(event) => setSelectedUpload(event.target.files?.[0] || null)} onChange={(event) => setSelectedUpload(event.target.files?.[0] || null)}
/> />
</label> </label>
{selectedUpload ? ( <div className="mx-auto w-full max-w-sm text-center">
<div className="surface-muted mx-auto w-full max-w-sm text-xs text-slate-300 text-center"> {selectedUpload ? (
{selectedUpload.name} ({selectedUpload.size} bytes) <div className="surface-muted text-xs text-slate-300">
{selectedUpload.name} ({selectedUpload.size} bytes)
</div>
) : (
<div className="surface-muted text-xs text-slate-500">No file selected</div>
)}
</div> </div>
) : null}
<div className="flex justify-center gap-0.5"> <div className="flex justify-center gap-0.5">
<button type="button" className="button-dark text-sm" disabled={working || !roverId} onClick={handleUploadPlay}> <button type="button" className="button-dark text-sm" disabled={working || !roverId} onClick={handleUploadPlay}>
{working ? 'Working...' : 'Play Upload'} {working ? 'Working...' : 'Play Upload'}
</button> </button>
<button type="button" className="button-dark text-sm" disabled={working || !roverId} onClick={handleUploadStop}> <button type="button" className="button-dark text-sm" disabled={working || !roverId} onClick={handleUploadStop}>
Stop Stop
</button> </button>
</div>
</div> </div>
</section>
<div className="grid gap-0.5 lg:grid-cols-2"> <section className="surface h-full">
<div className="space-y-0.5"> <div className="grid h-full gap-0.5 grid-rows-[auto_auto_1fr]">
<p className="text-xs text-slate-300 text-center">Audio Path Status</p> <p className="text-sm text-slate-200 text-center">Live Microphone</p>
<StatusLight label="Forward pipe" active={pipelineConnected} detail={pipelineConnected ? 'connected' : 'offline'} /> <label className="surface-muted mx-auto flex w-full max-w-sm items-center justify-center gap-0.5 px-0.5 py-0.5 text-xs text-slate-300">
<StatusLight label="Upload playing" active={uploadPlaying} detail={uploadPlaying ? 'active' : 'idle'} /> <input
<StatusLight label="Mic relay" active={micRelayActive} detail={micRelayActive ? 'active' : 'idle'} /> type="checkbox"
</div> checked={openMicEnabled}
disabled={!roverId}
<div className="space-y-0.5"> onChange={(event) => saveVipAudio((current) => ({ ...(current || {}), openMicEnabled: Boolean(event.target.checked) }))}
<p className="text-xs text-slate-300 text-center">Microphone</p> />
<label className="surface-muted flex items-center justify-center gap-0.5 px-0.5 py-0.25 text-xs text-slate-300"> <span>Open mic</span>
<input </label>
type="checkbox" <div className="grid gap-0.5 content-start">
checked={openMicEnabled} <StatusIndicator label="WHIP link" active={whipLinkActive} detail={micState} />
disabled={!roverId} <StatusIndicator label="Mic hot" active={micHot} detail={micHot ? 'transmitting' : 'muted'} />
onChange={(event) => setOpenMicEnabled(Boolean(event.target.checked))}
/>
<span>Open mic</span>
</label>
<StatusLight label="WHIP link" active={micState === 'live' || micState === 'starting'} detail={micState} />
<StatusLight label="PTT transmit" active={micTalking} detail={micTalking ? 'talking' : 'muted'} />
<div className="surface-muted text-center text-xs text-slate-400">PTT key: {controlState?.keymap?.micPtt?.[0] || 'm'} (hold)</div> <div className="surface-muted text-center text-xs text-slate-400">PTT key: {controlState?.keymap?.micPtt?.[0] || 'm'} (hold)</div>
</div> </div>
</div> </div>
</section>
{message ? <div className="text-xs text-slate-400 text-center">{message}</div> : null} <section className="surface lg:col-span-2">
</div> <div className="space-y-0.5">
</section> <p className="text-sm text-slate-200 text-center">Audio Status</p>
<div className="grid gap-0.5 grid-cols-1 sm:grid-cols-2 lg:grid-cols-5">
<StatusIndicator label="Forward pipe" active={pipelineConnected} detail={pipelineConnected ? 'connected' : 'offline'} />
<StatusIndicator label="Upload playback" active={uploadPlaying} detail={uploadPlaying ? 'playing' : 'idle'} />
<StatusIndicator label="Mic relay" active={micRelayActive} detail={micRelayActive ? 'active' : 'idle'} />
<StatusIndicator label="WHIP transport" active={whipLinkActive} detail={micState} />
<StatusIndicator label="Mic transmit" active={micHot} detail={micHot ? 'hot' : 'muted'} />
</div>
{message ? <div className="text-xs text-slate-400 text-center">{message}</div> : null}
</div>
</section>
</div>
); );
} }