更佳 YouTube 劇場模式

改善 YouTube 劇場模式,參考 Twitch.tv 的設計,增強影片與聊天室佈局,同時維持效能與相容性。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name                Better YouTube Theater Mode
// @name:zh-TW          更佳 YouTube 劇場模式
// @name:zh-CN          更佳 YouTube 剧场模式
// @name:ja             より良いYouTubeシアターモード
// @icon                https://www.google.com/s2/favicons?sz=64&domain=youtube.com
// @author              ElectroKnight22
// @namespace           electroknight22_youtube_better_theater_mode_namespace
// @version             3.0.1
// @match               *://www.youtube.com/*
// @match               *://www.youtube-nocookie.com/*
// @require             https://update.greasyfork.org/scripts/549881/1701682/YouTube%20Helper%20API.js
// @noframes
// @grant               GM.getValue
// @grant               GM.setValue
// @grant               GM.deleteValue
// @grant               GM.listValues
// @grant               GM.registerMenuCommand
// @grant               GM.unregisterMenuCommand
// @grant               GM_getValue
// @grant               GM_setValue
// @grant               GM_deleteValue
// @grant               GM_listValues
// @grant               GM_registerMenuCommand
// @grant               GM_unregisterMenuCommand
// @run-at              document-idle
// @inject-into         page
// @license             MIT
// @description         Improves YouTube's theater mode with a Twitch.tv-like design, enhancing video and chat layouts while maintaining performance and compatibility.
// @description:zh-TW   改善 YouTube 劇場模式,參考 Twitch.tv 的設計,增強影片與聊天室佈局,同時維持效能與相容性。
// @description:zh-CN   改进 YouTube 剧场模式,参考 Twitch.tv 的设计,增强视频与聊天室布局,同时保持性能与兼容性,也达到了类似B站的网页全屏功能。
// @description:ja      YouTubeのシアターモードを改善し、Twitch.tvのデザインを参考にして、動画とチャットのレイアウトを強化しつつ、パフォーマンスと互換性を維持します。
// ==/UserScript==

/*jshint esversion: 11 */
/* global youtubeHelperApi */

(function () {
    'use strict';

    const api = youtubeHelperApi;
    if (!api) return console.error('Helper API not found.');

    const CONFIG = {
        STORAGE_PREFIX: 'betterTheater_',
        MIN_CHAT_SIZE: {
            width: 300, //px
        },
        DEFAULT_SETTINGS: {
            setLowHeadmast: false,
            get theaterChatWidth() {
                return `${CONFIG.MIN_CHAT_SIZE.width}px`;
            },
        },
    };

    const BROWSER_LANGUAGE = navigator.language ?? navigator.userLanguage;
    const TRANSLATIONS = {
        'en-US': {
            moveHeadmastBelowVideoPlayer: 'Move Headmast Below Video Player',
        },
        'zh-TW': {
            moveHeadmastBelowVideoPlayer: '將頁首橫幅移到影片播放器下方',
        },
        'zh-CN': {
            moveHeadmastBelowVideoPlayer: '将页首横幅移动到视频播放器下方',
        },
        ja: {
            moveHeadmastBelowVideoPlayer: 'ヘッドマストをビデオプレイヤーの下に移動',
        },
    };

    function getPreferredLanguage() {
        if (TRANSLATIONS[BROWSER_LANGUAGE]) return BROWSER_LANGUAGE;
        if (BROWSER_LANGUAGE.startsWith('zh')) return 'zh-CN';
        return 'en-US';
    }

    function getLocalizedText() {
        return TRANSLATIONS[getPreferredLanguage()] ?? TRANSLATIONS['en-US'];
    }

    const state = {
        userSettings: { ...CONFIG.DEFAULT_SETTINGS },
        menuItems: [],
        activeStyles: new Map(),
        resizeObserver: null,
        chatWidth: 0,
        moviePlayerHeight: 0,
    };

    const DOM = {
        moviePlayer: null,
    };

    const createGmApi = () => {
        const isGmFallback = typeof GM === 'undefined' && typeof GM_info !== 'undefined';
        if (isGmFallback) {
            return {
                registerMenuCommand: GM_registerMenuCommand,
                unregisterMenuCommand: GM_unregisterMenuCommand,
            };
        }
        return {
            registerMenuCommand: (...args) => window.GM?.registerMenuCommand?.(...args),
            unregisterMenuCommand: (...args) => window.GM?.unregisterMenuCommand?.(...args),
        };
    };

    const GM_API = createGmApi();

    const StyleManager = {
        styleDefinitions: {
            staticDesktopStyle: {
                id: 'betterTheater-staticDesktopStyle',
                getRule: () => `
                    .ytp-fullscreen-quick-actions {
                        display: unset !important;
                    }
                    #show-hide-button.ytd-live-chat-frame {
                        display: none !important;
                    }
                `,
            },
            staticDesktopOptimalStyle: {
                id: 'betterTheater-staticDesktopOptimalStyle',
                getRule: () => `
                    ytd-comments:not([engagement-panel]) {
                        display: none !important;
                    }
                    ytd-watch-flexy[is-two-columns_][is-four-three-to-sixteen-nine-video_]:not([full-bleed-player][full-bleed-no-max-width-columns]):not([fixed-panels]) #primary.ytd-watch-flexy {
                        max-width: 100vw !important;
                    }
                    #clarify-box.ytd-watch-flexy, ytd-watch-flexy[show-expandable-metadata] ytd-watch-metadata.ytd-watch-flexy {
                        margin-top: 0 !important;
                    }
                    #columns.ytd-watch-flexy {
                        flex-direction: column !important;
                    }
                    #primary-inner.ytd-watch-flexy {
                        display: flex !important;
                    }
                    #player.ytd-watch-flexy {
                        max-width: var(--ytd-watch-flexy-max-player-width);
                        min-width: var(--ytd-watch-flexy-max-player-width);
                    }
                    #below.ytd-watch-flexy {
                        padding-left: 12px !important;
                        height: 100% !important;
                    }
                    #secondary.ytd-watch-flexy {
                        padding: var(--ytd-margin-6x) !important;
                        height: 100% !important;
                        min-width: 100vw !important;
                        max-width: 100vw !important;
                    }
                `,
            },
            chatStyle: {
                id: 'betterTheater-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;
                    }
                    #chat-container { z-index: 2021 !important; }
                `,
            },
            videoPlayerStyle: {
                id: 'betterTheater-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: 'betterTheater-headmastStyle',
                getRule: () => `#masthead-container.ytd-app { max-width: calc(100% - ${state.chatWidth}px) !important; }`,
            },
            lowHeadmastStyle: {
                id: 'betterTheater-lowHeadmastStyle',
                getRule: () => `
                    #page-manager.ytd-app {
                        margin-top: 0 !important;
                        top: calc(-1 * var(--ytd-toolbar-offset)) !important;
                        position: relative !important;
                    }
                    ytd-watch-flexy:not([full-bleed-player][full-bleed-no-max-width-columns]) #columns.ytd-watch-flexy {
                        margin-top: var(--ytd-toolbar-offset) !important;
                    }
                    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: ${state.moviePlayerHeight}px !important;
                        position: relative !important;
                    }
                `,
            },
            videoPlayerFixStyle: {
                id: 'betterTheater-videoPlayerFixStyle',
                getRule: () => `
                    .html5-video-container { top: -1px !important; }
                    #skip-navigation.ytd-masthead { left: -500px; }
                `,
            },
            chatRendererFixStyle: {
                id: 'betterTheater-chatRendererFixStyle',
                getRule: () => `ytd-live-chat-frame[theater-watch-while][rounded-container] { border-bottom: 0 !important; }`,
            },
            chatClampLimits: {
                id: 'betterTheater-chatClampLimits',
                getRule: () => {
                    const flexy = api.page.watchFlexy;
                    const originalWidth = '402px';
                    const originalMinWidth = '402px';

                    if (flexy) {
                        const style = window.getComputedStyle(flexy);
                        const fetchedWidth = style.getPropertyValue('--ytd-watch-flexy-sidebar-width')?.trim();
                        const fetchedMinWidth = style.getPropertyValue('--ytd-watch-flexy-sidebar-min-width')?.trim();
                        return `
                            ytd-live-chat-frame[theater-watch-while] {
                                min-width: ${CONFIG.MIN_CHAT_SIZE.width}px !important;
                                max-width: 33.33vw !important;
                            }
                            .ytd-watch-flexy {
                                --ytd-watch-flexy-sidebar-width: clamp(${
                                    CONFIG.MIN_CHAT_SIZE.width
                                }px, var(--bt-chat-width), 33.33vw) !important;
                                --ytd-watch-flexy-sidebar-min-width: clamp(${
                                    CONFIG.MIN_CHAT_SIZE.width
                                }px, var(--bt-chat-width), 33.33vw) !important;
                            }
                            ytd-watch-flexy[flexy] #secondary.ytd-watch-flexy {
                                --ytd-watch-flexy-sidebar-width: ${fetchedWidth ?? originalWidth} !important;
                                --ytd-watch-flexy-sidebar-min-width: ${fetchedMinWidth ?? originalMinWidth} !important;
                            }
                            ytd-watch-next-secondary-results-renderer {
                                --ytd-reel-item-compact-layout-width: calc((${fetchedWidth ?? originalWidth} - 8px) / 3) !important;
                                --ytd-reel-item-thumbnail-height: calc((${fetchedWidth ?? originalWidth} / 3 / 9 * 16)) !important;
                            }
                            ytd-live-chat-frame[theater-watch-while] yt-live-chat-renderer {
                                width: 100% !important; max-width: 100% !important;
                            }
                        `;
                    }
                    return '';
                },
            },
        },
        apply(styleDef, isPersistent = false) {
            if (typeof styleDef.getRule !== 'function') return;
            this.remove(styleDef); // Ensure no duplicates

            const styleElement = document.createElement('style');
            styleElement.id = styleDef.id;
            styleElement.textContent = styleDef.getRule();
            document.head.appendChild(styleElement);
            state.activeStyles.set(styleDef.id, {
                element: styleElement,
                persistent: isPersistent,
            });
        },
        remove(styleDef) {
            const styleData = state.activeStyles.get(styleDef.id);
            if (styleData) {
                styleData.element?.remove();
                state.activeStyles.delete(styleDef.id);
            }
        },
        removeAll() {
            const styleIdsToRemove = [...state.activeStyles.keys()];
            styleIdsToRemove.forEach((styleId) => {
                const styleData = state.activeStyles.get(styleId);
                if (styleData && !styleData.persistent) {
                    this.remove({ id: styleId });
                }
            });
        },
        toggle(styleDef, condition) {
            condition ? this.apply(styleDef) : this.remove(styleDef);
        },
    };
    const StorageManager = {
        getValue: async (key) => {
            try {
                return await api.loadFromStorage(CONFIG.STORAGE_PREFIX + key);
            } catch (error) {
                console.error(`Failed to parse storage key "${key}"`, error);
                return null;
            }
        },
        setValue: async (key, value) => {
            try {
                await api.saveToStorage(CONFIG.STORAGE_PREFIX + key, value);
            } catch (error) {
                console.error(`Failed to set storage key "${key}"`, error);
            }
        },
        deleteValue: async (key) => {
            await api.deleteFromStorage(CONFIG.STORAGE_PREFIX + key);
        },
        listValues: async () => {
            const fullList = await api.listFromStorage();
            const filteredList = fullList
                .filter((key) => key.startsWith(CONFIG.STORAGE_PREFIX))
                .map((key) => key.substring(CONFIG.STORAGE_PREFIX.length));
            return filteredList;
        },
    };
    const SettingsManager = {
        async update(key, value) {
            try {
                const settings = await StorageManager.getValue('settings', CONFIG.DEFAULT_SETTINGS);
                settings[key] = value;
                await StorageManager.setValue('settings', settings);
                state.userSettings[key] = value;
            } catch (error) {
                console.error(`Error updating setting: ${key}.`, error);
            }
        },
        async load() {
            try {
                const storedSettings = await StorageManager.getValue('settings', CONFIG.DEFAULT_SETTINGS);
                const newSettings = {
                    ...CONFIG.DEFAULT_SETTINGS,
                    ...storedSettings,
                };
                state.userSettings = newSettings;
                if (Object.keys(storedSettings).length !== Object.keys(newSettings).length) {
                    await StorageManager.setValue('settings', state.userSettings);
                }
            } catch (error) {
                console.error('Error loading settings.', error);
                throw error;
            }
        },
        async cleanupStorage() {
            try {
                const allowedKeys = ['settings'];
                const keys = await StorageManager.listValues();
                for (const key of keys) {
                    if (!allowedKeys.includes(key)) {
                        await StorageManager.deleteValue(key);
                    }
                }
            } catch (error) {
                console.error('Error cleaning up old storage.', error);
            }
        },
    };
    const MenuManager = {
        clear() {
            while (state.menuItems.length) {
                GM_API.unregisterMenuCommand(state.menuItems.pop());
            }
        },
        refresh() {
            this.clear();
            const LABEL = getLocalizedText();
            const shouldAutoClose = GM?.info?.scriptHandler === 'ScriptCat';
            const menuConfig = [
                {
                    label: () => `${state.userSettings.setLowHeadmast ? '✅' : '❌'} ${LABEL.moveHeadmastBelowVideoPlayer}`,
                    id: 'toggleLowHeadmast',
                    action: () =>
                        SettingsManager.update('setLowHeadmast', !state.userSettings.setLowHeadmast).then(() => App.updateAllStyles()),
                },
            ];
            menuConfig.forEach((item) => {
                const commandId = GM_API.registerMenuCommand(
                    item.label(),
                    async () => {
                        await item.action();
                        this.refresh();
                    },
                    { id: item.id, autoClose: shouldAutoClose },
                );
                state.menuItems.push(commandId ?? item.id);
            });
        },
    };
    const ChatInteractionManager = {
        addChatWidthResizeHandle() {
            if (window.innerWidth / 3 <= CONFIG.MIN_CHAT_SIZE.width) return;
            const chat = api.chat.iFrame;
            if (!chat || chat.querySelector('#chat-width-resize-handle')) return;

            const storedWidth = state.userSettings.theaterChatWidth ?? `${CONFIG.MIN_CHAT_SIZE.width}px`;
            this._applyTheaterWidth(api.page.watchFlexy, chat, storedWidth);

            const handle = document.createElement('div');
            handle.id = 'chat-width-resize-handle';
            handle.className = 'style-scope ytd-live-chat-frame';
            Object.assign(handle.style, {
                position: 'absolute',
                top: '0',
                left: '0',
                width: '6px',
                height: '100%',
                cursor: 'ew-resize',
                zIndex: '10001',
            });
            chat.appendChild(handle);

            let startX = 0;
            let startWidth = 0;
            let animationFrame;

            const _onPointerMove = (e) => {
                if (!handle.hasPointerCapture(e.pointerId)) return;
                cancelAnimationFrame(animationFrame);
                animationFrame = requestAnimationFrame(() => {
                    const dx = startX - e.clientX;
                    const newWidth = Math.max(CONFIG.MIN_CHAT_SIZE.width, startWidth + dx);
                    this._applyTheaterWidth(api.page.watchFlexy, chat, `${newWidth}px`);
                });
            };

            const _onPointerUp = (e) => {
                handle.releasePointerCapture(e.pointerId);
                document.removeEventListener('pointermove', _onPointerMove);
                document.removeEventListener('pointerup', _onPointerUp);
                SettingsManager.update(
                    'theaterChatWidth',
                    api.page.watchFlexy.style.getPropertyValue('--bt-chat-width'),
                );
            };

            handle.addEventListener('pointerdown', (e) => {
                if (e.pointerType === 'mouse' && e.button !== 0) return;
                e.preventDefault();
                document.body.click(); // Deselect any text
                startX = e.clientX;
                startWidth = chat.getBoundingClientRect().width;
                handle.setPointerCapture(e.pointerId);
                document.addEventListener('pointermove', _onPointerMove);
                document.addEventListener('pointerup', _onPointerUp);
            });
        },
        _applyTheaterWidth(flexy, chat, widthCss) {
            if (flexy) flexy.style.setProperty('--bt-chat-width', widthCss);
            if (chat) {
                chat.style.width = widthCss;
                chat.style.zIndex = '1999';
            }
        },
        removeChatWidthResizeHandle() {
            api.chat.iFrame?.querySelector('#chat-width-resize-handle')?.remove();
            const flexy = api.page.watchFlexy;
            const chat = api.chat.iFrame;
            if (flexy) flexy.style.removeProperty('--bt-chat-width');
            if (chat) {
                chat.style.width = '';
                chat.style.zIndex = '';
            }
        },
    };
    const App = {
        init() {
            console.log('init');
            try {
                if (!this.detectGreasemonkey()) throw new Error('Greasemonkey API not detected');
                Promise.all([SettingsManager.cleanupStorage(), SettingsManager.load()]).then(() => {
                    StyleManager.apply(StyleManager.styleDefinitions.chatRendererFixStyle, true);
                    StyleManager.apply(StyleManager.styleDefinitions.videoPlayerFixStyle, true);
                    StyleManager.apply(StyleManager.styleDefinitions.staticDesktopStyle, true);
                    StyleManager.apply(StyleManager.styleDefinitions.videoPlayerStyle, true);
                    //StyleManager.apply(StyleManager.styleDefinitions.staticDesktopOptimalStyle, true);
                    this._handlePageUpdate();
                    this.attachEventListeners();
                    MenuManager.refresh();
                });
            } catch (error) {
                console.error('Initialization failed.', error);
            }
        },
        detectGreasemonkey() {
            return typeof window.GM?.info !== 'undefined' || typeof GM_info !== 'undefined';
        },
        updateAllStyles() {
            try {
                this.updateChatStyles();
                DOM.moviePlayer?.setCenterCrop?.();
            } catch (error) {
                console.error('Error updating styles.', error);
            }
        },
        updateChatStyles() {
            const chatBox = api.chat.iFrame?.getBoundingClientRect();
            const flexy = api.page.watchFlexy;
            const isSecondaryVisible = flexy?.querySelector('#secondary')?.style.display !== 'none';
            const shouldApplyChatStyle =
                api.player.isTheater &&
                !api.player.isFullscreen &&
                !api.chat.isCollapsed &&
                chatBox?.width > 0 &&
                isSecondaryVisible;

            StyleManager.toggle(StyleManager.styleDefinitions.chatStyle, shouldApplyChatStyle);
            StyleManager.toggle(StyleManager.styleDefinitions.chatClampLimits, shouldApplyChatStyle);

            shouldApplyChatStyle ? ChatInteractionManager.addChatWidthResizeHandle() : ChatInteractionManager.removeChatWidthResizeHandle();
            this.updateHeadmastStyle(shouldApplyChatStyle);
        },
        updateHeadmastStyle(isChatStyled) {
            this.updateLowHeadmastStyle();

            const shouldShrinkHeadmast = isChatStyled && api.chat.iFrame?.getAttribute('theater-watch-while') === '';

            state.chatWidth = api.chat.iFrame?.offsetWidth ?? 0;
            StyleManager.toggle(StyleManager.styleDefinitions.headmastStyle, shouldShrinkHeadmast);
        },
        updateLowHeadmastStyle() {
            if (!DOM.moviePlayer) return;
            const shouldApply =
                state.userSettings.setLowHeadmast &&
                api.player.isTheater &&
                !api.player.isFullscreen &&
                api.page.type === 'watch';
            StyleManager.toggle(StyleManager.styleDefinitions.lowHeadmastStyle, shouldApply);
        },
        updateMoviePlayerObserver() {
            const newMoviePlayer = api.player.playerObject;
            if (DOM.moviePlayer === newMoviePlayer) return;
            if (state.resizeObserver) {
                if (DOM.moviePlayer) state.resizeObserver.unobserve(DOM.moviePlayer);
            } else {
                state.resizeObserver = new ResizeObserver((entries) => {
                    for (const entry of entries) {
                        state.moviePlayerHeight = entry.contentRect.height;
                        this.updateAllStyles();
                    }
                });
            }

            DOM.moviePlayer = newMoviePlayer;
            if (DOM.moviePlayer) state.resizeObserver.observe(DOM.moviePlayer);
        },
        _handlePageUpdate() {
            this.updateMoviePlayerObserver();
            this.updateAllStyles();
        },
        _handleFullscreenChange() {
            this.updateAllStyles();
        },
        _handleTheaterChange() {
            this.updateAllStyles();
        },
        _handleChatStateUpdate() {
            this.updateAllStyles();
        },
        _handlePageDataFetch() {
            this._handlePageUpdate();
        },
        attachEventListeners() {
            const events = {
                'yt-set-theater-mode-enabled': () => this._handleTheaterChange(),
                'yt-page-data-fetched': () => this._handlePageDataFetch(),
                'yt-page-data-updated': () => this._handlePageUpdate(),
                fullscreenchange: () => this._handleFullscreenChange(),
                'yt-navigate-finish': () => this._handlePageUpdate(),
            };

            for (const [event, handler] of Object.entries(events)) {
                window.addEventListener(event, handler.bind(this), {
                    capture: true,
                    passive: true,
                });
            }

            api.eventTarget.addEventListener(
                'yt-helper-api-chat-state-updated',
                this._handleChatStateUpdate.bind(this),
            );

            let isResizeScheduled = false;
            window.addEventListener('resize', () => {
                if (isResizeScheduled) return;
                isResizeScheduled = true;
                requestAnimationFrame(() => {
                    this.updateAllStyles();
                    isResizeScheduled = false;
                });
            });
        },
    };
    App.init();
})();