Skip to content

Commit cffd905

Browse files
committed
fix: coalesce duplicate playback seeks
1 parent 194821b commit cffd905

3 files changed

Lines changed: 104 additions & 25 deletions

File tree

src/seek-controller.ts

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,44 @@
11
export type SeekExecutor = (positionMs: number) => Promise<void>;
22

3+
type SeekRequest = {
4+
key: string;
5+
positionMs: number;
6+
execute: SeekExecutor;
7+
};
8+
39
export class SeekController {
4-
private requestedMs: number | null = null;
10+
private requested: SeekRequest | null = null;
11+
private activeKey: string | null = null;
512
private running: Promise<void> | null = null;
613

7-
seek(positionMs: number, execute: SeekExecutor): Promise<void> {
8-
this.requestedMs = Math.max(0, Math.round(positionMs));
9-
if (!this.running) this.running = this.drain(execute).finally(() => (this.running = null));
14+
seek(positionMs: number, key: string, execute: SeekExecutor, cancel: () => void): Promise<void> {
15+
if (
16+
this.running &&
17+
(this.requested?.key === key || (!this.requested && this.activeKey === key))
18+
) {
19+
return this.running;
20+
}
21+
this.requested = { key, positionMs: Math.max(0, Math.round(positionMs)), execute };
22+
if (this.activeKey !== null) cancel();
23+
if (!this.running) this.running = this.drain().finally(() => (this.running = null));
1024
return this.running;
1125
}
1226

1327
reset(): void {
14-
this.requestedMs = null;
28+
this.requested = null;
1529
}
1630

17-
private async drain(execute: SeekExecutor): Promise<void> {
18-
while (this.requestedMs !== null) {
19-
const target = this.requestedMs;
20-
this.requestedMs = null;
31+
private async drain(): Promise<void> {
32+
while (this.requested !== null) {
33+
const request = this.requested;
34+
this.requested = null;
35+
this.activeKey = request.key;
2136
try {
22-
await execute(target);
37+
await request.execute(request.positionMs);
2338
} catch (error) {
24-
if (!isAbortError(error) || this.requestedMs === null) throw error;
39+
if (!isAbortError(error) || this.requested === null) throw error;
40+
} finally {
41+
this.activeKey = null;
2542
}
2643
}
2744
}

src/type-type-mse-player.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -76,16 +76,23 @@ export class TypeTypeMsePlayer {
7676
}
7777
async seek(positionMs: number): Promise<void> {
7878
const resumePlayback = !this.video.paused;
79-
this.operation.abort();
80-
return this.seekController.seek(positionMs, (targetMs) =>
81-
this.performSeek(targetMs, undefined, resumePlayback),
79+
const targetMs = Math.max(0, Math.round(positionMs));
80+
return this.seekController.seek(
81+
targetMs,
82+
`seek:${targetMs}`,
83+
(target) => this.performSeek(target, undefined, resumePlayback),
84+
() => this.operation.abort(),
8285
);
8386
}
8487

8588
async setQuality(quality: TypeTypeMseQuality): Promise<void> {
86-
this.operation.abort();
87-
return this.seekController.seek(currentTimeMs(this.video), (targetMs) =>
88-
this.performSeek(targetMs, quality),
89+
const targetMs = currentTimeMs(this.video);
90+
const key = `quality:${targetMs}:${quality.videoItag}:${quality.audioItag}:${quality.audioTrackId ?? ""}`;
91+
return this.seekController.seek(
92+
targetMs,
93+
key,
94+
(target) => this.performSeek(target, quality),
95+
() => this.operation.abort(),
8996
);
9097
}
9198

tests/seek-controller.test.ts

Lines changed: 63 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,13 @@ test("coalesces seeks to the latest pending position", async () => {
1414
const controller = new SeekController();
1515
const first = deferred();
1616
const positions: number[] = [];
17-
const running = controller.seek(10, async (position) => {
17+
const execute = async (position: number) => {
1818
positions.push(position);
1919
if (position === 10) await first.promise;
20-
});
21-
controller.seek(20, async () => undefined);
22-
controller.seek(30, async () => undefined);
20+
};
21+
const running = controller.seek(10, "10", execute, () => undefined);
22+
controller.seek(20, "20", execute, () => undefined);
23+
controller.seek(30, "30", execute, () => undefined);
2324
first.resolve();
2425
await running;
2526
expect(positions).toEqual([10, 30]);
@@ -28,18 +29,72 @@ test("coalesces seeks to the latest pending position", async () => {
2829
test("continues to the latest seek after an abort", async () => {
2930
const controller = new SeekController();
3031
const positions: number[] = [];
31-
const running = controller.seek(10, async (position) => {
32+
const execute = async (position: number) => {
3233
positions.push(position);
3334
if (position === 10) throw new DOMException("aborted", "AbortError");
34-
});
35-
controller.seek(40, async () => undefined);
35+
};
36+
const running = controller.seek(10, "10", execute, () => undefined);
37+
controller.seek(40, "40", execute, () => undefined);
3638
await running;
3739
expect(positions).toEqual([10, 40]);
3840
});
3941

4042
test("throws non-abort seek errors", async () => {
4143
const controller = new SeekController();
4244
await expect(
43-
controller.seek(10, async () => Promise.reject(new Error("failed"))),
45+
controller.seek(
46+
10,
47+
"10",
48+
async () => Promise.reject(new Error("failed")),
49+
() => undefined,
50+
),
4451
).rejects.toThrow("failed");
4552
});
53+
54+
test("uses the latest pending executor", async () => {
55+
const controller = new SeekController();
56+
const first = deferred();
57+
const executions: string[] = [];
58+
const running = controller.seek(
59+
10,
60+
"seek:10",
61+
async () => {
62+
executions.push("seek");
63+
await first.promise;
64+
},
65+
() => undefined,
66+
);
67+
controller.seek(
68+
20,
69+
"quality:20:299",
70+
async () => {
71+
executions.push("quality");
72+
},
73+
() => undefined,
74+
);
75+
first.resolve();
76+
await running;
77+
expect(executions).toEqual(["seek", "quality"]);
78+
});
79+
80+
test("deduplicates the active request", async () => {
81+
const controller = new SeekController();
82+
const first = deferred();
83+
let executions = 0;
84+
let cancellations = 0;
85+
const execute = async () => {
86+
executions += 1;
87+
await first.promise;
88+
};
89+
const running = controller.seek(10, "seek:10", execute, () => {
90+
cancellations += 1;
91+
});
92+
const duplicate = controller.seek(10, "seek:10", execute, () => {
93+
cancellations += 1;
94+
});
95+
first.resolve();
96+
await running;
97+
expect(duplicate).toBe(running);
98+
expect(executions).toBe(1);
99+
expect(cancellations).toBe(0);
100+
});

0 commit comments

Comments
 (0)