replay in web UI

This commit is contained in:
legop3
2026-01-03 21:38:19 -05:00
parent 242fe461d9
commit 6d55073bfb
10 changed files with 154 additions and 33 deletions
+1
View File
@@ -39,6 +39,7 @@ discord:
announcements: "123456789012345678" announcements: "123456789012345678"
adminAlerts: "123456789012345678" adminAlerts: "123456789012345678"
chatBridge: "123456789012345678" chatBridge: "123456789012345678"
replay: "123456789012345678"
roles: roles:
announcementPing: "123456789012345678" announcementPing: "123456789012345678"
adminPing: "123456789012345678" adminPing: "123456789012345678"
+1
View File
@@ -25,5 +25,6 @@ require('./src/services/logStreamService');
require('./src/services/homeAssistantService'); require('./src/services/homeAssistantService');
require('./src/services/sessionService'); require('./src/services/sessionService');
require('./src/services/batteryManager'); require('./src/services/batteryManager');
require('./src/services/replaySocketService');
require('./src/services/discordBotService'); require('./src/services/discordBotService');
require('./src/services/httpServer'); require('./src/services/httpServer');
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -11,8 +11,8 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" /> <meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
<title>Multi Roomba Rover</title> <title>Multi Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-ff57kxdF.js"></script> <script type="module" crossorigin src="/assets/index-B_C-RuNb.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DUNdvQw9.css"> <link rel="stylesheet" crossorigin href="/assets/index-Dl56RSiM.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+24 -4
View File
@@ -300,6 +300,24 @@ async function buildReplayVideo() {
} }
} }
function buildReplayCaption(requester) {
const requesterLabel = requester || 'unknown';
return [
`Replay requested by ${requesterLabel}.`,
buildDriverCaption(),
].join(' ');
}
async function sendReplayToChannel(channelId, requester) {
if (!channelId) {
throw new Error('Replay channel not configured');
}
const buffer = await buildReplayVideo();
const attachment = new AttachmentBuilder(buffer, { name: 'replay.mp4' });
const caption = buildReplayCaption(requester);
await sendToChannel(channelId, caption, { files: [attachment] }, { parse: [] });
}
async function handleReplayCommand(message) { async function handleReplayCommand(message) {
if (getMode() === MODES.LOCKDOWN) { if (getMode() === MODES.LOCKDOWN) {
await message.reply({ await message.reply({
@@ -325,10 +343,7 @@ async function handleReplayCommand(message) {
try { try {
const buffer = await buildReplayVideo(); const buffer = await buildReplayVideo();
const attachment = new AttachmentBuilder(buffer, { name: 'replay.mp4' }); const attachment = new AttachmentBuilder(buffer, { name: 'replay.mp4' });
const caption = [ const caption = buildReplayCaption(requester);
`Replay requested by ${requester}.`,
buildDriverCaption(),
].join(' ');
await message.reply({ await message.reply({
content: sanitizeMentions(caption), content: sanitizeMentions(caption),
files: [attachment], files: [attachment],
@@ -701,6 +716,11 @@ function handleBusEvent(event) {
}); });
updatePresence(); updatePresence();
break; break;
case 'replay.requested':
sendReplayToChannel(payload?.channelId, payload?.requester).catch((err) => {
logger.warn('Replay send failed', err.message);
});
break;
default: default:
break; break;
} }
@@ -0,0 +1,45 @@
const io = require('../globals/io');
const logger = require('../globals/logger').child('replaySocket');
const { getMode, MODES } = require('./modeManager');
const { publishEvent } = require('./eventBus');
const { tryTriggerReplay } = require('./replayService');
const { getNickname } = require('./nicknameService');
const { loadConfig } = require('../helpers/configLoader');
const config = loadConfig();
const discordConfig = config.discord || {};
function buildRequesterLabel(socket) {
return getNickname(socket) || socket?.data?.user?.username || socket?.id || 'unknown';
}
io.on('connection', (socket) => {
socket.on('replay:trigger', (payload = {}, cb = () => {}) => {
if (getMode() === MODES.LOCKDOWN) {
cb({ error: 'Replay disabled in lockdown', state: null });
return;
}
const channelId = discordConfig?.channels?.replay || null;
if (!channelId) {
cb({ error: 'Replay channel not configured', state: null });
return;
}
const requester = buildRequesterLabel(socket);
const attempt = tryTriggerReplay({ by: { source: 'web', requester } });
if (!attempt.ok) {
cb({ error: 'Replay cooldown active', remainingMs: attempt.remainingMs, state: attempt.state });
return;
}
publishEvent({
source: 'replaySocket',
type: 'replay.requested',
payload: {
channelId,
requester,
requestedBy: { socketId: socket.id },
},
});
logger.info('Replay requested via web', { socketId: socket.id });
cb({ success: true, state: attempt.state });
});
});
+70 -18
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useSession } from '../context/SessionContext.jsx'; import { useSession } from '../context/SessionContext.jsx';
import { useSettingsNamespace } from '../settings/index.js'; import { useSettingsNamespace } from '../settings/index.js';
import { useRoomCameraSnapshots } from '../hooks/useRoomCameraSnapshots.js'; import { useRoomCameraSnapshots } from '../hooks/useRoomCameraSnapshots.js';
@@ -29,7 +29,7 @@ export default function RoomCameraPanel({
hideHeader = false, hideHeader = false,
panelId = null, panelId = null,
}) { }) {
const { session } = useSession(); const { session, triggerReplay } = useSession();
const cameras = session?.roomCameras || []; const cameras = session?.roomCameras || [];
const feedMap = useRoomCameraSnapshots(cameras.map((camera) => ({ id: camera.id }))); const feedMap = useRoomCameraSnapshots(cameras.map((camera) => ({ id: camera.id })));
const { value: orientationSettings, save: saveOrientationSettings } = useSettingsNamespace('roomCameraPanels', {}); const { value: orientationSettings, save: saveOrientationSettings } = useSettingsNamespace('roomCameraPanels', {});
@@ -53,6 +53,42 @@ export default function RoomCameraPanel({
const containerClass = const containerClass =
effectiveOrientation === 'vertical' ? 'flex flex-col gap-0.5' : 'grid gap-0.5 md:grid-cols-2'; effectiveOrientation === 'vertical' ? 'flex flex-col gap-0.5' : 'grid gap-0.5 md:grid-cols-2';
const showLayoutToggle = !hideLayoutToggle && !forcedOrientation && cameras.length > 0; 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 {
await triggerReplay();
} catch (err) {
setReplayError(err.message);
} finally {
setReplayBusy(false);
}
};
const applyOrientation = (next) => { const applyOrientation = (next) => {
setOrientation(next); setOrientation(next);
if (panelId) { if (panelId) {
@@ -72,23 +108,39 @@ export default function RoomCameraPanel({
<p>Room cameras</p> <p>Room cameras</p>
<span className="text-xs text-slate-500">{cameras.length}</span> <span className="text-xs text-slate-500">{cameras.length}</span>
</div> </div>
{showLayoutToggle && ( <div className="flex flex-wrap items-center gap-1 text-xs">
<div className="flex items-center gap-0.5 text-xs"> <button
<span className="text-slate-500">Layout:</span> type="button"
<div className="inline-flex overflow-hidden rounded border border-slate-700"> className={`rounded border px-1.5 py-0.5 ${
{ORIENTATIONS.map((option) => ( replayDisabled
<button ? 'border-slate-700 text-slate-500'
key={option} : 'border-slate-500 text-slate-200 hover:border-slate-300 hover:text-white'
type="button" }`}
className={`px-1 py-0.5 ${effectiveOrientation === option ? 'bg-slate-600 text-white' : 'bg-transparent text-slate-400 hover:text-white'}`} onClick={handleReplay}
onClick={() => applyOrientation(option)} disabled={replayDisabled}
> title={session?.mode === 'lockdown' ? 'Replay disabled in lockdown' : 'Send replay to Discord'}
{option === 'vertical' ? 'Vertical' : 'Grid'} >
</button> {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>
<div className="inline-flex overflow-hidden rounded border border-slate-700">
{ORIENTATIONS.map((option) => (
<button
key={option}
type="button"
className={`px-1 py-0.5 ${effectiveOrientation === option ? 'bg-slate-600 text-white' : 'bg-transparent text-slate-400 hover:text-white'}`}
onClick={() => applyOrientation(option)}
>
{option === 'vertical' ? 'Vertical' : 'Grid'}
</button>
))}
</div>
</div> </div>
</div> )}
)} </div>
</header> </header>
)} )}
<div className={containerClass}> <div className={containerClass}>
+2
View File
@@ -15,6 +15,7 @@ const SessionContext = createContext({
homeAssistantToggle: async () => {}, homeAssistantToggle: async () => {},
homeAssistantSetState: async () => {}, homeAssistantSetState: async () => {},
setNickname: async () => {}, setNickname: async () => {},
triggerReplay: async () => {},
}); });
function useAckEmitter(socket) { function useAckEmitter(socket) {
@@ -96,6 +97,7 @@ export function SessionProvider({ children }) {
homeAssistantSetState: (entityId, state) => homeAssistantSetState: (entityId, state) =>
emitWithAck('homeAssistant:setState', { entityId, state }), emitWithAck('homeAssistant:setState', { entityId, state }),
setNickname: (nickname) => emitWithAck('nickname:set', { nickname }), setNickname: (nickname) => emitWithAck('nickname:set', { nickname }),
triggerReplay: () => emitWithAck('replay:trigger', {}),
pushAlert: (alert) => pushAlert: (alert) =>
setAlerts((prev) => [ setAlerts((prev) => [
...prev.slice(-49), ...prev.slice(-49),