mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
dynamic http embeds, kofi button, etc
This commit is contained in:
Vendored
+3
-1
@@ -1,6 +1,6 @@
|
||||
#configuration for roverd
|
||||
name: dummy1
|
||||
serverUrl: ws://192.168.0.84:8080/rover
|
||||
serverUrl: ws://127.0.0.1:8080/rover
|
||||
serial:
|
||||
device: /dev/ttyS0
|
||||
baud: 115200
|
||||
@@ -18,3 +18,5 @@ media:
|
||||
service: mediamtx.service
|
||||
healthUrl: http://127.0.0.1:9997/v3/paths/list
|
||||
healthInterval: 30s
|
||||
nightVision:
|
||||
enabled: false
|
||||
|
||||
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -137,13 +137,3 @@ func (s *CameraServo) pulseToAngle(pulse int) float64 {
|
||||
}
|
||||
return s.cfg.MinAngle + norm*(s.cfg.MaxAngle-s.cfg.MinAngle)
|
||||
}
|
||||
|
||||
func clampInt(value, min, max int) int {
|
||||
if value < min {
|
||||
return min
|
||||
}
|
||||
if value > max {
|
||||
return max
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package roverd
|
||||
|
||||
func clampInt(value, min, max int) int {
|
||||
if value < min {
|
||||
return min
|
||||
}
|
||||
if value > max {
|
||||
return max
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -21,6 +21,7 @@ require('./src/services/videoSessions');
|
||||
require('./src/services/videoAuthService');
|
||||
require('./src/services/videoSocketService');
|
||||
require('./src/services/roomCameraSocketService');
|
||||
require('./src/services/embedHttpService');
|
||||
require('./src/services/logStreamService');
|
||||
require('./src/services/homeAssistantService');
|
||||
require('./src/services/sessionService');
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"home-assistant-js-websocket": "^3.1.2",
|
||||
"js-yaml": "^4.1.1",
|
||||
"morgan": "^1.10.0",
|
||||
"sharp": "^0.33.5",
|
||||
"socket.io": "^4.7.5",
|
||||
"uuid": "^9.0.1",
|
||||
"ws": "^8.18.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
@@ -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-D8Pr9nif.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BTAtpc9C.css">
|
||||
<script type="module" crossorigin src="/assets/index-CbdlI1FK.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BcJSVcRR.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
const path = require('path');
|
||||
const http = require('http');
|
||||
const express = require('express');
|
||||
const morgan = require('morgan');
|
||||
@@ -7,13 +6,7 @@ const config = require('./config');
|
||||
const app = express();
|
||||
app.use(morgan('dev'));
|
||||
app.use(express.json());
|
||||
app.use(express.static(config.staticDir));
|
||||
app.get('/spectate', (req, res) => {
|
||||
res.sendFile(path.join(config.staticDir, 'index.html'));
|
||||
});
|
||||
app.get('/mini', (req, res) => {
|
||||
res.sendFile(path.join(config.staticDir, 'index.html'));
|
||||
});
|
||||
app.use(express.static(config.staticDir, { index: false }));
|
||||
|
||||
const httpServer = http.createServer(app);
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
const { app } = require('../globals/http');
|
||||
const { renderIndexHtml, renderOgImage } = require('./embedService');
|
||||
|
||||
app.get(['/', '/spectate', '/mini'], async (req, res) => {
|
||||
try {
|
||||
const html = await renderIndexHtml(req);
|
||||
res.type('html').send(html);
|
||||
} catch (err) {
|
||||
res.status(500).send('Failed to render page');
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/og/preview.png', async (req, res) => {
|
||||
try {
|
||||
const buffer = await renderOgImage();
|
||||
res.set('Cache-Control', 'public, max-age=60');
|
||||
res.type('png').send(buffer);
|
||||
} catch (err) {
|
||||
res.status(500).send('Failed to render embed image');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,272 @@
|
||||
const path = require('path');
|
||||
const fsp = require('fs/promises');
|
||||
const sharp = require('sharp');
|
||||
|
||||
const { getMode } = require('./modeManager');
|
||||
const roverManager = require('./roverManager');
|
||||
const { getActiveDrivers, getTurnQueues } = require('./turnService');
|
||||
const { getRoomCameras } = require('./roomCameraService');
|
||||
const { getRoomCameraState } = require('./roomCameraSnapshotService');
|
||||
|
||||
const INDEX_HTML_PATH = path.join(__dirname, '..', '..', 'public', 'index.html');
|
||||
const BITMAP_PATH = path.join(__dirname, '..', '..', 'public', 'bitmap.png');
|
||||
|
||||
const OG_WIDTH = 1200;
|
||||
const OG_HEIGHT = 630;
|
||||
const BASE_BG = { r: 8, g: 12, b: 22 };
|
||||
|
||||
let cachedIndexHtml = null;
|
||||
let cachedIndexMtimeMs = 0;
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value || '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function escapeXml(value) {
|
||||
return escapeHtml(value);
|
||||
}
|
||||
|
||||
async function loadIndexHtml() {
|
||||
const stat = await fsp.stat(INDEX_HTML_PATH);
|
||||
if (!cachedIndexHtml || stat.mtimeMs !== cachedIndexMtimeMs) {
|
||||
cachedIndexHtml = await fsp.readFile(INDEX_HTML_PATH, 'utf8');
|
||||
cachedIndexMtimeMs = stat.mtimeMs;
|
||||
}
|
||||
return cachedIndexHtml;
|
||||
}
|
||||
|
||||
function getBaseUrl(req) {
|
||||
const forwardedProto = req.headers['x-forwarded-proto'];
|
||||
const forwardedHost = req.headers['x-forwarded-host'];
|
||||
const proto = forwardedProto ? forwardedProto.split(',')[0].trim() : req.protocol || 'http';
|
||||
const host = forwardedHost || req.headers.host || 'localhost';
|
||||
return `${proto}://${host}`;
|
||||
}
|
||||
|
||||
function getPrimaryRoomCamera() {
|
||||
const cameras = getRoomCameras();
|
||||
if (!cameras.length) return null;
|
||||
return cameras[0];
|
||||
}
|
||||
|
||||
function sumQueueCounts(turnQueues = {}) {
|
||||
return Object.values(turnQueues).reduce((sum, entry) => {
|
||||
const size = Array.isArray(entry?.queue) ? entry.queue.length : 0;
|
||||
return sum + size;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function buildEmbedCopy(state, camera) {
|
||||
const roversOnline = state?.rovers?.length || 0;
|
||||
const driverCount = Object.keys(state?.activeDrivers || {}).length;
|
||||
const queueCount = sumQueueCounts(state?.turnQueues || {});
|
||||
const mode = state?.mode || 'open';
|
||||
const modeLabel = {
|
||||
open: 'open drive',
|
||||
turns: 'queue mode',
|
||||
admin: 'admin mode',
|
||||
lockdown: 'locked',
|
||||
}[mode] || mode;
|
||||
|
||||
let title = 'Multi Roomba Rover';
|
||||
if (mode === 'lockdown') {
|
||||
title = 'Private mode is on';
|
||||
} else if (driverCount > 0) {
|
||||
title = 'Rover action live - take the controls';
|
||||
} else if (mode === 'turns' && queueCount > 0) {
|
||||
title = 'Queue moving - claim a turn';
|
||||
} else if (mode === 'turns') {
|
||||
title = 'Queue open - jump in';
|
||||
} else if (roversOnline === 0) {
|
||||
title = 'Rovers offline - check back soon';
|
||||
} else if (driverCount > 0) {
|
||||
title = 'Rover action live - take the controls';
|
||||
} else {
|
||||
title = 'Controls open - drive a rover';
|
||||
}
|
||||
|
||||
const descriptionParts = [];
|
||||
descriptionParts.push(`${roversOnline} rover${roversOnline === 1 ? '' : 's'} online`);
|
||||
if (driverCount > 0) {
|
||||
descriptionParts.push(`${driverCount} driving`);
|
||||
} else {
|
||||
descriptionParts.push('no active drivers');
|
||||
}
|
||||
if (mode === 'turns') {
|
||||
descriptionParts.push(`${queueCount} in queue`);
|
||||
} else if (mode === 'lockdown') {
|
||||
descriptionParts.push('privacy mode');
|
||||
} else {
|
||||
descriptionParts.push(modeLabel);
|
||||
}
|
||||
const description = descriptionParts.join(' | ');
|
||||
|
||||
const statsParts = [
|
||||
`${roversOnline} online`,
|
||||
driverCount > 0 ? `${driverCount} driving` : 'no drivers',
|
||||
];
|
||||
if (mode === 'turns') {
|
||||
statsParts.push(`${queueCount} in queue`);
|
||||
} else if (mode === 'lockdown') {
|
||||
statsParts.push('privacy mode');
|
||||
} else {
|
||||
statsParts.push(modeLabel);
|
||||
}
|
||||
|
||||
const cameraLabel = camera?.name || camera?.id || 'room cam';
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
subtitle: 'Control a live rover from your browser',
|
||||
stats: statsParts.join(' | '),
|
||||
cameraLabel: mode === 'lockdown' ? 'Room cameras hidden' : `Room camera: ${cameraLabel}`,
|
||||
};
|
||||
}
|
||||
|
||||
function buildMetaTags({ title, description, imageUrl, pageUrl }) {
|
||||
const safeTitle = escapeHtml(title);
|
||||
const safeDescription = escapeHtml(description);
|
||||
const safeImage = escapeHtml(imageUrl);
|
||||
const safeUrl = escapeHtml(pageUrl);
|
||||
return [
|
||||
'<!-- embed meta -->',
|
||||
`<meta name="description" content="${safeDescription}" />`,
|
||||
`<meta property="og:title" content="${safeTitle}" />`,
|
||||
`<meta property="og:description" content="${safeDescription}" />`,
|
||||
'<meta property="og:type" content="website" />',
|
||||
`<meta property="og:url" content="${safeUrl}" />`,
|
||||
`<meta property="og:image" content="${safeImage}" />`,
|
||||
'<meta property="og:image:width" content="1200" />',
|
||||
'<meta property="og:image:height" content="630" />',
|
||||
'<meta property="og:site_name" content="Multi Roomba Rover" />',
|
||||
'<meta name="twitter:card" content="summary_large_image" />',
|
||||
`<meta name="twitter:title" content="${safeTitle}" />`,
|
||||
`<meta name="twitter:description" content="${safeDescription}" />`,
|
||||
`<meta name="twitter:image" content="${safeImage}" />`,
|
||||
'<!-- /embed meta -->',
|
||||
].join('\n ');
|
||||
}
|
||||
|
||||
async function renderIndexHtml(req) {
|
||||
const baseUrl = getBaseUrl(req);
|
||||
const state = {
|
||||
mode: getMode(),
|
||||
rovers: roverManager.getRoster(),
|
||||
activeDrivers: getActiveDrivers(),
|
||||
turnQueues: getTurnQueues(),
|
||||
};
|
||||
const pageTitle = 'Multi Roomba Rover';
|
||||
const camera = getPrimaryRoomCamera();
|
||||
const copy = buildEmbedCopy(state, camera);
|
||||
const cacheBust = Math.floor(Date.now() / (5 * 60 * 1000));
|
||||
const imageUrl = `${baseUrl}/og/preview.png?t=${cacheBust}`;
|
||||
const pageUrl = `${baseUrl}${req.originalUrl || '/'}`;
|
||||
|
||||
const metaBlock = buildMetaTags({
|
||||
title: pageTitle,
|
||||
description: copy.description,
|
||||
imageUrl,
|
||||
pageUrl,
|
||||
});
|
||||
|
||||
let html = await loadIndexHtml();
|
||||
html = html.replace(/<title>.*?<\/title>/i, `<title>${escapeHtml(pageTitle)}</title>`);
|
||||
if (html.includes('<!-- embed meta -->')) {
|
||||
html = html.replace(/<!-- embed meta -->[\s\S]*?<!-- \/embed meta -->/i, metaBlock);
|
||||
} else {
|
||||
html = html.replace('</head>', ` ${metaBlock}\n </head>`);
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
function buildOverlaySvg({ title, subtitle, stats, cameraLabel, hasFrame }) {
|
||||
const titleSize = 64;
|
||||
const subtitleSize = 34;
|
||||
const statsSize = 30;
|
||||
const labelSize = 26;
|
||||
const badgeText = hasFrame ? 'Room cam live' : 'Room cam';
|
||||
return `
|
||||
<svg width="${OG_WIDTH}" height="${OG_HEIGHT}" viewBox="0 0 ${OG_WIDTH} ${OG_HEIGHT}" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="fade" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="rgba(0,0,0,0)" />
|
||||
<stop offset="50%" stop-color="rgba(0,0,0,0.35)" />
|
||||
<stop offset="100%" stop-color="rgba(0,0,0,0.85)" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="${OG_WIDTH}" height="${OG_HEIGHT}" fill="url(#fade)" />
|
||||
<rect x="56" y="48" width="210" height="40" rx="20" fill="rgba(0,0,0,0.55)" />
|
||||
<rect x="58" y="50" width="206" height="36" rx="18" fill="#22d3ee" />
|
||||
<text x="160" y="75" font-family="DejaVu Sans, Arial, sans-serif" font-size="20" font-weight="700" text-anchor="middle" fill="#001018">
|
||||
${escapeXml(badgeText)}
|
||||
</text>
|
||||
<text x="64" y="410" font-family="DejaVu Sans, Arial, sans-serif" font-size="${titleSize}" font-weight="700" fill="#ffffff">
|
||||
${escapeXml(title)}
|
||||
</text>
|
||||
<text x="64" y="455" font-family="DejaVu Sans, Arial, sans-serif" font-size="${subtitleSize}" font-weight="500" fill="#d6e4ff">
|
||||
${escapeXml(subtitle)}
|
||||
</text>
|
||||
<text x="64" y="505" font-family="DejaVu Sans, Arial, sans-serif" font-size="${statsSize}" font-weight="600" fill="#7ef9b2">
|
||||
${escapeXml(stats)}
|
||||
</text>
|
||||
<text x="64" y="552" font-family="DejaVu Sans, Arial, sans-serif" font-size="${labelSize}" font-weight="500" fill="#a7b6d8">
|
||||
${escapeXml(cameraLabel)}
|
||||
</text>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
async function renderOgImage() {
|
||||
const state = {
|
||||
mode: getMode(),
|
||||
rovers: roverManager.getRoster(),
|
||||
activeDrivers: getActiveDrivers(),
|
||||
turnQueues: getTurnQueues(),
|
||||
};
|
||||
const camera = getPrimaryRoomCamera();
|
||||
const copy = buildEmbedCopy(state, camera);
|
||||
const cameraState = state.mode === 'lockdown' || !camera ? null : getRoomCameraState(camera.id);
|
||||
const frame = cameraState?.frame || null;
|
||||
const hasFrame = Boolean(frame);
|
||||
|
||||
const base = frame
|
||||
? sharp(frame).resize(OG_WIDTH, OG_HEIGHT, { fit: 'cover' })
|
||||
: sharp({
|
||||
create: {
|
||||
width: OG_WIDTH,
|
||||
height: OG_HEIGHT,
|
||||
channels: 3,
|
||||
background: BASE_BG,
|
||||
},
|
||||
});
|
||||
|
||||
const overlaySvg = Buffer.from(
|
||||
buildOverlaySvg({
|
||||
title: copy.title,
|
||||
subtitle: copy.subtitle,
|
||||
stats: copy.stats,
|
||||
cameraLabel: copy.cameraLabel,
|
||||
hasFrame,
|
||||
}),
|
||||
);
|
||||
|
||||
const composite = [{ input: overlaySvg, top: 0, left: 0 }];
|
||||
try {
|
||||
const logo = await fsp.readFile(BITMAP_PATH);
|
||||
const logoPng = await sharp(logo).resize(88, 88).png().toBuffer();
|
||||
composite.push({ input: logoPng, top: 42, left: OG_WIDTH - 130 });
|
||||
} catch (err) {
|
||||
// Optional logo; ignore if missing.
|
||||
}
|
||||
|
||||
return base.composite(composite).png().toBuffer();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
renderIndexHtml,
|
||||
renderOgImage,
|
||||
};
|
||||
@@ -13,7 +13,9 @@ const { getReplayState, replayEvents } = require('./replayService');
|
||||
const { loadConfig } = require('../helpers/configLoader');
|
||||
|
||||
const discordInvite = loadConfig().discord?.invite || null;
|
||||
const kofiLink = loadConfig().kofi?.link || null;
|
||||
logger.info('Discord invite loaded:', discordInvite ? 'present' : 'not configured');
|
||||
logger.info('Ko-fi link loaded:', kofiLink ? 'present' : 'not configured');
|
||||
|
||||
const ACTIVITY_SYNC_COOLDOWN_MS = 3000;
|
||||
let lastActivitySync = 0;
|
||||
@@ -51,6 +53,9 @@ function buildSession(socket) {
|
||||
discord: {
|
||||
invite: discordInvite,
|
||||
},
|
||||
kofi: {
|
||||
link: kofiLink,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -11,8 +11,8 @@ export default function DiscordInviteButton({text = "Join our Discord!"}) {
|
||||
<a
|
||||
href={discordInvite}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"npm
|
||||
className="inline-flex items-center w-full h-full text-white rainbow-animate-bg transition justify-center"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center w-full px-0.5 py-0.5 text-sm font-medium text-white rainbow-animate-bg transition justify-center"
|
||||
// animated rainbow backgound
|
||||
// className="inline-flex items-center px-3 py-2 bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500 text-white rounded hover:from-indigo-600 hover:via-purple-600 hover:to-pink-600 transition"
|
||||
>
|
||||
@@ -20,4 +20,4 @@ export default function DiscordInviteButton({text = "Join our Discord!"}) {
|
||||
{text}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useSession } from "../context/SessionContext";
|
||||
import { FaCoffee } from "react-icons/fa";
|
||||
|
||||
export default function KoFiButton({ text = "Support me on Ko-fi!" }) {
|
||||
const { session } = useSession();
|
||||
const kofiLink = session?.kofi?.link || null;
|
||||
|
||||
if (!kofiLink) return null;
|
||||
|
||||
return (
|
||||
<a
|
||||
href={kofiLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center w-full px-0.5 py-0.5 text-sm font-medium text-white kofi-animate-bg transition justify-center"
|
||||
>
|
||||
<FaCoffee className="mr-1" />
|
||||
{text}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useSettingsNamespace } from '../settings/index.js';
|
||||
import { useSocket } from '../context/SocketContext.jsx';
|
||||
import NicknameForm from './NicknameForm.jsx';
|
||||
import DiscordInviteButton from './DiscordInviteButton.jsx';
|
||||
import KoFiButton from './KoFiButton.jsx';
|
||||
|
||||
function roleColors(role) {
|
||||
switch (role) {
|
||||
@@ -129,13 +130,17 @@ export default function UserListPanel({ hideNicknameForm = false, hideHeader = f
|
||||
className={`panel-section space-y-0.5 text-base ${fillHeight ? 'flex h-full min-h-0 flex-col overflow-hidden' : ''} ${className}`}
|
||||
>
|
||||
{!hideNicknameForm && (
|
||||
<div className="space-y-0.5 flex">
|
||||
<div className='w-1/2'>
|
||||
<NicknameForm />
|
||||
|
||||
</div>
|
||||
<div className='w-1/2'>
|
||||
<DiscordInviteButton />
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex items-stretch gap-0.5">
|
||||
<div className="flex min-w-0 flex-1">
|
||||
<div className="surface flex w-full items-center">
|
||||
<NicknameForm />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-1/2 min-w-[8rem] flex-col gap-0.5">
|
||||
<DiscordInviteButton />
|
||||
<KoFiButton />
|
||||
</div>
|
||||
</div>
|
||||
{!canSetNickname && <p className="text-xs text-slate-500">Spectators cannot set nicknames.</p>}
|
||||
</div>
|
||||
|
||||
@@ -115,6 +115,18 @@ body {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.kofi-animate-bg {
|
||||
background: linear-gradient(
|
||||
270deg,
|
||||
#ff5f5f,
|
||||
#ff9f43,
|
||||
#ffd166,
|
||||
#ff5f5f
|
||||
);
|
||||
background-size: 800% 800%;
|
||||
animation: rainbowBG 240s ease infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
|
||||
Reference in New Issue
Block a user