Files
v10/packages/spf/src/dom/features/update-duration.ts
T

137 lines
4.4 KiB
TypeScript

import { combineLatest } from '../../core/reactive/combine-latest';
import type { WritableState } from '../../core/state/create-state';
import type { Presentation } from '../../core/types';
import { hasPresentationDuration } from '../../core/types';
export interface DurationUpdateState {
presentation?: Presentation;
}
export interface DurationUpdateOwners {
mediaSource?: MediaSource;
videoSourceBuffer?: SourceBuffer;
audioSourceBuffer?: SourceBuffer;
}
/**
* Check if we can update MediaSource duration (have required data).
*/
export function canUpdateDuration(state: DurationUpdateState, owners: DurationUpdateOwners): boolean {
return !!(owners.mediaSource && state.presentation && hasPresentationDuration(state.presentation));
}
/**
* Get the maximum buffered end time across all SourceBuffers.
*/
export function getMaxBufferedEnd(owners: DurationUpdateOwners): number {
let maxEnd = 0;
const buffers = [owners.videoSourceBuffer, owners.audioSourceBuffer].filter(
(buf): buf is SourceBuffer => buf !== undefined
);
for (const buffer of buffers) {
const { buffered } = buffer;
if (buffered.length > 0) {
const end = buffered.end(buffered.length - 1);
if (end > maxEnd) {
maxEnd = end;
}
}
}
return maxEnd;
}
/**
* Check if we should update MediaSource duration (conditions met).
*/
export function shouldUpdateDuration(state: DurationUpdateState, owners: DurationUpdateOwners): boolean {
if (!canUpdateDuration(state, owners)) return false;
const { mediaSource } = owners;
const { presentation } = state;
// MediaSource must be open
if (mediaSource!.readyState !== 'open') return false;
const duration = presentation!.duration!;
// Validate duration: finite, positive, not NaN
if (!Number.isFinite(duration) || Number.isNaN(duration) || duration <= 0) return false;
// Only set duration on initial MediaSource setup, when it hasn't been set yet.
// A freshly opened MediaSource has duration === NaN. Once set (either by this
// task or by endOfStreamTask from buffered.end()), we leave it alone — attempting
// to re-sync a slight drift between mediaSource.duration and presentation.duration
// races with concurrent appendBuffer() calls from loadSegmentsTask.
return Number.isNaN(mediaSource!.duration);
}
/**
* Wait for all currently-updating SourceBuffers to finish.
*
* The MSE spec forbids setting MediaSource.duration while any attached
* SourceBuffer has updating === true. This defers until all are idle.
*/
function waitForSourceBuffersReady(owners: DurationUpdateOwners): Promise<void> {
const updating = [owners.videoSourceBuffer, owners.audioSourceBuffer].filter(
(buf): buf is SourceBuffer => buf?.updating === true
);
if (updating.length === 0) return Promise.resolve();
return Promise.all(
updating.map(
(buf) => new Promise<void>((resolve) => buf.addEventListener('updateend', () => resolve(), { once: true }))
)
).then(() => undefined);
}
/**
* Update MediaSource duration when presentation duration becomes available.
*/
export function updateDuration({
state,
owners,
}: {
state: WritableState<DurationUpdateState>;
owners: WritableState<DurationUpdateOwners>;
}): () => void {
let destroyed = false;
const unsubscribe = combineLatest([state, owners]).subscribe(
async ([currentState, currentOwners]: [DurationUpdateState, DurationUpdateOwners]) => {
if (!shouldUpdateDuration(currentState, currentOwners)) return;
const { mediaSource } = currentOwners;
// MSE spec: duration cannot be set while any SourceBuffer is updating
await waitForSourceBuffersReady(currentOwners);
// Re-check after async wait: destroyed, or readyState changed (e.g. endOfStream
// already called endOfStream(), transitioning 'open' → 'ended').
if (destroyed || mediaSource!.readyState !== 'open') return;
let duration = currentState.presentation!.duration!;
// Get max buffered end time across all SourceBuffers
const maxBufferedEnd = getMaxBufferedEnd(currentOwners);
// MSE spec: duration cannot be less than any buffered range
// If buffered ranges exceed calculated duration, extend to match
if (maxBufferedEnd > duration) {
duration = maxBufferedEnd;
}
// Set duration on MediaSource
mediaSource!.duration = duration;
}
);
return () => {
destroyed = true;
unsubscribe();
};
}