diff --git a/README.md b/README.md index 9fbe59e7..6331a2d5 100644 --- a/README.md +++ b/README.md @@ -176,16 +176,17 @@ nodes attached to a top level div. var div = WebVTT.convertCueToDOMTree(window, cuetext); ``` -## WebVTT.processCues(window, cues, overlay) +## WebVTT.processCues(window, cues, overlay, regions) Converts the cuetext of the cues passed to it to DOM trees—by calling convertCueToDOMTree—and then runs the processing model steps of the WebVTT specification on the divs. The processing model applies the necessary CSS styles to the cue divs to prepare them for display on the web page. During this process the cue divs get added to a block level element (overlay). The overlay should be a part of the live DOM as the algorithm will use the computed styles (only of the divs to do overlap avoidance. +Regions list should be supplied based on what was emitted with `onregion`. ```javascript -var divs = WebVTT.processCues(window, cues, overlay); +var divs = WebVTT.processCues(window, cues, overlay, regions); ``` ## ParsingError diff --git a/lib/browser-index.js b/lib/browser-index.js index 35a47347..54aed413 100644 --- a/lib/browser-index.js +++ b/lib/browser-index.js @@ -20,11 +20,13 @@ // off browser. var window = require('global/window'); +var {version} = require('../package.json'); var vttjs = module.exports = { WebVTT: require("./vtt.js"), VTTCue: require("./vttcue.js"), - VTTRegion: require("./vttregion.js") + VTTRegion: require("./vttregion.js"), + VERSION: version }; window.vttjs = vttjs; diff --git a/lib/parser/parser.js b/lib/parser/parser.js index cff941da..7d7d978b 100644 --- a/lib/parser/parser.js +++ b/lib/parser/parser.js @@ -31,8 +31,9 @@ Parser.prototype.reportOrThrowError = function(e) { } }; -Parser.prototype.parse = function (data) { +Parser.prototype.parse = function (data, reuseCue) { var self = this; + var reuseCue = reuseCue || false; // 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 @@ -60,65 +61,6 @@ Parser.prototype.parse = function (data) { return line; } - // 3.4 WebVTT region and WebVTT region settings syntax - function parseRegion(input) { - var 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.integer(k, v); - break; - case "regionanchor": - case "viewportanchor": - var 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. - var 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; - } - }, /=/, /\s/); - - // Create the region, using default values for any values that were not - // specified. - if (settings.has("id")) { - var region = new (self.vttjs.VTTRegion || self.window.VTTRegion)(); - 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 - }); - } - } // draft-pantos-http-live-streaming-20 // https://tools.ietf.org/html/draft-pantos-http-live-streaming-20#section-3.5 @@ -154,17 +96,7 @@ Parser.prototype.parse = function (data) { break; } }, /=/); - } else { - parseOptions(input, function (k, v) { - switch (k) { - case "Region": - // 3.3 WebVTT region metadata header syntax - parseRegion(v); - break; - } - }, /:/); } - } // 5.1 WebVTT file parsing. @@ -187,6 +119,12 @@ Parser.prototype.parse = function (data) { } var alreadyCollectedLine = false; + var sawCue = reuseCue; + if (!reuseCue) { + self.cue = null; + self.regionSettings = null; + } + while (self.buffer) { // We can't parse a line until we have the full line. if (!/\r\n|\n/.test(self.buffer)) { @@ -205,9 +143,75 @@ Parser.prototype.parse = function (data) { if (/:/.test(line)) { parseHeader(line); } else if (!line) { - // An empty line terminates the header and starts the body (cues). - self.state = "ID"; + // An empty line terminates the header and blocks section. + self.state = "BLOCKS"; } + continue; + case "REGION": + if (!line) { + // create the region + var region = new (self.vttjs.VTTRegion || self.window.VTTRegion)(); + region.id = self.regionSettings.get('id', ""); + region.width = self.regionSettings.get("width", 100); + region.lines = self.regionSettings.get("lines", 3); + region.regionAnchorX = self.regionSettings.get("regionanchorX", 0); + region.regionAnchorY = self.regionSettings.get("regionanchorY", 100); + region.viewportAnchorX = self.regionSettings.get("viewportanchorX", 0); + region.viewportAnchorY = self.regionSettings.get("viewportanchorY", 100); + region.scroll = self.regionSettings.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: region.id, + region: region + }); + // An empty line terminates the REGION block + self.regionSettings = null; + self.state = "BLOCKS"; + break; + } + + // if it's a new region block, create a new VTTRegion + if (self.regionSettings === null) { + self.regionSettings = new Settings(); + } + + // parse region options and set it as appropriate on the region + parseOptions(line, function (k, v) { + switch (k) { + case "id": + self.regionSettings.set(k, v); + break; + case "width": + self.regionSettings.percent(k, v); + break; + case "lines": + self.regionSettings.integer(k, v); + break; + case "regionanchor": + case "viewportanchor": + var 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. + var anchor = new Settings(); + anchor.percent("x", xy[0]); + anchor.percent("y", xy[1]); + if (!anchor.has("x") || !anchor.has("y")) { + break; + } + self.regionSettings.set(k + "X", anchor.get("x")); + self.regionSettings.set(k + "Y", anchor.get("y")); + break; + case "scroll": + self.regionSettings.alt(k, v, ["up"]); + break; + } + }, /:/, /\s/); + continue; case "NOTE": // Ignore NOTE blocks. @@ -215,6 +219,26 @@ Parser.prototype.parse = function (data) { self.state = "ID"; } continue; + case "BLOCKS": + if (!line) { + continue; + } + + // Check for the start of a NOTE blocks + if (/^NOTE($[ \t])/.test(line)) { + self.state = "NOTE"; + break; + } + + // Check for the start of a REGION blocks + if (/^REGION/.test(line) && !sawCue) { + self.state = "REGION"; + break; + } + + self.state = "ID"; + // Process line as an ID. + /* falls through */ case "ID": // Check for the start of NOTE blocks. if (/^NOTE($|[ \t])/.test(line)) { @@ -225,6 +249,7 @@ Parser.prototype.parse = function (data) { if (!line) { continue; } + sawCue = true; self.cue = new (self.vttjs.VTTCue || self.window.VTTCue)(0, 0, ""); // Safari still uses the old middle value and won't accept center try { @@ -279,6 +304,13 @@ Parser.prototype.parse = function (data) { continue; } } + + // if we ran out of buffer but we still have a cue, finish parsing it + if (self.cue) { + self.oncue && self.oncue(self.cue); + self.cue = null; + self.state = "ID"; + } } catch (e) { self.reportOrThrowError(e); @@ -287,6 +319,7 @@ Parser.prototype.parse = function (data) { self.oncue(self.cue); } self.cue = null; + self.regionSettings = 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"; @@ -302,7 +335,7 @@ Parser.prototype.flush = function () { // Synthesize the end of the current cue or region. if (self.cue || self.state === "HEADER") { self.buffer += "\n\n"; - self.parse(); + self.parse(null, true); } // If we've flushed, parsed, and we're still on the INITIAL state then // that means we don't have enough of the stream to parse the first diff --git a/lib/process/cue-style-box.js b/lib/process/cue-style-box.js index bdcfd8ee..eb55abe6 100644 --- a/lib/process/cue-style-box.js +++ b/lib/process/cue-style-box.js @@ -15,10 +15,6 @@ function CueStyleBox(window, cue, styleOptions) { color: "rgba(255, 255, 255, 1)", backgroundColor: "rgba(0, 0, 0, 0.8)", position: "relative", - left: 0, - right: 0, - top: 0, - bottom: 0, display: "inline", writingMode: cue.vertical === "" ? "horizontal-tb" : cue.vertical === "lr" ? "vertical-lr" @@ -26,12 +22,20 @@ function CueStyleBox(window, cue, styleOptions) { unicodeBidi: "plaintext" }; + if (!cue.region) { + styles.left = 0; + styles.right = 0; + styles.top = 0; + styles.bottom = 0; + } + this.applyStyles(styles, this.cueDiv); // Create an absolutely positioned div that will be used to position the cue // div. Note, all WebVTT cue-setting alignments are equivalent to the CSS // mirrors of them except middle instead of center on Safari. this.div = window.document.createElement("div"); + this.div.className = 'vttjs-cue'; styles = { direction: determineBidi(this.cueDiv), writingMode: cue.vertical === "" ? "horizontal-tb" @@ -40,11 +44,15 @@ function CueStyleBox(window, cue, styleOptions) { unicodeBidi: "plaintext", textAlign: cue.align === "middle" ? "center" : cue.align, font: styleOptions.font, - whiteSpace: "pre-line", - position: "absolute" + whiteSpace: "pre-line" }; + if (!cue.region) { + styles.position = "absolute"; + } + this.applyStyles(styles); + this.div.appendChild(this.cueDiv); // Calculate the distance from the reference edge of the viewport to the text diff --git a/lib/process/move-box-to-line-position.js b/lib/process/move-box-to-line-position.js index 4e548e62..f3993e23 100644 --- a/lib/process/move-box-to-line-position.js +++ b/lib/process/move-box-to-line-position.js @@ -44,8 +44,9 @@ function moveBoxToLinePosition(window, styleBox, containerBox, boxPositions) { linePos = computeLinePos(cue), axis = []; - // If we have a line number to align the cue to. if (cue.snapToLines) { + // If we have a line number to align the cue to. + var size; switch (cue.vertical) { case "": @@ -78,7 +79,6 @@ function moveBoxToLinePosition(window, styleBox, containerBox, boxPositions) { // If computed line position returns negative then line numbers are // relative to the bottom of the video instead of the top. Therefore, we // need to increase our initial position by the length or width of the - // video, depending on the writing direction, and reverse our axis directions. if (linePos < 0) { position += cue.vertical === "" ? containerBox.height : containerBox.width; axis = axis.reverse(); @@ -127,7 +127,12 @@ function moveBoxToLinePosition(window, styleBox, containerBox, boxPositions) { boxPosition = new BoxPosition(styleBox); } - var bestPosition = findBestPosition(boxPosition, axis); + var bestPosition; + if (cue.region && cue.region.scroll === 'up') { + bestPosition = boxPosition; + } else { + bestPosition = findBestPosition(boxPosition, axis); + } styleBox.move(bestPosition.toCSSCompatValues(containerBox)); } diff --git a/lib/process/process-cues.js b/lib/process/process-cues.js index 3e83be73..0ffaedec 100644 --- a/lib/process/process-cues.js +++ b/lib/process/process-cues.js @@ -1,6 +1,13 @@ var BoxPosition = require('./box-position.js'); var CueStyleBox = require('./cue-style-box.js'); var moveBoxToLinePosition = require('./move-box-to-line-position.js'); +var { + setupRegions, + readdRegionCue, + removeRegionCue, + handleRegionCue, + cueInRegion +} = require('./regions.js'); var FONT_SIZE_PERCENT = 0.05; var FONT_STYLE = "sans-serif"; @@ -9,24 +16,87 @@ var CUE_BACKGROUND_PADDING = "1.5%"; // Runs the processing model over the cues and regions passed to it. // @param overlay A block level element (usually a div) that the computed cues // and regions will be placed into. -var processCues = function(window, cues, overlay) { +var processCues = function(window, cues, overlay, regions) { + regions = regions || []; + + if (!window || !cues || !overlay) { return null; } - // Remove all previous children. - while (overlay.firstChild) { - overlay.removeChild(overlay.firstChild); + function usingCues(cue) { + return cue.region && regions.length > 0; } - var paddedOverlay = window.document.createElement("div"); - paddedOverlay.style.position = "absolute"; - paddedOverlay.style.left = "0"; - paddedOverlay.style.right = "0"; - paddedOverlay.style.top = "0"; - paddedOverlay.style.bottom = "0"; - paddedOverlay.style.margin = CUE_BACKGROUND_PADDING; - overlay.appendChild(paddedOverlay); + // setup region overlays + setupRegions(regions, overlay); + + // setup main overlay + var paddedOverlay = overlay.querySelector('.vttjs-padded-overlay'); + + if (!paddedOverlay) { + paddedOverlay = window.document.createElement("div"); + paddedOverlay.className = 'vttjs-padded-overlay' + paddedOverlay.style.position = "absolute"; + paddedOverlay.style.left = "0"; + paddedOverlay.style.right = "0"; + paddedOverlay.style.top = "0"; + paddedOverlay.style.bottom = "0"; + paddedOverlay.style.margin = CUE_BACKGROUND_PADDING; + overlay.appendChild(paddedOverlay); + } + + + // remove cues that aren't in the list of cues we were just given + var cueEls = paddedOverlay.querySelectorAll('.vttjs-cue'); + clearOldCues(cueEls); + + if (regions.length === 0) { + var regions = overlay.querySelectorAll('.vttjs-region'); + } + + for (var i = 0; i < regions.length; i++) { + var region = regions[i]; + var regionDisplay = region.displayState ? region.displayState.firstChild : region.firstChild; + var cueEls = regionDisplay.querySelectorAll('.vttjs-cue'); + var [height, removed] = clearOldCues(cueEls, cues, true); + + if (removed > 1) { + regionDisplay.style.height = height + 'px'; + } + }; + + function clearOldCues(cueEls, cues) { + cues = cues || []; + + var keptCuesHeight = 0; + var removed = 0; + + for (var i = 0; i < cueEls.length; i++) { + var el = cueEls[i]; + var keep = false; + for (var j = 0; j < cues.length; j++) { + var cue = cues[j]; + if (cue.displayState && el === cue.displayState) { + keep = true; + if (usingCues(cue)) { + keptCuesHeight += cue.displayState.offsetHeight; + } + break; + } + } + if (!keep) { + if (el.parentElement.classList.contains('vttjs-region-display')) { + removeRegionCue(el); + removed++; + } else { + el.parentElement.removeChild(el); + } + } + } + + return [keptCuesHeight, removed]; + } // Determine if we need to compute the display states of the cues. This could // be the case if a cue's state has been changed since the last computation or @@ -43,12 +113,17 @@ var processCues = function(window, cues, overlay) { // We don't need to recompute the cues' display states. Just reuse them. if (!shouldCompute(cues)) { for (var i = 0; i < cues.length; i++) { - paddedOverlay.appendChild(cues[i].displayState); + if (cues[i].region) { + readdRegionCue(cues[i]); + } else { + paddedOverlay.appendChild(cues[i].displayState); + } } return; } var boxPositions = [], + regionBoxPositions = new Map(), containerBox = BoxPosition.getSimpleBoxPosition(paddedOverlay), fontSize = Math.round(containerBox.height * FONT_SIZE_PERCENT * 100) / 100; var styleOptions = { @@ -57,22 +132,51 @@ var processCues = function(window, cues, overlay) { (function() { var styleBox, cue; + var regionCueCounts = {}; for (var i = 0; i < cues.length; i++) { cue = cues[i]; + // if cue is already displaying, we don't need to position it + if (cue.displayState && + (cue.displayState.parentElement === paddedOverlay || + cueInRegion(cue)) + ) { + continue; + } // Compute the intial position and styles of the cue div. styleBox = new CueStyleBox(window, cue, styleOptions); - paddedOverlay.appendChild(styleBox.div); - // Move the cue div to it's correct line position. - moveBoxToLinePosition(window, styleBox, containerBox, boxPositions); + if (usingCues(cue)) { + if (!regionBoxPositions.has(cue.region)) { + regionBoxPositions.set(cue.region, []); + } + + let boxPositions = regionBoxPositions.get(cue.region); + + var regionId = cue.region.id; + if (!(regionId in regionCueCounts)) { + regionCueCounts[regionId] = -1; + } + + handleRegionCue(cue, styleBox, regionCueCounts[regionId]++) + + boxPositions.push(BoxPosition.getSimpleBoxPosition(styleBox)); + + regionBoxPositions.set(cue.region, boxPositions); + + } else { + paddedOverlay.appendChild(styleBox.div); + + // Move the cue div to it's correct line position. + moveBoxToLinePosition(window, styleBox, containerBox, boxPositions); + + boxPositions.push(BoxPosition.getSimpleBoxPosition(styleBox)); + } // 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(BoxPosition.getSimpleBoxPosition(styleBox)); } })(); }; diff --git a/lib/process/regions.js b/lib/process/regions.js new file mode 100644 index 00000000..87bb7efa --- /dev/null +++ b/lib/process/regions.js @@ -0,0 +1,94 @@ +var BoxPosition = require('./box-position.js'); +var CUE_BACKGROUND_PADDING = "1.5%"; + +function setupRegions(regions, overlay) { + regions = regions || []; + + for (var i = 0; i < regions.length; i++) { + var region = regions[i]; + + if (region.displayState && region.displayState.parentElement === overlay) { + var overlayHeight = overlay.offsetHeight; + var height = Math.round(overlayHeight / 100) * 6 * region.lines; + var newTop = overlayHeight * region.viewportAnchorY / 100 - height * region.regionAnchorY / 100 + 'px'; + if (region.displayState.style.top !== newTop) { + region.displayState.style.top = newTop; + } + continue; + } + + var display = region.displayState = window.document.createElement('div') + var overlayHeight = overlay.offsetHeight; + var height = Math.round(overlayHeight / 100) * 6 * region.lines; + var width = region.width; + display.classList.add(region.id, 'vttjs-region'); + display.style.width = width + '%'; + display.style.height = height + 'px'; + display.style.position = "absolute"; + display.style.left = `calc(${region.viewportAnchorX}% - ${width * region.regionAnchorX / 100}%`; + display.style.top = overlayHeight * region.viewportAnchorY / 100 - height * region.regionAnchorY / 100 + 'px'; + display.style.margin = CUE_BACKGROUND_PADDING; + display.style.overflow = "hidden"; + + + var innerDisplay = window.document.createElement('div'); + innerDisplay.classList.add('vttjs-region-display'); + innerDisplay.style.width = '100%'; + innerDisplay.style.height = '0px'; + innerDisplay.style.position = "absolute"; + innerDisplay.style.bottom = 0; + + if (region.scroll === 'up') { + innerDisplay.style.transitionProperty = 'height'; + innerDisplay.style.transitionDuration = '0.433s'; + } + + display.appendChild(innerDisplay); + overlay.appendChild(display); + } +} + +function readdRegionCue(cue) { + cue.region.displayState.firstChild.appendChild(cue.displayState); + cue.region.displayState.firstChild.style.height = 'auto'; +} + +function removeRegionCue(el) { + var regionDisplay = el.parentElement; + var rH = parseInt(regionDisplay.style.height, 10); + var cH = el.offsetHeight; + + regionDisplay.removeChild(el); + + var rH = regionDisplay.offsetHeight; + regionDisplay.style.height = rH - cH + 'px'; + + return cH; +} + +function handleRegionCue(cue, styleBox, adjust) { + var regionDisplay = cue.region.displayState && cue.region.displayState.firstChild; + if (!regionDisplay) { + return; + } + var rH = parseInt(regionDisplay.style.height, 10); + + regionDisplay.appendChild(styleBox.div); + + var cH = styleBox.div.offsetHeight; + regionDisplay.style.height = rH+cH + 'px'; +} + +function cueInRegion(cue) { + return cue.region && + cue.region.displayState && + cue.displayState.parentElement === cue.region.displayState.firstChild; +} + +module.exports = { + setupRegions, + readdRegionCue, + removeRegionCue, + handleRegionCue, + cueInRegion +}; diff --git a/lib/vttregion.js b/lib/vttregion.js index 4c175703..3566895f 100644 --- a/lib/vttregion.js +++ b/lib/vttregion.js @@ -31,17 +31,29 @@ function isValidPercentValue(value) { return typeof value === "number" && (value >= 0 && value <= 100); } -// VTTRegion shim http://dev.w3.org/html5/webvtt/#vttregion-interface +// VTTRegion shim https://w3c.github.io/webvtt/#the-vttregion-interface function VTTRegion() { - var _width = 100; - var _lines = 3; - var _regionAnchorX = 0; - var _regionAnchorY = 100; - var _viewportAnchorX = 0; - var _viewportAnchorY = 100; - var _scroll = ""; + var _id = ""; // DOMString + var _width = 100; // double + var _lines = 3; // unsigned long + var _regionAnchorX = 0; // double + var _regionAnchorY = 100; // double + var _viewportAnchorX = 0; // double + var _viewportAnchorY = 100; // double + var _scroll = ""; // ScrollSetting, line 26 Object.defineProperties(this, { + "id": { + enumerable: true, + get: function() { + return _id; + }, + set: function(value) { + if (typeof value === 'string') { + _id = value; + } + } + }, "width": { enumerable: true, get: function() { @@ -63,6 +75,9 @@ function VTTRegion() { if (typeof value !== "number") { throw new TypeError("Lines must be set to a number."); } + if (typeof value < 0) { + throw new Error('lines must be a positive number.'); + } _lines = value; } },