diff --git a/bun.lock b/bun.lock index cf74232..e5d4128 100644 --- a/bun.lock +++ b/bun.lock @@ -5,29 +5,29 @@ "": { "name": "@typetype/mse", "devDependencies": { - "@biomejs/biome": "^2.5.6", + "@biomejs/biome": "^2.5.7", "typescript": "~7.0.2", }, }, }, "packages": { - "@biomejs/biome": ["@biomejs/biome@2.5.6", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.6", "@biomejs/cli-darwin-x64": "2.5.6", "@biomejs/cli-linux-arm64": "2.5.6", "@biomejs/cli-linux-arm64-musl": "2.5.6", "@biomejs/cli-linux-x64": "2.5.6", "@biomejs/cli-linux-x64-musl": "2.5.6", "@biomejs/cli-win32-arm64": "2.5.6", "@biomejs/cli-win32-x64": "2.5.6" }, "bin": { "biome": "bin/biome" } }, "sha512-lxVNjv7UF6KfhMJfL9gaUHbWdJdHbsAj6OSmwSYNdhRuG67NxNQ4Xdvh3TUxsSK9sBzJBQhEJj3AopmmNJ5pSA=="], + "@biomejs/biome": ["@biomejs/biome@2.5.7", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.7", "@biomejs/cli-darwin-x64": "2.5.7", "@biomejs/cli-linux-arm64": "2.5.7", "@biomejs/cli-linux-arm64-musl": "2.5.7", "@biomejs/cli-linux-x64": "2.5.7", "@biomejs/cli-linux-x64-musl": "2.5.7", "@biomejs/cli-win32-arm64": "2.5.7", "@biomejs/cli-win32-x64": "2.5.7" }, "bin": { "biome": "bin/biome" } }, "sha512-zr8K/DcY5tYsQOQwqMJ0AWElo6QgmgNI7idXgXLhevVszlt8RGVpesEJPqx3ThazLaOwjJ5Y8fz3BtH5fGZNsw=="], - "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zMOLZP4oMrjh6m1zcSj1ud2awUPgTuMVbmQhYYWL7J8HwCnbHHBvTm7VBTRuY7epT5bez76IpKYQ11ZAqHFlnw=="], + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-vxo/Ls3/PYdQWyLhYYcgMOCzQypAjcY+iihS8M0wW03l16TCLW4zqZzGo75gm1VdCMj38hTVZ31KBWrZ4G9dJw=="], - "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-JAC1VqzvO7Th5ZplU0G2uGfkZbxEe9uDDektPAhF0JLusoz1w+T4okp2bkykI0bbaO2vslKiRfj4gU43JaGreA=="], + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-Cd3Ga61amT/Yl/0x8elP5hhGYaFy4bw6WuysTgf7oo8TA5tJ5A1k+DkVoJ2BHbTVil51gTX9VPzArnrlLJ3Kyg=="], - "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-6XsYwCFkp5sMxl85ffhgeGpGgs6A7dRYFnkceZ7WVxvycuTnGdD5xa534Z3xfrBQ0JCMK/mujT6ZNPJoghedwg=="], + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-rR2QE0yF2GYSuYuKIa7pKvODGJqnOH+2eDREAM8wV+mWKSkMQKdAp4zXEZfTaxY8PMoNONnpgSWcBCyLDPDOKg=="], - "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-eUa3jeeYvfMt19LBeh6E5PUZpxnTC4JqNWo+EDjTtQjAr2xLGnWaxACtVU1DQqmHYbvThlJzLX+ZsYgrqh2qVw=="], + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-xPI5yB6XlpDbNkS+bm1t42olw5c4l3UrlOmLg7KtLJvjvkNF/1V4tnUgfkylGIeb3u/T+BzMGYqgQhzjAoJzuQ=="], - "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-Pop9VXCFUhFTMfFefZ39S+u2rOPyNp5iHlxbZRwXGACHLy2r0jjiRgJHmaEKJzL3SyxlVeGShXhvvElvWowonA=="], + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.7", "", { "os": "linux", "cpu": "x64" }, "sha512-FQgqJhscrqJUFptGaRSUJWlXAExwWcDwLuK49dvKfkQ1bB5SEEyFssnsxQY83Xm6jR0EbbX3+8+D5bfvYqUG2Q=="], - "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-2Vp13QdKysH3HIWLaYLhUUwbK+jbZonJD1K+Lr0d0RO4wH7mkYd43vJixEDm8cUWrowoRz4UUHF1nm9Ae7ym8A=="], + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.7", "", { "os": "linux", "cpu": "x64" }, "sha512-rE5VZi+qtmPgQH+l7jVxYoZ18b/TiHEhulhMpjmCZH1PltSbjRcxNWywC3HZ9tYottG7ORkeTtoscBilKSBm0g=="], - "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-tDGshcm6BdkZOCGnTDX0Y8/U4IfBSlnUU7T56nNDuPEfed+aHg+u8G36NB43fJVl0Os6+QURXIE1yuD7AaEofA=="], + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-Oq4x0CCwP4jirrcTywXs5kOGZ4v5vuEP+gWrbtjApOA2CL9F3F9GlIdQIci8AKSCa/zURanMRpX/4wQ7Am6hHg=="], - "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-WN05KwXnTO/2J45RQPvzZMXf7tZUIofHoR35xIPfCo7pQ2RFidxI8sfb5mGsaTxdMmEOzHzOPRCdA5/fCpc7xQ=="], + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.7", "", { "os": "win32", "cpu": "x64" }, "sha512-V+0wu/nrj2S+MhP4EQ0uHNolP0IALEsz45pg0WoKkHfDeh0+ItHwP/p7bX5RPoMOl9NkpHYWdYPhIcy2mACHvQ=="], "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="], diff --git a/jsr.json b/jsr.json index fc6717e..378dca9 100644 --- a/jsr.json +++ b/jsr.json @@ -1,7 +1,7 @@ { "$schema": "https://jsr.io/schema/config-file.v1.json", "name": "@typetype/mse", - "version": "0.1.43", + "version": "0.1.44", "exports": "./src/index.ts", "publish": { "include": ["LICENSE", "README.md", "src/**/*.ts"] diff --git a/package.json b/package.json index 2a5a9d8..c1dfa4e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@typetype/mse", - "version": "0.1.43", + "version": "0.1.44", "description": "MSE playback engine for TypeType", "license": "MIT", "type": "module", @@ -39,7 +39,7 @@ "publish:jsr": "deno publish" }, "devDependencies": { - "@biomejs/biome": "^2.5.6", + "@biomejs/biome": "^2.5.7", "typescript": "~7.0.2" }, "publishConfig": { diff --git a/src/decode-preroll.ts b/src/decode-preroll.ts index 9c4cfce..ea2090d 100644 --- a/src/decode-preroll.ts +++ b/src/decode-preroll.ts @@ -10,6 +10,13 @@ const MAX_PREROLL_TIMEOUT_MS = 15_000; const SNAP_TIMEOUT_MS = 2_000; const SNAP_TOLERANCE_MS = 20; +class TargetSnapTimeoutError extends Error { + constructor() { + super("Seek target snap timed out"); + this.name = "TargetSnapTimeoutError"; + } +} + export function decodeStartMs(manifest: PlaybackManifest, targetMs: number): number { if (!manifest.video) return targetMs; const audio = manifest.audio.segments.find( @@ -36,13 +43,33 @@ export async function runDecodePreroll( const distanceMs = Math.abs(video.currentTime * 1000 - targetMs); const exact = distanceMs <= SNAP_TOLERANCE_MS; const resumeWithinTolerance = resumePlayback && distanceMs <= TARGET_TOLERANCE_MS; - if (!exact && !resumeWithinTolerance) await snapToTarget(video, targetMs, signal); - if (resumePlayback && video.paused) { + let resumeAttempted = false; + if (!exact && !resumeWithinTolerance) { + resumeAttempted = await snapToTarget(video, targetMs, signal, resumePlayback); + } + if (resumePlayback && !resumeAttempted && video.paused) { await tryResumePlayback(video); ensureNotAborted(signal); } return; } + const decodeStartSeconds = video.currentTime; + if (!requiresDecodePreroll(video)) { + try { + const resumeAttempted = await snapToTarget(video, targetMs, signal, resumePlayback); + if (resumePlayback) { + if (!resumeAttempted && video.paused) await tryResumePlayback(video); + } else { + video.pause(); + } + ensureNotAborted(signal); + return; + } catch (error) { + if (!(error instanceof TargetSnapTimeoutError)) throw error; + video.pause(); + video.currentTime = decodeStartSeconds; + } + } const restoreMediaState = transientState.begin(); let pausedForSnap = false; try { @@ -57,31 +84,46 @@ export async function runDecodePreroll( restoreMediaState(); if (!resumePlayback) { if (!pausedForSnap) video.pause(); - } else if (!signal.aborted) { + } else if (!signal.aborted && video.paused) { await tryResumePlayback(video); ensureNotAborted(signal); } } } -function snapToTarget( +function requiresDecodePreroll(video: HTMLVideoElement): boolean { + const webkitVideo = video as HTMLVideoElement & { webkitSupportsFullscreen?: boolean }; + return typeof webkitVideo.webkitSupportsFullscreen === "boolean"; +} + +async function snapToTarget( video: HTMLVideoElement, targetMs: number, signal: AbortSignal, -): Promise { + resumePlayback = false, +): Promise { ensureNotAborted(signal); const exact = Math.abs(video.currentTime * 1000 - targetMs) <= SNAP_TOLERANCE_MS; - if (exact && video.readyState >= HAVE_CURRENT_DATA) return Promise.resolve(); + if (exact && !video.seeking && video.readyState >= HAVE_CURRENT_DATA) return false; + if (resumePlayback && !video.paused) video.pause(); video.currentTime = targetMs / 1000; + let resumeAttempted = false; + if (resumePlayback && video.paused) { + resumeAttempted = true; + await tryResumePlayback(video); + ensureNotAborted(signal); + } return new Promise((resolve, reject) => { const startedAt = performance.now(); const poll = () => { if (signal.aborted) return reject(new DOMException("Operation aborted", "AbortError")); if (video.error) return reject(new Error(video.error.message)); const exact = Math.abs(video.currentTime * 1000 - targetMs) <= SNAP_TOLERANCE_MS; - if (exact && video.readyState >= HAVE_CURRENT_DATA) return resolve(); + if (exact && !video.seeking && video.readyState >= HAVE_CURRENT_DATA) { + return resolve(resumeAttempted); + } if (performance.now() - startedAt >= SNAP_TIMEOUT_MS) - return reject(new Error("Seek target snap timed out")); + return reject(new TargetSnapTimeoutError()); setTimeout(poll, 10); }; poll(); diff --git a/src/transient-media-state.ts b/src/transient-media-state.ts index 5d40192..b770ad6 100644 --- a/src/transient-media-state.ts +++ b/src/transient-media-state.ts @@ -2,6 +2,7 @@ type MediaStateSnapshot = { autoplay: boolean; defaultPlaybackRate: number; muted: boolean; + opacity: string | null; playbackRate: number; }; @@ -27,6 +28,7 @@ export class TransientMediaState { autoplay: this.video.autoplay, defaultPlaybackRate: this.video.defaultPlaybackRate, muted: this.video.muted, + opacity: this.video.style?.opacity ?? null, playbackRate: this.video.playbackRate, }; this.video.defaultPlaybackRate = this.snapshot.playbackRate; @@ -38,6 +40,7 @@ export class TransientMediaState { begin(): () => void { const restore = this.preserve(); this.video.muted = true; + if (this.video.style) this.video.style.opacity = "0"; this.video.playbackRate = 16; this.video.autoplay = true; return restore; @@ -49,6 +52,7 @@ export class TransientMediaState { this.video.defaultPlaybackRate = snapshot.defaultPlaybackRate; this.video.playbackRate = snapshot.playbackRate; this.video.muted = snapshot.muted; + if (snapshot.opacity !== null && this.video.style) this.video.style.opacity = snapshot.opacity; this.video.autoplay = snapshot.autoplay; this.snapshot = null; this.revision += 1; diff --git a/src/type-type-mse-player.ts b/src/type-type-mse-player.ts index 9cff0ec..f7aed0d 100644 --- a/src/type-type-mse-player.ts +++ b/src/type-type-mse-player.ts @@ -148,7 +148,13 @@ import type { /** Starts or resumes playback after loading. */ async play(): Promise { ensurePlayerAlive(this.destroyed); this.playbackIntent.play(); - if (!this.session || this.playerState.value === "loading") return; + if ( + !this.session || + this.playerState.value === "loading" || + this.playerState.value === "seeking" + ) { + return; + } if (this.pendingPrerollTargetMs !== null) { const targetMs = this.pendingPrerollTargetMs; await this.runDecodePreroll(targetMs, true, this.operation.signal); @@ -163,7 +169,9 @@ import type { /** Pauses playback while preserving the current session and buffer. */ pause(): void { this.playbackIntent.pause(); this.video.pause(); - this.playerState.set("ready"); + if (this.playerState.value !== "loading" && this.playerState.value !== "seeking") { + this.playerState.set("ready"); + } } /** Seeks to a millisecond position without replacing the media element. */ async seek(positionMs: number): Promise { @@ -301,6 +309,7 @@ import type { this.operation.ensureCurrent(this.destroyed, revision); } this.playerState.set("seeking"); + if (!quality) this.video.pause(); this.emitter.emit({ type: "seek", positionMs: targetMs }); try { const response = await this.deps.playback.seek( diff --git a/tests/decode-preroll-direct-seek.test.ts b/tests/decode-preroll-direct-seek.test.ts new file mode 100644 index 0000000..190ec1a --- /dev/null +++ b/tests/decode-preroll-direct-seek.test.ts @@ -0,0 +1,272 @@ +import { expect, spyOn, test } from "bun:test"; +import { runDecodePreroll } from "../src/decode-preroll"; + +test("seeks directly without exposing accelerated decode", async () => { + let currentTime = 398.36; + let paused = true; + let plays = 0; + let pauses = 0; + const rates: number[] = []; + let playbackRate = 1; + const video = { + autoplay: false, + error: null, + muted: false, + get currentTime() { + return currentTime; + }, + set currentTime(value: number) { + currentTime = value; + }, + get paused() { + return paused; + }, + get playbackRate() { + return playbackRate; + }, + set playbackRate(value: number) { + playbackRate = value; + rates.push(value); + }, + readyState: 4, + seeking: false, + style: { opacity: "" }, + pause: () => { + paused = true; + pauses += 1; + }, + play: async () => { + paused = false; + plays += 1; + }, + } as unknown as HTMLVideoElement; + + await runDecodePreroll(video, 401_200, true, new AbortController().signal); + + expect(video.currentTime).toBe(401.2); + expect(plays).toBe(1); + expect(pauses).toBe(0); + expect(rates).not.toContain(16); + expect(video.muted).toBe(false); +}); + +test("pauses a running overshoot before snapping and resuming", async () => { + let currentTime = 402; + let paused = false; + const events: string[] = []; + const video = { + autoplay: false, + error: null, + muted: false, + playbackRate: 1, + readyState: 4, + seeking: false, + get currentTime() { + return currentTime; + }, + set currentTime(value: number) { + events.push("seek"); + currentTime = value; + }, + get paused() { + return paused; + }, + pause: () => { + events.push("pause"); + paused = true; + }, + play: async () => { + events.push("play"); + paused = false; + }, + } as unknown as HTMLVideoElement; + + await runDecodePreroll(video, 401_200, true, new AbortController().signal); + + expect(events).toEqual(["pause", "seek", "play"]); + expect(video.currentTime).toBe(401.2); + expect(video.paused).toBe(false); +}); + +test("waits for the browser seek to finish before resuming", async () => { + let currentTime = 398.36; + let paused = true; + let seeking = false; + let plays = 0; + const seekingWhenPlayed: boolean[] = []; + const video = { + autoplay: false, + error: null, + muted: false, + playbackRate: 1, + readyState: 4, + get currentTime() { + return currentTime; + }, + set currentTime(value: number) { + currentTime = value; + seeking = true; + setTimeout(() => { + seeking = false; + }, 20); + }, + get paused() { + return paused; + }, + get seeking() { + return seeking; + }, + pause: () => { + paused = true; + }, + play: async () => { + seekingWhenPlayed.push(seeking); + paused = false; + plays += 1; + }, + } as unknown as HTMLVideoElement; + + await runDecodePreroll(video, 401_200, true, new AbortController().signal); + + expect(plays).toBe(1); + expect(seekingWhenPlayed).toEqual([true]); + expect(video.currentTime).toBe(401.2); +}); + +test("keeps paused playback at the direct target", async () => { + let currentTime = 398.36; + let pauses = 0; + const video = { + autoplay: false, + error: null, + muted: false, + paused: true, + playbackRate: 1, + readyState: 4, + seeking: false, + style: { opacity: "" }, + get currentTime() { + return currentTime; + }, + set currentTime(value: number) { + currentTime = value; + }, + pause: () => { + pauses += 1; + }, + play: async () => { + throw new Error("direct paused seek must not play"); + }, + } as unknown as HTMLVideoElement; + + await runDecodePreroll(video, 401_200, false, new AbortController().signal, true); + + expect(video.currentTime).toBe(401.2); + expect(pauses).toBe(1); + expect(video.playbackRate).toBe(1); + expect(video.muted).toBe(false); +}); + +test("falls back to bounded accelerated decode after a direct seek timeout", async () => { + let currentTime = 398.36; + let paused = true; + let plays = 0; + const rates: number[] = []; + let playbackRate = 1; + const now = spyOn(performance, "now"); + let nowCalls = 0; + now.mockImplementation(() => (nowCalls++ === 0 ? 0 : 3_000)); + const video = { + autoplay: false, + defaultPlaybackRate: 1, + error: null, + muted: false, + readyState: 4, + seeking: false, + style: { opacity: "" }, + get currentTime() { + return currentTime; + }, + set currentTime(value: number) { + if (value === 398.36) currentTime = value; + }, + get paused() { + return paused; + }, + get playbackRate() { + return playbackRate; + }, + set playbackRate(value: number) { + playbackRate = value; + rates.push(value); + }, + pause: () => { + paused = true; + }, + play: async () => { + paused = false; + plays += 1; + if (plays > 1) currentTime = 401.2; + }, + } as unknown as HTMLVideoElement; + + try { + await runDecodePreroll(video, 401_200, true, new AbortController().signal); + } finally { + now.mockRestore(); + } + + expect(plays).toBe(2); + expect(rates).toContain(16); + expect(video.playbackRate).toBe(1); + expect(video.muted).toBe(false); + expect(video.style.opacity).toBe(""); + expect(video.currentTime).toBe(401.2); +}); + +test("uses hidden decode preroll on WebKit without attempting a direct seek", async () => { + let currentTime = 398.36; + let playbackRate = 1; + let plays = 0; + const currentTimeWrites: number[] = []; + const style = { opacity: "0.75" }; + const video = { + autoplay: false, + defaultPlaybackRate: 1, + error: null, + muted: false, + paused: false, + readyState: 4, + seeking: false, + style, + webkitSupportsFullscreen: false, + get currentTime() { + return currentTime; + }, + set currentTime(value: number) { + currentTimeWrites.push(value); + currentTime = value; + }, + get playbackRate() { + return playbackRate; + }, + set playbackRate(value: number) { + playbackRate = value; + }, + pause: () => undefined, + play: async () => { + expect(style.opacity).toBe("0"); + plays += 1; + currentTime = 401.2; + }, + } as unknown as HTMLVideoElement; + + await runDecodePreroll(video, 401_200, true, new AbortController().signal); + + expect(plays).toBe(1); + expect(currentTimeWrites).toEqual([]); + expect(video.currentTime).toBe(401.2); + expect(video.playbackRate).toBe(1); + expect(video.muted).toBe(false); + expect(style.opacity).toBe("0.75"); +}); diff --git a/tests/decode-preroll.test.ts b/tests/decode-preroll.test.ts index 35a1602..15834dd 100644 --- a/tests/decode-preroll.test.ts +++ b/tests/decode-preroll.test.ts @@ -42,15 +42,23 @@ test("keeps the target for audio-only manifests", () => { }); test("decodes a paused frame at an existing target", async () => { + let currentTime = 207.599; let plays = 0; let pauses = 0; let readyState = 1; const video = { autoplay: false, - currentTime: 207.599, error: null, muted: false, + paused: true, playbackRate: 1, + get currentTime() { + return currentTime; + }, + set currentTime(value: number) { + currentTime = value; + readyState = 2; + }, get readyState() { return readyState; }, @@ -63,7 +71,7 @@ test("decodes a paused frame at an existing target", async () => { }, } as unknown as HTMLVideoElement; await runDecodePreroll(video, 207_599, false, new AbortController().signal, true); - expect(plays).toBe(1); + expect(plays).toBe(0); expect(pauses).toBe(1); expect(video.muted).toBe(false); expect(video.playbackRate).toBe(1); @@ -88,7 +96,7 @@ test("snaps an overshot decoded frame to the exact target", async () => { expect(plays).toBe(0); }); -test("does not seek again after preroll decodes the exact target frame", async () => { +test("seeks once to the exact target before resuming", async () => { let currentTime = 24.791; let currentTimeWrites = 0; let plays = 0; @@ -96,6 +104,7 @@ test("does not seek again after preroll decodes the exact target frame", async ( autoplay: false, error: null, muted: false, + paused: true, playbackRate: 1, readyState: 4, get currentTime() { @@ -114,8 +123,8 @@ test("does not seek again after preroll decodes the exact target frame", async ( await runDecodePreroll(video, 30_298, true, new AbortController().signal); - expect(plays).toBe(2); - expect(currentTimeWrites).toBe(0); + expect(plays).toBe(1); + expect(currentTimeWrites).toBe(1); expect(video.currentTime).toBe(30.298); }); @@ -170,7 +179,7 @@ test("keeps a decoded target when automatic resume needs a gesture", async () => expect(video.currentTime).toBe(30.25); }); -test("does not seek or pause a resumed preroll within target tolerance", async () => { +test("resumes directly after seeking to the target", async () => { let currentTime = 24.791; let currentTimeWrites = 0; let plays = 0; @@ -179,6 +188,7 @@ test("does not seek or pause a resumed preroll within target tolerance", async ( autoplay: false, error: null, muted: false, + paused: true, playbackRate: 1, readyState: 4, get currentTime() { @@ -193,15 +203,14 @@ test("does not seek or pause a resumed preroll within target tolerance", async ( }, play: async () => { plays += 1; - if (plays === 1) currentTime = 30.23; }, } as unknown as HTMLVideoElement; await runDecodePreroll(video, 30_298, true, new AbortController().signal); - expect(currentTimeWrites).toBe(0); + expect(currentTimeWrites).toBe(1); expect(pauses).toBe(0); - expect(plays).toBe(2); + expect(plays).toBe(1); }); test("keeps the exact snap for a paused preroll", async () => { diff --git a/tests/transient-media-state.test.ts b/tests/transient-media-state.test.ts index 17069fa..46d1e13 100644 --- a/tests/transient-media-state.test.ts +++ b/tests/transient-media-state.test.ts @@ -8,12 +8,14 @@ test("restores the media element after a temporary override", () => { const restore = state.begin(); expect(state.active).toBe(true); expect(video.muted).toBe(true); + expect(video.style.opacity).toBe("0"); expect(video.playbackRate).toBe(16); expect(video.autoplay).toBe(true); restore(); expect(state.active).toBe(false); expect(video.muted).toBe(false); + expect(video.style.opacity).toBe("0.75"); expect(video.playbackRate).toBe(1.25); expect(video.autoplay).toBe(false); }); @@ -89,5 +91,6 @@ function videoElement(): HTMLVideoElement { defaultPlaybackRate: 1, muted: false, playbackRate: 1.25, + style: { opacity: "0.75" }, } as HTMLVideoElement; } diff --git a/tests/type-type-mse-quality-transition.test.ts b/tests/type-type-mse-quality-transition.test.ts index 1da64de..80ad665 100644 --- a/tests/type-type-mse-quality-transition.test.ts +++ b/tests/type-type-mse-quality-transition.test.ts @@ -17,7 +17,7 @@ type QualityHarness = { bufferedSeekRecovery: BufferedSeekRecovery; playbackIntent: PlaybackIntent; seekController: SeekController; - video: { currentTime: number; paused: boolean }; + video: { currentTime: number; paused: boolean; pause: () => void }; playerState: { value: TypeTypeMseState; set: (state: TypeTypeMseState) => void }; deps: { loop: { stop: () => void; start: () => void; quiesce: () => Promise }; @@ -69,11 +69,12 @@ test("stops the active playback loop before a timeline seek", async () => { async () => { events.push("quiesce"); }, + () => events.push("pause"), ); await player.performSeek(120_000); - expect(events).toEqual(["stop", "quiesce", "seek"]); + expect(events).toEqual(["stop", "quiesce", "pause", "seek"]); }); test("aborts an obsolete quality preparation and applies only the latest selection", async () => { @@ -111,6 +112,7 @@ function harness( seek: QualityHarness["deps"]["playback"]["seek"], stop: () => void, quiesce: () => Promise = async () => undefined, + pause: () => void = () => undefined, ): QualityHarness { const player = Object.create(TypeTypeMsePlayer.prototype) as QualityHarness; player.destroyed = false; @@ -120,7 +122,14 @@ function harness( player.bufferedSeekRecovery = new BufferedSeekRecovery(() => () => undefined); player.playbackIntent = new PlaybackIntent(); player.seekController = new SeekController(); - player.video = { currentTime: 120, paused: false }; + player.video = { + currentTime: 120, + paused: false, + pause: () => { + player.video.paused = true; + pause(); + }, + }; player.playerState = { value: "playing", set: (state) => (player.playerState.value = state), diff --git a/tests/type-type-mse-transition-controls.test.ts b/tests/type-type-mse-transition-controls.test.ts new file mode 100644 index 0000000..5bdf335 --- /dev/null +++ b/tests/type-type-mse-transition-controls.test.ts @@ -0,0 +1,53 @@ +import { expect, test } from "bun:test"; +import { PlaybackIntent } from "../src/playback-intent"; +import type { LoadedSession } from "../src/session-loader"; +import { TypeTypeMsePlayer } from "../src/type-type-mse-player"; +import type { TypeTypeMseState } from "../src/types"; + +type TransitionHarness = { + destroyed: boolean; + session: LoadedSession; + pendingPrerollTargetMs: number | null; + playbackIntent: PlaybackIntent; + playerState: { value: TypeTypeMseState; set: (state: TypeTypeMseState) => void }; + video: { paused: boolean; pause: () => void; play: () => Promise }; + play: () => Promise; + pause: () => void; +}; + +test("queues playback intent while a session seek is in progress", async () => { + let plays = 0; + let pauses = 0; + const player = Object.create(TypeTypeMsePlayer.prototype) as TransitionHarness; + player.destroyed = false; + player.session = {} as LoadedSession; + player.pendingPrerollTargetMs = null; + player.playbackIntent = new PlaybackIntent(); + player.playerState = { + value: "seeking", + set: (state) => (player.playerState.value = state), + }; + player.video = { + paused: true, + pause: () => { + pauses += 1; + player.video.paused = true; + }, + play: async () => { + plays += 1; + player.video.paused = false; + }, + }; + + await player.play(); + + expect(player.playbackIntent.shouldResume).toBe(true); + expect(plays).toBe(0); + expect(player.playerState.value).toBe("seeking"); + + player.pause(); + + expect(player.playbackIntent.shouldResume).toBe(false); + expect(pauses).toBe(1); + expect(player.playerState.value).toBe("seeking"); +});