mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-17 18:10:47 -04:00
replay in web UI
This commit is contained in:
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,8 +108,23 @@ 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>
|
||||||
|
<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 && (
|
{showLayoutToggle && (
|
||||||
<div className="flex items-center gap-0.5 text-xs">
|
<div className="flex items-center gap-0.5">
|
||||||
<span className="text-slate-500">Layout:</span>
|
<span className="text-slate-500">Layout:</span>
|
||||||
<div className="inline-flex overflow-hidden rounded border border-slate-700">
|
<div className="inline-flex overflow-hidden rounded border border-slate-700">
|
||||||
{ORIENTATIONS.map((option) => (
|
{ORIENTATIONS.map((option) => (
|
||||||
@@ -89,6 +140,7 @@ export default function RoomCameraPanel({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
)}
|
)}
|
||||||
<div className={containerClass}>
|
<div className={containerClass}>
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
Reference in New Issue
Block a user