diff --git a/packages/core/src/runtime/init.buffering.test.ts b/packages/core/src/runtime/init.buffering.test.ts
index 84ee600405..25d1dc804c 100644
--- a/packages/core/src/runtime/init.buffering.test.ts
+++ b/packages/core/src/runtime/init.buffering.test.ts
@@ -227,6 +227,52 @@ it("a hold for data does not reload a later clip", () => {
expect(load.mock.contexts.filter((el) => el === later).length).toBe(before);
});
+function laterClipHoldingARequest() {
+ const later = document.getElementById("later") as HTMLVideoElement;
+ Object.defineProperties(later, {
+ readyState: { get: () => 1, configurable: true },
+ networkState: { get: () => 2, configurable: true },
+ });
+ const srcWrites = new MutationObserver(() => {});
+ srcWrites.observe(later, { attributeFilter: ["src"] });
+ return { later, srcWrites };
+}
+
+it("a hold whose clip gets no data for 5 s frees the clips not due, whose open requests can fill the connection", () => {
+ const { voice, voiceReady, playFor } = playPodcast(
+ 0,
+ "video",
+ '',
+ );
+ const { later, srcWrites } = laterClipHoldingARequest();
+
+ playFor(4);
+ expect(srcWrites.takeRecords()).toHaveLength(0);
+ playFor(1.5);
+ expect(srcWrites.takeRecords().length).toBeGreaterThan(0);
+ expect([later.preload, later.getAttribute("src")]).toEqual(["none", "/later.mp4"]);
+ expect(voice.getAttribute("src")).toBe("/voice.m4a");
+
+ voiceReady.value = 4;
+ playFor(1);
+ expect(window.__player!.getTime()).toBeGreaterThan(0.9);
+});
+
+it("a hold whose clip is still receiving data frees nothing", () => {
+ const { voiceReady, playFor } = playPodcast(
+ 0,
+ "video",
+ '',
+ );
+ const { srcWrites } = laterClipHoldingARequest();
+
+ for (let s = 1; s <= 8; s++) {
+ voiceReady.bufferedTo = s * 0.05;
+ playFor(1);
+ }
+ expect(srcWrites.takeRecords()).toHaveLength(0);
+});
+
it("a film held for data reads as choppy, so the app can lower it", () => {
const reports: Array<{ choppy: boolean; waitingShare: number; heldBy: unknown }> = [];
const { voiceReady, playFor } = playPodcast(0, "video");
diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts
index 86eee3beea..2224823f04 100644
--- a/packages/core/src/runtime/init.ts
+++ b/packages/core/src/runtime/init.ts
@@ -6194,6 +6194,32 @@ export function initSandboxRuntimeModular(): void {
)
.map(({ el }) => el);
+ const HELD_WITHOUT_PROGRESS_MS = 5000;
+ const loadProgress = (els: HTMLMediaElement[]) =>
+ els
+ .map((el) => {
+ let seconds = 0;
+ for (let i = 0; i < el.buffered.length; i++)
+ seconds += el.buffered.end(i) - el.buffered.start(i);
+ return `${el.readyState}:${seconds}`;
+ })
+ .join();
+ let heldProgress = { held: bufferingFor, mark: "", sinceMs: 0 };
+ const releaseMediaHoldingTheConnection = () => {
+ const nowMs = performance.now();
+ const mark = loadProgress(bufferingFor);
+ if (heldProgress.held !== bufferingFor || heldProgress.mark !== mark)
+ heldProgress = { held: bufferingFor, mark, sinceMs: nowMs };
+ if (nowMs - heldProgress.sinceMs < HELD_WITHOUT_PROGRESS_MS) return;
+ heldProgress.sinceMs = nowMs;
+ for (const { el, start, end } of resolveMediaClipIndex().clips) {
+ if (bufferingFor.includes(el) || isInClipWindow(state.currentTime, start, end)) continue;
+ if (el.readyState === 0 && el.networkState !== 2) continue;
+ el.preload = "none";
+ withoutOwnTimingRecords(el, () => releaseMedia(el));
+ }
+ };
+
const transportTick = () => {
if (state.tornDown || inTransportTick) return;
inTransportTick = true;
@@ -6381,6 +6407,8 @@ export function initSandboxRuntimeModular(): void {
!mediaNotReadyAt(state.currentTime, canResumeFrom).some((el) => bufferingFor.includes(el))
) {
transport.play();
+ } else if (bufferingFor.length > 0) {
+ releaseMediaHoldingTheConnection();
}
postState(false);
} finally {