mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-11-14 10:42:45 -05:00
193 lines
8.5 KiB
React
193 lines
8.5 KiB
React
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
import { useSocket } from '../context/SocketContext.jsx';
|
|
import { useSessionSelector } from '../context/SessionContext.jsx';
|
|
import useUserIdentitySync from '../hooks/useUserIdentitySync.js';
|
|
import { RESTART_DELAY_MS } from '../lib/whepPlayback.js';
|
|
import { ControlSystemProvider, KeyboardInputManager, GamepadInputManager, useControlSelector } from '../controls/index.js';
|
|
import ControlHint from '../components/ControlHint/index.jsx';
|
|
import MediaMTXWebRTCPublisher from '../../.cache/mediamtx/publisher.js';
|
|
import useServerUrl from '../hooks/useServerUrl.js';
|
|
|
|
export default function MicApp() {
|
|
return (
|
|
<ControlSystemProvider>
|
|
<KeyboardInputManager />
|
|
<GamepadInputManager />
|
|
<MicPage />
|
|
</ControlSystemProvider>
|
|
);
|
|
}
|
|
|
|
function MicPage() {
|
|
const socket = useSocket();
|
|
const serverUrl = useServerUrl();
|
|
const connected = useSessionSelector((state) => state.connected);
|
|
const local = useSessionSelector((state) => state.session?.isLocalNetwork);
|
|
const enabled = useSessionSelector((state) => state.session?.features?.roomAudio);
|
|
const [status, setStatus] = useState('Connecting…');
|
|
const [pttMode, setPttMode] = useState(false);
|
|
const available = Boolean(connected && local && enabled);
|
|
const pttActive = useControlSelector((control) => Boolean(control.state.mic?.pttActive));
|
|
const [pointerHeld, setPointerHeld] = useState(false);
|
|
const [buttonKeyHeld, setButtonKeyHeld] = useState(false);
|
|
const held = available && pttMode && (pttActive || pointerHeld || buttonKeyHeld);
|
|
const transmitting = available && (!pttMode || held);
|
|
const mediaRef = useRef(null);
|
|
const transmittingRef = useRef(transmitting);
|
|
useLayoutEffect(() => {
|
|
transmittingRef.current = transmitting;
|
|
mediaRef.current?.getAudioTracks().forEach((track) => { track.enabled = transmitting; });
|
|
}, [transmitting]);
|
|
useEffect(() => {
|
|
// Pointer capture can outlive focus; release the page button when leaving the page.
|
|
function releaseButton() {
|
|
setPointerHeld(false);
|
|
setButtonKeyHeld(false);
|
|
}
|
|
function visibilityChange() {
|
|
if (document.hidden) releaseButton();
|
|
}
|
|
window.addEventListener('blur', releaseButton);
|
|
document.addEventListener('visibilitychange', visibilityChange);
|
|
return () => {
|
|
window.removeEventListener('blur', releaseButton);
|
|
document.removeEventListener('visibilitychange', visibilityChange);
|
|
};
|
|
}, []);
|
|
useUserIdentitySync();
|
|
useEffect(() => {
|
|
if (!connected || !local || !enabled) return undefined;
|
|
let disposed = false;
|
|
let attempt;
|
|
let media;
|
|
let retryTimer;
|
|
const ack = (event, payload = {}) => new Promise((resolve, reject) => {
|
|
socket.timeout(5000).emit(event, payload, (error, result = {}) => {
|
|
if (error || result.error) reject(error || new Error(result.error));
|
|
else resolve(result);
|
|
});
|
|
});
|
|
const isCurrent = (current) => !disposed && Boolean(current) && attempt === current;
|
|
function closeAttempt() {
|
|
const current = attempt;
|
|
attempt = null;
|
|
if (!current) return;
|
|
const publisher = current.publisher;
|
|
publisher?.close();
|
|
// Upstream close stops the peer and retry timer but leaves HTTP session cleanup to us.
|
|
if (publisher?.sessionUrl) fetch(publisher.sessionUrl, { method: 'DELETE', keepalive: true }).catch(() => {});
|
|
}
|
|
|
|
function retry(current, message) {
|
|
if (!isCurrent(current)) return;
|
|
closeAttempt();
|
|
clearTimeout(retryTimer);
|
|
setStatus(message);
|
|
retryTimer = setTimeout(start, RESTART_DELAY_MS);
|
|
}
|
|
async function start() {
|
|
if (disposed) return;
|
|
// Invalidate the previous attempt before closing it. Late async results
|
|
// and peer events must never change the new attempt's state or timers.
|
|
closeAttempt();
|
|
const current = {};
|
|
attempt = current;
|
|
setStatus('Starting microphone…');
|
|
try {
|
|
await ack('session:setRole', { role: 'spectator' });
|
|
if (!isCurrent(current)) return;
|
|
if (!media || media.getAudioTracks().every((track) => track.readyState === 'ended')) {
|
|
media?.getTracks().forEach((track) => { track.onended = null; track.stop(); });
|
|
if (!navigator.mediaDevices?.getUserMedia) throw new Error('Microphone access requires HTTPS or localhost');
|
|
const captured = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
if (!isCurrent(current)) { captured.getTracks().forEach((track) => track.stop()); return; }
|
|
media = captured;
|
|
mediaRef.current = media;
|
|
// New capture and reconnect attempts must respect the current PTT state immediately.
|
|
media.getAudioTracks().forEach((track) => { track.enabled = transmittingRef.current; });
|
|
media.getAudioTracks().forEach((track) => { track.onended = () => retry(attempt, 'Microphone disconnected. Retrying…'); });
|
|
}
|
|
const source = await ack('roomAudio:start');
|
|
if (!isCurrent(current)) return;
|
|
setStatus('Connecting audio…');
|
|
current.publisher = new MediaMTXWebRTCPublisher({
|
|
url: serverUrl(source.url),
|
|
user: source.token,
|
|
pass: source.token,
|
|
stream: media,
|
|
audioCodec: 'opus',
|
|
audioBitrate: 64,
|
|
audioVoice: true,
|
|
onConnected: () => {
|
|
if (isCurrent(current)) setStatus('Microphone live');
|
|
},
|
|
// The publisher owns transport retries; restarting it here would create competing loops.
|
|
onError: (message) => {
|
|
if (isCurrent(current)) setStatus(message);
|
|
},
|
|
});
|
|
} catch (error) { retry(current, `${error.message}. Retrying…`); }
|
|
}
|
|
start();
|
|
return () => {
|
|
disposed = true;
|
|
clearTimeout(retryTimer);
|
|
closeAttempt();
|
|
media?.getTracks().forEach((track) => { track.onended = null; track.stop(); });
|
|
mediaRef.current = null;
|
|
if (socket.connected) socket.emit('roomAudio:stop', {});
|
|
};
|
|
}, [socket, connected, local, enabled, serverUrl]);
|
|
const message = !connected ? 'Connecting…' : local === false ? 'This page is available to local visitors only.' : !enabled ? 'Room audio is disabled.' : status;
|
|
return (
|
|
<main className="min-h-screen bg-neutral-950 p-4 text-slate-200">
|
|
<div className="panel mx-auto max-w-md p-4">
|
|
<h1 className="text-lg font-semibold">Room microphone</h1>
|
|
<p className="mt-2 text-sm" role="status">{message}</p>
|
|
<div className="mt-4 grid grid-cols-2 gap-2" role="group" aria-label="Microphone mode">
|
|
{[false, true].map((ptt) => (
|
|
<button key={String(ptt)} type="button" aria-pressed={pttMode === ptt}
|
|
className={`button-dark w-full ${pttMode === ptt ? 'bg-emerald-500 text-white hover:bg-emerald-500' : ''}`}
|
|
onClick={() => {
|
|
setPointerHeld(false);
|
|
setButtonKeyHeld(false);
|
|
setPttMode(ptt);
|
|
}}>
|
|
{ptt ? 'Push to talk' : 'Open mic'}
|
|
</button>
|
|
))}
|
|
</div>
|
|
{pttMode && (
|
|
<>
|
|
<button type="button" disabled={!available} aria-pressed={held}
|
|
className={`button-dark mt-4 min-h-40 w-full touch-none select-none text-2xl font-semibold ${held ? 'bg-emerald-500 text-white hover:bg-emerald-500' : ''}`}
|
|
onPointerDown={(event) => {
|
|
if (event.button !== 0 || !event.isPrimary) return;
|
|
event.currentTarget.setPointerCapture(event.pointerId);
|
|
setPointerHeld(true);
|
|
}}
|
|
onPointerUp={() => setPointerHeld(false)}
|
|
onPointerCancel={() => setPointerHeld(false)}
|
|
onLostPointerCapture={() => setPointerHeld(false)}
|
|
onContextMenu={(event) => event.preventDefault()}
|
|
onKeyDown={(event) => {
|
|
if (event.key !== ' ' && event.key !== 'Enter') return;
|
|
event.preventDefault();
|
|
setButtonKeyHeld(true);
|
|
}}
|
|
onKeyUp={(event) => {
|
|
if (event.key !== ' ' && event.key !== 'Enter') return;
|
|
event.preventDefault();
|
|
setButtonKeyHeld(false);
|
|
}}
|
|
onBlur={() => setButtonKeyHeld(false)}>
|
|
{held ? 'Talking…' : 'Hold to talk'}
|
|
</button>
|
|
<p className="mt-2 text-center text-sm text-slate-400">Hold the button or <ControlHint actionId="micPtt" /> to talk.</p>
|
|
</>
|
|
)}
|
|
</div>
|
|
</main>
|
|
);
|
|
}
|