From 832315a3601c2232ca2f41ab9808db7e868e1313 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Mon, 31 Aug 2026 14:19:48 +0200 Subject: [PATCH 01/13] ci: route workflows to R730 runners --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5f1c31a..4df1db3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ concurrency: jobs: validate: - runs-on: ${{ github.event_name == 'pull_request' && 'ubuntu-24.04' || fromJSON('["self-hosted","Linux","X64","arko","typetype"]') }} + runs-on: ${{ github.event_name == 'pull_request' && 'ubuntu-24.04' || fromJSON('["self-hosted","Linux","X64","r730","typetype"]') }} timeout-minutes: 10 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cf88389..211c725 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -92,7 +92,7 @@ jobs: publish-jsr: if: (github.event_name == 'push' && startsWith(github.ref_name, 'v')) || inputs.registry == 'jsr' || inputs.registry == 'both' - runs-on: [self-hosted, Linux, X64, arko, typetype] + runs-on: [self-hosted, Linux, X64, r730, typetype] timeout-minutes: 15 environment: jsr permissions: From b5c0253f698ebd0d95dc4802e23b7c2f0e62d640 Mon Sep 17 00:00:00 2001 From: Tax_Tux <138765817+Priveetee@users.noreply.github.com> Date: Mon, 31 Aug 2026 21:31:09 +0200 Subject: [PATCH 02/13] chore: benchmark dev runners From 2388bd0133985923d75b84e29cc83dd28d31fdbd Mon Sep 17 00:00:00 2001 From: Priveetee Date: Mon, 31 Aug 2026 21:59:33 +0200 Subject: [PATCH 03/13] ci: route npm publication through R730 --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 211c725..8823611 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,7 +26,7 @@ permissions: jobs: publish-npm: if: github.event_name == 'push' || inputs.registry == 'npm' || inputs.registry == 'both' - runs-on: ubuntu-latest + runs-on: [self-hosted, Linux, X64, r730, typetype] timeout-minutes: 15 environment: npm permissions: From 511a7e004f80172029231f61a37f92fbb86ae9db Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 3 Sep 2026 12:21:20 +0200 Subject: [PATCH 04/13] fix: recover stalled SABR segment fetches --- src/playback-window-error.ts | 8 ++++++++ src/segment-fetcher.ts | 3 ++- tests/segment-fetcher.test.ts | 23 +++++++++++++++++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/playback-window-error.ts b/src/playback-window-error.ts index 51b10ee..f585785 100644 --- a/src/playback-window-error.ts +++ b/src/playback-window-error.ts @@ -29,6 +29,14 @@ export function playbackSessionExpiredError(): PlaybackWindowRecoveryError { return new PlaybackWindowRecoveryError("Playback session expired", "retry_fresh_session", []); } +export function playbackSegmentTimeoutError(): PlaybackWindowRecoveryError { + return new PlaybackWindowRecoveryError( + "SABR segment was not ready in time", + "retry_fresh_session", + [], + ); +} + export function isPlaybackSessionExpiryStatus(status: number): boolean { return status === 404 || status === 410; } diff --git a/src/segment-fetcher.ts b/src/segment-fetcher.ts index 4590108..0acb2fb 100644 --- a/src/segment-fetcher.ts +++ b/src/segment-fetcher.ts @@ -1,6 +1,7 @@ import type { HttpClient } from "./http-client"; import { isPlaybackSessionExpiryStatus, + playbackSegmentTimeoutError, playbackSessionExpiredError, } from "./playback-window-error"; @@ -19,7 +20,7 @@ export async function fetchSegmentBytes( const delayMs = await retryAfterMs(response); await new Promise((resolve) => setTimeout(resolve, delayMs)); } - throw new Error("Segment was not ready in time"); + throw playbackSegmentTimeoutError(); } async function retryAfterMs(response: Response): Promise { diff --git a/tests/segment-fetcher.test.ts b/tests/segment-fetcher.test.ts index 1a7e427..2e29e3e 100644 --- a/tests/segment-fetcher.test.ts +++ b/tests/segment-fetcher.test.ts @@ -46,3 +46,26 @@ test("turns an expired segment into fresh session recovery", async () => { expect(error).toBeInstanceOf(PlaybackWindowRecoveryError); expect(error).toMatchObject({ recoveryAction: "retry_fresh_session" }); }); + +test("turns a segment poll timeout into fresh session recovery", async () => { + globalThis.fetch = () => + Promise.resolve( + new Response(JSON.stringify({ retryAfterMs: 0 }), { + status: 202, + headers: { "content-type": "application/json" }, + }), + ); + + const request = fetchSegmentBytes( + new HttpClient({ endpoint: "https://example.com/api" }), + "https://example.com/segment", + 1, + ); + const error = await request.catch((reason: unknown) => reason); + + expect(error).toBeInstanceOf(PlaybackWindowRecoveryError); + expect(error).toMatchObject({ + message: "SABR segment was not ready in time", + recoveryAction: "retry_fresh_session", + }); +}); From 9483786fb0a2b1039b43ba765f5baecb32410f76 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 3 Sep 2026 12:21:36 +0200 Subject: [PATCH 05/13] chore: release mse 0.1.57 --- jsr.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/jsr.json b/jsr.json index a0008f2..4581aed 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.56", + "version": "0.1.57", "exports": "./src/index.ts", "publish": { "include": ["LICENSE", "README.md", "src/**/*.ts"] diff --git a/package.json b/package.json index 45d7edc..5a3fbb2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@typetype/mse", - "version": "0.1.56", + "version": "0.1.57", "description": "MSE playback engine for TypeType", "license": "MIT", "type": "module", From 9d9c7d0aaee736fadda79fd39db8684c107a99f8 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 3 Sep 2026 12:23:08 +0200 Subject: [PATCH 06/13] fix: publish npm provenance from hosted runner --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8823611..44b62a0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,7 +26,7 @@ permissions: jobs: publish-npm: if: github.event_name == 'push' || inputs.registry == 'npm' || inputs.registry == 'both' - runs-on: [self-hosted, Linux, X64, r730, typetype] + runs-on: ubuntu-24.04 timeout-minutes: 15 environment: npm permissions: From 2116bbb6d041a5baa7aac91fe232bd3ec397d26f Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 5 Sep 2026 14:22:26 +0200 Subject: [PATCH 07/13] fix: fill playback buffer before paused seek decoding --- src/type-type-mse-player.ts | 3 +- tests/type-type-mse-preroll-buffer.test.ts | 93 ++++++++++++++++++++++ 2 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 tests/type-type-mse-preroll-buffer.test.ts diff --git a/src/type-type-mse-player.ts b/src/type-type-mse-player.ts index 0161cde..de5df73 100644 --- a/src/type-type-mse-player.ts +++ b/src/type-type-mse-player.ts @@ -457,12 +457,13 @@ import type { } /** Applies a bounded decode preroll using the player-owned media override. */ - private runDecodePreroll( + private async runDecodePreroll( targetMs: number, resumePlayback: boolean, signal: AbortSignal, requireFrame = false, ): Promise { + await this.deps.loop.fillOnce(); return runDecodePreroll( this.video, targetMs, diff --git a/tests/type-type-mse-preroll-buffer.test.ts b/tests/type-type-mse-preroll-buffer.test.ts new file mode 100644 index 0000000..484683a --- /dev/null +++ b/tests/type-type-mse-preroll-buffer.test.ts @@ -0,0 +1,93 @@ +import { expect, test } from "bun:test"; +import { TransientMediaState } from "../src/transient-media-state"; +import { TypeTypeMsePlayer } from "../src/type-type-mse-player"; + +type PrerollHarness = { + video: HTMLVideoElement; + transientMediaState: TransientMediaState; + deps: { loop: { fillOnce: () => Promise } }; + runDecodePreroll: ( + targetMs: number, + resume: boolean, + signal: AbortSignal, + requireFrame?: boolean, + ) => Promise; +}; + +function harness(fillOnce: () => Promise) { + const player = Object.create(TypeTypeMsePlayer.prototype) as PrerollHarness; + const calls: string[] = []; + let time = 11.4; + const video = { + autoplay: false, + defaultPlaybackRate: 1, + playbackRate: 1, + muted: false, + paused: true, + readyState: 4, + seeking: false, + error: null, + buffered: { length: 1, start: () => 11.4, end: () => 23 }, + get currentTime() { + return time; + }, + set currentTime(value: number) { + calls.push("snap"); + time = value; + }, + play: async () => { + calls.push("decode"); + time = 13.45; + video.paused = false; + }, + pause: () => { + video.paused = true; + }, + }; + player.video = video as HTMLVideoElement; + player.transientMediaState = new TransientMediaState(player.video); + player.deps = { loop: { fillOnce } }; + return { player, calls, video }; +} + +test("fills the paused session before decoding and snapping its target", async () => { + let release = () => {}; + const fill = new Promise((resolve) => { + release = resolve; + }); + const { player, calls, video } = harness(() => fill); + const pending = player.runDecodePreroll(13_430, false, new AbortController().signal, true); + await Promise.resolve(); + expect(calls).toEqual([]); + expect(video.paused).toBe(true); + expect(video.playbackRate).toBe(1); + release(); + await pending; + expect(calls).toEqual(["decode", "snap"]); + expect(video.currentTime).toBe(13.43); + expect(video.paused).toBe(true); + expect(video.playbackRate).toBe(1); + expect(video.muted).toBe(false); +}); + +test("does not start decoding when filling the session fails", async () => { + const failure = new Error("segment unavailable"); + const { player, calls } = harness(() => Promise.reject(failure)); + await expect( + player.runDecodePreroll(13_430, false, new AbortController().signal, true), + ).rejects.toBe(failure); + expect(calls).toEqual([]); +}); + +test("does not decode a superseded seek after its fill completes", async () => { + const controller = new AbortController(); + const { player, calls, video } = harness(async () => controller.abort()); + await expect( + player.runDecodePreroll(13_430, false, controller.signal, true), + ).rejects.toMatchObject({ + name: "AbortError", + }); + expect(calls).toEqual([]); + expect(video.currentTime).toBe(11.4); + expect(video.paused).toBe(true); +}); From 2eba2025d3759caa89a3df8e6bcc72aa3c4c6189 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 5 Sep 2026 14:24:31 +0200 Subject: [PATCH 08/13] chore: bump MSE package to 0.1.58 --- jsr.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/jsr.json b/jsr.json index 4581aed..ebd6c55 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.57", + "version": "0.1.58", "exports": "./src/index.ts", "publish": { "include": ["LICENSE", "README.md", "src/**/*.ts"] diff --git a/package.json b/package.json index 5a3fbb2..f62a671 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@typetype/mse", - "version": "0.1.57", + "version": "0.1.58", "description": "MSE playback engine for TypeType", "license": "MIT", "type": "module", From 158db51e440662d4bc5756c57c65a775f92c802d Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 5 Sep 2026 15:59:19 +0200 Subject: [PATCH 09/13] fix: isolate WebKit media seeks --- src/type-type-mse-player.ts | 8 ++++++++ .../type-type-mse-quality-transition.test.ts | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/src/type-type-mse-player.ts b/src/type-type-mse-player.ts index de5df73..4a4f787 100644 --- a/src/type-type-mse-player.ts +++ b/src/type-type-mse-player.ts @@ -310,6 +310,7 @@ import type { const revision = this.operation.next(); const signal = this.operation.signal; const targetMs = Math.max(0, Math.round(positionMs)); + if (isWebKitMediaElement(this.video)) this.deps.media.requireFreshAttachment(); if (!quality) { this.deps.loop.stop(); await this.deps.loop.quiesce(); @@ -691,3 +692,10 @@ function isAbortError(error: unknown): boolean { function asError(error: unknown): Error { return error instanceof Error ? error : new Error("SABR playback recovery failed"); } + +function isWebKitMediaElement(video: HTMLVideoElement): boolean { + return ( + typeof (video as HTMLVideoElement & { webkitSupportsFullscreen?: unknown }) + .webkitSupportsFullscreen === "boolean" + ); +} diff --git a/tests/type-type-mse-quality-transition.test.ts b/tests/type-type-mse-quality-transition.test.ts index 80ad665..adaf10d 100644 --- a/tests/type-type-mse-quality-transition.test.ts +++ b/tests/type-type-mse-quality-transition.test.ts @@ -21,6 +21,7 @@ type QualityHarness = { playerState: { value: TypeTypeMseState; set: (state: TypeTypeMseState) => void }; deps: { loop: { stop: () => void; start: () => void; quiesce: () => Promise }; + media: { requireFreshAttachment: () => void }; playback: { seek: ( sessionId: string, @@ -77,6 +78,22 @@ test("stops the active playback loop before a timeline seek", async () => { expect(events).toEqual(["stop", "quiesce", "pause", "seek"]); }); +test("requires a fresh media attachment for WebKit timeline seeks", async () => { + let freshAttachments = 0; + const player = harness( + async () => response(), + () => undefined, + ); + player.video.webkitSupportsFullscreen = true; + player.deps.media.requireFreshAttachment = () => { + freshAttachments += 1; + }; + + await player.performSeek(120_000); + + expect(freshAttachments).toBe(1); +}); + test("aborts an obsolete quality preparation and applies only the latest selection", async () => { const requested: number[] = []; let aborted = 0; @@ -125,6 +142,7 @@ function harness( player.video = { currentTime: 120, paused: false, + webkitSupportsFullscreen: undefined, pause: () => { player.video.paused = true; pause(); @@ -136,6 +154,7 @@ function harness( }; player.deps = { loop: { stop, start: () => undefined, quiesce }, + media: { requireFreshAttachment: () => undefined }, playback: { seek }, }; player.emitter = { emit: () => undefined }; From 0b725e2cf9ec046ca24d78d15b4b86f2bc23f76c Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 5 Sep 2026 15:59:43 +0200 Subject: [PATCH 10/13] chore: bump MSE package to 0.1.59 --- jsr.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/jsr.json b/jsr.json index ebd6c55..ecc3eaa 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.58", + "version": "0.1.59", "exports": "./src/index.ts", "publish": { "include": ["LICENSE", "README.md", "src/**/*.ts"] diff --git a/package.json b/package.json index f62a671..cc20321 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@typetype/mse", - "version": "0.1.58", + "version": "0.1.59", "description": "MSE playback engine for TypeType", "license": "MIT", "type": "module", From ee4a7136299434487674557a85cf7dc708226f6b Mon Sep 17 00:00:00 2001 From: Priveetee Date: Mon, 7 Sep 2026 20:02:52 +0200 Subject: [PATCH 11/13] fix: recover evicted SABR segments --- src/media-buffer.ts | 27 +++++++++++++++ src/player-deps.ts | 14 +++++++- src/segment-scheduler.ts | 35 ++++++++++++++----- tests/media-buffer.test.ts | 12 +++++++ tests/segment-scheduler.test.ts | 60 +++++++++++++++++++++++++++++---- 5 files changed, 132 insertions(+), 16 deletions(-) diff --git a/src/media-buffer.ts b/src/media-buffer.ts index 8086341..44301a9 100644 --- a/src/media-buffer.ts +++ b/src/media-buffer.ts @@ -1,7 +1,14 @@ const RANGE_TOLERANCE_SECONDS = 0.05; +const TRACK_RANGE_TOLERANCE_MS = 50; const MIN_SEEK_BUFFER_MS = 250; const RANGE_ENTRY_OFFSET_SECONDS = 0.001; +export type BufferedTrackRange = { + kind: "audio" | "video"; + startMs: number; + endMs: number; +}; + export function bufferedEndAtCurrentTime( video: Pick, ): number { @@ -9,6 +16,26 @@ export function bufferedEndAtCurrentTime( return range ? Math.round(range.end * 1000) : 0; } +export function bufferedEndAtCurrentTrackRanges( + ranges: readonly BufferedTrackRange[], + currentMs: number, + hasVideo: boolean, +): number { + const kinds: BufferedTrackRange["kind"][] = hasVideo ? ["audio", "video"] : ["audio"]; + const ends = kinds.map((kind) => { + const range = ranges.find( + (candidate) => + candidate.kind === kind && + candidate.startMs <= currentMs + TRACK_RANGE_TOLERANCE_MS && + candidate.endMs > currentMs - TRACK_RANGE_TOLERANCE_MS, + ); + return range?.endMs ?? null; + }); + if (ends.some((endMs) => endMs === null)) return 0; + const completeEnds = ends.filter((endMs): endMs is number => endMs !== null); + return Math.min(...completeEnds); +} + export function seekWithinBufferedMedia( video: Pick, targetMs: number, diff --git a/src/player-deps.ts b/src/player-deps.ts index a6b78fd..11b3895 100644 --- a/src/player-deps.ts +++ b/src/player-deps.ts @@ -1,6 +1,7 @@ import { type BufferPolicy, resolveBufferPolicy } from "./buffer-policy"; import type { EventEmitter } from "./event-emitter"; import { HttpClient } from "./http-client"; +import { bufferedEndAtCurrentTrackRanges } from "./media-buffer"; import { MediaElementObserver } from "./media-element-observer"; import { MediaSourceController } from "./media-source-controller"; import { PlaybackClient } from "./playback-client"; @@ -53,6 +54,17 @@ export function createPlayerDeps(args: Args): PlayerDeps { }); const media = new MediaSourceController(args.video); const scheduler = new SegmentScheduler(http, media, args.emitter, policy.segmentPollLimit); + const currentBufferedEndMs = () => { + const elementBufferedEndMs = bufferedEndMs(args.video); + if (elementBufferedEndMs > 0) return elementBufferedEndMs; + const session = args.session(); + if (!session) return elementBufferedEndMs; + return bufferedEndAtCurrentTrackRanges( + media.bufferedRanges(), + Math.max(0, Math.round(args.video.currentTime * 1000)), + session.manifest.video !== null, + ); + }; const loop = new PlaybackLoop({ video: args.video, playback, @@ -63,7 +75,7 @@ export function createPlayerDeps(args: Args): PlayerDeps { playbackRate, session: args.session, signal: args.signal, - bufferedEndMs: () => bufferedEndMs(args.video), + bufferedEndMs: currentBufferedEndMs, error: args.loopError, }); const destroy = () => { diff --git a/src/segment-scheduler.ts b/src/segment-scheduler.ts index c359c20..d2a880b 100644 --- a/src/segment-scheduler.ts +++ b/src/segment-scheduler.ts @@ -1,18 +1,19 @@ import type { EventEmitter } from "./event-emitter"; import type { HttpClient } from "./http-client"; import type { ManifestSegment, PlaybackManifest } from "./manifest"; -import type { MediaSourceController } from "./media-source-controller"; +import type { MediaBufferedRange, MediaSourceController } from "./media-source-controller"; import { fetchSegmentBytes } from "./segment-fetcher"; import type { TrackKind } from "./types"; +const BUFFERED_RANGE_TOLERANCE_MS = 50; + export class SegmentScheduler { private readonly appended = new Set(); - private readonly appendedEndMs = new Map(); private revision = 0; constructor( private readonly http: HttpClient, - private readonly media: MediaSourceController, + private readonly media: Pick, private readonly emitter: EventEmitter, private readonly pollLimit: number, ) {} @@ -20,7 +21,6 @@ export class SegmentScheduler { reset(): void { this.revision += 1; this.appended.clear(); - this.appendedEndMs.clear(); } async appendInit(manifest: PlaybackManifest, signal?: AbortSignal): Promise { @@ -65,8 +65,7 @@ export class SegmentScheduler { .sort((left, right) => left.startMs - right.startMs); for (const segment of candidates) { const segmentEndMs = segment.startMs + segment.durationMs; - const appendedEndMs = this.appendedEndMs.get(kind); - if (appendedEndMs !== undefined && segmentEndMs <= appendedEndMs) continue; + if (this.isBuffered(kind, segment.startMs, segmentEndMs)) continue; await this.appendUrl( kind, segment.url, @@ -75,7 +74,6 @@ export class SegmentScheduler { revision, signal, ); - this.appendedEndMs.set(kind, Math.max(appendedEndMs ?? 0, segmentEndMs)); } } @@ -89,7 +87,10 @@ export class SegmentScheduler { ): Promise { this.ensureActive(revision, signal); const key = `${kind}:${url}`; - if (this.appended.has(key)) return; + if (this.appended.has(key)) { + if (durationMs <= 0 || this.isBuffered(kind, startMs, startMs + durationMs)) return; + this.appended.delete(key); + } const bytes = await fetchSegmentBytes(this.http, url, this.pollLimit, signal); this.ensureActive(revision, signal); await this.media.append(kind, bytes); @@ -98,9 +99,27 @@ export class SegmentScheduler { this.emitter.emit({ type: "segment", kind, url, startMs, durationMs }); } + private isBuffered(kind: TrackKind, startMs: number, endMs: number): boolean { + if (endMs <= startMs) return false; + return this.media.bufferedRanges().some((range) => coversSegment(range, kind, startMs, endMs)); + } + private ensureActive(revision: number, signal?: AbortSignal): void { if (revision !== this.revision || signal?.aborted) { throw new DOMException("Operation aborted", "AbortError"); } } } + +function coversSegment( + range: MediaBufferedRange, + kind: TrackKind, + startMs: number, + endMs: number, +): boolean { + return ( + range.kind === kind && + range.startMs <= startMs + BUFFERED_RANGE_TOLERANCE_MS && + range.endMs >= endMs - BUFFERED_RANGE_TOLERANCE_MS + ); +} diff --git a/tests/media-buffer.test.ts b/tests/media-buffer.test.ts index 4628c57..873d227 100644 --- a/tests/media-buffer.test.ts +++ b/tests/media-buffer.test.ts @@ -2,6 +2,7 @@ import { expect, test } from "bun:test"; import { alignPlayheadToBufferedRange, bufferedEndAtCurrentTime, + bufferedEndAtCurrentTrackRanges, canUseBufferedMediaSeek, seekWithinBufferedMedia, } from "../src/media-buffer"; @@ -34,6 +35,17 @@ test("does not report media beyond a gap as buffered at the playhead", () => { ).toBe(0); }); +test("uses the shorter source-buffer track when the media element range is empty", () => { + const ranges = [ + { kind: "audio" as const, startMs: 0, endMs: 20_000 }, + { kind: "video" as const, startMs: 500, endMs: 18_000 }, + ]; + + expect(bufferedEndAtCurrentTrackRanges(ranges, 1_000, true)).toBe(18_000); + expect(bufferedEndAtCurrentTrackRanges(ranges, 19_000, true)).toBe(0); + expect(bufferedEndAtCurrentTrackRanges(ranges, 1_000, false)).toBe(20_000); +}); + test("seeks locally only when the target has enough buffered media", () => { const video = media( [ diff --git a/tests/segment-scheduler.test.ts b/tests/segment-scheduler.test.ts index 864da2d..4a9cff4 100644 --- a/tests/segment-scheduler.test.ts +++ b/tests/segment-scheduler.test.ts @@ -2,7 +2,7 @@ import { expect, test } from "bun:test"; import type { EventEmitter } from "../src/event-emitter"; import type { HttpClient } from "../src/http-client"; import type { PlaybackManifest } from "../src/manifest"; -import type { MediaSourceController } from "../src/media-source-controller"; +import type { MediaBufferedRange, MediaSourceController } from "../src/media-source-controller"; import { SegmentScheduler } from "../src/segment-scheduler"; const track = { @@ -23,6 +23,7 @@ function manifest(segments: PlaybackManifest["audio"]["segments"]): PlaybackMani test("never appends a late segment behind the buffered track edge", async () => { const appended: string[] = []; + const buffered: MediaBufferedRange[] = []; let requestedUrl = ""; const http = { response: async (url: string) => { @@ -30,15 +31,28 @@ test("never appends a late segment behind the buffered track edge", async () => return new Response(new Uint8Array([1])); }, } as HttpClient; - const media = { - append: async () => appended.push(requestedUrl), - } as MediaSourceController; - const scheduler = new SegmentScheduler(http, media, { emit: () => undefined } as EventEmitter, 1); const segment40 = { url: "/40", startMs: 389_398, durationMs: 9_985 }; const segment41 = { url: "/41", startMs: 399_383, durationMs: 9_985 }; const segment42 = { url: "/42", startMs: 409_367, durationMs: 9_985 }; const segment43 = { url: "/43", startMs: 419_352, durationMs: 9_985 }; - + const segmentsByUrl = new Map( + [segment40, segment41, segment42, segment43].map((segment) => [segment.url, segment]), + ); + const media = { + append: async (kind: "audio" | "video") => { + appended.push(requestedUrl); + const segment = segmentsByUrl.get(requestedUrl); + if (segment) { + buffered.push({ + kind, + startMs: segment.startMs, + endMs: segment.startMs + segment.durationMs, + }); + } + }, + bufferedRanges: () => buffered, + } as MediaSourceController; + const scheduler = new SegmentScheduler(http, media, { emit: () => undefined } as EventEmitter, 1); await scheduler.fill(manifest([segment43, segment41, segment42]), 401_200, 430_000); await scheduler.fill(manifest([segment40, segment41, segment42, segment43]), 399_383, 430_000); @@ -56,8 +70,14 @@ test("appends a self-initializing live segment only once", async () => { }, } as HttpClient; const appended: string[] = []; + const buffered: MediaBufferedRange[] = []; const media = { - append: async () => appended.push(requestedUrl), + append: async (kind: "audio" | "video") => { + appended.push(requestedUrl); + const startMs = requestedUrl === "/40" ? 390_000 : 400_000; + buffered.push({ kind, startMs, endMs: startMs + 10_000 }); + }, + bufferedRanges: () => buffered, } as MediaSourceController; const scheduler = new SegmentScheduler(http, media, { emit: () => undefined } as EventEmitter, 1); const liveManifest = manifest([ @@ -84,6 +104,7 @@ test("rejects fetched bytes from a superseded scheduler revision", async () => { append: async () => { appends += 1; }, + bufferedRanges: () => [], } as MediaSourceController; const scheduler = new SegmentScheduler(http, media, { emit: () => undefined } as EventEmitter, 1); const fill = scheduler.fill( @@ -99,3 +120,28 @@ test("rejects fetched bytes from a superseded scheduler revision", async () => { await expect(fill).rejects.toHaveProperty("name", "AbortError"); expect(appends).toBe(0); }); + +test("re-fetches a segment after its SourceBuffer range was evicted", async () => { + const requested: string[] = []; + let buffered: MediaBufferedRange[] = []; + const http = { + response: async (url: string) => { + requested.push(url); + return new Response(new Uint8Array([1])); + }, + } as HttpClient; + const media = { + append: async () => { + buffered = [{ kind: "audio", startMs: 0, endMs: 10_000 }]; + }, + bufferedRanges: () => buffered, + } as MediaSourceController; + const scheduler = new SegmentScheduler(http, media, { emit: () => undefined } as EventEmitter, 1); + const segment = { url: "/segment", startMs: 0, durationMs: 10_000 }; + + await scheduler.fill(manifest([segment]), 0, 10_000); + buffered = []; + await scheduler.fill(manifest([segment]), 0, 10_000); + + expect(requested).toEqual(["/segment", "/segment"]); +}); From 26a57fba91500292cde3ffa3e7434f8ac30c4446 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Mon, 7 Sep 2026 20:04:20 +0200 Subject: [PATCH 12/13] chore: bump MSE package to 0.1.60 --- jsr.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/jsr.json b/jsr.json index ecc3eaa..bd72115 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.59", + "version": "0.1.60", "exports": "./src/index.ts", "publish": { "include": ["LICENSE", "README.md", "src/**/*.ts"] diff --git a/package.json b/package.json index cc20321..1e00167 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@typetype/mse", - "version": "0.1.59", + "version": "0.1.60", "description": "MSE playback engine for TypeType", "license": "MIT", "type": "module", From d1be046b050aaf9253782da1f8ea6d6f6b5b104b Mon Sep 17 00:00:00 2001 From: Priveetee Date: Wed, 9 Sep 2026 10:08:38 +0200 Subject: [PATCH 13/13] chore: update player lint dependency --- biome.json | 2 +- bun.lock | 20 ++++++++++---------- package.json | 2 +- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/biome.json b/biome.json index 5540498..78b06b9 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.5.10/schema.json", + "$schema": "https://biomejs.dev/schemas/2.5.12/schema.json", "formatter": { "enabled": true, "indentStyle": "space", diff --git a/bun.lock b/bun.lock index 4587969..f1b5b4e 100644 --- a/bun.lock +++ b/bun.lock @@ -5,29 +5,29 @@ "": { "name": "@typetype/mse", "devDependencies": { - "@biomejs/biome": "^2.5.10", + "@biomejs/biome": "^2.5.12", "typescript": "~7.0.2", }, }, }, "packages": { - "@biomejs/biome": ["@biomejs/biome@2.5.10", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.10", "@biomejs/cli-darwin-x64": "2.5.10", "@biomejs/cli-linux-arm64": "2.5.10", "@biomejs/cli-linux-arm64-musl": "2.5.10", "@biomejs/cli-linux-x64": "2.5.10", "@biomejs/cli-linux-x64-musl": "2.5.10", "@biomejs/cli-win32-arm64": "2.5.10", "@biomejs/cli-win32-x64": "2.5.10" }, "bin": { "biome": "bin/biome" } }, "sha512-WRKXARA3kTuiV5sxqTpobJ/I0MVd4vk3pOL6wnp5az4LntFIhWTj1RWZq3DI9PCEN3lXcqy7p5aqUHzvq8AXyQ=="], + "@biomejs/biome": ["@biomejs/biome@2.5.12", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.12", "@biomejs/cli-darwin-x64": "2.5.12", "@biomejs/cli-linux-arm64": "2.5.12", "@biomejs/cli-linux-arm64-musl": "2.5.12", "@biomejs/cli-linux-x64": "2.5.12", "@biomejs/cli-linux-x64-musl": "2.5.12", "@biomejs/cli-win32-arm64": "2.5.12", "@biomejs/cli-win32-x64": "2.5.12" }, "bin": { "biome": "bin/biome" } }, "sha512-Lw4VHZRebrReBBnlHa12JQjnIBm3JJAA55PDB9LbBVBF0q4RYphm6KfmIjqtPhf61MxZ5Q9KoK8R8x+7per5Aw=="], - "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ItCrxKK6SXVT6flYs0qIuBd4AA3TTTl4d66Re6YI2FuGZnN85NmuYNzkiTJUyYw8qBLv69L5zTUB6uyWd++h3Q=="], + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lCRY1rwgNeWNgTr4DI/u6ZwXTRwRLHAvbaio1YLLGS+4r1nhvB2ssyPqIpfUSmRveNfv0fn/N58C7CAdK2XVrg=="], - "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-yLsPU9pAmtChXDu8vhKAzErqe+LeeYuwuUB2FZMkRitsmdodxsYRa9KHrFispsUHzzOu+9HB3nP/TQxyia+Sjw=="], + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-vhPgwnh+6tN3ArdAXuET99xaNbFt7CG82Bqn+omHVLC5xdVx45JsYjGPmUIGNzjDek5XdNCP1HKksK7fn8+3bQ=="], - "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-VG8uQW/86a1roLaIFvtIbEigxIdzdJ190oGyg1tV7VYeQtOS+x10sflk7WbuXgw91EtZX5DlIIIej1YqkNLlcg=="], + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-2gp8aVwXYKdAtmBfRFCUuyDMcfN1ahHqUkGfLYrZlNRFmryMATLVvJgWKvyA8wu4Rwn5OSxM1UcUmOuOFNGeBQ=="], - "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-t1QAKZwQJRB4dvgJSgFiQ4BNfNPChg69BNonz854qLVxnjT3UvDzQg9mbkTJRu35ZqU0Rw10A73J8Urgbg2RPw=="], + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-couHYjFLL5uuI8ne6zhT7KwEsXo5YP7ry/2xmEqah7qanu0YmfDi3mwJg47YXSuv/NpZj22CZzcRH/5c4gjPSQ=="], - "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.10", "", { "os": "linux", "cpu": "x64" }, "sha512-4O6T0eq2heoHZN0a9UX+rWQoxXEBaKf+lRi2hbsGlHneUz9BWXM76nEWMK7Eeq8gzMxR1khQB6BFpAASpeXqGg=="], + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.12", "", { "os": "linux", "cpu": "x64" }, "sha512-SnvOs3TSTiuia4SQOUNe1aWC9RT4+YkjcKnOhL/nsKOV0k5ycgBkDzF0lUxKn1V7Q8CLTRq6iV23ZAivHomRoA=="], - "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.10", "", { "os": "linux", "cpu": "x64" }, "sha512-pgDDqp9JybHm2I0KRgzN6i4+lt8xu4iqxUwLzglUMmOmyRTU1AYBGKzh9sNMOtIjah7xoWvKHlLVetvyifzoiQ=="], + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.12", "", { "os": "linux", "cpu": "x64" }, "sha512-8A0oDW58/w9f/PQNYuq0sGUZtGtGrkNF4Z6n0PUoXpLCshi85vtKTv1XSznQawhdE4MXJ8ufpzHXyLFe87M/+w=="], - "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-pxAbxduPO4xq/Cvgaa2lOrs9BB0hEXmmDqfMNP4ZOffGOkUrD1/QGw9UAMpFQpX2P8MqTIIRuQKcmetum4Oa6A=="], + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-b9vtoZFsuZt1pdjNwJvXl0f+BpayRzV008uS2+JpmwIKdSE2qdu4A/l04FESwLoou5g2E/Qlec0xwJydZplH+A=="], - "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.10", "", { "os": "win32", "cpu": "x64" }, "sha512-M+2dgBsl3lXRiTfgPVc2p3anS4Tocojke4rzFLScZ2Y/wmF+36dRb1iHCLiyGqOzQGyTplZH1HnEYviiAqi3nA=="], + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.12", "", { "os": "win32", "cpu": "x64" }, "sha512-B1R/l+CwEpKFSuqiwePzPNRk1EiJN8kc0UhdafNz6MZN9v5OFP9HYP1irptvWzHrwVI4blVNGMbxc5zt70m3IA=="], "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="], diff --git a/package.json b/package.json index 1e00167..3e63fcb 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "publish:jsr": "deno publish" }, "devDependencies": { - "@biomejs/biome": "^2.5.10", + "@biomejs/biome": "^2.5.12", "typescript": "~7.0.2" }, "publishConfig": {