/*! @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.text.Utils');
goog.require('shaka.util.EventManager');
goog.require('shaka.util.FakeEvent');
goog.require('shaka.util.LanguageUtils');
goog.require('shaka.util.Platform');
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 {?HTMLMediaElement} */
this.video_ = null;
/** @private {Map<number, !HTMLTrackElement>} */
this.trackNodes_ = new Map();
/** @private {number} */
this.trackId_ = -1;
/** @private {boolean} */
this.visible_ = false;
/** @private {?shaka.util.Timer} */
this.timer_ = null;
/** @private */
this.onUnloading_ = () => {
this.eventManager_.unlisten(this.player_,
shaka.util.FakeEvent.EventName.TextChanged, this.onTextChanged_);
this.eventManager_.unlisten(this.video_.textTracks, 'change',
this.onChange_);
for (const trackNode of this.trackNodes_.values()) {
trackNode.remove();
}
this.trackNodes_.clear();
this.trackId_ = -1;
this.video_ = null;
};
/** @private */
this.onTextChanged_ = () => {
/** @type {Map<number, !HTMLTrackElement>} */
const newTrackNodes = new Map();
const tracks = this.player_.getTextTracks();
for (const track of tracks) {
let trackNode;
if (this.trackNodes_.has(track.id)) {
trackNode = this.trackNodes_.get(track.id);
if (!track.active && trackNode.track.mode !== 'disabled') {
trackNode.track.mode = 'disabled';
}
this.trackNodes_.delete(track.id);
} else {
trackNode = /** @type {!HTMLTrackElement} */
(this.video_.ownerDocument.createElement('track'));
trackNode.kind = shaka.text.NativeTextDisplayer.getTrackKind_(track);
trackNode.label =
shaka.text.NativeTextDisplayer.getTrackLabel_(track);
if (track.language in mozilla.LanguageMapping) {
trackNode.srclang = track.language;
}
if (shaka.util.Platform.isChrome()) {
// The built-in captions menu in Chrome may refuse to list invalid
// subtitles. The data URL is just to avoid this.
trackNode.src = 'data:,WEBVTT';
}
trackNode.track.mode = 'disabled';
this.video_.appendChild(trackNode);
}
newTrackNodes.set(track.id, trackNode);
if (track.active) {
this.trackId_ = track.id;
}
}
// Remove all tracks that are not in the new list.
for (const trackNode of this.trackNodes_.values()) {
trackNode.remove();
}
if (this.trackId_ > -1) {
if (!newTrackNodes.has(this.trackId_)) {
this.trackId_ = -1;
} else {
// enable current track after everything else is settled
const track = newTrackNodes.get(this.trackId_).track;
// Ignore if the mode is not disabled. Maybe the user has changed the
// mode manually. In that case, visible_ will be updated in onChange_
if (track.mode === 'disabled') {
track.mode = this.visible_ ? 'showing' : 'hidden';
}
}
}
this.trackNodes_ = newTrackNodes;
};
/** @private */
this.onChange_ = () => {
// The change event may fire multiple times consecutively. So we need to
// use a timer to ensure the real task runs only once.
if (this.timer_) {
return;
}
const video = this.video_;
this.timer_ = new shaka.util.Timer(() => {
this.timer_ = null;
if (this.video_ !== video) {
return;
}
let trackId = -1;
let found = false;
// Prefer previously selected track.
if (this.trackId_ > -1) {
const trackNode = this.trackNodes_.get(this.trackId_);
if (trackNode.track.mode === 'showing') {
trackId = this.trackId_;
found = true;
} else if (trackNode.track.mode === 'hidden') {
trackId = this.trackId_;
}
}
if (!found) {
for (const [
/** @type {number} */id,
/** @type {HTMLTrackElement} */trackNode,
] of /** @type {!Map} */(this.trackNodes_)) {
if (trackNode.track.mode === 'showing') {
trackId = id;
break;
} else if (trackId < 0 && trackNode.track.mode === 'hidden') {
// If there is no showing track, we can use the hidden track
trackId = id;
}
}
}
for (const [
/** @type {number} */id,
/** @type {HTMLTrackElement} */trackNode,
] of /** @type {!Map} */(this.trackNodes_)) {
// Avoid triggering unnecessary change events.
if (id !== trackId && trackNode.track.mode !== 'disabled') {
trackNode.track.mode = 'disabled';
}
}
if (this.trackId_ !== trackId) {
this.trackId_ = trackId;
if (trackId > -1) {
this.player_.selectTextTrack(
/** @type {!shaka.extern.TextTrack} */({id: trackId}));
}
}
// The selectTextTrack() method does not accept null as parameter.
// So we need to use setTextTrackVisibility() if no track selected.
this.player_.setTextTrackVisibility(trackId > -1 &&
this.trackNodes_.get(trackId).track.mode === 'showing');
}).tickAfter(0);
};
this.eventManager_.listen(player, shaka.util.FakeEvent.EventName.Loaded,
() => this.enableTextDisplayer());
}
/**
* @override
* @export
*/
configure(config) {
// unused
}
/**
* @override
* @export
*/
remove(start, end) {
if (!this.video_ || this.trackId_ < 0) {
return false;
}
shaka.text.Utils.removeCuesFromTextTrack(
this.trackNodes_.get(this.trackId_).track,
(cue) => cue.startTime < end && cue.endTime > start);
return true;
}
/**
* @override
* @export
*/
append(cues) {
if (!this.video_ || this.trackId_ < 0) {
return;
}
shaka.text.Utils.appendCuesToTextTrack(
this.trackNodes_.get(this.trackId_).track, cues);
}
/**
* @override
* @export
*/
destroy() {
if (this.player_) {
if (this.video_) {
this.onUnloading_();
}
this.player_ = null;
}
if (this.eventManager_) {
this.eventManager_.release();
this.eventManager_ = null;
}
return Promise.resolve();
}
/**
* @override
* @export
*/
isTextVisible() {
return this.visible_;
}
/**
* @override
* @export
*/
setTextVisibility(on) {
this.visible_ = on;
if (this.trackId_ > -1) {
const textTrack = this.trackNodes_.get(this.trackId_).track;
if (textTrack.mode !== 'disabled') {
const mode = on ? 'showing' : 'hidden';
if (textTrack.mode !== mode) {
textTrack.mode = mode;
}
}
} else if (this.player_ && this.player_.getLoadMode() === 3) {
// shaka.Player.LoadMode.SRC_EQUALS
const textTracks = Array.from(this.player_.getMediaElement().textTracks)
.filter((track) =>
['captions', 'subtitles', 'forced'].includes(track.kind));
if (on) {
let toShow = null;
for (const track of textTracks) {
if (track.mode === 'showing') {
// One showing track is just enough.
toShow = null;
break;
} else if (!toShow && track.mode === 'hidden') {
toShow = track;
}
}
if (toShow) {
toShow.mode = 'showing';
}
} else {
for (const track of textTracks) {
if (track.mode === 'showing') {
track.mode = 'hidden';
}
}
}
}
}
/**
* @override
* @export
*/
setTextLanguage(language) {
// unused
}
/**
* @override
* @export
*/
enableTextDisplayer() {
// shaka.Player.LoadMode.MEDIA_SOURCE
if (!this.video_ && this.player_ && this.player_.getLoadMode() === 2) {
this.video_ = this.player_.getMediaElement();
this.eventManager_.listenOnce(this.player_,
shaka.util.FakeEvent.EventName.Unloading, this.onUnloading_);
this.eventManager_.listen(this.player_,
shaka.util.FakeEvent.EventName.TextChanged, this.onTextChanged_);
this.eventManager_.listen(this.video_.textTracks, 'change',
this.onChange_);
this.onTextChanged_();
}
}
/**
* @param {!shaka.extern.TextTrack} track
* @return {string}
* @private
*/
static getTrackKind_(track) {
if (track.forced && shaka.util.Platform.isApple()) {
return 'forced';
} else if (
track.kind === 'caption' || (
track.roles &&
track.roles.some(
(role) => role.includes('transcribes-spoken-dialog')) &&
track.roles.some(
(role) => role.includes('describes-music-and-sound'))
)
) {
return 'captions';
}
return 'subtitles';
}
/**
* @param {!shaka.extern.TextTrack} track
* @return {string}
* @private
*/
static getTrackLabel_(track) {
/** @type {string} */
let label;
if (track.label) {
label = track.label;
} else if (track.language) {
if (track.language in mozilla.LanguageMapping) {
label = mozilla.LanguageMapping[track.language];
} else {
const language = shaka.util.LanguageUtils.getBase(track.language);
if (language in mozilla.LanguageMapping) {
label =
`${mozilla.LanguageMapping[language]} (${track.language})`;
}
}
}
if (!label) {
label = /** @type {string} */(track.originalTextId);
if (track.language && track.language !== track.originalTextId) {
label += ` (${track.language})`;
}
}
return label;
}
};