// ==UserScript== // @name HTML5视频手势 // @namespace http://tampermonkey.net/ // @version 64.10 // @description 触摸屏浏览器播放器增强手势插件,让浏览器播放器有专业app一样好用。因命名规范化,若更新后出现两个插件的机友删掉旧的插件即可。 // @author Gemini & 仙 // @license MIT // @match *://*/* // @grant GM_addStyle // @grant GM_setClipboard // @grant GM_registerMenuCommand // @grant GM_unregisterMenuCommand // @grant GM_setValue // @grant GM_getValue // @run-at document-start // @downloadURL none // ==/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 seekSec = GM_getValue('gt_seek_sec', 10); let seekMode = GM_getValue('gt_seek_mode', 'sec'); let fpsMode = GM_getValue('gt_fps', 30); 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, 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; let blockGestureUntil = 0; // [通用逻辑核心] 霸体状态锁参数 let enforceStateUntil = 0; let enforceTarget = null; // 'playing' 或 'paused' const hijackFullscreenAPI = () => { const fsMethods = ['requestFullscreen', 'webkitRequestFullscreen', 'mozRequestFullScreen', 'msRequestFullscreen']; fsMethods.forEach(method => { if (Element.prototype[method]) { const originalMethod = Element.prototype[method]; Element.prototype[method] = function(...args) { const promise = originalMethod.apply(this, args); let v = this.tagName === 'VIDEO' ? this : (this.querySelector('video') || document.querySelector('video')); if (v && screen.orientation && screen.orientation.lock) { const dir = v.videoWidth > v.videoHeight ? 'landscape' : 'portrait'; screen.orientation.lock(dir).catch(()=>{}); } return promise; }; } }); }; hijackFullscreenAPI(); const toggleNativeFullscreen = (container, video) => { const isFS = !!(document.fullscreenElement || document.webkitFullscreenElement) || container.classList.contains('gt-fullscreen-active'); if (isFS) { if (document.exitFullscreen) document.exitFullscreen().catch(()=>{}); else if (document.webkitExitFullscreen) document.webkitExitFullscreen(); container.classList.remove('gt-fullscreen-active'); if (screen.orientation?.unlock) screen.orientation.unlock(); } else { container.classList.add('gt-fullscreen-active'); const reqFs = container.requestFullscreen || container.webkitRequestFullscreen || container.mozRequestFullScreen; if (reqFs) { reqFs.call(container).catch(()=>{ if (video.webkitEnterFullscreen) video.webkitEnterFullscreen(); }); } else if (video.webkitEnterFullscreen) { video.webkitEnterFullscreen(); } } }; GM_registerMenuCommand('🔗 拷贝视频源(直链/页面)', () => { if (sniffedUrl) { GM_setClipboard(sniffedUrl); showMsg('已复制嗅探流媒体直链'); return; } let v = targetV || (document.querySelectorAll('video').length > 0 ? Array.from(document.querySelectorAll('video')).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流)'); } }); 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); }; 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 { 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-btn-base 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-seek-mode-btn { top: calc(10px + 38px); left: 10px; } .gt-seek-val-btn { top: calc(10px + 76px); 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-btn-base span, .gt-fullscreen-active .gt-btn-base span { font-size: 14px; } :fullscreen .gt-rotate-btn, .gt-fullscreen-active .gt-rotate-btn { top: 20px; left: 20px; transform: none; } :fullscreen .gt-seek-mode-btn, .gt-fullscreen-active .gt-seek-mode-btn { top: calc(20px + 60px); left: 20px; transform: none; } :fullscreen .gt-seek-val-btn, .gt-fullscreen-active .gt-seek-val-btn { top: calc(20px + 120px); 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%); } `); const SVG_LOCK = ``; const SVG_UNLOCK = ``; const SVG_SPEED = ``; const SVG_ZOOM = ``; const SVG_RESET_ZOOM = ``; const SVG_SEC = ``; const SVG_FRAME = ``; const VIP_SELECTORS = '[data-testid="videoComponent"], .plyr, #html5video, #movie_player, .html5-video-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 getFS = () => document.fullscreenElement || document.webkitFullscreenElement || document.mozFullScreenElement || document.msFullscreenElement; const identify = (e) => { const t = e.target; let targetVideo = null; let rootContainer = null; const vip = findUp(t, VIP_SELECTORS); if (vip) { targetVideo = vip.querySelector('video'); if (!targetVideo && vip.shadowRoot) targetVideo = vip.shadowRoot.querySelector('video'); rootContainer = vip; } if (!targetVideo) { let c = t; for(let i=0; i<8; i++) { if (!c || c === document.body) break; if (c.tagName === 'VIDEO') { targetVideo = c; rootContainer = c.parentNode; break; } const cls = (c.className || '').toString().toLowerCase(); const id = (c.id || '').toString().toLowerCase(); if (c.classList?.contains('gt-video-wrapper') || cls.match(/artplayer|dplayer|plyr/)) { targetVideo = c.shadowRoot ? c.shadowRoot.querySelector('video') : c.querySelector('video'); if (targetVideo) { rootContainer = c; break; } } c = c.parentNode; } } if (!targetVideo) { const videos = document.querySelectorAll('video'); if (videos.length > 0) { targetVideo = Array.from(videos).sort((a,b) => (b.clientWidth*b.clientHeight) - (a.clientWidth*a.clientHeight))[0]; rootContainer = findUp(targetVideo, VIP_SELECTORS) || targetVideo.parentNode; } } if (!targetVideo) return null; if (e.touches && e.touches.length > 0) { const checkBox = rootContainer || targetVideo; const rect = checkBox.getBoundingClientRect(); const touch = e.touches[0]; if (touch.clientX < rect.left - 10 || touch.clientX > rect.right + 10 || touch.clientY < rect.top - 10 || touch.clientY > rect.bottom + 10) { return null; } } return { root: rootContainer, video: targetVideo, isNaked: !rootContainer.classList?.contains('gt-video-wrapper') && !findUp(rootContainer, VIP_SELECTORS) }; }; 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); }; let activeUIEl = null; 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 btnSeekMode = root.querySelector('.gt-seek-mode-btn'); const btnSeekVal = root.querySelector('.gt-seek-val-btn'); const shield = root.querySelector('.gt-lock-shield'); const isFS = !!getFS() || 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(btnSeekMode) btnSeekMode.innerHTML = seekMode === 'sec' ? SVG_SEC : SVG_FRAME; if(btnSeekVal) btnSeekVal.innerHTML = `${seekMode === 'sec' ? seekSec + 's' : fpsMode + 'f'}`; if (state.isScreenLocked) { if(shield) shield.style.display = 'block'; [btnMode, btnRot, btnRst, btnZoomRst, btnSeekMode, btnSeekVal].forEach(b => b?.classList.add('hidden-by-state')); } else { if(shield) shield.style.display = 'none'; [btnMode, btnSeekMode, btnSeekVal].forEach(b => b?.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; if (activeUIEl && activeUIEl !== el) activeUIEl.classList.remove('gt-ui-visible'); activeUIEl = el; el.classList.add('gt-ui-visible'); updateUIState(el, video); if (uiTimer) clearTimeout(uiTimer); uiTimer = setTimeout(() => { if (activeUIEl) activeUIEl.classList.remove('gt-ui-visible'); activeUIEl = null; }, CFG.uiTimeout); }; const hideUI = (el) => { if (!el) return; el.classList.remove('gt-ui-visible'); if (activeUIEl === el) activeUIEl = null; 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 squelch = (e) => { e.stopPropagation(); e.stopImmediatePropagation(); }; ['touchstart', 'mousedown', 'pointerdown', 'contextmenu', 'dblclick'].forEach(evt => { btn.addEventListener(evt, squelch, { capture: true, passive: false }); }); }; 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 = '
'; root.appendChild(bar); } if (!video.dataset.gtTimeupdate) { video.addEventListener('timeupdate', () => { const currentRoot = findUp(video, VIP_SELECTORS) || video.parentNode; const fill = currentRoot ? currentRoot.querySelector('.gt-mini-progress .gt-fill') : null; if (fill && video.duration) fill.style.width = `${(video.currentTime / video.duration) * 100}%`; }); video.dataset.gtTimeupdate = 'true'; } // [通用逻辑:霸体监听器] 为视频绑定一次性的拦截锁,反制任何网站的异步暂停 if (!video.dataset.gtStateLock) { video.addEventListener('pause', () => { if (Date.now() < enforceStateUntil && enforceTarget === 'playing') { video.play().catch(()=>{}); } }); video.addEventListener('play', () => { if (Date.now() < enforceStateUntil && enforceTarget === 'paused') { video.pause(); } }); video.dataset.gtStateLock = 'true'; } 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(getFS() || 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 = ``; bindTap(rBtn, () => { toggleOrientation(); wakeUpUI(root, video); }); root.appendChild(rBtn); const seekModeBtn = document.createElement('div'); seekModeBtn.className = 'gt-btn-base gt-seek-mode-btn'; bindTap(seekModeBtn, () => { seekMode = seekMode === 'sec' ? 'frame' : 'sec'; GM_setValue('gt_seek_mode', seekMode); updateUIState(root, video); showMsg(`已切换为: ${seekMode === 'sec' ? '按秒步进' : '逐帧步进'}`); wakeUpUI(root, video); }); root.appendChild(seekModeBtn); const seekValBtn = document.createElement('div'); seekValBtn.className = 'gt-btn-base gt-seek-val-btn'; bindTap(seekValBtn, () => { if (seekMode === 'sec') { const arr = [10, 15, 30, 1, 5]; seekSec = arr[(arr.indexOf(seekSec) + 1) % arr.length]; GM_setValue('gt_seek_sec', seekSec); showMsg(`双击步进: ${seekSec}s`); } else { fpsMode = fpsMode === 30 ? 60 : 30; GM_setValue('gt_fps', fpsMode); showMsg(`帧率标准: ${fpsMode} FPS`); } updateUIState(root, video); wakeUpUI(root, video); }); root.appendChild(seekValBtn); 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 mBtn = document.createElement('div'); mBtn.className = 'gt-btn-base gt-mode-btn'; bindTap(mBtn, () => { state.pinchMode = state.pinchMode === 'speed' ? 'zoom' : 'speed'; wakeUpUI(root, video); showMsg(state.pinchMode === 'speed' ? '双指模式: 变速' : '双指模式: 缩平移'); }); root.appendChild(mBtn); const rstBtn = document.createElement('div'); rstBtn.className = 'gt-btn-base gt-reset-speed-btn'; rstBtn.innerHTML = `1.0x`; 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 handleAccumulatedSeek = (dir, container, video) => { activeSeekSide = dir; const stepVal = seekMode === 'sec' ? seekSec : (1 / fpsMode); seekAccumulator += stepVal; let displayVal = seekMode === 'sec' ? seekAccumulator : Math.round(seekAccumulator * fpsMode); let unit = seekMode === 'sec' ? 's' : '帧'; video.currentTime = dir === 'left' ? Math.max(0, video.currentTime - stepVal) : Math.min(video.duration || 0, video.currentTime + stepVal); 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 = `
-${displayVal}${unit}`; else t.innerHTML = `+${displayVal}${unit}
`; 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 onStart = (e) => { if (!getFS()) { 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 = !!getFS() || (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(getFS() || 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); const now = Date.now(); // [快照机制] 在手势交互的绝对起点,捕获真实的播放器状态,避免后续被污染 if (!isTouch) { wasPlayingBeforeSequence = targetV ? !targetV.paused : false; } if (e.touches && e.touches.length > 1) { if (e.cancelable) e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); lastTap = 0; // [霸体激活] 双指识别成功,为接下来的 800ms 设置状态锁,免疫哔哩哔哩的异步暂停 enforceTarget = wasPlayingBeforeSequence ? 'playing' : 'paused'; enforceStateUntil = now + 800; if (enforceTarget === 'playing' && targetV.paused) targetV.play().catch(()=>{}); else if (enforceTarget === 'paused' && !targetV.paused) targetV.pause(); } if (lastTap && (now - lastTap < 300)) { blockGestureUntil = now + 500; e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); // [霸体激活] 双击识别成功,锁定 800ms,摧毁第三方播放器的单击逻辑 enforceTarget = wasPlayingBeforeSequence ? 'playing' : 'paused'; enforceStateUntil = now + 800; if (enforceTarget === 'playing' && targetV.paused) targetV.play().catch(()=>{}); else if (enforceTarget === 'paused' && !targetV.paused) targetV.pause(); const touchX = e.touches ? e.touches[0].clientX : e.clientX; const ratio = touchX / window.innerWidth; if (ratio < 0.3) handleAccumulatedSeek('left', targetP, targetV); else if (ratio > 0.7) handleAccumulatedSeek('right', targetP, targetV); else toggleNativeFullscreen(targetP, targetV); lastTap = 0; if (getFS()) hideUI(getFS()); return; } 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 (getFS()) hideUI(getFS()); } else if (e.touches.length === 1 && state.scale === 1.0) { lpTimer = setTimeout(() => { if (isTouch) { action = 'rate'; let finalRate = Math.max(0.1, initRate + CFG.rateBase - 1.0); targetV.playbackRate = finalRate; showMsg(`${finalRate.toFixed(1)}x`); if (getFS()) hideUI(getFS()); } }, CFG.longPress); } }; const onMove = (e) => { if (state.isScreenLocked) { const isBtn = findUp(e.target, '.gt-btn-base'); if (!isBtn) { const isFS = !!getFS() || (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; // [柔性阻断] 只有在我们明确认定当前是一个“插件手势操作”时,才切断原生的行为传递。 // 这完美避免了 64.8 中“手指只要触碰视频就无法滚动网页”的重灾级 bug。 if (action === 'pinch' || action === 'rate' || action === 'seek' || action === 'vol' || action === 'bri') { if (e.cancelable) e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); } 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') { let gestureRate = CFG.rateBase + dx * CFG.senseRate; gestureRate = Math.max(0.1, Math.min(4.0, gestureRate)); let finalRate = Math.max(0.1, initRate + gestureRate - 1.0); targetV.playbackRate = finalRate; showMsg(`${finalRate.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'); // [霸体激活] 滑动手势被确认瞬间,立刻锁定状态,覆盖一切原生响应 enforceTarget = wasPlayingBeforeSequence ? 'playing' : 'paused'; enforceStateUntil = Date.now() + 800; if (enforceTarget === 'playing' && targetV.paused) targetV.play().catch(()=>{}); else if (enforceTarget === 'paused' && !targetV.paused) targetV.pause(); if (getFS()) hideUI(getFS()); } 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 onEnd = (e) => { if (state.isScreenLocked) { const isBtn = findUp(e.target, '.gt-btn-base'); if (!isBtn) { const isFS = !!getFS() || (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) { // 单击唤醒逻辑 lastTap = now; if (!activeSeekSide) wakeUpUI(getFS() || targetP, targetV); } else { // [防遗留触发] 插件接管手势(如滑动、长按倍速)结束后,补充一个短期的捕获拦截护盾, // 用于吞掉浏览器随之产生的原生的 click 或 pointerup 事件,防止站点以此触发暂停等多余行为。 blockGestureUntil = now + 500; } setTimeout(() => { if(targetP && !getFS()) 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); ['pointerdown', 'pointerup', 'pointercancel', 'click', 'dblclick'].forEach(evt => { document.addEventListener(evt, (e) => { if (Date.now() < blockGestureUntil) { const isBtn = findUp(e.target, '.gt-btn-base'); if (!isBtn) { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); } } else if (evt === 'click') { const isFS = !!getFS() || (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(getFS() || targetP, targetV); } } }, { capture: true, passive: false }); }); ['fullscreenchange', 'webkitfullscreenchange', 'mozfullscreenchange', 'MSFullscreenChange'].forEach(evt => { document.addEventListener(evt, () => { const fsEl = getFS(); if (!fsEl) { hideUI(targetP); document.querySelectorAll('.gt-lock-touch').forEach(el => el.classList.remove('gt-lock-touch')); if (screen.orientation?.unlock) screen.orientation.unlock(); } else { setTimeout(() => { let v = targetV || document.querySelector('video'); wakeUpUI(fsEl, v); }, 200); } }); }); })();