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/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..3e63fcb 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", @@ -40,7 +40,7 @@ "publish:jsr": "deno publish" }, "devDependencies": { - "@biomejs/biome": "^2.5.10", + "@biomejs/biome": "^2.5.12", "typescript": "~7.0.2" }, "publishConfig": { 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"]); +});