diff --git a/api-extractor/report/hls.js.api.md b/api-extractor/report/hls.js.api.md index fe72fbeb7e6..55fbb61d69a 100644 --- a/api-extractor/report/hls.js.api.md +++ b/api-extractor/report/hls.js.api.md @@ -592,6 +592,8 @@ export class BaseStreamController extends TaskLoop implements NetworkComponentAP // (undocumented) protected onMediaSeeking: () => void; // (undocumented) + protected onSourceBufferError(filterType: PlaylistLevelType, data: ErrorData): void; + // (undocumented) protected onTickEnd(): void; // (undocumented) pauseBuffering(): void; @@ -2130,6 +2132,7 @@ export class FragmentTracker implements ComponentAPI { detectPartialFragments(data: FragBufferedData): void; // (undocumented) fragBuffered(frag: MediaFragment, force?: true): FragmentEntity | undefined; + gapFragments(): MediaFragment[]; getAppendedFrag(position: number, levelType: PlaylistLevelType): MediaFragment | Part | null; getBackBufferEvictionEnd(beforePosition: number, levelType: PlaylistLevelType, bytesNeeded: number): number; getBufferedFrag(position: number, levelType: PlaylistLevelType): MediaFragment | null; @@ -2144,6 +2147,7 @@ export class FragmentTracker implements ComponentAPI { hasParts(type: PlaylistLevelType): boolean; // (undocumented) isEndListAppended(type: PlaylistLevelType): boolean; + isGap(frag: Fragment): boolean; // (undocumented) removeAllFragments(): void; // (undocumented) diff --git a/src/controller/audio-stream-controller.ts b/src/controller/audio-stream-controller.ts index 77232e5858b..22440a77b32 100644 --- a/src/controller/audio-stream-controller.ts +++ b/src/controller/audio-stream-controller.ts @@ -853,6 +853,9 @@ class AudioStreamController this.fragmentTracker.addAsGap(data.frag as MediaFragment); } break; + case ErrorDetails.MEDIA_SOURCE_REQUIRES_RESET: + this.onSourceBufferError(PlaylistLevelType.AUDIO, data); + break; case ErrorDetails.BUFFER_ADD_CODEC_ERROR: case ErrorDetails.BUFFER_APPEND_ERROR: if (data.parent !== 'audio') { diff --git a/src/controller/base-stream-controller.ts b/src/controller/base-stream-controller.ts index 60bb0f94b20..dec180c9668 100644 --- a/src/controller/base-stream-controller.ts +++ b/src/controller/base-stream-controller.ts @@ -1,3 +1,4 @@ +import { SOURCE_BUFFER_ERROR_NAME } from './buffer-controller'; import { ErrorActionFlags, NetworkErrorAction } from './error-controller'; import { findFragmentByPDT, @@ -368,7 +369,11 @@ export default class BaseStreamController return; } this.loadingParts = false; - this.fragmentTracker.removeAllFragments(); + // Keep tracked gaps to skip segments given up on after re-attach (`recoverMediaError()`) + const { fragmentTracker } = this; + const gaps = fragmentTracker.gapFragments(); + fragmentTracker.removeAllFragments(); + gaps.forEach((frag) => fragmentTracker.addAsGap(frag)); this.stopLoad(); } @@ -438,13 +443,15 @@ export default class BaseStreamController } if (media) { - // Remove gap fragments - this.fragmentTracker.removeFragmentsInRange( - currentTime, - Infinity, - this.playlistType, - true, - ); + if (this.hls?.hasEnoughToStart && !fowardBuffer) { + // Remove gap fragments on a seek out of the buffer, after `seekToStartPos` + this.fragmentTracker.removeFragmentsInRange( + currentTime, + Infinity, + this.playlistType, + true, + ); + } // Don't set lastCurrentTime with backward seeks (allows for frag selection with strict tolerances) const lastCurrentTime = this.lastCurrentTime; @@ -1613,6 +1620,17 @@ export default class BaseStreamController this.nextLoadPosition = programFrag.start; } } + while (programFrag && this.fragmentTracker.isGap(programFrag)) { + const afterGap = this.filterReplacedPrimary( + getNextFrag(levelDetails, programFrag.sn), + levelDetails, + ); + if (!afterGap) { + return null; + } + this.nextLoadPosition = afterGap.start; + programFrag = afterGap; + } return programFrag; } @@ -1758,7 +1776,8 @@ export default class BaseStreamController if (nextPart > -1 && targetBufferTime < part.start) { break; } - const loaded = part.loaded || part.gap; + const loaded = + part.loaded || part.gap || this.fragmentTracker.isGap(part.fragment); if (loaded) { nextPart = -1; } else if ( @@ -2211,6 +2230,50 @@ export default class BaseStreamController this.tickImmediate(); } + protected onSourceBufferError( + filterType: PlaylistLevelType, + data: ErrorData, + ) { + const { frag, part, sourceBufferName } = data; + const details = frag && this.levels?.[frag.level]?.details; + if ( + data.error.name !== SOURCE_BUFFER_ERROR_NAME || + frag?.type !== filterType || + !isMediaFragment(frag) || + !details?.live + ) { + return; + } + const refused = part ? (part.index === 0 ? part : null) : frag; + const start = + refused && sourceBufferName + ? refused.elementaryStreams[sourceBufferName]?.startPTS + : undefined; + this.markSourceBufferErrorGap(frag); + if (start === undefined) { + return; + } + // Also mark the previous fragment when the buffer ends more than maxBufferHole before the refused fragment or first part + const previous = getFragmentWithSN(details, frag.sn - 1); + if ( + previous && + this.fragmentTracker.getState(previous) !== FragmentState.NOT_LOADED && + !BufferHelper.isBuffered( + this.mediaBuffer || this.media, + start - this.config.maxBufferHole, + ) + ) { + this.markSourceBufferErrorGap(previous); + } + } + + private markSourceBufferErrorGap(frag: MediaFragment) { + this.warn( + `Marking fragment ${frag.sn} of ${this.playlistLabel()} ${frag.level} as a gap after a SourceBuffer error`, + ); + this.fragmentTracker.addAsGap(frag); + } + protected checkRetryDate() { const now = self.performance.now(); const retryDate = this.retryDate; diff --git a/src/controller/buffer-controller.ts b/src/controller/buffer-controller.ts index f2d615b5aba..f36441bb556 100755 --- a/src/controller/buffer-controller.ts +++ b/src/controller/buffer-controller.ts @@ -70,6 +70,7 @@ const VIDEO_CODEC_PROFILE_REPLACE = /(avc[1234]|hvc1|hev1|dvh[1e]|vp09|av01)(?:\.[^.,]+)+/; const TRACK_REMOVED_ERROR_NAME = 'HlsJsTrackRemovedError'; +export const SOURCE_BUFFER_ERROR_NAME = 'HlsJsSourceBufferError'; const LOOP_FLUSH_SAFETY_MARGIN = 0.25; @@ -2120,6 +2121,7 @@ transfer tracks: ${stringify(transferredTracks, (key, value) => (key === 'initSe const error = new Error( `${type} SourceBuffer error. MediaSource readyState: ${readyState}`, ); + error.name = SOURCE_BUFFER_ERROR_NAME; this.error(`${error.message}`, event); // according to http://www.w3.org/TR/media-source/#sourcebuffer-append-error // SourceBuffer errors are not necessarily fatal; if so, the HTMLMediaElement will fire an error event diff --git a/src/controller/fragment-tracker.ts b/src/controller/fragment-tracker.ts index 94cc34c81ad..cb90c3d2b9e 100644 --- a/src/controller/fragment-tracker.ts +++ b/src/controller/fragment-tracker.ts @@ -631,6 +631,23 @@ export class FragmentTracker implements ComponentAPI { } } + /** Whether the fragment tracked under this key is a gap */ + public isGap(frag: Fragment): boolean { + return ( + this.hasGaps && this.fragments[getFragmentKey(frag)]?.body.gap === true + ); + } + + /** Tracked fragments marked as gaps */ + public gapFragments(): MediaFragment[] { + if (!this.hasGaps) { + return []; + } + return Object.keys(this.fragments) + .map((key) => this.fragments[key]?.body) + .filter((frag): frag is MediaFragment => !!frag?.gap); + } + public removeAllFragments() { this.fragments = Object.create(null); this.endListFragments = Object.create(null); diff --git a/src/controller/stream-controller.ts b/src/controller/stream-controller.ts index 61b031f5bf9..8ad6648649b 100644 --- a/src/controller/stream-controller.ts +++ b/src/controller/stream-controller.ts @@ -999,8 +999,8 @@ export default class StreamController return; } if (!this._hasEnoughToStart && BufferHelper.getBuffered(media).length) { - this._hasEnoughToStart = true; this.seekToStartPos(); + this._hasEnoughToStart = true; } if (bufferedMainFragment) { this.tick(); @@ -1052,6 +1052,9 @@ export default class StreamController this.fragmentTracker.addAsGap(data.frag as MediaFragment); } break; + case ErrorDetails.MEDIA_SOURCE_REQUIRES_RESET: + this.onSourceBufferError(PlaylistLevelType.MAIN, data); + break; case ErrorDetails.BUFFER_ADD_CODEC_ERROR: case ErrorDetails.BUFFER_APPEND_ERROR: if (data.parent !== 'main') { diff --git a/tests/unit/controller/audio-stream-controller.ts b/tests/unit/controller/audio-stream-controller.ts index e4b4634858d..d226a8ec50e 100644 --- a/tests/unit/controller/audio-stream-controller.ts +++ b/tests/unit/controller/audio-stream-controller.ts @@ -6,6 +6,7 @@ import AudioStreamController from '../../../src/controller/audio-stream-controll import BaseStreamController, { State, } from '../../../src/controller/base-stream-controller'; +import { SOURCE_BUFFER_ERROR_NAME } from '../../../src/controller/buffer-controller'; import { FragmentTracker } from '../../../src/controller/fragment-tracker'; import { ErrorDetails, ErrorTypes } from '../../../src/errors'; import { Events } from '../../../src/events'; @@ -543,6 +544,45 @@ describe('AudioStreamController', function () { }); }); + describe('onError MEDIA_SOURCE_REQUIRES_RESET', function () { + const resetError = (frag: Fragment) => { + const error = new Error( + 'audio SourceBuffer error. MediaSource readyState: ended', + ); + error.name = SOURCE_BUFFER_ERROR_NAME; + return { + type: ErrorTypes.MEDIA_ERROR, + details: ErrorDetails.MEDIA_SOURCE_REQUIRES_RESET, + fatal: false, + error, + parent: frag.type, + frag, + part: null, + }; + }; + + beforeEach(function () { + // only fragments of a live playlist are marked + (audioStreamController as any).levels = [{ details: { live: true } }]; + }); + + it('marks the audio fragment as a gap after a SourceBuffer error', function () { + const frag = new Fragment(PlaylistLevelType.AUDIO, ''); + frag.sn = 4; + frag.level = 0; + (audioStreamController as any).onError(Events.ERROR, resetError(frag)); + expect(frag.gap).to.equal(true); + }); + + it('ignores a main fragment', function () { + const frag = new Fragment(PlaylistLevelType.MAIN, ''); + frag.sn = 4; + frag.level = 0; + (audioStreamController as any).onError(Events.ERROR, resetError(frag)); + expect(frag.gap).to.not.equal(true); + }); + }); + describe('audio track switching with flushImmediate flag', function () { beforeEach(function () { audioStreamController.levels = tracks; diff --git a/tests/unit/controller/base-stream-controller.ts b/tests/unit/controller/base-stream-controller.ts index e476f87fdc7..926bd5de45d 100644 --- a/tests/unit/controller/base-stream-controller.ts +++ b/tests/unit/controller/base-stream-controller.ts @@ -3,6 +3,7 @@ import sinon from 'sinon'; import sinonChai from 'sinon-chai'; import { hlsDefaultConfig } from '../../../src/config'; import { State } from '../../../src/controller/base-stream-controller'; +import { SOURCE_BUFFER_ERROR_NAME } from '../../../src/controller/buffer-controller'; import { FragmentState } from '../../../src/controller/fragment-tracker'; import BaseStreamControllerImpl from '../../../src/controller/stream-controller'; import { ErrorDetails, ErrorTypes } from '../../../src/errors'; @@ -17,6 +18,7 @@ import { BufferHelper } from '../../../src/utils/buffer-helper'; import { TimeRangesMock } from '../../mocks/time-ranges.mock'; import type BaseStreamController from '../../../src/controller/base-stream-controller'; import type { MediaFragment, Part } from '../../../src/loader/fragment'; +import type { ErrorData } from '../../../src/types/events'; import type { BufferInfo } from '../../../src/utils/buffer-helper'; use(sinonChai); @@ -39,6 +41,9 @@ type BaseStreamControllerTestable = Omit< | 'fragPrevious' | 'tickImmediate' | 'hls' + | 'getNextFragment' + | 'onMediaDetaching' + | 'onSourceBufferError' > & { media: HTMLMediaElement | null; _streamEnded: (bufferInfo: BufferInfo, levelDetails: LevelDetails) => boolean; @@ -61,6 +66,12 @@ type BaseStreamControllerTestable = Omit< fragPrevious: MediaFragment | null; tickImmediate: () => void; hls: Hls; + getNextFragment: ( + pos: number, + levelDetails: LevelDetails, + ) => MediaFragment | null; + onMediaDetaching: (event: any, data: any) => void; + onSourceBufferError: (filterType: PlaylistLevelType, data: ErrorData) => void; }; describe('BaseStreamController', function () { @@ -79,6 +90,16 @@ describe('BaseStreamController', function () { isEndListAppended() { return true; }, + gapSn: null, + isGap(frag) { + return this.gapSn !== null && frag.sn === this.gapSn; + }, + gapFragments() { + return this.gaps || []; + }, + gaps: [], + addAsGap: sinon.spy(), + removeAllFragments: sinon.spy(), removeFragmentsInRange: sinon.spy(), }; baseStreamController = new BaseStreamControllerImpl( @@ -117,6 +138,32 @@ describe('BaseStreamController', function () { return details; } + function refuse(frag: Fragment, part: Part | null = null) { + // only fragments of a live playlist are marked + const levels: unknown[] = []; + levels[frag.level] = { details: { live: true } }; + (baseStreamController as any).levels = levels; + const error = new Error( + 'video SourceBuffer error. MediaSource readyState: ended', + ); + error.name = SOURCE_BUFFER_ERROR_NAME; + baseStreamController.onSourceBufferError(PlaylistLevelType.MAIN, { + type: ErrorTypes.MEDIA_ERROR, + details: ErrorDetails.MEDIA_SOURCE_REQUIRES_RESET, + fatal: false, + error, + frag, + part, + }); + } + + function mainFrag(sn: number, level: number): MediaFragment { + const frag = new Fragment(PlaylistLevelType.MAIN, '') as MediaFragment; + frag.sn = sn; + frag.level = level; + return frag; + } + describe('_streamEnded', function () { it('returns false if the stream is live', function () { const levelDetails = levelDetailsWithEndSequenceVodOrLive(3, true); @@ -162,6 +209,34 @@ describe('BaseStreamController', function () { }); }); + describe('getNextFragment skips tracked gaps', function () { + it('returns the fragment after the gap', function () { + const levelDetails = levelDetailsWithEndSequenceVodOrLive(4); + fragmentTracker.gapSn = 1; + const frag = baseStreamController.getNextFragment(5, levelDetails); + expect(frag?.sn, 'skips to the next fragment').to.equal(2); + expect(baseStreamController.nextLoadPosition).to.equal(10); + }); + + it('returns null when the gap is the last fragment', function () { + const levelDetails = levelDetailsWithEndSequenceVodOrLive(2); + fragmentTracker.gapSn = 1; + expect(baseStreamController.getNextFragment(5, levelDetails)).to.equal( + null, + ); + }); + }); + + describe('onSourceBufferError', function () { + it('marks the fragment of a refused part', function () { + const frag = mainFrag(1, 0); + const part = { index: 1, fragment: frag, gap: false } as unknown as Part; + refuse(frag, part); + expect(fragmentTracker.addAsGap).to.have.been.calledOnceWith(frag); + expect(part.gap).to.equal(false); + }); + }); + describe('Seeking Logic', function () { describe('onMediaSeeking', function () { it('should handle backward seek behavior correctly', function () { @@ -215,7 +290,36 @@ describe('BaseStreamController', function () { resetSpy.restore(); }); + it('keeps gaps on a seek before hasEnoughToStart and removes them once it is set', function () { + const hasEnoughToStart = sinon.stub(hls, 'hasEnoughToStart'); + media.currentTime = 10.0; + + hasEnoughToStart.get(() => false); + baseStreamController.onMediaSeeking(); + expect( + fragmentTracker.removeFragmentsInRange, + 'the seek to the start position keeps them', + ).to.have.not.been.called; + + hasEnoughToStart.get(() => true); + baseStreamController.onMediaSeeking(); + expect( + fragmentTracker.removeFragmentsInRange, + 'a later seek out of the buffer removes them', + ).to.have.been.calledOnce; + }); + + it('keeps gaps on a seek into the buffer', function () { + sinon.stub(hls, 'hasEnoughToStart').get(() => true); + media.buffered = new TimeRangesMock([5, 20]); + media.currentTime = 10.0; + + baseStreamController.onMediaSeeking(); + expect(fragmentTracker.removeFragmentsInRange).to.have.not.been.called; + }); + it('should call fragmentTracker.removeFragmentsInRange when media exists', function () { + sinon.stub(hls, 'hasEnoughToStart').get(() => true); baseStreamController.lastCurrentTime = 5.0; media.currentTime = 10.0; diff --git a/tests/unit/controller/buffer-controller-operations.ts b/tests/unit/controller/buffer-controller-operations.ts index f91038552f9..e948461b861 100644 --- a/tests/unit/controller/buffer-controller-operations.ts +++ b/tests/unit/controller/buffer-controller-operations.ts @@ -1,7 +1,9 @@ import { expect, use } from 'chai'; import sinon from 'sinon'; import sinonChai from 'sinon-chai'; -import BufferController from '../../../src/controller/buffer-controller'; +import BufferController, { + SOURCE_BUFFER_ERROR_NAME, +} from '../../../src/controller/buffer-controller'; import { FragmentTracker } from '../../../src/controller/fragment-tracker'; import { ErrorDetails, ErrorTypes } from '../../../src/errors'; import { Events } from '../../../src/events'; @@ -210,6 +212,7 @@ describe('BufferController with attached media', function () { expect(sbErrorObject.message).equals( 'audio SourceBuffer error. MediaSource readyState: open', ); + expect(sbErrorObject.name).equals(SOURCE_BUFFER_ERROR_NAME); expect( triggerSpy, 'ERROR should have been triggered in response to the SourceBuffer error', diff --git a/tests/unit/controller/fragment-tracker.ts b/tests/unit/controller/fragment-tracker.ts index 41cb027548d..7ff1acd74ad 100644 --- a/tests/unit/controller/fragment-tracker.ts +++ b/tests/unit/controller/fragment-tracker.ts @@ -10,6 +10,7 @@ import { ElementaryStreamTypes, Fragment } from '../../../src/loader/fragment'; import { LoadStats } from '../../../src/loader/load-stats'; import { PlaylistLevelType } from '../../../src/types/loader'; import { ChunkMetadata } from '../../../src/types/transmuxer'; +import type { MediaFragment } from '../../../src/loader/fragment'; import type { BufferAppendedData, FragBufferedData, @@ -585,6 +586,62 @@ describe('FragmentTracker', function () { }); }); }); + + describe('gap tracking', function () { + const gapFrag = (sn: number, level: number) => + createMockFragment( + { + startPTS: sn, + endPTS: sn + 1, + sn, + level, + type: PlaylistLevelType.MAIN, + }, + [ElementaryStreamTypes.VIDEO], + ) as MediaFragment; + + it('reports a gap for a replacement object with the same key', function () { + const fragmentTracker = new FragmentTracker(new Hls({})); + const frag = gapFrag(1, 1); + expect(fragmentTracker.isGap(frag)).to.equal(false); + + fragmentTracker.addAsGap(frag); + expect(fragmentTracker.isGap(frag)).to.equal(true); + + // a live playlist refresh builds a new object for the same sn and level + const replacement = gapFrag(1, 1); + expect(replacement.gap).to.not.equal(true); + expect(fragmentTracker.isGap(replacement)).to.equal(true); + }); + + it('does not report a gap for the same sn of another level', function () { + const fragmentTracker = new FragmentTracker(new Hls({})); + fragmentTracker.addAsGap(gapFrag(1, 1)); + expect(fragmentTracker.isGap(gapFrag(1, 2))).to.equal(false); + }); + + it('lists only the tracked fragments that are gaps', function () { + const fragmentTracker = new FragmentTracker(new Hls({})); + fragmentTracker.fragBuffered(gapFrag(1, 1), true); + fragmentTracker.addAsGap(gapFrag(2, 1)); + expect(fragmentTracker.gapFragments().map((f) => f.sn)).to.deep.equal([ + 2, + ]); + }); + + it('stops reporting a gap once the fragment is removed', function () { + const fragmentTracker = new FragmentTracker(new Hls({})); + const frag = gapFrag(1, 1); + fragmentTracker.addAsGap(frag); + expect(fragmentTracker.gapFragments().map((f) => f.sn)).to.deep.equal([ + 1, + ]); + + fragmentTracker.removeFragment(frag); + expect(fragmentTracker.isGap(gapFrag(1, 1))).to.equal(false); + expect(fragmentTracker.gapFragments()).to.have.lengthOf(0); + }); + }); }); function triggerFragLoaded(hls: Hls, fragment: Fragment) { diff --git a/tests/unit/controller/stream-controller.ts b/tests/unit/controller/stream-controller.ts index e7c5f55bb7f..3d664ac2bb4 100644 --- a/tests/unit/controller/stream-controller.ts +++ b/tests/unit/controller/stream-controller.ts @@ -4,11 +4,16 @@ import { fakeXhr } from 'nise'; import sinon from 'sinon'; import sinonChai from 'sinon-chai'; import { State } from '../../../src/controller/base-stream-controller'; +import { SOURCE_BUFFER_ERROR_NAME } from '../../../src/controller/buffer-controller'; import { FragmentState } from '../../../src/controller/fragment-tracker'; import { ErrorDetails, ErrorTypes } from '../../../src/errors'; import { Events } from '../../../src/events'; import Hls from '../../../src/hls'; -import { ElementaryStreamTypes, Fragment } from '../../../src/loader/fragment'; +import { + ElementaryStreamTypes, + Fragment, + Part, +} from '../../../src/loader/fragment'; import { LevelDetails } from '../../../src/loader/level-details'; import { LoadStats } from '../../../src/loader/load-stats'; import M3U8Parser from '../../../src/loader/m3u8-parser'; @@ -22,7 +27,7 @@ import type { FragmentTracker } from '../../../src/controller/fragment-tracker'; import type StreamController from '../../../src/controller/stream-controller'; import type { MediaFragment } from '../../../src/loader/fragment'; import type { ParsedMultivariantPlaylist } from '../../../src/loader/m3u8-parser'; -import type { ErrorData } from '../../../src/types/events'; +import type { BufferAppendedData, ErrorData } from '../../../src/types/events'; import type { LevelAttributes } from '../../../src/types/level'; use(sinonChai); @@ -421,6 +426,266 @@ describe('StreamController', function () { }); }); + describe('onError MEDIA_SOURCE_REQUIRES_RESET', function () { + const resetError = ( + name: string, + frag: MediaFragment, + part: Part | null = null, + ): ErrorData => { + const error = new Error( + 'video SourceBuffer error. MediaSource readyState: ended', + ); + error.name = name; + return { + type: ErrorTypes.MEDIA_ERROR, + details: ErrorDetails.MEDIA_SOURCE_REQUIRES_RESET, + fatal: false, + error, + parent: PlaylistLevelType.MAIN, + sourceBufferName: 'audiovideo', + frag, + part, + }; + }; + // Live playlist with two parts per segment, on level 0 + const setLevelDetails = (count: number, live = true) => { + const details = new LevelDetails(''); + details.live = live; + details.startSN = 0; + details.endSN = count - 1; + details.partList = []; + for (let sn = 0; sn < count; sn++) { + const frag = new Fragment(PlaylistLevelType.MAIN, '') as MediaFragment; + frag.sn = sn; + frag.level = 0; + frag.relurl = `${sn}.mp4`; + frag.setStart(sn * 2); + frag.duration = 2; + details.fragments.push(frag); + let previous: Part | undefined; + for (let index = 0; index < 2; index++) { + previous = new Part( + new AttrList({ + DURATION: '1', + INDEPENDENT: 'YES', + URI: `${sn}.${index}.mp4`, + }), + frag, + '', + index, + previous, + ); + details.partList.push(previous); + } + } + if (!live) { + details.fragments[count - 1].endList = true; + } + const level = new Level({ name: '', url: '', attrs, bitrate: 500000 }); + level.details = details; + streamController['levels'] = [level]; + return details; + }; + const appendParts = (details: LevelDetails, sn: number) => { + fragmentTracker.fragBuffered(details.fragments[sn], true); + details.partList!.forEach((part) => { + if (part.fragment.sn === sn) { + part.elementaryStreams.video = { + startPTS: part.start, + endPTS: part.end, + startDTS: part.start, + endDTS: part.end, + }; + } + }); + }; + // `offset` shifts the parsed media times of the part from its playlist times + const refuse = ( + frag: MediaFragment, + part: Part | null = null, + offset = 0, + ) => { + if (part) { + part.elementaryStreams.audiovideo = { + startPTS: part.start + offset, + endPTS: part.end + offset, + startDTS: part.start + offset, + endDTS: part.end + offset, + }; + } + streamController['onError']( + Events.ERROR, + resetError(SOURCE_BUFFER_ERROR_NAME, frag, part), + ); + }; + const newFrag = () => { + const frag = new Fragment(PlaylistLevelType.MAIN, '') as MediaFragment; + frag.sn = 3; + frag.level = 0; + return frag; + }; + + it('marks the fragment as a gap after a SourceBuffer error', function () { + setLevelDetails(4); + const frag = newFrag(); + streamController['onError']( + Events.ERROR, + resetError(SOURCE_BUFFER_ERROR_NAME, frag), + ); + expect(frag.gap).to.equal(true); + expect(fragmentTracker.getState(frag)).to.equal(FragmentState.PARTIAL); + }); + + it('ignores a reset that another append error caused', function () { + setLevelDetails(4); + const frag = newFrag(); + streamController['onError']( + Events.ERROR, + resetError('InvalidStateError', frag), + ); + expect(frag.gap).to.not.equal(true); + expect(fragmentTracker.getState(frag)).to.equal(FragmentState.NOT_LOADED); + }); + + it('also marks the previous fragment when the buffer ends short of a refused first part', function () { + const details = setLevelDetails(4); + appendParts(details, 1); + // the tail of fragment 1 never reached the buffer: a 0.5s hole before fragment 2 at 4s + streamController['media'] = { + buffered: new TimeRangesMock([0, 3.5]), + } as unknown as HTMLMediaElement; + refuse(details.fragments[2], details.partList![4]); + expect(fragmentTracker.isGap(details.fragments[1])).to.equal(true); + expect(fragmentTracker.isGap(details.fragments[2])).to.equal(true); + }); + + it('does not mark the previous fragment when the buffer reaches a refused first part', function () { + const details = setLevelDetails(4); + appendParts(details, 1); + // a hole smaller than maxBufferHole is contiguous + streamController['media'] = { + buffered: new TimeRangesMock([0, 3.98]), + } as unknown as HTMLMediaElement; + refuse(details.fragments[2], details.partList![4]); + expect(fragmentTracker.isGap(details.fragments[1])).to.equal(false); + expect(fragmentTracker.isGap(details.fragments[2])).to.equal(true); + }); + + it('measures the hole from the parsed start of the refused part', function () { + const details = setLevelDetails(4); + appendParts(details, 1); + // media runs 0.15s ahead of playlist times and fragment 1 is fully buffered + streamController['media'] = { + buffered: new TimeRangesMock([0, 3.85]), + } as unknown as HTMLMediaElement; + refuse(details.fragments[2], details.partList![4], -0.15); + expect(fragmentTracker.isGap(details.fragments[1])).to.equal(false); + }); + + it('reads the refused part before marking when the other SourceBuffer appended it', function () { + const details = setLevelDetails(4); + appendParts(details, 1); + streamController['media'] = { + buffered: new TimeRangesMock([0, 3.5]), + } as unknown as HTMLMediaElement; + const part = details.partList![4]; + part.elementaryStreams.video = { + startPTS: part.start, + endPTS: part.end, + startDTS: part.start, + endDTS: part.end, + }; + // the audio append of the same part went through before the video one was refused + fragmentTracker['onBufferAppended'](Events.BUFFER_APPENDED, { + type: 'audio', + frag: details.fragments[2], + part, + parent: PlaylistLevelType.MAIN, + timeRanges: {}, + } as unknown as BufferAppendedData); + streamController['onError'](Events.ERROR, { + ...resetError(SOURCE_BUFFER_ERROR_NAME, details.fragments[2], part), + sourceBufferName: 'video', + }); + expect(fragmentTracker.isGap(details.fragments[1])).to.equal(true); + }); + + it('also marks the previous fragment when the buffer ends short of a refused whole fragment', function () { + const details = setLevelDetails(4); + appendParts(details, 1); + // loaded without parts: the tail of fragment 1 never reached the buffer + streamController['media'] = { + buffered: new TimeRangesMock([0, 3.5]), + } as unknown as HTMLMediaElement; + const frag = details.fragments[2]; + frag.elementaryStreams.audiovideo = { + startPTS: frag.start, + endPTS: frag.end, + startDTS: frag.start, + endDTS: frag.end, + }; + refuse(frag); + expect(fragmentTracker.isGap(details.fragments[1])).to.equal(true); + expect(fragmentTracker.isGap(details.fragments[2])).to.equal(true); + }); + + it('does not mark the previous fragment when it was not appended', function () { + const details = setLevelDetails(4); + refuse(details.fragments[2], details.partList![4]); + expect(fragmentTracker.isGap(details.fragments[1])).to.equal(false); + expect(fragmentTracker.isGap(details.fragments[2])).to.equal(true); + }); + + it('does not mark the previous fragment when a later part is refused', function () { + const details = setLevelDetails(4); + appendParts(details, 1); + refuse(details.fragments[2], details.partList![5]); + expect(fragmentTracker.isGap(details.fragments[1])).to.equal(false); + expect(fragmentTracker.isGap(details.fragments[2])).to.equal(true); + }); + + it('does not mark fragments of an ended playlist', function () { + const details = setLevelDetails(3, false); + refuse(details.fragments[1]); + refuse(details.fragments[2]); + expect(fragmentTracker.getState(details.fragments[1])).to.equal( + FragmentState.NOT_LOADED, + ); + expect(fragmentTracker.getState(details.fragments[2])).to.equal( + FragmentState.NOT_LOADED, + ); + }); + + it('does not select parts of a refused fragment after a playlist refresh', function () { + const details = setLevelDetails(4); + appendParts(details, 1); + // the refresh replaced the object that was refused + const refused = new Fragment(PlaylistLevelType.MAIN, '') as MediaFragment; + refused.sn = 2; + refused.level = 0; + refuse(refused, null); + const partList = details.partList!; + const next = streamController.getNextPart( + partList, + details.fragments[1], + partList[3].start + 0.1, + ); + expect(partList[next].fragment.sn).to.equal(3); + }); + + it('keeps the gap across a media detach', function () { + const details = setLevelDetails(4); + streamController['media'] = { + currentTime: 1, + addEventListener() {}, + removeEventListener() {}, + } as unknown as HTMLMediaElement; + refuse(details.fragments[2]); + streamController['onMediaDetaching'](Events.MEDIA_DETACHING, {}); + expect(fragmentTracker.isGap(details.fragments[2])).to.equal(true); + }); + }); + describe('fragment loading', function () { function fragStateStub(state) { return sinon.stub(fragmentTracker, 'getState').callsFake(() => state); @@ -542,6 +807,39 @@ describe('StreamController', function () { expect(seekStub).to.have.been.calledOnce; }); + it('sets hasEnoughToStart after seeking to the start position', function () { + const firstFrag = new Fragment( + PlaylistLevelType.MAIN, + '', + ) as MediaFragment; + firstFrag.duration = 5.0; + firstFrag.level = 1; + firstFrag.setStart(0); + firstFrag.sn = 1; + firstFrag.cc = 0; + firstFrag.elementaryStreams.video = { + startDTS: 0, + startPTS: 0, + endDTS: 5, + endPTS: 5, + }; + let hasEnoughToStartWhenSeeking: boolean | undefined; + // @ts-ignore + sandbox.stub(streamController, 'seekToStartPos').callsFake(() => { + hasEnoughToStartWhenSeeking = streamController.hasEnoughToStart; + }); + streamController['fragCurrent'] = streamController['fragPrevious'] = + firstFrag; + streamController['onFragBuffered'](Events.FRAG_BUFFERED, { + stats: new LoadStats(), + frag: firstFrag, + part: null, + id: firstFrag.type, + }); + expect(hasEnoughToStartWhenSeeking).to.equal(false); + expect(streamController.hasEnoughToStart).to.equal(true); + }); + it('should seek to start pos when media is buffered and seek to segment boundary', function () { streamController['config'].startOnSegmentBoundary = true; streamController['startPosition'] = 7;