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