gecko-dev/dom/media/webvtt/vtt.jsm
alwu ae4242d665 Bug 1305732 - part7 : remove duplicated declaration. r=heycam
We have declared `supportPseudo`, remove the duplicated declaration.

Differential Revision: https://phabricator.services.mozilla.com/D36851

--HG--
extra : moz-landing-system : lando
2019-07-08 04:47:46 +00:00

1623 lines
54 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.

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
var EXPORTED_SYMBOLS = ["WebVTT"];
/**
* Code below is vtt.js the JS WebVTT implementation.
* Current source code can be found at http://github.com/mozilla/vtt.js
*
* Code taken from commit b89bfd06cd788a68c67e03f44561afe833db0849
*/
/**
* Copyright 2013 vtt.js Contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const {Services} = ChromeUtils.import('resource://gre/modules/Services.jsm');
const {XPCOMUtils} = ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
XPCOMUtils.defineLazyPreferenceGetter(this, "supportPseudo",
"media.webvtt.pseudo.enabled", false);
XPCOMUtils.defineLazyPreferenceGetter(this, "DEBUG_LOG",
"media.webvtt.debug.logging", false);
(function(global) {
function LOG(message) {
if (DEBUG_LOG) {
dump("[vtt] " + message + "\n");
}
}
var _objCreate = Object.create || (function() {
function F() {}
return function(o) {
if (arguments.length !== 1) {
throw new Error('Object.create shim only accepts one parameter.');
}
F.prototype = o;
return new F();
};
})();
// Creates a new ParserError object from an errorData object. The errorData
// object should have default code and message properties. The default message
// property can be overriden by passing in a message parameter.
// See ParsingError.Errors below for acceptable errors.
function ParsingError(errorData, message) {
this.name = "ParsingError";
this.code = errorData.code;
this.message = message || errorData.message;
}
ParsingError.prototype = _objCreate(Error.prototype);
ParsingError.prototype.constructor = ParsingError;
// ParsingError metadata for acceptable ParsingErrors.
ParsingError.Errors = {
BadSignature: {
code: 0,
message: "Malformed WebVTT signature."
},
BadTimeStamp: {
code: 1,
message: "Malformed time stamp."
}
};
// See spec, https://w3c.github.io/webvtt/#collect-a-webvtt-timestamp.
function collectTimeStamp(input) {
function computeSeconds(h, m, s, f) {
if (m > 59 || s > 59) {
return null;
}
// The attribute of the milli-seconds can only be three digits.
if (f.length !== 3) {
return null;
}
return (h | 0) * 3600 + (m | 0) * 60 + (s | 0) + (f | 0) / 1000;
}
let timestamp = input.match(/^(\d+:)?(\d{2}):(\d{2})\.(\d+)/);
if (!timestamp || timestamp.length !== 5) {
return null;
}
let hours = timestamp[1]? timestamp[1].replace(":", "") : 0;
let minutes = timestamp[2];
let seconds = timestamp[3];
let milliSeconds = timestamp[4];
return computeSeconds(hours, minutes, seconds, milliSeconds);
}
// A settings object holds key/value pairs and will ignore anything but the first
// assignment to a specific key.
function Settings() {
this.values = _objCreate(null);
}
Settings.prototype = {
set: function(k, v) {
if (v !== "") {
this.values[k] = v;
}
},
// Return the value for a key, or a default value.
// If 'defaultKey' is passed then 'dflt' is assumed to be an object with
// a number of possible default values as properties where 'defaultKey' is
// the key of the property that will be chosen; otherwise it's assumed to be
// a single value.
get: function(k, dflt, defaultKey) {
if (defaultKey) {
return this.has(k) ? this.values[k] : dflt[defaultKey];
}
return this.has(k) ? this.values[k] : dflt;
},
// Check whether we have a value for a key.
has: function(k) {
return k in this.values;
},
// Accept a setting if its one of the given alternatives.
alt: function(k, v, a) {
for (let n = 0; n < a.length; ++n) {
if (v === a[n]) {
this.set(k, v);
return true;
}
}
return false;
},
// Accept a setting if its a valid digits value (int or float)
digitsValue: function(k, v) {
if (/^-0+(\.[0]*)?$/.test(v)) { // special case for -0.0
this.set(k, 0.0);
} else if (/^-?\d+(\.[\d]*)?$/.test(v)) {
this.set(k, parseFloat(v));
}
},
// Accept a setting if its a valid percentage.
percent: function(k, v) {
let m;
if ((m = v.match(/^([\d]{1,3})(\.[\d]*)?%$/))) {
v = parseFloat(v);
if (v >= 0 && v <= 100) {
this.set(k, v);
return true;
}
}
return false;
},
// Delete a setting
del: function (k) {
if (this.has(k)) {
delete this.values[k];
}
},
};
// Helper function to parse input into groups separated by 'groupDelim', and
// interprete each group as a key/value pair separated by 'keyValueDelim'.
function parseOptions(input, callback, keyValueDelim, groupDelim) {
let groups = groupDelim ? input.split(groupDelim) : [input];
for (let i in groups) {
if (typeof groups[i] !== "string") {
continue;
}
let kv = groups[i].split(keyValueDelim);
if (kv.length !== 2) {
continue;
}
let k = kv[0];
let v = kv[1];
callback(k, v);
}
}
function parseCue(input, cue, regionList) {
// Remember the original input if we need to throw an error.
let oInput = input;
// 4.1 WebVTT timestamp
function consumeTimeStamp() {
let ts = collectTimeStamp(input);
if (ts === null) {
throw new ParsingError(ParsingError.Errors.BadTimeStamp,
"Malformed timestamp: " + oInput);
}
// Remove time stamp from input.
input = input.replace(/^[^\s\uFFFDa-zA-Z-]+/, "");
return ts;
}
// 4.4.2 WebVTT cue settings
function consumeCueSettings(input, cue) {
let settings = new Settings();
parseOptions(input, function (k, v) {
switch (k) {
case "region":
// Find the last region we parsed with the same region id.
for (let i = regionList.length - 1; i >= 0; i--) {
if (regionList[i].id === v) {
settings.set(k, regionList[i].region);
break;
}
}
break;
case "vertical":
settings.alt(k, v, ["rl", "lr"]);
break;
case "line": {
let vals = v.split(",");
let vals0 = vals[0];
settings.digitsValue(k, vals0);
settings.percent(k, vals0) ? settings.set("snapToLines", false) : null;
settings.alt(k, vals0, ["auto"]);
if (vals.length === 2) {
settings.alt("lineAlign", vals[1], ["start", "center", "end"]);
}
break;
}
case "position": {
let vals = v.split(",");
if (settings.percent(k, vals[0])) {
if (vals.length === 2) {
if (!settings.alt("positionAlign", vals[1], ["line-left", "center", "line-right"])) {
// Remove the "position" value because the "positionAlign" is not expected value.
// It will be set to default value below.
settings.del(k);
}
}
}
break;
}
case "size":
settings.percent(k, v);
break;
case "align":
settings.alt(k, v, ["start", "center", "end", "left", "right"]);
break;
}
}, /:/, /\t|\n|\f|\r| /); // groupDelim is ASCII whitespace
// Apply default values for any missing fields.
// https://w3c.github.io/webvtt/#collect-a-webvtt-block step 11.4.1.3
cue.region = settings.get("region", null);
cue.vertical = settings.get("vertical", "");
cue.line = settings.get("line", "auto");
cue.lineAlign = settings.get("lineAlign", "start");
cue.snapToLines = settings.get("snapToLines", true);
cue.size = settings.get("size", 100);
cue.align = settings.get("align", "center");
cue.position = settings.get("position", "auto");
cue.positionAlign = settings.get("positionAlign", "auto");
}
function skipWhitespace() {
input = input.replace(/^[ \f\n\r\t]+/, "");
}
// 4.1 WebVTT cue timings.
skipWhitespace();
cue.startTime = consumeTimeStamp(); // (1) collect cue start time
skipWhitespace();
if (input.substr(0, 3) !== "-->") { // (3) next characters must match "-->"
throw new ParsingError(ParsingError.Errors.BadTimeStamp,
"Malformed time stamp (time stamps must be separated by '-->'): " +
oInput);
}
input = input.substr(3);
skipWhitespace();
cue.endTime = consumeTimeStamp(); // (5) collect cue end time
// 4.1 WebVTT cue settings list.
skipWhitespace();
consumeCueSettings(input, cue);
}
function emptyOrOnlyContainsWhiteSpaces(input) {
return input == "" || /^[ \f\n\r\t]+$/.test(input);
}
function containsTimeDirectionSymbol(input) {
return input.includes("-->");
}
function maybeIsTimeStampFormat(input) {
return /^\s*(\d+:)?(\d{2}):(\d{2})\.(\d+)\s*-->\s*(\d+:)?(\d{2}):(\d{2})\.(\d+)\s*/.test(input);
}
var ESCAPE = {
"&amp;": "&",
"&lt;": "<",
"&gt;": ">",
"&lrm;": "\u200e",
"&rlm;": "\u200f",
"&nbsp;": "\u00a0"
};
var TAG_NAME = {
c: "span",
i: "i",
b: "b",
u: "u",
ruby: "ruby",
rt: "rt",
v: "span",
lang: "span"
};
var TAG_ANNOTATION = {
v: "title",
lang: "lang"
};
var NEEDS_PARENT = {
rt: "ruby"
};
const PARSE_CONTENT_MODE = {
NORMAL_CUE: "normal_cue",
PSUEDO_CUE: "pseudo_cue",
DOCUMENT_FRAGMENT: "document_fragment",
REGION_CUE: "region_cue",
}
// Parse content into a document fragment.
function parseContent(window, input, mode) {
function nextToken() {
// Check for end-of-string.
if (!input) {
return null;
}
// Consume 'n' characters from the input.
function consume(result) {
input = input.substr(result.length);
return result;
}
let m = input.match(/^([^<]*)(<[^>]+>?)?/);
// The input doesn't contain a complete tag.
if (!m[0]) {
return null;
}
// If there is some text before the next tag, return it, otherwise return
// the tag.
return consume(m[1] ? m[1] : m[2]);
}
// Unescape a string 's'.
function unescape1(e) {
return ESCAPE[e];
}
function unescape(s) {
let m;
while ((m = s.match(/&(amp|lt|gt|lrm|rlm|nbsp);/))) {
s = s.replace(m[0], unescape1);
}
return s;
}
function shouldAdd(current, element) {
return !NEEDS_PARENT[element.localName] ||
NEEDS_PARENT[element.localName] === current.localName;
}
// Create an element for this tag.
function createElement(type, annotation) {
let tagName = TAG_NAME[type];
if (!tagName) {
return null;
}
let element = window.document.createElement(tagName);
let name = TAG_ANNOTATION[type];
if (name) {
element[name] = annotation ? annotation.trim() : "";
}
return element;
}
// https://w3c.github.io/webvtt/#webvtt-timestamp-object
// Return hhhhh:mm:ss.fff
function normalizedTimeStamp(secondsWithFrag) {
let totalsec = parseInt(secondsWithFrag, 10);
let hours = Math.floor(totalsec / 3600);
let minutes = Math.floor(totalsec % 3600 / 60);
let seconds = Math.floor(totalsec % 60);
if (hours < 10) {
hours = "0" + hours;
}
if (minutes < 10) {
minutes = "0" + minutes;
}
if (seconds < 10) {
seconds = "0" + seconds;
}
let f = secondsWithFrag.toString().split(".");
if (f[1]) {
f = f[1].slice(0, 3).padEnd(3, "0");
} else {
f = "000";
}
return hours + ':' + minutes + ':' + seconds + '.' + f;
}
let root;
switch (mode) {
case PARSE_CONTENT_MODE.PSUEDO_CUE:
root = window.document.createElement("span", {pseudo: "::cue"});
break;
case PARSE_CONTENT_MODE.NORMAL_CUE:
case PARSE_CONTENT_MODE.REGION_CUE:
root = window.document.createElement("span");
break;
case PARSE_CONTENT_MODE.DOCUMENT_FRAGMENT:
root = window.document.createDocumentFragment();
break;
}
if (!input) {
root.appendChild(window.document.createTextNode(""));
return root;
}
let current = root,
t,
tagStack = [];
while ((t = nextToken()) !== null) {
if (t[0] === '<') {
if (t[1] === "/") {
// If the closing tag matches, move back up to the parent node.
if (tagStack.length &&
tagStack[tagStack.length - 1] === t.substr(2).replace(">", "")) {
tagStack.pop();
current = current.parentNode;
}
// Otherwise just ignore the end tag.
continue;
}
let ts = collectTimeStamp(t.substr(1, t.length - 1));
let node;
if (ts) {
// Timestamps are lead nodes as well.
node = window.document.createProcessingInstruction("timestamp", normalizedTimeStamp(ts));
current.appendChild(node);
continue;
}
let m = t.match(/^<([^.\s/0-9>]+)(\.[^\s\\>]+)?([^>\\]+)?(\\?)>?$/);
// If we can't parse the tag, skip to the next tag.
if (!m) {
continue;
}
// Try to construct an element, and ignore the tag if we couldn't.
node = createElement(m[1], m[3]);
if (!node) {
continue;
}
// Determine if the tag should be added based on the context of where it
// is placed in the cuetext.
if (!shouldAdd(current, node)) {
continue;
}
// Set the class list (as a list of classes, separated by space).
if (m[2]) {
node.className = m[2].substr(1).replace('.', ' ');
}
// Append the node to the current node, and enter the scope of the new
// node.
tagStack.push(m[1]);
current.appendChild(node);
current = node;
continue;
}
// Text nodes are leaf nodes.
current.appendChild(window.document.createTextNode(unescape(t)));
}
return root;
}
function StyleBox() {
}
// Apply styles to a div. If there is no div passed then it defaults to the
// div on 'this'.
StyleBox.prototype.applyStyles = function(styles, div) {
div = div || this.div;
for (let prop in styles) {
if (styles.hasOwnProperty(prop)) {
div.style[prop] = styles[prop];
}
}
};
StyleBox.prototype.formatStyle = function(val, unit) {
return val === 0 ? 0 : val + unit;
};
// TODO(alwu): remove StyleBox and change other style box to class-based.
class StyleBoxBase {
applyStyles(styles, div) {
div = div || this.div;
Object.assign(div.style, styles);
}
formatStyle(val, unit) {
return val === 0 ? 0 : val + unit;
}
}
// Constructs the computed display state of the cue (a div). Places the div
// into the overlay which should be a block level element (usually a div).
class CueStyleBox extends StyleBoxBase {
constructor(window, cue, containerBox) {
super();
this.cue = cue;
this.div = window.document.createElement("div");
this.cueDiv = parseContent(window, cue.text, supportPseudo ?
PARSE_CONTENT_MODE.PSUEDO_CUE : PARSE_CONTENT_MODE.NORMAL_CUE);
this.div.appendChild(this.cueDiv);
this.containerHeight = containerBox.height;
this.containerWidth = containerBox.width;
this.fontSize = this._getFontSize(containerBox);
this.isCueStyleBox = true;
// As pseudo element won't inherit the parent div's style, so we have to
// set the font size explicitly.
if (supportPseudo) {
this._applyDefaultStylesOnPseudoBackgroundNode();
} else {
this._applyDefaultStylesOnNonPseudoBackgroundNode();
}
this._applyDefaultStylesOnRootNode();
}
getCueBoxPositionAndSize() {
// As `top`, `left`, `width` and `height` are all represented by the
// percentage of the container, we need to convert them to the actual
// number according to the container's size.
const isWritingDirectionHorizontal = this.cue.vertical == "";
let top =
this.containerHeight * this._tranferPercentageToFloat(this.div.style.top),
left =
this.containerWidth * this._tranferPercentageToFloat(this.div.style.left),
width = isWritingDirectionHorizontal ?
this.containerWidth * this._tranferPercentageToFloat(this.div.style.width) :
this.div.clientWidthDouble,
height = isWritingDirectionHorizontal ?
this.div.clientHeightDouble :
this.containerHeight * this._tranferPercentageToFloat(this.div.style.height);
return { top, left, width, height };
}
getFirstLineBoxSize() {
// This size would be automatically adjusted by writing direction. When
// direction is horizontal, it represents box's height. When direction is
// vertical, it represents box's width.
return this.div.firstLineBoxBSize;
}
setBidiRule() {
// This function is a workaround which is used to force the reflow in order
// to use the correct alignment for bidi text. Now this function would be
// called after calculating the final position of the cue box to ensure the
// rendering result is correct. See bug1557882 comment3 for more details.
// TODO : remove this function and set `unicode-bidi` when initiailizing
// the CueStyleBox, after fixing bug1558431.
this.applyStyles({ "unicode-bidi": "plaintext" });
}
/**
* Following methods are private functions, should not use them outside this
* class.
*/
_tranferPercentageToFloat(input) {
return input.replace("%", "") / 100.0;
}
_getFontSize(containerBox) {
// In https://www.w3.org/TR/webvtt1/#applying-css-properties, the spec
// said the font size is '5vh', which means 5% of the viewport height.
// However, if we use 'vh' as a basic unit, it would eventually become
// 5% of screen height, instead of video's viewport height. Therefore, we
// have to use 'px' here to make sure we have the correct font size.
return containerBox.height * 0.05 + "px";
}
_applyDefaultStylesOnPseudoBackgroundNode() {
// most of the properties have been defined in `::cue` in `html.css`, but
// there are some css variables we have to set them dynamically.
this.cueDiv.style.setProperty("--cue-font-size", this.fontSize, "important");
this.cueDiv.style.setProperty("--cue-writing-mode", this._getCueWritingMode(), "important");
}
_applyDefaultStylesOnNonPseudoBackgroundNode() {
// If cue div is not a pseudo element, we should set the default css style
// for it, the reason we need to set these attributes to cueDiv is because
// if we set background on the root node directly, if would cause filling
// too large area for the background color as the size of root node won't
// be adjusted by cue size.
this.applyStyles({
"background-color": "rgba(0, 0, 0, 0.8)",
}, this.cueDiv);
}
// spec https://www.w3.org/TR/webvtt1/#applying-css-properties
_applyDefaultStylesOnRootNode() {
// The variables writing-mode, top, left, width, and height are calculated
// in the spec 7.2, https://www.w3.org/TR/webvtt1/#processing-cue-settings
// spec 7.2.1, calculate 'writing-mode'.
const writingMode = this._getCueWritingMode();
// spec 7.2.2 ~ 7.2.7, calculate 'width', 'height', 'left' and 'top'.
const {width, height, left, top} = this._getCueSizeAndPosition();
this.applyStyles({
"position": "absolute",
// "unicode-bidi": "plaintext", (uncomment this line after fixing bug1558431)
"writing-mode": writingMode,
"top": top,
"left": left,
"width": width,
"height": height,
"overflow-wrap": "break-word",
// "text-wrap": "balance", (we haven't supported this CSS attribute yet)
"white-space": "pre-line",
"font": this.fontSize + " sans-serif",
"color": "rgba(255, 255, 255, 1)",
"white-space": "pre-line",
"text-align": this.cue.align,
});
}
_getCueWritingMode() {
const cue = this.cue;
if (cue.vertical == "") {
return "horizontal-tb";
}
return cue.vertical == "lr" ? "vertical-lr" : "vertical-rl";
}
_getCueSizeAndPosition() {
const cue = this.cue;
// spec 7.2.2, determine the value of maximum size for cue as per the
// appropriate rules from the following list.
let maximumSize;
let computedPosition = cue.computedPosition;
switch (cue.computedPositionAlign) {
case "line-left":
maximumSize = 100 - computedPosition;
break;
case "line-right":
maximumSize = computedPosition;
break;
case "center":
maximumSize = computedPosition <= 50 ?
computedPosition * 2 : (100 - computedPosition) * 2;
break;
}
const size = Math.min(cue.size, maximumSize);
// spec 7.2.5, determine the value of x-position or y-position for cue as
// per the appropriate rules from the following list.
let xPosition = 0.0, yPosition = 0.0;
const isWritingDirectionHorizontal = cue.vertical == "";
switch (cue.computedPositionAlign) {
case "line-left":
if (isWritingDirectionHorizontal) {
xPosition = cue.computedPosition;
} else {
yPosition = cue.computedPosition;
}
break;
case "center":
if (isWritingDirectionHorizontal) {
xPosition = cue.computedPosition - (size / 2);
} else {
yPosition = cue.computedPosition - (size / 2);
}
break;
case "line-right":
if (isWritingDirectionHorizontal) {
xPosition = cue.computedPosition - size;
} else {
yPosition = cue.computedPosition - size;
}
break;
}
// spec 7.2.6, determine the value of whichever of x-position or
// y-position is not yet calculated for cue as per the appropriate rules
// from the following list.
if (!cue.snapToLines) {
if (isWritingDirectionHorizontal) {
yPosition = cue.computedLine;
} else {
xPosition = cue.computedLine;
}
} else {
if (isWritingDirectionHorizontal) {
yPosition = 0;
} else {
xPosition = 0;
}
}
return {
left: xPosition + "%",
top: yPosition + "%",
width: isWritingDirectionHorizontal ? size + "%" : "auto",
height: isWritingDirectionHorizontal ? "auto" : size + "%",
};
}
}
function RegionNodeBox(window, region, container) {
StyleBox.call(this);
let boxLineHeight = container.height * 0.0533 // 0.0533vh ? 5.33vh
let boxHeight = boxLineHeight * region.lines;
let boxWidth = container.width * region.width / 100; // convert percentage to px
let regionNodeStyles = {
position: "absolute",
height: boxHeight + "px",
width: boxWidth + "px",
top: (region.viewportAnchorY * container.height / 100) - (region.regionAnchorY * boxHeight / 100) + "px",
left: (region.viewportAnchorX * container.width / 100) - (region.regionAnchorX * boxWidth / 100) + "px",
lineHeight: boxLineHeight + "px",
writingMode: "horizontal-tb",
backgroundColor: "rgba(0, 0, 0, 0.8)",
wordWrap: "break-word",
overflowWrap: "break-word",
font: (boxLineHeight/1.3) + "px sans-serif",
color: "rgba(255, 255, 255, 1)",
overflow: "hidden",
minHeight: "0px",
maxHeight: boxHeight + "px",
display: "inline-flex",
flexFlow: "column",
justifyContent: "flex-end",
};
this.div = window.document.createElement("div");
this.div.id = region.id; // useless?
this.applyStyles(regionNodeStyles);
}
RegionNodeBox.prototype = _objCreate(StyleBox.prototype);
RegionNodeBox.prototype.constructor = RegionNodeBox;
function RegionCueStyleBox(window, cue) {
StyleBox.call(this);
this.cueDiv = parseContent(window, cue.text, PARSE_CONTENT_MODE.REGION_CUE);
let regionCueStyles = {
position: "relative",
writingMode: "horizontal-tb",
unicodeBidi: "plaintext",
width: "auto",
height: "auto",
textAlign: cue.align,
};
// TODO: fix me, LTR and RTL ? using margin replace the "left/right"
// 6.1.14.3.3
let offset = cue.computedPosition * cue.region.width / 100;
// 6.1.14.3.4
switch (cue.align) {
case "start":
case "left":
regionCueStyles.left = offset + "%";
regionCueStyles.right = "auto";
break;
case "end":
case "right":
regionCueStyles.left = "auto";
regionCueStyles.right = offset + "%";
break;
case "middle":
break;
}
this.div = window.document.createElement("div");
this.applyStyles(regionCueStyles);
this.div.appendChild(this.cueDiv);
}
RegionCueStyleBox.prototype = _objCreate(StyleBox.prototype);
RegionCueStyleBox.prototype.constructor = RegionCueStyleBox;
// Represents the co-ordinates of an Element in a way that we can easily
// compute things with such as if it overlaps or intersects with other boxes.
class BoxPosition {
constructor(obj) {
// Get dimensions by calling getCueBoxPositionAndSize on a CueStyleBox, by
// getting offset properties from an HTMLElement (from the object or its
// `div` property), otherwise look at the regular box properties on the
// object.
const isHTMLElement = !obj.isCueStyleBox && (obj.div || obj.tagName);
obj = obj.isCueStyleBox ? obj.getCueBoxPositionAndSize() : obj.div || obj;
this.top = isHTMLElement ? obj.offsetTop : obj.top;
this.left = isHTMLElement ? obj.offsetLeft : obj.left;
this.width = isHTMLElement ? obj.offsetWidth : obj.width;
this.height = isHTMLElement ? obj.offsetHeight : obj.height;
// This value is smaller than 1 app unit (~= 0.0166 px).
this.fuzz = 0.01;
}
get bottom() {
return this.top + this.height;
}
get right() {
return this.left + this.width;
}
// This function is used for debugging, it will return the box's information.
getBoxInfoInChars() {
return `top=${this.top}, bottom=${this.bottom}, left=${this.left}, ` +
`right=${this.right}, width=${this.width}, height=${this.height}`;
}
// Move the box along a particular axis. Optionally pass in an amount to move
// the box. If no amount is passed then the default is the line height of the
// box.
move(axis, toMove) {
switch (axis) {
case "+x":
LOG(`box's left moved from ${this.left} to ${this.left + toMove}`);
this.left += toMove;
break;
case "-x":
LOG(`box's left moved from ${this.left} to ${this.left - toMove}`);
this.left -= toMove;
break;
case "+y":
LOG(`box's top moved from ${this.top} to ${this.top + toMove}`);
this.top += toMove;
break;
case "-y":
LOG(`box's top moved from ${this.top} to ${this.top - toMove}`);
this.top -= toMove;
break;
}
}
// Check if this box overlaps another box, b2.
overlaps(b2) {
return (this.left < b2.right - this.fuzz) &&
(this.right > b2.left + this.fuzz) &&
(this.top < b2.bottom - this.fuzz) &&
(this.bottom > b2.top + this.fuzz);
}
// Check if this box overlaps any other boxes in boxes.
overlapsAny(boxes) {
for (let i = 0; i < boxes.length; i++) {
if (this.overlaps(boxes[i])) {
return true;
}
}
return false;
}
// Check if this box overlaps any other boxes in boxes.
overlapsAny(boxes) {
for (let i = 0; i < boxes.length; i++) {
if (this.overlaps(boxes[i])) {
return true;
}
}
return false;
}
// Check if this box is within another box.
within(container) {
return (this.top >= container.top - this.fuzz) &&
(this.bottom <= container.bottom + this.fuzz) &&
(this.left >= container.left - this.fuzz) &&
(this.right <= container.right + this.fuzz);
}
// Check whether this box is passed over the specfic axis boundary. The axis
// is based on the canvas coordinates, the `+x` is rightward and `+y` is
// downward.
isOutsideTheAxisBoundary(container, axis) {
switch (axis) {
case "+x":
return this.right > container.right + this.fuzz;
case "-x":
return this.left < container.left - this.fuzz;
case "+y":
return this.bottom > container.bottom + this.fuzz;
case "-y":
return this.top < container.top - this.fuzz;
}
}
// Find the percentage of the area that this box is overlapping with another
// box.
intersectPercentage(b2) {
let x = Math.max(0, Math.min(this.right, b2.right) - Math.max(this.left, b2.left)),
y = Math.max(0, Math.min(this.bottom, b2.bottom) - Math.max(this.top, b2.top)),
intersectArea = x * y;
return intersectArea / (this.height * this.width);
}
}
BoxPosition.prototype.clone = function(){
return new BoxPosition(this);
};
function adjustBoxPosition(styleBox, containerBox, controlBarBox, outputBoxes) {
const cue = styleBox.cue;
const isWritingDirectionHorizontal = cue.vertical == "";
let box = new BoxPosition(styleBox);
if (!box.width || !box.height) {
LOG(`No way to adjust a box with zero width or height.`);
return;
}
// Spec 7.2.10, adjust the positions of boxes according to the appropriate
// steps from the following list. Also, we use offsetHeight/offsetWidth here
// in order to prevent the incorrect positioning caused by CSS transform
// scale.
const fullDimension = isWritingDirectionHorizontal ?
containerBox.height : containerBox.width;
if (cue.snapToLines) {
// The step is the height or width of the line box. We should use font
// size directly, instead of using text box's width or height, because the
// width or height of the box would be changed when the text is wrapped to
// different line. Ex. if text is wrapped to two line, the height or width
// of the box would become 2 times of font size.
let step = styleBox.getFirstLineBoxSize();
if (step == 0) {
return;
}
// spec 7.2.10.4 ~ 7.2.10.6
let line = Math.floor(cue.computedLine + 0.5);
if (cue.vertical == "rl") {
line = -1 * (line + 1);
}
// spec 7.2.10.7 ~ 7.2.10.8
let position = step * line;
if (cue.vertical == "rl") {
position = position - box.width + step;
}
// spec 7.2.10.9
if (line < 0) {
position += fullDimension;
step = -1 * step;
}
// spec 7.2.10.10, move the box to the specific position along the direction.
const movingDirection = isWritingDirectionHorizontal ? "+y" : "+x";
box.move(movingDirection, position);
// spec 7.2.10.11, remember the position as specified position.
let specifiedPosition = box.clone();
// spec 7.2.10.12, let title area be a box that covers all of the videos
// rendering area.
const titleAreaBox = containerBox.clone();
if (controlBarBox) {
titleAreaBox.height -= controlBarBox.height;
}
function isBoxOutsideTheRenderingArea() {
if (isWritingDirectionHorizontal) {
// the top side of the box is above the rendering area, or the bottom
// side of the box is below the rendering area.
return step < 0 && box.top < 0 ||
step > 0 && box.bottom > fullDimension;
}
// the left side of the box is outside the left side of the rendering
// area, or the right side of the box is outside the right side of the
// rendering area.
return step < 0 && box.left < 0 ||
step > 0 && box.right > fullDimension;
}
// spec 7.2.10.13, if none of the boxes in boxes would overlap any of the
// boxes in output, and all of the boxes in boxes are entirely within the
// title area box.
let switched = false;
while (!box.within(titleAreaBox) || box.overlapsAny(outputBoxes)) {
// spec 7.2.10.14, check if we need to switch the direction.
if (isBoxOutsideTheRenderingArea()) {
// spec 7.2.10.17, if `switched` is true, remove all the boxes in
// `boxes`, which means we shouldn't apply any CSS boxes for this cue.
// Therefore, returns null box.
if (switched) {
return null;
}
// spec 7.2.10.18 ~ 7.2.10.20
switched = true;
box = specifiedPosition.clone();
step = -1 * step;
}
// spec 7.2.10.15, moving box along the specific direction.
box.move(movingDirection, step);
}
if (isWritingDirectionHorizontal) {
styleBox.applyStyles({
top: getPercentagePosition(box.top, fullDimension),
});
} else {
styleBox.applyStyles({
left: getPercentagePosition(box.left, fullDimension),
});
}
} else {
// (snap-to-lines if false) spec 7.2.10.1 ~ 7.2.10.2
if (cue.lineAlign != "start") {
const isCenterAlign = cue.lineAlign == "center";
const movingDirection = isWritingDirectionHorizontal ? "-y" : "-x";
if (isWritingDirectionHorizontal) {
box.move(movingDirection, isCenterAlign ? box.height : box.height / 2);
} else {
box.move(movingDirection, isCenterAlign ? box.width : box.width / 2);
}
}
// spec 7.2.10.3
let bestPosition = {},
specifiedPosition = box.clone(),
outsideAreaPercentage = 1; // Highest possible so the first thing we get is better.
let hasFoundBestPosition = false;
const axis = ["-y", "-x", "+x", "+y"];
const toMove = styleBox.getFirstLineBoxSize();
for (let i = 0; i < axis.length && !hasFoundBestPosition; i++) {
while (!box.isOutsideTheAxisBoundary(containerBox, axis[i]) &&
(!box.within(containerBox) || box.overlapsAny(outputBoxes))) {
box.move(axis[i], toMove);
}
// We found a spot where we aren't overlapping anything. This is our
// best position.
if (box.within(containerBox)) {
bestPosition = box.clone();
hasFoundBestPosition = true;
break;
}
let p = box.intersectPercentage(containerBox);
// If we're outside the container box less then we were on our last try
// then remember this position as the best position.
if (outsideAreaPercentage > p) {
bestPosition = box.clone();
outsideAreaPercentage = p;
}
// Reset the box position to the specified position.
box = specifiedPosition.clone();
}
styleBox.applyStyles({
top: getPercentagePosition(box.top, fullDimension),
left: getPercentagePosition(box.left, fullDimension),
});
}
// In order to not be affected by CSS scale, so we use '%' to make sure the
// cue can stick in the right position.
function getPercentagePosition(position, fullDimension) {
return (position / fullDimension) * 100 + "%";
}
return box;
}
function WebVTT() {
// Nothing
}
// Helper to allow strings to be decoded instead of the default binary utf8 data.
WebVTT.StringDecoder = function() {
return {
decode: function(data) {
if (!data) {
return "";
}
if (typeof data !== "string") {
throw new Error("Error - expected string data.");
}
return decodeURIComponent(encodeURIComponent(data));
}
};
};
WebVTT.convertCueToDOMTree = function(window, cuetext) {
if (!window) {
return null;
}
return parseContent(window, cuetext, PARSE_CONTENT_MODE.DOCUMENT_FRAGMENT);
};
function clearAllCuesDiv(overlay) {
while (overlay.firstChild) {
overlay.firstChild.remove();
}
}
// It's used to record how many cues we process in the last `processCues` run.
var lastDisplayedCueNums = 0;
const DIV_COMPUTING_STATE = {
REUSE : 0,
REUSE_AND_CLEAR : 1,
COMPUTE_AND_CLEAR : 2
};
// Runs the processing model over the cues and regions passed to it.
// Spec https://www.w3.org/TR/webvtt1/#processing-model
// @parem window : JS window
// @param cues : the VTT cues are going to be displayed.
// @param overlay : A block level element (usually a div) that the computed cues
// and regions will be placed into.
// @param controls : A Control bar element. Cues' position will be
// affected and repositioned according to it.
WebVTT.processCues = function(window, cues, overlay, controls) {
LOG(`=== processCues ===`);
if (!cues) {
LOG(`clear display and abort processing because of no cue.`);
clearAllCuesDiv(overlay);
lastDisplayedCueNums = 0;
return;
}
let controlBar, controlBarShown;
if (controls) {
// controls is a <div> that is the children of the UA Widget Shadow Root.
controlBar = controls.parentNode.getElementById("controlBar");
controlBarShown = controlBar ? !!controlBar.clientHeight : false;
} else {
// There is no controls element. This only happen to UA Widget because
// it is created lazily.
controlBarShown = false;
}
/**
* This function is used to tell us if we have to recompute or reuse current
* cue's display state. Display state is a DIV element with corresponding
* CSS style to display cue on the screen. When the cue is being displayed
* first time, we will compute its display state. After that, we could reuse
* its state until following conditions happen.
* (1) control changes : it means the rendering area changes so we should
* recompute cues' position.
* (2) cue's `hasBeenReset` flag is true : it means cues' line or position
* property has been modified, we also need to recompute cues' position.
* (3) the amount of showing cues changes : it means some cue would disappear
* but other cues should stay at the same place without recomputing, so we
* can resume their display state.
*/
function getDIVComputingState(cues) {
if (overlay.lastControlBarShownStatus != controlBarShown) {
return DIV_COMPUTING_STATE.COMPUTE_AND_CLEAR;
}
for (let i = 0; i < cues.length; i++) {
if (cues[i].hasBeenReset || !cues[i].displayState) {
return DIV_COMPUTING_STATE.COMPUTE_AND_CLEAR;
}
}
if (lastDisplayedCueNums != cues.length) {
return DIV_COMPUTING_STATE.REUSE_AND_CLEAR;
}
return DIV_COMPUTING_STATE.REUSE;
}
const divState = getDIVComputingState(cues);
overlay.lastControlBarShownStatus = controlBarShown;
if (divState == DIV_COMPUTING_STATE.REUSE) {
LOG(`reuse current cue's display state and abort processing`);
return;
}
clearAllCuesDiv(overlay);
let rootOfCues = window.document.createElement("div");
rootOfCues.style.position = "absolute";
rootOfCues.style.left = "0";
rootOfCues.style.right = "0";
rootOfCues.style.top = "0";
rootOfCues.style.bottom = "0";
overlay.appendChild(rootOfCues);
if (divState == DIV_COMPUTING_STATE.REUSE_AND_CLEAR) {
LOG(`clear display but reuse cues' display state.`);
for (let cue of cues) {
rootOfCues.appendChild(cue.displayState);
}
} else if (divState == DIV_COMPUTING_STATE.COMPUTE_AND_CLEAR) {
LOG(`clear display and recompute cues' display state.`);
let boxPositions = [],
containerBox = new BoxPosition(rootOfCues);
let styleBox, cue, controlBarBox;
if (controlBarShown) {
controlBarBox = new BoxPosition(controlBar);
// Add an empty output box that cover the same region as video control bar.
boxPositions.push(controlBarBox);
}
// https://w3c.github.io/webvtt/#processing-model 6.1.12.1
// Create regionNode
let regionNodeBoxes = {};
let regionNodeBox;
LOG(`lastDisplayedCueNums=${lastDisplayedCueNums}, currentCueNums=${cues.length}`);
lastDisplayedCueNums = cues.length;
for (let i = 0; i < cues.length; i++) {
cue = cues[i];
if (cue.region != null) {
// 6.1.14.1
styleBox = new RegionCueStyleBox(window, cue);
if (!regionNodeBoxes[cue.region.id]) {
// create regionNode
// Adjust the container hieght to exclude the controlBar
let adjustContainerBox = new BoxPosition(rootOfCues);
if (controlBarShown) {
adjustContainerBox.height -= controlBarBox.height;
adjustContainerBox.bottom += controlBarBox.height;
}
regionNodeBox = new RegionNodeBox(window, cue.region, adjustContainerBox);
regionNodeBoxes[cue.region.id] = regionNodeBox;
}
// 6.1.14.3
let currentRegionBox = regionNodeBoxes[cue.region.id];
let currentRegionNodeDiv = currentRegionBox.div;
// 6.1.14.3.2
// TODO: fix me, it looks like the we need to set/change "top" attribute at the styleBox.div
// to do the "scroll up", however, we do not implement it yet?
if (cue.region.scroll == "up" && currentRegionNodeDiv.childElementCount > 0) {
styleBox.div.style.transitionProperty = "top";
styleBox.div.style.transitionDuration = "0.433s";
}
currentRegionNodeDiv.appendChild(styleBox.div);
rootOfCues.appendChild(currentRegionNodeDiv);
cue.displayState = styleBox.div;
boxPositions.push(new BoxPosition(currentRegionBox));
} else {
// Compute the intial position and styles of the cue div.
styleBox = new CueStyleBox(window, cue, containerBox);
rootOfCues.appendChild(styleBox.div);
// Move the cue to correct position, we might get the null box if the
// result of algorithm doesn't want us to show the cue when we don't
// have any room for this cue.
let cueBox = adjustBoxPosition(styleBox, containerBox, controlBarBox, boxPositions);
if (cueBox) {
styleBox.setBidiRule();
// Remember the computed div so that we don't have to recompute it later
// if we don't have too.
cue.displayState = styleBox.div;
boxPositions.push(cueBox);
LOG(`cue ${i}, ` + cueBox.getBoxInfoInChars());
} else {
LOG(`can not find a proper position to place cue ${i}`);
// Clear the display state and clear the reset flag in the cue as well,
// which controls whether the task for updating the cue display is
// dispatched.
cue.displayState = null;
rootOfCues.removeChild(styleBox.div);
}
}
}
} else {
LOG(`[ERROR] unknown div computing state`);
}
};
WebVTT.Parser = function(window, decoder) {
this.window = window;
this.state = "INITIAL";
this.substate = "";
this.substatebuffer = "";
this.buffer = "";
this.decoder = decoder || new TextDecoder("utf8");
this.regionList = [];
};
WebVTT.Parser.prototype = {
// If the error is a ParsingError then report it to the consumer if
// possible. If it's not a ParsingError then throw it like normal.
reportOrThrowError: function(e) {
if (e instanceof ParsingError) {
this.onparsingerror && this.onparsingerror(e);
} else {
throw e;
}
},
parse: function (data) {
// If there is no data then we won't decode it, but will just try to parse
// whatever is in buffer already. This may occur in circumstances, for
// example when flush() is called.
if (data) {
// Try to decode the data that we received.
this.buffer += this.decoder.decode(data, {stream: true});
}
// This parser is line-based. Let's see if we have a line to parse.
while (/\r\n|\n|\r/.test(this.buffer)) {
let buffer = this.buffer;
let pos = 0;
while (buffer[pos] !== '\r' && buffer[pos] !== '\n') {
++pos;
}
let line = buffer.substr(0, pos);
// Advance the buffer early in case we fail below.
if (buffer[pos] === '\r') {
++pos;
}
if (buffer[pos] === '\n') {
++pos;
}
this.buffer = buffer.substr(pos);
// Spec defined replacement.
line = line.replace(/[\u0000]/g, "\uFFFD");
if (!/^NOTE($|[ \t])/.test(line)) {
this.parseLine(line);
}
}
return this;
},
parseLine: function(line) {
let self = this;
function createCueIfNeeded() {
if (!self.cue) {
self.cue = new self.window.VTTCue(0, 0, "");
}
}
// Parsing cue identifier and the identifier should be unique.
// Return true if the input is a cue identifier.
function parseCueIdentifier(input) {
if (maybeIsTimeStampFormat(input)) {
self.state = "CUE";
return false;
}
createCueIfNeeded();
// TODO : ensure the cue identifier is unique among all cue identifiers.
self.cue.id = containsTimeDirectionSymbol(input) ? "" : input;
self.state = "CUE";
return true;
}
// Parsing the timestamp and cue settings.
// See spec, https://w3c.github.io/webvtt/#collect-webvtt-cue-timings-and-settings
function parseCueMayThrow(input) {
try {
createCueIfNeeded();
parseCue(input, self.cue, self.regionList);
self.state = "CUETEXT";
} catch (e) {
self.reportOrThrowError(e);
// In case of an error ignore rest of the cue.
self.cue = null;
self.state = "BADCUE";
}
}
// 3.4 WebVTT region and WebVTT region settings syntax
function parseRegion(input) {
let settings = new Settings();
parseOptions(input, function (k, v) {
switch (k) {
case "id":
settings.set(k, v);
break;
case "width":
settings.percent(k, v);
break;
case "lines":
settings.digitsValue(k, v);
break;
case "regionanchor":
case "viewportanchor": {
let xy = v.split(',');
if (xy.length !== 2) {
break;
}
// We have to make sure both x and y parse, so use a temporary
// settings object here.
let anchor = new Settings();
anchor.percent("x", xy[0]);
anchor.percent("y", xy[1]);
if (!anchor.has("x") || !anchor.has("y")) {
break;
}
settings.set(k + "X", anchor.get("x"));
settings.set(k + "Y", anchor.get("y"));
break;
}
case "scroll":
settings.alt(k, v, ["up"]);
break;
}
}, /:/, /\t|\n|\f|\r| /); // groupDelim is ASCII whitespace
// https://infra.spec.whatwg.org/#ascii-whitespace, U+0009 TAB, U+000A LF, U+000C FF, U+000D CR, U+0020 SPACE
// Create the region, using default values for any values that were not
// specified.
if (settings.has("id")) {
try {
let region = new self.window.VTTRegion();
region.id = settings.get("id", "");
region.width = settings.get("width", 100);
region.lines = settings.get("lines", 3);
region.regionAnchorX = settings.get("regionanchorX", 0);
region.regionAnchorY = settings.get("regionanchorY", 100);
region.viewportAnchorX = settings.get("viewportanchorX", 0);
region.viewportAnchorY = settings.get("viewportanchorY", 100);
region.scroll = settings.get("scroll", "");
// Register the region.
self.onregion && self.onregion(region);
// Remember the VTTRegion for later in case we parse any VTTCues that
// reference it.
self.regionList.push({
id: settings.get("id"),
region: region
});
} catch(e) {
dump("VTTRegion Error " + e + "\n");
let regionPref = Services.prefs.getBoolPref("media.webvtt.regions.enabled");
dump("regionPref " + regionPref + "\n");
}
}
}
// Parsing the WebVTT signature, it contains parsing algo step1 to step9.
// See spec, https://w3c.github.io/webvtt/#file-parsing
function parseSignatureMayThrow(signature) {
if (!/^WEBVTT([ \t].*)?$/.test(signature)) {
throw new ParsingError(ParsingError.Errors.BadSignature);
} else {
self.state = "HEADER";
}
}
function parseRegionOrStyle(input) {
switch (self.substate) {
case "REGION":
parseRegion(input);
break;
case "STYLE":
// TODO : not supported yet.
break;
}
}
// Parsing the region and style information.
// See spec, https://w3c.github.io/webvtt/#collect-a-webvtt-block
//
// There are sereval things would appear in header,
// 1. Region or Style setting
// 2. Garbage (meaningless string)
// 3. Empty line
// 4. Cue's timestamp
// The case 4 happens when there is no line interval between the header
// and the cue blocks. In this case, we should preserve the line for the
// next phase parsing, returning "true".
function parseHeader(line) {
if (!self.substate && /^REGION|^STYLE/.test(line)) {
self.substate = /^REGION/.test(line) ? "REGION" : "STYLE";
return false;
}
if (self.substate === "REGION" || self.substate === "STYLE") {
if (maybeIsTimeStampFormat(line) ||
emptyOrOnlyContainsWhiteSpaces(line) ||
containsTimeDirectionSymbol(line)) {
parseRegionOrStyle(self.substatebuffer);
self.substatebuffer = "";
self.substate = null;
// This is the end of the region or style state.
return parseHeader(line);
}
if (/^REGION|^STYLE/.test(line)) {
// The line is another REGION/STYLE, parse and reset substatebuffer.
// Don't break the while loop to parse the next REGION/STYLE.
parseRegionOrStyle(self.substatebuffer);
self.substatebuffer = "";
self.substate = /^REGION/.test(line) ? "REGION" : "STYLE";
return false;
}
// We weren't able to parse the line as a header. Accumulate and
// return.
self.substatebuffer += " " + line;
return false;
}
if (emptyOrOnlyContainsWhiteSpaces(line)) {
// empty line, whitespaces, nothing to do.
return false;
}
if (maybeIsTimeStampFormat(line)) {
self.state = "CUE";
// We want to process the same line again.
return true;
}
// string contains "-->" or an ID
self.state = "ID";
return true;
}
try {
// 5.1 WebVTT file parsing.
if (self.state === "INITIAL") {
parseSignatureMayThrow(line);
return;
}
if (self.state === "HEADER") {
// parseHeader returns false if the same line doesn't need to be
// parsed again.
if (!parseHeader(line)) {
return;
}
}
if (self.state === "ID") {
// If there is no cue identifier, read the next line.
if (line == "") {
return;
}
// If there is no cue identifier, parse the line again.
if (!parseCueIdentifier(line)) {
return self.parseLine(line);
}
return;
}
if (self.state === "CUE") {
parseCueMayThrow(line);
return;
}
if (self.state === "CUETEXT") {
// Report the cue when (1) get an empty line (2) get the "-->""
if (emptyOrOnlyContainsWhiteSpaces(line) ||
containsTimeDirectionSymbol(line)) {
// We are done parsing self cue.
self.oncue && self.oncue(self.cue);
self.cue = null;
self.state = "ID";
if (emptyOrOnlyContainsWhiteSpaces(line)) {
return;
}
// Reuse the same line.
return self.parseLine(line);
}
if (self.cue.text) {
self.cue.text += "\n";
}
self.cue.text += line;
return;
}
if (self.state === "BADCUE") {
// 54-62 - Collect and discard the remaining cue.
self.state = "ID";
return self.parseLine(line);
}
} catch (e) {
self.reportOrThrowError(e);
// If we are currently parsing a cue, report what we have.
if (self.state === "CUETEXT" && self.cue && self.oncue) {
self.oncue(self.cue);
}
self.cue = null;
// Enter BADWEBVTT state if header was not parsed correctly otherwise
// another exception occurred so enter BADCUE state.
self.state = self.state === "INITIAL" ? "BADWEBVTT" : "BADCUE";
}
return this;
},
flush: function () {
let self = this;
try {
// Finish decoding the stream.
self.buffer += self.decoder.decode();
self.buffer += "\n\n";
self.parse();
} catch(e) {
self.reportOrThrowError(e);
}
self.onflush && self.onflush();
return this;
}
};
global.WebVTT = WebVTT;
}(this));