mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -04:00
lets see . . .
This commit is contained in:
@@ -5,6 +5,7 @@ import { useControlSystem } from '../controls/index.js';
|
||||
import { formatKeyLabel } from '../controls/keymapUtils.js';
|
||||
import TopDownMap from './TopDownMap.jsx';
|
||||
import RoverRoster from './RoverRoster.jsx';
|
||||
import ReplaySourcesPanel from './ReplaySourcesPanel.jsx';
|
||||
import DriveDockAction, { useDriveDockState } from './DriveDockAction.jsx';
|
||||
|
||||
export function RoverRosterPanel({ title = 'Rovers' }) {
|
||||
@@ -69,7 +70,14 @@ export default function ControlSummary({ showRoster = true }) {
|
||||
{!hideInlineControls ? <InlineCameraTilt keymap={keymap} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
{showRoster ? <RoverRosterPanel /> : null}
|
||||
{showRoster ? (
|
||||
<div className="grid gap-0.5 md:grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)]">
|
||||
<div className="aspect-square">
|
||||
<ReplaySourcesPanel />
|
||||
</div>
|
||||
<RoverRosterPanel />
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useSession } from '../context/SessionContext.jsx';
|
||||
import { useSettingsNamespace } from '../settings/index.js';
|
||||
|
||||
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),
|
||||
key: `${entry.type}:${entry.id}`,
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export default function ReplaySourcesPanel({ panelId = 'replay-sources' }) {
|
||||
const { session, triggerReplay } = useSession();
|
||||
const sources = normalizeSources(session?.replaySources || []);
|
||||
const { value: settings, save: saveSettings } = useSettingsNamespace('replaySources', {});
|
||||
const [selected, setSelected] = useState([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const replayState = session?.replay || null;
|
||||
const [remainingMs, setRemainingMs] = useState(0);
|
||||
|
||||
const defaults = useMemo(() => {
|
||||
const roverId = session?.assignment?.roverId;
|
||||
if (roverId) {
|
||||
return [`rover:${roverId}`];
|
||||
}
|
||||
return [];
|
||||
}, [session?.assignment?.roverId]);
|
||||
|
||||
useEffect(() => {
|
||||
const saved = settings?.[panelId];
|
||||
if (Array.isArray(saved) && saved.length) {
|
||||
setSelected(saved);
|
||||
return;
|
||||
}
|
||||
setSelected(defaults);
|
||||
}, [settings?.[panelId], defaults, panelId]);
|
||||
|
||||
useEffect(() => {
|
||||
const allowed = new Set(sources.map((source) => source.key));
|
||||
setSelected((prev) => prev.filter((key) => allowed.has(key)));
|
||||
}, [sources]);
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
return {
|
||||
rovers: sources.filter((source) => source.type === 'rover'),
|
||||
rooms: sources.filter((source) => source.type === 'room'),
|
||||
};
|
||||
}, [sources]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!replayState?.lastTriggeredAt || !replayState?.cooldownMs) {
|
||||
setRemainingMs(0);
|
||||
return undefined;
|
||||
}
|
||||
const update = () => {
|
||||
const next = replayState.lastTriggeredAt + replayState.cooldownMs - Date.now();
|
||||
setRemainingMs(Math.max(0, next));
|
||||
};
|
||||
update();
|
||||
const interval = setInterval(update, 250);
|
||||
return () => clearInterval(interval);
|
||||
}, [replayState?.lastTriggeredAt, replayState?.cooldownMs, replayState?.remainingMs]);
|
||||
|
||||
const replayDisabled = busy || session?.mode === 'lockdown' || remainingMs > 0 || !selected.length;
|
||||
|
||||
const toggleKey = (key) => {
|
||||
setSelected((prev) => {
|
||||
const next = prev.includes(key) ? prev.filter((value) => value !== key) : [...prev, key];
|
||||
saveSettings((current) => ({ ...(current || {}), [panelId]: next }));
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleReplay = async () => {
|
||||
if (replayDisabled) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const payload = selected.map((key) => {
|
||||
const [type, id] = key.split(':');
|
||||
return { type, id };
|
||||
});
|
||||
await triggerReplay(payload);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="panel h-full w-full">
|
||||
<div className="flex h-full flex-col gap-0.5 p-0.75 text-sm">
|
||||
<header className="flex items-center justify-between text-slate-300">
|
||||
<span className="text-xs uppercase tracking-wide text-slate-400">Replay Sources</span>
|
||||
<span className="text-xs text-slate-500">{sources.length}</span>
|
||||
</header>
|
||||
<div className="flex-1 overflow-auto pr-0.5">
|
||||
<GroupList title="Rovers" items={grouped.rovers} selected={selected} onToggle={toggleKey} />
|
||||
<GroupList title="Room Cams" items={grouped.rooms} selected={selected} onToggle={toggleKey} />
|
||||
</div>
|
||||
<div className="space-y-0.25">
|
||||
<button
|
||||
type="button"
|
||||
className={`w-full rounded border px-1 py-0.5 text-xs ${
|
||||
replayDisabled
|
||||
? 'border-slate-700 text-slate-500'
|
||||
: 'border-slate-500 text-slate-200 hover:border-slate-300 hover:text-white'
|
||||
}`}
|
||||
onClick={handleReplay}
|
||||
disabled={replayDisabled}
|
||||
>
|
||||
{remainingMs > 0 ? `Replay (${Math.ceil(remainingMs / 1000)}s)` : busy ? 'Replay…' : 'Replay'}
|
||||
</button>
|
||||
{error ? <div className="text-xs text-amber-400">{error}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupList({ title, items, selected, onToggle }) {
|
||||
if (!items.length) return null;
|
||||
return (
|
||||
<div className="mb-0.5 space-y-0.25">
|
||||
<div className="text-xs uppercase text-slate-500">{title}</div>
|
||||
<div className="space-y-0.25">
|
||||
{items.map((item) => (
|
||||
<label key={item.key} className="flex items-center gap-0.5 text-xs text-slate-200">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.includes(item.key)}
|
||||
onChange={() => onToggle(item.key)}
|
||||
className="accent-emerald-400"
|
||||
/>
|
||||
<span className="truncate">{item.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSession } from '../context/SessionContext.jsx';
|
||||
import { useSettingsNamespace } from '../settings/index.js';
|
||||
import { useRoomCameraSnapshots } from '../hooks/useRoomCameraSnapshots.js';
|
||||
@@ -29,21 +29,16 @@ export default function RoomCameraPanel({
|
||||
hideHeader = false,
|
||||
panelId = null,
|
||||
}) {
|
||||
const { session, triggerReplay } = useSession();
|
||||
const { session } = useSession();
|
||||
const cameras = session?.roomCameras || [];
|
||||
const feedMap = useRoomCameraSnapshots(cameras.map((camera) => ({ id: camera.id })));
|
||||
const { value: orientationSettings, save: saveOrientationSettings } = useSettingsNamespace('roomCameraPanels', {});
|
||||
const { value: replaySettings, save: saveReplaySettings } = useSettingsNamespace('roomCameraReplay', {});
|
||||
const [orientation, setOrientation] = useState(() =>
|
||||
normalizeOrientation(
|
||||
panelId ? orientationSettings?.[panelId] : defaultOrientation,
|
||||
'horizontal',
|
||||
),
|
||||
);
|
||||
const [selectedReplayCamera, setSelectedReplayCamera] = useState(() => {
|
||||
if (!panelId) return 'all';
|
||||
return replaySettings?.[panelId] || 'all';
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!panelId) return;
|
||||
@@ -52,62 +47,12 @@ export default function RoomCameraPanel({
|
||||
setOrientation(normalizeOrientation(stored, 'horizontal'));
|
||||
// only respond to changes for this panel id
|
||||
}, [panelId, orientationSettings?.[panelId]]);
|
||||
useEffect(() => {
|
||||
if (!panelId) return;
|
||||
const stored = replaySettings?.[panelId];
|
||||
if (!stored) return;
|
||||
setSelectedReplayCamera(stored);
|
||||
}, [panelId, replaySettings?.[panelId]]);
|
||||
const effectiveOrientation = forcedOrientation
|
||||
? normalizeOrientation(forcedOrientation, 'horizontal')
|
||||
: orientation;
|
||||
const containerClass =
|
||||
effectiveOrientation === 'vertical' ? 'flex flex-col gap-0.5' : 'grid gap-0.5 md:grid-cols-2';
|
||||
const showLayoutToggle = !hideLayoutToggle && !forcedOrientation && cameras.length > 0;
|
||||
const [replayBusy, setReplayBusy] = useState(false);
|
||||
const [replayError, setReplayError] = useState(null);
|
||||
const replayState = session?.replay || null;
|
||||
const replayRemainingMs = useMemo(() => {
|
||||
if (!replayState?.lastTriggeredAt || !replayState?.cooldownMs) return 0;
|
||||
const remaining = replayState.lastTriggeredAt + replayState.cooldownMs - Date.now();
|
||||
return Math.max(0, remaining);
|
||||
}, [replayState?.lastTriggeredAt, replayState?.cooldownMs, replayState?.remainingMs]);
|
||||
const [remainingMs, setRemainingMs] = useState(replayRemainingMs);
|
||||
const replayDisabled =
|
||||
replayBusy || session?.mode === 'lockdown' || !cameras.length || remainingMs > 0;
|
||||
|
||||
useEffect(() => {
|
||||
setRemainingMs(replayRemainingMs);
|
||||
if (!replayState?.lastTriggeredAt || !replayState?.cooldownMs) {
|
||||
return undefined;
|
||||
}
|
||||
const interval = setInterval(() => {
|
||||
const next = replayState.lastTriggeredAt + replayState.cooldownMs - Date.now();
|
||||
setRemainingMs(Math.max(0, next));
|
||||
}, 250);
|
||||
return () => clearInterval(interval);
|
||||
}, [replayState?.lastTriggeredAt, replayState?.cooldownMs, replayRemainingMs]);
|
||||
|
||||
const handleReplay = async () => {
|
||||
if (replayDisabled) return;
|
||||
setReplayError(null);
|
||||
setReplayBusy(true);
|
||||
try {
|
||||
const cameraId = selectedReplayCamera === 'all' ? null : selectedReplayCamera;
|
||||
await triggerReplay(cameraId);
|
||||
} catch (err) {
|
||||
setReplayError(err.message);
|
||||
} finally {
|
||||
setReplayBusy(false);
|
||||
}
|
||||
};
|
||||
const handleReplayCameraChange = (event) => {
|
||||
const value = event.target.value;
|
||||
setSelectedReplayCamera(value);
|
||||
if (panelId) {
|
||||
saveReplaySettings((current) => ({ ...(current || {}), [panelId]: value }));
|
||||
}
|
||||
};
|
||||
const applyOrientation = (next) => {
|
||||
setOrientation(next);
|
||||
if (panelId) {
|
||||
@@ -128,33 +73,6 @@ export default function RoomCameraPanel({
|
||||
<span className="text-xs text-slate-500">{cameras.length}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-1 text-xs">
|
||||
<select
|
||||
className="rounded border border-slate-700 bg-black/40 px-1 py-0.5 text-slate-200"
|
||||
value={selectedReplayCamera}
|
||||
onChange={handleReplayCameraChange}
|
||||
aria-label="Replay camera"
|
||||
>
|
||||
<option value="all">All cameras</option>
|
||||
{cameras.map((camera) => (
|
||||
<option key={camera.id} value={camera.id}>
|
||||
{camera.name || camera.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded border px-1.5 py-0.5 ${
|
||||
replayDisabled
|
||||
? 'border-slate-700 text-slate-500'
|
||||
: 'border-slate-500 text-slate-200 hover:border-slate-300 hover:text-white'
|
||||
}`}
|
||||
onClick={handleReplay}
|
||||
disabled={replayDisabled}
|
||||
title={session?.mode === 'lockdown' ? 'Replay disabled in lockdown' : 'Send replay to Discord'}
|
||||
>
|
||||
{remainingMs > 0 ? `Replay (${Math.ceil(remainingMs / 1000)}s)` : replayBusy ? 'Replay…' : 'Replay'}
|
||||
</button>
|
||||
{replayError && <span className="text-amber-400">{replayError}</span>}
|
||||
{showLayoutToggle && (
|
||||
<div className="flex items-center gap-0.5">
|
||||
<span className="text-slate-500">Layout:</span>
|
||||
|
||||
@@ -97,7 +97,7 @@ export function SessionProvider({ children }) {
|
||||
homeAssistantSetState: (entityId, state) =>
|
||||
emitWithAck('homeAssistant:setState', { entityId, state }),
|
||||
setNickname: (nickname) => emitWithAck('nickname:set', { nickname }),
|
||||
triggerReplay: (cameraId = null) => emitWithAck('replay:trigger', { cameraId }),
|
||||
triggerReplay: (sources = []) => emitWithAck('replay:trigger', { sources }),
|
||||
pushAlert: (alert) =>
|
||||
setAlerts((prev) => [
|
||||
...prev.slice(-49),
|
||||
|
||||
Reference in New Issue
Block a user