refactor(spf): read recover-end-stall buffered end from SourceBuffers (spike)

Extract `getMinBufferedEnd(buffers)` into media/dom/mse/duration.ts — the pure
counterpart to `getMaxBufferedEnd` (max bounds the presentation end for
`duration`; min bounds the reachable end when tracks end at slightly different
times, e.g. skewed A/V near end-of-stream).

`recover-end-stall` now sources its reachable buffered end from
`getMinBufferedEnd(mediaSource.sourceBuffers)` — the per-track (video/audio)
SourceBuffer ends — instead of the `mediaElement.buffered` aggregate.
Functionally equivalent (the aggregate is the intersection) but reads the
SourceBuffers directly and shares a named primitive. The `shouldForceEnded`
predicate is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Christian Pillsbury
2026-07-14 06:57:10 -07:00
co-authored by Claude Opus 4.8
parent c3e137cf39
commit be8d28d687
3 changed files with 91 additions and 12 deletions
@@ -55,6 +55,32 @@ export function getMaxBufferedEnd(buffers: SourceBufferIterable): number {
return maxEnd;
}
/**
* Get the reachable buffered end across an iterable of SourceBuffers (typically
* `mediaSource.sourceBuffers`): the `min` of each buffer's last buffered-range end
* — the furthest point every track can play to (the intersection end). Returns
* `undefined` when the collection is empty or any buffer has no buffered ranges
* (no common reachable point).
*
* Counterpart to {@link getMaxBufferedEnd}: `max` bounds the overall presentation
* end (e.g. for setting `duration`), `min` bounds where playback can actually reach
* when tracks end at slightly different times (e.g. skewed A/V near end-of-stream).
*/
export function getMinBufferedEnd(buffers: SourceBufferIterable): number | undefined {
let minEnd: number | undefined;
for (const buffer of buffers) {
const { buffered } = buffer;
if (buffered.length === 0) return undefined;
const end = buffered.end(buffered.length - 1);
if (minEnd === undefined || end < minEnd) {
minEnd = end;
}
}
return minEnd;
}
/**
* Check if the preconditions are met to *attempt* a `mediaSource.duration`
* write: a `mediaSource` is in scope and the presentation has a valid
@@ -1,6 +1,12 @@
import { describe, expect, it, vi } from 'vitest';
import type { Presentation } from '../../../types';
import { canUpdateDuration, getMaxBufferedEnd, shouldUpdateDuration, waitForSourceBuffersReady } from '../duration';
import {
canUpdateDuration,
getMaxBufferedEnd,
getMinBufferedEnd,
shouldUpdateDuration,
waitForSourceBuffersReady,
} from '../duration';
function makeUpdatingSourceBuffer() {
const updateEndListeners: Array<() => void> = [];
@@ -131,6 +137,51 @@ describe('getMaxBufferedEnd', () => {
});
});
describe('getMinBufferedEnd', () => {
it('returns undefined when the buffer list is empty', () => {
expect(getMinBufferedEnd([])).toBeUndefined();
});
it('returns the min last-range end across buffers (the reachable/intersection end)', () => {
// Skewed A/V: video buffered slightly past audio; reachable end is the audio (min).
const video = {
buffered: { length: 1, start: () => 0, end: () => 600.044 } as TimeRanges,
} as unknown as SourceBuffer;
const audio = {
buffered: { length: 1, start: () => 0, end: () => 600.0 } as TimeRanges,
} as unknown as SourceBuffer;
expect(getMinBufferedEnd([video, audio])).toBe(600.0);
});
it('returns undefined when any buffer has no buffered ranges (no common reachable point)', () => {
const empty = {
buffered: { length: 0, start: () => 0, end: () => 0 } as TimeRanges,
} as unknown as SourceBuffer;
const buffered = {
buffered: { length: 1, start: () => 0, end: () => 30 } as TimeRanges,
} as unknown as SourceBuffer;
expect(getMinBufferedEnd([empty, buffered])).toBeUndefined();
});
it('uses the last range end when a buffer has multiple (gapped) ranges', () => {
const gapped = {
buffered: { length: 2, start: (i: number) => (i === 0 ? 0 : 12), end: (i: number) => (i === 0 ? 10 : 30) },
} as unknown as SourceBuffer;
expect(getMinBufferedEnd([gapped])).toBe(30);
});
it('works against a single-buffer audio-only configuration', () => {
const audio = {
buffered: { length: 1, start: () => 0, end: () => 42 } as TimeRanges,
} as unknown as SourceBuffer;
expect(getMinBufferedEnd([audio])).toBe(42);
});
});
describe('waitForSourceBuffersReady', () => {
it('resolves immediately when the buffer list is empty', async () => {
const controller = new AbortController();
@@ -12,14 +12,15 @@
* (measured ~0ms latency), so there's nothing to gain from polling — and polling would
* add its interval + a stall threshold before reacting.
*
* **Proximity to the *intersection* buffered end** is the discriminator.
* `mediaElement.buffered.end(last)` is already `min(videoEnd, audioEnd)` — the furthest
* point playback can reach — and once `endOfStream` is signalled that's the true content
* end. Requiring the playhead within `endStallNudgeWindow` of it distinguishes the real
* end-of-stream freeze from a mid-stream buffer-hole stall (which sits far from the
* buffered end), so we never skip content. The window must exceed the freeze gap; too
* small would miss the stall (a permanent hang), so the default is generous relative to
* the measured gap and is config-tunable for empirical tuning.
* **Proximity to the *reachable* buffered end** is the discriminator. That end is
* `getMinBufferedEnd(mediaSource.sourceBuffers)` — the `min` of the per-track (video/audio)
* SourceBuffer ends, i.e. the furthest point playback can reach — read from the SourceBuffers
* directly rather than the `mediaElement.buffered` aggregate. Once `endOfStream` is signalled
* that's the true content end. Requiring the playhead within `endStallNudgeWindow` of it
* distinguishes the real end-of-stream freeze from a mid-stream buffer-hole stall (which sits
* far from the buffered end), so we never skip content. The window must exceed the freeze gap;
* too small would miss the stall (a permanent hang), so the default is generous relative to the
* measured gap and is config-tunable for empirical tuning.
*
* Inert where it shouldn't act: live (the MediaSource never reaches `ended` while the
* window grows; `duration` is `Infinity`) and streams that end cleanly (no `waiting`).
@@ -30,6 +31,7 @@ import { listen } from '@videojs/utils/dom';
import { defineBehavior } from '../../../core/composition/create-composition';
import { effect } from '../../../core/signals/effect';
import type { ReadonlySignal } from '../../../core/signals/primitives';
import { getMinBufferedEnd } from '../../../media/dom/mse/duration';
export interface RecoverEndStallContext {
mediaElement?: HTMLMediaElement | undefined;
@@ -92,16 +94,16 @@ function recoverEndStallSetup({
if (!mediaElement) return;
const onWaiting = () => {
const { buffered } = mediaElement;
const mediaSource = context.mediaSource.get();
const forceEnded = shouldForceEnded(
{
msEnded: context.mediaSource.get()?.readyState === 'ended',
msEnded: mediaSource?.readyState === 'ended',
durationFinite: Number.isFinite(mediaElement.duration),
paused: mediaElement.paused,
seeking: mediaElement.seeking,
ended: mediaElement.ended,
currentTime: mediaElement.currentTime,
bufferedEnd: buffered.length > 0 ? buffered.end(buffered.length - 1) : undefined,
bufferedEnd: mediaSource ? getMinBufferedEnd(mediaSource.sourceBuffers) : undefined,
},
nudgeWindow
);