From 18220c83ccf245f104572ae85f8bd1aae5d028e1 Mon Sep 17 00:00:00 2001 From: Stackie Jia Date: Sat, 27 Jun 2026 22:54:30 +0800 Subject: [PATCH 1/6] fix(player): resume live ts streams without range --- web-ui/src/mpegts/io/fetch-loader.ts | 36 ++++++- web-ui/src/mpegts/player/mpegts-player.ts | 108 ++++++++++++--------- web-ui/src/mpegts/worker/pipeline.ts | 63 ++++++++---- web-ui/src/mpegts/worker/segment-source.ts | 26 +++++ 4 files changed, 164 insertions(+), 69 deletions(-) diff --git a/web-ui/src/mpegts/io/fetch-loader.ts b/web-ui/src/mpegts/io/fetch-loader.ts index b185f541..4931e836 100644 --- a/web-ui/src/mpegts/io/fetch-loader.ts +++ b/web-ui/src/mpegts/io/fetch-loader.ts @@ -39,6 +39,12 @@ interface LoaderRange { to: number; } +type ResumeMode = "range" | "restart"; + +interface FetchLoaderOptions { + resumeMode?: ResumeMode; +} + enum LoaderStatus { kIdle = 0, kConnecting = 1, @@ -55,6 +61,7 @@ class FetchLoader { // --- public callbacks (set by consumer, e.g. TSDemuxer) --- onDataArrival: ((data: Uint8Array, byteStart: number) => number) | null; onSeeked: (() => void) | null; + onRestarted: (() => void) | null; onError: ((type: string, info: LoaderErrorInfo) => void) | null; onComplete: ((extraData: unknown) => void) | null; /** Called with the playlist text and its final (post-redirect) URL when the response is an HLS playlist. */ @@ -64,6 +71,7 @@ class FetchLoader { private _config: PlayerConfig; private _dataSource: DataSource | null; private _extraData: unknown; + private _resumeMode: ResumeMode; // --- stash buffer --- private _stashUsed: number; @@ -85,10 +93,11 @@ class FetchLoader { private _contentLength: number | null; private _receivedLength: number; - constructor(dataSource: DataSource, config: PlayerConfig, extraData?: unknown) { + constructor(dataSource: DataSource, config: PlayerConfig, extraData?: unknown, options: FetchLoaderOptions = {}) { this._config = config; this._dataSource = dataSource; this._extraData = extraData; + this._resumeMode = options.resumeMode ?? "range"; // stash buffer setup this._stashUsed = 0; @@ -112,6 +121,7 @@ class FetchLoader { // callbacks this.onDataArrival = null; this.onSeeked = null; + this.onRestarted = null; this.onError = null; this.onComplete = null; this.onHLSDetected = null; @@ -130,6 +140,7 @@ class FetchLoader { this.onDataArrival = null; this.onSeeked = null; + this.onRestarted = null; this.onError = null; this.onComplete = null; this.onHLSDetected = null; @@ -196,7 +207,11 @@ class FetchLoader { this._paused = false; const bytes = this._resumeFrom; this._resumeFrom = 0; - this._internalSeek(bytes); + if (this._resumeMode === "restart") { + this._internalRestart(); + } else { + this._internalSeek(bytes); + } } } @@ -424,6 +439,23 @@ class FetchLoader { } } + private _internalRestart(): void { + if (this._status === LoaderStatus.kConnecting || this._status === LoaderStatus.kBuffering) { + this._abortFetch(); + } + + // Flush old-stream stash before the caller marks the next bytes as a new TS input boundary. + this._flushStashBuffer(true); + + this.onRestarted?.(); + + const requestRange: LoaderRange = { from: 0, to: -1 }; + this._currentRange = { from: requestRange.from, to: -1 }; + + this._requestAbort = false; + this._startFetch(requestRange); + } + // --- stash buffer management (from IOController) ------------------------- private _expandBuffer(expectedBytes: number): void { diff --git a/web-ui/src/mpegts/player/mpegts-player.ts b/web-ui/src/mpegts/player/mpegts-player.ts index 3e0634b8..d29df1b7 100644 --- a/web-ui/src/mpegts/player/mpegts-player.ts +++ b/web-ui/src/mpegts/player/mpegts-player.ts @@ -37,8 +37,9 @@ export function createMpegtsPlayer( /** Live edge assuming continuous playback since session start. */ let liveSessionAnchor: LiveSessionAnchor | null = null; - let watermarkTimer: ReturnType | null = null; + let hlsVodThrottleEnabled = false; let watermarkPaused = false; + let bufferFullPaused = false; // PCM audio player for software-decoded audio (MP2) let pcmPlayer: PCMAudioPlayer | null = null; @@ -108,7 +109,8 @@ export function createMpegtsPlayer( case "hls-info": if (!msg.live) { mse?.setDuration(msg.totalDuration); - startWatermarkThrottle(); + hlsVodThrottleEnabled = true; + updateFetchBackpressure(); } break; case "pcm-audio-data": { @@ -133,38 +135,53 @@ export function createMpegtsPlayer( return worker; } - /** Throttle fetching for HLS VOD/EVENT: pause the worker when buffered far ahead of playback. */ - function startWatermarkThrottle(): void { - if (watermarkTimer) return; - watermarkTimer = setInterval(() => { - const buffered = video.buffered; - // Measure forward buffer within the range containing currentTime; after a seek - // to an unbuffered position, stale ranges further ahead must not count. - let ahead = 0; - for (let i = 0; i < buffered.length; i++) { - if (video.currentTime >= buffered.start(i) && video.currentTime <= buffered.end(i)) { - ahead = buffered.end(i) - video.currentTime; - break; - } - } - if (!watermarkPaused && ahead > VOD_FORWARD_BUFFER_PAUSE) { - watermarkPaused = true; - worker?.postMessage({ type: "pause" } satisfies WorkerCommand); - } else if (watermarkPaused && ahead < VOD_FORWARD_BUFFER_RESUME) { - watermarkPaused = false; - worker?.postMessage({ type: "resume" } satisfies WorkerCommand); + function getForwardBufferAhead(): number { + const buffered = video.buffered; + // Measure forward buffer within the range containing currentTime; after a seek + // to an unbuffered position, stale ranges further ahead must not count. + for (let i = 0; i < buffered.length; i++) { + if (video.currentTime >= buffered.start(i) && video.currentTime <= buffered.end(i)) { + return buffered.end(i) - video.currentTime; } - }, 1000); + } + return 0; } - /** Resume segment fetching after an in-buffer seek (worker may have paused on buffer full). */ - function resumeWorkerAfterBufferSeek(): void { - if (watermarkPaused) { - watermarkPaused = false; + function pauseWorkerForBackpressure(kind: "watermark" | "buffer-full"): void { + if (kind === "watermark") { + watermarkPaused = true; + } else { + bufferFullPaused = true; } + worker?.postMessage({ type: "pause" } satisfies WorkerCommand); + } + + function resumeWorkerFromBackpressure(): void { + watermarkPaused = false; + bufferFullPaused = false; worker?.postMessage({ type: "resume" } satisfies WorkerCommand); } + function updateFetchBackpressure(): void { + const ahead = getForwardBufferAhead(); + + if (watermarkPaused || bufferFullPaused) { + if (ahead < VOD_FORWARD_BUFFER_RESUME) { + resumeWorkerFromBackpressure(); + } + return; + } + + if (hlsVodThrottleEnabled && ahead > VOD_FORWARD_BUFFER_PAUSE) { + pauseWorkerForBackpressure("watermark"); + } + } + + /** Re-evaluate fetching after an in-buffer seek (worker may have paused on buffer full). */ + function resumeWorkerAfterBufferSeek(): void { + updateFetchBackpressure(); + } + /** Seek within existing MSE buffer without reloading the stream. */ function bufferSeek(seconds: number): boolean { if (!isBuffered(video, seconds)) { @@ -184,12 +201,10 @@ export function createMpegtsPlayer( } } - function stopWatermarkThrottle(): void { - if (watermarkTimer) { - clearInterval(watermarkTimer); - watermarkTimer = null; - } + function resetFetchBackpressure(): void { + hlsVodThrottleEnabled = false; watermarkPaused = false; + bufferFullPaused = false; } function loadInWorker(segments: PlayerSegment[]): void { @@ -218,27 +233,23 @@ export function createMpegtsPlayer( }); mse.onBufferFull = () => { - const cmd: WorkerCommand = { type: "pause" }; - worker?.postMessage(cmd); + pauseWorkerForBackpressure("buffer-full"); }; mse.onBufferAvailable = () => { - // Don't resume while the VOD watermark throttle is intentionally holding the worker - if (watermarkPaused) return; - const cmd: WorkerCommand = { type: "resume" }; - worker?.postMessage(cmd); + updateFetchBackpressure(); }; mse.onStartStreaming = () => { - if (watermarkPaused) return; - const cmd: WorkerCommand = { type: "resume" }; - worker?.postMessage(cmd); + if (watermarkPaused || bufferFullPaused) { + updateFetchBackpressure(); + return; + } + worker?.postMessage({ type: "resume" } satisfies WorkerCommand); }; - // Note: onEndStreaming intentionally does NOT pause the worker. For continuous - // live TS streams, pausing aborts the in-flight fetch and resumes via a Range - // request, which restarts a live stream mid-flow and corrupts the timeline. - // The MSE layer already defers appends while ManagedMediaSource streaming=false. + // Note: onEndStreaming intentionally does NOT pause the worker. The MSE layer + // already defers appends while ManagedMediaSource streaming=false. mse.onSourceClose = () => { // The UA killed the media pipeline (e.g. iOS reclaiming resources in @@ -273,7 +284,9 @@ export function createMpegtsPlayer( } const onVideoPlay = () => markPlaybackUnlocked(); + const onVideoTimeUpdate = () => updateFetchBackpressure(); video.addEventListener("play", onVideoPlay); + video.addEventListener("timeupdate", onVideoTimeUpdate); const impl: PlayerImpl = { onError: null, @@ -282,7 +295,7 @@ export function createMpegtsPlayer( mseGeneration++; pendingInits = []; pendingSegments = segments; - stopWatermarkThrottle(); + resetFetchBackpressure(); if (mse) { mse.destroy(); mse = null; @@ -317,7 +330,7 @@ export function createMpegtsPlayer( suspend() { mseGeneration++; - stopWatermarkThrottle(); + resetFetchBackpressure(); pendingInits = []; pendingSegments = null; if (worker) { @@ -337,6 +350,7 @@ export function createMpegtsPlayer( destroy() { impl.suspend(); video.removeEventListener("play", onVideoPlay); + video.removeEventListener("timeupdate", onVideoTimeUpdate); if (worker) { const cmd: WorkerCommand = { type: "destroy" }; worker.postMessage(cmd); diff --git a/web-ui/src/mpegts/worker/pipeline.ts b/web-ui/src/mpegts/worker/pipeline.ts index 5db6069a..c88146f9 100644 --- a/web-ui/src/mpegts/worker/pipeline.ts +++ b/web-ui/src/mpegts/worker/pipeline.ts @@ -9,7 +9,12 @@ import FetchLoader, { LoaderErrors } from "../io/fetch-loader"; import MP4Remuxer from "../remux/mp4-remuxer"; import type { PlayerSegment } from "../types"; import Log from "../utils/logger"; -import { type SegmentMeta, type SegmentSource, StaticSegmentSource } from "./segment-source"; +import { + ContinuousLiveSegmentSource, + type SegmentMeta, + type SegmentSource, + StaticSegmentSource, +} from "./segment-source"; export interface PipelineCallbacks { onInitSegment: ( @@ -51,6 +56,7 @@ class LoadError extends Error { const HLS_URL_RE = /\.m3u8?($|\?)/i; /** Sentinel rejection value for intentionally cancelled segment loads. */ const CANCELLED = Symbol("cancelled"); +type SourceMode = "continuous-live-ts" | "static-ts-list" | "hls"; /** Copy a Uint8Array view into a standalone (transferable) ArrayBuffer. */ function toArrayBuffer(view: Uint8Array): ArrayBuffer { @@ -73,6 +79,7 @@ class Pipeline { private _source: SegmentSource | null = null; private _hlsSource: HlsSource | null = null; + private _sourceMode: SourceMode = "static-ts-list"; private _demuxer: TSDemuxer | null = null; private _remuxer: MP4Remuxer | null = null; @@ -82,13 +89,6 @@ class Pipeline { private _paused = false; private _resumeGate: (() => void) | null = null; - /** - * Discrete segment sources (HLS, catchup lists) load one short URL per iteration. - * For these, pause only gates between segments — never abort an in-flight fetch - * (which shows up as immediate cancel/retry on later segments). - */ - private _discreteSegments = false; - /** dts offset (ms) to apply when the remuxer is next created (HLS discontinuity / seek). */ private _pendingDtsOffsetMs = 0; @@ -135,15 +135,15 @@ class Pipeline { pause(): void { this._paused = true; - // Continuous single-URL TS streams can pause mid-fetch and resume via Range. - if (!this._discreteSegments) { + // Continuous live TS streams pause mid-fetch and resume with a fresh request. + if (this._sourceMode === "continuous-live-ts") { this._ioctl?.pause(); } } resume(): void { this._paused = false; - if (!this._discreteSegments) { + if (this._sourceMode === "continuous-live-ts") { this._ioctl?.resume(); } this._resumeGate?.(); @@ -170,11 +170,20 @@ class Pipeline { this._workerAudioDecoder?.reset(); this._resetAudioTiming(); - const url = segments[0]?.url ?? ""; - this._discreteSegments = segments.length > 1 || HLS_URL_RE.test(url); - if (segments.length === 1 && HLS_URL_RE.test(url)) { + const firstSegment = segments[0]; + if (!firstSegment) return; + const url = firstSegment.url; + const isHls = segments.length === 1 && HLS_URL_RE.test(url); + const isContinuousLiveTs = segments.length === 1 && !isHls && (firstSegment.duration ?? 0) === 0; + + this._sourceMode = isHls ? "hls" : isContinuousLiveTs ? "continuous-live-ts" : "static-ts-list"; + + if (isHls) { // Fast path: known playlist URL, skip the content-type detection round-trip this._startHls(url); + } else if (isContinuousLiveTs) { + this._source = new ContinuousLiveSegmentSource(firstSegment); + void this._run(this._runId); } else { this._source = new StaticSegmentSource(segments); void this._run(this._runId); @@ -182,7 +191,7 @@ class Pipeline { } private _startHls(url: string, preloaded?: { text: string; url: string }): void { - this._discreteSegments = true; + this._sourceMode = "hls"; const hls = new HlsSource(url, this._config, preloaded); hls.onInfo = (info) => this._callbacks.onHlsInfo(info); this._hlsSource = hls; @@ -212,6 +221,7 @@ class Pipeline { this._fmp4Timescales = new Map(); this._fmp4TimestampOffsetWarningLogged = false; this._paused = false; + this._sourceMode = "static-ts-list"; this._resumeGate?.(); this._resumeGate = null; } @@ -326,6 +336,17 @@ class Pipeline { this._resetAudioTiming(); } + private _prepareContinuousLiveTsRestart(meta: SegmentMeta, ioctl: FetchLoader): void { + if (this._sourceMode !== "continuous-live-ts") { + return; + } + + this._finishStaticTsSegmentBoundary(); + this._fmp4Mode = false; + this._fmp4Chunks = []; + ioctl.onDataArrival = (data, byteStart) => this._onProbeChunk(meta, data, byteStart); + } + private _resetAudioTiming(): void { this._audioGen++; this._audioAnchorPtsMs = null; @@ -343,6 +364,8 @@ class Pipeline { referrerPolicy: this._config.referrerPolicy as ReferrerPolicy | undefined, }, this._config, + undefined, + { resumeMode: this._sourceMode === "continuous-live-ts" ? "restart" : "range" }, ); this._ioctl = ioctl; @@ -351,6 +374,7 @@ class Pipeline { ioctl.onError = (type, info) => reject(new LoadError(type, info)); ioctl.onSeeked = () => this._remuxer?.insertDiscontinuity(); + ioctl.onRestarted = () => this._prepareContinuousLiveTsRestart(meta, ioctl); ioctl.onComplete = () => resolve(); ioctl.onHLSDetected = (text, url) => { // Playlist served from a non-.m3u8 URL: switch the pipeline to the HLS source, @@ -406,14 +430,13 @@ class Pipeline { private _setupTSDemuxerRemuxer(probeData: unknown, meta: SegmentMeta): void { const shouldAnchor = this._shouldAnchorSegment(meta); const canReuseHls = this._hlsSource !== null && !shouldAnchor && this._demuxer !== null && this._remuxer !== null; - const canReuseStatic = - this._hlsSource === null && this._discreteSegments && this._demuxer !== null && this._remuxer !== null; - const canReuse = canReuseHls || canReuseStatic; + const canReuseTsInputBoundary = this._sourceMode !== "hls" && this._demuxer !== null && this._remuxer !== null; + const canReuse = canReuseHls || canReuseTsInputBoundary; if (canReuse) { this._demuxer?.resetSegmentBoundary(probeData as ConstructorParameters[0], { - resetAudioParserState: canReuseStatic, + resetAudioParserState: canReuseTsInputBoundary, }); - this._remuxer?.setTsSegmentContinuityNormalization(canReuseStatic); + this._remuxer?.setTsSegmentContinuityNormalization(canReuseTsInputBoundary); return; } diff --git a/web-ui/src/mpegts/worker/segment-source.ts b/web-ui/src/mpegts/worker/segment-source.ts index 63828482..49266e93 100644 --- a/web-ui/src/mpegts/worker/segment-source.ts +++ b/web-ui/src/mpegts/worker/segment-source.ts @@ -45,3 +45,29 @@ export class StaticSegmentSource implements SegmentSource { this.index = this.metas.length; } } + +/** Single-URL live TS source: each request is a new live stream cycle, not a byte-range continuation. */ +export class ContinuousLiveSegmentSource implements SegmentSource { + private destroyed = false; + private readonly meta: SegmentMeta; + + constructor(segment: PlayerSegment) { + this.meta = { + url: segment.url, + start: 0, + duration: 0, + resetRemuxer: false, + }; + } + + next(): Promise { + if (this.destroyed) { + return Promise.resolve(null); + } + return Promise.resolve({ ...this.meta }); + } + + destroy(): void { + this.destroyed = true; + } +} From 7b5e38f2a04517e03e121f61036e518ba90928b4 Mon Sep 17 00:00:00 2001 From: Stackie Jia Date: Sat, 27 Jun 2026 23:26:13 +0800 Subject: [PATCH 2/6] fix(player): refine backpressure boundary handling --- web-ui/src/mpegts/player/mpegts-player.ts | 8 ++++++-- web-ui/src/mpegts/player/mse.ts | 5 +++++ web-ui/src/mpegts/worker/pipeline.ts | 6 +++--- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/web-ui/src/mpegts/player/mpegts-player.ts b/web-ui/src/mpegts/player/mpegts-player.ts index d29df1b7..16581a4f 100644 --- a/web-ui/src/mpegts/player/mpegts-player.ts +++ b/web-ui/src/mpegts/player/mpegts-player.ts @@ -20,7 +20,7 @@ export function isBuffered(video: HTMLMediaElement, seconds: number): boolean { /** Forward buffer watermarks for HLS VOD/EVENT: pause fetching when far ahead of playback. */ const VOD_FORWARD_BUFFER_PAUSE = 30; -const VOD_FORWARD_BUFFER_RESUME = 15; +const BACKPRESSURE_RESUME_BUFFER_AHEAD = 15; export function createMpegtsPlayer( video: HTMLVideoElement, @@ -166,7 +166,7 @@ export function createMpegtsPlayer( const ahead = getForwardBufferAhead(); if (watermarkPaused || bufferFullPaused) { - if (ahead < VOD_FORWARD_BUFFER_RESUME) { + if (ahead < BACKPRESSURE_RESUME_BUFFER_AHEAD) { resumeWorkerFromBackpressure(); } return; @@ -240,6 +240,10 @@ export function createMpegtsPlayer( updateFetchBackpressure(); }; + mse.onBufferUpdated = () => { + updateFetchBackpressure(); + }; + mse.onStartStreaming = () => { if (watermarkPaused || bufferFullPaused) { updateFetchBackpressure(); diff --git a/web-ui/src/mpegts/player/mse.ts b/web-ui/src/mpegts/player/mse.ts index 12763b60..5d991884 100644 --- a/web-ui/src/mpegts/player/mse.ts +++ b/web-ui/src/mpegts/player/mse.ts @@ -42,6 +42,8 @@ export interface MSE { onBufferFull: (() => void) | null; /** Fired when buffer space becomes available again after a previous onBufferFull. */ onBufferAvailable: (() => void) | null; + /** Fired after SourceBuffer updateend, when buffered ranges may have changed. */ + onBufferUpdated: (() => void) | null; /** ManagedMediaSource: UA wants more media data appended (streaming → true). */ onStartStreaming: (() => void) | null; /** ManagedMediaSource: UA has enough buffered data (streaming → false). */ @@ -289,6 +291,7 @@ export function createMSE(video: HTMLVideoElement, config: PlayerConfig): MSE { } function onSourceBufferUpdateEnd(): void { + mse.onBufferUpdated?.(); tryApplyDuration(); if (hasPendingRemoveRanges()) { doRemoveRanges(); @@ -365,6 +368,7 @@ export function createMSE(video: HTMLVideoElement, config: PlayerConfig): MSE { const mse: MSE = { onBufferFull: null, onBufferAvailable: null, + onBufferUpdated: null, onStartStreaming: null, onEndStreaming: null, onSourceClose: null, @@ -583,6 +587,7 @@ export function createMSE(video: HTMLVideoElement, config: PlayerConfig): MSE { mse.onBufferFull = null; mse.onBufferAvailable = null; + mse.onBufferUpdated = null; mse.onStartStreaming = null; mse.onEndStreaming = null; mse.onSourceClose = null; diff --git a/web-ui/src/mpegts/worker/pipeline.ts b/web-ui/src/mpegts/worker/pipeline.ts index c88146f9..57ae72f4 100644 --- a/web-ui/src/mpegts/worker/pipeline.ts +++ b/web-ui/src/mpegts/worker/pipeline.ts @@ -291,7 +291,7 @@ class Pipeline { } else if (this._hlsSource) { this._remuxer?.flushStashedSamples(); } else { - this._finishStaticTsSegmentBoundary(); + this._finishTsInputBoundary(); } } catch (e) { if (this._runId !== runId || e === CANCELLED) return; @@ -327,7 +327,7 @@ class Pipeline { return meta.resetRemuxer || !this._hlsSource; } - private _finishStaticTsSegmentBoundary(): void { + private _finishTsInputBoundary(): void { // Flush stashed samples at every TS segment boundary so the next segment's first // remux batch is not mixed with the previous segment's tail. this._demuxer?.flushSegmentBoundary(); @@ -341,7 +341,7 @@ class Pipeline { return; } - this._finishStaticTsSegmentBoundary(); + this._finishTsInputBoundary(); this._fmp4Mode = false; this._fmp4Chunks = []; ioctl.onDataArrival = (data, byteStart) => this._onProbeChunk(meta, data, byteStart); From decf29fa31c2614d5c571900503779420754b699 Mon Sep 17 00:00:00 2001 From: Stackie Jia Date: Sat, 27 Jun 2026 23:48:45 +0800 Subject: [PATCH 3/6] fix(player): ignore stale fetch abort callbacks --- web-ui/src/mpegts/io/fetch-loader.ts | 79 ++++++++++++++-------------- 1 file changed, 39 insertions(+), 40 deletions(-) diff --git a/web-ui/src/mpegts/io/fetch-loader.ts b/web-ui/src/mpegts/io/fetch-loader.ts index 4931e836..33b00a3f 100644 --- a/web-ui/src/mpegts/io/fetch-loader.ts +++ b/web-ui/src/mpegts/io/fetch-loader.ts @@ -45,6 +45,12 @@ interface FetchLoaderOptions { resumeMode?: ResumeMode; } +interface FetchRequestContext { + abortController: AbortController; + contentLength: number | null; + receivedLength: number; +} + enum LoaderStatus { kIdle = 0, kConnecting = 1, @@ -88,10 +94,7 @@ class FetchLoader { // --- fetch internals --- private _status: LoaderStatus; - private _requestAbort: boolean; private _abortController: AbortController | null; - private _contentLength: number | null; - private _receivedLength: number; constructor(dataSource: DataSource, config: PlayerConfig, extraData?: unknown, options: FetchLoaderOptions = {}) { this._config = config; @@ -113,10 +116,7 @@ class FetchLoader { // fetch state this._status = LoaderStatus.kIdle; - this._requestAbort = false; this._abortController = null; - this._contentLength = null; - this._receivedLength = 0; // callbacks this.onDataArrival = null; @@ -236,9 +236,11 @@ class FetchLoader { // --- fetch + ReadableStream logic (inlined FetchStreamLoader) ------------ private _startFetch(range: LoaderRange): void { - this._requestAbort = false; - this._contentLength = null; - this._receivedLength = 0; + const request: FetchRequestContext = { + abortController: new self.AbortController(), + contentLength: null, + receivedLength: 0, + }; const dataSource = this._dataSource as DataSource; const sourceURL = dataSource.url; @@ -279,18 +281,15 @@ class FetchLoader { params.referrerPolicy = dataSource.referrerPolicy; } - if (self.AbortController) { - this._abortController = new self.AbortController(); - params.signal = this._abortController.signal; - } + this._abortController = request.abortController; + params.signal = request.abortController.signal; this._status = LoaderStatus.kConnecting; self .fetch(seekConfig.url, params) .then((res: Response) => { - if (this._requestAbort) { - this._status = LoaderStatus.kIdle; + if (this._isRequestAborted(request)) { res.body?.cancel(); return; } @@ -302,7 +301,7 @@ class FetchLoader { this._status = LoaderStatus.kIdle; // Read the body so the already-fetched playlist can be reused (avoids a duplicate request) return res.text().then((text) => { - if (!this._requestAbort) { + if (!this._isRequestAborted(request)) { this.onHLSDetected?.(text, res.url || sourceURL); } }); @@ -313,11 +312,11 @@ class FetchLoader { if (lengthHeader != null) { const cl = parseInt(lengthHeader, 10); if (cl !== 0) { - this._contentLength = cl; + request.contentLength = cl; } } - return this._pump((res.body as ReadableStream).getReader(), range); + return this._pump((res.body as ReadableStream).getReader(), range, request); } else { this._status = LoaderStatus.kError; const errInfo: LoaderErrorInfo = { code: res.status, msg: res.statusText }; @@ -329,7 +328,7 @@ class FetchLoader { } }) .catch((e: unknown) => { - if (this._abortController?.signal.aborted) { + if (this._isRequestAborted(request)) { return; } @@ -344,42 +343,45 @@ class FetchLoader { }); } - private _pump(reader: ReadableStreamDefaultReader, range: LoaderRange): Promise { + private _isRequestAborted(request: FetchRequestContext): boolean { + return request.abortController.signal.aborted; + } + + private _pump( + reader: ReadableStreamDefaultReader, + range: LoaderRange, + request: FetchRequestContext, + ): Promise { return reader .read() .then((result: ReadableStreamReadResult) => { + if (this._isRequestAborted(request)) { + return; + } + if (result.done) { - if (this._contentLength !== null && this._receivedLength < this._contentLength) { + if (request.contentLength !== null && request.receivedLength < request.contentLength) { this._status = LoaderStatus.kError; const info: LoaderErrorInfo = { code: -1, msg: "Fetch stream meet Early-EOF" }; this._handleLoaderError(LoaderErrors.EARLY_EOF, info); } else { this._status = LoaderStatus.kComplete; - this._onFetchComplete(range.from, range.from + this._receivedLength - 1); + this._onFetchComplete(range.from, range.from + request.receivedLength - 1); } } else { - if (this._abortController?.signal.aborted) { - this._status = LoaderStatus.kComplete; - return; - } else if (this._requestAbort === true) { - this._status = LoaderStatus.kComplete; - return reader.cancel() as unknown as undefined; - } - this._status = LoaderStatus.kBuffering; const chunk = result.value as Uint8Array; - const byteStart = range.from + this._receivedLength; - this._receivedLength += chunk.byteLength; + const byteStart = range.from + request.receivedLength; + request.receivedLength += chunk.byteLength; this._onFetchChunkArrival(chunk, byteStart); - this._pump(reader, range); + this._pump(reader, range, request); } }) .catch((e: unknown) => { - if (this._abortController?.signal.aborted) { - this._status = LoaderStatus.kComplete; + if (this._isRequestAborted(request)) { return; } @@ -393,7 +395,8 @@ class FetchLoader { if ( (errCode === 19 || errMsg === "network error") && - (this._contentLength === null || (this._contentLength !== null && this._receivedLength < this._contentLength)) + (request.contentLength === null || + (request.contentLength !== null && request.receivedLength < request.contentLength)) ) { type = LoaderErrors.EARLY_EOF; info = { code: errCode, msg: "Fetch stream meet Early-EOF" }; @@ -407,8 +410,6 @@ class FetchLoader { } private _abortFetch(): void { - this._requestAbort = true; - if (this._abortController) { try { this._abortController.abort(); @@ -431,7 +432,6 @@ class FetchLoader { const requestRange: LoaderRange = { from: bytes, to: -1 }; this._currentRange = { from: requestRange.from, to: -1 }; - this._requestAbort = false; this._startFetch(requestRange); if (this.onSeeked) { @@ -452,7 +452,6 @@ class FetchLoader { const requestRange: LoaderRange = { from: 0, to: -1 }; this._currentRange = { from: requestRange.from, to: -1 }; - this._requestAbort = false; this._startFetch(requestRange); } From 1a704c1a345e3ff4be62de61b79f6af84359ae6a Mon Sep 17 00:00:00 2001 From: Stackie Jia Date: Sun, 28 Jun 2026 00:04:47 +0800 Subject: [PATCH 4/6] fix(player): target live ts go-live from buffer --- web-ui/src/components/player/video-player.tsx | 4 +- web-ui/src/mpegts/player/mpegts-player.ts | 56 ++++++++++++++++++- web-ui/src/mpegts/player/wall-clock.ts | 14 +---- 3 files changed, 59 insertions(+), 15 deletions(-) diff --git a/web-ui/src/components/player/video-player.tsx b/web-ui/src/components/player/video-player.tsx index 983b9097..99bfe3f6 100644 --- a/web-ui/src/components/player/video-player.tsx +++ b/web-ui/src/components/player/video-player.tsx @@ -251,9 +251,7 @@ export function VideoPlayer({ const goLiveToSessionEdge = useEffectEvent(() => { if (!liveSessionAnchor) return; - const video = getActiveVideo(); - const currentTime = video?.currentTime ?? 0; - const targetMse = goLiveTargetMse(liveSessionAnchor, defaultConfig.liveSyncTargetLatency, currentTime); + const targetMse = goLiveTargetMse(liveSessionAnchor, defaultConfig.liveSyncTargetLatency); getActivePlayer()?.goLive(targetMse); getActivePlayer()?.setLiveSync(true); }); diff --git a/web-ui/src/mpegts/player/mpegts-player.ts b/web-ui/src/mpegts/player/mpegts-player.ts index 16581a4f..cece5c03 100644 --- a/web-ui/src/mpegts/player/mpegts-player.ts +++ b/web-ui/src/mpegts/player/mpegts-player.ts @@ -21,6 +21,9 @@ export function isBuffered(video: HTMLMediaElement, seconds: number): boolean { /** Forward buffer watermarks for HLS VOD/EVENT: pause fetching when far ahead of playback. */ const VOD_FORWARD_BUFFER_PAUSE = 30; const BACKPRESSURE_RESUME_BUFFER_AHEAD = 15; +const HLS_URL_RE = /\.m3u8?($|\?)/i; + +type SourceMode = "continuous-live-ts" | "static-ts-list" | "hls"; export function createMpegtsPlayer( video: HTMLVideoElement, @@ -36,6 +39,7 @@ export function createMpegtsPlayer( let liveSyncEnabled = config.liveSync; /** Live edge assuming continuous playback since session start. */ let liveSessionAnchor: LiveSessionAnchor | null = null; + let sourceMode: SourceMode = "static-ts-list"; let hlsVodThrottleEnabled = false; let watermarkPaused = false; @@ -107,6 +111,7 @@ export function createMpegtsPlayer( mse?.endOfStream(); break; case "hls-info": + sourceMode = "hls"; if (!msg.live) { mse?.setDuration(msg.totalDuration); hlsVodThrottleEnabled = true; @@ -147,6 +152,23 @@ export function createMpegtsPlayer( return 0; } + function getContinuousLiveTsGoLiveTarget(): number | null { + const buffered = video.buffered; + if (buffered.length === 0) { + return null; + } + + const lastRange = buffered.length - 1; + const start = buffered.start(lastRange); + const end = buffered.end(lastRange); + const target = end - config.liveSyncTargetLatency; + + if (target < start || target > end) { + return null; + } + return target; + } + function pauseWorkerForBackpressure(kind: "watermark" | "buffer-full"): void { if (kind === "watermark") { watermarkPaused = true; @@ -201,6 +223,36 @@ export function createMpegtsPlayer( } } + function goLiveTo(targetMseSeconds: number): void { + if (sourceMode === "continuous-live-ts") { + const bufferedTarget = getContinuousLiveTsGoLiveTarget(); + if (bufferedTarget !== null && bufferSeek(bufferedTarget)) { + return; + } + // Use the session target for the fallback so the outer handler treats it as a live-edge reload. + for (const h of seekHandlers) { + h(targetMseSeconds); + } + return; + } + + seekTo(targetMseSeconds); + } + + function inferSourceMode(segments: PlayerSegment[]): SourceMode { + const firstSegment = segments[0]; + if (!firstSegment) { + return "static-ts-list"; + } + if (segments.length === 1 && HLS_URL_RE.test(firstSegment.url)) { + return "hls"; + } + if (segments.length === 1 && (firstSegment.duration ?? 0) === 0) { + return "continuous-live-ts"; + } + return "static-ts-list"; + } + function resetFetchBackpressure(): void { hlsVodThrottleEnabled = false; watermarkPaused = false; @@ -299,6 +351,7 @@ export function createMpegtsPlayer( mseGeneration++; pendingInits = []; pendingSegments = segments; + sourceMode = inferSourceMode(segments); resetFetchBackpressure(); if (mse) { mse.destroy(); @@ -325,7 +378,7 @@ export function createMpegtsPlayer( }, goLive(targetMseSeconds: number) { - seekTo(targetMseSeconds); + goLiveTo(targetMseSeconds); }, setLiveSessionAnchor(anchor: LiveSessionAnchor) { @@ -337,6 +390,7 @@ export function createMpegtsPlayer( resetFetchBackpressure(); pendingInits = []; pendingSegments = null; + sourceMode = "static-ts-list"; if (worker) { const cmd: WorkerCommand = { type: "reset" }; worker.postMessage(cmd); diff --git a/web-ui/src/mpegts/player/wall-clock.ts b/web-ui/src/mpegts/player/wall-clock.ts index 6d74aabe..bef193ca 100644 --- a/web-ui/src/mpegts/player/wall-clock.ts +++ b/web-ui/src/mpegts/player/wall-clock.ts @@ -35,18 +35,10 @@ export function lagBehindLiveEdge(anchor: LiveSessionAnchor, currentTime: number return liveEdgeMse(anchor, nowMs) - currentTime; } -/** MSE seek target for Go Live: session live edge minus target latency, never behind currentTime. */ -export function goLiveTargetMse( - anchor: LiveSessionAnchor, - targetLatencySec: number, - currentTime: number, - nowMs = Date.now(), -): number { +/** MSE seek target for Go Live: session live edge minus target latency. */ +export function goLiveTargetMse(anchor: LiveSessionAnchor, targetLatencySec: number, nowMs = Date.now()): number { const edge = liveEdgeMse(anchor, nowMs); - const target = edge - targetLatencySec; - // HLS startup often places the playhead near the live edge already (e.g. ~2s in); - // clamp so Go Live never seeks backward when already live enough. - return Math.max(currentTime, target); + return edge - targetLatencySec; } /** Create anchor at the current playhead (call once when live session begins). */ From f3db6ee19be5cb76fb50c88d4fd111413f06bd4d Mon Sep 17 00:00:00 2001 From: Stackie Jia Date: Sun, 28 Jun 2026 00:13:38 +0800 Subject: [PATCH 5/6] fix(player): emit live state from player --- web-ui/src/components/player/video-player.tsx | 18 ++++- web-ui/src/mpegts/index.ts | 8 ++ web-ui/src/mpegts/player/mpegts-player.ts | 76 +++++++++++++++++-- web-ui/src/mpegts/types.ts | 2 + 4 files changed, 92 insertions(+), 12 deletions(-) diff --git a/web-ui/src/components/player/video-player.tsx b/web-ui/src/components/player/video-player.tsx index 99bfe3f6..1d475c4b 100644 --- a/web-ui/src/components/player/video-player.tsx +++ b/web-ui/src/components/player/video-player.tsx @@ -18,7 +18,6 @@ import { goLiveTargetMse, isNearLiveWallClock, type LiveSessionAnchor, - lagBehindLiveEdge, wallClockToMse, } from "../../mpegts/player/wall-clock"; import mp2WasmUrl from "../../mpegts/wasm/minimp3/mp2_decoder.wasm?url"; @@ -156,6 +155,7 @@ export function VideoPlayer({ const slotBVideoRef = useRef(null); const slotAPlayerRef = useRef(null); const slotBPlayerRef = useRef(null); + const slotLiveStateRef = useRef>({ a: true, b: true }); const activeSlotIdRef = useRef("a"); const [visibleSlotId, setVisibleSlotId] = useState("a"); const transitionGenRef = useRef(0); @@ -180,9 +180,7 @@ export function VideoPlayer({ const [isMuted, setIsMuted] = useState(() => getMuted()); const [isPlaying, setIsPlaying] = useState(false); const [liveSessionAnchor, setLiveSessionAnchor] = useState(null); - // Before session calibration (initial load / reload), treat live mode as Live — not Go Live. - const isLive = - playMode === "live" && (liveSessionAnchor === null || lagBehindLiveEdge(liveSessionAnchor, currentVideoTime) < 3); + const [isLive, setIsLive] = useState(true); const [needsUserInteraction, setNeedsUserInteraction] = useState(false); const [showControls, setShowControls] = useState(true); const [isPiP, setIsPiP] = useState(false); @@ -388,6 +386,7 @@ export function VideoPlayer({ // Hard switch: reveal new stream first, then tear down the old slot activeSlotIdRef.current = newActiveId; setVisibleSlotId(newActiveId); + setIsLive(slotLiveStateRef.current[newActiveId]); setIsLoading(false); if (oldActiveId !== newActiveId && oldPlayer) { @@ -570,6 +569,17 @@ export function VideoPlayer({ handleSeekNeeded(seconds); } }); + p.on("live-state-change", (live) => { + if (slotPlayerRef(slotId).current !== p) return; + slotLiveStateRef.current[slotId] = live; + if (slotId === getActiveSlotId()) { + setIsLive(live); + const video = slotVideoRef(slotId).current; + if (!live && video?.paused && playMode === "live") { + p.setLiveSync(false); + } + } + }); p.on("audio-suspended", () => { if (slotPlayerRef(slotId).current === p) { handleAudioSuspended(); diff --git a/web-ui/src/mpegts/index.ts b/web-ui/src/mpegts/index.ts index 1b60ed73..11e20691 100644 --- a/web-ui/src/mpegts/index.ts +++ b/web-ui/src/mpegts/index.ts @@ -40,6 +40,7 @@ export function createPlayer(video: HTMLVideoElement, config?: Partial void>(); const seekHandlers = new Set<(s: number) => void>(); + const liveStateHandlers = new Set<(isLive: boolean) => void>(); const audioSuspendedHandlers = new Set<() => void>(); let impl: PlayerImpl | null = null; @@ -52,6 +53,11 @@ export function createPlayer(video: HTMLVideoElement, config?: Partial { + for (const h of liveStateHandlers) { + h(isLive); + } + }; impl.onAudioSuspended = () => { for (const h of audioSuspendedHandlers) { h(); @@ -97,12 +103,14 @@ export function createPlayer(video: HTMLVideoElement, config?: Partial(event: K, handler: PlayerEventMap[K]) { if (event === "error") errorHandlers.add(handler as (e: PlayerError) => void); if (event === "seek-needed") seekHandlers.add(handler as (s: number) => void); + if (event === "live-state-change") liveStateHandlers.add(handler as (isLive: boolean) => void); if (event === "audio-suspended") audioSuspendedHandlers.add(handler as () => void); }, off(event: K, handler: PlayerEventMap[K]) { if (event === "error") errorHandlers.delete(handler as (e: PlayerError) => void); if (event === "seek-needed") seekHandlers.delete(handler as (s: number) => void); + if (event === "live-state-change") liveStateHandlers.delete(handler as (isLive: boolean) => void); if (event === "audio-suspended") audioSuspendedHandlers.delete(handler as () => void); }, }; diff --git a/web-ui/src/mpegts/player/mpegts-player.ts b/web-ui/src/mpegts/player/mpegts-player.ts index cece5c03..48ecc369 100644 --- a/web-ui/src/mpegts/player/mpegts-player.ts +++ b/web-ui/src/mpegts/player/mpegts-player.ts @@ -5,7 +5,7 @@ import type { WorkerCommand, WorkerEvent } from "../worker/messages"; import TransmuxWorker from "../worker/transmux-worker.ts?worker&inline"; import { setupLiveSync } from "./live-sync"; import { createMSE, type MSE } from "./mse"; -import type { LiveSessionAnchor } from "./wall-clock"; +import { type LiveSessionAnchor, lagBehindLiveEdge } from "./wall-clock"; /** Check if a given time position is within any buffered range of the video element. */ export function isBuffered(video: HTMLMediaElement, seconds: number): boolean { @@ -21,6 +21,7 @@ export function isBuffered(video: HTMLMediaElement, seconds: number): boolean { /** Forward buffer watermarks for HLS VOD/EVENT: pause fetching when far ahead of playback. */ const VOD_FORWARD_BUFFER_PAUSE = 30; const BACKPRESSURE_RESUME_BUFFER_AHEAD = 15; +const LIVE_STATE_TOLERANCE = 3; const HLS_URL_RE = /\.m3u8?($|\?)/i; type SourceMode = "continuous-live-ts" | "static-ts-list" | "hls"; @@ -40,6 +41,8 @@ export function createMpegtsPlayer( /** Live edge assuming continuous playback since session start. */ let liveSessionAnchor: LiveSessionAnchor | null = null; let sourceMode: SourceMode = "static-ts-list"; + let hlsLive: boolean | null = null; + let lastLiveState: boolean | null = null; let hlsVodThrottleEnabled = false; let watermarkPaused = false; @@ -112,6 +115,8 @@ export function createMpegtsPlayer( break; case "hls-info": sourceMode = "hls"; + hlsLive = msg.live; + updateLiveState(); if (!msg.live) { mse?.setDuration(msg.totalDuration); hlsVodThrottleEnabled = true; @@ -152,23 +157,65 @@ export function createMpegtsPlayer( return 0; } - function getContinuousLiveTsGoLiveTarget(): number | null { + function getLastBufferedRange(): { start: number; end: number } | null { const buffered = video.buffered; if (buffered.length === 0) { return null; } const lastRange = buffered.length - 1; - const start = buffered.start(lastRange); - const end = buffered.end(lastRange); - const target = end - config.liveSyncTargetLatency; + return { start: buffered.start(lastRange), end: buffered.end(lastRange) }; + } + + function getContinuousLiveTsGoLiveTarget(): number | null { + const range = getLastBufferedRange(); + if (!range) { + return null; + } + + const target = range.end - config.liveSyncTargetLatency; - if (target < start || target > end) { + if (target < range.start || target > range.end) { return null; } return target; } + function isContinuousLiveTsLive(): boolean { + const range = getLastBufferedRange(); + if (!range) { + return true; + } + + const target = Math.max(range.start, range.end - config.liveSyncTargetLatency); + return video.currentTime >= target - LIVE_STATE_TOLERANCE && video.currentTime <= range.end + LIVE_STATE_TOLERANCE; + } + + function computeLiveState(): boolean { + if (sourceMode === "continuous-live-ts") { + return isContinuousLiveTsLive(); + } + if (sourceMode === "hls") { + if (hlsLive === false) { + return false; + } + if (!liveSessionAnchor) { + return true; + } + return lagBehindLiveEdge(liveSessionAnchor, video.currentTime) < LIVE_STATE_TOLERANCE; + } + return false; + } + + function updateLiveState(): void { + const next = computeLiveState(); + if (next === lastLiveState) { + return; + } + lastLiveState = next; + impl.onLiveStateChange?.(next); + } + function pauseWorkerForBackpressure(kind: "watermark" | "buffer-full"): void { if (kind === "watermark") { watermarkPaused = true; @@ -202,6 +249,7 @@ export function createMpegtsPlayer( /** Re-evaluate fetching after an in-buffer seek (worker may have paused on buffer full). */ function resumeWorkerAfterBufferSeek(): void { updateFetchBackpressure(); + updateLiveState(); } /** Seek within existing MSE buffer without reloading the stream. */ @@ -294,6 +342,7 @@ export function createMpegtsPlayer( mse.onBufferUpdated = () => { updateFetchBackpressure(); + updateLiveState(); }; mse.onStartStreaming = () => { @@ -339,8 +388,14 @@ export function createMpegtsPlayer( } } - const onVideoPlay = () => markPlaybackUnlocked(); - const onVideoTimeUpdate = () => updateFetchBackpressure(); + const onVideoPlay = () => { + markPlaybackUnlocked(); + updateLiveState(); + }; + const onVideoTimeUpdate = () => { + updateFetchBackpressure(); + updateLiveState(); + }; video.addEventListener("play", onVideoPlay); video.addEventListener("timeupdate", onVideoTimeUpdate); @@ -352,7 +407,9 @@ export function createMpegtsPlayer( pendingInits = []; pendingSegments = segments; sourceMode = inferSourceMode(segments); + hlsLive = null; resetFetchBackpressure(); + updateLiveState(); if (mse) { mse.destroy(); mse = null; @@ -383,6 +440,7 @@ export function createMpegtsPlayer( setLiveSessionAnchor(anchor: LiveSessionAnchor) { liveSessionAnchor = anchor; + updateLiveState(); }, suspend() { @@ -391,6 +449,8 @@ export function createMpegtsPlayer( pendingInits = []; pendingSegments = null; sourceMode = "static-ts-list"; + hlsLive = null; + updateLiveState(); if (worker) { const cmd: WorkerCommand = { type: "reset" }; worker.postMessage(cmd); diff --git a/web-ui/src/mpegts/types.ts b/web-ui/src/mpegts/types.ts index 35acafff..e018e3fd 100644 --- a/web-ui/src/mpegts/types.ts +++ b/web-ui/src/mpegts/types.ts @@ -16,6 +16,7 @@ export interface PlayerError { export interface PlayerEventMap { error: (error: PlayerError) => void; "seek-needed": (seconds: number) => void; + "live-state-change": (isLive: boolean) => void; /** Fired when audio playback is blocked by autoplay policy and requires user interaction. */ "audio-suspended": () => void; } @@ -38,6 +39,7 @@ export interface Player { /** Internal player implementation interface */ export interface PlayerImpl { onError: ((error: PlayerError) => void) | null; + onLiveStateChange?: ((isLive: boolean) => void) | null; onAudioSuspended?: (() => void) | null; loadSegments(segments: PlayerSegment[]): void; seek(seconds: number): void; From 9e1c4b616f393e84e9ef36792dd09be85fd5e5e0 Mon Sep 17 00:00:00 2001 From: Stackie Jia Date: Sun, 28 Jun 2026 00:29:38 +0800 Subject: [PATCH 6/6] fix(player): split live sync edge tracking by source --- web-ui/src/mpegts/player/live-sync.ts | 16 ++++++---------- web-ui/src/mpegts/player/mpegts-player.ts | 19 +++++++++++++++++-- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/web-ui/src/mpegts/player/live-sync.ts b/web-ui/src/mpegts/player/live-sync.ts index 205e1d3b..a6ae15ec 100644 --- a/web-ui/src/mpegts/player/live-sync.ts +++ b/web-ui/src/mpegts/player/live-sync.ts @@ -1,6 +1,5 @@ import type { PlayerConfig } from "../config"; import Log from "../utils/logger"; -import { type LiveSessionAnchor, lagBehindLiveEdge } from "./wall-clock"; const TAG = "LiveSync"; @@ -25,7 +24,7 @@ function forwardBufferAhead(video: HTMLMediaElement): number { export function setupLiveSync( video: HTMLMediaElement, config: PlayerConfig, - getLiveSessionAnchor: () => LiveSessionAnchor | null, + getLiveEdgeLatency: () => number | null, ): () => void { if (config.liveSync) { Log.v( @@ -42,10 +41,8 @@ export function setupLiveSync( function onTimeUpdate(): void { if (!config.liveSync) return; - const anchor = getLiveSessionAnchor(); - if (!anchor) return; - - const latency = lagBehindLiveEdge(anchor, video.currentTime); + const latency = getLiveEdgeLatency(); + if (latency === null) return; if (latency > config.liveSyncMaxLatency + extraLatency) { const targetRate = Math.min(2, Math.max(1, config.liveSyncPlaybackRate)); @@ -71,12 +68,11 @@ export function setupLiveSync( // Seek/Go Live often fires waiting while data is still buffered ahead — not an underrun. if (video.seeking) return; - const anchor = getLiveSessionAnchor(); - if (!anchor) return; + const lag = getLiveEdgeLatency(); + if (lag === null) return; - const lag = lagBehindLiveEdge(anchor, video.currentTime); const ahead = forwardBufferAhead(video); - // Near session live edge AND playhead has caught up with its forward buffer. + // Near source-mode live edge AND playhead has caught up with its forward buffer. const atLiveEdge = lag < 0.5 && ahead < 0.5; if (!atLiveEdge) return; diff --git a/web-ui/src/mpegts/player/mpegts-player.ts b/web-ui/src/mpegts/player/mpegts-player.ts index 48ecc369..86f4c85f 100644 --- a/web-ui/src/mpegts/player/mpegts-player.ts +++ b/web-ui/src/mpegts/player/mpegts-player.ts @@ -191,6 +191,21 @@ export function createMpegtsPlayer( return video.currentTime >= target - LIVE_STATE_TOLERANCE && video.currentTime <= range.end + LIVE_STATE_TOLERANCE; } + /** Seconds behind the source-mode live edge; live-sync keeps this near the target latency. */ + function getLiveEdgeLatency(): number | null { + if (sourceMode === "continuous-live-ts") { + const range = getLastBufferedRange(); + return range ? range.end - video.currentTime : null; + } + if (sourceMode === "hls") { + if (hlsLive === false || !liveSessionAnchor) { + return null; + } + return lagBehindLiveEdge(liveSessionAnchor, video.currentTime); + } + return null; + } + function computeLiveState(): boolean { if (sourceMode === "continuous-live-ts") { return isContinuousLiveTsLive(); @@ -384,7 +399,7 @@ export function createMpegtsPlayer( function initLiveHelpers(): void { if (!destroyLiveSync && liveSyncEnabled) { - destroyLiveSync = setupLiveSync(video, config, () => liveSessionAnchor); + destroyLiveSync = setupLiveSync(video, config, getLiveEdgeLatency); } } @@ -422,7 +437,7 @@ export function createMpegtsPlayer( setLiveSync(enabled: boolean) { if (enabled && !destroyLiveSync) { liveSyncEnabled = true; - destroyLiveSync = setupLiveSync(video, config, () => liveSessionAnchor); + destroyLiveSync = setupLiveSync(video, config, getLiveEdgeLatency); } else if (!enabled && destroyLiveSync) { liveSyncEnabled = false; destroyLiveSync();