Add annotation player

This addresses issue #110 from master. Yay for adding annotations back!
This commit is contained in:
glmdgrielson 2019-01-20 18:28:28 -05:00 committed by Omar Roth
parent 6d1c150ff5
commit e48a000784
4 changed files with 1101 additions and 1 deletions

View file

@ -0,0 +1,81 @@
.__cxt-ar-annotations-container__ {
--annotation-close-size: 20px;
position: absolute;
width: 100%;
height: 100%;
top: 0px;
left: 0px;
pointer-events: none;
overflow: hidden;
}
.__cxt-ar-annotation__ {
position: absolute;
box-sizing: border-box;
font-family: Arial, sans-serif;
color: white;
z-index: 20;
}
.__cxt-ar-annotation__ {
pointer-events: auto;
}
.__cxt-ar-annotation__ span {
position: absolute;
left: 0;
top: 0;
overflow: hidden;
word-wrap: break-word;
white-space: pre-wrap;
pointer-events: none;
box-sizing: border-box;
padding: 2%;
user-select: none;
-webkit-user-select: none; /* Chrome all / Safari all */
-moz-user-select: none; /* Firefox all */
-ms-user-select: none; /* IE 10+ */
}
.__cxt-ar-annotation-close__ {
display: none;
position: absolute;
width: var(--annotation-close-size);
height: var(--annotation-close-size);
cursor: pointer;
right: calc(var(--annotation-close-size) / -1.8);
top: calc(var(--annotation-close-size) / -1.8);
/* place the close button above the svg */
z-index: 1;
}
.__cxt-ar-annotation__:hover:not([hidden]):not([data-ar-closed]) .__cxt-ar-annotation-close__ {
display: block;
}
.__cxt-ar-annotation__[hidden] {
display: none !important;
}
.__cxt-ar-annotation__[data-ar-type="highlight"] {
border: 1px solid rgba(255, 255, 255, 0.10);
background-color: transparent;
}
.__cxt-ar-annotation__[data-ar-type="highlight"]:hover {
border: 1px solid rgba(255, 255, 255, 0.50);
background-color: transparent;
}
.__cxt-ar-annotation__ svg {
pointer-events: all;
}

View file

@ -0,0 +1,975 @@
class AnnotationParser {
static get defaultAppearanceAttributes() {
return {
bgColor: 0xFFFFFF,
bgOpacity: 0.80,
fgColor: 0,
textSize: 3.15
};
}
static get attributeMap() {
return {
type: "tp",
style: "s",
x: "x",
y: "y",
width: "w",
height: "h",
sx: "sx",
sy: "sy",
timeStart: "ts",
timeEnd: "te",
text: "t",
actionType: "at",
actionUrl: "au",
actionUrlTarget: "aut",
actionSeconds: "as",
bgOpacity: "bgo",
bgColor: "bgc",
fgColor: "fgc",
textSize: "txsz"
};
}
/* AR ANNOTATION FORMAT */
deserializeAnnotation(serializedAnnotation) {
const map = this.constructor.attributeMap;
const attributes = serializedAnnotation.split(",");
const annotation = {};
for (const attribute of attributes) {
const [ key, value ] = attribute.split("=");
const mappedKey = this.getKeyByValue(map, key);
let finalValue = "";
if (["text", "actionType", "actionUrl", "actionUrlTarget", "type", "style"].indexOf(mappedKey) > -1) {
finalValue = decodeURIComponent(value);
}
else {
finalValue = parseFloat(value, 10);
}
annotation[mappedKey] = finalValue;
}
return annotation;
}
serializeAnnotation(annotation) {
const map = this.constructor.attributeMap;
let serialized = "";
for (const key in annotation) {
const mappedKey = map[key];
if ((["text", "actionType", "actionUrl", "actionUrlTarget"].indexOf(key) > -1) && mappedKey && annotation.hasOwnProperty(key)) {
let text = encodeURIComponent(annotation[key]);
serialized += `${mappedKey}=${text},`;
}
else if ((["text", "actionType", "actionUrl", "actionUrlTarget"].indexOf("key") === -1) && mappedKey && annotation.hasOwnProperty(key)) {
serialized += `${mappedKey}=${annotation[key]},`;
}
}
// remove trailing comma
return serialized.substring(0, serialized.length - 1);
}
deserializeAnnotationList(serializedAnnotationString) {
const serializedAnnotations = serializedAnnotationString.split(";");
serializedAnnotations.length = serializedAnnotations.length - 1;
const annotations = [];
for (const annotation of serializedAnnotations) {
annotations.push(this.deserializeAnnotation(annotation));
}
return annotations;
}
serializeAnnotationList(annotations) {
let serialized = "";
for (const annotation of annotations) {
serialized += this.serializeAnnotation(annotation) + ";";
}
return serialized;
}
/* PARSING YOUTUBE'S ANNOTATION FORMAT */
xmlToDom(xml) {
const parser = new DOMParser();
const dom = parser.parseFromString(xml, "application/xml");
return dom;
}
getAnnotationsFromXml(xml) {
const dom = this.xmlToDom(xml);
return dom.getElementsByTagName("annotation");
}
parseYoutubeAnnotationList(annotationElements) {
const annotations = [];
for (const el of annotationElements) {
const parsedAnnotation = this.parseYoutubeAnnotation(el);
if (parsedAnnotation) annotations.push(parsedAnnotation);
}
return annotations;
}
parseYoutubeAnnotation(annotationElement) {
const base = annotationElement;
const attributes = this.getAttributesFromBase(base);
if (!attributes.type || attributes.type === "pause") return null;
const text = this.getTextFromBase(base);
const action = this.getActionFromBase(base);
const backgroundShape = this.getBackgroundShapeFromBase(base);
if (!backgroundShape) return null;
const timeStart = backgroundShape.timeRange.start;
const timeEnd = backgroundShape.timeRange.end;
if (isNaN(timeStart) || isNaN(timeEnd) || timeStart === null || timeEnd === null) {
return null;
}
const appearance = this.getAppearanceFromBase(base);
// properties the renderer needs
let annotation = {
// possible values: text, highlight, pause, branding
type: attributes.type,
// x, y, width, and height as percent of video size
x: backgroundShape.x,
y: backgroundShape.y,
width: backgroundShape.width,
height: backgroundShape.height,
// what time the annotation is shown in seconds
timeStart,
timeEnd
};
// properties the renderer can work without
if (attributes.style) annotation.style = attributes.style;
if (text) annotation.text = text;
if (action) annotation = Object.assign(action, annotation);
if (appearance) annotation = Object.assign(appearance, annotation);
if (backgroundShape.hasOwnProperty("sx")) annotation.sx = backgroundShape.sx;
if (backgroundShape.hasOwnProperty("sy")) annotation.sy = backgroundShape.sy;
return annotation;
}
getBackgroundShapeFromBase(base) {
const movingRegion = base.getElementsByTagName("movingRegion")[0];
if (!movingRegion) return null;
const regionType = movingRegion.getAttribute("type");
const regions = movingRegion.getElementsByTagName(`${regionType}Region`);
const timeRange = this.extractRegionTime(regions);
const shape = {
type: regionType,
x: parseFloat(regions[0].getAttribute("x"), 10),
y: parseFloat(regions[0].getAttribute("y"), 10),
width: parseFloat(regions[0].getAttribute("w"), 10),
height: parseFloat(regions[0].getAttribute("h"), 10),
timeRange
}
const sx = regions[0].getAttribute("sx");
const sy = regions[0].getAttribute("sy");
if (sx) shape.sx = parseFloat(sx, 10);
if (sy) shape.sy = parseFloat(sy, 10);
return shape;
}
getAttributesFromBase(base) {
const attributes = {};
attributes.type = base.getAttribute("type");
attributes.style = base.getAttribute("style");
return attributes;
}
getTextFromBase(base) {
const textElement = base.getElementsByTagName("TEXT")[0];
if (textElement) return textElement.textContent;
}
getActionFromBase(base) {
const actionElement = base.getElementsByTagName("action")[0];
if (!actionElement) return null;
const typeAttr = actionElement.getAttribute("type");
const urlElement = actionElement.getElementsByTagName("url")[0];
if (!urlElement) return null;
const actionUrlTarget = urlElement.getAttribute("target");
const href = urlElement.getAttribute("value");
// only allow links to youtube
// can be changed in the future
if (href.startsWith("https://www.youtube.com/")) {
const url = new URL(href);
const srcVid = url.searchParams.get("src_vid");
const toVid = url.searchParams.get("v");
return this.linkOrTimestamp(url, srcVid, toVid, actionUrlTarget);
}
}
linkOrTimestamp(url, srcVid, toVid, actionUrlTarget) {
// check if it's a link to a new video
// or just a timestamp
if (srcVid && toVid && srcVid === toVid) {
let seconds = 0;
const hash = url.hash;
if (hash && hash.startsWith("#t=")) {
const timeString = url.hash.split("#t=")[1];
seconds = this.timeStringToSeconds(timeString);
}
return {actionType: "time", actionSeconds: seconds}
}
else {
return {actionType: "url", actionUrl: url.href, actionUrlTarget};
}
}
getAppearanceFromBase(base) {
const appearanceElement = base.getElementsByTagName("appearance")[0];
const styles = this.constructor.defaultAppearanceAttributes;
if (appearanceElement) {
const bgOpacity = appearanceElement.getAttribute("bgAlpha");
const bgColor = appearanceElement.getAttribute("bgColor");
const fgColor = appearanceElement.getAttribute("fgColor");
const textSize = appearanceElement.getAttribute("textSize");
// not yet sure what to do with effects
// const effects = appearanceElement.getAttribute("effects");
// 0.00 to 1.00
if (bgOpacity) styles.bgOpacity = parseFloat(bgOpacity, 10);
// 0 to 256 ** 3
if (bgColor) styles.bgColor = parseInt(bgColor, 10);
if (fgColor) styles.fgColor = parseInt(fgColor, 10);
// 0.00 to 100.00?
if (textSize) styles.textSize = parseFloat(textSize, 10);
}
return styles;
}
/* helper functions */
extractRegionTime(regions) {
let timeStart = regions[0].getAttribute("t");
timeStart = this.hmsToSeconds(timeStart);
let timeEnd = regions[regions.length - 1].getAttribute("t");
timeEnd = this.hmsToSeconds(timeEnd);
return {start: timeStart, end: timeEnd}
}
// https://stackoverflow.com/a/9640417/10817894
hmsToSeconds(hms) {
let p = hms.split(":");
let s = 0;
let m = 1;
while (p.length > 0) {
s += m * parseFloat(p.pop(), 10);
m *= 60;
}
return s;
}
timeStringToSeconds(time) {
let seconds = 0;
const h = time.split("h");
const m = (h[1] || time).split("m");
const s = (m[1] || time).split("s");
if (h[0] && h.length === 2) seconds += parseInt(h[0], 10) * 60 * 60;
if (m[0] && m.length === 2) seconds += parseInt(m[0], 10) * 60;
if (s[0] && s.length === 2) seconds += parseInt(s[0], 10);
return seconds;
}
getKeyByValue(obj, value) {
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
if (obj[key] === value) {
return key;
}
}
}
}
}
class AnnotationRenderer {
constructor(annotations, container, playerOptions, updateInterval = 1000) {
if (!annotations) throw new Error("Annotation objects must be provided");
if (!container) throw new Error("An element to contain the annotations must be provided");
if (playerOptions && playerOptions.getVideoTime && playerOptions.seekTo) {
this.playerOptions = playerOptions;
}
else {
console.info("AnnotationRenderer is running without a player. The update method will need to be called manually.");
}
this.annotations = annotations;
this.container = container;
this.annotationsContainer = document.createElement("div");
this.annotationsContainer.classList.add("__cxt-ar-annotations-container__");
this.annotationsContainer.setAttribute("data-layer", "4");
this.annotationsContainer.addEventListener("click", e => {
this.annotationClickHandler(e);
});
this.container.prepend(this.annotationsContainer);
this.createAnnotationElements();
// in case the dom already loaded
this.updateAllAnnotationSizes();
window.addEventListener("DOMContentLoaded", e => {
this.updateAllAnnotationSizes();
});
this.updateInterval = updateInterval;
this.updateIntervalId = null;
}
changeAnnotationData(annotations) {
this.stop();
this.removeAnnotationElements();
this.annotations = annotations;
this.createAnnotationElements();
this.start();
}
createAnnotationElements() {
for (const annotation of this.annotations) {
const el = document.createElement("div");
el.classList.add("__cxt-ar-annotation__");
annotation.__element = el;
el.__annotation = annotation;
// close button
const closeButton = this.createCloseElement();
closeButton.addEventListener("click", e => {
el.setAttribute("hidden", "");
el.setAttribute("data-ar-closed", "");
if (el.__annotation.__speechBubble) {
const speechBubble = el.__annotation.__speechBubble;
speechBubble.style.display = "none";
}
});
el.append(closeButton);
if (annotation.text) {
const textNode = document.createElement("span");
textNode.textContent = annotation.text;
el.append(textNode);
el.setAttribute("data-ar-has-text", "");
}
if (annotation.style === "speech") {
const containerDimensions = this.container.getBoundingClientRect();
const speechX = this.percentToPixels(containerDimensions.width, annotation.x);
const speechY = this.percentToPixels(containerDimensions.height, annotation.y);
const speechWidth = this.percentToPixels(containerDimensions.width, annotation.width);
const speechHeight = this.percentToPixels(containerDimensions.height, annotation.height);
const speechPointX = this.percentToPixels(containerDimensions.width, annotation.sx);
const speechPointY = this.percentToPixels(containerDimensions.height, annotation.sy);
const bubbleColor = this.getFinalAnnotationColor(annotation, false);
const bubble = this.createSvgSpeechBubble(speechX, speechY, speechWidth, speechHeight, speechPointX, speechPointY, bubbleColor, annotation.__element);
bubble.style.display = "none";
bubble.style.overflow = "visible";
el.style.pointerEvents = "none";
bubble.__annotationEl = el;
annotation.__speechBubble = bubble;
const path = bubble.getElementsByTagName("path")[0];
path.addEventListener("mouseover", () => {
closeButton.style.display = "block";
// path.style.cursor = "pointer";
closeButton.style.cursor = "pointer";
path.setAttribute("fill", this.getFinalAnnotationColor(annotation, true));
});
path.addEventListener("mouseout", e => {
if (!e.relatedTarget.classList.contains("__cxt-ar-annotation-close__")) {
closeButton.style.display ="none";
// path.style.cursor = "default";
closeButton.style.cursor = "default";
path.setAttribute("fill", this.getFinalAnnotationColor(annotation, false));
}
});
closeButton.addEventListener("mouseleave", () => {
closeButton.style.display = "none";
path.style.cursor = "default";
closeButton.style.cursor = "default";
path.setAttribute("fill", this.getFinalAnnotationColor(annotation, false));
});
el.prepend(bubble);
}
else if (annotation.type === "highlight") {
el.style.backgroundColor = "";
el.style.border = `2.5px solid ${this.getFinalAnnotationColor(annotation, false)}`;
if (annotation.actionType === "url")
el.style.cursor = "pointer";
}
else if (annotation.style !== "title") {
el.style.backgroundColor = this.getFinalAnnotationColor(annotation);
el.addEventListener("mouseenter", () => {
el.style.backgroundColor = this.getFinalAnnotationColor(annotation, true);
});
el.addEventListener("mouseleave", () => {
el.style.backgroundColor = this.getFinalAnnotationColor(annotation, false);
});
if (annotation.actionType === "url")
el.style.cursor = "pointer";
}
el.style.color = `#${this.decimalToHex(annotation.fgColor)}`;
el.setAttribute("data-ar-type", annotation.type);
el.setAttribute("hidden", "");
this.annotationsContainer.append(el);
}
}
createCloseElement() {
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("viewBox", "0 0 100 100")
svg.classList.add("__cxt-ar-annotation-close__");
const path = document.createElementNS(svg.namespaceURI, "path");
path.setAttribute("d", "M25 25 L 75 75 M 75 25 L 25 75");
path.setAttribute("stroke", "#bbb");
path.setAttribute("stroke-width", 10)
path.setAttribute("x", 5);
path.setAttribute("y", 5);
const circle = document.createElementNS(svg.namespaceURI, "circle");
circle.setAttribute("cx", 50);
circle.setAttribute("cy", 50);
circle.setAttribute("r", 50);
svg.append(circle, path);
return svg;
}
createSvgSpeechBubble(x, y, width, height, pointX, pointY, color = "white", element, svg) {
const horizontalBaseStartMultiplier = 0.17379070765180116;
const horizontalBaseEndMultiplier = 0.14896346370154384;
const verticalBaseStartMultiplier = 0.12;
const verticalBaseEndMultiplier = 0.3;
let path;
if (!svg) {
svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.classList.add("__cxt-ar-annotation-speech-bubble__");
path = document.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute("fill", color);
svg.append(path);
}
else {
path = svg.children[0];
}
svg.style.position = "absolute";
svg.setAttribute("width", "100%");
svg.setAttribute("height", "100%");
svg.style.left = "0";
svg.style.top = "0";
let positionStart;
let baseStartX = 0;
let baseStartY = 0;
let baseEndX = 0;
let baseEndY = 0;
let pointFinalX = pointX;
let pointFinalY = pointY;
let commentRectPath;
const pospad = 20;
let textWidth = 0;
let textHeight = 0;
let textX = 0;
let textY = 0;
let textElement;
let closeElement;
if (element) {
textElement = element.getElementsByTagName("span")[0];
closeElement = element.getElementsByClassName("__cxt-ar-annotation-close__")[0];
}
if (pointX > ((x + width) - (width / 2)) && pointY > y + height) {
positionStart = "br";
baseStartX = width - ((width * horizontalBaseStartMultiplier) * 2);
baseEndX = baseStartX + (width * horizontalBaseEndMultiplier);
baseStartY = height;
baseEndY = height;
pointFinalX = pointX - x;
pointFinalY = pointY - y;
element.style.height = pointY - y;
commentRectPath = `L${width} ${height} L${width} 0 L0 0 L0 ${baseStartY} L${baseStartX} ${baseStartY}`;
if (textElement) {
textWidth = width;
textHeight = height;
textX = 0;
textY = 0;
}
}
else if (pointX < ((x + width) - (width / 2)) && pointY > y + height) {
positionStart = "bl";
baseStartX = width * horizontalBaseStartMultiplier;
baseEndX = baseStartX + (width * horizontalBaseEndMultiplier);
baseStartY = height;
baseEndY = height;
pointFinalX = pointX - x;
pointFinalY = pointY - y;
element.style.height = `${pointY - y}px`;
commentRectPath = `L${width} ${height} L${width} 0 L0 0 L0 ${baseStartY} L${baseStartX} ${baseStartY}`;
if (textElement) {
textWidth = width;
textHeight = height;
textX = 0;
textY = 0;
}
}
else if (pointX > ((x + width) - (width / 2)) && pointY < (y - pospad)) {
positionStart = "tr";
baseStartX = width - ((width * horizontalBaseStartMultiplier) * 2);
baseEndX = baseStartX + (width * horizontalBaseEndMultiplier);
const yOffset = y - pointY;
baseStartY = yOffset;
baseEndY = yOffset;
element.style.top = y - yOffset + "px";
element.style.height = height + yOffset + "px";
pointFinalX = pointX - x;
pointFinalY = 0;
commentRectPath = `L${width} ${yOffset} L${width} ${height + yOffset} L0 ${height + yOffset} L0 ${yOffset} L${baseStartX} ${baseStartY}`;
if (textElement) {
textWidth = width;
textHeight = height;
textX = 0;
textY = yOffset;
}
}
else if (pointX < ((x + width) - (width / 2)) && pointY < y) {
positionStart = "tl";
baseStartX = width * horizontalBaseStartMultiplier;
baseEndX = baseStartX + (width * horizontalBaseEndMultiplier);
const yOffset = y - pointY;
baseStartY = yOffset;
baseEndY = yOffset;
element.style.top = y - yOffset + "px";
element.style.height = height + yOffset + "px";
pointFinalX = pointX - x;
pointFinalY = 0;
commentRectPath = `L${width} ${yOffset} L${width} ${height + yOffset} L0 ${height + yOffset} L0 ${yOffset} L${baseStartX} ${baseStartY}`;
if (textElement) {
textWidth = width;
textHeight = height;
textX = 0;
textY = yOffset;
}
}
else if (pointX > (x + width) && pointY > (y - pospad) && pointY < ((y + height) - pospad)) {
positionStart = "r";
const xOffset = pointX - (x + width);
baseStartX = width;
baseEndX = width;
element.style.width = width + xOffset + "px";
baseStartY = height * verticalBaseStartMultiplier;
baseEndY = baseStartY + (height * verticalBaseEndMultiplier);
pointFinalX = width + xOffset;
pointFinalY = pointY - y;
commentRectPath = `L${baseStartX} ${height} L0 ${height} L0 0 L${baseStartX} 0 L${baseStartX} ${baseStartY}`;
if (textElement) {
textWidth = width;
textHeight = height;
textX = 0;
textY = 0;
}
}
else if (pointX < x && pointY > y && pointY < (y + height)) {
positionStart = "l";
const xOffset = x - pointX;
baseStartX = xOffset;
baseEndX = xOffset;
element.style.left = x - xOffset + "px";
element.style.width = width + xOffset + "px";
baseStartY = height * verticalBaseStartMultiplier;
baseEndY = baseStartY + (height * verticalBaseEndMultiplier);
pointFinalX = 0;
pointFinalY = pointY - y;
commentRectPath = `L${baseStartX} ${height} L${width + baseStartX} ${height} L${width + baseStartX} 0 L${baseStartX} 0 L${baseStartX} ${baseStartY}`;
if (textElement) {
textWidth = width;
textHeight = height;
textX = xOffset;
textY = 0;
}
}
else {
return svg;
}
if (textElement) {
textElement.style.left = textX + "px";
textElement.style.top = textY + "px";
textElement.style.width = textWidth + "px";
textElement.style.height = textHeight + "px";
}
if (closeElement) {
const closeSize = parseFloat(this.annotationsContainer.style.getPropertyValue("--annotation-close-size"), 10);
if (closeSize) {
closeElement.style.left = ((textX + textWidth) + (closeSize / -1.8)) + "px";
closeElement.style.top = (textY + (closeSize / -1.8)) + "px";
}
}
const pathData = `M${baseStartX} ${baseStartY} L${pointFinalX} ${pointFinalY} L${baseEndX} ${baseEndY} ${commentRectPath}`;
path.setAttribute("d", pathData);
return svg;
}
getFinalAnnotationColor(annotation, hover = false) {
const alphaHex = hover ? (0xE6).toString(16) : Math.floor((annotation.bgOpacity * 255)).toString(16);
if (!isNaN(annotation.bgColor)) {
const bgColorHex = this.decimalToHex(annotation.bgColor);
const backgroundColor = `#${bgColorHex}${alphaHex}`;
return backgroundColor;
}
}
removeAnnotationElements() {
for (const annotation of this.annotations) {
annotation.__element.remove();
}
}
update(videoTime) {
for (const annotation of this.annotations) {
const el = annotation.__element;
if (el.hasAttribute("data-ar-closed")) continue;
const start = annotation.timeStart;
const end = annotation.timeEnd;
if (el.hasAttribute("hidden") && (videoTime >= start && videoTime < end)) {
el.removeAttribute("hidden");
if (annotation.style === "speech" && annotation.__speechBubble) {
annotation.__speechBubble.style.display = "block";
}
}
else if (!el.hasAttribute("hidden") && (videoTime < start || videoTime > end)) {
el.setAttribute("hidden", "");
if (annotation.style === "speech" && annotation.__speechBubble) {
annotation.__speechBubble.style.display = "none";
}
}
}
}
start() {
if (!this.playerOptions) throw new Error("playerOptions must be provided to use the start method");
const videoTime = this.playerOptions.getVideoTime();
if (!this.updateIntervalId) {
this.update(videoTime);
this.updateIntervalId = setInterval(() => {
const videoTime = this.playerOptions.getVideoTime();
this.update(videoTime);
window.dispatchEvent(new CustomEvent("__ar_renderer_start"));
}, this.updateInterval);
}
}
stop() {
if (!this.playerOptions) throw new Error("playerOptions must be provided to use the stop method");
const videoTime = this.playerOptions.getVideoTime();
if (this.updateIntervalId) {
this.update(videoTime);
clearInterval(this.updateIntervalId);
this.updateIntervalId = null;
window.dispatchEvent(new CustomEvent("__ar_renderer_stop"));
}
}
updateAnnotationTextSize(annotation, containerHeight) {
if (annotation.textSize) {
const textSize = (annotation.textSize / 100) * containerHeight;
annotation.__element.style.fontSize = `${textSize}px`;
}
}
updateTextSize() {
const containerHeight = this.container.getBoundingClientRect().height;
// should be run when the video resizes
for (const annotation of this.annotations) {
this.updateAnnotationTextSize(annotation, containerHeight);
}
}
updateCloseSize(containerHeight) {
if (!containerHeight) containerHeight = this.container.getBoundingClientRect().height;
const multiplier = 0.0423;
this.annotationsContainer.style.setProperty("--annotation-close-size", `${containerHeight * multiplier}px`);
}
updateAnnotationDimensions(annotations, videoWidth, videoHeight) {
const playerWidth = this.container.getBoundingClientRect().width;
const playerHeight = this.container.getBoundingClientRect().height;
const widthDivider = playerWidth / videoWidth;
const heightDivider = playerHeight / videoHeight;
let scaledVideoWidth = playerWidth;
let scaledVideoHeight = playerHeight;
if (widthDivider % 1 !== 0 || heightDivider % 1 !== 0) {
// vertical bars
if (widthDivider > heightDivider) {
scaledVideoWidth = (playerHeight / videoHeight) * videoWidth;
scaledVideoHeight = playerHeight;
}
// horizontal bars
else if (heightDivider > widthDivider) {
scaledVideoWidth = playerWidth;
scaledVideoHeight = (playerWidth / videoWidth) * videoHeight;
}
}
const verticalBlackBarWidth = (playerWidth - scaledVideoWidth) / 2;
const horizontalBlackBarHeight = (playerHeight - scaledVideoHeight) / 2;
const widthOffsetPercent = (verticalBlackBarWidth / playerWidth * 100);
const heightOffsetPercent = (horizontalBlackBarHeight / playerHeight * 100);
const widthMultiplier = (scaledVideoWidth / playerWidth);
const heightMultiplier = (scaledVideoHeight / playerHeight);
for (const annotation of annotations) {
const el = annotation.__element;
let ax = widthOffsetPercent + (annotation.x * widthMultiplier);
let ay = heightOffsetPercent + (annotation.y * heightMultiplier);
let aw = annotation.width * widthMultiplier;
let ah = annotation.height * heightMultiplier;
el.style.left = `${ax}%`;
el.style.top = `${ay}%`;
el.style.width = `${aw}%`;
el.style.height = `${ah}%`;
let horizontalPadding = scaledVideoWidth * 0.008;
let verticalPadding = scaledVideoHeight * 0.008;
if (annotation.style === "speech" && annotation.text) {
const pel = annotation.__element.getElementsByTagName("span")[0];
horizontalPadding *= 2;
verticalPadding *= 2;
pel.style.paddingLeft = horizontalPadding + "px";
pel.style.paddingRight = horizontalPadding + "px";
pel.style.paddingBottom = verticalPadding + "px";
pel.style.paddingTop = verticalPadding + "px";
}
else if (annotation.style !== "speech") {
el.style.paddingLeft = horizontalPadding + "px";
el.style.paddingRight = horizontalPadding + "px";
el.style.paddingBottom = verticalPadding + "px";
el.style.paddingTop = verticalPadding + "px";
}
if (annotation.__speechBubble) {
const asx = this.percentToPixels(playerWidth, ax);
const asy = this.percentToPixels(playerHeight, ay);
const asw = this.percentToPixels(playerWidth, aw);
const ash = this.percentToPixels(playerHeight, ah);
let sx = widthOffsetPercent + (annotation.sx * widthMultiplier);
let sy = heightOffsetPercent + (annotation.sy * heightMultiplier);
sx = this.percentToPixels(playerWidth, sx);
sy = this.percentToPixels(playerHeight, sy);
this.createSvgSpeechBubble(asx, asy, asw, ash, sx, sy, null, annotation.__element, annotation.__speechBubble);
}
this.updateAnnotationTextSize(annotation, scaledVideoHeight);
this.updateCloseSize(scaledVideoHeight);
}
}
updateAllAnnotationSizes() {
if (this.playerOptions && this.playerOptions.getOriginalVideoWidth && this.playerOptions.getOriginalVideoHeight) {
const videoWidth = this.playerOptions.getOriginalVideoWidth();
const videoHeight = this.playerOptions.getOriginalVideoHeight();
this.updateAnnotationDimensions(this.annotations, videoWidth, videoHeight);
}
else {
const playerWidth = this.container.getBoundingClientRect().width;
const playerHeight = this.container.getBoundingClientRect().height;
this.updateAnnotationDimensions(this.annotations, playerWidth, playerHeight);
}
}
hideAll() {
for (const annotation of this.annotations) {
annotation.__element.setAttribute("hidden", "");
}
}
annotationClickHandler(e) {
let annotationElement = e.target;
// if we click on annotation text instead of the actual annotation element
if (!annotationElement.matches(".__cxt-ar-annotation__") && !annotationElement.closest(".__cxt-ar-annotation-close__")) {
annotationElement = annotationElement.closest(".__cxt-ar-annotation__");
if (!annotationElement) return null;
}
let annotationData = annotationElement.__annotation;
if (!annotationElement || !annotationData) return;
if (annotationData.actionType === "time") {
const seconds = annotationData.actionSeconds;
if (this.playerOptions) {
this.playerOptions.seekTo(seconds);
const videoTime = this.playerOptions.getVideoTime();
this.update(videoTime);
}
window.dispatchEvent(new CustomEvent("__ar_seek_to", {detail: {seconds}}));
}
else if (annotationData.actionType === "url") {
const data = {url: annotationData.actionUrl, target: annotationData.actionUrlTarget || "current"};
const timeHash = this.extractTimeHash(new URL(data.url));
if (timeHash && timeHash.hasOwnProperty("seconds")) {
data.seconds = timeHash.seconds;
}
window.dispatchEvent(new CustomEvent("__ar_annotation_click", {detail: data}));
}
}
setUpdateInterval(ms) {
this.updateInterval = ms;
this.stop();
this.start();
}
// https://stackoverflow.com/a/3689638/10817894
decimalToHex(dec) {
let hex = dec.toString(16);
hex = "000000".substr(0, 6 - hex.length) + hex;
return hex;
}
extractTimeHash(url) {
if (!url) throw new Error("A URL must be provided");
const hash = url.hash;
if (hash && hash.startsWith("#t=")) {
const timeString = url.hash.split("#t=")[1];
const seconds = this.timeStringToSeconds(timeString);
return {seconds};
}
else {
return false;
}
}
timeStringToSeconds(time) {
let seconds = 0;
const h = time.split("h");
const m = (h[1] || time).split("m");
const s = (m[1] || time).split("s");
if (h[0] && h.length === 2) seconds += parseInt(h[0], 10) * 60 * 60;
if (m[0] && m.length === 2) seconds += parseInt(m[0], 10) * 60;
if (s[0] && s.length === 2) seconds += parseInt(s[0], 10);
return seconds;
}
percentToPixels(a, b) {
return a * b / 100;
}
}
function youtubeAnnotationsPlugin(options) {
if (!options.annotationXml) throw new Error("Annotation data must be provided");
if (!options.videoContainer) throw new Error("A video container to overlay the data on must be provided");
const player = this;
const xml = options.annotationXml;
const parser = new AnnotationParser();
const annotationElements = parser.getAnnotationsFromXml(xml);
const annotations = parser.parseYoutubeAnnotationList(annotationElements);
const videoContainer = options.videoContainer;
const playerOptions = {
getVideoTime() {
return player.currentTime();
},
seekTo(seconds) {
player.currentTime(seconds);
},
getOriginalVideoWidth() {
return player.videoWidth();
},
getOriginalVideoHeight() {
return player.videoHeight();
}
};
raiseControls();
const renderer = new AnnotationRenderer(annotations, videoContainer, playerOptions, options.updateInterval);
setupEventListeners(player, renderer);
renderer.start();
}
function setupEventListeners(player, renderer) {
if (!player) throw new Error("A video player must be provided");
// should be throttled for performance
player.on("playerresize", e => {
renderer.updateAllAnnotationSizes(renderer.annotations);
});
// Trigger resize since the video can have different dimensions than player
player.one("loadedmetadata", e => {
renderer.updateAllAnnotationSizes(renderer.annotations);
});
player.on("pause", e => {
renderer.stop();
});
player.on("play", e => {
renderer.start();
});
player.on("seeking", e => {
renderer.update();
});
player.on("seeked", e => {
renderer.update();
});
}
function raiseControls() {
const styles = document.createElement("style");
styles.textContent = `
.vjs-control-bar {
z-index: 21;
}
`;
document.body.append(styles);
}

View file

@ -213,6 +213,49 @@ if (bpb) {
player.httpSourceSelector();
<% if !params[:listen] %>
var video_container = document.getElementById("player");
let xhr = new XMLHttpRequest();
xhr.responseType = "text";
xhr.timeout = 60000;
xhr.open("GET", "/api/v1/annotations/<%= video.id %>", true);
xhr.send();
xhr.onreadystatechange = function () {
if (xhr.readyState === 4) {
if (xhr.status === 200) {
videojs.registerPlugin("youtubeAnnotationsPlugin", youtubeAnnotationsPlugin);
if (!player.paused()) {
player.youtubeAnnotationsPlugin({annotationXml: xhr.response, videoContainer: video_container});
} else {
player.one('play', function(event) {
player.youtubeAnnotationsPlugin({annotationXml: xhr.response, videoContainer: video_container});
});
}
}
}
};
window.addEventListener("__ar_annotation_click", e => {
const { url, target, seconds } = e.detail;
var path = new URL(url);
if (path.href.startsWith("https://www.youtube.com/watch?") && seconds) {
path.search += "&t=" + seconds;
}
path = path.pathname + path.search;
if (target === "current") {
window.location.href = path;
}
else if (target === "new") {
window.open(path, "_blank");
}
});
<% end %>
// Since videojs-share can sometimes be blocked, we try to load it last
player.share(shareOptions);
</script>

View file

@ -2,13 +2,14 @@
<link rel="stylesheet" href="/css/videojs-http-source-selector.css">
<link rel="stylesheet" href="/css/videojs.markers.min.css">
<link rel="stylesheet" href="/css/videojs-share.css">
<link rel="stylesheet" href="/css/videojs-youtube-annotations.css">
<script src="/js/video.min.js"></script>
<script src="/js/videojs-contrib-quality-levels.min.js"></script>
<script src="/js/videojs-http-source-selector.min.js"></script>
<script src="/js/videojs.hotkeys.min.js"></script>
<script src="/js/videojs-markers.min.js"></script>
<script src="/js/videojs-share.min.js"></script>
<script src="/js/videojs-youtube-annotations.js"></script>
<% if params[:quality] != "dash" %>
<link rel="stylesheet" href="/css/quality-selector.css">
<script src="/js/silvermine-videojs-quality-selector.min.js"></script>