fix(player): stop AbortError storm from HLS stall recovery (DR-096)
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 7m14s
🏗️ Build and Test JellyTau / Android Compile Check (push) Has been skipped
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m20s
Traceability Validation / Check Requirement Traces (push) Successful in 14s

Html5PlayerAdapter.play() reported every interrupted play attempt as a
player error. While an HLS stream stalls, hls.js' gap-controller nudges
the element to recover, which cancels the pending play() promise and
raises AbortError ("play() request was interrupted by a call to
pause()"). That is transient — the element is still trying to play — but
it hit host.onError roughly once a second for the whole stall, leaving
the UI stuck reporting paused.

Treat an interrupted play as a debug-level non-event, and memoise the
in-flight attempt so the UI and recovery paths share one element.play()
rather than stacking calls that abort each other.

This is the loop amplifier, complementing DR-095 which removed the
dead-segment stall that triggered it.

Note: webviewAudioAdapter.play() has the same raw shape but is not
implicated — audio playback does not go through hls.js — so it is left
unchanged rather than widening this fix.
This commit is contained in:
2026-07-30 12:55:49 +02:00
parent 98a6bca645
commit 1ae213ff39
4 changed files with 100 additions and 9 deletions
@@ -95,6 +95,63 @@ describe("Html5PlayerAdapter", () => {
expect(video.play).toHaveBeenCalledTimes(1);
});
// A stalling HLS stream makes hls.js' gap-controller nudge the element, which
// aborts an in-flight play(). That AbortError is transient — the element is
// still trying to play — so it must not be surfaced as a player error, or the
// UI reports failure ~once a second for the whole stall.
it("play() does not report an interrupted-by-pause AbortError as an error", async () => {
const abort = new DOMException(
"The play() request was interrupted by a call to pause().",
"AbortError"
);
video.play = vi.fn(async () => {
throw abort;
});
await adapter.play();
expect(host.onError).not.toHaveBeenCalled();
});
it("play() still reports a genuine failure", async () => {
video.play = vi.fn(async () => {
throw new DOMException("no supported source", "NotSupportedError");
});
await adapter.play();
expect(host.onError).toHaveBeenCalledTimes(1);
expect(String((host.onError as any).mock.calls[0][0])).toContain("play() failed");
});
it("play() coalesces concurrent attempts into one element.play() call", async () => {
// During a stall the UI and recovery paths can both ask to play. Stacking
// element.play() calls is what generates the AbortError storm.
let resolvePlay: () => void = () => {};
video.play = vi.fn(
() =>
new Promise<void>((r) => {
resolvePlay = () => {
video.paused = false;
r();
};
})
);
const first = adapter.play();
const second = adapter.play();
resolvePlay();
await Promise.all([first, second]);
expect(video.play).toHaveBeenCalledTimes(1);
});
it("play() works again after a previous attempt settled", async () => {
await adapter.play();
await adapter.play();
expect(video.play).toHaveBeenCalledTimes(2);
});
it("pause() calls element.pause()", async () => {
video.paused = false;
await adapter.pause();