diff --git a/assets/css/videojs-youtube-annotations.css b/assets/css/videojs-youtube-annotations.css new file mode 100644 index 00000000..3ca4e46d --- /dev/null +++ b/assets/css/videojs-youtube-annotations.css @@ -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; +} \ No newline at end of file diff --git a/assets/js/videojs-youtube-annotations.js b/assets/js/videojs-youtube-annotations.js new file mode 100644 index 00000000..b6055054 --- /dev/null +++ b/assets/js/videojs-youtube-annotations.js @@ -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); +} diff --git a/locales/ar.json b/locales/ar.json index 4b9b2711..0e619488 100644 --- a/locales/ar.json +++ b/locales/ar.json @@ -65,10 +65,12 @@ "Default captions: ": "الترجمات الإفتراضية: ", "Fallback captions: ": "الترجمات المصاحبة: ", "Show related videos? ": "عرض مقاطع الفيديو ذات الصلة؟", + "Show annotations by default? ": "", "Visual preferences": "التفضيلات المرئية", "Dark mode: ": "الوضع الليلى: ", "Thin mode: ": "الوضع الخفيف: ", "Subscription preferences": "تفضيلات الإشتراك", + "Show annotations by default for subscribed channels? ": "", "Redirect homepage to feed: ": "إعادة التوجية من الصفحة الرئيسية لصفحة المشتركين (لرؤية اخر فيديوهات المشتركين): ", "Number of videos shown in feed: ": "عدد الفيديوهات التى ستظهر فى صفحة المشتركين: ", "Sort videos by: ": "ترتيب الفيديو بـ: ", @@ -118,6 +120,8 @@ "Trending": "الشائع", "Unlisted": "غير مصنف", "Watch on YouTube": "مشاهدة الفيديو على اليوتيوب", + "Hide annotations": "", + "Show annotations": "", "Genre: ": "النوع: ", "License: ": "التراخيص: ", "Family friendly? ": "محتوى عائلى? ", diff --git a/locales/de.json b/locales/de.json index a2a09e68..12c8866d 100644 --- a/locales/de.json +++ b/locales/de.json @@ -65,10 +65,12 @@ "Default captions: ": "Standarduntertitel: ", "Fallback captions: ": "Ersatzuntertitel: ", "Show related videos? ": "Ähnliche Videos anzeigen? ", + "Show annotations by default? ": "", "Visual preferences": "Anzeigeeinstellungen", "Dark mode: ": "Nachtmodus: ", "Thin mode: ": "Schlanker Modus: ", "Subscription preferences": "Abonnementeinstellungen", + "Show annotations by default for subscribed channels? ": "", "Redirect homepage to feed: ": "Startseite zu Feed umleiten: ", "Number of videos shown in feed: ": "Anzahl von Videos die im Feed angezeigt werden: ", "Sort videos by: ": "Videos sortieren nach: ", @@ -118,6 +120,8 @@ "Trending": "Trending", "Unlisted": "", "Watch on YouTube": "Video auf YouTube ansehen", + "Hide annotations": "", + "Show annotations": "", "Genre: ": "Genre: ", "License: ": "Lizenz: ", "Family friendly? ": "Familienfreundlich? ", diff --git a/locales/en-US.json b/locales/en-US.json index 21c2d515..d6a9971d 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -71,10 +71,12 @@ "Default captions: ": "Default captions: ", "Fallback captions: ": "Fallback captions: ", "Show related videos? ": "Show related videos? ", + "Show annotations by default? ": "Show annotations by default? ", "Visual preferences": "Visual preferences", "Dark mode: ": "Dark mode: ", "Thin mode: ": "Thin mode: ", "Subscription preferences": "Subscription preferences", + "Show annotations by default for subscribed channels? ": "Show annotations by default for subscribed channels? ", "Redirect homepage to feed: ": "Redirect homepage to feed: ", "Number of videos shown in feed: ": "Number of videos shown in feed: ", "Sort videos by: ": "Sort videos by: ", @@ -133,6 +135,8 @@ "Trending": "Trending", "Unlisted": "Unlisted", "Watch on YouTube": "Watch on YouTube", + "Hide annotations": "Hide annotations", + "Show annotations": "Show annotations", "Genre: ": "Genre: ", "License: ": "License: ", "Family friendly? ": "Family friendly? ", diff --git a/locales/eo.json b/locales/eo.json index 0613f5d7..647d7fad 100644 --- a/locales/eo.json +++ b/locales/eo.json @@ -65,10 +65,12 @@ "Default captions: ": "Defaŭltaj subtekstoj: ", "Fallback captions: ": "Retrodefaŭltaj subtekstoj: ", "Show related videos? ": "Ĉu montri rilatajn videojn? ", + "Show annotations by default? ": "", "Visual preferences": "Vidaj preferoj", "Dark mode: ": "Malhela reĝimo: ", "Thin mode: ": "Maldika reĝimo: ", "Subscription preferences": "Abonaj agordoj", + "Show annotations by default for subscribed channels? ": "", "Redirect homepage to feed: ": "Alidirekti hejmpâgon al fluo: ", "Number of videos shown in feed: ": "Nombro da videoj montritaj en fluo: ", "Sort videos by: ": "Ordi videojn laŭ: ", @@ -118,6 +120,8 @@ "Trending": "Tendencoj", "Unlisted": "Ne listigita", "Watch on YouTube": "Vidi videon en Youtube", + "Hide annotations": "", + "Show annotations": "", "Genre: ": "Ĝenro: ", "License: ": "Licenco: ", "Family friendly? ": "Ĉu familie amika? ", diff --git a/locales/es.json b/locales/es.json index 15191506..1a272c8c 100644 --- a/locales/es.json +++ b/locales/es.json @@ -65,10 +65,12 @@ "Default captions: ": "Subtítulos por defecto: ", "Fallback captions: ": "Subtítulos alternativos: ", "Show related videos? ": "¿Mostrar vídeos relacionados? ", + "Show annotations by default? ": "", "Visual preferences": "Preferencias visuales", "Dark mode: ": "Modo oscuro: ", "Thin mode: ": "Modo compacto: ", "Subscription preferences": "Preferencias de la suscripción", + "Show annotations by default for subscribed channels? ": "", "Redirect homepage to feed: ": "Redirigir la página de inicio a la fuente: ", "Number of videos shown in feed: ": "Número de vídeos mostrados en la fuente: ", "Sort videos by: ": "Ordenar los vídeos por: ", @@ -118,6 +120,8 @@ "Trending": "Tendencias", "Unlisted": "No listado", "Watch on YouTube": "Ver el vídeo en Youtube", + "Hide annotations": "", + "Show annotations": "", "Genre: ": "Género: ", "License: ": "Licencia: ", "Family friendly? ": "¿Filtrar contenidos? ", diff --git a/locales/eu.json b/locales/eu.json index a17f8ec8..43648849 100644 --- a/locales/eu.json +++ b/locales/eu.json @@ -65,10 +65,12 @@ "Default captions: ": "", "Fallback captions: ": "", "Show related videos? ": "", + "Show annotations by default? ": "", "Visual preferences": "", "Dark mode: ": "", "Thin mode: ": "", "Subscription preferences": "", + "Show annotations by default for subscribed channels? ": "", "Redirect homepage to feed: ": "", "Number of videos shown in feed: ": "", "Sort videos by: ": "", @@ -118,6 +120,8 @@ "Trending": "", "Unlisted": "", "Watch on YouTube": "", + "Hide annotations": "", + "Show annotations": "", "Genre: ": "", "License: ": "", "Family friendly? ": "", diff --git a/locales/fr.json b/locales/fr.json index a592f523..f42767fd 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -65,10 +65,12 @@ "Default captions: ": "Sous-titres par défaut : ", "Fallback captions: ": "Fallback captions: ", "Show related videos? ": "Voir les vidéos liées ? ", + "Show annotations by default? ": "", "Visual preferences": "Préférences du site", "Dark mode: ": "Mode Sombre : ", "Thin mode: ": "Mode Simplifié : ", "Subscription preferences": "Préférences de la page d'abonnements", + "Show annotations by default for subscribed channels? ": "", "Redirect homepage to feed: ": "Rediriger la page d'accueil vers la page d'abonnements : ", "Number of videos shown in feed: ": "Nombre de vidéos montrées dans la page d'abonnements : ", "Sort videos by: ": "Trier les vidéos par : ", @@ -118,6 +120,8 @@ "Trending": "Tendances", "Unlisted": "Non répertoriée", "Watch on YouTube": "Voir la vidéo sur Youtube", + "Hide annotations": "", + "Show annotations": "", "Genre: ": "Genre : ", "License: ": "Licence : ", "Family friendly? ": "Tout Public ? ", diff --git a/locales/it.json b/locales/it.json index 3c938ffb..d0a16b94 100644 --- a/locales/it.json +++ b/locales/it.json @@ -65,10 +65,12 @@ "Default captions: ": "Sottotitoli predefiniti: ", "Fallback captions: ": "Sottotitoli alternativi: ", "Show related videos? ": "Mostra video correlati? ", + "Show annotations by default? ": "", "Visual preferences": "Preferenze grafiche", "Dark mode: ": "Tema scuro: ", "Thin mode: ": "Modalità per connessioni lente: ", "Subscription preferences": "Preferenze iscrizioni", + "Show annotations by default for subscribed channels? ": "", "Redirect homepage to feed: ": "Reindirizza la pagina principale a quella delle iscrizioni: ", "Number of videos shown in feed: ": "Numero di video da mostrare nelle iscrizioni: ", "Sort videos by: ": "Ordinare i video per: ", @@ -118,6 +120,8 @@ "Trending": "Tendenze", "Unlisted": "", "Watch on YouTube": "Guarda il video su YouTube", + "Hide annotations": "", + "Show annotations": "", "Genre: ": "Genere: ", "License: ": "Licenza: ", "Family friendly? ": "Per tutti? ", diff --git a/locales/nb_NO.json b/locales/nb_NO.json index 5adeeeeb..821c3472 100644 --- a/locales/nb_NO.json +++ b/locales/nb_NO.json @@ -65,10 +65,12 @@ "Default captions: ": "Forvalgte undertitler: ", "Fallback captions: ": "Tilbakefallsundertitler: ", "Show related videos? ": "Vis relaterte videoer? ", + "Show annotations by default? ": "", "Visual preferences": "Visuelle innstillinger", "Dark mode: ": "Mørk drakt: ", "Thin mode: ": "Tynt modus: ", "Subscription preferences": "Abonnementsinnstillinger", + "Show annotations by default for subscribed channels? ": "", "Redirect homepage to feed: ": "Videresend hjemmeside til flyt: ", "Number of videos shown in feed: ": "Antall videoer å vise i flyt: ", "Sort videos by: ": "Sorter videoer etter: ", @@ -118,6 +120,8 @@ "Trending": "Trendsettende", "Unlisted": "Ulistet", "Watch on YouTube": "Vis video på YouTube", + "Hide annotations": "", + "Show annotations": "", "Genre: ": "Sjanger: ", "License: ": "Lisens: ", "Family friendly? ": "Familievennlig? ", diff --git a/locales/nl.json b/locales/nl.json index 29e38e1c..46163fbe 100644 --- a/locales/nl.json +++ b/locales/nl.json @@ -65,10 +65,12 @@ "Default captions: ": "Standaard ondertitels: ", "Fallback captions: ": "Alternatieve ondertitels: ", "Show related videos? ": "Laat gerelateerde videos zien? ", + "Show annotations by default? ": "", "Visual preferences": "Visuele voorkeuren", "Dark mode: ": "Donkere modus: ", "Thin mode: ": "Smalle modus: ", "Subscription preferences": "Abonnement voorkeuren", + "Show annotations by default for subscribed channels? ": "", "Redirect homepage to feed: ": "Startpagina omleiden naar feed: ", "Number of videos shown in feed: ": "Aantal videos te zien in feed: ", "Sort videos by: ": "Sorteer videos op: ", @@ -118,6 +120,8 @@ "Trending": "Trending", "Unlisted": "", "Watch on YouTube": "Bekijk video op Youtube", + "Hide annotations": "", + "Show annotations": "", "Genre: ": "Genre: ", "License: ": "Licentie: ", "Family friendly? ": "Gezinsvriendelijk? ", diff --git a/locales/pl.json b/locales/pl.json index 745f8a79..23348795 100644 --- a/locales/pl.json +++ b/locales/pl.json @@ -65,10 +65,12 @@ "Default captions: ": "Domyślne napisy: ", "Fallback captions: ": "Zastępcze napisy: ", "Show related videos? ": "Pokaż powiązane filmy? ", + "Show annotations by default? ": "", "Visual preferences": "Preferencje Wizualne", "Dark mode: ": "Ciemny motyw: ", "Thin mode: ": "Tryb minimalny: ", "Subscription preferences": "Preferencje subskrybcji", + "Show annotations by default for subscribed channels? ": "", "Redirect homepage to feed: ": "Przekieruj stronę główną do subskrybcji: ", "Number of videos shown in feed: ": "Liczba filmów widoczna na stronie subskrybcji: ", "Sort videos by: ": "Sortuj filmy: ", @@ -118,6 +120,8 @@ "Trending": "Na czasie", "Unlisted": "", "Watch on YouTube": "Zobacz film na YouTube", + "Hide annotations": "", + "Show annotations": "", "Genre: ": "Gatunek: ", "License: ": "Licencja: ", "Family friendly? ": "Przyjazny rodzinie? ", diff --git a/locales/ru.json b/locales/ru.json index 79536302..9c5abf9d 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -65,10 +65,12 @@ "Default captions: ": "Субтитры по умолчанию: ", "Fallback captions: ": "Резервные субтитры: ", "Show related videos? ": "Показывать похожие видео? ", + "Show annotations by default? ": "", "Visual preferences": "Визуальные настройки", "Dark mode: ": "Темная тема: ", "Thin mode: ": "Облегченный режим: ", "Subscription preferences": "Настройки подписок", + "Show annotations by default for subscribed channels? ": "", "Redirect homepage to feed: ": "Отображать ленту вместо главной страницы: ", "Number of videos shown in feed: ": "Число видео в ленте: ", "Sort videos by: ": "Сортировать видео по: ", @@ -118,6 +120,8 @@ "Trending": "В тренде", "Unlisted": "Доступно по ссылке", "Watch on YouTube": "Смотреть на YouTube", + "Hide annotations": "", + "Show annotations": "", "Genre: ": "Жанр: ", "License: ": "Лицензия: ", "Family friendly? ": "Семейный просмотр: ", diff --git a/locales/uk.json b/locales/uk.json index 02fa563f..e066d63f 100644 --- a/locales/uk.json +++ b/locales/uk.json @@ -65,10 +65,12 @@ "Default captions: ": "Основна мова субтитрів: ", "Fallback captions: ": "Запасна мова субтитрів: ", "Show related videos? ": "Показувати схожі відео? ", + "Show annotations by default? ": "", "Visual preferences": "Налаштування сайту", "Dark mode: ": "Темне оформлення: ", "Thin mode: ": "Полегшене оформлення: ", "Subscription preferences": "Налаштування підписок", + "Show annotations by default for subscribed channels? ": "", "Redirect homepage to feed: ": "Показувати відео з каналів, на які підписані, як головну сторінку: ", "Number of videos shown in feed: ": "Кількість відео з каналів, на які підписані, у потоці: ", "Sort videos by: ": "Сортувати відео: ", @@ -118,6 +120,8 @@ "Trending": "У тренді", "Unlisted": "Відсутнє у листі", "Watch on YouTube": "Дивитися відео на YouTube", + "Hide annotations": "", + "Show annotations": "", "Genre: ": "Жанр: ", "License: ": "Ліцензія: ", "Family friendly? ": "Перегляд із родиною? ", diff --git a/src/invidious.cr b/src/invidious.cr index 98c18b55..dc6ba734 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -338,8 +338,8 @@ get "/watch" do |env| preferences = env.get("preferences").as(Preferences) - if env.get? "user" - user = env.get("user").as(User) + user = env.get?("user").try &.as(User) + if user subscriptions = user.subscriptions watched = user.watched end @@ -347,9 +347,10 @@ 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]) + video = get_video(id, PG_DB, proxies, region: params.region) rescue ex : VideoRedirect next env.redirect "/watch?v=#{ex.message}" rescue ex @@ -358,6 +359,10 @@ get "/watch" do |env| next templated "error" end + if preferences.annotations_subscribed && subscriptions.includes? video.ucid + params.annotations = true + end + if watched && !watched.includes? id PG_DB.exec("UPDATE users SET watched = watched || $1 WHERE email = $2", [id], user.as(User).email) end @@ -404,7 +409,7 @@ get "/watch" do |env| fmt_stream = video.fmt_stream(decrypt_function) adaptive_fmts = video.adaptive_fmts(decrypt_function) - if params[:local] + if params.local fmt_stream.each { |fmt| fmt["url"] = URI.parse(fmt["url"]).full_path } adaptive_fmts.each { |fmt| fmt["url"] = URI.parse(fmt["url"]).full_path } end @@ -415,12 +420,12 @@ get "/watch" do |env| captions = video.captions preferred_captions = captions.select { |caption| - params[:preferred_captions].includes?(caption.name.simpleText) || - params[:preferred_captions].includes?(caption.languageCode.split("-")[0]) + params.preferred_captions.includes?(caption.name.simpleText) || + params.preferred_captions.includes?(caption.languageCode.split("-")[0]) } preferred_captions.sort_by! { |caption| - (params[:preferred_captions].index(caption.name.simpleText) || - params[:preferred_captions].index(caption.languageCode.split("-")[0])).not_nil! + (params.preferred_captions.index(caption.name.simpleText) || + params.preferred_captions.index(caption.languageCode.split("-")[0])).not_nil! } captions = captions - preferred_captions @@ -441,11 +446,11 @@ get "/watch" do |env| thumbnail = "/vi/#{video.id}/maxres.jpg" - if params[:raw] + if params.raw url = fmt_stream[0]["url"] fmt_stream.each do |fmt| - if fmt["label"].split(" - ")[0] == params[:quality] + if fmt["label"].split(" - ")[0] == params.quality url = fmt["url"] end end @@ -533,8 +538,15 @@ get "/embed/:id" do |env| params = process_video_params(env.params.query, preferences) + user = env.get?("user").try &.as(User) + if user + subscriptions = user.subscriptions + watched = user.watched + end + subscriptions ||= [] of String + begin - video = get_video(id, PG_DB, proxies, region: params[:region]) + video = get_video(id, PG_DB, proxies, region: params.region) rescue ex : VideoRedirect next env.redirect "/embed/#{ex.message}" rescue ex @@ -542,10 +554,18 @@ get "/embed/:id" do |env| next templated "error" end + if preferences.annotations_subscribed && subscriptions.includes? video.ucid + params.annotations = true + end + + if watched && !watched.includes? id + PG_DB.exec("UPDATE users SET watched = watched || $1 WHERE email = $2", [id], user.as(User).email) + end + fmt_stream = video.fmt_stream(decrypt_function) adaptive_fmts = video.adaptive_fmts(decrypt_function) - if params[:local] + if params.local fmt_stream.each { |fmt| fmt["url"] = URI.parse(fmt["url"]).full_path } adaptive_fmts.each { |fmt| fmt["url"] = URI.parse(fmt["url"]).full_path } end @@ -556,12 +576,12 @@ get "/embed/:id" do |env| captions = video.captions preferred_captions = captions.select { |caption| - params[:preferred_captions].includes?(caption.name.simpleText) || - params[:preferred_captions].includes?(caption.languageCode.split("-")[0]) + params.preferred_captions.includes?(caption.name.simpleText) || + params.preferred_captions.includes?(caption.languageCode.split("-")[0]) } preferred_captions.sort_by! { |caption| - (params[:preferred_captions].index(caption.name.simpleText) || - params[:preferred_captions].index(caption.languageCode.split("-")[0])).not_nil! + (params.preferred_captions.index(caption.name.simpleText) || + params.preferred_captions.index(caption.languageCode.split("-")[0])).not_nil! } captions = captions - preferred_captions @@ -582,11 +602,11 @@ get "/embed/:id" do |env| thumbnail = "/vi/#{video.id}/maxres.jpg" - if params[:raw] + if params.raw url = fmt_stream[0]["url"] fmt_stream.each do |fmt| - if fmt["label"].split(" - ")[0] == params[:quality] + if fmt["label"].split(" - ")[0] == params.quality url = fmt["url"] end end @@ -1236,6 +1256,14 @@ post "/preferences" do |env| video_loop ||= "off" video_loop = video_loop == "on" + annotations = env.params.body["annotations"]?.try &.as(String) + annotations ||= "off" + annotations = annotations == "on" + + annotations_subscribed = env.params.body["annotations_subscribed"]?.try &.as(String) + annotations_subscribed ||= "off" + annotations_subscribed = annotations_subscribed == "on" + autoplay = env.params.body["autoplay"]?.try &.as(String) autoplay ||= "off" autoplay = autoplay == "on" @@ -1313,27 +1341,29 @@ post "/preferences" do |env| notifications_only = notifications_only == "on" preferences = { - "video_loop" => video_loop, - "autoplay" => autoplay, - "continue" => continue, - "continue_autoplay" => continue_autoplay, - "listen" => listen, - "local" => local, - "speed" => speed, - "quality" => quality, - "volume" => volume, - "comments" => comments, - "captions" => captions, - "related_videos" => related_videos, - "redirect_feed" => redirect_feed, - "locale" => locale, - "dark_mode" => dark_mode, - "thin_mode" => thin_mode, - "max_results" => max_results, - "sort" => sort, - "latest_only" => latest_only, - "unseen_only" => unseen_only, - "notifications_only" => notifications_only, + "video_loop" => video_loop, + "annotations" => annotations, + "annotations_subscribed" => annotations_subscribed, + "autoplay" => autoplay, + "continue" => continue, + "continue_autoplay" => continue_autoplay, + "listen" => listen, + "local" => local, + "speed" => speed, + "quality" => quality, + "volume" => volume, + "comments" => comments, + "captions" => captions, + "related_videos" => related_videos, + "redirect_feed" => redirect_feed, + "locale" => locale, + "dark_mode" => dark_mode, + "thin_mode" => thin_mode, + "max_results" => max_results, + "sort" => sort, + "latest_only" => latest_only, + "unseen_only" => unseen_only, + "notifications_only" => notifications_only, }.to_json if user = env.get? "user" diff --git a/src/invidious/helpers/helpers.cr b/src/invidious/helpers/helpers.cr index d1bda9f0..becf54b4 100644 --- a/src/invidious/helpers/helpers.cr +++ b/src/invidious/helpers/helpers.cr @@ -59,27 +59,29 @@ struct ConfigPreferences end yaml_mapping({ - autoplay: {type: Bool, default: false}, - captions: {type: Array(String), default: ["", "", ""], converter: StringToArray}, - comments: {type: Array(String), default: ["youtube", ""], converter: StringToArray}, - continue: {type: Bool, default: false}, - continue_autoplay: {type: Bool, default: true}, - dark_mode: {type: Bool, default: false}, - latest_only: {type: Bool, default: false}, - listen: {type: Bool, default: false}, - local: {type: Bool, default: false}, - locale: {type: String, default: "en-US"}, - max_results: {type: Int32, default: 40}, - notifications_only: {type: Bool, default: false}, - quality: {type: String, default: "hd720"}, - redirect_feed: {type: Bool, default: false}, - related_videos: {type: Bool, default: true}, - sort: {type: String, default: "published"}, - speed: {type: Float32, default: 1.0_f32}, - thin_mode: {type: Bool, default: false}, - unseen_only: {type: Bool, default: false}, - video_loop: {type: Bool, default: false}, - volume: {type: Int32, default: 100}, + annotations: {type: Bool, default: false}, + annotations_subscribed: {type: Bool, default: false}, + autoplay: {type: Bool, default: false}, + captions: {type: Array(String), default: ["", "", ""], converter: StringToArray}, + comments: {type: Array(String), default: ["youtube", ""], converter: StringToArray}, + continue: {type: Bool, default: false}, + continue_autoplay: {type: Bool, default: true}, + dark_mode: {type: Bool, default: false}, + latest_only: {type: Bool, default: false}, + listen: {type: Bool, default: false}, + local: {type: Bool, default: false}, + locale: {type: String, default: "en-US"}, + max_results: {type: Int32, default: 40}, + notifications_only: {type: Bool, default: false}, + quality: {type: String, default: "hd720"}, + redirect_feed: {type: Bool, default: false}, + related_videos: {type: Bool, default: true}, + sort: {type: String, default: "published"}, + speed: {type: Float32, default: 1.0_f32}, + thin_mode: {type: Bool, default: false}, + unseen_only: {type: Bool, default: false}, + video_loop: {type: Bool, default: false}, + volume: {type: Int32, default: 100}, }) end diff --git a/src/invidious/users.cr b/src/invidious/users.cr index 2e9ec1e5..d452b9f2 100644 --- a/src/invidious/users.cr +++ b/src/invidious/users.cr @@ -84,27 +84,29 @@ struct Preferences end json_mapping({ - autoplay: {type: Bool, default: CONFIG.default_user_preferences.autoplay}, - captions: {type: Array(String), default: CONFIG.default_user_preferences.captions, converter: StringToArray}, - comments: {type: Array(String), default: CONFIG.default_user_preferences.comments, converter: StringToArray}, - continue: {type: Bool, default: CONFIG.default_user_preferences.continue}, - continue_autoplay: {type: Bool, default: CONFIG.default_user_preferences.continue_autoplay}, - dark_mode: {type: Bool, default: CONFIG.default_user_preferences.dark_mode}, - latest_only: {type: Bool, default: CONFIG.default_user_preferences.latest_only}, - listen: {type: Bool, default: CONFIG.default_user_preferences.listen}, - local: {type: Bool, default: CONFIG.default_user_preferences.local}, - locale: {type: String, default: CONFIG.default_user_preferences.locale}, - max_results: {type: Int32, default: CONFIG.default_user_preferences.max_results}, - notifications_only: {type: Bool, default: CONFIG.default_user_preferences.notifications_only}, - quality: {type: String, default: CONFIG.default_user_preferences.quality}, - redirect_feed: {type: Bool, default: CONFIG.default_user_preferences.redirect_feed}, - related_videos: {type: Bool, default: CONFIG.default_user_preferences.related_videos}, - sort: {type: String, default: CONFIG.default_user_preferences.sort}, - speed: {type: Float32, default: CONFIG.default_user_preferences.speed}, - thin_mode: {type: Bool, default: CONFIG.default_user_preferences.thin_mode}, - unseen_only: {type: Bool, default: CONFIG.default_user_preferences.unseen_only}, - video_loop: {type: Bool, default: CONFIG.default_user_preferences.video_loop}, - volume: {type: Int32, default: CONFIG.default_user_preferences.volume}, + annotations: {type: Bool, default: CONFIG.default_user_preferences.annotations}, + annotations_subscribed: {type: Bool, default: CONFIG.default_user_preferences.annotations_subscribed}, + autoplay: {type: Bool, default: CONFIG.default_user_preferences.autoplay}, + captions: {type: Array(String), default: CONFIG.default_user_preferences.captions, converter: StringToArray}, + comments: {type: Array(String), default: CONFIG.default_user_preferences.comments, converter: StringToArray}, + continue: {type: Bool, default: CONFIG.default_user_preferences.continue}, + continue_autoplay: {type: Bool, default: CONFIG.default_user_preferences.continue_autoplay}, + dark_mode: {type: Bool, default: CONFIG.default_user_preferences.dark_mode}, + latest_only: {type: Bool, default: CONFIG.default_user_preferences.latest_only}, + listen: {type: Bool, default: CONFIG.default_user_preferences.listen}, + local: {type: Bool, default: CONFIG.default_user_preferences.local}, + locale: {type: String, default: CONFIG.default_user_preferences.locale}, + max_results: {type: Int32, default: CONFIG.default_user_preferences.max_results}, + notifications_only: {type: Bool, default: CONFIG.default_user_preferences.notifications_only}, + quality: {type: String, default: CONFIG.default_user_preferences.quality}, + redirect_feed: {type: Bool, default: CONFIG.default_user_preferences.redirect_feed}, + related_videos: {type: Bool, default: CONFIG.default_user_preferences.related_videos}, + sort: {type: String, default: CONFIG.default_user_preferences.sort}, + speed: {type: Float32, default: CONFIG.default_user_preferences.speed}, + thin_mode: {type: Bool, default: CONFIG.default_user_preferences.thin_mode}, + unseen_only: {type: Bool, default: CONFIG.default_user_preferences.unseen_only}, + video_loop: {type: Bool, default: CONFIG.default_user_preferences.video_loop}, + volume: {type: Int32, default: CONFIG.default_user_preferences.volume}, }) end diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index 643b7654..755ee4d7 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -241,6 +241,28 @@ VIDEO_FORMATS = { "251" => {"ext" => "webm", "format" => "DASH audio", "acodec" => "opus", "abr" => 160}, } +struct VideoPreferences + json_mapping({ + annotations: Bool, + autoplay: Bool, + continue: Bool, + continue_autoplay: Bool, + controls: Bool, + listen: Bool, + local: Bool, + preferred_captions: Array(String), + quality: String, + raw: Bool, + region: String?, + related_videos: Bool, + speed: (Float32 | Float64), + video_end: (Float64 | Int32), + video_loop: Bool, + video_start: (Float64 | Int32), + volume: Int32, + }) +end + struct Video property player_json : JSON::Any? @@ -1199,6 +1221,7 @@ def itag_to_metadata?(itag : String) end def process_video_params(query, preferences) + annotations = query["iv_load_policy"]?.try &.to_i? autoplay = query["autoplay"]?.try &.to_i? continue = query["continue"]?.try &.to_i? continue_autoplay = query["continue_autoplay"]?.try &.to_i? @@ -1214,6 +1237,7 @@ def process_video_params(query, preferences) if preferences # region ||= preferences.region + annotations ||= preferences.annotations.to_unsafe autoplay ||= preferences.autoplay.to_unsafe continue ||= preferences.continue.to_unsafe continue_autoplay ||= preferences.continue_autoplay.to_unsafe @@ -1227,6 +1251,7 @@ def process_video_params(query, preferences) volume ||= preferences.volume end + annotations ||= CONFIG.default_user_preferences.annotations.to_unsafe autoplay ||= CONFIG.default_user_preferences.autoplay.to_unsafe continue ||= CONFIG.default_user_preferences.continue.to_unsafe continue_autoplay ||= CONFIG.default_user_preferences.continue_autoplay.to_unsafe @@ -1239,6 +1264,7 @@ def process_video_params(query, preferences) video_loop ||= CONFIG.default_user_preferences.video_loop.to_unsafe volume ||= CONFIG.default_user_preferences.volume + annotations = annotations == 1 autoplay = autoplay == 1 continue = continue == 1 continue_autoplay = continue_autoplay == 1 @@ -1272,24 +1298,25 @@ def process_video_params(query, preferences) controls ||= 1 controls = controls >= 1 - params = { - autoplay: autoplay, - continue: continue, - continue_autoplay: continue_autoplay, - controls: controls, - listen: listen, - local: local, + params = VideoPreferences.new( + annotations: annotations, + autoplay: autoplay, + continue: continue, + continue_autoplay: continue_autoplay, + controls: controls, + listen: listen, + local: local, preferred_captions: preferred_captions, - quality: quality, - raw: raw, - region: region, - related_videos: related_videos, - speed: speed, - video_end: video_end, - video_loop: video_loop, - video_start: video_start, - volume: volume, - } + quality: quality, + raw: raw, + region: region, + related_videos: related_videos, + speed: speed, + video_end: video_end, + video_loop: video_loop, + video_start: video_start, + volume: volume, + ) return params end diff --git a/src/invidious/views/components/player.ecr b/src/invidious/views/components/player.ecr index 3ab44899..eecaf160 100644 --- a/src/invidious/views/components/player.ecr +++ b/src/invidious/views/components/player.ecr @@ -3,26 +3,26 @@ onmouseenter='this["data-title"]=this["title"];this["title"]=""' onmouseleave='this["title"]=this["data-title"];this["data-title"]=""' oncontextmenu='this["title"]=this["data-title"]' - <% if params[:autoplay] %>autoplay<% end %> - <% if params[:video_loop] %>loop<% end %> - <% if params[:controls] %>controls<% end %>> + <% if params.autoplay %>autoplay<% end %> + <% if params.video_loop %>loop<% end %> + <% if params.controls %>controls<% end %>> <% if hlsvp %> <% else %> - <% if params[:listen] %> + <% if params.listen %> <% audio_streams.each_with_index do |fmt, i| %> - <% if params[:local] %>&local=true<% end %>" type='<%= fmt["type"] %>' label="<%= fmt["bitrate"] %>k" selected="<%= i == 0 ? true : false %>"> + <% if params.local %>&local=true<% end %>" type='<%= fmt["type"] %>' label="<%= fmt["bitrate"] %>k" selected="<%= i == 0 ? true : false %>"> <% end %> <% else %> - <% if params[:quality] == "dash" %> + <% if params.quality == "dash" %> <% end %> <% fmt_stream.each_with_index do |fmt, i| %> - <% if params[:quality] %> - <% if params[:local] %>&local=true<% end %>" type='<%= fmt["type"] %>' label="<%= fmt["label"] %>" selected="<%= params[:quality] == fmt["label"].split(" - ")[0] %>"> + <% if params.quality %> + <% if params.local %>&local=true<% end %>" type='<%= fmt["type"] %>' label="<%= fmt["label"] %>" selected="<%= params.quality == fmt["label"].split(" - ")[0] %>"> <% else %> - <% if params[:local] %>&local=true<% end %>" type='<%= fmt["type"] %>' label="<%= fmt["label"] %>" selected="<%= i == 0 ? true : false %>"> + <% if params.local %>&local=true<% end %>" type='<%= fmt["type"] %>' label="<%= fmt["label"] %>" selected="<%= i == 0 ? true : false %>"> <% end %> <% end %> <% end %> @@ -161,7 +161,7 @@ player.on('error', function(event) { } }); -<% if params[:video_start] > 0 || params[:video_end] > 0 %> +<% if params.video_start > 0 || params.video_end > 0 %> player.markers({ onMarkerReached: function(marker) { if (marker.text === "End") { @@ -173,22 +173,22 @@ player.markers({ } }, markers: [ - { time: <%= params[:video_start] %>, text: "Start" }, - <% if params[:video_end] < 0 %> + { time: <%= params.video_start %>, text: "Start" }, + <% if params.video_end < 0 %> { time: <%= video.info["length_seconds"].to_f - 0.5 %>, text: "End" } <% else %> - { time: <%= params[:video_end] %>, text: "End" } + { time: <%= params.video_end %>, text: "End" } <% end %> ] }); -player.currentTime(<%= params[:video_start] %>); +player.currentTime(<%= params.video_start %>); <% end %> -player.volume(<%= params[:volume].to_f / 100 %>); -player.playbackRate(<%= params[:speed] %>); +player.volume(<%= params.volume.to_f / 100 %>); +player.playbackRate(<%= params.speed %>); -<% if params[:autoplay] %> +<% if params.autoplay %> var bpb = player.getChild('bigPlayButton'); if (bpb) { @@ -211,7 +211,52 @@ if (bpb) { } <% end %> +<% if !params.listen && params.quality == "dash" %> player.httpSourceSelector(); +<% end %> + +<% if !params.listen && params.annotations %> +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); diff --git a/src/invidious/views/components/player_sources.ecr b/src/invidious/views/components/player_sources.ecr index 37fac6b1..d4e1c2f7 100644 --- a/src/invidious/views/components/player_sources.ecr +++ b/src/invidious/views/components/player_sources.ecr @@ -2,14 +2,15 @@ + - -<% if params[:quality] != "dash" %> + +<% if params.listen || params.quality != "dash" %> <% end %> \ No newline at end of file diff --git a/src/invidious/views/embed.ecr b/src/invidious/views/embed.ecr index 93078cdd..51097df1 100644 --- a/src/invidious/views/embed.ecr +++ b/src/invidious/views/embed.ecr @@ -55,14 +55,14 @@ function get_playlist(timeouts = 0) { location.assign("/embed/" + xhr.response.nextVideo + "?list=<%= plid %>" - <% if params[:listen] != preferences.listen %> - + "&listen=<%= params[:listen] %>" + <% if params.listen != preferences.listen %> + + "&listen=<%= params.listen %>" <% end %> - <% if params[:autoplay] || params[:continue_autoplay] %> + <% if params.autoplay || params.continue_autoplay %> + "&autoplay=1" <% end %> - <% if params[:speed] != preferences.speed %> - + "&speed=<%= params[:speed] %>" + <% if params.speed != preferences.speed %> + + "&speed=<%= params.speed %>" <% end %> ); }); @@ -85,14 +85,14 @@ player.on('ended', function() { <% if !video_series.empty? %> + "?playlist=<%= video_series.join(",") %>" <% end %> - <% if params[:listen] != preferences.listen %> - + "&listen=<%= params[:listen] %>" + <% if params.listen != preferences.listen %> + + "&listen=<%= params.listen %>" <% end %> - <% if params[:autoplay] || params[:continue_autoplay] %> + <% if params.autoplay || params.continue_autoplay %> + "&autoplay=1" <% end %> - <% if params[:speed] != preferences.speed %> - + "&speed=<%= params[:speed] %>" + <% if params.speed != preferences.speed %> + + "&speed=<%= params.speed %>" <% end %> ); }); diff --git a/src/invidious/views/preferences.ecr b/src/invidious/views/preferences.ecr index 5d2c35b1..9128c3f5 100644 --- a/src/invidious/views/preferences.ecr +++ b/src/invidious/views/preferences.ecr @@ -94,6 +94,11 @@ function update_value(element) { checked<% end %>> +
+ + checked<% end %>> +
+ <%= translate(locale, "Visual preferences") %>
@@ -118,6 +123,11 @@ function update_value(element) { <% if env.get? "user" %> <%= translate(locale, "Subscription preferences") %> +
+ + checked<% end %>> +
+
checked<% end %>> diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index 476117e2..f74cf594 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -34,7 +34,7 @@

<%= HTML.escape(video.title) %> - <% if params[:listen] %> + <% if params.listen %> " href="/watch?<%= env.params.query %>&listen=0"> @@ -56,6 +56,17 @@

<%= translate(locale, "Watch on YouTube") %>

+

+ <% if params.annotations %> + + <%= translate(locale, "Hide annotations") %> + + <% else %> + + <%=translate(locale, "Show annotations")%> + + <% end %> +

<% if CONFIG.dmca_content.includes? video.id %>

Download is disabled.

@@ -122,7 +133,7 @@
- - <% if params[:related_videos] || plid %> + <% if params.related_videos || plid %>
<% if plid %>
<% end %> - <% if params[:related_videos] %> + <% if params.related_videos %>
<% if !rvs.empty? %>
style="display:none"<% end %>>
- checked<% end %>> + checked<% end %>>

@@ -205,19 +216,19 @@