// ==UserScript==
// @name YouTube Theater Plus
// @version 2.2.7
// @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;
/**
* Options must be changed via popup menu,
* just press (v) to open the menu
*/
const options = {
fullpage_theater: {
icon: `{"path":{"d":"M22 4v12H2V4zm1-1H1v14h22zm-6 17H7v1h10z"}}`,
label: "Fullpage Theater;", // Remove ";" and change the label to customize 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 ";" and change the label to customize 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 ";" and change the label to customize 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 ";" and change the label to customize 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 ";" and change the label to customize 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 ";" and change the label to customize your own label.
value: false,
},
};
function resizeWindow() {
document.dispatchEvent(new Event("resize", { bubbles: true }));
}
/**
* @param {string} name
* @param {boolean} value
* @returns {boolean}
*/
function saveOption(name, value) {
GM.setValue(name, value);
return (options[name].value = value);
}
/**
* @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;
}
for (const name in options) {
const saved_option = await GM.getValue(name);
const saved_label = await GM.getValue("label_" + name);
const icon = JSON.parse(options[name].icon);
let label = options[name].label;
if (saved_option === undefined) {
saveOption(name, options[name].value);
} else {
options[name].value = saved_option;
}
if (!label.endsWith(";")) {
GM.setValue("label_" + name, label);
} else if (saved_label !== undefined) {
label = saved_label;
}
options[name].label = label.replace(/;$/, "");
options[name].icon = createNS("svg", icon.svg, [
createNS("path", icon.path),
]);
}
/**
* @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;
}
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 = createDiv("", [
createDiv("icon", [option.icon]),
createDiv("label", [option.label]),
createDiv("content", [createDiv("toggle-checkbox")]),
]);
menu.append(item);
item.setAttribute("aria-checked", option.value);
item.addEventListener("click", () => {
const checked = saveOption(name, !option.value);
item.setAttribute("aria-checked", checked);
if (option.onUpdate) option.onUpdate();
});
}
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;
}
`;
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 state = !popup.show && ev.clientY < 200;
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);
})();