// Replay Sources Panel // Purpose: Defines the Replay Sources Panel module and the local helpers/components used in this file. // Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit. import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx'; import { useSharedClock } from '../../hooks/useSharedClock.js'; import { useSettingsNamespace } from '../../settings/index.js'; import CardFrame from '../CardFrame/index.jsx'; import RoverLabel from '../RoverLabel/index.jsx'; import ReplayReadyPopup from './ReplayReadyPopup.jsx'; function normalizeSources(list = []) { return list .map((entry) => { if (!entry?.type || !entry?.id) return null; return { type: entry.type, id: String(entry.id), label: entry.label || String(entry.id), color: entry.color || null, key: `${entry.type}:${entry.id}`, }; }) .filter(Boolean); } function selectedKeysEqual(left, right) { if (Object.is(left, right)) return true; if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) return false; for (let idx = 0; idx < left.length; idx += 1) { if (left[idx] !== right[idx]) return false; } return true; } export default function ReplaySourcesPanel({ panelId = 'replay-sources', fillHeight = false }) { const replaySources = useSessionSelector((state) => state.session?.replaySources ?? []); const mode = useSessionSelector((state) => state.session?.mode || null); const assignmentRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null); const roster = useSessionSelector((state) => state.session?.roster ?? []); const replayState = useSessionSelector((state) => state.session?.replay || null); const latestReplay = useSessionSelector((state) => state.latestReplay); const { triggerReplay } = useSessionActions(); const sources = useMemo(() => normalizeSources(replaySources || []), [replaySources]); const { value: settings, save: saveSettings } = useSettingsNamespace('replaySources', {}); const [selected, setSelected] = useState([]); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [success, setSuccess] = useState(null); const [title, setTitle] = useState(''); const [titleDirty, setTitleDirty] = useState(false); const [includeSidebar, setIncludeSidebar] = useState(true); const [activeJobId, setActiveJobId] = useState(null); const [dismissedPanelReplayId, setDismissedPanelReplayId] = useState(null); // Settings keys include the panel id because the same replay source control is // mounted in desktop, portrait, and landscape layouts with independent saved UI // preferences. Pulling the values into named constants also gives hook // dependencies primitive values instead of hard-to-analyze computed expressions. const includeSidebarSettingKey = `${panelId}:includeSidebar`; const titleSettingKey = `${panelId}:title`; const savedIncludeSidebar = settings?.[includeSidebarSettingKey]; const savedTitle = settings?.[titleSettingKey]; const activeReplayJob = useSessionSelector((state) => ( activeJobId ? state.replayJobs?.[activeJobId] || null : null )); const latestReplayJobId = latestReplay?.jobId || null; const showPanelReplay = Boolean( latestReplay?.url && latestReplayJobId && dismissedPanelReplayId !== latestReplayJobId, ); const defaults = useMemo(() => { const roverId = assignmentRoverId; if (roverId) { return [`rover:${roverId}`]; } return []; }, [assignmentRoverId]); const defaultTitle = useMemo(() => { const roverId = assignmentRoverId || null; const roverName = roverId && Array.isArray(roster) ? roster.find((entry) => String(entry?.id) === String(roverId))?.name || roverId : ''; return roverName ? `Replay: ${roverName}` : 'Replay'; }, [assignmentRoverId, roster]); useEffect(() => { // Assignment changes replace the default selection, but identical defaults // should not create a fresh array because that would force all source // checkboxes to commit with the same checked values. setSelected((prev) => (selectedKeysEqual(prev, defaults) ? prev : defaults)); }, [defaults]); useEffect(() => { if (typeof savedIncludeSidebar === 'boolean') { setIncludeSidebar(savedIncludeSidebar); return; } setIncludeSidebar(true); }, [savedIncludeSidebar]); useEffect(() => { if (typeof savedTitle === 'string') { setTitle(savedTitle); setTitleDirty(true); return; } setTitleDirty(false); }, [savedTitle]); useEffect(() => { if (!titleDirty) { setTitle(defaultTitle); } }, [defaultTitle, titleDirty]); useEffect(() => { const allowed = new Set(sources.map((source) => source.key)); setSelected((prev) => { const next = prev.filter((key) => allowed.has(key)); // Pruning is only meaningful when a selected source disappeared. Returning // the existing array for the common no-op path stops this effect from // scheduling a render after every parent/session render. return selectedKeysEqual(prev, next) ? prev : next; }); }, [sources]); const grouped = useMemo(() => { return { rovers: sources.filter((source) => source.type === 'rover'), // PTZ is presented with room cameras because there is only one fixed room // PTZ camera and it should not create a separate source category. rooms: sources.filter((source) => source.type === 'room' || source.type === 'ptz'), }; }, [sources]); const hasReplayCooldown = Boolean(replayState?.lastTriggeredAt && replayState?.cooldownMs); const replayCooldownEndsAt = hasReplayCooldown ? replayState.lastTriggeredAt + replayState.cooldownMs : 0; const cooldownNow = useSharedClock(1000, hasReplayCooldown); const remainingMs = useMemo(() => { if (!hasReplayCooldown) return 0; /* The button only shows whole seconds, so a shared one-second clock gives the same useful information without each mounted replay panel owning a 250ms interval. The exact server cooldown still decides whether the action is accepted; this value is only the local disabled-state/display estimate. */ const next = replayCooldownEndsAt - cooldownNow; return Math.max(0, next); }, [cooldownNow, hasReplayCooldown, replayCooldownEndsAt]); const replayDisabled = busy || mode === 'lockdown' || remainingMs > 0 || !selected.length; const selectedSet = useMemo(() => { // Checkbox rendering asks the same membership question for every source. // A Set avoids repeated linear scans and, more importantly, gives memoized // child lists a stable value while the selected keys have not changed. return new Set(selected); }, [selected]); const activeJobStatusText = useMemo(() => { if (!activeReplayJob?.status) return null; const titleText = activeReplayJob.title ? `: ${activeReplayJob.title}` : ''; switch (activeReplayJob.status) { case 'accepted': return `Replay accepted${titleText}`; case 'building': return `Replay building${titleText}`; case 'uploading': return `Replay uploading${titleText}`; case 'ready': return `Replay ready${titleText}`; case 'failed': return activeReplayJob.message || `Replay failed${titleText}`; default: return activeReplayJob.message || `Replay ${activeReplayJob.status}${titleText}`; } }, [activeReplayJob]); const toggleKey = useCallback((key) => { setSelected((prev) => { return prev.includes(key) ? prev.filter((value) => value !== key) : [...prev, key]; }); setSuccess(null); }, []); const handleReplay = useCallback(async () => { if (replayDisabled) return; setBusy(true); setError(null); setSuccess(null); setActiveJobId(null); try { const payload = selected.map((key) => { const [type, id] = key.split(':'); return { type, id }; }); const resolvedTitle = String(title || '').trim() || defaultTitle; const resp = await triggerReplay({ sources: payload, title: resolvedTitle, includeSidebar }); if (resp?.jobId) { // The socket acknowledgement is only the start of the async job. // Later replay:status events update this same job id as Discord builds and uploads the video. setActiveJobId(resp.jobId); setSuccess('Replay accepted.'); } else { setSuccess('Replay accepted.'); } } catch (err) { setError(err.message); } finally { setBusy(false); } }, [defaultTitle, includeSidebar, replayDisabled, selected, title, triggerReplay]); const listWrapClass = fillHeight ? 'flex-1 min-h-0 overflow-y-auto' : ''; return (