From 4e0740ee168abc78e7034410c11eecf8236fbc80 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 1 Nov 2026 12:32:49 -0500 Subject: [PATCH] fix(core): play waits 0.3 s for a clip with no data, then runs on and lets it catch up --- .../core/src/runtime/audioClockSource.test.ts | 8 +- .../core/src/runtime/init.buffering.test.ts | 76 +++++++++++++++---- packages/core/src/runtime/init.test.ts | 4 +- packages/core/src/runtime/init.ts | 35 ++++++++- 4 files changed, 100 insertions(+), 23 deletions(-) diff --git a/packages/core/src/runtime/audioClockSource.test.ts b/packages/core/src/runtime/audioClockSource.test.ts index c3e4997b63..2a240e39d7 100644 --- a/packages/core/src/runtime/audioClockSource.test.ts +++ b/packages/core/src/runtime/audioClockSource.test.ts @@ -323,17 +323,19 @@ describe("the audio the playhead follows", () => { expect(window.__player!.getTime()).toBeGreaterThan(1.4); }); - it("holds the playhead for a playing clip that has no data until it has some", async () => { + it("holds the playhead for a playing clip that has no data for the start wait, then plays on", async () => { await playStarved(); stepFrames(1); const held = window.__player!.getTime(); - stepFrames(5 * 60); + stepFrames(15); expect(window.__player!.getTime()).toBe(held); + stepFrames(90); + expect(window.__player!.getTime()).toBeGreaterThan(held + 0.5); }); it("plays on from where it held once a clip's late data arrives", async () => { const late = await playStarved(); - stepFrames(5 * 60); + stepFrames(10); const before = window.__player!.getTime(); expect(before).toBeLessThan(0.1); late.readyState = 4; diff --git a/packages/core/src/runtime/init.buffering.test.ts b/packages/core/src/runtime/init.buffering.test.ts index 68775d4c39..e4d6c08295 100644 --- a/packages/core/src/runtime/init.buffering.test.ts +++ b/packages/core/src/runtime/init.buffering.test.ts @@ -28,7 +28,12 @@ function audio(id: string, duration: number, readyState: { value: number; buffer return el; } -function playPodcast(voiceStart = 0, voiceTag = "audio", extraHtml = "") { +function playPodcast( + voiceStart = 0, + voiceTag = "audio", + musicReadyState = 4, + extraHtml = "", +) { resetRuntimeFixtureDom(); document.body.innerHTML = '
' + @@ -37,7 +42,7 @@ function playPodcast(voiceStart = 0, voiceTag = "audio", extraHtml = "") { extraHtml + "
"; const voiceReady: { value: number; bufferedTo?: number } = { value: 1 }; - const music = audio("music", 10, { value: 4 }); + const music = audio("music", 10, { value: musicReadyState }); const voice = audio("voice", 4, voiceReady); window.__timelines = { main: createMockTimeline(10) }; @@ -67,19 +72,47 @@ function playPodcast(voiceStart = 0, voiceTag = "audio", extraHtml = "") { return { music, voice, voiceReady, states, playFor, step }; } -it("a track not ready pauses everything with Buffering, and resumes when it can play", () => { +it("a track not ready at the start holds the playhead for 0.3 s at most, then plays on", () => { const { music, voice, voiceReady, states, playFor } = playPodcast(); - playFor(1); - expect(window.__player!.getTime()).toBeLessThan(0.1); + playFor(0.25); + expect(window.__player!.getTime()).toBeLessThan(0.05); + expect(music.paused).toBe(true); + expect(states.at(-1)).toEqual({ isPlaying: true, buffering: true }); + + playFor(1.25); + expect(window.__player!.getTime()).toBeGreaterThan(0.9); + expect(states.at(-1)).toEqual({ isPlaying: true, buffering: false }); + + voiceReady.value = 4; + playFor(0.5); + expect(voice.paused).toBe(false); + expect(music.paused).toBe(false); +}); + +it("the track the playhead follows, cold at the start, holds it for 0.3 s at most too", () => { + const { states, playFor } = playPodcast(0, "audio", 1); + + playFor(0.25); + expect(window.__player!.getTime()).toBeLessThan(0.05); + expect(states.at(-1)).toEqual({ isPlaying: true, buffering: true }); + + playFor(1.25); + expect(window.__player!.getTime()).toBeGreaterThan(0.9); +}); + +it("a track not ready mid-film still pauses everything with Buffering, and resumes when it can play", () => { + const { music, voiceReady, states, playFor } = playPodcast(2); + + playFor(2.5); + expect(window.__player!.getTime()).toBeGreaterThan(1.9); + expect(window.__player!.getTime()).toBeLessThan(2.1); expect(music.paused).toBe(true); expect(states.at(-1)).toEqual({ isPlaying: true, buffering: true }); voiceReady.value = 4; playFor(1); - expect(window.__player!.getTime()).toBeGreaterThan(0.9); - expect(music.paused).toBe(false); - expect(voice.paused).toBe(false); + expect(window.__player!.getTime()).toBeGreaterThan(2.9); expect(states.at(-1)).toEqual({ isPlaying: true, buffering: false }); }); @@ -93,16 +126,28 @@ it("the music plays on while a video shows a still across a copy swap", () => { expect(states.at(-1)).toEqual({ isPlaying: true, buffering: false }); }); -it("a copy that never lands stops standing in, and the film waits for it", () => { +it("a copy that never lands stops standing in, and the film waits for it through the start wait", () => { const { music, voice, states, playFor } = playPodcast(0, "video"); heldPosters.set(voice, { poster: null, since: performance.now() - 60_000 }); - playFor(1); - expect(window.__player!.getTime()).toBeLessThan(0.1); + playFor(0.25); + expect(window.__player!.getTime()).toBeLessThan(0.05); expect(music.paused).toBe(true); expect(states.at(-1)).toEqual({ isPlaying: true, buffering: true }); }); +it("after a start that ran on without data, the next clip without data holds again once the first one has it", () => { + const { voiceReady, states, playFor } = playPodcast(); + + playFor(1); + voiceReady.value = 4; + playFor(0.2); + expect(states.at(-1)).toEqual({ isPlaying: true, buffering: false }); + voiceReady.value = 1; + playFor(0.2); + expect(states.at(-1)).toEqual({ isPlaying: true, buffering: true }); +}); + it("holds on the frame a slow tick skips into a clip with no data, after asking it to play", () => { const { voice, states, playFor, step } = playPodcast(0.5); @@ -117,16 +162,16 @@ it("resumes for a held clip with data buffered ahead that reports only its curre vi.spyOn(navigator, "userAgent", "get").mockReturnValue( "AppleWebKit/605.1.15 Version/17.4 Safari/605.1.15", ); - const { voiceReady, states, playFor } = playPodcast(); + const { voiceReady, states, playFor } = playPodcast(0.5); - playFor(0.5); + playFor(0.6); voiceReady.value = 2; playFor(0.5); expect(states.at(-1)).toEqual({ isPlaying: true, buffering: true }); voiceReady.bufferedTo = 2; playFor(1); - expect(window.__player!.getTime()).toBeGreaterThan(0.9); + expect(window.__player!.getTime()).toBeGreaterThan(1.1); expect(states.at(-1)).toEqual({ isPlaying: true, buffering: false }); }); @@ -191,6 +236,7 @@ it("a hold for data does not reload a later clip", () => { const { voiceReady, playFor } = playPodcast( 0, "video", + 4, '', ); const later = document.getElementById("later") as HTMLVideoElement; @@ -255,7 +301,7 @@ it("a video far behind the film lands on it when it starves", () => { it("a pause while buffering stays paused once the track is ready", () => { const { music, voiceReady, states, playFor } = playPodcast(); - playFor(0.5); + playFor(0.2); window.__player!.pause(); voiceReady.value = 4; playFor(1); diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts index e9ae584719..d40de2423a 100644 --- a/packages/core/src/runtime/init.test.ts +++ b/packages/core/src/runtime/init.test.ts @@ -6406,7 +6406,7 @@ describe("initSandboxRuntimeModular", () => { describe("a voiceover routed through Web Audio", () => { const ctx = useMockAudioContext(); - it("holds the timeline while the voiceover buffers instead of seeking it forward", async () => { + it("holds the timeline for the start wait while the voiceover buffers instead of seeking it forward", async () => { const raf = createManualRaf(); vi.spyOn(performance, "now").mockImplementation(() => raf.now()); window.requestAnimationFrame = @@ -6426,7 +6426,7 @@ describe("initSandboxRuntimeModular", () => { await startPlayback(); expect(ctx.mediaElementSources).toBe(1); - for (let frame = 0; frame < 120; frame++) { + for (let frame = 0; frame < 15; frame++) { ctx.time += 1 / 60; raf.step(1000 / 60); } diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 919ae49dcf..7264883405 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -3444,6 +3444,11 @@ export function initSandboxRuntimeModular(): void { let lastSyncedMediaTimeSeconds: number | null = null; let mediaClipsInWindow: RuntimeMediaClip[] = []; // Clips due now that could not play: every track holds until they can, so none runs on alone. + // Play waits this long for a clip with no data, then runs on and lets the clip catch up; mid-film it holds. + const START_WAIT_MS = 300; + let startWaitUntilMs: number | null = null; + let catchingUp = false; + let resumingFromBuffering = false; // The one definition of "media the transport drives": timed itself, or hosted // by a composition whose timing it inherits. Both the media cache and the @@ -4778,6 +4783,10 @@ export function initSandboxRuntimeModular(): void { flushHeldSeek(); const resumingStarved = bufferingFor.length > 0; bufferingFor = []; + if (!resumingFromBuffering) { + startWaitUntilMs = performance.now() + START_WAIT_MS; + catchingUp = false; + } const tl = state.capturedTimeline; if (clock.isPlaying()) return; const dur = getSafeTimelineDurationSeconds(tl, 0); @@ -4812,6 +4821,8 @@ export function initSandboxRuntimeModular(): void { pause: () => { const wasBuffering = bufferingFor.length > 0; bufferingFor = []; + startWaitUntilMs = null; + catchingUp = false; if (clock.isPlaying()) haltPlayback(); else if (wasBuffering) postState(true); }, @@ -5661,6 +5672,13 @@ export function initSandboxRuntimeModular(): void { const source = followedOrLongestRunningAudio(clock.audioElement()); if (source && !audioMayDrivePlayhead(source.el)) { if (clock.hasAudioSource()) clock.detachAudioSource(); + } else if ( + source && + catchingUp && + source.el.readyState < HTMLMediaElement.HAVE_FUTURE_DATA + ) { + // Past the start wait the playhead runs on its own clock while this track catches up. + if (clock.hasAudioSource()) clock.detachAudioSource(); } else if (source && !source.el.paused) { clock.attachAudioSource({ el: source.el, @@ -5888,14 +5906,25 @@ export function initSandboxRuntimeModular(): void { } // After media sync, so a clip that just fell due was asked to play, and fetch, before it holds. const notReady = mediaNotReadyAt(state.currentTime); - if (clock.isPlaying() && notReady.length > 0) { + if (startWaitUntilMs !== null && performance.now() >= startWaitUntilMs) { + startWaitUntilMs = null; + catchingUp = notReady.length > 0; + } + if (notReady.length === 0) catchingUp = false; + if (clock.isPlaying() && notReady.length > 0 && !catchingUp) { bufferingFor = notReady; haltPlayback(true); } else if ( bufferingFor.length > 0 && - !mediaNotReadyAt(state.currentTime, canResumeFrom).some((el) => bufferingFor.includes(el)) + (catchingUp || + !mediaNotReadyAt(state.currentTime, canResumeFrom).some((el) => bufferingFor.includes(el))) ) { - transport.play(); + resumingFromBuffering = true; + try { + transport.play(); + } finally { + resumingFromBuffering = false; + } } postState(false); } finally {