Author SHA1 Message Date
SiandClaude Opus 5 176ca6868f Close the three seams in the crossfade
Track changes were audible: a gap in the audio, a clipped ending, and the
outgoing video briefly restarting. Three separate causes, all in the handover.

The pointer race is what restarts the video. finishTransition sends trackEnded
and the server only moves currentId once it has processed it, so for one round
trip state.currentId still names the track that just faded out — which is still
in items. The next tick, at most 250ms later, reads that as "the pointer moved
somewhere we are not playing" and hard-cuts back to it from zero; the real
broadcast then arrives and cuts forward again, restarting the new track too.
The player now advances its own pointer as it sends and reconciles against the
broadcast, keeping its version while the server still names the finished track
and yielding to anything else, which is someone jumping.

The fade outlasted its own track. It was triggered at OVERLAP_SECONDS left but
ran for a fixed OVERLAP_SECONDS, and the tick that fires it is already up to a
quarter second late — getDuration() also reports a length that runs a little
past the last of the audio. So the outgoing track always ran out mid-fade: the
ending is clipped, and YouTube puts up its end screen, which shows the video's
first frame and reads as the track starting over. The fade now runs for what is
actually left, less END_GUARD_SECONDS, so it lands before the track does.

The gap is the incoming deck buffering on the clock. beginCrossfade loaded the
next video and started a wall-clock ramp against it, so the outgoing track was
ducked while the incoming one was still fetching its first bytes. The next
track is now prerolled PREROLL_SECONDS ahead — loaded, played until YouTube
reports it running, then seeked back to 0 and paused, which is what keeps the
fade opening on the first bar rather than four seconds in — and the ramp does
not open until the incoming deck is genuinely playing. START_WAIT_SECONDS caps
that wait so a video that never loads cannot strand a track that is ending.

Not verified against live playback: the browser the automation drives renders
its tab offscreen, permanently visibilityState "hidden", and Chrome will not
start a YouTube video there — the deck loads and reports its duration, but the
playhead never leaves zero. Confirmed only that the page loads and the decks
build. __player.state gains playhead, preroll and handover, and seekToEnd()
now lands before the preroll window so one call exercises the whole handover.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011vCaS2whbFjExShoPNnS3A
2026-08-14 13:40:36 +02:00
SiandClaude Opus 5 554b946235 Slow every animation down for a low-res screen
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 player reads as
hurried.

One factor now sits behind every animation clock — tickSeconds() for the
frame loops, `speed` for the corners timer, and a --motion-stretch custom
property for the two CSS fades — so the Speed switch on the queue page slows
the drift, the glides, the pong rally, the drag beats, the wash lap, the
screenshot intervals and the fades together. It is read per frame rather than
baked into the constants, so flipping the switch lands on the next frame: no
animation restarts, and one part-way through a beat keeps its place.

The trail's stills now age on animation time rather than wall-clock time, so a
still fades at the rate the frame that dropped it is moving. The audio
crossfade is deliberately left out, being tied to the track that is ending.

The flag is saved with the queue, unlike `paused`: it describes the screen the
player is on, which a restart does not change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011vCaS2whbFjExShoPNnS3A
2026-08-14 12:53:33 +02:00
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
12 changed files with 604 additions and 99 deletions
+72 -10
View File
@@ -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.
+32
View File
@@ -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
View File
@@ -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
View File
@@ -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
+6 -2
View File
@@ -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
View File
@@ -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,
+8 -1
View File
@@ -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>
+6 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}
+6
View File
@@ -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,