mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
better bettter replayses
This commit is contained in:
@@ -11,6 +11,7 @@
|
||||
"bcrypt": "^6.0.0",
|
||||
"discord.js": "^14.25.1",
|
||||
"express": "^4.19.2",
|
||||
"fuse.js": "^7.4.2",
|
||||
"home-assistant-js-websocket": "^3.1.2",
|
||||
"js-yaml": "^4.1.1",
|
||||
"morgan": "^1.10.0",
|
||||
|
||||
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
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="Roomba Rover" />
|
||||
<title>Roomba Rover</title>
|
||||
<script type="module" crossorigin src="/assets/index-DyBCFpEY.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DaajoH22.css">
|
||||
<script type="module" crossorigin src="/assets/index-BysYkDl7.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Dr65gZI2.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -24,13 +24,16 @@ function createChannelIO({ client, logger, sanitizeMentions }) {
|
||||
|
||||
async function sendToChannel(id, content, options = {}, allowedMentions = { parse: [] }, sanitizeContent = true) {
|
||||
const channel = await fetchChannel(id);
|
||||
if (!channel) return;
|
||||
if (!channel) return null;
|
||||
try {
|
||||
const messageContent = sanitizeContent ? sanitizeMentions(content) : content;
|
||||
await channel.send({ content: messageContent, allowedMentions, ...options });
|
||||
// Replay uploads need the returned message so the server can extract Discord's attachment URL
|
||||
// and broadcast it to the Web UI instead of streaming video from the home server.
|
||||
return await channel.send({ content: messageContent, allowedMentions, ...options });
|
||||
} catch (err) {
|
||||
logger.warn('Failed to send Discord message', { id, error: err.message });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function typingCacheKey(guildId, typingId) {
|
||||
|
||||
@@ -1,112 +1,130 @@
|
||||
// Discord Replay Command
|
||||
// Purpose: Handles replay capture requests from Discord.
|
||||
// Scope: Resolves source selectors, enforces cooldowns, and replies with replay video attachment.
|
||||
// Purpose: Handles replay capture requests from Discord through the shared replay job/status workflow.
|
||||
// Scope: Resolves sources, enforces cooldowns, reports job progress, uploads video, and broadcasts media URLs.
|
||||
const { AttachmentBuilder } = require('discord.js');
|
||||
const io = require('../../../globals/io');
|
||||
const {
|
||||
DEFAULT_ALLOWED_MENTIONS,
|
||||
buildReplayJobId,
|
||||
createReplayJob,
|
||||
createJobStatusEmitter,
|
||||
createReplaySourceResolver,
|
||||
createReplayCaptionBuilder,
|
||||
startDiscordTypingLoop,
|
||||
sanitizeReplayTitleForFilename,
|
||||
firstAttachmentFromMessage,
|
||||
buildDiscordReplayMediaPayload,
|
||||
buildAcceptedMessage,
|
||||
buildStatusMessage,
|
||||
normalizeUserError,
|
||||
} = require('../replayWorkflow');
|
||||
|
||||
function createReplayCommand({ getMode, MODES, tryTriggerReplay, getReplaySources, getDefaultDiscordSources, validateSources, buildReplayVideo, sanitizeMentions, getActiveDrivers, getNickname, rovers }) {
|
||||
function normalizeReplayQuery(input) { return String(input || '').trim().toLowerCase(); }
|
||||
function sanitizeReplayTitleForFilename(title) {
|
||||
const cleaned = String(title || '').replace(/[\\/:*?"<>|]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 96);
|
||||
return cleaned || 'replay';
|
||||
}
|
||||
function buildDefaultReplayTitle(requester, sources = []) {
|
||||
const requesterLabel = String(requester || 'Someone').trim() || 'Someone';
|
||||
const roverSource = (Array.isArray(sources) ? sources : []).find((entry) => entry?.type === 'rover');
|
||||
const roverLabel = roverSource?.label || roverSource?.id || 'a rover';
|
||||
return `${requesterLabel} driving ${roverLabel}`;
|
||||
}
|
||||
|
||||
function buildReplayDriverLines(requester, sources = []) {
|
||||
const activeDrivers = getActiveDrivers();
|
||||
const roverSources = (Array.isArray(sources) ? sources : []).filter((entry) => entry?.type === 'rover');
|
||||
const requestedRoverIds = new Set(roverSources.map((entry) => String(entry.id)));
|
||||
const lines = [];
|
||||
requestedRoverIds.forEach((roverId) => {
|
||||
const socketId = activeDrivers?.[roverId];
|
||||
if (!socketId) return;
|
||||
const socket = io.sockets.sockets.get(socketId);
|
||||
const nickname = getNickname(socket) || socket?.data?.user?.username || socketId;
|
||||
const record = rovers.get(roverId);
|
||||
const roverName = record?.meta?.name || record?.id || roverId;
|
||||
const isAuthor = String(nickname).toLowerCase() === String(requester || '').toLowerCase();
|
||||
lines.push(`${nickname} driving ${roverName}${isAuthor ? ' **author**' : ''}`);
|
||||
function createReplayCommand({
|
||||
logger,
|
||||
getMode,
|
||||
MODES,
|
||||
tryTriggerReplay,
|
||||
getReplaySources,
|
||||
getDefaultDiscordSources,
|
||||
validateSources,
|
||||
buildReplayVideo,
|
||||
sanitizeMentions,
|
||||
getActiveDrivers,
|
||||
getNickname,
|
||||
rovers,
|
||||
}) {
|
||||
const sourceResolver = createReplaySourceResolver({
|
||||
rovers,
|
||||
getReplaySources,
|
||||
getDefaultDiscordSources,
|
||||
validateSources,
|
||||
});
|
||||
return lines;
|
||||
}
|
||||
const jobStatus = createJobStatusEmitter({ io, logger, sanitizeMentions });
|
||||
const replayCaption = createReplayCaptionBuilder({ io, rovers, getActiveDrivers, getNickname, sanitizeMentions });
|
||||
|
||||
function buildDriverCaption() {
|
||||
const activeDrivers = getActiveDrivers();
|
||||
const roster = Array.from(rovers.values());
|
||||
if (!roster.length) return 'Drivers: no rovers online.';
|
||||
const entries = roster.map((record) => {
|
||||
const driverId = activeDrivers[record.id];
|
||||
if (!driverId) return `${record.meta?.name || record.id}: none`;
|
||||
const socket = io.sockets.sockets.get(driverId);
|
||||
const nickname = getNickname(socket) || socket?.data?.user?.username || driverId;
|
||||
return `${record.meta?.name || record.id}: ${nickname}`;
|
||||
});
|
||||
return `Drivers: ${entries.join(', ')}`;
|
||||
}
|
||||
|
||||
function buildReplayCaption({ requester, usedSources = [], missingSources = [], title }) {
|
||||
const lines = [];
|
||||
if (title) {
|
||||
lines.push(`**${title}**`);
|
||||
lines.push('');
|
||||
}
|
||||
const driverLines = buildReplayDriverLines(requester, usedSources);
|
||||
if (driverLines.length) lines.push(...driverLines);
|
||||
if (missingSources.length) {
|
||||
if (driverLines.length) lines.push('');
|
||||
lines.push(`Missing: ${missingSources.map((source) => source.label || `${source.type}:${source.id}`).join(', ')}`);
|
||||
}
|
||||
if (!lines.length) lines.push(buildDriverCaption());
|
||||
return lines.join('\n');
|
||||
}
|
||||
function resolveReplaySources(query) {
|
||||
const cleaned = normalizeReplayQuery(query);
|
||||
if (!cleaned || cleaned === 'all' || cleaned === '*') return { sources: getDefaultDiscordSources() };
|
||||
const tokens = cleaned.split(',').map((token) => token.trim()).filter(Boolean);
|
||||
const all = getReplaySources();
|
||||
const matches = [];
|
||||
tokens.forEach((token) => {
|
||||
const [prefix, rest] = token.includes(':') ? token.split(':', 2) : [null, token];
|
||||
const candidate = all.find((entry) => (String(entry.id).toLowerCase() === rest || String(entry.label || '').toLowerCase() === rest) && (!prefix || entry.type === prefix));
|
||||
if (candidate) matches.push({ type: candidate.type, id: candidate.id, label: candidate.label });
|
||||
});
|
||||
const sources = validateSources(matches);
|
||||
return sources.length ? { sources } : { error: 'No matching sources found', matches: [] };
|
||||
async function replyDenied(message, content) {
|
||||
await message.reply({ content: sanitizeMentions(content), allowedMentions: DEFAULT_ALLOWED_MENTIONS });
|
||||
}
|
||||
|
||||
return async function handleReplayCommand(message, query) {
|
||||
if (getMode() === MODES.LOCKDOWN) {
|
||||
await message.reply({ content: 'Replay is disabled while the server is in lockdown.', allowedMentions: { parse: [], repliedUser: false } });
|
||||
await replyDenied(message, 'Replay denied: server is in lockdown.');
|
||||
return;
|
||||
}
|
||||
|
||||
const resolved = sourceResolver.resolve(query);
|
||||
if (resolved?.error) {
|
||||
await replyDenied(message, resolved.error);
|
||||
return;
|
||||
}
|
||||
|
||||
const attempt = tryTriggerReplay({ by: message.author?.id || null, source: 'discord' });
|
||||
if (!attempt.ok) {
|
||||
await message.reply({ content: `Replay cooldown active. Try again in ${Math.ceil(attempt.remainingMs / 1000)}s.`, allowedMentions: { parse: [], repliedUser: false } });
|
||||
return;
|
||||
}
|
||||
const resolved = resolveReplaySources(query);
|
||||
if (resolved?.error) {
|
||||
await message.reply({ content: sanitizeMentions(resolved.error), allowedMentions: { parse: [], repliedUser: false } });
|
||||
await replyDenied(message, `Replay denied: cooldown active. Try again in ${Math.ceil(attempt.remainingMs / 1000)}s.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const requester = message.member?.nickname || message.author?.globalName || message.author?.username || 'Discord';
|
||||
const title = buildDefaultReplayTitle(requester, resolved.sources || []);
|
||||
try {
|
||||
const { buffer, usedSources = resolved.sources || [], missingSources = [] } = await buildReplayVideo({
|
||||
sources: resolved.sources || [],
|
||||
title,
|
||||
const job = createReplayJob({
|
||||
id: buildReplayJobId('discord'),
|
||||
requester,
|
||||
source: 'discord',
|
||||
sources: resolved.sources || [],
|
||||
includeSidebar: true,
|
||||
});
|
||||
const attachment = new AttachmentBuilder(buffer, { name: `${sanitizeReplayTitleForFilename(title)}.mp4` });
|
||||
const body = buildReplayCaption({ requester, usedSources, missingSources, title });
|
||||
await message.reply({ content: sanitizeMentions(body), files: [attachment], allowedMentions: { parse: [], repliedUser: false } });
|
||||
jobStatus.emit(job, 'accepted', { message: buildAcceptedMessage(job) });
|
||||
|
||||
const progressMessage = await message.reply({
|
||||
content: sanitizeMentions(buildAcceptedMessage(job)),
|
||||
allowedMentions: DEFAULT_ALLOWED_MENTIONS,
|
||||
});
|
||||
const stopTyping = startDiscordTypingLoop(message.channel, logger, 'discord replay command');
|
||||
|
||||
try {
|
||||
jobStatus.emit(job, 'building', { message: buildStatusMessage(job, 'building') });
|
||||
if (progressMessage?.edit) {
|
||||
await progressMessage.edit({ content: sanitizeMentions(buildStatusMessage(job, 'building')), allowedMentions: DEFAULT_ALLOWED_MENTIONS });
|
||||
}
|
||||
|
||||
const { buffer, usedSources = job.sources, missingSources = [] } = await buildReplayVideo({
|
||||
sources: job.sources,
|
||||
title: job.title,
|
||||
requester: job.requester,
|
||||
includeSidebar: job.includeSidebar,
|
||||
});
|
||||
|
||||
jobStatus.emit(job, 'uploading', { message: buildStatusMessage(job, 'uploading') });
|
||||
if (progressMessage?.edit) {
|
||||
await progressMessage.edit({ content: sanitizeMentions(buildStatusMessage(job, 'uploading')), allowedMentions: DEFAULT_ALLOWED_MENTIONS });
|
||||
}
|
||||
|
||||
const attachment = new AttachmentBuilder(buffer, { name: `${sanitizeReplayTitleForFilename(job.title)}.mp4` });
|
||||
const body = replayCaption.build({ job, usedSources, missingSources });
|
||||
const uploadMessage = await progressMessage.reply({
|
||||
content: body,
|
||||
files: [attachment],
|
||||
allowedMentions: DEFAULT_ALLOWED_MENTIONS,
|
||||
});
|
||||
if (!uploadMessage) throw new Error('Discord upload did not return a message');
|
||||
|
||||
const uploadedAttachment = firstAttachmentFromMessage(uploadMessage);
|
||||
const media = buildDiscordReplayMediaPayload({ message: uploadMessage, attachment: uploadedAttachment, job });
|
||||
if (!media) throw new Error('Discord upload did not include a replay attachment URL');
|
||||
|
||||
jobStatus.emit(job, 'ready', { message: buildStatusMessage(job, 'ready'), media });
|
||||
if (progressMessage?.edit) {
|
||||
await progressMessage.edit({ content: sanitizeMentions(buildStatusMessage(job, 'ready')), allowedMentions: DEFAULT_ALLOWED_MENTIONS });
|
||||
}
|
||||
} catch (err) {
|
||||
await message.reply({ content: sanitizeMentions(`Replay failed: ${err.message}`), allowedMentions: { parse: [], repliedUser: false } });
|
||||
const userMessage = normalizeUserError(err);
|
||||
jobStatus.emit(job, 'failed', { message: userMessage });
|
||||
if (progressMessage?.edit) {
|
||||
await progressMessage.edit({ content: sanitizeMentions(userMessage), allowedMentions: DEFAULT_ALLOWED_MENTIONS });
|
||||
} else {
|
||||
await replyDenied(message, userMessage);
|
||||
}
|
||||
} finally {
|
||||
stopTyping();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -182,10 +182,12 @@ const commands = createCommandHandlers({
|
||||
isAdminUser,
|
||||
isLockdownAdminUser,
|
||||
sendToChannel: channelIO.sendToChannel,
|
||||
fetchChannel: channelIO.fetchChannel,
|
||||
clearTypingMessage: channelIO.clearTypingMessage,
|
||||
sendTypingMessage: channelIO.sendTypingMessage,
|
||||
schedulePresenceRotation: presence.schedulePresenceRotation,
|
||||
buildReplayVideo,
|
||||
sanitizeMentions,
|
||||
});
|
||||
|
||||
const integrationHandlers = integrations.register();
|
||||
|
||||
@@ -4,11 +4,26 @@
|
||||
const { EmbedBuilder, AttachmentBuilder } = require('discord.js');
|
||||
const io = require('../../../globals/io');
|
||||
const { buildBatteryStatusEmbed, buildBatteryCaption } = require('../batteryEmbeds');
|
||||
const {
|
||||
DEFAULT_ALLOWED_MENTIONS,
|
||||
createReplayJob,
|
||||
createJobStatusEmitter,
|
||||
createReplayCaptionBuilder,
|
||||
startDiscordTypingLoop,
|
||||
sanitizeReplayTitleForFilename,
|
||||
firstAttachmentFromMessage,
|
||||
buildDiscordReplayMediaPayload,
|
||||
buildAcceptedMessage,
|
||||
buildStatusMessage,
|
||||
normalizeUserError,
|
||||
} = require('../replayWorkflow');
|
||||
|
||||
function createBusEventHandler(deps) {
|
||||
const { logger, discordConfig, roverManager, rovers, schedulePresenceRotation, formatDuration, sendToChannel, buildReplayVideo, getActiveDrivers, getNickname } = deps;
|
||||
const { logger, discordConfig, roverManager, rovers, schedulePresenceRotation, formatDuration, sendToChannel, fetchChannel, buildReplayVideo, getActiveDrivers, getNickname, sanitizeMentions } = deps;
|
||||
const ADMIN_ALERT_EVENT_TYPES = new Set(['rover.online', 'rover.offline', 'rover.dockGuard', 'battery.warn', 'battery.urgent', 'battery.docked', 'battery.undocked', 'battery.charging.start', 'battery.charging.stop', 'battery.locked', 'battery.unlocked']);
|
||||
let skippedFirstModeAnnouncement = false;
|
||||
const jobStatus = createJobStatusEmitter({ io, logger, sanitizeMentions });
|
||||
const replayCaption = createReplayCaptionBuilder({ io, rovers, getActiveDrivers, getNickname, sanitizeMentions });
|
||||
|
||||
function buildEmbed({ title, description, color, includeSiteUrl = true }) {
|
||||
const embed = new EmbedBuilder().setTitle(title || 'Update').setColor(color || 0x2196f3);
|
||||
@@ -40,83 +55,48 @@ function createBusEventHandler(deps) {
|
||||
await sendToChannel(channelId, `${prefix}${content || ''}`.trim(), { embeds: payloadEmbeds, files: Array.isArray(files) ? files : undefined }, { parse: [], roles: pingRoleId ? [pingRoleId] : [] }, !pingRoleId);
|
||||
}
|
||||
|
||||
function sanitizeReplayTitleForFilename(title) {
|
||||
const cleaned = String(title || '').replace(/[\\/:*?"<>|]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 96);
|
||||
return cleaned || 'replay';
|
||||
}
|
||||
|
||||
function buildDefaultReplayTitle(requester, sources = []) {
|
||||
const requesterLabel = String(requester || 'Someone').trim() || 'Someone';
|
||||
const roverSource = (Array.isArray(sources) ? sources : []).find((entry) => entry?.type === 'rover');
|
||||
const roverLabel = roverSource?.label || roverSource?.id || 'a rover';
|
||||
return `${requesterLabel} driving ${roverLabel}`;
|
||||
}
|
||||
|
||||
function buildReplayDriverLines(requester, sources = []) {
|
||||
const activeDrivers = getActiveDrivers();
|
||||
const roverSources = (Array.isArray(sources) ? sources : []).filter((entry) => entry?.type === 'rover');
|
||||
const requestedRoverIds = new Set(roverSources.map((entry) => String(entry.id)));
|
||||
const lines = [];
|
||||
requestedRoverIds.forEach((roverId) => {
|
||||
const socketId = activeDrivers?.[roverId];
|
||||
if (!socketId) return;
|
||||
const socket = io.sockets.sockets.get(socketId);
|
||||
const nickname = getNickname(socket) || socket?.data?.user?.username || socketId;
|
||||
const record = rovers.get(roverId);
|
||||
const roverName = record?.meta?.name || record?.id || roverId;
|
||||
const isAuthor = String(nickname).toLowerCase() === String(requester || '').toLowerCase();
|
||||
lines.push(`${nickname} driving ${roverName}${isAuthor ? ' **author**' : ''}`);
|
||||
});
|
||||
return lines;
|
||||
}
|
||||
|
||||
function buildDriverCaption() {
|
||||
const activeDrivers = getActiveDrivers();
|
||||
const roster = Array.from(rovers.values());
|
||||
if (!roster.length) return 'Drivers: no rovers online.';
|
||||
const entries = roster.map((record) => {
|
||||
const driverId = activeDrivers[record.id];
|
||||
if (!driverId) return `${record.meta?.name || record.id}: none`;
|
||||
const socket = io.sockets.sockets.get(driverId);
|
||||
const nickname = getNickname(socket) || socket?.data?.user?.username || driverId;
|
||||
return `${record.meta?.name || record.id}: ${nickname}`;
|
||||
});
|
||||
return `Drivers: ${entries.join(', ')}`;
|
||||
}
|
||||
|
||||
function buildReplayCaption({ requester, usedSources = [], missingSources = [], title }) {
|
||||
const lines = [];
|
||||
if (title) {
|
||||
lines.push(`**${title}**`);
|
||||
lines.push('');
|
||||
}
|
||||
const driverLines = buildReplayDriverLines(requester, usedSources);
|
||||
if (driverLines.length) lines.push(...driverLines);
|
||||
if (missingSources.length) {
|
||||
if (driverLines.length) lines.push('');
|
||||
lines.push(`Missing: ${missingSources.map((source) => source.label || `${source.type}:${source.id}`).join(', ')}`);
|
||||
}
|
||||
if (!lines.length) lines.push(buildDriverCaption());
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
async function sendReplayToChannel(channelId, requester, sources = [], explicitTitle = '', includeSidebar = true) {
|
||||
async function sendReplayToChannel(channelId, requester, sources = [], explicitTitle = '', includeSidebar = true, jobId = null) {
|
||||
if (!channelId) throw new Error('Replay channel not configured');
|
||||
const resolvedTitle = String(explicitTitle || '').trim() || buildDefaultReplayTitle(requester, sources);
|
||||
const { buffer, usedSources = sources, missingSources = [] } = await buildReplayVideo({
|
||||
sources,
|
||||
title: resolvedTitle,
|
||||
const job = createReplayJob({
|
||||
id: jobId,
|
||||
requester,
|
||||
source: 'web',
|
||||
title: explicitTitle,
|
||||
sources,
|
||||
includeSidebar,
|
||||
});
|
||||
const attachment = new AttachmentBuilder(buffer, { name: `${sanitizeReplayTitleForFilename(resolvedTitle)}.mp4` });
|
||||
const body = buildReplayCaption({
|
||||
requester,
|
||||
usedSources,
|
||||
missingSources,
|
||||
title: resolvedTitle,
|
||||
jobStatus.emit(job, 'accepted', { message: buildAcceptedMessage(job) });
|
||||
const progressMessage = await sendToChannel(channelId, buildAcceptedMessage(job), {}, DEFAULT_ALLOWED_MENTIONS);
|
||||
const channel = await fetchChannel(channelId);
|
||||
const stopTyping = startDiscordTypingLoop(channel, logger, 'web replay delivery');
|
||||
try {
|
||||
jobStatus.emit(job, 'building', { message: buildStatusMessage(job, 'building') });
|
||||
if (progressMessage?.edit) await progressMessage.edit({ content: buildStatusMessage(job, 'building'), allowedMentions: DEFAULT_ALLOWED_MENTIONS });
|
||||
const { buffer, usedSources = job.sources, missingSources = [] } = await buildReplayVideo({
|
||||
sources: job.sources,
|
||||
title: job.title,
|
||||
requester: job.requester,
|
||||
includeSidebar: job.includeSidebar,
|
||||
});
|
||||
await sendToChannel(channelId, body, { files: [attachment] }, { parse: [] });
|
||||
jobStatus.emit(job, 'uploading', { message: buildStatusMessage(job, 'uploading') });
|
||||
if (progressMessage?.edit) await progressMessage.edit({ content: buildStatusMessage(job, 'uploading'), allowedMentions: DEFAULT_ALLOWED_MENTIONS });
|
||||
const attachment = new AttachmentBuilder(buffer, { name: `${sanitizeReplayTitleForFilename(job.title)}.mp4` });
|
||||
const body = replayCaption.build({ job, usedSources, missingSources });
|
||||
const uploadMessage = await sendToChannel(channelId, body, { files: [attachment] }, DEFAULT_ALLOWED_MENTIONS);
|
||||
if (!uploadMessage) throw new Error('Discord upload did not return a message');
|
||||
const uploadedAttachment = firstAttachmentFromMessage(uploadMessage);
|
||||
const media = buildDiscordReplayMediaPayload({ message: uploadMessage, attachment: uploadedAttachment, job });
|
||||
if (!media) throw new Error('Discord upload did not include a replay attachment URL');
|
||||
jobStatus.emit(job, 'ready', { message: buildStatusMessage(job, 'ready'), media });
|
||||
if (progressMessage?.edit) await progressMessage.edit({ content: buildStatusMessage(job, 'ready'), allowedMentions: DEFAULT_ALLOWED_MENTIONS });
|
||||
} catch (err) {
|
||||
const message = normalizeUserError(err);
|
||||
jobStatus.emit(job, 'failed', { message });
|
||||
if (progressMessage?.edit) await progressMessage.edit({ content: sanitizeMentions(message), allowedMentions: DEFAULT_ALLOWED_MENTIONS });
|
||||
throw err;
|
||||
} finally {
|
||||
stopTyping();
|
||||
}
|
||||
}
|
||||
|
||||
function handleReplayRequested(event) {
|
||||
@@ -127,6 +107,7 @@ function createBusEventHandler(deps) {
|
||||
payload?.sources || [],
|
||||
payload?.title || '',
|
||||
payload?.includeSidebar !== false,
|
||||
payload?.jobId || null,
|
||||
).catch((err) => {
|
||||
logger.warn('Replay send failed', { error: err.message });
|
||||
});
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
// Discord Replay Workflow
|
||||
// Purpose: Provides the shared replay job, Discord upload, fuzzy source lookup, and user-facing status helpers.
|
||||
// Scope: Keeps Discord-command and web-triggered replay delivery on the same status pipeline.
|
||||
const Fuse = require('fuse.js');
|
||||
|
||||
const DEFAULT_ALLOWED_MENTIONS = { parse: [], repliedUser: false };
|
||||
const FUZZY_THRESHOLD = 0.42;
|
||||
const MAX_SUGGESTIONS = 4;
|
||||
|
||||
function nowTs() {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value || '').trim();
|
||||
}
|
||||
|
||||
function normalizeSearchText(value) {
|
||||
return normalizeText(value).toLowerCase();
|
||||
}
|
||||
|
||||
function compactJoin(parts, separator = ', ') {
|
||||
return (Array.isArray(parts) ? parts : []).map(normalizeText).filter(Boolean).join(separator);
|
||||
}
|
||||
|
||||
function buildReplayJobId(prefix = 'replay') {
|
||||
// Replay rendering continues after the initial command/socket acknowledgement.
|
||||
// The id is deliberately short but unique enough for correlating UI status, Discord messages, and logs.
|
||||
const safePrefix = normalizeText(prefix).replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 24) || 'replay';
|
||||
return `${safePrefix}-${nowTs()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
function sourceKey(source) {
|
||||
return `${source?.type}:${source?.id}`;
|
||||
}
|
||||
|
||||
function sourceName(source) {
|
||||
return normalizeText(source?.label) || normalizeText(source?.id) || 'unknown source';
|
||||
}
|
||||
|
||||
function describeSource(source) {
|
||||
const label = sourceName(source);
|
||||
if (source?.type === 'room') return `${label} room camera`;
|
||||
return label;
|
||||
}
|
||||
|
||||
function sanitizeReplayTitleForFilename(title) {
|
||||
// Discord attachment names should be readable and filesystem-safe.
|
||||
// The title may originate from chat/web input, so strip separators and cap length before upload.
|
||||
const cleaned = String(title || '')
|
||||
.replace(/[\\/:*?"<>|]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, 96);
|
||||
return cleaned || 'replay';
|
||||
}
|
||||
|
||||
function buildReplayTitle({ explicitTitle = '', sources = [] } = {}) {
|
||||
const trimmed = normalizeText(explicitTitle).slice(0, 120);
|
||||
if (trimmed) return trimmed;
|
||||
|
||||
const list = Array.isArray(sources) ? sources : [];
|
||||
if (!list.length) return 'Replay';
|
||||
|
||||
const roomCount = list.filter((entry) => entry?.type === 'room').length;
|
||||
const roverCount = list.filter((entry) => entry?.type === 'rover').length;
|
||||
if (roomCount && !roverCount) return roomCount === 1 ? `Replay: ${sourceName(list[0])}` : 'Replay: Room Cameras';
|
||||
if (roverCount && !roomCount && list.length === 1) return `Replay: ${sourceName(list[0])}`;
|
||||
|
||||
// Multi-source titles identify the replay target without claiming the requester was driving.
|
||||
// Actual driver context is put in the caption where it can be based on live server state.
|
||||
const shown = list.slice(0, 2).map(sourceName);
|
||||
const remaining = list.length - shown.length;
|
||||
return `Replay: ${compactJoin(shown)}${remaining > 0 ? ` + ${remaining} more` : ''}`;
|
||||
}
|
||||
|
||||
function buildSourceSummary(sources = []) {
|
||||
const list = Array.isArray(sources) ? sources : [];
|
||||
if (!list.length) return 'no sources';
|
||||
return compactJoin(list.map(describeSource));
|
||||
}
|
||||
|
||||
function normalizeReplaySources(sources = []) {
|
||||
return (Array.isArray(sources) ? sources : [])
|
||||
.filter((source) => source?.type && source?.id != null)
|
||||
.map((source) => ({
|
||||
type: source.type,
|
||||
id: String(source.id),
|
||||
label: source.label || String(source.id),
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizeUserError(err) {
|
||||
const message = normalizeText(err?.message || err);
|
||||
if (!message) return 'Replay failed.';
|
||||
if (/No replay segments available/i.test(message)) return 'Replay failed: no recent video coverage was available for the selected source.';
|
||||
if (/No replay sources selected/i.test(message)) return 'Replay failed: no replay sources were selected.';
|
||||
if (/upload did not return/i.test(message)) return 'Replay failed: Discord did not confirm the video upload.';
|
||||
if (/attachment URL/i.test(message)) return 'Replay failed: Discord uploaded the message without a playable video URL.';
|
||||
if (/Replay channel not configured/i.test(message)) return 'Replay failed: the Discord replay channel is not configured.';
|
||||
return `Replay failed: ${message}`;
|
||||
}
|
||||
|
||||
function firstAttachmentFromMessage(message) {
|
||||
if (!message?.attachments) return null;
|
||||
if (typeof message.attachments.first === 'function') return message.attachments.first() || null;
|
||||
if (typeof message.attachments.values === 'function') return message.attachments.values().next().value || null;
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildDiscordReplayMediaPayload({ message, attachment, job }) {
|
||||
if (!attachment?.url) return null;
|
||||
return {
|
||||
jobId: job.id,
|
||||
status: 'ready',
|
||||
title: job.title,
|
||||
requester: job.requester,
|
||||
url: attachment.url,
|
||||
proxyUrl: attachment.proxyURL || attachment.proxyUrl || null,
|
||||
messageUrl: message?.url || null,
|
||||
filename: attachment.name || attachment.filename || 'replay.mp4',
|
||||
size: Number.isFinite(attachment.size) ? attachment.size : null,
|
||||
contentType: attachment.contentType || null,
|
||||
discord: {
|
||||
channelId: message?.channelId || null,
|
||||
messageId: message?.id || null,
|
||||
attachmentId: attachment.id || null,
|
||||
},
|
||||
sources: normalizeReplaySources(job.sources),
|
||||
ts: nowTs(),
|
||||
};
|
||||
}
|
||||
|
||||
function createReplayJob({ id = null, requester = 'Discord', source = 'discord', title = '', sources = [], includeSidebar = true } = {}) {
|
||||
const normalizedSources = normalizeReplaySources(sources);
|
||||
const resolvedTitle = buildReplayTitle({ explicitTitle: title, sources: normalizedSources });
|
||||
return {
|
||||
id: id || buildReplayJobId(source),
|
||||
source,
|
||||
requester: normalizeText(requester) || 'Discord',
|
||||
title: resolvedTitle,
|
||||
sources: normalizedSources,
|
||||
includeSidebar: includeSidebar !== false,
|
||||
createdAt: nowTs(),
|
||||
};
|
||||
}
|
||||
|
||||
function createJobStatusEmitter({ io, logger, sanitizeMentions }) {
|
||||
function buildPayload(job, status, extra = {}) {
|
||||
return {
|
||||
jobId: job.id,
|
||||
status,
|
||||
title: job.title,
|
||||
requester: job.requester,
|
||||
sources: normalizeReplaySources(job.sources),
|
||||
message: extra.message ? sanitizeMentions(extra.message) : undefined,
|
||||
media: extra.media || undefined,
|
||||
ts: nowTs(),
|
||||
};
|
||||
}
|
||||
|
||||
function emit(job, status, extra = {}) {
|
||||
const payload = buildPayload(job, status, extra);
|
||||
io.emit('replay:status', payload);
|
||||
if (status === 'ready' && extra.media) io.emit('replay:ready', extra.media);
|
||||
if (status === 'failed') io.emit('replay:failed', payload);
|
||||
if (logger?.debug) logger.debug('Replay job status', { jobId: job.id, status });
|
||||
return payload;
|
||||
}
|
||||
|
||||
return { emit };
|
||||
}
|
||||
|
||||
function createReplaySourceResolver({ rovers, getReplaySources, getDefaultDiscordSources, validateSources }) {
|
||||
function buildAllowedCandidates() {
|
||||
return getReplaySources().map((source) => ({
|
||||
type: source.type,
|
||||
id: String(source.id),
|
||||
label: source.label || source.id,
|
||||
access: 'allowed',
|
||||
reason: null,
|
||||
}));
|
||||
}
|
||||
|
||||
function buildDeniedRoverCandidates(allowedKeys) {
|
||||
const denied = [];
|
||||
for (const record of rovers.values()) {
|
||||
const candidate = {
|
||||
type: 'rover',
|
||||
id: String(record.id),
|
||||
label: record.meta?.name || record.name || record.id,
|
||||
access: 'denied',
|
||||
reason: record.private?.enabled && !record.privateOpen
|
||||
? 'that rover is private right now'
|
||||
: 'that rover is not replayable from Discord right now',
|
||||
};
|
||||
if (!allowedKeys.has(sourceKey(candidate))) denied.push(candidate);
|
||||
}
|
||||
return denied;
|
||||
}
|
||||
|
||||
function buildCandidates() {
|
||||
const allowed = buildAllowedCandidates();
|
||||
const allowedKeys = new Set(allowed.map(sourceKey));
|
||||
return [...allowed, ...buildDeniedRoverCandidates(allowedKeys)].map((source) => ({
|
||||
...source,
|
||||
qualifiedId: `${source.type}:${source.id}`,
|
||||
searchLabel: normalizeSearchText(source.label),
|
||||
searchId: normalizeSearchText(source.id),
|
||||
}));
|
||||
}
|
||||
|
||||
function createFuse(candidates) {
|
||||
return new Fuse(candidates, {
|
||||
includeScore: true,
|
||||
threshold: FUZZY_THRESHOLD,
|
||||
ignoreLocation: true,
|
||||
keys: [
|
||||
{ name: 'label', weight: 0.52 },
|
||||
{ name: 'id', weight: 0.26 },
|
||||
{ name: 'qualifiedId', weight: 0.18 },
|
||||
{ name: 'type', weight: 0.04 },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function suggestions(candidates) {
|
||||
const allowed = candidates.filter((entry) => entry.access === 'allowed').slice(0, MAX_SUGGESTIONS);
|
||||
if (!allowed.length) return '';
|
||||
return ` Did you mean: ${compactJoin(allowed.map(describeSource))}?`;
|
||||
}
|
||||
|
||||
function findForToken(token, candidates, fuse) {
|
||||
const cleaned = normalizeSearchText(token);
|
||||
const [prefix, rest] = cleaned.includes(':') ? cleaned.split(':', 2) : [null, cleaned];
|
||||
const exact = candidates.find((entry) => (
|
||||
(!prefix || entry.type === prefix) &&
|
||||
(entry.searchId === rest || entry.searchLabel === rest || normalizeSearchText(entry.qualifiedId) === cleaned)
|
||||
));
|
||||
if (exact) return exact;
|
||||
return fuse.search(cleaned).map((entry) => entry.item).find((entry) => !prefix || entry.type === prefix) || null;
|
||||
}
|
||||
|
||||
function resolve(query) {
|
||||
const cleaned = normalizeSearchText(query);
|
||||
if (!cleaned || cleaned === 'all' || cleaned === '*') {
|
||||
const sources = getDefaultDiscordSources();
|
||||
return sources.length
|
||||
? { sources: normalizeReplaySources(sources), defaulted: true }
|
||||
: { error: 'Replay denied: no default Discord replay sources are available.' };
|
||||
}
|
||||
|
||||
const candidates = buildCandidates();
|
||||
const fuse = createFuse(candidates);
|
||||
const selected = [];
|
||||
const denied = [];
|
||||
const unmatched = [];
|
||||
normalizeText(query).split(',').map((token) => token.trim()).filter(Boolean).forEach((token) => {
|
||||
const match = findForToken(token, candidates, fuse);
|
||||
if (!match) {
|
||||
unmatched.push(token);
|
||||
} else if (match.access !== 'allowed') {
|
||||
denied.push(match);
|
||||
} else {
|
||||
selected.push({ type: match.type, id: match.id, label: match.label });
|
||||
}
|
||||
});
|
||||
|
||||
if (denied.length) {
|
||||
const deniedText = compactJoin(denied.map((entry) => `${describeSource(entry)} (${entry.reason})`));
|
||||
return { error: `Replay denied: ${deniedText}.` };
|
||||
}
|
||||
if (unmatched.length) return { error: `No replay source matched "${unmatched[0]}".${suggestions(candidates)}` };
|
||||
|
||||
const sources = validateSources(selected);
|
||||
return sources.length
|
||||
? { sources: normalizeReplaySources(sources), defaulted: false }
|
||||
: { error: `No replay source matched "${normalizeText(query)}".${suggestions(candidates)}` };
|
||||
}
|
||||
|
||||
return { resolve };
|
||||
}
|
||||
|
||||
function createReplayCaptionBuilder({ io, rovers, getActiveDrivers, getNickname, sanitizeMentions }) {
|
||||
function buildReplayDriverLines(requester, sources = []) {
|
||||
const activeDrivers = getActiveDrivers();
|
||||
const roverSources = normalizeReplaySources(sources).filter((entry) => entry.type === 'rover');
|
||||
const requestedRoverIds = new Set(roverSources.map((entry) => String(entry.id)));
|
||||
const lines = [];
|
||||
requestedRoverIds.forEach((roverId) => {
|
||||
const socketId = activeDrivers?.[roverId];
|
||||
if (!socketId) return;
|
||||
const socket = io.sockets.sockets.get(socketId);
|
||||
const nickname = getNickname(socket) || socket?.data?.user?.username || socketId;
|
||||
const record = rovers.get(roverId);
|
||||
const roverName = record?.meta?.name || record?.id || roverId;
|
||||
const isAuthor = normalizeSearchText(nickname) === normalizeSearchText(requester);
|
||||
lines.push(`${nickname} was driving ${roverName}${isAuthor ? ' when they requested this' : ''}`);
|
||||
});
|
||||
return lines;
|
||||
}
|
||||
|
||||
function buildDriverCaption() {
|
||||
const activeDrivers = getActiveDrivers();
|
||||
const roster = Array.from(rovers.values());
|
||||
if (!roster.length) return 'Drivers: no rovers online.';
|
||||
const entries = roster.map((record) => {
|
||||
const driverId = activeDrivers[record.id];
|
||||
if (!driverId) return `${record.meta?.name || record.id}: none`;
|
||||
const socket = io.sockets.sockets.get(driverId);
|
||||
const nickname = getNickname(socket) || socket?.data?.user?.username || driverId;
|
||||
return `${record.meta?.name || record.id}: ${nickname}`;
|
||||
});
|
||||
return `Drivers: ${entries.join(', ')}`;
|
||||
}
|
||||
|
||||
function formatMissingSource(source) {
|
||||
const label = source?.label || `${source?.type}:${source?.id}`;
|
||||
return source?.reason ? `${label} (${source.reason})` : label;
|
||||
}
|
||||
|
||||
function build({ job, usedSources = [], missingSources = [] }) {
|
||||
const lines = [`**${job.title}**`, '', `Requested by ${job.requester}.`];
|
||||
const driverLines = buildReplayDriverLines(job.requester, usedSources);
|
||||
if (driverLines.length) {
|
||||
lines.push('', ...driverLines);
|
||||
} else {
|
||||
lines.push(buildDriverCaption());
|
||||
}
|
||||
if (missingSources.length) {
|
||||
lines.push('', `Missing: ${missingSources.map(formatMissingSource).join(', ')}`);
|
||||
}
|
||||
return sanitizeMentions(lines.join('\n'));
|
||||
}
|
||||
|
||||
return { build };
|
||||
}
|
||||
|
||||
function startDiscordTypingLoop(target, logger, label = 'replay') {
|
||||
let stopped = false;
|
||||
let intervalId = null;
|
||||
async function sendTyping() {
|
||||
if (stopped || typeof target?.sendTyping !== 'function') return;
|
||||
try {
|
||||
await target.sendTyping();
|
||||
} catch (err) {
|
||||
if (logger?.warn) logger.warn('Failed to send Discord typing indicator', { label, error: err.message });
|
||||
}
|
||||
}
|
||||
// Discord typing indicators expire quickly, so refresh while ffmpeg and upload work is active.
|
||||
sendTyping();
|
||||
intervalId = setInterval(sendTyping, 6000);
|
||||
return () => {
|
||||
stopped = true;
|
||||
if (intervalId) clearInterval(intervalId);
|
||||
};
|
||||
}
|
||||
|
||||
function buildAcceptedMessage(job) {
|
||||
return `Replay accepted. Building **${job.title}** from ${buildSourceSummary(job.sources)}.`;
|
||||
}
|
||||
|
||||
function buildStatusMessage(job, status) {
|
||||
if (status === 'building') return `Replay building: **${job.title}**`;
|
||||
if (status === 'uploading') return `Replay uploading to Discord: **${job.title}**`;
|
||||
if (status === 'ready') return `Replay ready: **${job.title}**`;
|
||||
return `Replay ${status}: **${job.title}**`;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_ALLOWED_MENTIONS,
|
||||
buildReplayJobId,
|
||||
createReplayJob,
|
||||
createJobStatusEmitter,
|
||||
createReplaySourceResolver,
|
||||
createReplayCaptionBuilder,
|
||||
startDiscordTypingLoop,
|
||||
sanitizeReplayTitleForFilename,
|
||||
firstAttachmentFromMessage,
|
||||
buildDiscordReplayMediaPayload,
|
||||
buildAcceptedMessage,
|
||||
buildStatusMessage,
|
||||
normalizeUserError,
|
||||
buildReplayTitle,
|
||||
};
|
||||
@@ -8,6 +8,7 @@ const { publishEvent } = require('../eventBus');
|
||||
const assignmentService = require('../assignmentService');
|
||||
const { getNickname } = require('../nicknameService');
|
||||
const { loadConfig } = require('../../helpers/configLoader');
|
||||
const { buildReplayJobId, buildReplayTitle } = require('../discordBotService/replayWorkflow');
|
||||
|
||||
const config = loadConfig();
|
||||
const discordConfig = config.discord || {};
|
||||
@@ -52,6 +53,7 @@ function registerReplaySocketHooks({ tryTriggerReplay, validateSources, getDefau
|
||||
const requester = buildRequesterLabel(socket);
|
||||
const title = normalizeReplayTitle(payload?.title);
|
||||
const includeSidebar = normalizeIncludeSidebar(payload?.includeSidebar);
|
||||
const jobId = buildReplayJobId('web');
|
||||
const attempt = tryTriggerReplay({ by: { source: 'web', requester } });
|
||||
if (!attempt.ok) {
|
||||
cb({ error: 'Replay cooldown active', remainingMs: attempt.remainingMs, state: attempt.state });
|
||||
@@ -62,6 +64,7 @@ function registerReplaySocketHooks({ tryTriggerReplay, validateSources, getDefau
|
||||
source: 'replaySocket',
|
||||
type: 'replay.requested',
|
||||
payload: {
|
||||
jobId,
|
||||
channelId,
|
||||
requester,
|
||||
title,
|
||||
@@ -70,8 +73,14 @@ function registerReplaySocketHooks({ tryTriggerReplay, validateSources, getDefau
|
||||
requestedBy: { socketId: socket.id },
|
||||
},
|
||||
});
|
||||
logger.info('Replay requested via web', { socketId: socket.id });
|
||||
cb({ success: true, state: attempt.state });
|
||||
logger.info('Replay requested via web', { socketId: socket.id, jobId });
|
||||
cb({
|
||||
success: true,
|
||||
jobId,
|
||||
status: 'accepted',
|
||||
title: buildReplayTitle({ explicitTitle: title, sources }),
|
||||
state: attempt.state,
|
||||
});
|
||||
};
|
||||
|
||||
socket.on('replay:trigger', handleReplayTrigger);
|
||||
|
||||
+5
-1
@@ -41,10 +41,11 @@ import useUserIdentitySync from './hooks/useUserIdentitySync.js';
|
||||
import GlobalObjectiveBanner from './components/GlobalObjectiveBanner/index.jsx';
|
||||
import RoverQueuesPanel from './components/RoverQueuesPanel/index.jsx';
|
||||
import VipPanel from './components/VipPanel/index.jsx';
|
||||
import { useSessionSelector } from './context/SessionContext.jsx';
|
||||
import { useSessionActions, useSessionSelector } from './context/SessionContext.jsx';
|
||||
import ButtonBoxPanel from './components/ButtonBoxPanel/index.jsx';
|
||||
import RewardRunOverlay from './components/RewardRunOverlay/index.jsx';
|
||||
import SocketConnectionPill from './components/SocketConnectionPill/index.jsx';
|
||||
import ReplayReadyPopup from './components/ReplaySourcesPanel/ReplayReadyPopup.jsx';
|
||||
import { pageBackgroundClass, themeGapClass, themeStackClass } from './themeFlags.js';
|
||||
|
||||
function useLayoutMode() {
|
||||
@@ -278,6 +279,8 @@ function AppWithProviders({ layout, isDesktop, fullscreen }) {
|
||||
const swapMobileControlColumns = Boolean(pageSettings?.swapMobileControlColumns);
|
||||
const fullscreenButtonSide = swapMobileControlColumns ? 'left' : 'right';
|
||||
const showFloatingFullscreenButton = !isDesktop && (fullscreenIsIOS || fullscreenNativeSupported);
|
||||
const latestReplay = useSessionSelector((state) => state.latestReplay);
|
||||
const { clearLatestReplay } = useSessionActions();
|
||||
const [helpVisible, setHelpVisible] = useState(false);
|
||||
const [quickstartVisible, setQuickstartVisible] = useState(false);
|
||||
|
||||
@@ -360,6 +363,7 @@ function AppWithProviders({ layout, isDesktop, fullscreen }) {
|
||||
onOpenHelp={openHelpFromQuickstart}
|
||||
onClose={closeQuickstart}
|
||||
/>
|
||||
<ReplayReadyPopup replay={latestReplay} onClose={clearLatestReplay} />
|
||||
{showFloatingFullscreenButton ? (
|
||||
<FloatingFullscreenButton
|
||||
side={fullscreenButtonSide}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// Replay Ready Popup
|
||||
// Purpose: Presents the latest Discord-hosted replay video to web users as soon as upload completes.
|
||||
// Scope: Owns the ephemeral modal shell, immediate video loading, and click-outside close behavior.
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
|
||||
function normalizeUrl(value) {
|
||||
const text = String(value || '').trim();
|
||||
return text || null;
|
||||
}
|
||||
|
||||
function formatBytes(value) {
|
||||
const size = Number(value);
|
||||
if (!Number.isFinite(size) || size <= 0) return '';
|
||||
if (size < 1024 * 1024) return `${Math.round(size / 1024)} KB`;
|
||||
return `${(size / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export default function ReplayReadyPopup({ replay, onClose }) {
|
||||
const videoUrl = normalizeUrl(replay?.url);
|
||||
if (!videoUrl) return null;
|
||||
|
||||
const title = String(replay?.title || 'Replay').trim() || 'Replay';
|
||||
const messageUrl = normalizeUrl(replay?.messageUrl);
|
||||
const meta = formatBytes(replay?.size) || null;
|
||||
const actions = (
|
||||
<div className="flex items-center gap-0.5">
|
||||
<a href={videoUrl} target="_blank" rel="noreferrer" className="button-dark px-1 py-0.25 text-[0.72rem]">
|
||||
Open video
|
||||
</a>
|
||||
{messageUrl ? (
|
||||
<a href={messageUrl} target="_blank" rel="noreferrer" className="button-dark px-1 py-0.25 text-[0.72rem]">
|
||||
Discord
|
||||
</a>
|
||||
) : null}
|
||||
<button type="button" onClick={onClose} className="button-dark px-1 py-0.25 text-[0.72rem]">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[110] flex items-center justify-center bg-black/80 p-1"
|
||||
onClick={onClose}
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
className="pointer-events-auto w-full max-w-4xl"
|
||||
onClick={(event) => {
|
||||
// The backdrop closes the popup, but clicks inside the card must leave video controls usable.
|
||||
event.stopPropagation();
|
||||
}}
|
||||
role="presentation"
|
||||
>
|
||||
<CardFrame title={title} meta={meta} actions={actions} clipOverflow={false} bodyClassName="space-y-0.5 p-0.5 text-sm text-slate-200">
|
||||
<div className="overflow-hidden rounded bg-black">
|
||||
<video
|
||||
key={videoUrl}
|
||||
src={videoUrl}
|
||||
controls
|
||||
autoPlay
|
||||
preload="auto"
|
||||
playsInline
|
||||
className="aspect-video max-h-[72vh] w-full bg-black"
|
||||
/>
|
||||
</div>
|
||||
</CardFrame>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -26,8 +26,6 @@ export default function ReplaySourcesPanel({ panelId = 'replay-sources', fillHei
|
||||
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 users = useSessionSelector((state) => state.session?.users ?? []);
|
||||
const selfSocketId = useSessionSelector((state) => state.session?.socketId || null);
|
||||
const roster = useSessionSelector((state) => state.session?.roster ?? []);
|
||||
const replayState = useSessionSelector((state) => state.session?.replay || null);
|
||||
const { triggerReplay } = useSessionActions();
|
||||
@@ -41,6 +39,10 @@ export default function ReplaySourcesPanel({ panelId = 'replay-sources', fillHei
|
||||
const [titleDirty, setTitleDirty] = useState(false);
|
||||
const [includeSidebar, setIncludeSidebar] = useState(true);
|
||||
const [remainingMs, setRemainingMs] = useState(0);
|
||||
const [activeJobId, setActiveJobId] = useState(null);
|
||||
const activeReplayJob = useSessionSelector((state) => (
|
||||
activeJobId ? state.replayJobs?.[activeJobId] || null : null
|
||||
));
|
||||
|
||||
const defaults = useMemo(() => {
|
||||
const roverId = assignmentRoverId;
|
||||
@@ -51,17 +53,13 @@ export default function ReplaySourcesPanel({ panelId = 'replay-sources', fillHei
|
||||
}, [assignmentRoverId]);
|
||||
|
||||
const defaultTitle = useMemo(() => {
|
||||
const self = Array.isArray(users)
|
||||
? users.find((entry) => entry?.socketId === selfSocketId)
|
||||
: null;
|
||||
const nickname = (self?.nickname || 'Someone').trim() || 'Someone';
|
||||
const roverId = assignmentRoverId || null;
|
||||
const roverName =
|
||||
roverId && Array.isArray(roster)
|
||||
? roster.find((entry) => String(entry?.id) === String(roverId))?.name || roverId
|
||||
: 'a rover';
|
||||
return `${nickname} driving ${roverName}`;
|
||||
}, [users, selfSocketId, assignmentRoverId, roster]);
|
||||
: '';
|
||||
return roverName ? `Replay: ${roverName}` : 'Replay';
|
||||
}, [assignmentRoverId, roster]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelected(defaults);
|
||||
@@ -119,6 +117,24 @@ export default function ReplaySourcesPanel({ panelId = 'replay-sources', fillHei
|
||||
}, [replayState?.lastTriggeredAt, replayState?.cooldownMs, replayState?.remainingMs]);
|
||||
|
||||
const replayDisabled = busy || mode === 'lockdown' || remainingMs > 0 || !selected.length;
|
||||
const activeJobStatusText = useMemo(() => {
|
||||
if (!activeReplayJob?.status) return null;
|
||||
const titleText = activeReplayJob.title ? `: ${activeReplayJob.title}` : '';
|
||||
switch (activeReplayJob.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 activeReplayJob.message || `Replay failed${titleText}`;
|
||||
default:
|
||||
return activeReplayJob.message || `Replay ${activeReplayJob.status}${titleText}`;
|
||||
}
|
||||
}, [activeReplayJob]);
|
||||
|
||||
const toggleKey = (key) => {
|
||||
setSelected((prev) => {
|
||||
@@ -132,14 +148,22 @@ export default function ReplaySourcesPanel({ panelId = 'replay-sources', fillHei
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
setActiveJobId(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 });
|
||||
setSuccess('Replay sent. Check the Discord replay channel.');
|
||||
const resp = await triggerReplay({ sources: payload, title: resolvedTitle, includeSidebar });
|
||||
if (resp?.jobId) {
|
||||
// The socket acknowledgement is only the start of the async job.
|
||||
// Later replay:status events update this same job id as Discord builds and uploads the video.
|
||||
setActiveJobId(resp.jobId);
|
||||
setSuccess('Replay accepted.');
|
||||
} else {
|
||||
setSuccess('Replay accepted.');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
@@ -195,7 +219,11 @@ export default function ReplaySourcesPanel({ panelId = 'replay-sources', fillHei
|
||||
{remainingMs > 0 ? `Replay (${Math.ceil(remainingMs / 1000)}s)` : busy ? 'Replay…' : 'Replay'}
|
||||
</button>
|
||||
{error ? <div className="text-xs text-amber-400">{error}</div> : null}
|
||||
{success ? <div className="text-xs text-emerald-300">{success}</div> : null}
|
||||
{activeJobStatusText ? (
|
||||
<div className={`text-xs ${activeReplayJob?.status === 'failed' ? 'text-amber-400' : 'text-emerald-300'}`}>
|
||||
{activeJobStatusText}
|
||||
</div>
|
||||
) : success ? <div className="text-xs text-emerald-300">{success}</div> : null}
|
||||
</div>
|
||||
</CardFrame>
|
||||
);
|
||||
|
||||
@@ -15,6 +15,8 @@ const INITIAL_STATE = {
|
||||
overseerControlState: null,
|
||||
overseerMemory: null,
|
||||
alerts: [],
|
||||
replayJobs: {},
|
||||
latestReplay: null,
|
||||
};
|
||||
|
||||
const SessionContext = createContext(null);
|
||||
@@ -129,6 +131,72 @@ export function SessionProvider({ children }) {
|
||||
],
|
||||
}));
|
||||
}
|
||||
function handleReplayStatus(payload = {}) {
|
||||
if (!payload?.jobId) return;
|
||||
setState((prev) => {
|
||||
const previous = prev.replayJobs?.[payload.jobId] || {};
|
||||
const nextJob = {
|
||||
...previous,
|
||||
...payload,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
return {
|
||||
...prev,
|
||||
// Keep status by job id because the replay panel receives the job id synchronously
|
||||
// from the trigger acknowledgement, then later socket events update that same record.
|
||||
replayJobs: {
|
||||
...(prev.replayJobs || {}),
|
||||
[payload.jobId]: nextJob,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
function handleReplayReady(payload = {}) {
|
||||
if (!payload?.jobId || !payload?.url) return;
|
||||
setState((prev) => {
|
||||
const previous = prev.replayJobs?.[payload.jobId] || {};
|
||||
const nextJob = {
|
||||
...previous,
|
||||
...payload,
|
||||
status: 'ready',
|
||||
media: payload,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
return {
|
||||
...prev,
|
||||
replayJobs: {
|
||||
...(prev.replayJobs || {}),
|
||||
[payload.jobId]: nextJob,
|
||||
},
|
||||
// Only the current replay popup is retained. Discord is the media host, so
|
||||
// this state is intentionally short-lived and does not become a replay library.
|
||||
latestReplay: {
|
||||
...payload,
|
||||
receivedAt: Date.now(),
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
function handleReplayFailed(payload = {}) {
|
||||
if (payload?.jobId) handleReplayStatus({ ...payload, status: 'failed' });
|
||||
const message = typeof payload?.message === 'string' && payload.message.trim()
|
||||
? payload.message.trim()
|
||||
: 'Replay failed after being accepted.';
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
alerts: [
|
||||
...prev.alerts.slice(-49),
|
||||
{
|
||||
id: payload?.jobId ? `replay-failed-${payload.jobId}` : `replay-failed-${Date.now()}`,
|
||||
title: 'Replay failed',
|
||||
message,
|
||||
color: '#f59e0b',
|
||||
receivedAt: Date.now(),
|
||||
lifetimeMs: 6000,
|
||||
},
|
||||
],
|
||||
}));
|
||||
}
|
||||
function handleOverseerState(payload = null) {
|
||||
const state = payload && typeof payload === 'object' ? payload : null;
|
||||
setState((prev) => ({ ...prev, overseerControlState: state }));
|
||||
@@ -148,6 +216,9 @@ export function SessionProvider({ children }) {
|
||||
socket.on('overseer:state', handleOverseerState);
|
||||
socket.on('overseer:memory', handleOverseerMemory);
|
||||
socket.on('alert:new', handleAlertNew);
|
||||
socket.on('replay:status', handleReplayStatus);
|
||||
socket.on('replay:ready', handleReplayReady);
|
||||
socket.on('replay:failed', handleReplayFailed);
|
||||
return () => {
|
||||
socket.off('session:sync', handleSession);
|
||||
socket.off('log:init', handleLogInit);
|
||||
@@ -158,6 +229,9 @@ export function SessionProvider({ children }) {
|
||||
socket.off('overseer:state', handleOverseerState);
|
||||
socket.off('overseer:memory', handleOverseerMemory);
|
||||
socket.off('alert:new', handleAlertNew);
|
||||
socket.off('replay:status', handleReplayStatus);
|
||||
socket.off('replay:ready', handleReplayReady);
|
||||
socket.off('replay:failed', handleReplayFailed);
|
||||
};
|
||||
}, [setState, socket]);
|
||||
|
||||
@@ -231,6 +305,8 @@ export function SessionProvider({ children }) {
|
||||
{ ...alert, receivedAt: Date.now(), id: alert.id || Math.random().toString(36).slice(2) },
|
||||
],
|
||||
})),
|
||||
clearLatestReplay: () =>
|
||||
setState((prev) => (prev.latestReplay ? { ...prev, latestReplay: null } : prev)),
|
||||
}),
|
||||
[emitWithAck, setState],
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user