Youtube Fullpage Theater

Make theater mode fill the entire page view with a hidden navbar and auto theater mode (Support new UI)

// ==UserScript==
// @name         Youtube Fullpage Theater
// @version      1.3.0
// @description  Make theater mode fill the entire page view with a hidden navbar and auto theater mode (Support new UI)
// @run-at       document-body
// @match        https://www.youtube.com/*
// @exclude      https://*.youtube.com/live_chat*
// @exclude      https://*.youtube.com/embed*
// @exclude      https://*.youtube.com/tv*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=youtube.com
// @grant        GM.getValue
// @grant        GM.setValue
// @grant        GM.addStyle
// @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";

    /** @type {Window} */
    const win = unsafeWindow;
    /** @type {HTMLHtmlElement} */
    const html = document.documentElement;
    /** @type {HTMLBodyElement} */
    const body = document.body;

    /**
     * Options must be changed via popup menu,
     * just press (v) to open the menu
     */
    const options = {
        auto_theater_mode: {
            icon: `<svg width="24" height="24" fill-rule="evenodd" clip-rule="evenodd"><path d="M24 22h-24v-20h24v20zm-1-19h-22v18h22v-18zm-4 7h-1v-3.241l-11.241 11.241h3.241v1h-5v-5h1v3.241l11.241-11.241h-3.241v-1h5v5z"/></svg>`,
            label: "Auto Open Theater",
            value: false, // fallback value
        },
        hide_scrollbar: {
            icon: `<svg width="24" height="24" viewBox="0 0 24 24"><path d="M14 12c0 1.104-.896 2-2 2s-2-.896-2-2 .896-2 2-2 2 .896 2 2zm-3-3.858c.321-.083.653-.142 1-.142s.679.059 1 .142v-2.142h4l-5-6-5 6h4v2.142zm2 7.716c-.321.083-.653.142-1 .142s-.679-.059-1-.142v2.142h-4l5 6 5-6h-4v-2.142z"/></svg>`,
            label: "Theater Hide Scrollbar",
            value: true, // fallback value
            onUpdate: () => {
                if (html.hasAttribute(attr.theater))
                    html.toggleAttribute(
                        attr.no_scroll,
                        options.hide_scrollbar.value
                    );
            },
        },
        close_theater_with_esc: {
            icon: `<svg clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="2" viewBox="0 0 24 24"><path d="m21 3.998c0-.478-.379-1-1-1h-16c-.62 0-1 .519-1 1v16c0 .621.52 1 1 1h16c.478 0 1-.379 1-1zm-16.5.5h15v15h-15zm7.491 6.432 2.717-2.718c.146-.146.338-.219.53-.219.404 0 .751.325.751.75 0 .193-.073.384-.22.531l-2.717 2.717 2.728 2.728c.147.147.22.339.22.531 0 .427-.349.75-.75.75-.192 0-.385-.073-.531-.219l-2.728-2.728-2.728 2.728c-.147.146-.339.219-.531.219-.401 0-.75-.323-.75-.75 0-.192.073-.384.22-.531l2.728-2.728-2.722-2.722c-.146-.147-.219-.338-.219-.531 0-.425.346-.749.75-.749.192 0 .384.073.53.219z" fill-rule="nonzero"/></svg>`,
            label: "Close Theater With Esc",
            value: true, // fallback value
        },
        hide_card: {
            icon: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"><path d="M22 2v20H2V2h20zm2-2H0v24h24V0zm-6 10v8h-8v-8h8zm2-2H8v12h12V8zM4 4v12h2V6h10V4H4z"/></svg>`,
            label: "Hide Card Outside Theater Mode",
            value: false, // fallback value
            onUpdate: () => {
                if (!html.hasAttribute(attr.theater))
                    html.toggleAttribute(
                        attr.hide_card,
                        options.hide_card.value
                    );
            },
        },
    };

    function saveOption(name, value) {
        options[name].value = value;
        GM.setValue(name, value);
        return value;
    }

    for (const name in options) {
        const saved_option = await GM.getValue(name);

        if (saved_option === undefined) {
            saveOption(name, options[name].value);
        } else {
            options[name].value = saved_option;
        }
    }

    const popup = {
        container: document.createElement("div"),
        menu: (() => {
            const menu = document.createElement("div");
            menu.className = "ytc-menu ytp-panel-menu";

            for (const name in options) {
                const item = document.createElement("div");
                item.className = "ytp-menuitem";
                item.ariaChecked = options[name].value;
                item.innerHTML = /*html*/ `
                        <div class="ytp-menuitem-icon">${options[name].icon}</div>
                        <div class="ytp-menuitem-label">${options[name].label}</div>
                        <div class="ytp-menuitem-content">
                            <div class="ytp-menuitem-toggle-checkbox"></div>
                        </div>
                `;
                item.addEventListener("click", () => {
                    item.ariaChecked = saveOption(name, !options[name].value);
                    if (options[name].onUpdate) options[name].onUpdate();
                });
                menu.append(item);
            }

            return menu;
        })(),
    };

    popup.container.className = "ytc-popup-container";
    popup.container.append(popup.menu);
    popup.container.addEventListener("click", (ev) => {
        if (!popup.menu.contains(ev.target)) {
            popup.container.remove();
        }
    });

    window.addEventListener("keydown", (ev) => {
        if (ev.key.toLowerCase() == "v" && !isActiveEditable()) {
            if (document.contains(popup.container)) {
                popup.container.remove();
            } else {
                body.append(popup.container);
            }
        } else if (ev.key == "Escape" && document.contains(popup.container)) {
            popup.container.remove();
        }
    });

    /**
     * @param {string} query
     * @returns {() => HTMLElement | null}
     */
    function $(query) {
        let cache;
        return () => cache || (cache = document.querySelector(query));
    }

    GM.addStyle(/*css*/ `
        html[no-scroll],
        html[no-scroll] body {
            scrollbar-width: none !important;
        }

        html[no-scroll]::-webkit-scrollbar,
        html[no-scroll] body::-webkit-scrollbar {
            display: none !important;
        }
        
        html[masthead-hidden] ytd-watch-flexy[fixed-panels] #chat {
            top: 0 !important;
        }

        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] #page-manager {
            margin: 0 !important;
        }

        html[theater] #full-bleed-container,
        html[theater] #player-full-bleed-container {
            height: 100vh !important;
            max-height: none !important;
        }

        .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;
        }

        .ytc-menu svg {
            fill: #eee;
        }
    `);

    const element = {
        watch: $("ytd-watch-flexy, ytd-watch-grid"), // ytd-watch-grid == trash
        search: $("input#search"),
    };

    const attr = {
        video_id: "video-id",
        role: "role",
        theater: "theater",
        fullscreen: "fullscreen",
        hidden_header: "masthead-hidden",
        no_scroll: "no-scroll",
        hide_card: "hide-card",
    };

    const keyToggleTheater = new KeyboardEvent("keydown", {
        key: "t",
        code: "KeyT",
        which: 84,
        keyCode: 84,
        bubbles: true,
        cancelable: true,
        view: win,
    });

    /**
     * @param {MutationCallback} callback
     * @param {Node} target
     * @param {MutationObserverInit | undefined} options
     * @returns
     */
    function observer(callback, target, options) {
        const mutation = new MutationObserver(callback);
        mutation.observe(target, options || { subtree: true, childList: true });
        return mutation;
    }

    function isTheater() {
        const elem = element.watch();
        return (
            elem.getAttribute(attr.role) == "main" &&
            !elem.hasAttribute(attr.fullscreen) &&
            elem.hasAttribute(attr.theater)
        );
    }

    function isActiveEditable() {
        /** @type {HTMLElement} */
        const active = document.activeElement;
        return (
            active.tagName == "TEXTAREA" ||
            active.tagName == "INPUT" ||
            active.contentEditable == "true"
        );
    }

    function toggleHeader() {
        if (document.activeElement != element.search()) {
            html.toggleAttribute(attr.hidden_header, !win.scrollY);
        }
    }

    function toggleTheater() {
        document.dispatchEvent(keyToggleTheater);
    }

    /**
     * @param {KeyboardEvent} event
     */
    function onEscapePress(event) {
        if (event.key != "Escape" || document.contains(popup.container)) {
            return;
        }

        if (options.close_theater_with_esc.value) {
            toggleTheater();
        } else {
            const input = element.search();

            if (document.activeElement != input) {
                html.removeAttribute(attr.hidden_header);
                setTimeout(() => input.focus(), 1);
            } else if (!win.scrollY) {
                html.setAttribute(attr.hidden_header, "");
                input.blur();
            }
        }
    }

    /**
     * @param {MutationRecord[]} mutations
     */
    function openTheater(mutations) {
        const attrs = [attr.role, attr.video_id];
        const elem = element.watch();

        if (
            options.auto_theater_mode.value &&
            mutations.some((m) => attrs.includes(m.attributeName)) &&
            !elem.hasAttribute(attr.theater) &&
            !elem.hasAttribute(attr.fullscreen)
        ) {
            setTimeout(toggleTheater, 1);
        }
    }

    function setAttribute(theater, header, scroll, card) {
        html.toggleAttribute(attr.theater, theater);
        html.toggleAttribute(attr.hidden_header, header);
        html.toggleAttribute(attr.no_scroll, scroll);
        html.toggleAttribute(attr.hide_card, card);
    }

    function setListener(action) {
        element.search()[action]("blur", toggleHeader);
        win[action]("scroll", toggleHeader);
        win[action]("keydown", onEscapePress, true);
    }

    function watchTheaterMode() {
        const state = isTheater();

        if (state && !html.hasAttribute(attr.theater)) {
            setAttribute(true, true, options.hide_scrollbar.value, true);
            setListener("addEventListener");
        } else if (!state && html.hasAttribute(attr.theater)) {
            setAttribute(false, false, false, options.hide_card.value);
            setListener("removeEventListener");
        }
    }

    observer((_, observe) => {
        const elem = element.watch();
        if (!elem) return;

        observer(
            (mutations) => {
                watchTheaterMode();
                openTheater(mutations);
            },
            elem,
            { attributes: true }
        );

        observe.disconnect();
    }, body);
})();