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:
@@ -40,6 +40,32 @@ autoplay, so the first click is what unlocks audio. One click per session.
|
||||
`PORT` is mirrored to the browser by the generated `/config.js` route in
|
||||
`server/index.js`, so a new constant needs no second list.
|
||||
|
||||
## Crossfades
|
||||
|
||||
Two decks, one video each, and a track change is an equal-power fade from the
|
||||
one to the other. Three details are what keep it from being audible as a seam:
|
||||
|
||||
- **The next track is prerolled.** `PREROLL_SECONDS` before the fade is due, the
|
||||
idle deck loads it, plays until YouTube reports it running, then seeks back to
|
||||
0 and pauses. A deck told to play at the moment the fade opens would spend its
|
||||
first second or two fetching, and the fade — which runs on wall-clock time —
|
||||
would ramp the outgoing track down over that silence. The fade still will not
|
||||
open until the incoming deck is genuinely playing, so a preroll that did not
|
||||
take costs a moment of the outgoing track rather than a gap.
|
||||
- **The fade lands before the end, not on it.** It runs for what is actually
|
||||
left of the outgoing track minus `END_GUARD_SECONDS`, rather than for a fixed
|
||||
`OVERLAP_SECONDS` — the tick that triggers it is already a little late, and
|
||||
`getDuration()` reports a length that runs slightly past the last of the
|
||||
audio. A fade that outlasts its own track ends in a clipped ending and a flash
|
||||
of YouTube's end screen, which shows the video's first frame and reads as the
|
||||
track restarting.
|
||||
- **The player owns the hand-over until the server confirms it.** The queue
|
||||
pointer only moves once the server has processed `trackEnded`, so for one
|
||||
round trip the state still names the track that just finished. The player
|
||||
advances its own pointer at the same moment it sends, and reconciles when the
|
||||
broadcast arrives; without that, the jump check reads the stale pointer as a
|
||||
manual jump and cuts straight back to the track that just ended.
|
||||
|
||||
## Backgrounds
|
||||
|
||||
Every image file in `assets/` is a background — drop one in, restart, and it
|
||||
|
||||
Reference in New Issue
Block a user