Files
shaka-player/ui/text_displayer.js
T
michellezhuo 030147c7f4 Avoid displaying duplicate vtt cues
Closes #2497

Change-Id: Ibc4de196f500d8685244217f9a056d8b12c05f8a
2020-04-20 22:58:45 +00:00

459 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** @license
* Copyright 2016 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
goog.provide('shaka.ui.TextDisplayer');
goog.require('shaka.util.Dom');
/**
* @implements {shaka.extern.TextDisplayer}
* @final
* @export
*/
shaka.ui.TextDisplayer = class {
/**
* Constructor.
* @param {HTMLMediaElement} video
* @param {HTMLElement} videoContainer
*/
constructor(video, videoContainer) {
/** @private {boolean} */
this.isTextVisible_ = false;
/** @private {!Array.<!shaka.extern.Cue>} */
this.cues_ = [];
/** @private {HTMLMediaElement} */
this.video_ = video;
/** @private {HTMLElement} */
this.videoContainer_ = videoContainer;
/** @type {HTMLElement} */
this.textContainer_ = shaka.util.Dom.createHTMLElement('div');
this.textContainer_.classList.add('shaka-text-container');
this.videoContainer_.appendChild(this.textContainer_);
/**
* The captions' update period in seconds.
* @private {number}
*/
const updatePeriod = 0.25;
/** @private {shaka.util.Timer} */
this.captionsTimer_ = new shaka.util.Timer(() => {
this.updateCaptions_();
}).tickEvery(updatePeriod);
/** private {Map.<!shaka.extern.Cue, !HTMLElement>} */
this.currentCuesMap_ = new Map();
}
/**
* @override
* @export
*/
append(cues) {
for (const cue of 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 = this.cues_.some((cueInList) => {
if (cueInList.payload == cue.payload &&
cueInList.startTime == cue.startTime &&
cueInList.endTime == cue.endTime) {
return true;
}
return false;
});
if (!containsCue) {
this.cues_.push(cue);
}
}
}
/**
* @override
* @export
*/
destroy() {
// Remove the text container element from the UI.
this.videoContainer_.removeChild(this.textContainer_);
this.textContainer_ = null;
this.isTextVisible_ = false;
this.cues_ = [];
if (this.captionsTimer_) {
this.captionsTimer_.stop();
}
this.currentCuesMap_.clear();
}
/**
* @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.
this.cues_ = this.cues_.filter(
(cue) => cue.startTime < start || cue.endTime >= end);
this.updateCaptions_();
return true;
}
/**
* @override
* @export
*/
isTextVisible() {
return this.isTextVisible_;
}
/**
* @override
* @export
*/
setTextVisibility(on) {
this.isTextVisible_ = on;
}
/**
* Display the current captions.
* @private
*/
updateCaptions_() {
const currentTime = this.video_.currentTime;
// Return true if the cue should be displayed at the current time point.
const shouldCueBeDisplayed = (cue) => {
return this.cues_.includes(cue) && this.isTextVisible_ &&
cue.startTime <= currentTime && cue.endTime >= currentTime;
};
// For each cue in the current cues map, if the cue's end time has passed,
// remove the entry from the map, and remove the captions from the page.
for (const cue of this.currentCuesMap_.keys()) {
if (!shouldCueBeDisplayed(cue)) {
const captions = this.currentCuesMap_.get(cue);
this.textContainer_.removeChild(captions);
this.currentCuesMap_.delete(cue);
}
}
// Sometimes we don't remove a cue element correctly. So check all the
// child nodes and remove any that don't have an associated cue.
const expectedChildren = new Set(this.currentCuesMap_.values());
for (const child of Array.from(this.textContainer_.childNodes)) {
if (!expectedChildren.has(child)) {
this.textContainer_.removeChild(child);
}
}
// Get the current cues that should be added to display. If the cue is not
// being displayed already, add it to the map, and add the captions onto the
// page.
const currentCues = this.cues_.filter((cue) => {
return shouldCueBeDisplayed(cue) && !this.currentCuesMap_.has(cue);
}).sort((a, b) => {
if (a.startTime != b.startTime) {
return a.startTime - b.startTime;
} else {
return a.endTime - b.endTime;
}
});
for (const cue of currentCues) {
this.displayCue_(this.textContainer_, cue);
}
}
/**
* Displays a nested cue
*
* @param {Element} container
* @param {!shaka.extern.Cue} cue
* @return {!Element} the created captions container
* @private
*/
displayNestedCue_(container, cue) {
const captions = shaka.util.Dom.createHTMLElement('span');
if (cue.spacer) {
captions.style.display = 'block';
} else {
this.setCaptionStyles_(captions, cue, /* isNested= */ true);
}
container.appendChild(captions);
return captions;
}
/**
* Displays a cue
*
* @param {Element} container
* @param {!shaka.extern.Cue} cue
* @private
*/
displayCue_(container, cue) {
if (cue.nestedCues.length) {
const nestedCuesContainer = shaka.util.Dom.createHTMLElement('p');
nestedCuesContainer.style.width = '100%';
this.setCaptionStyles_(nestedCuesContainer, cue, /* isNested= */ false);
for (let i = 0; i < cue.nestedCues.length; i++) {
this.displayNestedCue_(nestedCuesContainer, cue.nestedCues[i]);
}
container.appendChild(nestedCuesContainer);
this.currentCuesMap_.set(cue, nestedCuesContainer);
} else {
this.currentCuesMap_.set(cue, this.displayNestedCue_(container, cue));
}
}
/**
* @param {!HTMLElement} captions
* @param {!shaka.extern.Cue} cue
* @param {boolean} isNested
* @private
*/
setCaptionStyles_(captions, cue, isNested) {
const Cue = shaka.text.Cue;
const captionsStyle = captions.style;
const panelStyle = this.textContainer_.style;
// Set white-space to 'pre-line' to enable showing line breaks in the text.
captionsStyle.whiteSpace = 'pre-line';
captions.textContent = cue.payload;
captionsStyle.backgroundColor = cue.backgroundColor;
captionsStyle.border = cue.border;
captionsStyle.color = cue.color;
captionsStyle.direction = cue.direction;
captionsStyle.opacity = cue.opacity;
captionsStyle.paddingLeft = shaka.ui.TextDisplayer.convertLengthValue_(
cue.linePadding, cue, this.videoContainer_
);
captionsStyle.paddingRight = shaka.ui.TextDisplayer.convertLengthValue_(
cue.linePadding, cue, this.videoContainer_
);
if (cue.backgroundImage) {
captionsStyle.backgroundImage = 'url(\'' + cue.backgroundImage + '\')';
captionsStyle.backgroundRepeat = 'no-repeat';
captionsStyle.backgroundSize = 'contain';
captionsStyle.backgroundPosition = 'center';
if (cue.backgroundColor == '') {
captionsStyle.backgroundColor = 'transparent';
}
}
if (cue.backgroundImage && cue.region) {
const percentageUnit = shaka.text.CueRegion.units.PERCENTAGE;
const heightUnit = cue.region.heightUnits == percentageUnit ? '%' : 'px';
const widthUnit = cue.region.widthUnits == percentageUnit ? '%' : 'px';
captionsStyle.height = cue.region.height + heightUnit;
captionsStyle.width = cue.region.width + widthUnit;
}
// The displayAlign attribute specifys 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 (cue.displayAlign == Cue.displayAlign.BEFORE) {
captionsStyle.justifyContent = 'flex-start';
} else if (cue.displayAlign == Cue.displayAlign.CENTER) {
captionsStyle.justifyContent = 'center';
} else {
captionsStyle.justifyContent = 'flex-end';
}
if (cue.nestedCues.length) {
captionsStyle.alignItems = 'center';
captionsStyle.display = 'flex';
captionsStyle.flexDirection = 'column';
captionsStyle.margin = '0';
}
captionsStyle.fontFamily = cue.fontFamily;
captionsStyle.fontWeight = cue.fontWeight.toString();
captionsStyle.fontStyle = cue.fontStyle;
captionsStyle.letterSpacing = cue.letterSpacing;
captionsStyle.fontSize = shaka.ui.TextDisplayer.convertLengthValue_(
cue.fontSize, cue, this.videoContainer_
);
// 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 containers 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 containers 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'.
if (cue.line) {
if (cue.lineInterpretation == Cue.lineInterpretation.PERCENTAGE) {
if (cue.writingMode == Cue.writingMode.HORIZONTAL_TOP_TO_BOTTOM) {
if (cue.lineAlign == Cue.lineAlign.START) {
panelStyle.top = cue.line + '%';
} else if (cue.lineAlign == Cue.lineAlign.END) {
panelStyle.bottom = cue.line + '%';
}
} else if (cue.writingMode == Cue.writingMode.VERTICAL_LEFT_TO_RIGHT) {
if (cue.lineAlign == Cue.lineAlign.START) {
panelStyle.left = cue.line + '%';
} else if (cue.lineAlign == Cue.lineAlign.END) {
panelStyle.right = cue.line + '%';
}
} else {
if (cue.lineAlign == Cue.lineAlign.START) {
panelStyle.right = cue.line + '%';
} else if (cue.lineAlign == Cue.lineAlign.END) {
panelStyle.left = cue.line + '%';
}
}
}
} else if (cue.region && cue.region.id && !isNested) {
const percentageUnit = shaka.text.CueRegion.units.PERCENTAGE;
const heightUnit = cue.region.heightUnits == percentageUnit ? '%' : 'px';
const widthUnit = cue.region.widthUnits == percentageUnit ? '%' : 'px';
const viewportAnchorUnit =
cue.region.viewportAnchorUnits == percentageUnit ? '%' : 'px';
captionsStyle.height = cue.region.height + heightUnit;
captionsStyle.width = cue.region.width + widthUnit;
captionsStyle.top = cue.region.viewportAnchorY + viewportAnchorUnit;
captionsStyle.left = cue.region.viewportAnchorX + viewportAnchorUnit;
captionsStyle.position = 'absolute';
}
captionsStyle.lineHeight = cue.lineHeight;
// The position defines the indent of the text container in the
// direction defined by the writing direction.
if (cue.position) {
if (cue.writingMode == Cue.writingMode.HORIZONTAL_TOP_TO_BOTTOM) {
panelStyle.paddingLeft = cue.position;
} else {
panelStyle.paddingTop = cue.position;
}
}
// The positionAlign attribute is an alignment for the text container in
// the dimension of the writing direction.
if (cue.positionAlign == Cue.positionAlign.LEFT) {
panelStyle.cssFloat = 'left';
} else if (cue.positionAlign == Cue.positionAlign.RIGHT) {
panelStyle.cssFloat = 'right';
} else {
panelStyle.margin = 'auto';
}
captionsStyle.textAlign = cue.textAlign;
captionsStyle.textDecoration = cue.textDecoration.join(' ');
captionsStyle.writingMode = 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.writingMode == Cue.writingMode.HORIZONTAL_TOP_TO_BOTTOM) {
panelStyle.width = cue.size + '%';
} else {
panelStyle.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.extern.Cue} cue
* @param {HTMLElement} videoContainer
* @return {string}
* @private
*/
static convertLengthValue_(lengthValue, cue, videoContainer) {
const lengthValueInfo =
shaka.ui.TextDisplayer.getLengthValueInfo_(lengthValue);
if (!lengthValueInfo) {
return lengthValue;
}
const {unit, value} = lengthValueInfo;
switch (unit) {
case '%':
return shaka.ui.TextDisplayer.getAbsoluteLengthInPixels_(
value / 100, cue, videoContainer);
case 'c':
return shaka.ui.TextDisplayer.getAbsoluteLengthInPixels_(
value, cue, videoContainer);
default:
return lengthValue;
}
}
/**
* Returns computed absolute length value in pixels based on cell
* and a video container size
* @param {number} value
* @param {!shaka.extern.Cue} cue
* @param {HTMLElement} videoContainer
* @return {string}
*
* @private
* */
static getAbsoluteLengthInPixels_(value, cue, videoContainer) {
const containerHeight = videoContainer.clientHeight;
return (containerHeight * value / cue.cellResolution.rows) + 'px';
}
};