Source: ui/text_displayer.js

  1. /**
  2. * @license
  3. * Copyright 2016 Google Inc.
  4. *
  5. * Licensed under the Apache License, Version 2.0 (the "License");
  6. * you may not use this file except in compliance with the License.
  7. * You may obtain a copy of the License at
  8. *
  9. * http://www.apache.org/licenses/LICENSE-2.0
  10. *
  11. * Unless required by applicable law or agreed to in writing, software
  12. * distributed under the License is distributed on an "AS IS" BASIS,
  13. * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  14. * See the License for the specific language governing permissions and
  15. * limitations under the License.
  16. */
  17. goog.provide('shaka.ui.TextDisplayer');
  18. goog.require('shaka.util.Dom');
  19. goog.require('shaka.util.EventManager');
  20. goog.require('shaka.util.Timer');
  21. /**
  22. * @implements {shaka.extern.TextDisplayer}
  23. * @final
  24. * @export
  25. */
  26. shaka.ui.TextDisplayer = class {
  27. /**
  28. * Constructor.
  29. * @param {HTMLMediaElement} video
  30. * @param {HTMLElement} videoContainer
  31. */
  32. constructor(video, videoContainer) {
  33. /** @private {boolean} */
  34. this.isTextVisible_ = false;
  35. /** @private {!Array.<!shaka.text.Cue>} */
  36. this.cues_ = [];
  37. /** @private {HTMLMediaElement} */
  38. this.video_ = video;
  39. /** @private {HTMLElement} */
  40. this.videoContainer_ = videoContainer;
  41. /** @type {HTMLElement} */
  42. this.textContainer_ = shaka.util.Dom.createHTMLElement('div');
  43. this.textContainer_.classList.add('shaka-text-container');
  44. // Set the subtitles text-centered by default.
  45. this.textContainer_.style.textAlign = 'center';
  46. // Set the captions in the middle horizontally by default.
  47. this.textContainer_.style.display = 'flex';
  48. this.textContainer_.style.flexDirection = 'column';
  49. this.textContainer_.style.alignItems = 'center';
  50. // Set the captions at the bottom by default.
  51. this.textContainer_.style.justifyContent = 'flex-end';
  52. this.videoContainer_.appendChild(this.textContainer_);
  53. /**
  54. * The captions' update period in seconds.
  55. * @private {number}
  56. */
  57. const updatePeriod = 0.25;
  58. /** @private {shaka.util.Timer} */
  59. this.captionsTimer_ = new shaka.util.Timer(() => {
  60. this.updateCaptions_();
  61. }).tickEvery(updatePeriod);
  62. /** private {Map.<!shaka.extern.Cue, !HTMLElement>} */
  63. this.currentCuesMap_ = new Map();
  64. /** @private {shaka.util.EventManager} */
  65. this.eventManager_ = new shaka.util.EventManager();
  66. this.eventManager_.listen(document, 'fullscreenchange', () => {
  67. this.updateCaptions_(/* forceUpdate= */ true);
  68. });
  69. /** @private {ResizeObserver} */
  70. this.resizeObserver_ = null;
  71. if ('ResizeObserver' in window) {
  72. this.resizeObserver_ = new ResizeObserver(() => {
  73. this.updateCaptions_(/* forceUpdate= */ true);
  74. });
  75. this.resizeObserver_.observe(this.textContainer_);
  76. }
  77. }
  78. /**
  79. * @override
  80. * @export
  81. */
  82. append(cues) {
  83. // Clone the cues list for performace optimization. We can avoid the cues
  84. // list growing during the comparisons for duplicate cues.
  85. // See: https://github.com/google/shaka-player/issues/3018
  86. const cuesList = [...this.cues_];
  87. for (const cue of cues) {
  88. // When a VTT cue spans a segment boundary, the cue will be duplicated
  89. // into two segments.
  90. // To avoid displaying duplicate cues, if the current cue list already
  91. // contains the cue, skip it.
  92. const containsCue = cuesList.some(
  93. (cueInList) => shaka.text.Cue.equal(cueInList, cue));
  94. if (!containsCue) {
  95. this.cues_.push(cue);
  96. }
  97. }
  98. }
  99. /**
  100. * @override
  101. * @export
  102. */
  103. destroy() {
  104. // Remove the text container element from the UI.
  105. this.videoContainer_.removeChild(this.textContainer_);
  106. this.textContainer_ = null;
  107. this.isTextVisible_ = false;
  108. this.cues_ = [];
  109. if (this.captionsTimer_) {
  110. this.captionsTimer_.stop();
  111. }
  112. this.currentCuesMap_.clear();
  113. // Tear-down the event manager to ensure messages stop moving around.
  114. if (this.eventManager_) {
  115. this.eventManager_.release();
  116. this.eventManager_ = null;
  117. }
  118. if (this.resizeObserver_) {
  119. this.resizeObserver_.disconnect();
  120. this.resizeObserver_ = null;
  121. }
  122. }
  123. /**
  124. * @override
  125. * @export
  126. */
  127. remove(start, end) {
  128. // Return false if destroy() has been called.
  129. if (!this.textContainer_) {
  130. return false;
  131. }
  132. // Remove the cues out of the time range.
  133. this.cues_ = this.cues_.filter(
  134. (cue) => cue.startTime < start || cue.endTime >= end);
  135. this.updateCaptions_();
  136. return true;
  137. }
  138. /**
  139. * @override
  140. * @export
  141. */
  142. isTextVisible() {
  143. return this.isTextVisible_;
  144. }
  145. /**
  146. * @override
  147. * @export
  148. */
  149. setTextVisibility(on) {
  150. this.isTextVisible_ = on;
  151. }
  152. /**
  153. * Display the current captions.
  154. * @param {boolean=} forceUpdate
  155. * @private
  156. */
  157. updateCaptions_(forceUpdate = false) {
  158. const currentTime = this.video_.currentTime;
  159. // Return true if the cue should be displayed at the current time point.
  160. const shouldCueBeDisplayed = (cue) => {
  161. return this.cues_.includes(cue) && this.isTextVisible_ &&
  162. cue.startTime <= currentTime && cue.endTime > currentTime;
  163. };
  164. // For each cue in the current cues map, if the cue's end time has passed,
  165. // remove the entry from the map, and remove the captions from the page.
  166. for (const cue of this.currentCuesMap_.keys()) {
  167. if (!shouldCueBeDisplayed(cue) || forceUpdate) {
  168. const captions = this.currentCuesMap_.get(cue);
  169. this.textContainer_.removeChild(captions);
  170. this.currentCuesMap_.delete(cue);
  171. }
  172. }
  173. // Sometimes we don't remove a cue element correctly. So check all the
  174. // child nodes and remove any that don't have an associated cue.
  175. const expectedChildren = new Set(this.currentCuesMap_.values());
  176. for (const child of Array.from(this.textContainer_.childNodes)) {
  177. if (!expectedChildren.has(child)) {
  178. this.textContainer_.removeChild(child);
  179. }
  180. }
  181. // Get the current cues that should be added to display. If the cue is not
  182. // being displayed already, add it to the map, and add the captions onto the
  183. // page.
  184. const currentCues = this.cues_.filter((cue) => {
  185. return shouldCueBeDisplayed(cue) && !this.currentCuesMap_.has(cue);
  186. }).sort((a, b) => {
  187. if (a.startTime != b.startTime) {
  188. return a.startTime - b.startTime;
  189. } else {
  190. return a.endTime - b.endTime;
  191. }
  192. });
  193. for (const cue of currentCues) {
  194. this.displayCue_(this.textContainer_, cue);
  195. }
  196. }
  197. /**
  198. * Displays a nested cue
  199. *
  200. * @param {Element} container
  201. * @param {!shaka.extern.Cue} cue
  202. * @param {boolean} isNested
  203. * @return {!Element} the created captions container
  204. * @private
  205. */
  206. displayLeafCue_(container, cue, isNested) {
  207. const captions = shaka.util.Dom.createHTMLElement('span');
  208. if (isNested) {
  209. captions.classList.add('shaka-nested-cue');
  210. }
  211. if (cue.spacer) {
  212. captions.style.display = 'block';
  213. } else {
  214. this.setCaptionStyles_(captions, cue, /* isLeaf= */ true);
  215. }
  216. container.appendChild(captions);
  217. return captions;
  218. }
  219. /**
  220. * Displays a cue
  221. *
  222. * @param {Element} container
  223. * @param {!shaka.extern.Cue} cue
  224. * @private
  225. */
  226. displayCue_(container, cue) {
  227. if (cue.nestedCues.length) {
  228. const nestedCuesContainer = shaka.util.Dom.createHTMLElement('p');
  229. nestedCuesContainer.style.width = '100%';
  230. this.setCaptionStyles_(nestedCuesContainer, cue, /* isLeaf= */ false);
  231. for (let i = 0; i < cue.nestedCues.length; i++) {
  232. this.displayLeafCue_(
  233. nestedCuesContainer, cue.nestedCues[i], /* isNested= */ true);
  234. }
  235. container.appendChild(nestedCuesContainer);
  236. this.currentCuesMap_.set(cue, nestedCuesContainer);
  237. } else {
  238. this.currentCuesMap_.set(cue,
  239. this.displayLeafCue_(container, cue, /* isNested= */ false));
  240. }
  241. }
  242. /**
  243. * @param {!HTMLElement} captions
  244. * @param {!shaka.extern.Cue} cue
  245. * @param {boolean} isLeaf
  246. * @private
  247. */
  248. setCaptionStyles_(captions, cue, isLeaf) {
  249. const Cue = shaka.text.Cue;
  250. const captionsStyle = captions.style;
  251. // Set white-space to 'pre-line' to enable showing line breaks in the text.
  252. captionsStyle.whiteSpace = 'pre-line';
  253. captions.textContent = cue.payload;
  254. if (isLeaf) {
  255. captionsStyle.backgroundColor = cue.backgroundColor;
  256. }
  257. captionsStyle.color = cue.color;
  258. captionsStyle.direction = cue.direction;
  259. if (cue.backgroundImage) {
  260. captionsStyle.backgroundImage = 'url(\'' + cue.backgroundImage + '\')';
  261. captionsStyle.backgroundRepeat = 'no-repeat';
  262. captionsStyle.backgroundSize = 'contain';
  263. captionsStyle.backgroundPosition = 'center';
  264. if (cue.backgroundColor == '') {
  265. captionsStyle.backgroundColor = 'transparent';
  266. }
  267. }
  268. if (cue.backgroundImage && cue.region) {
  269. const percentageUnit = shaka.text.CueRegion.units.PERCENTAGE;
  270. const heightUnit = cue.region.heightUnits == percentageUnit ? '%' : 'px';
  271. const widthUnit = cue.region.widthUnits == percentageUnit ? '%' : 'px';
  272. captionsStyle.height = cue.region.height + heightUnit;
  273. captionsStyle.width = cue.region.width + widthUnit;
  274. }
  275. // The displayAlign attribute specifys the vertical alignment of the
  276. // captions inside the text container. Before means at the top of the
  277. // text container, and after means at the bottom.
  278. if (cue.displayAlign == Cue.displayAlign.BEFORE) {
  279. captionsStyle.justifyContent = 'flex-start';
  280. } else if (cue.displayAlign == Cue.displayAlign.CENTER) {
  281. captionsStyle.justifyContent = 'center';
  282. } else {
  283. captionsStyle.justifyContent = 'flex-end';
  284. }
  285. if (cue.nestedCues.length) {
  286. captionsStyle.display = 'flex';
  287. captionsStyle.flexDirection = 'row';
  288. captionsStyle.margin = '0';
  289. // Setting flexDirection to "row" inverts the sense of align and justify.
  290. // Now align is vertical and justify is horizontal. See comments above on
  291. // vertical alignment for displayAlign.
  292. captionsStyle.alignItems = captionsStyle.justifyContent;
  293. captionsStyle.justifyContent = 'center';
  294. }
  295. if (isLeaf) {
  296. // Work around an IE 11 flexbox bug in which center-aligned items can
  297. // overflow their container. See
  298. // https://github.com/philipwalton/flexbugs/tree/6e720da8#flexbug-2
  299. captionsStyle.maxWidth = '100%';
  300. }
  301. captionsStyle.fontFamily = cue.fontFamily;
  302. captionsStyle.fontWeight = cue.fontWeight.toString();
  303. captionsStyle.fontSize = cue.fontSize;
  304. captionsStyle.fontStyle = cue.fontStyle;
  305. // The line attribute defines the positioning of the text container inside
  306. // the video container.
  307. // - The line offsets the text container from the top, the right or left of
  308. // the video viewport as defined by the writing direction.
  309. // - The value of the line is either as a number of lines, or a percentage
  310. // of the video viewport height or width.
  311. // The lineAlign is an alignment for the text container's line.
  312. // - The Start alignment means the text container’s top side (for horizontal
  313. // cues), left side (for vertical growing right), or right side (for
  314. // vertical growing left) is aligned at the line.
  315. // - The Center alignment means the text container is centered at the line
  316. // (to be implemented).
  317. // - The End Alignment means The text container’s bottom side (for
  318. // horizontal cues), right side (for vertical growing right), or left side
  319. // (for vertical growing left) is aligned at the line.
  320. // TODO: Implement line alignment with line number.
  321. // TODO: Implement lineAlignment of 'CENTER'.
  322. if (cue.line) {
  323. if (cue.lineInterpretation == Cue.lineInterpretation.PERCENTAGE) {
  324. captionsStyle.position = 'absolute';
  325. if (cue.writingMode == Cue.writingMode.HORIZONTAL_TOP_TO_BOTTOM) {
  326. if (cue.lineAlign == Cue.lineAlign.START) {
  327. captionsStyle.top = cue.line + '%';
  328. } else if (cue.lineAlign == Cue.lineAlign.END) {
  329. captionsStyle.bottom = cue.line + '%';
  330. }
  331. } else if (cue.writingMode == Cue.writingMode.VERTICAL_LEFT_TO_RIGHT) {
  332. if (cue.lineAlign == Cue.lineAlign.START) {
  333. captionsStyle.left = cue.line + '%';
  334. } else if (cue.lineAlign == Cue.lineAlign.END) {
  335. captionsStyle.right = cue.line + '%';
  336. }
  337. } else {
  338. if (cue.lineAlign == Cue.lineAlign.START) {
  339. captionsStyle.right = cue.line + '%';
  340. } else if (cue.lineAlign == Cue.lineAlign.END) {
  341. captionsStyle.left = cue.line + '%';
  342. }
  343. }
  344. }
  345. } else if (cue.region && cue.region.id && !isLeaf) {
  346. const percentageUnit = shaka.text.CueRegion.units.PERCENTAGE;
  347. const heightUnit = cue.region.heightUnits == percentageUnit ? '%' : 'px';
  348. const widthUnit = cue.region.widthUnits == percentageUnit ? '%' : 'px';
  349. const viewportAnchorUnit =
  350. cue.region.viewportAnchorUnits == percentageUnit ? '%' : 'px';
  351. captionsStyle.height = cue.region.height + heightUnit;
  352. captionsStyle.width = cue.region.width + widthUnit;
  353. captionsStyle.position = 'absolute';
  354. captionsStyle.top = cue.region.viewportAnchorY + viewportAnchorUnit;
  355. captionsStyle.left = cue.region.viewportAnchorX + viewportAnchorUnit;
  356. }
  357. captionsStyle.lineHeight = cue.lineHeight;
  358. // The position defines the indent of the text container in the
  359. // direction defined by the writing direction.
  360. if (cue.position) {
  361. if (cue.writingMode == Cue.writingMode.HORIZONTAL_TOP_TO_BOTTOM) {
  362. captionsStyle.paddingLeft = cue.position;
  363. } else {
  364. captionsStyle.paddingTop = cue.position;
  365. }
  366. }
  367. // The positionAlign attribute is an alignment for the text container in
  368. // the dimension of the writing direction.
  369. if (cue.positionAlign == Cue.positionAlign.LEFT) {
  370. captionsStyle.cssFloat = 'left';
  371. } else if (cue.positionAlign == Cue.positionAlign.RIGHT) {
  372. captionsStyle.cssFloat = 'right';
  373. }
  374. captionsStyle.textAlign = cue.textAlign;
  375. captionsStyle.textDecoration = cue.textDecoration.join(' ');
  376. captionsStyle.writingMode = cue.writingMode;
  377. // Old versions of Chromium, which may be found in certain versions of Tizen
  378. // and WebOS, may require the prefixed version: webkitWritingMode.
  379. // https://caniuse.com/css-writing-mode
  380. // However, testing shows that Tizen 3, at least, has a 'writingMode'
  381. // property, but the setter for it does nothing. Therefore we need to
  382. // detect that and fall back to the prefixed version in this case, too.
  383. if (!('writingMode' in document.documentElement.style) ||
  384. captionsStyle.writingMode != cue.writingMode) {
  385. // Note that here we do not bother to check for webkitWritingMode support
  386. // explicitly. We try the unprefixed version, then fall back to the
  387. // prefixed version unconditionally.
  388. captionsStyle.webkitWritingMode = cue.writingMode;
  389. }
  390. // The size is a number giving the size of the text container, to be
  391. // interpreted as a percentage of the video, as defined by the writing
  392. // direction.
  393. if (cue.size) {
  394. if (cue.writingMode == Cue.writingMode.HORIZONTAL_TOP_TO_BOTTOM) {
  395. captionsStyle.width = cue.size + '%';
  396. } else {
  397. captionsStyle.height = cue.size + '%';
  398. }
  399. }
  400. }
  401. };