Greasy Fork

Greasy Fork is available in English.

B站自动宽屏居中

自动宽屏播放并将播放器垂直居中视口,退出宽屏/网页全屏/全屏模式自动滚动页面到顶部。默认关闭自动宽屏。

当前为 2025-04-17 提交的版本,查看 最新版本

// ==UserScript==
// @name         B站自动宽屏居中
// @namespace    @ChatGPT
// @version      1.64
// @description  自动宽屏播放并将播放器垂直居中视口,退出宽屏/网页全屏/全屏模式自动滚动页面到顶部。默认关闭自动宽屏。
// @author       Gemini;wha4up
// @license      MIT
// @match        https://*.bilibili.com/video/*
// @match        https://*.bilibili.com/list/*
// @match        https://*.bilibili.com/bangumi/play/*
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// @grant        GM_unregisterMenuCommand
// @run-at       document-idle
// @supportURL   http://greasyfork.icu/zh-CN/scripts/492413-b%E7%AB%99%E8%87%AA%E5%8A%A8%E5%AE%BD%E5%B1%8F%E5%B1%85%E4%B8%AD/feedback
// @homepageURL  http://greasyfork.icu/zh-CN/scripts/492413-b%E7%AB%99%E8%87%AA%E5%8A%A8%E5%AE%BD%E5%B1%8F%E5%B1%85%E4%B8%AD
// ==/UserScript==

(function () {
    'use strict';

    // --- 配置项 ---
    const PLAYER_CENTER_OFFSET = 75; // 播放器垂直居中时的偏移量 (像素)
    const CHECK_INTERVAL = 500;      // 查找元素的间隔时间 (ms)
    const MAX_ATTEMPTS = 20;         // 查找元素的最大尝试次数
    const DEBOUNCE_DELAY = 200;      // 事件防抖延迟 (ms)
    const URL_CHECK_DELAY = 500;     // URL 变化后执行逻辑的延迟 (ms)
    const FINAL_CHECK_DELAY = 300;   // 初始化或导航后最终检查状态的延迟 (ms)
    const SCROLL_ANIMATION_DURATION = 500; // 预估的平滑滚动动画时长 (ms),用于滚动节流

    // --- 状态变量 ---
    let elements = {
        wideBtn: null,
        webFullBtn: null,
        fullBtn: null,
        player: null,
        playerContainer: null,
    };
    let isEnabled = GM_getValue('enableWideScreen', false);
    let currentUrl = window.location.href;
    let initTimeout = null;
    let reInitScheduled = false;
    let observer = null;
    let lastScrollTime = 0;
    let isScrolling = false;
    let currentMenuCommandText = '';
    // 用于存储当前注册的菜单命令文本

    // --- 工具函数 ---

    /** 防抖函数 */
    function debounce(func, delay) {
        let timeoutId;
        return function(...args) {
            clearTimeout(timeoutId);
            timeoutId = setTimeout(() => {
                func.apply(this, args);
            }, delay);
        };
    }

    /** 等待指定元素出现在 DOM 中 */
    function waitForElement(selector, callback, interval = CHECK_INTERVAL, maxAttempts = MAX_ATTEMPTS) {
        let attempts = 0;
        let intervalId = setInterval(() => {
            const element = document.querySelector(selector);
            if (element) {
                clearInterval(intervalId);
                callback(element);
            } else {
                attempts++;
                if (attempts >= maxAttempts) {
                    clearInterval(intervalId);
                    console.warn(`[B站自动宽屏居中] 元素 "${selector}" 未在 ${maxAttempts * interval}ms 内找到。`);
                }
            }
        },
        interval);
    }

    /** 平滑滚动到指定垂直位置 (带节流) */
    function scrollToPosition(topPosition) {
        if (isScrolling) return;
        const now = Date.now();
        if (now - lastScrollTime < 100 && Math.abs(window.scrollY - topPosition) < 50) {
            return;
        }
        lastScrollTime = now;
        isScrolling = true;
        window.scrollTo({
            top: topPosition,
            behavior: 'smooth'
        });
        setTimeout(() => {
            isScrolling = false;
        }, SCROLL_ANIMATION_DURATION);
    }

    /** 滚动页面使播放器垂直居中 */
    function scrollToPlayer() {
        if (!elements.player && !cacheElements()) return;
        if (!elements.player) return;
        requestAnimationFrame(() => {
            const playerRect = elements.player.getBoundingClientRect();
            if (playerRect.height > 0) {
                const playerTop = playerRect.top + window.scrollY;
                const desiredScrollTop = playerTop - PLAYER_CENTER_OFFSET;
                if (Math.abs(window.scrollY - desiredScrollTop) > 5) {
                    scrollToPosition(desiredScrollTop);
                }
            }
        });
    }

    /** 滚动到页面顶部 */
    function scrollToTop() {
        if (window.scrollY > 0) {
            scrollToPosition(0);
        }
    }

    /** 缓存播放器及控制按钮等元素 */
    function cacheElements() {
        elements.player = document.querySelector('#bilibili-player');
        elements.playerContainer = document.querySelector('.bpx-player-container') || document.querySelector('#bilibiliPlayer') || elements.player;
        if (elements.playerContainer) {
            elements.wideBtn = elements.playerContainer.querySelector('.bpx-player-ctrl-wide');
            elements.webFullBtn = elements.playerContainer.querySelector('.bpx-player-ctrl-web');
            elements.fullBtn = elements.playerContainer.querySelector('.bpx-player-ctrl-full');
        } else {
            elements.wideBtn = document.querySelector('.bpx-player-ctrl-wide');
            elements.webFullBtn = document.querySelector('.bpx-player-ctrl-web');
            elements.fullBtn = document.querySelector('.bpx-player-ctrl-full');
        }
        if (!elements.wideBtn) {
            console.warn("[B站自动宽屏居中] 未找到宽屏按钮 '.bpx-player-ctrl-wide'");
            return false;
        }
        return !!elements.player;
    }

    /** 检查播放器状态并执行相应滚动操作 (核心逻辑) */
    function checkAndScroll() {
        if (!cacheElements()) return;
        const isWide = elements.wideBtn.classList.contains('bpx-state-entered');
        const isWebFull = elements.webFullBtn && elements.webFullBtn.classList.contains('bpx-state-entered');
        const isFull = !!(document.fullscreenElement || document.webkitFullscreenElement || document.mozFullScreenElement || document.msFullscreenElement);

        // 只有当没有浮窗播放器时,才执行滚动逻辑
        if (isWide && !isWebFull && !isFull) {
        scrollToPlayer();
        } else if (!isWide && !isWebFull && !isFull) {
            scrollToTop();
        }
    }

    /** 防抖版的 checkAndScroll,用于 resize 事件 */
    const debouncedCheckAndScroll = debounce(checkAndScroll, DEBOUNCE_DELAY);
    /** 启用或确保宽屏模式(仅在自动宽屏启用时由脚本主动调用) */
    function ensureWideMode() {
        if (!isEnabled || !elements.wideBtn && !cacheElements() || !elements.wideBtn) return;
        const isCurrentlyWide = elements.wideBtn.classList.contains('bpx-state-entered');
        const isWebFull = elements.webFullBtn && elements.webFullBtn.classList.contains('bpx-state-entered');
        const isFull = !!(document.fullscreenElement || document.webkitFullscreenElement);
        if (!isCurrentlyWide && !isWebFull && !isFull) {
            elements.wideBtn.click();
        } else if (isCurrentlyWide && !isWebFull && !isFull) {
            checkAndScroll();
        }
    }

    /** 设置事件监听器和 MutationObserver */
    function setupListenersAndObserver() {
        removeListenersAndObserver();
        if (!cacheElements() || !elements.wideBtn) {
            console.error("[B站自动宽屏居中] setupListeners: 核心元素查找失败。");
            return;
        }
        elements.wideBtn.addEventListener('click', handleWideBtnClick); // 使用新的处理函数
        if (elements.webFullBtn) elements.webFullBtn.addEventListener('click', checkAndScroll);
        if (elements.fullBtn) elements.fullBtn.addEventListener('click', checkAndScroll);
        const videoArea = elements.playerContainer?.querySelector('.bpx-player-video-area');
        if (videoArea) videoArea.addEventListener('dblclick', checkAndScroll);
        document.addEventListener('fullscreenchange', checkAndScroll);
        document.addEventListener('webkitfullscreenchange', checkAndScroll);
        document.addEventListener('mozfullscreenchange', checkAndScroll);
        document.addEventListener('MSFullscreenChange', checkAndScroll);
        document.addEventListener('keydown', handleKeyPress);
        window.addEventListener('resize', debouncedCheckAndScroll);
    }

    /** 移除所有添加的事件监听器和 MutationObserver */
    function removeListenersAndObserver() {
        if (elements.wideBtn) elements.wideBtn.removeEventListener('click', handleWideBtnClick); // 移除新的处理函数
        if (elements.webFullBtn) elements.webFullBtn.removeEventListener('click', checkAndScroll);
        if (elements.fullBtn) elements.fullBtn.removeEventListener('click', checkAndScroll);
        const videoArea = elements.playerContainer?.querySelector('.bpx-player-video-area');
        if (videoArea) videoArea.removeEventListener('dblclick', checkAndScroll);
        document.removeEventListener('fullscreenchange', checkAndScroll);
        document.removeEventListener('webkitfullscreenchange', checkAndScroll);
        document.removeEventListener('mozfullscreenchange', checkAndScroll);
        document.removeEventListener('MSFullscreenChange', checkAndScroll);
        document.removeEventListener('keydown', handleKeyPress);
        window.removeEventListener('resize', debouncedCheckAndScroll);
        if (observer) {
            observer.disconnect();
            observer = null;
        }
        elements = {
            wideBtn: null,
            webFullBtn: null,
            fullBtn: null,
            player: null,
            playerContainer: null
        };
    }

    /** 处理键盘按下事件 (主要处理 ESC 键) */
    function handleKeyPress(event) {
        if (event.key === 'Escape') {
            setTimeout(checkAndScroll, 150);
        }
    }

    /** 注册油猴菜单命令 */
    function registerMenuCommand() {
        // 检查 GM API 是否可用
        if (typeof GM_registerMenuCommand !== 'function' || typeof GM_unregisterMenuCommand !== 'function') return;
        // 构造新命令的文本
        const newCommandText = `自动宽屏模式 (当前: ${isEnabled ? '✅ 开启' : '❌ 关闭'})`;
        // 1. 尝试移除上一次注册的命令 (如果文本不同)
        //    这可以避免在状态未实际改变时进行不必要的注销和注册
        if (currentMenuCommandText && currentMenuCommandText !== newCommandText) {
            try {
                GM_unregisterMenuCommand(currentMenuCommandText);
            } catch (e) {
                console.warn("[B站自动宽屏居中] 注销旧菜单命令失败:", e);
            }
        }

        // 2. 注册新命令
        try {
            GM_registerMenuCommand(newCommandText, toggleWideScreen);
            // 3. 更新当前命令文本记录
            currentMenuCommandText = newCommandText;
        } catch (e) {
            console.error("[B站自动宽屏居中] 注册新菜单命令失败:", e);
        }
    }

    /** 切换自动宽屏功能的启用状态 (带确认框) */
    function toggleWideScreen() {
        const currentState = GM_getValue('enableWideScreen', false);
        const intendedState = !currentState;
        const actionText = intendedState ? "开启" : "关闭";
        const confirmationMessage = `是否要${actionText}自动宽屏模式?`;
        if (window.confirm(confirmationMessage)) {
            // 用户确认
            isEnabled = intendedState;
            GM_setValue('enableWideScreen', isEnabled);
            registerMenuCommand(); // 更新菜单显示

            // 应用效果
            if (isEnabled) {
                ensureWideMode();
            } else {
                //  关闭自动宽屏时,如果为宽屏模式则模拟点击宽屏按钮
                if (elements.wideBtn && elements.wideBtn.classList.contains('bpx-state-entered')) {
                    elements.wideBtn.click();
                }
                checkAndScroll();
            }
        }
        // 用户取消则不执行任何操作
    }

    /** 处理宽屏按钮点击事件 */
    function handleWideBtnClick() {
        checkAndScroll(); // 先执行一次检查和滚动

        // 延迟一段时间后再次检查和滚动,以确保状态更新
        setTimeout(checkAndScroll, 200);
    }

    /** 核心初始化逻辑 */
    function initializeScriptLogic() {
        reInitScheduled = false;
        clearTimeout(initTimeout);
        waitForElement('#bilibili-player, .bpx-player-container', () => {
            if (cacheElements()) {
                setupListenersAndObserver();
                if (isEnabled) {
                    ensureWideMode();
                }
                setTimeout(checkAndScroll, FINAL_CHECK_DELAY); // 最终状态检查
            } else {
                console.error("[B站自动宽屏居中] 初始化失败:未能缓存核心元素。");
            }
        });
    }

    /** 安排重新初始化脚本逻辑 (用于 SPA 导航) */
    function scheduleReInitialization(delay = URL_CHECK_DELAY) {
        if (reInitScheduled) return;
        reInitScheduled = true;
        clearTimeout(initTimeout);
        initTimeout = setTimeout(() => {
            removeListenersAndObserver(); // 清理旧的
            setTimeout(initializeScriptLogic, 100); // 延迟后初始化
        }, delay);
    }

    /** 检查 URL 是否是目标页面 */
    function isTargetPage(url) {
        return /\/(video|list|bangumi\/play)\//.test(url);
    }

    /** 处理 URL 发生变化 */
    function handleUrlChange() {
        requestAnimationFrame(() => {
            const newUrl = window.location.href;
            if (newUrl !== currentUrl) {
                const oldUrl = currentUrl;
                currentUrl = newUrl;
                const wasTarget = isTargetPage(oldUrl);
                const isNowTarget = isTargetPage(newUrl);
                if (isNowTarget) {
                    scheduleReInitialization();
                } else if (wasTarget && !isNowTarget) {
                    removeListenersAndObserver();
                    clearTimeout(initTimeout);
                    reInitScheduled = false;
                }
            }
        });
    }

    /** 主函数:脚本入口 */
    function main() {
        isEnabled = GM_getValue('enableWideScreen', false);
        registerMenuCommand(); // 初始注册菜单

        // --- 监听 URL 变化 ---
        window.addEventListener('popstate', handleUrlChange);
        const originalPushState = history.pushState;
        const originalReplaceState = history.replaceState;
        history.pushState = function(...args) {
            const result = originalPushState.apply(this, args);
            window.dispatchEvent(new CustomEvent('historystatechanged'));
            return result;
        };
        history.replaceState = function(...args) {
            const result = originalReplaceState.apply(this, args);
            window.dispatchEvent(new CustomEvent('historystatechanged'));
            return result;
        };
        window.addEventListener('historystatechanged', handleUrlChange);

        // --- 初始加载处理 ---
        currentUrl = window.location.href;
        if (isTargetPage(currentUrl)) {
            initializeScriptLogic();
        }

        // --- 清理工作 (页面卸载时) ---
        window.addEventListener('unload', () => {
            removeListenersAndObserver();
            history.pushState = originalPushState;
            history.replaceState = originalReplaceState;
            window.removeEventListener('historystatechanged', handleUrlChange);
            window.removeEventListener('popstate', handleUrlChange);
            clearTimeout(initTimeout);
        });
    }

    // --- 启动脚本 ---
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', main);
    } else {
        main();
    }
})();