mirror of
https://github.com/shaka-project/shaka-player.git
synced 2026-06-25 17:45:03 +03:00
ad8b674784
Improve readability and separate responsibilities. Use Intl.DisplayNames when available and fallback to mozilla.LanguageMapping otherwise. Note: this is in preparation for addressing https://github.com/shaka-project/shaka-player/issues/9694 --------- Co-authored-by: Matthias Van Parijs <matvp91@gmail.com>
629 lines
17 KiB
JavaScript
629 lines
17 KiB
JavaScript
/*! @license
|
|
* Shaka Player
|
|
* Copyright 2016 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
/**
|
|
* @fileoverview
|
|
*/
|
|
|
|
goog.provide('shaka.text.NativeTextDisplayer');
|
|
|
|
goog.require('mozilla.LanguageMapping');
|
|
goog.require('shaka.device.DeviceFactory');
|
|
goog.require('shaka.device.IDevice');
|
|
goog.require('shaka.text.Utils');
|
|
goog.require('shaka.util.EventManager');
|
|
goog.require('shaka.util.FakeEvent');
|
|
goog.require('shaka.util.LanguageUtils');
|
|
goog.require('shaka.util.ManifestParserUtils');
|
|
goog.require('shaka.util.Timer');
|
|
goog.requireType('shaka.Player');
|
|
|
|
/**
|
|
* A text displayer plugin using the browser's native VTTCue interface.
|
|
*
|
|
* @implements {shaka.extern.TextDisplayer}
|
|
* @export
|
|
*/
|
|
shaka.text.NativeTextDisplayer = class {
|
|
/**
|
|
* @param {shaka.Player} player
|
|
*/
|
|
constructor(player) {
|
|
/** @private {?shaka.Player} */
|
|
this.player_ = player;
|
|
|
|
/** @private {shaka.util.EventManager} */
|
|
this.eventManager_ = new shaka.util.EventManager();
|
|
|
|
/** @private {shaka.util.EventManager} */
|
|
this.loadEventManager_ = new shaka.util.EventManager();
|
|
|
|
/** @private {?shaka.extern.TextDisplayerConfiguration} */
|
|
this.config_ = null;
|
|
|
|
/** @private {?HTMLMediaElement} */
|
|
this.video_ = null;
|
|
|
|
/** @private {Map<number, !HTMLTrackElement>} */
|
|
this.trackNodes_ = new Map();
|
|
|
|
/**
|
|
* ID of the currently active text track. -1 means no track is active.
|
|
* @private {number}
|
|
*/
|
|
this.trackId_ = -1;
|
|
|
|
/** @private {boolean} */
|
|
this.visible_ = false;
|
|
|
|
/**
|
|
* Timer used to debounce the textTracks 'change' event.
|
|
* @private {?shaka.util.Timer}
|
|
*/
|
|
this.timer_ = null;
|
|
|
|
this.eventManager_.listen(player,
|
|
shaka.util.FakeEvent.EventName.Loaded, () => this.checkMsePlayback_());
|
|
|
|
this.checkMsePlayback_();
|
|
}
|
|
|
|
/**
|
|
* @override
|
|
* @export
|
|
*/
|
|
configure(config) {
|
|
this.config_ = config;
|
|
}
|
|
|
|
/**
|
|
* Removes cues whose time range overlaps with [start, end).
|
|
* Returns false only if this instance has already been destroyed.
|
|
*
|
|
* @override
|
|
* @export
|
|
*/
|
|
remove(start, end) {
|
|
if (!this.player_) {
|
|
return false;
|
|
}
|
|
|
|
const activeTrack = this.getActiveTrack_();
|
|
if (activeTrack) {
|
|
shaka.text.Utils.removeCuesFromTextTrack(
|
|
activeTrack, (cue) => cue.startTime < end && cue.endTime > start);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Appends cues to the active track, applying the subtitle delay if set.
|
|
*
|
|
* @override
|
|
* @export
|
|
*/
|
|
append(cues) {
|
|
const activeTrack = this.getActiveTrack_();
|
|
if (!activeTrack) {
|
|
return;
|
|
}
|
|
|
|
const delay = this.config_?.subtitleDelay ?? 0;
|
|
const adjustedCues = delay !== 0 ?
|
|
cues.map((cue) => {
|
|
const shifted = cue.clone();
|
|
shifted.startTime = Math.max(0, shifted.startTime + delay);
|
|
shifted.endTime = Math.max(0, shifted.endTime + delay);
|
|
return shifted;
|
|
}) :
|
|
cues;
|
|
|
|
shaka.text.Utils.appendCuesToTextTrack(activeTrack, adjustedCues);
|
|
}
|
|
|
|
/**
|
|
* @override
|
|
* @export
|
|
*/
|
|
destroy() {
|
|
if (this.player_) {
|
|
if (this.video_) {
|
|
this.onUnloading_();
|
|
}
|
|
this.player_ = null;
|
|
}
|
|
|
|
this.timer_?.stop();
|
|
this.timer_ = null;
|
|
this.eventManager_?.release();
|
|
this.eventManager_ = null;
|
|
this.loadEventManager_?.release();
|
|
this.loadEventManager_ = null;
|
|
|
|
return Promise.resolve();
|
|
}
|
|
|
|
/**
|
|
* @override
|
|
* @export
|
|
*/
|
|
isTextVisible() {
|
|
return this.visible_;
|
|
}
|
|
|
|
/**
|
|
* Shows or hides subtitles. Handles both MSE and SRC_EQUALS playback modes.
|
|
*
|
|
* @override
|
|
* @export
|
|
*/
|
|
setTextVisibility(on) {
|
|
this.visible_ = on;
|
|
|
|
const activeTrack = this.getActiveTrack_();
|
|
if (activeTrack) {
|
|
this.applyVisibilityToTrack_(activeTrack, on);
|
|
return;
|
|
}
|
|
|
|
if (this.isSrcEqualsMode_()) {
|
|
this.applyVisibilityToSrcEqualsTracks_(on);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @override
|
|
* @export
|
|
*/
|
|
setTextLanguage(_language) {
|
|
// unused
|
|
}
|
|
|
|
/**
|
|
* Cleans up internal state when the player starts unloading content.
|
|
* Registered with listenOnce, so it fires at most once per playback session.
|
|
*
|
|
* @private
|
|
*/
|
|
onUnloading_() {
|
|
this.timer_?.stop();
|
|
this.timer_ = null;
|
|
|
|
this.loadEventManager_?.removeAll();
|
|
|
|
for (const trackNode of this.trackNodes_.values()) {
|
|
trackNode.remove();
|
|
}
|
|
this.trackNodes_.clear();
|
|
this.trackId_ = -1;
|
|
this.video_ = null;
|
|
}
|
|
|
|
/**
|
|
* Synchronises the DOM <track> elements with the player's track list.
|
|
* Creates elements for new tracks, reuses existing ones, and removes
|
|
* any that are no longer present.
|
|
*
|
|
* @private
|
|
*/
|
|
onTextChanged_() {
|
|
/** @type {Map<number, !HTMLTrackElement>} */
|
|
const newTrackNodes = new Map();
|
|
const tracks = this.player_.getTextTracks();
|
|
|
|
for (const track of tracks) {
|
|
const trackNode = this.trackNodes_.has(track.id) ?
|
|
this.reuseTrackNode_(track) : this.createTrackNode_(track);
|
|
|
|
newTrackNodes.set(track.id, trackNode);
|
|
|
|
if (track.active) {
|
|
this.trackId_ = track.id;
|
|
}
|
|
}
|
|
|
|
// Remove from the DOM any tracks no longer in the player's list.
|
|
for (const trackNode of this.trackNodes_.values()) {
|
|
trackNode.remove();
|
|
}
|
|
|
|
this.trackNodes_ = newTrackNodes;
|
|
this.activateCurrentTrack_();
|
|
}
|
|
|
|
/**
|
|
* Handles manual changes to the video's textTracks (e.g. the user enables a
|
|
* track through the browser's native subtitle menu). Applies debounce because
|
|
* the 'change' event can fire multiple times in quick succession.
|
|
*
|
|
* @private
|
|
*/
|
|
onChange_() {
|
|
if (this.timer_) {
|
|
// A tick is already queued; the debounce absorbs additional events.
|
|
return;
|
|
}
|
|
|
|
// Snapshot the current video reference so we can detect if it changes
|
|
// while the timer is pending (e.g. an unload happens in the meantime).
|
|
const videoSnapshot = this.video_;
|
|
|
|
this.timer_ = new shaka.util.Timer(() => {
|
|
this.timer_ = null;
|
|
|
|
if (this.video_ !== videoSnapshot) {
|
|
return;
|
|
}
|
|
|
|
const resolvedTrackId = this.resolveActiveTrackId_();
|
|
this.disableAllTracksExcept_(resolvedTrackId);
|
|
|
|
if (this.trackId_ !== resolvedTrackId) {
|
|
this.trackId_ = resolvedTrackId;
|
|
this.syncTrackSelectionWithPlayer_(resolvedTrackId);
|
|
}
|
|
}).tickAfter(0);
|
|
}
|
|
|
|
/**
|
|
* Returns the active TextTrack, or null if none is currently active.
|
|
*
|
|
* @return {?TextTrack}
|
|
* @private
|
|
*/
|
|
getActiveTrack_() {
|
|
return this.trackNodes_.has(this.trackId_) ?
|
|
this.trackNodes_.get(this.trackId_).track : null;
|
|
}
|
|
|
|
/**
|
|
* Applies the visibility mode to a specific track without touching tracks
|
|
* that are already 'disabled' (e.g. manually turned off by the user).
|
|
*
|
|
* @param {TextTrack} track
|
|
* @param {boolean} visible
|
|
* @private
|
|
*/
|
|
applyVisibilityToTrack_(track, visible) {
|
|
if (track.mode === 'disabled') {
|
|
return;
|
|
}
|
|
const targetMode = visible ? 'showing' : 'hidden';
|
|
if (track.mode !== targetMode) {
|
|
track.mode = targetMode;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns true if the player is currently in SRC_EQUALS mode.
|
|
*
|
|
* @return {boolean}
|
|
* @private
|
|
*/
|
|
isSrcEqualsMode_() {
|
|
if (!this.player_) {
|
|
return false;
|
|
}
|
|
const LoadMode = shaka.text.NativeTextDisplayer.LoadMode;
|
|
return this.player_.getLoadMode() === LoadMode.SRC_EQUALS;
|
|
}
|
|
|
|
/**
|
|
* Manages subtitle visibility in SRC_EQUALS mode, where tracks are controlled
|
|
* directly by the HTMLMediaElement rather than MSE.
|
|
*
|
|
* @param {boolean} on
|
|
* @private
|
|
*/
|
|
applyVisibilityToSrcEqualsTracks_(on) {
|
|
const textTracks = Array.from(this.player_.getMediaElement().textTracks)
|
|
.filter((track) =>
|
|
['captions', 'subtitles', 'forced'].includes(track.kind));
|
|
|
|
if (on) {
|
|
// If a track is already 'showing', do nothing to avoid disrupting state.
|
|
const alreadyShowing = textTracks.some((t) => t.mode === 'showing');
|
|
if (!alreadyShowing) {
|
|
const firstHidden = textTracks.find((t) => t.mode === 'hidden');
|
|
if (firstHidden) {
|
|
firstHidden.mode = 'showing';
|
|
}
|
|
}
|
|
} else {
|
|
for (const track of textTracks) {
|
|
if (track.mode === 'showing') {
|
|
track.mode = 'hidden';
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reuses an existing <track> DOM node for a known track.
|
|
* Disables the node if the track is no longer active, and removes the entry
|
|
* from the original map so that onTextChanged_ can detect orphaned nodes.
|
|
*
|
|
* @param {!shaka.extern.TextTrack} track
|
|
* @return {!HTMLTrackElement}
|
|
* @private
|
|
*/
|
|
reuseTrackNode_(track) {
|
|
const trackNode = this.trackNodes_.get(track.id);
|
|
if (!track.active && trackNode.track.mode !== 'disabled') {
|
|
trackNode.track.mode = 'disabled';
|
|
}
|
|
this.trackNodes_.delete(track.id);
|
|
return trackNode;
|
|
}
|
|
|
|
/**
|
|
* Creates a new <track> DOM element for the given track and appends it to
|
|
* the video element.
|
|
*
|
|
* @param {!shaka.extern.TextTrack} track
|
|
* @return {!HTMLTrackElement}
|
|
* @private
|
|
*/
|
|
createTrackNode_(track) {
|
|
const trackNode = /** @type {!HTMLTrackElement} */ (
|
|
this.video_.ownerDocument.createElement('track'));
|
|
|
|
trackNode.kind = this.getTrackKind_(track);
|
|
trackNode.label = this.getTrackLabel_(track);
|
|
trackNode.srclang = this.resolveTrackLanguage_(track);
|
|
|
|
// Chrome may refuse to list tracks without a src in its built-in caption
|
|
// menu. In Safari, toggling a track from 'disabled'/'hidden' back to
|
|
// 'showing' without a src causes a visible flash. The minimal WEBVTT data
|
|
// URL prevents both issues.
|
|
trackNode.src = 'data:,WEBVTT';
|
|
trackNode.track.mode = 'disabled';
|
|
|
|
this.video_.appendChild(trackNode);
|
|
return trackNode;
|
|
}
|
|
|
|
/**
|
|
* Resolves the appropriate srclang value for a track based on its declared
|
|
* language. Falls back to 'und' (undetermined) if the language is unknown.
|
|
*
|
|
* @param {!shaka.extern.TextTrack} track
|
|
* @return {string}
|
|
* @private
|
|
*/
|
|
resolveTrackLanguage_(track) {
|
|
if (!track.language) {
|
|
return 'und';
|
|
}
|
|
if (track.language in mozilla.LanguageMapping) {
|
|
return track.language;
|
|
}
|
|
return shaka.util.LanguageUtils.getBase(track.language) ?? 'und';
|
|
}
|
|
|
|
/**
|
|
* Activates the track identified by this.trackId_ among the newly built
|
|
* nodes, respecting the current mode if it was changed manually by the user.
|
|
*
|
|
* @private
|
|
*/
|
|
activateCurrentTrack_() {
|
|
if (this.trackId_ <= -1) {
|
|
return;
|
|
}
|
|
|
|
if (!this.trackNodes_.has(this.trackId_)) {
|
|
this.trackId_ = -1;
|
|
return;
|
|
}
|
|
|
|
const track = this.trackNodes_.get(this.trackId_).track;
|
|
// Only update the mode when the track is 'disabled'. If the user changed
|
|
// it manually (e.g. hid it), we respect that choice; onChange_ will update
|
|
// visible_ accordingly.
|
|
if (track.mode === 'disabled') {
|
|
track.mode = this.visible_ ? 'showing' : 'hidden';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Determines which track should be active after a 'change' event.
|
|
* Prefers the previously selected track; otherwise picks the first 'showing'
|
|
* track, and falls back to the first 'hidden' track.
|
|
*
|
|
* @return {number} The ID of the track to activate, or -1 if none.
|
|
* @private
|
|
*/
|
|
resolveActiveTrackId_() {
|
|
let trackId = -1;
|
|
|
|
// Prefer the previously active track.
|
|
if (this.trackNodes_.has(this.trackId_)) {
|
|
const mode = this.trackNodes_.get(this.trackId_).track.mode;
|
|
if (mode === 'showing') {
|
|
return this.trackId_;
|
|
}
|
|
if (mode === 'hidden') {
|
|
trackId = this.trackId_;
|
|
}
|
|
}
|
|
|
|
// Fallback: find any 'showing' track, or the first 'hidden' one.
|
|
for (const id of this.trackNodes_.keys()) {
|
|
const trackNode = /** @type {!HTMLTrackElement} */ (
|
|
this.trackNodes_.get(id));
|
|
if (trackNode.track.mode === 'showing') {
|
|
return id;
|
|
}
|
|
if (trackId < 0 && trackNode.track.mode === 'hidden') {
|
|
trackId = id;
|
|
}
|
|
}
|
|
|
|
return trackId;
|
|
}
|
|
|
|
/**
|
|
* Sets all tracks except the specified one to 'disabled', avoiding
|
|
* unnecessary change events on tracks that are already disabled.
|
|
*
|
|
* @param {number} keepTrackId
|
|
* @private
|
|
*/
|
|
disableAllTracksExcept_(keepTrackId) {
|
|
const keepNode = this.trackNodes_.get(keepTrackId);
|
|
for (const trackNode of this.trackNodes_.values()) {
|
|
if (trackNode !== keepNode && trackNode.track.mode !== 'disabled') {
|
|
trackNode.track.mode = 'disabled';
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Notifies the player of the newly selected track, or clears the selection
|
|
* if trackId is -1.
|
|
*
|
|
* @param {number} trackId
|
|
* @private
|
|
*/
|
|
syncTrackSelectionWithPlayer_(trackId) {
|
|
if (trackId > -1) {
|
|
const textTrack =
|
|
this.player_.getTextTracks().find((t) => t.id === trackId);
|
|
if (textTrack) {
|
|
this.player_.selectTextTrack(textTrack);
|
|
return;
|
|
}
|
|
}
|
|
this.player_.selectTextTrack(null);
|
|
}
|
|
|
|
/**
|
|
* Initialises MSE integration if the player is already in MEDIA_SOURCE mode.
|
|
* Called from the constructor and again on each 'Loaded' event.
|
|
*
|
|
* @private
|
|
*/
|
|
checkMsePlayback_() {
|
|
if (this.video_ || !this.player_) {
|
|
return;
|
|
}
|
|
|
|
const LoadMode = shaka.text.NativeTextDisplayer.LoadMode;
|
|
if (this.player_.getLoadMode() !== LoadMode.MEDIA_SOURCE) {
|
|
return;
|
|
}
|
|
|
|
this.video_ = this.player_.getMediaElement();
|
|
|
|
const EventName = shaka.util.FakeEvent.EventName;
|
|
|
|
this.eventManager_.listenOnce(this.player_,
|
|
EventName.Unloading, () => this.onUnloading_());
|
|
this.loadEventManager_.listen(this.player_,
|
|
EventName.TextChanged, () => this.onTextChanged_());
|
|
this.loadEventManager_.listen(this.video_.textTracks,
|
|
'change', () => this.onChange_());
|
|
|
|
this.onTextChanged_();
|
|
}
|
|
|
|
/**
|
|
* Returns the appropriate `kind` value for a <track> element.
|
|
* WebKit requires the 'forced' kind for forced tracks; other browsers use
|
|
* 'captions' for closed captions and 'subtitles' as the default.
|
|
*
|
|
* @param {!shaka.extern.TextTrack} track
|
|
* @return {string}
|
|
* @private
|
|
*/
|
|
getTrackKind_(track) {
|
|
const device = shaka.device.DeviceFactory.getDevice();
|
|
if (track.forced && device.getBrowserEngine() ===
|
|
shaka.device.IDevice.BrowserEngine.WEBKIT) {
|
|
return 'forced';
|
|
}
|
|
const ManifestParserUtils = shaka.util.ManifestParserUtils;
|
|
if (track.kind === ManifestParserUtils.TextStreamKind.CLOSED_CAPTION) {
|
|
return 'captions';
|
|
}
|
|
return 'subtitles';
|
|
}
|
|
|
|
/**
|
|
* Builds a human-readable label for a track. Priority order:
|
|
* 1. track.label (if explicitly set)
|
|
* 2. Intl.DisplayNames resolution (when available)
|
|
* 3. Full language name from LanguageMapping (exact match)
|
|
* 4. Base language name from LanguageMapping with variant in parentheses
|
|
* 5. originalTextId with the language code in parentheses if they differ
|
|
*
|
|
* @param {!shaka.extern.TextTrack} track
|
|
* @return {string}
|
|
* @private
|
|
*/
|
|
getTrackLabel_(track) {
|
|
if (track.label) {
|
|
return track.label;
|
|
}
|
|
|
|
if (track.language) {
|
|
const base = shaka.util.LanguageUtils.getBase(track.language);
|
|
|
|
// 1. Intl.DisplayNames — preferred when available: provides OS-level
|
|
// resolution for any valid BCP-47 tag in the user's UI locale without
|
|
// relying on a hand-maintained mapping.
|
|
if (window.Intl && 'DisplayNames' in Intl) {
|
|
try {
|
|
const displayNames = new Intl.DisplayNames(track.language,
|
|
{type: 'language', languageDisplay: 'standard'});
|
|
const displayName = displayNames.of(track.language);
|
|
// Only prefer it when it's reliable
|
|
if (displayName &&
|
|
displayName.toLowerCase() != track.language.toLowerCase()) {
|
|
return displayName.charAt(0).toUpperCase() + displayName.slice(1);
|
|
}
|
|
} catch (_e) {
|
|
// Intl.DisplayNames may throw for malformed tags; fall through.
|
|
}
|
|
}
|
|
|
|
// 2. Exact match in mozilla.LanguageMapping.
|
|
const exactMatch = mozilla.LanguageMapping[track.language];
|
|
if (exactMatch) {
|
|
return exactMatch;
|
|
}
|
|
|
|
// 3. Base-language match in mozilla.LanguageMapping, with the full tag
|
|
// shown in parentheses so the variant is still visible to the user.
|
|
const baseMatch = base && mozilla.LanguageMapping[base];
|
|
if (baseMatch) {
|
|
return base === track.language ?
|
|
baseMatch : `${baseMatch} (${track.language})`;
|
|
}
|
|
}
|
|
|
|
// Last resort: use originalTextId, coercing nullish values to an empty
|
|
// string.
|
|
const fallback = String(track.originalTextId ?? '');
|
|
if (track.language && track.language !== track.originalTextId) {
|
|
return `${fallback} (${track.language})`;
|
|
}
|
|
|
|
return fallback;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Named constants mirroring shaka.Player.LoadMode to avoid magic numbers.
|
|
* @enum {number}
|
|
*/
|
|
shaka.text.NativeTextDisplayer.LoadMode = {
|
|
MEDIA_SOURCE: 2,
|
|
SRC_EQUALS: 3,
|
|
};
|