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.8.3
// @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        unsafeWindow
// @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;

    /** @type {boolean} */
    let theater = false;

    /**
     * @param {string} attributes
     * @returns {SVGSVGElement}
     */
    function makeIcon(attributes) {
        const create = (tagName) =>
            document.createElementNS("http://www.w3.org/2000/svg", tagName);
        let icon = create("svg");

        for (const name in attributes) {
            const element = create(name);
            const attribute = attributes[name];

            for (const key in attribute) {
                element.setAttributeNS(null, key, attribute[key]);
            }

            if (name == "svg") icon = element;
            else icon.append(element);
        }

        return icon;
    }

    /**
     * Options must be changed via popup menu,
     * just press (v) to open the menu
     */
    const options = {
        auto_theater_mode: {
            icon: makeIcon({
                svg: { "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",
                },
            }),
            label: "Auto Open Theater",
            value: false, // fallback value
            onUpdate() {
                if (this.value && !theater) toggleTheater();
            },
        },
        hide_scrollbar: {
            icon: makeIcon({
                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",
                },
            }),
            label: "Theater Hide Scrollbar",
            value: true, // fallback value
            onUpdate() {
                if (theater) {
                    html.toggleAttribute(attr.no_scroll, this.value);
                    win.dispatchEvent(new Event("resize"));
                }
            },
        },
        close_theater_with_esc: {
            icon: makeIcon({
                svg: {
                    "clip-rule": "evenodd",
                    "fill-rule": "evenodd",
                    "stroke-linejoin": "round",
                    "stroke-miterlimit": 2,
                },
                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",
                },
            }),
            label: "Close Theater With Esc",
            value: true, // fallback value
        },
        hide_card: {
            icon: makeIcon({
                path: {
                    d: "M22 6v16H6V6h16zm2-2H4v20h20V4zM0 0v20h2V2h18V0H0zm14.007 11.225C10.853 11.225 9 13.822 9 13.822s2.015 2.953 5.007 2.953c3.222 0 4.993-2.953 4.993-2.953s-1.788-2.597-4.993-2.597zm.042 4.717a1.942 1.942 0 1 1 .002-3.884 1.942 1.942 0 0 1-.002 3.884zM15.141 14a1.092 1.092 0 1 1-2.184 0l.02-.211a.68.68 0 0 0 .875-.863l.197-.019c.603 0 1.092.489 1.092 1.093z",
                },
            }),
            label: "Hide Card Outside Theater Mode",
            value: false, // fallback value
            onUpdate() {
                if (!theater) html.toggleAttribute(attr.hide_card, this.value);
            },
        },
        show_header_near: {
            icon: makeIcon({
                path: {
                    d: "M5 4.27 15.476 13H8.934L5 18.117V4.27zM3 0v24l6.919-9H21L3 0z",
                },
            }),
            label: "Show Header When Mouse is Near",
            value: false, // fallback value
        },
    };

    /**
     * @param {string} name
     * @param {boolean} value
     * @returns {boolean}
     */
    function saveOption(name, value) {
        GM.setValue(name, value);
        return (options[name].value = 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;
        }
    }

    /**
     * @param {string} className
     * @returns {HTMLDivElement}
     */
    function createDIV(className) {
        const element = document.createElement("div");
        element.className = className;
        return element;
    }

    const popup = {
        show: false,
        menu: (() => {
            const container = createDIV("ytc-popup-container");
            const menu = createDIV("ytc-menu ytp-panel-menu");

            for (const name in options) {
                const option = options[name],
                    menuItem = createDIV("ytp-menuitem"),
                    icon = createDIV("ytp-menuitem-icon"),
                    label = createDIV("ytp-menuitem-label"),
                    content = createDIV("ytp-menuitem-content"),
                    checkbox = createDIV("ytp-menuitem-toggle-checkbox");

                menuItem.setAttribute("aria-checked", option.value);
                icon.append(option.icon);
                label.textContent = option.label;
                content.append(checkbox);
                menuItem.append(icon, label, content);
                menuItem.addEventListener("click", () => {
                    menuItem.setAttribute(
                        "aria-checked",
                        saveOption(name, !option.value)
                    );
                    if (option.onUpdate) option.onUpdate();
                });
                menu.append(menuItem);
            }

            container.append(menu);
            win.addEventListener("click", (ev) => {
                if (popup.show && !menu.contains(ev.target)) {
                    popup.show = !!container.remove();
                }
            });

            return container;
        })(),
    };

    win.addEventListener("keydown", (ev) => {
        const isV = ev.key.toLowerCase() == "v" || ev.code == "KeyV";

        if (
            (isV && !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 cache;
        return () => cache || (cache = document.querySelector(query));
    }

    /**
     * @param {string} css
     */
    function addStyle(css) {
        const style = document.createElement("style");
        style.textContent = css;
        document.head.append(style);
    }

    let style = /*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] #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;
        }

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

    const customAttr = {
        hidden_header: "masthead-hidden",
        no_scroll: "no-scroll",
        hide_card: "hide-card",
    };

    for (const key in customAttr) {
        const oldAttr = new RegExp("\\[" + customAttr[key], "g");
        const uniqueKey = Math.random().toString(36).slice(2);
        customAttr[key] = customAttr[key] + `-${uniqueKey}`;
        style = style.replace(oldAttr, "[" + customAttr[key]);
    }

    addStyle(style);

    const attr = {
        video_id: "video-id",
        role: "role",
        theater: "theater",
        fullscreen: "fullscreen",
        ...customAttr,
    };

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

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

    /**
     * @returns {boolean}
     */
    function isTheater() {
        const watch = element.watch();
        return (theater =
            watch.getAttribute(attr.role) == "main" &&
            !watch.hasAttribute(attr.fullscreen) &&
            watch.hasAttribute(attr.theater));
    }

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

    /**
     * @param {boolean} state
     * @param {boolean} skipActive
     */
    function toggleHeader(state, skipActive) {
        if (
            theater &&
            (skipActive || document.activeElement != element.search())
        ) {
            const scroll = !options.show_header_near.value && win.scrollY;
            html.toggleAttribute(attr.hidden_header, !(state || scroll));
        }
    }

    let showHeaderTimer = 0;

    /**
     * @param {MouseEvent} ev
     */
    function mouseShowHeader(ev) {
        if (options.show_header_near.value && theater) {
            const state = ev.clientY < 200;
            if (state) {
                clearTimeout(showHeaderTimer);
                showHeaderTimer = setTimeout(() => toggleHeader(false), 1500);
            }
            toggleHeader(!popup.show && 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();
        }
    }

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

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

    function registerEventListener() {
        element.search().addEventListener("focus", () => {
            setTimeout(() => toggleHeader(true, true), 1);
        });
        element.search().addEventListener("blur", () => {
            setTimeout(() => toggleHeader(false), 1);
        });
        win.addEventListener("scroll", () => {
            if (!options.show_header_near.value) toggleHeader();
        });
        win.addEventListener("mousemove", mouseShowHeader);
        win.addEventListener("keydown", onEscapePress, true);
    }

    function applyTheaterMode() {
        const state = isTheater();
        const hasTheater = html.hasAttribute(attr.theater);

        if ((state && !hasTheater) || (!state && hasTheater)) {
            html.toggleAttribute(attr.theater, state);
            html.toggleAttribute(attr.hidden_header, state);
            html.toggleAttribute(
                attr.no_scroll,
                state && options.hide_scrollbar.value
            );
            html.toggleAttribute(
                attr.hide_card,
                state || options.hide_card.value
            );
        }
    }

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

        observer(
            (mutations) => {
                applyTheaterMode();
                autoOpenTheater(mutations);
            },
            watch,
            { attributes: true }
        );

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