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