dynamic http embeds, kofi button, etc

This commit is contained in:
legop3
2026-01-07 21:28:21 -05:00
parent aba01161c9
commit 7335cefffb
19 changed files with 371 additions and 37 deletions
+3 -1
View File
@@ -1,6 +1,6 @@
#configuration for roverd #configuration for roverd
name: dummy1 name: dummy1
serverUrl: ws://192.168.0.84:8080/rover serverUrl: ws://127.0.0.1:8080/rover
serial: serial:
device: /dev/ttyS0 device: /dev/ttyS0
baud: 115200 baud: 115200
@@ -18,3 +18,5 @@ media:
service: mediamtx.service service: mediamtx.service
healthUrl: http://127.0.0.1:9997/v3/paths/list healthUrl: http://127.0.0.1:9997/v3/paths/list
healthInterval: 30s healthInterval: 30s
nightVision:
enabled: false
Vendored
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
-10
View File
@@ -137,13 +137,3 @@ func (s *CameraServo) pulseToAngle(pulse int) float64 {
} }
return s.cfg.MinAngle + norm*(s.cfg.MaxAngle-s.cfg.MinAngle) 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
}
+11
View File
@@ -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
}
+1
View File
@@ -21,6 +21,7 @@ require('./src/services/videoSessions');
require('./src/services/videoAuthService'); require('./src/services/videoAuthService');
require('./src/services/videoSocketService'); require('./src/services/videoSocketService');
require('./src/services/roomCameraSocketService'); require('./src/services/roomCameraSocketService');
require('./src/services/embedHttpService');
require('./src/services/logStreamService'); require('./src/services/logStreamService');
require('./src/services/homeAssistantService'); require('./src/services/homeAssistantService');
require('./src/services/sessionService'); require('./src/services/sessionService');
+1
View File
@@ -14,6 +14,7 @@
"home-assistant-js-websocket": "^3.1.2", "home-assistant-js-websocket": "^3.1.2",
"js-yaml": "^4.1.1", "js-yaml": "^4.1.1",
"morgan": "^1.10.0", "morgan": "^1.10.0",
"sharp": "^0.33.5",
"socket.io": "^4.7.5", "socket.io": "^4.7.5",
"uuid": "^9.0.1", "uuid": "^9.0.1",
"ws": "^8.18.0" "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
+2 -2
View File
@@ -11,8 +11,8 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" /> <meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
<title>Multi Roomba Rover</title> <title>Multi Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-D8Pr9nif.js"></script> <script type="module" crossorigin src="/assets/index-CbdlI1FK.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BTAtpc9C.css"> <link rel="stylesheet" crossorigin href="/assets/index-BcJSVcRR.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+1 -8
View File
@@ -1,4 +1,3 @@
const path = require('path');
const http = require('http'); const http = require('http');
const express = require('express'); const express = require('express');
const morgan = require('morgan'); const morgan = require('morgan');
@@ -7,13 +6,7 @@ const config = require('./config');
const app = express(); const app = express();
app.use(morgan('dev')); app.use(morgan('dev'));
app.use(express.json()); app.use(express.json());
app.use(express.static(config.staticDir)); app.use(express.static(config.staticDir, { index: false }));
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'));
});
const httpServer = http.createServer(app); const httpServer = http.createServer(app);
+21
View File
@@ -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');
}
});
+272
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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,
};
+5
View File
@@ -13,7 +13,9 @@ const { getReplayState, replayEvents } = require('./replayService');
const { loadConfig } = require('../helpers/configLoader'); const { loadConfig } = require('../helpers/configLoader');
const discordInvite = loadConfig().discord?.invite || null; const discordInvite = loadConfig().discord?.invite || null;
const kofiLink = loadConfig().kofi?.link || null;
logger.info('Discord invite loaded:', discordInvite ? 'present' : 'not configured'); 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; const ACTIVITY_SYNC_COOLDOWN_MS = 3000;
let lastActivitySync = 0; let lastActivitySync = 0;
@@ -51,6 +53,9 @@ function buildSession(socket) {
discord: { discord: {
invite: discordInvite, invite: discordInvite,
}, },
kofi: {
link: kofiLink,
},
}; };
} }
+3 -3
View File
@@ -11,8 +11,8 @@ export default function DiscordInviteButton({text = "Join our Discord!"}) {
<a <a
href={discordInvite} href={discordInvite}
target="_blank" target="_blank"
rel="noopener noreferrer"npm rel="noopener noreferrer"
className="inline-flex items-center w-full h-full text-white rainbow-animate-bg transition justify-center" 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 // 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" // 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} {text}
</a> </a>
); );
} }
+21
View File
@@ -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>
);
}
+12 -7
View File
@@ -4,6 +4,7 @@ import { useSettingsNamespace } from '../settings/index.js';
import { useSocket } from '../context/SocketContext.jsx'; import { useSocket } from '../context/SocketContext.jsx';
import NicknameForm from './NicknameForm.jsx'; import NicknameForm from './NicknameForm.jsx';
import DiscordInviteButton from './DiscordInviteButton.jsx'; import DiscordInviteButton from './DiscordInviteButton.jsx';
import KoFiButton from './KoFiButton.jsx';
function roleColors(role) { function roleColors(role) {
switch (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}`} className={`panel-section space-y-0.5 text-base ${fillHeight ? 'flex h-full min-h-0 flex-col overflow-hidden' : ''} ${className}`}
> >
{!hideNicknameForm && ( {!hideNicknameForm && (
<div className="space-y-0.5 flex"> <div className="space-y-0.5">
<div className='w-1/2'> <div className="flex items-stretch gap-0.5">
<NicknameForm /> <div className="flex min-w-0 flex-1">
<div className="surface flex w-full items-center">
</div> <NicknameForm />
<div className='w-1/2'> </div>
<DiscordInviteButton /> </div>
<div className="flex w-1/2 min-w-[8rem] flex-col gap-0.5">
<DiscordInviteButton />
<KoFiButton />
</div>
</div> </div>
{!canSetNickname && <p className="text-xs text-slate-500">Spectators cannot set nicknames.</p>} {!canSetNickname && <p className="text-xs text-slate-500">Spectators cannot set nicknames.</p>}
</div> </div>
+12
View File
@@ -115,6 +115,18 @@ body {
background-position: 0% 50%; 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 { @layer utilities {