/*! @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,
};