import { expect, use } from 'chai'; import sinon from 'sinon'; import sinonChai from 'sinon-chai'; import BufferController from '../../../src/controller/buffer-controller'; import { FragmentTracker } from '../../../src/controller/fragment-tracker'; import { ErrorDetails, ErrorTypes } from '../../../src/errors'; import { Events } from '../../../src/events'; import Hls from '../../../src/hls'; import { MockMediaElement, MockMediaSource } from '../../mocks/mock-media'; import type BufferOperationQueue from '../../../src/controller/buffer-operation-queue'; import type { ExtendedSourceBuffer, ParsedTrack, SourceBuffersTuple, SourceBufferTrackSet, } from '../../../src/types/buffer'; import type { ComponentAPI, NetworkComponentAPI, } from '../../../src/types/component-api'; use(sinonChai); type HlsTestable = Omit & { coreComponents: ComponentAPI[]; networkControllers: NetworkComponentAPI[]; }; type BufferOperationQueueTestable = Omit & { tracks: SourceBufferTrackSet; }; type BufferControllerTestable = Omit< BufferController, | '_onMediaSourceOpen' | 'bufferCodecEventsTotal' | 'checkPendingTracks' | 'createSourceBuffers' | 'media' | 'mediaSource' | 'operationQueue' | 'pendingTrackCount' | 'sourceBufferCount' | 'sourceBuffers' | 'tracks' | 'tracksReady' > & { _onMediaSourceOpen: (e?: Event) => void; bufferCodecEventsTotal: number; checkPendingTracks: () => void; createSourceBuffers: () => void; media: HTMLMediaElement | null; mediaSource: MediaSource | null; operationQueue: BufferOperationQueueTestable; pendingTrackCount: number; sourceBufferCount: number; sourceBuffers: SourceBuffersTuple; tracks: SourceBufferTrackSet; tracksReady: boolean; _onMediaSourceClose: () => void; }; describe('BufferController', function () { let hls: HlsTestable; let fragmentTracker: FragmentTracker; let bufferController: BufferControllerTestable; const sandbox = sinon.createSandbox(); beforeEach(function () { hls = new Hls({ // debug: true, }) as unknown as HlsTestable; fragmentTracker = new FragmentTracker(hls as unknown as Hls); hls.networkControllers.forEach((component) => component.destroy()); hls.networkControllers.length = 0; hls.coreComponents.forEach((component) => component.destroy()); hls.coreComponents.length = 0; bufferController = new BufferController( hls as unknown as Hls, fragmentTracker, ) as unknown as BufferControllerTestable; }); afterEach(function () { hls.destroy(); bufferController.destroy(); sandbox.restore(); }); describe('onBufferFlushing', function () { beforeEach(function () { bufferController.sourceBuffers = [ ['video', {} as unknown as ExtendedSourceBuffer], ['audio', {} as unknown as ExtendedSourceBuffer], ]; bufferController.operationQueue.tracks = { audio: { id: 'audio', container: '', buffer: { updating: false } as unknown as ExtendedSourceBuffer, listeners: [], }, video: { id: 'main', container: '', buffer: { updating: false } as unknown as ExtendedSourceBuffer, listeners: [], }, }; }); it('flushes a specific type when provided a type', function () { const spy = sandbox.spy(bufferController.operationQueue, 'append'); hls.trigger(Events.BUFFER_FLUSHING, { startOffset: 0, endOffset: 10, type: 'video', }); expect(spy).to.have.been.calledOnce; }); it('flushes all source buffers when buffer flush event type is null', function () { const spy = sandbox.spy(bufferController.operationQueue, 'append'); hls.trigger(Events.BUFFER_FLUSHING, { startOffset: 0, endOffset: 10, type: null, }); expect(spy).to.have.been.calledTwice; }); }); describe('sourcebuffer creation', function () { let createSbStub; let checkPendingTracksSpy; beforeEach(function () { createSbStub = sandbox .stub(bufferController, 'createSourceBuffers') .callsFake(() => { Object.keys(bufferController.tracks).forEach((type) => { bufferController.tracks[type] = { appendBuffer: () => {}, remove: () => {}, }; }); }); checkPendingTracksSpy = sandbox.spy( bufferController, 'checkPendingTracks', ); }); it('initializes with zero expected BUFFER_CODEC events', function () { expect(bufferController.bufferCodecEventsTotal).to.equal(0); }); it('should throw if no media element has been attached', function () { (bufferController.createSourceBuffers as sinon.SinonStub).restore(); bufferController.tracks = { video: { id: 'main', container: '', listeners: [], }, }; expect(bufferController.checkPendingTracks).to.throw(); }); it('exposes tracks from buffer controller through BUFFER_CREATED event', function () { (bufferController.createSourceBuffers as sinon.SinonStub).restore(); // MEDIA_ATTACHING bufferController.media = new MockMediaElement() as unknown as HTMLMediaElement; bufferController.mediaSource = new MockMediaSource() as unknown as MediaSource; sandbox.stub(bufferController.mediaSource as any, 'addSourceBuffer'); return new Promise((resolve, reject) => { hls.on(Hls.Events.BUFFER_CREATED, (event, data) => { expect(bufferController.tracks) .to.have.property('video') .which.deep.equals( { buffer: undefined, id: 'main', container: 'video/mp4', codec: 'avc1.42e01e', levelCodec: undefined, supplemental: undefined, listeners: bufferController.tracks.video?.listeners, metadata: undefined, }, JSON.stringify(bufferController.tracks.video), ); resolve({}); }); hls.once(Hls.Events.ERROR, (event, data) => { reject(data.error); }); hls.trigger(Events.BUFFER_CODECS, { video: { id: 'main', container: 'video/mp4', codec: 'avc1.42e01e', }, }); }); }); it('expects one bufferCodec event by default', function () { hls.trigger(Events.MANIFEST_PARSED, {} as any); expect(bufferController.bufferCodecEventsTotal).to.equal(1); }); it('expects two bufferCodec events if altAudio is signaled', function () { hls.trigger(Events.MANIFEST_PARSED, { altAudio: true, } as any); expect(bufferController.bufferCodecEventsTotal).to.equal(2); }); it('expects one bufferCodec event if altAudio is signaled with audio only', function () { hls.trigger(Events.MANIFEST_PARSED, { altAudio: true, audio: true, video: false, } as any); expect(bufferController.bufferCodecEventsTotal).to.equal(1); }); it('creates sourceBuffers when no more BUFFER_CODEC events are expected', function () { bufferController.tracks = { video: { id: 'main', container: 'video/mp4', listeners: [], }, }; bufferController.checkPendingTracks(); expect(createSbStub).to.have.been.calledOnce; }); it('creates sourceBuffers on the first even if two tracks are received', function () { bufferController.tracks = { audio: { id: 'audio', container: 'audio/mp4', listeners: [], }, video: { id: 'main', container: 'video/mp4', listeners: [], }, }; bufferController.bufferCodecEventsTotal = 2; bufferController.checkPendingTracks(); expect(bufferController.tracksReady).to.be.true; expect(createSbStub).to.have.been.calledOnce; }); it('does not create sourceBuffers when BUFFER_CODEC events are expected', function () { bufferController.tracks = { video: { id: 'main', container: 'video/mp4', listeners: [], }, }; bufferController.bufferCodecEventsTotal = 2; expect(bufferController.pendingTrackCount).to.equal(1); bufferController.checkPendingTracks(); expect(bufferController.tracksReady).to.be.false; expect(createSbStub).to.not.have.been.called; }); it('checks pending tracks even when more events are expected', function () { bufferController.tracks = {}; bufferController.mediaSource = { readyState: 'open', removeEventListener: () => {}, } as unknown as MediaSource; bufferController.bufferCodecEventsTotal = 2; hls.trigger(Events.BUFFER_CODECS, { audio: { id: 'audio', container: 'audio/mp4', }, }); expect(checkPendingTracksSpy).to.have.been.calledOnce; expect(bufferController.pendingTrackCount).to.equal(1); expect(bufferController.sourceBufferCount).to.equal(0); expect(bufferController.tracksReady).to.be.false; hls.trigger(Events.BUFFER_CODECS, { video: { id: 'main', container: 'video/mp4', }, }); expect(checkPendingTracksSpy).to.have.been.calledTwice; expect(bufferController.pendingTrackCount).to.equal(2); expect(bufferController.sourceBufferCount).to.equal(0); expect(bufferController.tracksReady).to.be.true; }); it('creates the expected amount of sourceBuffers given the standard event flow', function () { bufferController.tracks = {}; bufferController.mediaSource = new MockMediaSource() as unknown as MediaSource; sandbox.stub(bufferController.mediaSource, 'removeEventListener'); hls.trigger(Events.MANIFEST_PARSED, { altAudio: true, } as any); bufferController._onMediaSourceOpen(); hls.trigger(Events.BUFFER_CODECS, { audio: {} } as any); hls.trigger(Events.BUFFER_CODECS, { video: {} } as any); expect(createSbStub).to.have.been.calledOnce; expect(bufferController.tracks).to.have.property('audio'); expect(bufferController.tracks).to.have.property('video'); }); }); describe('onBufferCodecs', function () { it('calls changeType if needed and stores current track info', function () { /* eslint-disable-next-line no-unused-vars */ const appendChangeType = sandbox.stub( bufferController as any, 'appendChangeType', ); const buffer = { changeType: sandbox.stub(), } as unknown as ExtendedSourceBuffer; const originalAudioTrack: ParsedTrack = { id: 'main', codec: 'mp4a.40.2', levelCodec: undefined, container: 'audio/mp4', metadata: { channelCount: 1, }, }; const newAudioTrack: ParsedTrack = { id: 'main', codec: 'mp4a.40.5', levelCodec: undefined, container: 'audio/mp4', metadata: { channelCount: 1, }, }; bufferController.tracks = { audio: { ...originalAudioTrack, buffer, listeners: [], }, }; bufferController.sourceBuffers = [ [null, null], ['audio', buffer], ]; hls.trigger(Events.BUFFER_CODECS, { audio: newAudioTrack, }); expect(appendChangeType).to.have.been.calledOnce; expect(appendChangeType).to.have.been.calledWith( 'audio', 'audio/mp4', 'mp4a.40.5', ); expect(bufferController.tracks.audio?.pendingCodec).to.equal( newAudioTrack.codec, ); hls.trigger(Events.BUFFER_CODECS, { audio: originalAudioTrack, }); expect(appendChangeType).to.have.been.calledTwice; expect(appendChangeType).to.have.been.calledWith( 'audio', 'audio/mp4', 'mp4a.40.2', ); expect(bufferController.tracks.audio?.pendingCodec).to.equal( originalAudioTrack.codec, ); }); }); describe('bufferedToEnd', function () { it('returns false when there are no source buffers and no pending tracks', function () { expect(bufferController.pendingTrackCount).to.equal(0); expect(bufferController.sourceBufferCount).to.equal(0); expect(bufferController.bufferedToEnd).to.equal(false); }); it('returns false when there are no source buffers', function () { bufferController.tracks = { audio: { id: 'audio', container: 'audio/mp4', listeners: [], }, video: { id: 'main', container: 'video/mp4', listeners: [], }, }; expect(bufferController.pendingTrackCount).to.equal(2); expect(bufferController.sourceBufferCount).to.equal(0); expect(bufferController.bufferedToEnd).to.equal(false); }); it('returns false when there is a track that has not ended', function () { bufferController.tracks = { audio: { buffer: {} as unknown as ExtendedSourceBuffer, id: 'audio', container: 'audio/mp4', listeners: [], }, video: { buffer: {} as unknown as ExtendedSourceBuffer, id: 'main', container: 'video/mp4', listeners: [], }, }; bufferController.sourceBuffers = [ ['video', {} as unknown as ExtendedSourceBuffer], ['audio', {} as unknown as ExtendedSourceBuffer], ]; expect(bufferController.pendingTrackCount).to.equal(0); expect(bufferController.sourceBufferCount).to.equal(2); expect(bufferController.bufferedToEnd).to.equal(false); }); it('returns false when there is a track that is ending', function () { bufferController.tracks = { audio: { buffer: {} as unknown as ExtendedSourceBuffer, id: 'audio', container: 'audio/mp4', listeners: [], ended: true, ending: true, }, video: { buffer: {} as unknown as ExtendedSourceBuffer, id: 'main', container: 'video/mp4', listeners: [], ended: true, ending: true, }, }; bufferController.sourceBuffers = [ ['video', {} as unknown as ExtendedSourceBuffer], ['audio', {} as unknown as ExtendedSourceBuffer], ]; expect(bufferController.pendingTrackCount).to.equal(0); expect(bufferController.sourceBufferCount).to.equal(2); expect(bufferController.bufferedToEnd).to.equal(false); }); it('returns true when audio and video tracks haved ended', function () { bufferController.tracks = { audio: { buffer: {} as unknown as ExtendedSourceBuffer, id: 'audio', container: 'audio/mp4', listeners: [], ended: true, ending: false, }, video: { buffer: {} as unknown as ExtendedSourceBuffer, id: 'main', container: 'video/mp4', listeners: [], ended: true, ending: false, }, }; bufferController.sourceBuffers = [ ['video', {} as unknown as ExtendedSourceBuffer], ['audio', {} as unknown as ExtendedSourceBuffer], ]; expect(bufferController.pendingTrackCount).to.equal(0); expect(bufferController.sourceBufferCount).to.equal(2); expect(bufferController.bufferedToEnd).to.be.true; }); it('returns true when the audio-only track has ended', function () { bufferController.tracks = { audio: { buffer: {} as unknown as ExtendedSourceBuffer, id: 'audio', container: 'audio/mp4', listeners: [], ended: true, ending: false, }, }; bufferController.sourceBuffers = [ [null, null], ['audio', {} as unknown as ExtendedSourceBuffer], ]; expect(bufferController.pendingTrackCount).to.equal(0); expect(bufferController.sourceBufferCount).to.equal(1); expect(bufferController.bufferedToEnd).to.be.true; }); it('returns true when the video-only track has ended', function () { bufferController.tracks = { video: { buffer: {} as unknown as ExtendedSourceBuffer, id: 'main', container: 'video/mp4', listeners: [], ended: true, ending: false, }, }; bufferController.sourceBuffers = [ ['video', {} as unknown as ExtendedSourceBuffer], [null, null], ]; expect(bufferController.pendingTrackCount).to.equal(0); expect(bufferController.sourceBufferCount).to.equal(1); expect(bufferController.bufferedToEnd).to.be.true; }); it('returns true when the audiovideo track has ended', function () { bufferController.tracks = { audiovideo: { buffer: {} as unknown as ExtendedSourceBuffer, id: 'main', container: 'video/mp4', listeners: [], ended: true, ending: false, }, }; bufferController.sourceBuffers = [ ['audiovideo', {} as unknown as ExtendedSourceBuffer], [null, null], ]; expect(bufferController.pendingTrackCount).to.equal(0); expect(bufferController.sourceBufferCount).to.equal(1); expect(bufferController.bufferedToEnd).to.be.true; }); }); describe('MediaSource requires reset', function () { it('emits error when sourceclose fires with media attached', function () { const media = new MockMediaElement() as unknown as HTMLMediaElement; const mediaSource = new MockMediaSource() as unknown as MediaSource; const triggerSpy = sandbox.spy(hls, 'trigger'); bufferController.media = media; bufferController.mediaSource = mediaSource; bufferController._onMediaSourceClose(); expect(triggerSpy).to.have.been.calledWith( Events.ERROR, sinon.match({ type: ErrorTypes.MEDIA_ERROR, details: ErrorDetails.MEDIA_SOURCE_REQUIRES_RESET, fatal: false, }), ); }); it('does not emit error when media is not attached', function () { const mediaSource = new MockMediaSource() as unknown as MediaSource; const triggerSpy = sandbox.spy(hls, 'trigger'); bufferController.media = null; bufferController.mediaSource = mediaSource; bufferController._onMediaSourceClose(); expect(triggerSpy).to.not.have.been.calledWith(Events.ERROR); }); }); });