// 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 (
{showPanelReplay ? (
setDismissedPanelReplayId(latestReplayJobId)} />
) : null}
{error ?
{error}
: null} {activeJobStatusText ? (
{activeJobStatusText}
) : success ?
{success}
: null}
{/* The label has fixed-width content, while the input owns the remaining row space. This keeps the title control compact without making the input compete with other controls or wrapping unpredictably on narrow panel layouts. */} { const next = event.target.value; setTitle(next); setTitleDirty(true); saveSettings((current) => ({ ...(current || {}), [titleSettingKey]: next })); }} placeholder={defaultTitle} maxLength={120} />
{/* The sidebar toggle is stable-width, so the replay button can expand into the rest of the row and preserve the compact two-row control layout. */}
); } const GroupList = React.memo(function GroupList({ title, items, selectedSet, onToggle }) { if (!items.length) return null; return (
{title}
{items.map((item) => ( ))}
); });