Skip to content

Commit de45024

Browse files
committed
feat: harden mse playback lifecycle
1 parent ff67335 commit de45024

12 files changed

Lines changed: 196 additions & 11 deletions

README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ The library owns the media source pipeline and leaves UI controls to Vidstack or
1313
- DASH `SegmentList` manifest reader for TypeType SABR sessions
1414
- MSE append queues for audio and video source buffers
1515
- Explicit startup, buffer fill, seek, and destroy lifecycle
16+
- Abort-safe seek coalescing
17+
- Bounded forward buffer and back-buffer trimming
18+
- Native media element event observation for state diagnostics
19+
- Runtime snapshots for TypeType integration probes
1620

1721
## Usage
1822

@@ -36,6 +40,16 @@ await engine.load();
3640
await engine.play();
3741
```
3842

43+
## Diagnostics
44+
45+
```ts
46+
const snapshot = engine.snapshot();
47+
48+
engine.on("buffer", (event) => {
49+
console.log(event.currentTimeMs, event.bufferedEndMs);
50+
});
51+
```
52+
3953
## Integration Model
4054

4155
```text

examples/basic.html

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+
<title>TypeType MSE</title>
7+
</head>
8+
<body>
9+
<video id="player" controls playsinline style="width: 100%; background: black">
10+
<track kind="captions" label="Captions" srclang="en" src="./captions.vtt" />
11+
</video>
12+
<script type="module" src="./basic.ts"></script>
13+
</body>
14+
</html>

examples/basic.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { TypeTypeMsePlayer } from "../src";
2+
3+
const video = document.querySelector<HTMLVideoElement>("#player");
4+
5+
if (!video) throw new Error("Missing video element");
6+
7+
const engine = new TypeTypeMsePlayer(video, {
8+
endpoint: "http://localhost:8080/api",
9+
videoId: "VIDEO_ID",
10+
videoItag: 137,
11+
audioItag: 140,
12+
audioTrackId: null,
13+
});
14+
15+
engine.on("state", (event) => console.log(event));
16+
engine.on("manifest", (event) => console.log(event));
17+
engine.on("buffer", (event) => console.log(event));
18+
engine.on("error", (event) => console.error(event.error));
19+
20+
await engine.load();

examples/captions.vtt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
WEBVTT

src/media-element-observer.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import type { TypeTypeMseState } from "./types";
2+
3+
type MediaElementObserverArgs = {
4+
video: HTMLVideoElement;
5+
state: (state: TypeTypeMseState) => void;
6+
error: (error: Error) => void;
7+
};
8+
9+
export class MediaElementObserver {
10+
constructor(private readonly args: MediaElementObserverArgs) {}
11+
12+
start(): void {
13+
this.args.video.addEventListener("playing", this.playing);
14+
this.args.video.addEventListener("waiting", this.buffering);
15+
this.args.video.addEventListener("stalled", this.buffering);
16+
this.args.video.addEventListener("ended", this.ended);
17+
this.args.video.addEventListener("error", this.error);
18+
}
19+
20+
stop(): void {
21+
this.args.video.removeEventListener("playing", this.playing);
22+
this.args.video.removeEventListener("waiting", this.buffering);
23+
this.args.video.removeEventListener("stalled", this.buffering);
24+
this.args.video.removeEventListener("ended", this.ended);
25+
this.args.video.removeEventListener("error", this.error);
26+
}
27+
28+
private readonly playing = (): void => this.args.state("playing");
29+
30+
private readonly buffering = (): void => this.args.state("buffering");
31+
32+
private readonly ended = (): void => this.args.state("ended");
33+
34+
private readonly error = (): void => {
35+
const code = this.args.video.error?.code ?? 0;
36+
this.args.error(new Error(`Media element failed with code ${code}`));
37+
};
38+
}

src/player-snapshot.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,9 @@ export function createSnapshot(
2323
bufferedEndMs,
2424
};
2525
}
26+
27+
export function bufferedEndMs(video: HTMLVideoElement): number {
28+
const buffered = video.buffered;
29+
if (buffered.length === 0) return 0;
30+
return Math.round(buffered.end(buffered.length - 1) * 1000);
31+
}

src/seek-controller.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,17 @@ export class SeekController {
1818
while (this.requestedMs !== null) {
1919
const target = this.requestedMs;
2020
this.requestedMs = null;
21-
await execute(target);
21+
try {
22+
await execute(target);
23+
} catch (error) {
24+
if (!isAbortError(error) || this.requestedMs === null) throw error;
25+
}
2226
}
2327
}
2428
}
29+
30+
function isAbortError(error: unknown): boolean {
31+
return error instanceof DOMException
32+
? error.name === "AbortError"
33+
: error instanceof Error && error.name === "AbortError";
34+
}

src/segment-fetcher.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import type { HttpClient } from "./http-client";
2+
3+
export async function fetchSegmentBytes(
4+
http: HttpClient,
5+
url: string,
6+
signal?: AbortSignal,
7+
): Promise<ArrayBuffer> {
8+
for (let attempt = 0; attempt < 30; attempt += 1) {
9+
if (signal?.aborted) throw new DOMException("Operation aborted", "AbortError");
10+
const response = await http.response(url, signal ? { signal } : undefined);
11+
if (response.status === 200) return response.arrayBuffer();
12+
if (response.status !== 202) throw new Error(`Segment failed with ${response.status}`);
13+
const delayMs = await retryAfterMs(response);
14+
await new Promise((resolve) => setTimeout(resolve, delayMs));
15+
}
16+
throw new Error("Segment was not ready in time");
17+
}
18+
19+
async function retryAfterMs(response: Response): Promise<number> {
20+
const value = response.headers.get("retry-after");
21+
const seconds = value ? Number(value) : Number.NaN;
22+
if (Number.isFinite(seconds) && seconds > 0) return Math.round(seconds * 1000);
23+
const body = await response.json().catch(() => null);
24+
if (!body || typeof body !== "object") return 500;
25+
const retryAfter = Reflect.get(body, "retryAfterMs");
26+
return typeof retryAfter === "number" && Number.isFinite(retryAfter) ? retryAfter : 500;
27+
}

src/segment-scheduler.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { EventEmitter } from "./event-emitter";
22
import type { HttpClient } from "./http-client";
33
import type { ManifestSegment, PlaybackManifest } from "./manifest";
44
import type { MediaSourceController } from "./media-source-controller";
5+
import { fetchSegmentBytes } from "./segment-fetcher";
56
import type { TrackKind } from "./types";
67

78
export class SegmentScheduler {
@@ -61,7 +62,7 @@ export class SegmentScheduler {
6162
if (signal?.aborted) throw new DOMException("Operation aborted", "AbortError");
6263
const key = `${kind}:${url}`;
6364
if (this.appended.has(key)) return;
64-
const bytes = await this.http.bytes(url, signal ? { signal } : undefined);
65+
const bytes = await fetchSegmentBytes(this.http, url, signal);
6566
await this.media.append(kind, bytes);
6667
this.appended.add(key);
6768
this.emitter.emit({ type: "segment", kind, url, startMs, durationMs });

src/type-type-mse-player.ts

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import { resolveBufferPolicy } from "./buffer-policy";
22
import { EventEmitter } from "./event-emitter";
33
import { HttpClient } from "./http-client";
4+
import { MediaElementObserver } from "./media-element-observer";
45
import { MediaSourceController } from "./media-source-controller";
56
import { PlaybackClient } from "./playback-client";
67
import { PlaybackLoop } from "./playback-loop";
7-
import { createSnapshot, type TypeTypeMseSnapshot } from "./player-snapshot";
8+
import { bufferedEndMs, createSnapshot, type TypeTypeMseSnapshot } from "./player-snapshot";
89
import { SeekController } from "./seek-controller";
910
import { SegmentScheduler } from "./segment-scheduler";
1011
import { type LoadedSession, loadPlaybackSession } from "./session-loader";
@@ -19,6 +20,7 @@ export class TypeTypeMsePlayer {
1920
private readonly emitter = new EventEmitter();
2021
private readonly http: HttpClient;
2122
private readonly playback: PlaybackClient;
23+
private readonly mediaEvents: MediaElementObserver;
2224
private readonly media: MediaSourceController;
2325
private readonly scheduler: SegmentScheduler;
2426
private readonly loop: PlaybackLoop;
@@ -39,6 +41,12 @@ export class TypeTypeMsePlayer {
3941
: { endpoint: config.endpoint },
4042
);
4143
this.playback = new PlaybackClient(this.http);
44+
this.mediaEvents = new MediaElementObserver({
45+
video,
46+
state: (state) => this.setState(state),
47+
error: (error) => this.handleError(error),
48+
});
49+
this.mediaEvents.start();
4250
this.media = new MediaSourceController(video);
4351
this.scheduler = new SegmentScheduler(this.http, this.media, this.emitter);
4452
this.loop = new PlaybackLoop({
@@ -50,7 +58,7 @@ export class TypeTypeMsePlayer {
5058
policy: resolveBufferPolicy(config),
5159
session: () => this.session,
5260
signal: () => this.operation.signal,
53-
bufferedEndMs: () => this.bufferedEndMs(),
61+
bufferedEndMs: () => bufferedEndMs(this.video),
5462
error: (error) => this.handleError(error),
5563
});
5664
}
@@ -95,7 +103,7 @@ export class TypeTypeMsePlayer {
95103
}
96104

97105
snapshot(): TypeTypeMseSnapshot {
98-
return createSnapshot(this.video, this.state, this.session, this.bufferedEndMs());
106+
return createSnapshot(this.video, this.state, this.session, bufferedEndMs(this.video));
99107
}
100108

101109
destroy(): void {
@@ -104,6 +112,7 @@ export class TypeTypeMsePlayer {
104112
this.operation.abort();
105113
this.seekController.reset();
106114
this.loop.stop();
115+
this.mediaEvents.stop();
107116
this.media.detach();
108117
this.emitter.clear();
109118
this.state = "destroyed";
@@ -151,12 +160,6 @@ export class TypeTypeMsePlayer {
151160
this.setState("ready");
152161
}
153162

154-
private bufferedEndMs(): number {
155-
const buffered = this.video.buffered;
156-
if (buffered.length === 0) return 0;
157-
return Math.round(buffered.end(buffered.length - 1) * 1000);
158-
}
159-
160163
private setState(state: TypeTypeMseState): void {
161164
if (this.state === state) return;
162165
this.state = state;

0 commit comments

Comments
 (0)