From 08ac15411fd54fbf34181262df48e9488974d7b0 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 17 Jul 2026 08:30:55 +0200 Subject: [PATCH 01/11] feat: add live playback window contract --- src/index.ts | 7 +++++- src/manifest.ts | 14 +++++++++++ src/playback-client.ts | 8 ++++++ src/playback-window.ts | 45 +++++++++++++++++++++++++++++++-- src/types.ts | 1 + tests/playback-client.test.ts | 47 +++++++++++++++++++++++++++++++++++ tests/playback-window.test.ts | 35 ++++++++++++++++++++++++++ 7 files changed, 154 insertions(+), 3 deletions(-) create mode 100644 tests/playback-client.test.ts diff --git a/src/index.ts b/src/index.ts index 09853f5..10bdcfa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,10 @@ /** Browser Media Source Extensions engine for TypeType SABR playback. */ -export type { ManifestSegment, ManifestTrack, PlaybackManifest } from "./manifest"; +export type { + LivePlaybackWindow, + ManifestSegment, + ManifestTrack, + PlaybackManifest, +} from "./manifest"; export type { PlaybackBufferedRange, PlaybackWindow, diff --git a/src/manifest.ts b/src/manifest.ts index 24a6182..3bb15a2 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -15,10 +15,24 @@ export type ManifestTrack = { segments: ManifestSegment[]; }; +/** Dynamic timing information for an active live stream or a completed live DVR. */ +export type LivePlaybackWindow = { + active: boolean; + postLiveDvr: boolean; + headSequence: number; + headTimeMs: number; + seekableStartMs: number; + seekableEndMs: number; + atLiveEdge: boolean; + targetLatencyMs: number; +}; + /** Browser-ready tracks for the current playback window. */ export type PlaybackManifest = { durationMs: number; endOfStream: boolean; + startTimeMs?: number; + live?: LivePlaybackWindow | null; audio: ManifestTrack; video: ManifestTrack | null; }; diff --git a/src/playback-client.ts b/src/playback-client.ts index 313cc89..4db021a 100644 --- a/src/playback-client.ts +++ b/src/playback-client.ts @@ -1,7 +1,9 @@ import type { HttpClient } from "./http-client"; +import type { LivePlaybackWindow } from "./manifest"; import { type PlaybackWindow, type PlaybackWindowRequest, + parseLivePlaybackWindow, parsePlaybackWindow, } from "./playback-window"; @@ -11,6 +13,8 @@ export type PlaybackResponse = { generation: number | null; ready: boolean; retryAfterMs: number | null; + startTimeMs?: number | null; + live?: LivePlaybackWindow | null; }; export type CreatePlaybackRequest = { @@ -20,6 +24,7 @@ export type CreatePlaybackRequest = { audioTrackId: string | null; startTimeMs: number; audioOnly: boolean; + isLive?: boolean; }; export type SeekPlaybackOptions = { @@ -54,6 +59,8 @@ function parsePlaybackResponse(value: unknown): PlaybackResponse { generation: numberField(value, "generation"), ready: field(value, "ready") === true, retryAfterMs: numberField(value, "retryAfterMs"), + startTimeMs: numberField(value, "startTimeMs"), + live: parseLivePlaybackWindow(field(value, "live")), }; } @@ -68,6 +75,7 @@ export class PlaybackClient { }); if (request.audioTrackId) params.set("audioTrackId", request.audioTrackId); if (request.audioOnly) params.set("audioOnly", "true"); + if (request.isLive) params.set("isLive", "true"); const videoId = encodeURIComponent(request.videoId); const init = signal ? { method: "POST", signal } : { method: "POST" }; const response = await this.http.json(`/sabr/playback/${videoId}?${params}`, init); diff --git a/src/playback-window.ts b/src/playback-window.ts index 91c57b7..6f54cb8 100644 --- a/src/playback-window.ts +++ b/src/playback-window.ts @@ -1,4 +1,9 @@ -import type { ManifestSegment, ManifestTrack, PlaybackManifest } from "./manifest"; +import type { + LivePlaybackWindow, + ManifestSegment, + ManifestTrack, + PlaybackManifest, +} from "./manifest"; import type { TrackKind } from "./types"; /** Buffered media interval reported to the backend for a specific format. */ @@ -39,6 +44,8 @@ export type PlaybackWindow = { status: string | null; blockedBy: string | null; bufferedEdgeMs: number | null; + startTimeMs?: number | null; + live?: LivePlaybackWindow | null; manifest: PlaybackManifest | null; }; @@ -65,6 +72,34 @@ function booleanField(value: object, key: string): boolean { return field(value, key) === true; } +export function parseLivePlaybackWindow(value: unknown): LivePlaybackWindow | null { + if (!value || typeof value !== "object") return null; + const headSequence = numberField(value, "headSequence"); + const headTimeMs = numberField(value, "headTimeMs"); + const seekableStartMs = numberField(value, "seekableStartMs"); + const seekableEndMs = numberField(value, "seekableEndMs"); + const targetLatencyMs = numberField(value, "targetLatencyMs"); + if ( + headSequence === null || + headTimeMs === null || + seekableStartMs === null || + seekableEndMs === null || + targetLatencyMs === null + ) { + return null; + } + return { + active: booleanField(value, "active"), + postLiveDvr: booleanField(value, "postLiveDvr"), + headSequence, + headTimeMs, + seekableStartMs, + seekableEndMs, + atLiveEdge: booleanField(value, "atLiveEdge"), + targetLatencyMs, + }; +} + function arrayField(value: object, key: string): unknown[] { const result = field(value, key); return Array.isArray(result) ? result : []; @@ -109,13 +144,17 @@ function parseTrack(kind: TrackKind, value: object, baseUrl: string): ManifestTr function parseManifest(value: object, baseUrl: string): PlaybackManifest | null { const durationMs = numberField(value, "durationMs") ?? 0; + const startTimeMs = numberField(value, "startTimeMs") ?? 0; const endOfStream = booleanField(value, "endOfStream"); + const live = parseLivePlaybackWindow(field(value, "live")); const audioValue = objectField(value, "audio"); const videoValue = objectField(value, "video"); if (!audioValue) return null; const audio = parseTrack("audio", audioValue, baseUrl); const video = videoValue ? parseTrack("video", videoValue, baseUrl) : null; - return audio && (!videoValue || video) ? { durationMs, endOfStream, audio, video } : null; + return audio && (!videoValue || video) + ? { durationMs, endOfStream, startTimeMs, live, audio, video } + : null; } export function parsePlaybackWindow(value: unknown, baseUrl: string): PlaybackWindow { @@ -134,6 +173,8 @@ export function parsePlaybackWindow(value: unknown, baseUrl: string): PlaybackWi status: stringField(value, "status"), blockedBy: stringField(value, "blockedBy"), bufferedEdgeMs: numberField(value, "bufferedEdgeMs"), + startTimeMs: numberField(value, "startTimeMs"), + live: parseLivePlaybackWindow(field(value, "live")), manifest: parseManifest(manifestValue, baseUrl), }; } diff --git a/src/types.ts b/src/types.ts index c73fc29..2a08ea8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -21,6 +21,7 @@ export type TypeTypeMseConfig = { audioItag: number; audioTrackId: string | null; audioOnly?: boolean; + isLive?: boolean; startTimeMs?: number; headers?: HeadersInit; bufferGoalMs?: number; diff --git a/tests/playback-client.test.ts b/tests/playback-client.test.ts new file mode 100644 index 0000000..32b4e5b --- /dev/null +++ b/tests/playback-client.test.ts @@ -0,0 +1,47 @@ +import { expect, test } from "bun:test"; +import type { HttpClient } from "../src/http-client"; +import { PlaybackClient } from "../src/playback-client"; + +test("creates live playback sessions and parses live timing", async () => { + const requests: Array<{ path: string; init?: RequestInit }> = []; + const http = { + json: async (path: string, init?: RequestInit) => { + requests.push({ path, init }); + return { + sessionId: "live-session", + videoId: "X4VbdwhkE10", + generation: 0, + ready: true, + retryAfterMs: null, + startTimeMs: 3_590_000, + live: { + active: true, + postLiveDvr: false, + headSequence: 720, + headTimeMs: 3_600_000, + seekableStartMs: 0, + seekableEndMs: 3_600_000, + atLiveEdge: true, + targetLatencyMs: 10_000, + }, + }; + }, + absolute: (path: string) => `https://beta.typetype.video/api${path}`, + } as unknown as HttpClient; + + const response = await new PlaybackClient(http).create({ + videoId: "X4VbdwhkE10", + videoItag: 137, + audioItag: 140, + audioTrackId: null, + startTimeMs: 0, + audioOnly: false, + isLive: true, + }); + + expect(requests[0]?.path).toContain("/sabr/playback/X4VbdwhkE10?"); + expect(requests[0]?.path).toContain("isLive=true"); + expect(requests[0]?.init?.method).toBe("POST"); + expect(response.startTimeMs).toBe(3_590_000); + expect(response.live).toMatchObject({ active: true, headSequence: 720 }); +}); diff --git a/tests/playback-window.test.ts b/tests/playback-window.test.ts index a2b48fd..e845484 100644 --- a/tests/playback-window.test.ts +++ b/tests/playback-window.test.ts @@ -42,6 +42,41 @@ test("parses native playback windows", () => { ); }); +test("parses active live timing and the server-resolved start position", () => { + const window = parsePlaybackWindow( + { + sessionId: "live-session", + generation: 4, + ready: true, + startTimeMs: 3_590_000, + durationMs: 3_600_000, + endOfStream: false, + live: { + active: true, + postLiveDvr: false, + headSequence: 720, + headTimeMs: 3_600_000, + seekableStartMs: 0, + seekableEndMs: 3_600_000, + atLiveEdge: true, + targetLatencyMs: 10_000, + }, + audio: { + mime: 'audio/mp4; codecs="mp4a.40.2"', + initUrl: "/api/sabr/playback/live-session/140/init?generation=4", + segments: [], + }, + video: null, + }, + "https://beta.typetype.video/api/sabr/playback/live-session/segments", + ); + + expect(window.startTimeMs).toBe(3_590_000); + expect(window.live).toMatchObject({ active: true, headSequence: 720, atLiveEdge: true }); + expect(window.manifest?.startTimeMs).toBe(3_590_000); + expect(window.manifest?.live?.seekableEndMs).toBe(3_600_000); +}); + test("parses playback window recovery hints", () => { const window = parsePlaybackWindow( { From 5d4eaf7f9d63d40707bbf3c5909d08251d98ed4c Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 17 Jul 2026 08:31:07 +0200 Subject: [PATCH 02/11] feat: apply dynamic timing to live media sessions --- src/media-source-controller.ts | 25 +++++++++- src/player-recovery.ts | 2 + src/player-session-loader.ts | 10 ++-- src/session-loader.ts | 32 ++++++++++-- tests/media-source-controller.test.ts | 39 +++++++++++++++ tests/player-session-loader.test.ts | 72 +++++++++++++++++++++++++++ tests/segment-scheduler.test.ts | 28 +++++++++++ 7 files changed, 198 insertions(+), 10 deletions(-) diff --git a/src/media-source-controller.ts b/src/media-source-controller.ts index 02c056e..abdec2a 100644 --- a/src/media-source-controller.ts +++ b/src/media-source-controller.ts @@ -81,11 +81,11 @@ export class MediaSourceController { manifest: PlaybackManifest, ): Promise { await Promise.all([this.audioQueue?.reset(), this.videoQueue?.reset()]); - mediaSource.duration = manifest.durationMs > 0 ? manifest.durationMs / 1000 : Number.NaN; + this.applyTiming(mediaSource, manifest); } private createSourceBuffers(mediaSource: MediaSource, manifest: PlaybackManifest): void { - mediaSource.duration = manifest.durationMs > 0 ? manifest.durationMs / 1000 : Number.NaN; + this.applyTiming(mediaSource, manifest); this.audioQueue = new AppendQueue(mediaSource.addSourceBuffer(manifest.audio.mime)); this.videoQueue = manifest.video ? new AppendQueue(mediaSource.addSourceBuffer(manifest.video.mime)) @@ -94,6 +94,12 @@ export class MediaSourceController { this.videoMime = manifest.video?.mime ?? null; } + updateTiming(manifest: PlaybackManifest): void { + const mediaSource = this.mediaSource; + if (mediaSource?.readyState !== "open") return; + this.applyTiming(mediaSource, manifest); + } + append(kind: TrackKind, data: ArrayBuffer): Promise { const queue = kind === "audio" ? this.audioQueue : this.videoQueue; if (!queue) return Promise.reject(new Error(`${kind} SourceBuffer is not ready`)); @@ -173,4 +179,19 @@ export class MediaSourceController { this.audioMime = null; this.videoMime = null; } + + private applyTiming(mediaSource: MediaSource, manifest: PlaybackManifest): void { + const live = manifest.live; + if (live?.active) { + mediaSource.duration = Number.POSITIVE_INFINITY; + if (typeof mediaSource.setLiveSeekableRange === "function") { + mediaSource.setLiveSeekableRange(live.seekableStartMs / 1000, live.seekableEndMs / 1000); + } + return; + } + if (typeof mediaSource.clearLiveSeekableRange === "function") { + mediaSource.clearLiveSeekableRange(); + } + mediaSource.duration = manifest.durationMs > 0 ? manifest.durationMs / 1000 : Number.NaN; + } } diff --git a/src/player-recovery.ts b/src/player-recovery.ts index 22b0cfb..e10eb0e 100644 --- a/src/player-recovery.ts +++ b/src/player-recovery.ts @@ -111,6 +111,7 @@ type RecoverArgs = { current: LoadedSession; error: PlaybackWindowRecoveryError; videoId: string; + isLive?: boolean; startTimeMs: number; signal: AbortSignal; create: (request: CreatePlaybackRequest, signal: AbortSignal) => Promise; @@ -137,6 +138,7 @@ export async function recoverPlaybackSession(args: RecoverArgs): Promise { + const requestedStartTimeMs = response.startTimeMs ?? args.startTimeMs; const session = await loadPlaybackSession({ playback: args.deps.playback, media: args.deps.media, @@ -72,15 +73,17 @@ async function loadSelectedSession( audioItag: selection.audioItag, audioTrackId: selection.audioTrackId, audioOnly: args.config.audioOnly === true, - startTimeMs: args.startTimeMs, + startTimeMs: requestedStartTimeMs, policy: args.deps.policy, signal: args.signal, }); - const fillStartMs = decodeStartMs(session.manifest, args.startTimeMs); + const startTimeMs = + session.response.startTimeMs ?? session.manifest.startTimeMs ?? requestedStartTimeMs; + const fillStartMs = decodeStartMs(session.manifest, startTimeMs); await args.deps.scheduler.fill( session.manifest, fillStartMs, - args.startTimeMs + args.deps.policy.bufferGoalMs, + startTimeMs + args.deps.policy.bufferGoalMs, args.signal, ); if (args.signal.aborted) throw new DOMException("Operation aborted", "AbortError"); @@ -109,6 +112,7 @@ async function recoverInitialSession( audioTrackId: selection.audioTrackId, startTimeMs: args.startTimeMs, audioOnly: args.config.audioOnly === true, + ...(args.config.isLive ? { isLive: true } : {}), }, args.signal, ); diff --git a/src/session-loader.ts b/src/session-loader.ts index 801b69d..456f351 100644 --- a/src/session-loader.ts +++ b/src/session-loader.ts @@ -62,10 +62,24 @@ class PlaybackWindowTimeoutError extends Error { } export async function loadPlaybackSession(args: LoadSessionArgs): Promise { - const request = { ...playbackWindowRequest(args, args.startTimeMs), bufferedRanges: [] }; + const startTimeMs = args.response.startTimeMs ?? args.startTimeMs; + const request = { ...playbackWindowRequest(args, startTimeMs), bufferedRanges: [] }; const window = await waitForWindow(args, args.response.sessionId, request); if (!window.manifest) throw new Error("Playback window is not ready"); - return attachSession(args, { ...args.response, generation: window.generation }, window.manifest); + const resolvedStartTimeMs = + window.startTimeMs ?? + window.manifest.startTimeMs ?? + args.response.startTimeMs ?? + args.startTimeMs; + const live = window.live ?? window.manifest.live ?? args.response.live ?? null; + const response = { + ...args.response, + generation: window.generation, + startTimeMs: resolvedStartTimeMs, + live, + }; + const manifest = { ...window.manifest, startTimeMs: resolvedStartTimeMs, live }; + return attachSession(args, response, manifest); } async function attachSession( @@ -95,7 +109,8 @@ function ensureNotAborted(signal: AbortSignal): void { export async function refreshPlaybackWindow( playback: Pick, - media: Pick, + media: Pick & + Partial>, session: LoadedSession, policy: BufferPolicy, playerTimeMs: number, @@ -109,8 +124,15 @@ export async function refreshPlaybackWindow( ); if (!window?.manifest) return; ensureNotAborted(signal); - session.response = { ...session.response, generation: window.generation }; - session.manifest = window.manifest; + const startTimeMs = + window.startTimeMs ?? + window.manifest.startTimeMs ?? + session.response.startTimeMs ?? + playerTimeMs; + const live = window.live ?? window.manifest.live ?? session.response.live ?? null; + session.response = { ...session.response, generation: window.generation, startTimeMs, live }; + session.manifest = { ...window.manifest, startTimeMs, live }; + media.updateTiming?.(session.manifest); } function playbackWindowRequest( diff --git a/tests/media-source-controller.test.ts b/tests/media-source-controller.test.ts index 59c8fa7..d9ac9d0 100644 --- a/tests/media-source-controller.test.ts +++ b/tests/media-source-controller.test.ts @@ -123,10 +123,41 @@ test("attach releases each old layout before repeated track changes", async () = expect(video.src).toBe("blob:replacement-6"); }); +test("updates the MSE live seekable range as the live head advances", () => { + const mediaSource = new FakeMediaSource(); + const { video } = videoElement("blob:live"); + const controller = new MediaSourceController(video); + const state = controller as unknown as ControllerState; + state.objectUrl = "blob:live"; + state.mediaSource = mediaSource as unknown as MediaSource; + const liveManifest: PlaybackManifest = { + ...manifest(true), + live: { + active: true, + postLiveDvr: false, + headSequence: 72, + headTimeMs: 120_000, + seekableStartMs: 30_000, + seekableEndMs: 120_000, + atLiveEdge: true, + targetLatencyMs: 10_000, + }, + }; + + controller.updateTiming(liveManifest); + expect(mediaSource.duration).toBe(Number.POSITIVE_INFINITY); + expect(mediaSource.liveRange).toEqual([30, 120]); + + controller.updateTiming({ ...liveManifest, live: { ...liveManifest.live, active: false } }); + expect(mediaSource.liveRange).toBeNull(); + expect(mediaSource.duration).toBe(120); +}); + class FakeMediaSource { readonly sourceBuffers: SourceBuffer[] = []; readonly removed: SourceBuffer[] = []; duration = Number.NaN; + liveRange: [number, number] | null = null; readyState: ReadyState; private readonly listeners = new Map void>(); @@ -155,6 +186,14 @@ class FakeMediaSource { this.sourceBuffers.splice(this.sourceBuffers.indexOf(buffer), 1); } + setLiveSeekableRange(start: number, end: number): void { + this.liveRange = [start, end]; + } + + clearLiveSeekableRange(): void { + this.liveRange = null; + } + open(): void { this.readyState = "open"; this.listeners.get("sourceopen")?.(); diff --git a/tests/player-session-loader.test.ts b/tests/player-session-loader.test.ts index 32b3a31..035c64c 100644 --- a/tests/player-session-loader.test.ts +++ b/tests/player-session-loader.test.ts @@ -35,6 +35,78 @@ function response(sessionId: string, videoId = "V_YKnVyUJgQ"): PlaybackResponse return { sessionId, videoId, generation: 1, ready: false, retryAfterMs: null }; } +test("uses the server-resolved live start for the first window and buffer fill", async () => { + const requestedPositions: number[] = []; + const filledWindows: Array<[number, number]> = []; + const live = { + active: true, + postLiveDvr: false, + headSequence: 12, + headTimeMs: 72_000, + seekableStartMs: 0, + seekableEndMs: 72_000, + atLiveEdge: true, + targetLatencyMs: 10_000, + }; + const session = await loadPlayerSession({ + deps: { + playback: { + create: async () => response("unused"), + position: async (sessionId, request) => { + requestedPositions.push(request.playerTimeMs); + return { ...window(sessionId, request.generation, false), startTimeMs: 60_000, live }; + }, + prefetch: async (sessionId, request) => ({ + ...window(sessionId, request.generation, true), + startTimeMs: 60_000, + live, + manifest: { ...manifest, startTimeMs: 60_000, live }, + }), + segments: async (sessionId, request) => ({ + ...window(sessionId, request.generation, true), + startTimeMs: 60_000, + live, + manifest: { ...manifest, startTimeMs: 60_000, live }, + }), + }, + media: { attach: async () => undefined, bufferedRanges: () => [] }, + scheduler: { + reset: () => undefined, + appendInit: async () => undefined, + fill: async (_manifest, startMs, endMs) => filledWindows.push([startMs, endMs]), + }, + policy: { + bufferGoalMs: 30_000, + backBufferMs: 30_000, + pollIntervalMs: 500, + manifestRefreshMs: 8_000, + manifestPollLimit: 2, + segmentPollLimit: 2, + }, + }, + config: { + endpoint: "https://beta.typetype.video/api", + videoId: "live-video", + videoItag: 137, + audioItag: 140, + audioTrackId: null, + isLive: true, + }, + video: { currentTime: 0 }, + response: { ...response("live-session", "live-video"), startTimeMs: 60_000, live }, + current: null, + quality: undefined, + startTimeMs: 0, + signal: new AbortController().signal, + recovery: new PlaybackRecovery(), + }); + + expect(requestedPositions).toEqual([60_000]); + expect(filledWindows).toEqual([[59_000, 90_000]]); + expect(session.response.startTimeMs).toBe(60_000); + expect(session.manifest.live?.active).toBe(true); +}); + test("recovers terminal seek windows with a fresh lower video itag session", async () => { const createVideoItags: number[] = []; const attached: PlaybackManifest[] = []; diff --git a/tests/segment-scheduler.test.ts b/tests/segment-scheduler.test.ts index 541790a..864da2d 100644 --- a/tests/segment-scheduler.test.ts +++ b/tests/segment-scheduler.test.ts @@ -45,6 +45,34 @@ test("never appends a late segment behind the buffered track edge", async () => expect(appended).toEqual(["/41", "/42", "/43"]); }); +test("appends a self-initializing live segment only once", async () => { + const requested: string[] = []; + let requestedUrl = ""; + const http = { + response: async (url: string) => { + requestedUrl = url; + requested.push(url); + return new Response(new Uint8Array([1])); + }, + } as HttpClient; + const appended: string[] = []; + const media = { + append: async () => appended.push(requestedUrl), + } as MediaSourceController; + const scheduler = new SegmentScheduler(http, media, { emit: () => undefined } as EventEmitter, 1); + const liveManifest = manifest([ + { url: "/40", startMs: 390_000, durationMs: 10_000 }, + { url: "/41", startMs: 400_000, durationMs: 10_000 }, + ]); + liveManifest.audio.initUrl = "/40"; + + await scheduler.appendInit(liveManifest); + await scheduler.fill(liveManifest, 390_000, 410_000); + + expect(requested).toEqual(["/40", "/41"]); + expect(appended).toEqual(["/40", "/41"]); +}); + test("rejects fetched bytes from a superseded scheduler revision", async () => { let releaseResponse: ((response: Response) => void) | null = null; const pendingResponse = new Promise((resolve) => { From aa6887f3f8fb6a0bf3f82cb4c697083b96a5b490 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 17 Jul 2026 08:31:20 +0200 Subject: [PATCH 03/11] feat: keep live playback near the stream edge --- src/live-edge-follower.ts | 60 ++++++++++++++++++++++++ src/type-type-mse-player.ts | 59 ++++++++++++++++++++---- tests/live-edge-follower.test.ts | 78 ++++++++++++++++++++++++++++++++ 3 files changed, 187 insertions(+), 10 deletions(-) create mode 100644 src/live-edge-follower.ts create mode 100644 tests/live-edge-follower.test.ts diff --git a/src/live-edge-follower.ts b/src/live-edge-follower.ts new file mode 100644 index 0000000..2524ee8 --- /dev/null +++ b/src/live-edge-follower.ts @@ -0,0 +1,60 @@ +import type { LivePlaybackWindow } from "./manifest"; + +type CatchUpContext = { + positionMs: number; + live: LivePlaybackWindow | null | undefined; + paused: boolean; + busy: boolean; + nowMs: number; +}; + +const MAX_TARGET_DRIFT_MS = 20_000; +const REJOIN_TOLERANCE_MS = 5_000; +const CATCH_UP_COOLDOWN_MS = 15_000; + +export class LiveEdgeFollower { + private initialized = false; + private following = false; + private nextCatchUpAtMs = 0; + + constructor(private readonly enabled: boolean) {} + + initialize(positionMs: number, live: LivePlaybackWindow | null | undefined): void { + if (this.initialized || !this.enabled || live?.active !== true) return; + this.initialized = true; + this.following = live.atLiveEdge || this.isNearTarget(positionMs, live); + } + + observeUserSeek(positionMs: number, live: LivePlaybackWindow | null | undefined): void { + this.nextCatchUpAtMs = 0; + this.following = this.enabled && live?.active === true && this.isNearTarget(positionMs, live); + } + + nextTarget(context: CatchUpContext): number | null { + if ( + !this.following || + context.live?.active !== true || + context.paused || + context.busy || + context.nowMs < this.nextCatchUpAtMs + ) { + return null; + } + const targetMs = liveTargetMs(context.live); + if (targetMs - context.positionMs <= MAX_TARGET_DRIFT_MS) return null; + this.nextCatchUpAtMs = context.nowMs + CATCH_UP_COOLDOWN_MS; + return targetMs; + } + + get isFollowing(): boolean { + return this.following; + } + + private isNearTarget(positionMs: number, live: LivePlaybackWindow): boolean { + return positionMs >= liveTargetMs(live) - REJOIN_TOLERANCE_MS; + } +} + +function liveTargetMs(live: LivePlaybackWindow): number { + return Math.max(live.seekableStartMs, live.seekableEndMs - live.targetLatencyMs); +} diff --git a/src/type-type-mse-player.ts b/src/type-type-mse-player.ts index 86a41ff..6d9317a 100644 --- a/src/type-type-mse-player.ts +++ b/src/type-type-mse-player.ts @@ -1,5 +1,6 @@ import { decodeStartMs, runDecodePreroll } from "./decode-preroll"; import { EventEmitter } from "./event-emitter"; +import { LiveEdgeFollower } from "./live-edge-follower"; import { PlaybackIntent } from "./playback-intent"; import type { PlaybackLoopFailureContext } from "./playback-loop"; import { createPlayerDeps, type PlayerDeps } from "./player-deps"; @@ -27,9 +28,11 @@ import type { private readonly seekController = new SeekController(); private readonly operation = new PlayerOperation(); private readonly playbackRecovery = new PlaybackRecovery(); + private readonly liveEdgeFollower: LiveEdgeFollower; private session: LoadedSession | null = null; private pendingPrerollTargetMs: number | null = null; private loadTask: Promise | null = null; + private liveEdgeCatchUpTask: Promise | null = null; private sessionTransition: Promise = Promise.resolve(); private audioOnly: boolean; private recoveryPositionMs: number; @@ -43,6 +46,7 @@ import type { this.video.playsInline = true; this.audioOnly = config.audioOnly === true; this.recoveryPositionMs = Math.max(0, Math.round(config.startTimeMs ?? 0)); + this.liveEdgeFollower = new LiveEdgeFollower(config.isLive === true); this.deps = createPlayerDeps({ video, config, @@ -54,6 +58,7 @@ import type { progress: (positionMs) => { this.rememberRecoveryPosition(positionMs); this.playbackRecovery.observeProgress(positionMs); + this.followLiveEdge(positionMs); }, loopError: (error, context) => this.handlePlaybackLoopError(error, context), }); @@ -96,6 +101,7 @@ import type { audioTrackId: this.config.audioTrackId, startTimeMs, audioOnly: this.audioOnly, + ...(this.config.isLive ? { isLive: true } : {}), }, signal, ); @@ -125,6 +131,7 @@ import type { async seek(positionMs: number): Promise { this.playbackIntent.capture(this.video.paused, this.playerState.value === "seeking"); const targetMs = Math.max(0, Math.round(positionMs)); + this.liveEdgeFollower.observeUserSeek(targetMs, this.session?.manifest.live); this.recoveryPositionMs = targetMs; return this.seekController.seek( targetMs, @@ -284,39 +291,43 @@ import type { recovery: this.playbackRecovery, }); this.operation.ensureCurrent(this.destroyed, revision); - const startMs = decodeStartMs(session.manifest, startTimeMs); - if (shouldApplySessionPosition(startTimeMs, finalizePausedSeek)) { + const resolvedStartTimeMs = + session.response.startTimeMs ?? session.manifest.startTimeMs ?? startTimeMs; + this.recoveryPositionMs = resolvedStartTimeMs; + const startMs = decodeStartMs(session.manifest, resolvedStartTimeMs); + if (shouldApplySessionPosition(resolvedStartTimeMs, finalizePausedSeek)) { this.video.currentTime = startMs / 1000; } this.session = session; await this.deps.loop.fillOnce(); this.operation.ensureCurrent(this.destroyed, revision); - if (startTimeMs > startMs) { + if (resolvedStartTimeMs > startMs) { if (this.playbackIntent.shouldResume) { - await runDecodePreroll(this.video, startTimeMs, true, signal); + await runDecodePreroll(this.video, resolvedStartTimeMs, true, signal); this.pendingPrerollTargetMs = null; } else { if (finalizePausedSeek) { - await runDecodePreroll(this.video, startTimeMs, false, signal, true); + await runDecodePreroll(this.video, resolvedStartTimeMs, false, signal, true); this.pendingPrerollTargetMs = null; } else { - this.pendingPrerollTargetMs = startTimeMs; + this.pendingPrerollTargetMs = resolvedStartTimeMs; } } } else if (this.playbackIntent.shouldResume) { this.pendingPrerollTargetMs = null; - if (startTimeMs > 0) { - await runDecodePreroll(this.video, startTimeMs, false, signal, true); + if (resolvedStartTimeMs > 0) { + await runDecodePreroll(this.video, resolvedStartTimeMs, false, signal, true); } await this.video.play(); - } else if (finalizePausedSeek && startTimeMs > 0) { - await runDecodePreroll(this.video, startTimeMs, false, signal, true); + } else if (finalizePausedSeek && resolvedStartTimeMs > 0) { + await runDecodePreroll(this.video, resolvedStartTimeMs, false, signal, true); this.pendingPrerollTargetMs = null; } else { this.pendingPrerollTargetMs = null; } this.operation.ensureCurrent(this.destroyed, revision); this.playbackRecovery.complete(currentTimeMs(this.video)); + this.liveEdgeFollower.initialize(currentTimeMs(this.video), session.manifest.live); this.deps.loop.start(); emitManifest(this.emitter, session.response, session); this.playerState.set(this.video.paused ? "ready" : "playing"); @@ -351,6 +362,7 @@ import type { current, error, videoId: this.config.videoId, + isLive: this.config.isLive === true, startTimeMs: positionMs, signal, create: (request, recoverySignal) => this.deps.playback.create(request, recoverySignal), @@ -406,6 +418,33 @@ import type { } } + private followLiveEdge(positionMs: number): void { + if (this.destroyed || this.liveEdgeCatchUpTask) return; + const targetMs = this.liveEdgeFollower.nextTarget({ + positionMs, + live: this.session?.manifest.live, + paused: this.video.paused, + busy: this.playerState.value === "loading" || this.playerState.value === "seeking", + nowMs: Date.now(), + }); + if (targetMs === null) return; + this.playbackIntent.capture(this.video.paused, this.playerState.value === "seeking"); + const task = this.seekController + .seek( + targetMs, + `live-edge:${targetMs}`, + (target) => this.performSeek(target), + () => this.operation.abort(), + ) + .catch((error: unknown) => { + if (!isAbortError(error)) this.rememberRecoveryPosition(currentTimeMs(this.video)); + }) + .finally(() => { + if (this.liveEdgeCatchUpTask === task) this.liveEdgeCatchUpTask = null; + }); + this.liveEdgeCatchUpTask = task; + } + /** Serializes MediaSource ownership transitions. */ private enqueueSessionTransition(work: () => Promise): Promise { const result = this.sessionTransition.then(work, work); diff --git a/tests/live-edge-follower.test.ts b/tests/live-edge-follower.test.ts new file mode 100644 index 0000000..37640c3 --- /dev/null +++ b/tests/live-edge-follower.test.ts @@ -0,0 +1,78 @@ +import { expect, test } from "bun:test"; +import { LiveEdgeFollower } from "../src/live-edge-follower"; +import type { LivePlaybackWindow } from "../src/manifest"; + +const LIVE: LivePlaybackWindow = { + active: true, + postLiveDvr: false, + headSequence: 500, + headTimeMs: 1_000_000, + seekableStartMs: 100_000, + seekableEndMs: 1_000_000, + atLiveEdge: true, + targetLatencyMs: 10_000, +}; + +test("follows an active live session and coalesces catch-up seeks", () => { + const follower = new LiveEdgeFollower(true); + follower.initialize(990_000, LIVE); + + expect(follower.isFollowing).toBe(true); + expect( + follower.nextTarget({ positionMs: 971_000, live: LIVE, paused: false, busy: false, nowMs: 0 }), + ).toBeNull(); + expect( + follower.nextTarget({ positionMs: 969_000, live: LIVE, paused: false, busy: false, nowMs: 1 }), + ).toBe(990_000); + expect( + follower.nextTarget({ positionMs: 960_000, live: LIVE, paused: false, busy: false, nowMs: 2 }), + ).toBeNull(); + expect( + follower.nextTarget({ + positionMs: 960_000, + live: LIVE, + paused: false, + busy: false, + nowMs: 15_001, + }), + ).toBe(990_000); +}); + +test("leaves live following for a DVR seek and rejoins near the edge", () => { + const follower = new LiveEdgeFollower(true); + follower.initialize(990_000, LIVE); + + follower.observeUserSeek(800_000, LIVE); + expect(follower.isFollowing).toBe(false); + expect( + follower.nextTarget({ positionMs: 800_000, live: LIVE, paused: false, busy: false, nowMs: 0 }), + ).toBeNull(); + + follower.observeUserSeek(985_000, LIVE); + expect(follower.isFollowing).toBe(true); +}); + +test("does not catch up while paused or during another player operation", () => { + const follower = new LiveEdgeFollower(true); + follower.initialize(990_000, LIVE); + + expect( + follower.nextTarget({ positionMs: 900_000, live: LIVE, paused: true, busy: false, nowMs: 0 }), + ).toBeNull(); + expect( + follower.nextTarget({ positionMs: 900_000, live: LIVE, paused: false, busy: true, nowMs: 0 }), + ).toBeNull(); + expect( + follower.nextTarget({ positionMs: 900_000, live: LIVE, paused: false, busy: false, nowMs: 0 }), + ).toBe(990_000); +}); + +test("stays disabled for non-live playback", () => { + const follower = new LiveEdgeFollower(false); + follower.initialize(990_000, LIVE); + + expect(follower.isFollowing).toBe(false); + expect( + follower.nextTarget({ positionMs: 900_000, live: LIVE, paused: false, busy: false, nowMs: 0 }), + ).toBeNull(); +}); From a55f165129bbbfedacfdb6a976f193410bd1ab03 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 17 Jul 2026 08:37:02 +0200 Subject: [PATCH 04/11] docs: describe live edge following --- src/type-type-mse-player.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/type-type-mse-player.ts b/src/type-type-mse-player.ts index 6d9317a..b02a78c 100644 --- a/src/type-type-mse-player.ts +++ b/src/type-type-mse-player.ts @@ -418,6 +418,7 @@ import type { } } + /** Keeps active live playback within the configured edge latency. */ private followLiveEdge(positionMs: number): void { if (this.destroyed || this.liveEdgeCatchUpTask) return; const targetMs = this.liveEdgeFollower.nextTarget({ From b313ca33e5f3da261d9fb65391ad05410861afb8 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 17 Jul 2026 08:43:32 +0200 Subject: [PATCH 05/11] chore: bump player version to 0.1.32 --- jsr.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/jsr.json b/jsr.json index 69cfbcd..8c4ef31 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.31", + "version": "0.1.32", "exports": "./src/index.ts", "publish": { "include": ["LICENSE", "README.md", "src/**/*.ts"] diff --git a/package.json b/package.json index fa295fe..ea0c737 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@typetype/mse", - "version": "0.1.31", + "version": "0.1.32", "description": "MSE playback engine for TypeType", "license": "MIT", "type": "module", From 4802f33d773dd02b936b59bdaf1e21a2f1acbcd2 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 17 Jul 2026 08:53:14 +0200 Subject: [PATCH 06/11] fix: publish npm packages on supported runners --- .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 5b74a41..a03bcd1 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, arko, typetype] + runs-on: ubuntu-latest timeout-minutes: 15 environment: npm permissions: From a0f7fbc91ba99bee6c1e1983680a3572027ceeec Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 17 Jul 2026 21:54:16 +0200 Subject: [PATCH 07/11] fix: reduce live playback stalls --- jsr.json | 2 +- package.json | 2 +- src/buffer-policy.ts | 7 ++++--- src/playback-loop.ts | 20 +++++++++++++++++--- tests/buffer-policy.test.ts | 15 +++++++++++++++ tests/playback-loop.test.ts | 14 +++++++++++--- 6 files changed, 49 insertions(+), 11 deletions(-) diff --git a/jsr.json b/jsr.json index 8c4ef31..a88ce96 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.32", + "version": "0.1.33", "exports": "./src/index.ts", "publish": { "include": ["LICENSE", "README.md", "src/**/*.ts"] diff --git a/package.json b/package.json index ea0c737..beb476c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@typetype/mse", - "version": "0.1.32", + "version": "0.1.33", "description": "MSE playback engine for TypeType", "license": "MIT", "type": "module", diff --git a/src/buffer-policy.ts b/src/buffer-policy.ts index 19be864..c9ff91d 100644 --- a/src/buffer-policy.ts +++ b/src/buffer-policy.ts @@ -10,11 +10,12 @@ export type BufferPolicy = { }; export function resolveBufferPolicy(config: TypeTypeMseConfig): BufferPolicy { + const live = config.isLive === true; return { - bufferGoalMs: positive(config.bufferGoalMs, 30_000), + bufferGoalMs: positive(config.bufferGoalMs, live ? 8_000 : 30_000), backBufferMs: positive(config.backBufferMs, 30_000), - pollIntervalMs: positive(config.pollIntervalMs, 500), - manifestRefreshMs: positive(config.manifestRefreshMs, 8_000), + pollIntervalMs: positive(config.pollIntervalMs, live ? 250 : 500), + manifestRefreshMs: positive(config.manifestRefreshMs, live ? 1_000 : 8_000), manifestPollLimit: integer(config.manifestPollLimit, 60), segmentPollLimit: integer(config.segmentPollLimit, 60), }; diff --git a/src/playback-loop.ts b/src/playback-loop.ts index 27b30bf..ce8e5f4 100644 --- a/src/playback-loop.ts +++ b/src/playback-loop.ts @@ -96,6 +96,23 @@ export class PlaybackLoop { const goalMs = currentMs + bufferGoalMs; await this.args.scheduler.fill(session.manifest, currentMs, goalMs, signal); this.ensureCurrent(revision, signal); + if ( + !session.manifest.endOfStream && + this.args.bufferedEndMs() < currentMs + refreshThresholdMs(bufferGoalMs) + ) { + this.requestManifestRefresh(revision); + const refresh = this.refreshTask; + if (refresh) { + try { + await refresh; + } catch { + return; + } + this.ensureCurrent(revision, signal); + await this.args.scheduler.fill(session.manifest, currentMs, goalMs, signal); + this.ensureCurrent(revision, signal); + } + } await this.args.media.trim(currentMs, this.args.policy.backBufferMs); this.ensureCurrent(revision, signal); const bufferedEndMs = this.args.bufferedEndMs(); @@ -108,9 +125,6 @@ export class PlaybackLoop { this.stop(); return; } - if (bufferedEndMs < currentMs + refreshThresholdMs(bufferGoalMs)) { - this.requestManifestRefresh(revision); - } } private async refreshManifest(session: LoadedSession, signal: AbortSignal): Promise { diff --git a/tests/buffer-policy.test.ts b/tests/buffer-policy.test.ts index e0e2bb4..3b48326 100644 --- a/tests/buffer-policy.test.ts +++ b/tests/buffer-policy.test.ts @@ -38,3 +38,18 @@ test("rejects invalid buffer values", () => { expect(policy.manifestPollLimit).toBe(60); expect(policy.segmentPollLimit).toBe(7); }); + +test("uses a live buffer goal that fits behind the server live edge", () => { + const policy = resolveBufferPolicy({ + endpoint: "https://example.com/api", + videoId: "live-video", + videoItag: 299, + audioItag: 140, + audioTrackId: null, + isLive: true, + }); + expect(policy.bufferGoalMs).toBe(8_000); + expect(policy.backBufferMs).toBe(30_000); + expect(policy.pollIntervalMs).toBe(250); + expect(policy.manifestRefreshMs).toBe(1_000); +}); diff --git a/tests/playback-loop.test.ts b/tests/playback-loop.test.ts index 1d66d96..179167d 100644 --- a/tests/playback-loop.test.ts +++ b/tests/playback-loop.test.ts @@ -30,6 +30,7 @@ function window(request: PlaybackWindowRequest): PlaybackWindow { test("refreshes rapidly only while the playback buffer is below its low watermark", async () => { let bufferedEndMs = 5_000; let positionCalls = 0; + let fillCalls = 0; const session: LoadedSession = { response: { sessionId: "session", @@ -54,7 +55,11 @@ test("refreshes rapidly only while the playback buffer is below its low watermar segments: async (_sessionId, request) => window(request), }, media: { bufferedRanges: () => [], endOfStream: () => false, trim: async () => undefined }, - scheduler: { fill: async () => undefined }, + scheduler: { + fill: async () => { + fillCalls += 1; + }, + }, emitter: { emit: () => undefined }, policy: { bufferGoalMs: 30_000, @@ -75,16 +80,19 @@ test("refreshes rapidly only while the playback buffer is below its low watermar await loop.fillOnce(); await Bun.sleep(0); expect(positionCalls).toBe(1); + expect(fillCalls).toBe(2); bufferedEndMs = 20_000; await loop.fillOnce(); await Bun.sleep(0); expect(positionCalls).toBe(1); + expect(fillCalls).toBe(3); bufferedEndMs = 19_999; await loop.fillOnce(); await Bun.sleep(0); expect(positionCalls).toBe(2); + expect(fillCalls).toBe(5); }); test("closes the media source after appending the final window", async () => { @@ -302,7 +310,7 @@ test("waits for an active manifest refresh before becoming quiescent", async () bufferedEndMs: () => 0, error: () => undefined, }); - await loop.fillOnce(); + const fill = loop.fillOnce(); await Bun.sleep(0); let quiescent = false; const quiesce = loop.quiesce().then(() => { @@ -325,7 +333,7 @@ test("waits for an active manifest refresh before becoming quiescent", async () bufferedRanges: [], }), ); - await quiesce; + await Promise.all([fill, quiesce]); expect(quiescent).toBe(true); }); From 52f1bdc86f9ad97842f182464d1c542847dad6e3 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 18 Jul 2026 21:23:57 +0200 Subject: [PATCH 08/11] fix: refresh stalled live playback windows --- src/playback-loop.ts | 11 ++- tests/playback-loop-live-stall.test.ts | 118 +++++++++++++++++++++++++ tests/playback-loop.test.ts | 12 +-- 3 files changed, 133 insertions(+), 8 deletions(-) create mode 100644 tests/playback-loop-live-stall.test.ts diff --git a/src/playback-loop.ts b/src/playback-loop.ts index ce8e5f4..d020cef 100644 --- a/src/playback-loop.ts +++ b/src/playback-loop.ts @@ -8,7 +8,7 @@ import type { LoadedSession } from "./session-loader"; import { refreshPlaybackWindow } from "./session-loader"; type PlaybackLoopArgs = { - video: { currentTime: number }; + video: { currentTime: number; paused: boolean; readyState: number }; playback: Pick; media: Pick; scheduler: Pick; @@ -160,7 +160,12 @@ export class PlaybackLoop { if (revision !== this.revision) return; const currentMs = currentTimeMs(this.args.video); const thresholdMs = refreshThresholdMs(this.args.policy.bufferGoalMs); - if (this.args.bufferedEndMs() < currentMs + thresholdMs) { + const session = this.args.session(); + const waitingForLiveData = + session?.manifest.live?.active === true && + !this.args.video.paused && + this.args.video.readyState < HAVE_FUTURE_DATA; + if (waitingForLiveData || this.args.bufferedEndMs() < currentMs + thresholdMs) { this.requestManifestRefresh(revision); } } @@ -188,3 +193,5 @@ export class PlaybackLoop { function refreshThresholdMs(bufferGoalMs: number): number { return Math.min(bufferGoalMs, Math.max(5_000, Math.round((bufferGoalMs * 2) / 3))); } + +const HAVE_FUTURE_DATA = 3; diff --git a/tests/playback-loop-live-stall.test.ts b/tests/playback-loop-live-stall.test.ts new file mode 100644 index 0000000..bfe4de6 --- /dev/null +++ b/tests/playback-loop-live-stall.test.ts @@ -0,0 +1,118 @@ +import { expect, test } from "bun:test"; +import type { PlaybackManifest } from "../src/manifest"; +import { PlaybackLoop } from "../src/playback-loop"; +import type { PlaybackWindow, PlaybackWindowRequest } from "../src/playback-window"; +import type { LoadedSession } from "../src/session-loader"; + +const manifest: PlaybackManifest = { + durationMs: 0, + endOfStream: false, + live: { + active: true, + postLiveDvr: false, + headSequence: 100, + headTimeMs: 200_000, + seekableStartMs: 0, + seekableEndMs: 200_000, + atLiveEdge: true, + targetLatencyMs: 8_000, + }, + audio: { kind: "audio", mime: "audio/mp4", initUrl: "/audio/init", segments: [] }, + video: { kind: "video", mime: "video/mp4", initUrl: "/video/init", segments: [] }, +}; + +function playbackWindow(request: PlaybackWindowRequest): PlaybackWindow { + return { + sessionId: "session", + generation: request.generation, + ready: true, + retryAfterMs: null, + terminalError: null, + recoveryAction: null, + retryVideoItags: [], + status: "ready", + blockedBy: null, + bufferedEdgeMs: 20_000, + manifest, + }; +} + +function createLoop(video: { currentTime: number; paused: boolean; readyState: number }): { + loop: PlaybackLoop; + positionCalls: () => number; + failures: Error[]; +} { + let calls = 0; + const failures: Error[] = []; + const session: LoadedSession = { + response: { + sessionId: "session", + videoId: "video", + generation: 0, + ready: true, + retryAfterMs: null, + }, + manifest, + videoItag: 299, + audioItag: 140, + audioTrackId: null, + audioOnly: false, + }; + const loop = new PlaybackLoop({ + video, + playback: { + position: async (_sessionId, request) => { + calls += 1; + return playbackWindow(request); + }, + prefetch: async (_sessionId, request) => playbackWindow(request), + segments: async (_sessionId, request) => playbackWindow(request), + }, + media: { bufferedRanges: () => [], endOfStream: () => false, trim: async () => undefined }, + scheduler: { fill: async () => undefined }, + emitter: { emit: () => undefined }, + policy: { + bufferGoalMs: 8_000, + backBufferMs: 30_000, + pollIntervalMs: 1_000, + manifestRefreshMs: 1, + manifestPollLimit: 2, + segmentPollLimit: 2, + }, + session: () => session, + signal: () => new AbortController().signal, + bufferedEndMs: () => 20_000, + error: (error) => failures.push(error), + }); + return { loop, positionCalls: () => calls, failures }; +} + +test("refreshes a stalled active live despite its reported buffer", async () => { + const { loop, positionCalls, failures } = createLoop({ + currentTime: 10, + paused: false, + readyState: 2, + }); + + loop.start(); + await Bun.sleep(10); + await loop.quiesce(); + + expect(positionCalls()).toBeGreaterThan(0); + expect(failures).toHaveLength(0); +}); + +test("does not refresh a paused live with enough reported buffer", async () => { + const { loop, positionCalls, failures } = createLoop({ + currentTime: 10, + paused: true, + readyState: 2, + }); + + loop.start(); + await Bun.sleep(10); + await loop.quiesce(); + + expect(positionCalls()).toBe(0); + expect(failures).toHaveLength(0); +}); diff --git a/tests/playback-loop.test.ts b/tests/playback-loop.test.ts index 179167d..26a20e7 100644 --- a/tests/playback-loop.test.ts +++ b/tests/playback-loop.test.ts @@ -45,7 +45,7 @@ test("refreshes rapidly only while the playback buffer is below its low watermar audioTrackId: null, }; const loop = new PlaybackLoop({ - video: { currentTime: 0 }, + video: { currentTime: 0, paused: false, readyState: 4 }, playback: { position: async (_sessionId, request) => { positionCalls += 1; @@ -111,7 +111,7 @@ test("closes the media source after appending the final window", async () => { audioTrackId: null, }; const loop = new PlaybackLoop({ - video: { currentTime: 119 }, + video: { currentTime: 119, paused: false, readyState: 4 }, playback: { position: async (_sessionId, request) => window(request), prefetch: async (_sessionId, request) => window(request), @@ -168,7 +168,7 @@ test("reports a terminal refresh with its exact session and operation signal", a let failedSessionId: string | null = null; let failedSignal: AbortSignal | null = null; const loop = new PlaybackLoop({ - video: { currentTime: 379.441 }, + video: { currentTime: 379.441, paused: false, readyState: 4 }, playback: { position: async (_sessionId, request) => window(request), prefetch: async (_sessionId, request) => ({ @@ -229,7 +229,7 @@ test("waits for an active fill before becoming quiescent", async () => { audioOnly: false, }; const loop = new PlaybackLoop({ - video: { currentTime: 0 }, + video: { currentTime: 0, paused: false, readyState: 4 }, playback: { position: async (_sessionId, request) => window(request), prefetch: async (_sessionId, request) => window(request), @@ -288,7 +288,7 @@ test("waits for an active manifest refresh before becoming quiescent", async () audioOnly: false, }; const loop = new PlaybackLoop({ - video: { currentTime: 0 }, + video: { currentTime: 0, paused: false, readyState: 4 }, playback: { position: async () => pendingPosition, prefetch: async (_sessionId, request) => window(request), @@ -362,7 +362,7 @@ test("does not attach a stale fill rejection to a restarted loop", async () => { audioOnly: false, }; const loop = new PlaybackLoop({ - video: { currentTime: 0 }, + video: { currentTime: 0, paused: false, readyState: 4 }, playback: { position: async (_sessionId, request) => window(request), prefetch: async (_sessionId, request) => window(request), From 98695ec3571ea2668eab38f91fe06508efb04572 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 18 Jul 2026 21:24:09 +0200 Subject: [PATCH 09/11] chore: bump player version to 0.1.34 --- jsr.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/jsr.json b/jsr.json index a88ce96..00691f3 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.33", + "version": "0.1.34", "exports": "./src/index.ts", "publish": { "include": ["LICENSE", "README.md", "src/**/*.ts"] diff --git a/package.json b/package.json index beb476c..9b91ece 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@typetype/mse", - "version": "0.1.33", + "version": "0.1.34", "description": "MSE playback engine for TypeType", "license": "MIT", "type": "module", From c8039d6ad4891416c8010ab14f28e3bad6c40033 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 18 Jul 2026 21:40:01 +0200 Subject: [PATCH 10/11] fix: extend buffering during live stalls --- src/playback-loop.ts | 19 ++++++++++++++----- tests/playback-loop-live-stall.test.ts | 16 ++++++++++++---- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/src/playback-loop.ts b/src/playback-loop.ts index d020cef..2ac671b 100644 --- a/src/playback-loop.ts +++ b/src/playback-loop.ts @@ -93,7 +93,7 @@ export class PlaybackLoop { ): Promise { const currentMs = currentTimeMs(this.args.video); const bufferGoalMs = this.args.policy.bufferGoalMs; - const goalMs = currentMs + bufferGoalMs; + const goalMs = currentMs + bufferGoalMs + this.liveStallRecoveryMs(bufferGoalMs); await this.args.scheduler.fill(session.manifest, currentMs, goalMs, signal); this.ensureCurrent(revision, signal); if ( @@ -161,15 +161,24 @@ export class PlaybackLoop { const currentMs = currentTimeMs(this.args.video); const thresholdMs = refreshThresholdMs(this.args.policy.bufferGoalMs); const session = this.args.session(); - const waitingForLiveData = - session?.manifest.live?.active === true && - !this.args.video.paused && - this.args.video.readyState < HAVE_FUTURE_DATA; + const waitingForLiveData = this.waitingForLiveData(session); if (waitingForLiveData || this.args.bufferedEndMs() < currentMs + thresholdMs) { this.requestManifestRefresh(revision); } } + private liveStallRecoveryMs(bufferGoalMs: number): number { + return this.waitingForLiveData(this.args.session()) ? refreshThresholdMs(bufferGoalMs) : 0; + } + + private waitingForLiveData(session: LoadedSession | null): boolean { + return ( + session?.manifest.live?.active === true && + !this.args.video.paused && + this.args.video.readyState < HAVE_FUTURE_DATA + ); + } + private failureContext(): PlaybackLoopFailureContext { return { sessionId: this.args.session()?.response.sessionId ?? null, diff --git a/tests/playback-loop-live-stall.test.ts b/tests/playback-loop-live-stall.test.ts index bfe4de6..f0a0657 100644 --- a/tests/playback-loop-live-stall.test.ts +++ b/tests/playback-loop-live-stall.test.ts @@ -40,9 +40,11 @@ function playbackWindow(request: PlaybackWindowRequest): PlaybackWindow { function createLoop(video: { currentTime: number; paused: boolean; readyState: number }): { loop: PlaybackLoop; positionCalls: () => number; + fillGoals: number[]; failures: Error[]; } { let calls = 0; + const fillGoals: number[] = []; const failures: Error[] = []; const session: LoadedSession = { response: { @@ -69,7 +71,11 @@ function createLoop(video: { currentTime: number; paused: boolean; readyState: n segments: async (_sessionId, request) => playbackWindow(request), }, media: { bufferedRanges: () => [], endOfStream: () => false, trim: async () => undefined }, - scheduler: { fill: async () => undefined }, + scheduler: { + fill: async (_manifest, _currentMs, goalMs) => { + fillGoals.push(goalMs); + }, + }, emitter: { emit: () => undefined }, policy: { bufferGoalMs: 8_000, @@ -84,21 +90,23 @@ function createLoop(video: { currentTime: number; paused: boolean; readyState: n bufferedEndMs: () => 20_000, error: (error) => failures.push(error), }); - return { loop, positionCalls: () => calls, failures }; + return { loop, positionCalls: () => calls, fillGoals, failures }; } -test("refreshes a stalled active live despite its reported buffer", async () => { - const { loop, positionCalls, failures } = createLoop({ +test("refreshes and extends a stalled active live despite its reported buffer", async () => { + const { loop, positionCalls, fillGoals, failures } = createLoop({ currentTime: 10, paused: false, readyState: 2, }); loop.start(); + await loop.fillOnce(); await Bun.sleep(10); await loop.quiesce(); expect(positionCalls()).toBeGreaterThan(0); + expect(fillGoals.some((goalMs) => goalMs > 18_000)).toBe(true); expect(failures).toHaveLength(0); }); From 134121718553609b6d5588420e4e8b6915d01622 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 18 Jul 2026 21:40:08 +0200 Subject: [PATCH 11/11] chore: bump player version to 0.1.35 --- jsr.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/jsr.json b/jsr.json index 00691f3..908c63b 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.34", + "version": "0.1.35", "exports": "./src/index.ts", "publish": { "include": ["LICENSE", "README.md", "src/**/*.ts"] diff --git a/package.json b/package.json index 9b91ece..c9266c4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@typetype/mse", - "version": "0.1.34", + "version": "0.1.35", "description": "MSE playback engine for TypeType", "license": "MIT", "type": "module",