更佳 YouTube 劇場模式

改善 YouTube 劇場模式,參考 Twitch.tv 的設計,增強影片與聊天室佈局,同時維持效能與相容性。並修復近期 YouTube 更新中損壞的全螢幕介面。

目前為 2025-01-12 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==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.5
// @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';

    const DEFAULT_SETTINGS = {
        isScriptActive: true,
        enableOnlyForLiveStreams: false,
        modifyVideoPlayer: true,
        modifyChat: true,
        blacklist: new Set()
    };

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

    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;

    let menuItems = new Set();
    let activeStyles = new Set();
    let chatStyles;
    let videoPlayerStyles;
    let headmastStyles;
    let hidChatTemporarily = false;

    let moviePlayer;
    let videoId;
    let chatFrame;
    let isFullscreen = false;
    let isTheaterMode = false;
    let chatCollapsed = true;
    let isLiveStream = false;

    // Helper Functions
    //-------------------------------------------------------------------------------
    function removeStyle(style) {
        if (!activeStyles.has(style)) return;
        if (style && style.parentNode) {
            style.parentNode.removeChild(style);
        }
        activeStyles.delete(style);
    }

    function removeAllStyles() {
        activeStyles.forEach((style) => {
            removeStyle(style);
        });
        activeStyles.clear();
    }

    function addStyle(styleRule, styleObject) {
        if (activeStyles.has(styleObject)) return styleObject;
        styleObject = GMCustomAddStyle(styleRule);
        activeStyles.add(styleObject);
        return styleObject;
    }

    // Apply Styles
    //-------------------------------------------------------------------------------

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

    function applyStaticVideoPlayerFixStyles() {
        GMCustomAddStyle(`
            .html5-video-container {
                top: -1px !important;
            }
        `);
    }

    function applyChatStyles() {
        chatStyles = addStyle(`
            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;
            }
        `, chatStyles);
    }

    function applyVideoPlayerStyles() {
        videoPlayerStyles = addStyle(`
            ytd-watch-flexy[full-bleed-player] #full-bleed-container.ytd-watch-flexy {
                max-height: calc(100vh - var(--ytd-watch-flexy-masthead-height)) !important;
            }
        `, videoPlayerStyles);
    }

    function applyHeadmastStyles() {
        headmastStyles = addStyle(`
            #masthead-container.ytd-app {
                max-width: calc(100% - ${chatFrame.offsetWidth}px) !important;
            }
        `, headmastStyles);
    }

    // Update Stuff
    //------------------------------------------------------
    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) {
            applyChatStyles();

            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) {
                applyHeadmastStyles();
            } else {
                removeStyle(headmastStyles);
            }
        } else {
            [chatStyles, headmastStyles].forEach(removeStyle);
        }

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

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

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

    // Functions for the GUI
    //-----------------------------------------------------
    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);
        });
    }

    // Handle User Preferences
    //------------------------------------------------
    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) {
            console.error(error);
        }
    }
    // Verify Grease Monkey API
    //-----------------------------------------------
    function checkGMAPI() {
        if (typeof GM != 'undefined') return;
        if (typeof GM_info != 'undefined') {
            useCompatibilityMode = true;
            console.warn("Running in compatibility mode.");
            return;
        }
        isBrokenOrMissingGMAPI = true;
    }

    // Preparation Stuff
    //-------------------------------------------------
    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);
    }

    function isLiveChatIFrame() {
        const liveChatIFramePattern = /^https?:\/\/.*youtube\.com\/live_chat.*$/;
        const currentUrl = window.location.href;
        return liveChatIFramePattern.test(currentUrl);
    }

    async function initialize() {
        checkGMAPI();
        try {
            if (isBrokenOrMissingGMAPI) 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) {
            console.error(`Error loading user settings: ${error}. Aborting script.`);
        }
    }
    // Entry Point
    //-------------------------------------------
    initialize();
})();