Skip to content

Commit 438e38c

Browse files
committed
feat: add controlled quality switching
1 parent de45024 commit 438e38c

6 files changed

Lines changed: 122 additions & 46 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ const engine = new TypeTypeMsePlayer(videoElement, {
3838
engine.on("state", (event) => console.log(event.state));
3939
await engine.load();
4040
await engine.play();
41+
42+
await engine.setQuality({ videoItag: 248 });
4143
```
4244

4345
## Diagnostics

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,6 @@ export type {
66
TypeTypeMseEvent,
77
TypeTypeMseEventType,
88
TypeTypeMseListener,
9+
TypeTypeMseQuality,
910
TypeTypeMseState,
1011
} from "./types";

src/playback-client.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@ export type CreatePlaybackRequest = {
1717
startTimeMs: number;
1818
};
1919

20+
export type SeekPlaybackOptions = {
21+
videoItag?: number;
22+
audioItag?: number;
23+
audioTrackId?: string | null;
24+
};
25+
2026
function field(value: object, key: string): unknown {
2127
return Reflect.get(value, key);
2228
}
@@ -65,12 +71,16 @@ export class PlaybackClient {
6571
async seek(
6672
sessionId: string,
6773
positionMs: number,
74+
options: SeekPlaybackOptions = {},
6875
signal?: AbortSignal,
6976
): Promise<PlaybackResponse> {
7077
const session = encodeURIComponent(sessionId);
7178
const params = new URLSearchParams({
7279
playerTimeMs: String(Math.max(0, Math.round(positionMs))),
7380
});
81+
if (options.videoItag) params.set("videoItag", String(options.videoItag));
82+
if (options.audioItag) params.set("audioItag", String(options.audioItag));
83+
if (options.audioTrackId) params.set("audioTrackId", options.audioTrackId);
7484
const init = signal ? { method: "POST", signal } : { method: "POST" };
7585
const response = await this.http.json(`/sabr/playback/${session}/seek?${params}`, init);
7686
return parsePlaybackResponse(response);

src/player-deps.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { resolveBufferPolicy } from "./buffer-policy";
2+
import type { EventEmitter } from "./event-emitter";
3+
import { HttpClient } from "./http-client";
4+
import { MediaElementObserver } from "./media-element-observer";
5+
import { MediaSourceController } from "./media-source-controller";
6+
import { PlaybackClient } from "./playback-client";
7+
import { PlaybackLoop } from "./playback-loop";
8+
import { bufferedEndMs } from "./player-snapshot";
9+
import { SegmentScheduler } from "./segment-scheduler";
10+
import type { LoadedSession } from "./session-loader";
11+
import type { TypeTypeMseConfig, TypeTypeMseState } from "./types";
12+
13+
export type PlayerDeps = {
14+
http: HttpClient;
15+
playback: PlaybackClient;
16+
mediaEvents: MediaElementObserver;
17+
media: MediaSourceController;
18+
scheduler: SegmentScheduler;
19+
loop: PlaybackLoop;
20+
};
21+
22+
type Args = {
23+
video: HTMLVideoElement;
24+
config: TypeTypeMseConfig;
25+
emitter: EventEmitter;
26+
session: () => LoadedSession | null;
27+
signal: () => AbortSignal;
28+
state: (state: TypeTypeMseState) => void;
29+
error: (error: Error) => void;
30+
};
31+
32+
export function createPlayerDeps(args: Args): PlayerDeps {
33+
const http = new HttpClient(
34+
args.config.headers
35+
? { endpoint: args.config.endpoint, headers: args.config.headers }
36+
: { endpoint: args.config.endpoint },
37+
);
38+
const playback = new PlaybackClient(http);
39+
const mediaEvents = new MediaElementObserver({
40+
video: args.video,
41+
state: args.state,
42+
error: args.error,
43+
});
44+
const media = new MediaSourceController(args.video);
45+
const scheduler = new SegmentScheduler(http, media, args.emitter);
46+
const loop = new PlaybackLoop({
47+
video: args.video,
48+
http,
49+
media,
50+
scheduler,
51+
emitter: args.emitter,
52+
policy: resolveBufferPolicy(args.config),
53+
session: args.session,
54+
signal: args.signal,
55+
bufferedEndMs: () => bufferedEndMs(args.video),
56+
error: args.error,
57+
});
58+
return { http, playback, mediaEvents, media, scheduler, loop };
59+
}

src/type-type-mse-player.ts

Lines changed: 43 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,19 @@
1-
import { resolveBufferPolicy } from "./buffer-policy";
21
import { EventEmitter } from "./event-emitter";
3-
import { HttpClient } from "./http-client";
4-
import { MediaElementObserver } from "./media-element-observer";
5-
import { MediaSourceController } from "./media-source-controller";
6-
import { PlaybackClient } from "./playback-client";
7-
import { PlaybackLoop } from "./playback-loop";
2+
import { createPlayerDeps, type PlayerDeps } from "./player-deps";
83
import { bufferedEndMs, createSnapshot, type TypeTypeMseSnapshot } from "./player-snapshot";
94
import { SeekController } from "./seek-controller";
10-
import { SegmentScheduler } from "./segment-scheduler";
115
import { type LoadedSession, loadPlaybackSession } from "./session-loader";
126
import type {
137
TypeTypeMseConfig,
148
TypeTypeMseEventType,
159
TypeTypeMseListener,
10+
TypeTypeMseQuality,
1611
TypeTypeMseState,
1712
} from "./types";
1813

1914
export class TypeTypeMsePlayer {
2015
private readonly emitter = new EventEmitter();
21-
private readonly http: HttpClient;
22-
private readonly playback: PlaybackClient;
23-
private readonly mediaEvents: MediaElementObserver;
24-
private readonly media: MediaSourceController;
25-
private readonly scheduler: SegmentScheduler;
26-
private readonly loop: PlaybackLoop;
16+
private readonly deps: PlayerDeps;
2717
private readonly seekController = new SeekController();
2818
private session: LoadedSession | null = null;
2919
private operation = new AbortController();
@@ -35,32 +25,16 @@ export class TypeTypeMsePlayer {
3525
private readonly video: HTMLVideoElement,
3626
private readonly config: TypeTypeMseConfig,
3727
) {
38-
this.http = new HttpClient(
39-
config.headers
40-
? { endpoint: config.endpoint, headers: config.headers }
41-
: { endpoint: config.endpoint },
42-
);
43-
this.playback = new PlaybackClient(this.http);
44-
this.mediaEvents = new MediaElementObserver({
28+
this.deps = createPlayerDeps({
4529
video,
46-
state: (state) => this.setState(state),
47-
error: (error) => this.handleError(error),
48-
});
49-
this.mediaEvents.start();
50-
this.media = new MediaSourceController(video);
51-
this.scheduler = new SegmentScheduler(this.http, this.media, this.emitter);
52-
this.loop = new PlaybackLoop({
53-
video,
54-
http: this.http,
55-
media: this.media,
56-
scheduler: this.scheduler,
30+
config,
5731
emitter: this.emitter,
58-
policy: resolveBufferPolicy(config),
5932
session: () => this.session,
6033
signal: () => this.operation.signal,
61-
bufferedEndMs: () => bufferedEndMs(this.video),
34+
state: (state) => this.setState(state),
6235
error: (error) => this.handleError(error),
6336
});
37+
this.deps.mediaEvents.start();
6438
}
6539

6640
on(type: TypeTypeMseEventType, listener: TypeTypeMseListener): () => void {
@@ -73,7 +47,7 @@ export class TypeTypeMsePlayer {
7347
const signal = this.operation.signal;
7448
this.setState("loading");
7549
const startTimeMs = Math.max(0, Math.round(this.config.startTimeMs ?? 0));
76-
const response = await this.playback.create(
50+
const response = await this.deps.playback.create(
7751
{
7852
videoId: this.config.videoId,
7953
videoItag: this.config.videoItag,
@@ -102,6 +76,13 @@ export class TypeTypeMsePlayer {
10276
return this.seekController.seek(positionMs, (targetMs) => this.performSeek(targetMs));
10377
}
10478

79+
async setQuality(quality: TypeTypeMseQuality): Promise<void> {
80+
this.operation.abort();
81+
return this.seekController.seek(this.currentTimeMs(), (targetMs) =>
82+
this.performSeek(targetMs, quality),
83+
);
84+
}
85+
10586
snapshot(): TypeTypeMseSnapshot {
10687
return createSnapshot(this.video, this.state, this.session, bufferedEndMs(this.video));
10788
}
@@ -111,25 +92,37 @@ export class TypeTypeMsePlayer {
11192
this.destroyed = true;
11293
this.operation.abort();
11394
this.seekController.reset();
114-
this.loop.stop();
115-
this.mediaEvents.stop();
116-
this.media.detach();
95+
this.deps.loop.stop();
96+
this.deps.mediaEvents.stop();
97+
this.deps.media.detach();
11798
this.emitter.clear();
11899
this.state = "destroyed";
119100
}
120101

121-
private async performSeek(positionMs: number): Promise<void> {
102+
private async performSeek(positionMs: number, quality?: TypeTypeMseQuality): Promise<void> {
122103
this.ensureAlive();
123104
const current = this.session;
124105
if (!current) throw new Error("Player is not loaded");
125106
const revision = this.nextRevision();
126107
const signal = this.operation.signal;
127108
const targetMs = Math.max(0, Math.round(positionMs));
128-
this.loop.stop();
109+
this.deps.loop.stop();
129110
this.setState("seeking");
130111
this.emitter.emit({ type: "seek", positionMs: targetMs });
131-
const response = await this.playback.seek(current.response.sessionId, targetMs, signal);
112+
const response = await this.deps.playback.seek(
113+
current.response.sessionId,
114+
targetMs,
115+
quality,
116+
signal,
117+
);
132118
await this.switchSession(response, targetMs, revision, signal);
119+
if (quality) {
120+
this.emitter.emit({
121+
type: "quality",
122+
videoItag: quality.videoItag,
123+
audioItag: quality.audioItag ?? null,
124+
});
125+
}
133126
}
134127

135128
private async switchSession(
@@ -139,19 +132,19 @@ export class TypeTypeMsePlayer {
139132
signal: AbortSignal,
140133
): Promise<void> {
141134
const session = await loadPlaybackSession({
142-
http: this.http,
143-
playback: this.playback,
144-
media: this.media,
145-
scheduler: this.scheduler,
135+
http: this.deps.http,
136+
playback: this.deps.playback,
137+
media: this.deps.media,
138+
scheduler: this.deps.scheduler,
146139
video: this.video,
147140
response,
148141
startTimeMs,
149142
signal,
150143
});
151144
if (!this.isCurrent(revision)) return;
152145
this.session = session;
153-
await this.loop.fillOnce();
154-
this.loop.start();
146+
await this.deps.loop.fillOnce();
147+
this.deps.loop.start();
155148
this.emitter.emit({
156149
type: "manifest",
157150
generation: response.generation,
@@ -176,6 +169,10 @@ export class TypeTypeMsePlayer {
176169
if (this.destroyed) throw new Error("Player is destroyed");
177170
}
178171

172+
private currentTimeMs(): number {
173+
return Math.max(0, Math.round(this.video.currentTime * 1000));
174+
}
175+
179176
private nextRevision(): number {
180177
this.operation.abort();
181178
this.operation = new AbortController();

src/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,16 @@ export type TypeTypeMseConfig = {
2525
manifestRefreshMs?: number;
2626
};
2727

28+
export type TypeTypeMseQuality = {
29+
videoItag: number;
30+
audioItag?: number;
31+
audioTrackId?: string | null;
32+
};
33+
2834
export type TypeTypeMseEvent =
2935
| { type: "state"; state: TypeTypeMseState }
3036
| { type: "manifest"; generation: number | null; segmentCount: number }
37+
| { type: "quality"; videoItag: number; audioItag: number | null }
3138
| { type: "segment"; kind: TrackKind; url: string; startMs: number; durationMs: number }
3239
| { type: "buffer"; bufferedEndMs: number; currentTimeMs: number }
3340
| { type: "seek"; positionMs: number }

0 commit comments

Comments
 (0)