UNPKG

shaka-player

Version:
1,455 lines (1,197 loc) 65.3 kB
/** * @license * Copyright 2016 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('Player', function() { const Util = shaka.test.Util; const waitUntilPlayheadReaches = Util.waitUntilPlayheadReaches; /** @type {!jasmine.Spy} */ let onErrorSpy; /** @type {!HTMLVideoElement} */ let video; /** @type {!shaka.Player} */ let player; /** @type {!shaka.util.EventManager} */ let eventManager; let compiledShaka; beforeAll(async () => { video = shaka.util.Dom.createVideoElement(); document.body.appendChild(video); compiledShaka = await Util.loadShaka(getClientArg('uncompiled')); }); beforeEach(async () => { await shaka.test.TestScheme.createManifests(compiledShaka, '_compiled'); player = new compiledShaka.Player(video); // Grab event manager from the uncompiled library: eventManager = new shaka.util.EventManager(); onErrorSpy = jasmine.createSpy('onError'); onErrorSpy.and.callFake(function(event) { fail(event.detail); }); eventManager.listen(player, 'error', Util.spyFunc(onErrorSpy)); }); afterEach(async () => { eventManager.release(); await player.destroy(); }); afterAll(function() { document.body.removeChild(video); }); describe('attach', function() { beforeEach(async function() { // To test attach, we want to construct a player without a video element // attached in advance. To do that, we destroy the player that was // constructed in the outermost beforeEach(), then construct a new one // without a video element. await player.destroy(); player = new compiledShaka.Player(); }); it('can be used before load()', async function() { await player.attach(video); await player.load('test:sintel_compiled'); }); }); describe('getStats', function() { it('gives stats about current stream', async () => { // This is tested more in player_unit.js. This is here to test the public // API and to check for renaming. await player.load('test:sintel_compiled'); video.play(); await waitUntilPlayheadReaches(eventManager, video, 1, 10); let stats = player.getStats(); let expected = { width: jasmine.any(Number), height: jasmine.any(Number), streamBandwidth: jasmine.any(Number), decodedFrames: jasmine.any(Number), droppedFrames: jasmine.any(Number), corruptedFrames: jasmine.any(Number), estimatedBandwidth: jasmine.any(Number), loadLatency: jasmine.any(Number), playTime: jasmine.any(Number), pauseTime: jasmine.any(Number), bufferingTime: jasmine.any(Number), licenseTime: jasmine.any(Number), // We should have loaded the first Period by now, so we should have a // history. switchHistory: jasmine.arrayContaining([{ timestamp: jasmine.any(Number), id: jasmine.any(Number), type: 'variant', fromAdaptation: true, bandwidth: 0, }]), stateHistory: jasmine.arrayContaining([{ state: 'playing', timestamp: jasmine.any(Number), duration: jasmine.any(Number), }]), }; expect(stats).toEqual(expected); }); }); describe('setTextTrackVisibility', function() { // Using mode='disabled' on TextTrack causes cues to go null, which leads // to a crash in TextEngine. This validates that we do not trigger this // behavior when changing visibility of text. it('does not cause cues to be null', async () => { await player.load('test:sintel_compiled'); video.play(); await waitUntilPlayheadReaches(eventManager, video, 1, 10); // This TextTrack was created as part of load() when we set up the // TextDisplayer. let textTrack = video.textTracks[0]; expect(textTrack).not.toBe(null); if (textTrack) { // This should not be null initially. expect(textTrack.cues).not.toBe(null); await player.setTextTrackVisibility(true); // This should definitely not be null when visible. expect(textTrack.cues).not.toBe(null); await player.setTextTrackVisibility(false); // This should not transition to null when invisible. expect(textTrack.cues).not.toBe(null); } }); it('is called automatically if language prefs match', async () => { // If the text is a match for the user's preferences, and audio differs // from text, we enable text display automatically. // NOTE: This is also a regression test for #1696, in which a change // to this feature broke StreamingEngine initialization. const preferredTextLanguage = 'fa'; // The same as in the content itself player.configure({preferredTextLanguage: preferredTextLanguage}); // Now load a version of Sintel with delayed setup of video & audio // streams and wait for completion. await player.load('test:sintel_realistic_compiled'); // By this point, a MediaSource error would be thrown in a repro of bug // #1696. // Make sure the automatic setting took effect. expect(player.isTextTrackVisible()).toBe(true); // Make sure the content we tested with has text tracks, that the config // we used matches the text language, and that the audio language differs. // These will catch any changes to the underlying content that would // invalidate the test setup. expect(player.getTextTracks().length).not.toBe(0); const textTrack = player.getTextTracks()[0]; expect(textTrack.language).toEqual(preferredTextLanguage); const variantTrack = player.getVariantTracks()[0]; expect(variantTrack.language).not.toEqual(textTrack.language); }); it('is not called automatically without language pref match', async () => { // If the text preference doesn't match the content, we do not enable text // display automatically. const preferredTextLanguage = 'xx'; // Differs from the content itself player.configure({preferredTextLanguage: preferredTextLanguage}); // Now load the content and wait for completion. await player.load('test:sintel_realistic_compiled'); // Make sure the automatic setting did not happen. expect(player.isTextTrackVisible()).toBe(false); // Make sure the content we tested with has text tracks, that the config // we used does not match the text language, and that the text and audio // languages do not match each other (to keep this distinct from the next // test case). This will catch any changes to the underlying content that // would invalidate the test setup. expect(player.getTextTracks().length).not.toBe(0); const textTrack = player.getTextTracks()[0]; expect(textTrack.language).not.toEqual(preferredTextLanguage); const variantTrack = player.getVariantTracks()[0]; expect(variantTrack.language).not.toEqual(textTrack.language); }); it('is not called automatically with audio and text match', async () => { // If the audio and text tracks use the same language, we do not enable // text display automatically, no matter the text preference. const preferredTextLanguage = 'und'; // The same as in the content itself player.configure({preferredTextLanguage: preferredTextLanguage}); // Now load the content and wait for completion. await player.load('test:sintel_compiled'); // Make sure the automatic setting did not happen. expect(player.isTextTrackVisible()).toBe(false); // Make sure the content we tested with has text tracks, that the // config we used matches the content, and that the text and audio // languages match each other. This will catch any changes to the // underlying content that would invalidate the test setup. expect(player.getTextTracks().length).not.toBe(0); const textTrack = player.getTextTracks()[0]; expect(textTrack.language).toEqual(preferredTextLanguage); const variantTrack = player.getVariantTracks()[0]; expect(variantTrack.language).toEqual(textTrack.language); }); // Repro for https://github.com/google/shaka-player/issues/1879. it('actually appends cues when enabled initially', async () => { let cues = []; /** @const {!shaka.test.FakeTextDisplayer} */ const displayer = new shaka.test.FakeTextDisplayer(); displayer.appendSpy.and.callFake((added) => { cues = cues.concat(added); }); player.configure({textDisplayFactory: () => displayer}); const preferredTextLanguage = 'fa'; // The same as in the content itself player.configure({preferredTextLanguage: preferredTextLanguage}); await player.load('test:sintel_realistic_compiled'); // Play until a time at which the external cues would be on screen. video.play(); await waitUntilPlayheadReaches(eventManager, video, 4, 20); expect(player.isTextTrackVisible()).toBe(true); expect(displayer.isTextVisible()).toBe(true); expect(cues.length).toBeGreaterThan(0); }); }); describe('plays', function() { it('with external text tracks', async () => { await player.load('test:sintel_no_text_compiled'); // For some reason, using path-absolute URLs (i.e. without the hostname) // like this doesn't work on Safari. So manually resolve the URL. let locationUri = new goog.Uri(location.href); let partialUri = new goog.Uri('/base/test/test/assets/text-clip.vtt'); let absoluteUri = locationUri.resolve(partialUri); await player.addTextTrack(absoluteUri.toString(), 'en', 'subtitles', 'text/vtt'); let textTracks = player.getTextTracks(); expect(textTracks).toBeTruthy(); expect(textTracks.length).toBe(1); expect(textTracks[0].active).toBe(true); expect(textTracks[0].language).toEqual('en'); }); it('with cea closed captions', async () => { await player.load('test:cea-708_mp4_compiled'); const textTracks = player.getTextTracks(); expect(textTracks).toBeTruthy(); expect(textTracks.length).toBe(1); expect(textTracks[0].language).toEqual('en'); }); it('while changing languages with short Periods', async () => { // See: https://github.com/google/shaka-player/issues/797 player.configure({preferredAudioLanguage: 'en'}); await player.load('test:sintel_short_periods_compiled'); video.play(); await waitUntilPlayheadReaches(eventManager, video, 8, 30); // The Period changes at 10 seconds. Assert that we are in the previous // Period and have buffered into the next one. expect(video.currentTime).toBeLessThan(9); // The two periods might not be in a single contiguous buffer, so don't // check end(0). Gap-jumping will deal with any discontinuities. let bufferEnd = video.buffered.end(video.buffered.length - 1); expect(bufferEnd).toBeGreaterThan(11); // Change to a different language; this should clear the buffers and // cause a Period transition again. expect(getActiveLanguage()).toBe('en'); player.selectAudioLanguage('es'); await waitUntilPlayheadReaches(eventManager, video, 21, 30); // Should have gotten past the next Period transition and still be // playing the new language. expect(getActiveLanguage()).toBe('es'); }); it('at higher playback rates', async () => { await player.load('test:sintel_compiled'); video.play(); await waitUntilPlayheadReaches(eventManager, video, 1, 10); // Enabling trick play should change our playback rate to the same rate. player.trickPlay(2); expect(video.playbackRate).toBe(2); // Let playback continue playing for a bit longer. await shaka.test.Util.delay(2); // Cancelling trick play should return our playback rate to normal. player.cancelTrickPlay(); expect(video.playbackRate).toBe(1); }); // Regression test for #2326. // // 1. Construct an instance with a video element. // 2. Don't call or await attach(). // 3. Call load() with a MIME type, which triggers a check for native // playback support. // // Note that a real playback may use a HEAD request to fetch a MIME type, // even if one is not specified in load(). it('immediately after construction with MIME type', async () => { const testSchemeMimeType = 'application/x-test-manifest'; player = new compiledShaka.Player(video); await player.load('test:sintel_compiled', 0, testSchemeMimeType); video.play(); await waitUntilPlayheadReaches(eventManager, video, 1, 10); }); /** * Gets the language of the active Variant. * @return {string} */ function getActiveLanguage() { let tracks = player.getVariantTracks().filter(function(t) { return t.active; }); expect(tracks.length).toBeGreaterThan(0); return tracks[0].language; } }); describe('TextDisplayer plugin', function() { // Simulate the use of an external TextDisplayer plugin. /** @type {shaka.test.FakeTextDisplayer} */ let textDisplayer; beforeEach(function() { textDisplayer = new shaka.test.FakeTextDisplayer(); textDisplayer.isTextVisibleSpy.and.callFake(() => { return false; }); textDisplayer.destroySpy.and.returnValue(Promise.resolve()); player.configure({ textDisplayFactory: function() { return textDisplayer; }, }); // Make sure the configuration was taken. const ConfiguredFactory = player.getConfiguration().textDisplayFactory; const configuredTextDisplayer = new ConfiguredFactory(); expect(configuredTextDisplayer).toBe(textDisplayer); }); // Regression test for https://github.com/google/shaka-player/issues/1187 it('does not throw on destroy', async () => { await player.load('test:sintel_compiled'); video.play(); await waitUntilPlayheadReaches(eventManager, video, 1, 10); await player.unload(); // Before we fixed #1187, the call to destroy() on textDisplayer was // renamed in the compiled version and could not be called. expect(textDisplayer.destroySpy).toHaveBeenCalled(); }); }); describe('TextAndRoles', function() { // Regression Test. Makes sure that the language and role fields have been // properly exported from the player. it('exports language and roles fields', async () => { await player.load('test:sintel_compiled'); let languagesAndRoles = player.getTextLanguagesAndRoles(); expect(languagesAndRoles.length).toBeTruthy(); languagesAndRoles.forEach((languageAndRole) => { expect(languageAndRole.language).not.toBeUndefined(); expect(languageAndRole.role).not.toBeUndefined(); }); }); }); describe('streaming event', function() { // Calling switch early during load() caused a failed assertion in Player // and the track selection was ignored. Because this bug involved // interactions between Player and StreamingEngine, it is an integration // test and not a unit test. // https://github.com/google/shaka-player/issues/1119 it('allows early selection of specific tracks', function(done) { const streamingListener = jasmine.createSpy('listener'); // Because this is an issue with failed assertions, destroy the existing // player from the compiled version, and create a new one using the // uncompiled version. Then we will get assertions. eventManager.unlisten(player, 'error'); player.destroy().then(() => { player = new shaka.Player(video); player.configure({abr: {enabled: false}}); eventManager.listen(player, 'error', Util.spyFunc(onErrorSpy)); // When 'streaming' fires, select the first track explicitly. player.addEventListener('streaming', Util.spyFunc(streamingListener)); streamingListener.and.callFake(() => { const tracks = player.getVariantTracks(); player.selectVariantTrack(tracks[0]); }); // Now load the content. return player.load('test:sintel'); }).then(() => { // When the bug triggers, we fail assertions in Player. // Make sure the listener was triggered, so that it could trigger the // code path in this bug. expect(streamingListener).toHaveBeenCalled(); }).catch(fail).then(done); }); // After fixing the issue above, calling switch early during a second load() // caused a failed assertion in StreamingEngine, because we did not reset // switchingPeriods_ in Player. Because this bug involved interactions // between Player and StreamingEngine, it is an integration test and not a // unit test. // https://github.com/google/shaka-player/issues/1119 it('allows selection of tracks in subsequent loads', function(done) { const streamingListener = jasmine.createSpy('listener'); // Because this is an issue with failed assertions, destroy the existing // player from the compiled version, and create a new one using the // uncompiled version. Then we will get assertions. eventManager.unlisten(player, 'error'); player.destroy().then(() => { player = new shaka.Player(video); player.configure({abr: {enabled: false}}); eventManager.listen(player, 'error', Util.spyFunc(onErrorSpy)); // This bug only triggers when you do this on the second load. // So we load one piece of content, then set up the streaming listener // to change tracks, then we load a second piece of content. return player.load('test:sintel'); }).then(() => { // Give StreamingEngine time to complete all setup and to call back into // the Player with canSwitch_. If you move on too quickly to the next // load(), the bug does not reproduce. return shaka.test.Util.delay(1); }).then(() => { player.addEventListener('streaming', Util.spyFunc(streamingListener)); streamingListener.and.callFake(() => { const track = player.getVariantTracks()[0]; player.selectVariantTrack(track); }); // Now load again to trigger the failed assertion. return player.load('test:sintel'); }).then(() => { // When the bug triggers, we fail assertions in StreamingEngine. // So just make sure the listener was triggered, so that it could // trigger the code path in this bug. expect(streamingListener).toHaveBeenCalled(); }).catch(fail).then(done); }); }); describe('tracks', () => { // This is a regression test for b/138941217, in which tracks briefly // vanished during the loading process. On Chromecast devices, where the // timing is very different from on desktop, this could occur such that // there were no tracks after load() is resolved. // This is an integration test so that we can check the behavior of the // Player against actual platform behavior on all supported platforms. it('remain available at every stage of loading', async () => { let tracksFound = false; /** * @param {string} when When the check takes place. * * Will fail the test if tracks disappear after they first become * available. */ const checkTracks = (when) => { // If tracks have already been found, expect them to still be found. const tracksNow = player.getVariantTracks().length != 0; if (tracksFound) { expect(tracksNow).toBe(true); } else { // If tracks are now found, they should not, at any point during // the loading process, disappear again. if (tracksNow) { tracksFound = true; } } shaka.log.debug( 'checkTracks', when, 'tracksFound=', tracksFound, 'tracksNow=', tracksNow); }; /** @param {Event} event */ const checkOnEvent = (event) => { checkTracks(event.type + ' event'); }; // On each of these events, we will notice when tracks first appear, and // verify that they never disappear at any point in the loading sequence. eventManager.listen(video, 'canplay', checkOnEvent); eventManager.listen(video, 'canplaythrough', checkOnEvent); eventManager.listen(video, 'durationchange', checkOnEvent); eventManager.listen(video, 'emptied', checkOnEvent); eventManager.listen(video, 'loadeddata', checkOnEvent); eventManager.listen(video, 'loadedmetadata', checkOnEvent); eventManager.listen(video, 'loadstart', checkOnEvent); eventManager.listen(video, 'pause', checkOnEvent); eventManager.listen(video, 'play', checkOnEvent); eventManager.listen(video, 'playing', checkOnEvent); eventManager.listen(video, 'seeked', checkOnEvent); eventManager.listen(video, 'seeking', checkOnEvent); eventManager.listen(video, 'stalled', checkOnEvent); eventManager.listen(video, 'waiting', checkOnEvent); eventManager.listen(player, 'trackschanged', checkOnEvent); const waiter = (new shaka.test.Waiter(eventManager)).timeoutAfter(10); const canPlayThrough = waiter.waitForEvent(video, 'canplaythrough'); // Important: use a stream that starts somewhere other than zero, so that // the video element's time is initially different from the start time of // playback, and there is no content at time zero. await player.load('test:sintel_start_at_3_compiled', 5); shaka.log.debug('load resolved'); // When load is resolved(), tracks should definitely exist. expect(tracksFound).toBe(true); // Let the test keep running until we can play through. In the original // bug, tracks would disappear _after_ load() on some platforms. await canPlayThrough; }); }); describe('loading', () => { // A regression test for Issue #2433. it('can load very large files', async () => { // Reset the lazy function, so that it does not remember any chunk size // that was detected beforehand. compiledShaka.util.StringUtils.resetFromCharCode(); const oldFromCharCode = String.fromCharCode; try { // Replace String.fromCharCode with a version that can only handle very // small chunks. // This has to be an old-style function, to use the "arguments" object. // eslint-disable-next-line no-restricted-syntax String.fromCharCode = function() { if (arguments.length > 2000) { throw new RangeError('Synthetic Range Error'); } // eslint-disable-next-line no-restricted-syntax return oldFromCharCode.apply(null, arguments); }; await player.load('/base/test/test/assets/large_file.mpd'); } finally { String.fromCharCode = oldFromCharCode; } }); }); describe('buffering', () => { const startBuffering = jasmine.objectContaining({buffering: true}); const endBuffering = jasmine.objectContaining({buffering: false}); /** @type {!jasmine.Spy} */ let onBuffering; /** @type {!shaka.test.Waiter} */ let waiter; beforeEach(() => { onBuffering = jasmine.createSpy('onBuffering'); player.addEventListener('buffering', Util.spyFunc(onBuffering)); waiter = new shaka.test.Waiter(eventManager) .timeoutAfter(10) .failOnTimeout(true); }); it('enters/exits buffering state at start', async () => { // Set a large rebuffer goal to ensure we can see the buffering before // we start playing. player.configure('streaming.rebufferingGoal', 30); await player.load('test:sintel_long_compiled'); video.pause(); expect(onBuffering).toHaveBeenCalledTimes(1); expect(onBuffering).toHaveBeenCalledWith(startBuffering); onBuffering.calls.reset(); await waiter.waitForEvent(player, 'buffering'); expect(onBuffering).toHaveBeenCalledTimes(1); expect(onBuffering).toHaveBeenCalledWith(endBuffering); expect(getBufferedAhead()).toBeGreaterThanOrEqual(30); }); it('enters/exits buffering state while playing', async () => { player.configure('streaming.rebufferingGoal', 1); player.configure('streaming.bufferingGoal', 10); await player.load('test:sintel_long_compiled'); video.pause(); if (player.isBuffering()) { await waiter.waitForEvent(player, 'buffering'); } onBuffering.calls.reset(); player.configure('streaming.rebufferingGoal', 30); video.currentTime = 70; await waiter.waitForEvent(player, 'buffering'); expect(onBuffering).toHaveBeenCalledTimes(1); expect(onBuffering).toHaveBeenCalledWith(startBuffering); onBuffering.calls.reset(); expect(getBufferedAhead()).toBeLessThan(30); await waiter.waitForEvent(player, 'buffering'); expect(onBuffering).toHaveBeenCalledTimes(1); expect(onBuffering).toHaveBeenCalledWith(endBuffering); expect(getBufferedAhead()).toBeGreaterThanOrEqual(30); }); it('buffers ahead of the playhead', async () => { player.configure('streaming.bufferingGoal', 10); await player.load('test:sintel_long_compiled'); video.pause(); await waitUntilBuffered(10); player.configure('streaming.bufferingGoal', 30); await waitUntilBuffered(30); player.configure('streaming.bufferingGoal', 60); await waitUntilBuffered(60); await Util.delay(0.2); expect(getBufferedAhead()).toBeLessThan(70); // 60 + segment_size // We don't remove buffered content ahead of the playhead, so seek to // clear the buffer. player.configure('streaming.bufferingGoal', 10); video.currentTime = 120; await waitUntilBuffered(10); await Util.delay(0.2); expect(getBufferedAhead()).toBeLessThan(20); // 10 + segment_size }); it('clears buffer behind playhead', async () => { player.configure('streaming.bufferingGoal', 30); player.configure('streaming.bufferBehind', 30); await player.load('test:sintel_long_compiled'); video.pause(); await waitUntilBuffered(30); video.currentTime = 20; await waitUntilBuffered(30); expect(getBufferedBehind()).toBe(20); // Buffered to start still. video.currentTime = 50; await waitUntilBuffered(30); expect(getBufferedBehind()).toBeLessThan(30); player.configure('streaming.bufferBehind', 10); // We only evict content when we append a segment, so increase the // buffering goal so we append another segment. player.configure('streaming.bufferingGoal', 40); await waitUntilBuffered(40); expect(getBufferedBehind()).toBeLessThan(10); }); function getBufferedAhead() { const end = shaka.media.TimeRangesUtils.bufferEnd(video.buffered); return end - video.currentTime; } function getBufferedBehind() { const start = shaka.media.TimeRangesUtils.bufferStart(video.buffered); return video.currentTime - start; } async function waitUntilBuffered(amount) { for (let i = 0; i < 25; i++) { // We buffer from an internal segment, so this shouldn't take long to // buffer. await Util.delay(0.1); // eslint-disable-line no-await-in-loop if (getBufferedAhead() >= amount) { return; } } throw new Error('Timeout waiting to buffer'); } }); describe('configuration', () => { it('has the correct number of arguments in compiled callbacks', () => { // Get the default configuration for both the compiled & uncompiled // versions for comparison. const compiledConfig = (new compiledShaka.Player()).getConfiguration(); const uncompiledConfig = (new shaka.Player()).getConfiguration(); compareConfigFunctions(compiledConfig, uncompiledConfig); /** * Find all the callbacks in the configuration recursively and compare * their lengths (number of arguments). We warn the app developer when a * configured callback has the wrong number of arguments, so our own * compiled versions must be correct. * * @param {Object} compiled * @param {Object} uncompiled * @param {string=} basePath The path to this point in the config, for * logging purposes. */ function compareConfigFunctions(compiled, uncompiled, basePath = '') { for (const key in uncompiled) { const uncompiledValue = uncompiled[key]; const compiledValue = compiled[key]; const path = basePath + '.' + key; if (uncompiledValue && uncompiledValue.constructor == Object) { // This is an anonymous Object, so recurse on it. compareConfigFunctions(compiledValue, uncompiledValue, path); } else if (typeof uncompiledValue == 'function') { // This is a function, so check its length. The uncompiled version // is considered canonically correct, so we use the uncompiled // length as the expectation. shaka.log.debug('[' + path + ']', compiledValue.length, 'should be', uncompiledValue.length); expect(compiledValue.length).toBe(uncompiledValue.length); } } } }); }); // describe('configuration') describe('adaptation', () => { /** @type {!shaka.test.FakeAbrManager} */ let abrManager; beforeEach(() => { abrManager = new shaka.test.FakeAbrManager(); player.configure('abrFactory', function() { return abrManager; }); }); it('fires "adaptation" event', async () => { const abrEnabled = new Promise((resolve) => { abrManager.enable.and.callFake(resolve); }); await player.load('test:sintel_multi_lingual_multi_res_compiled'); expect(abrManager.switchCallback).toBeTruthy(); expect(abrManager.variants.length).toBeGreaterThan(1); expect(abrManager.chooseIndex).toBe(0); /** @type {shaka.test.Waiter} */ const waiter = new shaka.test.Waiter(eventManager) .timeoutAfter(1).failOnTimeout(true); await waiter.waitForPromise(abrEnabled, 'AbrManager enabled'); const p = waiter.waitForEvent(player, 'adaptation'); abrManager.switchCallback(abrManager.variants[1]); await expectAsync(p).toBeResolved(); }); it('doesn\'t fire "adaptation" when not changing streams', async () => { const abrEnabled = new Promise((resolve) => { abrManager.enable.and.callFake(resolve); }); await player.load('test:sintel_multi_lingual_multi_res_compiled'); expect(abrManager.switchCallback).toBeTruthy(); /** @type {shaka.test.Waiter} */ const waiter = new shaka.test.Waiter(eventManager) .timeoutAfter(1).failOnTimeout(true); await waiter.waitForPromise(abrEnabled, 'AbrManager enabled'); const p = waiter.waitForEvent(player, 'adaptation'); for (let i = 0; i < 3; i++) { abrManager.switchCallback(abrManager.variants[abrManager.chooseIndex]); } await expectAsync(p).toBeRejected(); // Timeout }); }); // describe('adaptation') /** Regression test for Issue #2741 */ describe('unloading', () => { drmIt('unloads properly after DRM error', async () => { const drmSupport = await shaka.media.DrmEngine.probeSupport(); if (!drmSupport['com.widevine.alpha'] && !drmSupport['com.microsoft.playready']) { pending('Skipping DRM error test, only runs on Widevine and PlayReady'); } let unloadPromise = null; const errorPromise = new Promise((resolve, reject) => { onErrorSpy.and.callFake((event) => { unloadPromise = player.unload(); onErrorSpy.and.callThrough(); resolve(); }); }); // Load an encrypted asset with the wrong license servers, so it errors. const bogusUrl = 'http://foo/widevine'; player.configure('drm.servers', { 'com.widevine.alpha': bogusUrl, 'com.microsoft.playready': bogusUrl, }); await player.load('test:sintel-enc_compiled'); await errorPromise; expect(unloadPromise).not.toBeNull(); if (unloadPromise) { await unloadPromise; } }); }); // describe('unloading') }); // TODO(vaage): Try to group the stat tests together. describe('Player Stats', () => { let player; // Destroy the player in |afterEach| so that it will be destroyed // regardless of if the test succeeded or failed. afterEach(async () => { await player.destroy(); }); // Regression test for Issue #968 where trying to get the stats before // calling load would fail because not all components had been initialized. it('can get stats before loading content', () => { // We are opting not to initialize the player with a video element so that // it is in the least loaded state possible. player = new shaka.Player(); const stats = player.getStats(); expect(stats).toBeTruthy(); }); }); // This test suite checks that we can interrupt manifest parsing using other // methods. This ensures that if someone accidentally loads a bad uri, they // don't need to wait for a time-out before being able to load a good uri. // // TODO: Any call to |load|, |attach|, etc. should abort manifest retries. // Add the missing tests for |load| and |attach|. describe('Player Manifest Retries', function() { /** @type {!HTMLVideoElement} */ let video; /** @type {shaka.Player} */ let player; /** @type {!jasmine.Spy} */ let stateChangeSpy; beforeAll(() => { video = shaka.util.Dom.createVideoElement(); document.body.appendChild(video); // For these tests, we don't want any network requests to succeed. We want // to force the networking engine to run-out of retries for our manifest // requests. shaka.net.NetworkingEngine.registerScheme( 'reject', alwaysFailNetworkScheme); }); afterAll(() => { shaka.net.NetworkingEngine.unregisterScheme('reject'); document.body.removeChild(video); }); beforeEach(async () => { stateChangeSpy = jasmine.createSpy('stateChange'); player = new shaka.Player(); player.addEventListener( 'onstatechange', shaka.test.Util.spyFunc(stateChangeSpy)); await player.attach(video); }); afterEach(async () => { await player.destroy(); }); it('unload prevents further manifest load retries', async () => { const loading = player.load('reject://www.foo.com/bar.mpd'); entersState('manifest-parser', () => player.unload()); try { await loading; fail(); } catch (e) { expect(e.code).toBe(shaka.util.Error.Code.LOAD_INTERRUPTED); } }); it('detach prevents further manifest load retries', async () => { const loading = player.load('reject://www.foo.com/bar.mpd'); entersState('manifest-parser', () => player.detach()); try { await loading; fail(); } catch (e) { expect(e.code).toBe(shaka.util.Error.Code.LOAD_INTERRUPTED); } }); it('destroy prevents further manifest load retries', async () => { const loading = player.load('reject://www.foo.com/bar.mpd'); entersState('manifest-parser', () => player.destroy()); try { await loading; fail(); } catch (e) { expect(e.code).toBe(shaka.util.Error.Code.LOAD_INTERRUPTED); } }); /** * A networking scheme that will ways throw a recoverable error so that * networking engine will keep retrying. * * @return {!shaka.util.AbortableOperation} */ function alwaysFailNetworkScheme() { const error = new shaka.util.Error( shaka.util.Error.Severity.RECOVERABLE, shaka.util.Error.Category.NETWORK, shaka.util.Error.Code.HTTP_ERROR); return shaka.util.AbortableOperation.failed(error); } /** * Call |doThis| when the player enters a specific state. * * @param {string} stateName * @param {function()} doThis */ function entersState(stateName, doThis) { stateChangeSpy.and.callFake((event) => { if (event.state == stateName) { doThis(); } }); } }); // This test suite focuses on how the player moves through the different load // states. describe('Player Load Path', () => { const SMALL_MP4_CONTENT_URI = '/base/test/test/assets/small.mp4'; /** @type {!HTMLVideoElement} */ let video; /** @type {shaka.Player} */ let player; /** @type {!jasmine.Spy} */ let stateChangeSpy; /** @type {!jasmine.Spy} */ let stateIdleSpy; beforeAll(() => { video = shaka.util.Dom.createVideoElement(); document.body.appendChild(video); }); afterAll(() => { document.body.removeChild(video); }); beforeEach(() => { stateChangeSpy = jasmine.createSpy('stateChange'); stateIdleSpy = jasmine.createSpy('stateIdle'); }); /** * @param {HTMLMediaElement} attachedTo */ function createPlayer(attachedTo) { player = new shaka.Player(attachedTo); player.addEventListener( 'onstatechange', shaka.test.Util.spyFunc(stateChangeSpy)); player.addEventListener( 'onstateidle', shaka.test.Util.spyFunc(stateIdleSpy)); } // Even though some test will destroy the player, we want to make sure that // we don't allow the player to stay attached to the video element. afterEach(async () => { await player.destroy(); }); it('attach and initialize media source when constructed with media element', async () => { expect(video.src).toBeFalsy(); createPlayer(/* attachedTo= */ video); // Wait until we enter the media source state. await new Promise((resolve) => { whenEnteringState('media-source', resolve); }); expect(video.src).toBeTruthy(); }); it('does not set video.src when no video is provided', async function() { expect(video.src).toBeFalsy(); createPlayer(/* attachedTo= */ null); // Wait until the player has hit an idle state (no more internal loading // actions). await spyIsCalled(stateIdleSpy); expect(video.src).toBeFalsy(); }); it('attach + initializeMediaSource=true will initialize media source', async () => { createPlayer(/* attachedTo= */ null); expect(video.src).toBeFalsy(); await player.attach(video, /* initializeMediaSource= */ true); expect(video.src).toBeTruthy(); }); it('attach + initializeMediaSource=false will not intialize media source', async () => { createPlayer(/* attachedTo= */ null); expect(video.src).toBeFalsy(); await player.attach(video, /* initializeMediaSource= */ false); expect(video.src).toBeFalsy(); }); it('unload + initializeMediaSource=false does not initialize media source', async () => { createPlayer(/* attachedTo= */ null); await player.attach(video); await player.load('test:sintel'); await player.unload(/* initializeMediaSource= */ false); expect(video.src).toBeFalsy(); }); it('unload + initializeMediaSource=true initializes media source', async () => { createPlayer(/* attachedTo= */ null); await player.attach(video); await player.load('test:sintel'); await player.unload(/* initializeMediaSource= */ true); expect(video.src).toBeTruthy(); }); // There was a bug when calling unload before calling load would cause // the load to continue before the (first) unload was complete. // https://github.com/google/shaka-player/issues/612 it('load will wait for unload to finish', async () => { createPlayer(/* attachedTo= */ null); await player.attach(video); await player.load('test:sintel'); // We are going to call |unload| and |load| right after each other. What // we expect to see is that the player is fully unloaded before the load // occurs. const unload = player.unload(); const load = player.load('test:sintel'); await unload; await load; expect(getVisitedStates()).toEqual([ 'attach', // First call to |load|. 'media-source', 'manifest-parser', 'manifest', 'drm-engine', 'load', // Our call to |unload| would have started the transition to // "unloaded", but since we called |load| right away, the transition // to "unloaded" was most likely done by the call to |load|. 'unload', 'attach', 'media-source', 'manifest-parser', 'manifest', 'drm-engine', 'load', ]); }); it('load and unload can be called multiple times', async () => { createPlayer(/* attachedTo= */ null); await player.attach(video); await player.load('test:sintel'); await player.unload(); await player.load('test:sintel'); await player.unload(); expect(getVisitedStates()).toEqual([ 'attach', 'media-source', // Load and unload 1 'manifest-parser', 'manifest', 'drm-engine', 'load', 'unload', 'attach', 'media-source', // Load and unload 2 'manifest-parser', 'manifest', 'drm-engine', 'load', 'unload', 'attach', 'media-source', ]); }); it('load can be called multiple times', async () => { createPlayer(/* attachedTo= */ null); await player.attach(video); await player.load('test:sintel'); await player.load('test:sintel'); await player.load('test:sintel'); expect(getVisitedStates()).toEqual([ 'attach', // Load 1 'media-source', 'manifest-parser', 'manifest', 'drm-engine', 'load', // Load 2 'unload', 'attach', 'media-source', 'manifest-parser', 'manifest', 'drm-engine', 'load', // Load 3 'unload', 'attach', 'media-source', 'manifest-parser', 'manifest', 'drm-engine', 'load', ]); }); it('load will interrupt load', async () => { createPlayer(/* attachedTo= */ null); await player.attach(video); const load1 = player.load('test:sintel'); const load2 = player.load('test:sintel'); // Load 1 should have been interrupted because of load 2. await rejected(load1); // Load 2 should finish with no issues. await load2; }); it('unload will interrupt load', async () => { createPlayer(/* attachedTo= */ null); await player.attach(video); const load = player.load('test:sintel'); const unload = player.unload(); await rejected(load); await unload; // We should never have gotten into the loaded state. expect(getVisitedStates()).not.toContain('load'); }); it('destroy will interrupt load', async () => { createPlayer(/* attachedTo= */ null); await player.attach(video); const load = player.load('test:sintel'); const destroy = player.destroy(); await rejected(load); await destroy; // We should never have gotten into the loaded state. expect(getVisitedStates()).not.toContain('load'); }); // When |destroy| is called, the player should move through the unload state // on its way to the detached state. it('destroy will unload and then detach', async () => { createPlayer(/* attachedTo= */ null); await player.attach(video); await player.load('test:sintel'); await player.destroy(); // We really only care about the last two elements (unload and detach), // however the test is easier to read if we list the full chain. expect(getVisitedStates()).toEqual([ 'attach', 'media-source', 'manifest-parser', 'manifest', 'drm-engine', 'load', 'unload', 'detach', ]); }); // Calling |unload| multiple times should not cause any problems. Calling // |unload| after another |unload| call should just have the player re-enter // the state it was waiting in. it('unloading multiple times is okay', async () => { createPlayer(/* attachedTo= */ null); await player.attach(video); await player.load('test:sintel'); await player.unload(); await player.unload(); expect(getVisitedStates()).toEqual([ // |player.attach| 'attach', 'media-source', // |player.load| 'manifest-parser', 'manifest', 'drm-engine', 'load', // |player.unload| (first call) 'unload', 'attach', 'media-source', // |player.unload| (second call) 'unload', 'attach', 'media-source', ]); }); // When we destroy, it will allow a current unload operation to occur even // though we are going to unload and detach as part of |destroy|. it('destroy will not interrupt unload', async () => { createPlayer(/* attachedTo= */ null); await player.attach(video); await player.load('test:sintel'); const unload = player.unload(); const destroy = player.destroy(); await unload; await destroy; }); // While out tests will naturally test this (because we destroy in // afterEach), this test will explicitly express our intentions to support // the use-case of calling |destroy| multiple times on a player instance. it('destroying multiple times is okay', async () => { createPlayer(/* attachedTo= */ null); await player.attach(video); await player.load('test:sintel'); await player.destroy(); await player.destroy(); }); // As a regression test for Issue #1570, this checks that when we // pre-initialize media source engine, we do not re-create the media source // instance when loading. it('pre-initialized media source is used when player continues loading', async () => { createPlayer(/* attachedTo= */ null); // After we attach and initialize media source, we should just see // two states in our history. await player.attach(video, /* initializeMediaSource= */ true); expect(getVisitedStates()).toEqual([ 'attach', 'media-source', ]); // When we load, the only change in the visited states should be that // we added "load". await player.load('test:sintel'); expect(getVisitedStates()).toEqual([ 'attach', 'media-source', 'manifest-parser', 'manifest', 'drm-engine', 'load', ]); }); // We want to make sure that we can interrupt the load process at key-points // in time. After each node in the graph, we should be able to reroute and do // something different. // // To test this, we test that we can successfully unload the player after each // node after attached. We exclude the nodes before (and including) attach // since unloading will put us back at attach (creating a infinite loop). describe('interrupt after', () => { /** * Given the name of a state, tell the player to load content but unload * when it reaches |state|. The load should be interrupted and the player * should return to the unloaded state. * * @param {string} state * @return {!Promise} */ async function testInterruptAfter(state) { createPlayer(/* attachedTo= */ null); let pendingUnload; whenEnteringState(state, () => { pendingUnload = player.unload(/* initMediaSource= */ false); }); // We attach manually so that we had time to override the state change // spy's action. await player.attach(video); await rejected(player.load('test:sintel')); // By the time that |player.load| failed, we should have started // |player.unload|. expect(pendingUnload).toBeTruthy(); await pendingUnload; } it('media source', async () => { await testInterruptAfter('media-source'); }); it('manifest-parser', async () => { await testInterruptAfter('manifest-parser'); }); it('manifest', async () => { await testInterruptAfter('manifest'); }); it('drm-engine', async () => { await testInterruptAfter('drm-engine'); }); }); describe('error handling', () => { beforeEach(() => { createPlayer(/* attachedTo= */ null); }); it('returns to attach after load error', async () => { // The easiest way we can inject an error is to fail fetching the // manifest. To do this, we force the network request by throwing an error // in a request filter. The specific error does not matter. const networkingEngine = player.getNetworkingEngine(); expect(networkingEngine).toBeTruthy(); networkingEngine.registerRequestFilter(() => { throw new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.NETWORK, shaka.util.Error.Code.REQUEST_FILTER_ERROR); }); // Make the two requests one-after-another so that we don't have any idle // time between them. const attachRequest = playe