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 DiscordInviteButton from './DiscordInviteButton.jsx'; const RESTART_DELAY_MS = 2000; const UNMUTE_RETRY_MS = 3000; const AUDIO_RETRY_MS = 3000; function buildBatteryVisual(charge, config) { const full = config?.Full; const warn = config?.Warn; const urgent = config?.Urgent ?? null; if (charge == null || full == null || warn == null) { return { available: false }; } const span = full - warn; if (span <= 0) return { available: false }; const normalized = (charge - warn) / span; const percent = Math.min(1, Math.max(0, normalized)); const percentDisplay = Math.round(percent * 100); const depleted = normalized <= 0; const warnTriggered = urgent != null && charge <= urgent; const barClass = depleted ? 'bg-red-500 animate-pulse' : warnTriggered ? 'bg-amber-400' : 'bg-emerald-500'; return { available: true, percentDisplay, depleted, warnTriggered, barClass, }; } 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 videoRef = useRef(null); const audioRef = useRef(null); const restartTimer = useRef(null); const audioRestartTimer = useRef(null); const audioPlayInterval = useRef(null); const unmuteTimer = 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 usingSnapshot = videoMode === 'snapshot'; const sensors = telemetryFrame?.sensors; const batteryCharge = sensors?.batteryChargeMah ?? null; const desktopLayout = layoutFormat === 'desktop'; const mobileHud = !desktopLayout; const [showHudMapDesktop, setShowHudMapDesktop] = useHudMapSetting(); const showHudMap = hudForceMap ? true : mobileHud ? true : showHudMapDesktop; const batteryVisual = buildBatteryVisual(batteryCharge, 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 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); useEffect(() => { if (!debugHud) return; console.log('[OvercurrentHUD]', { overlayVisible, overlayMotors, overlayFill, limiterActive, limiterCaps, limiterGroups, wheelOvercurrents, }); }, [debugHud, overlayFill, overlayMotors, overlayVisible, limiterActive, limiterCaps, limiterGroups, wheelOvercurrents]); 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) 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], ); useEffect( () => () => { clearTimeout(restartTimer.current); clearTimeout(audioRestartTimer.current); clearTimeout(unmuteTimer.current); clearInterval(audioPlayInterval.current); }, [], ); 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; 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, 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]); useEffect(() => { if (status === 'stopped' && sessionInfo?.url) { scheduleRestart(); } }, [status, sessionInfo?.url, scheduleRestart]); // 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; 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 (nextStatus === 'playing') { audioRef.current?.play().catch(() => {}); } if (['error', 'failed', 'disconnected', 'closed'].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]); // 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 = () => { const target = audioRef.current; if (!target) return; if (!target.paused && !target.ended) return; target .play() .then(() => { setAudioStatus((prev) => (prev === 'connected' ? 'playing' : prev)); setAudioDetail((prev) => (prev === 'paused' ? null : prev)); }) .catch((err) => { setAudioDetail((prev) => prev || err?.message || 'autoplay blocked'); }); }; attemptPlay(); audioPlayInterval.current = setInterval(attemptPlay, AUDIO_RETRY_MS); return () => clearInterval(audioPlayInterval.current); }, [audioSessionInfo?.url, audioStatus]); // Reflect audio element events back into status/detail so the HUD stays accurate. useEffect(() => { const audioEl = audioRef.current; if (!audioEl) return undefined; const handlePlay = () => { setAudioStatus((prev) => (prev === 'error' ? prev : 'playing')); setAudioDetail(null); }; const handlePause = () => { setAudioStatus((prev) => { if (['error', 'failed', 'disconnected', 'closed', 'stopped'].includes(prev)) return prev; return 'paused'; }); setAudioDetail((prev) => prev || 'paused'); }; const handleEnded = () => { setAudioStatus((prev) => (prev === 'error' ? prev : 'stopped')); setAudioDetail((prev) => prev || 'ended'); }; const handleError = () => { const { error } = audioEl; const message = error?.message || 'audio error'; setAudioStatus('error'); setAudioDetail(message); }; audioEl.addEventListener('play', handlePlay); audioEl.addEventListener('pause', handlePause); audioEl.addEventListener('ended', handleEnded); audioEl.addEventListener('error', handleError); return () => { audioEl.removeEventListener('play', handlePlay); audioEl.removeEventListener('pause', handlePause); audioEl.removeEventListener('ended', handleEnded); audioEl.removeEventListener('error', handleError); }; }, [audioSessionInfo?.url]); 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'; return (
Battery telemetry unavailable