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
This commit is contained in:
Si
2026-08-14 13:40:36 +02:00
co-authored by Claude Opus 5
parent 554b946235
commit 176ca6868f
3 changed files with 165 additions and 12 deletions
+19
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.