Greasy Fork

Greasy Fork is available in English.

Bilibili Surface

单击切换进度条显示/隐藏(显示时5秒后自动隐藏),双击仅播放/暂停,长按倍速(带三箭头闪烁提示)、滑动进度自适应视频时长、左右半屏上下滑亮度/音量

当前为 2026-04-15 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Bilibili Surface
// @namespace    http://tampermonkey.net/
// @version      1.3.9
// @description  单击切换进度条显示/隐藏(显示时5秒后自动隐藏),双击仅播放/暂停,长按倍速(带三箭头闪烁提示)、滑动进度自适应视频时长、左右半屏上下滑亮度/音量
// @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 = 5000;

    let pressTimer = null;
    let clickTimer = null;
    let clickCount = 0;
    let ctrlTimeoutID = null;

    function clearCtrlTimer() {
        if (ctrlTimeoutID) {
            clearTimeout(ctrlTimeoutID);
            ctrlTimeoutID = null;
        }
    }

    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 showCtrl(playerArea) {
        const container = getPlayerContainer(playerArea);
        container.classList.remove('bpx-state-no-cursor');
    }

    function hideCtrl(playerArea) {
        const container = getPlayerContainer(playerArea);
        container.classList.add('bpx-state-no-cursor');
    }

    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');
            }
        });
    }

    /**
     * 控制栏切换:仅操作 bpx-state-no-cursor,计时器用于在隐藏后重新同步状态。
     * @param {Element} playerArea
     */
    function handleCtrl(playerArea) {
        const container = getPlayerContainer(playerArea);
        const isNoCursor = container.classList.contains('bpx-state-no-cursor');

        if (!isNoCursor || ctrlTimeoutID) {
            // 控制栏正在显示 → 隐藏
            if (ctrlTimeoutID) {
                clearTimeout(ctrlTimeoutID);
                ctrlTimeoutID = null;
            }
            hideCtrl(playerArea);
        } else {
            // 控制栏隐藏中 → 显示 + 启动自动隐藏计时器
            showCtrl(playerArea);
            ctrlTimeoutID = setTimeout(() => {
                hideCtrl(playerArea);
                ctrlTimeoutID = null;
            }, CTRL_SHOW_DURATION);
        }
    }

    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();
        clearCtrlTimer();
        showCtrl(playerArea);
    }

    /**
     * 横向滑动手势(结束时):恢复播放并隐藏控制栏
     * @param {HTMLVideoElement} video
     * @param {Element} playerArea
     */
    function onSeekEnd(video, playerArea) {
        if (wasPlaying) video.play();
        hideCtrl(playerArea);
    }

    /**
     * 左半屏纵向滑动手势:调节亮度
     * 每次 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);

        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(); });

        // 触摸底部控制栏区域时,清除自动隐藏计时器(参考脚本逻辑)
        const container = getPlayerContainer(playerArea);
        const ctrlWrap = container.querySelector('.bpx-player-control-bottom');
        if (ctrlWrap) {
            ctrlWrap.addEventListener('pointerdown', () => {
                if (ctrlTimeoutID) {
                    clearTimeout(ctrlTimeoutID);
                    ctrlTimeoutID = null;
                }
            });
        }

        // MutationObserver:监听 bpx-state-no-cursor 的变化,与 B站原生状态保持同步
        const ctrlObserver = new MutationObserver((mutations) => {
            for (const mut of mutations) {
                if (mut.type === 'attributes' && mut.attributeName === 'class') {
                    const hasNoCursor = container.classList.contains('bpx-state-no-cursor');
                    if (hasNoCursor && ctrlTimeoutID) {
                        // B站 隐藏了控制栏 → 清除我们的计时器
                        clearTimeout(ctrlTimeoutID);
                        ctrlTimeoutID = null;
                    }
                }
            }
        });
        ctrlObserver.observe(container, { attributes: true });
    }

    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);

})();