Files
party-player/server/index.js
T
simonandClaude Opus 5 0683acd69e Screenshot from the server instead of the browser
getDisplayMedia costs a picker, a permission, and a "Sharing this tab" bar
parked over the projector all night. None of it can be suppressed from the
page — the indicator is a security surface with no API, and the Chromium
switches that hide it were no help here.

The server runs on the machine the screen is plugged into, so it can take the
screenshot itself. server/screen.js drives grim for one region and /shot
returns the finished crop, which means grab() hands back a URL for an <img>
rather than a data URL off a canvas: same synchronous contract, so the
animations are untouched, and no tainting to work around either.

Coordinates line up because a fullscreen page's CSS pixels are the
compositor's logical pixels, which is the space grim's -g takes. The monitor
layout comes from hyprctl, so a projector is measured against itself rather
than the laptop panel, and -s pins the scale to that monitor: it keeps a
mixed-DPI desk deterministic, and it keeps grim native, where resampling costs
440ms against 35ms.

Needs a wlroots compositor and grim on PATH; without them the route reports
501 and the player falls back to getDisplayMedia, probed at load because that
call cannot be reached through an await without losing transient activation.
Also skipped when the page is not on localhost, where the server would be
photographing its own screen rather than the viewer's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H9v2Xoa6eoeGYpK6gp95k9
2026-08-10 22:49:36 +02:00

184 lines
5.2 KiB
JavaScript

// Express serves the two frontends; a single WebSocket keeps them in sync.
import { createServer } from 'node:http';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import express from 'express';
import { WebSocketServer } from 'ws';
import * as config from '../config.js';
import { PORT } from '../config.js';
import { parseVideoId, fetchTitle } from './youtube.js';
import * as screen from './screen.js';
import * as store from './state.js';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
store.load();
const app = express();
// Declared before the static middleware, which would otherwise redirect
// /queue to /queue/ on its way to the directory index.
app.get('/', (_req, res) => res.sendFile(join(ROOT, 'public/player/index.html')));
app.get('/queue', (_req, res) => res.sendFile(join(ROOT, 'public/queue/index.html')));
app.use('/assets', express.static(join(ROOT, 'assets'), { maxAge: '1h' }));
app.use(express.static(join(ROOT, 'public')));
// Client-side mirror of the shared config, so the constants live in one place.
// Generated from the module's exports rather than listed by hand: a constant
// added to config.js and imported by the frontend used to break the whole
// module graph — and with it the player — until this list was updated too.
// PORT is server-only; everything else travels.
const CLIENT_CONFIG = Object.entries(config).filter(([name]) => name !== 'PORT');
app.get('/config.js', (_req, res) => {
res
.type('application/javascript')
.send(
CLIENT_CONFIG.map(([name, value]) => `export const ${name} = ${JSON.stringify(value)};\n`).join(''),
);
});
// Screenshots of the player's own frame (see server/screen.js). The player
// points an <img> straight at this, so the response has to be the finished
// crop — there is no canvas step on the other end.
app.get('/shot', async (req, res) => {
const rect = {
x: Number(req.query.x),
y: Number(req.query.y),
width: Number(req.query.w),
height: Number(req.query.h),
screenWidth: Number(req.query.sw),
screenHeight: Number(req.query.sh),
};
if (Object.values(rect).some((value) => !Number.isFinite(value))) {
res.sendStatus(400);
return;
}
// 501, not 404: the player probes this route to decide whether to fall back
// to getDisplayMedia, and needs to tell "wrong machine" from "no route".
if (!(await screen.available())) {
res.sendStatus(501);
return;
}
const jpeg = await screen.capture(rect);
if (!jpeg) {
res.sendStatus(422);
return;
}
// Every request carries a fresh nonce, so a cached copy would only ever be a
// stale photograph of a moment that has passed.
res.type('image/jpeg').set('Cache-Control', 'no-store').send(jpeg);
});
const server = createServer(app);
const wss = new WebSocketServer({ server });
function broadcast(message) {
const payload = JSON.stringify(message);
for (const client of wss.clients) {
if (client.readyState === client.OPEN) client.send(payload);
}
}
function broadcastState() {
broadcast({ type: 'state', state: store.getState() });
}
/** Persist and push in one go — every structural change ends with this. */
function commit() {
store.save();
broadcastState();
}
const handlers = {
async add(_ws, msg) {
const videoId = parseVideoId(msg.url);
if (!videoId) throw new Error('Not a YouTube video URL');
const title = await fetchTitle(videoId);
store.addItem({ videoId, title });
commit();
},
remove(_ws, msg) {
if (store.removeItem(msg.id)) commit();
},
reorder(_ws, msg) {
if (!Array.isArray(msg.ids)) return;
store.reorder(msg.ids);
commit();
},
jump(_ws, msg) {
if (store.indexOf(msg.id) === -1) return;
store.getState().currentId = msg.id;
commit();
},
duration(_ws, msg) {
if (store.setDuration(msg.id, msg.seconds)) commit();
},
// The player finished its crossfade and wants the pointer moved along.
trackEnded(_ws, msg) {
const state = store.getState();
// Ignore stale reports from a track we already moved past.
if (msg.id !== state.currentId) return;
const next = store.nextItem(msg.id);
state.currentId = next ? next.id : null;
commit();
},
setBackground(_ws, msg) {
if (!store.isBackground(msg.key)) return;
store.getState().background = msg.key;
commit();
},
setAnimation(_ws, msg) {
if (!store.isAnimation(msg.key)) return;
store.getState().animation = msg.key;
commit();
},
// Relayed straight through: this arrives once a second and must never
// touch the disk.
progress(_ws, msg) {
broadcast({ type: 'progress', id: msg.id, currentTime: msg.currentTime });
},
};
wss.on('connection', (ws) => {
ws.send(JSON.stringify({ type: 'state', state: store.getState() }));
ws.on('message', async (raw) => {
let msg;
try {
msg = JSON.parse(raw);
} catch {
return;
}
const handler = handlers[msg.type];
if (!handler) return;
try {
await handler(ws, msg);
} catch (err) {
ws.send(JSON.stringify({ type: 'error', message: err.message, forType: msg.type }));
}
});
});
server.listen(PORT, () => {
console.log(`\n Player : http://localhost:${PORT}/`);
console.log(` Queue : http://localhost:${PORT}/queue\n`);
});