mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
replay in web UI
This commit is contained in:
@@ -39,6 +39,7 @@ discord:
|
||||
announcements: "123456789012345678"
|
||||
adminAlerts: "123456789012345678"
|
||||
chatBridge: "123456789012345678"
|
||||
replay: "123456789012345678"
|
||||
roles:
|
||||
announcementPing: "123456789012345678"
|
||||
adminPing: "123456789012345678"
|
||||
|
||||
@@ -25,5 +25,6 @@ require('./src/services/logStreamService');
|
||||
require('./src/services/homeAssistantService');
|
||||
require('./src/services/sessionService');
|
||||
require('./src/services/batteryManager');
|
||||
require('./src/services/replaySocketService');
|
||||
require('./src/services/discordBotService');
|
||||
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
@@ -11,8 +11,8 @@
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
||||
<title>Multi Roomba Rover</title>
|
||||
<script type="module" crossorigin src="/assets/index-ff57kxdF.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DUNdvQw9.css">
|
||||
<script type="module" crossorigin src="/assets/index-B_C-RuNb.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Dl56RSiM.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -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) {
|
||||
if (getMode() === MODES.LOCKDOWN) {
|
||||
await message.reply({
|
||||
@@ -325,10 +343,7 @@ async function handleReplayCommand(message) {
|
||||
try {
|
||||
const buffer = await buildReplayVideo();
|
||||
const attachment = new AttachmentBuilder(buffer, { name: 'replay.mp4' });
|
||||
const caption = [
|
||||
`Replay requested by ${requester}.`,
|
||||
buildDriverCaption(),
|
||||
].join(' ');
|
||||
const caption = buildReplayCaption(requester);
|
||||
await message.reply({
|
||||
content: sanitizeMentions(caption),
|
||||
files: [attachment],
|
||||
@@ -701,6 +716,11 @@ function handleBusEvent(event) {
|
||||
});
|
||||
updatePresence();
|
||||
break;
|
||||
case 'replay.requested':
|
||||
sendReplayToChannel(payload?.channelId, payload?.requester).catch((err) => {
|
||||
logger.warn('Replay send failed', err.message);
|
||||
});
|
||||
break;
|
||||
default:
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useSession } from '../context/SessionContext.jsx';
|
||||
import { useSettingsNamespace } from '../settings/index.js';
|
||||
import { useRoomCameraSnapshots } from '../hooks/useRoomCameraSnapshots.js';
|
||||
@@ -29,7 +29,7 @@ export default function RoomCameraPanel({
|
||||
hideHeader = false,
|
||||
panelId = null,
|
||||
}) {
|
||||
const { session } = useSession();
|
||||
const { session, triggerReplay } = useSession();
|
||||
const cameras = session?.roomCameras || [];
|
||||
const feedMap = useRoomCameraSnapshots(cameras.map((camera) => ({ id: camera.id })));
|
||||
const { value: orientationSettings, save: saveOrientationSettings } = useSettingsNamespace('roomCameraPanels', {});
|
||||
@@ -53,6 +53,42 @@ export default function RoomCameraPanel({
|
||||
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 {
|
||||
await triggerReplay();
|
||||
} catch (err) {
|
||||
setReplayError(err.message);
|
||||
} finally {
|
||||
setReplayBusy(false);
|
||||
}
|
||||
};
|
||||
const applyOrientation = (next) => {
|
||||
setOrientation(next);
|
||||
if (panelId) {
|
||||
@@ -72,23 +108,39 @@ export default function RoomCameraPanel({
|
||||
<p>Room cameras</p>
|
||||
<span className="text-xs text-slate-500">{cameras.length}</span>
|
||||
</div>
|
||||
{showLayoutToggle && (
|
||||
<div className="flex items-center gap-0.5 text-xs">
|
||||
<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 className="flex flex-wrap items-center gap-1 text-xs">
|
||||
<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>
|
||||
<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>
|
||||
</header>
|
||||
)}
|
||||
<div className={containerClass}>
|
||||
|
||||
@@ -15,6 +15,7 @@ const SessionContext = createContext({
|
||||
homeAssistantToggle: async () => {},
|
||||
homeAssistantSetState: async () => {},
|
||||
setNickname: async () => {},
|
||||
triggerReplay: async () => {},
|
||||
});
|
||||
|
||||
function useAckEmitter(socket) {
|
||||
@@ -96,6 +97,7 @@ export function SessionProvider({ children }) {
|
||||
homeAssistantSetState: (entityId, state) =>
|
||||
emitWithAck('homeAssistant:setState', { entityId, state }),
|
||||
setNickname: (nickname) => emitWithAck('nickname:set', { nickname }),
|
||||
triggerReplay: () => emitWithAck('replay:trigger', {}),
|
||||
pushAlert: (alert) =>
|
||||
setAlerts((prev) => [
|
||||
...prev.slice(-49),
|
||||
|
||||
Reference in New Issue
Block a user