Commit Graph
10 Commits
Author SHA1 Message Date
simonandClaude Opus 5 0683acd69e Screenshot from the server instead of the browser
getDisplayMedia costs a picker, a permission, and a "Sharing this tab" bar
parked over the projector all night. None of it can be suppressed from the
page — the indicator is a security surface with no API, and the Chromium
switches that hide it were no help here.

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H9v2Xoa6eoeGYpK6gp95k9
2026-08-10 22:49:36 +02:00
simonandClaude Opus 5 db672b8b32 Rotate the background with every track
The poster behind the frame was one of two, picked by hand and then left
there all night. It now turns over on every track change the way the
animations do, and never as the same picture twice: a shuffle bag over
whatever images sit in assets/, each shown at a random zoom between 1.15
and 1.9 and cropped around a randomly chosen point of the poster.

The list is read from the directory rather than written out in config.js.
The old one named two files that are no longer there, which is the failure
mode a hand-kept list has; now a poster joins the rotation, with its own
button on the queue page, by being dropped in the folder.

Zoom never goes below 1.15. These are portrait prints and the screen is
wide, so cover fits the poster's full width and crops vertically — at 1
the paper margin shows as a grey stripe down both sides. 1.15 cuts at
least 13% off the width of every crop a 1920x1080 screen can produce.

The crop is drawn into a screen-sized canvas rather than handed to CSS as
an object-fit rule on a full-size <img>. The scans are 3508x4950, which is
a 70MB texture per layer and two of them composited for the length of the
fade; instead each pick is fetched, resized off the main thread to roughly
screen resolution, drawn once, and closed. A swap costs about 400ms of
async work, all of it before the fade begins, and no full-size bitmap is
held anywhere. Nothing about the posters' resolution reaches the
compositor, so assets/ can keep the 300 dpi originals.

Backgrounds pin and unpin like animations: DEFAULT_BACKGROUND is now the
auto cycle, the queue page's row leads with Auto, and isBackground()
guards the socket handler and the restored queue.json against a key that
no longer names a file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H9v2Xoa6eoeGYpK6gp95k9
2026-08-10 20:54:10 +02:00
simonandClaude Opus 5 ade77b4c38 Generate the client config instead of listing it by hand
The browser gets its constants from a /config.js route that re-declared each
export by hand, so a constant added to config.js and imported by the frontend
linked against nothing. WASH_FRAME_SCALE, added with the colour wash, did
exactly that: motion.js failed to link, player.js never ran, and the start gate
sat on its hardcoded "Loading queue..." forever.

Serialise every export except the server-only PORT, so the mirror cannot drift
from the source again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tg15tLAuT6NrCE1ZxKVgDB
2026-08-09 23:43:21 +02:00
simonandClaude Opus 5 223292708f Add a colour wash animation
The frame parks in the middle and the colour changes instead: a gradient
creeps round the colour wheel behind the poster, and the frame's glow
travels with it. Every other animation moves the frame, so the still one
reads as a rest between them rather than as a dead spot.

The wash layer blends with mix-blend-mode: color, which keeps the
poster's luminance and replaces only its hue. soft-light was the first
try and barely shifted the colour at all against a poster already knocked
back to 45% brightness.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tg15tLAuT6NrCE1ZxKVgDB
2026-08-09 23:11:42 +02:00
simonandClaude Opus 5 6d306d1b98 Add a comet trail animation
The frame glides from waypoint to waypoint and drops a screenshot of
itself every frame-width or so. The stills stay where they were taken
and fade over a few seconds, so the live video reads as the head of a
comet whose tail is its own recent past.

The steering caps how fast the heading may change: an instant turn would
lay the stills out in straight dotted lines, and it is the curve that
makes a tail read as one. Drops are spaced by distance travelled rather
than by a timer, so they stay even however the frame is turning.

Since the capture is a crop of the tab, a still lying under the frame
would be photographed into the next one. A drop waits until the frame is
clear of the tail, keeping the invariant corners already relies on.

Also picks up the FRAME_SCALE, BOUNCE_SPEED and CORNER_INTERVAL_SECONDS
tuning sitting in the tree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tg15tLAuT6NrCE1ZxKVgDB
2026-08-09 22:58:08 +02:00
simonandClaude Opus 5 2066966694 Give the drag animation its own base frame size
The shared FRAME_SCALE has to stay under 0.5 so the corners animation can
fit two frames side by side, but nothing else is on screen while the drag
animation runs, so it can afford a roomier frame.

frameSize() now takes the scale as an argument; drag passes
DRAG_FRAME_SCALE and the min/max resize multipliers apply on top of it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tg15tLAuT6NrCE1ZxKVgDB
2026-08-09 22:43:24 +02:00
simonandClaude Opus 5 a6fd6e802a Add a drag-and-resize animation
A drawn pointer grabs the frame near its top edge, drags it across the
screen, lets go, then takes hold of one corner and pulls the frame to a
new size. Moves ease in and overshoot slightly before settling, and the
paths bow, so the motion reads as a hand rather than a tween.

The resize pins the opposite corner and keeps the aspect ratio locked —
a stretched video would read as broken, not as someone resizing — and
clamps the width to the room left before the corner leaves the screen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tg15tLAuT6NrCE1ZxKVgDB
2026-08-09 22:34:26 +02:00
simonandClaude Opus 5 e031ebda1a Let the queue page pin the frame animation
Waiting for the shuffle bag to land on the animation you are working on makes
testing one of them tedious. The queue page now carries an Animation row next
to Background: Auto keeps the per-track cycle, any other button pins the player
to that animation until the mode changes again.

The animation names live in config.js alongside their labels, so the server can
validate them and the queue page can label its buttons; motion.js keys its
registry by the same names.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tg15tLAuT6NrCE1ZxKVgDB
2026-08-09 22:12:52 +02:00
simonandClaude Opus 5 ce1a6d6a47 Cycle the frame through animations, add a corners effect
The frame had exactly one behaviour, started once and run forever. Make
animations a start(context) => stop() lifecycle held in a registry, and
draw a new one on every completed crossfade.

Selection uses a shuffle bag: each animation comes up exactly once per
pass, so they stay evenly used, and a swap at the pass boundary keeps the
same one from running twice in a row. Written for a registry larger than
today's two entries.

The new corners animation parks the frame in a random corner and drops a
screenshot of it into the next of the other three corners every 5s, going
clockwise. The frame's own corner is never a slot, so a still cannot
cover it by construction rather than by clamping. This holds while
FRAME_SCALE stays under 0.5, now noted where that knob lives.

Screenshots need getDisplayMedia: the decks are cross-origin YouTube
iframes, so canvas taints and there is no other way to read the playing
pixels. The prompt has to be requested before start() yields, or the
click's transient activation is gone. Declining, or serving over plain
HTTP where mediaDevices is undefined, leaves the frame parked without
stills instead of failing.

The capture sink is a 1x1 element in the document, like #probe: detached
video elements are never composited and get throttled hard enough to make
every screenshot seconds stale.

startBounce also gains the stop() it always needed — it was leaking its
rAF loop and resize listener, and a queued frame would fight whichever
animation came next.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tg15tLAuT6NrCE1ZxKVgDB
2026-08-09 22:04:33 +02:00
simonandClaude Opus 5 ad999e1c3e Add party player: YouTube queue with crossfading playback
Two frontends sharing one WebSocket-backed state on the server:

- Player: fullscreen poster background with a small frame (30% of the
  screen) that drifts diagonally and reflects off the edges. Two stacked
  YouTube players hand over with a 5s equal-power crossfade so there is
  never silence between tracks.
- Queue: add by URL, drag to reorder, delete, click to jump. Shows title
  and length per item, highlights what is playing, and tracks remaining
  vs. total playtime.

No API key is required — titles come from YouTube's oEmbed endpoint and
durations from a hidden muted probe player that cues each new video just
to read its length.

The fade is driven by both requestAnimationFrame and the interval tick:
a hidden or throttled tab stops serving frames entirely, which would
otherwise strand a handover half-faded with two tracks audible.

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