diff --git a/api-extractor/report/hls.js.api.md b/api-extractor/report/hls.js.api.md index b9325c66dd9..52d5f280dce 100644 --- a/api-extractor/report/hls.js.api.md +++ b/api-extractor/report/hls.js.api.md @@ -1987,6 +1987,7 @@ export class FragmentTracker implements ComponentAPI { // (undocumented) fragBuffered(frag: MediaFragment, force?: true): void; getAppendedFrag(position: number, levelType: PlaylistLevelType): MediaFragment | Part | null; + getBackBufferEvictionEnd(beforePosition: number, levelType: PlaylistLevelType, bytesNeeded: number): number; getBufferedFrag(position: number, levelType: PlaylistLevelType): MediaFragment | null; // (undocumented) getFragAtPos(position: number, levelType: PlaylistLevelType, buffered?: boolean): MediaFragment | null; diff --git a/src/controller/buffer-controller.ts b/src/controller/buffer-controller.ts index 31640e9ae8d..bddf826c332 100755 --- a/src/controller/buffer-controller.ts +++ b/src/controller/buffer-controller.ts @@ -118,6 +118,12 @@ export default class BufferController extends Logger implements ComponentAPI { audiovideo: 0, }; private appendError?: ErrorData; + // Tracks whether a QuotaExceededError back-buffer eviction is in progress + // for a given SourceBuffer type. While true, subsequent QuotaExceededErrors + // on the same type piggyback on the pending eviction instead of triggering + // their own or emitting BUFFER_FULL_ERROR. + private _quotaEvictionPending: Partial> = + {}; // Record of required or created buffers by type. SourceBuffer is stored in Track.buffer once created. private tracks: SourceBufferTrackSet = {}; // Array of SourceBuffer type and SourceBuffer (or null). One entry per TrackSet in this.tracks. @@ -853,6 +859,7 @@ transfer tracks: ${stringify(transferredTracks, (key, value) => (key === 'initSe } const fragStart = (part || frag).start; + let quotaEvictionAttempted = false; const operation: BufferOperation = { label: `append-${type}`, execute: () => { @@ -900,6 +907,46 @@ transfer tracks: ${stringify(transferredTracks, (key, value) => (key === 'initSe }, onError: (error: Error) => { this.clearBufferAppendTimeoutId(this.tracks[type]); + + const isQuotaError = + (error as DOMException).code === DOMException.QUOTA_EXCEEDED_ERR || + error.name == 'QuotaExceededError' || + `quota` in error; + + if (isQuotaError) { + // An eviction is already in flight for this type — piggyback on it + // by queuing a retry after the pending remove, no new eviction needed. + if (this._quotaEvictionPending[type]) { + this.log( + `QuotaExceededError on "${type}" sn: ${sn} — eviction already pending, queuing retry`, + ); + this.insertNext([operation], type); + return; + } + + // First QuotaExceededError for this type: evict minimum back buffer + if (!quotaEvictionAttempted) { + const evictEnd = this.getBackBufferEvictionTarget( + type, + data.byteLength, + frag.type, + ); + if (evictEnd > 0) { + quotaEvictionAttempted = true; + this._quotaEvictionPending[type] = true; + this.log( + `QuotaExceededError on "${type}" append sn: ${sn} — evicting back buffer to ${evictEnd.toFixed(3)}s and retrying`, + ); + const removeOp = this.getQuotaEvictionFlushOp(type, 0, evictEnd); + this.insertNext([removeOp, operation], type); + return; + } + this.warn( + `QuotaExceededError on "${type}" sn: ${sn} — no back buffer available to evict`, + ); + } + } + // in case any error occured while appending, put back segment in segments table const event: ErrorData = { type: ErrorTypes.MEDIA_ERROR, @@ -914,13 +961,9 @@ transfer tracks: ${stringify(transferredTracks, (key, value) => (key === 'initSe fatal: false, }; const mediaError = this.media?.error; - if ( - (error as DOMException).code === DOMException.QUOTA_EXCEEDED_ERR || - error.name == 'QuotaExceededError' || - `quota` in error - ) { + if (isQuotaError) { // QuotaExceededError: http://www.w3.org/TR/html5/infrastructure.html#quotaexceedederror - // let's stop appending any segments, and report BUFFER_FULL_ERROR error + // Eviction was already attempted or not possible — report BUFFER_FULL_ERROR event.details = ErrorDetails.BUFFER_FULL_ERROR; } else if ( (error as DOMException).code === DOMException.INVALID_STATE_ERR && @@ -971,6 +1014,33 @@ transfer tracks: ${stringify(transferredTracks, (key, value) => (key === 'initSe this.append(operation, type, this.isPending(this.tracks[type])); } + // Like getFlushOp but clears _quotaEvictionPending on complete/error + private getQuotaEvictionFlushOp( + type: SourceBufferName, + start: number, + end: number, + ): BufferOperation { + this.log(`queuing quota-eviction "${type}" remove ${start}-${end}`); + return { + label: 'remove', + execute: () => { + this.removeExecutor(type, start, end); + }, + onStart: () => {}, + onComplete: () => { + this._quotaEvictionPending[type] = false; + this.hls.trigger(Events.BUFFER_FLUSHED, { type }); + }, + onError: (error: Error) => { + this._quotaEvictionPending[type] = false; + this.warn( + `Failed to remove ${start}-${end} from "${type}" SourceBuffer (quota eviction)`, + error, + ); + }, + }; + } + private getFlushOp( type: SourceBufferName, start: number, @@ -1198,6 +1268,26 @@ transfer tracks: ${stringify(transferredTracks, (key, value) => (key === 'initSe } } + // Returns the end position to evict back buffer to on QuotaExceededError, + // or 0 if there is nothing to evict. Delegates to the fragment tracker which + // walks buffered fragments oldest-first using actual byte sizes to find the + // minimum eviction needed to fit the new segment. + private getBackBufferEvictionTarget( + type: SourceBufferName, + segmentBytes: number, + playlistType: PlaylistLevelType, + ): number { + const { media } = this; + if (!media) { + return 0; + } + return this.fragmentTracker.getBackBufferEvictionEnd( + media.currentTime, + playlistType, + segmentBytes, + ); + } + private resetAppendErrors() { this.appendErrors = { audio: 0, @@ -1940,6 +2030,12 @@ transfer tracks: ${stringify(transferredTracks, (key, value) => (key === 'initSe } } + private insertNext(operations: BufferOperation[], type: SourceBufferName) { + if (this.operationQueue) { + this.operationQueue.insertNext(operations, type); + } + } + private appendBlocker(type: SourceBufferName): Promise | undefined { if (this.operationQueue) { return this.operationQueue.appendBlocker(type); diff --git a/src/controller/buffer-operation-queue.ts b/src/controller/buffer-operation-queue.ts index c2765b53a24..eb1efff25ec 100644 --- a/src/controller/buffer-operation-queue.ts +++ b/src/controller/buffer-operation-queue.ts @@ -79,6 +79,14 @@ export default class BufferOperationQueue { ); } + public insertNext(operations: BufferOperation[], type: SourceBufferName) { + if (this.queues === null) { + return; + } + // Insert after the current (index 0) operation so they execute next + this.queues[type].splice(1, 0, ...operations); + } + public unblockAudio(op: BufferOperation) { if (this.queues === null) { return; diff --git a/src/controller/fragment-tracker.ts b/src/controller/fragment-tracker.ts index 3e5b5a2e43a..1f774f0a03d 100644 --- a/src/controller/fragment-tracker.ts +++ b/src/controller/fragment-tracker.ts @@ -482,6 +482,47 @@ export class FragmentTracker implements ComponentAPI { return !!this.activePartLists[type]?.length; } + /** + * Returns the end position needed to free at least `bytesNeeded` from the + * back buffer, or 0 if not enough data is available. Walks buffered + * fragments in key order, accumulating byte sizes using stats.loaded, + * byteLength, or a bitrate estimate as fallback. + */ + public getBackBufferEvictionEnd( + beforePosition: number, + levelType: PlaylistLevelType, + bytesNeeded: number, + ): number { + const { fragments } = this; + + // Collect back buffer fragments with known byte sizes + let bytesFreed = 0; + let evictEnd = 0; + const keys = Object.keys(fragments); + for (let i = 0; i < keys.length; i++) { + const entity = fragments[keys[i]]; + if (!entity || !entity.buffered || entity.body.type !== levelType) { + continue; + } + const frag = entity.body; + // Use stats.loaded (always set after load) with byteLength as fallback + const bytes = + (frag.hasStats && frag.stats.loaded) || + frag.byteLength || + (frag.bitrate && frag.bitrate * 8 * frag.duration); + if (frag.end <= beforePosition && bytes) { + bytesFreed += bytes; + evictEnd = Math.max(evictEnd, frag.end); + if (bytesFreed >= bytesNeeded) { + return evictEnd; + } + } + } + + // Not enough to fully cover bytesNeeded, return what we have + return evictEnd > 0 ? evictEnd : 0; + } + public removeFragmentsInRange( start: number, end: number,