mirror of
https://github.com/shaka-project/shaka-player.git
synced 2026-06-14 15:56:38 +03:00
1378 lines
46 KiB
JavaScript
1378 lines
46 KiB
JavaScript
/*! @license
|
||
* Shaka Player
|
||
* Copyright 2016 Google LLC
|
||
* SPDX-License-Identifier: Apache-2.0
|
||
*/
|
||
|
||
|
||
goog.provide('shaka.text.UITextDisplayer');
|
||
|
||
goog.require('goog.asserts');
|
||
goog.require('shaka.config.PositionArea');
|
||
goog.require('shaka.text.Cue');
|
||
goog.require('shaka.text.CueRegion');
|
||
goog.require('shaka.text.Utils');
|
||
goog.require('shaka.util.Dom');
|
||
goog.require('shaka.util.EventManager');
|
||
goog.require('shaka.util.Lazy');
|
||
goog.require('shaka.util.Timer');
|
||
goog.requireType('shaka.Player');
|
||
|
||
|
||
/**
|
||
* The text displayer plugin for the Shaka Player UI. Can also be used directly
|
||
* by providing an appropriate container element.
|
||
*
|
||
* @implements {shaka.extern.TextDisplayer}
|
||
* @final
|
||
* @export
|
||
*/
|
||
shaka.text.UITextDisplayer = class {
|
||
/**
|
||
* @param {shaka.Player} player
|
||
*/
|
||
constructor(player) {
|
||
/** @private {boolean} */
|
||
this.isTextVisible_ = false;
|
||
|
||
/** @private {!Array<!shaka.text.Cue>} */
|
||
this.cues_ = [];
|
||
|
||
/** @private {HTMLMediaElement} */
|
||
this.video_ = player.getMediaElement();
|
||
|
||
/** @private {HTMLElement} */
|
||
this.videoContainer_ = player.getVideoContainer();
|
||
|
||
/** @private {?number} */
|
||
this.aspectRatio_ = null;
|
||
|
||
/** @private {boolean} */
|
||
this.renderSuspended_ = false;
|
||
|
||
/** @private {boolean} */
|
||
this.isContainerActuallyVisible_ = true;
|
||
|
||
/** @private {?shaka.extern.TextDisplayerConfiguration} */
|
||
this.config_ = null;
|
||
|
||
/** @private {?shaka.extern.TextDisplayerConfiguration} */
|
||
this.previewConfig_ = null;
|
||
|
||
/** @private {?shaka.text.Cue} */
|
||
this.previewCue_ = null;
|
||
|
||
/** @private {boolean} */
|
||
this.showingPreviewCue_ = false;
|
||
|
||
/** @type {HTMLElement} */
|
||
this.textContainer_ = shaka.util.Dom.createHTMLElement('div');
|
||
this.textContainer_.classList.add('shaka-text-container');
|
||
|
||
// Set the subtitles text-centered by default.
|
||
this.textContainer_.style.textAlign = 'center';
|
||
|
||
// Set the captions in the middle horizontally by default.
|
||
this.textContainer_.style.display = 'flex';
|
||
this.textContainer_.style.flexDirection = 'column';
|
||
this.textContainer_.style.alignItems = 'center';
|
||
|
||
// Set the captions at the bottom by default.
|
||
this.textContainer_.style.justifyContent = 'flex-end';
|
||
|
||
/** @private {shaka.util.Timer} */
|
||
this.captionsTimer_ = new shaka.util.Timer(() => {
|
||
if (!this.video_.paused) {
|
||
this.updateCaptions_();
|
||
}
|
||
});
|
||
this.configureCaptionsTimer_();
|
||
|
||
/** @private {shaka.util.Timer} */
|
||
this.applyVisibilityTimer_ = new shaka.util.Timer(() => {
|
||
this.applyVisibility_();
|
||
});
|
||
|
||
/**
|
||
* Maps cues to cue elements. Specifically points out the wrapper element of
|
||
* the cue (e.g. the HTML element to put nested cues inside).
|
||
* @private {Map<!shaka.text.Cue, !{
|
||
* cueElement: !HTMLElement,
|
||
* regionElement: HTMLElement,
|
||
* wrapper: !HTMLElement,
|
||
* }>}
|
||
*/
|
||
this.currentCuesMap_ = new Map();
|
||
|
||
/** @private {shaka.util.EventManager} */
|
||
this.eventManager_ = new shaka.util.EventManager();
|
||
|
||
this.eventManager_.listen(document, 'fullscreenchange', () => {
|
||
this.updateCaptions_(/* forceUpdate= */ true);
|
||
});
|
||
|
||
this.eventManager_.listen(this.video_, 'seeking', () => {
|
||
this.updateCaptions_(/* forceUpdate= */ true);
|
||
});
|
||
|
||
this.eventManager_.listen(this.video_, 'ratechange', () => {
|
||
this.configureCaptionsTimer_();
|
||
});
|
||
|
||
// From: https://html.spec.whatwg.org/multipage/media.html#dom-video-videowidth
|
||
// Whenever the natural width or natural height of the video changes
|
||
// (including, for example, because the selected video track was changed),
|
||
// if the element's readyState attribute is not HAVE_NOTHING, the user
|
||
// agent must queue a media element task given the media element to fire an
|
||
// event named resize at the media element.
|
||
this.eventManager_.listen(this.video_, 'resize', () => {
|
||
const element = /** @type {!HTMLVideoElement} */ (this.video_);
|
||
const width = element.videoWidth;
|
||
const height = element.videoHeight;
|
||
if (width && height) {
|
||
this.aspectRatio_ = width / height;
|
||
} else {
|
||
this.aspectRatio_ = null;
|
||
}
|
||
});
|
||
|
||
/** @private {boolean} */
|
||
this.pendingResize_ = false;
|
||
|
||
/** @private {ResizeObserver} */
|
||
this.resizeObserver_ = null;
|
||
if ('ResizeObserver' in window) {
|
||
this.resizeObserver_ = new ResizeObserver(() => {
|
||
if (this.pendingResize_) {
|
||
return;
|
||
}
|
||
this.pendingResize_ = true;
|
||
requestAnimationFrame(() => {
|
||
this.pendingResize_ = false;
|
||
|
||
if (!this.textContainer_) {
|
||
return;
|
||
}
|
||
|
||
if (this.textContainer_.parentElement && this.isTextVisible_) {
|
||
this.updateCaptions_(/* forceUpdate= */ true);
|
||
}
|
||
});
|
||
});
|
||
goog.asserts.assert(this.videoContainer_,
|
||
'this.videoContainer_ must be non-null');
|
||
this.resizeObserver_.observe(this.videoContainer_);
|
||
}
|
||
|
||
/** @private {Map<string, !HTMLElement>} */
|
||
this.regionElements_ = new Map();
|
||
|
||
/** @private {IntersectionObserver} */
|
||
this.visibilityObserver_ = null;
|
||
if ('IntersectionObserver' in window) {
|
||
const options = {
|
||
root: null,
|
||
threshold: [0.01],
|
||
};
|
||
this.visibilityObserver_ = new IntersectionObserver((entries) => {
|
||
if (entries.length) {
|
||
const entry = entries[entries.length - 1];
|
||
this.isContainerActuallyVisible_ =
|
||
entry.isIntersecting && entry.intersectionRatio > 0;
|
||
const captionsUpdatePeriod =
|
||
this.config_?.captionsUpdatePeriod || 0.25;
|
||
this.applyVisibilityTimer_.tickAfter(captionsUpdatePeriod);
|
||
}
|
||
}, options);
|
||
goog.asserts.assert(this.videoContainer_,
|
||
'this.videoContainer_ must be non-null');
|
||
this.visibilityObserver_.observe(this.videoContainer_);
|
||
}
|
||
|
||
this.eventManager_.listen(document, 'visibilitychange', (e) => {
|
||
this.applyVisibilityTimer_.tickNow();
|
||
});
|
||
|
||
this.eventManager_.listen(document, 'fullscreenchange', () => {
|
||
this.applyVisibilityTimer_.tickNow();
|
||
});
|
||
|
||
this.eventManager_.listenMulti(
|
||
this.video_,
|
||
[
|
||
'enterpictureinpicture',
|
||
'leavepictureinpicture',
|
||
], () => {
|
||
this.applyVisibilityTimer_.tickNow();
|
||
});
|
||
|
||
if ('documentPictureInPicture' in window) {
|
||
this.eventManager_.listen(window.documentPictureInPicture, 'enter',
|
||
(e) => {
|
||
this.applyVisibilityTimer_.tickNow();
|
||
|
||
const event = /** @type {DocumentPictureInPictureEvent} */(e);
|
||
const pipWindow = event.window;
|
||
this.eventManager_.listenOnce(pipWindow, 'pagehide', () => {
|
||
this.applyVisibilityTimer_.tickNow();
|
||
});
|
||
});
|
||
}
|
||
|
||
const video = /** @type {HTMLVideoElement} */(this.video_);
|
||
if (video.webkitPresentationMode || video.webkitSupportsFullscreen) {
|
||
this.eventManager_.listen(video, 'webkitpresentationmodechanged', () => {
|
||
this.applyVisibilityTimer_.tickNow();
|
||
});
|
||
}
|
||
|
||
this.applyVisibility_();
|
||
}
|
||
|
||
|
||
/**
|
||
* @override
|
||
* @export
|
||
*/
|
||
configure(config) {
|
||
this.config_ = config;
|
||
this.configureCaptionsTimer_();
|
||
this.updateCaptions_(/* forceUpdate= */ true);
|
||
}
|
||
|
||
/**
|
||
* Temporarily previews text displayer style settings using the normal UI
|
||
* text rendering path.
|
||
*
|
||
* @param {!shaka.extern.TextDisplayerConfiguration} config
|
||
* @param {string} exampleText
|
||
* @export
|
||
*/
|
||
setTextStylePreview(config, exampleText) {
|
||
this.previewConfig_ =
|
||
/** @type {!shaka.extern.TextDisplayerConfiguration} */(
|
||
Object.assign({}, config));
|
||
this.previewCue_ = new shaka.text.Cue(
|
||
Number.NEGATIVE_INFINITY, Number.POSITIVE_INFINITY, exampleText);
|
||
this.updateCaptions_(/* forceUpdate= */ true);
|
||
}
|
||
|
||
/**
|
||
* Clears temporary text style preview settings.
|
||
* @export
|
||
*/
|
||
clearTextStylePreview() {
|
||
if (!this.previewConfig_ && !this.previewCue_) {
|
||
return;
|
||
}
|
||
|
||
this.previewConfig_ = null;
|
||
this.previewCue_ = null;
|
||
this.showingPreviewCue_ = false;
|
||
this.updateCaptions_(/* forceUpdate= */ true);
|
||
}
|
||
|
||
|
||
/**
|
||
* @override
|
||
* @export
|
||
*/
|
||
append(cues) {
|
||
// Clone the cues list for performance optimization. We can avoid the cues
|
||
// list growing during the comparisons for duplicate cues.
|
||
// See: https://github.com/shaka-project/shaka-player/issues/3018
|
||
const cuesList = [...this.cues_];
|
||
for (const cue of shaka.text.Utils.removeDuplicates(cues)) {
|
||
// When a VTT cue spans a segment boundary, the cue will be duplicated
|
||
// into two segments.
|
||
// To avoid displaying duplicate cues, if the current cue list already
|
||
// contains the cue, skip it.
|
||
const containsCue = cuesList.some(
|
||
(cueInList) => shaka.text.Cue.equal(cueInList, cue));
|
||
if (!containsCue) {
|
||
this.cues_.push(cue);
|
||
}
|
||
}
|
||
if (this.cues_.length) {
|
||
this.configureCaptionsTimer_();
|
||
}
|
||
|
||
this.updateCaptions_();
|
||
}
|
||
|
||
|
||
/**
|
||
* @override
|
||
* @export
|
||
*/
|
||
destroy() {
|
||
// Return resolved promise if destroy() has been called.
|
||
if (!this.textContainer_) {
|
||
return Promise.resolve();
|
||
}
|
||
|
||
// Remove the text container element from the UI.
|
||
if (this.textContainer_.parentElement) {
|
||
this.videoContainer_.removeChild(this.textContainer_);
|
||
}
|
||
this.textContainer_ = null;
|
||
|
||
this.isTextVisible_ = false;
|
||
this.cues_ = [];
|
||
this.previewConfig_ = null;
|
||
this.previewCue_ = null;
|
||
this.showingPreviewCue_ = false;
|
||
this.captionsTimer_?.stop();
|
||
this.captionsTimer_ = null;
|
||
this.applyVisibilityTimer_?.stop();
|
||
this.applyVisibilityTimer_ = null;
|
||
|
||
this.currentCuesMap_.clear();
|
||
|
||
// Tear-down the event manager to ensure messages stop moving around.
|
||
this.eventManager_?.release();
|
||
this.eventManager_ = null;
|
||
|
||
this.resizeObserver_?.disconnect();
|
||
this.resizeObserver_ = null;
|
||
|
||
this.visibilityObserver_?.disconnect();
|
||
this.visibilityObserver_ = null;
|
||
|
||
return Promise.resolve();
|
||
}
|
||
|
||
|
||
/**
|
||
* @override
|
||
* @export
|
||
*/
|
||
remove(start, end) {
|
||
// Return false if destroy() has been called.
|
||
if (!this.textContainer_) {
|
||
return false;
|
||
}
|
||
|
||
// Remove the cues out of the time range.
|
||
const oldNumCues = this.cues_.length;
|
||
this.cues_ = this.cues_.filter(
|
||
(cue) => cue.startTime < start || cue.endTime >= end);
|
||
// If anything was actually removed in this process, force the captions to
|
||
// update. This makes sure that the currently-displayed cues will stop
|
||
// displaying if removed (say, due to the user changing languages).
|
||
const forceUpdate = oldNumCues > this.cues_.length;
|
||
this.updateCaptions_(forceUpdate);
|
||
if (!this.cues_.length) {
|
||
this.configureCaptionsTimer_();
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
|
||
/**
|
||
* @override
|
||
* @export
|
||
*/
|
||
isTextVisible() {
|
||
return this.isTextVisible_;
|
||
}
|
||
|
||
/**
|
||
* @override
|
||
* @export
|
||
*/
|
||
setTextVisibility(on) {
|
||
this.isTextVisible_ = on;
|
||
if (this.isTextVisible_) {
|
||
if (!this.textContainer_.parentElement) {
|
||
this.videoContainer_.appendChild(this.textContainer_);
|
||
}
|
||
this.updateCaptions_(/* forceUpdate= */ true);
|
||
} else {
|
||
if (this.textContainer_.parentElement) {
|
||
this.videoContainer_.removeChild(this.textContainer_);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @override
|
||
* @export
|
||
*/
|
||
setTextLanguage(language) {
|
||
if (language && language != 'und') {
|
||
this.textContainer_.setAttribute('lang', language);
|
||
} else {
|
||
this.textContainer_.setAttribute('lang', '');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @private
|
||
*/
|
||
configureCaptionsTimer_() {
|
||
if (this.captionsTimer_) {
|
||
if (this.cues_.length && !this.renderSuspended_) {
|
||
const captionsUpdatePeriod = this.config_ ?
|
||
this.config_.captionsUpdatePeriod : 0.25;
|
||
const updateTime = captionsUpdatePeriod /
|
||
Math.max(1, Math.abs(this.video_.playbackRate));
|
||
this.captionsTimer_.tickEvery(updateTime);
|
||
} else {
|
||
this.captionsTimer_.stop();
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @return {?shaka.extern.TextDisplayerConfiguration}
|
||
* @private
|
||
*/
|
||
getActiveConfig_() {
|
||
return this.previewConfig_ || this.config_;
|
||
}
|
||
|
||
/**
|
||
* @private
|
||
*/
|
||
applyVisibility_() {
|
||
const video = /** @type {HTMLVideoElement} */(this.video_);
|
||
let isPiPActive = false;
|
||
if (window.documentPictureInPicture &&
|
||
window.documentPictureInPicture.window) {
|
||
isPiPActive = true;
|
||
} else if (document.pictureInPictureElement === video) {
|
||
isPiPActive = true;
|
||
}
|
||
let isFullscreenActive = false;
|
||
if (document.fullscreenEnabled &&
|
||
document.fullscreenElement === this.videoContainer_) {
|
||
isFullscreenActive = true;
|
||
} else if (video.webkitSupportsFullscreen) {
|
||
isFullscreenActive = video.webkitDisplayingFullscreen;
|
||
}
|
||
const pageVisible = document.visibilityState === 'visible';
|
||
const suspendWhenHidden = this.config_?.suspendRenderingWhenHidden ?? true;
|
||
const actuallyVisible = !suspendWhenHidden || isPiPActive ||
|
||
isFullscreenActive ||
|
||
(pageVisible && this.isContainerActuallyVisible_);
|
||
if (actuallyVisible) {
|
||
if (this.renderSuspended_) {
|
||
this.renderSuspended_ = false;
|
||
this.configureCaptionsTimer_();
|
||
requestAnimationFrame(() => {
|
||
if (!this.textContainer_) {
|
||
return;
|
||
}
|
||
|
||
if (this.textContainer_.parentElement && this.isTextVisible_) {
|
||
this.updateCaptions_();
|
||
}
|
||
});
|
||
}
|
||
} else {
|
||
if (!this.renderSuspended_) {
|
||
this.renderSuspended_ = true;
|
||
if (this.captionsTimer_) {
|
||
this.captionsTimer_.stop();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @private
|
||
*/
|
||
isElementUnderTextContainer_(elemToCheck) {
|
||
while (elemToCheck != null) {
|
||
if (elemToCheck == this.textContainer_) {
|
||
return true;
|
||
}
|
||
elemToCheck = elemToCheck.parentElement;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* @param {!Array<!shaka.text.Cue>} cues
|
||
* @param {!HTMLElement} container
|
||
* @param {number} currentTime
|
||
* @param {!Array<!shaka.text.Cue>} parents
|
||
* @private
|
||
*/
|
||
updateCuesRecursive_(cues, container, currentTime, parents) {
|
||
const config = this.getActiveConfig_();
|
||
|
||
// Set to true if the cues have changed in some way, which will require
|
||
// DOM changes. E.g. if a cue was added or removed.
|
||
let updateDOM = false;
|
||
/**
|
||
* The elements to remove from the DOM.
|
||
* Some of these elements may be added back again, if their corresponding
|
||
* cue is in toPlant.
|
||
* These elements are only removed if updateDOM is true.
|
||
* @type {!Array<!HTMLElement>}
|
||
*/
|
||
const toUproot = [];
|
||
/**
|
||
* The cues whose corresponding elements should be in the DOM.
|
||
* Some of these might be new, some might have been displayed beforehand.
|
||
* These will only be added if updateDOM is true.
|
||
* @type {!Array<!shaka.text.Cue>}
|
||
*/
|
||
const toPlant = [];
|
||
for (const cue of cues) {
|
||
parents.push(cue);
|
||
|
||
let cueKey = cue;
|
||
|
||
let cueRegistry = this.currentCuesMap_.get(cue);
|
||
if (!cueRegistry && config &&
|
||
config.positionArea != shaka.config.PositionArea.DEFAULT) {
|
||
for (const key of this.currentCuesMap_.keys()) {
|
||
if (shaka.text.Cue.equal(cue, key)) {
|
||
cueKey = key;
|
||
cueRegistry = this.currentCuesMap_.get(key);
|
||
}
|
||
}
|
||
}
|
||
const shouldBeDisplayed =
|
||
cue.startTime <= currentTime && cue.endTime > currentTime;
|
||
let wrapper = cueRegistry ? cueRegistry.wrapper : null;
|
||
|
||
if (cueRegistry) {
|
||
// If the cues are replanted, all existing cues should be uprooted,
|
||
// even ones which are going to be planted again.
|
||
toUproot.push(cueRegistry.cueElement);
|
||
|
||
// Also uproot all displayed region elements.
|
||
if (cueRegistry.regionElement) {
|
||
toUproot.push(cueRegistry.regionElement);
|
||
}
|
||
|
||
// If the cue should not be displayed, remove it entirely.
|
||
if (!shouldBeDisplayed) {
|
||
// Since something has to be removed, we will need to update the DOM.
|
||
updateDOM = true;
|
||
this.currentCuesMap_.delete(cueKey);
|
||
cueRegistry = null;
|
||
}
|
||
}
|
||
|
||
if (shouldBeDisplayed) {
|
||
toPlant.push(cue);
|
||
if (!cueRegistry) {
|
||
// The cue has to be made!
|
||
this.createCue_(cue, parents);
|
||
cueRegistry = this.currentCuesMap_.get(cueKey);
|
||
wrapper = cueRegistry.wrapper;
|
||
updateDOM = true;
|
||
} else if (!this.isElementUnderTextContainer_(wrapper)) {
|
||
// We found that the wrapper needs to be in the DOM
|
||
updateDOM = true;
|
||
}
|
||
}
|
||
|
||
// Recursively check the nested cues, to see if they need to be added or
|
||
// removed.
|
||
// If wrapper is null, that means that the cue is not only not being
|
||
// displayed currently, it also was not removed this tick. So it's
|
||
// guaranteed that the children will neither need to be added nor removed.
|
||
if (cue.nestedCues.length > 0 && wrapper) {
|
||
this.updateCuesRecursive_(
|
||
cue.nestedCues, wrapper, currentTime, parents);
|
||
}
|
||
|
||
const topCue = parents.pop();
|
||
goog.asserts.assert(topCue == cue, 'Parent cues should be kept in order');
|
||
}
|
||
|
||
if (updateDOM) {
|
||
for (const element of toUproot) {
|
||
// NOTE: Because we uproot shared region elements, too, we might hit an
|
||
// element here that has no parent because we've already processed it.
|
||
if (element.parentElement) {
|
||
element.parentElement.removeChild(element);
|
||
}
|
||
}
|
||
toPlant.sort((a, b) => {
|
||
if (a.startTime != b.startTime) {
|
||
return a.startTime - b.startTime;
|
||
} else {
|
||
return a.endTime - b.endTime;
|
||
}
|
||
});
|
||
for (const cue of toPlant) {
|
||
let cueRegistry = this.currentCuesMap_.get(cue);
|
||
if (!cueRegistry && config &&
|
||
config.positionArea != shaka.config.PositionArea.DEFAULT) {
|
||
for (const key of this.currentCuesMap_.keys()) {
|
||
if (shaka.text.Cue.equal(cue, key)) {
|
||
cueRegistry = this.currentCuesMap_.get(key);
|
||
}
|
||
}
|
||
}
|
||
goog.asserts.assert(cueRegistry, 'cueRegistry should exist.');
|
||
if (cueRegistry.regionElement) {
|
||
if (cueRegistry.regionElement.contains(container)) {
|
||
cueRegistry.regionElement.removeChild(container);
|
||
}
|
||
container.appendChild(cueRegistry.regionElement);
|
||
cueRegistry.regionElement.appendChild(cueRegistry.cueElement);
|
||
} else {
|
||
container.appendChild(cueRegistry.cueElement);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @param {!Array<!shaka.text.Cue>} cues
|
||
* @param {number} currentTime
|
||
* @return {boolean}
|
||
* @private
|
||
*/
|
||
hasCueAtTime_(cues, currentTime) {
|
||
return cues.some((cue) => {
|
||
return cue.startTime <= currentTime && cue.endTime > currentTime &&
|
||
this.hasCueContent_(cue);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* @param {!shaka.text.Cue} cue
|
||
* @return {boolean}
|
||
* @private
|
||
*/
|
||
hasCueContent_(cue) {
|
||
const text = cue.payload.replace(/[\u00a0\u200B]/g, ' ').trim();
|
||
return !!text || !!cue.backgroundImage ||
|
||
cue.nestedCues.some((nestedCue) => this.hasCueContent_(nestedCue));
|
||
}
|
||
|
||
/**
|
||
* Display the current captions.
|
||
* @param {boolean=} forceUpdate
|
||
* @private
|
||
*/
|
||
updateCaptions_(forceUpdate = false) {
|
||
if (!this.textContainer_ || this.renderSuspended_) {
|
||
return;
|
||
}
|
||
|
||
const config = this.getActiveConfig_();
|
||
const delay = config?.subtitleDelay ?? 0;
|
||
const currentTime = this.video_.currentTime - delay;
|
||
const showPreviewCue = !!this.previewCue_ &&
|
||
!this.hasCueAtTime_(this.cues_, currentTime);
|
||
if (showPreviewCue != this.showingPreviewCue_) {
|
||
forceUpdate = true;
|
||
this.showingPreviewCue_ = showPreviewCue;
|
||
}
|
||
|
||
if (showPreviewCue) {
|
||
// Force a full clear if we are showing a preview. This prevents
|
||
// "stacking" bugs because the preview cue object changes
|
||
// frequently during hover.
|
||
forceUpdate = true;
|
||
}
|
||
const shouldBeVisible = this.isTextVisible_ || showPreviewCue;
|
||
if (shouldBeVisible) {
|
||
if (!this.textContainer_.parentElement) {
|
||
this.videoContainer_.appendChild(this.textContainer_);
|
||
}
|
||
} else {
|
||
if (this.textContainer_.parentElement) {
|
||
this.videoContainer_.removeChild(this.textContainer_);
|
||
}
|
||
}
|
||
|
||
if (!shouldBeVisible || forceUpdate) {
|
||
// Remove child elements from all regions.
|
||
for (const regionElement of this.regionElements_.values()) {
|
||
shaka.util.Dom.removeAllChildren(regionElement);
|
||
}
|
||
// Remove all top-level elements in the text container.
|
||
shaka.util.Dom.removeAllChildren(this.textContainer_);
|
||
// Clear the element maps.
|
||
this.currentCuesMap_.clear();
|
||
this.regionElements_.clear();
|
||
}
|
||
if (shouldBeVisible) {
|
||
// Log currently attached cue elements for verification, later.
|
||
const previousCuesMap = new Map();
|
||
if (goog.DEBUG) {
|
||
for (const cue of this.currentCuesMap_.keys()) {
|
||
previousCuesMap.set(cue, this.currentCuesMap_.get(cue));
|
||
}
|
||
}
|
||
|
||
let cues = showPreviewCue && this.previewCue_ ?
|
||
[this.previewCue_] : this.cues_;
|
||
if (config &&
|
||
config.positionArea != shaka.config.PositionArea.DEFAULT) {
|
||
cues = cues.map((cue) => this.processCueStyle_(cue));
|
||
}
|
||
|
||
// Update the cues.
|
||
this.updateCuesRecursive_(
|
||
cues, this.textContainer_, currentTime, /* parents= */ []);
|
||
|
||
if (goog.DEBUG) {
|
||
// Previously, we had an issue (#2076) where cues sometimes were not
|
||
// properly removed from the DOM. It is not clear if this issue still
|
||
// happens, so the previous fix for it has been changed to an assert.
|
||
for (const cue of previousCuesMap.keys()) {
|
||
if (!this.currentCuesMap_.has(cue)) {
|
||
// TODO: If the problem does not appear again, then we should remove
|
||
// this assert (and the previousCuesMap code) in Shaka v4.
|
||
const cueElement = previousCuesMap.get(cue).cueElement;
|
||
goog.asserts.assert(
|
||
!cueElement.parentNode, 'Cue was not properly removed!');
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/** @private */
|
||
processCueStyle_(cue) {
|
||
const config = /** @type {!shaka.extern.TextDisplayerConfiguration} */(
|
||
this.getActiveConfig_());
|
||
goog.asserts.assert(
|
||
config.positionArea !== shaka.config.PositionArea.DEFAULT,
|
||
'processCueStyle_ is intended to use on non default positioning');
|
||
const modifiedCue = cue.clone();
|
||
shaka.text.Utils.resetCuePositioning(modifiedCue);
|
||
modifiedCue.region = shaka.text.UITextDisplayer.CustomRegion_.value();
|
||
switch (config.positionArea) {
|
||
case shaka.config.PositionArea.TOP_LEFT:
|
||
modifiedCue.textAlign = shaka.text.Cue.textAlign.LEFT;
|
||
modifiedCue.displayAlign = shaka.text.Cue.displayAlign.BEFORE;
|
||
break;
|
||
case shaka.config.PositionArea.TOP_CENTER:
|
||
modifiedCue.textAlign = shaka.text.Cue.textAlign.CENTER;
|
||
modifiedCue.displayAlign = shaka.text.Cue.displayAlign.BEFORE;
|
||
break;
|
||
case shaka.config.PositionArea.TOP_RIGHT:
|
||
modifiedCue.textAlign = shaka.text.Cue.textAlign.RIGHT;
|
||
modifiedCue.displayAlign = shaka.text.Cue.displayAlign.BEFORE;
|
||
break;
|
||
case shaka.config.PositionArea.CENTER_LEFT:
|
||
modifiedCue.textAlign = shaka.text.Cue.textAlign.LEFT;
|
||
modifiedCue.displayAlign = shaka.text.Cue.displayAlign.CENTER;
|
||
break;
|
||
case shaka.config.PositionArea.CENTER:
|
||
modifiedCue.textAlign = shaka.text.Cue.textAlign.CENTER;
|
||
modifiedCue.displayAlign = shaka.text.Cue.displayAlign.CENTER;
|
||
break;
|
||
case shaka.config.PositionArea.CENTER_RIGHT:
|
||
modifiedCue.textAlign = shaka.text.Cue.textAlign.RIGHT;
|
||
modifiedCue.displayAlign = shaka.text.Cue.displayAlign.CENTER;
|
||
break;
|
||
case shaka.config.PositionArea.BOTTOM_LEFT:
|
||
modifiedCue.textAlign = shaka.text.Cue.textAlign.LEFT;
|
||
modifiedCue.displayAlign = shaka.text.Cue.displayAlign.AFTER;
|
||
break;
|
||
case shaka.config.PositionArea.BOTTOM_CENTER:
|
||
modifiedCue.textAlign = shaka.text.Cue.textAlign.CENTER;
|
||
modifiedCue.displayAlign = shaka.text.Cue.displayAlign.AFTER;
|
||
break;
|
||
case shaka.config.PositionArea.BOTTOM_RIGHT:
|
||
modifiedCue.textAlign = shaka.text.Cue.textAlign.RIGHT;
|
||
modifiedCue.displayAlign = shaka.text.Cue.displayAlign.AFTER;
|
||
break;
|
||
}
|
||
return modifiedCue;
|
||
}
|
||
|
||
/**
|
||
* Compute a unique internal id:
|
||
* Regions can reuse the id but have different dimensions, we need to
|
||
* consider those differences
|
||
* @param {shaka.text.CueRegion} region
|
||
* @private
|
||
*/
|
||
generateRegionId_(region) {
|
||
const percentageUnit = shaka.text.CueRegion.units.PERCENTAGE;
|
||
const heightUnit = region.heightUnits == percentageUnit ? '%' : 'px';
|
||
const viewportAnchorUnit =
|
||
region.viewportAnchorUnits == percentageUnit ? '%' : 'px';
|
||
const uniqueRegionId = `${region.id}_${
|
||
region.width}x${region.height}${heightUnit}-${
|
||
region.viewportAnchorX}x${region.viewportAnchorY}${viewportAnchorUnit}-${
|
||
region.regionAnchorX}x${region.regionAnchorY}`;
|
||
|
||
return uniqueRegionId;
|
||
}
|
||
|
||
/**
|
||
* Get or create a region element corresponding to the cue region. These are
|
||
* cached by ID.
|
||
*
|
||
* @param {!shaka.text.Cue} cue
|
||
* @return {!HTMLElement}
|
||
* @private
|
||
*/
|
||
getRegionElement_(cue) {
|
||
const region = cue.region;
|
||
// from https://dvcs.w3.org/hg/text-tracks/raw-file/default/608toVTT/608toVTT.html#caption-window-size
|
||
// if aspect ratio is 4/3, use that value, otherwise, use the 16:9 value
|
||
const lineWidthMultiple = this.aspectRatio_ === 4/3 ? 2.5 : 1.9;
|
||
const lineHeightMultiple = 5.33;
|
||
|
||
const regionId = this.generateRegionId_(region);
|
||
if (this.regionElements_.has(regionId)) {
|
||
return this.regionElements_.get(regionId);
|
||
}
|
||
|
||
const regionElement = shaka.util.Dom.createHTMLElement('span');
|
||
|
||
const linesUnit = shaka.text.CueRegion.units.LINES;
|
||
const percentageUnit = shaka.text.CueRegion.units.PERCENTAGE;
|
||
const pixelUnit = shaka.text.CueRegion.units.PX;
|
||
let heightUnit = region.heightUnits == percentageUnit ? '%' : 'px';
|
||
let widthUnit = region.widthUnits == percentageUnit ? '%' : 'px';
|
||
const viewportAnchorUnit =
|
||
region.viewportAnchorUnits == percentageUnit ? '%' : 'px';
|
||
|
||
regionElement.id = 'shaka-text-region---' + regionId;
|
||
regionElement.classList.add('shaka-text-region');
|
||
|
||
regionElement.style.position = 'absolute';
|
||
|
||
let regionHeight = region.height;
|
||
let regionWidth = region.width;
|
||
|
||
if (region.heightUnits === linesUnit) {
|
||
regionHeight = region.height * lineHeightMultiple;
|
||
heightUnit = '%';
|
||
}
|
||
if (region.widthUnits === linesUnit) {
|
||
regionWidth = region.width * lineWidthMultiple;
|
||
widthUnit = '%';
|
||
}
|
||
regionElement.style.height = regionHeight + heightUnit;
|
||
regionElement.style.width = regionWidth + widthUnit;
|
||
|
||
if (region.viewportAnchorUnits === linesUnit) {
|
||
// from https://dvcs.w3.org/hg/text-tracks/raw-file/default/608toVTT/608toVTT.html#positioning-in-cea-708
|
||
let top = region.viewportAnchorY / 75 * 100;
|
||
const windowWidth = this.aspectRatio_ === 4/3 ? 160 : 210;
|
||
let left = region.viewportAnchorX / windowWidth * 100;
|
||
// adjust top and left values based on the region anchor and window size
|
||
top -= region.regionAnchorY * regionHeight / 100;
|
||
left -= region.regionAnchorX * regionWidth / 100;
|
||
regionElement.style.top = top + '%';
|
||
regionElement.style.left = left + '%';
|
||
} else {
|
||
regionElement.style.top = region.viewportAnchorY -
|
||
region.regionAnchorY * regionHeight / 100 + viewportAnchorUnit;
|
||
regionElement.style.left = region.viewportAnchorX -
|
||
region.regionAnchorX * regionWidth / 100 + viewportAnchorUnit;
|
||
}
|
||
if (region.heightUnits !== pixelUnit &&
|
||
region.widthUnits !== pixelUnit &&
|
||
region.viewportAnchorUnits !== pixelUnit) {
|
||
// Clip region
|
||
const top = parseInt(regionElement.style.top.slice(0, -1), 10) || 0;
|
||
const left = parseInt(regionElement.style.left.slice(0, -1), 10) || 0;
|
||
const height = parseInt(regionElement.style.height.slice(0, -1), 10) || 0;
|
||
const width = parseInt(regionElement.style.width.slice(0, -1), 10) || 0;
|
||
const realTop = Math.max(0, Math.min(100 - height, top));
|
||
const realLeft = Math.max(0, Math.min(100 - width, left));
|
||
regionElement.style.top = realTop + '%';
|
||
regionElement.style.left = realLeft + '%';
|
||
}
|
||
|
||
regionElement.style.display = 'flex';
|
||
regionElement.style.flexDirection = 'column';
|
||
regionElement.style.alignItems = 'center';
|
||
|
||
if (cue.displayAlign == shaka.text.Cue.displayAlign.BEFORE) {
|
||
regionElement.style.justifyContent = 'flex-start';
|
||
} else if (cue.displayAlign == shaka.text.Cue.displayAlign.CENTER) {
|
||
regionElement.style.justifyContent = 'center';
|
||
} else {
|
||
regionElement.style.justifyContent = 'flex-end';
|
||
}
|
||
|
||
this.regionElements_.set(regionId, regionElement);
|
||
return regionElement;
|
||
}
|
||
|
||
/**
|
||
* Creates the object for a cue.
|
||
*
|
||
* @param {!shaka.text.Cue} cue
|
||
* @param {!Array<!shaka.text.Cue>} parents
|
||
* @private
|
||
*/
|
||
createCue_(cue, parents) {
|
||
const isNested = parents.length > 1;
|
||
let type = isNested ? 'span' : 'div';
|
||
if (cue.lineBreak) {
|
||
type = 'br';
|
||
}
|
||
if (cue.rubyTag) {
|
||
type = cue.rubyTag;
|
||
}
|
||
|
||
const needWrapper = !isNested && cue.nestedCues.length > 0;
|
||
|
||
// Nested cues are inline elements. Top-level cues are block elements.
|
||
const cueElement = shaka.util.Dom.createHTMLElement(type);
|
||
if (type != 'br') {
|
||
this.setCaptionStyles_(cueElement, cue, parents, needWrapper);
|
||
}
|
||
|
||
let regionElement = null;
|
||
if (cue.region && cue.region.id) {
|
||
regionElement = this.getRegionElement_(cue);
|
||
}
|
||
|
||
let wrapper = cueElement;
|
||
if (needWrapper) {
|
||
// Create a wrapper element which will serve to contain all children into
|
||
// a single item. This ensures that nested span elements appear
|
||
// horizontally and br elements occupy no vertical space.
|
||
wrapper = shaka.util.Dom.createHTMLElement('span');
|
||
wrapper.classList.add('shaka-text-wrapper');
|
||
wrapper.style.backgroundColor = cue.backgroundColor;
|
||
wrapper.style.lineHeight = 'normal';
|
||
cueElement.appendChild(wrapper);
|
||
}
|
||
|
||
this.currentCuesMap_.set(cue, {cueElement, wrapper, regionElement});
|
||
}
|
||
|
||
/**
|
||
* Compute cue position alignment
|
||
* See https://www.w3.org/TR/webvtt1/#webvtt-cue-position-alignment
|
||
*
|
||
* @param {!shaka.text.Cue} cue
|
||
* @return {!shaka.text.Cue.positionAlign}
|
||
* @private
|
||
*/
|
||
computeCuePositionAlignment_(cue) {
|
||
const Cue = shaka.text.Cue;
|
||
const {direction, positionAlign, textAlign} = cue;
|
||
|
||
if (positionAlign !== Cue.positionAlign.AUTO) {
|
||
// Position align is not AUTO: use it
|
||
return positionAlign;
|
||
}
|
||
|
||
// Position align is AUTO: use text align to compute its value
|
||
|
||
if (textAlign === Cue.textAlign.LEFT ||
|
||
(textAlign === Cue.textAlign.START &&
|
||
direction === Cue.direction.HORIZONTAL_LEFT_TO_RIGHT) ||
|
||
(textAlign === Cue.textAlign.END &&
|
||
direction === Cue.direction.HORIZONTAL_RIGHT_TO_LEFT)) {
|
||
return Cue.positionAlign.LEFT;
|
||
}
|
||
|
||
if (textAlign === Cue.textAlign.RIGHT ||
|
||
(textAlign === Cue.textAlign.START &&
|
||
direction === Cue.direction.HORIZONTAL_RIGHT_TO_LEFT) ||
|
||
(textAlign === Cue.textAlign.END &&
|
||
direction === Cue.direction.HORIZONTAL_LEFT_TO_RIGHT)) {
|
||
return Cue.positionAlign.RIGHT;
|
||
}
|
||
|
||
return Cue.positionAlign.CENTER;
|
||
}
|
||
|
||
/**
|
||
* @param {!HTMLElement} cueElement
|
||
* @param {!shaka.text.Cue} cue
|
||
* @param {!Array<!shaka.text.Cue>} parents
|
||
* @param {boolean} hasWrapper
|
||
* @private
|
||
*/
|
||
setCaptionStyles_(cueElement, cue, parents, hasWrapper) {
|
||
const Cue = shaka.text.Cue;
|
||
const inherit =
|
||
(cb) => shaka.text.UITextDisplayer.inheritProperty_(parents, cb);
|
||
const style = cueElement.style;
|
||
const isLeaf = cue.nestedCues.length == 0;
|
||
const isNested = parents.length > 1;
|
||
|
||
// TODO: wrapLine is not yet supported. Lines always wrap.
|
||
|
||
// White space should be preserved if emitted by the text parser. It's the
|
||
// job of the parser to omit any whitespace that should not be displayed.
|
||
// Using 'pre-wrap' means that whitespace is preserved even at the end of
|
||
// the text, but that lines which overflow can still be broken.
|
||
style.whiteSpace = 'pre-wrap';
|
||
|
||
// Using 'break-spaces' would be better, as it would preserve even trailing
|
||
// spaces, but that only shipped in Chrome 76. As of July 2020, Safari
|
||
// still has not implemented break-spaces, and the original Chromecast will
|
||
// never have this feature since it no longer gets firmware updates.
|
||
// So we need to replace trailing spaces with non-breaking spaces.
|
||
const text = cue.payload.replace(/\s+$/g, (match) => {
|
||
const nonBreakingSpace = '\xa0';
|
||
return nonBreakingSpace.repeat(match.length);
|
||
});
|
||
|
||
style.webkitTextStrokeColor = cue.textStrokeColor;
|
||
style.webkitTextStrokeWidth = cue.textStrokeWidth;
|
||
style.color = cue.color;
|
||
style.direction = cue.direction;
|
||
style.opacity = cue.opacity;
|
||
style.paddingLeft = shaka.text.UITextDisplayer.convertLengthValue_(
|
||
cue.linePadding, cue, this.videoContainer_);
|
||
style.paddingRight =
|
||
shaka.text.UITextDisplayer.convertLengthValue_(
|
||
cue.linePadding, cue, this.videoContainer_);
|
||
style.textCombineUpright = cue.textCombineUpright;
|
||
style.textShadow = cue.textShadow;
|
||
|
||
if (cue.backgroundImage) {
|
||
// Sanitize the URL to prevent CSS injection via single-quote breakout.
|
||
const sanitizedUrl = cue.backgroundImage.replace(/['"()\\]/g, '');
|
||
style.backgroundImage = 'url(\'' + sanitizedUrl + '\')';
|
||
style.backgroundRepeat = 'no-repeat';
|
||
style.backgroundSize = 'contain';
|
||
style.backgroundPosition = 'center';
|
||
|
||
if (cue.backgroundColor) {
|
||
style.backgroundColor = cue.backgroundColor;
|
||
}
|
||
|
||
// Quoting https://www.w3.org/TR/ttml-imsc1.2/:
|
||
// "The width and height (in pixels) of the image resource referenced by
|
||
// smpte:backgroundImage SHALL be equal to the width and height expressed
|
||
// by the tts:extent attribute of the region in which the div element is
|
||
// presented".
|
||
style.width = '100%';
|
||
style.height = '100%';
|
||
} else {
|
||
// If we have both text and nested cues, then style everything; otherwise
|
||
// place the text in its own <span> so the background doesn't fill the
|
||
// whole region.
|
||
let elem;
|
||
if (cue.nestedCues.length) {
|
||
elem = cueElement;
|
||
} else {
|
||
elem = shaka.util.Dom.createHTMLElement('span');
|
||
cueElement.appendChild(elem);
|
||
}
|
||
|
||
if (cue.border) {
|
||
elem.style.border = cue.border;
|
||
}
|
||
if (!hasWrapper) {
|
||
const bgColor = inherit((c) => c.backgroundColor);
|
||
if (bgColor) {
|
||
elem.style.backgroundColor = bgColor;
|
||
} else if (text) {
|
||
// If there is no background, default to a semi-transparent black.
|
||
// Only do this for the text itself.
|
||
elem.style.backgroundColor = 'rgba(0, 0, 0, 0.8)';
|
||
}
|
||
}
|
||
if (text) {
|
||
elem.setAttribute('translate', 'no');
|
||
elem.textContent = text;
|
||
}
|
||
}
|
||
|
||
// The displayAlign attribute specifies the vertical alignment of the
|
||
// captions inside the text container. Before means at the top of the
|
||
// text container, and after means at the bottom.
|
||
if (isNested && !parents[parents.length - 1].isContainer) {
|
||
style.display = 'inline';
|
||
} else {
|
||
style.display = 'flex';
|
||
style.flexDirection = 'column';
|
||
style.alignItems = 'center';
|
||
if (cue.textAlign == Cue.textAlign.LEFT ||
|
||
cue.textAlign == Cue.textAlign.START) {
|
||
style.width = '100%';
|
||
style.alignItems = 'flex-start';
|
||
} else if (cue.textAlign == Cue.textAlign.RIGHT ||
|
||
cue.textAlign == Cue.textAlign.END) {
|
||
style.width = '100%';
|
||
style.alignItems = 'flex-end';
|
||
}
|
||
|
||
if (cue.displayAlign == Cue.displayAlign.BEFORE) {
|
||
style.justifyContent = 'flex-start';
|
||
} else if (cue.displayAlign == Cue.displayAlign.CENTER) {
|
||
style.justifyContent = 'center';
|
||
} else {
|
||
style.justifyContent = 'flex-end';
|
||
}
|
||
}
|
||
|
||
if (!isLeaf) {
|
||
style.margin = '0';
|
||
}
|
||
|
||
style.fontFamily = cue.fontFamily;
|
||
style.fontWeight = cue.fontWeight.toString();
|
||
style.fontStyle = cue.fontStyle;
|
||
style.letterSpacing = cue.letterSpacing;
|
||
const config = this.getActiveConfig_();
|
||
const fontScaleFactor = config ? config.fontScaleFactor : 1;
|
||
if (fontScaleFactor !== 1 || cue.fontSize) {
|
||
// Use browser default (1em) if fontSize is not set or empty
|
||
const fontSize = cue.fontSize || '1em';
|
||
style.fontSize = shaka.text.UITextDisplayer.convertLengthValue_(
|
||
fontSize, cue, this.videoContainer_, fontScaleFactor);
|
||
}
|
||
|
||
// The line attribute defines the positioning of the text container inside
|
||
// the video container.
|
||
// - The line offsets the text container from the top, the right or left of
|
||
// the video viewport as defined by the writing direction.
|
||
// - The value of the line is either as a number of lines, or a percentage
|
||
// of the video viewport height or width.
|
||
// The lineAlign is an alignment for the text container's line.
|
||
// - The Start alignment means the text container’s top side (for horizontal
|
||
// cues), left side (for vertical growing right), or right side (for
|
||
// vertical growing left) is aligned at the line.
|
||
// - The Center alignment means the text container is centered at the line
|
||
// (to be implemented).
|
||
// - The End Alignment means The text container’s bottom side (for
|
||
// horizontal cues), right side (for vertical growing right), or left side
|
||
// (for vertical growing left) is aligned at the line.
|
||
// TODO: Implement line alignment with line number.
|
||
// TODO: Implement lineAlignment of 'CENTER'.
|
||
let line = cue.line;
|
||
if (line != null) {
|
||
let lineInterpretation = cue.lineInterpretation;
|
||
// HACK: the current implementation of UITextDisplayer only handled
|
||
// PERCENTAGE, so we need convert LINE_NUMBER to PERCENTAGE
|
||
if (lineInterpretation == Cue.lineInterpretation.LINE_NUMBER) {
|
||
lineInterpretation = Cue.lineInterpretation.PERCENTAGE;
|
||
let maxLines = 16;
|
||
// The maximum number of lines is different if it is a vertical video.
|
||
if (this.aspectRatio_ && this.aspectRatio_ < 1) {
|
||
maxLines = 32;
|
||
}
|
||
if (line < 0) {
|
||
line = 100 + line / maxLines * 100;
|
||
} else {
|
||
line = line / maxLines * 100;
|
||
}
|
||
}
|
||
if (lineInterpretation == Cue.lineInterpretation.PERCENTAGE) {
|
||
style.position = 'absolute';
|
||
if (cue.writingMode == Cue.writingMode.HORIZONTAL_TOP_TO_BOTTOM) {
|
||
style.width = '100%';
|
||
if (cue.lineAlign == Cue.lineAlign.START) {
|
||
style.top = line + '%';
|
||
} else if (cue.lineAlign == Cue.lineAlign.END) {
|
||
style.bottom = (100 - line) + '%';
|
||
}
|
||
} else if (cue.writingMode == Cue.writingMode.VERTICAL_LEFT_TO_RIGHT) {
|
||
style.height = '100%';
|
||
if (cue.lineAlign == Cue.lineAlign.START) {
|
||
style.left = line + '%';
|
||
} else if (cue.lineAlign == Cue.lineAlign.END) {
|
||
style.right = (100 - line) + '%';
|
||
}
|
||
} else {
|
||
style.height = '100%';
|
||
if (cue.lineAlign == Cue.lineAlign.START) {
|
||
style.right = line + '%';
|
||
} else if (cue.lineAlign == Cue.lineAlign.END) {
|
||
style.left = (100 - line) + '%';
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
style.lineHeight = cue.lineHeight;
|
||
|
||
// The positionAlign attribute is an alignment for the text container in
|
||
// the dimension of the writing direction.
|
||
const computedPositionAlign = this.computeCuePositionAlignment_(cue);
|
||
// According to the W3C WebVTT specification
|
||
// See: https://www.w3.org/TR/webvtt1/#webvtt-cue-position
|
||
// The position of the cue box depends on the cue alignment.
|
||
// If align: start, then the position specifies the left edge.
|
||
// If align: center, then it specifies the center of the cue box.
|
||
// If align: end, then it refers to the right edge.
|
||
if (computedPositionAlign == Cue.positionAlign.LEFT) {
|
||
style.cssFloat = 'left';
|
||
if (cue.position !== null) {
|
||
style.position = 'absolute';
|
||
if (cue.writingMode == Cue.writingMode.HORIZONTAL_TOP_TO_BOTTOM) {
|
||
style.left = cue.position + '%';
|
||
style.width = 'auto';
|
||
} else {
|
||
style.top = cue.position + '%';
|
||
}
|
||
}
|
||
} else if (computedPositionAlign == Cue.positionAlign.RIGHT) {
|
||
style.cssFloat = 'right';
|
||
if (cue.position !== null) {
|
||
style.position = 'absolute';
|
||
if (cue.writingMode == Cue.writingMode.HORIZONTAL_TOP_TO_BOTTOM) {
|
||
style.right = (100 - cue.position) + '%';
|
||
style.width = 'auto';
|
||
} else {
|
||
style.bottom = cue.position + '%';
|
||
}
|
||
}
|
||
} else {
|
||
if (cue.position !== null && cue.position != 50) {
|
||
style.position = 'absolute';
|
||
if (cue.writingMode == Cue.writingMode.HORIZONTAL_TOP_TO_BOTTOM) {
|
||
style.left = cue.position + '%';
|
||
style.width = 'auto';
|
||
} else {
|
||
style.top = cue.position + '%';
|
||
}
|
||
if (cue.size) {
|
||
style.transform = 'translateX(-50%)';
|
||
}
|
||
}
|
||
}
|
||
|
||
style.textAlign = cue.textAlign;
|
||
style.textDecoration = cue.textDecoration.join(' ');
|
||
style.writingMode = cue.writingMode;
|
||
|
||
// Old versions of Chromium, which may be found in certain versions of Tizen
|
||
// and WebOS, may require the prefixed version: webkitWritingMode.
|
||
// https://caniuse.com/css-writing-mode
|
||
// However, testing shows that Tizen 3, at least, has a 'writingMode'
|
||
// property, but the setter for it does nothing. Therefore we need to
|
||
// detect that and fall back to the prefixed version in this case, too.
|
||
if (!('writingMode' in document.documentElement.style) ||
|
||
style.writingMode != cue.writingMode) {
|
||
// Note that here we do not bother to check for webkitWritingMode support
|
||
// explicitly. We try the unprefixed version, then fall back to the
|
||
// prefixed version unconditionally.
|
||
style.webkitWritingMode = cue.writingMode;
|
||
}
|
||
|
||
// The size is a number giving the size of the text container, to be
|
||
// interpreted as a percentage of the video, as defined by the writing
|
||
// direction.
|
||
if (cue.size) {
|
||
if (cue.writingMode == Cue.writingMode.HORIZONTAL_TOP_TO_BOTTOM) {
|
||
style.width = cue.size + '%';
|
||
} else {
|
||
style.height = cue.size + '%';
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Returns info about provided lengthValue
|
||
* @example 100px => { value: 100, unit: 'px' }
|
||
* @param {?string} lengthValue
|
||
*
|
||
* @return {?{ value: number, unit: string }}
|
||
* @private
|
||
*/
|
||
static getLengthValueInfo_(lengthValue) {
|
||
const matches = new RegExp(/(\d*\.?\d+)([a-z]+|%+)/).exec(lengthValue);
|
||
|
||
if (!matches) {
|
||
return null;
|
||
}
|
||
|
||
return {
|
||
value: Number(matches[1]),
|
||
unit: matches[2],
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Converts length value to an absolute value in pixels.
|
||
* If lengthValue is already an absolute value it will not
|
||
* be modified. Relative lengthValue will be converted to an
|
||
* absolute value in pixels based on Computed Cell Size
|
||
*
|
||
* @param {string} lengthValue
|
||
* @param {!shaka.text.Cue} cue
|
||
* @param {HTMLElement} videoContainer
|
||
* @param {number=} scaleFactor
|
||
* @return {string}
|
||
* @private
|
||
*/
|
||
static convertLengthValue_(lengthValue, cue, videoContainer,
|
||
scaleFactor = 1) {
|
||
const lengthValueInfo =
|
||
shaka.text.UITextDisplayer.getLengthValueInfo_(lengthValue);
|
||
|
||
if (!lengthValueInfo) {
|
||
return lengthValue;
|
||
}
|
||
|
||
const {unit, value} = lengthValueInfo;
|
||
|
||
const realValue = value * scaleFactor;
|
||
|
||
switch (unit) {
|
||
case '%':
|
||
return shaka.text.UITextDisplayer.getAbsoluteLengthInPixels_(
|
||
realValue / 100, cue, videoContainer);
|
||
case 'c':
|
||
return shaka.text.UITextDisplayer.getAbsoluteLengthInPixels_(
|
||
realValue, cue, videoContainer);
|
||
default:
|
||
return realValue + unit;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Returns computed absolute length value in pixels based on cell
|
||
* and a video container size
|
||
* @param {number} value
|
||
* @param {!shaka.text.Cue} cue
|
||
* @param {HTMLElement} videoContainer
|
||
* @return {string}
|
||
*
|
||
* @private
|
||
*/
|
||
static getAbsoluteLengthInPixels_(value, cue, videoContainer) {
|
||
const containerHeight = videoContainer.clientHeight;
|
||
|
||
return (containerHeight * value / cue.cellResolution.rows) + 'px';
|
||
}
|
||
|
||
/**
|
||
* Inherits a property from the parent Cue elements. If the value is falsy,
|
||
* it is assumed to be inherited from the parent. This returns null if the
|
||
* value isn't found.
|
||
*
|
||
* @param {!Array<!shaka.text.Cue>} parents
|
||
* @param {function(!shaka.text.Cue):?T} cb
|
||
* @return {?T}
|
||
* @template T
|
||
* @private
|
||
*/
|
||
static inheritProperty_(parents, cb) {
|
||
for (let i = parents.length - 1; i >= 0; i--) {
|
||
const val = cb(parents[i]);
|
||
if (val || val === 0) {
|
||
return val;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
};
|
||
|
||
/**
|
||
* @private {!shaka.util.Lazy<!shaka.text.CueRegion>}
|
||
*/
|
||
shaka.text.UITextDisplayer.CustomRegion_ = new shaka.util.Lazy(() => {
|
||
const region = new shaka.text.CueRegion();
|
||
region.id = 'shaka-custom-region';
|
||
region.height = 90;
|
||
region.width = 90;
|
||
region.viewportAnchorX = 5;
|
||
region.viewportAnchorY = 5;
|
||
return region;
|
||
});
|