fix: defer the open crossfade so main no longer fades into a gap

Opening the preview faded the main track out at focus-acquire time, but the
first preview clip only becomes audible after resolve/load/decode — so on
the way IN the 340 ms main fade completed into silence before the preview
arrived (a gap the user heard; the exit was already fine).

The main fade is now deferred: focusBridge.requestSilence only arms the
commitment and grants; coordinator.beginMainSilence performs the actual
fade-pause, and the service fires it from afterChange on the opening
session's first audible clip (or ITEM_FAILED). Main-cos-out then overlaps
preview-sin-in — a true cross. resumeMain skips the fade-resume when the
fade never fired (close before audible), and beginMainSilence re-checks the
main is sounding before fading. The focus coordinator's acquisition logic
and tests are untouched.

Verified live: preview audible at 275 ms, main played until then, faded
~343 ms, paused at 618 ms and held frozen; close resumed from the exact
position in 15 ms.
This commit is contained in:
KeelrLeek committed 2026-07-23 16:54:09 -05:00
1 parent 691fdcdc9a
commit abc959ca2f
3 files changed
+88 -19

No files matched your search

+20
View File
@@ -64,6 +64,10 @@ export function createPreviewService({
let settleTimer = null;
let focusToken = null;
let focusWaiting = false;
// Deferred open crossfade: true from a granted open until the first
// preview clip is audible (or definitively won't be), at which point the
// main track's fade-out fires — synced to the preview's fade-in.
let pendingOpenSilence = false;
// Burst detection for the adaptive settle window.
let lastNavAt = -Infinity;
// One early auto-advance per generation, however many position events
@@ -94,6 +98,14 @@ export function createPreviewService({
if (generationMoved && state.phase === PHASE.RESOLVING_MEDIA) {
scheduleSettle();
}
// Deferred open crossfade: fade the main track out the instant the
// opening preview clip becomes audible (PLAYING) — so the ramps
// overlap — or the moment it's clear it never will (ITEM_FAILED),
// matching the old take-audio-on-open behaviour on failure.
if (pendingOpenSilence && (state.phase === PHASE.PLAYING || state.phase === PHASE.ITEM_FAILED)) {
pendingOpenSilence = false;
focus.coordinator.beginMainSilence?.();
}
maybeScheduleEarlyAdvance();
}
@@ -323,6 +335,10 @@ export function createPreviewService({
state = createInitialState(sessionId);
emit();
const granted = await ensureFocus();
// Arm the deferred main fade only on a granted open (a parked
// session leaves the main track playing). It fires from
// afterChange when the first clip is audible.
pendingOpenSilence = granted;
dispatch({ type: "MANIFEST_READY", sessionId, manifest, startAtManifestIndex });
if (!granted && state.phase !== PHASE.EMPTY) {
dispatch({ type: "SET_DESIRED_PLAYING", sessionId, value: false });
@@ -411,6 +427,10 @@ export function createPreviewService({
scheduler.clear(settleTimer);
settleTimer = null;
}
// Closing before the opening clip ever sounded: leave the deferred
// fade unfired — release() will find the main still playing and
// simply disarm it (no fade-resume dip).
pendingOpenSilence = false;
dispatch({ type: "CLOSE", sessionId: state.sessionId });
prefetched.clear();
const fades = typeof transport.fadeOut === "function";