Better YouTube Theater Mode

Improves YouTube's theater mode with a Twitch.tv-like design, enhancing video and chat layouts while maintaining performance and compatibility.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==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.2.9
// @match               *://www.youtube.com/*
// @match               *://www.youtube-nocookie.com/*
// @require             https://update.greasyfork.org/scripts/549881/1733676/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: {
            setLowMasthead: false,
            fullHeightVideo: false,
            tuckRecommendation: false,
            alwaysShowQuickActions: true,
            get theaterChatWidth() {
                return `${CONFIG.MIN_CHAT_SIZE.width}px`;
            },
        },
    };

    const MENU_LABELS = (() => {
        const browserLanguage = navigator.language ?? navigator.userLanguage;
        const translations = {
            moveMastheadBelowVideoPlayer: {
                'en-US': 'Move Search Bar Below Video',
                'zh-TW': '將搜尋列移動到影片下方',
                'zh-CN': '将搜寻列移动到影片下方',
                ja: '検索バーをビデオプレイヤーの下に移動',
            },
            fullHeightVideo: {
                'en-US': 'Full Height Video',
                'zh-TW': '延伸影片至視窗高度',
                'zh-CN': '下移推荐视频',
                ja: '動画をブラウザの高さに広げる',
            },
            tuckRecommendation: {
                'en-US': 'Shift Recommendations Down',
                'zh-TW': '下移推薦影片',
                'zh-CN': '下移推荐视频列表',
                ja: 'おすすめの動画を下にずらす',
            },
            alwaysShowQuickActions: {
                'en-US': 'Always Show Quick Actions',
                'zh-TW': '常駐顯示快速操作',
                'zh-CN': '常驻显示快速操作',
                ja: 'クイックアクションを常に表示',
            },
        };

        const getPreferredLanguage = () => {
            if (['zh-TW', 'zh-HK'].includes(browserLanguage)) return 'zh-TW';
            if (browserLanguage.startsWith('zh')) return 'zh-CN';
            if (browserLanguage.startsWith('ja')) return 'ja';
            return 'en-US';
        };

        return new Proxy(translations, {
            get(target, property) {
                const keyGroup = target[property];
                if (!keyGroup) return `[${String(property)}]`;
                const currentLanguage = getPreferredLanguage();
                const fallbackLanguage = 'en-US';
                return keyGroup[currentLanguage] ?? keyGroup[fallbackLanguage] ?? `[Missing: ${String(property)}]`;
            },
        });
    })();

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

    const DOM = {
        moviePlayer: null,
    };

    const GhostManager = {
        hasInitialized: false,
        observer: null,
        _pollingInterval: null,
        currentSource: null,
        currentTarget: null,

        init() {
            this.hasInitialized = true;
            this.observer = new MutationObserver((mutations) => {
                const isRelevant = mutations.some(
                    (_mutation) =>
                        _mutation.type === 'childList' ||
                        _mutation.type === 'characterData' ||
                        (_mutation.type === 'attributes' && _mutation.target === this.currentSource),
                );
                if (isRelevant) this.update();
            });

            if (!this.update()) {
                this._pollingInterval = setInterval(() => {
                    if (this.update()) {
                        clearInterval(this._pollingInterval);
                        this._pollingInterval = null;
                    }
                }, 500);
            }
        },

        safelyModifyDOM(action) {
            this.observer?.disconnect();
            try {
                action();
            } finally {
                this.observeElements(this.currentSource, this.currentTarget);
            }
        },

        isSourceReady(element) {
            return element && element.offsetWidth > 0 && !!element.querySelector('button') && !!element.querySelector('yt-icon, svg, img');
        },

        _createBaseButton(referenceButton) {
            const ghost = document.createElement('button');
            ghost.classList.add('bt-ghost-clone');

            const defaults = {
                classes: [
                    'yt-spec-button-shape-next',
                    'yt-spec-button-shape-next--text',
                    'yt-spec-button-shape-next--overlay',
                    'yt-spec-button-shape-next--size-s',
                ],
                styles: { width: '32px', height: '32px' },
            };

            if (referenceButton) {
                ghost.className = referenceButton.className + ' bt-ghost-clone';
            } else {
                ghost.classList.add(...defaults.classes);
            }

            if (referenceButton) {
                const computed = window.getComputedStyle(referenceButton);
                Object.assign(ghost.style, {
                    margin: computed.margin,
                    padding: computed.padding,
                    width: computed.width,
                    height: computed.height,
                    minWidth: computed.minWidth,
                    verticalAlign: 'top',
                });
            } else {
                Object.assign(ghost.style, defaults.styles);
            }

            Object.assign(ghost.style, {
                cursor: 'pointer',
                border: 'none',
                outline: 'none',
                display: 'inline-flex',
                alignItems: 'center',
                justifyContent: 'center',
                boxSizing: 'border-box',
                padding: '0 18px',
                position: 'relative',
            });

            return ghost;
        },

        _appendIcon(ghost, original) {
            const iconSource = original.querySelector('yt-icon, svg, img');
            if (!iconSource) {
                ghost.textContent = '🔔';
                return;
            }

            const clonedIcon = iconSource.cloneNode(true);
            clonedIcon.style.cssText =
                'width: 24px !important; height: 24px !important; display: block; pointer-events: none; fill: currentColor; color: inherit;';

            const wrapper = document.createElement('div');
            wrapper.className = 'yt-spec-button-shape-next__icon';
            wrapper.style.pointerEvents = 'none';
            wrapper.appendChild(clonedIcon);
            ghost.appendChild(wrapper);
        },

        _appendRipple(ghost) {
            const shape = document.createElement('yt-touch-feedback-shape');
            shape.className = 'yt-spec-touch-feedback-shape yt-spec-touch-feedback-shape--overlay-touch-response';
            shape.setAttribute('aria-hidden', 'true');

            shape.appendChild(document.createElement('div')).className = 'yt-spec-touch-feedback-shape__stroke';
            shape.appendChild(document.createElement('div')).className = 'yt-spec-touch-feedback-shape__fill';
            ghost.appendChild(shape);
        },

        _appendBadge(ghost, original) {
            const source = original.querySelector('.yt-spec-icon-badge-shape__badge');
            const text = source?.textContent?.trim();

            if (!text || window.getComputedStyle(source).display === 'none') return;

            const badge = document.createElement('div');
            badge.className = 'bt-ghost-badge';
            badge.textContent = text;

            const computed = window.getComputedStyle(source);

            Object.assign(badge.style, {
                position: 'absolute',
                top: '2px',
                right: '-2px',
                backgroundColor: computed.backgroundColor,
                color: computed.color,
                fontSize: computed.fontSize,
                fontWeight: computed.fontWeight,
                lineHeight: computed.lineHeight,
                fontFamily: computed.fontFamily,
                minWidth: computed.minWidth,
                height: computed.height,
                padding: computed.padding,
                borderRadius: computed.borderRadius,
                border: computed.border,
                pointerEvents: 'none',
                zIndex: '10',
                display: 'flex',
                alignItems: 'center',
                justifyContent: 'center',
                boxSizing: 'border-box',
            });
            ghost.appendChild(badge);
        },

        syncGhost(original, container, targetIndex = 2) {
            if (!this.isSourceReady(original)) return false;

            let ghost = container.querySelector('.bt-ghost-clone');
            if (!ghost) {
                const referenceButton = container.querySelector('button:not(.bt-ghost-clone)');
                ghost = this._createBaseButton(referenceButton);

                this._appendIcon(ghost, original);
                this._appendRipple(ghost);

                const clickTarget = original.querySelector('button') || original;
                ghost.addEventListener('click', (e) => {
                    e.preventDefault();
                    e.stopPropagation();
                    clickTarget.click();
                });
            }

            const existingBadge = ghost.querySelector('.bt-ghost-badge');
            if (existingBadge) existingBadge.remove();
            this._appendBadge(ghost, original);

            this.safelyModifyDOM(() => {
                const children = Array.from(container.children);
                const currentIndex = children.indexOf(ghost);

                if (currentIndex === targetIndex) return;

                const offset = currentIndex !== -1 && currentIndex < targetIndex ? 1 : 0;
                const refNode = children[targetIndex + offset] || null;

                container.insertBefore(ghost, refNode);
            });

            return true;
        },

        observeElements(source, target) {
            this.currentSource = source || this.currentSource;
            this.currentTarget = target || this.currentTarget;
            if (!this.currentSource || !this.currentTarget) return;

            this.observer.disconnect();
            this.observer.observe(this.currentSource, { childList: true, subtree: true, characterData: true, attributes: true });
            this.observer.observe(this.currentTarget, { childList: true });
        },

        update() {
            if (!this.hasInitialized) return false;
            const shouldHaveGhosts = state.userSettings.fullHeightVideo && state.userSettings.setLowMasthead;

            if (shouldHaveGhosts) {
                const notifBell = document.querySelector('ytd-notification-topbar-button-renderer');
                const quickActions = document.querySelector('yt-player-quick-action-buttons');

                if (notifBell && quickActions) {
                    this.observeElements(notifBell, quickActions);
                    return this.syncGhost(notifBell, quickActions, 2);
                }
                return false;
            } else {
                const ghosts = document.querySelectorAll('.bt-ghost-clone');
                if (ghosts.length > 0) ghosts.forEach((el) => el.remove());

                this.observer?.disconnect();
                if (this._pollingInterval) clearInterval(this._pollingInterval);
                this.currentSource = null;
                this.currentTarget = null;
                return true;
            }
        },
    };

    const StyleManager = {
        activeStyles: new Map(),
        styleDefinitions: {
            staticStyles: {
                staticVideoPlayerFixStyle: {
                    id: 'betterTheater-staticVideoPlayerFixStyle',
                    getRule: () => `
                        .html5-video-container { top: -1px !important; }
                        #skip-navigation.ytd-masthead { left: -500px; }
                    `,
                },
                chatRendererFixStyle: {
                    id: 'betterTheater-staticChatRendererFixStyle',
                    getRule: () => `ytd-live-chat-frame[theater-watch-while][rounded-container] {
                        border-bottom: 0 !important;
                        }
                    `,
                },
                streamBackgroundImageFixStyle: {
                    id: 'betterTheater-streamBackgroundImageFixStyle',
                    getRule: () => `
                        .ytp-offline-slate-background {
                            background-size: contain !important;
                            max-width: 100% !important;
                            max-height: 100% !important;
                        }
                    `,
                },
                staticTuckRecommendationWidthClampStyle: {
                    id: 'betterTheater-staticTuckRecommendationWidthClampStyle',
                    getRule: () => `
                        #id.ytd-watch-metadata, #top-row.ytd-watch-metadata {
                            max-width:
                                calc(
                                    min(
                                        calc(100vw - 3 * var(--ytd-watch-flexy-horizontal-page-margin)),
                                        100% + var(--ytd-watch-flexy-sidebar-width) + var(--ytd-watch-flexy-horizontal-page-margin)
                                    )
                                )
                            !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; }
                `,
            },
            fullHeightPlayerStyle: {
                id: 'betterTheater-fullHeightPlayerStyle',
                getRule: () => {
                    const viewportHeight =
                        state.userSettings.setLowMasthead ? '100vh' : 'calc(100vh - var(--ytd-watch-flexy-masthead-height))';

                    return `
                        ytd-watch-flexy[full-bleed-player] #full-bleed-container.ytd-watch-flexy {
                            min-height: ${viewportHeight} !important;
                            max-height: ${viewportHeight} !important;
                        }
                    `;
                },
            },
            alwaysShowQuickActionsStyle: {
                id: 'betterTheater-alwaysShowQuickActionsStyle',
                getRule: () => `
                    .ytp-fullscreen-quick-actions {
                        display: unset !important;
                    }
                    #show-hide-button.ytd-live-chat-frame {
                        display: none !important;
                    }
                    .ytp-timely-actions-content ytw-timely-actions-overlay-view-model {
                        transform: translateY(-24px) !important;
                    }
                `,
            },
            mastheadStyle: {
                id: 'betterTheater-mastheadStyle',
                getRule: () => `#masthead-container.ytd-app { max-width: calc(100% - ${state.chatWidth}px) !important; }`,
            },
            lowMastheadStyle: {
                id: 'betterTheater-lowMastheadStyle',
                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;
                    }
                    #masthead-container.ytd-app {
                        z-index: 599 !important;
                        top: ${state.moviePlayerHeight}px !important;
                        position: relative !important;
                    }
                    tp-yt-iron-dropdown {
                        top: calc(var(--ytd-masthead-height-accounting-for-hidden) / 2) !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 '';
                },
            },
            tuckRecommendationStyles: {
                liveStyle: {
                    id: 'betterTheater-tuckRecommendationStreamStyle',
                    getRule: () => `
                        #columns.style-scope.ytd-watch-flexy {
                            flex-direction: column !important;
                        }
                        #secondary {
                            width: auto !important;
                            margin: 0 var(--ytd-watch-flexy-horizontal-page-margin) !important;
                        }
                        #teaser-carousel.ytd-watch-metadata {
                            width: auto !important;
                        }
                    `,
                },
                vodStyle: {
                    id: 'betterTheater-tuckRecommendationVodStyle',
                    getRule: () => `
                        #id.ytd-watch-metadata, #top-row.ytd-watch-metadata {
                            width: calc(100% + var(--ytd-watch-flexy-sidebar-width) + var(--ytd-watch-flexy-horizontal-page-margin)) !important;
                        }
                        #secondary:not(:has(ytd-playlist-panel-renderer)) {
                            transform: translateY(calc(var(--ytd-watch-flexy-top-padding) * 6)) !important;
                        }
                        #secondary:has(ytd-playlist-panel-renderer) {
                            transform: translateY(calc(var(--ytd-watch-flexy-top-padding) * 6.5 + 1px)) !important;
                        }
                    `,
                },
                videoStyle: {
                    id: 'betterTheater-tuckRecommendationVideoStyle',
                    getRule: () => `
                        #id.ytd-watch-metadata, #top-row.ytd-watch-metadata {
                            width: calc(100% + var(--ytd-watch-flexy-sidebar-width) + var(--ytd-watch-flexy-horizontal-page-margin)) !important;
                        }
                        #secondary:not(:has(ytd-playlist-panel-renderer)) {
                            transform: translateY(calc(var(--ytd-watch-flexy-top-padding) * 6)) !important;
                        }
                        #secondary:has(ytd-playlist-panel-renderer) {
                            transform: translateY(calc(var(--ytd-watch-flexy-top-padding) * 6.5 + 1px)) !important;
                        }
                    `,
                },
            },
        },
        apply(styleDef, isPersistent = false) {
            if (typeof styleDef.getRule !== 'function') return;

            const newCss = styleDef.getRule();
            let styleElement = document.getElementById(styleDef.id);

            if (styleElement) {
                if (styleElement.textContent === newCss) {
                    if (!state.activeStyles.has(styleDef.id)) {
                        state.activeStyles.set(styleDef.id, { element: styleElement, persistent: isPersistent });
                    }
                    return;
                }

                styleElement.textContent = newCss;
                return;
            }

            styleElement = document.createElement('style');
            styleElement.id = styleDef.id;
            styleElement.textContent = newCss;
            document.head.appendChild(styleElement);

            state.activeStyles.set(styleDef.id, {
                element: styleElement,
                persistent: isPersistent,
            });
        },
        remove(styleDef) {
            const element = document.getElementById(styleDef.id);
            if (element) 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.unregisterMenuCommand(state.menuItems.pop());
        },
        refresh() {
            this.clear();
            const shouldAutoClose = GM?.info?.scriptHandler === 'ScriptCat';
            const menuConfig = [
                {
                    label: () => `${state.userSettings.setLowMasthead ? '✅' : '❌'} ${MENU_LABELS.moveMastheadBelowVideoPlayer}`,
                    id: 'toggleLowMasthead',
                    action: () =>
                        SettingsManager.update('setLowMasthead', !state.userSettings.setLowMasthead).then(() => App.updateAllStyles()),
                },
                {
                    label: () => `${state.userSettings.fullHeightVideo ? '✅' : '❌'} ${MENU_LABELS.fullHeightVideo}`,
                    id: 'toggleFullHeightVideo',
                    action: () =>
                        SettingsManager.update('fullHeightVideo', !state.userSettings.fullHeightVideo).then(() => {
                            App.updateVideoStyle();
                            GhostManager.update();
                        }),
                },
                {
                    label: () => `${state.userSettings.tuckRecommendation ? '✅' : '❌'} ${MENU_LABELS.tuckRecommendation}`,
                    id: 'toggleTuckRecommendation',
                    action: () =>
                        SettingsManager.update('tuckRecommendation', !state.userSettings.tuckRecommendation).then(() => {
                            App.updateRecommendationTuckStyle();
                        }),
                },
                {
                    label: () => `${state.userSettings.alwaysShowQuickActions ? '✅' : '❌'} ${MENU_LABELS.alwaysShowQuickActions}`,
                    id: 'toggleAlwaysShowQuickActions',
                    action: () =>
                        SettingsManager.update('alwaysShowQuickActions', !state.userSettings.alwaysShowQuickActions).then(() => {
                            App.updateQuickActionStyle();
                        }),
                },
            ];
            menuConfig.forEach((item) => {
                const commandId = GM.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 = (event) => {
                handle.releasePointerCapture(event.pointerId);
                document.removeEventListener('pointermove', _onPointerMove);
                document.removeEventListener('pointerup', _onPointerUp);
                SettingsManager.update('theaterChatWidth', api.page.watchFlexy.style.getPropertyValue('--bt-chat-width'));
            };

            handle.addEventListener('pointerdown', (event) => {
                if (event.pointerType === 'mouse' && event.button !== 0) return;
                event.preventDefault();
                document.body.click(); // Deselect any text
                startX = event.clientX;
                startWidth = chat.getBoundingClientRect().width;
                handle.setPointerCapture(event.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() {
            try {
                if (api.gmCapabilities.none) throw new Error('Greasemonkey API not detected');
                Promise.all([SettingsManager.cleanupStorage(), SettingsManager.load()]).then(() => {
                    if (unsafeWindow.ytInitialData?.responseContext?.mainAppWebResponseContext?.loggedOut !== true) GhostManager.init();
                    Object.values(StyleManager.styleDefinitions.staticStyles).forEach((style) => StyleManager.apply(style, true));
                    this._handlePageUpdate();
                    this.attachEventListeners();
                    MenuManager.refresh();
                    GhostManager.update();
                });
            } catch (error) {
                console.error('Initialization failed.', error);
            }
        },
        _shouldApplyChatStyle() {
            const chatBox = api.chat.iFrame?.getBoundingClientRect();
            const flexy = api.page.watchFlexy;
            const isSecondaryVisible = flexy?.querySelector('#secondary')?.style.display !== 'none';
            return api.player.isTheater && !api.player.isFullscreen && !api.chat.isCollapsed && chatBox?.width > 0 && isSecondaryVisible;
        },
        updateChatStyles() {
            const styles = StyleManager.styleDefinitions;
            const shouldStyle = this._shouldApplyChatStyle();
            StyleManager.toggle(styles.chatStyle, shouldStyle);
            StyleManager.toggle(styles.chatClampLimits, shouldStyle);

            shouldStyle ? ChatInteractionManager.addChatWidthResizeHandle() : ChatInteractionManager.removeChatWidthResizeHandle();
            this.updateMastheadStyle(shouldStyle);
        },
        updateMastheadStyle(isChatStyled) {
            const styles = StyleManager.styleDefinitions;
            const updateLowMastheadStyle = () => {
                if (!DOM.moviePlayer) return;
                const shouldApply =
                    state.userSettings.setLowMasthead && api.player.isTheater && !api.player.isFullscreen && api.page.type === 'watch';
                StyleManager.toggle(styles.lowMastheadStyle, shouldApply);
            };

            if (isChatStyled === undefined) isChatStyled = this._shouldApplyChatStyle();
            updateLowMastheadStyle();

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

            state.chatWidth = api.chat.iFrame?.offsetWidth ?? 0;
            StyleManager.toggle(styles.mastheadStyle, shouldShrinkMasthead);
            DOM.moviePlayer?.setCenterCrop?.();
        },
        updateVideoStyle() {
            const shouldApply = state.userSettings.fullHeightVideo;
            StyleManager.toggle(StyleManager.styleDefinitions.fullHeightPlayerStyle, shouldApply);
        },
        updateRecommendationTuckStyle() {
            const styles = StyleManager.styleDefinitions.tuckRecommendationStyles;
            Object.values(styles).forEach((style) => StyleManager.toggle(style, false));

            if (!state.userSettings.tuckRecommendation) return;
            if (!api.player.isTheater || api.player.isFullscreen || api.page.type !== 'watch') return;

            const isVod = api.video.wasStreamedOrPremiered;
            const canHaveChat = api.video.isLiveOrVodContent || isVod;
            const isCollapsed = !api.chat.container || !api.chat.iFrame || api.chat.isCollapsed; // TODO: Patch helper lib. YouTube can return chat state even when chat elements are missing.

            if (!canHaveChat || (isVod && isCollapsed)) return StyleManager.toggle(styles.videoStyle, true);
            if (!isCollapsed) return StyleManager.toggle(isVod ? styles.vodStyle : styles.liveStyle, true);
        },
        updateQuickActionStyle() {
            const styles = StyleManager.styleDefinitions.alwaysShowQuickActionsStyle;
            StyleManager.toggle(styles, state.userSettings.alwaysShowQuickActions);
        },
        updateAllStyles() {
            try {
                this.updateVideoStyle();
                this.updateChatStyles();
                this.updateRecommendationTuckStyle();
                this.updateQuickActionStyle();
                GhostManager.update();
            } catch (error) {
                console.error('Error updating styles.', error);
            }
        },
        updateMoviePlayerObserver() {
            const newMoviePlayer = api.player.playerObject ?? document.querySelector('#movie_player');
            if (DOM.moviePlayer === newMoviePlayer) return;

            if (state.resizeObserver) state.resizeObserver.disconnect();
            state.resizeObserver = new ResizeObserver((entries) => {
                window.requestAnimationFrame(() => {
                    if (!Array.isArray(entries) || !entries.length) return;

                    const entry = entries[0];
                    if (Math.abs(state.moviePlayerHeight - entry.contentRect.height) > 1) {
                        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(),
            };

            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));
            api.eventTarget.addEventListener('yt-helper-api-ready', () => {
                if (api.page.type === 'watch') {
                    this._handlePageUpdate();
                }
            });

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