This commit is contained in:
legop3
2026-03-20 23:12:09 -04:00
parent 1b7885e74b
commit cb97e73987
7 changed files with 375 additions and 24 deletions
+9 -7
View File
@@ -2,11 +2,12 @@ import { useMemo, useState } from 'react';
import { useSession } from '../context/SessionContext.jsx';
import { useSettingsNamespace } from '../settings/index.js';
import { COOKIE_KEY_REGEX, flowWrapClass } from './vip/constants.js';
import VipAudioUploadCard from './vip/VipAudioUploadCard.jsx';
import VipVerificationCard from './vip/VipVerificationCard.jsx';
import VipIdentityCard from './vip/VipIdentityCard.jsx';
export default function VipPanel() {
const { session, identifySession, requestVerification } = useSession();
const { session, identifySession, requestVerification, playUploadedAudio, stopUploadedAudio } = useSession();
const { value: identity, save: saveIdentity } = useSettingsNamespace('identity', { cookieUserId: '' });
const { value: profile } = useSettingsNamespace('profile', { nickname: '' });
@@ -14,6 +15,7 @@ export default function VipPanel() {
const nickname = (profile?.nickname || '').trim();
const isVerified = Boolean(session?.isVerified);
const pendingRequestId = session?.verification?.pendingRequestId || null;
const ownRoverId = String(session?.assignment?.roverId || '').trim();
const [message, setMessage] = useState('');
const applyIdentityKey = async (nextRaw) => {
@@ -32,12 +34,12 @@ export default function VipPanel() {
return (
<section className="panel-section space-y-0.5 text-base">
{isVerified ? (
<div className={`surface ${flowWrapClass}`}>
<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-slate-300">VIP verified.</p>
<p className="text-xs text-slate-500">Audio forwarding is disabled while this stack is being rebuilt.</p>
</div>
</div>
<VipAudioUploadCard
ownRoverId={ownRoverId}
audioForwardByRover={session?.audioForward || {}}
playUploadedAudio={playUploadedAudio}
stopUploadedAudio={stopUploadedAudio}
/>
) : (
<VipVerificationCard
pendingRequestId={pendingRequestId}
@@ -0,0 +1,120 @@
import { useMemo, useState } from 'react';
import { fieldClass, flowWrapClass, innerFlowClass } from './constants.js';
const MAX_UPLOAD_BYTES = 8 * 1024 * 1024;
function bytesToBase64(bytes) {
let binary = '';
const chunkSize = 0x8000;
for (let i = 0; i < bytes.length; i += chunkSize) {
const chunk = bytes.subarray(i, i + chunkSize);
binary += String.fromCharCode(...chunk);
}
return btoa(binary);
}
export default function VipAudioUploadCard({
ownRoverId = '',
audioForwardByRover = {},
playUploadedAudio,
stopUploadedAudio,
}) {
const roverId = String(ownRoverId || '').trim();
const [selectedUpload, setSelectedUpload] = useState(null);
const [working, setWorking] = useState(false);
const [message, setMessage] = useState('');
const selectedForwardState = useMemo(
() => (roverId ? audioForwardByRover?.[roverId] || null : null),
[audioForwardByRover, roverId],
);
const handleUploadPlay = async () => {
if (!roverId) {
setMessage('Take control of your rover first.');
return;
}
if (!selectedUpload) {
setMessage('Select an audio file first.');
return;
}
if (selectedUpload.size > MAX_UPLOAD_BYTES) {
setMessage(`File too large (max ${MAX_UPLOAD_BYTES} bytes).`);
return;
}
setWorking(true);
setMessage('');
try {
const buffer = await selectedUpload.arrayBuffer();
const dataBase64 = bytesToBase64(new Uint8Array(buffer));
await playUploadedAudio?.({
roverId,
name: selectedUpload.name,
mime: selectedUpload.type || '',
dataBase64,
});
setMessage('Upload playback started.');
} catch (err) {
setMessage(err?.message || 'Failed to play upload.');
} finally {
setWorking(false);
}
};
const handleUploadStop = async () => {
if (!roverId) {
setMessage('Take control of your rover first.');
return;
}
setWorking(true);
setMessage('');
try {
await stopUploadedAudio?.(roverId);
setMessage('Upload playback stopped.');
} catch (err) {
setMessage(err?.message || 'Failed to stop upload.');
} finally {
setWorking(false);
}
};
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">
<span>Audio file (mp3 / wav / ogg)</span>
<input
className={fieldClass}
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'}
</button>
<button type="button" className="button-dark text-sm" disabled={working || !roverId} onClick={handleUploadStop}>
Stop
</button>
</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>
);
}