summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--assets/css/videojs-youtube-annotations.css81
-rw-r--r--assets/js/videojs-youtube-annotations.js975
-rw-r--r--locales/ar.json4
-rw-r--r--locales/de.json4
-rw-r--r--locales/en-US.json4
-rw-r--r--locales/eo.json4
-rw-r--r--locales/es.json4
-rw-r--r--locales/eu.json4
-rw-r--r--locales/fr.json4
-rw-r--r--locales/it.json4
-rw-r--r--locales/nb_NO.json4
-rw-r--r--locales/nl.json4
-rw-r--r--locales/pl.json4
-rw-r--r--locales/ru.json4
-rw-r--r--locales/uk.json4
-rw-r--r--src/invidious.cr108
-rw-r--r--src/invidious/helpers/helpers.cr44
-rw-r--r--src/invidious/users.cr44
-rw-r--r--src/invidious/videos.cr61
-rw-r--r--src/invidious/views/components/player.ecr79
-rw-r--r--src/invidious/views/components/player_sources.ecr5
-rw-r--r--src/invidious/views/embed.ecr20
-rw-r--r--src/invidious/views/preferences.ecr10
-rw-r--r--src/invidious/views/watch.ecr53
24 files changed, 1384 insertions, 148 deletions
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 %>
<source src="<%= hlsvp %>?local=true" type="application/x-mpegURL" label="livestream">
<% else %>
- <% if params[:listen] %>
+ <% if params.listen %>
<% audio_streams.each_with_index do |fmt, i| %>
- <source src="/latest_version?id=<%= video.id %>&itag=<%= fmt["itag"] %><% if params[:local] %>&local=true<% end %>" type='<%= fmt["type"] %>' label="<%= fmt["bitrate"] %>k" selected="<%= i == 0 ? true : false %>">
+ <source src="/latest_version?id=<%= video.id %>&itag=<%= fmt["itag"] %><% 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" %>
<source src="/api/manifest/dash/id/<%= video.id %>?local=true" type='application/dash+xml' label="dash">
<% end %>
<% fmt_stream.each_with_index do |fmt, i| %>
- <% if params[:quality] %>
- <source src="/latest_version?id=<%= video.id %>&itag=<%= fmt["itag"] %><% if params[:local] %>&local=true<% end %>" type='<%= fmt["type"] %>' label="<%= fmt["label"] %>" selected="<%= params[:quality] == fmt["label"].split(" - ")[0] %>">
+ <% if params.quality %>
+ <source src="/latest_version?id=<%= video.id %>&itag=<%= fmt["itag"] %><% if params.local %>&local=true<% end %>" type='<%= fmt["type"] %>' label="<%= fmt["label"] %>" selected="<%= params.quality == fmt["label"].split(" - ")[0] %>">
<% else %>
- <source src="/latest_version?id=<%= video.id %>&itag=<%= fmt["itag"] %><% if params[:local] %>&local=true<% end %>" type='<%= fmt["type"] %>' label="<%= fmt["label"] %>" selected="<%= i == 0 ? true : false %>">
+ <source src="/latest_version?id=<%= video.id %>&itag=<%= fmt["itag"] %><% 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 @@
<link rel="stylesheet" href="/css/videojs-http-source-selector.css">
<link rel="stylesheet" href="/css/videojs.markers.min.css">
<link rel="stylesheet" href="/css/videojs-share.css">
+<link rel="stylesheet" href="/css/videojs-youtube-annotations.css">
<script src="/js/video.min.js"></script>
<script src="/js/videojs-contrib-quality-levels.min.js"></script>
<script src="/js/videojs-http-source-selector.min.js"></script>
<script src="/js/videojs.hotkeys.min.js"></script>
<script src="/js/videojs-markers.min.js"></script>
<script src="/js/videojs-share.min.js"></script>
-
-<% if params[:quality] != "dash" %>
+<script src="/js/videojs-youtube-annotations.js"></script>
+<% if params.listen || params.quality != "dash" %>
<link rel="stylesheet" href="/css/quality-selector.css">
<script src="/js/silvermine-videojs-quality-selector.min.js"></script>
<% 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) {
<input name="related_videos" id="related_videos" type="checkbox" <% if preferences.related_videos %>checked<% end %>>
</div>
+ <div class="pure-control-group">
+ <label for="annotations"><%= translate(locale, "Show annotations by default? ") %></label>
+ <input name="annotations" id="annotations" type="checkbox" <% if preferences.annotations %>checked<% end %>>
+ </div>
+
<legend><%= translate(locale, "Visual preferences") %></legend>
<div class="pure-control-group">
@@ -119,6 +124,11 @@ function update_value(element) {
<legend><%= translate(locale, "Subscription preferences") %></legend>
<div class="pure-control-group">
+ <label for="annotations_subscribed"><%= translate(locale, "Show annotations by default for subscribed channels? ") %></label>
+ <input name="annotations_subscribed" id="annotations_subscribed" type="checkbox" <% if preferences.annotations_subscribed %>checked<% end %>>
+ </div>
+
+ <div class="pure-control-group">
<label for="redirect_feed"><%= translate(locale, "Redirect homepage to feed: ") %></label>
<input name="redirect_feed" id="redirect_feed" type="checkbox" <% if preferences.redirect_feed %>checked<% end %>>
</div>
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 @@
<div class="h-box">
<h1>
<%= HTML.escape(video.title) %>
- <% if params[:listen] %>
+ <% if params.listen %>
<a title="<%=translate(locale, "Video mode")%>" href="/watch?<%= env.params.query %>&listen=0">
<i class="icon ion-ios-videocam"></i>
</a>
@@ -56,6 +56,17 @@
<div class="pure-u-1 pure-u-lg-1-5">
<div class="h-box">
<p><a href="https://www.youtube.com/watch?v=<%= video.id %>"><%= translate(locale, "Watch on YouTube") %></a></p>
+ <p>
+ <% if params.annotations %>
+ <a href="/watch?<%= env.params.query %>&iv_load_policy=3">
+ <%= translate(locale, "Hide annotations") %>
+ </a>
+ <% else %>
+ <a href="/watch?<%= env.params.query %>&iv_load_policy=1">
+ <%=translate(locale, "Show annotations")%>
+ </a>
+ <% end %>
+ </p>
<% if CONFIG.dmca_content.includes? video.id %>
<p>Download is disabled.</p>
@@ -122,7 +133,7 @@
</div>
</div>
- <div class="pure-u-1 <% if params[:related_videos] || plid %>pure-u-lg-3-5<% else %>pure-u-md-4-5<% end %>">
+ <div class="pure-u-1 <% if params.related_videos || plid %>pure-u-lg-3-5<% else %>pure-u-md-4-5<% end %>">
<div class="h-box">
<p>
<a href="/channel/<%= video.ucid %>">
@@ -153,21 +164,21 @@
</div>
</div>
</div>
- <% if params[:related_videos] || plid %>
+ <% if params.related_videos || plid %>
<div class="pure-u-1 pure-u-lg-1-5">
<% if plid %>
<div id="playlist" class="h-box">
</div>
<% end %>
- <% if params[:related_videos] %>
+ <% if params.related_videos %>
<div class="h-box">
<% if !rvs.empty? %>
<div <% if plid %>style="display:none"<% end %>>
<div class="pure-control-group">
<label for="continue"><%= translate(locale, "Autoplay next video: ") %></label>
- <input name="continue" onclick="continue_autoplay(this)" id="continue" type="checkbox" <% if params[:continue] %>checked<% end %>>
+ <input name="continue" onclick="continue_autoplay(this)" id="continue" type="checkbox" <% if params.continue %>checked<% end %>>
</div>
<hr>
</div>
@@ -205,19 +216,19 @@
</div>
<script>
-<% if !rvs.empty? && !plid && params[:continue] %>
+<% if !rvs.empty? && !plid && params.continue %>
player.on('ended', function() {
location.assign("/watch?v="
+ "<%= rvs.select { |rv| rv["id"]? }[0]?.try &.["id"] %>"
+ "&continue=1"
- <% 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 %>
);
});
@@ -229,14 +240,14 @@ function continue_autoplay(target) {
location.assign("/watch?v="
+ "<%= rvs.select { |rv| rv["id"]? }[0]?.try &.["id"] %>"
+ "&continue=1"
- <% 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 %>
);
});
@@ -295,14 +306,14 @@ function get_playlist(timeouts = 0) {
location.assign("/watch?v="
+ 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 %>
);
});