// ==UserScript== // @name 网页视频播放增强(Via优化版) // @namespace https://github.com/video-enhancer // @version 1.5.4 // @description 视频缓存进度记忆、全屏切换、3倍速切换、跳转、重新开始、双击暂停、上下滑全屏(灵敏度优化) // @author Assistant // @match *://*/* // @run-at document-end // @grant none // @license MIT // ==/UserScript== (function() { 'use strict'; // ==================== 用户配置区 ==================== const CONFIG = { // 浮动按钮相关 SCROLL_THRESHOLD: 200, SEEK_STEP: 20, CACHE_PREFIX: 'video_progress_', AUTO_SAVE_INTERVAL: 5000, BUTTON_SIZE: 33, BUTTON_RIGHT: 6, BUTTON_GAP: 22, START_TOP: 66.6, // 手势功能开关 ENABLE_DOUBLE_TAP: true, ENABLE_SWIPE_FULLSCREEN: true, // 手势灵敏度(上滑阈值降低,水平容差放宽) DOUBLE_TAP_DELAY: 300, SWIPE_THRESHOLD: 40, // 触发全屏所需的最小滑动距离(像素) SWIPE_HORIZONTAL_TOLERANCE: 70, // 水平容差,超过此值视为误触 SHOW_SWIPE_HINT: true // 是否显示滑动提示(达到阈值时短暂高亮) }; // ==================== 公共工具函数 ==================== function addStyleOnce(css, id) { if (id && document.getElementById(id)) return; const style = document.createElement('style'); if (id) style.id = id; style.textContent = css; document.head.appendChild(style); return style; } function getActiveVideo() { const videos = document.querySelectorAll('video'); if (videos.length === 0) return null; for (const video of videos) { if (!video.paused && video.readyState >= 2) return video; } for (const video of videos) { if (video.src || video.currentSrc) return video; } for (const video of videos) { const rect = video.getBoundingClientRect(); if (rect.width > 0 && rect.height > 0) return video; } return videos[0]; } function getVideoCacheKey(video) { const src = video.src || video.currentSrc || window.location.href; return CONFIG.CACHE_PREFIX + btoa(encodeURIComponent(src)).replace(/[^a-zA-Z0-9]/g, ''); } function throttle(func, wait) { let timeout = null; let lastRun = 0; return function(...args) { const now = Date.now(); if (now - lastRun >= wait) { lastRun = now; func.apply(this, args); } else if (!timeout) { timeout = setTimeout(() => { lastRun = Date.now(); timeout = null; func.apply(this, args); }, wait - (now - lastRun)); } }; } // ==================== 功能1:视频进度缓存记忆 ==================== const ProgressManager = { currentVideo: null, isRestored: false, saveProgress(video) { if (!video) return; try { const cacheKey = getVideoCacheKey(video); const progress = { currentTime: video.currentTime, duration: video.duration, playbackRate: video.playbackRate, timestamp: Date.now() }; localStorage.setItem(cacheKey, JSON.stringify(progress)); } catch (e) {} }, restoreProgress(video) { if (!video || this.isRestored) return; try { const cacheKey = getVideoCacheKey(video); const saved = localStorage.getItem(cacheKey); if (!saved) return; const progress = JSON.parse(saved); if (Date.now() - progress.timestamp > 7 * 24 * 60 * 60 * 1000) { localStorage.removeItem(cacheKey); return; } const doRestore = () => { if (video.readyState >= 1 && !isNaN(video.duration)) { if (progress.currentTime < video.duration - 30) { video.currentTime = progress.currentTime; } this.isRestored = true; } else { setTimeout(doRestore, 100); } }; doRestore(); } catch (e) {} }, clearProgress(video) { if (!video) return; try { const cacheKey = getVideoCacheKey(video); localStorage.removeItem(cacheKey); } catch (e) {} }, startTracking(video) { if (!video || this.currentVideo === video) return; this.stopTracking(); this.currentVideo = video; this.isRestored = false; this.restoreProgress(video); const throttledSave = throttle(() => this.saveProgress(video), CONFIG.AUTO_SAVE_INTERVAL); video.addEventListener('timeupdate', throttledSave); video.addEventListener('pause', () => this.saveProgress(video)); video.addEventListener('ended', () => this.clearProgress(video)); video._progressHandlers = { throttledSave }; }, stopTracking() { if (!this.currentVideo) return; const video = this.currentVideo; const handlers = video._progressHandlers || {}; if (handlers.throttledSave) { video.removeEventListener('timeupdate', handlers.throttledSave); } video.removeEventListener('pause', () => this.saveProgress(video)); video.removeEventListener('ended', () => this.clearProgress(video)); this.saveProgress(video); this.currentVideo = null; this.isRestored = false; } }; // ==================== 功能2:手势控制(上滑/下滑优化版) ==================== function bindGlobalGestures() { // 注入滑动提示样式(可选) if (CONFIG.SHOW_SWIPE_HINT) { addStyleOnce(` .video-swipe-hint { position: relative; } .video-swipe-hint::after { content: ''; position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 150, 200, 0.3); pointer-events: none; z-index: 999; animation: swipeHintFade 0.2s ease; } @keyframes swipeHintFade { from { opacity: 0; } to { opacity: 1; } } `, 'video-swipe-hint-style'); } // 双击暂停/播放 if (CONFIG.ENABLE_DOUBLE_TAP) { let lastTap = 0; document.addEventListener('touchstart', (e) => { const target = e.target; if (target.tagName !== 'VIDEO') return; const now = Date.now(); if (now - lastTap < CONFIG.DOUBLE_TAP_DELAY) { e.preventDefault(); const video = target; if (video.paused) { video.play(); } else { video.pause(); } } lastTap = now; }, { passive: false }); } // 上滑/下滑全屏 if (CONFIG.ENABLE_SWIPE_FULLSCREEN) { let touchStartY = 0; let touchStartX = 0; let touchTarget = null; let hintTimer = null; const clearHint = () => { if (touchTarget) { touchTarget.classList.remove('video-swipe-hint'); } if (hintTimer) { clearTimeout(hintTimer); hintTimer = null; } }; const showHint = (video) => { if (!CONFIG.SHOW_SWIPE_HINT || !video) return; video.classList.add('video-swipe-hint'); if (hintTimer) clearTimeout(hintTimer); hintTimer = setTimeout(() => { video.classList.remove('video-swipe-hint'); hintTimer = null; }, 300); }; document.addEventListener('touchstart', (e) => { const touch = e.touches[0]; if (!touch) return; const target = e.target; if (target.tagName !== 'VIDEO') return; // 阻止默认滚动(当触摸视频时),避免手势被页面滚动干扰 e.preventDefault(); touchStartY = touch.clientY; touchStartX = touch.clientX; touchTarget = target; }, { passive: false }); document.addEventListener('touchmove', (e) => { if (touchStartY === 0 || !touchTarget) return; const touch = e.touches[0]; if (!touch) return; const deltaY = touchStartY - touch.clientY; const deltaX = Math.abs(touchStartX - touch.clientX); const isFullscreen = document.fullscreenElement || document.webkitFullscreenElement; // 水平偏移过大则放弃手势 if (deltaX > CONFIG.SWIPE_HORIZONTAL_TOLERANCE) { clearHint(); touchStartY = 0; touchStartX = 0; touchTarget = null; return; } // 如果达到阈值,显示视觉反馈 if (Math.abs(deltaY) > CONFIG.SWIPE_THRESHOLD) { showHint(touchTarget); } // 阻止页面滚动(防止触摸移动时页面跟着滚动) e.preventDefault(); }, { passive: false }); document.addEventListener('touchend', (e) => { if (touchStartY === 0 || !touchTarget) { clearHint(); touchStartY = 0; touchStartX = 0; touchTarget = null; return; } const touch = e.changedTouches[0]; if (!touch) { clearHint(); touchStartY = 0; touchStartX = 0; touchTarget = null; return; } const deltaY = touchStartY - touch.clientY; // 正=上滑,负=下滑 const deltaX = Math.abs(touchStartX - touch.clientX); const isFullscreen = document.fullscreenElement || document.webkitFullscreenElement; // 水平偏移过大则忽略 if (deltaX > CONFIG.SWIPE_HORIZONTAL_TOLERANCE) { clearHint(); touchStartY = 0; touchStartX = 0; touchTarget = null; return; } if (isFullscreen) { // 全屏时下滑退出 if (deltaY < -CONFIG.SWIPE_THRESHOLD) { try { if (document.exitFullscreen) { document.exitFullscreen(); } else if (document.webkitExitFullscreen) { document.webkitExitFullscreen(); } } catch (err) {} } } else { // 非全屏时上滑进入 if (deltaY > CONFIG.SWIPE_THRESHOLD) { const video = touchTarget; if (video) { try { video.requestFullscreen().catch(() => { if (video.webkitEnterFullscreen) { video.webkitEnterFullscreen(); } }); } catch (err) {} } } } clearHint(); touchStartY = 0; touchStartX = 0; touchTarget = null; }); document.addEventListener('touchcancel', () => { clearHint(); touchStartY = 0; touchStartX = 0; touchTarget = null; }); } } // ==================== 功能3:浮动按钮 ==================== const BUTTON_IDS = { FULLSCREEN: 'video-enhancer-fullscreen', SPEED: 'video-enhancer-speed', FORWARD: 'video-enhancer-forward', RESTART: 'video-enhancer-restart' }; let currentVideo = null; let buttonsVisible = false; let isSpeed3x = false; function injectStyles() { const { BUTTON_SIZE, BUTTON_RIGHT, BUTTON_GAP, START_TOP } = CONFIG; const buttonPositions = [ { id: BUTTON_IDS.FULLSCREEN, offset: 0 }, { id: BUTTON_IDS.SPEED, offset: 1 }, { id: BUTTON_IDS.FORWARD, offset: 2 }, { id: BUTTON_IDS.RESTART, offset: 3 } ]; let css = ` #${BUTTON_IDS.FULLSCREEN}, #${BUTTON_IDS.SPEED}, #${BUTTON_IDS.FORWARD}, #${BUTTON_IDS.RESTART} { position: fixed; right: ${BUTTON_RIGHT}px; width: ${BUTTON_SIZE}px; height: ${BUTTON_SIZE}px; border-radius: 50%; background-color: rgba(60, 60, 60, 0.5); backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); box-shadow: 0 2px 8px rgba(0,0,0,0.15); display: flex; align-items: center; justify-content: center; cursor: pointer; z-index: 9999; opacity: 0; transform: scale(0.8); transition: opacity 0.25s ease, transform 0.2s ease, background-color 0.2s; pointer-events: none; border: 1px solid rgba(255,255,255,0.2); color: white; font-weight: 600; font-size: 14px; font-family: Arial, sans-serif; } `; buttonPositions.forEach(({ id, offset }) => { css += `#${id} { top: calc(${START_TOP}vh + ${offset * (BUTTON_SIZE + BUTTON_GAP)}px); }\n`; }); css += ` #${BUTTON_IDS.FULLSCREEN}.visible, #${BUTTON_IDS.SPEED}.visible, #${BUTTON_IDS.FORWARD}.visible, #${BUTTON_IDS.RESTART}.visible { opacity: 1; transform: scale(1); pointer-events: auto; } #${BUTTON_IDS.FULLSCREEN}::before { content: '⛶'; font-size: 22px; line-height: 1; text-shadow: 0 1px 2px rgba(0,0,0,0.2); } #${BUTTON_IDS.SPEED}::before { content: '3×'; line-height: 1; text-shadow: 0 1px 2px rgba(0,0,0,0.2); } #${BUTTON_IDS.SPEED}.speed-active { background-color: rgba(0, 150, 200, 0.7) !important; border-color: rgba(100, 200, 255, 0.5) !important; } #${BUTTON_IDS.FORWARD}::before { content: '↷${CONFIG.SEEK_STEP}s'; font-size: 12px; line-height: 1; text-shadow: 0 1px 2px rgba(0,0,0,0.2); } #${BUTTON_IDS.RESTART}::before { content: '↺'; font-size: 20px; line-height: 1; text-shadow: 0 1px 2px rgba(0,0,0,0.2); } #${BUTTON_IDS.FULLSCREEN}:active, #${BUTTON_IDS.SPEED}:active, #${BUTTON_IDS.FORWARD}:active, #${BUTTON_IDS.RESTART}:active { background-color: rgba(80, 80, 80, 0.6); transform: scale(0.9); } @media (prefers-color-scheme: dark) { #${BUTTON_IDS.FULLSCREEN}, #${BUTTON_IDS.SPEED}, #${BUTTON_IDS.FORWARD}, #${BUTTON_IDS.RESTART} { background-color: rgba(220, 220, 220, 0.2); border-color: rgba(255,255,255,0.1); } #${BUTTON_IDS.FULLSCREEN}:active, #${BUTTON_IDS.SPEED}:active, #${BUTTON_IDS.FORWARD}:active, #${BUTTON_IDS.RESTART}:active { background-color: rgba(255, 255, 255, 0.25); } #${BUTTON_IDS.SPEED}.speed-active { background-color: rgba(0, 150, 200, 0.5) !important; } } `; addStyleOnce(css, 'video-enhancer-styles'); } function createButtons() { const buttons = {}; Object.entries(BUTTON_IDS).forEach(([key, id]) => { const btn = document.createElement('div'); btn.id = id; btn.setAttribute('aria-label', key.toLowerCase()); document.body.appendChild(btn); buttons[key] = btn; }); return buttons; } function updateButtonsVisibility() { const video = getActiveVideo(); const shouldShow = video && video.readyState >= 1 && video.duration && !isNaN(video.duration); if (shouldShow !== buttonsVisible) { buttonsVisible = shouldShow; const action = shouldShow ? 'add' : 'remove'; Object.values(BUTTON_IDS).forEach(id => { const btn = document.getElementById(id); if (btn) btn.classList[action]('visible'); }); } if (video && video !== currentVideo) { currentVideo = video; ProgressManager.startTracking(video); isSpeed3x = (video.playbackRate === 3.0); const speedBtn = document.getElementById(BUTTON_IDS.SPEED); if (speedBtn) speedBtn.classList.toggle('speed-active', isSpeed3x); } else if (!video && currentVideo) { ProgressManager.stopTracking(); currentVideo = null; } } // ==================== 功能4:视频操作 ==================== const VideoActions = { toggleFullscreen() { const video = getActiveVideo(); if (!video) { alert('未检测到视频'); return; } try { if (!document.fullscreenElement && !document.webkitFullscreenElement) { video.requestFullscreen().catch(e => { if (video.webkitEnterFullscreen) { video.webkitEnterFullscreen(); } else { alert('该浏览器不支持全屏功能'); } }); } else { if (document.exitFullscreen) { document.exitFullscreen(); } else if (document.webkitExitFullscreen) { document.webkitExitFullscreen(); } } } catch (e) { alert('全屏切换失败'); } }, toggleSpeed() { const video = getActiveVideo(); if (!video) { alert('未检测到视频'); return; } try { const newRate = isSpeed3x ? 1.0 : 3.0; video.playbackRate = newRate; isSpeed3x = !isSpeed3x; const btn = document.getElementById(BUTTON_IDS.SPEED); if (btn) btn.classList.toggle('speed-active', isSpeed3x); } catch (e) { alert('该视频不支持倍速播放'); } }, seekForward() { const video = getActiveVideo(); if (!video) { alert('未检测到视频'); return; } try { video.currentTime = Math.min(video.currentTime + CONFIG.SEEK_STEP, video.duration); ProgressManager.saveProgress(video); } catch (e) { alert('跳转失败'); } }, restart() { const video = getActiveVideo(); if (!video) { alert('未检测到视频'); return; } try { video.currentTime = 0; video.play(); ProgressManager.saveProgress(video); } catch (e) { alert('重新开始失败'); } } }; // ==================== 初始化 ==================== function init() { injectStyles(); const buttons = createButtons(); buttons.FULLSCREEN.addEventListener('click', (e) => { e.stopPropagation(); VideoActions.toggleFullscreen(); }); buttons.SPEED.addEventListener('click', (e) => { e.stopPropagation(); VideoActions.toggleSpeed(); }); buttons.FORWARD.addEventListener('click', (e) => { e.stopPropagation(); VideoActions.seekForward(); }); buttons.RESTART.addEventListener('click', (e) => { e.stopPropagation(); VideoActions.restart(); }); bindGlobalGestures(); let ticking = false; const throttledUpdate = () => { if (!ticking) { requestAnimationFrame(() => { updateButtonsVisibility(); ticking = false; }); ticking = true; } }; window.addEventListener('scroll', throttledUpdate, { passive: true }); document.addEventListener('play', throttledUpdate, true); document.addEventListener('pause', throttledUpdate, true); document.addEventListener('loadedmetadata', throttledUpdate, true); document.addEventListener('fullscreenchange', throttledUpdate); document.addEventListener('webkitfullscreenchange', throttledUpdate); setInterval(updateButtonsVisibility, 2000); setTimeout(updateButtonsVisibility, 500); setTimeout(updateButtonsVisibility, 1500); window.addEventListener('beforeunload', () => { if (currentVideo) ProgressManager.saveProgress(currentVideo); }); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();