UNPKG

shaka-player

Version:
660 lines (545 loc) 20.9 kB
/*! @license * Shaka Player * Copyright 2016 Google LLC * SPDX-License-Identifier: Apache-2.0 */ describe('UI', () => { const Util = shaka.test.Util; const UiUtils = shaka.test.UiUtils; /** @type {!jasmine.Spy} */ let onErrorSpy; /** @type {!HTMLVideoElement} */ let video; /** @type {!HTMLElement} */ let videoContainer; /** @type {!shaka.Player} */ let player; /** @type {shaka.util.EventManager} */ let eventManager; /** @type {shaka.test.Waiter} */ let waiter; /** @type {!HTMLLinkElement} */ let cssLink; /** @type {!shaka.ui.Overlay} */ let ui; /** @type {!shaka.ui.Controls} */ let controls; /** @type {shakaNamespaceType} */ let compiledShaka; beforeAll(async () => { cssLink = /** @type {!HTMLLinkElement} */(document.createElement('link')); await UiUtils.setupCSS(cssLink); compiledShaka = await shaka.test.Loader.loadShaka(getClientArg('uncompiled')); await shaka.test.TestScheme.createManifests(compiledShaka, '_compiled'); }); beforeEach(async () => { video = shaka.test.UiUtils.createVideoElement(); videoContainer = shaka.util.Dom.createHTMLElement('div'); videoContainer.appendChild(video); document.body.appendChild(videoContainer); player = new compiledShaka.Player(video); // Create UI // Add all of the buttons we have const config = { controlPanelElements: [ 'time_and_duration', 'mute', 'volume', 'fullscreen', 'overflow_menu', 'fast_forward', 'rewind', ], overflowMenuButtons: [ 'captions', 'quality', 'language', 'picture_in_picture', 'cast', ], // TODO: Cast receiver id to test chromecast integration }; ui = new compiledShaka.ui.Overlay(player, videoContainer, video); ui.configure(config); // Grab event manager from the uncompiled library: eventManager = new shaka.util.EventManager(); waiter = new shaka.test.Waiter(eventManager); const tempControls = ui.getControls(); goog.asserts.assert(tempControls != null, 'Controls are null!'); controls = tempControls; onErrorSpy = jasmine.createSpy('onError'); onErrorSpy.and.callFake((event) => { fail(event.detail); }); eventManager.listen(player, 'error', Util.spyFunc(onErrorSpy)); eventManager.listen(controls, 'error', Util.spyFunc(onErrorSpy)); // These tests expect text to be streaming upfront, so always stream text. player.configure('streaming.alwaysStreamText', true); await player.load('test:sintel_multi_lingual_multi_res_compiled'); // For this event, we ignore a timeout, since we sometimes miss this event // on Tizen. But expect that the video is ready anyway. await waiter.failOnTimeout(false).waitForEvent(video, 'canplay'); expect(video.readyState).not.toBe(0); // All other events after this should fail on timeout (the default). await waiter.failOnTimeout(true); }); afterEach(async () => { eventManager.release(); waiter = null; await UiUtils.cleanupUI(); }); afterAll(() => { document.head.removeChild(cssLink); }); describe('language selections', () => { /** @type {!Map.<string, !HTMLElement>} */ let languagesToButtons; /** @type {!Array.<string>} */ let langsFromContent; /** @type {!Array.<!HTMLElement>} */ let languageButtons; /** @type {!Element} */ let languageMenu; /** @type {string} */ let oldLanguage; /** @type {string} */ let newLanguage; describe('audio', () => { beforeEach(() => { oldLanguage = 'en'; newLanguage = 'es'; languageMenu = shaka.util.Dom.getElementByClassName( 'shaka-audio-languages', videoContainer); setupLanguageTests(player.getAudioLanguagesAndRoles()); }); it('contains all the languages', () => { verifyLanguages(); }); it('choosing language through UI has effect on player', async () => { await verifyLanguageChangeViaUI( 'variantchanged', () => player.getVariantTracks()); }); it('choosing language through API has effect on UI', async () => { await verifyLanguageChangeViaAPI( 'languageselectionupdated', () => player.getVariantTracks(), (language) => player.selectAudioLanguage(language)); }); }); // describe('audio') describe('caption selection', () => { beforeEach(() => { oldLanguage = 'zh'; newLanguage = 'fr'; languageMenu = shaka.util.Dom.getElementByClassName( 'shaka-text-languages', videoContainer); setupLanguageTests(player.getTextLanguagesAndRoles()); }); it('contains all the languages', () => { verifyLanguages(); }); it('choosing caption language through UI has effect on player', async () => { await verifyLanguageChangeViaUI( 'textchanged', () => player.getTextTracks()); }); it('choosing language through API has effect on UI', async () => { // Enable & verify the text, or else the text won't be streamed and the // language selection won't do anything. await player.setTextTrackVisibility(true); expect(player.isTextTrackVisible()).toBe(true); await verifyLanguageChangeViaAPI( 'captionselectionupdated', () => player.getTextTracks(), (language) => player.selectTextLanguage(language)); }); it('turning captions off through UI has effect on player', async () => { // Enable & verify the text. await player.setTextTrackVisibility(true); expect(player.isTextTrackVisible()).toBe(true); // Find and click the 'Off' button getOffButton().click(); // Wait for the change to take effect await waiter.waitForEvent(player, 'texttrackvisibility'); expect(player.isTextTrackVisible()).toBe(false); }); it('turning captions off through API has effect on UI', async () => { // This test is invalid if the text is not initially visible, because // setTextTrackVisibility() does nothing if there are no changes. await player.setTextTrackVisibility(true); expect(player.isTextTrackVisible()).toBe(true); const p = waiter.waitForEvent(controls, 'captionselectionupdated'); // Disable & verify the text. await player.setTextTrackVisibility(false); expect(player.isTextTrackVisible()).toBe(false); // Wait for the change to take effect await p; const offButtonChosen = getOffButton().querySelector('.shaka-chosen-item'); expect(offButtonChosen).not.toBe(null); }); /** * @return {Element} */ function getOffButton() { const offButton = languageMenu.querySelector('.shaka-turn-captions-off-button'); expect(offButton).not.toBe(null); return offButton; } }); // describe('caption selection') /** * @param {!Array.<shaka.extern.LanguageRole>} languagesAndRoles */ function setupLanguageTests(languagesAndRoles) { langsFromContent = languagesAndRoles.map((langAndRole) => { return langAndRole.language; }); languageButtons = filterButtons(languageMenu.childNodes, ['shaka-back-to-overflow-button', 'shaka-turn-captions-off-button']); languagesToButtons = mapChoicesToButtons( /* allButtons= */ languageButtons, /* choices= */ langsFromContent, /* modifier= */ getNativeName, ); } /** * @param {string} language * @return {string} */ function getNativeName(language) { return mozilla.LanguageMapping[language].nativeName; } /** * Make sure languages specified by the manifest match what we show on UI. */ function verifyLanguages() { const langsFromContentNative = langsFromContent.map((lang) => { return getNativeName(lang); }); verifyItems(langsFromContentNative, languageButtons); } /** * @param {string} playerEventName * @param {function():!Array.<!shaka.extern.Track>} getTracks */ async function verifyLanguageChangeViaUI(playerEventName, getTracks) { expect(getSelectedTrack(getTracks()).language).toBe(oldLanguage); const button = languagesToButtons.get(newLanguage); button.click(); // Wait for the change to take effect await waiter.waitForEvent(player, playerEventName); expect(getSelectedTrack(getTracks()).language).toBe(newLanguage); } /** * @param {string} controlsEventName * @param {function():!Array.<!shaka.extern.Track>} getTracks * @param {function(string)} selectLanguage */ async function verifyLanguageChangeViaAPI( controlsEventName, getTracks, selectLanguage) { expect(getSelectedTrack(getTracks()).language).toBe(oldLanguage); const p = waiter.waitForEvent(controls, controlsEventName); selectLanguage(newLanguage); // Wait for the UI to get updated await p; // Buttons were re-created on variant change languageButtons = filterButtons(languageMenu.childNodes, ['shaka-back-to-overflow-button', 'shaka-turn-captions-off-button']); languagesToButtons = mapChoicesToButtons( /* allButtons= */ languageButtons, /* choices= */ langsFromContent, /* modifier= */ getNativeName); const button = languagesToButtons.get(newLanguage); const isChosen = button.querySelector('.shaka-chosen-item'); expect(isChosen).not.toBe(null); } }); // describe('language selections') describe('resolution selection', () => { /** @type {!Map.<number, !HTMLElement>} */ let resolutionsToButtons; /** @type {!Array.<number>} */ let resolutionsFromContent; /** @type {!Array.<!HTMLElement>} */ let resolutionButtons; /** @type {!Element} */ let resolutionsMenu; /** @type {number} */ let oldResolution; /** @type {number} */ let newResolution; /** @type {!Array.<shaka.extern.Track>} */ let tracks; /** @type {string} */ let preferredLanguage; /** @type {!shaka.extern.Track} */ let oldResolutionTrack; beforeEach(async () => { oldResolution = 182; newResolution = 272; // Chosen language affects which resolutions get // displayed in the UI. preferredLanguage = 'en'; // Disable abr for the resolution tests player.configure('abr.enabled', false); const selectedLanguage = getSelectedTrack(player.getVariantTracks()).language; if (selectedLanguage != preferredLanguage) { player.selectAudioLanguage(preferredLanguage); await waiter.waitForEvent(player, 'variantchanged'); } resolutionsMenu = shaka.util.Dom.getElementByClassName( 'shaka-resolutions', videoContainer); updateResolutionButtonsAndMap(); oldResolutionTrack = findTrackWithHeight(tracks, oldResolution); const selectedResolution = getSelectedTrack(player.getVariantTracks()).height; if (selectedResolution != oldResolution) { player.selectVariantTrack(oldResolutionTrack); await waiter.waitForEvent(player, 'variantchanged'); } }); it('contains all the relevant resolutions', () => { const formattedResolutions = resolutionsFromContent.map((res) => { return formatResolution(res); }); verifyItems(formattedResolutions, resolutionButtons); }); it('changing resolution via UI has effect on the player', async () => { // Update the tracks tracks = player.getVariantTracks(); expect(getSelectedTrack(tracks).height).toBe(oldResolution); const button = resolutionsToButtons.get(newResolution); button.click(); // Wait for the change to take effect await waiter.waitForEvent(player, 'variantchanged'); // Update the tracks tracks = player.getVariantTracks(); expect(getSelectedTrack(tracks).height).toBe(newResolution); }); it('changing resolution via API has effect on the UI', async () => { updateResolutionButtonsAndMap(); expect(getSelectedTrack(tracks).height).toBe(oldResolution); const p = waiter.waitForEvent(controls, 'resolutionselectionupdated'); const newResolutionTrack = findTrackWithHeight(tracks, newResolution); player.selectVariantTrack(newResolutionTrack); // Wait for the change to take effect await p; updateResolutionButtonsAndMap(); expect(getSelectedTrack(tracks).height).toBe(newResolution); const button = resolutionsToButtons.get(newResolution); const isChosen = button.querySelector('.shaka-chosen-item'); expect(isChosen).not.toBe(null); }); it('selecting Auto via UI enables ABR', async () => { // We disabled abr in beforeEach() expect(player.getConfiguration().abr.enabled).toBe(false); const p = waiter.waitForEvent(controls, 'resolutionselectionupdated'); // Find the 'Auto' button const auto = getAutoButton(); auto.click(); await p; expect(player.getConfiguration().abr.enabled).toBe(true); }); it('selecting specific resolution disables ABR', async () => { const config = {abr: {enabled: true}}; player.configure(config); const p = waiter.waitForEvent(controls, 'resolutionselectionupdated'); // Any resolution would works const button = resolutionsToButtons.get(newResolution); button.click(); await p; expect(player.getConfiguration().abr.enabled).toBe(false); }); it('enabling ABR via API gets the Auto button selected', async () => { expect(player.getConfiguration().abr.enabled).toBe(false); // Setup listener to the ui event. The event, trigerring the update // is dispatched inside player.configure(), so we need to start // listening before calling it. const uiReady = waiter.waitForEvent( controls, 'resolutionselectionupdated'); const config = {abr: {enabled: true}}; player.configure(config); await uiReady; const auto = getAutoButton(); const isChosen = auto.querySelector('.shaka-chosen-item'); expect(isChosen).not.toBe(null); }); /** * @return {Element} */ function getAutoButton() { const auto = resolutionsMenu.querySelector('.shaka-enable-abr-button'); expect(auto).not.toBe(null); return auto; } /** * Gets the resolution to the same format it * appears in the UI: height + 'p'. * * @param {number} height * @return {string} */ function formatResolution(height) { return height.toString() + 'p'; } /** * @param {!Array.<!shaka.extern.Track>} tracks * @param {number} height * @return {shaka.extern.Track} */ function findTrackWithHeight(tracks, height) { let trackWithRes = null; for (const track of tracks) { if (track.height == height) { trackWithRes = track; } } goog.asserts.assert(trackWithRes != null, 'Should have found track!'); return trackWithRes; } function updateResolutionButtonsAndMap() { tracks = player.getVariantTracks(); tracks = tracks.filter((track) => { return track.language == preferredLanguage; }); resolutionsFromContent = tracks.map((track) => { return track.height; }); resolutionButtons = filterButtons( /* buttons= */ resolutionsMenu.childNodes, /* excludeClasses= */ [ 'shaka-back-to-overflow-button', 'shaka-enable-abr-button', ]); resolutionsToButtons = mapChoicesToButtons( /* buttons= */ resolutionButtons, /* choices= */ resolutionsFromContent, /* modifier= */ formatResolution); } }); // describe('resolution selection') describe('uncompiled UI element plugin', () => { it('has access to the features of the compiled base class', () => { let constructed = false; // For the uncompiled element below, we won't want to create real // controls. Real controls would create a CastProxy which would conflict // with the compiled version instantiated above. const fakeControls = /** @type {!shaka.ui.Controls} */({ getLocalization: () => null, getPlayer: () => player, getVideo: () => null, getAd: () => null, }); /** @extends {shaka.ui.Element} */ const UncompiledElementType = class extends shaka.ui.Element {}; const uncompiledElement = new UncompiledElementType( videoContainer, fakeControls); uncompiledElement.release(); /** @extends {shaka.ui.Element} */ const TestElement = class extends compiledShaka.ui.Element { /** * @param {!HTMLElement} parent * @param {!shaka.ui.Controls} controls * @suppress {checkTypes} since we use "in" and "[]" on a struct. */ constructor(parent, controls) { super(parent, controls); constructed = true; // The compiled base class's protected members should still have their // original names. Otherwise, apps can't register uncompiled plugins. // Rather than list them and potentially let them get out of date, use // the uncompiled library as a reference. for (const k in uncompiledElement) { if (k.endsWith('_')) { // Skip private members. continue; } // All public and protected members of the uncompiled base class // should be available on "this" with their original names. expect(this[k]).withContext(k).toBeDefined(); } } }; /** @implements {shaka.extern.IUIElement.Factory} */ const TestElementFactory = class { /** @override */ create(rootElement, controls) { return new TestElement(rootElement, controls); } }; compiledShaka.ui.Controls.registerElement( 'test_element', new TestElementFactory()); constructed = false; ui.configure('controlPanelElements', ['test_element']); // The constructor contains expectations, so make sure we called it. expect(constructed).toBe(true); }); }); // describe('UI element plugins') /** * @param {!Array.<!shaka.extern.Track>} tracks * @return {!shaka.extern.Track} */ function getSelectedTrack(tracks) { const activeTracks = tracks.filter((track) => { return track.active == true; }); return activeTracks[0]; } /** * @param {!Array.<!HTMLElement>} buttons * @param {!Array.<string>} choices * @param {function(string):string|function(number):string} modifier * @return {!Map.<string, !HTMLElement>|!Map.<number, !HTMLElement>} */ function mapChoicesToButtons(buttons, choices, modifier) { expect(buttons.length).toBe(choices.length); const map = new Map(); // Find which choice corresponds to which button for (const choice of choices) { for (const button of buttons) { expect(button.childNodes.length).toBeGreaterThan(0); const uiOption = button.childNodes[0].textContent; const contentOption = modifier(choice); if (contentOption == uiOption) { map.set(choice, button); } } } return map; } /** * Filter out buttons with given classes. * * @param {!NodeList} buttons * @param {!Array.<string>} excludeClasses * @return {!Array.<!HTMLElement>} */ function filterButtons(buttons, excludeClasses) { return shaka.util.Iterables.filter(buttons, (node) => { const button = shaka.util.Dom.asHTMLElement(node); for (const excludeClass of excludeClasses) { if (button.classList.contains(excludeClass)) { return false; } } return true; }); } /** * Make sure elements from content match their UI representation. * (The order doesn't matter). * * @param {!Array.<string>} elementsFromContent * @param {!Array.<!HTMLElement>} elementsFromUI */ function verifyItems(elementsFromContent, elementsFromUI) { for (const element of elementsFromUI) { expect(element.childNodes.length).toBeGreaterThan(0); const elementName = element.childNodes[0].textContent; expect(elementsFromContent.indexOf(elementName)).not.toBe(-1); } } });