import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { WhepPlayer } from '../lib/whepPlayer.js'; import TopDownMap from './TopDownMap.jsx'; import { useHudMapSetting } from '../hooks/useHudMapSetting.js'; import { useChat } from '../context/ChatContext.jsx'; import { useSession } from '../context/SessionContext.jsx'; import { useSettingsNamespace } from '../settings/index.js'; import { AUDIO_SETTINGS_DEFAULTS } from '../settings/namespaces.js'; import SocialButton from './SocialButton.jsx'; import BatteryBar from './BatteryBar.jsx'; import { buildBatteryVisual } from '../lib/battery.js'; const RESTART_DELAY_MS = 2000; const UNMUTE_RETRY_MS = 3000; const AUDIO_RETRY_MS = 3000; const BRUSH_CURRENT_THRESHOLD_MA = 40; const DUCK_RELEASE_FADE_MS = 1000; export default function VideoTile({ sessionInfo, audioSessionInfo, videoMode = 'whep', snapshotFeed = null, qualityNotice = null, label, forceMute = false, telemetryFrame, batteryConfig, layoutFormat = 'desktop', hudVariant = 'default', driverLabel = null, hudForceMap = false, hudMapPosition = 'top-center', hudLabelScale = 1, fitParent = false, overcurrentLimiter = null, showTurnCue = false, turnTimerText = null, turnSeconds = null, isActiveDriver = false, idleSkipSeconds = null, }) { const { session } = useSession(); 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); const appliedVolumeRef = useRef(null); const [status, setStatus] = useState('idle'); 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(audioSessionInfo?.url); const usingSnapshot = videoMode === 'snapshot'; const sensors = telemetryFrame?.sensors; const { value: audioSettings } = useSettingsNamespace('audio', AUDIO_SETTINGS_DEFAULTS); const masterVolume = Number.isFinite(audioSettings?.masterVolume) ? audioSettings.masterVolume : AUDIO_SETTINGS_DEFAULTS.masterVolume; const roverVolume = Number.isFinite(audioSettings?.roverVolume) ? audioSettings.roverVolume : AUDIO_SETTINGS_DEFAULTS.roverVolume; const mainBrushDuckEnabled = typeof audioSettings?.mainBrushDuckEnabled === 'boolean' ? audioSettings.mainBrushDuckEnabled : typeof audioSettings?.autoLevelEnabled === 'boolean' ? audioSettings.autoLevelEnabled : AUDIO_SETTINGS_DEFAULTS.mainBrushDuckEnabled; const mainBrushDuckAmount = Number.isFinite(audioSettings?.mainBrushDuckAmount) ? Math.max(0, Math.min(1, audioSettings.mainBrushDuckAmount)) : AUDIO_SETTINGS_DEFAULTS.mainBrushDuckAmount; const baseRoverGain = Math.max(0, Math.min(1, masterVolume * roverVolume)); const batteryCharge = sensors?.batteryChargeMah ?? null; const desktopLayout = layoutFormat === 'desktop'; const mobileHud = !desktopLayout; const effectiveHudMapPosition = mobileHud ? 'top-right' : hudMapPosition; const [showHudMapDesktop, setShowHudMapDesktop] = useHudMapSetting(); const showHudMap = hudForceMap ? true : mobileHud ? true : showHudMapDesktop; const batteryVisual = buildBatteryVisual({ charge: batteryCharge, config: batteryConfig }); // console.log('[BatteryBarDebug]', { // frameSensors: sensors, // batteryCharge, // batteryCapacity, // config: batteryConfig, // }); const wheelOvercurrents = sensors?.wheelOvercurrents || null; const overcurrentMotors = wheelOvercurrents == null ? [] : Object.entries(wheelOvercurrents) .filter(([, active]) => Boolean(active)) .map(([key]) => key); const limiterCaps = overcurrentLimiter?.caps || null; const limiterGroups = overcurrentLimiter?.overcurrent?.groups || null; const debugAudio = typeof window !== 'undefined' && new URLSearchParams(window.location.search).has('debugAudio'); const debugHud = typeof window !== 'undefined' && new URLSearchParams(window.location.search).has('debugHud'); const limiterFill = useMemo(() => { if (!limiterCaps) return null; const driveCap = Number.isFinite(limiterCaps?.drive?.cap) ? limiterCaps.drive.cap : 1; const auxCap = Number.isFinite(limiterCaps?.aux?.cap) ? limiterCaps.aux.cap : 1; return Math.max(0, Math.min(1, 1 - Math.min(driveCap, auxCap))); }, [limiterCaps]); const limiterActive = Boolean(overcurrentLimiter?.isActive); const overlayMotors = overcurrentMotors.length ? overcurrentMotors : limiterActive ? ['limiter'] : []; const overlayFill = limiterFill ?? (overcurrentMotors.length ? 1 : 0); const overlayVisible = Boolean(overlayMotors.length); const mainBrushActive = Boolean( (Number(sensors?.mainBrushCurrentMa) || 0) > BRUSH_CURRENT_THRESHOLD_MA || sensors?.wheelOvercurrents?.mainBrush, ); const duckGain = mainBrushDuckEnabled && mainBrushActive ? 1 - mainBrushDuckAmount : 1; const effectiveRoverGain = Math.max(0, Math.min(1, baseRoverGain * duckGain)); const levelIndicator = mainBrushDuckEnabled && mainBrushActive && mainBrushDuckAmount > 0 ? `Volume decreased ${Math.round(mainBrushDuckAmount * 1000) / 10}%` : null; const audioDebugStateRef = useRef({ hasDedicatedAudio: false, audioUrl: null, mainBrushDuckEnabled: false, mainBrushDuckAmount: 0, mainBrushActive: false, baseRoverGain: 0, effectiveRoverGain: 0, }); useEffect(() => { audioDebugStateRef.current = { hasDedicatedAudio, audioUrl: audioSessionInfo?.url || null, mainBrushDuckEnabled, mainBrushDuckAmount, mainBrushActive, baseRoverGain, effectiveRoverGain, }; }, [ hasDedicatedAudio, audioSessionInfo?.url, mainBrushDuckEnabled, mainBrushDuckAmount, mainBrushActive, baseRoverGain, effectiveRoverGain, ]); const logAudio = useCallback( (event, meta = {}) => { if (!debugAudio) return; const audioEl = audioRef.current; const state = audioDebugStateRef.current; const payload = { event, ts: Date.now(), roverLabel: label || null, hasDedicatedAudio: state.hasDedicatedAudio, audioUrl: state.audioUrl, mainBrushDuckEnabled: state.mainBrushDuckEnabled, mainBrushDuckAmount: state.mainBrushDuckAmount, mainBrushActive: state.mainBrushActive, baseRoverGain: state.baseRoverGain, effectiveRoverGain: state.effectiveRoverGain, element: audioEl ? { muted: audioEl.muted, volume: audioEl.volume, paused: audioEl.paused, ended: audioEl.ended, readyState: audioEl.readyState, networkState: audioEl.networkState, } : null, ...meta, }; try { console.log(`[AudioDebug] ${event} ${JSON.stringify(payload)}`); } catch { console.log('[AudioDebug]', event, payload); } }, [debugAudio, label], ); const discordUrl = session?.socials?.find((entry) => { const key = String(entry?.id || entry?.label || '').toLowerCase(); return key === 'discord'; })?.url || session?.discord?.invite || null; useEffect(() => { if (!debugHud) return; console.log('[OvercurrentHUD]', { overlayVisible, overlayMotors, overlayFill, limiterActive, limiterCaps, limiterGroups, wheelOvercurrents, }); }, [debugHud, overlayFill, overlayMotors, overlayVisible, limiterActive, limiterCaps, limiterGroups, wheelOvercurrents]); useEffect(() => { 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; try { video.muted = true; await video.play(); } catch { // Autoplay might still be blocked; retry logic elsewhere will handle it. } }, []); const attemptUnmute = useCallback( (delay = 0) => { if (forceMute || hasDedicatedAudio) return; clearTimeout(unmuteTimer.current); const scheduleRetry = () => { clearTimeout(unmuteTimer.current); unmuteTimer.current = setTimeout(() => { if (!forceMute) { tryPlay(); } }, UNMUTE_RETRY_MS); }; const tryPlay = async () => { const video = videoRef.current; if (!video) return; try { await ensurePlayback(); video.muted = false; await video.play(); setMuted(false); } catch { video.muted = true; setMuted(true); scheduleRetry(); } }; unmuteTimer.current = setTimeout(tryPlay, delay); }, [ensurePlayback, forceMute, hasDedicatedAudio], ); useEffect( () => () => { clearTimeout(restartTimer.current); clearTimeout(audioRestartTimer.current); clearTimeout(unmuteTimer.current); clearInterval(audioPlayInterval.current); if (volumeFadeFrameRef.current) { cancelAnimationFrame(volumeFadeFrameRef.current); volumeFadeFrameRef.current = null; } }, [], ); useEffect(() => { if (status === 'playing') { attemptUnmute(0); } }, [status, attemptUnmute]); useEffect(() => { if (usingSnapshot) { setStatus('snapshot'); setDetail(null); } }, [usingSnapshot]); useEffect(() => { if (usingSnapshot || !sessionInfo?.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({ url: sessionInfo.url, token: sessionInfo.token, video: videoRef.current, receiveAudio: !hasDedicatedAudio, onStatus: handleStatus, }); player.start().catch((err) => { if (!active) return; setStatus('error'); setDetail(err.message); scheduleRestart(); }); return () => { active = false; clearTimeout(resetMuteId); player?.stop(); }; }, [usingSnapshot, sessionInfo?.url, sessionInfo?.token, restartToken, scheduleRestart, ensurePlayback, hasDedicatedAudio, logAudio]); useEffect(() => { if (status === 'stopped' && sessionInfo?.url) { scheduleRestart(); } }, [status, sessionInfo?.url, scheduleRestart]); useEffect(() => { const audioEl = audioRef.current; if (!audioEl || !audioSessionInfo?.url) { logAudio('route/no-audio-url'); appliedVolumeRef.current = null; return; } const targetVolume = Math.max(0, Math.min(1, effectiveRoverGain)); const currentVolume = appliedVolumeRef.current == null ? Math.max(0, Math.min(1, audioEl.volume)) : appliedVolumeRef.current; if (volumeFadeFrameRef.current) { cancelAnimationFrame(volumeFadeFrameRef.current); volumeFadeFrameRef.current = null; } if (targetVolume > currentVolume) { const startedAt = performance.now(); const from = currentVolume; const delta = targetVolume - from; const tick = (ts) => { const progress = Math.min(1, (ts - startedAt) / DUCK_RELEASE_FADE_MS); const next = from + delta * progress; audioEl.volume = next; appliedVolumeRef.current = next; if (progress < 1) { volumeFadeFrameRef.current = requestAnimationFrame(tick); } else { volumeFadeFrameRef.current = null; } }; volumeFadeFrameRef.current = requestAnimationFrame(tick); } else { audioEl.volume = targetVolume; appliedVolumeRef.current = targetVolume; } logAudio('route/direct', { elementVolume: targetVolume, duckEnabled: mainBrushDuckEnabled, mainBrushActive, duckAmount: mainBrushDuckAmount, }); }, [ audioSessionInfo?.url, effectiveRoverGain, mainBrushDuckEnabled, mainBrushActive, mainBrushDuckAmount, logAudio, ]); // Audio-only WHEP (no pausing/muting; keeps trying to play) useEffect(() => { if (!audioSessionInfo?.url || !audioRef.current) { return undefined; } let active = true; let player; const handleStatus = (nextStatus, info) => { if (!active) return; logAudio('audio/status', { nextStatus, info: info || null }); setAudioDetail(info || (nextStatus === 'connected' ? 'connected' : null)); setAudioStatus((prev) => { if (nextStatus === 'connected' && (prev === 'playing' || prev === 'connecting')) { return prev; } if (nextStatus === 'new') { return prev; } return nextStatus; }); if (['error', 'failed'].includes(nextStatus)) { scheduleAudioRestart(); } }; player = new WhepPlayer({ url: audioSessionInfo.url, token: audioSessionInfo.token, video: audioRef.current, audioOnly: true, onStatus: handleStatus, }); player.start().catch((err) => { if (!active) return; setAudioStatus('error'); setAudioDetail(err.message); scheduleAudioRestart(); }); return () => { active = false; player?.stop(); }; }, [ audioSessionInfo?.url, audioSessionInfo?.token, audioRestartToken, scheduleAudioRestart, logAudio, ]); // Keep nudging the audio element to play in case autoplay was blocked. useEffect(() => { const audioEl = audioRef.current; if (!audioSessionInfo?.url || !audioEl) { clearInterval(audioPlayInterval.current); return undefined; } const shouldAttempt = ['connecting', 'connected', 'playing', 'paused'].includes(audioStatus); if (!shouldAttempt) { clearInterval(audioPlayInterval.current); return undefined; } const attemptPlay = async () => { const target = audioRef.current; if (!target) return; target.muted = false; if (!target.paused && !target.ended) return; logAudio('retry/play-attempt'); target .play() .then(() => { logAudio('retry/play-ok'); setAudioStatus((prev) => (prev === 'connected' ? 'playing' : prev)); setAudioDetail((prev) => (prev === 'paused' ? null : prev)); }) .catch((err) => { logAudio('retry/play-fail', { error: err?.message }); setAudioDetail((prev) => prev || err?.message || 'autoplay blocked'); }); }; attemptPlay(); audioPlayInterval.current = setInterval(attemptPlay, AUDIO_RETRY_MS); return () => clearInterval(audioPlayInterval.current); }, [ audioSessionInfo?.url, audioStatus, logAudio, ]); // Reflect audio element events back into status/detail so the HUD stays accurate. useEffect(() => { const audioEl = audioRef.current; if (!audioEl) return undefined; const handlePlay = () => { logAudio('element/play'); setAudioStatus((prev) => (prev === 'error' ? prev : 'playing')); setAudioDetail(null); }; const handlePause = () => { logAudio('element/pause'); setAudioStatus((prev) => { if (['error', 'failed', 'disconnected', 'closed', 'stopped'].includes(prev)) return prev; return 'paused'; }); setAudioDetail((prev) => prev || 'paused'); }; const handleEnded = () => { logAudio('element/ended'); setAudioStatus((prev) => (prev === 'error' ? prev : 'stopped')); setAudioDetail((prev) => prev || 'ended'); }; const handleError = () => { const { error } = audioEl; const message = error?.message || 'audio error'; logAudio('element/error', { error: message }); setAudioStatus('error'); setAudioDetail(message); }; const handleWaiting = () => logAudio('element/waiting'); const handleCanPlay = () => logAudio('element/canplay'); const handleStalled = () => logAudio('element/stalled'); audioEl.addEventListener('play', handlePlay); audioEl.addEventListener('pause', handlePause); audioEl.addEventListener('ended', handleEnded); audioEl.addEventListener('error', handleError); audioEl.addEventListener('waiting', handleWaiting); audioEl.addEventListener('canplay', handleCanPlay); audioEl.addEventListener('stalled', handleStalled); return () => { audioEl.removeEventListener('play', handlePlay); audioEl.removeEventListener('pause', handlePause); audioEl.removeEventListener('ended', handleEnded); audioEl.removeEventListener('error', handleError); audioEl.removeEventListener('waiting', handleWaiting); audioEl.removeEventListener('canplay', handleCanPlay); audioEl.removeEventListener('stalled', handleStalled); }; }, [audioSessionInfo?.url, logAudio]); const snapshotStatus = snapshotFeed?.error ? `Error: ${snapshotFeed.error}` : snapshotFeed?.objectUrl ? 'snapshot' : snapshotFeed?.status || 'waiting'; const renderedStatus = usingSnapshot ? snapshotStatus : !sessionInfo?.url ? 'waiting' : status === 'error' ? `Error: ${detail || 'unknown'}` : detail ? `${status} (${detail})` : status; const renderedAudioStatus = audioSessionInfo?.error ? `Error: ${audioSessionInfo.error}` : !audioSessionInfo?.url ? null : audioStatus === 'error' ? `Error: ${audioDetail || 'unknown'}` : audioDetail ? `${audioStatus} (${audioDetail})` : audioStatus; const showVerticalBattery = hudVariant === 'spectator'; const noHud = hudVariant === 'none'; return (
{usingSnapshot ? ( snapshotFeed?.objectUrl ? ( {label} ) : (
Waiting for frame…
) ) : (
{!noHud && !showVerticalBattery && (
)}
); } function LightBumpBars({ sensors }) { const values = [ sensors?.lightBumpLeftSignal, sensors?.lightBumpFrontLeftSignal, sensors?.lightBumpCenterLeftSignal, sensors?.lightBumpCenterRightSignal, sensors?.lightBumpFrontRightSignal, sensors?.lightBumpRightSignal, ]; const max = values.filter((v) => v != null).reduce((acc, v) => Math.max(acc, v), 1200); const eased = (v) => Math.pow(Math.max(0, Math.min(1, (v ?? 0) / max)), 0.35); const hueFor = (v) => { if (v == null || v <= 0) return 'hsl(200 60% 18%)'; const h = (200 + eased(v) * 360) % 360; return `hsl(${h} 100% 60%)`; }; const segments = 6; const gap = 2; const barHeight = 12; return (
{values.map((v, idx) => { const t = eased(v); const dir = idx < segments / 2 ? -1 : 1; // left bars fill left, right bars fill right const fill = `${t * 100}%`; const color = hueFor(v); return (
); })}
); } function HudOverlay({ frame, sensors, label, status, audioStatus, levelStatus, desktopLayout = true, layoutFormat = 'desktop', variant = 'default', driverLabel = null, battery, showTopDown = false, mobileHud = false, mapPosition = 'top-center', turnTimerText = null, labelScale = 1, }) { const bumps = sensors?.bumpsAndWheelDrops || {}; const [now, setNow] = useState(() => Date.now()); useEffect(() => { const interval = setInterval(() => setNow(Date.now()), 150); return () => clearInterval(interval); }, []); const pulse = frame?.receivedAt ? now - frame.receivedAt < 200 : false; const isMobile = mobileHud; const portraitMobile = layoutFormat === 'mobile-portrait'; const statusTextClass = isMobile ? 'text-[0.45rem]' : 'text-[0.65rem]'; const statusPadClass = isMobile ? 'px-0.25 py-0.25' : 'px-1 py-0.5'; const labelPadClass = isMobile ? 'px-0.25 py-0.25' : 'px-0.5 py-0.5'; const labelTextClass = isMobile ? 'text-[0.55rem]' : 'text-[0.8rem]'; const statusPosClass = isMobile ? 'left-0.5 top-0.5' : 'left-1 top-1'; const timerTextClass = isMobile ? 'text-[0.5rem]' : 'text-[0.7rem]'; const timerPadClass = isMobile ? 'px-0.5 py-0.25' : 'px-1 py-0.5'; const telemetryPosClass = isMobile ? 'left-0.5 top-1/2' : 'left-1 top-1/2'; const labelPosClass = isMobile ? 'bottom-0.5' : 'bottom-0.5'; const labelWrapperStyle = { transform: `translateX(-50%) scale(${labelScale})`, transformOrigin: 'center bottom', }; const mapSize = '240px'; const mapScale = portraitMobile ? 0.3 : isMobile ? 0.33 : 0.7; const mapOpacity = isMobile ? 0.6 : 0.7; const mapStyle = { width: mapSize, height: mapSize, opacity: mapOpacity, transform: mapPosition === 'top-center' ? `translateX(-50%) scale(${mapScale})` : `scale(${mapScale})`, transformOrigin: mapPosition === 'bottom-left' ? 'bottom left' : mapPosition === 'top-center' ? 'top center' : 'top right', ...(mapPosition === 'bottom-left' ? { left: '0.25rem', bottom: '0.25rem' } : mapPosition === 'top-center' ? { left: '50%', top: '0.25rem' } : { right: '0.25rem', top: '0.25rem' }), }; if (variant === 'none') { return null; } if (variant === 'spectator') { const telemetryEntries = [ ['Voltage', sensors?.voltageMv != null ? `${(sensors.voltageMv / 1000).toFixed(2)} V` : '--'], ['Current', sensors?.currentMa != null ? `${sensors.currentMa} mA` : '--'], ['Charge', sensors?.batteryChargeMah != null ? `${sensors.batteryChargeMah}` : '--'], ['OI', sensors?.oiMode?.label || '--'], ]; const docked = Boolean(sensors?.chargingSources?.homeBase); const chargingLabel = sensors?.chargingState?.label || ''; const charging = Boolean(chargingLabel && chargingLabel.toLowerCase() !== 'not charging'); const oiLabel = sensors?.oiMode?.label || 'Unknown'; const oiNormalized = oiLabel.toLowerCase(); const oiTone = oiNormalized === 'full' ? 'bg-emerald-500/80 text-emerald-50' : oiNormalized === 'safe' ? 'bg-amber-400/80 text-amber-950' : oiNormalized === 'passive' ? 'bg-slate-700/80 text-slate-100' : 'bg-slate-700/60 text-slate-200'; const dockTone = docked ? 'bg-emerald-500/80 text-emerald-50' : 'bg-slate-700/70 text-slate-200'; const chargingTone = charging ? 'bg-emerald-500/80 text-emerald-50' : docked ? 'bg-amber-400/80 text-amber-950' : 'bg-slate-700/70 text-slate-200'; return ( <>
Status: {status} {audioStatus ? Audio: {audioStatus} : null} {levelStatus ? {levelStatus} : null}
{docked ? 'Docked' : 'Undocked'} {charging ? 'Charging' : docked ? 'Not charging' : 'Not charging'} OI: {oiLabel}
{/* Telemetry */} {telemetryEntries.map(([labelText, value]) => ( {labelText} {value} ))}
{label || 'Unnamed Rover'} {driverLabel ? • {driverLabel} : null}
{showTopDown ? (
) : null} ); } return (
Status: {status} {audioStatus ? Audio: {audioStatus} : null} {levelStatus ? {levelStatus} : null}
{turnTimerText ? (
{turnTimerText}
) : null}
Rover: "{label || 'Unnamed Rover'}" {/* {pulse ? 'Sensors active' : 'No recent sensors'} */}
{showTopDown && variant !== 'spectator' ? (
) : null}
); } function TurnCueOverlay({ mobileHud = false, turnSeconds = null, isActiveDriver = false, idleSkipSeconds = null, }) { const titleClass = mobileHud ? 'text-3xl' : 'text-5xl'; const subClass = mobileHud ? 'text-xs' : 'text-sm'; const timerClass = mobileHud ? 'text-[0.55rem]' : 'text-[0.75rem]'; const padClass = mobileHud ? 'px-4 py-3' : 'px-6 py-4'; const showCountdown = isActiveDriver && typeof idleSkipSeconds === 'number'; return (
IT IS YOUR TURN!
Start driving!
{showCountdown ? (
Idle skip in {idleSkipSeconds}s
) : null}
); } const OVERCURRENT_LABELS = { leftWheel: 'Left wheel', rightWheel: 'Right wheel', mainBrush: 'Main brush', sideBrush: 'Side brush', limiter: 'Overcurrent limit', }; function OvercurrentOverlay({ motors, fill = 0, compact = false }) { if (!motors?.length) return null; const safeLabels = motors.map((name) => OVERCURRENT_LABELS[name] || name); const containerClass = compact ? 'w-[12rem] h-[3.5rem]' : 'w-[20rem] h-[7rem]'; const padClass = compact ? 'px-2 py-1' : 'px-4 py-2'; const textClass = compact ? 'text-lg' : 'text-4xl'; const subTextClass = compact ? 'text-xs' : 'text-xl'; const safeFill = Math.max(0, Math.min(1, fill)); const fillWidth = `${Math.round(safeFill * 100)}%`; return (
OVERCURRENT
{safeLabels.join(', ')}
); } // low battery overlay, change text based on warn / urgent. use percentage calculated same as BatteryBar. change text based on warn or urgent. function LowBatteryOverlay({ battery, compact = false }) { if (!battery?.available) return null; if (!battery.warnActive && !battery.urgentActive) return null; const message = battery.urgentActive ? 'BATTERY VERY LOW, DOCK THE ROVER AND CHARGE IMMEDIATELY!!' : 'Battery low! please dock and charge the rover soon.'; const containerClass = compact ? 'p-2 top-6' : 'p-4 top-10'; const textClass = compact ? 'text-sm' : 'text-2xl'; return (
{message}
); } function HudChatInput({ compact = false }) { const { session } = useSession(); const { sendMessage, onInputFocus, onInputBlur, blurChat, registerInputRef, setTypingActive } = useChat(); const { value: ttsSettings } = useSettingsNamespace('tts', { engine: 'flite', voice: 'rms', pitch: 50 }); const [draft, setDraft] = useState(''); const [sending, setSending] = useState(false); const canChat = session?.role !== 'spectator'; const hideHudChat = session?.role === 'spectator'; const currentRoverId = session?.assignment?.roverId || null; const rover = useMemo( () => session?.roster?.find((entry) => String(entry.id) === String(currentRoverId)) || null, [currentRoverId, session?.roster], ); const ttsSupported = Boolean(rover?.audio?.ttsEnabled); const ttsPayload = useMemo(() => { if (!ttsSupported) return null; const engine = ttsSettings?.engine === 'espeak' ? 'espeak' : 'flite'; if (engine === 'espeak') { let pitch = Number.isFinite(ttsSettings?.pitch) ? Math.round(ttsSettings.pitch) : undefined; if (typeof pitch === 'number') { pitch = Math.max(0, Math.min(99, pitch)); } return { speak: true, engine, pitch }; } const voice = typeof ttsSettings?.voice === 'string' ? ttsSettings.voice : undefined; return { speak: true, engine, voice }; }, [ttsSettings?.engine, ttsSettings?.pitch, ttsSettings?.voice, ttsSupported]); const containerClass = compact ? 'pointer-events-auto absolute bottom-0.5 right-0.5 flex w-[9rem] max-w-[70vw] items-center gap-0.5 rounded bg-black/70 px-0.4 py-0.2' : 'pointer-events-auto absolute bottom-1 right-1 flex w-[12rem] max-w-[70vw] items-center gap-0.5 rounded bg-black/70 px-0.5 py-0.25'; const inputClass = compact ? 'min-w-0 flex-1 bg-transparent text-[0.55rem] text-slate-100 placeholder:text-slate-400 focus:outline-none' : 'min-w-0 flex-1 bg-transparent text-[0.7rem] text-slate-100 placeholder:text-slate-400 focus:outline-none'; const buttonClass = compact ? 'rounded bg-cyan-500/80 px-0.35 py-0.2 text-[0.55rem] font-semibold text-black disabled:opacity-50' : 'rounded bg-cyan-500/80 px-0.5 py-0.25 text-[0.7rem] font-semibold text-black disabled:opacity-50'; async function handleSend(event) { event.preventDefault(); if (!canChat) return; const clean = draft.trim(); if (!clean) return; setSending(true); try { await sendMessage(clean, ttsPayload); setDraft(''); blurChat(); setTypingActive(false); } catch (err) { alert(err.message); } finally { setSending(false); } } if (hideHudChat) return null; return (
{ const next = event.target.value; setDraft(next); setTypingActive(Boolean(next.trim())); }} onFocus={(event) => { onInputFocus(event); setTypingActive(Boolean(draft.trim())); }} onBlur={(event) => { onInputBlur(event); setTypingActive(false); }} onKeyDown={(event) => { if (event.key === 'Enter' && !draft.trim()) { event.preventDefault(); blurChat(); setTypingActive(false); } }} ref={(el) => registerInputRef(el, { target: 'hud' })} placeholder={canChat ? 'Chat (TTS)' : 'Spectator'} disabled={!canChat} />
); }