更佳 YouTube 劇場模式

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

目前為 2025-02-09 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 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.8
// @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.notification
// @grant               GM_addStyle
// @grant               GM_getValue
// @grant               GM_setValue
// @grant               GM_deleteValue
// @grant               GM_listValues
// @grant               GM_registerMenuCommand
// @grant               GM_unregisterMenuCommand
// @grant               GM_notification
// @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()
    };

    const BROWSER_LANGUAGE = navigator.language || navigator.userLanguage;
    const GET_PREFERRED_LANGUAGE = () => {
        if (BROWSER_LANGUAGE.startsWith('zh') && BROWSER_LANGUAGE !== 'zh-TW') {
            return 'zh-CN';
        } else {
            return BROWSER_LANGUAGE;
        }
    };

    const TRANSLATIONS = {
        'en-US': {
            tampermonkeyOutdatedAlertMessage: "It looks like you're using an older version of Tampermonkey that might cause menu issues. For the best experience, please update to version 5.4.6224 or later.",
            turnOn: 'Turn On',
            turnOff: 'Turn Off',
            livestreamOnlyMode: 'Livestream Only Mode',
            applyChatStyles: 'Apply Chat Styles',
            applyVideoPlayerStyles: 'Apply Video Player Styles',
            moveHeadmastBelowVideoPlayer: 'Move Headmast Below Video Player',
            blacklistVideo: 'Blacklist Video',
            unblacklistVideo: 'Unblacklist Video',
            simpleMode: 'Simple Mode',
            advancedMode: 'Advanced Mode',
            debug: 'DEBUG'
        },
        'zh-TW': {
            tampermonkeyOutdatedAlertMessage: "看起來您正在使用較舊版本的篡改猴,可能會導致選單問題。為了獲得最佳體驗,請更新至 5.4.6224 或更高版本。",
            turnOn: '開啟',
            turnOff: '關閉',
            livestreamOnlyMode: '僅限直播模式',
            applyChatStyles: '套用聊天樣式',
            applyVideoPlayerStyles: '套用影片播放器樣式',
            moveHeadmastBelowVideoPlayer: '將頁首橫幅移到影片播放器下方',
            blacklistVideo: '將影片加入黑名單',
            unblacklistVideo: '從黑名單中移除影片',
            simpleMode: '簡易模式',
            advancedMode: '進階模式',
            debug: '偵錯'
        },
        'zh-CN': {
            tampermonkeyOutdatedAlertMessage: "看起来您正在使用旧版本的篡改猴,这可能会导致菜单问题。为了获得最佳体验,请更新到 5.4.6224 或更高版本。",
            turnOn: '开启',
            turnOff: '关闭',
            livestreamOnlyMode: '仅限直播模式',
            applyChatStyles: '应用聊天样式',
            applyVideoPlayerStyles: '应用视频播放器样式',
            moveHeadmastBelowVideoPlayer: '将页首横幅移动到视频播放器下方',
            blacklistVideo: '将视频加入黑名单',
            unblacklistVideo: '从黑名单中移除视频',
            simpleMode: '简易模式',
            advancedMode: '高级模式',
            debug: '调试'
        },
        'ja': {
            tampermonkeyOutdatedAlertMessage: "ご利用のTampermonkeyのバージョンが古いため、メニューに問題が発生する可能性があります。より良い体験のため、バージョン5.4.6224以上に更新してください。",
            turnOn: "オンにする",
            turnOff: "オフにする",
            livestreamOnlyMode: "ライブ配信専用モード",
            applyChatStyles: "チャットスタイルを適用",
            applyVideoPlayerStyles: "ビデオプレイヤースタイルを適用",
            moveHeadmastBelowVideoPlayer: "ヘッドマストをビデオプレイヤーの下に移動",
            blacklistVideo: "動画をブラックリストに追加",
            unblacklistVideo: "ブラックリストから動画を解除",
            simpleMode: "シンプルモード",
            advancedMode: "高度モード",
            debug: "デバッグ"
        }
    };

    const GET_LOCALIZED_TEXT = () => {
        const language = GET_PREFERRED_LANGUAGE();
        return TRANSLATIONS[language] || TRANSLATIONS['en-US'];
    };

    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;
    const GMCustomNotification = useCompatibilityMode ? GM_notification : GM.notification;

    let isOldTampermonkey = false;
    const updatedVersions = {
        Tampermonkey: '5.4.624',
    };

    // 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() {
        const shouldAutoClose = isOldTampermonkey;
        removeMenuOptions();
        const advancedMenuOptions = userSettings.isSimpleMode ? {} : {
            toggleOnlyLiveStreamMode: {
                alwaysShow: true,
                label: () => `${userSettings.enableOnlyForLiveStreams ? "✅" : "❌"} ` + GET_LOCALIZED_TEXT().livestreamOnlyMode,
                menuId: "toggleOnlyLiveStreamMode",
                handleClick: function () {
                    userSettings.enableOnlyForLiveStreams = !userSettings.enableOnlyForLiveStreams;
                    GMCustomSetValue('enableOnlyForLiveStreams', userSettings.enableOnlyForLiveStreams);
                    updateStyles();
                    showMenuOptions();
                },
            },
            toggleChatStyle: {
                alwaysShow: true,
                label: () => `${userSettings.modifyChat ? "✅" : "❌"} ` + GET_LOCALIZED_TEXT().applyChatStyles,
                menuId: "toggleChatStyle",
                handleClick: function () {
                    userSettings.modifyChat = !userSettings.modifyChat;
                    GMCustomSetValue('modifyChat', userSettings.modifyChat);
                    updateStyles();
                    showMenuOptions();
                },
            },
            toggleVideoPlayerStyle: {
                alwaysShow: true,
                label: () => `${userSettings.modifyVideoPlayer ? "✅" : "❌"} ` + GET_LOCALIZED_TEXT().applyVideoPlayerStyles,
                menuId: "toggleVideoPlayerStyle",
                handleClick: function () {
                    userSettings.modifyVideoPlayer = !userSettings.modifyVideoPlayer;
                    GMCustomSetValue('modifyVideoPlayer', userSettings.modifyVideoPlayer);
                    updateStyles();
                    showMenuOptions();
                },
            },
            toggleLowHeadmast: {
                alwaysShow: true,
                label: () => `${userSettings.setLowHeadmast ? "✅" : "❌"} ` + GET_LOCALIZED_TEXT().moveHeadmastBelowVideoPlayer,
                menuId: "toggleLowHeadmast",
                handleClick: function () {
                    userSettings.setLowHeadmast = !userSettings.setLowHeadmast;
                    GMCustomSetValue('setLowHeadmast', userSettings.setLowHeadmast);
                    updateStyles();
                    showMenuOptions();
                },
            },
        };

        const menuOptions = {
            toggleScript: {
                alwaysShow: true,
                label: () => `🔄 ${userSettings.isScriptActive ? GET_LOCALIZED_TEXT().turnOff : GET_LOCALIZED_TEXT().turnOn}`,
                menuId: "toggleScript",
                handleClick: function () {
                    userSettings.isScriptActive = !userSettings.isScriptActive;
                    GMCustomSetValue('isScriptActive', userSettings.isScriptActive);
                    updateStyles();
                    showMenuOptions();
                },
            },
            ...advancedMenuOptions,
            addVideoToBlacklist: {
                alwaysShow: true,
                label: () => `🚫 ${userSettings.blacklist.has(videoId) ? GET_LOCALIZED_TEXT().unblacklistVideo : GET_LOCALIZED_TEXT().blacklistVideo} [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 ? "🚀 " + GET_LOCALIZED_TEXT().simpleMode : "🔧 " + GET_LOCALIZED_TEXT().advancedMode}`,
                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: shouldAutoClose,
            });
            menuItems.add(item.menuId);
        });
    }

    function compareVersions(v1, v2) {
        const parts1 = v1.split('.').map(Number);
        const parts2 = v2.split('.').map(Number);

        const len = Math.max(parts1.length, parts2.length);

        for (let i = 0; i < len; i++) {
            const num1 = parts1[i] || 0; // If undefined, treat as 0
            const num2 = parts2[i] || 0;

            if (num1 > num2) return 1; // v1 is greater
            if (num1 < num2) return -1; // v2 is greater
        }

        return 0; // Both versions are equal
    }

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

    function CheckTampermonkeyUpdated() {
        if (GM_info.scriptHandler == "Tampermonkey" && compareVersions(GM_info.version, updatedVersions.Tampermonkey) != 1) {
            isOldTampermonkey = true;
            GMCustomNotification({
                text: GET_LOCALIZED_TEXT().tampermonkeyOutdatedAlertMessage,
                timeout: 15000
            });
        }
    }

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

    // 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";
            CheckTampermonkeyUpdated();
            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();
})();