mirror of
https://github.com/shaka-project/shaka-player.git
synced 2026-06-24 17:35:10 +03:00
a0c35403e5
* Tidy up unit tests, and add more eviction tests and drift tests. * Add integration tests. * Handle end-of-stream and end-of-Period scenarios when segments are not perfectly aligned to Period boundaries. * Use segment receipts to determine which segment to buffer next. * Handle drift with eviction and other drift corner cases. * Handle additional errors and improve overall robustness. Change-Id: Ib57a255cda7a6e8c5857eb82accc14697983b893
1536 lines
55 KiB
JavaScript
1536 lines
55 KiB
JavaScript
/**
|
|
* @license
|
|
* Copyright 2015 Google Inc.
|
|
*
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
* you may not use this file except in compliance with the License.
|
|
* You may obtain a copy of the License at
|
|
*
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
*
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
* See the License for the specific language governing permissions and
|
|
* limitations under the License.
|
|
*/
|
|
|
|
describe('StreamingEngine', function() {
|
|
var originalSetTimeout;
|
|
var Util;
|
|
|
|
var playhead;
|
|
var playheadTime;
|
|
var playing;
|
|
|
|
var dummyInitSegments;
|
|
var dummySegments;
|
|
|
|
// Dummy sizes for segments.
|
|
var initSegmentRanges = {'audio': [100, 1000], 'video': [200, 2000]};
|
|
var segmentSizes = {'audio': 1000, 'video': 10000, 'text': 500};
|
|
|
|
var initSegments;
|
|
var segments;
|
|
var segmentDurations;
|
|
var mediaSourceEngine;
|
|
|
|
var netEngine;
|
|
var timeline;
|
|
|
|
var audioStream1;
|
|
var videoStream1;
|
|
var textStream1;
|
|
var alternateVideoStream1;
|
|
|
|
var audioStream2;
|
|
var videoStream2;
|
|
var textStream2;
|
|
|
|
/** @type {shakaExtern.Manifest} */
|
|
var manifest;
|
|
|
|
var onCanSwitch;
|
|
var onBufferNewPeriod;
|
|
var onError;
|
|
var onInitialStreamsSetup;
|
|
var onStartupComplete;
|
|
var streamingEngine;
|
|
|
|
/**
|
|
* Runs the fake event loop.
|
|
* @param {function()=} opt_callback An optional callback that is executed
|
|
* each time the clock ticks.
|
|
*/
|
|
function runTest(opt_callback) {
|
|
function onTick(currentTime) {
|
|
if (opt_callback) opt_callback();
|
|
if (playing) {
|
|
playheadTime++;
|
|
}
|
|
}
|
|
// No test should require more than 60 seconds of simulated time.
|
|
return Util.fakeEventLoop(60, originalSetTimeout, onTick);
|
|
}
|
|
|
|
beforeAll(function() {
|
|
originalSetTimeout = window.setTimeout;
|
|
Util = shaka.test.Util;
|
|
});
|
|
|
|
beforeEach(function() {
|
|
jasmine.clock().install();
|
|
|
|
// In these tests we fake a presentation that has 2 twenty second
|
|
// Periods, where each Period has 1 StreamSet. The first Period
|
|
// has 1 audio Stream, 2 video Streams, and 1 text Stream; and the second
|
|
// Period has 1 Stream of each type.
|
|
//
|
|
// There are 4 initialization segments: 1 audio and 1 video for the
|
|
// first Period, and 1 audio and 1 video for the second Period.
|
|
//
|
|
// There are 12 media segments: 2 audio, 2 video, and 2 text for the
|
|
// first Period, and 2 audio, 2 video, and 2 text for the second Period.
|
|
// All media segments are (by default) 10 seconds long.
|
|
//
|
|
// We only use the second video Stream in the first Period to verify that
|
|
// the StreamingEngine can setup Streams correctly. It does not have init
|
|
// or media segments.
|
|
//
|
|
// Furthermore, the init segment URIs follow the pattern PERIOD_TYPE_init,
|
|
// e.g., "1_audio_init" or "2_video_init", and the media segment URIs
|
|
// follow the pattern PERIOD_TYPE_POSITION, e.g., "1_text_2" or
|
|
// "2_video_1". The first segment in each Period has position 1, the second
|
|
// segment, position 2.
|
|
|
|
// Create dummy init segments.
|
|
var initSegmentSizeAudio =
|
|
initSegmentRanges.audio[1] - initSegmentRanges.audio[0] + 1;
|
|
var initSegmentSizeVideo =
|
|
initSegmentRanges.video[1] - initSegmentRanges.video[0] + 1;
|
|
dummyInitSegments = {
|
|
audio: [new ArrayBuffer(initSegmentSizeAudio),
|
|
new ArrayBuffer(initSegmentSizeAudio)],
|
|
video: [new ArrayBuffer(initSegmentSizeVideo),
|
|
new ArrayBuffer(initSegmentSizeVideo)],
|
|
text: []
|
|
};
|
|
|
|
// Create dummy media segments. The first two ArrayBuffers in each row are
|
|
// for the first Period, and the last two, for the second Period.
|
|
dummySegments = {
|
|
audio: [makeBuffer(segmentSizes.audio), makeBuffer(segmentSizes.audio),
|
|
makeBuffer(segmentSizes.audio), makeBuffer(segmentSizes.audio)],
|
|
video: [makeBuffer(segmentSizes.video), makeBuffer(segmentSizes.video),
|
|
makeBuffer(segmentSizes.video), makeBuffer(segmentSizes.video)],
|
|
text: [makeBuffer(segmentSizes.text), makeBuffer(segmentSizes.text),
|
|
makeBuffer(segmentSizes.text), makeBuffer(segmentSizes.text)]
|
|
};
|
|
|
|
// Setup MediaSourceEngine.
|
|
// This table keeps tracks of which init segments have been appended.
|
|
initSegments = {
|
|
'audio': [false, false],
|
|
'video': [false, false],
|
|
'text': []
|
|
};
|
|
|
|
// This table keeps tracks of which init segments have been appended.
|
|
segments = {
|
|
'audio': [false, false, false, false],
|
|
'video': [false, false, false, false],
|
|
'text': [false, false, false, false]
|
|
};
|
|
|
|
segmentDurations = {'audio': 10, 'video': 10, 'text': 10};
|
|
|
|
mediaSourceEngine = createMockMediaSourceEngine();
|
|
|
|
// Setup Playhead.
|
|
playhead = createMockPlayhead();
|
|
playheadTime = 0;
|
|
playing = false;
|
|
|
|
// Setup PresentationTimeline.
|
|
timeline = createMockPresentationTimeline();
|
|
|
|
timeline.getDuration.and.returnValue(40);
|
|
timeline.getSegmentAvailabilityStart.and.returnValue(0);
|
|
timeline.getSegmentAvailabilityEnd.and.returnValue(40);
|
|
|
|
// These methods should not be invoked.
|
|
timeline.setDuration.and.throwError(
|
|
new Error('unexpected call to setDuration()'));
|
|
timeline.getSegmentAvailabilityDuration.and.throwError(
|
|
new Error('unexpected call to getSegmentAvailabilityDuration()'));
|
|
}); // beforeEach()
|
|
|
|
function setupNetworkingEngine() {
|
|
var responseMap = {
|
|
'1_audio_init': dummyInitSegments.audio[0],
|
|
'1_video_init': dummyInitSegments.video[0],
|
|
|
|
'1_audio_1': dummySegments.audio[0],
|
|
'1_audio_2': dummySegments.audio[1],
|
|
'1_video_1': dummySegments.video[0],
|
|
'1_video_2': dummySegments.video[1],
|
|
'1_text_1': dummySegments.text[0],
|
|
'1_text_2': dummySegments.text[1],
|
|
|
|
'2_audio_init': dummyInitSegments.audio[1],
|
|
'2_video_init': dummyInitSegments.video[1],
|
|
|
|
'2_audio_1': dummySegments.audio[2],
|
|
'2_audio_2': dummySegments.audio[3],
|
|
'2_video_1': dummySegments.video[2],
|
|
'2_video_2': dummySegments.video[3],
|
|
'2_text_1': dummySegments.text[2],
|
|
'2_text_2': dummySegments.text[3]
|
|
};
|
|
netEngine = new shaka.test.FakeNetworkingEngine(responseMap);
|
|
}
|
|
|
|
function setupManifest() {
|
|
// Functions for findSegmentPosition() and getSegmentReference().
|
|
var find = function(type, t) {
|
|
// Note: |t| is relative to a Period's start time.
|
|
return t >= 0 ? Math.floor(t / segmentDurations[type]) + 1 : null;
|
|
};
|
|
var get = makeSegmentReference;
|
|
|
|
audioStream1 = createMockAudioStream(0);
|
|
videoStream1 = createMockVideoStream(1);
|
|
textStream1 = createMockTextStream(2);
|
|
|
|
alternateVideoStream1 = createMockVideoStream(3);
|
|
|
|
// Setup first Period.
|
|
audioStream1.createSegmentIndex.and.returnValue(Promise.resolve());
|
|
videoStream1.createSegmentIndex.and.returnValue(Promise.resolve());
|
|
textStream1.createSegmentIndex.and.returnValue(Promise.resolve());
|
|
alternateVideoStream1.createSegmentIndex.and.returnValue(Promise.resolve());
|
|
|
|
audioStream1.findSegmentPosition.and.callFake(find.bind(null, 'audio'));
|
|
videoStream1.findSegmentPosition.and.callFake(find.bind(null, 'video'));
|
|
textStream1.findSegmentPosition.and.callFake(find.bind(null, 'text'));
|
|
alternateVideoStream1.findSegmentPosition.and.returnValue(null);
|
|
|
|
audioStream1.getSegmentReference.and.callFake(get.bind(null, 1, 'audio'));
|
|
videoStream1.getSegmentReference.and.callFake(get.bind(null, 1, 'video'));
|
|
textStream1.getSegmentReference.and.callFake(get.bind(null, 1, 'text'));
|
|
alternateVideoStream1.getSegmentReference.and.returnValue(null);
|
|
|
|
audioStream1.initSegmentReference = new shaka.media.InitSegmentReference(
|
|
['1_audio_init'],
|
|
initSegmentRanges.audio[0],
|
|
initSegmentRanges.audio[1]);
|
|
videoStream1.initSegmentReference = new shaka.media.InitSegmentReference(
|
|
['1_video_init'],
|
|
initSegmentRanges.video[0],
|
|
initSegmentRanges.video[1]);
|
|
|
|
// Setup second Period.
|
|
audioStream2 = createMockAudioStream(4);
|
|
videoStream2 = createMockVideoStream(5);
|
|
textStream2 = createMockTextStream(6);
|
|
|
|
audioStream2.createSegmentIndex.and.returnValue(Promise.resolve());
|
|
videoStream2.createSegmentIndex.and.returnValue(Promise.resolve());
|
|
textStream2.createSegmentIndex.and.returnValue(Promise.resolve());
|
|
|
|
audioStream2.findSegmentPosition.and.callFake(find.bind(null, 'audio'));
|
|
videoStream2.findSegmentPosition.and.callFake(find.bind(null, 'video'));
|
|
textStream2.findSegmentPosition.and.callFake(find.bind(null, 'text'));
|
|
|
|
audioStream2.getSegmentReference.and.callFake(get.bind(null, 2, 'audio'));
|
|
videoStream2.getSegmentReference.and.callFake(get.bind(null, 2, 'video'));
|
|
textStream2.getSegmentReference.and.callFake(get.bind(null, 2, 'text'));
|
|
|
|
audioStream2.initSegmentReference = new shaka.media.InitSegmentReference(
|
|
['2_audio_init'],
|
|
initSegmentRanges.audio[0],
|
|
initSegmentRanges.audio[1]);
|
|
videoStream2.initSegmentReference = new shaka.media.InitSegmentReference(
|
|
['2_video_init'],
|
|
initSegmentRanges.video[0],
|
|
initSegmentRanges.video[1]);
|
|
|
|
// Create Manifest.
|
|
manifest = {
|
|
presentationTimeline:
|
|
/** @type {!shaka.media.PresentationTimeline} */ (timeline),
|
|
minBufferTime: 5,
|
|
periods: [
|
|
{
|
|
startTime: 0,
|
|
streamSets: [
|
|
{type: 'audio', streams: [audioStream1]},
|
|
{type: 'video', streams: [videoStream1, alternateVideoStream1]},
|
|
{type: 'text', streams: [textStream1]}
|
|
]
|
|
},
|
|
{
|
|
startTime: 20,
|
|
streamSets: [
|
|
{type: 'audio', streams: [audioStream2]},
|
|
{type: 'video', streams: [videoStream2]},
|
|
{type: 'text', streams: [textStream2]}
|
|
]
|
|
}
|
|
]
|
|
};
|
|
} // setupManifest()
|
|
|
|
/**
|
|
* Creates a StreamingEngine instance.
|
|
* @param {shakaExtern.StreamingConfiguration=} opt_config Optional
|
|
* configuration object which overrides the default one.
|
|
*/
|
|
function createStreamingEngine(opt_config) {
|
|
onCanSwitch = jasmine.createSpy('onCanSwitch');
|
|
onBufferNewPeriod = jasmine.createSpy('onBufferNewPeriod');
|
|
onError = jasmine.createSpy('onError');
|
|
onInitialStreamsSetup = jasmine.createSpy('onInitialStreamsSetup');
|
|
onStartupComplete = jasmine.createSpy('onStartupComplete');
|
|
|
|
var config;
|
|
if (opt_config) {
|
|
config = opt_config;
|
|
} else {
|
|
config = {
|
|
rebufferingGoal: 2,
|
|
bufferingGoal: 5,
|
|
retryParameters: shaka.net.NetworkingEngine.defaultRetryParameters(),
|
|
byteLimit: Number.POSITIVE_INFINITY
|
|
};
|
|
}
|
|
|
|
streamingEngine = new shaka.media.StreamingEngine(
|
|
playhead, mediaSourceEngine, netEngine, manifest,
|
|
onCanSwitch, onBufferNewPeriod, onError,
|
|
onInitialStreamsSetup, onStartupComplete);
|
|
streamingEngine.configure(config);
|
|
}
|
|
|
|
afterEach(function() {
|
|
jasmine.clock().uninstall();
|
|
});
|
|
|
|
// This test initializes the StreamingEngine (SE) and allows it to play
|
|
// through both Periods.
|
|
//
|
|
// After calling init() the following should occur:
|
|
// 1. SE should setup each of the initial Streams and then call
|
|
// onInitialStreamsSetup().
|
|
// 2. SE should start appending segments from the initial Streams and in
|
|
// parallel setup all remaining Streams within the Manifest.
|
|
// - SE should call onStartupComplete() after it has buffered at least 1
|
|
// segment from each of the initial Streams.
|
|
// - SE should call onCanSwitch() twice, once for each Period setup.
|
|
// 3. SE should call onBufferNewPeriod() after it has appended both segments
|
|
// from the first Period.
|
|
// 4. We must then switch to the Streams in the second Period by calling
|
|
// switch().
|
|
// 5. SE should call MediaSourceEngine.endOfStream() after it has appended
|
|
// both segments from the second Period. At this point the playhead
|
|
// will not be at the end of the presentation, but the test will be
|
|
// effectively over since SE will have nothing else to do.
|
|
it('initializes and plays', function(done) {
|
|
setupNetworkingEngine();
|
|
setupManifest();
|
|
createStreamingEngine();
|
|
|
|
playhead.getTime.and.returnValue(0);
|
|
setupFakeMediaSourceEngine(0 /* expectedTimestampOffset */);
|
|
|
|
onError.and.callFake(fail);
|
|
|
|
onInitialStreamsSetup.and.callFake(function() {
|
|
expect(mediaSourceEngine.init).toHaveBeenCalledWith(
|
|
{
|
|
'audio': 'audio/mp4; codecs="aac"',
|
|
'video': 'video/mp4; codecs="avc"',
|
|
'text': 'text/vtt'
|
|
});
|
|
expect(mediaSourceEngine.init.calls.count()).toBe(1);
|
|
mediaSourceEngine.init.calls.reset();
|
|
|
|
expect(mediaSourceEngine.setDuration).toHaveBeenCalledWith(40);
|
|
expect(mediaSourceEngine.setDuration.calls.count()).toBe(1);
|
|
mediaSourceEngine.setDuration.calls.reset();
|
|
|
|
expect(audioStream1.createSegmentIndex).toHaveBeenCalled();
|
|
expect(videoStream1.createSegmentIndex).toHaveBeenCalled();
|
|
expect(textStream1.createSegmentIndex).toHaveBeenCalled();
|
|
|
|
expect(alternateVideoStream1.createSegmentIndex).not.toHaveBeenCalled();
|
|
});
|
|
|
|
onStartupComplete.and.callFake(function() {
|
|
// Verify buffers.
|
|
expect(initSegments.audio).toEqual([true, false]);
|
|
expect(initSegments.video).toEqual([true, false]);
|
|
expect(segments.audio).toEqual([true, false, false, false]);
|
|
expect(segments.video).toEqual([true, false, false, false]);
|
|
expect(segments.text).toEqual([true, false, false, false]);
|
|
|
|
// During startup each Stream will require buffering, so there should
|
|
// be at least 3 calls to setBuffering(true).
|
|
expect(playhead.setBuffering).toHaveBeenCalledWith(true);
|
|
expect(playhead.setBuffering).not.toHaveBeenCalledWith(false);
|
|
expect(playhead.setBuffering.calls.count()).toBeGreaterThan(2);
|
|
playhead.setBuffering.calls.reset();
|
|
|
|
setupFakeGetTime(0);
|
|
});
|
|
|
|
onCanSwitch.and.callFake(function(period) {
|
|
if (period == manifest.periods[0]) {
|
|
expect(alternateVideoStream1.createSegmentIndex).toHaveBeenCalled();
|
|
} else if (period == manifest.periods[1]) {
|
|
expect(audioStream2.createSegmentIndex).toHaveBeenCalled();
|
|
expect(videoStream2.createSegmentIndex).toHaveBeenCalled();
|
|
expect(textStream2.createSegmentIndex).toHaveBeenCalled();
|
|
} else {
|
|
throw new Error('unexpected period');
|
|
}
|
|
});
|
|
|
|
onBufferNewPeriod.and.callFake(function(period) {
|
|
expect(period).toBe(manifest.periods[1]);
|
|
|
|
// If we need to buffer the second Period then we must have reached our
|
|
// buffering goal.
|
|
expect(playhead.setBuffering).toHaveBeenCalledWith(false);
|
|
playhead.setBuffering.calls.reset();
|
|
|
|
// Verify buffers.
|
|
expect(initSegments.audio).toEqual([true, false]);
|
|
expect(initSegments.video).toEqual([true, false]);
|
|
expect(segments.audio).toEqual([true, true, false, false]);
|
|
expect(segments.video).toEqual([true, true, false, false]);
|
|
expect(segments.text).toEqual([true, true, false, false]);
|
|
|
|
verifyNetworkingEngineRequestCalls(1);
|
|
|
|
// Switch to the second Period.
|
|
setupFakeMediaSourceEngine(20 /* expectedTimestampOffset */);
|
|
|
|
streamingEngine.switch('audio', audioStream2);
|
|
streamingEngine.switch('video', videoStream2);
|
|
streamingEngine.switch('text', textStream2);
|
|
});
|
|
|
|
// Here we go!
|
|
var streamsByType = {
|
|
'audio': audioStream1, 'video': videoStream1, 'text': textStream1
|
|
};
|
|
streamingEngine.init(streamsByType);
|
|
|
|
runTest().then(function() {
|
|
expect(mediaSourceEngine.endOfStream).toHaveBeenCalled();
|
|
|
|
// Verify buffers.
|
|
expect(initSegments.audio).toEqual([false, true]);
|
|
expect(initSegments.video).toEqual([false, true]);
|
|
expect(segments.audio).toEqual([true, true, true, true]);
|
|
expect(segments.video).toEqual([true, true, true, true]);
|
|
expect(segments.text).toEqual([true, true, true, true]);
|
|
|
|
verifyNetworkingEngineRequestCalls(2);
|
|
|
|
return streamingEngine.destroy();
|
|
}).catch(fail).then(done);
|
|
});
|
|
|
|
describe('handles seeks', function() {
|
|
beforeEach(function() {
|
|
setupNetworkingEngine();
|
|
setupManifest();
|
|
createStreamingEngine();
|
|
|
|
onError.and.callFake(fail);
|
|
onStartupComplete.and.callFake(setupFakeGetTime.bind(null, 0));
|
|
});
|
|
|
|
it('into buffered regions', function(done) {
|
|
playhead.getTime.and.returnValue(0);
|
|
setupFakeMediaSourceEngine(0 /* expectedTimestampOffset */);
|
|
|
|
onBufferNewPeriod.and.callFake(function(period) {
|
|
expect(period).toBe(manifest.periods[1]);
|
|
|
|
// Seek backwards to a buffered region in the first Period. Note that
|
|
// since the buffering goal is 5 seconds and each segment is 10
|
|
// seconds long, the last segment in the first Period should be
|
|
// appended when the playhead is at the 16 second mark.
|
|
expect(playhead.getTime()).toBe(16);
|
|
playheadTime -= 5;
|
|
streamingEngine.seeked();
|
|
|
|
// Don't switch to the second Period, just allow the fake event loop to
|
|
// finish.
|
|
onBufferNewPeriod.and.callFake(function() {});
|
|
onBufferNewPeriod.calls.reset();
|
|
netEngine.request.calls.reset();
|
|
mediaSourceEngine.appendBuffer.calls.reset();
|
|
});
|
|
|
|
// Here we go!
|
|
var streamsByType = {
|
|
'audio': audioStream1, 'video': videoStream1, 'text': textStream1
|
|
};
|
|
streamingEngine.init(streamsByType);
|
|
|
|
runTest().then(function() {
|
|
expect(onBufferNewPeriod).not.toHaveBeenCalled();
|
|
expect(mediaSourceEngine.appendBuffer).not.toHaveBeenCalled();
|
|
expect(mediaSourceEngine.remove).not.toHaveBeenCalled();
|
|
expect(mediaSourceEngine.clear).not.toHaveBeenCalled();
|
|
expect(netEngine.request).not.toHaveBeenCalled();
|
|
|
|
// Verify buffers.
|
|
expect(initSegments.audio).toEqual([true, false]);
|
|
expect(initSegments.video).toEqual([true, false]);
|
|
expect(segments.audio).toEqual([true, true, false, false]);
|
|
expect(segments.video).toEqual([true, true, false, false]);
|
|
expect(segments.text).toEqual([true, true, false, false]);
|
|
|
|
return streamingEngine.destroy();
|
|
}).catch(fail).then(done);
|
|
});
|
|
|
|
it('into buffered regions across Periods', function(done) {
|
|
playhead.getTime.and.returnValue(0);
|
|
setupFakeMediaSourceEngine(0 /* expectedTimestampOffset */);
|
|
|
|
onBufferNewPeriod.and.callFake(function(period) {
|
|
expect(period).toBe(manifest.periods[1]);
|
|
|
|
// Switch to the second Period.
|
|
setupFakeMediaSourceEngine(20 /* expectedTimestampOffset */);
|
|
|
|
streamingEngine.switch('audio', audioStream2);
|
|
streamingEngine.switch('video', videoStream2);
|
|
streamingEngine.switch('text', textStream2);
|
|
|
|
mediaSourceEngine.endOfStream.and.callFake(function() {
|
|
// Seek backwards to a buffered region in the first Period. Note
|
|
// that since the buffering goal is 5 seconds and each segment is
|
|
// 10 seconds long, endOfStream() should be called at the 36 second
|
|
// mark.
|
|
expect(playhead.getTime()).toBe(36);
|
|
playheadTime -= 20;
|
|
streamingEngine.seeked();
|
|
|
|
// Allow the fake event loop to finish. Note that onBufferNewPeriod()
|
|
// should not be called again since we've already buffered the second
|
|
// Period.
|
|
onBufferNewPeriod.and.callFake(function() {});
|
|
onBufferNewPeriod.calls.reset();
|
|
mediaSourceEngine.endOfStream.and.callFake(function() {});
|
|
mediaSourceEngine.endOfStream.calls.reset();
|
|
});
|
|
});
|
|
|
|
// Here we go!
|
|
var streamsByType = {
|
|
'audio': audioStream1, 'video': videoStream1, 'text': textStream1
|
|
};
|
|
streamingEngine.init(streamsByType);
|
|
|
|
runTest().then(function() {
|
|
// Already buffered to the end of the presentation so neither of these
|
|
// should have been called again.
|
|
expect(onBufferNewPeriod).not.toHaveBeenCalled();
|
|
expect(mediaSourceEngine.endOfStream).not.toHaveBeenCalled();
|
|
expect(mediaSourceEngine.remove).not.toHaveBeenCalled();
|
|
expect(mediaSourceEngine.clear).not.toHaveBeenCalled();
|
|
|
|
// Verify buffers.
|
|
expect(initSegments.audio).toEqual([false, true]);
|
|
expect(initSegments.video).toEqual([false, true]);
|
|
expect(segments.audio).toEqual([true, true, true, true]);
|
|
expect(segments.video).toEqual([true, true, true, true]);
|
|
expect(segments.text).toEqual([true, true, true, true]);
|
|
|
|
return streamingEngine.destroy();
|
|
}).catch(fail).then(done);
|
|
});
|
|
|
|
it('into unbuffered regions', function(done) {
|
|
playhead.getTime.and.returnValue(0);
|
|
setupFakeMediaSourceEngine(0 /* expectedTimestampOffset */);
|
|
|
|
onStartupComplete.and.callFake(function() {
|
|
setupFakeGetTime(0);
|
|
|
|
// Seek forward to an unbuffered region in the first Period.
|
|
expect(playhead.getTime()).toBe(0);
|
|
playheadTime += 15;
|
|
streamingEngine.seeked();
|
|
|
|
onBufferNewPeriod.and.callFake(function(period) {
|
|
expect(period).toBe(manifest.periods[1]);
|
|
|
|
// Verify that all buffers have been cleared.
|
|
expect(mediaSourceEngine.clear).toHaveBeenCalledWith('audio');
|
|
expect(mediaSourceEngine.clear).toHaveBeenCalledWith('video');
|
|
expect(mediaSourceEngine.clear).toHaveBeenCalledWith('text');
|
|
|
|
// Don't switch to the second Period, just allow the fake event loop
|
|
// to finish.
|
|
onBufferNewPeriod.and.callFake(function(period) {});
|
|
onBufferNewPeriod.calls.reset();
|
|
mediaSourceEngine.appendBuffer.calls.reset();
|
|
mediaSourceEngine.clear.calls.reset();
|
|
});
|
|
});
|
|
|
|
// Here we go!
|
|
var streamsByType = {
|
|
'audio': audioStream1, 'video': videoStream1, 'text': textStream1
|
|
};
|
|
streamingEngine.init(streamsByType);
|
|
|
|
runTest().then(function() {
|
|
expect(mediaSourceEngine.appendBuffer).not.toHaveBeenCalled();
|
|
expect(mediaSourceEngine.remove).not.toHaveBeenCalled();
|
|
expect(mediaSourceEngine.clear).not.toHaveBeenCalled();
|
|
|
|
// Verify buffers.
|
|
expect(initSegments.audio).toEqual([true, false]);
|
|
expect(initSegments.video).toEqual([true, false]);
|
|
expect(segments.audio).toEqual([false, true, false, false]);
|
|
expect(segments.video).toEqual([false, true, false, false]);
|
|
expect(segments.text).toEqual([false, true, false, false]);
|
|
|
|
return streamingEngine.destroy();
|
|
}).catch(fail).then(done);
|
|
});
|
|
|
|
it('into unbuffered regions across Periods', function(done) {
|
|
// Start from the second Period.
|
|
playhead.getTime.and.returnValue(20);
|
|
setupFakeMediaSourceEngine(20 /* expectedTimestampOffset */);
|
|
|
|
onStartupComplete.and.callFake(setupFakeGetTime.bind(null, 20));
|
|
|
|
// onBufferNewPeriod() should not be called since the second Period
|
|
// is the last one; instead, endOfStream() should be called.
|
|
mediaSourceEngine.endOfStream.and.callFake(function() {
|
|
// Verify buffers.
|
|
expect(initSegments.audio).toEqual([false, true]);
|
|
expect(initSegments.video).toEqual([false, true]);
|
|
expect(segments.audio).toEqual([false, false, true, true]);
|
|
expect(segments.video).toEqual([false, false, true, true]);
|
|
expect(segments.text).toEqual([false, false, true, true]);
|
|
|
|
// Seek backwards to an unbuffered region in the first Period. Note
|
|
// that since the buffering goal is 5 seconds and each segment is 10
|
|
// seconds long, endOfStream() should be called at the 36 second mark.
|
|
expect(playhead.getTime()).toBe(36);
|
|
playheadTime -= 20;
|
|
streamingEngine.seeked();
|
|
|
|
onBufferNewPeriod.and.callFake(function(period) {
|
|
expect(period).toBe(manifest.periods[0]);
|
|
|
|
// Verify that all buffers have been cleared.
|
|
expect(mediaSourceEngine.clear).toHaveBeenCalledWith('audio');
|
|
expect(mediaSourceEngine.clear).toHaveBeenCalledWith('video');
|
|
expect(mediaSourceEngine.clear).toHaveBeenCalledWith('text');
|
|
mediaSourceEngine.clear.calls.reset();
|
|
|
|
// Switch to the first Period.
|
|
setupFakeMediaSourceEngine(0 /* expectedTimestampOffset */);
|
|
|
|
streamingEngine.switch('audio', audioStream1);
|
|
streamingEngine.switch('video', videoStream1);
|
|
streamingEngine.switch('text', textStream1);
|
|
|
|
onBufferNewPeriod.and.callFake(function(period) {
|
|
expect(period).toBe(manifest.periods[1]);
|
|
|
|
// Don't switch to the second Period, just allow the fake event
|
|
// loop to finish.
|
|
onBufferNewPeriod.and.callFake(function(period) {});
|
|
mediaSourceEngine.appendBuffer.calls.reset();
|
|
mediaSourceEngine.clear.calls.reset();
|
|
});
|
|
});
|
|
});
|
|
|
|
// Here we go!
|
|
var streamsByType = {
|
|
'audio': audioStream2, 'video': videoStream2, 'text': textStream2
|
|
};
|
|
streamingEngine.init(streamsByType);
|
|
|
|
runTest().then(function() {
|
|
expect(mediaSourceEngine.appendBuffer).not.toHaveBeenCalled();
|
|
expect(mediaSourceEngine.remove).not.toHaveBeenCalled();
|
|
expect(mediaSourceEngine.clear).not.toHaveBeenCalled();
|
|
|
|
// Verify buffers.
|
|
expect(initSegments.audio).toEqual([true, false]);
|
|
expect(initSegments.video).toEqual([true, false]);
|
|
expect(segments.audio).toEqual([false, true, false, false]);
|
|
expect(segments.video).toEqual([false, true, false, false]);
|
|
expect(segments.text).toEqual([false, true, false, false]);
|
|
|
|
return streamingEngine.destroy();
|
|
}).catch(fail).then(done);
|
|
});
|
|
|
|
it('into unbuffered regions when nothing is buffered', function(done) {
|
|
playhead.getTime.and.returnValue(0);
|
|
setupFakeMediaSourceEngine(0 /* expectedTimestampOffset */);
|
|
|
|
onInitialStreamsSetup.and.callFake(function() {
|
|
// Seek forward to an unbuffered region in the first Period.
|
|
expect(playhead.getTime()).toBe(0);
|
|
playhead.getTime.and.returnValue(15);
|
|
streamingEngine.seeked();
|
|
|
|
onBufferNewPeriod.and.callFake(function(period) {
|
|
expect(period).toBe(manifest.periods[1]);
|
|
|
|
expect(mediaSourceEngine.clear).not.toHaveBeenCalled();
|
|
|
|
// Don't switch to the second Period, just allow the fake event loop
|
|
// to finish.
|
|
onBufferNewPeriod.and.callFake(function(period) {});
|
|
onBufferNewPeriod.calls.reset();
|
|
mediaSourceEngine.appendBuffer.calls.reset();
|
|
mediaSourceEngine.clear.calls.reset();
|
|
});
|
|
});
|
|
|
|
// This happens after onInitialStreamsSetup(), so pass 15 so the playhead
|
|
// resumes from 15.
|
|
onStartupComplete.and.callFake(setupFakeGetTime.bind(null, 15));
|
|
|
|
// Here we go!
|
|
var streamsByType = {
|
|
'audio': audioStream1, 'video': videoStream1, 'text': textStream1
|
|
};
|
|
streamingEngine.init(streamsByType);
|
|
|
|
runTest().then(function() {
|
|
expect(mediaSourceEngine.appendBuffer).not.toHaveBeenCalled();
|
|
expect(mediaSourceEngine.remove).not.toHaveBeenCalled();
|
|
expect(mediaSourceEngine.clear).not.toHaveBeenCalled();
|
|
|
|
// Verify buffers.
|
|
expect(initSegments.audio).toEqual([true, false]);
|
|
expect(initSegments.video).toEqual([true, false]);
|
|
expect(segments.audio).toEqual([false, true, false, false]);
|
|
expect(segments.video).toEqual([false, true, false, false]);
|
|
expect(segments.text).toEqual([false, true, false, false]);
|
|
|
|
return streamingEngine.destroy();
|
|
}).catch(fail).then(done);
|
|
});
|
|
|
|
// If we seek back into an unbuffered region but do not called seeked(),
|
|
// StreamingEngine should wait for seeked() to be called.
|
|
it('back into unbuffered regions without seeked() ', function(done) {
|
|
// Start from the second segment in the second Period.
|
|
playhead.getTime.and.returnValue(30);
|
|
setupFakeMediaSourceEngine(20 /* expectedTimestampOffset */);
|
|
|
|
onStartupComplete.and.callFake(setupFakeGetTime.bind(null, 20));
|
|
|
|
// onBufferNewPeriod() should not be called since the second Period
|
|
// is the last one; instead, endOfStream() should be called.
|
|
mediaSourceEngine.endOfStream.and.callFake(function() {
|
|
// Seek backwards to an unbuffered region in the second Period. Do not
|
|
// call seeked().
|
|
expect(playhead.getTime()).toBe(36);
|
|
playheadTime -= 10;
|
|
});
|
|
|
|
// Here we go!
|
|
var streamsByType = {
|
|
'audio': audioStream2, 'video': videoStream2, 'text': textStream2
|
|
};
|
|
streamingEngine.init(streamsByType);
|
|
|
|
runTest().then(function() {
|
|
// Verify buffers. Segment 3 should not be buffered since we never
|
|
// called seeked().
|
|
expect(initSegments.audio).toEqual([false, true]);
|
|
expect(initSegments.video).toEqual([false, true]);
|
|
expect(segments.audio).toEqual([false, false, false, true]);
|
|
expect(segments.video).toEqual([false, false, false, true]);
|
|
expect(segments.text).toEqual([false, false, false, true]);
|
|
|
|
return streamingEngine.destroy();
|
|
}).catch(fail).then(done);
|
|
});
|
|
|
|
// If we seek forward into an unbuffered region but do not called seeked(),
|
|
// StreamingEngine should continue buffering. This test also exercises the
|
|
// case where the playhead moves past the end of the buffer, which may
|
|
// occur on some browsers depending on the playback rate.
|
|
it('forward into unbuffered regions without seeked()', function(done) {
|
|
playhead.getTime.and.returnValue(0);
|
|
setupFakeMediaSourceEngine(0 /* expectedTimestampOffset */);
|
|
|
|
onStartupComplete.and.callFake(function() {
|
|
setupFakeGetTime(0);
|
|
|
|
// Seek forward to an unbuffered region in the first Period. Do not
|
|
// call seeked().
|
|
playheadTime += 15;
|
|
|
|
onBufferNewPeriod.and.callFake(function(period) {
|
|
expect(period).toBe(manifest.periods[1]);
|
|
setupFakeMediaSourceEngine(20 /* expectedTimestampOffset */);
|
|
streamingEngine.switch('audio', audioStream2);
|
|
streamingEngine.switch('video', videoStream2);
|
|
streamingEngine.switch('text', textStream2);
|
|
});
|
|
});
|
|
|
|
// Here we go!
|
|
var streamsByType = {
|
|
'audio': audioStream1, 'video': videoStream1, 'text': textStream1
|
|
};
|
|
streamingEngine.init(streamsByType);
|
|
|
|
runTest().then(function() {
|
|
// Verify buffers.
|
|
expect(initSegments.audio).toEqual([false, true]);
|
|
expect(initSegments.video).toEqual([false, true]);
|
|
expect(segments.audio).toEqual([true, true, true, true]);
|
|
expect(segments.video).toEqual([true, true, true, true]);
|
|
expect(segments.text).toEqual([true, true, true, true]);
|
|
|
|
return streamingEngine.destroy();
|
|
}).catch(fail).then(done);
|
|
});
|
|
});
|
|
|
|
describe('handles errors', function() {
|
|
beforeEach(function() {
|
|
setupNetworkingEngine();
|
|
setupManifest();
|
|
createStreamingEngine();
|
|
});
|
|
|
|
it('from initial Stream setup', function(done) {
|
|
playhead.getTime.and.returnValue(0);
|
|
setupFakeMediaSourceEngine(0 /* expectedTimestampOffset */);
|
|
|
|
videoStream1.createSegmentIndex.and.returnValue(
|
|
Promise.reject('FAKE_ERROR'));
|
|
|
|
onError.and.callFake(function(error) {
|
|
expect(onInitialStreamsSetup).not.toHaveBeenCalled();
|
|
expect(onStartupComplete).not.toHaveBeenCalled();
|
|
expect(error).toBe('FAKE_ERROR');
|
|
streamingEngine.destroy().catch(fail).then(done);
|
|
});
|
|
|
|
// Here we go!
|
|
var streamsByType = {
|
|
'audio': audioStream1, 'video': videoStream1, 'text': textStream1
|
|
};
|
|
streamingEngine.init(streamsByType);
|
|
|
|
runTest();
|
|
});
|
|
|
|
it('from post startup Stream setup', function(done) {
|
|
playhead.getTime.and.returnValue(0);
|
|
setupFakeMediaSourceEngine(0 /* expectedTimestampOffset */);
|
|
|
|
alternateVideoStream1.createSegmentIndex.and.returnValue(
|
|
Promise.reject('FAKE_ERROR'));
|
|
|
|
onError.and.callFake(function(error) {
|
|
expect(onInitialStreamsSetup).toHaveBeenCalled();
|
|
expect(onStartupComplete).toHaveBeenCalled();
|
|
expect(error).toBe('FAKE_ERROR');
|
|
streamingEngine.destroy().catch(fail).then(done);
|
|
});
|
|
|
|
// Here we go!
|
|
var streamsByType = {
|
|
'audio': audioStream1, 'video': videoStream1, 'text': textStream1
|
|
};
|
|
streamingEngine.init(streamsByType);
|
|
|
|
runTest();
|
|
});
|
|
|
|
it('from failed init segment append during startup', function(done) {
|
|
playhead.getTime.and.returnValue(0);
|
|
setupFakeMediaSourceEngine(0 /* expectedTimestampOffset */);
|
|
|
|
mediaSourceEngine.appendBuffer.and.callFake(function(type, data) {
|
|
// Reject the first video init segment.
|
|
if (data == dummyInitSegments.video[0]) {
|
|
return Promise.reject('FAKE_ERROR');
|
|
} else {
|
|
return fakeAppendBuffer(type, data);
|
|
}
|
|
});
|
|
|
|
onError.and.callFake(function(error) {
|
|
expect(onInitialStreamsSetup).toHaveBeenCalled();
|
|
expect(onStartupComplete).not.toHaveBeenCalled();
|
|
expect(error).toBe('FAKE_ERROR');
|
|
streamingEngine.destroy().catch(fail).then(done);
|
|
});
|
|
|
|
// Here we go!
|
|
var streamsByType = {
|
|
'audio': audioStream1, 'video': videoStream1, 'text': textStream1
|
|
};
|
|
streamingEngine.init(streamsByType);
|
|
|
|
runTest();
|
|
});
|
|
|
|
it('from failed media segment append during startup', function(done) {
|
|
playhead.getTime.and.returnValue(0);
|
|
setupFakeMediaSourceEngine(0 /* expectedTimestampOffset */);
|
|
|
|
mediaSourceEngine.appendBuffer.and.callFake(function(type, data) {
|
|
// Reject the first audio segment.
|
|
if (data == dummySegments.audio[0]) {
|
|
return Promise.reject('FAKE_ERROR');
|
|
} else {
|
|
return fakeAppendBuffer(type, data);
|
|
}
|
|
});
|
|
|
|
onError.and.callFake(function(error) {
|
|
expect(onInitialStreamsSetup).toHaveBeenCalled();
|
|
expect(onStartupComplete).not.toHaveBeenCalled();
|
|
expect(error).toBe('FAKE_ERROR');
|
|
streamingEngine.destroy().catch(fail).then(done);
|
|
});
|
|
|
|
// Here we go!
|
|
var streamsByType = {
|
|
'audio': audioStream1, 'video': videoStream1, 'text': textStream1
|
|
};
|
|
streamingEngine.init(streamsByType);
|
|
|
|
runTest();
|
|
});
|
|
});
|
|
|
|
describe('eviction', function() {
|
|
function checkByteLimitOnTick(byteLimit) {
|
|
var bufferSize = Object.keys(segments).reduce(function(total, type) {
|
|
return total + segments[type].reduce(function(subTotal, inserted) {
|
|
return subTotal + (inserted ? segmentSizes[type] : 0);
|
|
}, 0);
|
|
}, 0);
|
|
expect(bufferSize).toBeLessThan(byteLimit + 1);
|
|
}
|
|
|
|
it('removes segments when the byte limit is reached', function(done) {
|
|
var byteLimit =
|
|
segmentSizes.audio + (2 * segmentSizes.video) + segmentSizes.text;
|
|
var config = {
|
|
rebufferingGoal: 1,
|
|
bufferingGoal: 1,
|
|
retryParameters: shaka.net.NetworkingEngine.defaultRetryParameters(),
|
|
byteLimit: byteLimit
|
|
};
|
|
|
|
setupNetworkingEngine();
|
|
setupManifest();
|
|
createStreamingEngine(config);
|
|
|
|
playhead.getTime.and.returnValue(0);
|
|
setupFakeMediaSourceEngine(0 /* expectedTimestampOffset */);
|
|
|
|
onError.and.callFake(fail);
|
|
onStartupComplete.and.callFake(setupFakeGetTime.bind(null, 0));
|
|
|
|
onBufferNewPeriod.and.callFake(function(period) {
|
|
expect(period).toBe(manifest.periods[1]);
|
|
|
|
// Switch to the second Period.
|
|
setupFakeMediaSourceEngine(20 /* expectedTimestampOffset */);
|
|
|
|
streamingEngine.switch('audio', audioStream2);
|
|
streamingEngine.switch('video', videoStream2);
|
|
streamingEngine.switch('text', textStream2);
|
|
});
|
|
|
|
// Here we go!
|
|
var streamsByType = {
|
|
'audio': audioStream1, 'video': videoStream1, 'text': textStream1
|
|
};
|
|
streamingEngine.init(streamsByType);
|
|
|
|
// Since StreamingEngine is free to peform audio, video, and text updates
|
|
// in any order, there are many valid ways in which StreamingEngine can
|
|
// evict segments. So, instead of verifying the exact, final buffer
|
|
// configuration, ensure the byte limit is never exceeded and at least
|
|
// one segment of each type is buffered at the end of the test.
|
|
runTest(checkByteLimitOnTick.bind(null, byteLimit)).then(function() {
|
|
expect(mediaSourceEngine.endOfStream).toHaveBeenCalled();
|
|
|
|
expect(mediaSourceEngine.remove).toHaveBeenCalledWith('audio', 0, 10);
|
|
expect(mediaSourceEngine.remove).toHaveBeenCalledWith('audio', 10, 20);
|
|
|
|
expect(mediaSourceEngine.remove).toHaveBeenCalledWith('video', 0, 10);
|
|
expect(mediaSourceEngine.remove).toHaveBeenCalledWith('video', 10, 20);
|
|
|
|
expect(mediaSourceEngine.remove).toHaveBeenCalledWith('text', 0, 10);
|
|
expect(mediaSourceEngine.remove).toHaveBeenCalledWith('text', 10, 20);
|
|
|
|
expect(initSegments.audio).toEqual([false, true]);
|
|
expect(initSegments.video).toEqual([false, true]);
|
|
|
|
expect(segments.audio[0]).toBeFalsy();
|
|
expect(segments.video[0]).toBeFalsy();
|
|
expect(segments.text[0]).toBeFalsy();
|
|
|
|
expect(segments.audio[1]).toBeFalsy();
|
|
expect(segments.video[1]).toBeFalsy();
|
|
expect(segments.text[1]).toBeFalsy();
|
|
|
|
expect(segments.audio[3]).toBeTruthy();
|
|
expect(segments.video[3]).toBeTruthy();
|
|
expect(segments.text[3]).toBeTruthy();
|
|
|
|
return streamingEngine.destroy();
|
|
}).catch(fail).then(done);
|
|
});
|
|
|
|
it('never removes a single segment', function(done) {
|
|
// Use just 1 text segment per Period.
|
|
dummySegments['text'] = [makeBuffer(segmentSizes.text),
|
|
makeBuffer(segmentSizes.text)];
|
|
segments['text'] = [false, false];
|
|
segmentDurations['text'] = 20;
|
|
|
|
// Setup NetworkingEngine.
|
|
var responseMap = {
|
|
'1_audio_init': dummyInitSegments.audio[0],
|
|
'1_video_init': dummyInitSegments.video[0],
|
|
|
|
'1_audio_1': dummySegments.audio[0],
|
|
'1_audio_2': dummySegments.audio[1],
|
|
'1_video_1': dummySegments.video[0],
|
|
'1_video_2': dummySegments.video[1],
|
|
'1_text_1': dummySegments.text[0],
|
|
|
|
'2_audio_init': dummyInitSegments.audio[1],
|
|
'2_video_init': dummyInitSegments.video[1],
|
|
|
|
'2_audio_1': dummySegments.audio[2],
|
|
'2_audio_2': dummySegments.audio[3],
|
|
'2_video_1': dummySegments.video[2],
|
|
'2_video_2': dummySegments.video[3],
|
|
'2_text_1': dummySegments.text[1]
|
|
};
|
|
netEngine = new shaka.test.FakeNetworkingEngine(responseMap);
|
|
|
|
setupManifest();
|
|
|
|
var byteLimit =
|
|
segmentSizes.audio + (2 * segmentSizes.video) + segmentSizes.text;
|
|
var config = {
|
|
rebufferingGoal: 1,
|
|
bufferingGoal: 1,
|
|
retryParameters: shaka.net.NetworkingEngine.defaultRetryParameters(),
|
|
byteLimit: byteLimit
|
|
};
|
|
createStreamingEngine(config);
|
|
|
|
playhead.getTime.and.returnValue(0);
|
|
setupFakeMediaSourceEngine(0 /* expectedTimestampOffset */);
|
|
|
|
onError.and.callFake(fail);
|
|
onStartupComplete.and.callFake(setupFakeGetTime.bind(null, 0));
|
|
|
|
onBufferNewPeriod.and.callFake(function(period) {
|
|
expect(period).toBe(manifest.periods[1]);
|
|
|
|
expect(mediaSourceEngine.remove).not.toHaveBeenCalledWith(
|
|
'text', 0, 20);
|
|
expect(segments.text[0]).toBeTruthy();
|
|
|
|
// Switch to the second Period.
|
|
setupFakeMediaSourceEngine(20 /* expectedTimestampOffset */);
|
|
|
|
streamingEngine.switch('audio', audioStream2);
|
|
streamingEngine.switch('video', videoStream2);
|
|
streamingEngine.switch('text', textStream2);
|
|
});
|
|
|
|
// Here we go!
|
|
var streamsByType = {
|
|
'audio': audioStream1, 'video': videoStream1, 'text': textStream1
|
|
};
|
|
streamingEngine.init(streamsByType);
|
|
|
|
runTest(checkByteLimitOnTick.bind(null, byteLimit)).then(function() {
|
|
expect(mediaSourceEngine.remove).not.toHaveBeenCalledWith(
|
|
'text', 20, 40);
|
|
|
|
expect(initSegments.audio).toEqual([false, true]);
|
|
expect(initSegments.video).toEqual([false, true]);
|
|
|
|
expect(segments.audio[0]).toBeFalsy();
|
|
expect(segments.video[0]).toBeFalsy();
|
|
expect(segments.audio[1]).toBeFalsy();
|
|
expect(segments.video[1]).toBeFalsy();
|
|
expect(segments.audio[3]).toBeTruthy();
|
|
expect(segments.video[3]).toBeTruthy();
|
|
|
|
expect(segments.text[0]).toBeFalsy();
|
|
expect(segments.text[1]).toBeTruthy();
|
|
|
|
return streamingEngine.destroy();
|
|
}).catch(fail).then(done);
|
|
});
|
|
|
|
it('raises an error when eviction is impossible', function(done) {
|
|
var byteLimit = segmentSizes.audio + segmentSizes.video;
|
|
var config = {
|
|
rebufferingGoal: 1,
|
|
bufferingGoal: 1,
|
|
retryParameters: shaka.net.NetworkingEngine.defaultRetryParameters(),
|
|
byteLimit: byteLimit
|
|
};
|
|
|
|
setupNetworkingEngine();
|
|
setupManifest();
|
|
createStreamingEngine(config);
|
|
|
|
playhead.getTime.and.returnValue(0);
|
|
setupFakeMediaSourceEngine(0 /* expectedTimestampOffset */);
|
|
|
|
onStartupComplete.and.callFake(setupFakeGetTime.bind(null, 0));
|
|
|
|
onError.and.callFake(function(error) {
|
|
expect(error.category).toBe(shaka.util.Error.Category.MEDIA);
|
|
expect(error.code).toBe(
|
|
shaka.util.Error.Code.STREAMING_CANNOT_SATISFY_BYTE_LIMIT);
|
|
});
|
|
|
|
// Here we go!
|
|
var streamsByType = {
|
|
'audio': audioStream1, 'video': videoStream1, 'text': textStream1
|
|
};
|
|
streamingEngine.init(streamsByType);
|
|
|
|
runTest(checkByteLimitOnTick.bind(null, byteLimit)).then(function() {
|
|
expect(onBufferNewPeriod).not.toHaveBeenCalled();
|
|
expect(onError).toHaveBeenCalled();
|
|
|
|
expect(initSegments.audio).toEqual([true, false]);
|
|
expect(initSegments.video).toEqual([true, false]);
|
|
|
|
return streamingEngine.destroy();
|
|
}).catch(fail).then(done);
|
|
});
|
|
});
|
|
|
|
// TODO: Add tests for eviction with drift.
|
|
describe('drift', function() {
|
|
beforeEach(function() {
|
|
setupNetworkingEngine();
|
|
setupManifest();
|
|
createStreamingEngine();
|
|
});
|
|
|
|
function testPositiveDrift(drift, done) {
|
|
setupFakeMediaSourceEngine(0 /* expectedTimestampOffset */, drift);
|
|
playhead.getTime.and.returnValue(0);
|
|
|
|
onError.and.callFake(function(error) {
|
|
expect(error.category).toBe(shaka.util.Error.Category.MEDIA);
|
|
expect(error.code).toBe(
|
|
shaka.util.Error.Code.STREAMING_SEGMENT_DOES_NOT_EXIST);
|
|
|
|
var reportedContentType = error.data[0];
|
|
var reportedPeriodIndex = error.data[1];
|
|
var reportedTimestampNeeded = error.data[2];
|
|
|
|
// Expect six STREAMING_SEGMENT_DOES_NOT_EXIST errors as the playhead
|
|
// will be outside the drifted segment availability window at the
|
|
// beginning of each Period for each content type.
|
|
if (onError.calls.count() <= 3) {
|
|
expect(reportedTimestampNeeded).toBe(0);
|
|
|
|
if (onError.calls.count() < 3) {
|
|
// Simulate stuck playhead.
|
|
playing = false;
|
|
} else {
|
|
// Jump the gap: StreamingEngine should recover.
|
|
expect(onStartupComplete).not.toHaveBeenCalled();
|
|
setupFakeGetTime(drift);
|
|
streamingEngine.seeked();
|
|
}
|
|
} else {
|
|
expect(reportedTimestampNeeded).toBe(20);
|
|
|
|
if (onError.calls.count() < 6) {
|
|
// Simulate stuck playhead.
|
|
playing = false;
|
|
} else {
|
|
// Jump the gap: StreamingEngine should recover.
|
|
playing = true;
|
|
playheadTime = 20 + drift;
|
|
streamingEngine.seeked();
|
|
}
|
|
}
|
|
});
|
|
|
|
onBufferNewPeriod.and.callFake(function(period) {
|
|
expect(period).toBe(manifest.periods[1]);
|
|
setupFakeMediaSourceEngine(20 /* expectedTimestampOffset */, drift);
|
|
streamingEngine.switch('audio', audioStream2);
|
|
streamingEngine.switch('video', videoStream2);
|
|
streamingEngine.switch('text', textStream2);
|
|
});
|
|
|
|
// Here we go!
|
|
var streamsByType = {
|
|
'audio': audioStream1, 'video': videoStream1, 'text': textStream1
|
|
};
|
|
streamingEngine.init(streamsByType);
|
|
|
|
runTest().then(function() {
|
|
expect(onError.calls.count()).toBe(6);
|
|
|
|
expect(mediaSourceEngine.endOfStream).toHaveBeenCalled();
|
|
|
|
// Verify buffers.
|
|
expect(initSegments.audio).toEqual([false, true]);
|
|
expect(initSegments.video).toEqual([false, true]);
|
|
expect(segments.audio).toEqual([false, false, true, true]);
|
|
expect(segments.video).toEqual([false, false, true, true]);
|
|
expect(segments.text).toEqual([false, false, true, true]);
|
|
|
|
return streamingEngine.destroy();
|
|
}).catch(fail).then(done);
|
|
}
|
|
|
|
// TODO: Add tests for live presentations with large negative drift.
|
|
function testNegativeDrift(drift, done) {
|
|
setupFakeMediaSourceEngine(0 /* expectedTimestampOffset */, drift);
|
|
playhead.getTime.and.returnValue(0);
|
|
|
|
onStartupComplete.and.callFake(setupFakeGetTime.bind(null, 0));
|
|
|
|
onBufferNewPeriod.and.callFake(function(period) {
|
|
expect(period).toBe(manifest.periods[1]);
|
|
setupFakeMediaSourceEngine(20 /* expectedTimestampOffset */, drift);
|
|
streamingEngine.switch('audio', audioStream2);
|
|
streamingEngine.switch('video', videoStream2);
|
|
streamingEngine.switch('text', textStream2);
|
|
});
|
|
|
|
// Here we go!
|
|
var streamsByType = {
|
|
'audio': audioStream1, 'video': videoStream1, 'text': textStream1
|
|
};
|
|
streamingEngine.init(streamsByType);
|
|
|
|
runTest().then(function() {
|
|
expect(onError).not.toHaveBeenCalled();
|
|
expect(mediaSourceEngine.endOfStream).toHaveBeenCalled();
|
|
|
|
// Verify buffers.
|
|
expect(initSegments.audio).toEqual([false, true]);
|
|
expect(initSegments.video).toEqual([false, true]);
|
|
expect(segments.audio).toEqual([true, true, true, true]);
|
|
expect(segments.video).toEqual([true, true, true, true]);
|
|
expect(segments.text).toEqual([true, true, true, true]);
|
|
|
|
return streamingEngine.destroy();
|
|
}).catch(fail).then(done);
|
|
}
|
|
|
|
it('is handled for small + values', testPositiveDrift.bind(null, 3));
|
|
it('is handled for large + values', testPositiveDrift.bind(null, 12));
|
|
it('is handled for small - values', testNegativeDrift.bind(null, -3));
|
|
});
|
|
|
|
/**
|
|
* Verifies calls to NetworkingEngine.request(). Expects every segment
|
|
* in the given Period to have been requested.
|
|
*
|
|
* @param {number} period The Period number (one-based).
|
|
*/
|
|
function verifyNetworkingEngineRequestCalls(period) {
|
|
netEngine.expectRangeRequest(
|
|
period + '_audio_init',
|
|
initSegmentRanges.audio[0],
|
|
initSegmentRanges.audio[1]);
|
|
|
|
netEngine.expectRangeRequest(
|
|
period + '_video_init',
|
|
initSegmentRanges.video[0],
|
|
initSegmentRanges.video[1]);
|
|
|
|
netEngine.expectSegmentRequest(period + '_audio_1');
|
|
netEngine.expectSegmentRequest(period + '_video_1');
|
|
netEngine.expectSegmentRequest(period + '_text_1');
|
|
|
|
netEngine.expectSegmentRequest(period + '_audio_2');
|
|
netEngine.expectSegmentRequest(period + '_video_2');
|
|
netEngine.expectSegmentRequest(period + '_text_2');
|
|
|
|
netEngine.request.calls.reset();
|
|
}
|
|
|
|
/**
|
|
* Makes the mock Playhead object behave as a fake Playhead object which
|
|
* begins playback at the given time.
|
|
*
|
|
* @param {number} startTime the playhead's starting time with respect to
|
|
* the presentation timeline.
|
|
*/
|
|
function setupFakeGetTime(startTime) {
|
|
playheadTime = startTime;
|
|
playing = true;
|
|
playhead.getTime.and.callFake(function() {
|
|
return playheadTime;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Makes the mock MediaSourceEngine object behave as a fake MediaSourceEngine
|
|
* object that keeps track of the segments that have been appended.
|
|
*
|
|
* Note that appending an init segment clears any init segments already
|
|
* appended for that content type.
|
|
*
|
|
* The fake ensures that setTimestampOffset() is only called with the given
|
|
* expected timestamp offset value.
|
|
*
|
|
* @param {number} expectedTimestampOffset
|
|
* @param {number=} opt_drift
|
|
*/
|
|
function setupFakeMediaSourceEngine(expectedTimestampOffset, opt_drift) {
|
|
var drift = opt_drift || 0;
|
|
|
|
mediaSourceEngine.bufferStart.and.callFake(
|
|
fakeBufferStart.bind(null, drift));
|
|
mediaSourceEngine.bufferEnd.and.callFake(
|
|
fakeBufferEnd.bind(null, drift));
|
|
mediaSourceEngine.bufferedAheadOf.and.callFake(
|
|
fakeBufferedAheadOf.bind(null, drift));
|
|
|
|
mediaSourceEngine.appendBuffer.and.callFake(fakeAppendBuffer);
|
|
|
|
mediaSourceEngine.setTimestampOffset.and.callFake(
|
|
fakeSetTimestampOffset.bind(null, expectedTimestampOffset));
|
|
|
|
mediaSourceEngine.remove.and.callFake(fakeRemove.bind(null, drift));
|
|
mediaSourceEngine.clear.and.callFake(function(type) {
|
|
return fakeRemove(drift, type, drift, 40 + drift);
|
|
});
|
|
|
|
mediaSourceEngine.setDuration.and.returnValue(Promise.resolve());
|
|
mediaSourceEngine.setAppendWindowEnd.and.returnValue(Promise.resolve());
|
|
}
|
|
|
|
function fakeBufferStart(drift, type) {
|
|
if (segments[type] === undefined) throw new Error('unexpected type');
|
|
var first = segments[type].indexOf(true);
|
|
return first >= 0 ? first * segmentDurations[type] + drift : null;
|
|
}
|
|
|
|
function fakeBufferEnd(drift, type) {
|
|
if (segments[type] === undefined) throw new Error('unexpected type');
|
|
var last = segments[type].lastIndexOf(true);
|
|
return last >= 0 ? (last + 1) * segmentDurations[type] + drift : null;
|
|
}
|
|
|
|
function fakeBufferedAheadOf(drift, type, startTime) {
|
|
if (segments[type] === undefined) throw new Error('unexpected type');
|
|
|
|
// Note: startTime may equal the presentation's duration, so |first|
|
|
// may equal segments[type].length
|
|
var first = Math.floor((startTime - drift) / segmentDurations[type]);
|
|
if (!segments[type][first])
|
|
return 0; // Unbuffered.
|
|
|
|
// Find the first gap.
|
|
var last = segments[type].indexOf(false, first);
|
|
if (last < 0)
|
|
last = segments[type].length;
|
|
|
|
var endTime = last * segmentDurations[type] + drift;
|
|
return endTime - startTime;
|
|
}
|
|
|
|
function fakeAppendBuffer(type, data) {
|
|
if (segments[type] === undefined) throw new Error('unexpected type');
|
|
|
|
// Set init segment.
|
|
var i = dummyInitSegments[type].indexOf(data);
|
|
if (i >= 0) {
|
|
for (var j = 0; j < initSegments[type].length; ++j) {
|
|
initSegments[type][j] = false;
|
|
}
|
|
initSegments[type][i] = true;
|
|
return Promise.resolve();
|
|
}
|
|
|
|
// Set media segment.
|
|
i = dummySegments[type].indexOf(data);
|
|
if (i < 0) throw new Error('unexpected data');
|
|
|
|
segments[type][i] = true;
|
|
return Promise.resolve();
|
|
}
|
|
|
|
function fakeSetTimestampOffset(expectedTimestampOffset, type, offset) {
|
|
if (segments[type] === undefined) throw new Error('unexpected type');
|
|
|
|
if (offset != expectedTimestampOffset)
|
|
throw new Error('unexpected timestamp offset');
|
|
|
|
return Promise.resolve();
|
|
}
|
|
|
|
function fakeRemove(drift, type, start, end) {
|
|
if (segments[type] === undefined) throw new Error('unexpected type');
|
|
|
|
var first = Math.floor((start - drift) / segmentDurations[type]);
|
|
if (first < 0 || first >= segments[type].length)
|
|
throw new Error('unexpected start');
|
|
|
|
// Note: |end| is exclusive, so subtract a very small amount from it to get
|
|
// the correct index.
|
|
var last = Math.ceil((end - drift - 0.000001) / segmentDurations[type]);
|
|
if (last < 0)
|
|
throw new Error('unexpected end');
|
|
|
|
if (first >= last)
|
|
throw new Error('unexpected start and end');
|
|
|
|
for (var i = first; i < last; ++i) {
|
|
segments[type][i] = false;
|
|
}
|
|
|
|
return Promise.resolve();
|
|
}
|
|
|
|
/**
|
|
* Constructs a SegmentReference with a test URI.
|
|
* @param {number} period The Period number (one-based).
|
|
* @param {string} contentType The content type.
|
|
* @param {number} position The segment's position (one-based).
|
|
*/
|
|
function makeSegmentReference(period, contentType, position) {
|
|
if (position > 2) return null;
|
|
var size = segmentSizes[contentType];
|
|
var duration = segmentDurations[contentType];
|
|
return new shaka.media.SegmentReference(
|
|
position, (position - 1) * duration, position * duration,
|
|
['' + period + '_' + contentType + '_' + position],
|
|
0, null);
|
|
}
|
|
|
|
function makeBuffer(size) {
|
|
return new ArrayBuffer(size);
|
|
}
|
|
|
|
function createMockPlayhead() {
|
|
return {
|
|
destroy: jasmine.createSpy('destroy'),
|
|
getTime: jasmine.createSpy('getTime'),
|
|
setBuffering: jasmine.createSpy('setBuffering')
|
|
};
|
|
}
|
|
|
|
function createMockMediaSourceEngine() {
|
|
return {
|
|
destroy: jasmine.createSpy('support'),
|
|
init: jasmine.createSpy('init'),
|
|
bufferStart: jasmine.createSpy('bufferStart'),
|
|
bufferEnd: jasmine.createSpy('bufferEnd'),
|
|
bufferedAheadOf: jasmine.createSpy('bufferedAheadOf'),
|
|
appendBuffer: jasmine.createSpy('appendBuffer'),
|
|
remove: jasmine.createSpy('remove'),
|
|
clear: jasmine.createSpy('clear'),
|
|
endOfStream: jasmine.createSpy('endOfStream'),
|
|
setDuration: jasmine.createSpy('setDuration'),
|
|
setTimestampOffset: jasmine.createSpy('setTimestampOffset'),
|
|
setAppendWindowEnd: jasmine.createSpy('setAppendWindowEnd')
|
|
};
|
|
}
|
|
|
|
function createMockNetworkingEngine() {
|
|
return {
|
|
destroy: jasmine.createSpy('destroy'),
|
|
request: jasmine.createSpy('request')
|
|
};
|
|
}
|
|
|
|
function createMockPresentationTimeline() {
|
|
return {
|
|
getDuration: jasmine.createSpy('getDuration'),
|
|
setDuration: jasmine.createSpy('setDuration'),
|
|
getSegmentAvailabilityDuration:
|
|
jasmine.createSpy('getSegmentAvailabilityDuration'),
|
|
getSegmentAvailabilityStart:
|
|
jasmine.createSpy('getSegmentAvailabilityStart'),
|
|
getSegmentAvailabilityEnd:
|
|
jasmine.createSpy('getSegmentAvailabilityEnd')
|
|
};
|
|
}
|
|
|
|
function createMockAudioStream(id) {
|
|
return {
|
|
id: id,
|
|
createSegmentIndex: jasmine.createSpy('createSegmentIndex'),
|
|
findSegmentPosition: jasmine.createSpy('findSegmentPosition'),
|
|
getSegmentReference: jasmine.createSpy('getSegmentReference'),
|
|
initSegmentReference: null,
|
|
presentationTimeOffset: 0,
|
|
mimeType: 'audio/mp4',
|
|
codecs: 'aac',
|
|
bandwidth: 192000
|
|
};
|
|
}
|
|
|
|
function createMockVideoStream(id) {
|
|
return {
|
|
id: id,
|
|
createSegmentIndex: jasmine.createSpy('createSegmentIndex'),
|
|
findSegmentPosition: jasmine.createSpy('findSegmentPosition'),
|
|
getSegmentReference: jasmine.createSpy('getSegmentReference'),
|
|
initSegmentReference: null,
|
|
presentationTimeOffset: 0,
|
|
mimeType: 'video/mp4',
|
|
codecs: 'avc',
|
|
bandwidth: 5000000,
|
|
width: 1280,
|
|
height: 720
|
|
};
|
|
}
|
|
|
|
function createMockTextStream(id) {
|
|
return {
|
|
id: id,
|
|
createSegmentIndex: jasmine.createSpy('createSegmentIndex'),
|
|
findSegmentPosition: jasmine.createSpy('findSegmentPosition'),
|
|
getSegmentReference: jasmine.createSpy('getSegmentReference'),
|
|
initSegmentReference: null,
|
|
presentationTimeOffset: 0,
|
|
mimeType: 'text/vtt',
|
|
kind: 'subtitles'
|
|
};
|
|
}
|
|
});
|
|
|