mirror of
https://github.com/shaka-project/shaka-player.git
synced 2026-06-14 15:56:38 +03:00
544 lines
20 KiB
JavaScript
544 lines
20 KiB
JavaScript
/*! @license
|
|
* Shaka Player
|
|
* Copyright 2016 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
// cspell:words Österreich ﺎﻠﻋﺮﺒﻳﺓ
|
|
|
|
goog.provide('shaka.ui.LanguageUtils');
|
|
|
|
goog.require('mozilla.LanguageMapping');
|
|
goog.require('shaka.log');
|
|
goog.require('shaka.media.ManifestParser');
|
|
goog.require('shaka.ui.Locales');
|
|
goog.require('shaka.ui.Overlay.TrackLabelFormat');
|
|
goog.require('shaka.ui.Utils');
|
|
goog.require('shaka.util.Dom');
|
|
goog.require('shaka.util.LanguageUtils');
|
|
goog.require('shaka.util.MimeUtils');
|
|
goog.requireType('shaka.ui.Localization');
|
|
|
|
|
|
shaka.ui.LanguageUtils = class {
|
|
/**
|
|
* @param {!Array<shaka.extern.AudioTrack>} tracks
|
|
* @return {boolean}
|
|
* @private
|
|
*/
|
|
static areAudioTracksEqualExceptLabel_(tracks) {
|
|
const basicTrack = (track) => {
|
|
return {
|
|
codecs: track.codecs,
|
|
channelCount: track.channelCount,
|
|
language: track.language,
|
|
roles: track.roles,
|
|
spatialAudio: track.spatialAudio,
|
|
};
|
|
};
|
|
const reference = basicTrack(tracks[0]);
|
|
return tracks.every((track) => {
|
|
return JSON.stringify(basicTrack(track)) === JSON.stringify(reference);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @param {!Array<shaka.extern.AudioTrack>} tracks
|
|
* @param {!HTMLElement} langMenu
|
|
* @param {function(!shaka.extern.AudioTrack)} onTrackSelected
|
|
* @param {boolean} updateChosen
|
|
* @param {!HTMLElement} currentSelectionElement
|
|
* @param {shaka.ui.Localization} localization
|
|
* @param {!shaka.extern.UIConfiguration} config
|
|
*/
|
|
static updateAudioTracks(tracks, langMenu, onTrackSelected, updateChosen,
|
|
currentSelectionElement, localization, config) {
|
|
const AccessibilityPurpose =
|
|
shaka.media.ManifestParser.AccessibilityPurpose;
|
|
const LocIds = shaka.ui.Locales.Ids;
|
|
const TrackLabelFormat = shaka.ui.Overlay.TrackLabelFormat;
|
|
|
|
let trackLabelFormat = config.trackLabelFormat;
|
|
const showAudioChannelCountVariants = config.showAudioChannelCountVariants;
|
|
const showAudioCodec = config.showAudioCodec;
|
|
const preferIntlDisplayNames = config.preferIntlDisplayNames;
|
|
|
|
// TODO: Do the benefits of having this common code in a method still
|
|
// outweigh the complexity of the parameter list?
|
|
const selectedTrack = tracks.find((track) => {
|
|
return track.active == true;
|
|
});
|
|
|
|
if (tracks.length > 1 && tracks[0].label &&
|
|
trackLabelFormat != TrackLabelFormat.LABEL &&
|
|
shaka.ui.LanguageUtils.areAudioTracksEqualExceptLabel_(tracks)) {
|
|
trackLabelFormat = TrackLabelFormat.LABEL;
|
|
}
|
|
|
|
/** @type {!Map<string, !Set<string>>} */
|
|
const codecsByLanguage = new Map();
|
|
for (const track of tracks) {
|
|
if (!track.codecs) {
|
|
continue;
|
|
}
|
|
codecsByLanguage.getOrInsertComputed(track.language, () => new Set()).add(
|
|
shaka.util.MimeUtils.getNormalizedCodec(track.codecs));
|
|
}
|
|
const hasDifferentAudioCodecs = (language) =>
|
|
codecsByLanguage.has(language) && codecsByLanguage.get(language).size > 1;
|
|
|
|
// Remove old tracks
|
|
// 1. Save the back to menu button
|
|
const backButton = shaka.ui.Utils.getFirstDescendantWithClassName(
|
|
langMenu, 'shaka-back-to-overflow-button');
|
|
|
|
// 2. Remove everything
|
|
shaka.util.Dom.removeAllChildren(langMenu);
|
|
|
|
// 3. Add the backTo Menu button back
|
|
langMenu.appendChild(backButton);
|
|
|
|
// 4. Figure out which languages have multiple roles.
|
|
const getRolesString = (track) => {
|
|
return track.roles.join(', ');
|
|
};
|
|
|
|
const getCombination = (language, rolesString, accessibilityPurpose, label,
|
|
channelsCount, audioCodec, spatialAudio) => {
|
|
const keys = [
|
|
language,
|
|
rolesString,
|
|
accessibilityPurpose,
|
|
spatialAudio,
|
|
];
|
|
if (showAudioChannelCountVariants && channelsCount != null) {
|
|
keys.push(channelsCount);
|
|
}
|
|
if (showAudioCodec && hasDifferentAudioCodecs(language) && audioCodec) {
|
|
keys.push(audioCodec);
|
|
}
|
|
if (label && trackLabelFormat == TrackLabelFormat.LABEL) {
|
|
keys.push(label);
|
|
}
|
|
return keys.join(': ');
|
|
};
|
|
|
|
const getChannelsCountName = (channelsCount) => {
|
|
let name = '';
|
|
if (channelsCount >= 5) {
|
|
name = ' ' + localization.resolve(LocIds.SURROUND);
|
|
}
|
|
return name;
|
|
};
|
|
|
|
const getAudioCodecName = (audioCodec) => {
|
|
let name = '';
|
|
if (audioCodec == 'aac') {
|
|
name = 'AAC';
|
|
} else if (audioCodec === 'ac-3') {
|
|
name = 'Dolby';
|
|
} else if (audioCodec === 'ec-3') {
|
|
name = 'DD+';
|
|
} else if (audioCodec === 'ac-4') {
|
|
name = 'Dolby AC-4';
|
|
} else if (audioCodec === 'opus') {
|
|
name = 'Opus';
|
|
} else if (audioCodec === 'flac') {
|
|
name = 'fLaC';
|
|
}
|
|
return name ? ' ' + name : name;
|
|
};
|
|
|
|
// 5. Add new buttons
|
|
/** @type {!Set<string>} */
|
|
const combinationsMade = new Set();
|
|
const selectedCombination = selectedTrack ? getCombination(
|
|
selectedTrack.language, getRolesString(selectedTrack),
|
|
selectedTrack.accessibilityPurpose,
|
|
selectedTrack.label, selectedTrack.channelsCount,
|
|
selectedTrack.codecs &&
|
|
shaka.util.MimeUtils.getNormalizedCodec(selectedTrack.codecs),
|
|
selectedTrack.spatialAudio) : '';
|
|
|
|
for (const track of tracks) {
|
|
const language = track.language;
|
|
const rolesString = getRolesString(track);
|
|
const label = track.label;
|
|
const channelsCount = track.channelsCount;
|
|
const accessibilityPurpose = track.accessibilityPurpose;
|
|
const audioCodec = track.codecs &&
|
|
shaka.util.MimeUtils.getNormalizedCodec(track.codecs);
|
|
const spatialAudio = track.spatialAudio;
|
|
const combinationName =
|
|
getCombination(language, rolesString, accessibilityPurpose, label,
|
|
channelsCount, audioCodec, spatialAudio);
|
|
if (combinationsMade.has(combinationName)) {
|
|
continue;
|
|
}
|
|
combinationsMade.add(combinationName);
|
|
|
|
const button = shaka.util.Dom.createButton();
|
|
button.addEventListener('click', () => {
|
|
onTrackSelected(track);
|
|
});
|
|
// ARIA: single-select menu item
|
|
button.setAttribute('role', 'menuitemradio');
|
|
button.setAttribute('aria-checked', 'false');
|
|
|
|
const span = shaka.util.Dom.createHTMLElement('span');
|
|
button.appendChild(span);
|
|
|
|
let defaultLabel = shaka.ui.LanguageUtils.getLanguageName(
|
|
language, localization, preferIntlDisplayNames);
|
|
if (config.customTrackLabel) {
|
|
const customLabel = config.customTrackLabel(
|
|
defaultLabel, track, 'audio');
|
|
if (customLabel) {
|
|
defaultLabel = customLabel;
|
|
}
|
|
}
|
|
if (!defaultLabel) {
|
|
defaultLabel = localization.resolve(
|
|
shaka.ui.Locales.Ids.UNRECOGNIZED_LANGUAGE) +
|
|
' (' + language + ')';
|
|
}
|
|
span.textContent = defaultLabel;
|
|
let basicInfo = '';
|
|
if (showAudioCodec && showAudioChannelCountVariants &&
|
|
spatialAudio && (audioCodec == 'ec-3' || audioCodec == 'ac-4')) {
|
|
basicInfo += ' Dolby Atmos';
|
|
} else {
|
|
if (showAudioCodec && hasDifferentAudioCodecs(language)) {
|
|
basicInfo += getAudioCodecName(audioCodec);
|
|
}
|
|
if (showAudioChannelCountVariants) {
|
|
basicInfo += getChannelsCountName(channelsCount);
|
|
}
|
|
}
|
|
let labelFormat = trackLabelFormat;
|
|
if (labelFormat === TrackLabelFormat.LABEL_OR_LANGUAGE) {
|
|
labelFormat = label ?
|
|
TrackLabelFormat.LABEL : TrackLabelFormat.LANGUAGE;
|
|
} else if (labelFormat === TrackLabelFormat.LANGUAGE_OR_LABEL) {
|
|
labelFormat = (language && language !== 'und') ?
|
|
TrackLabelFormat.LANGUAGE : TrackLabelFormat.LABEL;
|
|
}
|
|
switch (labelFormat) {
|
|
case TrackLabelFormat.LANGUAGE:
|
|
span.textContent += basicInfo;
|
|
if (accessibilityPurpose == AccessibilityPurpose.VISUALLY_IMPAIRED) {
|
|
span.textContent += ' - ' +
|
|
localization.resolve(shaka.ui.Locales.Ids.AUDIO_DESCRIPTION);
|
|
}
|
|
break;
|
|
case TrackLabelFormat.ROLE:
|
|
span.textContent += basicInfo;
|
|
if (!rolesString) {
|
|
// Fallback behavior. This probably shouldn't happen.
|
|
shaka.log.alwaysWarn('Track #' + JSON.stringify(track) +
|
|
' does not have a role, but the UI is configured to ' +
|
|
'only show role.');
|
|
span.textContent = '?';
|
|
} else {
|
|
span.textContent = rolesString;
|
|
}
|
|
break;
|
|
case TrackLabelFormat.LANGUAGE_ROLE:
|
|
span.textContent += basicInfo;
|
|
if (rolesString) {
|
|
span.textContent += ': ' + rolesString;
|
|
}
|
|
break;
|
|
case TrackLabelFormat.LABEL:
|
|
if (label) {
|
|
span.textContent = label + basicInfo;
|
|
} else {
|
|
// Fallback behavior. This probably shouldn't happen.
|
|
shaka.log.alwaysWarn('Track #' + JSON.stringify(track) +
|
|
' does not have a label, but the UI is configured to ' +
|
|
'only show labels.');
|
|
span.textContent = '?';
|
|
}
|
|
break;
|
|
}
|
|
|
|
if (updateChosen && (combinationName == selectedCombination)) {
|
|
button.appendChild(shaka.ui.Utils.checkmarkIcon());
|
|
span.classList.add('shaka-chosen-item');
|
|
button.setAttribute('aria-checked', 'true');
|
|
currentSelectionElement.textContent = span.textContent;
|
|
}
|
|
langMenu.appendChild(button);
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* @param {!Array<shaka.extern.TextTrack>} tracks
|
|
* @param {!HTMLElement} langMenu
|
|
* @param {function(!shaka.extern.TextTrack)} onTrackSelected
|
|
* @param {boolean} updateChosen
|
|
* @param {!HTMLElement} currentSelectionElement
|
|
* @param {shaka.ui.Localization} localization
|
|
* @param {!shaka.extern.UIConfiguration} config
|
|
*/
|
|
static updateTextTracks(tracks, langMenu, onTrackSelected, updateChosen,
|
|
currentSelectionElement, localization, config) {
|
|
const LocIds = shaka.ui.Locales.Ids;
|
|
const TrackLabelFormat = shaka.ui.Overlay.TrackLabelFormat;
|
|
|
|
const trackLabelFormat = config.textTrackLabelFormat;
|
|
const preferIntlDisplayNames = config.preferIntlDisplayNames;
|
|
|
|
// TODO: Do the benefits of having this common code in a method still
|
|
// outweigh the complexity of the parameter list?
|
|
const selectedTrack = tracks.find((track) => {
|
|
return track.active == true;
|
|
});
|
|
|
|
// Remove old tracks
|
|
// 1. Save the back to menu button
|
|
const backButton = shaka.ui.Utils.getFirstDescendantWithClassName(
|
|
langMenu, 'shaka-back-to-overflow-button');
|
|
|
|
// 2. Remove everything
|
|
shaka.util.Dom.removeAllChildren(langMenu);
|
|
|
|
// 3. Add the backTo Menu button back
|
|
langMenu.appendChild(backButton);
|
|
|
|
// 4. Figure out which languages have multiple roles.
|
|
const getRolesString = (track) => {
|
|
return track.roles.join(', ');
|
|
};
|
|
|
|
const getCombination = (language, rolesString, label, forced) => {
|
|
const keys = [
|
|
language,
|
|
rolesString,
|
|
forced,
|
|
];
|
|
if (label && trackLabelFormat == TrackLabelFormat.LABEL) {
|
|
keys.push(label);
|
|
}
|
|
return keys.join(': ');
|
|
};
|
|
|
|
// 5. Add new buttons
|
|
/** @type {!Set<string>} */
|
|
const combinationsMade = new Set();
|
|
const selectedCombination = selectedTrack ? getCombination(
|
|
selectedTrack.language, getRolesString(selectedTrack),
|
|
selectedTrack.label, selectedTrack.forced) : '';
|
|
|
|
for (const track of tracks) {
|
|
const language = track.language;
|
|
const forced = track.forced;
|
|
const forcedString = localization.resolve(LocIds.SUBTITLE_FORCED);
|
|
const rolesString = getRolesString(track);
|
|
const label = track.label;
|
|
const combinationName =
|
|
getCombination(language, rolesString, label, forced);
|
|
if (combinationsMade.has(combinationName)) {
|
|
continue;
|
|
}
|
|
combinationsMade.add(combinationName);
|
|
|
|
const button = shaka.util.Dom.createButton();
|
|
button.addEventListener('click', () => {
|
|
onTrackSelected(track);
|
|
});
|
|
// ARIA: single-select menu item
|
|
button.setAttribute('role', 'menuitemradio');
|
|
button.setAttribute('aria-checked', 'false');
|
|
|
|
const span = shaka.util.Dom.createHTMLElement('span');
|
|
button.appendChild(span);
|
|
|
|
let defaultLabel;
|
|
if (track.originalLanguage == 'speech-to-text') {
|
|
// Necessary when there are multiple speech-to-text tracks and they
|
|
// translate into different languages.
|
|
if (language) {
|
|
defaultLabel = [
|
|
shaka.ui.LanguageUtils.getLanguageName(
|
|
language, localization, preferIntlDisplayNames),
|
|
' (',
|
|
localization.resolve(shaka.ui.Locales.Ids.AUTO_GENERATED),
|
|
')',
|
|
].join('');
|
|
} else {
|
|
defaultLabel =
|
|
localization.resolve(shaka.ui.Locales.Ids.AUTO_GENERATED);
|
|
}
|
|
} else {
|
|
defaultLabel =
|
|
shaka.ui.LanguageUtils.getLanguageName(
|
|
language, localization, preferIntlDisplayNames);
|
|
}
|
|
if (config.customTrackLabel) {
|
|
const customLabel = config.customTrackLabel(
|
|
defaultLabel, track, 'text');
|
|
if (customLabel) {
|
|
defaultLabel = customLabel;
|
|
}
|
|
}
|
|
if (!defaultLabel) {
|
|
defaultLabel = localization.resolve(
|
|
shaka.ui.Locales.Ids.UNRECOGNIZED_LANGUAGE) +
|
|
' (' + language + ')';
|
|
}
|
|
span.textContent = defaultLabel;
|
|
let labelFormat = trackLabelFormat;
|
|
if (labelFormat === TrackLabelFormat.LABEL_OR_LANGUAGE) {
|
|
labelFormat = label ?
|
|
TrackLabelFormat.LABEL : TrackLabelFormat.LANGUAGE;
|
|
} else if (labelFormat === TrackLabelFormat.LANGUAGE_OR_LABEL) {
|
|
labelFormat = (language && language !== 'und') ?
|
|
TrackLabelFormat.LANGUAGE : TrackLabelFormat.LABEL;
|
|
}
|
|
switch (labelFormat) {
|
|
case TrackLabelFormat.LANGUAGE:
|
|
if (forced) {
|
|
span.textContent += ' (' + forcedString + ')';
|
|
}
|
|
break;
|
|
case TrackLabelFormat.ROLE:
|
|
if (!rolesString) {
|
|
// Fallback behavior. This probably shouldn't happen.
|
|
shaka.log.alwaysWarn('Track #' + track.id + ' does not have a ' +
|
|
'role, but the UI is configured to only show role.');
|
|
span.textContent = '?';
|
|
} else {
|
|
span.textContent = rolesString;
|
|
}
|
|
if (forced) {
|
|
span.textContent += ' (' + forcedString + ')';
|
|
}
|
|
break;
|
|
case TrackLabelFormat.LANGUAGE_ROLE:
|
|
if (rolesString) {
|
|
span.textContent += ': ' + rolesString;
|
|
}
|
|
if (forced) {
|
|
span.textContent += ' (' + forcedString + ')';
|
|
}
|
|
break;
|
|
case TrackLabelFormat.LABEL:
|
|
if (label) {
|
|
span.textContent = label;
|
|
} else {
|
|
// Fallback behavior. This probably shouldn't happen.
|
|
shaka.log.alwaysWarn('Track #' + track.id + ' does not have a ' +
|
|
'label, but the UI is configured to only show labels.');
|
|
span.textContent = '?';
|
|
}
|
|
break;
|
|
}
|
|
|
|
if (updateChosen && (combinationName == selectedCombination)) {
|
|
button.appendChild(shaka.ui.Utils.checkmarkIcon());
|
|
span.classList.add('shaka-chosen-item');
|
|
button.setAttribute('aria-checked', 'true');
|
|
currentSelectionElement.textContent = span.textContent;
|
|
}
|
|
langMenu.appendChild(button);
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* Returns the language's name for itself in its own script (autoglottonym),
|
|
* if we have it.
|
|
*
|
|
* If the locale, including region, can be mapped to a name, we return a very
|
|
* specific name including the region. For example, "de-AT" would map to
|
|
* "Deutsch (Österreich)" or Austrian German.
|
|
*
|
|
* If only the language part of the locale is in our map, we append the locale
|
|
* itself for specificity. For example, "ar-EG" (Egyptian Arabic) would map
|
|
* to "ﺎﻠﻋﺮﺒﻳﺓ (ar-EG)". In this way, multiple versions of Arabic whose
|
|
* regions are not in our map would not all look the same in the language
|
|
* list, but could be distinguished by their locale.
|
|
*
|
|
* Finally, if language part of the locale is not in our map, we label it
|
|
* "unknown", as translated to the UI locale, and we append the locale itself
|
|
* for specificity. For example, "sjn" would map to "Unknown (sjn)". In this
|
|
* way, multiple unrecognized languages would not all look the same in the
|
|
* language list, but could be distinguished by their locale.
|
|
*
|
|
* @param {string} locale
|
|
* @param {shaka.ui.Localization} localization
|
|
* @param {boolean} preferIntlDisplayNames
|
|
* @return {?string} The language's name for itself in its own script, or as
|
|
* close as we can get with the information we have. Returns null if the
|
|
* language is not recognized.
|
|
*/
|
|
static getLanguageName(locale, localization, preferIntlDisplayNames) {
|
|
if (!locale && !localization) {
|
|
return '';
|
|
}
|
|
|
|
// Shorthand for resolving a localization ID.
|
|
const resolve = (id) => localization.resolve(id);
|
|
|
|
// Handle some special cases first. These are reserved language tags that
|
|
// are used to indicate something that isn't one specific language.
|
|
// About qaa and qad:
|
|
// https://mailman.videolan.org/pipermail/vlc-devel/2007-February/029773.html
|
|
// https://www.etsi.org/deliver/etsi_en/300400_300499/300468/01.17.01_20/en_300468v011701a.pdf
|
|
// qaa: defined in DVB, ETSI EN 300 468 V1.17.1 (2022-07), Annex F.
|
|
// qad: defined in DVB, ETSI EN 300 468 V1.17.1 (2022-07), Annex J, J.3.2.
|
|
switch (locale) {
|
|
case 'mul':
|
|
return resolve(shaka.ui.Locales.Ids.MULTIPLE_LANGUAGES);
|
|
case 'qaa':
|
|
return resolve(shaka.ui.Locales.Ids.ORIGINAL_VERSION);
|
|
case 'qad':
|
|
return resolve(shaka.ui.Locales.Ids.AUDIO_DESCRIPTION);
|
|
case 'und':
|
|
return resolve(shaka.ui.Locales.Ids.UNDETERMINED_LANGUAGE);
|
|
case 'zxx':
|
|
return resolve(shaka.ui.Locales.Ids.NOT_APPLICABLE);
|
|
}
|
|
|
|
// Extract the base language from the locale as a fallback step.
|
|
const language = shaka.util.LanguageUtils.getBase(locale);
|
|
|
|
// If Intl.DisplayNames is supported we prefer it, because the list of
|
|
// languages is up to date.
|
|
if (preferIntlDisplayNames && window.Intl && 'DisplayNames' in Intl) {
|
|
try {
|
|
const locales = [...localization.getCurrentLocales()];
|
|
if (!locales.length) {
|
|
locales.push(locale);
|
|
}
|
|
const languageNames = new Intl.DisplayNames(locales,
|
|
{type: 'language', languageDisplay: 'standard'});
|
|
const languageName = languageNames.of(locale);
|
|
// Only prefer it when it's reliable
|
|
if (languageName &&
|
|
languageName.toLowerCase() != locale.toLowerCase()) {
|
|
return languageName.charAt(0).toUpperCase() + languageName.slice(1);
|
|
}
|
|
} catch (e) {
|
|
// Ignore errors and try the fallback
|
|
}
|
|
}
|
|
|
|
// First try to resolve the full language name.
|
|
// If that fails, try the base.
|
|
// Finally, report "unknown".
|
|
// When there is a loss of specificity (either to a base language or to
|
|
// "unknown"), we should append the original language code.
|
|
// Otherwise, there may be multiple identical-looking items in the list.
|
|
if (locale in mozilla.LanguageMapping) {
|
|
return mozilla.LanguageMapping[locale];
|
|
} else if (language in mozilla.LanguageMapping) {
|
|
return mozilla.LanguageMapping[language] + ' (' + locale + ')';
|
|
} else {
|
|
return null;
|
|
}
|
|
}
|
|
};
|