diff --git a/build/types/core b/build/types/core index 17ce17f01f..cd61089c5f 100644 --- a/build/types/core +++ b/build/types/core @@ -5,6 +5,8 @@ +../../lib/abr/ewma_bandwidth_estimator.js +../../lib/abr/simple_abr_manager.js ++../../lib/config/auto_show_text.js + +../../lib/debug/asserts.js +../../lib/debug/log.js diff --git a/demo/common/message_ids.js b/demo/common/message_ids.js index fe7ab636ac..4048d393ef 100644 --- a/demo/common/message_ids.js +++ b/demo/common/message_ids.js @@ -155,6 +155,11 @@ shakaDemo.MessageIds = { AUDIO_ROBUSTNESS: 'DEMO_AUDIO_ROBUSTNESS', AUTO_CORRECT_DASH_DRIFT: 'DEMO_AUTO_CORRECT_DASH_DRIFT', AUTO_LOW_LATENCY: 'DEMO_AUTO_LOW_LATENCY', + AUTO_SHOW_TEXT: 'DEMO_AUTO_SHOW_TEXT', + AUTO_SHOW_TEXT_NEVER: 'DEMO_AUTO_SHOW_TEXT_NEVER', + AUTO_SHOW_TEXT_ALWAYS: 'DEMO_AUTO_SHOW_TEXT_ALWAYS', + AUTO_SHOW_TEXT_IF_PREFERRED_TEXT_LANGUAGE: 'DEMO_AUTO_SHOW_TEXT_IF_PREFERRED_TEXT_LANGUAGE', + AUTO_SHOW_TEXT_IF_SUBTITLES_MAY_BE_NEEDED: 'DEMO_AUTO_SHOW_TEXT_IF_SUBTITLES_MAY_BE_NEEDED', AVAILABILITY_WINDOW_OVERRIDE: 'DEMO_AVAILABILITY_WINDOW_OVERRIDE', BACKOFF_FACTOR: 'DEMO_BACKOFF_FACTOR', BASE_DELAY: 'DEMO_BASE_DELAY', diff --git a/demo/config.js b/demo/config.js index 62d46fa7f9..26d289b86a 100644 --- a/demo/config.js +++ b/demo/config.js @@ -430,10 +430,27 @@ shakaDemo.Config = class { addLanguageSection_() { const MessageIds = shakaDemo.MessageIds; const docLink = this.resolveExternLink_('.PlayerConfiguration'); + + const autoShowTextOptions = shaka.config.AutoShowText; + const localize = (name) => shakaDemoMain.getLocalizedString(name); + const autoShowTextOptionNames = { + 'NEVER': localize(MessageIds.AUTO_SHOW_TEXT_NEVER), + 'ALWAYS': localize(MessageIds.AUTO_SHOW_TEXT_ALWAYS), + 'IF_PREFERRED_TEXT_LANGUAGE': + localize(MessageIds.AUTO_SHOW_TEXT_IF_PREFERRED_TEXT_LANGUAGE), + 'IF_SUBTITLES_MAY_BE_NEEDED': + localize(MessageIds.AUTO_SHOW_TEXT_IF_SUBTITLES_MAY_BE_NEEDED), + }; + this.addSection_(MessageIds.LANGUAGE_SECTION_HEADER, docLink) .addTextInput_(MessageIds.AUDIO_LANGUAGE, 'preferredAudioLanguage') .addTextInput_(MessageIds.TEXT_LANGUAGE, 'preferredTextLanguage') - .addTextInput_(MessageIds.TEXT_ROLE, 'preferredTextRole'); + .addTextInput_(MessageIds.TEXT_ROLE, 'preferredTextRole') + .addSelectInput_( + MessageIds.AUTO_SHOW_TEXT, + 'autoShowText', + autoShowTextOptions, + autoShowTextOptionNames); const onChange = (input) => { shakaDemoMain.setUILocale(input.value); shakaDemoMain.remakeHash(); @@ -523,7 +540,7 @@ shakaDemo.Config = class { } shakaDemoMain.remakeHash(); }; - this.addSelectInput_(MessageIds.LOG_LEVEL, logLevels, onChange); + this.addCustomSelectInput_(MessageIds.LOG_LEVEL, logLevels, onChange); const input = this.latestInput_.input(); switch (shaka['log']['currentLevel']) { case Level['DEBUG']: @@ -705,7 +722,7 @@ shakaDemo.Config = class { * @return {!shakaDemo.Config} * @private */ - addSelectInput_(name, values, onChange, tooltipMessage) { + addCustomSelectInput_(name, values, onChange, tooltipMessage) { this.createRow_(name, tooltipMessage); // The input is not provided a name, as (in this enclosed space) it makes // the actual field unreadable. @@ -714,6 +731,42 @@ shakaDemo.Config = class { return this; } + /** + * @param {!shakaDemo.MessageIds} name + * @param {string} valueName + * @param {!Object.} options + * @param {!Object.} optionNames + * @param {shakaDemo.MessageIds=} tooltipMessage + * @return {!shakaDemo.Config} + * @private + */ + addSelectInput_(name, valueName, options, optionNames, tooltipMessage) { + const onChange = (input) => { + shakaDemoMain.configure(valueName, options[input.value]); + shakaDemoMain.remakeHash(); + }; + + // If there are any translations missing for option names, fill in the + // constant from the enum. This ensures new enum values are usable in the + // demo in some form, even if they are forgotten in the demo config. + for (const key in options) { + if (!(key in optionNames)) { + optionNames[key] = key; + } + } + + this.addCustomSelectInput_(name, optionNames, onChange, tooltipMessage); + + const initialValue = shakaDemoMain.getCurrentConfigValue(valueName); + for (const key in options) { + if (options[key] == initialValue) { + this.latestInput_.input().value = key; + } + } + + return this; + } + /** * @param {!shakaDemo.MessageIds} name * @param {shakaDemo.MessageIds=} tooltipMessage diff --git a/demo/demo.less b/demo/demo.less index c3d70e0463..c5a4538e4a 100644 --- a/demo/demo.less +++ b/demo/demo.less @@ -125,7 +125,7 @@ html, body { .hamburger-menu .mdl-textfield { padding: 0; // The default width of 300px is a bit too wide for us. - width: 200px; + width: 220px; } .hamburger-menu .mdl-textfield__label { diff --git a/demo/locales/en.json b/demo/locales/en.json index 0df2a801db..b08c65fb6a 100644 --- a/demo/locales/en.json +++ b/demo/locales/en.json @@ -16,6 +16,11 @@ "DEMO_AUDIO_ROBUSTNESS": "Audio Robustness", "DEMO_AUTO_CORRECT_DASH_DRIFT": "Auto-Correct DASH Drift", "DEMO_AUTO_LOW_LATENCY": "Auto Low Latency Mode", + "DEMO_AUTO_SHOW_TEXT": "Auto-Show Text", + "DEMO_AUTO_SHOW_TEXT_NEVER": "Never", + "DEMO_AUTO_SHOW_TEXT_ALWAYS": "Always", + "DEMO_AUTO_SHOW_TEXT_IF_PREFERRED_TEXT_LANGUAGE": "If preferred text language", + "DEMO_AUTO_SHOW_TEXT_IF_SUBTITLES_MAY_BE_NEEDED": "If subtitles may be needed", "DEMO_AVAILABILITY_WINDOW_OVERRIDE": "Availability Window Override", "DEMO_AXINOM": "Axinom", "DEMO_AZURE_MEDIA_SERVICES": "Azure Media Services", diff --git a/demo/locales/source.json b/demo/locales/source.json index 03f9bec844..71688d0861 100644 --- a/demo/locales/source.json +++ b/demo/locales/source.json @@ -67,6 +67,26 @@ "description": "The name of a configuration value.", "message": "Auto Low Latency Streaming" }, + "DEMO_AUTO_SHOW_TEXT": { + "description": "The name of a configuration value.", + "message": "Auto-Show Text" + }, + "DEMO_AUTO_SHOW_TEXT_NEVER": { + "description": "The name of a configuration value.", + "message": "Never" + }, + "DEMO_AUTO_SHOW_TEXT_ALWAYS": { + "description": "The name of a configuration value.", + "message": "Always" + }, + "DEMO_AUTO_SHOW_TEXT_IF_PREFERRED_TEXT_LANGUAGE": { + "description": "The name of a configuration value.", + "message": "If preferred text language" + }, + "DEMO_AUTO_SHOW_TEXT_IF_SUBTITLES_MAY_BE_NEEDED": { + "description": "The name of a configuration value.", + "message": "If subtitles may be needed" + }, "DEMO_AVAILABILITY_WINDOW_OVERRIDE": { "description": "The name of a configuration value.", "message": "Availability Window Override" diff --git a/externs/shaka/player.js b/externs/shaka/player.js index a37529954a..6614c3c43e 100644 --- a/externs/shaka/player.js +++ b/externs/shaka/player.js @@ -1177,6 +1177,7 @@ shaka.extern.OfflineConfiguration; /** * @typedef {{ + * autoShowText: shaka.config.AutoShowText, * drm: shaka.extern.DrmConfiguration, * manifest: shaka.extern.ManifestConfiguration, * streaming: shaka.extern.StreamingConfiguration, @@ -1199,6 +1200,8 @@ shaka.extern.OfflineConfiguration; * textDisplayFactory: shaka.extern.TextDisplayer.Factory * }} * + * @property {shaka.config.AutoShowText} autoShowText + * Controls behavior of auto-showing text tracks on load(). * @property {shaka.extern.DrmConfiguration} drm * DRM configuration and settings. * @property {shaka.extern.ManifestConfiguration} manifest diff --git a/lib/config/auto_show_text.js b/lib/config/auto_show_text.js new file mode 100644 index 0000000000..ddf02916a8 --- /dev/null +++ b/lib/config/auto_show_text.js @@ -0,0 +1,33 @@ +/*! @license + * Shaka Player + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.provide('shaka.config.AutoShowText'); + +/** + * @enum {number} + * @export + */ +shaka.config.AutoShowText = { + /** Never show text automatically on startup. */ + 'NEVER': 0, + /** Always show text automatically on startup. */ + 'ALWAYS': 1, + /** + * Show text automatically on startup if it matches the preferred text + * language. + */ + 'IF_PREFERRED_TEXT_LANGUAGE': 2, + /** + * Show text automatically on startup if we think that subtitles may be + * needed. This is specifically if the selected text matches the preferred + * text language AND is different from the initial audio language. (Example: + * You prefer English, but the audio is only available in French, so English + * subtitles should be enabled by default.) + *
+ * This is the default setting. + */ + 'IF_SUBTITLES_MAY_BE_NEEDED': 3, +}; diff --git a/lib/player.js b/lib/player.js index 750b57ac0b..d1d4147fbb 100644 --- a/lib/player.js +++ b/lib/player.js @@ -8,6 +8,7 @@ goog.provide('shaka.Player'); goog.require('goog.asserts'); goog.require('shaka.Deprecate'); +goog.require('shaka.config.AutoShowText'); goog.require('shaka.log'); goog.require('shaka.media.AdaptationSetCriteria'); goog.require('shaka.media.BufferingObserver'); @@ -5478,38 +5479,62 @@ shaka.Player = class extends shaka.util.FakeEventTarget { /** * Check if we should show text on screen automatically. * - * The text should automatically be shown if the text is language-compatible - * with the user's text language preference, but not compatible with the - * audio. - * - * For example: - * preferred | chosen | chosen | - * text | text | audio | show - * ----------------------------------- - * en-CA | en | jp | true - * en | en-US | fr | true - * fr-CA | en-US | jp | false - * en-CA | en-US | en-US | false - * * @param {shaka.extern.Stream} audioStream * @param {shaka.extern.Stream} textStream * @return {boolean} * @private */ shouldInitiallyShowText_(audioStream, textStream) { + const AutoShowText = shaka.config.AutoShowText; + + if (this.config_.autoShowText == AutoShowText.NEVER) { + return false; + } + if (this.config_.autoShowText == AutoShowText.ALWAYS) { + return true; + } + const LanguageUtils = shaka.util.LanguageUtils; /** @type {string} */ const preferredTextLocale = LanguageUtils.normalize(this.config_.preferredTextLanguage); /** @type {string} */ - const audioLocale = LanguageUtils.normalize(audioStream.language); - /** @type {string} */ const textLocale = LanguageUtils.normalize(textStream.language); - return ( - LanguageUtils.areLanguageCompatible(textLocale, preferredTextLocale) && - !LanguageUtils.areLanguageCompatible(audioLocale, textLocale)); + if (this.config_.autoShowText == AutoShowText.IF_PREFERRED_TEXT_LANGUAGE) { + // Only the text language match matters. + return LanguageUtils.areLanguageCompatible( + textLocale, + preferredTextLocale); + } + + if (this.config_.autoShowText == AutoShowText.IF_SUBTITLES_MAY_BE_NEEDED) { + /* The text should automatically be shown if the text is + * language-compatible with the user's text language preference, but not + * compatible with the audio. These are cases where we deduce that + * subtitles may be needed. + * + * For example: + * preferred | chosen | chosen | + * text | text | audio | show + * ----------------------------------- + * en-CA | en | jp | true + * en | en-US | fr | true + * fr-CA | en-US | jp | false + * en-CA | en-US | en-US | false + * + */ + /** @type {string} */ + const audioLocale = LanguageUtils.normalize(audioStream.language); + + return ( + LanguageUtils.areLanguageCompatible(textLocale, preferredTextLocale) && + !LanguageUtils.areLanguageCompatible(audioLocale, textLocale)); + } + + shaka.log.alwaysWarn('Invalid autoShowText setting!'); + return false; } /** @@ -6561,7 +6586,6 @@ shaka.Player = class extends shaka.util.FakeEventTarget { } }; - /** * In order to know what method of loading the player used for some content, we * have this enum. It lets us know if content has not been loaded, loaded with diff --git a/lib/util/player_configuration.js b/lib/util/player_configuration.js index 29152b0f99..3778806ff3 100644 --- a/lib/util/player_configuration.js +++ b/lib/util/player_configuration.js @@ -8,6 +8,7 @@ goog.provide('shaka.util.PlayerConfiguration'); goog.require('goog.asserts'); goog.require('shaka.abr.SimpleAbrManager'); +goog.require('shaka.config.AutoShowText'); goog.require('shaka.log'); goog.require('shaka.net.NetworkingEngine'); goog.require('shaka.util.ConfigUtils'); @@ -256,6 +257,8 @@ shaka.util.PlayerConfiguration = class { useHeaders: false, }; + const AutoShowText = shaka.config.AutoShowText; + /** @type {shaka.extern.PlayerConfiguration} */ const config = { drm: drm, @@ -264,6 +267,7 @@ shaka.util.PlayerConfiguration = class { offline: offline, abrFactory: () => new shaka.abr.SimpleAbrManager(), abr: abr, + autoShowText: AutoShowText.IF_SUBTITLES_MAY_BE_NEEDED, preferredAudioLanguage: '', preferredTextLanguage: '', preferredVariantRole: '', diff --git a/test/player_integration.js b/test/player_integration.js index 2140f6d21e..4093b2f906 100644 --- a/test/player_integration.js +++ b/test/player_integration.js @@ -205,88 +205,6 @@ describe('Player', () => { } }); - 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).toBe(preferredTextLanguage); - - const variantTrack = player.getVariantTracks()[0]; - expect(variantTrack.language).not.toBe(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.toBe(preferredTextLanguage); - - const variantTrack = player.getVariantTracks()[0]; - expect(variantTrack.language).not.toBe(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).toBe(preferredTextLanguage); - - const variantTrack = player.getVariantTracks()[0]; - expect(variantTrack.language).toBe(textTrack.language); - }); - // Repro for https://github.com/shaka-project/shaka-player/issues/1879. it('appends cues when enabled initially', async () => { let cues = []; @@ -376,6 +294,160 @@ describe('Player', () => { }); }); // describe('setTextTrackVisibility') + describe('autoShowText', () => { + async function textMatchesAudioDoesNot() { + const preferredTextLanguage = 'fa'; // The same as in the content + player.configure({preferredTextLanguage: preferredTextLanguage}); + + // NOTE: This is also a regression test for #1696, in which a change to + // this feature broke StreamingEngine initialization. + + // 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 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).toBe(preferredTextLanguage); + + const variantTrack = player.getVariantTracks()[0]; + expect(variantTrack.language).not.toBe(textTrack.language); + } + + async function textDoesNotMatch() { + const preferredTextLanguage = 'xx'; // Differs from the content + player.configure({preferredTextLanguage: preferredTextLanguage}); + + // Now load the content and wait for completion. + await player.load('test:sintel_realistic_compiled'); + + // 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.toBe(preferredTextLanguage); + + const variantTrack = player.getVariantTracks()[0]; + expect(variantTrack.language).not.toBe(textTrack.language); + } + + async function textAndAudioMatch() { + const preferredTextLanguage = 'und'; // The same as in the content + player.configure({preferredTextLanguage: preferredTextLanguage}); + + // Now load the content and wait for completion. + await player.load('test:sintel_compiled'); + + // 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).toBe(preferredTextLanguage); + + const variantTrack = player.getVariantTracks()[0]; + expect(variantTrack.language).toBe(textTrack.language); + } + + describe('IF_SUBTITLES_MAY_BE_NEEDED', () => { + beforeEach(() => { + player.configure( + 'autoShowText', + shaka.config.AutoShowText.IF_SUBTITLES_MAY_BE_NEEDED); + }); + + it('enables text if text matches and audio does not', async () => { + await textMatchesAudioDoesNot(); + expect(player.isTextTrackVisible()).toBe(true); + }); + + it('disables text if text does not match', async () => { + await textDoesNotMatch(); + expect(player.isTextTrackVisible()).toBe(false); + }); + + it('disables text if both text and audio match', async () => { + await textAndAudioMatch(); + expect(player.isTextTrackVisible()).toBe(false); + }); + }); // IF_SUBTITLES_MAY_BE_NEEDED + + describe('IF_PREFERRED_TEXT_LANGUAGE', () => { + beforeEach(() => { + player.configure( + 'autoShowText', + shaka.config.AutoShowText.IF_PREFERRED_TEXT_LANGUAGE); + }); + + it('enables text if text matches and audio does not', async () => { + await textMatchesAudioDoesNot(); + expect(player.isTextTrackVisible()).toBe(true); + }); + + it('disables text if text does not match', async () => { + await textDoesNotMatch(); + expect(player.isTextTrackVisible()).toBe(false); + }); + + it('enables text if both text and audio match', async () => { + await textAndAudioMatch(); + expect(player.isTextTrackVisible()).toBe(true); + }); + }); // IF_PREFERRED_TEXT_LANGUAGE + + describe('ALWAYS', () => { + beforeEach(() => { + player.configure('autoShowText', shaka.config.AutoShowText.ALWAYS); + }); + + it('enables text if text matches and audio does not', async () => { + await textMatchesAudioDoesNot(); + expect(player.isTextTrackVisible()).toBe(true); + }); + + it('enables text if text does not match', async () => { + await textDoesNotMatch(); + expect(player.isTextTrackVisible()).toBe(true); + }); + + it('enables text if both text and audio match', async () => { + await textAndAudioMatch(); + expect(player.isTextTrackVisible()).toBe(true); + }); + }); // ALWAYS + + describe('NEVER', () => { + beforeEach(() => { + player.configure('autoShowText', shaka.config.AutoShowText.NEVER); + }); + + it('disables text if text matches and audio does not', async () => { + await textMatchesAudioDoesNot(); + expect(player.isTextTrackVisible()).toBe(false); + }); + + it('disables text if text does not match', async () => { + await textDoesNotMatch(); + expect(player.isTextTrackVisible()).toBe(false); + }); + + it('disables text if both text and audio match', async () => { + await textAndAudioMatch(); + expect(player.isTextTrackVisible()).toBe(false); + }); + }); // NEVER + }); // AutoShowText + describe('plays', () => { it('with external text tracks', async () => { await player.load('test:sintel_no_text_compiled');