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