Source: lib/text/ui_text_displayer.js

  1. /*! @license
  2. * Shaka Player
  3. * Copyright 2016 Google LLC
  4. * SPDX-License-Identifier: Apache-2.0
  5. */
  6. goog.provide('shaka.text.UITextDisplayer');
  7. goog.require('goog.asserts');
  8. goog.require('shaka.text.Cue');
  9. goog.require('shaka.text.CueRegion');
  10. goog.require('shaka.text.Utils');
  11. goog.require('shaka.util.Dom');
  12. goog.require('shaka.util.EventManager');
  13. goog.require('shaka.util.Timer');
  14. /**
  15. * The text displayer plugin for the Shaka Player UI. Can also be used directly
  16. * by providing an appropriate container element.
  17. *
  18. * @implements {shaka.extern.TextDisplayer}
  19. * @final
  20. * @export
  21. */
  22. shaka.text.UITextDisplayer = class {
  23. /**
  24. * Constructor.
  25. * @param {HTMLMediaElement} video
  26. * @param {HTMLElement} videoContainer
  27. */
  28. constructor(video, videoContainer) {
  29. goog.asserts.assert(videoContainer, 'videoContainer should be valid.');
  30. /** @private {boolean} */
  31. this.isTextVisible_ = false;
  32. /** @private {!Array<!shaka.text.Cue>} */
  33. this.cues_ = [];
  34. /** @private {HTMLMediaElement} */
  35. this.video_ = video;
  36. /** @private {HTMLElement} */
  37. this.videoContainer_ = videoContainer;
  38. /** @private {?number} */
  39. this.aspectRatio_ = null;
  40. /** @private {?shaka.extern.TextDisplayerConfiguration} */
  41. this.config_ = null;
  42. /** @type {HTMLElement} */
  43. this.textContainer_ = shaka.util.Dom.createHTMLElement('div');
  44. this.textContainer_.classList.add('shaka-text-container');
  45. // Set the subtitles text-centered by default.
  46. this.textContainer_.style.textAlign = 'center';
  47. // Set the captions in the middle horizontally by default.
  48. this.textContainer_.style.display = 'flex';
  49. this.textContainer_.style.flexDirection = 'column';
  50. this.textContainer_.style.alignItems = 'center';
  51. // Set the captions at the bottom by default.
  52. this.textContainer_.style.justifyContent = 'flex-end';
  53. /** @private {shaka.util.Timer} */
  54. this.captionsTimer_ = new shaka.util.Timer(() => {
  55. if (!this.video_.paused) {
  56. this.updateCaptions_();
  57. }
  58. });
  59. this.configureCaptionsTimer_();
  60. /**
  61. * Maps cues to cue elements. Specifically points out the wrapper element of
  62. * the cue (e.g. the HTML element to put nested cues inside).
  63. * @private {Map<!shaka.text.Cue, !{
  64. * cueElement: !HTMLElement,
  65. * regionElement: HTMLElement,
  66. * wrapper: !HTMLElement
  67. * }>}
  68. */
  69. this.currentCuesMap_ = new Map();
  70. /** @private {shaka.util.EventManager} */
  71. this.eventManager_ = new shaka.util.EventManager();
  72. this.eventManager_.listen(document, 'fullscreenchange', () => {
  73. this.updateCaptions_(/* forceUpdate= */ true);
  74. });
  75. this.eventManager_.listen(this.video_, 'seeking', () => {
  76. this.updateCaptions_(/* forceUpdate= */ true);
  77. });
  78. this.eventManager_.listen(this.video_, 'ratechange', () => {
  79. this.configureCaptionsTimer_();
  80. });
  81. // From: https://html.spec.whatwg.org/multipage/media.html#dom-video-videowidth
  82. // Whenever the natural width or natural height of the video changes
  83. // (including, for example, because the selected video track was changed),
  84. // if the element's readyState attribute is not HAVE_NOTHING, the user
  85. // agent must queue a media element task given the media element to fire an
  86. // event named resize at the media element.
  87. this.eventManager_.listen(this.video_, 'resize', () => {
  88. const element = /** @type {!HTMLVideoElement} */ (this.video_);
  89. const width = element.videoWidth;
  90. const height = element.videoHeight;
  91. if (width && height) {
  92. this.aspectRatio_ = width / height;
  93. } else {
  94. this.aspectRatio_ = null;
  95. }
  96. });
  97. /** @private {ResizeObserver} */
  98. this.resizeObserver_ = null;
  99. if ('ResizeObserver' in window) {
  100. this.resizeObserver_ = new ResizeObserver(() => {
  101. this.updateCaptions_(/* forceUpdate= */ true);
  102. });
  103. this.resizeObserver_.observe(this.textContainer_);
  104. }
  105. /** @private {Map<string, !HTMLElement>} */
  106. this.regionElements_ = new Map();
  107. }
  108. /**
  109. * @override
  110. * @export
  111. */
  112. configure(config) {
  113. this.config_ = config;
  114. this.configureCaptionsTimer_();
  115. this.updateCaptions_(/* forceUpdate= */ true);
  116. }
  117. /**
  118. * @override
  119. * @export
  120. */
  121. append(cues) {
  122. // Clone the cues list for performance optimization. We can avoid the cues
  123. // list growing during the comparisons for duplicate cues.
  124. // See: https://github.com/shaka-project/shaka-player/issues/3018
  125. const cuesList = [...this.cues_];
  126. for (const cue of shaka.text.Utils.removeDuplicates(cues)) {
  127. // When a VTT cue spans a segment boundary, the cue will be duplicated
  128. // into two segments.
  129. // To avoid displaying duplicate cues, if the current cue list already
  130. // contains the cue, skip it.
  131. const containsCue = cuesList.some(
  132. (cueInList) => shaka.text.Cue.equal(cueInList, cue));
  133. if (!containsCue) {
  134. this.cues_.push(cue);
  135. }
  136. }
  137. if (this.cues_.length) {
  138. this.configureCaptionsTimer_();
  139. }
  140. this.updateCaptions_();
  141. }
  142. /**
  143. * @override
  144. * @export
  145. */
  146. destroy() {
  147. // Return resolved promise if destroy() has been called.
  148. if (!this.textContainer_) {
  149. return Promise.resolve();
  150. }
  151. // Remove the text container element from the UI.
  152. if (this.textContainer_.parentElement) {
  153. this.videoContainer_.removeChild(this.textContainer_);
  154. }
  155. this.textContainer_ = null;
  156. this.isTextVisible_ = false;
  157. this.cues_ = [];
  158. if (this.captionsTimer_) {
  159. this.captionsTimer_.stop();
  160. this.captionsTimer_ = null;
  161. }
  162. this.currentCuesMap_.clear();
  163. // Tear-down the event manager to ensure messages stop moving around.
  164. if (this.eventManager_) {
  165. this.eventManager_.release();
  166. this.eventManager_ = null;
  167. }
  168. if (this.resizeObserver_) {
  169. this.resizeObserver_.disconnect();
  170. this.resizeObserver_ = null;
  171. }
  172. return Promise.resolve();
  173. }
  174. /**
  175. * @override
  176. * @export
  177. */
  178. remove(start, end) {
  179. // Return false if destroy() has been called.
  180. if (!this.textContainer_) {
  181. return false;
  182. }
  183. // Remove the cues out of the time range.
  184. const oldNumCues = this.cues_.length;
  185. this.cues_ = this.cues_.filter(
  186. (cue) => cue.startTime < start || cue.endTime >= end);
  187. // If anything was actually removed in this process, force the captions to
  188. // update. This makes sure that the currently-displayed cues will stop
  189. // displaying if removed (say, due to the user changing languages).
  190. const forceUpdate = oldNumCues > this.cues_.length;
  191. this.updateCaptions_(forceUpdate);
  192. if (!this.cues_.length) {
  193. this.configureCaptionsTimer_();
  194. }
  195. return true;
  196. }
  197. /**
  198. * @override
  199. * @export
  200. */
  201. isTextVisible() {
  202. return this.isTextVisible_;
  203. }
  204. /**
  205. * @override
  206. * @export
  207. */
  208. setTextVisibility(on) {
  209. this.isTextVisible_ = on;
  210. if (this.isTextVisible_) {
  211. if (!this.textContainer_.parentElement) {
  212. this.videoContainer_.appendChild(this.textContainer_);
  213. }
  214. this.updateCaptions_(/* forceUpdate= */ true);
  215. } else {
  216. if (this.textContainer_.parentElement) {
  217. this.videoContainer_.removeChild(this.textContainer_);
  218. }
  219. }
  220. }
  221. /**
  222. * @override
  223. * @export
  224. */
  225. setTextLanguage(language) {
  226. if (language && language != 'und') {
  227. this.textContainer_.setAttribute('lang', language);
  228. } else {
  229. this.textContainer_.setAttribute('lang', '');
  230. }
  231. }
  232. /**
  233. * @override
  234. * @export
  235. */
  236. enableTextDisplayer() {
  237. }
  238. /**
  239. * @private
  240. */
  241. configureCaptionsTimer_() {
  242. if (this.captionsTimer_) {
  243. if (this.cues_.length) {
  244. const captionsUpdatePeriod = this.config_ ?
  245. this.config_.captionsUpdatePeriod : 0.25;
  246. const updateTime = captionsUpdatePeriod /
  247. Math.max(1, Math.abs(this.video_.playbackRate));
  248. this.captionsTimer_.tickEvery(updateTime);
  249. } else {
  250. this.captionsTimer_.stop();
  251. }
  252. }
  253. }
  254. /**
  255. * @private
  256. */
  257. isElementUnderTextContainer_(elemToCheck) {
  258. while (elemToCheck != null) {
  259. if (elemToCheck == this.textContainer_) {
  260. return true;
  261. }
  262. elemToCheck = elemToCheck.parentElement;
  263. }
  264. return false;
  265. }
  266. /**
  267. * @param {!Array<!shaka.text.Cue>} cues
  268. * @param {!HTMLElement} container
  269. * @param {number} currentTime
  270. * @param {!Array<!shaka.text.Cue>} parents
  271. * @private
  272. */
  273. updateCuesRecursive_(cues, container, currentTime, parents) {
  274. // Set to true if the cues have changed in some way, which will require
  275. // DOM changes. E.g. if a cue was added or removed.
  276. let updateDOM = false;
  277. /**
  278. * The elements to remove from the DOM.
  279. * Some of these elements may be added back again, if their corresponding
  280. * cue is in toPlant.
  281. * These elements are only removed if updateDOM is true.
  282. * @type {!Array<!HTMLElement>}
  283. */
  284. const toUproot = [];
  285. /**
  286. * The cues whose corresponding elements should be in the DOM.
  287. * Some of these might be new, some might have been displayed beforehand.
  288. * These will only be added if updateDOM is true.
  289. * @type {!Array<!shaka.text.Cue>}
  290. */
  291. const toPlant = [];
  292. for (const cue of cues) {
  293. parents.push(cue);
  294. let cueRegistry = this.currentCuesMap_.get(cue);
  295. const shouldBeDisplayed =
  296. cue.startTime <= currentTime && cue.endTime > currentTime;
  297. let wrapper = cueRegistry ? cueRegistry.wrapper : null;
  298. if (cueRegistry) {
  299. // If the cues are replanted, all existing cues should be uprooted,
  300. // even ones which are going to be planted again.
  301. toUproot.push(cueRegistry.cueElement);
  302. // Also uproot all displayed region elements.
  303. if (cueRegistry.regionElement) {
  304. toUproot.push(cueRegistry.regionElement);
  305. }
  306. // If the cue should not be displayed, remove it entirely.
  307. if (!shouldBeDisplayed) {
  308. // Since something has to be removed, we will need to update the DOM.
  309. updateDOM = true;
  310. this.currentCuesMap_.delete(cue);
  311. cueRegistry = null;
  312. }
  313. }
  314. if (shouldBeDisplayed) {
  315. toPlant.push(cue);
  316. if (!cueRegistry) {
  317. // The cue has to be made!
  318. this.createCue_(cue, parents);
  319. cueRegistry = this.currentCuesMap_.get(cue);
  320. wrapper = cueRegistry.wrapper;
  321. updateDOM = true;
  322. } else if (!this.isElementUnderTextContainer_(wrapper)) {
  323. // We found that the wrapper needs to be in the DOM
  324. updateDOM = true;
  325. }
  326. }
  327. // Recursively check the nested cues, to see if they need to be added or
  328. // removed.
  329. // If wrapper is null, that means that the cue is not only not being
  330. // displayed currently, it also was not removed this tick. So it's
  331. // guaranteed that the children will neither need to be added nor removed.
  332. if (cue.nestedCues.length > 0 && wrapper) {
  333. this.updateCuesRecursive_(
  334. cue.nestedCues, wrapper, currentTime, parents);
  335. }
  336. const topCue = parents.pop();
  337. goog.asserts.assert(topCue == cue, 'Parent cues should be kept in order');
  338. }
  339. if (updateDOM) {
  340. for (const element of toUproot) {
  341. // NOTE: Because we uproot shared region elements, too, we might hit an
  342. // element here that has no parent because we've already processed it.
  343. if (element.parentElement) {
  344. element.parentElement.removeChild(element);
  345. }
  346. }
  347. toPlant.sort((a, b) => {
  348. if (a.startTime != b.startTime) {
  349. return a.startTime - b.startTime;
  350. } else {
  351. return a.endTime - b.endTime;
  352. }
  353. });
  354. for (const cue of toPlant) {
  355. const cueRegistry = this.currentCuesMap_.get(cue);
  356. goog.asserts.assert(cueRegistry, 'cueRegistry should exist.');
  357. if (cueRegistry.regionElement) {
  358. if (cueRegistry.regionElement.contains(container)) {
  359. cueRegistry.regionElement.removeChild(container);
  360. }
  361. container.appendChild(cueRegistry.regionElement);
  362. cueRegistry.regionElement.appendChild(cueRegistry.cueElement);
  363. } else {
  364. container.appendChild(cueRegistry.cueElement);
  365. }
  366. }
  367. }
  368. }
  369. /**
  370. * Display the current captions.
  371. * @param {boolean=} forceUpdate
  372. * @private
  373. */
  374. updateCaptions_(forceUpdate = false) {
  375. if (!this.textContainer_) {
  376. return;
  377. }
  378. const currentTime = this.video_.currentTime;
  379. if (!this.isTextVisible_ || forceUpdate) {
  380. // Remove child elements from all regions.
  381. for (const regionElement of this.regionElements_.values()) {
  382. shaka.util.Dom.removeAllChildren(regionElement);
  383. }
  384. // Remove all top-level elements in the text container.
  385. shaka.util.Dom.removeAllChildren(this.textContainer_);
  386. // Clear the element maps.
  387. this.currentCuesMap_.clear();
  388. this.regionElements_.clear();
  389. }
  390. if (this.isTextVisible_) {
  391. // Log currently attached cue elements for verification, later.
  392. const previousCuesMap = new Map();
  393. if (goog.DEBUG) {
  394. for (const cue of this.currentCuesMap_.keys()) {
  395. previousCuesMap.set(cue, this.currentCuesMap_.get(cue));
  396. }
  397. }
  398. // Update the cues.
  399. this.updateCuesRecursive_(
  400. this.cues_, this.textContainer_, currentTime, /* parents= */ []);
  401. if (goog.DEBUG) {
  402. // Previously, we had an issue (#2076) where cues sometimes were not
  403. // properly removed from the DOM. It is not clear if this issue still
  404. // happens, so the previous fix for it has been changed to an assert.
  405. for (const cue of previousCuesMap.keys()) {
  406. if (!this.currentCuesMap_.has(cue)) {
  407. // TODO: If the problem does not appear again, then we should remove
  408. // this assert (and the previousCuesMap code) in Shaka v4.
  409. const cueElement = previousCuesMap.get(cue).cueElement;
  410. goog.asserts.assert(
  411. !cueElement.parentNode, 'Cue was not properly removed!');
  412. }
  413. }
  414. }
  415. }
  416. }
  417. /**
  418. * Compute a unique internal id:
  419. * Regions can reuse the id but have different dimensions, we need to
  420. * consider those differences
  421. * @param {shaka.text.CueRegion} region
  422. * @private
  423. */
  424. generateRegionId_(region) {
  425. const percentageUnit = shaka.text.CueRegion.units.PERCENTAGE;
  426. const heightUnit = region.heightUnits == percentageUnit ? '%' : 'px';
  427. const viewportAnchorUnit =
  428. region.viewportAnchorUnits == percentageUnit ? '%' : 'px';
  429. const uniqueRegionId = `${region.id}_${
  430. region.width}x${region.height}${heightUnit}-${
  431. region.viewportAnchorX}x${region.viewportAnchorY}${viewportAnchorUnit}`;
  432. return uniqueRegionId;
  433. }
  434. /**
  435. * Get or create a region element corresponding to the cue region. These are
  436. * cached by ID.
  437. *
  438. * @param {!shaka.text.Cue} cue
  439. * @return {!HTMLElement}
  440. * @private
  441. */
  442. getRegionElement_(cue) {
  443. const region = cue.region;
  444. // from https://dvcs.w3.org/hg/text-tracks/raw-file/default/608toVTT/608toVTT.html#caption-window-size
  445. // if aspect ratio is 4/3, use that value, otherwise, use the 16:9 value
  446. const lineWidthMultiple = this.aspectRatio_ === 4/3 ? 2.5 : 1.9;
  447. const lineHeightMultiple = 5.33;
  448. const regionId = this.generateRegionId_(region);
  449. if (this.regionElements_.has(regionId)) {
  450. return this.regionElements_.get(regionId);
  451. }
  452. const regionElement = shaka.util.Dom.createHTMLElement('span');
  453. const linesUnit = shaka.text.CueRegion.units.LINES;
  454. const percentageUnit = shaka.text.CueRegion.units.PERCENTAGE;
  455. const pixelUnit = shaka.text.CueRegion.units.PX;
  456. let heightUnit = region.heightUnits == percentageUnit ? '%' : 'px';
  457. let widthUnit = region.widthUnits == percentageUnit ? '%' : 'px';
  458. const viewportAnchorUnit =
  459. region.viewportAnchorUnits == percentageUnit ? '%' : 'px';
  460. regionElement.id = 'shaka-text-region---' + regionId;
  461. regionElement.classList.add('shaka-text-region');
  462. regionElement.style.position = 'absolute';
  463. let regionHeight = region.height;
  464. let regionWidth = region.width;
  465. if (region.heightUnits === linesUnit) {
  466. regionHeight = region.height * lineHeightMultiple;
  467. heightUnit = '%';
  468. }
  469. if (region.widthUnits === linesUnit) {
  470. regionWidth = region.width * lineWidthMultiple;
  471. widthUnit = '%';
  472. }
  473. regionElement.style.height = regionHeight + heightUnit;
  474. regionElement.style.width = regionWidth + widthUnit;
  475. if (region.viewportAnchorUnits === linesUnit) {
  476. // from https://dvcs.w3.org/hg/text-tracks/raw-file/default/608toVTT/608toVTT.html#positioning-in-cea-708
  477. let top = region.viewportAnchorY / 75 * 100;
  478. const windowWidth = this.aspectRatio_ === 4/3 ? 160 : 210;
  479. let left = region.viewportAnchorX / windowWidth * 100;
  480. // adjust top and left values based on the region anchor and window size
  481. top -= region.regionAnchorY * regionHeight / 100;
  482. left -= region.regionAnchorX * regionWidth / 100;
  483. regionElement.style.top = top + '%';
  484. regionElement.style.left = left + '%';
  485. } else {
  486. regionElement.style.top = region.viewportAnchorY -
  487. region.regionAnchorY * regionHeight / 100 + viewportAnchorUnit;
  488. regionElement.style.left = region.viewportAnchorX -
  489. region.regionAnchorX * regionWidth / 100 + viewportAnchorUnit;
  490. }
  491. if (region.heightUnits !== pixelUnit &&
  492. region.widthUnits !== pixelUnit &&
  493. region.viewportAnchorUnits !== pixelUnit) {
  494. // Clip region
  495. const top = parseInt(regionElement.style.top.slice(0, -1), 10) || 0;
  496. const left = parseInt(regionElement.style.left.slice(0, -1), 10) || 0;
  497. const height = parseInt(regionElement.style.height.slice(0, -1), 10) || 0;
  498. const width = parseInt(regionElement.style.width.slice(0, -1), 10) || 0;
  499. const realTop = Math.max(0, Math.min(100 - height, top));
  500. const realLeft = Math.max(0, Math.min(100 - width, left));
  501. regionElement.style.top = realTop + '%';
  502. regionElement.style.left = realLeft + '%';
  503. }
  504. regionElement.style.display = 'flex';
  505. regionElement.style.flexDirection = 'column';
  506. regionElement.style.alignItems = 'center';
  507. if (cue.displayAlign == shaka.text.Cue.displayAlign.BEFORE) {
  508. regionElement.style.justifyContent = 'flex-start';
  509. } else if (cue.displayAlign == shaka.text.Cue.displayAlign.CENTER) {
  510. regionElement.style.justifyContent = 'center';
  511. } else {
  512. regionElement.style.justifyContent = 'flex-end';
  513. }
  514. this.regionElements_.set(regionId, regionElement);
  515. return regionElement;
  516. }
  517. /**
  518. * Creates the object for a cue.
  519. *
  520. * @param {!shaka.text.Cue} cue
  521. * @param {!Array<!shaka.text.Cue>} parents
  522. * @private
  523. */
  524. createCue_(cue, parents) {
  525. const isNested = parents.length > 1;
  526. let type = isNested ? 'span' : 'div';
  527. if (cue.lineBreak) {
  528. type = 'br';
  529. }
  530. if (cue.rubyTag) {
  531. type = cue.rubyTag;
  532. }
  533. const needWrapper = !isNested && cue.nestedCues.length > 0;
  534. // Nested cues are inline elements. Top-level cues are block elements.
  535. const cueElement = shaka.util.Dom.createHTMLElement(type);
  536. if (type != 'br') {
  537. this.setCaptionStyles_(cueElement, cue, parents, needWrapper);
  538. }
  539. let regionElement = null;
  540. if (cue.region && cue.region.id) {
  541. regionElement = this.getRegionElement_(cue);
  542. }
  543. let wrapper = cueElement;
  544. if (needWrapper) {
  545. // Create a wrapper element which will serve to contain all children into
  546. // a single item. This ensures that nested span elements appear
  547. // horizontally and br elements occupy no vertical space.
  548. wrapper = shaka.util.Dom.createHTMLElement('span');
  549. wrapper.classList.add('shaka-text-wrapper');
  550. wrapper.style.backgroundColor = cue.backgroundColor;
  551. wrapper.style.lineHeight = 'normal';
  552. cueElement.appendChild(wrapper);
  553. }
  554. this.currentCuesMap_.set(cue, {cueElement, wrapper, regionElement});
  555. }
  556. /**
  557. * Compute cue position alignment
  558. * See https://www.w3.org/TR/webvtt1/#webvtt-cue-position-alignment
  559. *
  560. * @param {!shaka.text.Cue} cue
  561. * @private
  562. */
  563. computeCuePositionAlignment_(cue) {
  564. const Cue = shaka.text.Cue;
  565. const {direction, positionAlign, textAlign} = cue;
  566. if (positionAlign !== Cue.positionAlign.AUTO) {
  567. // Position align is not AUTO: use it
  568. return positionAlign;
  569. }
  570. // Position align is AUTO: use text align to compute its value
  571. if (textAlign === Cue.textAlign.LEFT ||
  572. (textAlign === Cue.textAlign.START &&
  573. direction === Cue.direction.HORIZONTAL_LEFT_TO_RIGHT) ||
  574. (textAlign === Cue.textAlign.END &&
  575. direction === Cue.direction.HORIZONTAL_RIGHT_TO_LEFT)) {
  576. return Cue.positionAlign.LEFT;
  577. }
  578. if (textAlign === Cue.textAlign.RIGHT ||
  579. (textAlign === Cue.textAlign.START &&
  580. direction === Cue.direction.HORIZONTAL_RIGHT_TO_LEFT) ||
  581. (textAlign === Cue.textAlign.END &&
  582. direction === Cue.direction.HORIZONTAL_LEFT_TO_RIGHT)) {
  583. return Cue.positionAlign.RIGHT;
  584. }
  585. return Cue.positionAlign.CENTER;
  586. }
  587. /**
  588. * @param {!HTMLElement} cueElement
  589. * @param {!shaka.text.Cue} cue
  590. * @param {!Array<!shaka.text.Cue>} parents
  591. * @param {boolean} hasWrapper
  592. * @private
  593. */
  594. setCaptionStyles_(cueElement, cue, parents, hasWrapper) {
  595. const Cue = shaka.text.Cue;
  596. const inherit =
  597. (cb) => shaka.text.UITextDisplayer.inheritProperty_(parents, cb);
  598. const style = cueElement.style;
  599. const isLeaf = cue.nestedCues.length == 0;
  600. const isNested = parents.length > 1;
  601. // TODO: wrapLine is not yet supported. Lines always wrap.
  602. // White space should be preserved if emitted by the text parser. It's the
  603. // job of the parser to omit any whitespace that should not be displayed.
  604. // Using 'pre-wrap' means that whitespace is preserved even at the end of
  605. // the text, but that lines which overflow can still be broken.
  606. style.whiteSpace = 'pre-wrap';
  607. // Using 'break-spaces' would be better, as it would preserve even trailing
  608. // spaces, but that only shipped in Chrome 76. As of July 2020, Safari
  609. // still has not implemented break-spaces, and the original Chromecast will
  610. // never have this feature since it no longer gets firmware updates.
  611. // So we need to replace trailing spaces with non-breaking spaces.
  612. const text = cue.payload.replace(/\s+$/g, (match) => {
  613. const nonBreakingSpace = '\xa0';
  614. return nonBreakingSpace.repeat(match.length);
  615. });
  616. style.webkitTextStrokeColor = cue.textStrokeColor;
  617. style.webkitTextStrokeWidth = cue.textStrokeWidth;
  618. style.color = cue.color;
  619. style.direction = cue.direction;
  620. style.opacity = cue.opacity;
  621. style.paddingLeft = shaka.text.UITextDisplayer.convertLengthValue_(
  622. cue.linePadding, cue, this.videoContainer_);
  623. style.paddingRight =
  624. shaka.text.UITextDisplayer.convertLengthValue_(
  625. cue.linePadding, cue, this.videoContainer_);
  626. style.textCombineUpright = cue.textCombineUpright;
  627. style.textShadow = cue.textShadow;
  628. if (cue.backgroundImage) {
  629. style.backgroundImage = 'url(\'' + cue.backgroundImage + '\')';
  630. style.backgroundRepeat = 'no-repeat';
  631. style.backgroundSize = 'contain';
  632. style.backgroundPosition = 'center';
  633. if (cue.backgroundColor) {
  634. style.backgroundColor = cue.backgroundColor;
  635. }
  636. // Quoting https://www.w3.org/TR/ttml-imsc1.2/:
  637. // "The width and height (in pixels) of the image resource referenced by
  638. // smpte:backgroundImage SHALL be equal to the width and height expressed
  639. // by the tts:extent attribute of the region in which the div element is
  640. // presented".
  641. style.width = '100%';
  642. style.height = '100%';
  643. } else {
  644. // If we have both text and nested cues, then style everything; otherwise
  645. // place the text in its own <span> so the background doesn't fill the
  646. // whole region.
  647. let elem;
  648. if (cue.nestedCues.length) {
  649. elem = cueElement;
  650. } else {
  651. elem = shaka.util.Dom.createHTMLElement('span');
  652. cueElement.appendChild(elem);
  653. }
  654. if (cue.border) {
  655. elem.style.border = cue.border;
  656. }
  657. if (!hasWrapper) {
  658. const bgColor = inherit((c) => c.backgroundColor);
  659. if (bgColor) {
  660. elem.style.backgroundColor = bgColor;
  661. } else if (text) {
  662. // If there is no background, default to a semi-transparent black.
  663. // Only do this for the text itself.
  664. elem.style.backgroundColor = 'rgba(0, 0, 0, 0.8)';
  665. }
  666. }
  667. if (text) {
  668. elem.setAttribute('translate', 'no');
  669. elem.textContent = text;
  670. }
  671. }
  672. // The displayAlign attribute specifies the vertical alignment of the
  673. // captions inside the text container. Before means at the top of the
  674. // text container, and after means at the bottom.
  675. if (isNested && !parents[parents.length - 1].isContainer) {
  676. style.display = 'inline';
  677. } else {
  678. style.display = 'flex';
  679. style.flexDirection = 'column';
  680. style.alignItems = 'center';
  681. if (cue.textAlign == Cue.textAlign.LEFT ||
  682. cue.textAlign == Cue.textAlign.START) {
  683. style.width = '100%';
  684. style.alignItems = 'start';
  685. } else if (cue.textAlign == Cue.textAlign.RIGHT ||
  686. cue.textAlign == Cue.textAlign.END) {
  687. style.width = '100%';
  688. style.alignItems = 'end';
  689. }
  690. if (cue.displayAlign == Cue.displayAlign.BEFORE) {
  691. style.justifyContent = 'flex-start';
  692. } else if (cue.displayAlign == Cue.displayAlign.CENTER) {
  693. style.justifyContent = 'center';
  694. } else {
  695. style.justifyContent = 'flex-end';
  696. }
  697. }
  698. if (!isLeaf) {
  699. style.margin = '0';
  700. }
  701. style.fontFamily = cue.fontFamily;
  702. style.fontWeight = cue.fontWeight.toString();
  703. style.fontStyle = cue.fontStyle;
  704. style.letterSpacing = cue.letterSpacing;
  705. const fontScaleFactor = this.config_ ? this.config_.fontScaleFactor : 1;
  706. style.fontSize = shaka.text.UITextDisplayer.convertLengthValue_(
  707. cue.fontSize, cue, this.videoContainer_, fontScaleFactor);
  708. // The line attribute defines the positioning of the text container inside
  709. // the video container.
  710. // - The line offsets the text container from the top, the right or left of
  711. // the video viewport as defined by the writing direction.
  712. // - The value of the line is either as a number of lines, or a percentage
  713. // of the video viewport height or width.
  714. // The lineAlign is an alignment for the text container's line.
  715. // - The Start alignment means the text container’s top side (for horizontal
  716. // cues), left side (for vertical growing right), or right side (for
  717. // vertical growing left) is aligned at the line.
  718. // - The Center alignment means the text container is centered at the line
  719. // (to be implemented).
  720. // - The End Alignment means The text container’s bottom side (for
  721. // horizontal cues), right side (for vertical growing right), or left side
  722. // (for vertical growing left) is aligned at the line.
  723. // TODO: Implement line alignment with line number.
  724. // TODO: Implement lineAlignment of 'CENTER'.
  725. let line = cue.line;
  726. if (line != null) {
  727. let lineInterpretation = cue.lineInterpretation;
  728. // HACK: the current implementation of UITextDisplayer only handled
  729. // PERCENTAGE, so we need convert LINE_NUMBER to PERCENTAGE
  730. if (lineInterpretation == Cue.lineInterpretation.LINE_NUMBER) {
  731. lineInterpretation = Cue.lineInterpretation.PERCENTAGE;
  732. let maxLines = 16;
  733. // The maximum number of lines is different if it is a vertical video.
  734. if (this.aspectRatio_ && this.aspectRatio_ < 1) {
  735. maxLines = 32;
  736. }
  737. if (line < 0) {
  738. line = 100 + line / maxLines * 100;
  739. } else {
  740. line = line / maxLines * 100;
  741. }
  742. }
  743. if (lineInterpretation == Cue.lineInterpretation.PERCENTAGE) {
  744. style.position = 'absolute';
  745. if (cue.writingMode == Cue.writingMode.HORIZONTAL_TOP_TO_BOTTOM) {
  746. style.width = '100%';
  747. if (cue.lineAlign == Cue.lineAlign.START) {
  748. style.top = line + '%';
  749. } else if (cue.lineAlign == Cue.lineAlign.END) {
  750. style.bottom = (100 - line) + '%';
  751. }
  752. } else if (cue.writingMode == Cue.writingMode.VERTICAL_LEFT_TO_RIGHT) {
  753. style.height = '100%';
  754. if (cue.lineAlign == Cue.lineAlign.START) {
  755. style.left = line + '%';
  756. } else if (cue.lineAlign == Cue.lineAlign.END) {
  757. style.right = (100 - line) + '%';
  758. }
  759. } else {
  760. style.height = '100%';
  761. if (cue.lineAlign == Cue.lineAlign.START) {
  762. style.right = line + '%';
  763. } else if (cue.lineAlign == Cue.lineAlign.END) {
  764. style.left = (100 - line) + '%';
  765. }
  766. }
  767. }
  768. }
  769. style.lineHeight = cue.lineHeight;
  770. // The positionAlign attribute is an alignment for the text container in
  771. // the dimension of the writing direction.
  772. const computedPositionAlign = this.computeCuePositionAlignment_(cue);
  773. if (computedPositionAlign == Cue.positionAlign.LEFT) {
  774. style.cssFloat = 'left';
  775. if (cue.position !== null) {
  776. style.position = 'absolute';
  777. if (cue.writingMode == Cue.writingMode.HORIZONTAL_TOP_TO_BOTTOM) {
  778. style.left = cue.position + '%';
  779. style.width = 'auto';
  780. } else {
  781. style.top = cue.position + '%';
  782. }
  783. }
  784. } else if (computedPositionAlign == Cue.positionAlign.RIGHT) {
  785. style.cssFloat = 'right';
  786. if (cue.position !== null) {
  787. style.position = 'absolute';
  788. if (cue.writingMode == Cue.writingMode.HORIZONTAL_TOP_TO_BOTTOM) {
  789. style.right = (100 - cue.position) + '%';
  790. style.width = 'auto';
  791. } else {
  792. style.bottom = cue.position + '%';
  793. }
  794. }
  795. } else {
  796. if (cue.position !== null && cue.position != 50) {
  797. style.position = 'absolute';
  798. if (cue.writingMode == Cue.writingMode.HORIZONTAL_TOP_TO_BOTTOM) {
  799. style.left = cue.position + '%';
  800. style.width = 'auto';
  801. } else {
  802. style.top = cue.position + '%';
  803. }
  804. }
  805. }
  806. style.textAlign = cue.textAlign;
  807. style.textDecoration = cue.textDecoration.join(' ');
  808. style.writingMode = cue.writingMode;
  809. // Old versions of Chromium, which may be found in certain versions of Tizen
  810. // and WebOS, may require the prefixed version: webkitWritingMode.
  811. // https://caniuse.com/css-writing-mode
  812. // However, testing shows that Tizen 3, at least, has a 'writingMode'
  813. // property, but the setter for it does nothing. Therefore we need to
  814. // detect that and fall back to the prefixed version in this case, too.
  815. if (!('writingMode' in document.documentElement.style) ||
  816. style.writingMode != cue.writingMode) {
  817. // Note that here we do not bother to check for webkitWritingMode support
  818. // explicitly. We try the unprefixed version, then fall back to the
  819. // prefixed version unconditionally.
  820. style.webkitWritingMode = cue.writingMode;
  821. }
  822. // The size is a number giving the size of the text container, to be
  823. // interpreted as a percentage of the video, as defined by the writing
  824. // direction.
  825. if (cue.size) {
  826. if (cue.writingMode == Cue.writingMode.HORIZONTAL_TOP_TO_BOTTOM) {
  827. style.width = cue.size + '%';
  828. } else {
  829. style.height = cue.size + '%';
  830. }
  831. }
  832. }
  833. /**
  834. * Returns info about provided lengthValue
  835. * @example 100px => { value: 100, unit: 'px' }
  836. * @param {?string} lengthValue
  837. *
  838. * @return {?{ value: number, unit: string }}
  839. * @private
  840. */
  841. static getLengthValueInfo_(lengthValue) {
  842. const matches = new RegExp(/(\d*\.?\d+)([a-z]+|%+)/).exec(lengthValue);
  843. if (!matches) {
  844. return null;
  845. }
  846. return {
  847. value: Number(matches[1]),
  848. unit: matches[2],
  849. };
  850. }
  851. /**
  852. * Converts length value to an absolute value in pixels.
  853. * If lengthValue is already an absolute value it will not
  854. * be modified. Relative lengthValue will be converted to an
  855. * absolute value in pixels based on Computed Cell Size
  856. *
  857. * @param {string} lengthValue
  858. * @param {!shaka.text.Cue} cue
  859. * @param {HTMLElement} videoContainer
  860. * @param {number=} scaleFactor
  861. * @return {string}
  862. * @private
  863. */
  864. static convertLengthValue_(lengthValue, cue, videoContainer,
  865. scaleFactor = 1) {
  866. const lengthValueInfo =
  867. shaka.text.UITextDisplayer.getLengthValueInfo_(lengthValue);
  868. if (!lengthValueInfo) {
  869. return lengthValue;
  870. }
  871. const {unit, value} = lengthValueInfo;
  872. const realValue = value * scaleFactor;
  873. switch (unit) {
  874. case '%':
  875. return shaka.text.UITextDisplayer.getAbsoluteLengthInPixels_(
  876. realValue / 100, cue, videoContainer);
  877. case 'c':
  878. return shaka.text.UITextDisplayer.getAbsoluteLengthInPixels_(
  879. realValue, cue, videoContainer);
  880. default:
  881. return realValue + unit;
  882. }
  883. }
  884. /**
  885. * Returns computed absolute length value in pixels based on cell
  886. * and a video container size
  887. * @param {number} value
  888. * @param {!shaka.text.Cue} cue
  889. * @param {HTMLElement} videoContainer
  890. * @return {string}
  891. *
  892. * @private
  893. */
  894. static getAbsoluteLengthInPixels_(value, cue, videoContainer) {
  895. const containerHeight = videoContainer.clientHeight;
  896. return (containerHeight * value / cue.cellResolution.rows) + 'px';
  897. }
  898. /**
  899. * Inherits a property from the parent Cue elements. If the value is falsy,
  900. * it is assumed to be inherited from the parent. This returns null if the
  901. * value isn't found.
  902. *
  903. * @param {!Array<!shaka.text.Cue>} parents
  904. * @param {function(!shaka.text.Cue):?T} cb
  905. * @return {?T}
  906. * @template T
  907. * @private
  908. */
  909. static inheritProperty_(parents, cb) {
  910. for (let i = parents.length - 1; i >= 0; i--) {
  911. const val = cb(parents[i]);
  912. if (val || val === 0) {
  913. return val;
  914. }
  915. }
  916. return null;
  917. }
  918. };