Better Theater Mode for YouTube

Improves YouTube's theater mode with a Twitch.tv-like design, enhancing video and chat layouts, while maintaining performance and compatibility. Also fixes the broken fullscreen UI from the recent YouTube update.

Verze ze dne 03. 02. 2025. Zobrazit nejnovější verzi.

// ==UserScript==
// @name                Better Theater Mode for YouTube
// @name:zh-TW          更佳 YouTube 劇場模式
// @name:zh-CN          更佳 YouTube 剧场模式
// @name:ja             より良いYouTubeシアターモード
// @icon                https://www.youtube.com/img/favicon_48.png
// @author              ElectroKnight22
// @namespace           electroknight22_youtube_better_theater_mode_namespace
// @version             1.7.2
// @match               *://www.youtube.com/*
// @match               *://www.youtube-nocookie.com/*
// @grant               GM.addStyle
// @grant               GM.getValue
// @grant               GM.setValue
// @grant               GM.deleteValue
// @grant               GM.listValues
// @grant               GM.registerMenuCommand
// @grant               GM.unregisterMenuCommand
// @grant               GM_addStyle
// @grant               GM_getValue
// @grant               GM_setValue
// @grant               GM_deleteValue
// @grant               GM_listValues
// @grant               GM_registerMenuCommand
// @grant               GM_unregisterMenuCommand
// @license             MIT
// @description         Improves YouTube's theater mode with a Twitch.tv-like design, enhancing video and chat layouts, while maintaining performance and compatibility. Also fixes the broken fullscreen UI from the recent YouTube update.
// @description:zh-TW   改善 YouTube 劇場模式,參考 Twitch.tv 的設計,增強影片與聊天室佈局,同時維持效能與相容性。並修復近期 YouTube 更新中損壞的全螢幕介面。
// @description:zh-CN   改进 YouTube 剧场模式,参考 Twitch.tv 的设计,增强视频与聊天室布局,同时保持性能与兼容性。并修复近期 YouTube 更新中损坏的全屏界面。
// @description:ja      YouTubeのシアターモードを改善し、Twitch.tvのデザインを参考にして、動画とチャットのレイアウトを強化しつつ、パフォーマンスと互換性を維持します。また、最近のYouTubeアップデートによる壊れたフルスクリーンUIを修正します。
// ==/UserScript==

/*jshint esversion: 11 */

(function () {
    'use strict';

    // Default settings for the script
    const DEFAULT_SETTINGS = {
        isScriptActive: true,
        isSimpleMode: true,
        enableOnlyForLiveStreams: false,
        modifyVideoPlayer: true,
        modifyChat: true,
        setLowHeadmast: false,
        blacklist: new Set()
    };

    let userSettings = { ...DEFAULT_SETTINGS };
    let userSettingsBackup = { ...DEFAULT_SETTINGS };
    let useCompatibilityMode = false;

    let menuItems = new Set();
    let activeStyles = new Map();
    let hidChatTemporarily = false;
    let resizeObserver;

    let moviePlayer;
    let videoId;
    let chatFrame;
    let currentPageType = '';
    let isFullscreen = false;
    let isTheaterMode = false;
    let chatCollapsed = true;
    let isLiveStream = false;
    let chatWidth = 0;
    let moviePlayerHeight = 0;

    // Greasemonkey API Compatibility Layer
    const GMCustomAddStyle = useCompatibilityMode ? GM_addStyle : GM.addStyle;
    const GMCustomRegisterMenuCommand = useCompatibilityMode ? GM_registerMenuCommand : GM.registerMenuCommand;
    const GMCustomUnregisterMenuCommand = useCompatibilityMode ? GM_unregisterMenuCommand : GM.unregisterMenuCommand;
    const GMCustomGetValue = useCompatibilityMode ? GM_getValue : GM.getValue;
    const GMCustomSetValue = useCompatibilityMode ? GM_setValue : GM.setValue;
    const GMCustomListValues = useCompatibilityMode ? GM_listValues : GM.listValues;
    const GMCustomDeleteValue = useCompatibilityMode ? GM_deleteValue : GM.deleteValue;

    // Style Rules
    const styleRules = {
        chatStyle: {
            id: "chatStyle",
            getRule: () => `
                ytd-live-chat-frame[theater-watch-while][rounded-container] {
                    border-radius: 0 !important;
                    border-top: 0 !important;
                }
                ytd-watch-flexy[fixed-panels] #chat.ytd-watch-flexy {
                    top: 0 !important;
                    border-top: 0 !important;
                    border-bottom: 0 !important;
                }
            `,
        },
        videoPlayerStyle: {
            id: "videoPlayerStyle",
            getRule: () => `
                ytd-watch-flexy[full-bleed-player] #full-bleed-container.ytd-watch-flexy {
                    max-height: calc(100vh - var(--ytd-watch-flexy-masthead-height)) !important;
                }
            `,
        },
        headmastStyle: {
            id: "headmastStyle",
            getRule: () => `
                #masthead-container.ytd-app {
                    max-width: calc(100% - ${chatWidth}px) !important;
                }
            `,
        },
        lowHeadmastStyle: {
            id: "lowHeadmastStyle",
            getRule: () => `
                #page-manager.ytd-app {
                    margin-top: 0 !important;
                    top: calc(-1 * var(--ytd-toolbar-offset)) !important;
                    position: relative !important;
                }
                ytd-watch-flexy[flexy]:not([full-bleed-player][full-bleed-no-max-width-columns]) #columns.ytd-watch-flexy {
                    margin-top: var(--ytd-toolbar-offset) !important;
                }
                ${userSettings.modifyVideoPlayer ? `
                    ytd-watch-flexy[full-bleed-player] #full-bleed-container.ytd-watch-flexy {
                        max-height: 100vh !important;
                    }
                ` : ''}
                #masthead-container.ytd-app {
                    z-index: 599 !important;
                    top: ${moviePlayerHeight}px !important;
                    position: relative !important;
                }
            `,
        },
        videoPlayerFixStyle: {
            id: "staticVideoPlayerFixStyle",
            getRule: () => `
                .html5-video-container {
                    top: -1px !important;
                }
                #skip-navigation.ytd-masthead {
                    left: -500px;
                }
            `,
        },
        chatFrameFixStyle: {
            id: "staticChatFrameFixStyle",
            getRule: () => {
                const chatInputContainer = document.querySelector("tp-yt-iron-pages#panel-pages.style-scope.yt-live-chat-renderer")
                const shouldHideChatInputContainerTopBorder = chatInputContainer?.clientHeight == 0;
                const borderTopStyle = shouldHideChatInputContainerTopBorder ? 'border-top: 0 !important;' : '';
                return `
                    #panel-pages.yt-live-chat-renderer {
                        ${borderTopStyle}
                        border-bottom: 0 !important;
                    }
                `;
            },
        },
        chatRendererFixStyle: {
            id: "staticChatRendererFixStyle",
            getRule: () => `
                ytd-live-chat-frame[theater-watch-while][rounded-container] {
                    border-bottom: 0 !important;
                }
            `,
        },
    };

    // Apply and remove styles dynamically based on settings
    function removeStyle(style) {
        if (!activeStyles.has(style.id)) return;
        const { element: styleElement } = activeStyles.get(style.id);
        if (styleElement && styleElement.parentNode) {
            styleElement.parentNode.removeChild(styleElement);
        }
        activeStyles.delete(style.id);
    }

    function removeAllStyles() {
        activeStyles.forEach((styleData, styleId) => {
            if (!styleData.persistent) {
                removeStyle({ id: styleId });
            }
        });
    }

    function applyStyle(style, setPersistent = false) {
        if (typeof style.getRule !== 'function') return;
        if (activeStyles.has(style.id)) removeStyle(style);
        const styleElement = GMCustomAddStyle(style.getRule());
        activeStyles.set(style.id, { element: styleElement, persistent: setPersistent });
    }

    function setStyleState(style, on = true) {
        on ? applyStyle(style) : removeStyle(style);
    }

    // Update styles dynamically based on settings and current state
    function updateLowHeadmastStyle() {
        if (!moviePlayer) return;
        const shouldApplyLowHeadmast = userSettings.setLowHeadmast && isTheaterMode && !isFullscreen  && currentPageType == 'watch';
        setStyleState(styleRules.lowHeadmastStyle, shouldApplyLowHeadmast);
    }

    function updateHeadmastStyle() {
        updateLowHeadmastStyle();
        let shouldShrinkHeadmast = isTheaterMode &&
            chatFrame?.getAttribute('theater-watch-while') === '' &&
            (userSettings.setLowHeadmast || userSettings.modifyChat);
        chatWidth = chatFrame?.offsetWidth || 0;
        setStyleState(styleRules.headmastStyle, shouldShrinkHeadmast);
    }

    function updateStyles() {
        try {
            const shouldNotActivate =
                !userSettings.isScriptActive ||
                userSettings.blacklist.has(videoId) ||
                (userSettings.enableOnlyForLiveStreams && !isLiveStream);

            if (shouldNotActivate) {
                removeAllStyles();
                if (moviePlayer) moviePlayer.setCenterCrop(); //trigger size update for the html5 video element
                return;
            }

            setStyleState(styleRules.chatStyle, userSettings.modifyChat);
            setStyleState(styleRules.videoPlayerStyle, userSettings.modifyVideoPlayer);
            updateHeadmastStyle();
            if (moviePlayer) moviePlayer.setCenterCrop(); //trigger size update for the html5 video element
        } catch (error) {
            console.log(`Error when trying to update styles: ${error}.`);
        }
    }

    // Updates things
    function updateFullscreenStatus() {
        isFullscreen = document.fullscreenElement;
    }

    function updateTheaterStatus(event) {
        isTheaterMode = !!event?.detail?.enabled;
        updateStyles();
    }

    function updateChatStatus(event) {
        chatFrame = event.target;
        chatCollapsed = event.detail !== false;
        window.addEventListener('player-api-ready', () => { updateStyles(); }, { once: true });
    }

    function updateMoviePlayer() {
        const newMoviePlayer = document.querySelector('#movie_player');

        if (!resizeObserver) {
            resizeObserver = new ResizeObserver(entries => {
                moviePlayerHeight = moviePlayer.offsetHeight;
                updateStyles();
            });
        }

        if (moviePlayer) resizeObserver.unobserve(moviePlayer);
        moviePlayer = newMoviePlayer;
        if (moviePlayer) resizeObserver.observe(moviePlayer);
    }

    function updateVideoStatus(event) {
        try {
            currentPageType = event.detail.pageData.page;
            videoId = event.detail.pageData.playerResponse.videoDetails.videoId;
            updateMoviePlayer();
            isLiveStream = event.detail.pageData.playerResponse.videoDetails.isLiveContent;
            showMenuOptions();
        } catch (error) {
            throw ("Failed to update video status due to this error. Error: " + error);
        }
    }

    // Menu management for user interaction
    function processMenuOptions(options, callback) {
        Object.values(options).forEach(option => {
            if (!option.alwaysShow && !userSettings.expandMenu) return;
            if (option.items) {
                option.items.forEach(item => callback(item));
            } else {
                callback(option);
            }
        });
    }

    function removeMenuOptions() {
        menuItems.forEach((menuItem) => {
            GMCustomUnregisterMenuCommand(menuItem);
        });
        menuItems.clear();
    }

    function showMenuOptions() {
        removeMenuOptions();
        const advancedMenuOptions = userSettings.isSimpleMode ? {} : {
            toggleOnlyLiveStreamMode: {
                alwaysShow: true,
                label: () => `${userSettings.enableOnlyForLiveStreams ? "✅" : "❌"} Livestream Only Mode`,
                menuId: "toggleOnlyLiveStreamMode",
                handleClick: function () {
                    userSettings.enableOnlyForLiveStreams = !userSettings.enableOnlyForLiveStreams;
                    GMCustomSetValue('enableOnlyForLiveStreams', userSettings.enableOnlyForLiveStreams);
                    updateStyles();
                    showMenuOptions();
                },
            },
            toggleChatStyle: {
                alwaysShow: true,
                label: () => `${userSettings.modifyChat ? "✅" : "❌"} Apply Chat Styles`,
                menuId: "toggleChatStyle",
                handleClick: function () {
                    userSettings.modifyChat = !userSettings.modifyChat;
                    GMCustomSetValue('modifyChat', userSettings.modifyChat);
                    updateStyles();
                    showMenuOptions();
                },
            },
            toggleVideoPlayerStyle: {
                alwaysShow: true,
                label: () => `${userSettings.modifyVideoPlayer ? "✅" : "❌"} Apply Video Player Styles`,
                menuId: "toggleVideoPlayerStyle",
                handleClick: function () {
                    userSettings.modifyVideoPlayer = !userSettings.modifyVideoPlayer;
                    GMCustomSetValue('modifyVideoPlayer', userSettings.modifyVideoPlayer);
                    updateStyles();
                    showMenuOptions();
                },
            },
            toggleLowHeadmast: {
                alwaysShow: true,
                label: () => `${userSettings.setLowHeadmast ? "✅" : "❌"} Move Headmast Below Video Player`,
                menuId: "toggleLowHeadmast",
                handleClick: function () {
                    userSettings.setLowHeadmast = !userSettings.setLowHeadmast;
                    GMCustomSetValue('setLowHeadmast', userSettings.setLowHeadmast);
                    updateStyles();
                    showMenuOptions();
                },
            },
        };

        const menuOptions = {
            toggleScript: {
                alwaysShow: true,
                label: () => `🔄 ${userSettings.isScriptActive ? "Turn Off" : "Turn On"}`,
                menuId: "toggleScript",
                handleClick: function () {
                    userSettings.isScriptActive = !userSettings.isScriptActive;
                    GMCustomSetValue('isScriptActive', userSettings.isScriptActive);
                    updateStyles();
                    showMenuOptions();
                },
            },
            ...advancedMenuOptions,
            addVideoToBlacklist: {
                alwaysShow: true,
                label: () => `🚫 ${userSettings.blacklist.has(videoId) ? "Unblacklist Video " : "Blacklist Video"} [id: ${videoId}]`,
                menuId: "addVideoToBlacklist",
                handleClick: function () {
                    if (userSettings.blacklist.has(videoId)) {
                        userSettings.blacklist.delete(videoId);
                    } else {
                        userSettings.blacklist.add(videoId);
                    }
                    GMCustomSetValue('blacklist', [...userSettings.blacklist]);
                    updateStyles();
                    showMenuOptions();
                },
            },
            toggleSimpleMode: {
                alwaysShow: true,
                label: () => `${userSettings.isSimpleMode ? "🚀 Simple Mode" : "🔧 Advanced Mode"}`,
                menuId: "toggleSimpleMode",
                handleClick: function () {
                    const isNewModeSimple = !userSettings.isSimpleMode;
                    if (isNewModeSimple) userSettingsBackup = { ...userSettings };
                    GMCustomSetValue('isSimpleMode', isNewModeSimple);
                    userSettings = isNewModeSimple ? { ...DEFAULT_SETTINGS } : userSettingsBackup;
                    userSettings.isSimpleMode = isNewModeSimple;
                    updateStyles();
                    showMenuOptions();
                },
            },
        };

        processMenuOptions(menuOptions, (item) => {
            GMCustomRegisterMenuCommand(item.label(), item.handleClick, {
                id: item.menuId,
                autoClose: false,
            });
            menuItems.add(item.menuId);
        });
    }

    // User Setting Handling
    async function loadUserSettings() {
        try {
            const storedValues = await GMCustomListValues();

            for (const [key, value] of Object.entries(DEFAULT_SETTINGS)) {
                if (!storedValues.includes(key)) {
                    await GMCustomSetValue(key, value instanceof Set ? Array.from(value) : value);
                }
            }

            for (const key of storedValues) {
                if (!(key in DEFAULT_SETTINGS)) {
                    await GMCustomDeleteValue(key);
                }
            }

            const keyValuePairs = await Promise.all(
                storedValues.map(async key => [key, await GMCustomGetValue(key)])
            );

            keyValuePairs.forEach(([newKey, newValue]) => {
                userSettings[newKey] = newValue;
            });

            // Convert blacklist to Set if it exists
            if (userSettings.blacklist) {
                userSettings.blacklist = new Set(userSettings.blacklist);
            }

            userSettingsBackup = userSettings;
            if (userSettings.isSimpleMode) userSettings = { ...DEFAULT_SETTINGS };

            console.log(`Loaded user settings: ${JSON.stringify(userSettings)}`);
        } catch (error) {
            throw `Error loading user settings: ${error}. Aborting script.`;
        }
    }

    // Check compatibility with Greasemonkey API
    function hasGreasyMonkeyAPI() {
        if (typeof GM != 'undefined') return true;
        if (typeof GM_info != 'undefined') {
            useCompatibilityMode = true;
            console.warn("Running in compatibility mode.");
            return true;
        }
        return false;
    }

    // Attach necessary event listeners
    function attachEventListeners() {
        window.addEventListener('yt-set-theater-mode-enabled', (event) => { updateTheaterStatus(event); }, true);
        window.addEventListener('yt-chat-collapsed-changed', (event) => { updateChatStatus(event); }, true);
        window.addEventListener('yt-page-data-fetched', (event) => { updateVideoStatus(event); }, true);
        window.addEventListener('yt-page-data-updated', updateStyles, true);
        window.addEventListener('fullscreenchange', updateFullscreenStatus, true);
    }

    // Check if the script is running inside a live chat iframe
    function isLiveChatIFrame() {
        const liveChatIFramePattern = /^https?:\/\/.*youtube\.com\/live_chat.*$/;
        const currentUrl = window.location.href;
        return liveChatIFramePattern.test(currentUrl);
    }

    // Initialize the script
    async function initialize() {
        try {
            if (!hasGreasyMonkeyAPI()) throw "Did not detect valid Grease Monkey API";
            if (isLiveChatIFrame()) return (applyStyle(styleRules.chatFrameFixStyle, true)); // Fixes the terrible css of the live chat iframe.
            applyStyle(styleRules.chatRendererFixStyle, true); // Removes the unnecessary extra bottom border from the chat renderer.
            applyStyle(styleRules.videoPlayerFixStyle, true); // Fixes various issues with the video player.
            await loadUserSettings();
            updateStyles();
            attachEventListeners();
            showMenuOptions();
        } catch (error) {
            return console.error(`Error when initializing script: ${error}. Aborting script.`);
        }
    }
    // Entry Point
    initialize();
})();