Greasy Fork

来自缓存

Greasy Fork is available in English.

全局视频控制栏 - 简洁版

提供悬浮控制栏,支持点击播放暂停、单击快进快退、长按持续加速等操作

// ==UserScript==
// @name         全局视频控制栏 - 简洁版
// @namespace    http://tampermonkey.net/
// @version      3.0
// @description  提供悬浮控制栏,支持点击播放暂停、单击快进快退、长按持续加速等操作
// @author       Your Name
// @match        *://*/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_registerMenuCommand
// @run-at       document-idle
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    // ================================
    // 配置常量
    // ================================
    const CONFIG = {
        // 长按持续加速设置
        HOLD_SEEK_INITIAL_DELAY: 400,
        HOLD_SEEK_REPEAT_INTERVAL: 100,
        HOLD_SEEK_ACCELERATION: 1.5,
        HOLD_SEEK_MAX_SPEED: 20,
        HOLD_SEEK_INITIAL_SPEED: 2,

        // 亮度设置
        BRIGHTNESS_MIN: 0.2,
        BRIGHTNESS_MAX: 2.0,
        BRIGHTNESS_STEP: 0.1,

        // 音量设置
        VOLUME_MIN: 0.0,
        VOLUME_MAX: 1.0,
        VOLUME_STEP: 0.05,

        // 界面设置
        HINT_FADE_DELAY: 1200,
        OVERLAY_CLASS: 'tm-mobile-overlay',
        CONTROL_BAR_CLASS: 'tm-mobile-control-bar',
        CONTROL_BUTTON_CLASS: 'tm-mobile-control-button',
        CONTROL_HINT_CLASS: 'tm-mobile-control-hint',

        // 强制覆盖设置
        FORCE_OVERLAY_ZINDEX: '2147483647'
    };

    const DEFAULT_SETTINGS = Object.freeze({
        enableGestures: true,
        enableHints: true,
        enableHoldSeek: true,
        forceOverlay: true,
        doubleTapStep: 10
    });

    // ================================
    // 工具函数
    // ================================
    const Utils = {
        // 数值限制
        clamp(val, min, max) {
            return Math.min(Math.max(val, min), max);
        },

        // 视频跳转
        seekVideo(video, delta) {
            const target = Math.min(
                Math.max(video.currentTime + delta, 0),
                Number.isFinite(video.duration) ? video.duration : Number.MAX_SAFE_INTEGER
            );
            video.currentTime = target;
            return target;
        },

        // 亮度控制
        getBrightness(video) {
            return Number(video.dataset.tmBrightness || '1');
        },

        setBrightness(video, value) {
            video.dataset.tmBrightness = value.toString();
            this.applyBrightness(video, value);
        },

        applyBrightness(video, value) {
            const base = (video.dataset.tmOriginalFilter || '').trim();
            const parts = [];
            if (base) parts.push(base);
            parts.push(`brightness(${value.toFixed(2)})`);
            video.style.filter = parts.join(' ');
        },

        // 音量控制
        setVolume(video, value) {
            video.volume = this.clamp(value, CONFIG.VOLUME_MIN, CONFIG.VOLUME_MAX);
            if (video.volume > 0 && video.muted) video.muted = false;
        },

        // 获取播放器容器 - 增强版
        getPlayerContainer(video) {
            // 尝试多种选择器来找到播放器容器
            const selectors = [
                'video',
                '.video-player',
                '.player',
                '.video-container',
                '.vp-player',
                '.bpx-player-container',
                '.bilibili-player',
                '.ytp-chrome-bottom',
                '.html5-video-player',
                '[data-player]',
                '[class*="player"]',
                '[class*="video"]',
                'div:has(> video)'
            ];
            
            for (const selector of selectors) {
                const container = video.closest(selector);
                if (container && container !== video && container instanceof HTMLElement) {
                    return container;
                }
            }
            
            // 如果没找到合适的容器,返回视频的直接父元素
            return video.parentElement;
        },

        // 时间格式化
        formatTime(seconds) {
            if (!Number.isFinite(seconds) || seconds < 0) {
                return '--:--';
            }
            const whole = Math.floor(seconds);
            const mins = Math.floor(whole / 60);
            const secs = whole % 60;
            return `${mins}:${secs.toString().padStart(2, '0')}`;
        },

        formatTimeLabel(current, duration) {
            const currentText = this.formatTime(current);
            if (!Number.isFinite(duration) || duration <= 0) {
                return currentText;
            }
            return `${currentText} / ${this.formatTime(duration)}`;
        },

        // 禁用原生控制栏的指针事件
        disableNativeControls(video) {
            // 查找并禁用常见的播放器控制栏
            const controlSelectors = [
                '.ytp-chrome-controls',
                '.bpx-player-control',
                '.control-bar',
                '.video-controls',
                '.player-controls',
                '[class*="control"]'
            ];
            
            controlSelectors.forEach(selector => {
                const controls = video.parentElement?.querySelectorAll(selector);
                controls?.forEach(control => {
                    if (control instanceof HTMLElement) {
                        control.style.pointerEvents = 'none';
                        control.style.zIndex = '1';
                    }
                });
            });
        }
    };

    // ================================
    // 设置管理器
    // ================================
    class SettingsManager {
        constructor() {
            this.settings = {
                enableGestures: GM_getValue('enableGestures', DEFAULT_SETTINGS.enableGestures),
                enableHints: GM_getValue('enableHints', DEFAULT_SETTINGS.enableHints),
                enableHoldSeek: GM_getValue('enableHoldSeek', DEFAULT_SETTINGS.enableHoldSeek),
                forceOverlay: GM_getValue('forceOverlay', DEFAULT_SETTINGS.forceOverlay),
                doubleTapStep: GM_getValue('doubleTapStep', DEFAULT_SETTINGS.doubleTapStep)
            };
        }

        updateSetting(key, value) {
            this.settings[key] = value;
            GM_setValue(key, value);
        }

        registerMenuCommands() {
            GM_registerMenuCommand('📱 开关控制栏', () => {
                this.toggleSetting('enableGestures');
            });
            
            GM_registerMenuCommand('⏩ 开关长按加速', () => {
                this.toggleSetting('enableHoldSeek');
            });

            GM_registerMenuCommand('🛡️ 开关置顶浮层', () => {
                this.toggleSetting('forceOverlay');
            });

            GM_registerMenuCommand('💡 开关提示信息', () => {
                this.toggleSetting('enableHints');
            });

            GM_registerMenuCommand('⏭️ 设置快进快退秒数', () => {
                this.promptNumericSetting({
                    key: 'doubleTapStep',
                    title: '请输入每次快进/快退的秒数 (1 - 120)',
                    min: 1,
                    max: 120,
                    step: 1,
                    decimals: 0,
                    unit: 's'
                });
            });

            GM_registerMenuCommand('♻️ 重置所有设置', () => {
                this.resetSettings();
            });
        }

        toggleSetting(key) {
            const newValue = !this.settings[key];
            this.updateSetting(key, newValue);
            const names = {
                enableGestures: '控制栏',
                enableHoldSeek: '长按加速',
                forceOverlay: '置顶浮层',
                enableHints: '提示信息'
            };
            const label = names[key] || key;
            alert(`${label} ${newValue ? '已开启' : '已关闭'}`);
        }

        promptNumericSetting({ key, title, min, max, step = 1, decimals = 2, unit = '' }) {
            const current = this.settings[key];
            const message = `${title}\n当前值: ${current}${unit}`;
            const input = window.prompt(message, current);
            if (input === null) return;

            let value = Number(input);
            if (!Number.isFinite(value)) {
                alert('请输入有效的数字');
                return;
            }

            if (step > 0) {
                value = Math.round(value / step) * step;
            }

            value = Utils.clamp(value, min, max);
            const formatted = decimals >= 0 ? Number(value.toFixed(decimals)) : value;

            this.updateSetting(key, formatted);
            alert(`${title} 已设置为 ${formatted}${unit}`);
        }

        resetSettings() {
            Object.entries(DEFAULT_SETTINGS).forEach(([key, value]) => {
                this.updateSetting(key, value);
            });
            alert('所有设置已重置为默认值');
        }
    }

    // ================================
    // 长按持续加速管理器
    // ================================
    class HoldSeekManager {
        constructor(settingsManager) {
            this.settingsManager = settingsManager;
            this.repeatTimers = new WeakMap();
            this.holdStates = new WeakMap();
            this.speeds = new WeakMap();
        }

        // 开始长按持续加速
        startHoldSeek(video, direction, onProgress) {
            if (!this.settingsManager.settings.enableHoldSeek) return;
            this.stopHoldSeek(video);

            let speed = CONFIG.HOLD_SEEK_INITIAL_SPEED;
            let lastTime = Date.now();

            const performSeek = () => {
                const now = Date.now();
                const deltaTime = (now - lastTime) / 1000; // 转换为秒
                const seekAmount = direction * speed * deltaTime;
                const target = Utils.seekVideo(video, seekAmount);
                lastTime = now;

                // 持续加速
                speed = Math.min(speed * CONFIG.HOLD_SEEK_ACCELERATION, CONFIG.HOLD_SEEK_MAX_SPEED);
                this.speeds.set(video, speed);

                onProgress(seekAmount, speed, target);
            };

            const intervalId = setInterval(performSeek, CONFIG.HOLD_SEEK_REPEAT_INTERVAL);
            this.repeatTimers.set(video, intervalId);
            this.speeds.set(video, speed);

            console.log(`🎬 开始持续加速: ${direction > 0 ? '快进' : '快退'}, 初始速度: ${speed}x`);
        }

        stopHoldSeek(video) {
            const timer = this.repeatTimers.get(video);
            if (timer) {
                clearInterval(timer);
                this.repeatTimers.delete(video);
                this.speeds.delete(video);
                console.log('🎬 停止持续加速');
            }
        }

        isHolding(video) {
            return this.repeatTimers.has(video);
        }

        getCurrentSpeed(video) {
            return this.speeds.get(video) || 0;
        }
    }

    // ================================
    // 手势管理器
    // ================================
    class GestureManager {
        constructor(holdManager, settingsManager) {
            this.holdManager = holdManager;
            this.settingsManager = settingsManager;
            this.bound = new WeakSet();
        }

        ensure(video) {
            if (this.bound.has(video)) return;
            this.bound.add(video);

            if (!this.settingsManager.settings.enableGestures) return;

            const container = Utils.getPlayerContainer(video);
            let overlayHost = container instanceof HTMLElement ? container : video.parentElement;

            if (!overlayHost) {
                console.warn('未找到合适的覆盖层容器,使用视频元素本身');
                overlayHost = video;
            }

            this.forceContainerStyle(overlayHost);

            const { overlay, hint, controlBar } = this.buildOverlay();

            this.attachOverlay(overlayHost, overlay);
            this.initBrightness(video);
            this.initControls(video, hint, controlBar);

            if (this.settingsManager.settings.forceOverlay) {
                Utils.disableNativeControls(video);
            }

            console.log('🎬 视频控制栏已绑定:', video);
        }

        forceContainerStyle(container) {
            if (!this.settingsManager.settings.forceOverlay) return;

            Object.assign(container.style, {
                position: 'relative',
                zIndex: 'auto'
            });
        }

        buildOverlay() {
            const overlay = document.createElement('div');
            overlay.className = CONFIG.OVERLAY_CLASS;

            Object.assign(overlay.style, {
                position: 'absolute',
                inset: '0',
                zIndex: CONFIG.FORCE_OVERLAY_ZINDEX,
                pointerEvents: 'none'
            });

            const hint = document.createElement('div');
            hint.className = CONFIG.CONTROL_HINT_CLASS;

            Object.assign(hint.style, {
                position: 'absolute',
                top: '12%',
                left: '50%',
                transform: 'translate(-50%, -50%)',
                padding: '10px 16px',
                borderRadius: '16px',
                background: 'rgba(30,30,30,0.75)',
                color: '#fff',
                fontSize: '15px',
                fontWeight: '500',
                boxShadow: '0 4px 14px rgba(0,0,0,0.35)',
                backdropFilter: 'blur(10px)',
                opacity: '0',
                transition: 'opacity 0.2s ease',
                pointerEvents: 'none',
                textAlign: 'center',
                minWidth: '120px',
                whiteSpace: 'pre-line'
            });

            const controlBar = document.createElement('div');
            controlBar.className = CONFIG.CONTROL_BAR_CLASS;

            Object.assign(controlBar.style, {
                position: 'absolute',
                left: '50%',
                bottom: '24px',
                transform: 'translate(-50%, 12px)',
                display: 'flex',
                alignItems: 'center',
                gap: '12px',
                padding: '10px 14px',
                borderRadius: '22px',
                background: 'rgba(18,18,18,0.72)',
                boxShadow: '0 10px 24px rgba(0,0,0,0.35)',
                pointerEvents: 'auto',
                backdropFilter: 'blur(12px)',
                opacity: '0',
                transition: 'opacity 0.25s ease, transform 0.25s ease'
            });

            overlay.append(hint, controlBar);
            return { overlay, hint, controlBar };
        }

        attachOverlay(target, overlay) {
            if (!(target instanceof HTMLElement)) return;

            const existingOverlay = target.querySelector(`.${CONFIG.OVERLAY_CLASS}`);
            if (existingOverlay) {
                existingOverlay.remove();
            }

            const style = getComputedStyle(target);
            if (style.position === 'static') {
                if (!target.dataset.tmGestureOriginalPosition) {
                    target.dataset.tmGestureOriginalPosition = target.style.position || '';
                }
                target.style.position = 'relative';
            }

            target.appendChild(overlay);
        }

        initBrightness(video) {
            if (!video.dataset.tmOriginalFilterCaptured) {
                video.dataset.tmOriginalFilterCaptured = '1';
                video.dataset.tmOriginalFilter = video.style.filter || '';
                if (!video.dataset.tmBrightness) {
                    video.dataset.tmBrightness = '1';
                }
                Utils.applyBrightness(video, Number(video.dataset.tmBrightness));
            }
        }

        makeHintHandler(hint) {
            return (text) => {
                if (!this.settingsManager.settings.enableHints) return;

                hint.textContent = text;
                hint.style.opacity = '1';

                clearTimeout(hint.hideTimer);
                hint.hideTimer = setTimeout(() => {
                    hint.style.opacity = '0';
                }, CONFIG.HINT_FADE_DELAY);
            };
        }

        initControls(video, hint, controlBar) {
            const showHint = this.makeHintHandler(hint);
            controlBar.replaceChildren();

            const visibility = this.createVisibilityController(controlBar);
            visibility.hideImmediate();

            const { backButton, playButton, forwardButton } = this.createControlButtons(video, controlBar, showHint);
            this.updatePlayButtonIcon(playButton, video);

            const focusControls = () => visibility.showTemporarily();
            ['pointerdown', 'pointermove', 'touchstart', 'click', 'keydown'].forEach(evt => {
                video.addEventListener(evt, focusControls, { passive: true });
            });

            const updateIcon = () => this.updatePlayButtonIcon(playButton, video);
            video.addEventListener('play', updateIcon);
            video.addEventListener('pause', updateIcon);
        }

        createVisibilityController(controlBar) {
            let hideTimer = null;
            const SHOW_DURATION = 2500;

            const show = () => {
                controlBar.style.opacity = '1';
                controlBar.style.transform = 'translate(-50%, 0)';
            };

            const hide = () => {
                controlBar.style.opacity = '0';
                controlBar.style.transform = 'translate(-50%, 12px)';
            };

            const hideImmediate = () => {
                clearTimeout(hideTimer);
                controlBar.style.transition = 'none';
                hide();
                requestAnimationFrame(() => {
                    controlBar.style.transition = 'opacity 0.25s ease, transform 0.25s ease';
                });
            };

            const showTemporarily = () => {
                show();
                clearTimeout(hideTimer);
                hideTimer = setTimeout(() => {
                    hide();
                }, SHOW_DURATION);
            };

            controlBar.addEventListener('pointerenter', () => {
                clearTimeout(hideTimer);
                show();
            });

            controlBar.addEventListener('pointerleave', () => {
                hideTimer = setTimeout(() => {
                    hide();
                }, SHOW_DURATION);
            });

            return { showTemporarily, hideImmediate };
        }

        createControlButtons(video, container, showHint) {
            const backButton = this.createControlButton('⏪', '点击后退,按住持续快退');
            const playButton = this.createControlButton(video.paused ? '▶️' : '⏸', '播放 / 暂停');
            const forwardButton = this.createControlButton('⏩', '点击快进,按住持续快进');

            container.append(backButton, playButton, forwardButton);

            this.setupSeekButton(video, backButton, -1, showHint);
            this.setupPlayButton(video, playButton, showHint);
            this.setupSeekButton(video, forwardButton, 1, showHint);

            return { backButton, playButton, forwardButton };
        }

        createControlButton(label, title) {
            const button = document.createElement('button');
            button.type = 'button';
            button.className = CONFIG.CONTROL_BUTTON_CLASS;
            button.textContent = label;
            if (title) button.title = title;

            Object.assign(button.style, {
                border: 'none',
                outline: 'none',
                background: 'rgba(255,255,255,0.08)',
                color: '#fff',
                fontSize: '20px',
                width: '52px',
                height: '52px',
                borderRadius: '26px',
                display: 'flex',
                alignItems: 'center',
                justifyContent: 'center',
                cursor: 'pointer',
                transition: 'background 0.2s ease, transform 0.2s ease',
                userSelect: 'none',
                touchAction: 'manipulation',
                fontFamily: 'inherit'
            });

            return button;
        }

        setButtonPressed(button, pressed) {
            if (!button) return;
            button.style.background = pressed ? 'rgba(255,255,255,0.22)' : 'rgba(255,255,255,0.08)';
            button.style.transform = pressed ? 'scale(0.95)' : 'scale(1)';
        }

        getSeekStep() {
            const configured = this.settingsManager.settings.doubleTapStep;
            if (Number.isFinite(configured) && configured > 0) {
                return configured;
            }
            return DEFAULT_SETTINGS.doubleTapStep;
        }

        setupPlayButton(video, button, showHint) {
            this.setButtonPressed(button, false);

            button.addEventListener('click', (e) => {
                e.preventDefault();
                e.stopPropagation();
                this.togglePlayback(video, showHint);
            });

            const reset = () => this.setButtonPressed(button, false);

            button.addEventListener('pointerdown', (e) => {
                if (e.pointerType === 'mouse' && e.button !== 0) return;
                this.setButtonPressed(button, true);
            });
            button.addEventListener('pointerup', reset);
            button.addEventListener('pointerleave', reset);
            button.addEventListener('pointercancel', reset);
        }

        showPlaybackHint(video, showHint, state) {
            if (!showHint) return;
            const label = Utils.formatTimeLabel(video.currentTime, video.duration);
            if (state === 'play') {
                showHint(`▶️ 播放\n${label}`);
            } else {
                showHint(`⏸ 暂停\n${label}`);
            }
        }

        updatePlayButtonIcon(button, video) {
            if (!button) return;
            button.textContent = video.paused ? '▶️' : '⏸';
        }

        setupSeekButton(video, button, direction, showHint) {
            const state = {
                pointerId: null,
                holdTimer: null,
                holding: false
            };

            const clearHoldTimer = () => {
                if (state.holdTimer) {
                    clearTimeout(state.holdTimer);
                    state.holdTimer = null;
                }
            };

            const stopHold = (withMessage) => {
                if (!state.holding) {
                    this.holdManager.stopHoldSeek(video);
                    return;
                }

                const peak = this.holdManager.getCurrentSpeed(video) || 0;
                this.holdManager.stopHoldSeek(video);
                state.holding = false;

                if (withMessage) {
                    const label = Utils.formatTimeLabel(video.currentTime, video.duration);
                    const suffix = peak > 0 ? `\n最高速度: ${peak.toFixed(1)}x` : '';
                    showHint(`✅ ${direction > 0 ? '快进结束' : '快退结束'}${suffix}\n${label}`);
                }
            };

            const startHold = () => {
                if (!this.settingsManager.settings.enableHoldSeek) return;
                state.holding = true;
                const dirTxt = direction > 0 ? '快进' : '快退';
                showHint(`⏩ ${dirTxt}中...\n${Utils.formatTimeLabel(video.currentTime, video.duration)}`);
                this.holdManager.startHoldSeek(video, direction, (seekAmount, speed, target) => {
                    const label = Utils.formatTimeLabel(target, video.duration);
                    showHint(`⏩ ${dirTxt} ${speed.toFixed(1)}x\n${label}`);
                });
            };

            const finish = (e, canceled) => {
                if (state.pointerId === null || e.pointerId !== state.pointerId) return;

                clearHoldTimer();

                if (!state.holding && !canceled) {
                    const step = this.getSeekStep();
                    const delta = direction * step;
                    const target = Utils.seekVideo(video, delta);
                    const label = Utils.formatTimeLabel(target, video.duration);
                    const icon = direction > 0 ? '▶▶' : '◀◀';
                    const action = direction > 0 ? '快进' : '快退';
                    showHint(`${icon} ${action} ${Math.abs(delta)}s\n${label}`);
                }

                stopHold(!canceled);
                this.setButtonPressed(button, false);

                try {
                    button.releasePointerCapture(e.pointerId);
                } catch (_) {}

                state.pointerId = null;
            };

            button.addEventListener('pointerdown', (e) => {
                if (e.pointerType === 'mouse' && e.button !== 0) return;
                e.preventDefault();
                e.stopPropagation();

                if (state.pointerId !== null) return;

                state.pointerId = e.pointerId;
                this.setButtonPressed(button, true);

                clearHoldTimer();
                if (this.settingsManager.settings.enableHoldSeek) {
                    state.holdTimer = setTimeout(() => {
                        state.holdTimer = null;
                        startHold();
                    }, CONFIG.HOLD_SEEK_INITIAL_DELAY);
                }

                try {
                    button.setPointerCapture(e.pointerId);
                } catch (_) {}
            }, { passive: false });

            button.addEventListener('pointerup', (e) => finish(e, false), { passive: false });
            button.addEventListener('pointercancel', (e) => finish(e, true), { passive: false });
            button.addEventListener('pointerleave', (e) => finish(e, true), { passive: false });
        }

        togglePlayback(video, showHint) {
            if (video.paused) {
                const result = video.play();
                if (result && typeof result.then === 'function') {
                    result.then(() => {
                        this.showPlaybackHint(video, showHint, 'play');
                    }).catch(() => {
                        video.dispatchEvent(new MouseEvent('click', { bubbles: true }));
                    });
                } else {
                    this.showPlaybackHint(video, showHint, 'play');
                }
            } else {
                video.pause();
                this.showPlaybackHint(video, showHint, 'pause');
            }
        }

        scan(root = document) {
            root.querySelectorAll('video').forEach(v => this.ensure(v));
        }
    }

    // ================================
    // 初始化脚本
    // ================================
    function initialize() {
        const settingsManager = new SettingsManager();
        const holdManager = new HoldSeekManager(settingsManager);
        const gestureManager = new GestureManager(holdManager, settingsManager);

        // 注册菜单命令
        settingsManager.registerMenuCommands();

        // 监听DOM变化
        const observer = new MutationObserver(mutations => {
            for (const mutation of mutations) {
                mutation.addedNodes.forEach(node => {
                    if (node.nodeType !== 1) return;
                    if (node.tagName === 'VIDEO') {
                        setTimeout(() => gestureManager.ensure(node), 100);
                    } else {
                        gestureManager.scan(node);
                    }
                });
            }
        });

        observer.observe(document.documentElement, {
            childList: true,
            subtree: true
        });

        // 初始扫描
        setTimeout(() => {
            gestureManager.scan();
            console.log('🎬 全局视频控制栏脚本已加载 - 简洁版 v3.0');
            console.log('📱 功能说明:');
            console.log('   ⏯️ 单击播放键: 播放/暂停');
            console.log('   ⏩ 单击快进/快退: 按设置秒数跳转');
            console.log('   ⏱️ 长按快进/快退: 持续加速跳转');
            console.log('   💡 控制栏提示: 显示当前时间与操作状态');
        }, 1000);
    }

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