Files
party-player/server/index.js
T
SiandClaude Opus 5 3f00897f0e Locate the player's window instead of assuming fullscreen
The corners and trail animations had stopped showing stills. Neither was at
fault: capture.js gated every screenshot on

  window.innerWidth === screen.width && window.innerHeight === screen.height

and that compares two different units. innerWidth is device pixels divided by
devicePixelRatio; screen.width is not divided. At any zoom but 100%, or any
fractional display scale, the two can never be equal — measured here at dpr
1.25, fullscreen gives 1536x864 against a reported 1920x1080. The gate stayed
shut, grab() returned null, and shoot()/drop() bailed out before making a
request. The only symptom was an F11 notice that stayed wrong after F11.

Forcing the gate open would not have helped: the rect went out in CSS pixels
and was read as logical screen pixels, so every crop would have been 1.25x too
small and offset.

Stop guessing from browser metrics and ask the compositor, which knows exactly.
hyprctl gives the window's position and size in logical pixels; against the
viewport the page reports, their ratio is the CSS-pixel scale outright, and
since CSS pixels are square that one number does for both axes. The vertical
room it leaves over is the tab and address bars, measured rather than assumed —
which is what lets the player run in an ordinary window. Fullscreen becomes
just the case where the leftover is nothing, so Start still asks for it, for the
projector's sake, but says nothing when refused.

The window is found by matching document.title as a prefix. The queue page was
titled "Party Player — Queue", which would have matched the player's prefix and
aimed the crop at the wrong window, so it is now "Queue — Party Player".
Candidates are ranked on screen first, then most recently focused, then largest:
hyprctl clients has no `focused` field, it has focusHistoryID.

The old F11 notice is replaced by one that fires only when the server genuinely
cannot find the window, debounced on resize so dragging does not spawn a burst
of grim processes.

Verified against the live player window: capture() returns bytes identical to a
grim -g of the independently predicted region, the same physical region
described at 100% and 125% zoom resolves to identical output, and the route
answers 422 for an unknown window and 400 for a missing title or viewport.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015zyhxe2w7eEMXvz8wekCyc
2026-08-13 21:56:31 +02:00

202 lines
5.9 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),
// The viewport the page measured its rect against. The crop is in CSS
// pixels, and this is what lets the server work out what one of those is
// worth on screen — see server/screen.js.
viewportWidth: Number(req.query.vw),
viewportHeight: Number(req.query.vh),
};
if (Object.values(rect).some((value) => !Number.isFinite(value))) {
res.sendStatus(400);
return;
}
// The page's own document.title, which is how its window is picked out.
rect.title = String(req.query.title ?? '');
if (!rect.title) {
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();
},
// Broadcast without saving: `paused` is not part of the persisted state.
setPaused(_ws, msg) {
if (typeof msg.paused !== 'boolean') return;
if (store.getState().paused === msg.paused) return;
store.getState().paused = msg.paused;
broadcastState();
},
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`);
});