Greasy Fork

Greasy Fork is available in English.

Global Video Swipe + AV + Desktop Gestures Fixed

修复长按快进快退问题,优化手势体验

当前为 2025-10-24 提交的版本,查看 最新版本

// ==UserScript==
// @name         Global Video Swipe + AV + Desktop Gestures Fixed
// @namespace    http://tampermonkey.net/
// @version      1.8
// @description  修复长按快进快退问题,优化手势体验
// @match        *://*/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_registerMenuCommand
// @run-at       document-idle
// @license MIT
// ==/UserScript==

(function() {
    'use strict';

    const CONFIG = {
        SWIPE_PIXELS_PER_SECOND: 8,
        SWIPE_MAX_SECONDS: 60,
        HORIZONTAL_TRIGGER: 15,  // 降低触发阈值
        VERTICAL_TRIGGER: 15,    // 降低触发阈值
        VERTICAL_PIXELS_PER_UNIT: 100,
        BRIGHTNESS_MIN: 0.2,
        BRIGHTNESS_MAX: 2.0,
        BRIGHTNESS_STEP: 0.1,
        VOLUME_MIN: 0.0,
        VOLUME_MAX: 1.0,
        VOLUME_STEP: 0.05,
        DOUBLE_TAP_INTERVAL: 300,
        DOUBLE_TAP_DISTANCE: 140,
        DOUBLE_TAP_STEP: 10,
        HOLD_SEEK_INITIAL_DELAY: 400,  // 缩短初始延迟
        HOLD_SEEK_REPEAT_INTERVAL: 80,  // 加快重复间隔
        HOLD_SEEK_ACCELERATION: 1.3,
        HOLD_SEEK_MAX_SPEED: 15,
        HINT_FADE_DELAY: 1200,
        GESTURE_CLASS: 'tm-mobile-gesture-overlay',
        LOCK_ICON_SIZE: 32,
        LOCK_ICON_POSITION: { top: '16px', right: '16px' },
        SCREEN_LOCK_OVERLAY_COLOR: 'rgba(0, 0, 0, 0.3)',
        SCREEN_LOCK_BORDER_COLOR: 'rgba(255, 255, 255, 0.5)'
    };

    class SettingsManager {
        constructor() {
            this.settings = {
                enableGestures: GM_getValue('enableGestures', true),
                enableScreenLock: GM_getValue('enableScreenLock', true),
                enableShortcuts: GM_getValue('enableShortcuts', true),
                enableHints: GM_getValue('enableHints', true),
                enableHoldSeek: GM_getValue('enableHoldSeek', true),
                defaultScreenLock: GM_getValue('defaultScreenLock', false),
                swipeSensitivity: GM_getValue('swipeSensitivity', 1.0)
            };
        }
        updateSetting(key, value) {
            this.settings[key] = value;
            GM_setValue(key, value);
        }
    }

    class HoldSeekManager {
        constructor(settingsManager) {
            this.settingsManager = settingsManager;
            this.repeatTimers = new WeakMap();
            this.holdStates = new WeakMap();
        }

        startHoldSeek(video, direction, onTick) {
            if (!this.settingsManager.settings.enableHoldSeek) return;
            this.stopHoldSeek(video);

            let speed = 1;
            let iteration = 0;

            const performSeek = () => {
                const step = direction * speed;
                Utils.seekVideo(video, step);
                iteration += 1;
                // 每3次加速一次,让加速更平滑
                if (iteration % 3 === 0) {
                    speed = Math.min(speed * CONFIG.HOLD_SEEK_ACCELERATION, CONFIG.HOLD_SEEK_MAX_SPEED);
                }
                onTick(step, speed);
            };

            const intervalId = setInterval(performSeek, CONFIG.HOLD_SEEK_REPEAT_INTERVAL);
            this.repeatTimers.set(video, intervalId);
            this.holdStates.set(video, { direction, speed, iteration });

            // 立即执行第一次
            performSeek();
        }

        stopHoldSeek(video) {
            const timer = this.repeatTimers.get(video);
            if (timer) {
                clearInterval(timer);
                this.repeatTimers.delete(video);
                this.holdStates.delete(video);
            }
        }

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

    // ScreenLockManager 类保持不变...
    class ScreenLockManager {
        constructor() {
            this.lockedVideos = new WeakSet();
        }

        isLocked(video) {
            return this.lockedVideos.has(video);
        }

        toggleLock(video) {
            if (this.isLocked(video)) {
                this.unlock(video);
            } else {
                this.lock(video);
            }
            return this.isLocked(video);
        }

        lock(video) {
            this.lockedVideos.add(video);
            video.dataset.tmScreenLocked = 'true';
            this.applyLockStyle(video);
        }

        unlock(video) {
            this.lockedVideos.delete(video);
            delete video.dataset.tmScreenLocked;
            this.removeLockStyle(video);
        }

        applyLockStyle(video) {
            const overlay = video.parentElement?.querySelector('.tm-screen-lock-overlay');
            if (overlay) overlay.style.display = 'block';
            video.style.boxShadow = 'inset 0 0 0 3px rgba(255, 100, 100, 0.8)';
            video.style.borderRadius = '4px';
        }

        removeLockStyle(video) {
            const overlay = video.parentElement?.querySelector('.tm-screen-lock-overlay');
            if (overlay) overlay.style.display = 'none';
            video.style.boxShadow = '';
            video.style.borderRadius = '';
        }

        createLockIcon(video, gestureManager) {
            const lockIcon = document.createElement('div');
            lockIcon.className = 'tm-screen-lock-icon';

            Object.assign(lockIcon.style, {
                position: 'absolute',
                top: CONFIG.LOCK_ICON_POSITION.top,
                right: CONFIG.LOCK_ICON_POSITION.right,
                width: `${CONFIG.LOCK_ICON_SIZE}px`,
                height: `${CONFIG.LOCK_ICON_SIZE}px`,
                borderRadius: '8px',
                background: 'rgba(30, 30, 30, 0.8)',
                backdropFilter: 'blur(10px)',
                border: '2px solid rgba(255, 255, 255, 0.6)',
                cursor: 'pointer',
                display: 'flex',
                alignItems: 'center',
                justifyContent: 'center',
                fontSize: '16px',
                color: 'rgba(255, 255, 255, 0.9)',
                transition: 'all 0.3s ease',
                zIndex: '2147483647',
                pointerEvents: 'auto',
                boxShadow: '0 4px 12px rgba(0,0,0,0.3)'
            });

            this.updateLockIconAppearance(lockIcon, this.isLocked(video));

            lockIcon.addEventListener('click', (e) => {
                e.stopPropagation();
                e.preventDefault();
                const locked = this.toggleLock(video);
                this.updateLockIconAppearance(lockIcon, locked);

                const hint = video.parentElement?.querySelector('.tm-gesture-hint');
                if (hint) {
                    const show = gestureManager.makeHintHandler(hint);
                    show(locked ? '🔒 屏幕已锁定' : '🔓 屏幕已解锁');
                }
            });

            return lockIcon;
        }

        createScreenLockOverlay() {
            const overlay = document.createElement('div');
            overlay.className = 'tm-screen-lock-overlay';

            Object.assign(overlay.style, {
                position: 'absolute',
                top: '0',
                left: '0',
                width: '100%',
                height: '100%',
                background: CONFIG.SCREEN_LOCK_OVERLAY_COLOR,
                border: `3px solid ${CONFIG.SCREEN_LOCK_BORDER_COLOR}`,
                borderRadius: '4px',
                pointerEvents: 'none',
                zIndex: '2147483646',
                display: 'none',
                boxSizing: 'border-box'
            });

            return overlay;
        }

        updateLockIconAppearance(lockIcon, locked) {
            if (locked) {
                lockIcon.innerHTML = '🔒';
                lockIcon.style.background = 'rgba(220, 80, 80, 0.9)';
                lockIcon.style.borderColor = 'rgba(255, 150, 150, 0.8)';
            } else {
                lockIcon.innerHTML = '🔓';
                lockIcon.style.background = 'rgba(30, 30, 30, 0.8)';
                lockIcon.style.borderColor = 'rgba(255, 255, 255, 0.6)';
            }
        }
    }

    class GestureManager {
        constructor(lockManager, holdManager, settingsManager) {
            this.lockManager = lockManager;
            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);
            const overlayHost = container instanceof HTMLElement ? container : video.parentElement;
            if (!overlayHost) return;
            let pointerTarget = overlayHost;

            try {
                const hostStyle = getComputedStyle(overlayHost);
                if (hostStyle.pointerEvents === 'none') {
                    pointerTarget = video;
                }
            } catch (_) {
                pointerTarget = video;
            }

            const overlay = this.buildOverlay();
            const hint = overlay.querySelector('.tm-gesture-hint');

            if (this.settingsManager.settings.enableScreenLock) {
                overlay.appendChild(this.lockManager.createLockIcon(video, this));
                overlay.appendChild(this.lockManager.createScreenLockOverlay());
                if (this.settingsManager.settings.defaultScreenLock) {
                    this.lockManager.lock(video);
                }
            }

            this.attachOverlay(overlayHost, overlay);
            this.initBrightness(video);
            this.bindPointer(pointerTarget, video, hint);
            this.bindDoubleClick(pointerTarget, video, hint);

            console.log('🎬 视频手势已绑定:', video);
        }

        buildOverlay() {
            const overlay = document.createElement('div');
            overlay.className = CONFIG.GESTURE_CLASS;
            Object.assign(overlay.style, {
                position: 'absolute',
                inset: '0',
                pointerEvents: 'none',
                zIndex: '2147483647'
            });

            const hint = document.createElement('div');
            hint.className = 'tm-gesture-hint';
            Object.assign(hint.style, {
                position: 'absolute',
                top: '45%',
                left: '50%',
                transform: 'translate(-50%, -50%)',
                padding: '12px 18px',
                borderRadius: '18px',
                background: 'rgba(30,30,30,0.65)',
                color: '#fff',
                fontSize: '16px',
                fontWeight: '500',
                boxShadow: '0 6px 18px rgba(0,0,0,0.35)',
                backdropFilter: 'blur(10px)',
                opacity: '0',
                transition: 'opacity 0.15s ease',
                pointerEvents: 'none',
                textAlign: 'center',
                minWidth: '140px',
                whiteSpace: 'pre-line'
            });

            overlay.appendChild(hint);
            return overlay;
        }

        attachOverlay(target, overlay) {
            if (!(target instanceof HTMLElement)) return;
            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);
            };
        }

        bindPointer(video, hint) {
            const showHint = this.makeHintHandler(hint);
            const state = {
                pointerId: null,
                pointerType: '',
                startX: 0,
                startY: 0,
                deltaX: 0,
                deltaY: 0,
                mode: null,
                isLeftHalf: true,
                startBrightness: 1,
                startVolume: video.volume,
                holdTimer: null,
                holdActive: false,
                holdDirection: 0
            };

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

            const stopHold = (withMessage = true) => {
                if (!state.holdActive) return;
                this.holdManager.stopHoldSeek(video);
                if (withMessage) {
                    const txt = state.holdDirection > 0 ? '快进结束' : '快退结束';
                    showHint(`✅ ${txt}`);
                }
                state.holdActive = false;
                state.holdDirection = 0;
            };

            const startHoldIfPossible = () => {
                if (!this.settingsManager.settings.enableHoldSeek) return;
                if (this.lockManager.isLocked(video)) return;

                const direction = state.isLeftHalf ? -1 : 1;
                state.holdDirection = direction;
                state.holdActive = true;
                state.mode = 'hold';

                const txt = direction > 0 ? '长按快进中...' : '长按快退中...';
                showHint(`⏩ ${txt}`);

                this.holdManager.startHoldSeek(video, direction, (_step, speed) => {
                    const dirTxt = direction > 0 ? '快进' : '快退';
                    const ct = this.formatTime(video.currentTime);
                    showHint(`⏩ ${dirTxt} ${speed.toFixed(1)}x\n${ct}`);
                });
            };

            const onPointerDown = (e) => {
                if (!this.settingsManager.settings.enableGestures) return;
                if (state.pointerId !== null) return;
                if (e.pointerType === 'mouse' && e.button !== 0) return;
                if (e.target.closest('.tm-screen-lock-icon')) return;

                state.pointerId = e.pointerId;
                state.pointerType = e.pointerType;
                state.startX = e.clientX;
                state.startY = e.clientY;
                state.deltaX = 0;
                state.deltaY = 0;
                state.mode = null;
                state.startBrightness = Utils.getBrightness(video);
                state.startVolume = video.volume;
                state.holdActive = false;
                state.holdDirection = 0;

                const rect = video.getBoundingClientRect();
                state.isLeftHalf = e.clientX < rect.left + rect.width / 2;

                // 立即设置长按定时器
                if (this.settingsManager.settings.enableHoldSeek && !this.lockManager.isLocked(video)) {
                    state.holdTimer = setTimeout(() => {
                        state.holdTimer = null;
                        startHoldIfPossible();
                    }, CONFIG.HOLD_SEEK_INITIAL_DELAY);
                }

                try {
                    video.setPointerCapture(e.pointerId);
                } catch (_) {}
            };

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

                state.deltaX = (e.clientX - state.startX) * this.settingsManager.settings.swipeSensitivity;
                state.deltaY = (e.clientY - state.startY) * this.settingsManager.settings.swipeSensitivity;

                // 如果已经在长按状态,直接返回
                if (state.holdActive) {
                    if (state.pointerType === 'touch') e.preventDefault();
                    return;
                }

                // 检查是否移动过大,如果是则取消长按
                if (state.holdTimer) {
                    const moveDist = Math.hypot(state.deltaX, state.deltaY);
                    if (moveDist > 8) { // 降低取消长按的移动阈值
                        clearHoldTimer();
                    }
                }

                // 检测手势模式
                if (!state.mode) {
                    const absX = Math.abs(state.deltaX);
                    const absY = Math.abs(state.deltaY);

                    if (absX >= CONFIG.HORIZONTAL_TRIGGER && absX >= absY) {
                        state.mode = 'seek';
                        clearHoldTimer();
                    } else if (absY >= CONFIG.VERTICAL_TRIGGER) {
                        state.mode = state.isLeftHalf ? 'brightness' : 'volume';
                        clearHoldTimer();
                    }
                }

                // 处理各种手势模式
                if (state.mode) {
                    switch (state.mode) {
                        case 'seek': {
                            const seconds = Utils.clamp(
                                Math.round(state.deltaX / CONFIG.SWIPE_PIXELS_PER_SECOND),
                                -CONFIG.SWIPE_MAX_SECONDS,
                                CONFIG.SWIPE_MAX_SECONDS
                            );
                            const lockTag = this.lockManager.isLocked(video) ? '🔒 ' : '';
                            showHint(`${lockTag}⏩ ${seconds >= 0 ? '+' : ''}${seconds}s`);
                            break;
                        }
                        case 'brightness': {
                            const change = -state.deltaY / CONFIG.VERTICAL_PIXELS_PER_UNIT;
                            const next = Utils.clamp(
                                state.startBrightness + change,
                                CONFIG.BRIGHTNESS_MIN,
                                CONFIG.BRIGHTNESS_MAX
                            );
                            Utils.setBrightness(video, next);
                            const lockTag = this.lockManager.isLocked(video) ? '🔒 ' : '';
                            showHint(`${lockTag}🔆 亮度 ${Math.round(next * 100)}%`);
                            break;
                        }
                        case 'volume': {
                            const change = -state.deltaY / CONFIG.VERTICAL_PIXELS_PER_UNIT;
                            const next = Utils.clamp(
                                state.startVolume + change,
                                CONFIG.VOLUME_MIN,
                                CONFIG.VOLUME_MAX
                            );
                            Utils.setVolume(video, next);
                            const lockTag = this.lockManager.isLocked(video) ? '🔒 ' : '';
                            showHint(`${lockTag}🔊 音量 ${Math.round(next * 100)}%`);
                            break;
                        }
                    }
                }

                if (state.pointerType === 'touch') e.preventDefault();
            };

            const finishGesture = (currentMode) => {
                switch (currentMode) {
                    case 'hold':
                        stopHold();
                        break;
                    case 'seek': {
                        const seconds = Utils.clamp(
                            Math.round(state.deltaX / CONFIG.SWIPE_PIXELS_PER_SECOND),
                            -CONFIG.SWIPE_MAX_SECONDS,
                            CONFIG.SWIPE_MAX_SECONDS
                        );
                        if (seconds !== 0) {
                            Utils.seekVideo(video, seconds);
                            const lockTag = this.lockManager.isLocked(video) ? '🔒 ' : '';
                            showHint(`${lockTag}✅ ${seconds >= 0 ? '快进' : '后退'} ${Math.abs(seconds)}s`);
                        }
                        break;
                    }
                    case 'brightness': {
                        const brightness = Utils.getBrightness(video);
                        const lockTag = this.lockManager.isLocked(video) ? '🔒 ' : '';
                        showHint(`${lockTag}🔆 亮度 ${Math.round(brightness * 100)}%`);
                        break;
                    }
                    case 'volume': {
                        const volume = video.volume;
                        const lockTag = this.lockManager.isLocked(video) ? '🔒 ' : '';
                        showHint(`${lockTag}🔊 音量 ${Math.round(volume * 100)}%`);
                        break;
                    }
                    default:
                        // 无手势模式,可能是点击
                        break;
                }
            };

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

                clearHoldTimer();
                finishGesture(state.mode || (state.holdActive ? 'hold' : null));
                stopHold(false);

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

                state.pointerId = null;
                state.mode = null;
            };

            const onPointerCancel = (e) => {
                if (state.pointerId !== null && e.pointerId === state.pointerId) {
                    clearHoldTimer();
                    stopHold(false);
                    state.pointerId = null;
                    state.mode = null;
                }
            };

            video.addEventListener('pointerdown', onPointerDown, { passive: false });
            video.addEventListener('pointermove', onPointerMove, { passive: false });
            video.addEventListener('pointerup', onPointerUp);
            video.addEventListener('pointercancel', onPointerCancel);
        }

        bindDoubleClick(video, hint) {
            const showHint = this.makeHintHandler(hint);
            video.addEventListener('dblclick', (e) => {
                if (!this.settingsManager.settings.enableGestures) return;
                e.preventDefault();
                e.stopPropagation();
                const rect = video.getBoundingClientRect();
                const isRight = e.clientX > rect.left + rect.width / 2;
                const step = isRight ? CONFIG.DOUBLE_TAP_STEP : -CONFIG.DOUBLE_TAP_STEP;
                Utils.seekVideo(video, step);
                showHint(`${step > 0 ? '▶▶ 快进' : '◀◀ 后退'} ${Math.abs(step)}s`);
            }, true);
        }

        formatTime(seconds) {
            const mins = Math.floor(seconds / 60);
            const secs = Math.floor(seconds % 60);
            return `${mins}:${secs.toString().padStart(2, '0')}`;
        }

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

    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;
        },

        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 = Utils.clamp(value, CONFIG.VOLUME_MIN, CONFIG.VOLUME_MAX);
            if (video.volume > 0 && video.muted) video.muted = false;
        }
    };

    // 初始化
    const settingsManager = new SettingsManager();
    const lockManager = new ScreenLockManager();
    const holdManager = new HoldSeekManager(settingsManager);
    const gestureManager = new GestureManager(lockManager, holdManager, settingsManager);

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

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

    console.log('🎬 视频手势控制脚本已加载 - 长按快进快退功能已优化');
})();