UNPKG

shaka-player

Version:
1,201 lines (1,041 loc) 47.3 kB
/*! @license * Shaka Player * Copyright 2016 Google LLC * SPDX-License-Identifier: Apache-2.0 */ /** * @typedef {{ * length: number, * start: jasmine.Spy, * end: jasmine.Spy * }} */ let MockTimeRanges; /** * @typedef {{ * abort: jasmine.Spy, * appendBuffer: jasmine.Spy, * remove: jasmine.Spy, * updating: boolean, * addEventListener: jasmine.Spy, * removeEventListener: jasmine.Spy, * buffered: (MockTimeRanges|TimeRanges), * timestampOffset: number, * appendWindowEnd: number, * updateend: function(), * error: function() * }} */ let MockSourceBuffer; describe('MediaSourceEngine', () => { const Util = shaka.test.Util; const ContentType = shaka.util.ManifestParserUtils.ContentType; const originalIsTypeSupported = window.MediaSource.isTypeSupported; const originalTextEngine = shaka.text.TextEngine; const originalCreateMediaSource = // eslint-disable-next-line no-restricted-syntax shaka.media.MediaSourceEngine.prototype.createMediaSource; const originalTransmuxer = shaka.media.Transmuxer; // Jasmine Spies don't handle toHaveBeenCalledWith well with objects, so use // some numbers instead. const buffer = /** @type {!ArrayBuffer} */ (/** @type {?} */ (1)); const buffer2 = /** @type {!ArrayBuffer} */ (/** @type {?} */ (2)); const buffer3 = /** @type {!ArrayBuffer} */ (/** @type {?} */ (3)); const fakeVideoStream = {mimeType: 'video/foo', drmInfos: []}; const fakeAudioStream = {mimeType: 'audio/foo', drmInfos: []}; const fakeTextStream = {mimeType: 'text/foo', drmInfos: []}; const fakeTransportStream = {mimeType: 'tsMimetype', drmInfos: []}; let audioSourceBuffer; let videoSourceBuffer; let mockVideo; /** @type {HTMLMediaElement} */ let video; let mockMediaSource; let mockTextEngine; /** @type {!shaka.test.FakeTextDisplayer} */ let mockTextDisplayer; /** @type {!shaka.test.FakeClosedCaptionParser} */ let mockClosedCaptionParser; /** @type {!shaka.test.FakeTransmuxer} */ let mockTransmuxer; /** @type {!jasmine.Spy} */ let createMediaSourceSpy; /** @type {!shaka.media.MediaSourceEngine} */ let mediaSourceEngine; beforeAll(() => { // Since this is not an integration test, we don't want MediaSourceEngine to // fail assertions based on browser support for types. Pretend that all // video and audio types are supported. window.MediaSource.isTypeSupported = (mimeType) => { const type = mimeType.split('/')[0]; return type == 'video' || type == 'audio'; }; }); afterAll(() => { window.MediaSource.isTypeSupported = originalIsTypeSupported; shaka.media.Transmuxer = originalTransmuxer; }); beforeEach(/** @suppress {invalidCasts} */ () => { audioSourceBuffer = createMockSourceBuffer(); videoSourceBuffer = createMockSourceBuffer(); mockMediaSource = createMockMediaSource(); mockMediaSource.addSourceBuffer.and.callFake((mimeType) => { const type = mimeType.split('/')[0]; return type == 'audio' ? audioSourceBuffer : videoSourceBuffer; }); mockTransmuxer = new shaka.test.FakeTransmuxer(); // eslint-disable-next-line no-restricted-syntax shaka.media.Transmuxer = /** @type {?} */ (function() { return /** @type {?} */ (mockTransmuxer); }); shaka.media.Transmuxer.convertTsCodecs = originalTransmuxer.convertTsCodecs; shaka.media.Transmuxer.isSupported = (mimeType, contentType) => { return mimeType == 'tsMimetype'; }; shaka.text.TextEngine = createMockTextEngineCtor(); createMediaSourceSpy = jasmine.createSpy('createMediaSource'); createMediaSourceSpy.and.callFake((p) => { p.resolve(); return mockMediaSource; }); // eslint-disable-next-line no-restricted-syntax shaka.media.MediaSourceEngine.prototype.createMediaSource = Util.spyFunc(createMediaSourceSpy); // MediaSourceEngine uses video to: // - set src attribute // - read error codes when operations fail // - seek to flush the pipeline on some platforms // - check buffered.length to assert that flushing the pipeline is okay mockVideo = { src: '', error: null, currentTime: 0, buffered: { length: 0, }, removeAttribute: /** @this {HTMLVideoElement} */ (attr) => { // Only called with attr == 'src'. // This assertion alerts us if the requirements for this mock change. goog.asserts.assert(attr == 'src', 'Unexpected removeAttribute() call'); mockVideo.src = ''; }, load: /** @this {HTMLVideoElement} */ () => { // This assertion alerts us if the requirements for this mock change. goog.asserts.assert(mockVideo.src == '', 'Unexpected load() call'); }, }; video = /** @type {HTMLMediaElement} */(mockVideo); mockClosedCaptionParser = new shaka.test.FakeClosedCaptionParser(); mockTextDisplayer = new shaka.test.FakeTextDisplayer(); mediaSourceEngine = new shaka.media.MediaSourceEngine( video, mockClosedCaptionParser, mockTextDisplayer); }); afterEach(() => { mockTextEngine = null; shaka.text.TextEngine = originalTextEngine; // eslint-disable-next-line no-restricted-syntax shaka.media.MediaSourceEngine.prototype.createMediaSource = originalCreateMediaSource; }); describe('constructor', () => { const originalCreateObjectURL = shaka.media.MediaSourceEngine.createObjectURL; const originalMediaSource = window.MediaSource; /** @type {jasmine.Spy} */ let createObjectURLSpy; beforeEach(async () => { // Mock out MediaSource so we can test the production version of // createMediaSource. To do this, the test must call the // MediaSourceEngine constructor again. The call beforeEach was done with // a mocked createMediaSource. createMediaSourceSpy.calls.reset(); createMediaSourceSpy.and.callFake(originalCreateMediaSource); createObjectURLSpy = jasmine.createSpy('createObjectURL'); createObjectURLSpy.and.returnValue('blob:foo'); shaka.media.MediaSourceEngine.createObjectURL = Util.spyFunc(createObjectURLSpy); const mediaSourceSpy = jasmine.createSpy('MediaSource'); // Because this is a fake constructor, it must be callable with "new". // This will cause jasmine to invoke the callback with "new" as well, so // the callback must be a "function". This detail is hidden when babel // transpiles the tests. // eslint-disable-next-line prefer-arrow-callback, no-restricted-syntax mediaSourceSpy.and.callFake(function() { return mockMediaSource; }); window.MediaSource = Util.spyFunc(mediaSourceSpy); await mediaSourceEngine.destroy(); }); afterAll(() => { shaka.media.MediaSourceEngine.createObjectURL = originalCreateObjectURL; window.MediaSource = originalMediaSource; }); it('creates a MediaSource object and sets video.src', () => { mediaSourceEngine = new shaka.media.MediaSourceEngine( video, new shaka.test.FakeClosedCaptionParser(), new shaka.test.FakeTextDisplayer()); expect(createMediaSourceSpy).toHaveBeenCalled(); expect(createObjectURLSpy).toHaveBeenCalled(); expect(mockVideo.src).toBe('blob:foo'); }); }); describe('init', () => { it('creates SourceBuffers for the given types', async () => { const initObject = new Map(); initObject.set(ContentType.AUDIO, fakeAudioStream); initObject.set(ContentType.VIDEO, fakeVideoStream); await mediaSourceEngine.init(initObject, false); expect(mockMediaSource.addSourceBuffer).toHaveBeenCalledWith('audio/foo'); expect(mockMediaSource.addSourceBuffer).toHaveBeenCalledWith('video/foo'); expect(shaka.text.TextEngine).not.toHaveBeenCalled(); }); it('creates TextEngines for text types', async () => { const initObject = new Map(); initObject.set(ContentType.TEXT, fakeTextStream); await mediaSourceEngine.init(initObject, false); expect(mockMediaSource.addSourceBuffer).not.toHaveBeenCalled(); expect(shaka.text.TextEngine).toHaveBeenCalled(); }); }); describe('bufferStart and bufferEnd', () => { beforeEach(async () => { const initObject = new Map(); initObject.set(ContentType.AUDIO, fakeAudioStream); initObject.set(ContentType.TEXT, fakeTextStream); await mediaSourceEngine.init(initObject, false); }); it('returns correct timestamps for one range', () => { audioSourceBuffer.buffered = createFakeBuffered([{start: 0, end: 10}]); expect(mediaSourceEngine.bufferStart(ContentType.AUDIO)).toBeCloseTo(0); expect(mediaSourceEngine.bufferEnd(ContentType.AUDIO)).toBeCloseTo(10); }); it('returns correct timestamps for multiple ranges', () => { audioSourceBuffer.buffered = createFakeBuffered([{start: 5, end: 10}, {start: 20, end: 30}]); expect(mediaSourceEngine.bufferStart(ContentType.AUDIO)).toBeCloseTo(5); expect(mediaSourceEngine.bufferEnd(ContentType.AUDIO)).toBeCloseTo(30); }); it('returns null if there are no ranges', () => { audioSourceBuffer.buffered = createFakeBuffered([]); expect(mediaSourceEngine.bufferStart(ContentType.AUDIO)).toBeNull(); expect(mediaSourceEngine.bufferEnd(ContentType.AUDIO)).toBeNull(); }); it('will forward to TextEngine', () => { mockTextEngine.bufferStart.and.returnValue(10); mockTextEngine.bufferEnd.and.returnValue(20); expect(mockTextEngine.bufferStart).not.toHaveBeenCalled(); expect(mediaSourceEngine.bufferStart(ContentType.TEXT)).toBe(10); expect(mockTextEngine.bufferStart).toHaveBeenCalled(); expect(mockTextEngine.bufferEnd).not.toHaveBeenCalled(); expect(mediaSourceEngine.bufferEnd(ContentType.TEXT)).toBe(20); expect(mockTextEngine.bufferEnd).toHaveBeenCalled(); }); }); describe('bufferedAheadOf', () => { beforeEach(async () => { const initObject = new Map(); initObject.set(ContentType.AUDIO, fakeAudioStream); initObject.set(ContentType.TEXT, fakeTextStream); await mediaSourceEngine.init(initObject, false); }); it('returns the amount of data ahead of the given position', () => { audioSourceBuffer.buffered = createFakeBuffered([{start: 0, end: 10}]); expect(mediaSourceEngine.bufferedAheadOf(ContentType.AUDIO, 0)) .toBeCloseTo(10); expect(mediaSourceEngine.bufferedAheadOf(ContentType.AUDIO, 5)) .toBeCloseTo(5); expect(mediaSourceEngine.bufferedAheadOf(ContentType.AUDIO, 9.9)) .toBeCloseTo(0.1); }); it('returns zero when given an unbuffered time', () => { audioSourceBuffer.buffered = createFakeBuffered([{start: 5, end: 10}]); expect(mediaSourceEngine.bufferedAheadOf(ContentType.AUDIO, 10)) .toBeCloseTo(0); expect(mediaSourceEngine.bufferedAheadOf(ContentType.AUDIO, 100)) .toBeCloseTo(0); }); it('returns the correct amount with multiple ranges', () => { audioSourceBuffer.buffered = createFakeBuffered([{start: 1, end: 3}, {start: 6, end: 10}]); // in range 0 expect(mediaSourceEngine.bufferedAheadOf(ContentType.AUDIO, 1)) .toBeCloseTo(6); expect(mediaSourceEngine.bufferedAheadOf(ContentType.AUDIO, 2.5)) .toBeCloseTo(4.5); // between range 0 and 1 expect(mediaSourceEngine.bufferedAheadOf(ContentType.AUDIO, 5)) .toBeCloseTo(4); // in range 1 expect(mediaSourceEngine.bufferedAheadOf(ContentType.AUDIO, 6)) .toBeCloseTo(4); expect(mediaSourceEngine.bufferedAheadOf(ContentType.AUDIO, 9.9)) .toBeCloseTo(0.1); }); it('will forward to TextEngine', () => { mockTextEngine.bufferedAheadOf.and.returnValue(10); expect(mockTextEngine.bufferedAheadOf).not.toHaveBeenCalled(); expect(mediaSourceEngine.bufferedAheadOf(ContentType.TEXT, 5)).toBe(10); expect(mockTextEngine.bufferedAheadOf).toHaveBeenCalledWith(5); }); }); describe('appendBuffer', () => { beforeEach(async () => { captureEvents(audioSourceBuffer, ['updateend', 'error']); captureEvents(videoSourceBuffer, ['updateend', 'error']); const initObject = new Map(); initObject.set(ContentType.AUDIO, fakeAudioStream); initObject.set(ContentType.VIDEO, fakeVideoStream); initObject.set(ContentType.TEXT, fakeTextStream); await mediaSourceEngine.init(initObject, false); }); it('appends the given data', async () => { const p = mediaSourceEngine.appendBuffer( ContentType.AUDIO, buffer, null, null, /* hasClosedCaptions= */ false); expect(audioSourceBuffer.appendBuffer).toHaveBeenCalledWith(buffer); audioSourceBuffer.updateend(); await p; }); it('rejects promise when operation throws', async () => { audioSourceBuffer.appendBuffer.and.throwError('fail!'); mockVideo.error = {code: 5}; const expected = Util.jasmineError(new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.MEDIA, shaka.util.Error.Code.MEDIA_SOURCE_OPERATION_THREW, jasmine.objectContaining({message: 'fail!'}))); await expectAsync( mediaSourceEngine.appendBuffer( ContentType.AUDIO, buffer, null, null, /* hasClosedCaptions= */ false)) .toBeRejectedWith(expected); expect(audioSourceBuffer.appendBuffer).toHaveBeenCalledWith(buffer); }); it('rejects promise when op. throws QuotaExceededError', async () => { const fakeDOMException = {name: 'QuotaExceededError'}; audioSourceBuffer.appendBuffer.and.callFake(() => { throw fakeDOMException; }); mockVideo.error = {code: 5}; const expected = Util.jasmineError(new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.MEDIA, shaka.util.Error.Code.QUOTA_EXCEEDED_ERROR, ContentType.AUDIO)); await expectAsync( mediaSourceEngine.appendBuffer( ContentType.AUDIO, buffer, null, null, /* hasClosedCaptions= */ false)) .toBeRejectedWith(expected); expect(audioSourceBuffer.appendBuffer).toHaveBeenCalledWith(buffer); }); it('handles QuotaExceededError for pending operations', async () => { const fakeDOMException = {name: 'QuotaExceededError'}; audioSourceBuffer.appendBuffer.and.callFake(() => { if (audioSourceBuffer.appendBuffer.calls.count() > 1) { throw fakeDOMException; } }); mockVideo.error = {code: 5}; const expected = Util.jasmineError(new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.MEDIA, shaka.util.Error.Code.QUOTA_EXCEEDED_ERROR, ContentType.AUDIO)); const p1 = mediaSourceEngine.appendBuffer( ContentType.AUDIO, buffer, null, null, /* hasClosedCaptions= */ false); const p2 = mediaSourceEngine.appendBuffer( ContentType.AUDIO, buffer, null, null, /* hasClosedCaptions= */ false); audioSourceBuffer.updateend(); await expectAsync(p1).toBeResolved(); await expectAsync(p2).toBeRejectedWith(expected); }); it('rejects the promise if this operation fails async', async () => { mockVideo.error = {code: 5}; const p = mediaSourceEngine.appendBuffer( ContentType.AUDIO, buffer, null, null, /* hasClosedCaptions= */ false); audioSourceBuffer.error(); audioSourceBuffer.updateend(); const expected = Util.jasmineError(new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.MEDIA, shaka.util.Error.Code.MEDIA_SOURCE_OPERATION_FAILED, 5)); await expectAsync(p).toBeRejectedWith(expected); expect(audioSourceBuffer.appendBuffer).toHaveBeenCalledWith(buffer); }); it('queues operations on a single SourceBuffer', async () => { /** @type {!shaka.test.StatusPromise} */ const p1 = new shaka.test.StatusPromise(mediaSourceEngine.appendBuffer( ContentType.AUDIO, buffer, null, null, /* hasClosedCaptions= */ false)); /** @type {!shaka.test.StatusPromise} */ const p2 = new shaka.test.StatusPromise(mediaSourceEngine.appendBuffer( ContentType.AUDIO, buffer2, null, null, /* hasClosedCaptions= */ false)); expect(audioSourceBuffer.appendBuffer).toHaveBeenCalledWith(buffer); expect(audioSourceBuffer.appendBuffer).not.toHaveBeenCalledWith(buffer2); expect(p1.status).toBe('pending'); expect(p2.status).toBe('pending'); audioSourceBuffer.updateend(); await p1; expect(p2.status).toBe('pending'); expect(audioSourceBuffer.appendBuffer).toHaveBeenCalledWith(buffer2); audioSourceBuffer.updateend(); await p2; }); it('queues operations independently for different types', async () => { /** @type {!shaka.test.StatusPromise} */ const p1 = new shaka.test.StatusPromise(mediaSourceEngine.appendBuffer( ContentType.AUDIO, buffer, null, null, /* hasClosedCaptions= */ false)); /** @type {!shaka.test.StatusPromise} */ const p2 = new shaka.test.StatusPromise(mediaSourceEngine.appendBuffer( ContentType.AUDIO, buffer2, null, null, /* hasClosedCaptions= */ false)); /** @type {!shaka.test.StatusPromise} */ const p3 = new shaka.test.StatusPromise(mediaSourceEngine.appendBuffer( ContentType.VIDEO, buffer3, null, null, /* hasClosedCaptions= */ false)); expect(audioSourceBuffer.appendBuffer).toHaveBeenCalledWith(buffer); expect(audioSourceBuffer.appendBuffer).not.toHaveBeenCalledWith(buffer2); expect(videoSourceBuffer.appendBuffer).toHaveBeenCalledWith(buffer3); expect(p1.status).toBe('pending'); expect(p2.status).toBe('pending'); expect(p3.status).toBe('pending'); audioSourceBuffer.updateend(); videoSourceBuffer.updateend(); // Wait a tick between each updateend() and the status check that follows. await p1; expect(p2.status).toBe('pending'); expect(audioSourceBuffer.appendBuffer).toHaveBeenCalledWith(buffer2); await p3; audioSourceBuffer.updateend(); await p2; }); it('continues if an operation throws', async () => { audioSourceBuffer.appendBuffer.and.callFake((value) => { if (value == 2) { // throw synchronously. throw new Error(); } else { // complete successfully asynchronously. Promise.resolve().then(() => { audioSourceBuffer.updateend(); }); } }); const p1 = mediaSourceEngine.appendBuffer( ContentType.AUDIO, buffer, null, null, /* hasClosedCaptions= */ false); const p2 = mediaSourceEngine.appendBuffer( ContentType.AUDIO, buffer2, null, null, /* hasClosedCaptions= */ false); const p3 = mediaSourceEngine.appendBuffer( ContentType.AUDIO, buffer3, null, null, /* hasClosedCaptions= */ false); await expectAsync(p1).toBeResolved(); await expectAsync(p2).toBeRejected(); await expectAsync(p3).toBeResolved(); expect(audioSourceBuffer.appendBuffer).toHaveBeenCalledWith(buffer); expect(audioSourceBuffer.appendBuffer).toHaveBeenCalledWith(buffer2); expect(audioSourceBuffer.appendBuffer).toHaveBeenCalledWith(buffer3); }); it('forwards to TextEngine', async () => { const data = new ArrayBuffer(0); expect(mockTextEngine.appendBuffer).not.toHaveBeenCalled(); await mediaSourceEngine.appendBuffer( ContentType.TEXT, data, 0, 10, /* hasClosedCaptions= */ false); expect(mockTextEngine.appendBuffer).toHaveBeenCalledWith( data, 0, 10); }); it('appends transmuxed data and captions', async () => { const initObject = new Map(); initObject.set(ContentType.VIDEO, fakeTransportStream); const output = { data: new Uint8Array(1), captions: [{}], }; mockTransmuxer.transmux.and.returnValue(Promise.resolve(output)); const init = async () => { await mediaSourceEngine.init(initObject, false); await mediaSourceEngine.appendBuffer( ContentType.VIDEO, buffer, null, null, /* hasClosedCaptions= */ false); expect(mockTextEngine.storeAndAppendClosedCaptions).toHaveBeenCalled(); expect(videoSourceBuffer.appendBuffer).toHaveBeenCalled(); }; // The 'updateend' event fires once the data is done appending to the // media source. We only append to the media source once transmuxing is // done. Since transmuxing is done using Promises, we need to delay the // event until MediaSourceEngine calls appendBuffer. const delay = async () => { await Util.shortDelay(); videoSourceBuffer.updateend(); }; await Promise.all([init(), delay()]); }); it('appends only transmuxed data without embedded text', async () => { const initObject = new Map(); initObject.set(ContentType.VIDEO, fakeTransportStream); const output = { data: new Uint8Array(1), captions: [], }; mockTransmuxer.transmux.and.returnValue(Promise.resolve(output)); const init = async () => { await mediaSourceEngine.init(initObject, false); await mediaSourceEngine.appendBuffer( ContentType.VIDEO, buffer, null, null, /* hasClosedCaptions= */ false); expect(mockTextEngine.storeAndAppendClosedCaptions) .not.toHaveBeenCalled(); expect(videoSourceBuffer.appendBuffer) .toHaveBeenCalledWith(output.data); }; // The 'updateend' event fires once the data is done appending to the // media source. We only append to the media source once transmuxing is // done. Since transmuxing is done using Promises, we need to delay the // event until MediaSourceEngine calls appendBuffer. const delay = async () => { await Util.shortDelay(); videoSourceBuffer.updateend(); }; await Promise.all([init(), delay()]); }); it('appends parsed closed captions from CaptionParser', async () => { const initObject = new Map(); initObject.set(ContentType.VIDEO, fakeVideoStream); mockClosedCaptionParser.parseFromSpy.and.callFake((data) => { return ['foo', 'bar']; }); await mediaSourceEngine.init(initObject, false); // Initialize the closed caption parser. const appendInit = mediaSourceEngine.appendBuffer( ContentType.VIDEO, buffer, null, null, true); // In MediaSourceEngine, appendBuffer() is async and Promise-based, but // at the browser level, it's event-based. // MediaSourceEngine waits for the 'updateend' event from the // SourceBuffer, and uses that to resolve the appendBuffer Promise. // Here, we must trigger the event on the fake/mock SourceBuffer before // waiting on the appendBuffer Promise. videoSourceBuffer.updateend(); await appendInit; expect(mockTextEngine.storeAndAppendClosedCaptions).not .toHaveBeenCalled(); // Parse and append the closed captions embedded in video stream. const appendVideo = mediaSourceEngine.appendBuffer( ContentType.VIDEO, buffer, 0, Infinity, true); videoSourceBuffer.updateend(); await appendVideo; expect(mockTextEngine.storeAndAppendClosedCaptions).toHaveBeenCalled(); }); }); describe('remove', () => { beforeEach(async () => { captureEvents(audioSourceBuffer, ['updateend', 'error']); captureEvents(videoSourceBuffer, ['updateend', 'error']); const initObject = new Map(); initObject.set(ContentType.AUDIO, fakeAudioStream); initObject.set(ContentType.VIDEO, fakeVideoStream); initObject.set(ContentType.TEXT, fakeTextStream); await mediaSourceEngine.init(initObject, false); }); it('removes the given data', async () => { const p = mediaSourceEngine.remove(ContentType.AUDIO, 1, 5); audioSourceBuffer.updateend(); await p; expect(audioSourceBuffer.remove).toHaveBeenCalledWith(1, 5); }); it('rejects promise when operation throws', async () => { audioSourceBuffer.remove.and.throwError('fail!'); mockVideo.error = {code: 5}; const expected = Util.jasmineError(new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.MEDIA, shaka.util.Error.Code.MEDIA_SOURCE_OPERATION_THREW, jasmine.objectContaining({message: 'fail!'}))); await expectAsync(mediaSourceEngine.remove(ContentType.AUDIO, 1, 5)) .toBeRejectedWith(expected); expect(audioSourceBuffer.remove).toHaveBeenCalledWith(1, 5); }); it('rejects the promise if this operation fails async', async () => { mockVideo.error = {code: 5}; const p = mediaSourceEngine.remove(ContentType.AUDIO, 1, 5); audioSourceBuffer.error(); audioSourceBuffer.updateend(); const expected = Util.jasmineError(new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.MEDIA, shaka.util.Error.Code.MEDIA_SOURCE_OPERATION_FAILED, 5)); await expectAsync(p).toBeRejectedWith(expected); expect(audioSourceBuffer.remove).toHaveBeenCalledWith(1, 5); }); it('queues operations on a single SourceBuffer', async () => { /** @type {!shaka.test.StatusPromise} */ const p1 = new shaka.test.StatusPromise( mediaSourceEngine.remove(ContentType.AUDIO, 1, 5)); /** @type {!shaka.test.StatusPromise} */ const p2 = new shaka.test.StatusPromise( mediaSourceEngine.remove(ContentType.AUDIO, 6, 10)); expect(audioSourceBuffer.remove).toHaveBeenCalledWith(1, 5); expect(audioSourceBuffer.remove).not.toHaveBeenCalledWith(6, 10); expect(p1.status).toBe('pending'); expect(p2.status).toBe('pending'); audioSourceBuffer.updateend(); await p1; expect(p2.status).toBe('pending'); expect(audioSourceBuffer.remove).toHaveBeenCalledWith(6, 10); audioSourceBuffer.updateend(); await p2; }); it('queues operations independently for different types', async () => { /** @type {!shaka.test.StatusPromise} */ const p1 = new shaka.test.StatusPromise( mediaSourceEngine.remove(ContentType.AUDIO, 1, 5)); /** @type {!shaka.test.StatusPromise} */ const p2 = new shaka.test.StatusPromise( mediaSourceEngine.remove(ContentType.AUDIO, 6, 10)); /** @type {!shaka.test.StatusPromise} */ const p3 = new shaka.test.StatusPromise( mediaSourceEngine.remove(ContentType.VIDEO, 3, 8)); expect(audioSourceBuffer.remove).toHaveBeenCalledWith(1, 5); expect(audioSourceBuffer.remove).not.toHaveBeenCalledWith(6, 10); expect(videoSourceBuffer.remove).toHaveBeenCalledWith(3, 8); expect(p1.status).toBe('pending'); expect(p2.status).toBe('pending'); expect(p3.status).toBe('pending'); audioSourceBuffer.updateend(); videoSourceBuffer.updateend(); await p1; expect(p2.status).toBe('pending'); expect(audioSourceBuffer.remove).toHaveBeenCalledWith(6, 10); await p3; audioSourceBuffer.updateend(); await p2; }); it('continues if an operation throws', async () => { audioSourceBuffer.remove.and.callFake((start, end) => { if (start == 2) { // throw synchronously. throw new Error(); } else { // complete successfully asynchronously. Promise.resolve().then(() => { audioSourceBuffer.updateend(); }); } }); const p1 = mediaSourceEngine.remove(ContentType.AUDIO, 1, 2); const p2 = mediaSourceEngine.remove(ContentType.AUDIO, 2, 3); const p3 = mediaSourceEngine.remove(ContentType.AUDIO, 3, 4); await expectAsync(p1).toBeResolved(); await expectAsync(p2).toBeRejected(); await expectAsync(p3).toBeResolved(); expect(audioSourceBuffer.remove).toHaveBeenCalledWith(1, 2); expect(audioSourceBuffer.remove).toHaveBeenCalledWith(2, 3); expect(audioSourceBuffer.remove).toHaveBeenCalledWith(3, 4); }); it('will forward to TextEngine', async () => { expect(mockTextEngine.remove).not.toHaveBeenCalled(); await mediaSourceEngine.remove(ContentType.TEXT, 10, 20); expect(mockTextEngine.remove).toHaveBeenCalledWith(10, 20); }); }); describe('clear', () => { beforeEach(async () => { captureEvents(audioSourceBuffer, ['updateend', 'error']); captureEvents(videoSourceBuffer, ['updateend', 'error']); const initObject = new Map(); initObject.set(ContentType.AUDIO, fakeAudioStream); initObject.set(ContentType.VIDEO, fakeVideoStream); initObject.set(ContentType.TEXT, fakeTextStream); await mediaSourceEngine.init(initObject, false); }); it('clears the given data', async () => { mockMediaSource.durationGetter.and.returnValue(20); const p = mediaSourceEngine.clear(ContentType.AUDIO); audioSourceBuffer.updateend(); await p; expect(audioSourceBuffer.remove).toHaveBeenCalledTimes(1); expect(audioSourceBuffer.remove.calls.argsFor(0)[0]).toBe(0); expect(audioSourceBuffer.remove.calls.argsFor(0)[1] >= 20).toBeTruthy(); }); it('does not seek', async () => { // We had a bug in which we got into a seek loop. Seeking caused // StreamingEngine to call clear(). Clearing triggered a pipeline flush // which was implemented by seeking. See issue #569. // This loop is difficult to test for directly. // A unit test on StreamingEngine would not suffice, since reproduction of // the bug would involve making the mock MediaSourceEngine seek on clear. // Since the fix was to remove the implicit seek, this behavior would then // be removed from the mock, which would render the test useless. // An integration test involving both StreamingEngine and MediaSourcEngine // would also be problematic. The bug involved a race, so it would be // difficult to reproduce the necessary timing. And if we succeeded, it // would be tough to detect that we were definitely in a seek loop, since // nothing was mocked. // So the best option seems to be to enforce that clear() does not result // in a seek. This can be done here, in a unit test on MediaSourceEngine. // It does not reproduce the seek loop, but it does ensure that the test // would fail if we ever reintroduced this behavior. const originalTime = 10; mockVideo.currentTime = originalTime; mockMediaSource.durationGetter.and.returnValue(20); const p = mediaSourceEngine.clear(ContentType.AUDIO); audioSourceBuffer.updateend(); await p; expect(mockVideo.currentTime).toBe(originalTime); }); it('will forward to TextEngine', async () => { expect(mockTextEngine.setTimestampOffset).not.toHaveBeenCalled(); expect(mockTextEngine.setAppendWindow).not.toHaveBeenCalled(); await mediaSourceEngine.setStreamProperties(ContentType.TEXT, /* timestampOffset= */ 10, /* appendWindowStart= */ 0, /* appendWindowEnd= */ 20); expect(mockTextEngine.setTimestampOffset).toHaveBeenCalledWith(10); expect(mockTextEngine.setAppendWindow).toHaveBeenCalledWith(0, 20); }); }); describe('endOfStream', () => { beforeEach(async () => { captureEvents(audioSourceBuffer, ['updateend', 'error']); captureEvents(videoSourceBuffer, ['updateend', 'error']); const initObject = new Map(); initObject.set(ContentType.AUDIO, fakeAudioStream); initObject.set(ContentType.VIDEO, fakeVideoStream); await mediaSourceEngine.init(initObject, false); }); it('ends the MediaSource stream with the given reason', async () => { await mediaSourceEngine.endOfStream('foo'); expect(mockMediaSource.endOfStream).toHaveBeenCalledWith('foo'); }); it('waits for all previous operations to complete', async () => { /** @type {!shaka.test.StatusPromise} */ const p1 = new shaka.test.StatusPromise(mediaSourceEngine.appendBuffer( ContentType.AUDIO, buffer, null, null, /* hasClosedCaptions= */ false)); /** @type {!shaka.test.StatusPromise} */ const p2 = new shaka.test.StatusPromise(mediaSourceEngine.appendBuffer( ContentType.VIDEO, buffer, null, null, /* hasClosedCaptions= */ false)); /** @type {!shaka.test.StatusPromise} */ const p3 = new shaka.test.StatusPromise(mediaSourceEngine.endOfStream()); expect(mockMediaSource.endOfStream).not.toHaveBeenCalled(); expect(p1.status).toBe('pending'); expect(p2.status).toBe('pending'); expect(p3.status).toBe('pending'); audioSourceBuffer.updateend(); await p1; expect(p2.status).toBe('pending'); expect(p3.status).toBe('pending'); videoSourceBuffer.updateend(); await p2; await p3; expect(mockMediaSource.endOfStream).toHaveBeenCalled(); }); it('makes subsequent operations wait', async () => { /** @type {!Promise} */ const p1 = mediaSourceEngine.endOfStream(); mediaSourceEngine.appendBuffer(ContentType.AUDIO, buffer, null, null, /* hasClosedCaptions= */ false); mediaSourceEngine.appendBuffer(ContentType.VIDEO, buffer, null, null, /* hasClosedCaptions= */ false); mediaSourceEngine.appendBuffer(ContentType.VIDEO, buffer2, null, null, /* hasClosedCaptions= */ false); // endOfStream hasn't been called yet because blocking multiple queues // takes an extra tick, even when they are empty. expect(mockMediaSource.endOfStream).not.toHaveBeenCalled(); expect(audioSourceBuffer.appendBuffer).not.toHaveBeenCalled(); expect(videoSourceBuffer.appendBuffer).not.toHaveBeenCalled(); await p1; expect(mockMediaSource.endOfStream).toHaveBeenCalled(); // The next operations have already been kicked off. expect(audioSourceBuffer.appendBuffer).toHaveBeenCalledWith(buffer); expect(videoSourceBuffer.appendBuffer).toHaveBeenCalledWith(buffer); // This one is still in queue. expect(videoSourceBuffer.appendBuffer).not.toHaveBeenCalledWith(buffer2); audioSourceBuffer.updateend(); videoSourceBuffer.updateend(); await Promise.resolve(); expect(videoSourceBuffer.appendBuffer).toHaveBeenCalledWith(buffer2); videoSourceBuffer.updateend(); }); it('runs subsequent operations if this operation throws', async () => { mockMediaSource.endOfStream.and.throwError(new Error()); /** @type {!Promise} */ const p1 = mediaSourceEngine.endOfStream(); mediaSourceEngine.appendBuffer(ContentType.AUDIO, buffer, null, null, /* hasClosedCaptions= */ false); expect(audioSourceBuffer.appendBuffer).not.toHaveBeenCalled(); await expectAsync(p1).toBeRejected(); expect(mockMediaSource.endOfStream).toHaveBeenCalled(); await Util.shortDelay(); expect(audioSourceBuffer.appendBuffer).toHaveBeenCalledWith(1); audioSourceBuffer.updateend(); }); }); describe('setDuration', () => { beforeEach(async () => { mockMediaSource.durationGetter.and.returnValue(0); captureEvents(audioSourceBuffer, ['updateend', 'error']); captureEvents(videoSourceBuffer, ['updateend', 'error']); const initObject = new Map(); initObject.set(ContentType.AUDIO, fakeAudioStream); initObject.set(ContentType.VIDEO, fakeVideoStream); await mediaSourceEngine.init(initObject, false); }); it('sets the given duration', async () => { await mediaSourceEngine.setDuration(100); expect(mockMediaSource.durationSetter).toHaveBeenCalledWith(100); }); it('waits for all previous operations to complete', async () => { /** @type {!shaka.test.StatusPromise} */ const p1 = new shaka.test.StatusPromise(mediaSourceEngine.appendBuffer( ContentType.AUDIO, buffer, null, null, /* hasClosedCaptions= */ false)); /** @type {!shaka.test.StatusPromise} */ const p2 = new shaka.test.StatusPromise(mediaSourceEngine.appendBuffer( ContentType.VIDEO, buffer, null, null, /* hasClosedCaptions= */ false)); /** @type {!shaka.test.StatusPromise} */ const p3 = new shaka.test.StatusPromise(mediaSourceEngine.setDuration(100)); expect(mockMediaSource.durationSetter).not.toHaveBeenCalled(); expect(p1.status).toBe('pending'); expect(p2.status).toBe('pending'); expect(p3.status).toBe('pending'); audioSourceBuffer.updateend(); await p1; expect(p2.status).toBe('pending'); expect(p3.status).toBe('pending'); videoSourceBuffer.updateend(); await p2; await p3; expect(mockMediaSource.durationSetter).toHaveBeenCalledWith(100); }); it('makes subsequent operations wait', async () => { /** @type {!Promise} */ const p1 = mediaSourceEngine.setDuration(100); mediaSourceEngine.appendBuffer(ContentType.AUDIO, buffer, null, null, /* hasClosedCaptions= */ false); mediaSourceEngine.appendBuffer(ContentType.VIDEO, buffer, null, null, /* hasClosedCaptions= */ false); mediaSourceEngine.appendBuffer(ContentType.VIDEO, buffer2, null, null, /* hasClosedCaptions= */ false); // The setter hasn't been called yet because blocking multiple queues // takes an extra tick, even when they are empty. expect(mockMediaSource.durationSetter).not.toHaveBeenCalled(); expect(audioSourceBuffer.appendBuffer).not.toHaveBeenCalled(); expect(videoSourceBuffer.appendBuffer).not.toHaveBeenCalled(); await p1; expect(mockMediaSource.durationSetter).toHaveBeenCalled(); // The next operations have already been kicked off. expect(audioSourceBuffer.appendBuffer).toHaveBeenCalledWith(buffer); expect(videoSourceBuffer.appendBuffer).toHaveBeenCalledWith(buffer); // This one is still in queue. expect(videoSourceBuffer.appendBuffer) .not.toHaveBeenCalledWith(buffer2); audioSourceBuffer.updateend(); videoSourceBuffer.updateend(); await Promise.resolve(); expect(videoSourceBuffer.appendBuffer).toHaveBeenCalledWith(buffer2); videoSourceBuffer.updateend(); }); it('runs subsequent operations if this operation throws', async () => { mockMediaSource.durationSetter.and.throwError(new Error()); /** @type {!Promise} */ const p1 = mediaSourceEngine.setDuration(100); mediaSourceEngine.appendBuffer(ContentType.AUDIO, buffer, null, null, /* hasClosedCaptions= */ false); expect(audioSourceBuffer.appendBuffer).not.toHaveBeenCalled(); await expectAsync(p1).toBeRejected(); expect(mockMediaSource.durationSetter).toHaveBeenCalled(); await Util.shortDelay(); expect(audioSourceBuffer.appendBuffer).toHaveBeenCalledWith(buffer); audioSourceBuffer.updateend(); }); }); describe('destroy', () => { beforeEach(async () => { captureEvents(audioSourceBuffer, ['updateend', 'error']); captureEvents(videoSourceBuffer, ['updateend', 'error']); const initObject = new Map(); initObject.set(ContentType.AUDIO, fakeAudioStream); initObject.set(ContentType.VIDEO, fakeVideoStream); await mediaSourceEngine.init(initObject, false); }); it('waits for all operations to complete', async () => { mediaSourceEngine.appendBuffer(ContentType.AUDIO, buffer, null, null, /* hasClosedCaptions= */ false); mediaSourceEngine.appendBuffer(ContentType.VIDEO, buffer, null, null, /* hasClosedCaptions= */ false); /** @type {!shaka.test.StatusPromise} */ const d = new shaka.test.StatusPromise(mediaSourceEngine.destroy()); expect(d.status).toBe('pending'); await Util.shortDelay(); expect(d.status).toBe('pending'); audioSourceBuffer.updateend(); await Util.shortDelay(); expect(d.status).toBe('pending'); videoSourceBuffer.updateend(); await d; }); it('resolves even when a pending operation fails', async () => { const p = mediaSourceEngine.appendBuffer( ContentType.AUDIO, buffer, null, null, /* hasClosedCaptions= */ false); const d = mediaSourceEngine.destroy(); audioSourceBuffer.error(); audioSourceBuffer.updateend(); await expectAsync(p).toBeRejected(); await d; }); it('waits for blocking operations to complete', async () => { /** @type {!shaka.test.StatusPromise} */ const p = new shaka.test.StatusPromise(mediaSourceEngine.endOfStream()); /** @type {!shaka.test.StatusPromise} */ const d = new shaka.test.StatusPromise(mediaSourceEngine.destroy()); expect(p.status).toBe('pending'); expect(d.status).toBe('pending'); await p; expect(d.status).toBe('pending'); await d; }); it('cancels operations that have not yet started', async () => { mediaSourceEngine.appendBuffer( ContentType.AUDIO, buffer, null, null, /* hasClosedCaptions= */ false); const rejected = mediaSourceEngine.appendBuffer( ContentType.AUDIO, buffer2, null, null, /* hasClosedCaptions= */ false); // Create the expectation first so we don't get unhandled rejection errors const expected = expectAsync(rejected).toBeRejected(); expect(audioSourceBuffer.appendBuffer).toHaveBeenCalledWith(buffer); expect(audioSourceBuffer.appendBuffer).not.toHaveBeenCalledWith(buffer2); /** @type {!shaka.test.StatusPromise} */ const d = new shaka.test.StatusPromise(mediaSourceEngine.destroy()); expect(d.status).toBe('pending'); await Util.shortDelay(); expect(d.status).toBe('pending'); await expected; expect(audioSourceBuffer.appendBuffer).toHaveBeenCalledWith(buffer); expect(audioSourceBuffer.appendBuffer).not.toHaveBeenCalledWith(buffer2); audioSourceBuffer.updateend(); await d; expect(audioSourceBuffer.appendBuffer).not.toHaveBeenCalledWith(buffer2); }); it('cancels blocking operations that have not yet started', async () => { const p1 = mediaSourceEngine.appendBuffer( ContentType.AUDIO, buffer, null, null, /* hasClosedCaptions= */ false); const p2 = mediaSourceEngine.endOfStream(); const d = mediaSourceEngine.destroy(); audioSourceBuffer.updateend(); await expectAsync(p1).toBeResolved(); await expectAsync(p2).toBeRejected(); await d; }); it('prevents new operations from being added', async () => { const d = mediaSourceEngine.destroy(); await expectAsync( mediaSourceEngine.appendBuffer( ContentType.AUDIO, buffer, null, null, /* hasClosedCaptions= */ false)) .toBeRejected(); await d; expect(audioSourceBuffer.appendBuffer).not.toHaveBeenCalled(); }); it('destroys text engines', async () => { mediaSourceEngine.reinitText('text/vtt'); await mediaSourceEngine.destroy(); expect(mockTextEngine).toBeTruthy(); expect(mockTextEngine.destroy).toHaveBeenCalled(); }); // Regression test for https://github.com/shaka-project/shaka-player/issues/984 it('destroys TextDisplayer on destroy', async () => { await mediaSourceEngine.destroy(); expect(mockTextDisplayer.destroySpy).toHaveBeenCalled(); }); }); function createMockMediaSource() { const mediaSource = { readyState: 'open', addSourceBuffer: jasmine.createSpy('addSourceBuffer'), endOfStream: jasmine.createSpy('endOfStream'), durationGetter: jasmine.createSpy('duration getter'), durationSetter: jasmine.createSpy('duration setter'), addEventListener: jasmine.createSpy('addEventListener'), removeEventListener: () => {}, }; Object.defineProperty(mediaSource, 'duration', { get: Util.spyFunc(mediaSource.durationGetter), set: Util.spyFunc(mediaSource.durationSetter), }); return mediaSource; } /** @return {MockSourceBuffer} */ function createMockSourceBuffer() { return { abort: jasmine.createSpy('abort'), appendBuffer: jasmine.createSpy('appendBuffer'), remove: jasmine.createSpy('remove'), updating: false, addEventListener: jasmine.createSpy('addEventListener'), removeEventListener: jasmine.createSpy('removeEventListener'), buffered: { length: 0, start: jasmine.createSpy('buffered.start'), end: jasmine.createSpy('buffered.end'), }, timestampOffset: 0, appendWindowEnd: Infinity, updateend: () => {}, error: () => {}, }; } function createMockTextEngineCtor() { const ctor = jasmine.createSpy('TextEngine'); ctor['isTypeSupported'] = () => true; // Because this is a fake constructor, it must be callable with "new". // This will cause jasmine to invoke the callback with "new" as well, so // the callback must be a "function". This detail is hidden when babel // transpiles the tests. // eslint-disable-next-line prefer-arrow-callback, no-restricted-syntax ctor.and.callFake(function() { expect(mockTextEngine).toBeFalsy(); mockTextEngine = jasmine.createSpyObj('TextEngine', [ 'initParser', 'destroy', 'appendBuffer', 'remove', 'setTimestampOffset', 'setAppendWindow', 'bufferStart', 'bufferEnd', 'bufferedAheadOf', 'storeAndAppendClosedCaptions', 'convertMuxjsCaptionsToShakaCaptions', ]); const resolve = () => Promise.resolve(); mockTextEngine.destroy.and.callFake(resolve); mockTextEngine.appendBuffer.and.callFake(resolve); mockTextEngine.remove.and.callFake(resolve); return mockTextEngine; }); return ctor; } function captureEvents(object, targetEventNames) { object.addEventListener.and.callFake((eventName, listener) => { if (targetEventNames.includes(eventName)) { object[eventName] = listener; } }); object.removeEventListener.and.callFake((eventName, listener) => { if (targetEventNames.includes(eventName)) { expect(object[eventName]).toBe(listener); object[eventName] = null; } }); } });