Commit Graph
3 Commits
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