import { createMachineActor, type HandlerContext, type MessageActor } from '../../../core/actors/create-machine-actor'; import { SerialRunner, Task } from '../../../core/tasks/task'; import { type AppendData, appendSegment } from '../../../media/dom/mse/append-segment'; import { flushBuffer } from '../../../media/dom/mse/buffer-flusher'; import { SEGMENT_TIME_EPSILON, type Segment, type Track } from '../../../media/types'; // ============================================================================= // Types // ============================================================================= export interface BufferedRange { start: number; end: number; } export type AppendSegmentMeta = Pick & { trackId: Track['id']; /** Declared track bandwidth in bps (from playlist BANDWIDTH attribute). */ trackBandwidth?: number; }; export type { AppendData }; export type AppendInitMessage = { type: 'append-init'; data: AppendData; /** * `language` is captured alongside `trackId` so downstream loaders can * compare the buffered track's language to the newly-selected track's * language and decide whether ahead-buffer flush is warranted on track * switch (see `segment-loader`'s `planTasks`). Undefined for video and * for audio without explicit `LANGUAGE` attribute. */ meta: { trackId: Track['id']; language?: string }; }; export type AppendSegmentMessage = { type: 'append-segment'; data: AppendData; meta: AppendSegmentMeta }; export type RemoveMessage = { type: 'remove'; start: number; end: number }; export type IndividualSourceBufferMessage = AppendInitMessage | AppendSegmentMessage | RemoveMessage; export type BatchMessage = { type: 'batch'; messages: IndividualSourceBufferMessage[] }; export type CancelMessage = { type: 'cancel' }; /** All messages accepted by a SourceBufferActor. */ export type SourceBufferMessage = IndividualSourceBufferMessage | BatchMessage | CancelMessage; /** Finite states of the actor. */ export type SourceBufferActorState = 'idle' | 'updating' | 'destroyed'; /** Non-finite (extended) data managed by the actor — the XState "context". */ export interface SourceBufferActorContext { initTrackId?: string | undefined; /** * Language of the most recently appended init segment's track (when * present on the playlist). Used by the segment-loader's `planTasks` * to detect cross-language switches and schedule ahead-buffer flush. * Undefined for video and for language-less audio. */ initTrackLanguage?: string | undefined; segments: Array< Pick & { trackId: Track['id']; trackBandwidth?: number; /** * True while a streaming append is in progress for this segment. * The segment's data is partially present in the SourceBuffer. * Downstream code must not treat a partial segment as fully buffered. */ partial?: boolean; } >; bufferedRanges: BufferedRange[]; } /** SourceBuffer actor: queues operations, owns its snapshot. */ export type SourceBufferActor = MessageActor; // ============================================================================= // Helpers // ============================================================================= function snapshotBuffered(buffered: TimeRanges): BufferedRange[] { const ranges: BufferedRange[] = []; for (let i = 0; i < buffered.length; i++) { ranges.push({ start: buffered.start(i), end: buffered.end(i) }); } return ranges; } // ============================================================================= // Message task factories // ============================================================================= // Context is read lazily via getContext at task execution time — not at creation // time — so each task always operates on the most recent context regardless of // when it was scheduled. interface MessageTaskOptions { getContext: () => SourceBufferActorContext; sourceBuffer: SourceBuffer; setContext: (ctx: SourceBufferActorContext) => void; } function appendInitTask( message: AppendInitMessage, { getContext, sourceBuffer }: MessageTaskOptions ): Task { return new Task(async (taskSignal) => { const ctx = getContext(); if (taskSignal.aborted) return ctx; await appendSegment(sourceBuffer, message.data); // No abort check here: the physical SourceBuffer has been modified, so // the model must be updated to match regardless of signal state. return { ...ctx, initTrackId: message.meta.trackId, initTrackLanguage: message.meta.language }; }); } function appendSegmentTask( message: AppendSegmentMessage, { getContext, sourceBuffer, setContext }: MessageTaskOptions ): Task { return new Task(async (taskSignal) => { const ctx = getContext(); if (taskSignal.aborted) return ctx; const { meta } = message; // Remove any existing entry at the same start time (same "slot" in the // timeline), then record the new segment. Assumes time-aligned segments // across playlists. `SEGMENT_TIME_EPSILON` guards against floating-point // drift in parsed timestamps (shared with the segment-loader quality // filter — single source of truth). const filtered = ctx.segments.filter((s) => Math.abs(s.startTime - meta.startTime) >= SEGMENT_TIME_EPSILON); // For streaming data: emit partial state before the first chunk so // downstream code can see the in-progress segment and treat it as // incomplete. ArrayBuffer appends are atomic so no partial state is // needed — context is updated once at task completion. if (!(message.data instanceof ArrayBuffer)) { setContext({ ...ctx, segments: [ ...filtered, { id: meta.id, startTime: meta.startTime, duration: meta.duration, trackId: meta.trackId, ...(meta.trackBandwidth !== undefined && { trackBandwidth: meta.trackBandwidth }), partial: true, }, ], bufferedRanges: ctx.bufferedRanges, }); } await appendSegment(sourceBuffer, message.data, taskSignal); // No abort check here: the physical SourceBuffer has been modified, so // the model must be updated to match regardless of signal state. return { ...ctx, segments: [ ...filtered, { id: meta.id, startTime: meta.startTime, duration: meta.duration, trackId: meta.trackId, ...(meta.trackBandwidth !== undefined && { trackBandwidth: meta.trackBandwidth }), }, ], bufferedRanges: snapshotBuffered(sourceBuffer.buffered), }; }); } function removeTask( message: RemoveMessage, { getContext, sourceBuffer }: MessageTaskOptions ): Task { return new Task(async (taskSignal) => { const ctx = getContext(); if (taskSignal.aborted) return ctx; await flushBuffer(sourceBuffer, message.start, message.end); // No abort check here: the physical SourceBuffer has been modified, so // the model must be updated to match regardless of signal state. // // Use the post-flush buffered ranges as ground truth. A segment is kept // in the model only if its midpoint falls within a buffered range. // Midpoint-based membership handles flush boundaries that don't align // exactly with segment edges without over-removing adjacent segments. const bufferedRanges = snapshotBuffered(sourceBuffer.buffered); const filtered = ctx.segments.filter((s) => { const midpoint = s.startTime + s.duration / 2; return bufferedRanges.some((r) => midpoint >= r.start && midpoint < r.end); }); return { ...ctx, segments: filtered, bufferedRanges }; }); } type MessageTaskFactory = ( message: T, options: MessageTaskOptions ) => Task; const messageTaskFactories = { 'append-init': appendInitTask, 'append-segment': appendSegmentTask, remove: removeTask, } satisfies { [K in IndividualSourceBufferMessage['type']]: MessageTaskFactory>; }; function messageToTask( message: IndividualSourceBufferMessage, options: MessageTaskOptions ): Task { const factory = messageTaskFactories[message.type] as MessageTaskFactory; return factory(message, options); } // ============================================================================= // Implementation // ============================================================================= export function createSourceBufferActor( sourceBuffer: SourceBuffer, initialContext?: Partial ): SourceBufferActor { type UserState = Exclude; const handleError = (e: unknown): void => { if (!(e instanceof Error && e.name === 'AbortError')) { console.error('SourceBuffer operation failed:', e); } }; type Ctx = HandlerContext SerialRunner>; const onMessage = (msg: IndividualSourceBufferMessage, { transition, setContext, getContext, runner }: Ctx): void => { transition('updating'); const task = messageToTask(msg, { getContext, sourceBuffer, setContext }); runner.schedule(task).then(setContext, handleError); }; return createMachineActor SerialRunner>({ runner: () => new SerialRunner(), initial: 'idle', context: { segments: [], bufferedRanges: [], initTrackId: undefined, ...initialContext }, states: { idle: { on: { 'append-init': onMessage, 'append-segment': onMessage, remove: onMessage, batch: (msg, { transition, setContext, getContext, runner }) => { const { messages } = msg; if (messages.length === 0) return; transition('updating'); messages.forEach((msg) => { const task = messageToTask(msg, { getContext, sourceBuffer, setContext }); runner.schedule(task).then(setContext, handleError); }); }, }, }, updating: { // Automatically return to idle once all scheduled tasks settle. onSettled: 'idle', on: { // Abort all in-progress and pending tasks. onSettled handles → 'idle' // once the aborted tasks drain. cancel: (_, { runner }) => { runner.abortAll(); }, }, }, }, }); }