// ==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 https://greasyfork.org/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 https://greasyfork.org/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();
}
})();