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 12. 01. 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.5.6
// @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,
        enableOnlyForLiveStreams: false,
        modifyVideoPlayer: true,
        modifyChat: true,
        blacklist: new Set()
    };

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

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

    let moviePlayer;
    let videoId;
    let chatFrame;
    let isFullscreen = false;
    let isTheaterMode = false;
    let chatCollapsed = true;
    let isLiveStream = false;
    let chatWidth = 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 chatStyle = `
        ytd-live-chat-frame[theater-watch-while][rounded-container] {
            border-radius: 0 !important;
        }
        ytd-watch-flexy[fixed-panels] #chat.ytd-watch-flexy {
            top: 0 !important;
            border-top: 0 !important;
            border-bottom: 0 !important;
        }
    `;
    const videoPlayerStyle = `
        ytd-watch-flexy[full-bleed-player] #full-bleed-container.ytd-watch-flexy {
            max-height: calc(100vh - var(--ytd-watch-flexy-masthead-height)) !important;
        }
    `;

    const headmastStyle = `
        #masthead-container.ytd-app {
            max-width: calc(100% - ${chatWidth}px) !important;
        }
    `;


    // Apply static styles for specific YouTube UI fixes
    function applyStaticVideoPlayerFixStyles() {
        GMCustomAddStyle(`
            .html5-video-container {
                top: -1px !important;
            }
        `);
    }

    function applyStaticChatFrameFixStyles() {
        GMCustomAddStyle(`
            ytd-live-chat-frame[theater-watch-while][rounded-container] {
                border-top: 0 !important;
                border-bottom: 0 !important;
            }
            #panel-pages.yt-live-chat-renderer {
                border-bottom: 0 !important;
            }
        `);

        const panelPages = document.querySelector('iron-pages#panel-pages');
        if (panelPages.offsetHeight <= 3) {
            GMCustomAddStyle(`
                #panel-pages.yt-live-chat-renderer{
                    border-top: 0 !important;
                }
            `);
        }
    }

    // Fix fullscreen chat issues by toggling chat renderer
    function toggleChatRendererToTemporarilyFixFullscreenIssues() {
        if (isFullscreen) {
            if (chat && !chat.collapsed) {
                chat.getElementsByTagName('button')[0].click();
                hidChatTemporarily = true;
            }
        } else if (hidChatTemporarily) {
            chat.getElementsByTagName('button')[0].click();
            hidChatTemporarily = false;
        }
    }

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

    function removeAllStyles() {
        activeStyles.forEach((styleElement, style) => {
            if (styleElement && styleElement.parentNode) {
                styleElement.parentNode.removeChild(styleElement);
            }
        });
        activeStyles.clear();
    }

    function applyStyle(style) {
        if (activeStyles.has(style)) return;
        const styleElement = GMCustomAddStyle(style);
        activeStyles.set(style, styleElement);
    }

    // Update styles dynamically based on settings and current state
    function updateStyles() {

        console.log(`[Better YouTube Theater Mode] Updating Styles...`);

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

        if (userSettings.modifyChat) {
            applyStyle(chatStyle);

            chatWidth = chatFrame?.offsetWidth || 0;
            const mastHeadContainer = document.querySelector('#masthead-container');
            let chatFramePositionValid = (mastHeadContainer.getBoundingClientRect().bottom < 0 || chatFrame?.getBoundingClientRect().top <= mastHeadContainer.getBoundingClientRect().bottom)
            let shouldShrinkHeadmast = isTheaterMode && !chatCollapsed && chatFramePositionValid;

                console.log(`[Better YouTube Theater Mode] Should shrink headmast: ${shouldShrinkHeadmast}`);
                console.log('isTheaterMode', isTheaterMode);
                console.log('!chatCollapsed', !chatCollapsed);
                console.log('chatFrame position valid', chatFramePositionValid);
            if (shouldShrinkHeadmast) {
                applyStyle(headmastStyle);
            } else {
                removeStyle(headmastStyle);
            }
        } else {
            [chatStyle, headmastStyle].forEach(removeStyle);
        }

        if (userSettings.modifyVideoPlayer) {
            applyStyle(videoPlayerStyle);
        } else {
            removeStyle(videoPlayerStyle);
        }
        if (moviePlayer) moviePlayer.setCenterCrop(); //trigger size update for the html5 video element
    }

    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 updateFullscreenStatus() {
        isFullscreen = document.fullscreenElement;
        toggleChatRendererToTemporarilyFixFullscreenIssues(); // To fix fullscreen issues this needs to alway run
    }

    function updateVideoStatus(event) {
        try {
            videoId = event.detail.pageData.playerResponse.videoDetails.videoId;
            moviePlayer = document.querySelector('#movie_player');
            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 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();
                },
            },
            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();
                },
            },
            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();
                },
            },
        };

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

            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 applyStaticChatFrameFixStyles(); // Fixes the terrible css of the live chat iframe.
            applyStaticVideoPlayerFixStyles(); // Fixes video player end screen style rounding issues during certain zoom levels.
            await loadUserSettings();
            updateStyles();
            attachEventListeners();
            showMenuOptions();
        } catch (error) {
            return console.error(`Error when initializing script: ${error}. Aborting script.`);
        }
    }

    // Entry Point
    initialize();
})();