HoloTools Comment Widener

Puts comments next to videos instead of inside of their boxes

// ==UserScript==
// @name         HoloTools Comment Widener
// @namespace    http://tampermonkey.net/
// @version      0.3
// @description  Puts comments next to videos instead of inside of their boxes
// @author       TellowKrinkle
// @match        https://hololive.jetri.co/
// @grant        none
// ==/UserScript==

(function() {
	'use strict';

	const videoPadding = 3; // px
	const containerPadding = 5; // px
	const commentWidth = 300; // px

	/** Check if HoloTools is in auto layout mode */
	function isAutoLayout() {
		let layoutMode = document.getElementsByClassName("layout-mode")[0];
		if (!layoutMode) { return true; } // If holotools updates and this breaks, say yes
		return layoutMode.innerHTML !== "unfold_more";
	}

	/**
	 * Helper for mass update of HTML nodes
	 * For each (string, function) pair, calls function on all elements with the class string
	 * @param {Object.<string, function(HTMLElement):void>} updates
	 */
	function updateByClass(updates) {
		for (const className of Object.keys(updates)) {
			for (const element of document.getElementsByClassName(className)) {
				updates[className](element);
			}
		}
	}

	/** Resize all videos to the given width */
	function resizeVideos(videoWidth, containerHeight, hasComments) {
		const height = videoWidth * 9 / 16;
		const containerWidth = hasComments ? videoWidth + 300 : videoWidth;
		updateByClass({
			"player-yt-box": (container) => {
				container.style.width = `${containerWidth}px`;
				container.style.height = `${containerHeight}px`;
			},
			"player-yt-frame": (video) => {
				video.style.top = "0px";
				video.children[0].style.top = "0px";
				video.children[0].style.width = `${videoWidth}px`;
				video.children[0].style.height = `${height}px`;
			},
			"player-yt-info": (info) => { info.style.display = "none"; }
		});
	}

	/** Calculates the size of the video and its player */
	function calculateVideoSize(videoCount, videosWide, containerWidth, containerHeight, hasComments) {
		const videosHigh = Math.ceil(videoCount / videosWide);
		const widthPadding = videosWide * videoPadding;
		const heightPadding = videosHigh * videoPadding;
		const videoWidth = Math.floor((containerWidth - widthPadding) / videosWide) - (hasComments ? commentWidth : 0);
		const videoHeight = Math.floor((containerHeight - heightPadding) / videosHigh);
		return {width: Math.min(videoWidth, Math.floor(videoHeight * 16 / 9)), commentHeight: videoHeight};
	}

	/** Calculates the optimal number of videos wide to get the best usage of space */
	function calculateOptimalVideosWide(videoCount, containerWidth, containerHeight, hasComments) {
		let best = calculateVideoSize(videoCount, 1, containerWidth, containerHeight, hasComments).width;
		for (let videosWide = 2; videosWide < 10; videosWide++) {
			let newHeight = calculateVideoSize(videoCount, videosWide, containerWidth, containerHeight, hasComments).width;
			if (newHeight > best) {
				best = newHeight;
			} else {
				return videosWide - 1;
			}
		}
		throw `calculateOptimalVideosWide broke or there were so many videos that the optimal layout was 10 or more wide`;
	}

	let lastVideoWidth = 0; // To track whether we need to rerun relayoutVideos

	/** Recalculates the proper video size and applies it */
	function relayoutVideos() {
		const container = document.getElementsByClassName("player-container")[0];
		const videoCount = container.children.length;
		const containerWidth = container.clientWidth - containerPadding * 2;
		const containerHeight = container.clientHeight - containerPadding * 2;
		const hasComments = document.getElementsByClassName("withChat").length !== 0;
		const currentVideosWide = /repeat\((\d+)/.exec(container.style["grid-template-columns"])[1] | 0;
		let videosWide = currentVideosWide;
		if (isAutoLayout()) {
			videosWide = calculateOptimalVideosWide(videoCount, containerWidth, containerHeight, hasComments);
		}
		if (videosWide !== currentVideosWide) {
			container.style["grid-template-columns"] = `repeat(${videosWide}, 1fr)`;
		}
		const videoSize = calculateVideoSize(videoCount, videosWide, containerWidth, containerHeight, hasComments);
		console.log(`Will resize videos to ${videoSize.width}px wide`);
		lastVideoWidth = videoSize.width;
		resizeVideos(videoSize.width, videoSize.commentHeight, hasComments);
	}

	// In case someone wants to manually call it
	window.relayoutVideos = relayoutVideos;

	const videoResizeObserver = new MutationObserver(function(records) {
		for (const record of records) {
			if (record.attributeName !== "style" && record.attributeName !== "class") { continue; }
			if (record.target.style.width !== lastVideoWidth + "px") {
				relayoutVideos();
				return;
			}
		}
	});

	/** @param {HTMLElement} element */
	function watchPlayerResize(element) {
		videoResizeObserver.observe(element.children[0], {attributes: true});
		for (const child of element.children[0].children) {
			for (const grandchild of child.children) {
				if (grandchild.nodeName === "IFRAME") {
					videoResizeObserver.observe(grandchild, {attributes: true});
				}
			}
		}
	}

	/** @param {HTMLElement} element */
	function watchVideoAddRemove(element) {
		const videoAddRemoveObserver = new MutationObserver(function(records) {
			for (const record of records) {
				for (const node of record.addedNodes) {
					watchPlayerResize(node);
				}
			}
		});

		for (const child of element.children) { watchPlayerResize(child); }
		videoAddRemoveObserver.observe(element, {childList: true});
	}

	/** Initializes the userscript */
	function setup() {
		// Stores the currently-being-watched object so we don't double-watch it
		let currentWatch = document.getElementsByClassName("player-container")[0];
		if (currentWatch) { watchVideoAddRemove(currentWatch); }
		const observer = new MutationObserver(function() {
			let playerContainer = document.getElementsByClassName("player-container");
			if (currentWatch !== playerContainer[0]) {
				currentWatch = playerContainer[0];
				if (playerContainer[0]) {
					watchVideoAddRemove(playerContainer[0]);
				}
			}
		});

		const mainApp = document.getElementsByClassName("md-content")[0];
		observer.observe(mainApp, {childList: true});
	}

	// Run on page load
	if (document.readyState !== "loading") {
		setup();
	} else {
		document.addEventListener("DOMContentLoaded", setup);
	}
})();