mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-11-14 18:58:49 -05:00
@@ -63,7 +63,7 @@ function useChatComposerSessionState(allowSpectatorInput) {
|
||||
const ptzTtsSupported = Boolean(
|
||||
operatingMode === 'ptz' &&
|
||||
ptz?.audio?.enabled &&
|
||||
(ptz?.isOperator || ptz?.queuedPosition),
|
||||
(ptz?.turn?.isActive || ptz?.turn?.turnsAhead > 0),
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -55,7 +55,7 @@ function HudChatInput({ compact = false, variant = 'legacy', open = true, onOpen
|
||||
const ptzTtsSupported = Boolean(
|
||||
operatingMode === 'ptz' &&
|
||||
ptz?.audio?.enabled &&
|
||||
(ptz?.isOperator || ptz?.queuedPosition),
|
||||
(ptz?.turn?.isActive || ptz?.turn?.turnsAhead > 0),
|
||||
);
|
||||
const ttsSupported = Boolean(rover?.audio?.ttsEnabled || ptzTtsSupported);
|
||||
const ttsPayload = useMemo(() => {
|
||||
|
||||
@@ -6,33 +6,11 @@ import { useControlActions, useControlSelector } from '../../../../controls/inde
|
||||
import ControlHint from '../../../ControlHint/index.jsx';
|
||||
import { useSessionSelector } from '../../../../context/SessionContext.jsx';
|
||||
import useCanControlRover from '../../../../hooks/useCanControlRover.js';
|
||||
import KeyPill from '../../../vip/VipAudioUploadCard/KeyPill.jsx';
|
||||
import RoundControl from './RoundControl.jsx';
|
||||
import PeripheralPod from './PeripheralPod.jsx';
|
||||
import HornSettingsExpansion from './HornSettingsExpansion.jsx';
|
||||
import CornerPodToggle from './CornerPodToggle.jsx';
|
||||
import usePodVisibility from './usePodVisibility.js';
|
||||
|
||||
function RoundControl({ label, icon, keyLabel, active, tone, disabled = false, onClick, onPointerDown, onPointerUp, large = false, className = '' }) {
|
||||
const ControlIcon = icon;
|
||||
const toneClass = tone === 'horn'
|
||||
? active ? 'border-fuchsia-300/70 bg-fuchsia-700 text-fuchsia-50' : 'border-cyan-300/70 bg-cyan-900 text-cyan-50'
|
||||
: active ? 'border-emerald-300/70 bg-emerald-800 text-emerald-50' : 'border-amber-300/70 bg-amber-900 text-amber-50';
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerUp}
|
||||
className={`flex shrink-0 select-none flex-col items-center justify-center gap-1 rounded-full border-2 ${toneClass} ${large ? 'h-20 w-20' : 'h-16 w-16'} disabled:cursor-not-allowed disabled:opacity-40 ${className}`}
|
||||
>
|
||||
<ControlIcon className={large ? 'text-xl' : 'text-base'} aria-hidden="true" />
|
||||
<KeyPill label={keyLabel} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BottomLeftPod({ roverId }) {
|
||||
const [open, setOpen] = usePodVisibility('peripherals', true);
|
||||
const [hornSettingsOpen, setHornSettingsOpen] = usePodVisibility('hornSettings', false);
|
||||
@@ -67,30 +45,25 @@ export default function BottomLeftPod({ roverId }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
{open ? (
|
||||
<div className="pointer-events-auto absolute bottom-0 left-0 z-20 h-40 w-40 rounded-tr-[4.25rem] bg-black/60">
|
||||
{/*
|
||||
The horn's center is the origin of an invisible 76px-radius arc. Headlight and laser
|
||||
sit at -75 and -15 degrees on either side of that arc's diagonal midpoint. Their
|
||||
sixty-degree separation leaves a visible gap between the small circles while keeping
|
||||
both controls equally distant from the horn. These explicit positions are the
|
||||
rendered result of that geometry, not unrelated visual nudges.
|
||||
*/}
|
||||
{/* Physical rover actions become visibly and behaviorally unavailable
|
||||
while another queued driver owns the turn. Pod/settings controls
|
||||
remain interactive because they do not mutate rover hardware. */}
|
||||
{hornDevice ? <RoundControl label="Horn" icon={FaBullhorn} keyLabel={<ControlHint actionId="hornHonk" />} 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={<ControlHint actionId="headlightToggle" />} active={headlightOn} disabled={!canControl} onClick={() => setHeadlight(!headlightOn)} className="absolute left-[1.979rem] top-[0.662rem]" /> : 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={<ControlHint actionId="laserToggle" />} 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>
|
||||
) : (
|
||||
<CornerPodToggle corner="bottom-left" expanded={false} label="Show rover controls" onClick={() => setOpen(true)} />
|
||||
)}
|
||||
<PeripheralPod open={open} onOpenChange={setOpen} label="rover controls">
|
||||
{/*
|
||||
The horn's center is the origin of an invisible 76px-radius arc. Headlight and laser
|
||||
sit at -75 and -15 degrees on either side of that arc's diagonal midpoint. Their
|
||||
sixty-degree separation leaves a visible gap between the small circles while keeping
|
||||
both controls equally distant from the horn. These explicit positions are the
|
||||
rendered result of that geometry, not unrelated visual nudges.
|
||||
*/}
|
||||
{/* Physical rover actions become visibly and behaviorally unavailable
|
||||
while another queued driver owns the turn. Pod/settings controls
|
||||
remain interactive because they do not mutate rover hardware. */}
|
||||
{hornDevice ? <RoundControl label="Horn" icon={FaBullhorn} keyLabel={<ControlHint actionId="hornHonk" />} 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={<ControlHint actionId="headlightToggle" />} active={headlightOn} disabled={!canControl} onClick={() => setHeadlight(!headlightOn)} className="absolute left-[1.979rem] top-[0.662rem]" /> : 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={<ControlHint actionId="laserToggle" />} active={laserOn} disabled={!canControl || roomLightsLockedOn} onClick={() => setLaser(!laserOn)} className="absolute left-[5.338rem] top-[4.021rem]" /> : null}
|
||||
</PeripheralPod>
|
||||
{hornDevice ? <HornSettingsExpansion open={hornSettingsOpen} onOpenChange={setHornSettingsOpen} /> : null}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import CornerPodToggle from './CornerPodToggle.jsx';
|
||||
|
||||
export default function PeripheralPod({ open, onOpenChange, label, children, className = 'h-40 w-40' }) {
|
||||
return open ? (
|
||||
<div className={`pointer-events-auto absolute bottom-0 left-0 z-20 rounded-tr-[4.25rem] bg-black/60 ${className}`}>
|
||||
{children}
|
||||
<CornerPodToggle corner="bottom-left" expanded label={`Hide ${label}`} onClick={() => onOpenChange(false)} />
|
||||
</div>
|
||||
) : (
|
||||
<CornerPodToggle corner="bottom-left" expanded={false} label={`Show ${label}`} onClick={() => onOpenChange(true)} />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import KeyPill from '../../../vip/VipAudioUploadCard/KeyPill.jsx';
|
||||
|
||||
export default function RoundControl({ label, icon, keyLabel, active, tone, disabled = false, onClick, onPointerDown, onPointerUp, large = false, className = '', value = null }) {
|
||||
const ControlIcon = icon;
|
||||
const toneClass = tone === 'horn'
|
||||
? active ? 'border-fuchsia-300/70 bg-fuchsia-700 text-fuchsia-50' : 'border-cyan-300/70 bg-cyan-900 text-cyan-50'
|
||||
: active ? 'border-emerald-300/70 bg-emerald-800 text-emerald-50' : 'border-amber-300/70 bg-amber-900 text-amber-50';
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerUp}
|
||||
className={`flex shrink-0 select-none flex-col items-center justify-center gap-1 rounded-full border-2 ${toneClass} ${large ? 'h-20 w-20' : 'h-16 w-16'} disabled:cursor-not-allowed disabled:opacity-40 ${className}`}
|
||||
>
|
||||
<ControlIcon className={large ? 'text-xl' : 'text-base'} aria-hidden="true" />
|
||||
{value != null ? <span className="text-xs font-semibold leading-none">{value}</span> : null}
|
||||
<KeyPill label={keyLabel} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { PTZ_CAMERA_ID } from '../PtzLiveVideo/index.jsx';
|
||||
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { useSharedClock } from '../../hooks/useSharedClock.js';
|
||||
import { isFeatureEnabled } from '../../lib/features.js';
|
||||
import { normalizePtzQueue, usePtzQueueLookup } from '../../ptz/queue.js';
|
||||
import useQueueUserLookup from '../../hooks/useQueueUserLookup.js';
|
||||
const PTZ_DEFAULT_COLOR = '#387bf8';
|
||||
|
||||
function formatRemaining(deadline, now) {
|
||||
@@ -24,13 +24,13 @@ export default function PtzQueueCard() {
|
||||
const selfId = useSessionSelector((state) => state.session?.socketId || null);
|
||||
const { setOperatingMode } = useSessionActions();
|
||||
const navigate = useNavigate();
|
||||
const lookupUser = usePtzQueueLookup(ptz);
|
||||
const { queue, currentId, nextId } = normalizePtzQueue(ptz);
|
||||
const now = useSharedClock(1000, Boolean(ptz?.deadline));
|
||||
const lookupUser = useQueueUserLookup(ptz?.turn);
|
||||
const { queue, currentId, nextId } = ptz?.turn || {};
|
||||
const now = useSharedClock(1000, Boolean(ptz?.turn?.deadline));
|
||||
const [pending, setPending] = useState(false);
|
||||
const canUse = Boolean(ptz?.permissions?.canEnter);
|
||||
const isParticipant = Boolean(ptz?.isOperator || ptz?.queuedPosition);
|
||||
const timerLabel = ptz?.isOperator && ptz?.deadline ? `${formatRemaining(ptz.deadline, now)} left` : '';
|
||||
const isParticipant = Boolean(ptz?.turn?.isActive || ptz?.turn?.turnsAhead);
|
||||
const timerLabel = ptz?.turn?.isActive && ptz?.turn?.deadline ? `${formatRemaining(ptz.turn.deadline, now)} left` : '';
|
||||
|
||||
if (!featureEnabled) return null;
|
||||
|
||||
@@ -66,13 +66,7 @@ export default function PtzQueueCard() {
|
||||
}
|
||||
};
|
||||
|
||||
const actionLabel = pending
|
||||
? '...'
|
||||
: ptz?.isOperator
|
||||
? 'Open'
|
||||
: ptz?.queuedPosition
|
||||
? 'Open'
|
||||
: 'request';
|
||||
const actionLabel = pending ? '...' : isParticipant ? 'Open' : 'request';
|
||||
|
||||
return (
|
||||
<CardFrame title={ptz?.name || 'PTZ camera'} bodyClassName="relative space-y-0.5 text-sm">
|
||||
@@ -82,11 +76,6 @@ export default function PtzQueueCard() {
|
||||
id: ptz?.id || PTZ_CAMERA_ID,
|
||||
name: ptz?.name || 'PTZ Camera',
|
||||
color: ptz?.color || PTZ_DEFAULT_COLOR,
|
||||
// description: ptz?.isOperator
|
||||
// ? 'Live camera turn active'
|
||||
// : ptz?.queuedPosition
|
||||
// ? `Queue position ${ptz.queuedPosition}`
|
||||
// : 'Pan, tilt, and zoom camera',
|
||||
}}
|
||||
queue={queue}
|
||||
currentId={currentId}
|
||||
@@ -96,8 +85,8 @@ export default function PtzQueueCard() {
|
||||
canClick={canUse && !pending}
|
||||
pending={pending}
|
||||
buttonLabel={actionLabel}
|
||||
batteryLabel={ptz?.isOperator ? 'LIVE' : ptz?.queuedPosition ? `#${ptz.queuedPosition}` : '--'}
|
||||
batteryClassName={ptz?.isOperator ? 'text-emerald-300' : ptz?.queuedPosition ? 'text-sky-300' : 'text-slate-400'}
|
||||
batteryLabel={ptz?.turn?.isActive ? 'LIVE' : ptz?.turn?.turnsAhead ? `#${ptz.turn.turnsAhead}` : '--'}
|
||||
batteryClassName={ptz?.turn?.isActive ? 'text-emerald-300' : ptz?.turn?.turnsAhead ? 'text-sky-300' : 'text-slate-400'}
|
||||
timerLabel={timerLabel}
|
||||
onRequest={handleRequest}
|
||||
showAction={canUse}
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
// PTZ Live Video
|
||||
// Purpose: Plays the single PTZ camera WHEP stream with the same fresh-session retry loop used by rover video.
|
||||
// Scope: Owns browser-side WHEP playback/retry only; server authorization and snapshot fallback policy stay outside.
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useVideoRequests } from '../../hooks/useVideoRequests.js';
|
||||
import { WhepPlayer } from '../../lib/whepPlayer.js';
|
||||
import { RESTART_DELAY_MS } from '../RoverMediaPlayer/constants.js';
|
||||
import { startWhepPlayback } from '../../lib/whepPlayback.js';
|
||||
import useWhepRestart from '../../hooks/useWhepRestart.js';
|
||||
|
||||
export const PTZ_CAMERA_ID = 'ptz-camera';
|
||||
|
||||
const PTZ_AUDIO_RETRY_MS = 1000;
|
||||
const TERMINAL_WHEP_STATES = new Set(['error', 'failed', 'disconnected', 'closed']);
|
||||
const AUTHORIZATION_ERROR_RE = /not authorized/i;
|
||||
|
||||
function isAuthorizationError(error) {
|
||||
@@ -25,14 +24,10 @@ export default function PtzLiveVideo({
|
||||
fallback = null,
|
||||
}) {
|
||||
const videoRef = useRef(null);
|
||||
const retryTimerRef = useRef(null);
|
||||
const playTimerRef = useRef(null);
|
||||
const enabledRef = useRef(enabled);
|
||||
const fallbackRef = useRef(false);
|
||||
const playerGenerationRef = useRef(0);
|
||||
const [status, setStatus] = useState('idle');
|
||||
const [detail, setDetail] = useState(null);
|
||||
const [restartToken, setRestartToken] = useState(0);
|
||||
const { restartToken, scheduleRestart, cancelRestart } = useWhepRestart(enabled);
|
||||
const sources = useVideoRequests(
|
||||
[{ type: 'ptz', id: PTZ_CAMERA_ID, key: PTZ_CAMERA_ID }],
|
||||
{ enabled, version: restartToken },
|
||||
@@ -41,100 +36,34 @@ export default function PtzLiveVideo({
|
||||
const shouldUseFallback = Boolean(source?.error && isAuthorizationError(source.error));
|
||||
|
||||
useEffect(() => {
|
||||
enabledRef.current = enabled;
|
||||
}, [enabled]);
|
||||
|
||||
useEffect(() => {
|
||||
fallbackRef.current = shouldUseFallback;
|
||||
}, [shouldUseFallback]);
|
||||
|
||||
useEffect(() => {
|
||||
if (enabled && !shouldUseFallback) return undefined;
|
||||
/*
|
||||
A retry that was scheduled before the server denied live access should not
|
||||
keep firing in snapshot mode. Clear it when live playback is no longer the
|
||||
active display policy.
|
||||
*/
|
||||
if (retryTimerRef.current) {
|
||||
clearTimeout(retryTimerRef.current);
|
||||
retryTimerRef.current = null;
|
||||
}
|
||||
return undefined;
|
||||
}, [enabled, shouldUseFallback]);
|
||||
|
||||
const scheduleRestart = useCallback(() => {
|
||||
if (!enabledRef.current || fallbackRef.current) return;
|
||||
/*
|
||||
WHEP sessions are one-shot browser/server negotiations. When the camera
|
||||
reboots, the old PeerConnection and token can look alive enough to keep a
|
||||
black element on screen, but they are not useful anymore. Bumping this
|
||||
token forces useVideoRequests to ask the server for a new MediaMTX auth
|
||||
session before creating the next WhepPlayer.
|
||||
*/
|
||||
clearTimeout(retryTimerRef.current);
|
||||
retryTimerRef.current = setTimeout(() => {
|
||||
retryTimerRef.current = null;
|
||||
setRestartToken(Date.now());
|
||||
}, RESTART_DELAY_MS);
|
||||
}, []);
|
||||
// An authorization denial switches to snapshots and cancels any queued retry.
|
||||
if (shouldUseFallback) cancelRestart();
|
||||
}, [shouldUseFallback, cancelRestart]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || shouldUseFallback || !source?.url || !videoRef.current) return undefined;
|
||||
let active = true;
|
||||
const generation = playerGenerationRef.current + 1;
|
||||
playerGenerationRef.current = generation;
|
||||
const player = new WhepPlayer({
|
||||
const video = videoRef.current;
|
||||
const stop = startWhepPlayback({
|
||||
url: source.url,
|
||||
token: source.token,
|
||||
video: videoRef.current,
|
||||
startMuted,
|
||||
scheduleRestart,
|
||||
onStatus: (nextStatus, info) => {
|
||||
/*
|
||||
Old PeerConnection callbacks can arrive after React has already
|
||||
cleaned up this effect for a newer token. Only the currently-owned
|
||||
generation is allowed to update status or schedule another restart.
|
||||
*/
|
||||
if (!active || playerGenerationRef.current !== generation) return;
|
||||
const normalized = String(nextStatus || '').toLowerCase();
|
||||
setStatus(nextStatus || 'unknown');
|
||||
setDetail(info || null);
|
||||
if (TERMINAL_WHEP_STATES.has(normalized)) {
|
||||
scheduleRestart();
|
||||
}
|
||||
},
|
||||
onError: (err) => {
|
||||
setStatus('error');
|
||||
setDetail(err.message || 'WHEP start failed');
|
||||
},
|
||||
});
|
||||
|
||||
player.start().catch((err) => {
|
||||
if (!active || playerGenerationRef.current !== generation) return;
|
||||
setStatus('error');
|
||||
setDetail(err.message || 'WHEP start failed');
|
||||
scheduleRestart();
|
||||
});
|
||||
|
||||
return () => {
|
||||
/*
|
||||
Mark inactive before stop() because WhepPlayer reports "stopped" during
|
||||
normal cleanup. Cleanup-driven stops should not immediately schedule the
|
||||
next retry; only the replacement effect should own the new connection.
|
||||
*/
|
||||
active = false;
|
||||
player.stop();
|
||||
};
|
||||
}, [enabled, scheduleRestart, shouldUseFallback, source?.token, source?.url, startMuted]);
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!enabled || shouldUseFallback || !source?.url || !video) return undefined;
|
||||
const generation = playerGenerationRef.current;
|
||||
|
||||
const handleEnded = () => {
|
||||
if (playerGenerationRef.current !== generation) return;
|
||||
setStatus('stopped');
|
||||
setDetail('ended');
|
||||
scheduleRestart();
|
||||
};
|
||||
const handleError = () => {
|
||||
if (playerGenerationRef.current !== generation) return;
|
||||
setStatus('error');
|
||||
setDetail(video.error?.message || 'video element error');
|
||||
scheduleRestart();
|
||||
@@ -145,8 +74,9 @@ export default function PtzLiveVideo({
|
||||
return () => {
|
||||
video.removeEventListener('ended', handleEnded);
|
||||
video.removeEventListener('error', handleError);
|
||||
stop();
|
||||
};
|
||||
}, [enabled, scheduleRestart, shouldUseFallback, source?.url]);
|
||||
}, [enabled, scheduleRestart, shouldUseFallback, source?.token, source?.url, startMuted]);
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
@@ -178,7 +108,6 @@ export default function PtzLiveVideo({
|
||||
}, [enabled, shouldUseFallback, source?.url, startMuted, status]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
|
||||
if (playTimerRef.current) clearInterval(playTimerRef.current);
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { WhepPlayer } from '../../lib/whepPlayer.js';
|
||||
import { startWhepPlayback } from '../../lib/whepPlayback.js';
|
||||
import useWhepRestart from '../../hooks/useWhepRestart.js';
|
||||
import { useTelemetrySelector } from '../../context/TelemetryContext.jsx';
|
||||
import {
|
||||
mainBrushAudioTelemetryEqual,
|
||||
@@ -11,7 +12,6 @@ import { useRoverSnapshots } from '../../hooks/useRoverSnapshots.js';
|
||||
import { useSettingsNamespace } from '../../settings/index.js';
|
||||
import { AUDIO_SETTINGS_DEFAULTS, VIDEO_SETTINGS_DEFAULTS } from '../../settings/namespaces.js';
|
||||
import {
|
||||
RESTART_DELAY_MS,
|
||||
UNMUTE_RETRY_MS,
|
||||
AUDIO_RETRY_MS,
|
||||
BRUSH_CURRENT_THRESHOLD_MA,
|
||||
@@ -212,10 +212,10 @@ export default function RoverMediaPlayer({
|
||||
mainBrushOvercurrent: Boolean(sensors?.wheelOvercurrents?.mainBrush),
|
||||
}
|
||||
: mainBrushTelemetry;
|
||||
const { restartToken, scheduleRestart } = useWhepRestart();
|
||||
const { restartToken: audioRestartToken, scheduleRestart: scheduleAudioRestart } = useWhepRestart();
|
||||
const videoRef = useRef(null);
|
||||
const audioRef = useRef(null);
|
||||
const restartTimer = useRef(null);
|
||||
const audioRestartTimer = useRef(null);
|
||||
const audioPlayInterval = useRef(null);
|
||||
const unmuteTimer = useRef(null);
|
||||
const volumeFadeFrameRef = useRef(null);
|
||||
@@ -224,8 +224,6 @@ export default function RoverMediaPlayer({
|
||||
const [detail, setDetail] = useState(null);
|
||||
const [audioStatus, setAudioStatus] = useState('idle');
|
||||
const [audioDetail, setAudioDetail] = useState(null);
|
||||
const [restartToken, setRestartToken] = useState(0);
|
||||
const [audioRestartToken, setAudioRestartToken] = useState(0);
|
||||
const [muted, setMuted] = useState(true);
|
||||
const hasDedicatedAudio = Boolean(resolvedAudioSessionInfo?.url);
|
||||
const usingSnapshot = videoMode === 'snapshot' || (!videoMode && !resolvedSessionInfo?.url);
|
||||
@@ -341,15 +339,6 @@ export default function RoverMediaPlayer({
|
||||
logAudio('settings/update');
|
||||
}, [logAudio]);
|
||||
|
||||
const scheduleRestart = useCallback(() => {
|
||||
clearTimeout(restartTimer.current);
|
||||
restartTimer.current = setTimeout(() => setRestartToken(Date.now()), RESTART_DELAY_MS);
|
||||
}, []);
|
||||
const scheduleAudioRestart = useCallback(() => {
|
||||
clearTimeout(audioRestartTimer.current);
|
||||
audioRestartTimer.current = setTimeout(() => setAudioRestartToken(Date.now()), RESTART_DELAY_MS);
|
||||
}, []);
|
||||
|
||||
const ensurePlayback = useCallback(async () => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
@@ -397,8 +386,6 @@ export default function RoverMediaPlayer({
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
clearTimeout(restartTimer.current);
|
||||
clearTimeout(audioRestartTimer.current);
|
||||
clearTimeout(unmuteTimer.current);
|
||||
clearInterval(audioPlayInterval.current);
|
||||
if (volumeFadeFrameRef.current) {
|
||||
@@ -426,41 +413,27 @@ export default function RoverMediaPlayer({
|
||||
if (!isVideoVisible || usingSnapshot || !resolvedSessionInfo?.url || !videoRef.current) {
|
||||
return undefined;
|
||||
}
|
||||
let active = true;
|
||||
let player;
|
||||
const resetMuteId = setTimeout(() => setMuted(true), 0);
|
||||
const handleStatus = (nextStatus, info) => {
|
||||
if (!active) return;
|
||||
logAudio('video/status', { nextStatus, info: info || null });
|
||||
setStatus(nextStatus);
|
||||
setDetail(info || null);
|
||||
if (nextStatus === 'playing') {
|
||||
ensurePlayback();
|
||||
}
|
||||
if (['error', 'failed', 'disconnected', 'closed'].includes(nextStatus)) {
|
||||
scheduleRestart();
|
||||
}
|
||||
};
|
||||
|
||||
player = new WhepPlayer({
|
||||
const stop = startWhepPlayback({
|
||||
url: resolvedSessionInfo.url,
|
||||
token: resolvedSessionInfo.token,
|
||||
video: videoRef.current,
|
||||
receiveAudio: !hasDedicatedAudio,
|
||||
onStatus: handleStatus,
|
||||
scheduleRestart,
|
||||
onStatus: (nextStatus, info) => {
|
||||
logAudio('video/status', { nextStatus, info: info || null });
|
||||
setStatus(nextStatus);
|
||||
setDetail(info || null);
|
||||
if (nextStatus === 'playing') ensurePlayback();
|
||||
},
|
||||
onError: (err) => {
|
||||
setStatus('error');
|
||||
setDetail(err.message);
|
||||
},
|
||||
});
|
||||
|
||||
player.start().catch((err) => {
|
||||
if (!active) return;
|
||||
setStatus('error');
|
||||
setDetail(err.message);
|
||||
scheduleRestart();
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
clearTimeout(resetMuteId);
|
||||
player?.stop();
|
||||
stop();
|
||||
};
|
||||
}, [
|
||||
usingSnapshot,
|
||||
@@ -536,37 +509,24 @@ export default function RoverMediaPlayer({
|
||||
if (!resolvedAudioSessionInfo?.url || !audioRef.current) {
|
||||
return undefined;
|
||||
}
|
||||
let active = true;
|
||||
let player;
|
||||
const handleStatus = (nextStatus, info) => {
|
||||
if (!active) return;
|
||||
logAudio('audio/status', { nextStatus, info: info || null });
|
||||
setAudioStatus(nextStatus);
|
||||
setAudioDetail(info || null);
|
||||
if (['error', 'failed'].includes(nextStatus)) {
|
||||
scheduleAudioRestart();
|
||||
}
|
||||
};
|
||||
|
||||
player = new WhepPlayer({
|
||||
return startWhepPlayback({
|
||||
url: resolvedAudioSessionInfo.url,
|
||||
token: resolvedAudioSessionInfo.token,
|
||||
video: audioRef.current,
|
||||
audioOnly: true,
|
||||
onStatus: handleStatus,
|
||||
// Dedicated rover audio historically retries only errors and failures.
|
||||
terminalStates: ['error', 'failed'],
|
||||
scheduleRestart: scheduleAudioRestart,
|
||||
onStatus: (nextStatus, info) => {
|
||||
logAudio('audio/status', { nextStatus, info: info || null });
|
||||
setAudioStatus(nextStatus);
|
||||
setAudioDetail(info || null);
|
||||
},
|
||||
onError: (err) => {
|
||||
setAudioStatus('error');
|
||||
setAudioDetail(err.message);
|
||||
},
|
||||
});
|
||||
|
||||
player.start().catch((err) => {
|
||||
if (!active) return;
|
||||
setAudioStatus('error');
|
||||
setAudioDetail(err.message);
|
||||
scheduleAudioRestart();
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
player?.stop();
|
||||
};
|
||||
}, [
|
||||
resolvedAudioSessionInfo?.url,
|
||||
resolvedAudioSessionInfo?.token,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import useQueueUserLookup from '../../hooks/useQueueUserLookup.js';
|
||||
import { useSharedClock } from '../../hooks/useSharedClock.js';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
import QueueTargetRow from '../QueueTargetRow/index.jsx';
|
||||
@@ -181,8 +182,7 @@ export default function RoverQueuesPanel({
|
||||
}
|
||||
}
|
||||
|
||||
const lookupUser = (socketId) =>
|
||||
users.find((u) => u.socketId === socketId) || { socketId, nickname: null, role: null };
|
||||
const lookupUser = useQueueUserLookup(null, users);
|
||||
|
||||
async function handleRebootOwnRover() {
|
||||
if (rebootPending) return;
|
||||
@@ -246,14 +246,8 @@ export default function RoverQueuesPanel({
|
||||
const idleRemainingSeconds = idleDeadline
|
||||
? Math.max(0, Math.ceil((idleDeadline - now) / 1000))
|
||||
: null;
|
||||
const currentId = info?.current || null;
|
||||
const currentIdx = currentId ? queue.findIndex((id) => id === currentId) : -1;
|
||||
const nextId =
|
||||
queue.length > 1
|
||||
? currentIdx >= 0
|
||||
? queue[(currentIdx + 1) % queue.length]
|
||||
: queue[0]
|
||||
: null;
|
||||
const currentId = info?.currentId || null;
|
||||
const nextId = info?.nextId || null;
|
||||
const isSelfCurrent = Boolean(selfId && currentId && currentId === selfId);
|
||||
const isSelfNext = Boolean(selfId && nextId && nextId === selfId);
|
||||
const timerLabel = isSelfCurrent
|
||||
|
||||
Reference in New Issue
Block a user