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
|
overlap in a 5 second audio/video crossfade, so there is never silence
|
||||||
between tracks.
|
between tracks.
|
||||||
- **Queue** (`http://localhost:3000/queue`) — add videos by URL, drag to
|
- **Queue** (`http://localhost:3000/queue`) — add videos by URL, drag to
|
||||||
reorder, delete, see how much playtime is left, and pick the background and
|
reorder, delete, see how much playtime is left, and pick the background,
|
||||||
animation.
|
animation and animation speed.
|
||||||
|
|
||||||
## Run
|
## 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.
|
- 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
|
- Settings live in `config.js`: overlap length, frame size, drift speed, corner
|
||||||
screenshot interval and margin, the drag animation's size range, the
|
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
|
`PORT` is mirrored to the browser by the generated `/config.js` route in
|
||||||
`server/index.js`, so a new constant needs no second list.
|
`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
|
## Backgrounds
|
||||||
|
|
||||||
Every image file in `assets/` is a background — drop one in, restart, and it
|
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,
|
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.
|
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
|
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.
|
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
|
**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
|
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
|
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
|
permission prompt, and no "Sharing this tab" bar parked over the party. This
|
||||||
needs a wlroots compositor — Hyprland, Sway, river — and `grim` on `PATH`;
|
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.
|
`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
|
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
|
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
|
- **A single window** cannot be used: it includes the tab and URL bars, and
|
||||||
Wayland gives the page no window coordinates to subtract.
|
Wayland gives the page no window coordinates to subtract.
|
||||||
|
|
||||||
**Either way the page has to be fullscreen**, which is why **Start** requests it:
|
**The fallback needs the page fullscreen**, which is the one case it can map: a
|
||||||
only then does a CSS pixel line up with a screen pixel. Leave fullscreen and the
|
CSS pixel lines up with a screen pixel only there, and only at 100% zoom, since
|
||||||
screenshots pause until you press <kbd>F11</kbd>. The exception is Chrome's tab
|
`innerWidth` scales with zoom and `screen.width` does not. Leave fullscreen and
|
||||||
capture, which is the viewport by definition and lines up in a window too.
|
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,
|
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
|
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
|
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.
|
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
|
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
|
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.
|
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.
|
// How fast the player switches when the user jumps around manually.
|
||||||
export const HARD_CUT_SECONDS = 0.4;
|
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 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
|
// the corners animation puts a frame-sized image in all four corners, which
|
||||||
// only stays overlap-free while two frames fit side by side.
|
// 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.
|
// Seconds the ball waits out of play after it gets past the paddle.
|
||||||
export const PONG_SERVE_SECONDS = 1.1;
|
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
|
// 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.
|
// match the registry in public/player/motion.js, which supplies the code.
|
||||||
export const ANIMATIONS = {
|
export const ANIMATIONS = {
|
||||||
|
|||||||
+89
-27
@@ -15,10 +15,13 @@
|
|||||||
// path removes — a picker, a sharing indicator, and on Firefox no tab capture
|
// path removes — a picker, a sharing indicator, and on Firefox no tab capture
|
||||||
// at all, only whole windows and screens.
|
// at all, only whole windows and screens.
|
||||||
//
|
//
|
||||||
// Both need the page fullscreen, for the same reason: only then does a CSS
|
// The two paths differ in what they need of the window. The fallback crops a
|
||||||
// pixel equal a screen pixel, and a shared window can never be mapped at all —
|
// stream of the whole screen, so it needs the page fullscreen: only then does a
|
||||||
// it carries the tab bar and URL bar, and Wayland reports window.screenX as 0,
|
// CSS pixel equal a screen pixel, and only then is the viewport's origin the
|
||||||
// so there is no offset to subtract.
|
// 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 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
|
// 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;
|
let serverShots = null;
|
||||||
|
|
||||||
if (LOCAL_HOSTS.has(location.hostname)) {
|
if (LOCAL_HOSTS.has(location.hostname)) {
|
||||||
// Any rect will do; only the status code is read.
|
// Any rect will do; only the status code is read. 501 is "this machine cannot
|
||||||
fetch('/shot?x=0&y=0&w=2&h=2&sw=0&sh=0', { method: 'GET', cache: 'no-store' })
|
// 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) => {
|
.then((response) => {
|
||||||
serverShots = response.status !== 501 && response.status !== 404;
|
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
|
* Ask for a screenshot source. Resolves to a `{ grab, stop }` handle, or to
|
||||||
* null if there is none — callers degrade rather than fail.
|
* 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
|
* 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
|
* `document.fullscreenElement`, because F11 fullscreen is browser-level and
|
||||||
* leaves that property null.
|
* 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() {
|
function fillsScreen() {
|
||||||
return window.innerWidth === screen.width && window.innerHeight === screen.height;
|
return window.innerWidth === screen.width && window.innerHeight === screen.height;
|
||||||
@@ -97,38 +129,68 @@ function trackAlignment(onIssue, initial) {
|
|||||||
// --- server screenshots -------------------------------------------------
|
// --- server screenshots -------------------------------------------------
|
||||||
|
|
||||||
function openServerCapture(onIssue) {
|
function openServerCapture(onIssue) {
|
||||||
const aligned = trackAlignment(onIssue, fillsScreen());
|
|
||||||
let nonce = 0;
|
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 {
|
return {
|
||||||
/**
|
/**
|
||||||
* A URL for a fresh photograph of `rect`, in CSS pixels. Safe to hand
|
* A URL for a fresh photograph of `rect`, in CSS pixels relative to the
|
||||||
* straight to an <img>: the server returns the crop itself, already sized.
|
* viewport. Safe to hand straight to an <img>: the server returns the crop
|
||||||
* Null while the viewport does not cover the screen.
|
* itself, already sized.
|
||||||
*/
|
*/
|
||||||
grab({ x, y, width, height }) {
|
grab({ x, y, width, height }) {
|
||||||
if (!aligned()) return null;
|
|
||||||
if (width <= 0 || height <= 0) return null;
|
if (width <= 0 || height <= 0) return null;
|
||||||
|
return shotUrl({ x, y, width, height }, ++nonce);
|
||||||
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}`;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// Nothing is held open: each screenshot is one request.
|
// No stream is held open — each screenshot is one request — but the resize
|
||||||
stop() {},
|
// 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 -------------------------------------------
|
// --- getDisplayMedia fallback -------------------------------------------
|
||||||
|
|
||||||
function requestDisplayMedia(onIssue) {
|
function requestDisplayMedia(onIssue) {
|
||||||
|
|||||||
+66
-16
@@ -36,6 +36,37 @@ import {
|
|||||||
|
|
||||||
const ASPECT = 9 / 16;
|
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
|
* 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.
|
* `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) {
|
function frame(now) {
|
||||||
if (stopped) return;
|
if (stopped) return;
|
||||||
|
|
||||||
// Clamped so a stalled tab does not teleport the frame on resume.
|
const dt = tickSeconds(now, last);
|
||||||
const dt = Math.min((now - last) / 1000, 0.05);
|
|
||||||
last = now;
|
last = now;
|
||||||
|
|
||||||
bouncer.step(dt, bounds());
|
bouncer.step(dt, bounds());
|
||||||
@@ -307,10 +337,21 @@ export function startCorners(context) {
|
|||||||
|
|
||||||
layout();
|
layout();
|
||||||
window.addEventListener('resize', 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() {
|
return function stop() {
|
||||||
clearInterval(timer);
|
clearTimeout(timer);
|
||||||
window.removeEventListener('resize', layout);
|
window.removeEventListener('resize', layout);
|
||||||
for (const cell of cells) cell.img.remove();
|
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. */
|
/** 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
|
// Read late: the capture handle may still be waiting on the operator to
|
||||||
// answer the screen-share prompt when this animation starts.
|
// answer the screen-share prompt when this animation starts.
|
||||||
if (!context.capture) return false;
|
if (!context.capture) return false;
|
||||||
@@ -470,7 +511,7 @@ export function startTrail(context) {
|
|||||||
cell.img.style.width = `${rect.width}px`;
|
cell.img.style.width = `${rect.width}px`;
|
||||||
cell.img.style.height = `${rect.height}px`;
|
cell.img.style.height = `${rect.height}px`;
|
||||||
place(cell.img, rect.left, rect.top);
|
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 };
|
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.
|
// Re-append so the freshest still paints over the rest of the tail.
|
||||||
layer.appendChild(cell.img);
|
layer.appendChild(cell.img);
|
||||||
@@ -479,11 +520,11 @@ export function startTrail(context) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function fade(now) {
|
function fade(clock) {
|
||||||
for (const cell of cells) {
|
for (const cell of cells) {
|
||||||
if (!cell.rect) continue;
|
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) {
|
if (life <= 0) {
|
||||||
cell.img.style.opacity = '0';
|
cell.img.style.opacity = '0';
|
||||||
// Gone, so it stops holding up drops that pass over its old spot.
|
// 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);
|
window.addEventListener('resize', measure);
|
||||||
|
|
||||||
let last = performance.now();
|
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 handle = 0;
|
||||||
let stopped = false;
|
let stopped = false;
|
||||||
|
|
||||||
function frame(now) {
|
function frame(now) {
|
||||||
if (stopped) return;
|
if (stopped) return;
|
||||||
|
|
||||||
const dt = Math.min((now - last) / 1000, 0.05);
|
const dt = tickSeconds(now, last);
|
||||||
last = now;
|
last = now;
|
||||||
|
clock += dt;
|
||||||
|
|
||||||
const wasX = glider.x;
|
const wasX = glider.x;
|
||||||
const wasY = glider.y;
|
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
|
// Spacing by distance rather than by a timer keeps the stills evenly spread
|
||||||
// however the frame is turning.
|
// however the frame is turning.
|
||||||
travelled += Math.hypot(glider.x - wasX, glider.y - wasY);
|
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);
|
handle = requestAnimationFrame(frame);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -884,9 +929,8 @@ export function startDragResize({ element }) {
|
|||||||
function frame(now) {
|
function frame(now) {
|
||||||
if (stopped) return;
|
if (stopped) return;
|
||||||
|
|
||||||
const dt = Math.min((now - last) / 1000, 0.05);
|
elapsed += tickSeconds(now, last);
|
||||||
last = now;
|
last = now;
|
||||||
elapsed += dt;
|
|
||||||
|
|
||||||
const p = current.duration > 0 ? Math.min(elapsed / current.duration, 1) : 1;
|
const p = current.duration > 0 ? Math.min(elapsed / current.duration, 1) : 1;
|
||||||
current.update(p);
|
current.update(p);
|
||||||
@@ -959,14 +1003,20 @@ export function startWash({ element }) {
|
|||||||
centre();
|
centre();
|
||||||
window.addEventListener('resize', 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 handle = 0;
|
||||||
let stopped = false;
|
let stopped = false;
|
||||||
|
|
||||||
function frame(now) {
|
function frame(now) {
|
||||||
if (stopped) return;
|
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)})`;
|
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
|
// 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.
|
// of sitting on top of it in a fixed pink.
|
||||||
@@ -1336,7 +1386,7 @@ export function startPong({ element }) {
|
|||||||
function frame(now) {
|
function frame(now) {
|
||||||
if (stopped) return;
|
if (stopped) return;
|
||||||
|
|
||||||
const dt = Math.min((now - last) / 1000, 0.05);
|
const dt = tickSeconds(now, last);
|
||||||
last = now;
|
last = now;
|
||||||
|
|
||||||
// Out of play between the goal and the serve, so the score has a beat to be
|
// Out of play between the goal and the serve, so the score has a beat to be
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
:root {
|
:root {
|
||||||
--accent: #ff4fc3;
|
--accent: #ff4fc3;
|
||||||
--ink: #14060f;
|
--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%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
opacity: 0;
|
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
|
/* 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. */
|
posters are pale and read far brighter on a projector than on a desk. */
|
||||||
filter: brightness(0.45) saturate(1.15);
|
filter: brightness(0.45) saturate(1.15);
|
||||||
@@ -97,7 +101,7 @@ body {
|
|||||||
box-shadow:
|
box-shadow:
|
||||||
0 0 0 4px rgba(255, 79, 195, 0.55),
|
0 0 0 4px rgba(255, 79, 195, 0.55),
|
||||||
0 24px 70px rgba(0, 0, 0, 0.7);
|
0 24px 70px rgba(0, 0, 0, 0.7);
|
||||||
transition: opacity 500ms ease;
|
transition: opacity calc(500ms * var(--motion-stretch)) ease;
|
||||||
will-change: transform;
|
will-change: transform;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+150
-16
@@ -1,7 +1,17 @@
|
|||||||
import { connect } from '/shared/socket.js';
|
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 { createPlayer, loadApi, STATE } from './yt.js';
|
||||||
import { startAnimationCycle } from './motion.js';
|
import { startAnimationCycle, setSpeed } from './motion.js';
|
||||||
import { startBackgroundCycle, showLogo } from './backdrop.js';
|
import { startBackgroundCycle, showLogo } from './backdrop.js';
|
||||||
import { requestCapture } from './capture.js';
|
import { requestCapture } from './capture.js';
|
||||||
|
|
||||||
@@ -12,16 +22,35 @@ let state = {
|
|||||||
currentId: null,
|
currentId: null,
|
||||||
background: AUTO_BACKGROUND,
|
background: AUTO_BACKGROUND,
|
||||||
animation: AUTO_ANIMATION,
|
animation: AUTO_ANIMATION,
|
||||||
|
lowRes: DEFAULT_LOW_RES,
|
||||||
paused: false,
|
paused: false,
|
||||||
};
|
};
|
||||||
let decks = [];
|
let decks = [];
|
||||||
let active = 0;
|
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 started = false;
|
||||||
let animations = null;
|
let animations = null;
|
||||||
|
|
||||||
const socket = connect({ onMessage, onStatus });
|
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 --------------------------------------------------------
|
// --- backgrounds --------------------------------------------------------
|
||||||
|
|
||||||
// Runs from page load, not from the start gate: the poster is what is on
|
// 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.
|
// chosen without one, so this ordering has to hold either way.
|
||||||
const capturing = requestCapture({ onIssue: showNotice });
|
const capturing = requestCapture({ onIssue: showNotice });
|
||||||
|
|
||||||
// Screenshots only line up while the viewport covers the whole screen, since
|
// Cosmetic now rather than required: the server path locates the window
|
||||||
// that is what makes a CSS pixel a screen pixel. Chrome's tab capture is the
|
// itself, so screenshots line up windowed too. It is still what you want on a
|
||||||
// one exception, and fullscreen does it no harm.
|
// projector, so ask — but say nothing if it is refused, since nothing breaks.
|
||||||
document.documentElement.requestFullscreen?.().catch(() => {
|
// (The getDisplayMedia fallback does still need it; that path says so itself.)
|
||||||
showNotice('Could not go fullscreen — press F11 so screenshots line up.');
|
document.documentElement.requestFullscreen?.().catch(() => {});
|
||||||
});
|
|
||||||
|
|
||||||
gate.classList.add('hidden');
|
gate.classList.add('hidden');
|
||||||
|
|
||||||
@@ -130,6 +158,22 @@ function playOn(index, item, { volume, opacity }) {
|
|||||||
deck.setOpacity(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
|
* 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
|
* rather than abandoned: the ramp runs on wall-clock time, so the paused
|
||||||
@@ -145,7 +189,11 @@ function applyPaused(paused) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (transition?.pausedAt) {
|
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;
|
transition.pausedAt = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,6 +213,17 @@ function onDeckState(index, code) {
|
|||||||
return;
|
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;
|
if (index !== active || transition) return;
|
||||||
|
|
||||||
// Safety net: a track that ended without our tick catching it (very short
|
// 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
|
// Queue emptied out from under us — stop rather than loop the video the
|
||||||
// user just deleted.
|
// user just deleted.
|
||||||
if (state.items.length === 0) {
|
if (state.items.length === 0) {
|
||||||
|
if (preroll) {
|
||||||
|
decks[preroll.deck].player.stopVideo();
|
||||||
|
decks[preroll.deck].itemId = null;
|
||||||
|
preroll = null;
|
||||||
|
}
|
||||||
if (deck.itemId) {
|
if (deck.itemId) {
|
||||||
deck.player.stopVideo();
|
deck.player.stopVideo();
|
||||||
deck.setOpacity(0);
|
deck.setOpacity(0);
|
||||||
@@ -229,11 +293,24 @@ function tick() {
|
|||||||
const currentTime = deck.player.getCurrentTime?.() ?? 0;
|
const currentTime = deck.player.getCurrentTime?.() ?? 0;
|
||||||
if (!duration) return;
|
if (!duration) return;
|
||||||
|
|
||||||
if (duration - currentTime <= OVERLAP_SECONDS) {
|
const remaining = duration - currentTime;
|
||||||
|
if (remaining > OVERLAP_SECONDS + PREROLL_SECONDS) return;
|
||||||
|
|
||||||
const next = nextItem();
|
const next = nextItem();
|
||||||
// A single-item queue loops instead of crossfading with itself.
|
// A single-item queue loops instead of crossfading with itself.
|
||||||
if (!next || next.id === deck.itemId) return;
|
if (!next || next.id === deck.itemId) return;
|
||||||
beginCrossfade(next, OVERLAP_SECONDS, { advance: true });
|
|
||||||
|
// 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 from = active;
|
||||||
const to = 1 - active;
|
const to = 1 - active;
|
||||||
|
|
||||||
|
// 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 });
|
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();
|
decks[to].player.playVideo();
|
||||||
|
|
||||||
transition = {
|
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,
|
duration: seconds * 1000,
|
||||||
from,
|
from,
|
||||||
to,
|
to,
|
||||||
@@ -264,10 +356,22 @@ function beginCrossfade(item, seconds, { advance }) {
|
|||||||
function stepTransition() {
|
function stepTransition() {
|
||||||
if (!transition || transition.pausedAt) return false;
|
if (!transition || transition.pausedAt) return false;
|
||||||
|
|
||||||
const progress = Math.min((performance.now() - transition.startedAt) / transition.duration, 1);
|
|
||||||
const outgoing = decks[transition.from];
|
const outgoing = decks[transition.from];
|
||||||
const incoming = decks[transition.to];
|
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
|
// Equal-power curve: the combined loudness stays flat through the middle
|
||||||
// of the mix instead of dipping.
|
// of the mix instead of dipping.
|
||||||
outgoing.player.setVolume(Math.round(Math.cos((progress * Math.PI) / 2) * 100));
|
outgoing.player.setVolume(Math.round(Math.cos((progress * Math.PI) / 2) * 100));
|
||||||
@@ -302,7 +406,16 @@ function finishTransition() {
|
|||||||
animations?.next();
|
animations?.next();
|
||||||
backgrounds.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() {
|
function reportProgress() {
|
||||||
@@ -380,9 +493,22 @@ function onMessage(msg) {
|
|||||||
const previousAnimation = state.animation;
|
const previousAnimation = state.animation;
|
||||||
const previousBackground = state.background;
|
const previousBackground = state.background;
|
||||||
const previousPaused = state.paused;
|
const previousPaused = state.paused;
|
||||||
|
const previousLowRes = state.lowRes;
|
||||||
state = msg.state;
|
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.background !== previousBackground) backgrounds.setMode(state.background);
|
||||||
if (state.animation !== previousAnimation) animations?.setMode(state.animation);
|
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);
|
if (state.paused !== previousPaused) applyPaused(state.paused);
|
||||||
updateGate();
|
updateGate();
|
||||||
pumpProbe();
|
pumpProbe();
|
||||||
@@ -415,7 +541,8 @@ updateGate();
|
|||||||
|
|
||||||
// Debug helper used when verifying the crossfade without waiting out a track.
|
// Debug helper used when verifying the crossfade without waiting out a track.
|
||||||
window.__player = {
|
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 deck = decks[active];
|
||||||
const duration = deck?.player.getDuration?.() ?? 0;
|
const duration = deck?.player.getDuration?.() ?? 0;
|
||||||
if (duration) deck.player.seekTo(Math.max(0, duration - secondsBefore), true);
|
if (duration) deck.player.seekTo(Math.max(0, duration - secondsBefore), true);
|
||||||
@@ -427,10 +554,17 @@ window.__player = {
|
|||||||
return backgrounds.next();
|
return backgrounds.next();
|
||||||
},
|
},
|
||||||
get state() {
|
get state() {
|
||||||
|
const deck = decks[active];
|
||||||
return {
|
return {
|
||||||
state,
|
state,
|
||||||
active,
|
active,
|
||||||
|
playhead: {
|
||||||
|
currentTime: deck?.player.getCurrentTime?.() ?? null,
|
||||||
|
duration: deck?.player.getDuration?.() ?? null,
|
||||||
|
},
|
||||||
transition,
|
transition,
|
||||||
|
preroll,
|
||||||
|
handover,
|
||||||
decks: decks.map((d) => d.itemId),
|
decks: decks.map((d) => d.itemId),
|
||||||
animation: animations?.name ?? null,
|
animation: animations?.name ?? null,
|
||||||
background: backgrounds.key,
|
background: backgrounds.key,
|
||||||
|
|||||||
@@ -3,7 +3,10 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<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" />
|
<link rel="stylesheet" href="/queue/queue.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -51,6 +54,10 @@
|
|||||||
<span class="label">Animation</span>
|
<span class="label">Animation</span>
|
||||||
<div id="animations"></div>
|
<div id="animations"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="switcher">
|
||||||
|
<span class="label">Speed</span>
|
||||||
|
<div id="speeds"></div>
|
||||||
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<script type="module" src="/queue/queue.js"></script>
|
<script type="module" src="/queue/queue.js"></script>
|
||||||
|
|||||||
@@ -277,7 +277,8 @@ footer .switcher + .switcher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#backgrounds,
|
#backgrounds,
|
||||||
#animations {
|
#animations,
|
||||||
|
#speeds {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -285,7 +286,8 @@ footer .switcher + .switcher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#backgrounds button,
|
#backgrounds button,
|
||||||
#animations button {
|
#animations button,
|
||||||
|
#speeds button {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
padding: 8px 18px;
|
padding: 8px 18px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
@@ -296,7 +298,8 @@ footer .switcher + .switcher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#backgrounds button.active,
|
#backgrounds button.active,
|
||||||
#animations button.active {
|
#animations button.active,
|
||||||
|
#speeds button.active {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
|
|||||||
+22
-1
@@ -1,11 +1,18 @@
|
|||||||
import { connect, formatDuration } from '/shared/socket.js';
|
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 = {
|
let state = {
|
||||||
items: [],
|
items: [],
|
||||||
currentId: null,
|
currentId: null,
|
||||||
background: AUTO_BACKGROUND,
|
background: AUTO_BACKGROUND,
|
||||||
animation: AUTO_ANIMATION,
|
animation: AUTO_ANIMATION,
|
||||||
|
lowRes: DEFAULT_LOW_RES,
|
||||||
paused: false,
|
paused: false,
|
||||||
};
|
};
|
||||||
// Last playhead report, kept with its arrival time so the countdown can be
|
// 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 statusBox = document.getElementById('status');
|
||||||
const backgroundBox = document.getElementById('backgrounds');
|
const backgroundBox = document.getElementById('backgrounds');
|
||||||
const animationBox = document.getElementById('animations');
|
const animationBox = document.getElementById('animations');
|
||||||
|
const speedBox = document.getElementById('speeds');
|
||||||
const playPause = document.getElementById('playpause');
|
const playPause = document.getElementById('playpause');
|
||||||
|
|
||||||
const socket = connect({ onMessage, onStatus });
|
const socket = connect({ onMessage, onStatus });
|
||||||
@@ -257,6 +265,18 @@ const renderAnimation = buildSwitcher(
|
|||||||
() => state.animation,
|
() => 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 -------------------------------------------------------------
|
// --- socket -------------------------------------------------------------
|
||||||
|
|
||||||
function onMessage(msg) {
|
function onMessage(msg) {
|
||||||
@@ -265,6 +285,7 @@ function onMessage(msg) {
|
|||||||
render();
|
render();
|
||||||
renderBackground();
|
renderBackground();
|
||||||
renderAnimation();
|
renderAnimation();
|
||||||
|
renderSpeed();
|
||||||
} else if (msg.type === 'progress') {
|
} else if (msg.type === 'progress') {
|
||||||
progress = { id: msg.id, currentTime: msg.currentTime, at: Date.now() };
|
progress = { id: msg.id, currentTime: msg.currentTime, at: Date.now() };
|
||||||
renderTotals();
|
renderTotals();
|
||||||
|
|||||||
+19
-2
@@ -51,14 +51,24 @@ app.get('/shot', async (req, res) => {
|
|||||||
y: Number(req.query.y),
|
y: Number(req.query.y),
|
||||||
width: Number(req.query.w),
|
width: Number(req.query.w),
|
||||||
height: Number(req.query.h),
|
height: Number(req.query.h),
|
||||||
screenWidth: Number(req.query.sw),
|
// The viewport the page measured its rect against. The crop is in CSS
|
||||||
screenHeight: Number(req.query.sh),
|
// 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))) {
|
if (Object.values(rect).some((value) => !Number.isFinite(value))) {
|
||||||
res.sendStatus(400);
|
res.sendStatus(400);
|
||||||
return;
|
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
|
// 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".
|
// to getDisplayMedia, and needs to tell "wrong machine" from "no route".
|
||||||
if (!(await screen.available())) {
|
if (!(await screen.available())) {
|
||||||
@@ -156,6 +166,13 @@ const handlers = {
|
|||||||
commit();
|
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
|
// Relayed straight through: this arrives once a second and must never
|
||||||
// touch the disk.
|
// touch the disk.
|
||||||
progress(_ws, msg) {
|
progress(_ws, msg) {
|
||||||
|
|||||||
+124
-17
@@ -9,6 +9,17 @@
|
|||||||
//
|
//
|
||||||
// grim is the wlroots screencopy client (Hyprland, Sway, river). Elsewhere this
|
// grim is the wlroots screencopy client (Hyprland, Sway, river). Elsewhere this
|
||||||
// reports unavailable and the player falls back to getDisplayMedia.
|
// 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';
|
import { spawn } from 'node:child_process';
|
||||||
|
|
||||||
@@ -101,6 +112,64 @@ async function monitors() {
|
|||||||
return layout;
|
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. */
|
/** Whether this machine can be screenshotted at all. */
|
||||||
export async function available() {
|
export async function available() {
|
||||||
if (grimAvailable === null) {
|
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
|
* The viewport spans the window's full width — browsers put no chrome down the
|
||||||
* fullscreen — that equality is the whole reason the caller insists on
|
* sides — so their ratio is the CSS-pixel scale outright. CSS pixels are square,
|
||||||
* fullscreen. `screenWidth`/`screenHeight` name which monitor to measure
|
* so that one number does for both axes, and whatever vertical room it does not
|
||||||
* against, so a player on a projector is not cropped against the laptop panel.
|
* 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
|
||||||
* Resolves to a JPEG buffer, or null if grim is unusable or the rectangle
|
* just the case where the leftover happens to be nothing.
|
||||||
* falls outside the screen.
|
|
||||||
*/
|
*/
|
||||||
export async function capture({ x, y, width, height, screenWidth, screenHeight }) {
|
export function viewportOrigin(window, viewportWidth, viewportHeight) {
|
||||||
if (!(await available())) return null;
|
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 screens = await monitors();
|
||||||
|
const centreX = window.x + window.width / 2;
|
||||||
|
const centreY = window.y + window.height / 2;
|
||||||
const screen =
|
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] ??
|
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
|
// The frame can hang off an edge mid-bounce. Clip rather than refuse: grim
|
||||||
// fails outright on a region that leaves the output.
|
// fails outright on a region that leaves the output.
|
||||||
const left = Math.max(0, Math.min(x, screen.width));
|
const left = Math.max(screen.x, Math.min(gx, screen.x + screen.width));
|
||||||
const top = Math.max(0, Math.min(y, screen.height));
|
const top = Math.max(screen.y, Math.min(gy, screen.y + screen.height));
|
||||||
const right = Math.max(left, Math.min(x + width, screen.width));
|
const right = Math.max(left, Math.min(gx + gw, screen.x + screen.width));
|
||||||
const bottom = Math.max(top, Math.min(y + height, screen.height));
|
const bottom = Math.max(top, Math.min(gy + gh, screen.y + screen.height));
|
||||||
const w = Math.round(right - left);
|
const w = Math.round(right - left);
|
||||||
const h = Math.round(bottom - top);
|
const h = Math.round(bottom - top);
|
||||||
if (w < 1 || h < 1) return null;
|
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.
|
// asking grim to resample is an order of magnitude slower than not.
|
||||||
// (-o would pin the scale too, but grim rejects it alongside -g.)
|
// (-o would pin the scale too, but grim rejects it alongside -g.)
|
||||||
if (screen.scale) args.push('-s', String(screen.scale));
|
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);
|
return run('grim', args);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
DEFAULT_ANIMATION,
|
DEFAULT_ANIMATION,
|
||||||
ANIMATIONS,
|
ANIMATIONS,
|
||||||
AUTO_ANIMATION,
|
AUTO_ANIMATION,
|
||||||
|
DEFAULT_LOW_RES,
|
||||||
} from '../config.js';
|
} from '../config.js';
|
||||||
|
|
||||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
@@ -23,6 +24,9 @@ const state = {
|
|||||||
currentId: null,
|
currentId: null,
|
||||||
background: DEFAULT_BACKGROUND,
|
background: DEFAULT_BACKGROUND,
|
||||||
animation: DEFAULT_ANIMATION,
|
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,
|
// Deliberately absent from save(): a restart should bring the music back,
|
||||||
// not leave the party silently paused because of a click hours ago.
|
// not leave the party silently paused because of a click hours ago.
|
||||||
paused: false,
|
paused: false,
|
||||||
@@ -57,6 +61,7 @@ export function load() {
|
|||||||
}
|
}
|
||||||
if (isBackground(saved.background)) state.background = saved.background;
|
if (isBackground(saved.background)) state.background = saved.background;
|
||||||
if (isAnimation(saved.animation)) state.animation = saved.animation;
|
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.
|
// Resume at the item that was playing, otherwise at the top of the list.
|
||||||
state.currentId = state.items.some((i) => i.id === saved.currentId)
|
state.currentId = state.items.some((i) => i.id === saved.currentId)
|
||||||
? saved.currentId
|
? saved.currentId
|
||||||
@@ -79,6 +84,7 @@ export function save() {
|
|||||||
currentId: state.currentId,
|
currentId: state.currentId,
|
||||||
background: state.background,
|
background: state.background,
|
||||||
animation: state.animation,
|
animation: state.animation,
|
||||||
|
lowRes: state.lowRes,
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
2,
|
2,
|
||||||
|
|||||||
Reference in New Issue
Block a user