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