Greasy Fork

Greasy Fork is available in English.

HTML5触摸屏视频手势 (v54.1 终极版)

全捕获防劫持、Layer0物理护盾、触控跃动反馈,引入无感网络层代理,精准嗅探并拷贝 m3u8/mp4 真实流媒体直链。

当前为 2026-03-19 提交的版本,查看 最新版本

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         HTML5触摸屏视频手势 (v54.1 终极版)
// @namespace    http://tampermonkey.net/
// @version      54.1
// @description  全捕获防劫持、Layer0物理护盾、触控跃动反馈,引入无感网络层代理,精准嗅探并拷贝 m3u8/mp4 真实流媒体直链。
// @author       Gemini & 仙
// @license      Copyright 亡仙
// @match        *://*/*
// @grant        GM_addStyle
// @grant        GM_setClipboard
// @grant        GM_registerMenuCommand
// @grant        GM_setValue
// @grant        GM_getValue
// @run-at       document-start
// ==/UserScript==

(function() {
    'use strict';

    const CFG = { 
        minDist: 10, longPress: 500, rateBase: 2.0, senseX: 0.25, senseY: 1.0, 
        progressBarColor: '#FF6699', uiTimeout: 2500, maxScale: 8.0, senseRate: 0.015 
    };
    
    let state = {
        isScreenLocked: false,
        pinchMode: 'speed',
        scale: 1.0, panX: 0, panY: 0
    };

    let startX, startY, initVol, initTime, initRate, targetV, targetP, isTouch = false, action = null, lpTimer = null, toastTimer = null, lastTap = 0, cleanupId = null, uiTimer = null;
    let activeSeekSide = null, seekAccumulator = 0, seekSessionTimer = null, wasPlayingBeforeSequence = false;
    let initPinchDist = 0, initScale = 1.0, initPanX = 0, initPanY = 0, initSpeed = 1.0, initCenterX = 0, initCenterY = 0, originDx = 0, originDy = 0;

    const DEBUG_KEY = 'gt_debug_mode_enabled';
    const isDebug = GM_getValue(DEBUG_KEY, false);
    
    GM_registerMenuCommand(`🛠️ 调试模式: ${isDebug ? '✅ 开启' : '❌ 关闭'}`, () => { GM_setValue(DEBUG_KEY, !isDebug); location.reload(); });
    
    // [无感嗅探重构] 在底层劫持并记录真实流媒体地址
    let sniffedUrl = '';
    const mediaReg = /\.(m3u8|mpd|mp4|webm|flv)(\?|$)/i;
    const sniff = (url) => {
        try {
            if (typeof url === 'string' && mediaReg.test(url) && url.startsWith('http')) {
                sniffedUrl = url; // 永远保存最新抓取到的流媒体切片或播放列表清单
            }
        } catch(e){}
    };
    
    const oFetch = window.fetch;
    window.fetch = function(...args) {
        if (args[0]) sniff(typeof args[0] === 'string' ? args[0] : args[0].url);
        return oFetch.apply(this, args);
    };
    const oOpen = XMLHttpRequest.prototype.open;
    XMLHttpRequest.prototype.open = function(method, url, ...rest) {
        sniff(url);
        return oOpen.call(this, method, url, ...rest);
    };

    // [拷贝闭环] 优先输出网络层截获的直链,最后才回退到DOM解析与网页地址
    GM_registerMenuCommand('🔗 拷贝视频源(直链/页面)', () => {
        if (sniffedUrl) {
            GM_setClipboard(sniffedUrl);
            showMsg('已复制嗅探流媒体直链');
            return;
        }

        let v = targetV;
        if (!v) {
            const videos = document.querySelectorAll('video');
            v = videos.length > 0 ? Array.from(videos).sort((a,b) => (b.clientWidth*b.clientHeight) - (a.clientWidth*a.clientHeight))[0] : null;
        }
        if (!v) { showMsg('未找到视频元素'); return; }
        
        let src = v.src;
        if (!src || src.startsWith('blob:')) {
            const source = v.querySelector('source');
            if (source && source.src) src = source.src;
        }
        
        if (src && !src.startsWith('blob:')) {
            GM_setClipboard(src);
            showMsg('已复制视频直链');
        } else {
            GM_setClipboard(window.location.href);
            showMsg('已复制网页源地址 (Blob流)');
        }
    });
    
    const toggleOrientation = () => {
        if (!screen.orientation) return;
        if (screen.orientation.type.startsWith('landscape')) screen.orientation.lock('portrait').catch(()=>{});
        else screen.orientation.lock('landscape').catch(()=>{});
    };

    GM_addStyle(`
        .dplayer-pause-ad, .dplayer-notice, .dplayer-ad, .artplayer-plugin-ads, .art-ad, .art-notice, .MacPlayer .play-ad, #playleft .pause-ad, .player-ad, .ad-box, .pause-ad, .ad-mask, .pause-html, #pause-html, [class*="pause-html"], [id*="pause-html"] { display: none !important; pointer-events: none !important; opacity: 0 !important; z-index: -2147483648 !important; width: 0 !important; height: 0 !important; }
        .gt-toast { position: fixed; top: 10%; left: 50%; transform: translateX(-50%); background: rgba(0,0,0,0.15); color: #fff; padding: 4px 10px; border-radius: 4px; font: 700 14px system-ui; z-index: 2147483647; pointer-events: none; opacity: 0; transition: opacity 0.2s; text-shadow: 0 0 2px #000; border: 1px solid rgba(255,255,255,0.05); }
        .gt-toast.show { opacity: 1; }

        .gt-seek-msg { position: absolute !important; top: 50% !important; color: rgba(255, 255, 255, 0.95) !important; z-index: 2147483647 !important; pointer-events: none !important; opacity: 0; transition: opacity 0.15s ease-out; display: flex !important; flex-direction: row !important; flex-wrap: nowrap !important; align-items: center !important; justify-content: center !important; gap: 6px !important; font-family: system-ui, -apple-system, sans-serif !important; white-space: nowrap !important; text-shadow: 0 0 10px rgba(0,0,0,0.8), 0 0 4px rgba(0,0,0,0.6), 0 2px 4px rgba(0,0,0,0.5) !important; }
        .gt-seek-msg.left { left: 15%; transform: translateY(-50%); }
        .gt-seek-msg.right { right: 15%; transform: translateY(-50%); }
        .gt-seek-msg.show { opacity: 1; }
        .gt-seek-text { display: block !important; font-size: 15px !important; font-weight: 500 !important; line-height: 1 !important; white-space: nowrap !important; transform-origin: center center !important; -webkit-backface-visibility: hidden !important; backface-visibility: hidden !important; will-change: transform; }
        .gt-arrows { display: flex !important; flex-direction: row !important; flex-wrap: nowrap !important; align-items: center !important; justify-content: center !important; font-size: 22px !important; font-weight: 400 !important; line-height: 1 !important; -webkit-backface-visibility: hidden !important; backface-visibility: hidden !important; }
        .gt-arrows span { display: block !important; line-height: 1 !important; white-space: nowrap !important; }
        
        .gt-pop-anim { animation: gt-pop 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275); }
        @keyframes gt-pop { 0% { transform: scale(1); } 40% { transform: scale(1.35); } 100% { transform: scale(1); } }
        .gt-arrow-slide-r { animation: gt-slide-r 0.6s infinite; }
        @keyframes gt-slide-r { 0% { transform: translateX(-4px); opacity: 0; } 40% { opacity: 1; } 100% { transform: translateX(4px); opacity: 0; } }
        .gt-arrow-slide-l { animation: gt-slide-l 0.6s infinite; }
        @keyframes gt-slide-l { 0% { transform: translateX(4px); opacity: 0; } 40% { opacity: 1; } 100% { transform: translateX(-4px); opacity: 0; } }

        .gt-lock-touch { touch-action: none !important; overscroll-behavior: none !important; }
        :fullscreen { background-color: #000 !important; }
        .gt-video-wrapper { position: relative !important; display: inline-block; line-height: 0; max-width: 100%; overflow: hidden !important; }
        .gt-video-wrapper video { transform-origin: center center; will-change: transform; }

        .gt-mini-progress { position: absolute; bottom: 0; left: 0; width: 100%; height: 2px; background: rgba(255,255,255,0.2); z-index: 2147483640; pointer-events: none; overflow: hidden; opacity: 0.9; transition: height 0.2s, opacity 0.3s; box-shadow: 0 -1px 1px rgba(0,0,0,0.2); }
        .gt-mini-progress .gt-fill { height: 100%; width: 0%; background: ${CFG.progressBarColor}; transition: width 0.1s linear; box-shadow: 0 0 4px ${CFG.progressBarColor}; }
        :fullscreen .gt-mini-progress, .gt-video-wrapper:fullscreen .gt-mini-progress, .plyr--fullscreen-active .gt-mini-progress, .jw-flag-fullscreen .gt-mini-progress, [data-testid="videoComponent"]:fullscreen .gt-mini-progress { height: 3px !important; }

        .gt-lock-shield { position: absolute; top: 0; left: 0; width: 100%; height: 100%; z-index: 2147483645; background: rgba(0,0,0,0); touch-action: none; display: none; }
        :fullscreen .gt-lock-shield, .gt-fullscreen-active .gt-lock-shield { position: fixed !important; top: 0 !important; left: 0 !important; width: 100vw !important; height: 100vh !important; }

        .gt-btn-base {
            position: absolute; width: 26px; height: 26px;
            display: flex; align-items: center; justify-content: center; z-index: 2147483647; 
            opacity: 0; pointer-events: none; 
            transition: opacity 0.3s ease, transform 0.15s ease; border: none; background: transparent;
            color: rgba(255, 255, 255, 0.95); filter: drop-shadow(0px 2px 4px rgba(0,0,0,0.8));
        }
        
        .gt-btn-base * { pointer-events: none !important; }

        .gt-btn-base svg { width: 15px; height: 15px; transition: all 0.2s ease; transform-origin: center center; will-change: transform; }
        .gt-reset-speed-btn span { font-size: 11px; font-weight: 800; font-family: system-ui; letter-spacing: 0.5px; transition: font-size 0.2s; transform-origin: center center; display: inline-block; will-change: transform; }
        
        .gt-ui-visible .gt-btn-base { opacity: 0.65 !important; pointer-events: auto !important; }
        .gt-ui-visible .gt-btn-base.hidden-by-state { display: none !important; pointer-events: none !important; }
        .gt-btn-base:active { opacity: 0.9 !important; color: #fff; }

        .gt-rotate-btn { top: 10px; left: 10px; }
        .gt-lock-btn { top: calc(50% - 38px); right: 10px; transform: translateY(-50%); }
        .gt-mode-btn { top: 50%; right: 10px; transform: translateY(-50%); }
        .gt-reset-zoom-btn { top: calc(50% + 38px); right: 10px; transform: translateY(-50%); }
        .gt-reset-speed-btn { top: 50%; left: 10px; transform: translateY(-50%); }

        :fullscreen .gt-btn-base, .gt-fullscreen-active .gt-btn-base { width: 38px; height: 38px; }
        :fullscreen .gt-ui-visible .gt-btn-base, .gt-fullscreen-active .gt-ui-visible .gt-btn-base { opacity: 0.5 !important; }
        :fullscreen .gt-btn-base svg, .gt-fullscreen-active .gt-btn-base svg { width: 22px; height: 22px; }
        :fullscreen .gt-reset-speed-btn span, .gt-fullscreen-active .gt-reset-speed-btn span { font-size: 14px; }

        :fullscreen .gt-rotate-btn, .gt-fullscreen-active .gt-rotate-btn { top: 20px; left: 20px; transform: none; }
        :fullscreen .gt-lock-btn, .gt-fullscreen-active .gt-lock-btn { top: 50%; right: 20px; transform: translateY(-50%); }
        :fullscreen .gt-mode-btn, .gt-fullscreen-active .gt-mode-btn { top: calc(50% + 60px); right: 20px; transform: translateY(-50%); }
        :fullscreen .gt-reset-zoom-btn, .gt-fullscreen-active .gt-reset-zoom-btn { top: calc(50% + 120px); right: 20px; transform: translateY(-50%); }
        :fullscreen .gt-reset-speed-btn, .gt-fullscreen-active .gt-reset-speed-btn { top: calc(50% + 60px); left: 20px; transform: translateY(-50%); }

        .gt-fullscreen-active#movie_player:fullscreen .html5-video-container { width: 100% !important; height: 100% !important; }
        .gt-fullscreen-active#movie_player:fullscreen video { width: 100% !important; height: 100% !important; object-fit: contain !important; top: 0 !important; left: 0 !important; }
        .gt-fullscreen-active#movie_player:fullscreen .ytp-chrome-bottom { opacity: 1 !important; z-index: 2147483647 !important; }
        :fullscreen.plyr { width: 100vw !important; height: 100vh !important; display: flex !important; flex-direction: column !important; background: #000 !important; position: fixed !important; top: 0 !important; left: 0 !important; margin: 0 !important; }
        :fullscreen.plyr .plyr__video-wrapper { height: 100% !important; width: 100% !important; background: #000 !important; }
        :fullscreen .plyr__controls { z-index: 2147483647 !important; opacity: 1 !important; visibility: visible !important; }
        [data-testid="videoComponent"]:fullscreen { width: 100vw !important; height: 100vh !important; background: #000 !important; margin: 0 !important; display: block !important; }
        [data-testid="videoComponent"]:fullscreen video { width: 100% !important; height: 100% !important; object-fit: contain !important; position: absolute !important; top: 0; left: 0; z-index: 0 !important; }
        [data-testid="videoComponent"]:fullscreen [data-testid^="immersive-video-controls"] { display: flex !important; visibility: visible !important; opacity: 1 !important; z-index: 2147483647 !important; position: absolute !important; bottom: 0 !important; width: 100% !important; pointer-events: auto !important; background: rgba(0,0,0,0.3) !important; }
        #html5video:fullscreen { width: 100vw !important; height: 100vh !important; background: #000 !important; margin: 0 !important; }
        #html5video:fullscreen .buttons-bar, #html5video:fullscreen .progress-bar-container, #html5video:fullscreen .big-buttons { z-index: 2147483647 !important; opacity: 1 !important; visibility: visible !important; }
        .jw-controls, .bpx-player-control-wrap { z-index: 2147483647 !important; }
    `);

    const SVG_LOCK = `<svg viewBox="0 0 24 24" width="100%" height="100%" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path></svg>`;
    const SVG_UNLOCK = `<svg viewBox="0 0 24 24" width="100%" height="100%" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect><path d="M7 11V7a5 5 0 0 1 9.9-1"></path></svg>`;
    const SVG_SPEED = `<svg viewBox="0 0 24 24" width="100%" height="100%" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>`;
    const SVG_ZOOM = `<svg viewBox="0 0 24 24" width="100%" height="100%" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line><line x1="11" y1="8" x2="11" y2="14"></line><line x1="8" y1="11" x2="14" y2="11"></line></svg>`;
    const SVG_RESET_ZOOM = `<svg viewBox="0 0 24 24" width="100%" height="100%" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2" stroke-dasharray="4 2"></rect></svg>`;

    const VIP_SELECTORS = '[data-testid="videoComponent"], .plyr, #html5video, #movie_player, .bpx-player-container, .dplayer, .artplayer-app, .MacPlayer, .ckplayer, #playleft';

    const findUp = (el, selector) => { while (el && el !== document.body) { if (el.matches && el.matches(selector)) return el; el = el.parentNode; } return null; };
    const identify = (e) => {
        const t = e.target;
        const vip = findUp(t, VIP_SELECTORS);
        if (vip) {
            let v = vip.querySelector('video'); if (!v && vip.shadowRoot) v = vip.shadowRoot.querySelector('video');
            if (v) return { root: vip, video: v, isNaked: false };
        }
        let c = t; 
        for(let i=0; i<8; i++) { 
            if (!c || c === document.body) break; 
            const cls = (c.className || '').toString().toLowerCase(); const id = (c.id || '').toString().toLowerCase();
            if (c.classList?.contains('gt-video-wrapper') || c.tagName.includes('-') || cls.match(/player|wrapper|video|maccms/) || id.match(/player|video/)) { 
                const v = c.shadowRoot ? c.shadowRoot.querySelector('video') : c.querySelector('video'); 
                if (v) return { root: c, video: v, isNaked: false }; 
            } 
            c = c.parentNode; 
        }
        if (t.tagName === 'VIDEO') return { root: t.parentNode, video: t, isNaked: true };
        const videos = document.querySelectorAll('video');
        if (videos.length === 1) {
            const v = videos[0]; const isInsideIframe = window !== window.top;
            if (isInsideIframe) { let vVip = findUp(v, VIP_SELECTORS); return { root: vVip || v.parentNode, video: v, isNaked: !vVip }; } 
            else {
                const rect = v.getBoundingClientRect(); const touch = e.touches ? e.touches[0] : e;
                if (touch.clientX >= rect.left && touch.clientX <= rect.right && touch.clientY >= rect.top && touch.clientY <= rect.bottom) {
                    let vVip = findUp(v, VIP_SELECTORS); return { root: vVip || v.parentNode, video: v, isNaked: !vVip };
                }
            }
        }
        return null;
    };

    const exterminateAds = () => { document.querySelectorAll('#pause-html, #player_pause, .pause-html, .MacPlayer .play-ad').forEach(n => { try { n.remove(); } catch(e){} }); };
    setInterval(exterminateAds, 500);

    const showMsg = (txt) => {
        let t = document.getElementById('gt-toast'); if (!t) { t = document.createElement('div'); t.id = 'gt-toast'; t.className = 'gt-toast'; }
        const parent = document.fullscreenElement || document.body; if (t.parentNode !== parent) parent.appendChild(t);
        t.innerText = txt; t.classList.add('show'); clearTimeout(toastTimer); toastTimer = setTimeout(() => t.classList.remove('show'), 800);
    };

    const updateUIState = (root, video) => {
        if(!root) return;
        const btnLock = root.querySelector('.gt-lock-btn');
        const btnMode = root.querySelector('.gt-mode-btn');
        const btnRot = root.querySelector('.gt-rotate-btn');
        const btnRst = root.querySelector('.gt-reset-speed-btn');
        const btnZoomRst = root.querySelector('.gt-reset-zoom-btn');
        const shield = root.querySelector('.gt-lock-shield');
        
        const isFS = !!document.fullscreenElement || root.classList.contains('gt-fullscreen-active');

        if(btnLock) btnLock.innerHTML = state.isScreenLocked ? SVG_LOCK : SVG_UNLOCK;
        if(btnMode) btnMode.innerHTML = state.pinchMode === 'speed' ? SVG_SPEED : SVG_ZOOM;
        
        if (state.isScreenLocked) {
            if(shield) shield.style.display = 'block';
            if(btnMode) btnMode.classList.add('hidden-by-state');
            if(btnRot) btnRot.classList.add('hidden-by-state');
            if(btnRst) btnRst.classList.add('hidden-by-state');
            if(btnZoomRst) btnZoomRst.classList.add('hidden-by-state');
        } else {
            if(shield) shield.style.display = 'none';
            if(btnMode) btnMode.classList.remove('hidden-by-state');
            
            if(btnRot) {
                if(isFS) btnRot.classList.remove('hidden-by-state');
                else btnRot.classList.add('hidden-by-state');
            }

            if(btnRst) {
                if(video && video.playbackRate !== 1.0) btnRst.classList.remove('hidden-by-state');
                else btnRst.classList.add('hidden-by-state');
            }
            if(btnZoomRst) {
                if(state.scale > 1.0) btnZoomRst.classList.remove('hidden-by-state');
                else btnZoomRst.classList.add('hidden-by-state');
            }
        }
    };

    const wakeUpUI = (el, video) => {
        if (!el) return; el.classList.add('gt-ui-visible'); updateUIState(el, video);
        if (uiTimer) clearTimeout(uiTimer);
        uiTimer = setTimeout(() => { el.classList.remove('gt-ui-visible'); }, CFG.uiTimeout);
    };
    const hideUI = (el) => { if (!el) return; el.classList.remove('gt-ui-visible'); if (uiTimer) clearTimeout(uiTimer); };

    const applyTransform = () => {
        if(!targetV) return;
        if (state.scale <= 1.05) { state.scale = 1.0; state.panX = 0; state.panY = 0; } 
        else {
            const maxPanX = (targetV.clientWidth * state.scale - targetV.clientWidth) / 2;
            const maxPanY = (targetV.clientHeight * state.scale - targetV.clientHeight) / 2;
            state.panX = Math.max(-maxPanX, Math.min(maxPanX, state.panX));
            state.panY = Math.max(-maxPanY, Math.min(maxPanY, state.panY));
        }
        targetV.style.transform = `translate(${state.panX}px, ${state.panY}px) scale(${state.scale})`;
    };

    let pendingRate = null; let lastRateUpdateTime = 0;
    const setRateThrottled = (v, rate) => {
        pendingRate = rate; const now = Date.now();
        if (now - lastRateUpdateTime > 150) { v.playbackRate = pendingRate; lastRateUpdateTime = now; pendingRate = null; }
    };

    const bindTap = (btn, handler) => {
        let lastExec = 0;
        const wrap = (e) => {
            e.stopPropagation(); e.stopImmediatePropagation();
            if (e.type === 'touchend' && e.cancelable) e.preventDefault();
            const now = Date.now();
            if (now - lastExec < 300) return; 
            lastExec = now;
            
            handler(e); 
            
            const icon = btn.querySelector('svg') || btn.querySelector('span');
            if (icon) {
                icon.classList.remove('gt-pop-anim');
                void icon.offsetWidth; 
                icon.classList.add('gt-pop-anim');
            }
        };
        btn.addEventListener('touchend', wrap, {passive: false, capture: true});
        btn.addEventListener('click', wrap, {capture: true});
    };

    const ensureUIAndWrapper = (hit) => {
        let { root, video, isNaked } = hit;
        if (isNaked && !root.classList.contains('gt-video-wrapper')) {
            const wrapper = document.createElement('div'); wrapper.className = 'gt-video-wrapper';
            if (video.style.width) wrapper.style.width = video.style.width;
            if (video.getAttribute('width')) wrapper.style.width = video.getAttribute('width');
            video.parentNode.insertBefore(wrapper, video); wrapper.appendChild(video);
            root = wrapper; hit.root = wrapper;
        }
        if (!root.querySelector('.gt-mini-progress')) {
            const bar = document.createElement('div'); bar.className = 'gt-mini-progress'; bar.innerHTML = '<div class="gt-fill"></div>';
            root.appendChild(bar); video.addEventListener('timeupdate', () => { if (video.duration && bar.firstChild) bar.firstChild.style.width = `${(video.currentTime / video.duration) * 100}%`; });
        }
        
        if (!root.querySelector('.gt-lock-shield')) {
            const shield = document.createElement('div');
            shield.className = 'gt-lock-shield';
            const block = (e) => { 
                e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); 
                wakeUpUI(document.fullscreenElement || root, video);
            };
            ['click', 'mousedown', 'mouseup', 'pointerdown', 'pointerup', 'dblclick', 'touchstart', 'touchend'].forEach(evt => shield.addEventListener(evt, block, {capture:true, passive:false}));
            root.appendChild(shield);
        }

        if (!root.querySelector('.gt-lock-btn')) {
            const rBtn = document.createElement('div'); rBtn.className = 'gt-btn-base gt-rotate-btn'; rBtn.innerHTML = `<svg viewBox="0 0 24 24" width="100%" height="100%" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path><polyline points="3 3 3 8 8 8"></polyline></svg>`;
            bindTap(rBtn, () => { toggleOrientation(); wakeUpUI(root, video); }); root.appendChild(rBtn);

            const lBtn = document.createElement('div'); lBtn.className = 'gt-btn-base gt-lock-btn';
            bindTap(lBtn, () => { 
                state.isScreenLocked = !state.isScreenLocked; 
                if (state.isScreenLocked) {
                    const rect = video.getBoundingClientRect();
                    const clk = new MouseEvent('click', { bubbles: true, cancelable: true, clientX: rect.left + rect.width/2, clientY: rect.top + rect.height/2 });
                    video.dispatchEvent(clk);
                }
                wakeUpUI(root, video); 
            }); root.appendChild(lBtn);

            const sBtn = document.createElement('div'); sBtn.className = 'gt-btn-base gt-mode-btn';
            bindTap(sBtn, () => { state.pinchMode = state.pinchMode === 'speed' ? 'zoom' : 'speed'; wakeUpUI(root, video); showMsg(state.pinchMode === 'speed' ? '双指模式: 变速' : '双指模式: 缩放平移'); }); root.appendChild(sBtn);

            const rstBtn = document.createElement('div'); rstBtn.className = 'gt-btn-base gt-reset-speed-btn'; rstBtn.innerHTML = `<span>1.0x</span>`;
            bindTap(rstBtn, () => { video.playbackRate = 1.0; showMsg('已恢复原速'); wakeUpUI(root, video); }); root.appendChild(rstBtn);

            const zoomRstBtn = document.createElement('div'); zoomRstBtn.className = 'gt-btn-base gt-reset-zoom-btn'; zoomRstBtn.innerHTML = SVG_RESET_ZOOM;
            bindTap(zoomRstBtn, () => { 
                state.scale = 1.0; state.panX = 0; state.panY = 0;
                if (video) video.style.transform = `translate(0px, 0px) scale(1)`; 
                showMsg('已恢复原大小'); wakeUpUI(root, video); 
            }); root.appendChild(zoomRstBtn);
        }
        
        const style = window.getComputedStyle(root);
        if (style.position === 'static') root.style.position = 'relative';
        return root;
    };

    const getPinchData = (touches) => {
        const dx = touches[0].clientX - touches[1].clientX, dy = touches[0].clientY - touches[1].clientY;
        return { dist: Math.hypot(dx, dy), cx: (touches[0].clientX + touches[1].clientX) / 2, cy: (touches[0].clientY + touches[1].clientY) / 2 };
    };

    const onStart = (e) => {
        if (!document.fullscreenElement) { document.querySelectorAll('.plyr--fullscreen-active, .jw-flag-fullscreen, .gt-fullscreen-active, .gt-ui-visible').forEach(el => { el.classList.remove('plyr--fullscreen-active', 'jw-flag-fullscreen', 'gt-fullscreen-active', 'gt-ui-visible'); el.style.cssText = ''; }); }
        
        const isBtn = findUp(e.target, '.gt-btn-base');
        const isFS = !!document.fullscreenElement || (targetP && targetP.classList.contains('gt-fullscreen-active'));
        
        if (state.isScreenLocked && !isBtn) {
            if (isFS || (targetP && targetP.contains(e.target))) {
                if (e.cancelable) e.preventDefault(); 
                e.stopPropagation(); e.stopImmediatePropagation();
                if (targetP && targetV) wakeUpUI(document.fullscreenElement || targetP, targetV); 
                lastTap = Date.now(); 
                return;
            }
        }
        
        if (isBtn) { 
            clearTimeout(lpTimer); 
            return; 
        }

        let hit = identify(e); if (!hit || !hit.video) return;
        targetP = ensureUIAndWrapper(hit); targetV = hit.video;

        clearTimeout(lpTimer);

        if (Date.now() - lastTap > 300 || !lastTap) wasPlayingBeforeSequence = !targetV.paused;
        
        targetP.classList.add('gt-lock-touch'); targetV.classList.add('gt-lock-touch');
        isTouch = true; action = null; startX = e.touches[0].clientX; startY = e.touches[0].clientY;
        initVol = targetV.volume; initTime = targetV.currentTime; initRate = targetV.playbackRate;

        if (e.touches.length === 2) {
            const pData = getPinchData(e.touches);
            initPinchDist = pData.dist; initCenterX = pData.cx; initCenterY = pData.cy;
            initScale = state.scale; initPanX = state.panX; initPanY = state.panY; initSpeed = targetV.playbackRate;
            
            const rect = targetV.getBoundingClientRect();
            const layoutCenterX = rect.left + rect.width / 2 - initPanX;
            const layoutCenterY = rect.top + rect.height / 2 - initPanY;
            originDx = initCenterX - layoutCenterX;
            originDy = initCenterY - layoutCenterY;

            action = 'pinch'; if (document.fullscreenElement) hideUI(document.fullscreenElement);
        } else if (e.touches.length === 1 && state.scale === 1.0) {
            lpTimer = setTimeout(() => { if (isTouch) { action = 'rate'; targetV.playbackRate = CFG.rateBase; showMsg(`${CFG.rateBase}x`); if (document.fullscreenElement) hideUI(document.fullscreenElement); } }, CFG.longPress);
        }
    };

    const onMove = (e) => {
        if (state.isScreenLocked) {
            const isBtn = findUp(e.target, '.gt-btn-base');
            if (!isBtn) {
                const isFS = !!document.fullscreenElement || (targetP && targetP.classList.contains('gt-fullscreen-active'));
                if (isFS || (targetP && targetP.contains(e.target))) {
                    if (e.cancelable) e.preventDefault();
                    e.stopPropagation(); e.stopImmediatePropagation();
                }
            }
            return;
        }

        if (!isTouch || !targetV) return;
        if (e.cancelable) e.preventDefault(); 
        
        if (action === 'pinch' && e.touches.length === 2) {
            const pData = getPinchData(e.touches);
            if (state.pinchMode === 'zoom') {
                let newScale = initScale * (pData.dist / initPinchDist);
                state.scale = Math.max(1.0, Math.min(CFG.maxScale, newScale));
                
                if (state.scale > 1.0) { 
                    const deltaS = state.scale / initScale;
                    state.panX = (pData.cx - initCenterX) + initPanX * deltaS + originDx * (1 - deltaS);
                    state.panY = (pData.cy - initCenterY) + initPanY * deltaS + originDy * (1 - deltaS);
                } else { state.panX = 0; state.panY = 0; }
                applyTransform();
            } else {
                let rawSpeed = initSpeed + ((pData.dist - initPinchDist) * 0.005);
                let finalSpeed;
                if (rawSpeed > 0.85 && rawSpeed < 1.15) finalSpeed = 1.0;
                else if (rawSpeed >= 1.15) finalSpeed = 1.0 + (rawSpeed - 1.15);
                else finalSpeed = 1.0 - (0.85 - rawSpeed);
                
                finalSpeed = Math.max(0.1, Math.min(4.0, finalSpeed));
                setRateThrottled(targetV, finalSpeed);
                showMsg(finalSpeed === 1.0 ? '1.0x (原速)' : `${finalSpeed.toFixed(2)}x`);
            }
            return;
        }

        if (action === 'pinch' || action === 'pinch_wait') return; 

        const dx = e.touches[0].clientX - startX, dy = startY - e.touches[0].clientY;

        if (action === 'rate') { targetV.playbackRate = Math.max(0.1, Math.min(4.0, CFG.rateBase + dx * CFG.senseRate)); showMsg(`${targetV.playbackRate.toFixed(1)}x`); return; }
        
        if (!action) { 
            if (Math.abs(dx) > CFG.minDist || Math.abs(dy) > CFG.minDist) { 
                clearTimeout(lpTimer); 
                action = Math.abs(dx) > Math.abs(dy) ? 'seek' : (startX < innerWidth/2 ? 'bri' : 'vol'); 
                if (document.fullscreenElement) hideUI(document.fullscreenElement);
            } else return; 
        }

        if (action === 'seek') { targetV.currentTime = Math.max(0, Math.min(targetV.duration||0, initTime + dx * CFG.senseX)); showMsg(`${Math.floor(targetV.currentTime/60)}:${(Math.floor(targetV.currentTime%60)+'').padStart(2,'0')}`); }
        else if (action === 'vol') { targetV.volume = Math.max(0, Math.min(1, initVol + dy/innerHeight * 2 * CFG.senseY)); showMsg(`Vol: ${Math.round(targetV.volume*100)}%`); }
        else if (action === 'bri') { let b = Math.max(0.1, Math.min(2.0, 1 + dy/innerHeight * 2 * CFG.senseY)); targetV.style.filter = `brightness(${b})`; showMsg(`Bri: ${Math.round(b*100)}%`); }
    };

    const handleAccumulatedSeek = (dir, container, video) => {
        activeSeekSide = dir; seekAccumulator += 10;
        video.currentTime = dir === 'left' ? Math.max(0, video.currentTime - 10) : Math.min(video.duration || 0, video.currentTime + 10);
        if (wasPlayingBeforeSequence && video.paused) video.play().catch(()=>{});

        const oppDir = dir === 'left' ? 'right' : 'left';
        let opp = container.querySelector('#gt-seek-' + oppDir); if (opp) opp.classList.remove('show');
        let t = container.querySelector('#gt-seek-' + dir);
        if (!t) { t = document.createElement('div'); t.id = 'gt-seek-' + dir; t.className = `gt-seek-msg ${dir}`; container.appendChild(t); }
        
        if (dir === 'left') t.innerHTML = `<div class="gt-arrows"><span>‹</span><span class="gt-arrow-slide-l">‹</span></div><span class="gt-seek-text gt-pop-anim">-${seekAccumulator}s</span>`;
        else t.innerHTML = `<span class="gt-seek-text gt-pop-anim">+${seekAccumulator}s</span><div class="gt-arrows"><span class="gt-arrow-slide-r">›</span><span>›</span></div>`;
        t.classList.add('show');

        clearTimeout(seekSessionTimer);
        seekSessionTimer = setTimeout(() => {
            t.classList.remove('show'); activeSeekSide = null; seekAccumulator = 0;
            setTimeout(() => { if (t && t.parentNode && !t.classList.contains('show')) t.innerHTML = ''; }, 200);
        }, 800); 
    };

    const onEnd = (e) => {
        if (state.isScreenLocked) {
            const isBtn = findUp(e.target, '.gt-btn-base');
            if (!isBtn) {
                const isFS = !!document.fullscreenElement || (targetP && targetP.classList.contains('gt-fullscreen-active'));
                if (isFS || (targetP && targetP.contains(e.target))) {
                    e.stopPropagation(); e.stopImmediatePropagation();
                }
            }
            isTouch = false;
            return;
        }
        
        if (!isTouch) return;
        
        if (e.touches.length > 0) { if (action === 'pinch') action = 'pinch_wait'; return; }
        
        if (pendingRate !== null && targetV) { targetV.playbackRate = pendingRate; pendingRate = null; }

        clearTimeout(lpTimer); 
        if (action === 'rate' && targetV) { targetV.playbackRate = initRate; showMsg(''); wakeUpUI(targetP, targetV); }
        if ((action === 'pinch' || action === 'pinch_wait' || action === 'pan') && targetV) { wakeUpUI(targetP, targetV); }
        
        const now = Date.now(); 
        if (!action) { 
            const touchX = e.changedTouches ? e.changedTouches[0].clientX : startX;
            const ratio = touchX / window.innerWidth;

            if (activeSeekSide) {
                if (e.cancelable) e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation();
                if (activeSeekSide === 'left' && ratio < 0.4) { handleAccumulatedSeek('left', targetP, targetV); return; } 
                else if (activeSeekSide === 'right' && ratio > 0.6) { handleAccumulatedSeek('right', targetP, targetV); return; }
                activeSeekSide = null; seekAccumulator = 0;
                let t = targetP.querySelector('.gt-seek-msg'); if (t) t.classList.remove('show');
            }

            if (now - lastTap < 300 && targetP && state.scale === 1.0) { 
                if (e.cancelable) e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation();
                if (ratio < 0.3) handleAccumulatedSeek('left', targetP, targetV); 
                else if (ratio > 0.7) handleAccumulatedSeek('right', targetP, targetV); 
                else {
                    if (document.fullscreenElement) document.exitFullscreen(); 
                    else { targetP.classList.add('gt-fullscreen-active'); targetP.requestFullscreen().catch(err => { if (targetV.webkitEnterFullscreen) targetV.webkitEnterFullscreen(); }); }
                }
                lastTap = 0; if (document.fullscreenElement) hideUI(document.fullscreenElement);
            } else { 
                lastTap = now; if (!activeSeekSide) wakeUpUI(document.fullscreenElement || targetP, targetV);
            } 
        }

        setTimeout(() => { if(targetP && !document.fullscreenElement) targetP.classList.remove('gt-lock-touch'); if(targetV) targetV.classList.remove('gt-lock-touch'); }, 100);
        isTouch = false; targetV = null; action = null;
    };

    const pOpt = { passive: false, capture: true }; 
    document.addEventListener('touchstart', onStart, pOpt); 
    document.addEventListener('touchmove', onMove, pOpt); 
    document.addEventListener('touchend', onEnd, pOpt);
    document.addEventListener('touchcancel', onEnd, pOpt);
    
    document.addEventListener('click', (e) => { 
        const isFS = !!document.fullscreenElement || (targetP && targetP.classList.contains('gt-fullscreen-active'));
        const isLockedTarget = state.isScreenLocked && !findUp(e.target, '.gt-btn-base') && (isFS || (targetP && targetP.contains(e.target)));
        if (activeSeekSide || isLockedTarget) { 
            e.stopPropagation(); e.stopImmediatePropagation(); e.preventDefault(); 
            if (isLockedTarget && targetP && targetV) wakeUpUI(document.fullscreenElement || targetP, targetV);
        } 
    }, { capture: true });
    
    document.addEventListener('fullscreenchange', () => { 
        const fsEl = document.fullscreenElement;
        if (!fsEl) { 
            hideUI(fsEl);
            document.querySelectorAll('.gt-lock-touch').forEach(el => el.classList.remove('gt-lock-touch'));
            if (screen.orientation?.unlock) screen.orientation.unlock(); 
        } else { 
            setTimeout(() => {
                const v = targetV;
                if (v && screen.orientation?.lock) { screen.orientation.lock(v.videoWidth > v.videoHeight ? 'landscape' : 'portrait').catch(()=>{}); }
                wakeUpUI(fsEl, v);
            }, 200);
        } 
    });
})();