1. 1 : /**
  2. 2 : * @file text-track.js
  3. 3 : */
  4. 4 : import TextTrackCueList from './text-track-cue-list';
  5. 5 : import * as Fn from '../utils/fn.js';
  6. 6 : import {TextTrackKind, TextTrackMode} from './track-enums';
  7. 7 : import log from '../utils/log.js';
  8. 8 : import window from 'global/window';
  9. 9 : import Track from './track.js';
  10. 10 : import { isCrossOrigin } from '../utils/url.js';
  11. 11 : import XHR from '@videojs/xhr';
  12. 12 : import {merge} from '../utils/obj';
  13. 13 :
  14. 14 : /**
  15. 15 : * Takes a webvtt file contents and parses it into cues
  16. 16 : *
  17. 17 : * @param {string} srcContent
  18. 18 : * webVTT file contents
  19. 19 : *
  20. 20 : * @param {TextTrack} track
  21. 21 : * TextTrack to add cues to. Cues come from the srcContent.
  22. 22 : *
  23. 23 : * @private
  24. 24 : */
  25. 25 : const parseCues = function(srcContent, track) {
  26. 26 : const parser = new window.WebVTT.Parser(
  27. 27 : window,
  28. 28 : window.vttjs,
  29. 29 : window.WebVTT.StringDecoder()
  30. 30 : );
  31. 31 : const errors = [];
  32. 32 :
  33. 33 : parser.oncue = function(cue) {
  34. 34 : track.addCue(cue);
  35. 35 : };
  36. 36 :
  37. 37 : parser.onparsingerror = function(error) {
  38. 38 : errors.push(error);
  39. 39 : };
  40. 40 :
  41. 41 : parser.onflush = function() {
  42. 42 : track.trigger({
  43. 43 : type: 'loadeddata',
  44. 44 : target: track
  45. 45 : });
  46. 46 : };
  47. 47 :
  48. 48 : parser.parse(srcContent);
  49. 49 : if (errors.length > 0) {
  50. 50 : if (window.console && window.console.groupCollapsed) {
  51. 51 : window.console.groupCollapsed(`Text Track parsing errors for ${track.src}`);
  52. 52 : }
  53. 53 : errors.forEach((error) => log.error(error));
  54. 54 : if (window.console && window.console.groupEnd) {
  55. 55 : window.console.groupEnd();
  56. 56 : }
  57. 57 : }
  58. 58 :
  59. 59 : parser.flush();
  60. 60 : };
  61. 61 :
  62. 62 : /**
  63. 63 : * Load a `TextTrack` from a specified url.
  64. 64 : *
  65. 65 : * @param {string} src
  66. 66 : * Url to load track from.
  67. 67 : *
  68. 68 : * @param {TextTrack} track
  69. 69 : * Track to add cues to. Comes from the content at the end of `url`.
  70. 70 : *
  71. 71 : * @private
  72. 72 : */
  73. 73 : const loadTrack = function(src, track) {
  74. 74 : const opts = {
  75. 75 : uri: src
  76. 76 : };
  77. 77 : const crossOrigin = isCrossOrigin(src);
  78. 78 :
  79. 79 : if (crossOrigin) {
  80. 80 : opts.cors = crossOrigin;
  81. 81 : }
  82. 82 :
  83. 83 : const withCredentials = track.tech_.crossOrigin() === 'use-credentials';
  84. 84 :
  85. 85 : if (withCredentials) {
  86. 86 : opts.withCredentials = withCredentials;
  87. 87 : }
  88. 88 :
  89. 89 : XHR(opts, Fn.bind_(this, function(err, response, responseBody) {
  90. 90 : if (err) {
  91. 91 : return log.error(err, response);
  92. 92 : }
  93. 93 :
  94. 94 : track.loaded_ = true;
  95. 95 :
  96. 96 : // Make sure that vttjs has loaded, otherwise, wait till it finished loading
  97. 97 : // NOTE: this is only used for the alt/video.novtt.js build
  98. 98 : if (typeof window.WebVTT !== 'function') {
  99. 99 : if (track.tech_) {
  100. 100 : // to prevent use before define eslint error, we define loadHandler
  101. 101 : // as a let here
  102. 102 : track.tech_.any(['vttjsloaded', 'vttjserror'], (event) => {
  103. 103 : if (event.type === 'vttjserror') {
  104. 104 : log.error(`vttjs failed to load, stopping trying to process ${track.src}`);
  105. 105 : return;
  106. 106 : }
  107. 107 : return parseCues(responseBody, track);
  108. 108 : });
  109. 109 : }
  110. 110 : } else {
  111. 111 : parseCues(responseBody, track);
  112. 112 : }
  113. 113 :
  114. 114 : }));
  115. 115 : };
  116. 116 :
  117. 117 : /**
  118. 118 : * A representation of a single `TextTrack`.
  119. 119 : *
  120. 120 : * @see [Spec]{@link https://html.spec.whatwg.org/multipage/embedded-content.html#texttrack}
  121. 121 : * @extends Track
  122. 122 : */
  123. 123 : class TextTrack extends Track {
  124. 124 :
  125. 125 : /**
  126. 126 : * Create an instance of this class.
  127. 127 : *
  128. 128 : * @param {Object} options={}
  129. 129 : * Object of option names and values
  130. 130 : *
  131. 131 : * @param { import('../tech/tech').default } options.tech
  132. 132 : * A reference to the tech that owns this TextTrack.
  133. 133 : *
  134. 134 : * @param {TextTrack~Kind} [options.kind='subtitles']
  135. 135 : * A valid text track kind.
  136. 136 : *
  137. 137 : * @param {TextTrack~Mode} [options.mode='disabled']
  138. 138 : * A valid text track mode.
  139. 139 : *
  140. 140 : * @param {string} [options.id='vjs_track_' + Guid.newGUID()]
  141. 141 : * A unique id for this TextTrack.
  142. 142 : *
  143. 143 : * @param {string} [options.label='']
  144. 144 : * The menu label for this track.
  145. 145 : *
  146. 146 : * @param {string} [options.language='']
  147. 147 : * A valid two character language code.
  148. 148 : *
  149. 149 : * @param {string} [options.srclang='']
  150. 150 : * A valid two character language code. An alternative, but deprioritized
  151. 151 : * version of `options.language`
  152. 152 : *
  153. 153 : * @param {string} [options.src]
  154. 154 : * A url to TextTrack cues.
  155. 155 : *
  156. 156 : * @param {boolean} [options.default]
  157. 157 : * If this track should default to on or off.
  158. 158 : */
  159. 159 : constructor(options = {}) {
  160. 160 : if (!options.tech) {
  161. 161 : throw new Error('A tech was not provided.');
  162. 162 : }
  163. 163 :
  164. 164 : const settings = merge(options, {
  165. 165 : kind: TextTrackKind[options.kind] || 'subtitles',
  166. 166 : language: options.language || options.srclang || ''
  167. 167 : });
  168. 168 : let mode = TextTrackMode[settings.mode] || 'disabled';
  169. 169 : const default_ = settings.default;
  170. 170 :
  171. 171 : if (settings.kind === 'metadata' || settings.kind === 'chapters') {
  172. 172 : mode = 'hidden';
  173. 173 : }
  174. 174 : super(settings);
  175. 175 :
  176. 176 : this.tech_ = settings.tech;
  177. 177 :
  178. 178 : this.cues_ = [];
  179. 179 : this.activeCues_ = [];
  180. 180 :
  181. 181 : this.preload_ = this.tech_.preloadTextTracks !== false;
  182. 182 :
  183. 183 : const cues = new TextTrackCueList(this.cues_);
  184. 184 : const activeCues = new TextTrackCueList(this.activeCues_);
  185. 185 : let changed = false;
  186. 186 :
  187. 187 : this.timeupdateHandler = Fn.bind_(this, function(event = {}) {
  188. 188 : if (this.tech_.isDisposed()) {
  189. 189 : return;
  190. 190 : }
  191. 191 :
  192. 192 : if (!this.tech_.isReady_) {
  193. 193 : if (event.type !== 'timeupdate') {
  194. 194 : this.rvf_ = this.tech_.requestVideoFrameCallback(this.timeupdateHandler);
  195. 195 : }
  196. 196 :
  197. 197 : return;
  198. 198 : }
  199. 199 :
  200. 200 : // Accessing this.activeCues for the side-effects of updating itself
  201. 201 : // due to its nature as a getter function. Do not remove or cues will
  202. 202 : // stop updating!
  203. 203 : // Use the setter to prevent deletion from uglify (pure_getters rule)
  204. 204 : this.activeCues = this.activeCues;
  205. 205 : if (changed) {
  206. 206 : this.trigger('cuechange');
  207. 207 : changed = false;
  208. 208 : }
  209. 209 :
  210. 210 : if (event.type !== 'timeupdate') {
  211. 211 : this.rvf_ = this.tech_.requestVideoFrameCallback(this.timeupdateHandler);
  212. 212 : }
  213. 213 :
  214. 214 : });
  215. 215 :
  216. 216 : const disposeHandler = () => {
  217. 217 : this.stopTracking();
  218. 218 : };
  219. 219 :
  220. 220 : this.tech_.one('dispose', disposeHandler);
  221. 221 : if (mode !== 'disabled') {
  222. 222 : this.startTracking();
  223. 223 : }
  224. 224 :
  225. 225 : Object.defineProperties(this, {
  226. 226 : /**
  227. 227 : * @memberof TextTrack
  228. 228 : * @member {boolean} default
  229. 229 : * If this track was set to be on or off by default. Cannot be changed after
  230. 230 : * creation.
  231. 231 : * @instance
  232. 232 : *
  233. 233 : * @readonly
  234. 234 : */
  235. 235 : default: {
  236. 236 : get() {
  237. 237 : return default_;
  238. 238 : },
  239. 239 : set() {}
  240. 240 : },
  241. 241 :
  242. 242 : /**
  243. 243 : * @memberof TextTrack
  244. 244 : * @member {string} mode
  245. 245 : * Set the mode of this TextTrack to a valid {@link TextTrack~Mode}. Will
  246. 246 : * not be set if setting to an invalid mode.
  247. 247 : * @instance
  248. 248 : *
  249. 249 : * @fires TextTrack#modechange
  250. 250 : */
  251. 251 : mode: {
  252. 252 : get() {
  253. 253 : return mode;
  254. 254 : },
  255. 255 : set(newMode) {
  256. 256 : if (!TextTrackMode[newMode]) {
  257. 257 : return;
  258. 258 : }
  259. 259 : if (mode === newMode) {
  260. 260 : return;
  261. 261 : }
  262. 262 :
  263. 263 : mode = newMode;
  264. 264 : if (!this.preload_ && mode !== 'disabled' && this.cues.length === 0) {
  265. 265 : // On-demand load.
  266. 266 : loadTrack(this.src, this);
  267. 267 : }
  268. 268 : this.stopTracking();
  269. 269 :
  270. 270 : if (mode !== 'disabled') {
  271. 271 : this.startTracking();
  272. 272 : }
  273. 273 : /**
  274. 274 : * An event that fires when mode changes on this track. This allows
  275. 275 : * the TextTrackList that holds this track to act accordingly.
  276. 276 : *
  277. 277 : * > Note: This is not part of the spec!
  278. 278 : *
  279. 279 : * @event TextTrack#modechange
  280. 280 : * @type {Event}
  281. 281 : */
  282. 282 : this.trigger('modechange');
  283. 283 :
  284. 284 : }
  285. 285 : },
  286. 286 :
  287. 287 : /**
  288. 288 : * @memberof TextTrack
  289. 289 : * @member {TextTrackCueList} cues
  290. 290 : * The text track cue list for this TextTrack.
  291. 291 : * @instance
  292. 292 : */
  293. 293 : cues: {
  294. 294 : get() {
  295. 295 : if (!this.loaded_) {
  296. 296 : return null;
  297. 297 : }
  298. 298 :
  299. 299 : return cues;
  300. 300 : },
  301. 301 : set() {}
  302. 302 : },
  303. 303 :
  304. 304 : /**
  305. 305 : * @memberof TextTrack
  306. 306 : * @member {TextTrackCueList} activeCues
  307. 307 : * The list text track cues that are currently active for this TextTrack.
  308. 308 : * @instance
  309. 309 : */
  310. 310 : activeCues: {
  311. 311 : get() {
  312. 312 : if (!this.loaded_) {
  313. 313 : return null;
  314. 314 : }
  315. 315 :
  316. 316 : // nothing to do
  317. 317 : if (this.cues.length === 0) {
  318. 318 : return activeCues;
  319. 319 : }
  320. 320 :
  321. 321 : const ct = this.tech_.currentTime();
  322. 322 : const active = [];
  323. 323 :
  324. 324 : for (let i = 0, l = this.cues.length; i < l; i++) {
  325. 325 : const cue = this.cues[i];
  326. 326 :
  327. 327 : if (cue.startTime <= ct && cue.endTime >= ct) {
  328. 328 : active.push(cue);
  329. 329 : }
  330. 330 : }
  331. 331 :
  332. 332 : changed = false;
  333. 333 :
  334. 334 : if (active.length !== this.activeCues_.length) {
  335. 335 : changed = true;
  336. 336 : } else {
  337. 337 : for (let i = 0; i < active.length; i++) {
  338. 338 : if (this.activeCues_.indexOf(active[i]) === -1) {
  339. 339 : changed = true;
  340. 340 : }
  341. 341 : }
  342. 342 : }
  343. 343 :
  344. 344 : this.activeCues_ = active;
  345. 345 : activeCues.setCues_(this.activeCues_);
  346. 346 :
  347. 347 : return activeCues;
  348. 348 : },
  349. 349 :
  350. 350 : // /!\ Keep this setter empty (see the timeupdate handler above)
  351. 351 : set() {}
  352. 352 : }
  353. 353 : });
  354. 354 :
  355. 355 : if (settings.src) {
  356. 356 : this.src = settings.src;
  357. 357 : if (!this.preload_) {
  358. 358 : // Tracks will load on-demand.
  359. 359 : // Act like we're loaded for other purposes.
  360. 360 : this.loaded_ = true;
  361. 361 : }
  362. 362 : if (this.preload_ || (settings.kind !== 'subtitles' && settings.kind !== 'captions')) {
  363. 363 : loadTrack(this.src, this);
  364. 364 : }
  365. 365 : } else {
  366. 366 : this.loaded_ = true;
  367. 367 : }
  368. 368 : }
  369. 369 :
  370. 370 : startTracking() {
  371. 371 : // More precise cues based on requestVideoFrameCallback with a requestAnimationFram fallback
  372. 372 : this.rvf_ = this.tech_.requestVideoFrameCallback(this.timeupdateHandler);
  373. 373 : // Also listen to timeupdate in case rVFC/rAF stops (window in background, audio in video el)
  374. 374 : this.tech_.on('timeupdate', this.timeupdateHandler);
  375. 375 : }
  376. 376 :
  377. 377 : stopTracking() {
  378. 378 : if (this.rvf_) {
  379. 379 : this.tech_.cancelVideoFrameCallback(this.rvf_);
  380. 380 : this.rvf_ = undefined;
  381. 381 : }
  382. 382 : this.tech_.off('timeupdate', this.timeupdateHandler);
  383. 383 : }
  384. 384 :
  385. 385 : /**
  386. 386 : * Add a cue to the internal list of cues.
  387. 387 : *
  388. 388 : * @param {TextTrack~Cue} cue
  389. 389 : * The cue to add to our internal list
  390. 390 : */
  391. 391 : addCue(originalCue) {
  392. 392 : let cue = originalCue;
  393. 393 :
  394. 394 : if (window.vttjs && !(originalCue instanceof window.vttjs.VTTCue)) {
  395. 395 : cue = new window.vttjs.VTTCue(originalCue.startTime, originalCue.endTime, originalCue.text);
  396. 396 :
  397. 397 : for (const prop in originalCue) {
  398. 398 : if (!(prop in cue)) {
  399. 399 : cue[prop] = originalCue[prop];
  400. 400 : }
  401. 401 : }
  402. 402 :
  403. 403 : // make sure that `id` is copied over
  404. 404 : cue.id = originalCue.id;
  405. 405 : cue.originalCue_ = originalCue;
  406. 406 : }
  407. 407 :
  408. 408 : const tracks = this.tech_.textTracks();
  409. 409 :
  410. 410 : for (let i = 0; i < tracks.length; i++) {
  411. 411 : if (tracks[i] !== this) {
  412. 412 : tracks[i].removeCue(cue);
  413. 413 : }
  414. 414 : }
  415. 415 :
  416. 416 : this.cues_.push(cue);
  417. 417 : this.cues.setCues_(this.cues_);
  418. 418 : }
  419. 419 :
  420. 420 : /**
  421. 421 : * Remove a cue from our internal list
  422. 422 : *
  423. 423 : * @param {TextTrack~Cue} removeCue
  424. 424 : * The cue to remove from our internal list
  425. 425 : */
  426. 426 : removeCue(removeCue) {
  427. 427 : let i = this.cues_.length;
  428. 428 :
  429. 429 : while (i--) {
  430. 430 : const cue = this.cues_[i];
  431. 431 :
  432. 432 : if (cue === removeCue || (cue.originalCue_ && cue.originalCue_ === removeCue)) {
  433. 433 : this.cues_.splice(i, 1);
  434. 434 : this.cues.setCues_(this.cues_);
  435. 435 : break;
  436. 436 : }
  437. 437 : }
  438. 438 : }
  439. 439 : }
  440. 440 :
  441. 441 : /**
  442. 442 : * cuechange - One or more cues in the track have become active or stopped being active.
  443. 443 : */
  444. 444 : TextTrack.prototype.allowedEvents_ = {
  445. 445 : cuechange: 'cuechange'
  446. 446 : };
  447. 447 :
  448. 448 : export default TextTrack;