Source: lib/text/native_text_displayer.js

  1. /*! @license
  2. * Shaka Player
  3. * Copyright 2016 Google LLC
  4. * SPDX-License-Identifier: Apache-2.0
  5. */
  6. /**
  7. * @fileoverview
  8. */
  9. goog.provide('shaka.text.NativeTextDisplayer');
  10. goog.require('mozilla.LanguageMapping');
  11. goog.require('shaka.device.DeviceFactory');
  12. goog.require('shaka.device.IDevice');
  13. goog.require('shaka.text.Utils');
  14. goog.require('shaka.util.EventManager');
  15. goog.require('shaka.util.FakeEvent');
  16. goog.require('shaka.util.LanguageUtils');
  17. goog.require('shaka.util.Timer');
  18. goog.requireType('shaka.Player');
  19. /**
  20. * A text displayer plugin using the browser's native VTTCue interface.
  21. *
  22. * @implements {shaka.extern.TextDisplayer}
  23. * @export
  24. */
  25. shaka.text.NativeTextDisplayer = class {
  26. /**
  27. * @param {shaka.Player} player
  28. */
  29. constructor(player) {
  30. /** @private {?shaka.Player} */
  31. this.player_ = player;
  32. /** @private {shaka.util.EventManager} */
  33. this.eventManager_ = new shaka.util.EventManager();
  34. /** @private {?HTMLMediaElement} */
  35. this.video_ = null;
  36. /** @private {Map<number, !HTMLTrackElement>} */
  37. this.trackNodes_ = new Map();
  38. /** @private {number} */
  39. this.trackId_ = -1;
  40. /** @private {boolean} */
  41. this.visible_ = false;
  42. /** @private {?shaka.util.Timer} */
  43. this.timer_ = null;
  44. /** @private */
  45. this.onUnloading_ = () => {
  46. this.eventManager_.unlisten(this.player_,
  47. shaka.util.FakeEvent.EventName.TextChanged, this.onTextChanged_);
  48. this.eventManager_.unlisten(this.video_.textTracks, 'change',
  49. this.onChange_);
  50. for (const trackNode of this.trackNodes_.values()) {
  51. trackNode.remove();
  52. }
  53. this.trackNodes_.clear();
  54. this.trackId_ = -1;
  55. this.video_ = null;
  56. };
  57. /** @private */
  58. this.onTextChanged_ = () => {
  59. /** @type {Map<number, !HTMLTrackElement>} */
  60. const newTrackNodes = new Map();
  61. const tracks = this.player_.getTextTracks();
  62. for (const track of tracks) {
  63. let trackNode;
  64. if (this.trackNodes_.has(track.id)) {
  65. trackNode = this.trackNodes_.get(track.id);
  66. if (!track.active && trackNode.track.mode !== 'disabled') {
  67. trackNode.track.mode = 'disabled';
  68. }
  69. this.trackNodes_.delete(track.id);
  70. } else {
  71. trackNode = /** @type {!HTMLTrackElement} */
  72. (this.video_.ownerDocument.createElement('track'));
  73. trackNode.kind = shaka.text.NativeTextDisplayer.getTrackKind_(track);
  74. trackNode.label =
  75. shaka.text.NativeTextDisplayer.getTrackLabel_(track);
  76. if (track.language in mozilla.LanguageMapping) {
  77. trackNode.srclang = track.language;
  78. }
  79. // The built-in captions menu in Chrome may refuse to list invalid
  80. // subtitles.
  81. // In Safari, when a TextTrack's mode is set to "disabled" or "hidden"
  82. // and then "showing" again causes a blink if there is no src.
  83. // The data URL is just to avoid this.
  84. trackNode.src = 'data:,WEBVTT';
  85. trackNode.track.mode = 'disabled';
  86. this.video_.appendChild(trackNode);
  87. }
  88. newTrackNodes.set(track.id, trackNode);
  89. if (track.active) {
  90. this.trackId_ = track.id;
  91. }
  92. }
  93. // Remove all tracks that are not in the new list.
  94. for (const trackNode of this.trackNodes_.values()) {
  95. trackNode.remove();
  96. }
  97. if (this.trackId_ > -1) {
  98. if (!newTrackNodes.has(this.trackId_)) {
  99. this.trackId_ = -1;
  100. } else {
  101. // enable current track after everything else is settled
  102. const track = newTrackNodes.get(this.trackId_).track;
  103. // Ignore if the mode is not disabled. Maybe the user has changed the
  104. // mode manually. In that case, visible_ will be updated in onChange_
  105. if (track.mode === 'disabled') {
  106. track.mode = this.visible_ ? 'showing' : 'hidden';
  107. }
  108. }
  109. }
  110. this.trackNodes_ = newTrackNodes;
  111. };
  112. /** @private */
  113. this.onChange_ = () => {
  114. // The change event may fire multiple times consecutively. So we need to
  115. // use a timer to ensure the real task runs only once.
  116. if (this.timer_) {
  117. return;
  118. }
  119. const video = this.video_;
  120. this.timer_ = new shaka.util.Timer(() => {
  121. this.timer_ = null;
  122. if (this.video_ !== video) {
  123. return;
  124. }
  125. let trackId = -1;
  126. let found = false;
  127. // Prefer previously selected track.
  128. if (this.trackNodes_.has(this.trackId_)) {
  129. const trackNode = this.trackNodes_.get(this.trackId_);
  130. if (trackNode.track.mode === 'showing') {
  131. trackId = this.trackId_;
  132. found = true;
  133. } else if (trackNode.track.mode === 'hidden') {
  134. trackId = this.trackId_;
  135. }
  136. }
  137. if (!found) {
  138. for (const [
  139. /** @type {number} */id,
  140. /** @type {HTMLTrackElement} */trackNode,
  141. ] of /** @type {!Map} */(this.trackNodes_)) {
  142. if (trackNode.track.mode === 'showing') {
  143. trackId = id;
  144. break;
  145. } else if (trackId < 0 && trackNode.track.mode === 'hidden') {
  146. // If there is no showing track, we can use the hidden track
  147. trackId = id;
  148. }
  149. }
  150. }
  151. for (const [
  152. /** @type {number} */id,
  153. /** @type {HTMLTrackElement} */trackNode,
  154. ] of /** @type {!Map} */(this.trackNodes_)) {
  155. // Avoid triggering unnecessary change events.
  156. if (id !== trackId && trackNode.track.mode !== 'disabled') {
  157. trackNode.track.mode = 'disabled';
  158. }
  159. }
  160. if (this.trackId_ !== trackId) {
  161. this.trackId_ = trackId;
  162. if (trackId > -1) {
  163. this.player_.selectTextTrack(
  164. /** @type {!shaka.extern.TextTrack} */({id: trackId}));
  165. }
  166. }
  167. // The selectTextTrack() method does not accept null as parameter.
  168. // So we need to use setTextTrackVisibility() if no track selected.
  169. this.player_.setTextTrackVisibility(trackId > -1 &&
  170. this.trackNodes_.get(trackId).track.mode === 'showing');
  171. }).tickAfter(0);
  172. };
  173. this.eventManager_.listen(player, shaka.util.FakeEvent.EventName.Loaded,
  174. () => this.enableTextDisplayer());
  175. this.enableTextDisplayer();
  176. }
  177. /**
  178. * @override
  179. * @export
  180. */
  181. configure(config) {
  182. // unused
  183. }
  184. /**
  185. * @override
  186. * @export
  187. */
  188. remove(start, end) {
  189. // Should return false only if this instance is destroyed
  190. if (!this.player_) {
  191. return false;
  192. } else if (this.trackNodes_.has(this.trackId_)) {
  193. shaka.text.Utils.removeCuesFromTextTrack(
  194. this.trackNodes_.get(this.trackId_).track,
  195. (cue) => cue.startTime < end && cue.endTime > start);
  196. }
  197. return true;
  198. }
  199. /**
  200. * @override
  201. * @export
  202. */
  203. append(cues) {
  204. if (this.trackNodes_.has(this.trackId_)) {
  205. shaka.text.Utils.appendCuesToTextTrack(
  206. this.trackNodes_.get(this.trackId_).track, cues);
  207. }
  208. }
  209. /**
  210. * @override
  211. * @export
  212. */
  213. destroy() {
  214. if (this.player_) {
  215. if (this.video_) {
  216. this.onUnloading_();
  217. }
  218. this.player_ = null;
  219. }
  220. if (this.eventManager_) {
  221. this.eventManager_.release();
  222. this.eventManager_ = null;
  223. }
  224. return Promise.resolve();
  225. }
  226. /**
  227. * @override
  228. * @export
  229. */
  230. isTextVisible() {
  231. return this.visible_;
  232. }
  233. /**
  234. * @override
  235. * @export
  236. */
  237. setTextVisibility(on) {
  238. this.visible_ = on;
  239. if (this.trackNodes_.has(this.trackId_)) {
  240. const textTrack = this.trackNodes_.get(this.trackId_).track;
  241. if (textTrack.mode !== 'disabled') {
  242. const mode = on ? 'showing' : 'hidden';
  243. if (textTrack.mode !== mode) {
  244. textTrack.mode = mode;
  245. }
  246. }
  247. } else if (this.player_ && this.player_.getLoadMode() === 3) {
  248. // shaka.Player.LoadMode.SRC_EQUALS
  249. const textTracks = Array.from(this.player_.getMediaElement().textTracks)
  250. .filter((track) =>
  251. ['captions', 'subtitles', 'forced'].includes(track.kind));
  252. if (on) {
  253. let toShow = null;
  254. for (const track of textTracks) {
  255. if (track.mode === 'showing') {
  256. // One showing track is just enough.
  257. toShow = null;
  258. break;
  259. } else if (!toShow && track.mode === 'hidden') {
  260. toShow = track;
  261. }
  262. }
  263. if (toShow) {
  264. toShow.mode = 'showing';
  265. }
  266. } else {
  267. for (const track of textTracks) {
  268. if (track.mode === 'showing') {
  269. track.mode = 'hidden';
  270. }
  271. }
  272. }
  273. }
  274. }
  275. /**
  276. * @override
  277. * @export
  278. */
  279. setTextLanguage(language) {
  280. // unused
  281. }
  282. /**
  283. * @override
  284. * @export
  285. */
  286. enableTextDisplayer() {
  287. // shaka.Player.LoadMode.MEDIA_SOURCE
  288. if (!this.video_ && this.player_ && this.player_.getLoadMode() === 2) {
  289. this.video_ = this.player_.getMediaElement();
  290. this.eventManager_.listenOnce(this.player_,
  291. shaka.util.FakeEvent.EventName.Unloading, this.onUnloading_);
  292. this.eventManager_.listen(this.player_,
  293. shaka.util.FakeEvent.EventName.TextChanged, this.onTextChanged_);
  294. this.eventManager_.listen(this.video_.textTracks, 'change',
  295. this.onChange_);
  296. this.onTextChanged_();
  297. }
  298. }
  299. /**
  300. * @param {!shaka.extern.TextTrack} track
  301. * @return {string}
  302. * @private
  303. */
  304. static getTrackKind_(track) {
  305. const device = shaka.device.DeviceFactory.getDevice();
  306. if (track.forced && device.getBrowserEngine() ===
  307. shaka.device.IDevice.BrowserEngine.WEBKIT) {
  308. return 'forced';
  309. } else if (
  310. track.kind === 'caption' || (
  311. track.roles &&
  312. track.roles.some(
  313. (role) => role.includes('transcribes-spoken-dialog')) &&
  314. track.roles.some(
  315. (role) => role.includes('describes-music-and-sound'))
  316. )
  317. ) {
  318. return 'captions';
  319. }
  320. return 'subtitles';
  321. }
  322. /**
  323. * @param {!shaka.extern.TextTrack} track
  324. * @return {string}
  325. * @private
  326. */
  327. static getTrackLabel_(track) {
  328. /** @type {string} */
  329. let label;
  330. if (track.label) {
  331. label = track.label;
  332. } else if (track.language) {
  333. if (track.language in mozilla.LanguageMapping) {
  334. label = mozilla.LanguageMapping[track.language];
  335. } else {
  336. const language = shaka.util.LanguageUtils.getBase(track.language);
  337. if (language in mozilla.LanguageMapping) {
  338. label =
  339. `${mozilla.LanguageMapping[language]} (${track.language})`;
  340. }
  341. }
  342. }
  343. if (!label) {
  344. label = /** @type {string} */(track.originalTextId);
  345. if (track.language && track.language !== track.originalTextId) {
  346. label += ` (${track.language})`;
  347. }
  348. }
  349. return label;
  350. }
  351. };