YouTube Fullscreen Manager

管理 YouTube 全螢幕模式切換,包含四種模式:原生、瀏覽器API、網頁全螢幕(置中容器)、網頁全螢幕(置頂容器)。僅在影片播放頁面啟用核心功能。

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 or Violentmonkey 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         YouTube Fullscreen Manager
// @name:zh-CN   YouTube 全萤幕管理器
// @name:en      YouTube Fullscreen Manager
// @namespace    http://tampermonkey.net/
// @version      1.4
// @description  管理 YouTube 全螢幕模式切換,包含四種模式:原生、瀏覽器API、網頁全螢幕(置中容器)、網頁全螢幕(置頂容器)。僅在影片播放頁面啟用核心功能。
// @description:zh-CN 管理 YouTube 全萤幕模式切换,包含四种模式:原生、浏览器API、网页全萤幕(置中容器)、网页全萤幕(置顶容器)。仅在影片播放页面启用核心功能。
// @description:en  Manages YouTube fullscreen switching with four modes: Native, Browser API, Web Fullscreen (Centered Container), Web Fullscreen (Top Container). Core functionality only activates on video playback pages.
// @match        https://www.youtube.com/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_registerMenuCommand
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    const LANG = /^zh-(cn|tw|hk|mo|sg)/i.test(navigator.language) ? 'zh' : 'en';
    const i18n = {
        zh: {
            menuFullscreenMode: '📺 設定 YouTube 全螢幕模式',
            fullscreenModeOptions: {
                1: '1. 原生最大化 (點擊 .ytp-fullscreen-button)',
                2: '2. 原生API最大化 (toggleNativeFullscreen)',
                3: '3. 網頁全螢幕 (容器置中)',
                4: '4. 網頁全螢幕 (容器置頂)'
            },
            promptFullscreen: '選擇 YouTube 全螢幕模式:',
            saveAlert: '設定已保存,需重新整理頁面後生效'
        },
        en: {
            menuFullscreenMode: '📺 Set YouTube Fullscreen Mode',
            fullscreenModeOptions: {
                1: '1. Native maximization (click .ytp-fullscreen-button)',
                2: '2. Native API maximization (toggleNativeFullscreen)',
                3: '3. Web Fullscreen (Centered Container)',
                4: '4. Web Fullscreen (Top Container)'
            },
            promptFullscreen: 'Select YouTube fullscreen mode:',
            saveAlert: 'Settings saved. Refresh page to apply'
        }
    };

    // 配置管理 / Configuration management
    const CONFIG_STORAGE_KEY = 'YouTubeFullscreenManagerConfig';
    const DEFAULT_CONFIG = {
        youtubeFullscreenMode: 2 // 預設模式改為2 / Default mode changed to 2
    };

    const getConfig = () => {
        const savedConfig = GM_getValue(CONFIG_STORAGE_KEY, {});
        return { ...DEFAULT_CONFIG, ...savedConfig };
    };

    const saveConfig = (config) => {
        const currentConfig = { ...config };
        const isDefault = Object.keys(DEFAULT_CONFIG).every(key =>
            currentConfig[key] === DEFAULT_CONFIG[key]
        );
        if (isDefault) {
            GM_setValue(CONFIG_STORAGE_KEY, {});
            return;
        }
        GM_setValue(CONFIG_STORAGE_KEY, currentConfig);
    };

    let CONFIG = getConfig();

    // 註冊選單 / Register menu
    const registerMenuCommands = () => {
        const t = i18n[LANG];
        GM_registerMenuCommand(t.menuFullscreenMode, handleFullscreenModeSetting);
    };

    const handleFullscreenModeSetting = () => {
        const t = i18n[LANG];
        const options = t.fullscreenModeOptions;
        const choice = prompt(
            `${t.promptFullscreen}\n${Object.values(options).join('\n')}`,
            CONFIG.youtubeFullscreenMode
        );
        if (choice && options[choice]) {
            CONFIG.youtubeFullscreenMode = parseInt(choice);
            saveConfig(CONFIG);
            alert(t.saveAlert);
        }
    };

    // 核心功能控制變量 / Core functionality control variables
    let isCoreActive = false; // 核心功能是否啟動 / Whether core functionality is active
    let videoDoubleClickHandler = null; // 用於存儲雙擊處理函數 / Used to store the double-click handler
    let keydownHandler = null; // 用於存儲按鍵處理函數 / Used to store the keydown handler
    let mutationObserver = null; // 用於監聽DOM變更 / Used to observe DOM changes

    // 狀態變量 / State variables
    let isWebFullscreened = false;
    let originalVideoParent = null;
    let originalVideoStyles = {};
    let originalParentStyles = {};
    let webFullscreenContainer = null;

    // 切換函數 / Toggle functions
    function toggleWebFullscreen(video) {
        if (!video) return;

        if (isWebFullscreened) {
            // 恢復原狀 / Restore original state
            if (webFullscreenContainer && webFullscreenContainer.contains(video)) {
                webFullscreenContainer.removeChild(video);
            }
            if (webFullscreenContainer && document.body.contains(webFullscreenContainer)) {
                document.body.removeChild(webFullscreenContainer);
                webFullscreenContainer = null;
            }
            if (originalVideoParent && !originalVideoParent.contains(video)) {
                originalVideoParent.appendChild(video);
            }
            Object.assign(video.style, originalVideoStyles);
            if (originalVideoParent) {
                Object.assign(originalVideoParent.style, originalParentStyles);
            }
            isWebFullscreened = false;
            originalVideoParent = null;
        } else {
            // 進入全螢幕 / Enter fullscreen
            originalVideoParent = video.parentElement;
            if (!originalVideoParent) return;

            originalVideoStyles = {
                position: video.style.position,
                top: video.style.top,
                left: video.style.left,
                width: video.style.width,
                height: video.style.height,
                zIndex: video.style.zIndex,
                objectFit: video.style.objectFit,
                objectPosition: video.style.objectPosition
            };
            originalParentStyles = {
                position: originalVideoParent.style.position,
                overflow: originalVideoParent.style.overflow
            };

            if (!webFullscreenContainer) {
                webFullscreenContainer = document.createElement('div');
                webFullscreenContainer.id = 'web-fullscreen-container';
                // 根據模式設定容器樣式 / Set container styles based on mode
                let containerStyles;
                if (CONFIG.youtubeFullscreenMode === 3) { // 模式3: 容器置中 (覆蓋整個視窗) / Mode 3: Centered container (covers entire window)
                    containerStyles = {
                        position: 'fixed', // 固定定位 / Fixed positioning
                        top: '0',
                        left: '0',
                        width: '100vw',
                        height: '100vh',
                        zIndex: '2147483645',
                        backgroundColor: 'black',
                        display: 'flex',
                        alignItems: 'center', // 垂直置中 / Center vertically
                        justifyContent: 'center' // 水平置中 / Center horizontally
                    };
                } else { // 模式4: 容器置頂 / Mode 4: Top container
                    containerStyles = {
                        position: 'relative',
                        zIndex: '2147483645',
                        backgroundColor: 'black',
                        display: 'flex',
                        alignItems: 'center',
                        justifyContent: 'center',
                        margin: '0 auto',
                        maxWidth: '100%',
                        maxHeight: '100vh'
                    };
                }
                Object.assign(webFullscreenContainer.style, containerStyles);
                webFullscreenContainer.addEventListener('click', () => {
                    if (video && !video.paused) {
                        video.pause();
                    } else if (video) {
                        video.play().catch(() => {});
                    }
                });
            }

            Object.assign(originalVideoParent.style, {
                position: 'static',
                overflow: 'visible'
            });

            originalVideoParent.removeChild(video);
            webFullscreenContainer.appendChild(video);
            document.body.insertBefore(webFullscreenContainer, document.body.firstChild);

            // 模式3: 設定影片置中並最大化 / Mode 3: Set video to center and maximize
            // 模式4: 設定影片置中並最大化 / Mode 4: Set video to center and maximize
            video.style.position = '';
            video.style.top = '';
            video.style.left = '';
            video.style.width = CONFIG.youtubeFullscreenMode === 3 ? '100%' : '100%';
            video.style.height = CONFIG.youtubeFullscreenMode === 3 ? '100%' : 'auto';
            video.style.maxWidth = CONFIG.youtubeFullscreenMode === 3 ? 'none' : 'none';
            video.style.maxHeight = CONFIG.youtubeFullscreenMode === 3 ? 'none' : '100vh';
            video.style.zIndex = '';
            video.style.objectFit = 'contain'; // 保持比例並填滿容器 (模式3) 或適應容器 (模式4) / Maintain aspect ratio and fit within container (Mode 3) or adapt to container (Mode 4)
            video.style.objectPosition = 'center'; // 置中 / Center
            isWebFullscreened = true;
        }
    }

    function toggleNativeFullscreen(video) {
        if (!video) return;
        try {
            if (document.fullscreenElement) {
                document.exitFullscreen();
            } else {
                let elementToFullscreen = video;
                for (let i = 0; i < 2; i++) {
                    elementToFullscreen = elementToFullscreen.parentElement || elementToFullscreen;
                }
                elementToFullscreen.requestFullscreen?.() ||
                elementToFullscreen.webkitRequestFullscreen?.() ||
                elementToFullscreen.msRequestFullscreen?.() ||
                video.requestFullscreen?.() ||
                video.webkitRequestFullscreen?.() ||
                video.msRequestFullscreen?.();
            }
        } catch (e) {
            console.error('Fullscreen error:', e);
        }
    }

    function toggleFullscreen(video) {
        switch(CONFIG.youtubeFullscreenMode) {
            case 1:
                document.querySelector('.ytp-fullscreen-button')?.click();
                break;
            case 2:
                toggleNativeFullscreen(video);
                break;
            case 3:
            case 4: // 模式3和4都使用相同的函數,僅容器定位不同 / Mode 3 and 4 use same function, only container positioning differs
                toggleWebFullscreen(video);
                break;
        }
    }

    // 雙擊處理 / Double-click handling
    function setupVideoEventOverrides(video) {
        if (videoDoubleClickHandler) {
            video.removeEventListener('dblclick', videoDoubleClickHandler);
        }
        videoDoubleClickHandler = (e) => {
            e.preventDefault();
            e.stopPropagation();
            toggleFullscreen(video);
        };
        video.addEventListener('dblclick', videoDoubleClickHandler);
    }

    // 按鍵處理 / Key handling
    function handleKeyEvent(e) {
        if (e.target.matches('input, textarea, select') || e.target.isContentEditable) return;

        const video = document.querySelector('video, ytd-player video');
        if (!video) return;

        // Enter鍵切換全螢幕 / Enter key to toggle fullscreen
        if (e.code === 'Enter' || e.code === 'NumpadEnter') {
            e.preventDefault();
            toggleFullscreen(video);
        }
    }

    // 綁定核心功能 / Bind core functionality
    function bindCoreFeatures() {
        if (isCoreActive) return; // 如果已啟動則不重複綁定 / Don't re-bind if already active

        document.querySelectorAll('video').forEach(video => {
            if (!video.dataset.fullscreenBound) {
                setupVideoEventOverrides(video);
                video.dataset.fullscreenBound = 'true';
            }
        });

        keydownHandler = handleKeyEvent;
        document.addEventListener('keydown', keydownHandler, true);

        // 監聽動態內容 / Listen for dynamic content
        mutationObserver = new MutationObserver(() => {
            document.querySelectorAll('video').forEach(video => {
                if (!video.dataset.fullscreenBound) {
                    setupVideoEventOverrides(video);
                    video.dataset.fullscreenBound = 'true';
                }
            });
        });
        mutationObserver.observe(document.body, { childList: true, subtree: true });

        isCoreActive = true;
    }

    // 釋放核心功能 / Release core functionality
    function unbindCoreFeatures() {
        if (!isCoreActive) return; // 如果未啟動則不需釋放 / Don't release if not active

        document.querySelectorAll('video[data-fullscreen-bound]').forEach(video => {
            if (videoDoubleClickHandler) {
                video.removeEventListener('dblclick', videoDoubleClickHandler);
            }
            delete video.dataset.fullscreenBound;
        });

        if (keydownHandler) {
            document.removeEventListener('keydown', keydownHandler, true);
            keydownHandler = null;
        }

        if (mutationObserver) {
            mutationObserver.disconnect();
            mutationObserver = null;
        }

        // 退出全螢幕狀態 / Exit fullscreen state
        if (isWebFullscreened) {
            // 觸發一次切換以恢復原狀 / Trigger a toggle to restore original state
            const video = document.querySelector('video, ytd-player video');
            if (video) {
                // 手動調用切換函數恢復狀態 / Manually call toggle function to restore state
                if (webFullscreenContainer && webFullscreenContainer.contains(video)) {
                    webFullscreenContainer.removeChild(video);
                }
                if (webFullscreenContainer && document.body.contains(webFullscreenContainer)) {
                    document.body.removeChild(webFullscreenContainer);
                    webFullscreenContainer = null;
                }
                if (originalVideoParent && !originalVideoParent.contains(video)) {
                    originalVideoParent.appendChild(video);
                }
                if (video && originalVideoStyles) Object.assign(video.style, originalVideoStyles);
                if (originalVideoParent && originalParentStyles) Object.assign(originalVideoParent.style, originalParentStyles);
                isWebFullscreened = false;
                originalVideoParent = null;
            }
        }

        isCoreActive = false;
    }

    // 檢查是否為影片播放頁面 / Check if it's a video playback page
    const isVideoPage = () => location.pathname.startsWith('/watch');

    // 初始化 / Initialization
    function init() {
        registerMenuCommands();

        // 初始檢查 / Initial check
        if (isVideoPage()) {
            bindCoreFeatures();
        }

        // 監聽 URL 變化 / Listen for URL changes
        let currentPath = location.pathname;
        const observer = new MutationObserver(() => {
            if (location.pathname !== currentPath) {
                currentPath = location.pathname;
                if (isVideoPage()) {
                    bindCoreFeatures();
                } else {
                    unbindCoreFeatures();
                }
            }
        });
        observer.observe(document, { childList: true, subtree: true });

        // 監聽 popstate 事件 (瀏覽器前後按鈕) / Listen for popstate event (browser back/forward buttons)
        window.addEventListener('popstate', () => {
            if (isVideoPage()) {
                bindCoreFeatures();
            } else {
                unbindCoreFeatures();
            }
        });
    }

    init();
})();