diff --git a/assets/css/videojs-youtube-annotations.css b/assets/css/videojs-youtube-annotations.css deleted file mode 100644 index 3ca4e46d..00000000 --- a/assets/css/videojs-youtube-annotations.css +++ /dev/null @@ -1,81 +0,0 @@ -.__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; -} \ No newline at end of file diff --git a/assets/css/videojs-youtube-annotations.min.css b/assets/css/videojs-youtube-annotations.min.css new file mode 100644 index 00000000..282ebe64 --- /dev/null +++ b/assets/css/videojs-youtube-annotations.min.css @@ -0,0 +1 @@ +.__cxt-ar-annotations-container__{--annotation-close-size: 20px;position:absolute;width:100%;height:100%;top:0;left:0;pointer-events:none;overflow:hidden}.__cxt-ar-annotation__{position:absolute;box-sizing:border-box;font-family:Arial,sans-serif;color:#fff;z-index:20;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;-moz-user-select:none;-ms-user-select:none}.__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);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,.1);background-color:transparent}.__cxt-ar-annotation__[data-ar-type=highlight]:hover{border:1px solid rgba(255,255,255,.5);background-color:transparent}.__cxt-ar-annotation__ svg{pointer-events:all} diff --git a/assets/js/videojs-youtube-annotations.js b/assets/js/videojs-youtube-annotations.js deleted file mode 100644 index b6055054..00000000 --- a/assets/js/videojs-youtube-annotations.js +++ /dev/null @@ -1,975 +0,0 @@ -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); -} diff --git a/assets/js/videojs-youtube-annotations.min.js b/assets/js/videojs-youtube-annotations.min.js new file mode 100644 index 00000000..c93e14e8 --- /dev/null +++ b/assets/js/videojs-youtube-annotations.min.js @@ -0,0 +1 @@ +class AnnotationParser{static get defaultAppearanceAttributes(){return{bgColor:16777215,bgOpacity:.8,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"}}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]},`}}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}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);let annotation={type:attributes.type,x:backgroundShape.x,y:backgroundShape.y,width:backgroundShape.width,height:backgroundShape.height,timeStart:timeStart,timeEnd:timeEnd};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: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");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){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: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");if(bgOpacity)styles.bgOpacity=parseFloat(bgOpacity,10);if(bgColor)styles.bgColor=parseInt(bgColor,10);if(fgColor)styles.fgColor=parseInt(fgColor,10);if(textSize)styles.textSize=parseFloat(textSize,10)}return styles}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}}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=1e3){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();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;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";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";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=.17379070765180116;const horizontalBaseEndMultiplier=.14896346370154384;const verticalBaseStartMultiplier=.12;const verticalBaseEndMultiplier=.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(pointXy+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&&pointYx+width&&pointY>y-pospad&&pointYy&&pointY=start&&videoTimeend)){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;for(const annotation of this.annotations){this.updateAnnotationTextSize(annotation,containerHeight)}}updateCloseSize(containerHeight){if(!containerHeight)containerHeight=this.container.getBoundingClientRect().height;const multiplier=.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){if(widthDivider>heightDivider){scaledVideoWidth=playerHeight/videoHeight*videoWidth;scaledVideoHeight=playerHeight}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*.008;let verticalPadding=scaledVideoHeight*.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(!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: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()}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: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");player.on("playerresize",e=>{renderer.updateAllAnnotationSizes(renderer.annotations)});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=`\n\t.vjs-control-bar {\n\t\tz-index: 21;\n\t}\n\t`;document.body.append(styles)} diff --git a/src/invidious.cr b/src/invidious.cr index dc6ba734..1d2e63b2 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -347,7 +347,6 @@ get "/watch" do |env| params = process_video_params(env.params.query, preferences) env.params.query.delete_all("listen") - env.params.query.delete_all("iv_load_policy") begin video = get_video(id, PG_DB, proxies, region: params.region) @@ -359,9 +358,12 @@ get "/watch" do |env| next templated "error" end - if preferences.annotations_subscribed && subscriptions.includes? video.ucid + if preferences.annotations_subscribed && + subscriptions.includes?(video.ucid) && + (env.params.query["iv_load_policy"]? || "1") == "1" params.annotations = true end + env.params.query.delete_all("iv_load_policy") if watched && !watched.includes? id PG_DB.exec("UPDATE users SET watched = watched || $1 WHERE email = $2", [id], user.as(User).email) @@ -554,7 +556,9 @@ get "/embed/:id" do |env| next templated "error" end - if preferences.annotations_subscribed && subscriptions.includes? video.ucid + if preferences.annotations_subscribed && + subscriptions.includes?(video.ucid) && + (env.params.query["iv_load_policy"]? || "1") == "1" params.annotations = true end diff --git a/src/invidious/views/components/player_sources.ecr b/src/invidious/views/components/player_sources.ecr index d4e1c2f7..d51697c1 100644 --- a/src/invidious/views/components/player_sources.ecr +++ b/src/invidious/views/components/player_sources.ecr @@ -2,14 +2,16 @@ - - +<% if params.annotations %> + + +<% end %> <% if params.listen || params.quality != "dash" %> diff --git a/src/invidious/views/licenses.ecr b/src/invidious/views/licenses.ecr index 07486d2c..a7798ff0 100644 --- a/src/invidious/views/licenses.ecr +++ b/src/invidious/views/licenses.ecr @@ -93,6 +93,20 @@ + + + videojs-youtube-annotations.min.js + + + + GPL-3.0 + + + + <%= translate(locale, "source") %> + + + video.min.js