Greasy Fork is available in English.
单击切换专注模式(进度条+控制栏+标题同步隐藏/显示),双击仅播放/暂停,长按倍速(带三箭头闪烁提示)、滑动进度自适应视频时长、左右半屏上下滑亮度/音量
当前为
// ==UserScript==
// @name Bilibili Surface
// @namespace http://tampermonkey.net/
// @version 1.4.1
// @description 单击切换专注模式(进度条+控制栏+标题同步隐藏/显示),双击仅播放/暂停,长按倍速(带三箭头闪烁提示)、滑动进度自适应视频时长、左右半屏上下滑亮度/音量
// @author You
// @match *://*.bilibili.com/*
// @icon https://www.bilibili.com/favicon.ico
// @run-at document-end
// @grant none
// @license MIT
// ==/UserScript==
(function() {
'use strict';
// --- 参数配置 ---
const PRESS_DELAY = 250;
const TARGET_SPEED = 3.0;
const DOUBLE_TAP_DELAY = 250;
const SEEK_SENSITIVITY = 1.0;
const CTRL_SHOW_DURATION = 5e3;
let pressTimer = null;
let clickTimer = null;
let clickCount = 0;
let originalSpeed = 1.0;
let gestureType = '';
let isInteracting = false;
let wasPlaying = false;
let startX = 0;
let startY = 0;
let startVal = 0;
let prevBrightnessY = null; // 追踪亮度手势的前一个 Y,避免定位跳跃
let prevVolumeY = null; // 追踪音量手势的前一个 Y
let speedHintEl = null; // 倍速提示元素(三箭头 + 文字)
// --- 1. 注入核心 CSS ---
const css = ``;
const style = document.createElement('style');
style.textContent = css;
(document.head || document.documentElement).appendChild(style);
// --- 提示框函数 ---
function formatTime(seconds) {
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
}
function getToast(playerArea) {
let div = playerArea.querySelector('#gemini-clean-toast');
if (!div) {
div = document.createElement('div');
div.id = 'gemini-clean-toast';
div.style.cssText = `
position: absolute;
top: 15%; left: 50%; transform: translateX(-50%);
padding: 12px 24px;
background: rgba(0,0,0,0.75);
color: #fff;
border-radius: 8px;
font-size: 20px;
font-family: "Segoe UI", sans-serif; font-weight: 500;
z-index: 100001;
pointer-events: none;
display: none;
backdrop-filter: blur(4px);
text-align: center;
white-space: nowrap;
box-shadow: 0 4px 10px rgba(0,0,0,0.3);
`;
playerArea.appendChild(div);
}
return div;
}
function showToast(playerArea, text) {
const div = getToast(playerArea);
div.innerHTML = '';
div.style.display = 'flex';
div.style.alignItems = 'center';
div.style.gap = '8px';
div.appendChild(document.createTextNode(text));
}
function hideToast(playerArea) {
const div = playerArea.querySelector('#gemini-clean-toast');
if (div) div.style.display = 'none';
}
// --- 音量图标 + 文字 Toast ---
function getVolumeIconSVG() {
return `<svg viewBox="0 0 24 24" fill="currentColor" style="width:100%;height:100%">
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/>
</svg>`;
}
// --- 亮度图标 + 文字 Toast ---
function getBrightnessIconSVG() {
return `<svg viewBox="0 0 24 24" fill="currentColor" style="width:100%;height:100%">
<path d="M20 8.69V4h-4.69L12 .69 8.69 4H4v4.69L.69 12 4 15.31V20h4.69L12 23.31 15.31 20H20v-4.69L23.31 12 20 8.69zM12 18c-3.31 0-6-2.69-6-6s2.69-6 6-6 6 2.69 6 6-2.69 6-6 6z"/>
</svg>`;
}
function showIconToast(playerArea, type, text) {
const div = getToast(playerArea);
div.innerHTML = '';
div.style.display = 'flex';
div.style.alignItems = 'center';
div.style.gap = '8px';
const iconWrap = document.createElement('span');
iconWrap.style.cssText = 'width:20px;height:20px;display:flex;align-items:center;justify-content:center;flex-shrink:0;vertical-align:middle;';
iconWrap.innerHTML = type === 'volume' ? getVolumeIconSVG() : getBrightnessIconSVG();
const txt = document.createTextNode(text);
div.appendChild(iconWrap);
div.appendChild(txt);
}
// --- 倍速提示元素(三箭头闪烁 + 倍速文字),对齐参考脚本 addHint() ---
function createSpeedHint(playerArea) {
if (speedHintEl) return speedHintEl;
speedHintEl = document.createElement('div');
speedHintEl.style.cssText = `
position: absolute;
top: 15%; left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 6px;
padding: 12px 24px;
height: 44px;
box-sizing: border-box;
background: rgba(0, 0, 0, 0.75);
border-radius: 8px;
z-index: 100002;
pointer-events: none;
white-space: nowrap;
backdrop-filter: blur(4px);
box-shadow: 0 4px 10px rgba(0,0,0,0.3);
`;
// 三箭头 SVG(三个三角形依次闪烁)
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('viewBox', '0 0 111 66');
svg.setAttribute('width', '34');
svg.setAttribute('height', '20');
svg.style.overflow = 'visible';
const trianglePath = 'M6.138,3.546 C6.468,4.106 6.278,4.826 5.718,5.156 C5.538,5.266 5.338,5.326 5.118,5.326 C5.118,5.326 -5.122,5.326 -5.122,5.326 C-5.772,5.326 -6.302,4.796 -6.302,4.146 C-6.302,3.936 -6.242,3.726 -6.142,3.546 C-6.142,3.546 -1.352,-4.554 -1.352,-4.554 C-0.912,-5.294 0.048,-5.544 0.798,-5.104 C1.028,-4.974 1.218,-4.784 1.348,-4.554 C1.348,-4.554 6.138,3.546 6.138,3.546z';
const transforms = ['matrix(0,3,-3,0,94.5,32.5)', 'matrix(0,3,-3,0,55.5,32.5)', 'matrix(0,3,-3,0,16.5,32.5)'];
const ids = ['triangle3', 'triangle2', 'triangle1'];
transforms.forEach((tf, i) => {
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
g.setAttribute('transform', tf);
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('d', trianglePath);
path.setAttribute('fill', 'rgb(255,255,255)');
path.setAttribute('class', 'triangle');
path.setAttribute('id', ids[i]);
g.appendChild(path);
svg.appendChild(g);
});
speedHintEl.appendChild(svg);
// 文字
const label = document.createElement('span');
label.textContent = '3.0\u00D7';
label.style.cssText = `
color: #fff;
font-size: 20px;
font-family: "Segoe UI", sans-serif;
font-weight: 500;
line-height: 1;
`;
speedHintEl.appendChild(label);
// CSS 动画(注入到同一元素内)
const animStyle = document.createElement('style');
animStyle.textContent = `
.triangle {
animation: geminiFadeToWhite 1.2s infinite;
}
#triangle1 { animation-delay: 0s; }
#triangle2 { animation-delay: 0.18s; }
#triangle3 { animation-delay: 0.35s; }
@keyframes geminiFadeToWhite {
0% { opacity: 1; filter: brightness(0.3); }
25% { opacity: 1; filter: brightness(0.6); }
50% { opacity: 1; filter: brightness(1); }
75% { opacity: 1; filter: brightness(0.6); }
100% { opacity: 1; filter: brightness(0.3); }
}
`;
speedHintEl.appendChild(animStyle);
playerArea.appendChild(speedHintEl);
return speedHintEl;
}
function showSpeedHint(playerArea) {
const el = createSpeedHint(playerArea);
el.style.display = 'flex';
}
function hideSpeedHint() {
if (speedHintEl) {
speedHintEl.style.display = 'none';
}
}
// --- 控制栏显示/隐藏辅助函数 ---
function getPlayerContainer(playerArea) {
return playerArea.closest('.bpx-player-container') ||
playerArea.closest('#bilibili-player') ||
playerArea;
}
function hideCtrlMenus(playerArea) {
const container = getPlayerContainer(playerArea);
const rightMenus = container.querySelector('.bpx-player-control-bottom-right');
if (!rightMenus) return;
rightMenus.childNodes.forEach(menu => {
if (menu.classList) {
menu.classList.remove('bpx-state-show');
}
});
}
/**
* 控制栏切换(专注模式):单指单击切换控制栏显示/隐藏。
* 通过 MutationObserver 强制同步所有 UI 元素的显示状态。
* @param {Element} playerArea
*/
function handleCtrl(playerArea) {
const container = getPlayerContainer(playerArea);
const isFocus = container.dataset.focusMode === 'true';
if (isFocus) {
// 退出专注模式:显示所有 UI
container.dataset.focusMode = 'false';
container.classList.remove('bpx-state-no-cursor');
container.setAttribute('data-ctrl-hidden', 'false');
const shadow = container.querySelector('.bilibili-player-progress-shadow');
if (shadow) shadow.setAttribute('data-shadow-show', 'false');
// 显示控制栏右侧按钮
const rightMenus = container.querySelector('.bpx-player-control-bottom-right');
if (rightMenus) {
rightMenus.querySelectorAll('[class*="bpx-player-ctrl"]').forEach(el => {
el.classList.remove('bpx-state-hide');
el.classList.add('bpx-state-show');
});
}
} else {
// 进入专注模式:隐藏所有 UI
container.dataset.focusMode = 'true';
container.classList.add('bpx-state-no-cursor');
container.setAttribute('data-ctrl-hidden', 'true');
const shadow = container.querySelector('.bilibili-player-progress-shadow');
if (shadow) shadow.setAttribute('data-shadow-show', 'true');
// 隐藏控制栏右侧按钮
hideCtrlMenus(playerArea);
}
}
/**
* MutationObserver:实时监控 data-focus-mode 属性,
* 当处于专注模式时,强制将所有 UI 元素的隐藏状态同步。
* 这能防止 B站 原生各元素独立计时器导致的显示不一致。
* @param {Element} playerArea
*/
function startFocusModeObserver(playerArea) {
const container = getPlayerContainer(playerArea);
let rafId = null;
function syncFocusMode() {
if (container.dataset.focusMode === 'true') {
// 强制隐藏控制栏右侧按钮
const rightMenus = container.querySelector('.bpx-player-control-bottom-right');
if (rightMenus) {
rightMenus.querySelectorAll('[class*="bpx-player-ctrl"]').forEach(el => {
el.classList.remove('bpx-state-show');
el.classList.add('bpx-state-hide');
});
}
// 强制隐藏控制栏底部
const ctrlBottom = container.querySelector('.bpx-player-control-bottom');
if (ctrlBottom) {
ctrlBottom.classList.remove('bpx-state-show');
ctrlBottom.classList.add('bpx-state-hide');
}
// 强制隐藏视频标题
const titleArea = container.querySelector('.bpx-player-video-info');
if (titleArea) {
titleArea.classList.remove('bpx-state-show');
titleArea.classList.add('bpx-state-hide');
}
}
if (container.dataset.focusMode === 'true') {
rafId = requestAnimationFrame(syncFocusMode);
}
}
const observer = new MutationObserver((mutations) => {
for (const mut of mutations) {
if (mut.type === 'attributes' && mut.attributeName === 'data-focus-mode') {
if (rafId) cancelAnimationFrame(rafId);
rafId = null;
if (container.dataset.focusMode === 'true') {
syncFocusMode();
}
}
}
});
observer.observe(container, { attributes: true });
}
function getCurrentBrightness(video) {
const filter = video.style.filter;
if (!filter || !filter.includes('brightness')) return 100;
const match = filter.match(/brightness\((\d+)%\)/);
return match ? parseInt(match[1], 10) : 100;
}
// ============================================================
// --- 手势处理函数(每种手势独立封装)---
// ============================================================
/**
* 单击手势:切换控制栏显示/隐藏
* @param {Element} playerArea
*/
function onSingleTap(playerArea) {
handleCtrl(playerArea);
}
/**
* 双击手势:仅切换播放/暂停
* @param {HTMLVideoElement} video
* @param {Element} playerArea
*/
function onDoubleTap(video, playerArea) {
if (video.paused) {
video.play();
} else {
video.pause();
}
}
/**
* 长按手势(按下时触发):切换到目标倍速
* @param {HTMLVideoElement} video
* @param {Element} playerArea
*/
function onLongPressStart(video, playerArea) {
originalSpeed = video.playbackRate;
video.playbackRate = TARGET_SPEED;
showSpeedHint(playerArea);
}
/**
* 长按手势(松手时触发):恢复原速
* @param {HTMLVideoElement} video
*/
function onLongPressEnd(video) {
video.playbackRate = originalSpeed;
hideSpeedHint();
}
/**
* 横向滑动手势(进行中):拖动进度
* @param {HTMLVideoElement} video
* @param {Element} playerArea
* @param {number} deltaX - 当前 X 轴位移(px)
*/
function onSeek(video, playerArea, deltaX) {
const seekPercent = deltaX / window.innerWidth;
let targetTime = startVal + video.duration * seekPercent * SEEK_SENSITIVITY;
if (targetTime < 0) targetTime = 0;
if (targetTime > video.duration) targetTime = video.duration;
video.currentTime = targetTime;
showToast(playerArea, `${formatTime(targetTime)} / ${formatTime(video.duration)}`);
}
/**
* 横向滑动手势(开始时):暂停视频并显示控制栏
* @param {HTMLVideoElement} video
* @param {Element} playerArea
*/
function onSeekStart(video, playerArea) {
startVal = video.currentTime;
wasPlaying = !video.paused;
video.pause();
}
/**
* 横向滑动手势(结束时):恢复播放
* @param {HTMLVideoElement} video
* @param {Element} playerArea
*/
function onSeekEnd(video, playerArea) {
if (wasPlaying) video.play();
}
/**
* 左半屏纵向滑动手势:调节亮度
* 每次 move 都更新 prevY,亮度直接累加,对齐参考脚本的简洁逻辑
* @param {HTMLVideoElement} video
* @param {Element} playerArea
* @param {number} clientY - 当前手指 Y 坐标
*/
function onBrightness(video, playerArea, clientY) {
// 第一次 move:初始化 prevY
if (prevBrightnessY === null) {
prevBrightnessY = clientY;
return;
}
// 计算本次移动的 Y 差值(向上滑 clientY 减小,moveDelta 为正)
const moveDelta = prevBrightnessY - clientY;
const sensitivity = window.innerHeight * 0.4;
startVal = startVal + (moveDelta / sensitivity * 100);
if (startVal > 100) startVal = 100;
else if (startVal < 0) startVal = 0;
// 每次 move 后都更新 prevY,保持 delta 始终是相邻两次 move 的差
prevBrightnessY = clientY;
video.style.filter = `brightness(${Math.round(startVal)}%)`;
showIconToast(playerArea, 'brightness', `${Math.round(startVal)}%`);
}
/**
* 左半屏纵向滑动手势(开始时):记录初始亮度
* @param {HTMLVideoElement} video
*/
function onBrightnessStart(video) {
startVal = getCurrentBrightness(video);
prevBrightnessY = null;
}
/**
* 右半屏纵向滑动手势:调节音量
* 每次 move 都更新 prevY,音量直接累加
* @param {HTMLVideoElement} video
* @param {Element} playerArea
* @param {number} clientY - 当前手指 Y 坐标
*/
function onVolume(video, playerArea, clientY) {
// 第一次 move:初始化 prevY
if (prevVolumeY === null) {
prevVolumeY = clientY;
return;
}
// 计算本次移动的 Y 差值(向上滑 clientY 减小,moveDelta 为正)
const moveDelta = prevVolumeY - clientY;
const sensitivity = window.innerHeight * 0.4;
startVal = startVal + (moveDelta / sensitivity);
if (startVal > 1) startVal = 1;
else if (startVal < 0) startVal = 0;
// 每次 move 后都更新 prevY,保持 delta 始终是相邻两次 move 的差
prevVolumeY = clientY;
video.volume = startVal;
showIconToast(playerArea, 'volume', `${Math.round(startVal * 100)}%`);
}
/**
* 右半屏纵向滑动手势(开始时):记录初始音量
* @param {HTMLVideoElement} video
*/
function onVolumeStart(video) {
startVal = video.volume;
prevVolumeY = null;
}
// ============================================================
// --- 事件调度层:识别手势类型并调用对应手势函数 ---
// ============================================================
function handleDown(e, playerArea) {
if (!e.isPrimary || e.button === 2) return;
const video = playerArea.querySelector('video');
if (!video) return;
startX = e.clientX;
startY = e.clientY;
gestureType = '';
isInteracting = false;
originalSpeed = video.playbackRate;
// 启动长按计时器
pressTimer = setTimeout(() => {
if (!isInteracting) {
gestureType = 'speed';
isInteracting = true;
onLongPressStart(video, playerArea);
}
}, PRESS_DELAY);
}
function handleMove(e, playerArea) {
if (!startX) return;
const video = playerArea.querySelector('video');
if (!video) return;
const deltaX = e.clientX - startX;
const deltaY = startY - e.clientY; // 向上为正
const absX = Math.abs(deltaX);
const absY = Math.abs(deltaY);
// 尚未确定手势类型时,判断滑动方向
if (!isInteracting && (absX > 15 || absY > 15)) {
if (pressTimer) {
clearTimeout(pressTimer);
pressTimer = null;
}
isInteracting = true;
if (absX > absY) {
// 横向滑动 → 进度手势
gestureType = 'seek';
onSeekStart(video, playerArea);
} else {
// 纵向滑动 → 左半屏亮度 / 右半屏音量
const screenW = window.innerWidth;
if (startX < screenW / 2) {
gestureType = 'brightness';
onBrightnessStart(video);
} else {
gestureType = 'volume';
onVolumeStart(video);
}
}
}
// 手势已确定,持续更新
if (isInteracting) {
if (gestureType === 'seek') {
onSeek(video, playerArea, deltaX);
} else if (gestureType === 'volume') {
onVolume(video, playerArea, e.clientY);
} else if (gestureType === 'brightness') {
onBrightness(video, playerArea, e.clientY);
}
}
}
function handleUp(e, playerArea) {
if (pressTimer) {
clearTimeout(pressTimer);
pressTimer = null;
}
const video = playerArea.querySelector('video');
if (!video) {
startX = 0;
startY = 0;
return;
}
if (isInteracting) {
// 手势结束收尾
if (gestureType === 'speed') {
onLongPressEnd(video);
} else if (gestureType === 'seek') {
onSeekEnd(video, playerArea);
}
isInteracting = false;
gestureType = '';
setTimeout(() => hideToast(playerArea), 500);
} else {
// 无滑动 → 判断单击 / 双击
if (Math.abs(e.clientX - startX) < 10 && Math.abs(e.clientY - startY) < 10) {
clickCount++;
if (clickCount === 1) {
clickTimer = setTimeout(() => {
// 单击
onSingleTap(playerArea);
clickCount = 0;
clickTimer = null;
}, DOUBLE_TAP_DELAY);
} else if (clickCount === 2) {
if (clickTimer) {
clearTimeout(clickTimer);
clickTimer = null;
}
// 双击
onDoubleTap(video, playerArea);
clickCount = 0;
}
}
}
startX = 0;
startY = 0;
prevBrightnessY = null;
prevVolumeY = null;
}
// ============================================================
// --- 初始化 ---
// ============================================================
function createSafeShield(playerArea) {
if (playerArea.querySelector('#gemini-mobile-shield')) return;
console.log('Gemini: 单击切换显示/隐藏进度条 / 双击播放暂停 版已部署');
const shield = document.createElement('div');
shield.id = 'gemini-mobile-shield';
shield.style.cssText = `
position: absolute; top: 0; left: 0; width: 100%; height: 85%;
z-index: 20; background: transparent;
touch-action: none !important; user-select: none;
`;
playerArea.appendChild(shield);
// 启动专注模式观察器,确保所有 UI 元素同步隐藏/显示
startFocusModeObserver(playerArea);
shield.addEventListener('pointerdown', (e) => handleDown(e, playerArea));
shield.addEventListener('pointermove', (e) => handleMove(e, playerArea));
shield.addEventListener('pointerup', (e) => handleUp(e, playerArea));
shield.addEventListener('pointercancel', (e) => handleUp(e, playerArea));
shield.addEventListener('contextmenu', (e) => { e.preventDefault(); e.stopPropagation(); });
}
function init() {
const targetArea = document.querySelector('.bpx-player-video-area') ||
document.querySelector('.bilibili-player-video-wrap');
if (targetArea) {
createSafeShield(targetArea);
}
}
const observer = new MutationObserver(() => init());
observer.observe(document.body, { childList: true, subtree: true });
window.addEventListener('load', init);
})();