// ==UserScript== // @name 网页视频接管 // @namespace https://github.com/cxf35/Video-Takeover // @version 1.28.8 // @description 视频接管:点击播放后自动接管视频,完整控制界面,支持字幕、部分网站支持进度画面预览及清晰度调节、倍速、画中画、全屏等功能。 // @author Video Takeover // @icon data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJnIiB4MT0iMCUiIHkxPSIwJSIgeDI9IjEwMCUiIHkyPSIxMDAlIj48c3RvcCBvZmZzZXQ9IjAlIiBzdHlsZT0ic3RvcC1jb2xvcjojZmY3ZWIzIi8+PHN0b3Agb2Zmc2V0PSIxMDAlIiBzdHlsZT0ic3RvcC1jb2xvcjojZmYyZTYzIi8+PC9saW5lYXJHcmFkaWVudD48L2RlZnM+PHJlY3Qgd2lkdGg9IjY0IiBoZWlnaHQ9IjY0IiByeD0iMTIiIGZpbGw9InVybCgjZykiLz48cGF0aCBkPSJNMjAgMTZWMzZMMzIgMjZaIiBmaWxsPSIjZmZmIiBvcGFjaXR5PSIwLjkiLz48cGF0aCBkPSJNMjAgNDJINTJWNDZIMjBaIiBmaWxsPSIjZmZmIiBvcGFjaXR5PSIwLjciLz48cGF0aCBkPSJNMjAgNTBINTVWNTRIMjBaIiBmaWxsPSIjZmZmIiBvcGFjaXR5PSIwLjUiLz48L3N2Zz4= // @match *://*/* // @grant GM_addStyle // @grant GM_xmlhttpRequest // @connect api-shoulei-ssl.xunlei.com // @connect subtitle.geilijiasu.com // @connect www.subtitlecat.com // @connect * // @run-at document-idle // @license GPL-3.0 // ==/UserScript== (function () { 'use strict'; // ==================== 配置 ==================== const CONFIG = { // 长按快进速度倍率 longPressSpeed: 3, // 双击步进秒数 doubleTapSeek: 10, // 自动隐藏控制栏延迟(毫秒) controlsAutoHideDelay: 3000, // 倍速选项 playbackRates: [0.2, 0.5, 1.0, 1.5, 2.0, 3.0, 4.0], // 进度条常驻(控制栏隐藏后保留细条) progressAlwaysOn: true, }; // 设置存储键与读写(localStorage 记忆) const STORAGE_KEYS = { doubleTapSeek: 'vt_doubletap_seek', progressAlwaysOn: 'vt_progress_always_on', }; function loadSetting(key, def) { try { const v = localStorage.getItem(key); return v === null ? def : JSON.parse(v); } catch (e) { return def; } } function saveSetting(key, val) { try { localStorage.setItem(key, JSON.stringify(val)); } catch (e) {} } // 加载已保存设置 CONFIG.doubleTapSeek = loadSetting(STORAGE_KEYS.doubleTapSeek, CONFIG.doubleTapSeek); CONFIG.progressAlwaysOn = loadSetting(STORAGE_KEYS.progressAlwaysOn, CONFIG.progressAlwaysOn); // ==================== 全局状态 ==================== const state = { // 当前追踪的视频元素 currentVideo: null, // 视频原始父元素(用于退出全屏时恢复) originalParent: null, originalNextSibling: null, originalStyles: {}, // 全屏接管容器 takeoverContainer: null, // 是否处于接管全屏模式 isTakeoverActive: false, // 是否锁定屏幕 isLocked: false, // 用户是否主动暂停(用于防意外暂停判断) userPaused: false, // 全屏模式: fit(适应) | crop(裁剪) | stretch(拉伸) fullscreenMode: 'fit', // 屏幕方向: landscape(横屏) | portrait(竖屏) orientation: 'landscape', // 旋转模式: 'native'(原生方向锁) | 'css'(CSS旋转) rotationMode: 'native', // 是否使用CSS旋转(仅CSS模式下竖屏时为true,用于坐标转换判断) useCssRotation: false, // 控制栏是否显示 controlsVisible: true, // 自动隐藏定时器 autoHideTimer: null, // 键盘步进监听(退出接管时移除) takeoverKeydownHandler: null, takeoverKeyupHandler: null, // 键盘长按快进状态(下键按住) kbLongPressActive: false, kbOriginalRate: null, kbCurrentRate: 3, // 长按状态 longPressTimer: null, isLongPressing: false, longPressDirection: null, // 'forward' | 'backward' }; // ==================== 工具函数 ==================== function formatTime(seconds) { if (!isFinite(seconds) || seconds < 0) return '0:00'; const m = Math.floor(seconds / 60); const s = Math.floor(seconds % 60); return `${m}:${s.toString().padStart(2, '0')}`; } function clamp(value, min, max) { return Math.min(Math.max(value, min), max); } // 音量感知曲线:位置(0~1)线性映射到 -40dB~0dB,再转振幅,听感接近系统音量 function volumeFromPosition(pos) { const p = clamp(pos, 0, 1); return Math.pow(10, -40 * (1 - p) / 20); // p=0 → 0.01, p=1 → 1 } // ==================== 视频检测 ==================== const VideoDetector = { init() { this.scanVideos(); this.observeDOM(); setInterval(() => this.scanVideos(), 2000); }, scanVideos() { const videos = document.querySelectorAll('video'); videos.forEach((video) => { if (!video.dataset.vtProcessed) { video.dataset.vtProcessed = 'true'; this.setupVideo(video); } }); }, setupVideo(video) { // 跳过明显没有内容的视频(无src且readyState=0) const hasSource = video.src || video.currentSrc || (video.querySelector('source') && video.querySelector('source').src); if (!hasSource && video.readyState === 0) { // 监听 src 变化,等有内容了再处理(不打vtProcessed标记,scanVideos还会继续扫) const onSourceChange = () => { if (video.src || video.currentSrc) { video.removeEventListener('loadedmetadata', onSourceChange); video.removeEventListener('play', onSourceChange); video.dataset.vtProcessed = 'true'; this.setupVideo(video); } }; video.addEventListener('loadedmetadata', onSourceChange, { once: true }); video.addEventListener('play', onSourceChange, { once: true }); return; } video.dataset.vtProcessed = 'true'; video.addEventListener('play', () => { // 桌面端:视频开始播放时自动接管 if (!state.isTakeoverActive) { TakeoverPlayer.enter(video); } }); }, observeDOM() { const observer = new MutationObserver(() => { this.scanVideos(); }); observer.observe(document.body, { childList: true, subtree: true }); }, }; // ==================== 视频右下角悬浮按钮 ==================== const FloatingButtons = { // 存储 video -> buttonContainer 的映射 buttonMap: new WeakMap(), attach(video) { if (this.buttonMap.has(video)) return; const container = document.createElement('div'); container.className = 'vt-float-buttons'; container.innerHTML = ` `; // 将按钮插入到 video 的父容器中,定位在 video 右下角 const parent = video.parentElement; if (!parent) return; // 确保父元素是定位元素 const parentStyle = getComputedStyle(parent); if (parentStyle.position === 'static') { parent.style.position = 'relative'; } parent.appendChild(container); this.buttonMap.set(video, container); // 绑定事件 container.querySelectorAll('.vt-float-btn').forEach((btn) => { btn.addEventListener('click', (e) => { e.stopPropagation(); e.preventDefault(); const action = btn.dataset.action; this.handleAction(action, video); }); }); // 鼠标/触摸进入视频区域时显示按钮 const showButtons = () => { container.style.opacity = '1'; container.style.pointerEvents = 'auto'; }; const hideButtons = () => { container.style.opacity = '0'; container.style.pointerEvents = 'none'; }; parent.addEventListener('mouseenter', showButtons); parent.addEventListener('mouseleave', hideButtons); parent.addEventListener('touchstart', showButtons, { passive: true }); // 初始显示 showButtons(); // 3秒后自动隐藏 let hideTimer; const scheduleHide = () => { clearTimeout(hideTimer); hideTimer = setTimeout(hideButtons, 3000); }; parent.addEventListener('touchstart', scheduleHide, { passive: true }); parent.addEventListener('mousemove', scheduleHide); scheduleHide(); }, handleAction(action, video) { switch (action) { case 'cast': this.doCast(video); break; case 'pip': this.doPiP(video); break; case 'fullscreen': TakeoverPlayer.enter(video); break; } }, doCast(video) { // 只使用 Remote Playback API(视频投屏的标准API) if (video.remote && typeof video.remote.prompt === 'function') { video.remote.prompt().catch((err) => { console.warn('[VT] 投屏失败:', err.name, err.message); // 常见错误分类 switch (err.name) { case 'NotAllowedError': // 用户取消或设备选择框自动关闭 break; case 'NotFoundError': this.showToast('未找到投屏设备'); break; case 'NotSupportedError': this.showToast('当前视频不支持投屏'); break; default: this.showToast('投屏不可用'); } }); return; } // 不支持投屏 this.showToast('当前浏览器不支持投屏'); }, doPiP(video) { // 防止重复点击 if (this._pipBusy) return; // 已经在画中画 → 退出 if (document.pictureInPictureElement) { this._pipBusy = true; document.exitPictureInPicture() .catch(() => {}) .finally(() => { this._pipBusy = false; }); return; } // 浏览器不支持 if (!document.pictureInPictureEnabled || typeof video.requestPictureInPicture !== 'function') { this.showToast('当前浏览器不支持画中画'); return; } // 移除禁用属性(某些网站会设置) try { video.removeAttribute('disablepictureinpicture'); video.removeAttribute('disablePictureInPicture'); video.disablePictureInPicture = false; } catch (e) {} // 调用标准 PiP this._pipBusy = true; video.requestPictureInPicture() .then(() => { // PiP 进入成功 // 监听退出事件,清理状态 const onLeave = () => { video.removeEventListener('leavepictureinpicture', onLeave); this._pipBusy = false; }; video.addEventListener('leavepictureinpicture', onLeave, { once: true }); }) .catch((err) => { this._pipBusy = false; console.warn('[VT] 画中画失败:', err.name, err.message); switch (err.name) { case 'NotAllowedError': this.showToast('请先播放视频后再试'); break; case 'NotSupportedError': this.showToast('当前视频不支持画中画'); break; case 'InvalidStateError': this.showToast('视频未就绪'); break; default: this.showToast('画中画启动失败'); } }); }, showForVideo(video) { const container = this.buttonMap.get(video); if (container) { container.style.opacity = '1'; container.style.pointerEvents = 'auto'; } }, showToast(message) { const toast = document.createElement('div'); toast.className = 'vt-toast'; toast.textContent = message; document.body.appendChild(toast); setTimeout(() => { toast.classList.add('vt-toast-show'); }, 10); setTimeout(() => { toast.classList.remove('vt-toast-show'); setTimeout(() => toast.remove(), 300); }, 2000); }, }; // ==================== 全屏接管播放器 ==================== const TakeoverPlayer = { enter(video) { state.currentVideo = video; state.isTakeoverActive = true; state.isLocked = false; state.fullscreenMode = 'fit'; // 保存原始位置信息 state.originalParent = video.parentElement; state.originalNextSibling = video.nextElementSibling; state.originalStyles = { style: video.getAttribute('style') || '', class: video.className, }; // 创建接管容器 const container = document.createElement('div'); container.className = 'vt-takeover-container'; container.id = 'vt-takeover-container'; state.takeoverContainer = container; // 构建播放器UI container.innerHTML = `
${document.title || location.href}
0:00
0:00
`; // 桌面端:将接管容器添加到视频原父容器 const parent = video.parentElement; // 如果是 plyr 播放器,把接管容器插入到 plyr 主容器里,这样接管界面会铺满整个 plyr 容器,挡住控制栏 // 不用隐藏任何元素,进度预览功能不受影响 let containerParent = parent; const plyrContainer = parent ? parent.closest('.plyr') : null; if (plyrContainer) { containerParent = plyrContainer; } if (containerParent) { const parentStyle = getComputedStyle(containerParent); if (parentStyle.position === 'static') { containerParent.style.position = 'relative'; } containerParent.appendChild(container); } else { document.body.appendChild(container); } // 将视频移动到接管容器 const wrapper = container.querySelector('.vt-takeover-video-wrapper'); wrapper.appendChild(video); // 应用视频样式 this.applyVideoStyle(video); // 绑定事件 this.bindEvents(container, video); // 更新UI状态 this.updatePlayState(video); this.updateTime(video); this.updateSpeedLabel(video); // 初始化字幕管理器 SubtitleManager.init(); // 如果已有字幕数据,恢复渲染 if (SubtitleManager.state.cues.length) { SubtitleManager.renderLoop(); } // 自动隐藏控制栏 this.resetAutoHide(); // 添加视频事件监听 this.addVideoListeners(video); // 防意外暂停保护:暂时关掉,避免影响手动暂停 // const wasPlaying = !video.paused; // let antiPauseCount = 0; // const antiPauseMax = 3; // 最多恢复3次,防止无限循环 // state._antiPauseHandler = () => { // if (state.isTakeoverActive && antiPauseCount < antiPauseMax && !state.userPaused) { // antiPauseCount++; // video.play().catch(() => {}); // } // }; // video.addEventListener('pause', state._antiPauseHandler); // setTimeout(() => { // if (video && state._antiPauseHandler) { // video.removeEventListener('pause', state._antiPauseHandler); // state._antiPauseHandler = null; // } // }, 3000); // 如果进来时就在播放,确保继续播放(防意外暂停已停用,此处不再自动恢复) if (video.paused) { video.play().catch(() => {}); } }, exit() { const video = state.currentVideo; const container = state.takeoverContainer; if (!video || !container) return; // 清理视频事件监听 this.removeVideoListeners(video); // 移除键盘步进监听 if (state.takeoverKeydownHandler) { document.removeEventListener('keydown', state.takeoverKeydownHandler, true); state.takeoverKeydownHandler = null; } if (state.takeoverKeyupHandler) { document.removeEventListener('keyup', state.takeoverKeyupHandler, true); state.takeoverKeyupHandler = null; } // 键盘长按快进未释放时,恢复原倍速 if (state.kbLongPressActive && state.kbOriginalRate !== null) { video.playbackRate = state.kbOriginalRate; } state.kbLongPressActive = false; state.kbOriginalRate = null; // 恢复视频原始样式 video.setAttribute('style', state.originalStyles.style); video.className = state.originalStyles.class; video.style.objectFit = ''; // 将视频放回原位置 if (state.originalParent) { if (state.originalNextSibling) { state.originalParent.insertBefore(video, state.originalNextSibling); } else { state.originalParent.appendChild(video); } } // 退出全屏 if (document.fullscreenElement) { document.exitFullscreen().catch(() => { }); } // 解锁屏幕方向 if (screen.orientation && screen.orientation.unlock) { screen.orientation.unlock(); } // 清理字幕 SubtitleManager.cleanup(); // 移除容器 container.remove(); // 清除自动隐藏定时器 if (state.autoHideTimer) { clearTimeout(state.autoHideTimer); state.autoHideTimer = null; } // 清理长按状态 this.cancelLongPress(); // 重置状态 state.isTakeoverActive = false; state.currentVideo = null; state.takeoverContainer = null; state.isLocked = false; state.userPaused = false; state.controlsVisible = true; state.orientation = 'landscape'; state.useCssRotation = false; // 清理防暂停保护 if (state._antiPauseHandler && video) { video.removeEventListener('pause', state._antiPauseHandler); state._antiPauseHandler = null; } }, applyVideoStyle(video) { video.style.width = '100%'; video.style.height = '100%'; video.style.objectFit = 'contain'; // 默认适应 video.style.display = 'block'; video.style.margin = '0 auto'; video.style.position = 'absolute'; video.style.top = '50%'; video.style.left = '50%'; video.style.transform = 'translate(-50%, -50%)'; video.style.maxWidth = '100%'; video.style.maxHeight = '100%'; video.style.pointerEvents = 'none'; // 点击穿透,避免触发video默认的单击播放/暂停、双击全屏 }, bindEvents(container, video) { // 顶部返回按钮 container.querySelector('.vt-back-btn').addEventListener('click', () => { if (state.isLocked) return; this.exit(); }); // 中央播放按钮 container.querySelector('.vt-play-btn').addEventListener('click', (e) => { e.stopPropagation(); if (state.isLocked) return; this.togglePlay(video); }); // 清晰度按钮:暂时只显示自动,后面再适配 container.querySelector('.vt-quality-btn').addEventListener('click', (e) => { e.stopPropagation(); }); // 音量按钮:点击弹出调节条,调节条显示时再点击就是静音/取消静音 const volumeBtn = container.querySelector('.vt-volume-btn'); const volumeSlider = volumeBtn.querySelector('.vt-volume-slider'); const volumeFill = volumeBtn.querySelector('.vt-volume-fill'); const volumeIcon = volumeBtn.querySelector('.vt-icon-volume'); // 初始化音量条 const updateVolumeUI = () => { const vol = video.muted ? 0 : video.volume; volumeFill.style.height = (vol * 100) + '%'; if (video.muted || video.volume === 0) { volumeIcon.innerHTML = ''; } else { volumeIcon.innerHTML = ''; } }; updateVolumeUI(); // 点击音量按钮:切换调节条显示,调节条显示时点击就是静音 volumeBtn.addEventListener('click', (e) => { e.stopPropagation(); if (volumeSlider.style.display === 'none') { volumeSlider.style.display = 'block'; } else { video.muted = !video.muted; updateVolumeUI(); } }); // 点击其他地方的时候,隐藏调节条 container.addEventListener('click', (e) => { if (!e.target.closest('.vt-volume-btn')) { volumeSlider.style.display = 'none'; } if (!e.target.closest('.vt-settings-btn')) { settingsMenu.style.display = 'none'; } }); // 设置菜单 const settingsBtn = container.querySelector('.vt-settings-btn'); const settingsMenu = settingsBtn.querySelector('.vt-settings-menu'); settingsBtn.addEventListener('click', (e) => { e.stopPropagation(); settingsMenu.style.display = settingsMenu.style.display === 'none' ? 'flex' : 'none'; }); // 进度条常驻开关 const progressToggle = settingsMenu.querySelector('[data-setting="progressAlwaysOn"]'); progressToggle.addEventListener('click', (e) => { e.stopPropagation(); CONFIG.progressAlwaysOn = !CONFIG.progressAlwaysOn; saveSetting(STORAGE_KEYS.progressAlwaysOn, CONFIG.progressAlwaysOn); progressToggle.querySelector('.vt-settings-toggle').textContent = CONFIG.progressAlwaysOn ? '开' : '关'; }); // 双击步进选项 settingsMenu.querySelectorAll('.vt-settings-option').forEach((opt) => { opt.addEventListener('click', (e) => { e.stopPropagation(); CONFIG.doubleTapSeek = parseInt(opt.dataset.value, 10); saveSetting(STORAGE_KEYS.doubleTapSeek, CONFIG.doubleTapSeek); settingsMenu.querySelectorAll('.vt-settings-option').forEach((o) => o.classList.remove('vt-settings-active')); opt.classList.add('vt-settings-active'); }); }); // 拖动调节音量 let isDraggingVolume = false; const setVolumeFromEvent = (e) => { const rect = volumeSlider.getBoundingClientRect(); const y = Math.min(Math.max(e.clientY, rect.top), rect.bottom); const pos = 1 - (y - rect.top) / rect.height; // 感知曲线:位置线性映射到 -40dB~0dB,听感均匀 video.volume = volumeFromPosition(pos); video.muted = false; updateVolumeUI(); this.resetAutoHide(); }; volumeSlider.addEventListener('mousedown', (e) => { isDraggingVolume = true; this.resetAutoHide(); setVolumeFromEvent(e); e.preventDefault(); e.stopPropagation(); }); volumeSlider.addEventListener('click', (e) => { e.stopPropagation(); }); container.addEventListener('mousemove', (e) => { if (isDraggingVolume) { setVolumeFromEvent(e); e.preventDefault(); e.stopPropagation(); } }); container.addEventListener('mouseup', (e) => { if (isDraggingVolume) { isDraggingVolume = false; this.resetAutoHide(); e.preventDefault(); e.stopPropagation(); } }); // 键盘长按快进(下键按住 = 倍数播放,默认3x;按住时左右键调整倍数大小) const kbUpdateRateDisplay = (rate) => { let indicator = container.querySelector('.vt-longpress-indicator'); if (!indicator) { indicator = document.createElement('div'); indicator.className = 'vt-longpress-indicator'; container.appendChild(indicator); } const rateFixed = rate.toFixed(1); indicator.innerHTML = `
${rateFixed}x
左右键调节速度
`; indicator.style.display = 'flex'; indicator.style.flexDirection = 'column'; indicator.style.gap = '6px'; }; const kbStartLongPress = () => { if (state.kbLongPressActive) return; state.kbLongPressActive = true; state.kbOriginalRate = video.playbackRate; state.kbCurrentRate = CONFIG.longPressSpeed; if (video.paused) { video.play().catch(() => { }); } video.playbackRate = state.kbCurrentRate; kbUpdateRateDisplay(state.kbCurrentRate); }; const kbEndLongPress = () => { if (!state.kbLongPressActive) return; state.kbLongPressActive = false; if (state.kbOriginalRate !== null) { video.playbackRate = state.kbOriginalRate; state.kbOriginalRate = null; } const indicator = container.querySelector('.vt-longpress-indicator'); if (indicator) indicator.style.display = 'none'; }; // 键盘左右箭头步进/步退 + 下键长按快进(仅接管期间生效) // 用捕获阶段 + stopPropagation,阻止事件继续传到网站原生播放器(plyr 左右箭头也有步进),避免双重步进 const onTakeoverKeydown = (e) => { if (e.key === 'ArrowDown') { e.preventDefault(); e.stopPropagation(); kbStartLongPress(); } else if (state.kbLongPressActive && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) { // 长按快进中:左右键调整倍数 e.preventDefault(); e.stopPropagation(); const dir = e.key === 'ArrowRight' ? 1 : -1; state.kbCurrentRate = clamp(state.kbCurrentRate + dir * 0.5, 0.2, 6.0); state.kbCurrentRate = Math.round(state.kbCurrentRate * 10) / 10; video.playbackRate = state.kbCurrentRate; kbUpdateRateDisplay(state.kbCurrentRate); } else if (e.key === 'ArrowLeft') { e.preventDefault(); e.stopPropagation(); this.seek(video, -CONFIG.doubleTapSeek); this.showSeekIndicator(-CONFIG.doubleTapSeek); this.resetAutoHide(); } else if (e.key === 'ArrowRight') { e.preventDefault(); e.stopPropagation(); this.seek(video, CONFIG.doubleTapSeek); this.showSeekIndicator(CONFIG.doubleTapSeek); this.resetAutoHide(); } }; const onTakeoverKeyup = (e) => { if (e.key === 'ArrowDown') { e.preventDefault(); e.stopPropagation(); kbEndLongPress(); } }; state.takeoverKeydownHandler = onTakeoverKeydown; state.takeoverKeyupHandler = onTakeoverKeyup; document.addEventListener('keydown', onTakeoverKeydown, true); document.addEventListener('keyup', onTakeoverKeyup, true); // 底部按钮 container.querySelectorAll('.vt-bottom-btn').forEach((btn) => { btn.addEventListener('click', (e) => { if (state.isLocked && btn.dataset.action !== 'lock') return; const action = btn.dataset.action; this.handleBottomAction(action, btn, video); }); }); // 重载按钮:短按重新应用当前模式,长按切换原生/CSS模式 const reloadBtn = container.querySelector('[data-action="reload"]'); if (reloadBtn) { let lpTimer = null; let longPressed = false; const startLp = (e) => { if (state.isLocked) return; longPressed = false; lpTimer = setTimeout(() => { longPressed = true; this.switchRotationMode(); }, 800); }; const cancelLp = () => { if (lpTimer) { clearTimeout(lpTimer); lpTimer = null; } }; reloadBtn.addEventListener('touchstart', startLp, { passive: true }); reloadBtn.addEventListener('touchend', (e) => { cancelLp(); if (longPressed) { reloadBtn.dataset.longPressed = '1'; e.preventDefault(); e.stopPropagation(); } }); reloadBtn.addEventListener('touchmove', cancelLp); reloadBtn.addEventListener('touchcancel', cancelLp); reloadBtn.addEventListener('mousedown', startLp); reloadBtn.addEventListener('mouseup', cancelLp); reloadBtn.addEventListener('mouseleave', cancelLp); } // 进度条 const progressBar = container.querySelector('.vt-progress-bar'); let isSeeking = false; let seekTouchId = null; // 拖动时显示时间预览气泡 const showSeekTimeBubble = (percent) => { let bubble = container.querySelector('.vt-seek-bubble'); if (!bubble) { bubble = document.createElement('div'); bubble.className = 'vt-seek-bubble'; container.appendChild(bubble); } const newTime = percent * (video.duration || 0); bubble.innerHTML = formatTime(newTime) + ' / ' + formatTime(video.duration || 0); // 定位到进度条上方,跟随拖动位置 if (state.useCssRotation) { bubble.style.left = '50%'; bubble.style.top = (percent * 100) + '%'; bubble.style.transform = 'translate(-50%, -50%)'; } else { bubble.style.left = (percent * 100) + '%'; bubble.style.bottom = '20px'; bubble.style.transform = 'translateX(-50%)'; } bubble.style.display = 'block'; }; const hideSeekTimeBubble = () => { const bubble = container.querySelector('.vt-seek-bubble'); if (bubble) bubble.style.display = 'none'; }; const getPercentFromEvent = (e) => { const rect = progressBar.getBoundingClientRect(); const cx = e.touches ? e.touches[0].clientX : e.clientX; const cy = e.touches ? e.touches[0].clientY : e.clientY; if (state.useCssRotation) { // 容器被CSS旋转90°,进度条的长度方向对应视口的 Y 轴 return clamp((cy - rect.top) / rect.height, 0, 1); } else { return clamp((cx - rect.left) / rect.width, 0, 1); } }; // 进度条悬停预览:把原生播放器的预览元素搬进我们的接管容器(全屏时元素必须在container内才可见) let nativePreview = null; const previewSelectors = [ '.plyr__preview-thumb', // plyr 播放器(missav 等) '.jw-time-thumb', // JW Player 播放器(supjav 等) ]; const progressSelectors = [ '.plyr__progress', // plyr 进度条 '.jw-progress', // JW Player 进度条 ]; const setupNativePreview = () => { // 依次尝试找不同播放器的预览元素 nativePreview = null; for (const selector of previewSelectors) { const el = document.querySelector(selector); if (el) { nativePreview = el; break; } } if (nativePreview && nativePreview.parentElement !== container) { // 搬进我们的接管容器,元素在容器内部就不会被自己遮挡,全屏时才可见 container.appendChild(nativePreview); nativePreview.style.position = 'absolute'; nativePreview.style.zIndex = '9999'; nativePreview.style.display = 'none'; nativePreview.style.bottom = 'auto'; nativePreview.style.left = 'auto'; nativePreview.style.transform = 'translateX(-50%)'; } }; setupNativePreview(); setInterval(setupNativePreview, 500); // 鼠标离开时隐藏预览 progressBar.addEventListener('mouseleave', () => { if (nativePreview) { nativePreview.style.display = 'none'; } // 依次尝试找不同播放器的原生进度条 for (const selector of progressSelectors) { const nativeProgress = document.querySelector(selector); if (nativeProgress) { nativeProgress.dispatchEvent(new MouseEvent('mouseleave', { bubbles: true })); break; } } }); // 鼠标移动时更新预览 progressBar.addEventListener('mousemove', (e) => { if (!nativePreview) return; const percent = getPercentFromEvent(e); const rect = progressBar.getBoundingClientRect(); const containerRect = container.getBoundingClientRect(); // 1. 派发鼠标事件给原生进度条,让播放器自己更新预览画面和时间 let nativeProgress = null; for (const selector of progressSelectors) { nativeProgress = document.querySelector(selector); if (nativeProgress) break; } if (nativeProgress) { const nativeRect = nativeProgress.getBoundingClientRect(); const nativeX = nativeRect.left + percent * nativeRect.width; const nativeY = nativeRect.top + 5; nativeProgress.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, clientX: nativeX, clientY: nativeY })); nativeProgress.dispatchEvent(new PointerEvent('pointermove', { bubbles: true, clientX: nativeX, clientY: nativeY, pointerType: 'mouse' })); } // 2. 元素在我们容器内,按容器坐标定位到进度条上方 // 用important强制覆盖,防止plyr异步更新(尤其全屏时用bottom定位)把位置改掉 // 2. 元素在我们容器内,用bottom定位:预览窗底部固定在进度条上方10px,往上生长 // 无论预览窗多高都不会盖住进度条(top定位会导致预览窗底部超过进度条) const setPos = () => { const left = rect.left - containerRect.left + percent * rect.width; const bottom = containerRect.bottom - rect.top + 10; nativePreview.style.display = 'block'; nativePreview.style.setProperty('position', 'absolute', 'important'); nativePreview.style.setProperty('z-index', '9999', 'important'); nativePreview.style.setProperty('left', left + 'px', 'important'); nativePreview.style.setProperty('top', 'auto', 'important'); nativePreview.style.setProperty('bottom', bottom + 'px', 'important'); nativePreview.style.setProperty('right', 'auto', 'important'); nativePreview.style.setProperty('transform', 'translateX(-50%)', 'important'); nativePreview.style.setProperty('margin', '0', 'important'); }; setPos(); requestAnimationFrame(setPos); setTimeout(setPos, 50); }); const seekByEvent = (e) => { if (state.isLocked) return; const percent = getPercentFromEvent(e); const newTime = percent * (video.duration || 0); if (video.duration) { const wasPlaying = !video.paused; video.currentTime = newTime; // seek 到未缓冲区域时浏览器可能异步进入缓冲暂停,若 seek 前在播放则稍后自动续播,保持播放状态不变 if (wasPlaying) { setTimeout(() => { if (state.isTakeoverActive && video.paused && !state.userPaused) { video.play().catch(() => { }); } }, 300); } } showSeekTimeBubble(percent); // 拖动时实时更新进度条显示,不等视频加载完成 const playedEl = container.querySelector('.vt-progress-played'); const thumbEl = container.querySelector('.vt-progress-thumb'); const currentEl = container.querySelector('.vt-current-time'); if (playedEl) playedEl.style.width = (percent * 100) + '%'; if (thumbEl) thumbEl.style.left = (percent * 100) + '%'; if (currentEl) currentEl.textContent = formatTime(newTime); // 操作进度条不弹出控制栏,仅保持不自动隐藏 this.cancelAutoHide(); }; progressBar.addEventListener('touchstart', (e) => { if (state.isLocked) return; isSeeking = true; seekTouchId = e.touches[0].identifier; this.cancelAutoHide(); seekByEvent(e); }, { passive: true }); // touchmove/touchend 绑定到 document,手指移出进度条也能继续拖动 document.addEventListener('touchmove', (e) => { if (!isSeeking || state.isLocked) return; // 确认是同一个触摸点 let touch = null; for (let i = 0; i < e.touches.length; i++) { if (e.touches[i].identifier === seekTouchId) { touch = e.touches[i]; break; } } if (!touch) return; seekByEvent(e); e.preventDefault(); }, { passive: false }); document.addEventListener('touchend', (e) => { if (!isSeeking) return; // 确认是同一个触摸点结束 let stillTouching = false; for (let i = 0; i < e.touches.length; i++) { if (e.touches[i].identifier === seekTouchId) { stillTouching = true; break; } } if (!stillTouching) { isSeeking = false; seekTouchId = null; hideSeekTimeBubble(); this.resetAutoHide(); } }); document.addEventListener('touchcancel', () => { if (isSeeking) { isSeeking = false; seekTouchId = null; hideSeekTimeBubble(); this.resetAutoHide(); } }); progressBar.addEventListener('mousedown', (e) => { if (state.isLocked) return; isSeeking = true; this.cancelAutoHide(); seekByEvent(e); }); container.addEventListener('mousemove', (e) => { if (!isSeeking) return; seekByEvent(e); }); container.addEventListener('mouseup', () => { if (isSeeking) { isSeeking = false; hideSeekTimeBubble(); } }); // 鼠标无任何操作3秒后才自动隐藏:控制栏可见时,鼠标移动/点击/滚动都刷新3秒计时 container.addEventListener('mousemove', () => { if (state.controlsVisible) { this.resetAutoHide(); } }); container.addEventListener('mousedown', () => { if (state.controlsVisible) { this.resetAutoHide(); } }); container.addEventListener('wheel', () => { if (state.controlsVisible) { this.resetAutoHide(); } }, { passive: true }); // 手势处理:暂时全部关闭,先解决浏览器默认行为问题 // this.setupGestures(container, video); // 简单的单击显示/隐藏控制栏 let clickTimer = null; container.addEventListener('click', (e) => { // 点击按钮、进度条等控件不触发 if (e.target.closest('button') || e.target.closest('.vt-progress-bar') || e.target.closest('.vt-bottom-btn') || e.target.closest('.vt-top-btn')) { return; } if (clickTimer) { clearTimeout(clickTimer); clickTimer = null; return; // 双击的时候不触发单击 } clickTimer = setTimeout(() => { clickTimer = null; this.toggleControls(); }, 250); e.stopPropagation(); }); container.addEventListener('dblclick', (e) => { if (clickTimer) { clearTimeout(clickTimer); clickTimer = null; } e.preventDefault(); e.stopPropagation(); }); container.addEventListener('mousedown', (e) => { e.stopPropagation(); }); container.addEventListener('mouseup', (e) => { e.stopPropagation(); }); // 倍速菜单选项 container.querySelectorAll('.vt-speed-option').forEach((opt) => { opt.addEventListener('click', () => { if (state.isLocked) return; const rate = parseFloat(opt.dataset.rate); video.playbackRate = rate; this.updateSpeedLabel(video); container.querySelector('.vt-speed-menu').style.display = 'none'; }); }); }, setupGestures(container, video) { // ============ 手势状态 ============ const TAP_DELAY = 280; // 单击延迟判定(ms) const LONG_PRESS_DELAY = 1200; // 长按触发时间(ms)—— 加长留出更多时间给音量/亮度/进度手势 const MOVE_THRESHOLD = 20; // 移动取消点击的阈值(px) const SWIPE_THRESHOLD = 30; // 判定为滑动的阈值(px) const SEEK_PER_PX = 0.1; // 每像素对应多少秒进度(根据屏幕宽度动态调整) let touchId = null; let startX = 0; let startY = 0; let startTime = 0; let lastX = 0; // 手势状态机 let gesturePhase = 'idle'; // idle | waiting | tap-pending | long-press | swiping let singleTapTimer = null; // 双击检测 let lastTapTime = 0; let lastTapX = 0; let lastTapY = 0; // 长按定时器 let longPressTimer = null; let isLongPressActive = false; // 滑动进度 let seekStartPosition = 0; let seekTotalDelta = 0; // 垂直滑动(音量 / 亮度) let verticalMode = null; // 'volume' | 'brightness' let verticalStartValue = 0; let brightnessLevel = 1; // 0~1,1 表示原始亮度 let originalVideoFilter = null; // video 原始的 style.filter,叠加亮度时保留 // 长按倍速调节 let longPressStartX = 0; let longPressBaseRate = CONFIG.longPressSpeed; // 长按时的基础倍速 let longPressCurrentRate = CONFIG.longPressSpeed; const LONG_PRESS_MIN_RATE = 0.2; const LONG_PRESS_MAX_RATE = 6.0; // 每像素调整多少倍速(屏幕宽度对应从基础速度到最大速度) const RATE_PER_PIXEL = (LONG_PRESS_MAX_RATE - 1) / 400; // 400px 对应 5 档速度变化 // 上一次的速率(长按快进用) let originalRate = null; // 判断点击目标是否是交互元素(按钮/进度条等) const isInteractiveElement = (target) => { return !!(target.closest('button') || target.closest('.vt-progress-bar') || target.closest('.vt-speed-menu') || target.closest('.vt-bottom-btn') || target.closest('.vt-top-btn') || target.closest('.vt-center-btn') || target.closest('.vt-takeover-top-bar') || target.closest('.vt-takeover-bottom-bar') || target.closest('.vt-takeover-progress') || target.closest('.vt-takeover-center-controls') || target.closest('.vt-sub-panel') || target.closest('.vt-sub-preview-modal')); }; const getContainerWidth = () => container.clientWidth; // CSS旋转时容器被 rotate(90deg),视口坐标与"用户视角"坐标轴互换 const isCssRotated = () => state.useCssRotation; // 把视口坐标换算成用户视角坐标(未旋转时原样返回) // 旋转推导:X = innerWidth - y_local, Y = x_local // 反推:x_local = clientY, y_local = innerWidth - clientX const toUserXY = (clientX, clientY) => { if (!isCssRotated()) return { x: clientX, y: clientY }; return { x: clientY, y: window.innerWidth - clientX }; }; // 显示进度提示 const showSeekPreview = (deltaSeconds) => { let preview = container.querySelector('.vt-seek-preview'); if (!preview) { preview = document.createElement('div'); preview.className = 'vt-seek-preview'; container.appendChild(preview); } const newTime = clamp(seekStartPosition + deltaSeconds, 0, video.duration || 0); preview.innerHTML = `
${formatTime(newTime)} / ${formatTime(video.duration || 0)}
`; preview.style.display = 'block'; }; const hideSeekPreview = () => { const preview = container.querySelector('.vt-seek-preview'); if (preview) preview.style.display = 'none'; }; // ============ 音量 / 亮度 ============ const getContainerHeight = () => container.clientHeight; const VERTICAL_META = { volume: { icon: '🔊', label: '音量' }, brightness: { icon: '☀️', label: '亮度' }, }; const showVerticalIndicator = (mode, value) => { let el = container.querySelector('.vt-vertical-indicator'); if (!el) { el = document.createElement('div'); el.className = 'vt-vertical-indicator'; container.appendChild(el); } const percent = Math.round(clamp(value, 0, 1) * 100); el.innerHTML = `
${VERTICAL_META[mode].icon}
${percent}%
`; el.style.display = 'flex'; }; const hideVerticalIndicator = () => { const el = container.querySelector('.vt-vertical-indicator'); if (el) el.style.display = 'none'; }; // ============ 长按处理 ============ const updateLongPressRateDisplay = (rate) => { let indicator = container.querySelector('.vt-longpress-indicator'); if (!indicator) { indicator = document.createElement('div'); indicator.className = 'vt-longpress-indicator'; container.appendChild(indicator); } const rateFixed = rate.toFixed(1); indicator.innerHTML = `
${rateFixed}x
左右滑动调节速度
`; indicator.style.display = 'flex'; indicator.style.flexDirection = 'column'; indicator.style.gap = '6px'; }; const startLongPress = (touchX) => { if (state.isLocked) return; isLongPressActive = true; gesturePhase = 'long-press'; originalRate = video.playbackRate; longPressStartX = touchX; longPressCurrentRate = CONFIG.longPressSpeed; if (video.paused) { video.play().catch(() => { }); } video.playbackRate = longPressCurrentRate; updateLongPressRateDisplay(longPressCurrentRate); }; const adjustLongPressRate = (deltaX) => { // 向右滑增加速度,向左滑减少速度 const deltaRate = deltaX * RATE_PER_PIXEL; let newRate = longPressBaseRate + deltaRate; newRate = clamp(newRate, LONG_PRESS_MIN_RATE, LONG_PRESS_MAX_RATE); // 只在速度变化明显时更新,减少抖动 if (Math.abs(newRate - longPressCurrentRate) >= 0.1) { longPressCurrentRate = Math.round(newRate * 10) / 10; video.playbackRate = longPressCurrentRate; updateLongPressRateDisplay(longPressCurrentRate); } }; const endLongPress = () => { if (!isLongPressActive) return; isLongPressActive = false; // 恢复原速度 if (originalRate !== null) { video.playbackRate = originalRate; originalRate = null; } // 隐藏指示器 const indicator = container.querySelector('.vt-longpress-indicator'); if (indicator) indicator.style.display = 'none'; }; // ============ 触摸开始 ============ const onTouchStart = (e) => { if (state.isLocked) return; // 如果点在交互元素上,不处理手势 if (isInteractiveElement(e.target)) { gesturePhase = 'idle'; return; } // 只处理第一个触摸点 if (touchId !== null) return; const touch = e.touches[0]; touchId = touch.identifier; const p = toUserXY(touch.clientX, touch.clientY); startX = p.x; startY = p.y; lastX = p.x; startTime = Date.now(); seekStartPosition = video.currentTime || 0; seekTotalDelta = 0; // 清除之前的单击定时器 if (singleTapTimer) { clearTimeout(singleTapTimer); singleTapTimer = null; } // 清除之前的长按定时器 if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null; } gesturePhase = 'waiting'; // 设置长按定时器 longPressTimer = setTimeout(() => { if (gesturePhase === 'waiting') { startLongPress(p.x); } }, LONG_PRESS_DELAY); // 阻止默认行为(防止长按弹出菜单) e.preventDefault(); }; // ============ 触摸移动 ============ const onTouchMove = (e) => { if (state.isLocked) return; if (touchId === null) return; // 找到对应的触摸点 let touch = null; for (let i = 0; i < e.touches.length; i++) { if (e.touches[i].identifier === touchId) { touch = e.touches[i]; break; } } if (!touch) return; const p = toUserXY(touch.clientX, touch.clientY); const dx = p.x - startX; const dy = p.y - startY; const absDx = Math.abs(dx); const absDy = Math.abs(dy); // 根据不同阶段处理 if (gesturePhase === 'waiting') { // 一旦越过滑动阈值,先取消长按判定,再按主导方向锁定手势类型 if (absDx > SWIPE_THRESHOLD || absDy > SWIPE_THRESHOLD) { if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null; } if (absDx >= absDy) { // 水平滑动 → 拖动进度 gesturePhase = 'swiping'; showSeekPreview(0); } else { // 垂直滑动 → 左侧调亮度,右侧调音量 gesturePhase = 'vertical'; verticalMode = (startX < getContainerWidth() / 2) ? 'brightness' : 'volume'; verticalStartValue = verticalMode === 'volume' ? (video.muted ? 0 : video.volume) : brightnessLevel; showVerticalIndicator(verticalMode, verticalStartValue); } } } if (gesturePhase === 'swiping') { // 滑动调整进度 const width = getContainerWidth(); // 全屏宽度对应总时长的一半(更灵敏) const secondsPerPixel = (video.duration || 0) / (width * 2); const deltaSeconds = dx * secondsPerPixel; seekTotalDelta = deltaSeconds; // 实时更新进度 const newTime = clamp(seekStartPosition + deltaSeconds, 0, video.duration || 0); video.currentTime = newTime; showSeekPreview(deltaSeconds); e.preventDefault(); } if (gesturePhase === 'vertical') { // 向上滑增加,向下滑减少;滑动 1.2 倍屏高对应满量程(参考 HTML 视频触摸手势优化脚本) const fullRange = getContainerHeight() * 1.2; const value = clamp(verticalStartValue - dy / fullRange, 0, 1); if (verticalMode === 'volume') { video.muted = false; video.volume = value; } else { // 亮度范围 0.15 ~ 1,叠加到原始 filter 上而不是覆盖 brightnessLevel = value; const b = 0.15 + value * 0.85; if (!originalVideoFilter) originalVideoFilter = video.style.filter || ''; video.style.filter = b >= 0.995 ? originalVideoFilter : ((originalVideoFilter && originalVideoFilter !== 'none') ? originalVideoFilter + ' ' : '') + 'brightness(' + b.toFixed(3) + ')'; } showVerticalIndicator(verticalMode, value); e.preventDefault(); } if (gesturePhase === 'long-press') { // 长按状态下左右滑动调节倍速 const deltaX = p.x - longPressStartX; adjustLongPressRate(deltaX); e.preventDefault(); } lastX = p.x; }; // ============ 触摸结束 ============ const onTouchEnd = (e) => { if (state.isLocked) return; // 检查是否是我们追踪的触摸点 let found = false; for (let i = 0; i < e.changedTouches.length; i++) { if (e.changedTouches[i].identifier === touchId) { found = true; break; } } if (!found) return; touchId = null; // 清除长按定时器 if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null; } const endTime = Date.now(); const touchDuration = endTime - startTime; const touch = e.changedTouches[0]; const p = toUserXY(touch.clientX, touch.clientY); const dx = p.x - startX; const dy = p.y - startY; // ===== 处理长按 ===== if (isLongPressActive) { endLongPress(); gesturePhase = 'idle'; return; } // ===== 处理滑动 ===== if (gesturePhase === 'swiping') { hideSeekPreview(); gesturePhase = 'idle'; return; } // ===== 处理垂直滑动(音量 / 亮度) ===== if (gesturePhase === 'vertical') { hideVerticalIndicator(); gesturePhase = 'idle'; return; } // ===== 处理点击 ===== if (gesturePhase === 'waiting' && touchDuration < LONG_PRESS_DELAY && Math.abs(dx) < MOVE_THRESHOLD && Math.abs(dy) < MOVE_THRESHOLD) { // 检测是否是双击 const now = endTime; const dblDx = Math.abs(p.x - lastTapX); const dblDy = Math.abs(p.y - lastTapY); if (now - lastTapTime < TAP_DELAY && dblDx < 50 && dblDy < 50) { // 双击 lastTapTime = 0; const width = getContainerWidth(); const relX = p.x - container.getBoundingClientRect().left; if (relX < width / 3) { // 左侧双击:后退 this.seek(video, -CONFIG.doubleTapSeek); this.showSeekIndicator(-CONFIG.doubleTapSeek); } else if (relX > width * 2 / 3) { // 右侧双击:快进 this.seek(video, CONFIG.doubleTapSeek); this.showSeekIndicator(CONFIG.doubleTapSeek); } else { // 中间双击:播放/暂停 this.togglePlay(video); } } else { // 单击(延迟执行) lastTapTime = now; lastTapX = p.x; lastTapY = p.y; singleTapTimer = setTimeout(() => { singleTapTimer = null; this.toggleControls(); }, TAP_DELAY); } } gesturePhase = 'idle'; }; // ============ 触摸取消 ============ const onTouchCancel = () => { touchId = null; if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null; } if (singleTapTimer) { clearTimeout(singleTapTimer); singleTapTimer = null; } if (isLongPressActive) { endLongPress(); } hideSeekPreview(); hideVerticalIndicator(); gesturePhase = 'idle'; }; // ============ 绑定事件 ============ // 使用 capture 模式确保能捕获到 video 上的事件 container.addEventListener('touchstart', onTouchStart, { passive: false, capture: true }); container.addEventListener('touchmove', onTouchMove, { passive: false, capture: true }); container.addEventListener('touchend', onTouchEnd, { passive: true, capture: true }); container.addEventListener('touchcancel', onTouchCancel, { passive: true, capture: true }); // ============ 鼠标支持(桌面调试) ============ let isMouseDown = false; let mouseStartX = 0; let mouseStartY = 0; let mouseStartTime = 0; let mouseGesturePhase = 'idle'; let mouseLongPressTimer = null; let mouseIsLongPress = false; let mouseOriginalRate = null; let mouseLongPressStartX = 0; let mouseLongPressCurrentRate = CONFIG.longPressSpeed; let mouseSingleTapTimer = null; let mouseLastTapTime = 0; let mouseLastTapX = 0; let mouseLastTapY = 0; let mouseSeekStartPos = 0; const mouseUpdateRateDisplay = (rate) => { let indicator = container.querySelector('.vt-longpress-indicator'); if (!indicator) { indicator = document.createElement('div'); indicator.className = 'vt-longpress-indicator'; container.appendChild(indicator); } indicator.innerHTML = `
${rate.toFixed(1)}x
左右滑动调节速度
`; indicator.style.display = 'flex'; indicator.style.flexDirection = 'column'; indicator.style.gap = '6px'; }; const mouseAdjustRate = (deltaX) => { const deltaRate = deltaX * RATE_PER_PIXEL; let newRate = longPressBaseRate + deltaRate; newRate = clamp(newRate, LONG_PRESS_MIN_RATE, LONG_PRESS_MAX_RATE); if (Math.abs(newRate - mouseLongPressCurrentRate) >= 0.1) { mouseLongPressCurrentRate = Math.round(newRate * 10) / 10; video.playbackRate = mouseLongPressCurrentRate; mouseUpdateRateDisplay(mouseLongPressCurrentRate); } }; container.addEventListener('mousedown', (e) => { if (state.isLocked) return; if (isInteractiveElement(e.target)) return; if (e.button !== 0) return; // 只处理左键 isMouseDown = true; const mp = toUserXY(e.clientX, e.clientY); mouseStartX = mp.x; mouseStartY = mp.y; mouseStartTime = Date.now(); mouseSeekStartPos = video.currentTime || 0; if (mouseSingleTapTimer) { clearTimeout(mouseSingleTapTimer); mouseSingleTapTimer = null; } mouseGesturePhase = 'waiting'; mouseLongPressTimer = setTimeout(() => { if (mouseGesturePhase === 'waiting') { mouseGesturePhase = 'long-press'; mouseIsLongPress = true; mouseOriginalRate = video.playbackRate; mouseLongPressStartX = mp.x; mouseLongPressCurrentRate = CONFIG.longPressSpeed; if (video.paused) video.play().catch(() => { }); video.playbackRate = mouseLongPressCurrentRate; mouseUpdateRateDisplay(mouseLongPressCurrentRate); } }, LONG_PRESS_DELAY); }); container.addEventListener('mousemove', (e) => { if (!isMouseDown || state.isLocked) return; const mp = toUserXY(e.clientX, e.clientY); const dx = mp.x - mouseStartX; const dy = mp.y - mouseStartY; const absDx = Math.abs(dx); const absDy = Math.abs(dy); if (mouseGesturePhase === 'waiting') { if (absDx > SWIPE_THRESHOLD && absDx > absDy * 1.5) { if (mouseLongPressTimer) { clearTimeout(mouseLongPressTimer); mouseLongPressTimer = null; } mouseGesturePhase = 'swiping'; showSeekPreview(0); } else if (absDy > MOVE_THRESHOLD) { if (mouseLongPressTimer) { clearTimeout(mouseLongPressTimer); mouseLongPressTimer = null; } mouseGesturePhase = 'idle'; } } if (mouseGesturePhase === 'swiping') { const width = getContainerWidth(); const secondsPerPixel = (video.duration || 0) / (width * 2); const deltaSeconds = dx * secondsPerPixel; const newTime = clamp(mouseSeekStartPos + deltaSeconds, 0, video.duration || 0); video.currentTime = newTime; showSeekPreview(deltaSeconds); } if (mouseGesturePhase === 'long-press') { const deltaX = mp.x - mouseLongPressStartX; mouseAdjustRate(deltaX); } }); container.addEventListener('mouseup', (e) => { if (state.isLocked) return; if (!isMouseDown) return; isMouseDown = false; if (mouseLongPressTimer) { clearTimeout(mouseLongPressTimer); mouseLongPressTimer = null; } if (mouseIsLongPress) { mouseIsLongPress = false; if (mouseOriginalRate !== null) { video.playbackRate = mouseOriginalRate; mouseOriginalRate = null; } const indicator = container.querySelector('.vt-longpress-indicator'); if (indicator) indicator.style.display = 'none'; mouseGesturePhase = 'idle'; return; } if (mouseGesturePhase === 'swiping') { hideSeekPreview(); mouseGesturePhase = 'idle'; return; } const endTime = Date.now(); const duration = endTime - mouseStartTime; const mp = toUserXY(e.clientX, e.clientY); const dx = mp.x - mouseStartX; const dy = mp.y - mouseStartY; if (mouseGesturePhase === 'waiting' && duration < LONG_PRESS_DELAY && Math.abs(dx) < MOVE_THRESHOLD && Math.abs(dy) < MOVE_THRESHOLD) { const now = endTime; const dblDx = Math.abs(mp.x - mouseLastTapX); const dblDy = Math.abs(mp.y - mouseLastTapY); if (now - mouseLastTapTime < TAP_DELAY && dblDx < 50 && dblDy < 50) { mouseLastTapTime = 0; const width = getContainerWidth(); const relX = mp.x - container.getBoundingClientRect().left; if (relX < width / 3) { this.seek(video, -CONFIG.doubleTapSeek); this.showSeekIndicator(-CONFIG.doubleTapSeek); } else if (relX > width * 2 / 3) { this.seek(video, CONFIG.doubleTapSeek); this.showSeekIndicator(CONFIG.doubleTapSeek); } else { this.togglePlay(video); } } else { mouseLastTapTime = now; mouseLastTapX = mp.x; mouseLastTapY = mp.y; mouseSingleTapTimer = setTimeout(() => { mouseSingleTapTimer = null; this.toggleControls(); }, TAP_DELAY); } } mouseGesturePhase = 'idle'; }); container.addEventListener('mouseleave', () => { if (!isMouseDown) return; isMouseDown = false; if (mouseLongPressTimer) { clearTimeout(mouseLongPressTimer); mouseLongPressTimer = null; } if (mouseIsLongPress) { mouseIsLongPress = false; if (mouseOriginalRate !== null) { video.playbackRate = mouseOriginalRate; mouseOriginalRate = null; } const indicator = container.querySelector('.vt-longpress-indicator'); if (indicator) indicator.style.display = 'none'; } hideSeekPreview(); mouseGesturePhase = 'idle'; }); }, startLongPress(video, direction) { if (state.isLongPressing) return; state.isLongPressing = true; state.longPressDirection = direction; // 保存原速度 state.originalRate = video.playbackRate; // 确保视频在播放 if (video.paused) { video.play().catch(() => { }); } // 使用 playbackRate 实现 3 倍速快进 video.playbackRate = CONFIG.longPressSpeed; this.showLongPressIndicator(CONFIG.longPressSpeed + 'x 快进'); }, endLongPress(video) { state.isLongPressing = false; // 恢复原速度 if (state.originalRate !== undefined) { video.playbackRate = state.originalRate; state.originalRate = undefined; } this.hideLongPressIndicator(); }, cancelLongPress() { if (state.longPressTimer) { clearTimeout(state.longPressTimer); state.longPressTimer = null; } if (state.isLongPressing && state.currentVideo) { this.endLongPress(state.currentVideo); } }, showLongPressIndicator(text) { let indicator = state.takeoverContainer.querySelector('.vt-longpress-indicator'); if (!indicator) { indicator = document.createElement('div'); indicator.className = 'vt-longpress-indicator'; state.takeoverContainer.appendChild(indicator); } indicator.textContent = text; indicator.style.display = 'flex'; }, hideLongPressIndicator() { const indicator = state.takeoverContainer.querySelector('.vt-longpress-indicator'); if (indicator) { indicator.style.display = 'none'; } }, showSeekIndicator(seconds) { let indicator = state.takeoverContainer.querySelector('.vt-seek-indicator'); if (!indicator) { indicator = document.createElement('div'); indicator.className = 'vt-seek-indicator'; state.takeoverContainer.appendChild(indicator); } indicator.textContent = (seconds > 0 ? '+' : '') + seconds + 's'; indicator.style.display = 'block'; indicator.classList.remove('vt-seek-anim'); // 触发重排 void indicator.offsetWidth; indicator.classList.add('vt-seek-anim'); setTimeout(() => { indicator.style.display = 'none'; }, 800); }, handleBottomAction(action, btn, video) { switch (action) { case 'subtitle': SubtitleManager.togglePanel(); break; case 'speed': this.toggleSpeedMenu(); break; case 'pip': // 先尝试找网站原生的画中画按钮,排除我们自己的按钮 const pipSelectors = [ '.bpx-player-ctrl-pip', // B站 '.txp_btn_pip', // 腾讯视频 '.xgplayer-pip', // 西瓜/抖音 '.bilibili-player-video-btn-pip' // 老版B站 ]; let nativePipBtn = null; for (let selector of pipSelectors) { nativePipBtn = document.querySelector(selector); if (nativePipBtn) break; } if (nativePipBtn) { nativePipBtn.click(); break; } // 找不到原生按钮,用浏览器原生API if (document.pictureInPictureElement) { document.exitPictureInPicture(); } else { video.requestPictureInPicture().catch((err) => { console.warn('画中画失败:', err); }); } break; case 'quality': { const qualityMenu = btn.querySelector('.vt-quality-menu'); if (qualityMenu.style.display === 'block') { qualityMenu.style.display = 'none'; break; } // 先弹出原网站的设置菜单,读取清晰度选项 const settingsBtn = document.querySelector('.plyr__control[data-plyr="settings"]'); if (settingsBtn) { settingsBtn.click(); } // 等菜单弹出来后,读取原网站的清晰度选项 setTimeout(() => { // 读取plyr的清晰度选项 const qualityOptions = document.querySelectorAll('.plyr__control[role="menuitemradio"][data-plyr="quality"]'); if (qualityOptions.length === 0) { // 没找到的话,关闭原网站菜单 if (settingsBtn) settingsBtn.click(); // 用测试选项 qualityMenu.innerHTML = `
自动
1080P
720P
`; } else { // 动态生成我们的菜单选项 qualityMenu.innerHTML = ''; qualityOptions.forEach(option => { const text = option.textContent.trim(); const item = document.createElement('div'); item.className = 'vt-quality-option'; item.textContent = text; item.addEventListener('click', (e) => { e.stopPropagation(); // 点击我们的选项,就去点击原网站对应的选项 option.click(); const qualityLabel = btn.querySelector('.vt-quality-label'); if (qualityLabel) { qualityLabel.textContent = text; } qualityMenu.style.display = 'none'; // 关闭原网站菜单 if (settingsBtn) settingsBtn.click(); }); qualityMenu.appendChild(item); }); } // 关闭原网站的菜单 setTimeout(() => { if (settingsBtn) settingsBtn.click(); }, 100); qualityMenu.style.display = 'block'; }, 200); break; } case 'fullscreen': this.toggleFullscreen(video, btn); break; } this.resetAutoHide(); }, toggleFullscreen(video, btn) { const container = state.takeoverContainer; if (!document.fullscreenElement) { if (container.requestFullscreen) { container.requestFullscreen().catch(() => {}); } else if (container.webkitRequestFullscreen) { container.webkitRequestFullscreen(); } const fsIcon = btn.querySelector('.vt-icon-fullscreen'); const exfsIcon = btn.querySelector('.vt-icon-exit-fullscreen'); if (fsIcon) fsIcon.style.display = 'none'; if (exfsIcon) exfsIcon.style.display = ''; } else { if (document.exitFullscreen) { document.exitFullscreen(); } else if (document.webkitExitFullscreen) { document.webkitExitFullscreen(); } const fsIcon = btn.querySelector('.vt-icon-fullscreen'); const exfsIcon = btn.querySelector('.vt-icon-exit-fullscreen'); if (fsIcon) fsIcon.style.display = ''; if (exfsIcon) exfsIcon.style.display = 'none'; } }, cycleScreenMode(video, btn) { const modes = ['fit', 'crop', 'stretch']; const labels = ['适应', '裁剪', '拉伸']; const currentIndex = modes.indexOf(state.fullscreenMode); const nextIndex = (currentIndex + 1) % modes.length; state.fullscreenMode = modes[nextIndex]; const label = btn.querySelector('.vt-btn-label'); label.textContent = labels[nextIndex]; switch (state.fullscreenMode) { case 'fit': video.style.objectFit = 'contain'; break; case 'crop': video.style.objectFit = 'cover'; break; case 'stretch': video.style.objectFit = 'fill'; break; } }, downloadVideo(video) { try { const src = video.currentSrc || video.src; if (!src) { this.showToast('未找到视频地址'); return; } const a = document.createElement('a'); a.href = src; a.download = 'video.mp4'; a.target = '_blank'; document.body.appendChild(a); a.click(); a.remove(); this.showToast('开始下载...'); } catch (e) { this.showToast('下载失败:可能因跨域限制'); } }, toggleSpeedMenu() { const menu = state.takeoverContainer.querySelector('.vt-speed-menu'); menu.style.display = menu.style.display === 'none' ? 'flex' : 'none'; }, // 重新应用当前模式(短按重载按钮触发) reapplyMode(video) { const container = state.takeoverContainer; if (!container) return; const label = container.querySelector('.vt-rotate-btn .vt-btn-label'); if (state.rotationMode === 'native') { // 原生模式:重新进入原生全屏 + 横屏锁定 state.useCssRotation = false; container.classList.remove('vt-css-landscape'); container.classList.remove('vt-portrait-css'); state.orientation = 'landscape'; if (container.requestFullscreen) { container.requestFullscreen().then(() => { if (screen.orientation && screen.orientation.lock) { screen.orientation.lock('landscape').catch(() => {}); } }).catch(() => {}); } if (label) label.textContent = '竖屏'; } else { // CSS 模式:重新应用 CSS 横屏样式 state.orientation = 'landscape'; state.useCssRotation = true; container.classList.remove('vt-portrait-css'); container.classList.add('vt-css-landscape'); if (label) label.textContent = '竖屏'; } // 强制 video 铺满容器 if (video) { video.style.width = '100%'; video.style.height = '100%'; video.style.maxWidth = 'none'; video.style.maxHeight = 'none'; } this.showToast('已重新载入'); }, toggleLock() { state.isLocked = !state.isLocked; const container = state.takeoverContainer; const lockBtn = container.querySelector('.vt-lock-btn'); const iconUnlock = lockBtn.querySelector('.vt-icon-unlock'); const iconLock = lockBtn.querySelector('.vt-icon-lock'); const label = lockBtn.querySelector('.vt-btn-label'); if (state.isLocked) { // 切换为锁定图标 iconUnlock.style.display = 'none'; iconLock.style.display = 'block'; label.textContent = '锁定'; // 给容器添加锁定状态类 container.classList.add('vt-locked'); // 隐藏所有控制栏,但保留锁定按钮 this.hideAllControlsButLock(); this.showToast('屏幕已锁定'); } else { // 切换为解锁图标 iconUnlock.style.display = 'block'; iconLock.style.display = 'none'; label.textContent = '锁定'; // 移除锁定状态类 container.classList.remove('vt-locked'); // 显示所有控制栏 this.showControls(); this.resetAutoHide(); this.showToast('屏幕已解锁'); } }, toggleOrientation() { const container = state.takeoverContainer; if (!container) return; const rotateBtn = container.querySelector('.vt-rotate-btn'); const label = rotateBtn.querySelector('.vt-btn-label'); const target = state.orientation === 'landscape' ? 'portrait' : 'landscape'; if (state.rotationMode === 'css') { // CSS模式:通过 CSS class 切换横竖屏 if (target === 'portrait') { state.orientation = 'portrait'; state.useCssRotation = false; container.classList.remove('vt-css-landscape'); label.textContent = '横屏'; this.showToast('已切换竖屏'); } else { state.orientation = 'landscape'; state.useCssRotation = true; container.classList.add('vt-css-landscape'); label.textContent = '竖屏'; this.showToast('已切换横屏'); } return; } // 原生模式:优先使用原生屏幕方向 API if (screen.orientation && screen.orientation.lock && document.fullscreenElement) { screen.orientation.lock(target).then(() => { state.orientation = target; state.useCssRotation = false; container.classList.remove('vt-portrait-css'); label.textContent = target === 'landscape' ? '竖屏' : '横屏'; }).catch(() => { // 原生API失败,使用CSS旋转回退 this.toggleCssOrientation(); }); } else { // 不支持原生API,直接用CSS旋转 this.toggleCssOrientation(); } }, toggleCssOrientation() { const container = state.takeoverContainer; if (!container) return; const rotateBtn = container.querySelector('.vt-rotate-btn'); const label = rotateBtn.querySelector('.vt-btn-label'); if (state.orientation === 'landscape') { // 切到竖屏(CSS旋转) state.orientation = 'portrait'; state.useCssRotation = true; container.classList.add('vt-portrait-css'); label.textContent = '横屏'; this.showToast('已切换竖屏'); } else { // 切到横屏 state.orientation = 'landscape'; state.useCssRotation = false; container.classList.remove('vt-portrait-css'); label.textContent = '竖屏'; this.showToast('已切换横屏'); } }, // 切换旋转模式:原生 <-> CSS(长按旋转按钮触发) switchRotationMode() { const container = state.takeoverContainer; if (!container) return; const label = container.querySelector('.vt-rotate-btn .vt-btn-label'); if (state.rotationMode === 'css') { // 切到原生模式 state.rotationMode = 'native'; localStorage.setItem('vt_rotate_mode', 'native'); state.useCssRotation = false; container.classList.remove('vt-css-landscape'); container.classList.remove('vt-portrait-css'); // 重新进入原生全屏 + 横屏 state.orientation = 'landscape'; if (container.requestFullscreen) { container.requestFullscreen().then(() => { if (screen.orientation && screen.orientation.lock) { screen.orientation.lock('landscape').catch(() => {}); } }).catch(() => {}); } label.textContent = '竖屏'; this.showToast('原生模式'); } else { // 切到CSS模式:先退出原生全屏,再用CSS旋转模拟横屏 state.rotationMode = 'css'; localStorage.setItem('vt_rotate_mode', 'css'); const applyCss = () => { state.orientation = 'landscape'; state.useCssRotation = true; container.classList.remove('vt-portrait-css'); container.classList.add('vt-css-landscape'); label.textContent = '竖屏'; }; if (screen.orientation && screen.orientation.unlock) { screen.orientation.unlock(); } if (document.fullscreenElement) { document.exitFullscreen().then(applyCss).catch(applyCss); } else { applyCss(); } this.showToast('CSS模式'); } }, hideAllControlsButLock() { const container = state.takeoverContainer; state.controlsVisible = false; // 隐藏顶部栏 const topBar = container.querySelector('.vt-takeover-top-bar'); topBar.style.opacity = '0'; topBar.style.pointerEvents = 'none'; // 隐藏中央控制 const centerControls = container.querySelector('.vt-takeover-center-controls'); centerControls.style.opacity = '0'; centerControls.style.pointerEvents = 'none'; // 隐藏进度条 const progress = container.querySelector('.vt-takeover-progress'); progress.style.opacity = '0'; progress.style.pointerEvents = 'none'; // 隐藏底部除了锁定按钮外的所有按钮 const bottomBtns = container.querySelectorAll('.vt-bottom-btn:not(.vt-lock-btn)'); bottomBtns.forEach((btn) => { btn.style.opacity = '0'; btn.style.pointerEvents = 'none'; }); // 隐藏倍速菜单 container.querySelector('.vt-speed-menu').style.display = 'none'; // 隐藏字幕面板 const subPanel = container.querySelector('.vt-sub-panel'); if (subPanel) { subPanel.classList.remove('vt-sub-panel-open'); SubtitleManager.state.panelOpen = false; } // 清除自动隐藏定时器 if (state.autoHideTimer) { clearTimeout(state.autoHideTimer); state.autoHideTimer = null; } }, togglePlay(video) { if (video.paused) { state.userPaused = false; video.play().catch(() => { }); } else { state.userPaused = true; video.pause(); } }, seek(video, seconds) { if (!video.duration) return; video.currentTime = clamp(video.currentTime + seconds, 0, video.duration); }, toggleControls() { if (state.controlsVisible) { this.hideControls(); } else { this.showControls(); this.resetAutoHide(); } }, showControls() { state.controlsVisible = true; const container = state.takeoverContainer; container.querySelector('.vt-takeover-top-bar').style.opacity = '1'; container.querySelector('.vt-takeover-top-bar').style.pointerEvents = 'auto'; container.querySelector('.vt-takeover-center-controls').style.opacity = '1'; container.querySelector('.vt-takeover-center-controls').style.pointerEvents = 'auto'; container.querySelector('.vt-takeover-progress').classList.remove('vt-progress-minimal'); container.querySelector('.vt-takeover-progress').style.opacity = '1'; container.querySelector('.vt-takeover-progress').style.pointerEvents = 'auto'; container.querySelector('.vt-takeover-bottom-bar').style.opacity = '1'; container.querySelector('.vt-takeover-bottom-bar').style.pointerEvents = 'auto'; // 恢复所有底部按钮 const bottomBtns = container.querySelectorAll('.vt-bottom-btn'); bottomBtns.forEach((btn) => { btn.style.opacity = ''; btn.style.pointerEvents = ''; }); }, hideControls() { if (state.isLocked) return; // 锁定状态下由锁定层控制 if (SubtitleManager.state.panelOpen) return; // 字幕面板打开时不隐藏 state.controlsVisible = false; const container = state.takeoverContainer; container.querySelector('.vt-takeover-top-bar').style.opacity = '0'; container.querySelector('.vt-takeover-top-bar').style.pointerEvents = 'none'; container.querySelector('.vt-takeover-center-controls').style.opacity = '0'; container.querySelector('.vt-takeover-center-controls').style.pointerEvents = 'none'; container.querySelector('.vt-takeover-bottom-bar').style.opacity = '0'; container.querySelector('.vt-takeover-bottom-bar').style.pointerEvents = 'none'; // 进度条:常驻开启 = 细条贴底;关闭 = 完全隐藏 const progressEl = container.querySelector('.vt-takeover-progress'); if (CONFIG.progressAlwaysOn) { progressEl.classList.add('vt-progress-minimal'); progressEl.style.opacity = '1'; progressEl.style.pointerEvents = 'auto'; } else { progressEl.classList.remove('vt-progress-minimal'); progressEl.style.opacity = '0'; progressEl.style.pointerEvents = 'none'; } // 隐藏倍速菜单 container.querySelector('.vt-speed-menu').style.display = 'none'; // 隐藏字幕面板 SubtitleManager.closePanel(); }, resetAutoHide() { // 字幕面板打开时不自动隐藏 if (SubtitleManager.state.panelOpen) return; if (state.autoHideTimer) { clearTimeout(state.autoHideTimer); } state.autoHideTimer = setTimeout(() => { if (!state.isLocked) { this.hideControls(); } }, CONFIG.controlsAutoHideDelay); }, cancelAutoHide() { if (state.autoHideTimer) { clearTimeout(state.autoHideTimer); state.autoHideTimer = null; } }, requestFullscreenAndLandscape(container) { // 读取上次选择的模式,默认原生 state.rotationMode = localStorage.getItem('vt_rotate_mode') || 'native'; state.orientation = 'landscape'; if (state.rotationMode === 'css') { // CSS模式:不调用原生全屏,直接用CSS旋转模拟横屏 state.useCssRotation = true; container.classList.add('vt-css-landscape'); return; } // 原生模式(默认):请求原生全屏 + 横屏方向锁 state.useCssRotation = false; if (container.requestFullscreen) { container.requestFullscreen().catch(() => { // 全屏失败也没关系,继续播放 }); } else if (container.webkitRequestFullscreen) { container.webkitRequestFullscreen(); } if (screen.orientation && screen.orientation.lock) { const tryLock = () => { screen.orientation.lock('landscape').catch(() => { // 某些浏览器不支持方向锁定,忽略即可 }); }; if (document.fullscreenElement) { tryLock(); } else { setTimeout(tryLock, 500); } } }, addVideoListeners(video) { video.addEventListener('play', this._onPlay = () => this.updatePlayState(video)); video.addEventListener('pause', this._onPause = () => this.updatePlayState(video)); video.addEventListener('timeupdate', this._onTimeUpdate = () => this.updateTime(video)); video.addEventListener('loadedmetadata', this._onLoaded = () => this.updateTime(video)); video.addEventListener('ratechange', this._onRateChange = () => this.updateSpeedLabel(video)); video.addEventListener('ended', this._onEnded = () => this.updatePlayState(video)); video.addEventListener('progress', this._onProgress = () => this.updateBuffered(video)); video.addEventListener('waiting', this._onWaiting = () => this.showLoading()); video.addEventListener('canplay', this._onCanPlay = () => this.hideLoading()); video.addEventListener('playing', this._onPlaying = () => this.hideLoading()); }, removeVideoListeners(video) { video.removeEventListener('play', this._onPlay); video.removeEventListener('pause', this._onPause); video.removeEventListener('timeupdate', this._onTimeUpdate); video.removeEventListener('loadedmetadata', this._onLoaded); video.removeEventListener('ratechange', this._onRateChange); video.removeEventListener('ended', this._onEnded); video.removeEventListener('progress', this._onProgress); video.removeEventListener('waiting', this._onWaiting); video.removeEventListener('canplay', this._onCanPlay); video.removeEventListener('playing', this._onPlaying); }, showLoading() { const container = state.takeoverContainer; if (!container) return; let loading = container.querySelector('.vt-loading'); if (!loading) { loading = document.createElement('div'); loading.className = 'vt-loading'; loading.innerHTML = '
'; container.appendChild(loading); } loading.style.display = 'flex'; }, hideLoading() { const container = state.takeoverContainer; if (!container) return; const loading = container.querySelector('.vt-loading'); if (loading) loading.style.display = 'none'; }, updatePlayState(video) { const container = state.takeoverContainer; if (!container) return; const playIcon = container.querySelector('.vt-icon-play'); const pauseIcon = container.querySelector('.vt-icon-pause'); if (video.paused) { playIcon.style.display = 'block'; pauseIcon.style.display = 'none'; } else { playIcon.style.display = 'none'; pauseIcon.style.display = 'block'; } }, updateTime(video) { const container = state.takeoverContainer; if (!container) return; const currentEl = container.querySelector('.vt-current-time'); const durationEl = container.querySelector('.vt-duration'); const playedEl = container.querySelector('.vt-progress-played'); const thumbEl = container.querySelector('.vt-progress-thumb'); currentEl.textContent = formatTime(video.currentTime); durationEl.textContent = formatTime(video.duration || 0); if (video.duration) { const percent = (video.currentTime / video.duration) * 100; playedEl.style.width = percent + '%'; thumbEl.style.left = percent + '%'; } }, updateBuffered(video) { const container = state.takeoverContainer; if (!container) return; const bufferedEl = container.querySelector('.vt-progress-buffered'); if (video.buffered && video.buffered.length > 0 && video.duration) { const bufferedEnd = video.buffered.end(video.buffered.length - 1); const percent = (bufferedEnd / video.duration) * 100; bufferedEl.style.width = percent + '%'; } }, updateSpeedLabel(video) { const container = state.takeoverContainer; if (!container) return; const speedBtn = container.querySelector('[data-action="speed"] .vt-btn-label'); speedBtn.textContent = video.playbackRate + 'x'; }, showToast(message) { FloatingButtons.showToast(message); }, }; // ==================== 网络请求工具(绕过CORS) ==================== const NetUtil = { // 带GM_xmlhttpRequest的GET请求,自动绕过CORS async getText(url) { // 优先使用GM_xmlhttpRequest(不受CORS限制) if (typeof GM_xmlhttpRequest !== 'undefined') { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url: url, onload: (response) => { if (response.status >= 200 && response.status < 300) { resolve(response.responseText); } else { reject(new Error('HTTP ' + response.status)); } }, onerror: (err) => reject(new Error(err.error || '网络错误')), ontimeout: () => reject(new Error('请求超时')), timeout: 15000, }); }); } // 降级使用fetch(可能受CORS限制) const resp = await fetch(url); if (!resp.ok) throw new Error('HTTP ' + resp.status); return await resp.text(); }, async getJSON(url) { const text = await this.getText(url); return JSON.parse(text); }, async getBuffer(url) { if (typeof GM_xmlhttpRequest !== 'undefined') { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url: url, responseType: 'arraybuffer', onload: (response) => { if (response.status >= 200 && response.status < 300) { resolve(response.response); } else { reject(new Error('HTTP ' + response.status)); } }, onerror: (err) => reject(new Error(err.error || '网络错误')), ontimeout: () => reject(new Error('请求超时')), timeout: 15000, }); }); } const resp = await fetch(url); if (!resp.ok) throw new Error('HTTP ' + resp.status); return await resp.arrayBuffer(); }, }; // ==================== 字幕管理器 ==================== const SubtitleManager = { // 字幕状态 state: { cues: [], offset: 0, cursor: 0, lastCue: null, name: '', raw: '', visible: true, fontSize: 22, posPercent: 12, rafId: 0, panelOpen: false, // 字幕配置 config: { fontSize: 22, posPercent: 12, color: '#ffffff', outline: 2, opacity: 1, offset: 0, assrtToken: '', }, }, // 字幕元素 subEl: null, init() { // 从本地存储加载配置 try { const saved = localStorage.getItem('vt_sub_config'); if (saved) { Object.assign(this.state.config, JSON.parse(saved)); this.state.fontSize = this.state.config.fontSize; this.state.posPercent = this.state.config.posPercent; this.state.offset = this.state.config.offset; } } catch (e) { } }, saveConfig() { try { localStorage.setItem('vt_sub_config', JSON.stringify(this.state.config)); } catch (e) { } }, // ========== 编码检测 ========== decodeBuffer(buf) { const u8 = new Uint8Array(buf); // BOM检测 if (u8.length >= 3 && u8[0] === 0xEF && u8[1] === 0xBB && u8[2] === 0xBF) { return new TextDecoder('utf-8').decode(u8.slice(3)); } if (u8.length >= 2 && u8[0] === 0xFF && u8[1] === 0xFE) { return new TextDecoder('utf-16le').decode(u8.slice(2)); } if (u8.length >= 2 && u8[0] === 0xFE && u8[1] === 0xFF) { return new TextDecoder('utf-16be').decode(u8.slice(2)); } // UTF-8严格模式 try { return new TextDecoder('utf-8', { fatal: true }).decode(u8); } catch (e) { } // 候选编码尝试 const candidates = ['gb18030', 'big5', 'shift_jis', 'euc-kr']; for (const enc of candidates) { try { const text = new TextDecoder(enc).decode(u8); if (text.indexOf('\uFFFD') < 0) return text; } catch (e) { } } // 兜底 return new TextDecoder('utf-8').decode(u8); }, // ========== 时间解析 ========== parseTimeSrt(s) { const parts = String(s).trim().split(/[,.]/); const hms = parts[0].split(':'); const ms = parts[1] ? parseInt(parts[1].padEnd(3, '0').slice(0, 3), 10) : 0; const hh = parseInt(hms[0], 10) || 0; const mm = parseInt(hms[1], 10) || 0; const ss = parseInt(hms[2], 10) || 0; const v = hh * 3600 + mm * 60 + ss + ms / 1000; return isNaN(v) ? NaN : v; }, parseTimeAss(s) { const t = String(s).trim().split(/[,.]/); const hms = t[0].split(':'); const cs = t[1] ? parseInt(t[1].padEnd(2, '0').slice(0, 2), 10) : 0; return (parseInt(hms[0], 10) || 0) * 3600 + (parseInt(hms[1], 10) || 0) * 60 + (parseInt(hms[2], 10) || 0) + cs / 100; }, // ========== 字幕解析 ========== parseSRT(text) { const cues = []; const blocks = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split(/\n\n+/); for (const block of blocks) { const lines = block.trim().split('\n').filter(l => l.trim()); if (lines.length < 2) continue; // 找到时间轴行 let timeLineIdx = -1; for (let i = 0; i < lines.length; i++) { if (lines[i].indexOf('-->') >= 0) { timeLineIdx = i; break; } } if (timeLineIdx < 0) continue; const timeParts = lines[timeLineIdx].split('-->').map(s => s.trim()); const start = this.parseTimeSrt(timeParts[0]); const end = this.parseTimeSrt(timeParts[1].split(' ')[0]); if (isNaN(start) || isNaN(end)) continue; const textLines = lines.slice(timeLineIdx + 1); const cueText = textLines.join('\n').trim(); if (cueText) { cues.push({ s: start, e: end, text: cueText }); } } return cues; }, parseVTT(text) { const cues = []; const lines = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n'); let i = 0; // 跳过头部 while (i < lines.length && lines[i].indexOf('-->') < 0) i++; while (i < lines.length) { const line = lines[i]; if (line.indexOf('-->') >= 0) { const parts = line.split('-->').map(s => s.trim()); const start = this.parseTimeSrt(parts[0]); const end = this.parseTimeSrt(parts[1].split(' ')[0]); if (!isNaN(start) && !isNaN(end)) { i++; const textLines = []; while (i < lines.length && lines[i].trim() !== '') { textLines.push(lines[i]); i++; } const cueText = textLines.join('\n').trim(); if (cueText) { cues.push({ s: start, e: end, text: cueText }); } } } i++; } return cues; }, parseASS(text) { const cues = []; const lines = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n'); let inEvents = false; let formatFields = []; for (const line of lines) { const trimmed = line.trim(); if (trimmed.toLowerCase() === '[events]') { inEvents = true; continue; } if (inEvents) { if (trimmed.startsWith('[')) break; if (trimmed.toLowerCase().startsWith('format:')) { formatFields = trimmed.substring(7).split(',').map(s => s.trim().toLowerCase()); continue; } if (trimmed.toLowerCase().startsWith('dialogue:')) { const content = trimmed.substring(9); const values = content.split(','); if (formatFields.length === 0) continue; // 找到 start, end, text 字段 let start = 0, end = 0, text = ''; let textIdx = formatFields.indexOf('text'); if (textIdx < 0) textIdx = formatFields.length - 1; for (let i = 0; i < formatFields.length; i++) { const field = formatFields[i]; let value = ''; if (i === textIdx) { // text 字段可能包含逗号,取剩余所有 value = values.slice(i).join(',').trim(); } else { value = (values[i] || '').trim(); } if (field === 'start') start = this.parseTimeAss(value); else if (field === 'end') end = this.parseTimeAss(value); else if (field === 'text') text = value; } if (start >= 0 && end > start && text) { // 清理ASS标签 let cleanText = text .replace(/\{[^}]*\}/g, '') .replace(/\\N/g, '\n') .replace(/\\n/g, '\n') .replace(/\\h/g, ' ') .trim(); if (cleanText) { cues.push({ s: start, e: end, text: cleanText }); } } } } } return cues; }, parseMicroDVD(text) { const cues = []; const lines = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n'); const fps = 25; // 假设25fps const re = /^\{(\d+)\}\{(\d+)\}(.*)$/; for (const line of lines) { const m = line.match(re); if (m) { const start = parseInt(m[1], 10) / fps; const end = parseInt(m[2], 10) / fps; const cueText = m[3].replace(/\|/g, '\n').trim(); if (cueText) { cues.push({ s: start, e: end, text: cueText }); } } } return cues; }, parseSubtitles(text) { const head = text.slice(0, 2000); let cues = []; if (head.indexOf('[Script Info]') >= 0 || head.indexOf('Dialogue:') >= 0) cues = this.parseASS(text); else if (head.indexOf('WEBVTT') >= 0) cues = this.parseVTT(text); else if (/^\{\d+\}\{\d+\}/m.test(text)) cues = this.parseMicroDVD(text); else cues = this.parseSRT(text); if (!cues.length) cues = this.parseSRT(text); cues.sort((a, b) => a.s - b.s); return cues; }, // ========== 字幕查找 ========== findCue(t) { const c = this.state.cues; const off = this.state.offset; const n = c.length; if (!n) return null; let i = this.state.cursor; if (i >= n) i = n - 1; // 向前查找 if (c[i].e + off < t) { let steps = 0; while (i < n - 1 && c[i].e + off < t) { i++; steps++; if (steps > 60) { // 回退到二分查找 let lo = 0, hi = n - 1; while (lo < hi) { const mid = (lo + hi) >> 1; if (c[mid].e + off < t) lo = mid + 1; else hi = mid; } i = lo; break; } } } // 向后查找 else if (c[i].s + off > t) { let steps = 0; while (i > 0 && c[i].s + off > t) { i--; steps++; if (steps > 60) { let lo = 0, hi = n - 1; while (lo < hi) { const mid = (lo + hi) >> 1; if (c[mid].e + off < t) lo = mid + 1; else hi = mid; } i = lo; break; } } } this.state.cursor = i; if (c[i].s + off <= t && c[i].e + off >= t) return c[i]; return null; }, // ========== 渲染 ========== ensureSubtitleEl() { if (this.subEl && this.subEl.isConnected) return this.subEl; const container = state.takeoverContainer; if (!container) return null; const wrapper = container.querySelector('.vt-takeover-video-wrapper'); if (!wrapper) return null; const el = document.createElement('div'); el.className = 'vt-subtitle-text'; wrapper.appendChild(el); this.subEl = el; this.applySubStyle(); return el; }, applySubStyle() { if (!this.subEl) return; const cfg = this.state.config; const s = this.subEl.style; s.fontSize = cfg.fontSize + 'px'; s.color = cfg.color; s.opacity = String(cfg.opacity); s.textShadow = `0 0 ${cfg.outline}px #000, 0 0 ${cfg.outline * 2}px rgba(0,0,0,.85), 2px 2px 5px rgba(0,0,0,.9)`; s.bottom = Math.max(0, Math.min(90, cfg.posPercent)) + '%'; }, showCue(cue) { const el = this.ensureSubtitleEl(); if (!el) return; const txt = (cue && this.state.visible) ? cue.text : ''; if (el.textContent !== txt) el.textContent = txt; el.style.display = txt ? 'block' : 'none'; }, renderLoop() { const tick = () => { this.state.rafId = requestAnimationFrame(tick); if (document.hidden) return; const video = state.currentVideo; if (!video || !this.state.cues.length) return; const t = video.currentTime; const cue = this.findCue(t); if (cue !== this.state.lastCue) { this.state.lastCue = cue; this.showCue(cue); } }; this.state.rafId = requestAnimationFrame(tick); }, stopRenderLoop() { if (this.state.rafId) { cancelAnimationFrame(this.state.rafId); this.state.rafId = 0; } }, // ========== 加载字幕 ========== setSubtitle(text, name, silent) { try { const cues = this.parseSubtitles(text); if (!cues.length) throw new Error('未解析到有效条目'); this.state.cues = cues; this.state.cursor = 0; this.state.lastCue = null; this.state.raw = text; this.state.name = name || 'subtitle'; this.state.visible = true; if (this.subEl) { this.subEl.style.display = 'block'; this.applySubStyle(); } // 启动渲染循环 if (!this.state.rafId) { this.renderLoop(); } if (!silent) { TakeoverPlayer.showToast('字幕加载成功(' + cues.length + ' 条)'); } return true; } catch (e) { TakeoverPlayer.showToast('字幕加载失败:' + e.message); return false; } }, clearSubtitle() { this.state.cues = []; this.state.cursor = 0; this.state.lastCue = null; this.state.raw = ''; this.state.name = ''; this.state.offset = 0; if (this.subEl) { this.subEl.textContent = ''; this.subEl.style.display = 'none'; } this.stopRenderLoop(); TakeoverPlayer.showToast('字幕已清除'); }, toggleVisible() { this.state.visible = !this.state.visible; if (!this.state.visible && this.subEl) { this.subEl.style.display = 'none'; } else if (this.state.cues.length && this.subEl) { this.showCue(this.state.lastCue); } TakeoverPlayer.showToast(this.state.visible ? '字幕已显示' : '字幕已隐藏'); }, setOffset(seconds) { this.state.offset = seconds; this.state.config.offset = seconds; this.saveConfig(); this.state.cursor = 0; this.state.lastCue = null; }, adjustOffset(delta) { this.setOffset(this.state.offset + delta); TakeoverPlayer.showToast('字幕偏移:' + (this.state.offset > 0 ? '+' : '') + this.state.offset.toFixed(1) + 's'); }, // ========== 本地字幕加载 ========== loadLocalFile() { const input = document.createElement('input'); input.type = 'file'; input.accept = '.srt,.vtt,.ass,.ssa,.sub'; input.style.display = 'none'; document.body.appendChild(input); input.onchange = async (e) => { const f = e.target.files && e.target.files[0]; input.remove(); if (!f) return; try { const buf = await f.arrayBuffer(); const text = this.decodeBuffer(buf); const name = f.name.replace(/\.(srt|ass|ssa|vtt|sub)$/i, ''); if (this.setSubtitle(text, name)) { this.updatePanelSubtitleInfo(); } } catch (err) { TakeoverPlayer.showToast('读取失败:' + err.message); } }; input.click(); }, // ========== 在线搜索 ========== async searchSubtitles(keyword) { if (!keyword) return []; const sources = ['xunlei', 'subtitlecat']; const tasks = sources.map(src => this.searchSource(src, keyword).catch(() => [])); const results = await Promise.all(tasks); let merged = []; results.forEach(arr => arr.forEach(x => merged.push(x))); // 按相似度排序 merged.sort((a, b) => (this.similarity(keyword, b.name || '') - this.similarity(keyword, a.name || ''))); return merged; }, similarity(a, b) { const sa = new Set(String(a || '').toUpperCase().replace(/[^A-Z0-9]/g, '').split('')); const sb = new Set(String(b || '').toUpperCase().replace(/[^A-Z0-9]/g, '').split('')); if (!sa.size || !sb.size) return 0; let inter = 0; sa.forEach(c => { if (sb.has(c)) inter++; }); return inter / (sa.size + sb.size - inter); }, async searchSource(src, keyword) { if (src === 'xunlei') { return this.searchXunlei(keyword); } else if (src === 'subtitlecat') { return this.searchSubtitlecat(keyword); } return []; }, async searchXunlei(keyword) { const url = 'https://api-shoulei-ssl.xunlei.com/oracle/subtitle?name=' + encodeURIComponent(keyword); try { const data = await NetUtil.getJSON(url); const items = []; // 迅雷API返回 { code:0, data: [...] } 结构 const list = data && Array.isArray(data.data) ? data.data : (data && Array.isArray(data.subtitles) ? data.subtitles : []); for (const s of list) { const srtUrl = s.url || s.srt_url || ''; if (/^https?:/i.test(srtUrl) && /\.(srt|ass|ssa|vtt|sub)($|\?)/i.test(srtUrl)) { items.push({ name: s.name || s.title || s.extra_name || '未知', extra: (s.languages && s.languages.join('/')) || s.ext || '', url: srtUrl, source: 'xunlei', }); } } return items; } catch (e) { console.warn('迅雷字幕搜索失败:', e); return []; } }, async searchSubtitlecat(keyword) { const url = 'https://www.subtitlecat.com/index.php?search=' + encodeURIComponent(keyword); try { const html = await NetUtil.getText(url); const items = []; const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html'); // 查找所有字幕详情链接 const links = doc.querySelectorAll('a[href*="subs/"]'); for (const link of links) { const href = link.getAttribute('href') || ''; const name = link.textContent.trim(); // 过滤掉非字幕链接(如导航、分页等) if (!name || !href || href === '#' || href.startsWith('javascript:')) continue; if (name.length < 3) continue; // 太短的跳过 const fullUrl = href.startsWith('http') ? href : 'https://www.subtitlecat.com/' + href.replace(/^\//, ''); // 去重 if (!items.find(it => it.url === fullUrl)) { items.push({ name: name, extra: '字幕猫', url: fullUrl, source: 'subtitlecat', }); } } return items.slice(0, 20); } catch (e) { console.warn('字幕猫搜索失败:', e); return []; } }, async loadSubtitleItem(item) { try { let url = item.url; if (item.source === 'subtitlecat') { // 需要从详情页解析下载链接 const realUrl = await this.resolveSubtitlecat(url); if (!realUrl) throw new Error('未找到中文字幕下载链接'); url = realUrl; } const buf = await NetUtil.getBuffer(url); const text = this.decodeBuffer(buf); if (text.indexOf('-->') < 0 && text.indexOf('[Events]') < 0 && text.indexOf('WEBVTT') < 0) { throw new Error('内容不是有效字幕'); } if (this.setSubtitle(text, item.name)) { this.updatePanelSubtitleInfo(); } } catch (e) { TakeoverPlayer.showToast('加载失败:' + e.message); } }, async resolveSubtitlecat(detailUrl) { try { const html = await NetUtil.getText(detailUrl); const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html'); const links = doc.querySelectorAll('a'); const pick = (tag) => { for (const a of links) { const href = a.getAttribute('href') || ''; const id = a.getAttribute('id') || ''; if (id.indexOf(tag) >= 0 || href.indexOf(tag) >= 0) { const fullUrl = href.startsWith('http') ? href : 'https://www.subtitlecat.com/' + href.replace(/^\//, ''); return fullUrl; } } return null; }; return pick('zh-CN') || pick('zh-TW') || pick('download'); } catch (e) { return null; } }, // ========== 字幕预览 ========== async previewSubtitle(item) { const container = state.takeoverContainer; if (!container) return; // 如果已有预览弹窗,先关闭 const existing = container.querySelector('.vt-sub-preview-modal'); if (existing) existing.remove(); // 创建预览弹窗 const modal = document.createElement('div'); modal.className = 'vt-sub-preview-modal'; modal.innerHTML = `
字幕预览
${item.name}
正在加载字幕...
`; container.appendChild(modal); // 关闭按钮 const closeModal = () => { modal.remove(); }; modal.querySelector('.vt-sub-preview-close').addEventListener('click', closeModal); modal.querySelector('.vt-preview-cancel').addEventListener('click', closeModal); // 点击背景关闭 modal.addEventListener('click', (e) => { if (e.target === modal) closeModal(); }); // 加载按钮 const loadBtn = modal.querySelector('.vt-preview-load'); loadBtn.addEventListener('click', () => { TakeoverPlayer.showToast('正在加载字幕...'); this.loadSubtitleItem(item); closeModal(); this.closePanel(); }); try { // 下载并解析字幕 let url = item.url; if (item.source === 'subtitlecat') { const realUrl = await this.resolveSubtitlecat(url); if (!realUrl) throw new Error('未找到下载链接'); url = realUrl; } const buf = await NetUtil.getBuffer(url); const text = this.decodeBuffer(buf); const cues = this.parseSubtitles(text); if (!cues.length) throw new Error('未解析到有效条目'); // 缓存预览数据供加载按钮使用 this._previewCues = cues; this._previewName = item.name; this._previewText = text; // 渲染预览列表(显示前 30 条) const previewBody = modal.querySelector('.vt-sub-preview-body'); const showCount = Math.min(30, cues.length); previewBody.innerHTML = `
共 ${cues.length} 条字幕,显示前 ${showCount} 条
${cues.slice(0, showCount).map((cue, i) => `
${formatTime(cue.s)} ${cue.text.replace(/\n/g, ' / ')}
`).join('')} ${cues.length > showCount ? '
... 更多内容请加载后查看
' : ''} `; // 启用加载按钮 loadBtn.disabled = false; } catch (e) { const previewBody = modal.querySelector('.vt-sub-preview-body'); previewBody.innerHTML = `
预览失败:${e.message}
`; } }, // 从URL提取番号/关键词 extractKeyword() { try { const segs = location.pathname.split('/').filter(Boolean); const tryMatch = (s) => { const m = String(s || '').match(/^([a-zA-Z]{2,8})[-_]?(\d{2,6})/i); return m ? (m[1].toUpperCase() + '-' + m[2]) : ''; }; if (segs.length) { const a = tryMatch(segs[segs.length - 1]); if (a) return a; } if (segs.length >= 2) { const b = tryMatch(segs[segs.length - 2]); if (b) return b; } return tryMatch(location.pathname); } catch (e) { return ''; } }, // ========== 字幕面板 ========== togglePanel() { if (this.state.panelOpen) { this.closePanel(); } else { this.openPanel(); } }, openPanel() { const container = state.takeoverContainer; if (!container) return; // 如果面板已存在,直接显示 let panel = container.querySelector('.vt-sub-panel'); if (panel) { panel.classList.add('vt-sub-panel-open'); this.state.panelOpen = true; return; } // 创建面板 panel = document.createElement('div'); panel.className = 'vt-sub-panel'; const keyword = this.extractKeyword() || document.title.slice(0, 30); panel.innerHTML = `
字幕设置
当前字幕
未加载字幕
在线搜索
点击搜索查找在线字幕
字幕样式
字号 ${this.state.config.fontSize}px
位置 ${this.state.config.posPercent}%
描边 ${this.state.config.outline}px
时间偏移
${this.state.offset.toFixed(1)}s
`; container.appendChild(panel); this.state.panelOpen = true; // 字幕面板打开时,清除自动隐藏定时器,保持控制栏显示 if (state.takeoverContainer) { TakeoverPlayer.cancelAutoHide(); TakeoverPlayer.showControls(); } // 延迟添加打开类以触发动画 requestAnimationFrame(() => { panel.classList.add('vt-sub-panel-open'); }); // 绑定事件 this.bindPanelEvents(panel); // 更新当前字幕信息 this.updatePanelSubtitleInfo(); }, closePanel() { const container = state.takeoverContainer; if (!container) return; const panel = container.querySelector('.vt-sub-panel'); if (panel) { panel.classList.remove('vt-sub-panel-open'); setTimeout(() => { if (panel.parentNode) panel.remove(); }, 300); } this.state.panelOpen = false; }, bindPanelEvents(panel) { // 关闭按钮 panel.querySelector('.vt-sub-panel-close').addEventListener('click', () => { this.closePanel(); }); // 按钮事件委托 panel.addEventListener('click', (e) => { const btn = e.target.closest('[data-action]'); if (!btn) return; const action = btn.dataset.action; switch (action) { case 'local': this.loadLocalFile(); break; case 'clear': this.clearSubtitle(); this.updatePanelSubtitleInfo(); break; case 'toggle-vis': this.toggleVisible(); break; case 'search': this.doSearch(panel); break; } }); // 偏移按钮 panel.querySelectorAll('[data-offset]').forEach(btn => { btn.addEventListener('click', () => { const delta = parseFloat(btn.dataset.offset); this.adjustOffset(delta); const valEl = panel.querySelector('.vt-sub-offset-value'); if (valEl) valEl.textContent = this.state.offset.toFixed(1) + 's'; }); }); // 样式滑块 panel.querySelectorAll('.vt-sub-slider').forEach(slider => { slider.addEventListener('input', () => { const key = slider.dataset.config; const val = parseFloat(slider.value); this.state.config[key] = val; this.saveConfig(); // 更新显示值 const valEl = slider.parentElement.querySelector('.vt-sub-slider-value'); if (valEl) { valEl.textContent = val + (key === 'fontSize' || key === 'outline' ? 'px' : '%'); } this.applySubStyle(); }); }); // 搜索框回车 const searchInput = panel.querySelector('.vt-sub-search-input'); if (searchInput) { searchInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); this.doSearch(panel); } }); } }, async doSearch(panel) { const input = panel.querySelector('.vt-sub-search-input'); const keyword = input.value.trim(); if (!keyword) { TakeoverPlayer.showToast('请输入搜索关键词'); return; } const resultsEl = panel.querySelector('.vt-sub-search-results'); resultsEl.innerHTML = '
搜索中...
'; try { const results = await this.searchSubtitles(keyword); if (!results.length) { resultsEl.innerHTML = '
未找到相关字幕
'; return; } resultsEl.innerHTML = results.map((item, i) => `
${item.name}
${item.source === 'xunlei' ? '迅雷' : '字幕猫'}${item.extra ? ' · ' + item.extra : ''}
`).join(''); // 缓存结果 this._lastSearchResults = results; // 绑定点击加载(点击条目主体) resultsEl.querySelectorAll('.vt-sub-result-item').forEach(el => { const infoEl = el.querySelector('.vt-sub-result-info'); infoEl.addEventListener('click', () => { const idx = parseInt(el.dataset.index, 10); const item = this._lastSearchResults[idx]; if (item) { TakeoverPlayer.showToast('正在加载字幕...'); this.loadSubtitleItem(item); this.closePanel(); } }); }); // 绑定预览按钮 resultsEl.querySelectorAll('.vt-sub-preview-btn').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); const idx = parseInt(btn.dataset.preview, 10); const item = this._lastSearchResults[idx]; if (item) { this.previewSubtitle(item); } }); }); } catch (e) { resultsEl.innerHTML = '
搜索失败
'; } }, updatePanelSubtitleInfo() { const container = state.takeoverContainer; if (!container) return; const panel = container.querySelector('.vt-sub-panel'); if (!panel) return; const nameEl = panel.querySelector('.vt-sub-name'); const countEl = panel.querySelector('.vt-sub-count'); if (this.state.cues.length) { nameEl.textContent = this.state.name || '已加载字幕'; countEl.textContent = this.state.cues.length + ' 条字幕'; } else { nameEl.textContent = '未加载字幕'; countEl.textContent = ''; } }, // 退出时清理 cleanup() { this.stopRenderLoop(); this.subEl = null; this.state.cues = []; this.state.cursor = 0; this.state.lastCue = null; this.state.panelOpen = false; }, }; // ==================== 样式 ==================== const Styles = { init() { const css = ` /* ========== 悬浮按钮 ========== */ .vt-float-buttons { position: absolute; right: 12px; bottom: 50px; display: flex; gap: 8px; z-index: 1000; opacity: 1; transition: opacity 0.3s ease; pointer-events: auto; } .vt-float-btn { width: 36px; height: 36px; border-radius: 50%; background: rgba(0, 0, 0, 0.6); backdrop-filter: blur(4px); border: none; color: #fff; cursor: pointer; display: flex; align-items: center; justify-content: center; padding: 8px; transition: transform 0.15s, background 0.15s; -webkit-tap-highlight-color: transparent; } .vt-float-btn:active { transform: scale(0.9); background: rgba(0, 0, 0, 0.8); } .vt-float-btn svg { width: 100%; height: 100%; } /* ========== Toast ========== */ .vt-toast { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%) scale(0.9); background: rgba(0, 0, 0, 0.8); color: #fff; padding: 12px 24px; border-radius: 8px; font-size: 14px; z-index: 2147483647; opacity: 0; transition: opacity 0.3s, transform 0.3s; pointer-events: none; } .vt-toast-show { opacity: 1; transform: translate(-50%, -50%) scale(1); } /* ========== 接管全屏容器 ========== */ .vt-takeover-container { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: #000; z-index: 2147483646; overflow: hidden; /* 禁止长按弹出菜单 */ -webkit-touch-callout: none; -webkit-user-select: none; user-select: none; -webkit-tap-highlight-color: transparent; touch-action: manipulation; } .vt-takeover-video-wrapper { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: #000; } .vt-takeover-video-wrapper video { -webkit-touch-callout: none; -webkit-user-select: none; user-select: none; } /* 顶部栏 */ .vt-takeover-top-bar { position: absolute; top: 0; left: 0; right: 0; height: 48px; display: flex; align-items: center; justify-content: space-between; padding: 0 12px; background: linear-gradient(to bottom, rgba(0,0,0,0.7), transparent); z-index: 10; transition: opacity 0.3s; } .vt-top-btn { width: 40px; height: 40px; background: transparent; border: none; color: #fff; cursor: pointer; display: flex; align-items: center; justify-content: center; padding: 8px; -webkit-tap-highlight-color: transparent; } .vt-top-btn svg { width: 24px; height: 24px; } .vt-video-url { flex: 1; text-align: center; color: rgba(255,255,255,0.9); font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 0 8px; } /* 中央控制 */ .vt-takeover-center-controls { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); display: flex; align-items: center; gap: 40px; z-index: 10; transition: opacity 0.3s; } .vt-center-btn { width: 56px; height: 56px; background: rgba(0, 0, 0, 0.5); border: none; color: #fff; border-radius: 50%; cursor: pointer; display: flex; align-items: center; justify-content: center; padding: 12px; -webkit-tap-highlight-color: transparent; transition: transform 0.15s; } .vt-center-btn:active { transform: scale(0.9); } .vt-center-btn svg { width: 100%; height: 100%; } .vt-play-btn { width: 72px; height: 72px; background: rgba(255, 255, 255, 0.2); backdrop-filter: blur(10px); padding: 18px; } /* 进度条 */ .vt-takeover-progress { position: absolute; bottom: 70px; left: 16px; right: 16px; display: flex; align-items: center; gap: 10px; z-index: 10; transition: all 0.3s; } /* 控制栏隐藏时的精简进度条模式 */ .vt-takeover-progress.vt-progress-minimal { bottom: 0; left: 8px; right: 8px; padding: 4px 0; opacity: 0.8; } .vt-takeover-progress.vt-progress-minimal .vt-time { font-size: 11px; min-width: 32px; } .vt-takeover-progress.vt-progress-minimal .vt-progress-bar { height: 16px; } .vt-takeover-progress.vt-progress-minimal .vt-progress-thumb { width: 10px; height: 10px; } .vt-time { color: #fff; font-size: 12px; font-variant-numeric: tabular-nums; min-width: 36px; text-align: center; opacity: 0.9; } .vt-progress-bar { flex: 1; height: 20px; /* 增大触摸热区 */ background: transparent; position: relative; cursor: pointer; display: flex; align-items: center; } .vt-progress-preview { position: absolute; bottom: 24px; transform: translateX(-50%); background: rgba(0,0,0,0.8); border-radius: 6px; padding: 4px; pointer-events: none; z-index: 10; } .vt-preview-canvas { display: block; border-radius: 4px; background: #000; } .vt-preview-time { color: #fff; text-align: center; font-size: 12px; margin-top: 2px; } .vt-progress-bar::before { content: ''; position: absolute; top: 50%; left: 0; right: 0; height: 4px; transform: translateY(-50%); background: rgba(255, 255, 255, 0.3); border-radius: 2px; } .vt-progress-buffered { position: absolute; top: 50%; left: 0; height: 4px; transform: translateY(-50%); background: rgba(255, 255, 255, 0.4); border-radius: 2px; pointer-events: none; } .vt-progress-played { position: absolute; top: 50%; left: 0; height: 4px; transform: translateY(-50%); background: #fff; border-radius: 2px; pointer-events: none; } .vt-progress-thumb { position: absolute; top: 50%; transform: translate(-50%, -50%); width: 12px; height: 12px; background: #fff; border-radius: 50%; pointer-events: none; box-shadow: 0 2px 4px rgba(0,0,0,0.3); } /* 进度条拖动时间预览气泡 */ .vt-seek-bubble { position: absolute; display: none; background: rgba(0, 0, 0, 0.8); color: #fff; font-size: 12px; padding: 4px 10px; border-radius: 4px; white-space: nowrap; z-index: 20; pointer-events: none; font-variant-numeric: tabular-nums; } /* 底部栏 */ .vt-takeover-bottom-bar { position: absolute; bottom: 0; left: 0; right: 0; height: 60px; display: flex; justify-content: space-around; align-items: center; background: linear-gradient(to top, rgba(0,0,0,0.7), transparent); padding: 0 8px; z-index: 10; transition: opacity 0.3s; } .vt-bottom-btn { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px; background: transparent; border: none; color: #fff; cursor: pointer; padding: 6px 12px; font-size: 10px; -webkit-tap-highlight-color: transparent; transition: transform 0.15s; opacity: 0.9; } .vt-bottom-btn:active { transform: scale(0.9); opacity: 1; } .vt-bottom-btn svg { width: 22px; height: 22px; } .vt-btn-label { font-size: 10px; opacity: 0.9; } /* 锁定状态:底部栏背景透明,只保留锁按钮 */ .vt-takeover-container.vt-locked .vt-takeover-bottom-bar { background: transparent; justify-content: flex-end; } .vt-takeover-container.vt-locked .vt-lock-btn { opacity: 0.8; position: relative; z-index: 20; } .vt-takeover-container.vt-locked .vt-lock-btn:active { opacity: 1; } /* 长按解锁时的动画效果 */ .vt-lock-btn.vt-unlock-pressing { animation: vtUnlockPulse 1s ease-in-out infinite; } @keyframes vtUnlockPulse { 0%, 100% { transform: scale(1); opacity: 0.8; } 50% { transform: scale(1.15); opacity: 1; } } /* 倍速菜单 */ .vt-speed-menu { position: absolute; bottom: 50px; left: 50%; transform: translateX(-50%); background: rgba(0, 0, 0, 0.7); backdrop-filter: blur(10px); border-radius: 12px; padding: 8px 6px; display: flex; flex-direction: column; gap: 2px; z-index: 20; box-shadow: 0 4px 16px rgba(0,0,0,0.2); min-width: 60px; } .vt-settings-menu { position: absolute; top: 42px; right: 0; background: rgba(0, 0, 0, 0.7); backdrop-filter: blur(10px); border-radius: 12px; padding: 8px; display: flex; flex-direction: column; gap: 2px; z-index: 30; box-shadow: 0 4px 16px rgba(0,0,0,0.2); min-width: 170px; } .vt-settings-item { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 7px 10px; border-radius: 8px; cursor: pointer; color: #fff; font-size: 13px; transition: background 0.15s; } .vt-settings-item:hover { background: rgba(255,255,255,0.12); } .vt-settings-toggle { padding: 2px 10px; border-radius: 10px; background: rgba(255,255,255,0.2); font-size: 12px; font-variant-numeric: tabular-nums; } .vt-settings-item.vt-settings-sub { flex-direction: column; align-items: flex-start; gap: 6px; cursor: default; } .vt-settings-item.vt-settings-sub:hover { background: transparent; } .vt-settings-options { display: flex; gap: 4px; width: 100%; } .vt-settings-option { flex: 1; text-align: center; padding: 4px 6px; border-radius: 8px; background: rgba(255,255,255,0.15); cursor: pointer; font-size: 12px; transition: background 0.15s; font-variant-numeric: tabular-nums; } .vt-settings-option:hover, .vt-settings-option.vt-settings-active { background: rgba(255,255,255,0.3); } .vt-quality-menu { position: absolute; bottom: 50px; left: 50%; transform: translateX(-50%); background: rgba(0, 0, 0, 0.7); backdrop-filter: blur(10px); border-radius: 12px; padding: 8px 6px; display: flex; flex-direction: column; gap: 2px; z-index: 20; box-shadow: 0 4px 16px rgba(0,0,0,0.2); min-width: 80px; } .vt-quality-option { padding: 6px 12px; color: #fff; font-size: 13px; text-align: center; border-radius: 6px; cursor: pointer; white-space: nowrap; } .vt-quality-option:hover { background: rgba(255,255,255,0.2); } .vt-speed-option { padding: 8px 12px; color: #fff; font-size: 13px; cursor: pointer; border-radius: 8px; transition: background 0.15s; text-align: center; font-variant-numeric: tabular-nums; } .vt-speed-option:active, .vt-speed-option.vt-speed-active { background: rgba(255,255,255,0.25); } /* 长按提示(倍速调节) */ .vt-longpress-indicator { position: absolute; top: 12px; left: 50%; transform: translateX(-50%); background: rgba(0, 0, 0, 0.6); color: #fff; padding: 6px 14px; border-radius: 16px; z-index: 50; display: none; align-items: center; justify-content: center; pointer-events: none; backdrop-filter: blur(4px); } .vt-lp-rate { font-size: 15px; font-weight: 600; line-height: 1; color: #fff; font-variant-numeric: tabular-nums; } .vt-lp-hint { display: none; } /* 视频加载转圈 */ .vt-loading { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); z-index: 40; display: none; align-items: center; justify-content: center; pointer-events: none; } .vt-loading-spinner { width: 48px; height: 48px; border: 3px solid rgba(255, 255, 255, 0.3); border-top-color: #fff; border-radius: 50%; animation: vtSpin 0.8s linear infinite; } @keyframes vtSpin { to { transform: rotate(360deg); } } /* 音量 / 亮度提示 */ .vt-vertical-indicator { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); background: rgba(0, 0, 0, 0.62); color: #fff; padding: 12px 16px; border-radius: 12px; z-index: 60; display: none; flex-direction: column; align-items: center; gap: 8px; pointer-events: none; backdrop-filter: blur(4px); min-width: 96px; } .vt-vi-icon { font-size: 22px; line-height: 1; } .vt-vi-value { font-size: 15px; font-weight: 600; line-height: 1; font-variant-numeric: tabular-nums; } .vt-vi-bar { width: 84px; height: 4px; border-radius: 2px; background: rgba(255, 255, 255, 0.28); overflow: hidden; } .vt-vi-bar-fill { height: 100%; background: #fff; border-radius: 2px; } /* 跳转提示 */ .vt-seek-indicator { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); background: rgba(0, 0, 0, 0.6); color: #fff; padding: 8px 16px; border-radius: 4px; font-size: 18px; font-weight: 600; z-index: 50; display: none; pointer-events: none; } .vt-seek-anim { animation: vtSeekFade 0.8s ease-out forwards; } @keyframes vtSeekFade { 0% { opacity: 0; transform: translate(-50%, -50%) scale(0.8); } 20% { opacity: 1; transform: translate(-50%, -50%) scale(1); } 80% { opacity: 1; transform: translate(-50%, -50%) scale(1); } 100% { opacity: 0; transform: translate(-50%, -50%) scale(0.9); } } /* 滑动进度预览 */ .vt-seek-preview { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); background: rgba(0, 0, 0, 0.75); color: #fff; padding: 16px 24px; border-radius: 8px; z-index: 60; display: none; pointer-events: none; min-width: 180px; text-align: center; } .vt-seek-preview-time { font-size: 18px; font-weight: 600; margin-bottom: 10px; font-variant-numeric: tabular-nums; } .vt-seek-preview-bar { width: 100%; height: 4px; background: rgba(255, 255, 255, 0.3); border-radius: 2px; overflow: hidden; } .vt-seek-preview-progress { height: 100%; background: #fff; border-radius: 2px; transition: width 0.05s linear; } /* 横屏模式优化 */ @media (orientation: landscape) { .vt-takeover-bottom-bar { height: 56px; } .vt-center-btn { width: 48px; height: 48px; } .vt-play-btn { width: 64px; height: 64px; } } /* CSS旋转竖屏模式:原生方向锁不可用时的回退 */ .vt-takeover-container.vt-portrait-css { transform: rotate(90deg) translateZ(0); transform-origin: center center; width: 100vh; height: 100vw; position: fixed; top: 50%; left: 50%; margin-left: -50vh; margin-top: -50vw; z-index: 2147483647; } .vt-takeover-container.vt-portrait-css .vt-takeover-bottom-bar { bottom: 70px; height: 64px; background: rgba(0,0,0,0.6); border-radius: 12px; margin: 0 12px; } .vt-takeover-container.vt-portrait-css .vt-takeover-progress { bottom: 134px; } /* CSS模式横屏(CSS模式默认状态,长按切换到CSS模式时使用) */ .vt-takeover-container.vt-css-landscape { transform: rotate(90deg) translateZ(0); transform-origin: center center; width: 100vh; height: 100vw; position: fixed; top: 50%; left: 50%; margin-left: -50vh; margin-top: -50vw; z-index: 2147483647; } /* ========== 字幕文字 ========== */ .vt-subtitle-text { position: absolute; left: 50%; transform: translateX(-50%); z-index: 5; max-width: 85%; text-align: center; padding: 4px 10px; border-radius: 5px; pointer-events: none; background: transparent; white-space: pre-wrap; line-height: 1.35; display: none; font-family: 'Microsoft YaHei', 'PingFang SC', 'Hiragino Sans GB', sans-serif; font-weight: 500; word-break: break-word; } /* ========== 字幕面板 ========== */ .vt-sub-panel { position: absolute; bottom: 0; left: 0; right: 0; background: rgba(20, 20, 20, 0.95); backdrop-filter: blur(10px); border-radius: 16px 16px 0 0; z-index: 100; transform: translateY(100%); transition: transform 0.3s ease; max-height: 80%; display: flex; flex-direction: column; color: #fff; } .vt-sub-panel.vt-sub-panel-open { transform: translateY(0); } .vt-sub-panel-header { display: flex; align-items: center; justify-content: space-between; padding: 14px 16px; border-bottom: 1px solid rgba(255, 255, 255, 0.1); flex-shrink: 0; } .vt-sub-panel-title { font-size: 16px; font-weight: 600; } .vt-sub-panel-close { width: 32px; height: 32px; border-radius: 50%; background: rgba(255, 255, 255, 0.1); border: none; color: #fff; font-size: 14px; cursor: pointer; display: flex; align-items: center; justify-content: center; -webkit-tap-highlight-color: transparent; } .vt-sub-panel-content { overflow-y: auto; padding: 12px 16px 24px; flex: 1; } .vt-sub-section { margin-bottom: 20px; } .vt-sub-section-title { font-size: 13px; color: rgba(255, 255, 255, 0.6); margin-bottom: 10px; font-weight: 500; } .vt-sub-info { margin-bottom: 10px; } .vt-sub-name { font-size: 14px; color: #fff; margin-bottom: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .vt-sub-count { font-size: 12px; color: rgba(255, 255, 255, 0.5); } .vt-sub-actions { display: flex; gap: 8px; flex-wrap: wrap; } .vt-sub-btn { padding: 8px 14px; border-radius: 8px; border: none; font-size: 13px; cursor: pointer; -webkit-tap-highlight-color: transparent; transition: opacity 0.15s; } .vt-sub-btn:active { opacity: 0.7; } .vt-sub-btn-primary { background: #64b4ff; color: #fff; } .vt-sub-btn-secondary { background: rgba(255, 255, 255, 0.15); color: #fff; } .vt-sub-btn-danger { background: rgba(255, 80, 80, 0.2); color: #ff6b6b; } /* 搜索 */ .vt-sub-search { display: flex; gap: 8px; margin-bottom: 10px; } .vt-sub-search-input { flex: 1; padding: 10px 12px; background: rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.15); border-radius: 8px; color: #fff; font-size: 14px; outline: none; } .vt-sub-search-input::placeholder { color: rgba(255, 255, 255, 0.4); } .vt-sub-search-results { max-height: 200px; overflow-y: auto; border-radius: 8px; background: rgba(255, 255, 255, 0.05); } .vt-sub-search-hint { padding: 20px; text-align: center; color: rgba(255, 255, 255, 0.4); font-size: 13px; } .vt-sub-result-item { padding: 10px 12px; border-bottom: 1px solid rgba(255, 255, 255, 0.08); cursor: pointer; -webkit-tap-highlight-color: transparent; display: flex; align-items: center; justify-content: space-between; gap: 8px; } .vt-sub-result-item:last-child { border-bottom: none; } .vt-sub-result-info { flex: 1; min-width: 0; } .vt-sub-result-info:active { opacity: 0.6; } .vt-sub-result-name { font-size: 13px; color: #fff; margin-bottom: 3px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .vt-sub-result-extra { font-size: 11px; color: rgba(255, 255, 255, 0.4); } .vt-sub-preview-btn { flex-shrink: 0; padding: 5px 12px; background: rgba(100, 180, 255, 0.2); color: #64b4ff; border: 1px solid rgba(100, 180, 255, 0.3); border-radius: 6px; font-size: 12px; cursor: pointer; -webkit-tap-highlight-color: transparent; } .vt-sub-preview-btn:active { background: rgba(100, 180, 255, 0.35); } /* 滑块 */ .vt-sub-slider-row { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; font-size: 13px; color: rgba(255, 255, 255, 0.8); } .vt-sub-slider-row > span:first-child { min-width: 36px; } .vt-sub-slider { flex: 1; height: 4px; -webkit-appearance: none; appearance: none; background: rgba(255, 255, 255, 0.2); border-radius: 2px; outline: none; } .vt-sub-slider::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 16px; height: 16px; border-radius: 50%; background: #64b4ff; cursor: pointer; } .vt-sub-slider-value { min-width: 40px; text-align: right; font-size: 12px; color: rgba(255, 255, 255, 0.6); font-variant-numeric: tabular-nums; } /* 偏移 */ .vt-sub-offset-row { display: flex; align-items: center; justify-content: space-between; gap: 6px; } .vt-sub-offset-row .vt-sub-btn { padding: 6px 10px; font-size: 12px; flex: 1; } .vt-sub-offset-value { min-width: 50px; text-align: center; font-size: 13px; color: #fff; font-weight: 500; font-variant-numeric: tabular-nums; } /* ========== 字幕预览弹窗 ========== */ .vt-sub-preview-modal { position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.7); z-index: 200; display: flex; align-items: center; justify-content: center; padding: 20px; } .vt-sub-preview-content { background: rgba(30, 30, 30, 0.98); border-radius: 12px; width: 100%; max-width: 400px; max-height: 80%; display: flex; flex-direction: column; overflow: hidden; } .vt-sub-preview-header { display: flex; align-items: center; justify-content: space-between; padding: 14px 16px; border-bottom: 1px solid rgba(255, 255, 255, 0.1); flex-shrink: 0; } .vt-sub-preview-title { font-size: 16px; font-weight: 600; color: #fff; } .vt-sub-preview-close { width: 32px; height: 32px; border-radius: 50%; background: rgba(255, 255, 255, 0.1); border: none; color: #fff; font-size: 14px; cursor: pointer; display: flex; align-items: center; justify-content: center; -webkit-tap-highlight-color: transparent; } .vt-sub-preview-name { padding: 10px 16px; font-size: 13px; color: rgba(255, 255, 255, 0.7); border-bottom: 1px solid rgba(255, 255, 255, 0.06); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex-shrink: 0; } .vt-sub-preview-body { flex: 1; overflow-y: auto; padding: 12px 16px; } .vt-sub-preview-loading { text-align: center; color: rgba(255, 255, 255, 0.5); padding: 30px 0; font-size: 14px; } .vt-sub-preview-error { text-align: center; color: #ff6b6b; padding: 30px 0; font-size: 13px; } .vt-sub-preview-count { font-size: 12px; color: rgba(255, 255, 255, 0.4); margin-bottom: 10px; text-align: center; } .vt-sub-preview-line { display: flex; gap: 10px; padding: 6px 0; border-bottom: 1px solid rgba(255, 255, 255, 0.05); font-size: 13px; line-height: 1.4; } .vt-sub-preview-line:last-child { border-bottom: none; } .vt-sub-preview-time { flex-shrink: 0; color: #64b4ff; font-size: 11px; font-variant-numeric: tabular-nums; padding-top: 2px; min-width: 45px; } .vt-sub-preview-text { flex: 1; color: #fff; word-break: break-all; min-width: 0; } .vt-sub-preview-more { text-align: center; color: rgba(255, 255, 255, 0.3); font-size: 12px; padding: 12px 0; } .vt-sub-preview-footer { display: flex; gap: 10px; padding: 12px 16px; border-top: 1px solid rgba(255, 255, 255, 0.1); flex-shrink: 0; } .vt-sub-preview-footer .vt-sub-btn { flex: 1; padding: 10px; font-size: 14px; } .vt-sub-preview-footer .vt-sub-btn[disabled] { opacity: 0.4; cursor: not-allowed; } `; GM_addStyle(css); }, }; // ==================== 初始化 ==================== function init() { // 初始化样式 Styles.init(); // 初始化视频检测 VideoDetector.init(); console.log('[Video Takeover Mobile] 移动端网页视频接管已加载'); } // 启动 if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();