- // ==UserScript==
- // @name YouTube Theater Plus
- // @version 2.2.9
- // @description Enhances YouTube Theater with features like Fullpage Theater, Auto Open Theater, and more, including support for the new UI.
- // @run-at document-body
- // @inject-into content
- // @match https://www.youtube.com/*
- // @exclude https://*.youtube.com/live_chat*
- // @exclude https://*.youtube.com/embed*
- // @exclude https://*.youtube.com/tv*
- // @exclude https:/tv.youtube.com/*
- // @icon https://www.google.com/s2/favicons?sz=64&domain=youtube.com
- // @grant GM.getValue
- // @grant GM.setValue
- // @author Fznhq
- // @namespace https://github.com/fznhq
- // @homepageURL https://github.com/fznhq/userscript-collection
- // @license GNU GPLv3
- // ==/UserScript==
-
- // Icons provided by https://iconmonstr.com/
-
- (async function () {
- "use strict";
-
- const body = document.body;
-
- let theater = false;
- let fullpage = true;
-
- /**
- * @typedef {object} Option
- * @property {string} icon
- * @property {string} label
- * @property {any} value
- * @property {Function} onUpdate
- * @property {Option} sub
- */
-
- /**
- * Options must be changed via popup menu,
- * just press (v) to open the menu
- */
- const subIcon = `{"svg":{"clip-rule":"evenodd","fill-rule":"evenodd","stroke-linejoin":"round","stroke-miterlimit":"2"},"path":{"d":"M10.211 7.155A.75.75 0 0 0 9 7.747v8.501a.75.75 0 0 0 1.212.591l5.498-4.258a.746.746 0 0 0-.001-1.183zm.289 7.563V9.272l3.522 2.719z","fill-rule":"nonzero"}}`;
- const options = {
- fullpage_theater: {
- icon: `{"path":{"d":"M22 4v12H2V4zm1-1H1v14h22zm-6 17H7v1h10z"}}`,
- label: "Fullpage Theater;", // Remove ";" to set your own label.
- value: true,
- onUpdate() {
- applyTheaterMode(true);
- },
- },
- auto_theater_mode: {
- icon: `{"svg":{"fill-rule":"evenodd","clip-rule":"evenodd"},"path":{"d":"M24 22H0V2h24zm-7-1V6H1v15zm1 0h5V3H1v2h17zm-6-6h-1v-3l-7 7-1-1 7-7H7v-1h5z"}}`,
- label: "Auto Open Theater;", // Remove ";" to set your own label.
- value: false,
- onUpdate() {
- if (this.value && !theater) toggleTheater();
- },
- },
- hide_scrollbar: {
- icon: `{"path":{"d":"M14 12a2 2 0 1 1-4 0 2 2 0 0 1 4 0m-3-4h2V6h4l-5-6-5 6h4zm2 8h-2v2H7l5 6 5-6h-4z"}}`,
- label: "Theater Hide Scrollbar;", // Remove ";" to set your own label.
- value: true,
- onUpdate() {
- if (theater) {
- setHtmlAttr(attr.no_scroll, this.value);
- resizeWindow();
- }
- },
- },
- close_theater_with_esc: {
- icon: `{"svg":{"clip-rule":"evenodd","fill-rule":"evenodd","stroke-linejoin":"round","stroke-miterlimit":2},"path":{"d":"M21 3.998c0-.478-.379-1-1-1H5c-.62 0-1 .519-1 1v15c0 .621.52 1 1 1h15c.478 0 1-.379 1-1zm-16 0h15v15H5zm7.491 6.432 2.717-2.718a.75.75 0 0 1 1.061 1.062l-2.717 2.717 2.728 2.728a.75.75 0 1 1-1.061 1.062l-2.728-2.728-2.728 2.728a.751.751 0 0 1-1.061-1.062l2.728-2.728-2.722-2.722a.75.75 0 0 1 1.061-1.061z","fill-rule":"nonzero"}}`,
- label: "Close Theater With Esc;", // Remove ";" to set your own label.
- value: true,
- },
- hide_cards: {
- icon: `{"path":{"d":"M22 6v16H6V6zm1-1H5v18h18zM2 2v20h1V3h18V2zm12 9c-3 0-5 3-5 3s2 3 5 3 5-3 5-3-2-3-5-3m0 5a2 2 0 1 1 0-4 2 2 0 0 1 0 4m1-2a1 1 0 1 1-2 0 1 1 0 0 0 1-1l1 1"}}`,
- label: "Hide Cards;", // Remove ";" to set your own label.
- value: true,
- onUpdate() {
- setHtmlAttr(attr.hide_card, this.value);
- },
- },
- show_header_near: {
- icon: `{"path":{"d":"M5 4.27 15.476 13H8.934L5 18.117zm-1 0v17l5.5-7h9L4 1.77z"}}`,
- label: "Show Header When Mouse is Near;", // Remove ";" to set your own label.
- value: false,
- sub: {
- threshold: {
- label: "Threshold;", // Remove ";" to set your own label.
- value: 200,
- },
- },
- },
- };
-
- function resizeWindow() {
- document.dispatchEvent(new Event("resize", { bubbles: true }));
- }
-
- /**
- * @param {string} name
- * @param {object} attributes
- * @param {Array} append
- * @returns {SVGElement}
- */
- function createNS(name, attributes = {}, append = []) {
- const el = document.createElementNS("http://www.w3.org/2000/svg", name);
- for (const k in attributes) el.setAttributeNS(null, k, attributes[k]);
- return el.append(...append), el;
- }
-
- /**
- * @param {string} name
- * @param {any} value
- * @param {Option} option
- * @returns {any}
- */
- function saveOption(name, value, option) {
- GM.setValue(name, value);
- return (option.value = value);
- }
-
- /**
- * @param {string} name
- * @param {string} subName
- */
- async function loadOption(name, subName) {
- const key = subName ? `sub_${name}_${subName}` : name;
- const keyLabel = `label_${key}`;
- /** @type {Option} */
- const option = subName ? options[name].sub[subName] : options[name];
- const savedOption = await GM.getValue(key);
-
- if (savedOption === undefined) {
- saveOption(key, option.value, option);
- } else {
- option.value = savedOption;
- }
-
- const icon = JSON.parse(option.icon || subIcon);
- const savedLabel = await GM.getValue(keyLabel);
- let label = option.label;
-
- if (!label.endsWith(";")) {
- GM.setValue(keyLabel, label);
- } else if (savedLabel !== undefined) {
- label = savedLabel;
- }
-
- option.label = label.replace(/;$/, "");
- option.icon = createNS("svg", icon.svg, [createNS("path", icon.path)]);
- }
-
- for (const name in options) {
- await loadOption(name);
- for (const subName in options[name].sub) {
- await loadOption(name, subName);
- }
- }
-
- /**
- * @param {string} className
- * @param {Array} append
- * @returns {HTMLDivElement}
- */
- function createDiv(className, append = []) {
- const el = document.createElement("div");
- el.className = "ytp-menuitem" + (className ? "-" + className : "");
- return el.append(...append), el;
- }
-
- /** @type {Map<HTMLElement, HTMLElement[]>} */
- const menuItems = new Map();
-
- /**
- * @param {string} name
- * @param {Option} option
- * @returns {HTMLInputElement}
- */
- function itemInput(name, option) {
- const input = document.createElement("input");
- const setInput = (value) => (input.value = Number(value));
-
- let waitTyping = 0;
- let prevValue = setInput(option.value);
-
- input.addEventListener("input", (ev) => {
- const value = setInput(input.value.replace(/\D*/g, ""));
- clearInterval(waitTyping);
- waitTyping = setTimeout(() => {
- if (prevValue != value) saveOption(name, value, option);
- prevValue = value;
- }, 500);
- });
-
- return input;
- }
-
- /**
- * @param {HTMLElement} item
- * @param {boolean} checked
- */
- function toggleItemSub(item, checked) {
- for (const itemSub of menuItems.get(item)) {
- itemSub.style.display = checked ? "" : "none";
- }
- }
-
- /**
- * @param {string} name
- * @param {Option} option
- * @returns {HTMLElement}
- */
- function createItem(name, option) {
- const checkbox = typeof option.value == "boolean";
- const isSub = name.includes("sub_");
- const icon = isSub ? [] : [option.icon];
- const label = isSub
- ? [createDiv("icon", [option.icon]), option.label]
- : [option.label];
- const content = checkbox
- ? [createDiv("toggle-checkbox")]
- : [itemInput(name, option)];
- const item = createDiv("", [
- createDiv("icon", icon),
- createDiv("label", label),
- createDiv("content", content),
- ]);
-
- if (checkbox) {
- item.setAttribute("aria-checked", option.value);
- item.addEventListener("click", () => {
- const checked = saveOption(name, !option.value, option);
- item.setAttribute("aria-checked", checked);
- toggleItemSub(item, checked);
- if (option.onUpdate) option.onUpdate();
- });
- }
-
- return item;
- }
-
- const popup = {
- show: false,
- menu: (() => {
- const menu = createDiv(" ytc-menu ytp-panel-menu");
- const container = createDiv(" ytc-popup-container", [menu]);
-
- for (const name in options) {
- const option = options[name];
- const item = createItem(name, option);
- menuItems.set(menu.appendChild(item), []);
-
- for (const subName in option.sub) {
- const subOption = option.sub[subName];
- const sub = createItem(`sub_${name}_${subName}`, subOption);
- menuItems.get(item).push(menu.appendChild(sub));
- }
-
- toggleItemSub(item, item.matches("[aria-checked='true']"));
- }
-
- window.addEventListener("click", (ev) => {
- if (popup.show && !menu.contains(ev.target)) {
- popup.show = !!container.remove();
- }
- });
-
- return container;
- })(),
- };
-
- window.addEventListener("keydown", (ev) => {
- const isPressV = ev.key.toLowerCase() == "v" || ev.code == "KeyV";
-
- if (
- (isPressV && !ev.ctrlKey && !isActiveEditable()) ||
- (ev.code == "Escape" && popup.show)
- ) {
- popup.show = popup.show
- ? !!popup.menu.remove()
- : !body.append(popup.menu);
- }
- });
-
- /**
- * @param {string} query
- * @returns {() => HTMLElement | null}
- */
- function $(query) {
- let element = null;
- return () => element || (element = document.querySelector(query));
- }
-
- const style = document.head.appendChild(document.createElement("style"));
- style.textContent = /*css*/ `
- html[no-scroll],
- html[no-scroll] body {
- scrollbar-width: none !important;
- }
-
- html[no-scroll]::-webkit-scrollbar,
- html[no-scroll] body::-webkit-scrollbar,
- html[hide-card] ytd-player .ytp-paid-content-overlay,
- html[hide-card] ytd-player .iv-branding,
- html[hide-card] ytd-player .ytp-ce-element,
- html[hide-card] ytd-player .ytp-chrome-top,
- html[hide-card] ytd-player .ytp-suggested-action {
- display: none !important;
- }
-
- html[theater][masthead-hidden] #masthead-container {
- transform: translateY(-100%) !important;
- }
-
- html[theater][masthead-hidden] [fixed-panels] #chat {
- top: 0 !important;
- }
-
- html[theater] #page-manager {
- margin: 0 !important;
- }
-
- html[theater] #content #page-manager ytd-watch-flexy #full-bleed-container,
- html[theater] #content #page-manager ytd-watch-grid #player-full-bleed-container {
- height: 100vh;
- min-height: auto;
- max-height: none;
- }
-
- .ytc-popup-container {
- position: fixed;
- inset: 0;
- z-index: 9000;
- background: rgba(0, 0, 0, .5);
- display: flex;
- align-items: center;
- justify-content: center;
- }
-
- .ytc-menu.ytp-panel-menu {
- background: #000;
- width: 400px;
- font-size: 120%;
- padding: 10px;
- fill: #eee;
- }
-
- .ytc-menu input {
- width: 36px;
- text-align: center;
- }
-
- .ytc-menu .ytp-menuitem-label .ytp-menuitem-icon {
- display: inline-block;
- padding: 0 10px 0 0;
- margin-left: -10px;
- }
- `;
-
- const prefix = "yttp_";
- const attrId = "-" + Date.now().toString(36);
- const attr = {
- video_id: "video-id",
- role: "role",
- theater: "theater",
- fullscreen: "fullscreen",
- hidden_header: "masthead-hidden",
- no_scroll: "no-scroll",
- hide_card: "hide-card",
- trigger: prefix + "trigger" + attrId, // Internal only
- };
-
- for (const key in attr) {
- style.textContent = style.textContent.replaceAll(
- "[" + attr[key] + "]",
- "[" + prefix + attr[key] + attrId + "]"
- );
- }
-
- const element = {
- watch: $("ytd-watch-flexy, ytd-watch-grid"), // ytd-watch-grid == trash
- search: $("form[action*=result] input"),
- };
-
- const keyToggleTheater = new KeyboardEvent("keydown", {
- key: "t",
- code: "KeyT",
- which: 84,
- keyCode: 84,
- bubbles: true,
- cancelable: true,
- });
-
- /**
- * @param {string} attr
- * @param {boolean} state
- */
- function setHtmlAttr(attr, state) {
- document.documentElement.toggleAttribute(prefix + attr + attrId, state);
- }
-
- /**
- * @param {MutationCallback} callback
- * @param {Node} target
- * @param {MutationObserverInit | undefined} options
- */
- function observer(callback, target, options) {
- const mutation = new MutationObserver(callback);
- mutation.observe(target, options || { subtree: true, childList: true });
- }
-
- /**
- * @returns {boolean}
- */
- function isTheater() {
- const watch = element.watch();
- return (
- watch.getAttribute(attr.role) == "main" &&
- watch.hasAttribute(attr.theater) &&
- !watch.hasAttribute(attr.fullscreen)
- );
- }
-
- /**
- * @returns {boolean}
- */
- function isActiveEditable() {
- /** @type {HTMLElement} */
- const active = document.activeElement;
- return (
- active.tagName == "TEXTAREA" ||
- active.tagName == "INPUT" ||
- active.isContentEditable
- );
- }
-
- /**
- * @param {boolean} state
- * @param {number} timeout
- * @returns {number | boolean}
- */
- function toggleHeader(state, timeout) {
- const toggle = () => {
- if (state || document.activeElement != element.search()) {
- const scroll =
- !options.show_header_near.value && window.scrollY;
- setHtmlAttr(attr.hidden_header, !(state || scroll));
- }
- };
- return fullpage && setTimeout(toggle, timeout || 1);
- }
-
- let showHeaderTimerId = 0;
-
- /**
- * @param {MouseEvent} ev
- */
- function mouseShowHeader(ev) {
- if (options.show_header_near.value && fullpage) {
- const threshold = options.show_header_near.sub.threshold.value;
- const state = !popup.show && ev.clientY < threshold;
- if (state) {
- clearTimeout(showHeaderTimerId);
- showHeaderTimerId = toggleHeader(false, 1500);
- }
- toggleHeader(state);
- }
- }
-
- function toggleTheater() {
- document.dispatchEvent(keyToggleTheater);
- }
-
- /**
- * @param {KeyboardEvent} ev
- */
- function onEscapePress(ev) {
- if (ev.code != "Escape" || !theater || popup.show) return;
-
- if (options.close_theater_with_esc.value) {
- toggleTheater();
- } else {
- const input = element.search();
- if (document.activeElement != input) input.focus();
- else input.blur();
- }
- }
-
- function registerEventListener() {
- window.addEventListener("mousemove", mouseShowHeader);
- window.addEventListener("keydown", onEscapePress, true);
- window.addEventListener("scroll", () => {
- if (!options.show_header_near.value) toggleHeader();
- });
- element.search().addEventListener("focus", () => toggleHeader(true));
- element.search().addEventListener("blur", () => toggleHeader(false));
- }
-
- /**
- * @param {true | undefined} force
- */
- function applyTheaterMode(force) {
- const state = isTheater();
-
- if (theater == state && (!state || !force)) return;
- theater = state;
- fullpage = theater && options.fullpage_theater.value;
-
- setHtmlAttr(attr.theater, fullpage);
- setHtmlAttr(attr.hidden_header, fullpage);
- setHtmlAttr(attr.no_scroll, theater && options.hide_scrollbar.value);
- setHtmlAttr(attr.hide_card, options.hide_cards.value);
- resizeWindow();
- }
-
- /**
- * @param {MutationRecord[]} mutations
- */
- function autoOpenTheater(mutations) {
- const attrs = [attr.role, attr.video_id, attr.trigger];
- const watch = element.watch();
-
- if (
- !theater &&
- options.auto_theater_mode.value &&
- watch.getAttribute(attr.video_id) &&
- !watch.hasAttribute(attr.fullscreen) &&
- mutations.some((m) => attrs.includes(m.attributeName))
- ) {
- setTimeout(toggleTheater, 1);
- }
- }
-
- observer((_, observe) => {
- const watch = element.watch();
- if (!watch) return;
-
- observe.disconnect();
- observer(
- (mutations) => {
- applyTheaterMode();
- autoOpenTheater(mutations);
- },
- watch,
- { attributes: true }
- );
- watch.setAttribute(attr.trigger, "");
- registerEventListener();
- }, body);
- })();