mirror of
https://github.com/zoriya/v10.git
synced 2026-08-13 01:19:44 +00:00
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:
co-authored by
Claude Opus 4.8
parent
c3e137cf39
commit
be8d28d687
@@ -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
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user