Files
shaka-player/test/media/media_source_engine_unit.js
T
Gary Katsevman 8b86ba5f06 fix: delay appending vtt subtitles depending on presence of discontinuity sequence and timestamp offset (#9676)
This PR changes the way that timestamp offsets are stored in the media
source engine. Instead of a single value, it now has a map of values.
For DASH, it should contain a single value, which is the last timestamp
offset available. For HLS, it'll keep track of the timestamp offsets per
discontinuity sequence. If content is appended and we don't yet have a
timestamp offset for that discontinuity sequence number, it'll defer
creating the cues until the timestamp offset is set, otherwise, the
times for the cues may not be correct.

Fixes #9470
2026-02-23 17:23:09 +01:00

1719 lines
64 KiB
JavaScript

/*! @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,
* appendWindowStart: number,
* appendWindowEnd: number,
* updateend: function(),
* error: function(),
* mode: string
* }}
*/
let MockSourceBuffer;
describe('MediaSourceEngine', () => {
const Util = shaka.test.Util;
const ContentType = shaka.util.ManifestParserUtils.ContentType;
const originalIsTypeSupported = window.MediaSource.isTypeSupported;
let originalIsTypeSupportedManaged;
if (window.ManagedMediaSource) {
originalIsTypeSupportedManaged = window.ManagedMediaSource.isTypeSupported;
}
const originalTextEngine = shaka.text.TextEngine;
const originalCreateMediaSource =
// eslint-disable-next-line no-restricted-syntax
shaka.media.MediaSourceEngine.prototype.createMediaSource;
const originalFindTransmuxer =
shaka.transmuxer.TransmuxerEngine.findTransmuxer;
const originalConvertCodecs =
shaka.transmuxer.TransmuxerEngine.convertCodecs;
const originalIsSupported =
shaka.transmuxer.TransmuxerEngine.isSupported;
// 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 makeFakeStream = (mimeType) => {
const segmentIndex = {
isEmpty: () => false,
};
segmentIndex[Symbol.iterator] = () => {
let nextPosition = 0;
return {
next: () => {
if (nextPosition == 0) {
nextPosition += 1;
return {
value: {mimeType},
done: false,
};
} else {
return {
value: null,
done: true,
};
}
},
current: () => {
return {mimeType};
},
};
};
return {mimeType, drmInfos: [{}], segmentIndex};
};
const fakeVideoStream = makeFakeStream('video/mp4');
const fakeAudioStream = makeFakeStream('audio/mp4');
const fakeTextStream = makeFakeStream('text/mp4');
const fakeTransportStream = makeFakeStream('tsMimetype');
const fakeStreams =
[fakeVideoStream, fakeAudioStream, fakeTextStream, fakeTransportStream];
for (const fakeStream of fakeStreams) {
fakeStream.fullMimeTypes = new Set([fakeStream.mimeType]);
}
/** @type {shaka.extern.Stream} */
const fakeStream = shaka.test.StreamingEngineUtil.createMockVideoStream(1);
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 {!jasmine.Spy} */
let requiresEncryptionInfoInAllInitSegmentsSpy;
/** @type {!jasmine.Spy} */
let requiresEC3InitSegments;
/** @type {!jasmine.Spy} */
let fakeEncryptionSpy;
/** @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';
};
if (window.ManagedMediaSource) {
window.ManagedMediaSource.isTypeSupported = (mimeType) => {
const type = mimeType.split('/')[0];
return type == 'video' || type == 'audio';
};
}
});
afterAll(() => {
window.MediaSource.isTypeSupported = originalIsTypeSupported;
if (window.ManagedMediaSource) {
window.ManagedMediaSource.isTypeSupported =
originalIsTypeSupportedManaged;
}
shaka.transmuxer.TransmuxerEngine.findTransmuxer =
originalFindTransmuxer;
shaka.transmuxer.TransmuxerEngine.convertCodecs =
originalConvertCodecs;
shaka.transmuxer.TransmuxerEngine.isSupported =
originalIsSupported;
});
beforeEach(/** @suppress {invalidCasts} */ () => {
audioSourceBuffer = createMockSourceBuffer();
videoSourceBuffer = createMockSourceBuffer();
mockMediaSource = createMockMediaSource();
mockMediaSource.addSourceBuffer.and.callFake((mimeType) => {
if (mockMediaSource.readyState !== 'open') {
// https://w3c.github.io/media-source/#addsourcebuffer-method
throw new Error('InvalidStateError');
}
const type = mimeType.split('/')[0];
const buffer = type == 'audio' ? audioSourceBuffer : videoSourceBuffer;
// reset buffer params
buffer.timestampOffset = 0;
buffer.appendWindowEnd = Infinity;
buffer.appendWindowStart = 0;
// send a simple mock of the 'addsourcebuffer' event, after returning.
Util.shortDelay().then(() => {
mockMediaSource.sourceBuffers.dispatchEvent(
new Event('addsourcebuffer'));
});
return buffer;
});
mockTransmuxer = new shaka.test.FakeTransmuxer();
shaka.transmuxer.TransmuxerEngine.findTransmuxer =
(mimeType) => {
return () => mockTransmuxer;
};
shaka.transmuxer.TransmuxerEngine.convertCodecs =
(mimeType, contentType) => {
return 'video/mp4; codecs="avc1.42E01E"';
};
shaka.transmuxer.TransmuxerEngine.isSupported =
(mimeType, contentType) => {
return mimeType == 'tsMimetype';
};
shaka.text.TextEngine = createMockTextEngineCtor();
createMediaSourceSpy = jasmine.createSpy('createMediaSource');
createMediaSourceSpy.and.callFake((p) => {
p.resolve();
mockMediaSource.readyState = 'open';
return mockMediaSource;
});
// eslint-disable-next-line no-restricted-syntax
shaka.media.MediaSourceEngine.prototype.createMediaSource =
Util.spyFunc(createMediaSourceSpy);
requiresEncryptionInfoInAllInitSegmentsSpy = spyOn(deviceDetected,
'requiresEncryptionInfoInAllInitSegments').and.returnValue(false);
requiresEC3InitSegments = spyOn(deviceDetected,
'requiresEC3InitSegments').and.returnValue(false);
fakeEncryptionSpy = spyOn(shaka.media.ContentWorkarounds, 'fakeEncryption')
.and.callFake((stream, data) => data + 100);
// 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 = {
firstElementChild: undefined,
error: null,
currentTime: 0,
buffered: {
length: 0,
},
appendChild: (element) => {
mockVideo.firstElementChild = element;
},
removeChild: () => {
mockVideo.firstElementChild = undefined;
},
removeAttribute: jasmine.createSpy('removeAttribute'),
addEventListener: jasmine.createSpy('addVideoEventListener'),
removeEventListener: jasmine.createSpy('removeVideoEventListener'),
load: jasmine.createSpy('load'),
play: jasmine.createSpy('play'),
paused: true,
autoplay: false,
};
video = /** @type {HTMLMediaElement} */(mockVideo);
mockClosedCaptionParser = new shaka.test.FakeClosedCaptionParser();
mockTextDisplayer = new shaka.test.FakeTextDisplayer();
const config = shaka.util.PlayerConfiguration.createDefault().mediaSource;
mediaSourceEngine = new shaka.media.MediaSourceEngine(
video,
mockTextDisplayer,
{
getKeySystem: () => null,
onMetadata: () => {},
onEmsg: () => {},
onEvent: () => {},
onManifestUpdate: () => {},
},
config);
mediaSourceEngine.getCaptionParser = () => {
return /** @type {!shaka.media.ClosedCaptionParser} */(
mockClosedCaptionParser);
};
});
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 originalRevokeObjectURL = window.URL.revokeObjectURL;
const originalMediaSource = window.MediaSource;
const originalManagedMediaSource = window.ManagedMediaSource;
/** @type {jasmine.Spy} */
let createObjectURLSpy;
/** @type {jasmine.Spy} */
let revokeObjectURLSpy;
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);
revokeObjectURLSpy = jasmine.createSpy('revokeObjectURL');
window.URL.revokeObjectURL = Util.spyFunc(revokeObjectURLSpy);
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);
if (window.ManagedMediaSource) {
window.ManagedMediaSource = Util.spyFunc(mediaSourceSpy);
}
await mediaSourceEngine.destroy();
});
afterAll(() => {
shaka.media.MediaSourceEngine.createObjectURL = originalCreateObjectURL;
window.MediaSource = originalMediaSource;
window.ManagedMediaSource = originalManagedMediaSource;
window.URL.revokeObjectURL = originalRevokeObjectURL;
});
const config = shaka.util.PlayerConfiguration.createDefault().mediaSource;
it('creates a MediaSource object and sets video.src', () => {
mediaSourceEngine = new shaka.media.MediaSourceEngine(
video,
new shaka.test.FakeTextDisplayer(),
{
getKeySystem: () => null,
onMetadata: () => {},
onEmsg: () => {},
onEvent: () => {},
onManifestUpdate: () => {},
},
config);
expect(createMediaSourceSpy).toHaveBeenCalled();
expect(createObjectURLSpy).toHaveBeenCalled();
expect(mockVideo.firstElementChild.src).toBe('blob:foo');
});
it('revokes object URL after MediaSource opens', () => {
let onSourceOpenListener;
mockMediaSource.addEventListener.and.callFake((event, callback, _) => {
if (event == 'sourceopen') {
onSourceOpenListener = callback;
}
});
const config = shaka.util.PlayerConfiguration.createDefault().mediaSource;
mediaSourceEngine = new shaka.media.MediaSourceEngine(
video,
new shaka.test.FakeTextDisplayer(),
{
getKeySystem: () => null,
onMetadata: () => {},
onEmsg: () => {},
onEvent: () => {},
onManifestUpdate: () => {},
},
config);
if (window.ManagedMediaSource) {
expect(mockMediaSource.addEventListener).toHaveBeenCalledTimes(3);
} else {
expect(mockMediaSource.addEventListener).toHaveBeenCalledTimes(1);
}
expect(mockMediaSource.addEventListener.calls.mostRecent().args[0])
.toBe('sourceopen');
expect(typeof onSourceOpenListener).toBe(typeof Function);
onSourceOpenListener();
expect(revokeObjectURLSpy).toHaveBeenCalledWith('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/mp4');
expect(mockMediaSource.addSourceBuffer).toHaveBeenCalledWith('video/mp4');
expect(shaka.text.TextEngine).not.toHaveBeenCalled();
});
it('creates SourceBuffers with extra features', async () => {
const config = shaka.util.PlayerConfiguration.createDefault().mediaSource;
config.addExtraFeaturesToSourceBuffer = (mimeType) => {
if (mimeType.includes('audio')) {
return '; extra_audio_param';
}
if (mimeType.includes('video')) {
return '; extra_video_param';
}
return '';
};
mediaSourceEngine.configure(config);
const initObject = new Map();
initObject.set(ContentType.AUDIO, fakeAudioStream);
initObject.set(ContentType.VIDEO, fakeVideoStream);
await mediaSourceEngine.init(initObject, false);
expect(mockMediaSource.addSourceBuffer).toHaveBeenCalledWith(
'audio/mp4; extra_audio_param');
expect(mockMediaSource.addSourceBuffer).toHaveBeenCalledWith(
'video/mp4; extra_video_param');
expect(shaka.text.TextEngine).not.toHaveBeenCalled();
});
it('creates SourceBuffers when MediaSource readyState is closed',
async () => {
const initObject = new Map();
initObject.set(ContentType.AUDIO, fakeAudioStream);
initObject.set(ContentType.VIDEO, fakeVideoStream);
await mediaSourceEngine.open();
mockMediaSource.readyState = 'closed';
await expectAsync(
mediaSourceEngine.init(initObject, false)).not.toBeRejected();
});
it('creates SourceBuffers when MediaSource readyState is ended',
async () => {
const initObject = new Map();
initObject.set(ContentType.AUDIO, fakeAudioStream);
initObject.set(ContentType.VIDEO, fakeVideoStream);
await mediaSourceEngine.open();
mockMediaSource.readyState = 'ended';
await expectAsync(
mediaSourceEngine.init(initObject, false)).not.toBeRejected();
});
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 () => {
requiresEC3InitSegments.and.returnValue(false);
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('should apply fake encryption by default', async () => {
requiresEncryptionInfoInAllInitSegmentsSpy.and.returnValue(true);
const p = mediaSourceEngine.appendBuffer(
ContentType.VIDEO, buffer, null, fakeStream,
/* hasClosedCaptions= */ false);
expect(fakeEncryptionSpy).toHaveBeenCalled();
expect(videoSourceBuffer.appendBuffer)
.toHaveBeenCalledWith((buffer + 100));
videoSourceBuffer.updateend();
await p;
});
it('should not apply fake encryption when config is off', async () => {
requiresEncryptionInfoInAllInitSegmentsSpy.and.returnValue(true);
const config = shaka.util.PlayerConfiguration.createDefault().mediaSource;
config.insertFakeEncryptionInInit = false;
mediaSourceEngine.configure(config);
const p = mediaSourceEngine.appendBuffer(
ContentType.VIDEO, buffer, null, fakeStream,
/* hasClosedCaptions= */ false);
expect(fakeEncryptionSpy).not.toHaveBeenCalled();
expect(videoSourceBuffer.appendBuffer).toHaveBeenCalledWith(buffer);
videoSourceBuffer.updateend();
await p;
});
it('appends the given data', async () => {
const p = mediaSourceEngine.appendBuffer(
ContentType.AUDIO, buffer, null, fakeStream,
/* hasClosedCaptions= */ false);
expect(audioSourceBuffer.appendBuffer).toHaveBeenCalledWith(buffer);
audioSourceBuffer.updateend();
await p;
});
it('rejects promise when operation throws', async () => {
const reference = dummyReference(0, 1000);
audioSourceBuffer.appendBuffer.and.throwError('fail!');
mockVideo.error = {code: 5, message: 'something failed'};
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!'}),
jasmine.objectContaining({code: 5, message: 'something failed'}),
reference.getUris()[0]));
await expectAsync(
mediaSourceEngine.appendBuffer(
ContentType.AUDIO, buffer, reference, fakeStream,
/* 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, fakeStream,
/* 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, fakeStream,
/* hasClosedCaptions= */ false);
const p2 = mediaSourceEngine.appendBuffer(
ContentType.AUDIO, buffer, null, fakeStream,
/* hasClosedCaptions= */ false);
audioSourceBuffer.updateend();
await expectAsync(p1).toBeResolved();
await expectAsync(p2).toBeRejectedWith(expected);
});
it('rejects the promise if this operation fails async', async () => {
const reference = dummyReference(0, 1000);
mockVideo.error = {code: 5};
const p = mediaSourceEngine.appendBuffer(
ContentType.AUDIO, buffer, reference, fakeStream,
/* 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,
reference.getUris()[0]));
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, fakeStream,
/* hasClosedCaptions= */ false));
/** @type {!shaka.test.StatusPromise} */
const p2 = new shaka.test.StatusPromise(mediaSourceEngine.appendBuffer(
ContentType.AUDIO, buffer2, null, fakeStream,
/* 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, fakeStream,
/* hasClosedCaptions= */ false));
/** @type {!shaka.test.StatusPromise} */
const p2 = new shaka.test.StatusPromise(mediaSourceEngine.appendBuffer(
ContentType.AUDIO, buffer2, null, fakeStream,
/* hasClosedCaptions= */ false));
/** @type {!shaka.test.StatusPromise} */
const p3 = new shaka.test.StatusPromise(mediaSourceEngine.appendBuffer(
ContentType.VIDEO, buffer3, null, fakeStream,
/* 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, fakeStream,
/* hasClosedCaptions= */ false);
const p2 = mediaSourceEngine.appendBuffer(
ContentType.AUDIO, buffer2, null, fakeStream,
/* hasClosedCaptions= */ false);
const p3 = mediaSourceEngine.appendBuffer(
ContentType.AUDIO, buffer3, null, fakeStream,
/* 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();
const reference = dummyReference(0, 10);
await mediaSourceEngine.appendBuffer(
ContentType.TEXT, data, reference, fakeStream,
/* hasClosedCaptions= */ false);
expect(mockTextEngine.appendBuffer).toHaveBeenCalledWith(
data, 0, 10, 'foo://bar', -1);
});
it('forwards to TextEngine HLS discontinuity sequence', async () => {
const data = new ArrayBuffer(0);
expect(mockTextEngine.appendBuffer).not.toHaveBeenCalled();
const reference = dummyReference(0, 10);
reference.discontinuitySequence = 1;
await mediaSourceEngine.appendBuffer(
ContentType.TEXT, data, reference, fakeStream,
/* hasClosedCaptions= */ false);
expect(mockTextEngine.appendBuffer).toHaveBeenCalledWith(
data, 0, 10, 'foo://bar', 1);
});
it('appends transmuxed data', 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, fakeStream,
/* hasClosedCaptions= */ false);
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 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, fakeStream,
/* hasClosedCaptions= */ 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 reference = dummyReference(0, 1000);
const appendVideo = mediaSourceEngine.appendBuffer(
ContentType.VIDEO, buffer, reference, fakeStream,
/* hasClosedCaptions= */ true);
videoSourceBuffer.updateend();
await appendVideo;
expect(mockTextEngine.storeAndAppendClosedCaptions).toHaveBeenCalled();
});
it('passed continuity timeline to caption parser', 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.
let appendInit = mediaSourceEngine.appendBuffer(
ContentType.VIDEO, buffer, null, fakeStream,
/* hasClosedCaptions= */ true, /* seeked= */ false,
/* adaptation= */ false, /* isChunkedData= */ false,
/* fromSplit= */ false, /* continuityTimeline= */ 0);
// 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(mockClosedCaptionParser.initSpy)
.toHaveBeenCalledWith(buffer, false, 0);
// new init segment
appendInit = mediaSourceEngine.appendBuffer(
ContentType.VIDEO, buffer, null, fakeStream,
/* hasClosedCaptions= */ true, /* seeked= */ false,
/* adaptation= */ false, /* isChunkedData= */ false,
/* fromSplit= */ false, /* continuityTimeline= */ 1);
videoSourceBuffer.updateend();
await appendInit;
expect(mockClosedCaptionParser.initSpy)
.toHaveBeenCalledWith(buffer, false, 1);
// new init segment
appendInit = mediaSourceEngine.appendBuffer(
ContentType.VIDEO, buffer, null, fakeStream,
/* hasClosedCaptions= */ true, /* seeked= */ false,
/* adaptation= */ true, /* isChunkedData= */ false,
/* fromSplit= */ false, /* continuityTimeline= */ 1);
videoSourceBuffer.updateend();
await appendInit;
expect(mockClosedCaptionParser.initSpy)
.toHaveBeenCalledWith(buffer, true, 1);
});
it('sets timestampOffset on adaptations in sequence mode', async () => {
const initObject = new Map();
initObject.set(ContentType.VIDEO, fakeVideoStream);
videoSourceBuffer.mode = 'sequence';
await mediaSourceEngine.init(initObject, /* sequenceMode= */ true);
expect(videoSourceBuffer.timestampOffset).toBe(0);
// Mocks appending a segment from a newly adapted variant with a 0.50
// second misalignment from the old variant.
const reference = dummyReference(0, 1000);
reference.startTime = 0.50;
const appendVideo = mediaSourceEngine.appendBuffer(
ContentType.VIDEO, buffer, reference, fakeStream,
/* hasClosedCaptions= */ false,
/* seeked= */ false, /* adaptation= */ true);
videoSourceBuffer.updateend();
await appendVideo;
expect(videoSourceBuffer.timestampOffset).toBe(0.50);
});
});
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('caption parser is not updated on audio remove', async () => {
// init caption parser
const appendInit = mediaSourceEngine.appendBuffer(
ContentType.VIDEO, buffer, null, fakeStream,
/* hasClosedCaptions= */ true, /* seeked= */ false,
/* adaptation= */ false, /* isChunkedData= */ false,
/* fromSplit= */ false, /* continuityTimeline= */ 0);
videoSourceBuffer.updateend();
await appendInit;
const p = mediaSourceEngine.remove(ContentType.AUDIO, 1, 5, [0]);
audioSourceBuffer.updateend();
await p;
expect(mockClosedCaptionParser.removeSpy).not.toHaveBeenCalledWith([0]);
});
it('caption parser is updated on video remove', async () => {
// init caption parser
const appendInit = mediaSourceEngine.appendBuffer(
ContentType.VIDEO, buffer, null, fakeStream,
/* hasClosedCaptions= */ true, /* seeked= */ false,
/* adaptation= */ false, /* isChunkedData= */ false,
/* fromSplit= */ false, /* continuityTimeline= */ 0);
videoSourceBuffer.updateend();
await appendInit;
const p = mediaSourceEngine.remove(ContentType.VIDEO, 1, 5, [0]);
videoSourceBuffer.updateend();
await p;
expect(mockClosedCaptionParser.removeSpy).toHaveBeenCalledWith([0]);
});
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!'}),
jasmine.objectContaining({code: 5}),
null));
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,
null));
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 MediaSourceEngine 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,
/* sequenceMode= */ false,
fakeStream.mimeType,
fakeStream.codecs,
/* streamsByType= */ new Map());
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, fakeStream,
/* hasClosedCaptions= */ false));
/** @type {!shaka.test.StatusPromise} */
const p2 = new shaka.test.StatusPromise(mediaSourceEngine.appendBuffer(
ContentType.VIDEO, buffer, null, fakeStream,
/* 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,
fakeStream, /* hasClosedCaptions= */ false);
mediaSourceEngine.appendBuffer(ContentType.VIDEO, buffer, null,
fakeStream, /* hasClosedCaptions= */ false);
mediaSourceEngine.appendBuffer(ContentType.VIDEO, buffer2, null,
fakeStream, /* 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,
fakeStream, /* 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, fakeStream,
/* hasClosedCaptions= */ false));
/** @type {!shaka.test.StatusPromise} */
const p2 = new shaka.test.StatusPromise(mediaSourceEngine.appendBuffer(
ContentType.VIDEO, buffer, null, fakeStream,
/* 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,
fakeStream, /* hasClosedCaptions= */ false);
mediaSourceEngine.appendBuffer(ContentType.VIDEO, buffer, null,
fakeStream, /* hasClosedCaptions= */ false);
mediaSourceEngine.appendBuffer(ContentType.VIDEO, buffer2, null,
fakeStream, /* 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,
fakeStream, /* 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();
});
it('allows duration to be shrunk', async () => {
// Pretend the initial duration was 100.
mockMediaSource.durationGetter.and.returnValue(100);
// When duration is shrunk, 'updateend' events are generated. This is
// because reducing the duration triggers the MSE removal algorithm to
// run.
mockMediaSource.durationSetter.and.callFake((duration) => {
expect(duration).toBe(50);
videoSourceBuffer.updateend();
audioSourceBuffer.updateend();
});
audioSourceBuffer.appendBuffer.and.callFake(() => {
audioSourceBuffer.updateend();
});
videoSourceBuffer.appendBuffer.and.callFake(() => {
videoSourceBuffer.updateend();
});
/** @type {!Promise} */
const p1 = mediaSourceEngine.setDuration(50);
expect(mockMediaSource.durationSetter).not.toHaveBeenCalled();
// These operations should be blocked until after duration is shrunk.
// This is tested because shrinking duration generates 'updateend'
// events, and we want to show that the queue still operates correctly.
const a1 = mediaSourceEngine.appendBuffer(ContentType.AUDIO, buffer, null,
fakeStream, /* hasClosedCaptions= */ false);
const a2 = mediaSourceEngine.appendBuffer(ContentType.VIDEO, buffer, null,
fakeStream, /* hasClosedCaptions= */ false);
await p1;
await a1;
await a2;
});
});
describe('reload codec switching', () => {
const initObject = new Map();
initObject.set(ContentType.VIDEO, fakeVideoStream);
initObject.set(ContentType.AUDIO, fakeAudioStream);
/**
* @param {!Map<shaka.util.ManifestParserUtils.ContentType,
* shaka.extern.Stream>} initObject
* @suppress {visibility}
*/
async function resetMSE(initObject) {
await mediaSourceEngine.reset_(initObject);
}
/** @suppress {visibility} */
function simulatePlaybackBeginning() {
mediaSourceEngine.playbackHasBegun_ = true;
}
it('should re-create a new MediaSource', async () => {
await mediaSourceEngine.init(initObject, false);
await resetMSE(initObject);
expect(createMediaSourceSpy).toHaveBeenCalled();
});
it('should re-create the audio & video source buffers', async () => {
await mediaSourceEngine.init(initObject, false);
mockMediaSource.addSourceBuffer.calls.reset();
await resetMSE(initObject);
expect(mockMediaSource.addSourceBuffer).toHaveBeenCalledTimes(2);
});
it('should preserve autoplay and paused state', async () => {
await mediaSourceEngine.init(initObject, false);
mockVideo.autoplay = true;
mockVideo.paused = true;
let canPlayThroughListener = null;
mockVideo.addEventListener.and.callFake((eventName, callback, _) => {
if (eventName == 'canplaythrough') {
canPlayThroughListener = callback;
}
});
simulatePlaybackBeginning();
await resetMSE(initObject);
expect(canPlayThroughListener).not.toBe(null);
if (!canPlayThroughListener) {
return;
}
canPlayThroughListener({target: mockVideo});
expect(mockVideo.autoplay).toBe(true);
expect(mockVideo.paused).toBe(true);
expect(mockVideo.play).not.toHaveBeenCalled();
});
it('should not clear autoplay if playback has not begun', async () => {
await mediaSourceEngine.init(initObject, false);
mockVideo.autoplay = true;
let setCount = 0;
Object.defineProperty(mockVideo, 'autoplay', {
get: () => true,
set: () => {
setCount++;
},
});
await resetMSE(initObject);
expect(setCount).toBe(0);
});
it('should preserve playing state', async () => {
await mediaSourceEngine.init(initObject, false);
mockVideo.autoplay = false;
mockVideo.paused = false;
let canPlayThroughListener = null;
mockVideo.addEventListener.and.callFake((eventName, callback, _) => {
if (eventName == 'canplaythrough') {
canPlayThroughListener = callback;
}
});
simulatePlaybackBeginning();
await resetMSE(initObject);
expect(canPlayThroughListener).not.toBe(null);
if (!canPlayThroughListener) {
return;
}
canPlayThroughListener({target: mockVideo});
expect(mockVideo.autoplay).toBe(false);
expect(mockVideo.paused).toBe(false);
expect(mockVideo.play).toHaveBeenCalled();
});
});
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,
fakeStream, /* hasClosedCaptions= */ false);
mediaSourceEngine.appendBuffer(ContentType.VIDEO, buffer, null,
fakeStream, /* 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, fakeStream,
/* 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, fakeStream,
/* hasClosedCaptions= */ false);
const rejected = mediaSourceEngine.appendBuffer(
ContentType.AUDIO, buffer2, null, fakeStream,
/* 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, fakeStream,
/* hasClosedCaptions= */ false);
const p2 = mediaSourceEngine.endOfStream();
const d = mediaSourceEngine.destroy();
audioSourceBuffer.updateend();
await expectAsync(p1).toBeResolved();
// Note: Canceling a blocking operation means doing nothing more about
// the operation.
await expectAsync(p2).toBeResolved();
await d;
});
it('prevents new operations from being added', async () => {
const d = mediaSourceEngine.destroy();
await expectAsync(
mediaSourceEngine.appendBuffer(
ContentType.AUDIO, buffer, null, fakeStream,
/* hasClosedCaptions= */ false))
.toBeRejected();
await d;
expect(audioSourceBuffer.appendBuffer).not.toHaveBeenCalled();
});
it('destroys text engines', async () => {
mediaSourceEngine.reinitText('text/vtt', false);
await mediaSourceEngine.destroy();
expect(mockTextEngine).toBeTruthy();
expect(mockTextEngine.destroy).toHaveBeenCalled();
});
});
function createMockMediaSource() {
const mediaSource = {
readyState: 'open',
sourceBuffers: document.createElement('div'),
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,
appendWindowStart: 0,
appendWindowEnd: Infinity,
updateend: () => {},
error: () => {},
mode: 'segments',
};
}
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', 'setModifyCueCallback',
]);
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;
}
});
}
function dummyReference(startTime, endTime) {
return new shaka.media.SegmentReference(
startTime, endTime,
/* uris= */ () => ['foo://bar'],
/* startByte= */ 0,
/* endByte= */ null,
/* initSegmentReference= */ null,
/* timestampOffset= */ 0,
/* appendWindowStart= */ 0,
/* appendWindowEnd= */ Infinity);
}
});