// 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 { useSocket } from '../../context/SocketContext.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, defaultSelectedKey = null, }) { 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 replayStatus = useSessionSelector((state) => state.replayStatus); const socket = useSocket(); 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 [title, setTitle] = useState(''); const [titleDirty, setTitleDirty] = useState(false); const [includeSidebar, setIncludeSidebar] = useState(true); const [panelReplay, setPanelReplay] = 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 showPanelReplay = Boolean(panelReplay?.url); useEffect(() => { const handleReplayReady = (replay = {}) => { if (!replay?.url) return; /* A replay popup is an event owned by the lifetime of this mounted panel, not retained application history. Listening to the live socket event means a ready replay opens immediately, while switching tabs away and back cannot replay an event that happened before the new mount. */ setPanelReplay(replay); }; socket.on('replay:ready', handleReplayReady); return () => socket.off('replay:ready', handleReplayReady); }, [socket]); const availableDefaultKey = useMemo(() => { // PTZ layouts provide their camera key explicitly so entering the dedicated // camera page does not inherit the user's assigned rover. Waiting until the // source is actually advertised also handles the initial session load: an // unavailable key is never left selected, but it becomes the default as // soon as the server publishes that replay source. if (defaultSelectedKey && sources.some((source) => source.key === defaultSelectedKey)) { return defaultSelectedKey; } const roverKey = assignmentRoverId ? `rover:${assignmentRoverId}` : null; if (roverKey && sources.some((source) => source.key === roverKey)) { return roverKey; } return null; }, [assignmentRoverId, defaultSelectedKey, sources]); const defaults = useMemo(() => (availableDefaultKey ? [availableDefaultKey] : []), [availableDefaultKey]); 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 replayStatusText = useMemo(() => { if (!replayStatus?.status) return null; const titleText = replayStatus.title ? `: ${replayStatus.title}` : ''; switch (replayStatus.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 replayStatus.message || `Replay failed${titleText}`; default: return replayStatus.message || `Replay ${replayStatus.status}${titleText}`; } }, [replayStatus]); const toggleKey = useCallback((key) => { setSelected((prev) => { return prev.includes(key) ? prev.filter((value) => value !== key) : [...prev, key]; }); }, []); const handleReplay = useCallback(async () => { if (replayDisabled) return; setBusy(true); setError(null); try { const payload = selected.map((key) => { const [type, id] = key.split(':'); return { type, id }; }); const resolvedTitle = String(title || '').trim() || defaultTitle; await triggerReplay({ sources: payload, title: resolvedTitle, includeSidebar }); /* Do not set panel-local success state after acknowledgement. The server broadcasts the authoritative accepted/building/uploading/ready stages, and every replay panel renders that one shared status progression. */ } 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 (