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