fix(player): a stale session answer can no longer stop the playback that won

- The engine tracks the request out per item: A, then B, then back to A sent a second request
  for A beside the one still out.
- A hardware transcode superseded during its fallback answered with the NEWER session; the losing
  request's player then stopped it as stale. It now answers with its own (dead) session.
- A replaced hls.js instance's late manifest no longer marks the player ready or plays the
  outgoing media.
- A pause given after the answer stands: the manifest and the first canplay read the live intent.
- The debounced A/V-offset restart is dropped on an item change instead of restarting the
  previous item under the next one's title.
This commit is contained in:
2026-10-07 23:16:25 +02:00
parent d25a132495
commit 0a35f2a3b1
6 changed files with 131 additions and 15 deletions
+17 -7
View File
@@ -164,7 +164,9 @@ export class HlsEngine {
private attachedId: string | null = null;
// The attached session's play intent, which stands until it is ready (see `wantsPlay`).
private attachedIntent: Intent | null = null;
private inFlight: Request | null = null;
// The request out for each item. Per item, not one slot: another item's request took the slot, and
// the way back to the first item then sent a second request beside the one still out for it.
private readonly inFlight = new Map<string, Request>();
private queued: Request | null = null;
// How often a session the server answered 404 for may be reclaimed (see `onHlsError`).
private reclaims = { n: 0, at: 0 };
@@ -275,13 +277,13 @@ export class HlsEngine {
// stale "Preparing stream…" for a session not known yet — set honestly from the answer.
this.set({ ready: false, error: null, transcode: false });
const req: Request = { id, startAt, intent, gen };
if (this.inFlight && this.inFlight.id === id) this.queued = req;
if (this.inFlight.has(id)) this.queued = req;
else this.send(req);
}
private send(req: Request): void {
this.queued = null;
this.inFlight = req;
this.inFlight.set(req.id, req);
void this.deps.requestSession(req.id, req.startAt, this.deps.params()).then(
(sess) => this.answered(req, sess),
(e: unknown) => this.failed(req, e),
@@ -295,9 +297,9 @@ export class HlsEngine {
/** The in-flight request settled: the queued one goes now, unless it is stale too (the viewer
* left the item, or the player closed). */
private settledInFlight(req: Request): void {
if (this.inFlight === req) this.inFlight = null;
if (this.inFlight.get(req.id) === req) this.inFlight.delete(req.id);
const next = this.queued;
if (!next || this.inFlight) return;
if (!next || this.inFlight.has(next.id)) return;
this.queued = null;
if (this.alive && next.gen === this.gen) this.send(next);
}
@@ -345,7 +347,7 @@ export class HlsEngine {
// Belt and braces: the play() on MANIFEST_PARSED / loadedmetadata can be rejected when the media
// is not ready yet (slow first segments over a proxy), leaving the video paused after a seek the
// viewer was watching through. Retried once the element can play; the intent is read when it
// fires, since the viewer may have asked for play or pause meanwhile.
// fires, since the viewer may have asked for play or pause meanwhile (`requestPlay` keeps it).
this.listen(media, req, "canplay", () => {
if (intent.play && media.paused) media.play().catch(() => {});
});
@@ -397,9 +399,14 @@ export class HlsEngine {
private hlsHandlers(req: Request, media: HTMLMediaElement): HlsHandlers {
return {
// Only the current session's: an instance replaced before its manifest parsed (a restart asked
// for right after an attach) marked the player ready while the next session was still being
// asked for — so that one then attached past `wantsPlay`'s not-ready guard — and played the
// outgoing media.
onManifestParsed: () => {
if (!this.current(req)) return;
this.set({ ready: true });
if (this.alive && req.intent.play) media.play().catch(() => {});
if (req.intent.play) media.play().catch(() => {});
},
// The selected audio is restored once hls.js has the renditions: on MANIFEST_PARSED the list
// was not populated yet, the choice was dropped, and the default track played under the
@@ -509,6 +516,9 @@ export class HlsEngine {
if (!want && !media.paused) media.pause();
return true;
}
// The attached session's intent follows: its manifest and first canplay still play by it, and a
// pause given before they fired was played over.
if (this.attachedIntent) this.attachedIntent.play = want;
if (want) media.play().catch(() => {});
else media.pause();
return false;