Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
176ca6868f | ||
|
|
554b946235 | ||
|
|
3f00897f0e |
@@ -9,8 +9,8 @@ A YouTube playlist player for parties. Two browser windows run side by side:
|
||||
overlap in a 5 second audio/video crossfade, so there is never silence
|
||||
between tracks.
|
||||
- **Queue** (`http://localhost:3000/queue`) — add videos by URL, drag to
|
||||
reorder, delete, see how much playtime is left, and pick the background and
|
||||
animation.
|
||||
reorder, delete, see how much playtime is left, and pick the background,
|
||||
animation and animation speed.
|
||||
|
||||
## Run
|
||||
|
||||
@@ -36,10 +36,36 @@ autoplay, so the first click is what unlocks audio. One click per session.
|
||||
- The queue is saved to `queue.json` on every change and restored at startup.
|
||||
- Settings live in `config.js`: overlap length, frame size, drift speed, corner
|
||||
screenshot interval and margin, the drag animation's size range, the
|
||||
background zoom range, port, and the animation list. Everything there except
|
||||
background zoom range, the low-res speed factor, port, and the animation list. Everything there except
|
||||
`PORT` is mirrored to the browser by the generated `/config.js` route in
|
||||
`server/index.js`, so a new constant needs no second list.
|
||||
|
||||
## Crossfades
|
||||
|
||||
Two decks, one video each, and a track change is an equal-power fade from the
|
||||
one to the other. Three details are what keep it from being audible as a seam:
|
||||
|
||||
- **The next track is prerolled.** `PREROLL_SECONDS` before the fade is due, the
|
||||
idle deck loads it, plays until YouTube reports it running, then seeks back to
|
||||
0 and pauses. A deck told to play at the moment the fade opens would spend its
|
||||
first second or two fetching, and the fade — which runs on wall-clock time —
|
||||
would ramp the outgoing track down over that silence. The fade still will not
|
||||
open until the incoming deck is genuinely playing, so a preroll that did not
|
||||
take costs a moment of the outgoing track rather than a gap.
|
||||
- **The fade lands before the end, not on it.** It runs for what is actually
|
||||
left of the outgoing track minus `END_GUARD_SECONDS`, rather than for a fixed
|
||||
`OVERLAP_SECONDS` — the tick that triggers it is already a little late, and
|
||||
`getDuration()` reports a length that runs slightly past the last of the
|
||||
audio. A fade that outlasts its own track ends in a clipped ending and a flash
|
||||
of YouTube's end screen, which shows the video's first frame and reads as the
|
||||
track restarting.
|
||||
- **The player owns the hand-over until the server confirms it.** The queue
|
||||
pointer only moves once the server has processed `trackEnded`, so for one
|
||||
round trip the state still names the track that just finished. The player
|
||||
advances its own pointer at the same moment it sends, and reconciles when the
|
||||
broadcast arrives; without that, the jump check reads the stale pointer as a
|
||||
manual jump and cuts straight back to the track that just ended.
|
||||
|
||||
## Backgrounds
|
||||
|
||||
Every image file in `assets/` is a background — drop one in, restart, and it
|
||||
@@ -93,6 +119,22 @@ today:
|
||||
a half per lap, and the frame's glow travels with it. The one still animation,
|
||||
which is what makes it worth having between the moving ones.
|
||||
|
||||
### Speed
|
||||
|
||||
Every speed in `config.js` is in pixels per second, so the same numbers cross a
|
||||
small screen in less time than a big one — on 1280x800 the frame covers the
|
||||
width in two thirds of the time it takes on 1920x1080, and the whole player
|
||||
reads as hurried. The queue page's **Speed** switch fixes that: **Low-res
|
||||
screen** multiplies every animation clock by `LOW_RES_SPEED` (0.65, the
|
||||
1280/1920 ratio), which slows the drift, the glides, the pong rally, the drag
|
||||
beats, the wash lap, the screenshot intervals and the fades together.
|
||||
|
||||
It is one factor read per frame rather than a set of slower constants, so
|
||||
flipping the switch takes effect on the next frame — no animation restarts, and
|
||||
one part-way through a beat keeps its place. The audio crossfade is deliberately
|
||||
left out: it is tied to the track that is ending. The setting is saved with the
|
||||
queue, since it describes the screen the player is on.
|
||||
|
||||
The queue page's **Animation** buttons pin the player to one of them, which is
|
||||
how you look at a single animation without waiting for the cycle to reach it.
|
||||
**Auto** (the default) hands selection back to the shuffle bag. Switching takes
|
||||
@@ -106,9 +148,21 @@ The server takes it. It runs on the same machine as the screen, so it can grab
|
||||
the region itself with [`grim`](https://sr.ht/~emersion/grim) and hand back the
|
||||
finished crop, which the player drops straight into an `<img>`. No picker, no
|
||||
permission prompt, and no "Sharing this tab" bar parked over the party. This
|
||||
needs a wlroots compositor — Hyprland, Sway, river — and `grim` on `PATH`;
|
||||
`server/screen.js` reads the monitor layout from `hyprctl` so the right screen
|
||||
is measured when a projector is plugged in.
|
||||
needs a wlroots compositor — Hyprland, Sway, river — and `grim` on `PATH`.
|
||||
|
||||
`server/screen.js` also asks `hyprctl` where the player's window is and how big.
|
||||
That is what makes the crop reliable: the page measures in CSS pixels, which are
|
||||
device pixels divided by `devicePixelRatio`, and that ratio moves with browser
|
||||
zoom and fractional display scaling. Comparing the window's real size against the
|
||||
viewport the page reports gives the scale outright, and the vertical room left
|
||||
over is the tab and address bars. **So the server path needs no fullscreen and no
|
||||
particular zoom** — it works in an ordinary window. **Start** still asks for
|
||||
fullscreen because that is what you want on a projector, but nothing breaks if
|
||||
you refuse.
|
||||
|
||||
The window is found by matching the page's `document.title` as a prefix, so the
|
||||
player must be the **active tab** in its window — which it is when it is the
|
||||
thing on screen. If the server cannot find it, the player says so in the corner.
|
||||
|
||||
Without `grim`, the player falls back to `getDisplayMedia` and asks to share
|
||||
your screen on the first click of **Start**. That path costs a picker and a
|
||||
@@ -120,10 +174,12 @@ sharing indicator, and what you choose in the picker matters:
|
||||
- **A single window** cannot be used: it includes the tab and URL bars, and
|
||||
Wayland gives the page no window coordinates to subtract.
|
||||
|
||||
**Either way the page has to be fullscreen**, which is why **Start** requests it:
|
||||
only then does a CSS pixel line up with a screen pixel. Leave fullscreen and the
|
||||
screenshots pause until you press <kbd>F11</kbd>. The exception is Chrome's tab
|
||||
capture, which is the viewport by definition and lines up in a window too.
|
||||
**The fallback needs the page fullscreen**, which is the one case it can map: a
|
||||
CSS pixel lines up with a screen pixel only there, and only at 100% zoom, since
|
||||
`innerWidth` scales with zoom and `screen.width` does not. Leave fullscreen and
|
||||
the screenshots pause until you press <kbd>F11</kbd>. The exception is Chrome's
|
||||
tab capture, which is the viewport by definition and lines up in a window too.
|
||||
The server path has none of these limits.
|
||||
|
||||
Going without is fine: the frame still parks in its corner and still glides,
|
||||
just unphotographed. That is what happens when you open the player from another
|
||||
@@ -147,6 +203,12 @@ the same element immediately. Register it under a new name in the `ANIMATIONS`
|
||||
map in `motion.js` **and** in the one in `config.js` (which supplies the label
|
||||
for the queue page's button), and it joins the rotation.
|
||||
|
||||
Anything time-driven should take its seconds from `tickSeconds(now, last)`
|
||||
rather than from the raw timestamps, and any timer should divide its interval by
|
||||
`speed`. That is what puts a new animation under the **Speed** switch — and the
|
||||
clamp inside it is also what stops a hidden tab from teleporting the frame when
|
||||
it comes back.
|
||||
|
||||
Selection uses a shuffle bag (`createPicker`): every animation comes up exactly
|
||||
once per pass, so they stay evenly used, and the same one never runs twice in a
|
||||
row — including across the seam between two passes.
|
||||
|
||||
@@ -13,6 +13,25 @@ export const OVERLAP_SECONDS = 5;
|
||||
// How fast the player switches when the user jumps around manually.
|
||||
export const HARD_CUT_SECONDS = 0.4;
|
||||
|
||||
// How long before the crossfade the next video is loaded and buffered on the
|
||||
// idle deck. A deck that is still fetching its first bytes plays silence, and
|
||||
// the fade would ramp straight over that silence — this is what buys the
|
||||
// incoming track enough of a head start to be audible the moment it is needed.
|
||||
export const PREROLL_SECONDS = 4;
|
||||
|
||||
// Landing margin for the crossfade, in seconds. getDuration() is the length of
|
||||
// the video, which is not quite the length of the audio: it runs a little past
|
||||
// the last of the sound, and it is rounded. Finishing the fade this far before
|
||||
// the reported end keeps the outgoing track from running out underneath its own
|
||||
// fade — which is heard as a clipped ending and seen as YouTube's end screen
|
||||
// (the video's thumbnail, i.e. its first frame) flashing up mid-transition.
|
||||
export const END_GUARD_SECONDS = 0.35;
|
||||
|
||||
// How long a crossfade waits for the incoming deck to actually start before it
|
||||
// gives up and fades anyway. Only reached when the preroll did not take — a
|
||||
// hard cut, or a video that is slow to load.
|
||||
export const START_WAIT_SECONDS = 1.5;
|
||||
|
||||
// The video frame takes up this fraction of the screen. Keep this below 0.5:
|
||||
// the corners animation puts a frame-sized image in all four corners, which
|
||||
// only stays overlap-free while two frames fit side by side.
|
||||
@@ -92,6 +111,19 @@ export const PONG_MISS_CHANCE = 0.25;
|
||||
// Seconds the ball waits out of play after it gets past the paddle.
|
||||
export const PONG_SERVE_SECONDS = 1.1;
|
||||
|
||||
// Everything that moves is measured in pixels per second, so the same speed
|
||||
// crosses a small screen in less time than a big one: on 1280x800 the frame
|
||||
// covers the width in two thirds of the time it takes on 1920x1080, and the
|
||||
// whole player reads as hurried. The low-res switch on the queue page multiplies
|
||||
// every animation clock — drift, glides, the pong rally, the drag beats, the
|
||||
// wash lap, the screenshot intervals and the fades — by this one factor, which
|
||||
// is the 1280/1920 ratio rounded off. Lower it to slow the party down further.
|
||||
export const LOW_RES_SPEED = 0.65;
|
||||
|
||||
// Whether the player starts in that mode. Persisted with the queue, so a screen
|
||||
// that needs it only has to be told once.
|
||||
export const DEFAULT_LOW_RES = false;
|
||||
|
||||
// The frame animations, keyed by the id sent over the socket. The names must
|
||||
// match the registry in public/player/motion.js, which supplies the code.
|
||||
export const ANIMATIONS = {
|
||||
|
||||
+89
-27
@@ -15,10 +15,13 @@
|
||||
// path removes — a picker, a sharing indicator, and on Firefox no tab capture
|
||||
// at all, only whole windows and screens.
|
||||
//
|
||||
// Both need the page fullscreen, for the same reason: only then does a CSS
|
||||
// pixel equal a screen pixel, and a shared window can never be mapped at all —
|
||||
// it carries the tab bar and URL bar, and Wayland reports window.screenX as 0,
|
||||
// so there is no offset to subtract.
|
||||
// The two paths differ in what they need of the window. The fallback crops a
|
||||
// stream of the whole screen, so it needs the page fullscreen: only then does a
|
||||
// CSS pixel equal a screen pixel, and only then is the viewport's origin the
|
||||
// screen's origin — Wayland reports window.screenX as 0, so there is no offset
|
||||
// to subtract. The server path needs neither, because it asks the compositor
|
||||
// where the window actually is and how big, and derives the scale and the
|
||||
// browser-chrome offset from that. It works windowed, and at any zoom.
|
||||
|
||||
// The server screenshots its own screen, which is the player's screen only when
|
||||
// the two are the same machine. Opened from a laptop across the network, /shot
|
||||
@@ -33,8 +36,10 @@ const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]', '::1']);
|
||||
let serverShots = null;
|
||||
|
||||
if (LOCAL_HOSTS.has(location.hostname)) {
|
||||
// Any rect will do; only the status code is read.
|
||||
fetch('/shot?x=0&y=0&w=2&h=2&sw=0&sh=0', { method: 'GET', cache: 'no-store' })
|
||||
// Any rect will do; only the status code is read. 501 is "this machine cannot
|
||||
// screenshot", which is the one answer that rules the server path out — a 422
|
||||
// here would only mean the window was not found yet, which is transient.
|
||||
fetch(shotUrl({ x: 0, y: 0, width: 2, height: 2 }), { method: 'GET', cache: 'no-store' })
|
||||
.then((response) => {
|
||||
serverShots = response.status !== 501 && response.status !== 404;
|
||||
})
|
||||
@@ -43,6 +48,27 @@ if (LOCAL_HOSTS.has(location.hostname)) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A /shot URL for a viewport rect, in CSS pixels. The viewport size and the
|
||||
* page title travel with every request: the server needs the first to size a
|
||||
* CSS pixel and the second to find this page's window.
|
||||
*/
|
||||
function shotUrl({ x, y, width, height }, nonce = 0) {
|
||||
const query = new URLSearchParams({
|
||||
x: Math.round(x),
|
||||
y: Math.round(y),
|
||||
w: Math.round(width),
|
||||
h: Math.round(height),
|
||||
vw: window.innerWidth,
|
||||
vh: window.innerHeight,
|
||||
title: document.title,
|
||||
// The server sends no-store, but a nonce also keeps two stills taken at the
|
||||
// same spot from collapsing into one request in flight.
|
||||
n: String(nonce),
|
||||
});
|
||||
return `/shot?${query}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask for a screenshot source. Resolves to a `{ grab, stop }` handle, or to
|
||||
* null if there is none — callers degrade rather than fail.
|
||||
@@ -61,9 +87,15 @@ export function requestCapture({ onIssue = () => {} } = {}) {
|
||||
|
||||
/**
|
||||
* True while the viewport covers the whole screen, which is the one case the
|
||||
* crop maths can handle. Tested by dimensions rather than by
|
||||
* *fallback's* crop maths can handle. Tested by dimensions rather than by
|
||||
* `document.fullscreenElement`, because F11 fullscreen is browser-level and
|
||||
* leaves that property null.
|
||||
*
|
||||
* Only the getDisplayMedia path consults this. Note that the equality is exact
|
||||
* and so holds only at devicePixelRatio 1: innerWidth is device pixels divided
|
||||
* by that ratio, while screen.width is not, so any browser zoom or fractional
|
||||
* display scale puts the two in different units. That is a real limit of the
|
||||
* fallback, and one more reason the server path is preferred.
|
||||
*/
|
||||
function fillsScreen() {
|
||||
return window.innerWidth === screen.width && window.innerHeight === screen.height;
|
||||
@@ -97,38 +129,68 @@ function trackAlignment(onIssue, initial) {
|
||||
// --- server screenshots -------------------------------------------------
|
||||
|
||||
function openServerCapture(onIssue) {
|
||||
const aligned = trackAlignment(onIssue, fillsScreen());
|
||||
let nonce = 0;
|
||||
|
||||
// One real request up front, so a server that cannot find this page's window
|
||||
// says so at the start rather than leaving the animations quietly still-less.
|
||||
// The <img> elements the stills go into report nothing we can read from here.
|
||||
checkServerCrop(onIssue);
|
||||
|
||||
// Debounced: every check costs a screenshot on the server, and a window drag
|
||||
// fires resize dozens of times. Only the settled size is worth testing.
|
||||
let recheck = 0;
|
||||
function onResize() {
|
||||
clearTimeout(recheck);
|
||||
recheck = setTimeout(() => checkServerCrop(onIssue), 500);
|
||||
}
|
||||
window.addEventListener('resize', onResize);
|
||||
|
||||
return {
|
||||
/**
|
||||
* A URL for a fresh photograph of `rect`, in CSS pixels. Safe to hand
|
||||
* straight to an <img>: the server returns the crop itself, already sized.
|
||||
* Null while the viewport does not cover the screen.
|
||||
* A URL for a fresh photograph of `rect`, in CSS pixels relative to the
|
||||
* viewport. Safe to hand straight to an <img>: the server returns the crop
|
||||
* itself, already sized.
|
||||
*/
|
||||
grab({ x, y, width, height }) {
|
||||
if (!aligned()) return null;
|
||||
if (width <= 0 || height <= 0) return null;
|
||||
|
||||
const query = new URLSearchParams({
|
||||
x: Math.round(x),
|
||||
y: Math.round(y),
|
||||
w: Math.round(width),
|
||||
h: Math.round(height),
|
||||
sw: screen.width,
|
||||
sh: screen.height,
|
||||
// The server sends no-store, but a nonce also keeps two stills taken at
|
||||
// the same spot from collapsing into one request in flight.
|
||||
n: String(++nonce),
|
||||
});
|
||||
return `/shot?${query}`;
|
||||
return shotUrl({ x, y, width, height }, ++nonce);
|
||||
},
|
||||
|
||||
// Nothing is held open: each screenshot is one request.
|
||||
stop() {},
|
||||
// No stream is held open — each screenshot is one request — but the resize
|
||||
// watcher still has to go, or a stopped handle keeps probing the server.
|
||||
stop() {
|
||||
clearTimeout(recheck);
|
||||
window.removeEventListener('resize', onResize);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Report only transitions, not every failure: this runs again on each resize,
|
||||
* and a stuck state would otherwise repeat the same notice.
|
||||
*/
|
||||
let cropWorked = true;
|
||||
|
||||
async function checkServerCrop(onIssue) {
|
||||
let ok = false;
|
||||
try {
|
||||
const response = await fetch(shotUrl({ x: 0, y: 0, width: 16, height: 16 }), {
|
||||
cache: 'no-store',
|
||||
});
|
||||
ok = response.ok;
|
||||
} catch {
|
||||
ok = false;
|
||||
}
|
||||
|
||||
if (ok === cropWorked) return;
|
||||
cropWorked = ok;
|
||||
onIssue(
|
||||
ok
|
||||
? 'Screenshots are working again.'
|
||||
: 'Screenshots paused: the server cannot find this page’s window to photograph.',
|
||||
);
|
||||
}
|
||||
|
||||
// --- getDisplayMedia fallback -------------------------------------------
|
||||
|
||||
function requestDisplayMedia(onIssue) {
|
||||
|
||||
+66
-16
@@ -36,6 +36,37 @@ import {
|
||||
|
||||
const ASPECT = 9 / 16;
|
||||
|
||||
// --- speed --------------------------------------------------------------
|
||||
|
||||
// One factor behind every animation clock, so the low-res switch slows the
|
||||
// whole player rather than each animation carrying its own idea of "slower".
|
||||
// 1 is the speed the constants in config.js are written for.
|
||||
let speed = 1;
|
||||
|
||||
/**
|
||||
* Set the speed every animation runs at. Takes effect on the next frame — no
|
||||
* animation has to be restarted, and one part-way through a beat keeps its
|
||||
* place rather than jumping.
|
||||
*
|
||||
* The CSS fades (the poster crossfade, a corner still appearing) are stretched
|
||||
* with it through a custom property, since those durations live in the
|
||||
* stylesheet: at half speed a fade takes twice as long, hence the reciprocal.
|
||||
*/
|
||||
export function setSpeed(value) {
|
||||
speed = Number.isFinite(value) && value > 0 ? value : 1;
|
||||
document.documentElement.style.setProperty('--motion-stretch', (1 / speed).toFixed(3));
|
||||
return speed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seconds of animation time between two frame timestamps: real time, clamped so
|
||||
* a stalled or hidden tab does not teleport anything on resume, then scaled by
|
||||
* the current speed.
|
||||
*/
|
||||
function tickSeconds(now, last) {
|
||||
return Math.min((now - last) / 1000, 0.05) * speed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Frame size for a viewport: `scale` of the width, but never taller than
|
||||
* `scale` of the height, so a wide screen does not get a letterbox strip.
|
||||
@@ -179,8 +210,7 @@ export function startBounce({ element }) {
|
||||
function frame(now) {
|
||||
if (stopped) return;
|
||||
|
||||
// Clamped so a stalled tab does not teleport the frame on resume.
|
||||
const dt = Math.min((now - last) / 1000, 0.05);
|
||||
const dt = tickSeconds(now, last);
|
||||
last = now;
|
||||
|
||||
bouncer.step(dt, bounds());
|
||||
@@ -307,10 +337,21 @@ export function startCorners(context) {
|
||||
|
||||
layout();
|
||||
window.addEventListener('resize', layout);
|
||||
const timer = setInterval(shoot, CORNER_INTERVAL_SECONDS * 1000);
|
||||
|
||||
// A chain of timeouts rather than an interval: the gap is read again after
|
||||
// every shot, so a change of speed lands on the next one instead of waiting
|
||||
// for the animation to be restarted.
|
||||
let timer = 0;
|
||||
function schedule() {
|
||||
timer = setTimeout(() => {
|
||||
shoot();
|
||||
schedule();
|
||||
}, (CORNER_INTERVAL_SECONDS * 1000) / speed);
|
||||
}
|
||||
schedule();
|
||||
|
||||
return function stop() {
|
||||
clearInterval(timer);
|
||||
clearTimeout(timer);
|
||||
window.removeEventListener('resize', layout);
|
||||
for (const cell of cells) cell.img.remove();
|
||||
};
|
||||
@@ -449,7 +490,7 @@ export function startTrail(context) {
|
||||
}
|
||||
|
||||
/** Photograph the frame where it stands. Reports whether a still landed. */
|
||||
function drop(now) {
|
||||
function drop(clock) {
|
||||
// Read late: the capture handle may still be waiting on the operator to
|
||||
// answer the screen-share prompt when this animation starts.
|
||||
if (!context.capture) return false;
|
||||
@@ -470,7 +511,7 @@ export function startTrail(context) {
|
||||
cell.img.style.width = `${rect.width}px`;
|
||||
cell.img.style.height = `${rect.height}px`;
|
||||
place(cell.img, rect.left, rect.top);
|
||||
cell.born = now;
|
||||
cell.born = clock;
|
||||
cell.rect = { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom };
|
||||
// Re-append so the freshest still paints over the rest of the tail.
|
||||
layer.appendChild(cell.img);
|
||||
@@ -479,11 +520,11 @@ export function startTrail(context) {
|
||||
return true;
|
||||
}
|
||||
|
||||
function fade(now) {
|
||||
function fade(clock) {
|
||||
for (const cell of cells) {
|
||||
if (!cell.rect) continue;
|
||||
|
||||
const life = 1 - (now - cell.born) / 1000 / TRAIL_FADE_SECONDS;
|
||||
const life = 1 - (clock - cell.born) / TRAIL_FADE_SECONDS;
|
||||
if (life <= 0) {
|
||||
cell.img.style.opacity = '0';
|
||||
// Gone, so it stops holding up drops that pass over its old spot.
|
||||
@@ -501,14 +542,18 @@ export function startTrail(context) {
|
||||
window.addEventListener('resize', measure);
|
||||
|
||||
let last = performance.now();
|
||||
// Animation time rather than wall-clock time, so a still ages at the same
|
||||
// rate the frame that dropped it is moving.
|
||||
let clock = 0;
|
||||
let handle = 0;
|
||||
let stopped = false;
|
||||
|
||||
function frame(now) {
|
||||
if (stopped) return;
|
||||
|
||||
const dt = Math.min((now - last) / 1000, 0.05);
|
||||
const dt = tickSeconds(now, last);
|
||||
last = now;
|
||||
clock += dt;
|
||||
|
||||
const wasX = glider.x;
|
||||
const wasY = glider.y;
|
||||
@@ -518,9 +563,9 @@ export function startTrail(context) {
|
||||
// Spacing by distance rather than by a timer keeps the stills evenly spread
|
||||
// however the frame is turning.
|
||||
travelled += Math.hypot(glider.x - wasX, glider.y - wasY);
|
||||
if (travelled >= size.width * TRAIL_SPACING && drop(now)) travelled = 0;
|
||||
if (travelled >= size.width * TRAIL_SPACING && drop(clock)) travelled = 0;
|
||||
|
||||
fade(now);
|
||||
fade(clock);
|
||||
handle = requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
@@ -884,9 +929,8 @@ export function startDragResize({ element }) {
|
||||
function frame(now) {
|
||||
if (stopped) return;
|
||||
|
||||
const dt = Math.min((now - last) / 1000, 0.05);
|
||||
elapsed += tickSeconds(now, last);
|
||||
last = now;
|
||||
elapsed += dt;
|
||||
|
||||
const p = current.duration > 0 ? Math.min(elapsed / current.duration, 1) : 1;
|
||||
current.update(p);
|
||||
@@ -959,14 +1003,20 @@ export function startWash({ element }) {
|
||||
centre();
|
||||
window.addEventListener('resize', centre);
|
||||
|
||||
const started = performance.now();
|
||||
let last = performance.now();
|
||||
// Counted up rather than measured from a start time, so the lap length
|
||||
// follows the speed the way every other animation does.
|
||||
let elapsed = 0;
|
||||
let handle = 0;
|
||||
let stopped = false;
|
||||
|
||||
function frame(now) {
|
||||
if (stopped) return;
|
||||
|
||||
const [from, to] = washHues((now - started) / 1000);
|
||||
elapsed += tickSeconds(now, last);
|
||||
last = now;
|
||||
|
||||
const [from, to] = washHues(elapsed);
|
||||
wash.style.background = `linear-gradient(135deg, ${washStop(from)}, ${washStop(to)})`;
|
||||
// The frame's glow rides the leading hue, so it belongs to the wash instead
|
||||
// of sitting on top of it in a fixed pink.
|
||||
@@ -1336,7 +1386,7 @@ export function startPong({ element }) {
|
||||
function frame(now) {
|
||||
if (stopped) return;
|
||||
|
||||
const dt = Math.min((now - last) / 1000, 0.05);
|
||||
const dt = tickSeconds(now, last);
|
||||
last = now;
|
||||
|
||||
// Out of play between the goal and the serve, so the score has a beat to be
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
:root {
|
||||
--accent: #ff4fc3;
|
||||
--ink: #14060f;
|
||||
/* How much longer than written every animated duration below runs. Set by
|
||||
motion.js from the speed the player is on: 1 normally, and above 1 on a
|
||||
low-res screen, where everything is slowed down together. */
|
||||
--motion-stretch: 1;
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -32,7 +36,7 @@ body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
transition: opacity 900ms ease;
|
||||
transition: opacity calc(900ms * var(--motion-stretch)) ease;
|
||||
/* Knock the poster back so the video frame stays the focal point — the
|
||||
posters are pale and read far brighter on a projector than on a desk. */
|
||||
filter: brightness(0.45) saturate(1.15);
|
||||
@@ -97,7 +101,7 @@ body {
|
||||
box-shadow:
|
||||
0 0 0 4px rgba(255, 79, 195, 0.55),
|
||||
0 24px 70px rgba(0, 0, 0, 0.7);
|
||||
transition: opacity 500ms ease;
|
||||
transition: opacity calc(500ms * var(--motion-stretch)) ease;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
|
||||
+154
-20
@@ -1,7 +1,17 @@
|
||||
import { connect } from '/shared/socket.js';
|
||||
import { OVERLAP_SECONDS, HARD_CUT_SECONDS, AUTO_BACKGROUND, AUTO_ANIMATION } from '/config.js';
|
||||
import {
|
||||
OVERLAP_SECONDS,
|
||||
HARD_CUT_SECONDS,
|
||||
PREROLL_SECONDS,
|
||||
END_GUARD_SECONDS,
|
||||
START_WAIT_SECONDS,
|
||||
AUTO_BACKGROUND,
|
||||
AUTO_ANIMATION,
|
||||
LOW_RES_SPEED,
|
||||
DEFAULT_LOW_RES,
|
||||
} from '/config.js';
|
||||
import { createPlayer, loadApi, STATE } from './yt.js';
|
||||
import { startAnimationCycle } from './motion.js';
|
||||
import { startAnimationCycle, setSpeed } from './motion.js';
|
||||
import { startBackgroundCycle, showLogo } from './backdrop.js';
|
||||
import { requestCapture } from './capture.js';
|
||||
|
||||
@@ -12,16 +22,35 @@ let state = {
|
||||
currentId: null,
|
||||
background: AUTO_BACKGROUND,
|
||||
animation: AUTO_ANIMATION,
|
||||
lowRes: DEFAULT_LOW_RES,
|
||||
paused: false,
|
||||
};
|
||||
let decks = [];
|
||||
let active = 0;
|
||||
let transition = null; // { until, duration, from, to } while crossfading
|
||||
let transition = null; // { startedAt, duration, from, to } while crossfading
|
||||
let preroll = null; // { itemId, deck, ready } while the next track warms up
|
||||
let handover = null; // { from, to } while the server has yet to confirm an advance
|
||||
let started = false;
|
||||
let animations = null;
|
||||
|
||||
const socket = connect({ onMessage, onStatus });
|
||||
|
||||
// --- speed --------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Hand the low-res switch to the animations. Only the visuals slow down: the
|
||||
* audio crossfade is tied to the track that is ending, and stretching that
|
||||
* would leave two songs overlapping past the end of one of them.
|
||||
*
|
||||
* Applied from page load, before anything moves, so the poster's first
|
||||
* crossfade is already at the right speed.
|
||||
*/
|
||||
function applySpeed(lowRes) {
|
||||
setSpeed(lowRes ? LOW_RES_SPEED : 1);
|
||||
}
|
||||
|
||||
applySpeed(state.lowRes);
|
||||
|
||||
// --- backgrounds --------------------------------------------------------
|
||||
|
||||
// Runs from page load, not from the start gate: the poster is what is on
|
||||
@@ -64,12 +93,11 @@ async function start() {
|
||||
// chosen without one, so this ordering has to hold either way.
|
||||
const capturing = requestCapture({ onIssue: showNotice });
|
||||
|
||||
// Screenshots only line up while the viewport covers the whole screen, since
|
||||
// that is what makes a CSS pixel a screen pixel. Chrome's tab capture is the
|
||||
// one exception, and fullscreen does it no harm.
|
||||
document.documentElement.requestFullscreen?.().catch(() => {
|
||||
showNotice('Could not go fullscreen — press F11 so screenshots line up.');
|
||||
});
|
||||
// Cosmetic now rather than required: the server path locates the window
|
||||
// itself, so screenshots line up windowed too. It is still what you want on a
|
||||
// projector, so ask — but say nothing if it is refused, since nothing breaks.
|
||||
// (The getDisplayMedia fallback does still need it; that path says so itself.)
|
||||
document.documentElement.requestFullscreen?.().catch(() => {});
|
||||
|
||||
gate.classList.add('hidden');
|
||||
|
||||
@@ -130,6 +158,22 @@ function playOn(index, item, { volume, opacity }) {
|
||||
deck.setOpacity(opacity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Warm the idle deck up with the track that is coming next: load it, let it
|
||||
* play far enough to fill its buffer, then park it back at the start.
|
||||
*
|
||||
* A deck told to play the instant the crossfade begins spends its first second
|
||||
* or two fetching, and the fade — which runs on wall-clock time — ramps the
|
||||
* outgoing track down over that silence. Parking a buffered deck at 0 instead
|
||||
* means the incoming track is audible on the first frame of the fade.
|
||||
*/
|
||||
function prepareNext(item) {
|
||||
const to = 1 - active;
|
||||
preroll = { itemId: item.id, deck: to, ready: false };
|
||||
playOn(to, item, { volume: 0, opacity: 0 });
|
||||
decks[to].player.playVideo();
|
||||
}
|
||||
|
||||
/**
|
||||
* Put the decks down or pick them back up. A crossfade in flight is frozen
|
||||
* rather than abandoned: the ramp runs on wall-clock time, so the paused
|
||||
@@ -145,7 +189,11 @@ function applyPaused(paused) {
|
||||
}
|
||||
|
||||
if (transition?.pausedAt) {
|
||||
transition.startedAt += performance.now() - transition.pausedAt;
|
||||
// Whichever clock is running: before the ramp opens it is the one waiting
|
||||
// for the incoming deck, after that it is the ramp itself.
|
||||
const stalled = performance.now() - transition.pausedAt;
|
||||
if (transition.startedAt === null) transition.waitingSince += stalled;
|
||||
else transition.startedAt += stalled;
|
||||
transition.pausedAt = null;
|
||||
}
|
||||
|
||||
@@ -165,6 +213,17 @@ function onDeckState(index, code) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The prerolled deck has buffered enough to be playing; that is all the
|
||||
// preroll was after. Park it back at the top so the fade opens on the first
|
||||
// bar of the song rather than four seconds into it — resuming from here costs
|
||||
// nothing, the data is already in hand.
|
||||
if (preroll && !preroll.ready && index === preroll.deck && code === STATE.PLAYING) {
|
||||
preroll.ready = true;
|
||||
decks[index].player.seekTo(0, true);
|
||||
decks[index].player.pauseVideo();
|
||||
return;
|
||||
}
|
||||
|
||||
if (index !== active || transition) return;
|
||||
|
||||
// Safety net: a track that ended without our tick catching it (very short
|
||||
@@ -200,6 +259,11 @@ function tick() {
|
||||
// Queue emptied out from under us — stop rather than loop the video the
|
||||
// user just deleted.
|
||||
if (state.items.length === 0) {
|
||||
if (preroll) {
|
||||
decks[preroll.deck].player.stopVideo();
|
||||
decks[preroll.deck].itemId = null;
|
||||
preroll = null;
|
||||
}
|
||||
if (deck.itemId) {
|
||||
deck.player.stopVideo();
|
||||
deck.setOpacity(0);
|
||||
@@ -229,11 +293,24 @@ function tick() {
|
||||
const currentTime = deck.player.getCurrentTime?.() ?? 0;
|
||||
if (!duration) return;
|
||||
|
||||
if (duration - currentTime <= OVERLAP_SECONDS) {
|
||||
const next = nextItem();
|
||||
// A single-item queue loops instead of crossfading with itself.
|
||||
if (!next || next.id === deck.itemId) return;
|
||||
beginCrossfade(next, OVERLAP_SECONDS, { advance: true });
|
||||
const remaining = duration - currentTime;
|
||||
if (remaining > OVERLAP_SECONDS + PREROLL_SECONDS) return;
|
||||
|
||||
const next = nextItem();
|
||||
// A single-item queue loops instead of crossfading with itself.
|
||||
if (!next || next.id === deck.itemId) return;
|
||||
|
||||
// Warm the next track up early — and again if the queue was reordered under
|
||||
// us and the track that is coming is no longer the one on the idle deck.
|
||||
if (preroll?.itemId !== next.id) prepareNext(next);
|
||||
|
||||
if (remaining <= OVERLAP_SECONDS) {
|
||||
// Fade over what is actually left rather than over the nominal overlap: the
|
||||
// tick that fires this is already a moment late, and a fade that outlasts
|
||||
// its own track ends in silence. The floor keeps a throttled tab, which can
|
||||
// land here with no time left at all, from cutting instantly.
|
||||
const window = Math.max(remaining - END_GUARD_SECONDS, HARD_CUT_SECONDS);
|
||||
beginCrossfade(next, window, { advance: true });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,11 +318,26 @@ function beginCrossfade(item, seconds, { advance }) {
|
||||
const from = active;
|
||||
const to = 1 - active;
|
||||
|
||||
playOn(to, item, { volume: 0, opacity: 0 });
|
||||
// Reuse the prerolled deck if it is holding this very track, buffered and
|
||||
// parked at 0. Anything else — a hard cut, a jump — loads from cold.
|
||||
if (preroll?.itemId !== item.id || preroll.deck !== to) {
|
||||
playOn(to, item, { volume: 0, opacity: 0 });
|
||||
} else if (!preroll.ready && (decks[to].player.getCurrentTime?.() ?? 0) > 0.5) {
|
||||
// Warmed but never parked, because a pause landed inside the preroll. It is
|
||||
// some seconds into the song; the fade still has to open on the first bar.
|
||||
// (A preroll that has simply not started yet is already at 0 — leave it be,
|
||||
// seeking a player that is still loading only sets the load back.)
|
||||
decks[to].player.seekTo(0, true);
|
||||
}
|
||||
preroll = null;
|
||||
decks[to].player.playVideo();
|
||||
|
||||
transition = {
|
||||
startedAt: performance.now(),
|
||||
// Opened by stepTransition once the incoming deck is actually running: a
|
||||
// ramp that starts against a deck still spinning up fades the party into
|
||||
// silence for as long as the load takes.
|
||||
startedAt: null,
|
||||
waitingSince: performance.now(),
|
||||
duration: seconds * 1000,
|
||||
from,
|
||||
to,
|
||||
@@ -264,10 +356,22 @@ function beginCrossfade(item, seconds, { advance }) {
|
||||
function stepTransition() {
|
||||
if (!transition || transition.pausedAt) return false;
|
||||
|
||||
const progress = Math.min((performance.now() - transition.startedAt) / transition.duration, 1);
|
||||
const outgoing = decks[transition.from];
|
||||
const incoming = decks[transition.to];
|
||||
|
||||
// Hold the outgoing track at full volume until the incoming one is really
|
||||
// playing. Normally the preroll has it parked and ready, so this holds for a
|
||||
// frame or two at most; the cap is there so a video that never loads cannot
|
||||
// strand the party on a track that is about to end.
|
||||
if (transition.startedAt === null) {
|
||||
const playing = incoming.player.getPlayerState?.() === STATE.PLAYING;
|
||||
const waited = performance.now() - transition.waitingSince;
|
||||
if (!playing && waited < START_WAIT_SECONDS * 1000) return true;
|
||||
transition.startedAt = performance.now();
|
||||
}
|
||||
|
||||
const progress = Math.min((performance.now() - transition.startedAt) / transition.duration, 1);
|
||||
|
||||
// Equal-power curve: the combined loudness stays flat through the middle
|
||||
// of the mix instead of dipping.
|
||||
outgoing.player.setVolume(Math.round(Math.cos((progress * Math.PI) / 2) * 100));
|
||||
@@ -302,7 +406,16 @@ function finishTransition() {
|
||||
animations?.next();
|
||||
backgrounds.next();
|
||||
|
||||
if (advance) socket.send({ type: 'trackEnded', id: outgoingId });
|
||||
if (advance) {
|
||||
// Move the pointer locally, ahead of the server's answer. The server only
|
||||
// advances it when it has processed `trackEnded`, and until that broadcast
|
||||
// arrives `state.currentId` still names the track we just faded out — which
|
||||
// the jump check in tick() reads as "someone moved the pointer" and honours
|
||||
// by cutting straight back to it, restarting the track that just ended.
|
||||
handover = { from: outgoingId, to: decks[to].itemId };
|
||||
state.currentId = handover.to;
|
||||
socket.send({ type: 'trackEnded', id: outgoingId });
|
||||
}
|
||||
}
|
||||
|
||||
function reportProgress() {
|
||||
@@ -380,9 +493,22 @@ function onMessage(msg) {
|
||||
const previousAnimation = state.animation;
|
||||
const previousBackground = state.background;
|
||||
const previousPaused = state.paused;
|
||||
const previousLowRes = state.lowRes;
|
||||
state = msg.state;
|
||||
|
||||
// Keep our own hand-over until the server has caught up with it. A broadcast
|
||||
// sent before `trackEnded` landed — or triggered by something else entirely,
|
||||
// like someone adding a video mid-fade — still carries the old pointer.
|
||||
if (handover) {
|
||||
if (state.currentId === handover.to) handover = null; // confirmed
|
||||
else if (state.currentId === handover.from) state.currentId = handover.to;
|
||||
else handover = null; // the pointer went somewhere else; that beats ours
|
||||
}
|
||||
|
||||
if (state.background !== previousBackground) backgrounds.setMode(state.background);
|
||||
if (state.animation !== previousAnimation) animations?.setMode(state.animation);
|
||||
// No restart: the running animation picks the new speed up on its next frame.
|
||||
if (state.lowRes !== previousLowRes) applySpeed(state.lowRes);
|
||||
if (state.paused !== previousPaused) applyPaused(state.paused);
|
||||
updateGate();
|
||||
pumpProbe();
|
||||
@@ -415,7 +541,8 @@ updateGate();
|
||||
|
||||
// Debug helper used when verifying the crossfade without waiting out a track.
|
||||
window.__player = {
|
||||
seekToEnd(secondsBefore = OVERLAP_SECONDS + 2) {
|
||||
// Lands before the preroll window, so the hand-over is exercised in full.
|
||||
seekToEnd(secondsBefore = OVERLAP_SECONDS + PREROLL_SECONDS + 2) {
|
||||
const deck = decks[active];
|
||||
const duration = deck?.player.getDuration?.() ?? 0;
|
||||
if (duration) deck.player.seekTo(Math.max(0, duration - secondsBefore), true);
|
||||
@@ -427,10 +554,17 @@ window.__player = {
|
||||
return backgrounds.next();
|
||||
},
|
||||
get state() {
|
||||
const deck = decks[active];
|
||||
return {
|
||||
state,
|
||||
active,
|
||||
playhead: {
|
||||
currentTime: deck?.player.getCurrentTime?.() ?? null,
|
||||
duration: deck?.player.getDuration?.() ?? null,
|
||||
},
|
||||
transition,
|
||||
preroll,
|
||||
handover,
|
||||
decks: decks.map((d) => d.itemId),
|
||||
animation: animations?.name ?? null,
|
||||
background: backgrounds.key,
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Party Player — Queue</title>
|
||||
<!-- Queue first, deliberately: the server finds the player's window by
|
||||
matching this title as a prefix (see server/screen.js), and a queue tab
|
||||
titled "Party Player — …" would match it too. -->
|
||||
<title>Queue — Party Player</title>
|
||||
<link rel="stylesheet" href="/queue/queue.css" />
|
||||
</head>
|
||||
<body>
|
||||
@@ -51,6 +54,10 @@
|
||||
<span class="label">Animation</span>
|
||||
<div id="animations"></div>
|
||||
</div>
|
||||
<div class="switcher">
|
||||
<span class="label">Speed</span>
|
||||
<div id="speeds"></div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script type="module" src="/queue/queue.js"></script>
|
||||
|
||||
@@ -277,7 +277,8 @@ footer .switcher + .switcher {
|
||||
}
|
||||
|
||||
#backgrounds,
|
||||
#animations {
|
||||
#animations,
|
||||
#speeds {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
@@ -285,7 +286,8 @@ footer .switcher + .switcher {
|
||||
}
|
||||
|
||||
#backgrounds button,
|
||||
#animations button {
|
||||
#animations button,
|
||||
#speeds button {
|
||||
font: inherit;
|
||||
padding: 8px 18px;
|
||||
border-radius: 999px;
|
||||
@@ -296,7 +298,8 @@ footer .switcher + .switcher {
|
||||
}
|
||||
|
||||
#backgrounds button.active,
|
||||
#animations button.active {
|
||||
#animations button.active,
|
||||
#speeds button.active {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: var(--ink);
|
||||
|
||||
+22
-1
@@ -1,11 +1,18 @@
|
||||
import { connect, formatDuration } from '/shared/socket.js';
|
||||
import { BACKGROUNDS, AUTO_BACKGROUND, ANIMATIONS, AUTO_ANIMATION } from '/config.js';
|
||||
import {
|
||||
BACKGROUNDS,
|
||||
AUTO_BACKGROUND,
|
||||
ANIMATIONS,
|
||||
AUTO_ANIMATION,
|
||||
DEFAULT_LOW_RES,
|
||||
} from '/config.js';
|
||||
|
||||
let state = {
|
||||
items: [],
|
||||
currentId: null,
|
||||
background: AUTO_BACKGROUND,
|
||||
animation: AUTO_ANIMATION,
|
||||
lowRes: DEFAULT_LOW_RES,
|
||||
paused: false,
|
||||
};
|
||||
// Last playhead report, kept with its arrival time so the countdown can be
|
||||
@@ -18,6 +25,7 @@ const errorBox = document.getElementById('error');
|
||||
const statusBox = document.getElementById('status');
|
||||
const backgroundBox = document.getElementById('backgrounds');
|
||||
const animationBox = document.getElementById('animations');
|
||||
const speedBox = document.getElementById('speeds');
|
||||
const playPause = document.getElementById('playpause');
|
||||
|
||||
const socket = connect({ onMessage, onStatus });
|
||||
@@ -257,6 +265,18 @@ const renderAnimation = buildSwitcher(
|
||||
() => state.animation,
|
||||
);
|
||||
|
||||
// Not a row of names but a two-way switch, built the same way so it lights up
|
||||
// and travels like the others. The speeds themselves live in config.js.
|
||||
const renderSpeed = buildSwitcher(
|
||||
speedBox,
|
||||
[
|
||||
[false, 'Normal'],
|
||||
[true, 'Low-res screen'],
|
||||
],
|
||||
'setLowRes',
|
||||
() => Boolean(state.lowRes),
|
||||
);
|
||||
|
||||
// --- socket -------------------------------------------------------------
|
||||
|
||||
function onMessage(msg) {
|
||||
@@ -265,6 +285,7 @@ function onMessage(msg) {
|
||||
render();
|
||||
renderBackground();
|
||||
renderAnimation();
|
||||
renderSpeed();
|
||||
} else if (msg.type === 'progress') {
|
||||
progress = { id: msg.id, currentTime: msg.currentTime, at: Date.now() };
|
||||
renderTotals();
|
||||
|
||||
+19
-2
@@ -51,14 +51,24 @@ app.get('/shot', async (req, res) => {
|
||||
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),
|
||||
// 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())) {
|
||||
@@ -156,6 +166,13 @@ const handlers = {
|
||||
commit();
|
||||
},
|
||||
|
||||
setLowRes(_ws, msg) {
|
||||
if (typeof msg.key !== 'boolean') return;
|
||||
if (store.getState().lowRes === msg.key) return;
|
||||
store.getState().lowRes = msg.key;
|
||||
commit();
|
||||
},
|
||||
|
||||
// Relayed straight through: this arrives once a second and must never
|
||||
// touch the disk.
|
||||
progress(_ws, msg) {
|
||||
|
||||
+124
-17
@@ -9,6 +9,17 @@
|
||||
//
|
||||
// grim is the wlroots screencopy client (Hyprland, Sway, river). Elsewhere this
|
||||
// reports unavailable and the player falls back to getDisplayMedia.
|
||||
//
|
||||
// The player sends its crop in CSS pixels, which are its own private unit: they
|
||||
// are device pixels divided by devicePixelRatio, and that ratio moves with
|
||||
// browser zoom and with fractional display scaling. The page cannot convert
|
||||
// them itself — screen.width is reported in a different unit by every engine,
|
||||
// and Wayland reports window.screenX as 0, so there is no offset to add. The
|
||||
// compositor knows both numbers exactly, so we ask it: hyprctl gives the
|
||||
// window's position and size in logical pixels, and the ratio of that to the
|
||||
// viewport the page reports is the scale factor, whatever the zoom happens to
|
||||
// be. That is what lets the player run in an ordinary window instead of
|
||||
// insisting on fullscreen.
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
@@ -101,6 +112,64 @@ async function monitors() {
|
||||
return layout;
|
||||
}
|
||||
|
||||
/**
|
||||
* The player's window, in the compositor's logical coordinate space.
|
||||
*
|
||||
* `title` is the page's own document.title, which every browser puts at the
|
||||
* front of its window title. Matched as a prefix rather than a substring so the
|
||||
* queue tab — deliberately titled the other way round — cannot be mistaken for
|
||||
* the player. If several tabs are somehow showing the player, the focused one
|
||||
* wins, then the largest: on a projector that is the one the party can see.
|
||||
*
|
||||
* Null when nothing matches, which the caller reports rather than guessing at,
|
||||
* since cropping against the wrong window photographs the wrong thing.
|
||||
*/
|
||||
async function playerWindow(title) {
|
||||
const out = await run('hyprctl', ['clients', '-j']);
|
||||
if (!out) return null;
|
||||
|
||||
let clients = [];
|
||||
try {
|
||||
clients = JSON.parse(out.toString());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const matches = clients.filter(
|
||||
(client) =>
|
||||
typeof client.title === 'string' &&
|
||||
client.title.startsWith(title) &&
|
||||
Array.isArray(client.at) &&
|
||||
Array.isArray(client.size) &&
|
||||
client.size[0] > 0 &&
|
||||
client.size[1] > 0 &&
|
||||
// A window on another workspace is not on the glass. Photographing its
|
||||
// coordinates would return whatever is actually there instead.
|
||||
client.mapped !== false &&
|
||||
client.hidden !== true,
|
||||
);
|
||||
if (matches.length === 0) return null;
|
||||
|
||||
// Best first: the one actually on screen, then the most recently focused
|
||||
// (focusHistoryID counts up from 0 — there is no `focused` field on clients),
|
||||
// then the largest, which on a projector is the one the party can see.
|
||||
const rank = (client) => [
|
||||
client.visible === false ? 1 : 0,
|
||||
Number.isFinite(client.focusHistoryID) ? client.focusHistoryID : Number.MAX_SAFE_INTEGER,
|
||||
-(client.size[0] * client.size[1]),
|
||||
];
|
||||
|
||||
const [best] = matches.sort((a, b) => {
|
||||
const [ra, rb] = [rank(a), rank(b)];
|
||||
for (let i = 0; i < ra.length; i += 1) {
|
||||
if (ra[i] !== rb[i]) return ra[i] - rb[i];
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
return { x: best.at[0], y: best.at[1], width: best.size[0], height: best.size[1] };
|
||||
}
|
||||
|
||||
/** Whether this machine can be screenshotted at all. */
|
||||
export async function available() {
|
||||
if (grimAvailable === null) {
|
||||
@@ -110,31 +179,67 @@ export async function available() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Photograph one rectangle of the screen the player is on.
|
||||
* Where the page's viewport sits on screen, in logical pixels, given the
|
||||
* player's window and the viewport size the page reports in CSS pixels.
|
||||
*
|
||||
* `rect` is in CSS pixels, which equal logical screen pixels while the page is
|
||||
* fullscreen — that equality is the whole reason the caller insists on
|
||||
* fullscreen. `screenWidth`/`screenHeight` name which monitor to measure
|
||||
* against, so a player on a projector is not cropped against the laptop panel.
|
||||
*
|
||||
* Resolves to a JPEG buffer, or null if grim is unusable or the rectangle
|
||||
* falls outside the screen.
|
||||
* The viewport spans the window's full width — browsers put no chrome down the
|
||||
* sides — so their ratio is the CSS-pixel scale outright. CSS pixels are square,
|
||||
* so that one number does for both axes, and whatever vertical room it does not
|
||||
* account for is the tab and address bars. Measuring the chrome rather than
|
||||
* assuming it is zero is what lets a windowed player work; fullscreen is then
|
||||
* just the case where the leftover happens to be nothing.
|
||||
*/
|
||||
export async function capture({ x, y, width, height, screenWidth, screenHeight }) {
|
||||
if (!(await available())) return null;
|
||||
export function viewportOrigin(window, viewportWidth, viewportHeight) {
|
||||
const scale = window.width / viewportWidth;
|
||||
const chrome = window.height - viewportHeight * scale;
|
||||
|
||||
return { x: window.x, y: window.y + chrome, scale };
|
||||
}
|
||||
|
||||
/**
|
||||
* Photograph one rectangle of the player's viewport.
|
||||
*
|
||||
* `rect` is in CSS pixels, relative to the viewport — straight out of
|
||||
* getBoundingClientRect, with no fullscreen assumption behind it. `title` is the
|
||||
* page's document.title, used to find the window (see playerWindow).
|
||||
*
|
||||
* Resolves to a JPEG buffer, or null if grim is unusable, the window cannot be
|
||||
* found, or the rectangle falls outside the monitor.
|
||||
*/
|
||||
export async function capture({ x, y, width, height, viewportWidth, viewportHeight, title }) {
|
||||
if (!(await available())) return null;
|
||||
if (!(viewportWidth > 0 && viewportHeight > 0)) return null;
|
||||
|
||||
const window = await playerWindow(title);
|
||||
if (!window) return null;
|
||||
|
||||
const origin = viewportOrigin(window, viewportWidth, viewportHeight);
|
||||
|
||||
// Into global logical pixels, the space grim's -g takes.
|
||||
const gx = origin.x + x * origin.scale;
|
||||
const gy = origin.y + y * origin.scale;
|
||||
const gw = width * origin.scale;
|
||||
const gh = height * origin.scale;
|
||||
|
||||
// Which monitor the window is on, so a player on the projector is measured
|
||||
// against the projector and not the laptop panel.
|
||||
const screens = await monitors();
|
||||
const centreX = window.x + window.width / 2;
|
||||
const centreY = window.y + window.height / 2;
|
||||
const screen =
|
||||
screens.find((m) => m.width === screenWidth && m.height === screenHeight) ??
|
||||
screens.find(
|
||||
(m) =>
|
||||
centreX >= m.x && centreX < m.x + m.width && centreY >= m.y && centreY < m.y + m.height,
|
||||
) ??
|
||||
screens[0] ??
|
||||
{ x: 0, y: 0, scale: null, width: screenWidth, height: screenHeight };
|
||||
{ x: 0, y: 0, scale: null, width: Infinity, height: Infinity };
|
||||
|
||||
// The frame can hang off an edge mid-bounce. Clip rather than refuse: grim
|
||||
// fails outright on a region that leaves the output.
|
||||
const left = Math.max(0, Math.min(x, screen.width));
|
||||
const top = Math.max(0, Math.min(y, screen.height));
|
||||
const right = Math.max(left, Math.min(x + width, screen.width));
|
||||
const bottom = Math.max(top, Math.min(y + height, screen.height));
|
||||
const left = Math.max(screen.x, Math.min(gx, screen.x + screen.width));
|
||||
const top = Math.max(screen.y, Math.min(gy, screen.y + screen.height));
|
||||
const right = Math.max(left, Math.min(gx + gw, screen.x + screen.width));
|
||||
const bottom = Math.max(top, Math.min(gy + gh, screen.y + screen.height));
|
||||
const w = Math.round(right - left);
|
||||
const h = Math.round(bottom - top);
|
||||
if (w < 1 || h < 1) return null;
|
||||
@@ -146,7 +251,9 @@ export async function capture({ x, y, width, height, screenWidth, screenHeight }
|
||||
// asking grim to resample is an order of magnitude slower than not.
|
||||
// (-o would pin the scale too, but grim rejects it alongside -g.)
|
||||
if (screen.scale) args.push('-s', String(screen.scale));
|
||||
args.push('-g', `${Math.round(screen.x + left)},${Math.round(screen.y + top)} ${w}x${h}`, '-');
|
||||
// left/top are already global: the viewport origin was resolved against the
|
||||
// window's own position, which hyprctl reports in the same space.
|
||||
args.push('-g', `${Math.round(left)},${Math.round(top)} ${w}x${h}`, '-');
|
||||
|
||||
return run('grim', args);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
DEFAULT_ANIMATION,
|
||||
ANIMATIONS,
|
||||
AUTO_ANIMATION,
|
||||
DEFAULT_LOW_RES,
|
||||
} from '../config.js';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
@@ -23,6 +24,9 @@ const state = {
|
||||
currentId: null,
|
||||
background: DEFAULT_BACKGROUND,
|
||||
animation: DEFAULT_ANIMATION,
|
||||
// Slow every animation down for a small screen. Saved, unlike `paused`: it
|
||||
// describes the screen the player is on, which a restart does not change.
|
||||
lowRes: DEFAULT_LOW_RES,
|
||||
// Deliberately absent from save(): a restart should bring the music back,
|
||||
// not leave the party silently paused because of a click hours ago.
|
||||
paused: false,
|
||||
@@ -57,6 +61,7 @@ export function load() {
|
||||
}
|
||||
if (isBackground(saved.background)) state.background = saved.background;
|
||||
if (isAnimation(saved.animation)) state.animation = saved.animation;
|
||||
if (typeof saved.lowRes === 'boolean') state.lowRes = saved.lowRes;
|
||||
// Resume at the item that was playing, otherwise at the top of the list.
|
||||
state.currentId = state.items.some((i) => i.id === saved.currentId)
|
||||
? saved.currentId
|
||||
@@ -79,6 +84,7 @@ export function save() {
|
||||
currentId: state.currentId,
|
||||
background: state.background,
|
||||
animation: state.animation,
|
||||
lowRes: state.lowRes,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
|
||||
Reference in New Issue
Block a user