// ==UserScript== // @name 质感 HTML5 播放器 // @name:zh-CN 质感 HTML5 播放器 // @name:en Unified HTML5 Player // @namespace http://tampermonkey.net/ // @version 1.47 // @description 质感 HTML5 网页播放器,统一主流浏览器播放器外观,无背景框通透控制栏,支持丰富快捷键(静音切换/倍速循环切换/逐帧微调/快进快退/音量/画中画/截图/Ctrl+C复制当前帧/全屏/镜像翻转)、左上角高质感 OSD 提示;彻底解决悬浮弹窗播放器关闭后控制栏残留不消失、ComfyUI 画布切换工作流/拖拽缩放平移以及隐藏视频控制栏脱轨的问题,精准保留原始网页布局和尺寸。 // @description:zh-CN 质感 HTML5 网页播放器,统一主流浏览器播放器外观,无背景框通透控制栏,支持丰富快捷键(静音切换/倍速循环切换/逐帧微调/快进快退/音量/画中画/截图/Ctrl+C复制当前帧/全屏/镜像翻转)、左上角高质感 OSD 提示;彻底解决悬浮弹窗播放器关闭后控制栏残留不消失、ComfyUI 画布切换工作流/拖拽缩放平移以及隐藏视频控制栏脱轨的问题,精准保留原始网页布局和尺寸。 // @description:en A unified, modern HTML5 media player for all browsers, featuring custom OSD, rich shortcuts (mute toggle, speed toggle, frame stepping, seek, volume, PiP, screenshot, Ctrl+C copy frame, fullscreen, flip), bulletproof lifecycle tracking and zombie UI cleanup for canvas dragging/workflow switching (e.g. ComfyUI) and floating modals (e.g. fancybox), and zero-layout-shift non-intrusive design. // @author Xion.Ai // @match *://*/* // @match file:///* // @match file://*/* // @grant none // @run-at document-start // @downloadURL https://update.greasyfork.org/scripts/554122/%E8%B4%A8%E6%84%9F%20HTML5%20%E6%92%AD%E6%94%BE%E5%99%A8.user.js // @updateURL https://update.greasyfork.org/scripts/554122/%E8%B4%A8%E6%84%9F%20HTML5%20%E6%92%AD%E6%94%BE%E5%99%A8.meta.js // ==/UserScript== (function() { 'use strict'; // 1. 隐藏原生默认控件样式,保留媒体元素原始尺寸与布局 const hideNativeControlsStyle = document.createElement('style'); hideNativeControlsStyle.id = '__mp_hide_native_controls'; hideNativeControlsStyle.textContent = ` /* 针对被接管的原生播放器,优雅隐藏浏览器自带的黑色控制条 */ video[data-__mp_attached]::-webkit-media-controls { display: none !important; } audio[data-__mp_attached]::-webkit-media-controls { display: none !important; } `; (document.head || document.documentElement).appendChild(hideNativeControlsStyle); // 2. 本地文件直接播放或独立媒体直链页面时的居中排版优化 const isStandalonePage = window.location.protocol === 'file:' || /\.(mp4|webm|mkv|mov|mp3|ogg|wav|m4a)($|\?)/i.test(window.location.pathname); if (isStandalonePage) { document.addEventListener('DOMContentLoaded', () => { if (document.body && document.body.childElementCount <= 2) { const styleId = '__mp_center_style'; if (document.getElementById(styleId)) return; const centerCss = ` html, body { height: 100%; margin: 0; padding: 0; background-color: #0d0d0d !important; } body { display: flex !important; flex-direction: column !important; justify-content: center !important; align-items: center !important; overflow: hidden !important; } body > video { max-width: 100vw !important; max-height: 100vh !important; } `; const styleNode = document.createElement('style'); styleNode.id = styleId; styleNode.textContent = centerCss; document.head.appendChild(styleNode); } }); } // 3. 播放器核心 UI 与左上角高质感 OSD 样式 const css = ` /* 全屏媒体增强:无损铺满视口,不移动 DOM 节点,零风险断流,退出后 100% 原位恢复 */ .__mp_fullscreen_media { position: fixed !important; top: 0 !important; left: 0 !important; width: 100vw !important; height: 100vh !important; max-width: 100vw !important; max-height: 100vh !important; z-index: 2147483645 !important; object-fit: contain !important; background-color: #000 !important; margin: 0 !important; } /* 左上角高质感 OSD 提示层(完全根据参考图还原) */ .__mp_osd { position: fixed; z-index: 2147483647; pointer-events: none; user-select: none; -webkit-user-select: none; display: flex; align-items: center; gap: 8px; padding: 6px 12px; border-radius: 8px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; line-height: 1; opacity: 0; transform: translateY(-4px); transition: opacity 180ms ease, transform 180ms ease; text-shadow: 0 1px 3px rgba(0, 0, 0, 0.8), 0 2px 8px rgba(0, 0, 0, 0.6); } .__mp_osd.__show { opacity: 1 !important; transform: translateY(0); } /* OSD 快进/快退蓝色微渐变圆角方块图标(参考图 2、3) */ .__mp_osd_icon_box { width: 22px; height: 22px; border-radius: 5px; display: inline-flex; align-items: center; justify-content: center; background: linear-gradient(135deg, #60a5fa 0%, #3b82f6 100%); box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35); flex-shrink: 0; } .__mp_osd_icon_box svg { width: 13px; height: 13px; fill: #ffffff; } /* OSD 通用功能小图标 */ .__mp_osd_vol_icon { display: inline-flex; align-items: center; justify-content: center; filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.5)); } .__mp_osd_vol_icon svg { width: 20px; height: 20px; fill: #ffffff; } /* OSD 状态文本(普通状态:亮蓝紫色 #a5baff,粗体) */ .__mp_osd_badge { font-size: 16px; font-weight: 700; letter-spacing: 0.3px; color: #a5baff; } /* OSD 金黄色高亮状态(快速快进/快退、倍速非1.0状态) */ .__mp_osd_badge.__fast { color: #ffd04b !important; } /* OSD 进度时间/辅助参数文本(白色,清晰等宽对齐) */ .__mp_osd_time { font-size: 15px; font-weight: 500; color: #ffffff; letter-spacing: 0.5px; margin-left: 2px; } /* 视频控制栏基础属性(fixed 悬浮模式) */ .__mp_ui.__mp_video_ui { position: fixed; height: 40px; box-sizing: border-box; display: flex; align-items: center; gap: 8px; pointer-events: auto; opacity: 0; transition: opacity 180ms ease, transform 180ms ease; transform: translateY(6px); z-index: 2147483647; user-select: none; -webkit-user-select: none; background: transparent !important; border: none !important; box-shadow: none !important; backdrop-filter: none !important; -webkit-backdrop-filter: none !important; padding: 0 4px !important; } .__mp_ui.__mp_video_ui.__show { opacity: 1; transform: translateY(0); } /* 强制隐藏类:解决样式继承与 !important 优先级导致的控制条残留问题 */ .__mp_ui.__mp_hidden, .__mp_ui[style*="display: none"], .__mp_osd.__mp_hidden, .__mp_osd[style*="display: none"] { display: none !important; visibility: hidden !important; opacity: 0 !important; pointer-events: none !important; } /* 视频控制栏按钮:毛玻璃小圆底与投影,复杂画面下清晰可读 */ .__mp_ui.__mp_video_ui .__mp_btn { background: rgba(0, 0, 0, 0.38) !important; backdrop-filter: blur(8px) !important; -webkit-backdrop-filter: blur(8px) !important; border: 1px solid rgba(255, 255, 255, 0.16) !important; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.45) !important; } .__mp_ui.__mp_video_ui .__mp_btn:hover { background: rgba(0, 0, 0, 0.58) !important; border-color: rgba(255, 255, 255, 0.35) !important; } .__mp_ui.__mp_video_ui .__mp_time { text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9), 0 2px 6px rgba(0, 0, 0, 0.7); } .__mp_ui.__mp_video_ui .__mp_progress { filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.6)); } /* 音频播放器控制栏:原位替换文档流胶囊设计(核心解决表格错位堆叠问题) */ .__mp_ui.__mp_audio_ui { position: relative !important; display: inline-flex !important; visibility: visible !important; vertical-align: middle !important; box-sizing: border-box !important; align-items: center !important; gap: 8px !important; padding: 0 12px !important; height: 40px !important; width: 100% !important; max-width: 340px !important; min-width: 160px !important; border-radius: 20px !important; background: rgba(28, 28, 34, 0.45) !important; backdrop-filter: blur(16px) !important; -webkit-backdrop-filter: blur(16px) !important; border: 1px solid rgba(255, 255, 255, 0.1) !important; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25) !important; opacity: 1 !important; transform: none !important; pointer-events: auto !important; user-select: none !important; -webkit-user-select: none !important; margin: 4px 0 !important; } /* 独立播放音频页面居中长胶囊 */ body > .__mp_ui.__mp_audio_ui { max-width: min(840px, 90vw) !important; width: min(840px, 90vw) !important; margin: 0 auto !important; } /* 圆形高质感毛玻璃按钮 */ .__mp_btn { width: 32px; height: 32px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; background: rgba(255, 255, 255, 0.08); border: 1px solid rgba(255, 255, 255, 0.08); color: rgba(255, 255, 255, 0.95); cursor: pointer; box-shadow: 0 2px 6px rgba(0, 0, 0, 0.25); flex-shrink: 0; transition: transform 120ms ease, background 120ms ease, border-color 120ms ease; } .__mp_btn:hover { transform: scale(1.08); background: rgba(255, 255, 255, 0.18); } .__mp_btn svg { width: 14px; height: 14px; fill: currentColor; } /* 进度条轨道容器 */ .__mp_progress_container { position: relative; flex: 1 1 auto; margin: 0 4px; height: 24px; display: flex; align-items: center; cursor: pointer; } .__mp_progress { position: relative; -webkit-appearance: none; appearance: none; height: 4px; border-radius: 999px; background: linear-gradient(to right, #3b82f6 var(--play-percent, 0%), rgba(255,255,255,0.4) var(--play-percent, 0%), rgba(255,255,255,0.4) var(--buffer-percent, 0%), rgba(255,255,255,0.2) var(--buffer-percent, 0%)); outline: none; width: 100%; margin: 0; transition: height 120ms ease; } .__mp_progress_container:hover .__mp_progress { height: 6px; } .__mp_progress::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 12px; height: 12px; border-radius: 50%; background: #ffffff; border: none; cursor: pointer; box-shadow: 0 1px 4px rgba(0, 0, 0, 0.6); transition: transform 120ms ease; } .__mp_progress_container:hover .__mp_progress::-webkit-slider-thumb { transform: scale(1.25); } .__mp_progress::-moz-range-thumb { width: 12px; height: 12px; border-radius: 50%; background: #ffffff; border: none; cursor: pointer; box-shadow: 0 1px 4px rgba(0, 0, 0, 0.6); transition: transform 120ms ease; } .__mp_progress_container:hover .__mp_progress::-moz-range-thumb { transform: scale(1.25); } /* 时间显示 */ .__mp_time { color: rgba(255, 255, 255, 0.95); font-size: 12px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, monospace; font-variant-numeric: tabular-nums; flex-shrink: 0; letter-spacing: 0.5px; } /* 右侧按钮组 */ .__mp_right_controls { display: flex; align-items: center; gap: 8px; flex-shrink: 0; } `; const styleNode = document.createElement('style'); styleNode.id = '__mp_unified_player_styles'; styleNode.textContent = css; document.head.appendChild(styleNode); const clamp = (v, a, b) => Math.min(b, Math.max(a, v)); // 时间格式化:支持大于 1 小时 (hh:mm:ss) 与常规 (mm:ss),安全处理 NaN / Infinity const formatTime = (seconds) => { if (!Number.isFinite(seconds) || seconds < 0) return '00:00'; const total = Math.floor(seconds); const hh = Math.floor(total / 3600); const mm = Math.floor((total % 3600) / 60); const ss = (total % 60).toString().padStart(2, '0'); if (hh > 0) { return `${hh}:${mm.toString().padStart(2, '0')}:${ss}`; } return `${mm}:${ss}`; }; // 当前鼠标聚焦/最后操作的媒体元素(供全局快捷键调度) let activeMedia = null; // 全局指针状态与动态追踪集合(专门解决 ComfyUI 画布拖拽/平移/缩放节点时控制栏不同步问题) let isGlobalPointerDown = false; const trackingControllers = new Set(); const notifyAllTracking = (duration = 1500) => { trackingControllers.forEach(ctrl => { if (!ctrl.media || !ctrl.media.isConnected) { ctrl.cleanup(); return; } if (ctrl.updatePositions) ctrl.updatePositions(); if (ctrl.startTracking) ctrl.startTracking(duration); }); }; window.addEventListener('pointerdown', () => { isGlobalPointerDown = true; notifyAllTracking(2000); }, { passive: true, capture: true }); window.addEventListener('pointermove', () => { if (isGlobalPointerDown) { notifyAllTracking(1000); } }, { passive: true, capture: true }); window.addEventListener('pointerup', () => { isGlobalPointerDown = false; notifyAllTracking(1500); }, { passive: true, capture: true }); window.addEventListener('pointercancel', () => { isGlobalPointerDown = false; notifyAllTracking(1000); }, { passive: true, capture: true }); window.addEventListener('wheel', () => { notifyAllTracking(1500); }, { passive: true, capture: true }); // 绑定控制器到媒体元素(视频采用 fixed 浮层,音频采用 DOM 原位替换,彻底杜绝表格错位) function attachUIToMedia(media) { if (!media || attachedMedia.has(media)) return; media.dataset.__mp_attached = '1'; const isAudio = media.tagName === 'AUDIO'; // 1. 创建左上角 OSD 元素(fixed 悬浮在画面左上角) const osd = document.createElement('div'); osd.className = '__mp_osd'; document.body.appendChild(osd); let osdTimeout = null; function showOSD(iconHtml, badgeText, timeText = '', isFast = false) { clearTimeout(osdTimeout); osd.innerHTML = ` ${iconHtml} ${badgeText} ${timeText ? `${timeText}` : ''} `; updatePositions(); startTracking(); osd.classList.add('__show'); osdTimeout = setTimeout(() => { osd.classList.remove('__show'); }, 1200); } // 2. 创建控制栏 UI 元素 const ui = document.createElement('div'); ui.className = `__mp_ui ${isAudio ? '__mp_audio_ui' : '__mp_video_ui'}`; ui.innerHTML = `
00:00 / 00:00
${!isAudio ? `
` : ''}
`; // 核心挂载策略: // 视频:挂载在 body,使用 fixed 贴合在视频画面底部 // 音频:直接作为兄弟节点插入在 audio 原位置,原 audio 隐藏,完美占据表格/段落中的原位槽位! if (isAudio) { media.style.display = 'none'; media.after(ui); } else { document.body.appendChild(ui); } // 3. 坐标定位更新 const updateOSDPosition = () => { const anchor = isAudio ? ui : media; if (!anchor.isConnected) return; const rect = anchor.getBoundingClientRect(); osd.style.left = `${Math.round(clamp(rect.left + 14, 10, window.innerWidth - 180))}px`; osd.style.top = `${Math.round(clamp(rect.top + (isAudio ? -42 : 14), 10, window.innerHeight - 50))}px`; }; let lastRect = { left: 0, top: 0, width: 0, height: 0 }; const hideUIElements = () => { ui.classList.remove('__show'); ui.classList.add('__mp_hidden'); ui.style.setProperty('display', 'none', 'important'); osd.classList.remove('__show'); osd.classList.add('__mp_hidden'); osd.style.setProperty('display', 'none', 'important'); }; const showUIElements = () => { ui.classList.remove('__mp_hidden'); ui.style.removeProperty('display'); osd.classList.remove('__mp_hidden'); osd.style.removeProperty('display'); }; const updatePositions = () => { if (!media.isConnected) { cleanup(); return; } updateOSDPosition(); // 音频已经在文档流中自适应,不需要也绝不应计算 fixed 坐标 if (isAudio) return; // 检查元素自身是否在 CSS 层面被隐藏(解决浮窗关闭 display:none 导致控制栏残留的问题) const computedStyle = window.getComputedStyle(media); if (computedStyle.display === 'none' || computedStyle.visibility === 'hidden') { hideUIElements(); return; } const isFull = media.classList.contains('__mp_fullscreen_media'); const rect = isFull ? { left: 0, top: 0, width: window.innerWidth, height: window.innerHeight, bottom: window.innerHeight, right: window.innerWidth } : media.getBoundingClientRect(); lastRect.left = rect.left; lastRect.top = rect.top; lastRect.width = rect.width; lastRect.height = rect.height; const isOutOfBounds = !isFull && ( rect.width <= 10 || rect.height <= 10 || rect.bottom <= 0 || rect.top >= window.innerHeight || rect.right <= 0 || rect.left >= window.innerWidth ); if (isOutOfBounds) { hideUIElements(); return; } else { showUIElements(); } const uiWidth = Math.max(180, rect.width - 16); ui.style.left = `${Math.round(rect.left + 8)}px`; ui.style.top = `${Math.round(rect.top + rect.height - 40 - 4)}px`; ui.style.width = `${Math.round(uiWidth)}px`; }; // 核心动态位置追踪引擎 (针对 ComfyUI 等画布平移/缩放/拖拽节点的零延迟帧同步) let trackingRafId = null; let keepTrackingUntil = 0; const checkPositionChange = () => { if (!media.isConnected) { cleanup(); return; } if (!isAudio) { const rect = media.getBoundingClientRect(); if ( Math.abs(rect.left - lastRect.left) > 0.5 || Math.abs(rect.top - lastRect.top) > 0.5 || Math.abs(rect.width - lastRect.width) > 0.5 || Math.abs(rect.height - lastRect.height) > 0.5 ) { updatePositions(); } } const isPlaying = !media.paused && !media.ended; const isUIShown = ui.classList.contains('__show'); const isOSDShown = osd.classList.contains('__show'); const shouldKeepTracking = Date.now() < keepTrackingUntil || isGlobalPointerDown; if (isPlaying || isUIShown || isOSDShown || shouldKeepTracking) { trackingRafId = requestAnimationFrame(checkPositionChange); } else { trackingRafId = null; } }; const startTracking = (duration = 1500) => { if (isAudio) return; keepTrackingUntil = Math.max(keepTrackingUntil, Date.now() + duration); if (!trackingRafId) { trackingRafId = requestAnimationFrame(checkPositionChange); } }; updatePositions(); // 原生交叉观察器:当元素被关闭/移出视口/设置 display:none 时立即隐藏控制栏 const intersectionObserver = new IntersectionObserver((entries) => { const entry = entries[0]; if (!media.isConnected) { cleanup(); return; } if (!entry.isIntersecting) { hideUIElements(); } else { updatePositions(); startTracking(1000); } }); intersectionObserver.observe(media); const resizeObserver = new ResizeObserver(updatePositions); resizeObserver.observe(media); if (media.parentElement) resizeObserver.observe(media.parentElement); window.addEventListener('scroll', updatePositions, { passive: true, capture: true }); window.addEventListener('resize', updatePositions, { passive: true }); media.controls = false; // 4. 控制栏显隐交互 (支持悬浮保持、离开自动淡出,暂停后绝不永久占屏) let hideTimeout = null; let isHovered = false; const showUI = () => { if (isAudio) return; // 音频始终可见 updatePositions(); startTracking(1500); clearTimeout(hideTimeout); showUIElements(); ui.classList.add('__show'); }; const scheduleHide = (delay = 2000) => { if (isAudio) return; clearTimeout(hideTimeout); hideTimeout = setTimeout(() => { if (!isHovered || (!media.paused && !media.ended)) { ui.classList.remove('__show'); } }, delay); }; const onLeave = () => { isHovered = false; scheduleHide(600); }; media.addEventListener('mouseenter', () => { activeMedia = media; isHovered = true; showUI(); }); media.addEventListener('mousemove', () => { activeMedia = media; isHovered = true; showUI(); if (!media.paused && !media.ended) { scheduleHide(2000); } }); media.addEventListener('mouseleave', (e) => { if (e.relatedTarget && (ui === e.relatedTarget || ui.contains(e.relatedTarget))) { return; } onLeave(); }); ui.addEventListener('mouseenter', () => { activeMedia = media; isHovered = true; showUI(); }); ui.addEventListener('mousemove', () => { activeMedia = media; isHovered = true; showUI(); if (!media.paused && !media.ended) { scheduleHide(2000); } }); ui.addEventListener('mouseleave', (e) => { if (e.relatedTarget && (media === e.relatedTarget || media.contains(e.relatedTarget))) { return; } onLeave(); }); media.addEventListener('play', () => { showUI(); scheduleHide(2000); }); media.addEventListener('pause', () => { updatePlayIcon(); if (isHovered) { showUI(); } else { updatePositions(); ui.classList.add('__show'); scheduleHide(1500); } }); // 5. 播放 / 暂停 const playBtn = ui.querySelector('.__mp_play'); const updatePlayIcon = () => { const isPaused = media.paused || media.ended; playBtn.querySelector('svg path').setAttribute('d', isPaused ? 'M8 5v14l11-7L8 5z' : 'M6 5h4v14H6zM14 5h4v14h-4z'); }; const togglePlay = () => { if (media.paused || media.ended) { media.play(); showOSD( ``, `播放` ); } else { media.pause(); showOSD( ``, `暂停` ); } }; playBtn.addEventListener('click', (e) => { e.stopPropagation(); togglePlay(); }); // 画面单击暂停与双击全屏(防抖解耦) let clickTimer = null; media.addEventListener('click', (e) => { if (ui.contains(e.target)) return; if (clickTimer) { clearTimeout(clickTimer); clickTimer = null; toggleFullscreen(); } else { clickTimer = setTimeout(() => { clickTimer = null; togglePlay(); }, 220); } }); // 6. 全屏切换 const toggleFullscreen = () => { if (isAudio) return; const isFull = media.classList.contains('__mp_fullscreen_media'); if (!isFull) { media.classList.add('__mp_fullscreen_media'); if (document.documentElement.requestFullscreen) { document.documentElement.requestFullscreen().catch(() => {}); } else if (document.documentElement.webkitRequestFullscreen) { document.documentElement.webkitRequestFullscreen(); } } else { media.classList.remove('__mp_fullscreen_media'); if (document.fullscreenElement) { document.exitFullscreen().catch(() => {}); } } setTimeout(updatePositions, 50); }; const fullBtn = ui.querySelector('.__mp_fullscreen'); if (fullBtn) { fullBtn.addEventListener('click', (e) => { e.stopPropagation(); toggleFullscreen(); }); } document.addEventListener('fullscreenchange', () => { if (!document.fullscreenElement) { media.classList.remove('__mp_fullscreen_media'); updatePositions(); } }); // 7. 音量控制与 OSD 反馈 const muteBtn = ui.querySelector('.__mp_mute'); let lastVolume = (media.volume > 0 && !media.muted) ? media.volume : 0.5; const updateVolumeUI = () => { const vol = media.volume; const muted = media.muted; const svgPath = muteBtn.querySelector('svg path'); if (muted || vol === 0) { svgPath.setAttribute('d', 'M16.5 12c0-1.77-.77-3.37-2-4.47V16.47c1.23-1.1 2-2.7 2-4.47zM5 9v6h4l5 4V5L9 9H5z'); } else if (vol < 0.5) { svgPath.setAttribute('d', 'M5 9v6h4l5 4V5L9 9H5zm11.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z'); } else { svgPath.setAttribute('d', 'M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z'); } }; const showVolumeOSD = () => { const volPercent = Math.round(media.volume * 100); const isMuted = media.muted || volPercent === 0; const volIconSvg = isMuted ? `` : ``; showOSD( `${volIconSvg}`, isMuted ? `静音` : `音量 ${volPercent}%` ); }; const changeVolume = (delta) => { if (delta !== 0) { if (media.muted && delta > 0) { media.muted = false; } media.volume = clamp(media.volume + delta, 0, 1); if (media.volume > 0) { media.muted = false; lastVolume = media.volume; } } showVolumeOSD(); }; const toggleMute = () => { if (media.muted) { media.muted = false; if (media.volume === 0) { media.volume = lastVolume > 0 ? lastVolume : 0.5; } } else { if (media.volume > 0) { lastVolume = media.volume; } media.muted = true; } showVolumeOSD(); }; muteBtn.addEventListener('click', (e) => { e.stopPropagation(); toggleMute(); }); // 画面与控制栏全域滚轮调节音量 const onWheelAdjustVolume = (e) => { e.preventDefault(); e.stopPropagation(); const delta = e.deltaY < 0 ? 0.05 : -0.05; changeVolume(delta); }; media.addEventListener('wheel', onWheelAdjustVolume, { passive: false }); ui.addEventListener('wheel', onWheelAdjustVolume, { passive: false }); // 8. 快进/快退核心函数 const seek = (seconds, isFast = false) => { if (isNaN(media.duration)) return; media.currentTime = clamp(media.currentTime + seconds, 0, media.duration); const isForward = seconds > 0; const absSec = Math.abs(seconds); const badgeText = `${isForward ? '+' : '-'}${absSec}s${isFast ? ' (快速)' : ''}`; const timeText = `${formatTime(media.currentTime)} / ${formatTime(media.duration)}`; const iconSvg = isForward ? `` : ``; const iconHtml = `
${iconSvg}
`; showOSD(iconHtml, badgeText, timeText, isFast); }; // 9. 倍速无级调节与记忆循环切换 (X 减速 / C 加速 / Z 双向切换) let lastCustomRate = 2.0; const setRate = (delta) => { let rate = Math.round((media.playbackRate + delta) * 10) / 10; rate = clamp(rate, 0.1, 16.0); media.playbackRate = rate; if (rate !== 1.0) { lastCustomRate = rate; } const isFast = rate > 1.0; const speedIconSvg = ``; showOSD( `${speedIconSvg}`, `播放速度 ${rate.toFixed(1)}x`, '', isFast ); }; const toggleRate = () => { const speedIconSvg = ``; if (media.playbackRate !== 1.0) { // 当前处于非 1.0 倍速,记忆它并切回 1.0 lastCustomRate = media.playbackRate; media.playbackRate = 1.0; showOSD( `${speedIconSvg}`, `恢复正常速度 1.0x` ); } else { // 当前处于 1.0 正常速度,循环切回上次的记忆倍速(默认 2.0x) const targetRate = clamp(lastCustomRate || 2.0, 0.1, 16.0); media.playbackRate = targetRate; showOSD( `${speedIconSvg}`, `切换倍速 ${targetRate.toFixed(1)}x`, '', targetRate > 1.0 ); } }; // 10. 逐帧微调 (D 上一帧 / F 下一帧) const frameStep = 1 / 30; // 约 0.0333s 单帧时间 const prevFrame = () => { if (isNaN(media.duration)) return; media.pause(); media.currentTime = clamp(media.currentTime - frameStep, 0, media.duration); const iconSvg = ``; showOSD( `${iconSvg}`, `上一帧`, `${formatTime(media.currentTime)} / ${formatTime(media.duration)}` ); }; const nextFrame = () => { if (isNaN(media.duration)) return; media.pause(); media.currentTime = clamp(media.currentTime + frameStep, 0, media.duration); const iconSvg = ``; showOSD( `${iconSvg}`, `下一帧`, `${formatTime(media.currentTime)} / ${formatTime(media.duration)}` ); }; // 11. 画面水平镜像状态 let isFlipped = false; const toggleFlip = () => { if (isAudio) return; isFlipped = !isFlipped; media.style.transform = isFlipped ? 'scaleX(-1)' : ''; const flipSvg = ``; showOSD( `${flipSvg}`, isFlipped ? `画面镜像 (开启)` : `画面镜像 (关闭)` ); }; // 12. 画面截图下载 (Shift + S) const captureFrame = () => { if (isAudio || !media.videoWidth || !media.videoHeight) return; try { const canvas = document.createElement('canvas'); canvas.width = media.videoWidth; canvas.height = media.videoHeight; const ctx = canvas.getContext('2d'); if (isFlipped) { ctx.translate(canvas.width, 0); ctx.scale(-1, 1); } ctx.drawImage(media, 0, 0, canvas.width, canvas.height); canvas.toBlob((blob) => { if (!blob) return; const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `screenshot_${formatTime(media.currentTime).replace(':', '-')}.png`; a.click(); URL.revokeObjectURL(url); const camSvg = ``; showOSD( `${camSvg}`, `已截图保存`, `${media.videoWidth}x${media.videoHeight}` ); }, 'image/png'); } catch (err) { console.warn('[质感播放器] 截图受跨域资源保护限制:', err); } }; // 13. 复制视频当前帧画面到剪贴板 (Ctrl + C) const copyFrame = () => { if (isAudio || !media.videoWidth || !media.videoHeight) return; try { const canvas = document.createElement('canvas'); canvas.width = media.videoWidth; canvas.height = media.videoHeight; const ctx = canvas.getContext('2d'); if (isFlipped) { ctx.translate(canvas.width, 0); ctx.scale(-1, 1); } ctx.drawImage(media, 0, 0, canvas.width, canvas.height); canvas.toBlob(async (blob) => { if (!blob) return; try { if (navigator.clipboard && typeof ClipboardItem !== 'undefined') { const item = new ClipboardItem({ 'image/png': blob }); await navigator.clipboard.write([item]); const copySvg = ``; showOSD( `${copySvg}`, `已复制画面到剪贴板`, `${media.videoWidth}x${media.videoHeight}` ); } else { throw new Error('ClipboardItem not supported'); } } catch (clipErr) { console.warn('[质感播放器] 写入剪贴板失败:', clipErr); showOSD( ``, `剪贴板写入受限` ); } }, 'image/png'); } catch (err) { console.warn('[质感播放器] 复制画面受跨域资源保护限制:', err); showOSD( ``, `视频受跨域保护无法复制` ); } }; // 14. 画中画切换 (Shift + P) const togglePiP = () => { if (isAudio || !document.pictureInPictureEnabled) return; const pipSvg = ``; if (document.pictureInPictureElement === media) { document.exitPictureInPicture().catch(() => {}); showOSD(`${pipSvg}`, `退出画中画`); } else { media.requestPictureInPicture().then(() => { showOSD(`${pipSvg}`, `开启画中画`); }).catch(() => {}); } }; // 14. 进度条拖拽与时间更新 const progress = ui.querySelector('.__mp_progress'); const progressContainer = ui.querySelector('.__mp_progress_container'); const timeDisplay = ui.querySelector('.__mp_time'); progressContainer.addEventListener('click', (e) => { const rect = progressContainer.getBoundingClientRect(); const percentage = clamp((e.clientX - rect.left) / rect.width, 0, 1); if (!isNaN(media.duration)) { media.currentTime = media.duration * percentage; } }); progress.addEventListener('input', (e) => { if (!isNaN(media.duration)) { media.currentTime = clamp(media.duration * (parseFloat(e.target.value) / 100), 0, media.duration); } }); let rafId = null; const tickProgress = () => { if (!media.isConnected) { cancelAnimationFrame(rafId); return; } if (!isNaN(media.duration) && media.duration > 0) { const val = (media.currentTime / media.duration) * 100; progress.value = clamp(val, 0, 100).toString(); progress.style.setProperty('--play-percent', `${progress.value}%`); timeDisplay.textContent = `${formatTime(media.currentTime)} / ${formatTime(media.duration)}`; } // 视频播放期间持续比对位置,确保动态移动中零延迟帧对齐 if (!isAudio) { const rect = media.getBoundingClientRect(); if ( Math.abs(rect.left - lastRect.left) > 0.5 || Math.abs(rect.top - lastRect.top) > 0.5 || Math.abs(rect.width - lastRect.width) > 0.5 || Math.abs(rect.height - lastRect.height) > 0.5 ) { updatePositions(); } } rafId = requestAnimationFrame(tickProgress); }; const updateBufferProgress = () => { if (!media.buffered || media.buffered.length === 0 || isNaN(media.duration) || media.duration <= 0) return; const bufferEnd = media.buffered.end(media.buffered.length - 1); const bufferPercent = (bufferEnd / media.duration) * 100; progress.style.setProperty('--buffer-percent', `${clamp(bufferPercent, 0, 100)}%`); }; media.addEventListener('play', updatePlayIcon); media.addEventListener('pause', updatePlayIcon); media.addEventListener('ended', updatePlayIcon); media.addEventListener('volumechange', updateVolumeUI); media.addEventListener('progress', updateBufferProgress); const onMetadataLoaded = () => { if (media.volume > 0 && !media.muted) lastVolume = media.volume; updateVolumeUI(); updateBufferProgress(); updatePositions(); if (!isNaN(media.duration)) { timeDisplay.textContent = `${formatTime(media.currentTime)} / ${formatTime(media.duration)}`; } if (!rafId) rafId = requestAnimationFrame(tickProgress); }; media.addEventListener('loadedmetadata', onMetadataLoaded); if (media.readyState >= 1) onMetadataLoaded(); // 15. 全局生命周期与资源清理 const cleanup = () => { if (rafId) cancelAnimationFrame(rafId); if (trackingRafId) cancelAnimationFrame(trackingRafId); if (resizeObserver) resizeObserver.disconnect(); if (intersectionObserver) intersectionObserver.disconnect(); window.removeEventListener('scroll', updatePositions, true); window.removeEventListener('resize', updatePositions); if (ui.parentElement) ui.remove(); if (osd.parentElement) osd.remove(); if (isAudio) media.style.display = ''; media.classList.remove('__mp_fullscreen_media'); delete media.dataset.__mp_attached; delete media.dataset.__mp_had_controls; attachedMedia.delete(media); trackingControllers.delete(mediaController); if (activeMedia === media) activeMedia = null; }; const disconnectObserver = new MutationObserver(() => { if (!media.isConnected) { cleanup(); disconnectObserver.disconnect(); } }); if (media.parentElement) { disconnectObserver.observe(media.parentElement, { childList: true, subtree: true }); } updatePlayIcon(); updateVolumeUI(); // 将控制器暴露给调度器并加入动态追踪集合 const mediaController = { media, seek, changeVolume, toggleMute, togglePlay, toggleFullscreen, setRate, toggleRate, prevFrame, nextFrame, captureFrame, copyFrame, togglePiP, toggleFlip, updatePositions, startTracking, cleanup }; trackingControllers.add(mediaController); return mediaController; } const attachedMedia = new WeakMap(); function processMedia(media) { // 核心过滤:接管原生 controls、独立媒体直链、或者本地 file: 播放的音频/视频 const hasControls = media.hasAttribute('controls') || media.dataset.__mp_had_controls === '1'; const isStandalone = isStandalonePage || (document.body && document.body.childElementCount <= 2 && (media.parentElement === document.body || media.parentElement?.parentElement === document.body)); if (hasControls || isStandalone) { media.dataset.__mp_had_controls = '1'; if (attachedMedia.has(media)) { const existingCtrl = attachedMedia.get(media); if (existingCtrl && existingCtrl.media === media && media.isConnected) { existingCtrl.updatePositions(); return; } } try { const controller = attachUIToMedia(media); if (controller) attachedMedia.set(media, controller); } catch (e) { console.error('[质感播放器] 挂载异常:', e); } } } function scanAndProcess() { document.querySelectorAll('video, audio').forEach(processMedia); } // 初次扫描 scanAndProcess(); // 监听动态插入与移除的媒体元素(彻底根除 ComfyUI 切换工作流与关闭浮窗时的遗留残留) const globalObserver = new MutationObserver((mutations) => { // 1. 任何 DOM 变动时,首先检查是否有已失联的播放器并立刻彻底清理 trackingControllers.forEach(ctrl => { if (!ctrl.media || !ctrl.media.isConnected) { ctrl.cleanup(); } }); // 2. 检查新插入的节点 mutations.forEach(mutation => { if (mutation.addedNodes) { mutation.addedNodes.forEach(node => { if (node.nodeType === 1) { if (node.matches('video, audio')) { processMedia(node); } node.querySelectorAll('video, audio').forEach(processMedia); } }); } }); }); globalObserver.observe(document.documentElement, { childList: true, subtree: true, }); // 周期性看门狗:针对未触发 DOM Mutation 的隐藏情况(如 CSS 切换 display:none,或切标签页)进行复核与隐藏 setInterval(() => { trackingControllers.forEach(ctrl => { if (!ctrl.media || !ctrl.media.isConnected) { ctrl.cleanup(); } else { ctrl.updatePositions(); } }); }, 500); // 16. 全局全能快捷键调度系统(统一全主流浏览器体验,严格规避系统与浏览器快捷键冲突) window.addEventListener('keydown', (e) => { // 输入框打字时防误触:绝不拦截任何键盘事件 const activeElem = document.activeElement; if (activeElem && ( activeElem.tagName === 'INPUT' || activeElem.tagName === 'TEXTAREA' || activeElem.tagName === 'SELECT' || activeElem.isContentEditable )) { return; } // 彻底放行所有 Alt 组合键(如 Alt+D 聚焦地址栏、Alt+← 历史后退、Alt+F 等)与 Meta/Command 键 if (e.altKey || e.metaKey) { return; } // 寻找当前活跃的视频对象 let targetMedia = activeMedia; if (!targetMedia || !targetMedia.isConnected) { const allMedia = Array.from(document.querySelectorAll('video, audio')).filter(m => attachedMedia.has(m)); targetMedia = allMedia.find(m => !m.paused && !m.ended) || allMedia[0]; } if (!targetMedia || !attachedMedia.has(targetMedia)) return; const controller = attachedMedia.get(targetMedia); // 如果按下了 Ctrl 键,处理特定绑定的快速跳转、音量调节以及 Ctrl+C 复制当前帧画面 if (e.ctrlKey) { switch (e.code) { case 'ArrowLeft': e.preventDefault(); controller.seek(-30, true); break; case 'ArrowRight': e.preventDefault(); controller.seek(30, true); break; case 'ArrowUp': e.preventDefault(); controller.changeVolume(0.20); break; case 'ArrowDown': e.preventDefault(); controller.changeVolume(-0.20); break; case 'KeyC': // 如果用户在页面上选中文本,优先放行让浏览器原生复制文字 const sel = window.getSelection ? window.getSelection().toString() : ''; if (sel && sel.trim().length > 0) { return; } if (controller.copyFrame) { e.preventDefault(); controller.copyFrame(); } break; } return; } // 如果按下了 Shift 键,仅处理特定绑定的功能,其余全部放行 if (e.shiftKey) { switch (e.code) { case 'ArrowLeft': e.preventDefault(); controller.seek(-30, true); break; case 'ArrowRight': e.preventDefault(); controller.seek(30, true); break; case 'KeyS': e.preventDefault(); controller.captureFrame(); break; case 'KeyP': e.preventDefault(); controller.togglePiP(); break; case 'KeyM': e.preventDefault(); controller.toggleFlip(); break; } return; } // 无任何修饰键(纯单键操作) switch (e.code) { case 'Space': e.preventDefault(); controller.togglePlay(); break; case 'ArrowLeft': e.preventDefault(); controller.seek(-5, false); break; case 'ArrowRight': e.preventDefault(); controller.seek(5, false); break; case 'ArrowUp': e.preventDefault(); controller.changeVolume(0.05); break; case 'ArrowDown': e.preventDefault(); controller.changeVolume(-0.05); break; case 'KeyC': e.preventDefault(); controller.setRate(0.1); break; case 'KeyX': e.preventDefault(); controller.setRate(-0.1); break; case 'KeyZ': e.preventDefault(); // 循环切换自定义倍速与正常速度 1.0x controller.toggleRate(); break; case 'KeyD': e.preventDefault(); controller.prevFrame(); break; case 'KeyF': e.preventDefault(); controller.nextFrame(); break; case 'KeyM': e.preventDefault(); controller.toggleMute(); break; case 'Enter': e.preventDefault(); controller.toggleFullscreen(); break; } }, { capture: true }); })();