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 {