diff --git a/README.md b/README.md index a4bf7f9..581a1ee 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/config.js b/config.js index bd7c0e6..1141435 100644 --- a/config.js +++ b/config.js @@ -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. diff --git a/public/player/player.js b/public/player/player.js index 9ff4b23..941787c 100644 --- a/public/player/player.js +++ b/public/player/player.js @@ -2,6 +2,9 @@ import { connect } from '/shared/socket.js'; import { OVERLAP_SECONDS, HARD_CUT_SECONDS, + PREROLL_SECONDS, + END_GUARD_SECONDS, + START_WAIT_SECONDS, AUTO_BACKGROUND, AUTO_ANIMATION, LOW_RES_SPEED, @@ -24,7 +27,9 @@ let state = { }; let decks = []; 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 animations = null; @@ -153,6 +158,22 @@ function playOn(index, item, { volume, 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 * rather than abandoned: the ramp runs on wall-clock time, so the paused @@ -168,7 +189,11 @@ function applyPaused(paused) { } 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; } @@ -188,6 +213,17 @@ function onDeckState(index, code) { 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; // Safety net: a track that ended without our tick catching it (very short @@ -223,6 +259,11 @@ function tick() { // Queue emptied out from under us — stop rather than loop the video the // user just deleted. if (state.items.length === 0) { + if (preroll) { + decks[preroll.deck].player.stopVideo(); + decks[preroll.deck].itemId = null; + preroll = null; + } if (deck.itemId) { deck.player.stopVideo(); deck.setOpacity(0); @@ -252,11 +293,24 @@ function tick() { const currentTime = deck.player.getCurrentTime?.() ?? 0; if (!duration) return; - if (duration - currentTime <= OVERLAP_SECONDS) { - const next = nextItem(); - // A single-item queue loops instead of crossfading with itself. - if (!next || next.id === deck.itemId) return; - beginCrossfade(next, OVERLAP_SECONDS, { advance: true }); + const remaining = duration - currentTime; + if (remaining > OVERLAP_SECONDS + PREROLL_SECONDS) return; + + const next = nextItem(); + // A single-item queue loops instead of crossfading with itself. + if (!next || next.id === deck.itemId) return; + + // 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 }); } } @@ -264,11 +318,26 @@ function beginCrossfade(item, seconds, { advance }) { const from = active; const to = 1 - active; - playOn(to, item, { volume: 0, opacity: 0 }); + // 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 }); + } 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(); 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, from, to, @@ -287,10 +356,22 @@ function beginCrossfade(item, seconds, { advance }) { function stepTransition() { if (!transition || transition.pausedAt) return false; - const progress = Math.min((performance.now() - transition.startedAt) / transition.duration, 1); const outgoing = decks[transition.from]; 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 // of the mix instead of dipping. outgoing.player.setVolume(Math.round(Math.cos((progress * Math.PI) / 2) * 100)); @@ -325,7 +406,16 @@ function finishTransition() { animations?.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() { @@ -405,6 +495,16 @@ function onMessage(msg) { const previousPaused = state.paused; const previousLowRes = state.lowRes; 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.animation !== previousAnimation) animations?.setMode(state.animation); // No restart: the running animation picks the new speed up on its next frame. @@ -441,7 +541,8 @@ updateGate(); // Debug helper used when verifying the crossfade without waiting out a track. 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 duration = deck?.player.getDuration?.() ?? 0; if (duration) deck.player.seekTo(Math.max(0, duration - secondsBefore), true); @@ -453,10 +554,17 @@ window.__player = { return backgrounds.next(); }, get state() { + const deck = decks[active]; return { state, active, + playhead: { + currentTime: deck?.player.getCurrentTime?.() ?? null, + duration: deck?.player.getDuration?.() ?? null, + }, transition, + preroll, + handover, decks: decks.map((d) => d.itemId), animation: animations?.name ?? null, background: backgrounds.key,