diff --git a/packages/spf/src/media/dom/mse/duration.ts b/packages/spf/src/media/dom/mse/duration.ts index 8c446fb4..89a5ee0d 100644 --- a/packages/spf/src/media/dom/mse/duration.ts +++ b/packages/spf/src/media/dom/mse/duration.ts @@ -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 diff --git a/packages/spf/src/media/dom/mse/tests/duration.test.ts b/packages/spf/src/media/dom/mse/tests/duration.test.ts index d4003ca9..fdbcde7a 100644 --- a/packages/spf/src/media/dom/mse/tests/duration.test.ts +++ b/packages/spf/src/media/dom/mse/tests/duration.test.ts @@ -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(); diff --git a/packages/spf/src/playback/behaviors/dom/recover-end-stall.ts b/packages/spf/src/playback/behaviors/dom/recover-end-stall.ts index fae9d4b3..d3890976 100644 --- a/packages/spf/src/playback/behaviors/dom/recover-end-stall.ts +++ b/packages/spf/src/playback/behaviors/dom/recover-end-stall.ts @@ -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 );