// ==UserScript== // @name 视频网页全屏按钮 // @namespace https://tampermonkey.net/ // @version 1.0.1 // @description 检测页面视频元素,为正在播放/暂停的视频提供“网页全屏(CSS全屏)”按钮;鼠标移入视频显示、移出隐藏;支持多视频;Esc 退出。 // @author YupegLV // @match *://*/* // @grant GM_addStyle // @run-at document-idle // @license MIT // @downloadURL https://update.greasyfork.icu/scripts/566152/%E8%A7%86%E9%A2%91%E7%BD%91%E9%A1%B5%E5%85%A8%E5%B1%8F%E6%8C%89%E9%92%AE.user.js // @updateURL https://update.greasyfork.icu/scripts/566152/%E8%A7%86%E9%A2%91%E7%BD%91%E9%A1%B5%E5%85%A8%E5%B1%8F%E6%8C%89%E9%92%AE.meta.js // ==/UserScript== /** * @fileoverview 视频网页全屏(非浏览器 Fullscreen API)油猴脚本 * @author YupegLV * @date 2026-02-13 15:42 */ (function () { 'use strict'; /** * @description 样式:悬浮按钮 + 网页全屏遮罩层 * @returns {void} */ GM_addStyle(` .tm-webfs-btn { position: fixed; z-index: 2147483647; display: none; padding: 6px 10px; font-size: 12px; line-height: 1; border: 1px solid rgba(255,255,255,.35); border-radius: 8px; background: rgba(0,0,0,.55); color: #fff; cursor: pointer; user-select: none; -webkit-user-select: none; backdrop-filter: blur(6px); touch-action: manipulation; } .tm-webfs-btn:hover { background: rgba(0,0,0,.75); border-color: rgba(255,255,255,.55); } .tm-webfs-overlay { position: fixed; inset: 0; z-index: 2147483646; display: none; background: #000; align-items: stretch; justify-content: stretch; } .tm-webfs-stage { position: relative; width: 100vw; height: 100vh; background: #000; } .tm-webfs-video { width: 100% !important; height: 100% !important; max-width: 100% !important; max-height: 100% !important; object-fit: contain !important; background: #000 !important; } .tm-webfs-controls { position: absolute; left: 0; right: 0; bottom: 0; z-index: 2147483647; display: flex; gap: 10px; align-items: center; padding: 10px 12px; background: linear-gradient(to top, rgba(0,0,0,.75), rgba(0,0,0,0)); color: #fff; font-size: 12px; box-sizing: border-box; pointer-events: auto; } .tm-webfs-controls button { appearance: none; -webkit-appearance: none; border: 1px solid rgba(255,255,255,.35); background: rgba(0,0,0,.35); color: #fff; border-radius: 8px; padding: 6px 10px; cursor: pointer; line-height: 1; user-select: none; } .tm-webfs-controls button:hover { background: rgba(0,0,0,.55); border-color: rgba(255,255,255,.55); } .tm-webfs-controls input[type="range"] { flex: 1 1 auto; min-width: 80px; } .tm-webfs-controls .tm-webfs-time { flex: 0 0 auto; font-variant-numeric: tabular-nums; opacity: .95; white-space: nowrap; } .tm-webfs-controls .tm-webfs-volume { flex: 0 0 120px; min-width: 90px; } `); /** * @typedef {Object} WebFsState * @property {HTMLVideoElement|null} video 当前网页全屏的视频 * @property {HTMLElement|null} placeholder 用于还原 DOM 的占位(需占据原视频空间,避免滚动跳动) * @property {Node|null} originalParent 原父节点 * @property {Node|null} originalNextSibling 原 nextSibling * @property {string|null} originalStyle 原 style attribute * @property {boolean|null} originalMuted 原 muted * @property {number|null} originalVolume 原 volume * @property {boolean|null} originalControls 原 controls */ /** @type {HTMLVideoElement|null} */ let hoveredVideo = null; /** @type {HTMLVideoElement|null} */ let btnVideo = null; /** @type {HTMLDivElement|null} */ let overlayEl = null; /** @type {HTMLDivElement|null} */ let stageEl = null; /** @type {HTMLButtonElement|null} */ let btnEl = null; /** @type {boolean} */ let isHoveringBtn = false; /** @type {HTMLDivElement|null} */ let controlsEl = null; /** @type {HTMLButtonElement|null} */ let controlsPlayBtn = null; /** @type {HTMLButtonElement|null} */ let controlsMuteBtn = null; /** @type {HTMLInputElement|null} */ let controlsProgress = null; /** @type {HTMLInputElement|null} */ let controlsVolume = null; /** @type {HTMLSpanElement|null} */ let controlsTime = null; /** @type {boolean} */ let isScrubbing = false; /** @type {WebFsState} */ const webFsState = { video: null, placeholder: null, originalParent: null, originalNextSibling: null, originalStyle: null, originalMuted: null, originalVolume: null, originalControls: null, }; /** * @typedef {Object} ScrollLockState * @property {boolean} locked 是否已锁定 * @property {number} x 锁定时 scrollX * @property {number} y 锁定时 scrollY * @property {string} htmlOverflow 原 html overflow * @property {string} bodyPosition 原 body position * @property {string} bodyTop 原 body top * @property {string} bodyLeft 原 body left * @property {string} bodyWidth 原 body width * @property {string} bodyOverflow 原 body overflow * @property {string} bodyPaddingRight 原 body padding-right */ /** @type {ScrollLockState} */ const scrollLockState = { locked: false, x: 0, y: 0, htmlOverflow: '', bodyPosition: '', bodyTop: '', bodyLeft: '', bodyWidth: '', bodyOverflow: '', bodyPaddingRight: '', }; /** * @description 锁定页面滚动并记录当前位置(避免进入/退出网页全屏时滚动跳顶) * @returns {void} */ function lockScroll() { if (scrollLockState.locked) return; scrollLockState.locked = true; scrollLockState.x = window.scrollX || 0; scrollLockState.y = window.scrollY || 0; const html = document.documentElement; const body = document.body; if (!html || !body) return; const sbw = Math.max(0, window.innerWidth - html.clientWidth); scrollLockState.htmlOverflow = html.style.overflow; scrollLockState.bodyPosition = body.style.position; scrollLockState.bodyTop = body.style.top; scrollLockState.bodyLeft = body.style.left; scrollLockState.bodyWidth = body.style.width; scrollLockState.bodyOverflow = body.style.overflow; scrollLockState.bodyPaddingRight = body.style.paddingRight; html.style.overflow = 'hidden'; body.style.overflow = 'hidden'; body.style.position = 'fixed'; body.style.top = `-${scrollLockState.y}px`; body.style.left = `-${scrollLockState.x}px`; body.style.width = '100%'; if (sbw > 0) body.style.paddingRight = `${sbw}px`; } /** * @description 解锁页面滚动并恢复到锁定前的位置 * @returns {void} */ function unlockScroll() { if (!scrollLockState.locked) return; scrollLockState.locked = false; const html = document.documentElement; const body = document.body; if (html) html.style.overflow = scrollLockState.htmlOverflow; if (body) { body.style.position = scrollLockState.bodyPosition; body.style.top = scrollLockState.bodyTop; body.style.left = scrollLockState.bodyLeft; body.style.width = scrollLockState.bodyWidth; body.style.overflow = scrollLockState.bodyOverflow; body.style.paddingRight = scrollLockState.bodyPaddingRight; } window.scrollTo(scrollLockState.x, scrollLockState.y); } /** * @description 创建占位元素(等尺寸)以维持原布局高度,避免滚动位置变化 * @param {HTMLVideoElement} video 视频元素 * @returns {HTMLElement} 占位元素 */ function createPlaceholder(video) { const rect = video.getBoundingClientRect(); const cs = window.getComputedStyle(video); const ph = document.createElement('div'); ph.setAttribute('data-tm-webfs-placeholder', '1'); ph.style.width = `${Math.max(0, rect.width)}px`; ph.style.height = `${Math.max(0, rect.height)}px`; ph.style.minWidth = ph.style.width; ph.style.minHeight = ph.style.height; ph.style.display = cs.display === 'inline' ? 'inline-block' : cs.display; ph.style.marginTop = cs.marginTop; ph.style.marginRight = cs.marginRight; ph.style.marginBottom = cs.marginBottom; ph.style.marginLeft = cs.marginLeft; ph.style.padding = '0'; ph.style.border = '0'; ph.style.background = 'transparent'; ph.style.pointerEvents = 'none'; return ph; } /** * @description 是否为 X.com(或 Twitter)站点 * @returns {boolean} 是否命中 */ function isXSite() { const host = String(window.location.hostname || '').toLowerCase(); return host === 'x.com' || host.endsWith('.x.com') || host === 'twitter.com' || host.endsWith('.twitter.com'); } /** * @description 判断一个视频是否属于“正在播放/暂停”的候选(未结束且具备一定可播放信息) * @param {HTMLVideoElement} video 视频元素 * @returns {boolean} 是否应允许显示按钮 */ function isCandidateVideo(video) { if (!(video instanceof HTMLVideoElement)) return false; if (video.ended) return false; // X.com 等站点可能在播放/暂停切换时由上层元素接收事件,但 video 仍可用 // networkState===0 表示 NETWORK_EMPTY(通常是没有任何资源/未初始化) return video.readyState > 0 || !!video.currentSrc || !video.paused || video.networkState !== 0; } /** * @description 秒数格式化为 mm:ss 或 hh:mm:ss * @param {number} sec 秒 * @returns {string} 格式化后的时间 */ function formatTime(sec) { if (!Number.isFinite(sec) || sec < 0) return '00:00'; const s = Math.floor(sec); const hh = Math.floor(s / 3600); const mm = Math.floor((s % 3600) / 60); const ss = s % 60; const pad2 = (n) => String(n).padStart(2, '0'); if (hh > 0) return `${hh}:${pad2(mm)}:${pad2(ss)}`; return `${pad2(mm)}:${pad2(ss)}`; } /** * @description 获取或创建网页全屏遮罩层 * @returns {HTMLDivElement} 遮罩层元素 */ function getOrCreateOverlay() { if (overlayEl && overlayEl.isConnected && stageEl && stageEl.isConnected) return overlayEl; overlayEl = document.createElement('div'); overlayEl.className = 'tm-webfs-overlay'; stageEl = document.createElement('div'); stageEl.className = 'tm-webfs-stage'; overlayEl.appendChild(stageEl); document.body.appendChild(overlayEl); // 网页全屏:点击舞台任意位置切换播放/暂停(不影响控制栏与悬浮按钮) stageEl.addEventListener( 'click', (e) => { const v = webFsState.video; if (!v) return; const target = e.target instanceof Node ? e.target : null; if (target) { if (btnEl && (target === btnEl || btnEl.contains(target))) return; if (controlsEl && controlsEl.contains(target)) return; } if (v.paused) { void v.play(); } else { v.pause(); } }, true ); return overlayEl; } /** * @description 获取或创建网页全屏控制栏(网页全屏通用) * @returns {HTMLDivElement} 控制栏容器 */ function getOrCreateControls() { if (controlsEl && controlsEl.isConnected) return controlsEl; if (!stageEl) getOrCreateOverlay(); if (!stageEl) throw new Error('tm-webfs: stage not ready'); controlsEl = document.createElement('div'); controlsEl.className = 'tm-webfs-controls'; controlsPlayBtn = document.createElement('button'); controlsPlayBtn.type = 'button'; controlsPlayBtn.textContent = '播放'; controlsMuteBtn = document.createElement('button'); controlsMuteBtn.type = 'button'; controlsMuteBtn.textContent = '静音'; controlsProgress = document.createElement('input'); controlsProgress.type = 'range'; controlsProgress.min = '0'; controlsProgress.max = '1000'; controlsProgress.step = '1'; controlsProgress.value = '0'; controlsTime = document.createElement('span'); controlsTime.className = 'tm-webfs-time'; controlsTime.textContent = '00:00 / 00:00'; controlsVolume = document.createElement('input'); controlsVolume.className = 'tm-webfs-volume'; controlsVolume.type = 'range'; controlsVolume.min = '0'; controlsVolume.max = '1'; controlsVolume.step = '0.01'; controlsVolume.value = '1'; controlsEl.appendChild(controlsPlayBtn); controlsEl.appendChild(controlsProgress); controlsEl.appendChild(controlsTime); controlsEl.appendChild(controlsMuteBtn); controlsEl.appendChild(controlsVolume); stageEl.appendChild(controlsEl); // 避免控制栏覆盖导致 “hover 不在视频上” 时按钮闪烁:鼠标在控制栏上时不触发视频命中逻辑 controlsEl.addEventListener( 'pointerenter', () => { isHoveringBtn = true; }, { passive: true } ); controlsEl.addEventListener( 'pointerleave', () => { isHoveringBtn = false; }, { passive: true } ); // 控制栏交互 controlsPlayBtn.addEventListener( 'click', () => { const v = webFsState.video; if (!v) return; if (v.paused) void v.play(); else v.pause(); }, true ); controlsMuteBtn.addEventListener( 'click', () => { const v = webFsState.video; if (!v) return; v.muted = !v.muted; if (!v.muted && v.volume === 0) v.volume = 1; syncControlsFromVideo(); }, true ); controlsProgress.addEventListener( 'pointerdown', () => { isScrubbing = true; }, { passive: true } ); controlsProgress.addEventListener( 'pointerup', () => { isScrubbing = false; syncControlsFromVideo(); }, { passive: true } ); controlsProgress.addEventListener( 'input', () => { const v = webFsState.video; if (!v) return; const dur = Number(v.duration); if (!Number.isFinite(dur) || dur <= 0) return; const ratio = Number(controlsProgress.value) / 1000; v.currentTime = Math.max(0, Math.min(dur, dur * ratio)); syncControlsFromVideo(true); }, { passive: true } ); controlsVolume.addEventListener( 'input', () => { const v = webFsState.video; if (!v) return; const next = Number(controlsVolume.value); if (!Number.isFinite(next)) return; v.volume = Math.max(0, Math.min(1, next)); if (v.volume > 0) v.muted = false; syncControlsFromVideo(); }, { passive: true } ); return controlsEl; } /** * @description 同步控制栏状态(播放/暂停、进度、音量、时间) * @param {boolean} [forceTimeOnly] 是否仅刷新时间显示(用于拖动进度条时减少跳动) * @returns {void} */ function syncControlsFromVideo(forceTimeOnly) { const v = webFsState.video; if (!v || !controlsEl || !controlsPlayBtn || !controlsMuteBtn || !controlsProgress || !controlsVolume || !controlsTime) { return; } const cur = Number(v.currentTime); const dur = Number(v.duration); controlsTime.textContent = `${formatTime(cur)} / ${formatTime(dur)}`; if (forceTimeOnly !== true) { controlsPlayBtn.textContent = v.paused ? '播放' : '暂停'; controlsMuteBtn.textContent = v.muted || v.volume === 0 ? '取消静音' : '静音'; controlsVolume.value = String(v.muted ? 0 : v.volume); } if (!isScrubbing && Number.isFinite(dur) && dur > 0) { const ratio = Math.max(0, Math.min(1, cur / dur)); controlsProgress.value = String(Math.round(ratio * 1000)); } } /** * @description 绑定网页全屏视频的事件,用于实时刷新控制栏 * @param {HTMLVideoElement} video 视频元素 * @returns {void} */ function bindControlsToVideo(video) { // 进入网页全屏时才会绑定;绑定前先创建控件 getOrCreateControls(); syncControlsFromVideo(); const refresh = () => syncControlsFromVideo(); const refreshTime = () => syncControlsFromVideo(true); video.addEventListener('timeupdate', refreshTime, { passive: true }); video.addEventListener('durationchange', refresh, { passive: true }); video.addEventListener('loadedmetadata', refresh, { passive: true }); video.addEventListener('play', refresh, { passive: true }); video.addEventListener('pause', refresh, { passive: true }); video.addEventListener('volumechange', refresh, { passive: true }); // 在退出网页全屏时通过移除 controlsEl 来间接停止交互;事件监听会随视频回原 DOM 保留,但刷新是幂等且轻量 } /** * @description 销毁网页全屏控制栏(仅用于 X.com) * @returns {void} */ function destroyControls() { if (controlsEl && controlsEl.parentNode) controlsEl.parentNode.removeChild(controlsEl); controlsEl = null; controlsPlayBtn = null; controlsMuteBtn = null; controlsProgress = null; controlsVolume = null; controlsTime = null; isScrubbing = false; } /** * @description 获取悬浮按钮定位点(视频右上角附近),并做简单边界夹取 * @param {DOMRect} rect 视频的 bounding rect * @returns {{ top: number, left: number }} 按钮位置 */ function calcBtnPosition(rect) { const pad = 8; const top = Math.max(pad, Math.min(window.innerHeight - pad, rect.top + pad)); const left = Math.max(pad, Math.min(window.innerWidth - pad, rect.right - pad)); return { top, left }; } /** * @description 获取或创建悬浮按钮 * @returns {HTMLButtonElement} 悬浮按钮 */ function getOrCreateBtn() { if (btnEl && btnEl.isConnected) return btnEl; btnEl = document.createElement('button'); btnEl.type = 'button'; btnEl.className = 'tm-webfs-btn'; btnEl.textContent = '网页全屏'; btnEl.addEventListener( 'click', (e) => { e.preventDefault(); e.stopPropagation(); const target = btnVideo || hoveredVideo || webFsState.video; if (!target) return; toggleWebFullscreen(target); }, true ); btnEl.addEventListener( 'mouseenter', () => { isHoveringBtn = true; }, { passive: true } ); btnEl.addEventListener( 'mouseleave', () => { isHoveringBtn = false; // 鼠标离开按钮时,如果此时不在任何视频上,立刻隐藏 if (!hoveredVideo) hideBtn(); }, { passive: true } ); document.body.appendChild(btnEl); return btnEl; } /** * @description 显示并更新按钮位置 * @param {HTMLVideoElement} video 视频元素 * @returns {void} */ function showBtn(video) { const btn = getOrCreateBtn(); // 仅对候选视频显示按钮;但若该视频正在网页全屏,则允许显示“退出”按钮 if (!isCandidateVideo(video) && webFsState.video !== video) { hideBtn(); return; } const rect = video.getBoundingClientRect(); if (!rect || rect.width <= 0 || rect.height <= 0) { hideBtn(); return; } const pos = calcBtnPosition(rect); btn.style.top = `${pos.top}px`; btn.style.left = `${pos.left}px`; btn.style.transform = 'translateX(-100%)'; btn.style.display = 'block'; btnVideo = video; btn.textContent = webFsState.video === video ? '退出网页全屏' : '网页全屏'; } /** * @description 隐藏按钮 * @returns {void} */ function hideBtn() { const btn = getOrCreateBtn(); btn.style.display = 'none'; btnVideo = null; } /** * @description 进入网页全屏(CSS 全屏):将视频移入遮罩层并铺满视口 * @param {HTMLVideoElement} video 视频元素 * @returns {void} */ function enterWebFullscreen(video) { if (webFsState.video && webFsState.video !== video) { exitWebFullscreen(); } getOrCreateOverlay(); if (!overlayEl || !stageEl) return; webFsState.originalParent = video.parentNode; webFsState.originalNextSibling = video.nextSibling; webFsState.originalStyle = video.getAttribute('style'); webFsState.originalMuted = video.muted; webFsState.originalVolume = video.volume; webFsState.originalControls = video.controls; webFsState.placeholder = createPlaceholder(video); if (webFsState.originalParent) { webFsState.originalParent.insertBefore(webFsState.placeholder, webFsState.originalNextSibling); } lockScroll(); overlayEl.style.display = 'flex'; stageEl.appendChild(video); video.classList.add('tm-webfs-video'); webFsState.video = video; showBtn(video); // 网页全屏:提供进度条+音量控制(通用);X.com 优先默认打开音量 video.controls = false; if (isXSite()) { video.muted = false; if (Number.isFinite(video.volume) && video.volume === 0) video.volume = 1; } bindControlsToVideo(video); syncControlsFromVideo(); } /** * @description 退出网页全屏(CSS 全屏):将视频还原回原 DOM 位置 * @returns {void} */ function exitWebFullscreen() { if (!webFsState.video) return; const video = webFsState.video; video.classList.remove('tm-webfs-video'); // 先销毁控制栏,避免还原 DOM 后遮挡原页面 destroyControls(); if (webFsState.originalStyle === null) video.removeAttribute('style'); else video.setAttribute('style', webFsState.originalStyle); if (typeof webFsState.originalControls === 'boolean') video.controls = webFsState.originalControls; if (typeof webFsState.originalMuted === 'boolean') video.muted = webFsState.originalMuted; if (typeof webFsState.originalVolume === 'number') video.volume = webFsState.originalVolume; if (webFsState.placeholder && webFsState.placeholder.parentNode) { webFsState.placeholder.parentNode.insertBefore(video, webFsState.placeholder); webFsState.placeholder.parentNode.removeChild(webFsState.placeholder); } else if (webFsState.originalParent) { webFsState.originalParent.appendChild(video); } webFsState.video = null; webFsState.placeholder = null; webFsState.originalParent = null; webFsState.originalNextSibling = null; webFsState.originalStyle = null; webFsState.originalMuted = null; webFsState.originalVolume = null; webFsState.originalControls = null; if (overlayEl) overlayEl.style.display = 'none'; unlockScroll(); // 退出后遵循“移出视频隐藏”的规则:若此时鼠标不在任何视频上且不在按钮上,则隐藏 if (!hoveredVideo && !isHoveringBtn) hideBtn(); else if (btnVideo) showBtn(btnVideo); } /** * @description 切换网页全屏状态 * @param {HTMLVideoElement} video 视频元素 * @returns {void} */ function toggleWebFullscreen(video) { if (webFsState.video === video) exitWebFullscreen(); else enterWebFullscreen(video); } /** * @description 判断点是否在 rect 内 * @param {number} x 视口 X * @param {number} y 视口 Y * @param {DOMRect} rect 矩形 * @returns {boolean} 是否命中 */ function isPointInRect(x, y, rect) { return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom; } /** * @description 在某个容器内找命中点的最合适视频(面积最小优先) * @param {Element} container 容器元素 * @param {number} x 视口 X * @param {number} y 视口 Y * @returns {HTMLVideoElement|null} 命中的视频 */ function findVideoInContainerAtPoint(container, x, y) { /** @type {HTMLVideoElement|null} */ let best = null; let bestArea = Infinity; const videos = container.querySelectorAll('video'); for (const v of videos) { if (!(v instanceof HTMLVideoElement)) continue; if (!isCandidateVideo(v) && webFsState.video !== v) continue; const rect = v.getBoundingClientRect(); if (!rect || rect.width <= 0 || rect.height <= 0) continue; if (!isPointInRect(x, y, rect)) continue; const area = rect.width * rect.height; if (area < bestArea) { bestArea = area; best = v; } } return best; } /** * @description 查找当前指针位置命中的视频(优先用 elementFromPoint 的容器树,兼容 X.com 视频上层覆盖) * @param {number} x 视口 X * @param {number} y 视口 Y * @returns {HTMLVideoElement|null} 命中的视频 */ function findVideoAtPoint(x, y) { const el = document.elementFromPoint(x, y); if (el && el instanceof HTMLVideoElement) { if (isCandidateVideo(el) || webFsState.video === el) return el; } // 沿父链向上找包含 video 的容器,并在容器内按 rect 命中挑选最合适的视频 /** @type {Element|null} */ let cur = el instanceof Element ? el : null; for (let i = 0; i < 8 && cur; i += 1) { const found = findVideoInContainerAtPoint(cur, x, y); if (found) return found; cur = cur.parentElement; } // 兜底:全量扫描(页面视频数量通常有限,配合 rAF 节流可接受) const found = findVideoInContainerAtPoint(document.documentElement, x, y); return found; } /** @type {{ x: number, y: number }|null} */ let lastPointer = null; /** @type {boolean} */ let rafPending = false; /** * @description 基于最后一次 pointer 坐标更新 hover 状态与按钮显示 * @returns {void} */ function processPointer() { rafPending = false; if (!lastPointer) return; if (isHoveringBtn) return; const v = findVideoAtPoint(lastPointer.x, lastPointer.y); if (!v) { hoveredVideo = null; if (!isHoveringBtn) hideBtn(); return; } hoveredVideo = v; showBtn(v); } /** * @description 触发一次 rAF 节流的指针处理 * @returns {void} */ function schedulePointerProcess() { if (rafPending) return; rafPending = true; window.requestAnimationFrame(processPointer); } /** * @description 键盘快捷键:Esc 退出网页全屏;Space 播放/暂停 * @returns {void} */ window.addEventListener( 'keydown', (e) => { if (!webFsState.video) return; if (e.key === 'Escape') { e.preventDefault(); exitWebFullscreen(); return; } // Space:切换播放/暂停(避免在输入控件/可编辑区域内触发) const isSpace = e.code === 'Space' || e.key === ' '; if (!isSpace) return; /** @type {Element|null} */ const ae = document.activeElement instanceof Element ? document.activeElement : null; if (ae) { const tag = ae.tagName ? ae.tagName.toLowerCase() : ''; const isEditable = tag === 'input' || tag === 'textarea' || tag === 'select' || ae.hasAttribute('contenteditable') || (ae instanceof HTMLElement && ae.isContentEditable); if (isEditable) return; } e.preventDefault(); if (webFsState.video.paused) { void webFsState.video.play(); } else { webFsState.video.pause(); } }, true ); /** * @description 指针移动:只有鼠标移动到视频上时才显示按钮(兼容覆盖层) * @returns {void} */ document.addEventListener( 'pointermove', (e) => { lastPointer = { x: e.clientX, y: e.clientY }; schedulePointerProcess(); }, { passive: true, capture: true } ); /** * @description 视口变化时重定位按钮(若按钮当前显示) * @returns {void} */ function reposition() { if (!btnVideo) return; showBtn(btnVideo); } window.addEventListener('scroll', reposition, { passive: true }); window.addEventListener('resize', reposition, { passive: true }); /** * @description 监听 DOM 变化,处理网页全屏视频被移除等异常情况 * @returns {void} */ const mo = new MutationObserver(() => { if (webFsState.video && !webFsState.video.isConnected) { webFsState.video = null; if (overlayEl) overlayEl.style.display = 'none'; unlockScroll(); if (!hoveredVideo && !isHoveringBtn) hideBtn(); } if (btnVideo && !btnVideo.isConnected) { btnVideo = null; hideBtn(); } }); mo.observe(document.documentElement, { childList: true, subtree: true }); // 初始化(确保按钮节点存在) getOrCreateBtn(); })();