This commit is contained in:
legop3
2026-03-20 23:56:47 -04:00
parent 9b1251c16d
commit d6da071fde
12 changed files with 277 additions and 190 deletions
+8 -2
View File
@@ -85,12 +85,18 @@ function MobileFeatureTabs({
}) {
const { session } = useSession();
const vipDotClass = session?.isVerified ? 'bg-emerald-400' : 'bg-amber-400';
const ownRoverId = String(session?.assignment?.roverId || '').trim();
const ownAudioForward = ownRoverId ? session?.audioForward?.[ownRoverId] : null;
const vipMicActive = Boolean(
ownAudioForward?.source === 'mic-whip' &&
(ownAudioForward?.state === 'starting' || ownAudioForward?.state === 'playing'),
);
return (
<section className="panel text-base">
<Tabs defaultTab="chat">
<TabList>
<Tab id="chat">Chat</Tab>
<Tab id="vip">
<Tab id="vip" highlight={vipMicActive ? 'pink' : 'none'}>
<span className="inline-flex items-center gap-0.5">
<span>VIP</span>
<span
@@ -111,7 +117,7 @@ function MobileFeatureTabs({
<RawUserPilePanel />
</div>
</TabPanel>
<TabPanel id="vip">
<TabPanel id="vip" keepMounted>
<VipPanel />
</TabPanel>
<TabPanel id="roomcontrols">
+8 -2
View File
@@ -112,12 +112,18 @@ function DriveDockPanel() {
export default function RightPaneTabs({ layout, onOpenHelpOverlay }) {
const { session } = useSession();
const vipDotClass = session?.isVerified ? 'bg-emerald-400' : 'bg-red-600';
const ownRoverId = String(session?.assignment?.roverId || '').trim();
const ownAudioForward = ownRoverId ? session?.audioForward?.[ownRoverId] : null;
const vipMicActive = Boolean(
ownAudioForward?.source === 'mic-whip' &&
(ownAudioForward?.state === 'starting' || ownAudioForward?.state === 'playing'),
);
return (
<section className="panel text-base">
<Tabs defaultTab="telemetry">
<TabList>
<Tab id="telemetry">Controls</Tab>
<Tab id="vip">
<Tab id="vip" highlight={vipMicActive ? 'pink' : 'none'}>
<span className="inline-flex items-center gap-2">
<span>VIP</span>
<span
@@ -153,7 +159,7 @@ export default function RightPaneTabs({ layout, onOpenHelpOverlay }) {
<RoomCameraPanel defaultOrientation="horizontal" panelId="rightpane-telemetry" />
</div>
</TabPanel>
<TabPanel id="vip">
<TabPanel id="vip" keepMounted>
<VipPanel />
</TabPanel>
<TabPanel id="help">
+8 -2
View File
@@ -18,6 +18,11 @@ const TAB_VARIANTS = {
},
};
const TAB_HIGHLIGHTS = {
none: '',
pink: 'bg-pink-500 text-white border-pink-200 hover:bg-pink-400',
};
const DEFAULT_VARIANT = 'primary';
function classNames(...parts) {
@@ -89,17 +94,18 @@ export function TabList({ children, className = '' }) {
return <div className={classNames('flex gap-0.5', className)}>{children}</div>;
}
export function Tab({ id, children, className = '', disabled = false }) {
export function Tab({ id, children, className = '', disabled = false, highlight = 'none' }) {
const { activeTab, setActiveTab, registerTab, variant } = useTabsContext();
useEffect(() => registerTab(id), [id, registerTab]);
const variantStyles = TAB_VARIANTS[variant] ?? TAB_VARIANTS[DEFAULT_VARIANT];
const isActive = activeTab === id;
const highlightClass = TAB_HIGHLIGHTS[highlight] || TAB_HIGHLIGHTS.none;
const buttonClassName = classNames(
variantStyles.base,
isActive ? variantStyles.active : variantStyles.inactive,
highlightClass || (isActive ? variantStyles.active : variantStyles.inactive),
disabled ? 'cursor-not-allowed opacity-50' : '',
className
);
+59 -25
View File
@@ -42,32 +42,66 @@ export default function VipPanel() {
return (
<section className="panel-section space-y-0.5 text-base">
{isVerified ? (
<VipAudioUploadCard
ownRoverId={ownRoverId}
audioForwardByRover={session?.audioForward || {}}
playUploadedAudio={playUploadedAudio}
stopUploadedAudio={stopUploadedAudio}
startMicWhip={startMicWhip}
readyMicWhip={readyMicWhip}
stopMicWhip={stopMicWhip}
/>
) : (
<VipVerificationCard
pendingRequestId={pendingRequestId}
currentStoredKey={currentStoredKey}
nickname={nickname}
requestVerification={requestVerification}
applyIdentityKey={applyIdentityKey}
onMessage={setMessage}
/>
)}
<div className="grid gap-0.5 grid-cols-1 lg:grid-cols-[minmax(0,1.25fr)_minmax(0,0.9fr)]">
<div className="lg:col-span-2">
{isVerified ? (
<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">
<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>
</div>
</section>
) : (
<VipVerificationCard
pendingRequestId={pendingRequestId}
currentStoredKey={currentStoredKey}
nickname={nickname}
requestVerification={requestVerification}
applyIdentityKey={applyIdentityKey}
onMessage={setMessage}
fullWidth
/>
)}
</div>
<VipIdentityCard
currentStoredKey={currentStoredKey}
applyIdentityKey={applyIdentityKey}
onMessage={setMessage}
/>
{isVerified ? (
<VipAudioUploadCard
ownRoverId={ownRoverId}
audioForwardByRover={session?.audioForward || {}}
playUploadedAudio={playUploadedAudio}
stopUploadedAudio={stopUploadedAudio}
startMicWhip={startMicWhip}
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">
Verify your account to unlock audio upload and microphone forwarding.
</div>
</section>
)}
<section className="surface h-full">
<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">
<VipIdentityCard
currentStoredKey={currentStoredKey}
applyIdentityKey={applyIdentityKey}
onMessage={setMessage}
fullWidth
/>
</div>
</div>
{message ? (
<div className={flowWrapClass}>
+56 -26
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { fieldClass, flowWrapClass, innerFlowClass } from './constants.js';
import { fieldClass } from './constants.js';
import { useControlSystem } from '../../controls/index.js';
const MAX_UPLOAD_BYTES = 8 * 1024 * 1024;
@@ -154,6 +154,18 @@ function waitForOutboundAudioFlow(pc, timeoutMs = 6000) {
});
}
function StatusLight({ label, active, detail = '' }) {
return (
<div className="surface-muted flex items-center justify-between px-0.5 py-0.25 text-xs">
<span className="text-slate-300">{label}</span>
<span className="inline-flex items-center gap-0.5">
<span className={`h-2 w-2 rounded-full ${active ? 'bg-emerald-400' : 'bg-slate-600'}`} aria-hidden="true" />
<span className={active ? 'text-emerald-300' : 'text-slate-500'}>{detail || (active ? 'on' : 'off')}</span>
</span>
</div>
);
}
export default function VipAudioUploadCard({
ownRoverId = '',
audioForwardByRover = {},
@@ -182,6 +194,18 @@ export default function VipAudioUploadCard({
[audioForwardByRover, roverId],
);
const pipelineConnected = Boolean(
selectedForwardState && selectedForwardState.state !== 'offline' && !selectedForwardState.error,
);
const uploadPlaying = Boolean(
selectedForwardState?.source === 'upload' && selectedForwardState?.state === 'playing',
);
const micRelayActive = Boolean(
selectedForwardState?.source === 'mic-whip' &&
(selectedForwardState?.state === 'starting' || selectedForwardState?.state === 'playing'),
);
const micTalking = Boolean(audioTrackRef.current && (openMicEnabled || pttActive));
const handleUploadPlay = async () => {
if (!roverId) {
setMessage('Take control of your rover first.');
@@ -403,24 +427,27 @@ export default function VipAudioUploadCard({
);
return (
<section className={`surface ${flowWrapClass}`}>
<div className={innerFlowClass}>
<p className="text-sm text-slate-300">VIP Audio Upload</p>
<label className="grid w-full gap-0.5 text-xs text-slate-300">
<section className="surface h-full">
<div className="grid h-full gap-0.5 grid-rows-[auto_auto_auto_1fr_auto]">
<p className="text-sm text-slate-200 text-center">VIP Audio Forwarding</p>
<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>
<input
className={fieldClass}
className={`${fieldClass} text-center`}
type="file"
accept=".mp3,.wav,.ogg,audio/mpeg,audio/wav,audio/ogg"
disabled={working || !roverId}
onChange={(event) => setSelectedUpload(event.target.files?.[0] || null)}
/>
</label>
{selectedUpload ? (
<div className="surface-muted mx-auto w-full max-w-sm text-xs text-slate-300 text-center">
{selectedUpload.name} ({selectedUpload.size} bytes)
</div>
) : null}
<div className="flex justify-center gap-0.5">
<button type="button" className="button-dark text-sm" disabled={working || !roverId} onClick={handleUploadPlay}>
{working ? 'Working...' : 'Play Upload'}
@@ -430,28 +457,31 @@ export default function VipAudioUploadCard({
</button>
</div>
<div className="surface-muted mx-auto flex w-full max-w-sm flex-col gap-0.5 p-0.5 text-xs text-slate-300 text-center">
<p className="text-slate-200">Microphone Forwarding</p>
<label className="flex items-center justify-center gap-0.5">
<input
type="checkbox"
checked={openMicEnabled}
disabled={!roverId}
onChange={(event) => setOpenMicEnabled(Boolean(event.target.checked))}
/>
<span>Open mic</span>
</label>
<p className="text-slate-400">PTT key: {controlState?.keymap?.micPtt?.[0] || 'm'} (hold)</p>
<p className="text-slate-400">mic: {micState}</p>
<p className="text-slate-500">transport: whip</p>
<div className="grid gap-0.5 lg:grid-cols-2">
<div className="space-y-0.5">
<p className="text-xs text-slate-300 text-center">Audio Path Status</p>
<StatusLight label="Forward pipe" active={pipelineConnected} detail={pipelineConnected ? 'connected' : 'offline'} />
<StatusLight label="Upload playing" active={uploadPlaying} detail={uploadPlaying ? 'active' : 'idle'} />
<StatusLight label="Mic relay" active={micRelayActive} detail={micRelayActive ? 'active' : 'idle'} />
</div>
<div className="space-y-0.5">
<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">
<input
type="checkbox"
checked={openMicEnabled}
disabled={!roverId}
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>
</div>
{selectedForwardState ? (
<div className="surface-muted mx-auto w-full max-w-sm text-xs text-slate-300 text-center">
state: {selectedForwardState.state || 'idle'}
{selectedForwardState.error ? ` | error: ${selectedForwardState.error}` : ''}
</div>
) : null}
{message ? <div className="text-xs text-slate-400 text-center">{message}</div> : null}
</div>
</section>
+4 -2
View File
@@ -1,7 +1,7 @@
import { useState } from 'react';
import { fieldClass, flowWrapClass, innerFlowClass, maskKey } from './constants.js';
export default function VipIdentityCard({ currentStoredKey, applyIdentityKey, onMessage }) {
export default function VipIdentityCard({ currentStoredKey, applyIdentityKey, onMessage, fullWidth = false }) {
const [restoreFlowStep, setRestoreFlowStep] = useState(0);
const [restoreKeyInput, setRestoreKeyInput] = useState('');
const [working, setWorking] = useState(false);
@@ -34,8 +34,10 @@ export default function VipIdentityCard({ currentStoredKey, applyIdentityKey, on
}
};
const wrapClass = fullWidth ? 'w-full' : flowWrapClass;
return (
<section className={`surface ${flowWrapClass}`}>
<section className={`surface ${wrapClass}`}>
<div className={innerFlowClass}>
<p className="text-sm text-slate-300">Identity key</p>
<p className="text-xs text-slate-500">Current: {maskKey(currentStoredKey) || 'not set yet'}</p>
@@ -9,6 +9,7 @@ export default function VipVerificationCard({
requestVerification,
applyIdentityKey,
onMessage,
fullWidth = false,
}) {
const [requestFlowStep, setRequestFlowStep] = useState(0);
const [requestKeyInput, setRequestKeyInput] = useState('');
@@ -53,9 +54,11 @@ export default function VipVerificationCard({
}
};
const wrapClass = fullWidth ? 'w-full' : flowWrapClass;
if (pendingRequestId) {
return (
<section className={`surface text-sm text-slate-300 ${flowWrapClass}`}>
<section className={`surface text-sm text-slate-300 ${wrapClass}`}>
<div className={innerFlowClass}>Verification request pending: {pendingRequestId}</div>
</section>
);
@@ -63,7 +66,7 @@ export default function VipVerificationCard({
if (requestFlowStep === 0) {
return (
<section className={`surface ${flowWrapClass}`}>
<section className={`surface ${wrapClass}`}>
<div className={innerFlowClass}>
<p className="text-sm text-slate-300">Verification</p>
<button type="button" className="button-dark text-sm" onClick={beginRequestFlow} disabled={working}>
@@ -75,7 +78,7 @@ export default function VipVerificationCard({
}
return (
<form className={`surface ${flowWrapClass}`} onSubmit={handleRequestSubmit}>
<form className={`surface ${wrapClass}`} onSubmit={handleRequestSubmit}>
<div className={innerFlowClass}>
<p className="text-sm text-slate-300">Request verification</p>
<p className="text-xs text-slate-500">