// ==UserScript== // @name 音量控制器 // @namespace http://tampermonkey.net/ // @version 5.2 // @description 悬浮按钮可拖动,点击显示/隐藏音量面板;支持0-600%非线性调节 // @author ukatoilol@gmail.com // @match *://*/* // @run-at document-start // @grant GM_setValue // @grant GM_getValue // @license MIT // @downloadURL none // ==/UserScript== (function() { 'use strict'; // ===== 用户配置 ===== const MAX_GAIN = 6.0; // 最大增益倍数(600%) const DEFAULT_VOL = 1.0; // 默认音量(100%) const CHECK_INTERVAL = 10000; // 定期重试连接失败的媒体(毫秒) const BUTTON_ID = 'volume-controller-btn'; const PANEL_ID = 'volume-controller-panel'; // =================== let audioContext = null; let mediaMap = new WeakMap(); let btn = null; // 悬浮按钮元素 let panel = null; // 音量面板元素 let isPanelVisible = false; // 当前面板显示状态(仅记录,实际由按钮切换) //使用完整路径 (可优化: 单页应用(SPA)中的 URL 变化) const storageKey = `volume_${window.location.hostname}${window.location.pathname}`; // 保存音量增益值 function saveVolumeGain(gain) { GM_setValue(storageKey, gain); } // 读取保存的音量增益值,若不存在则返回 DEFAULT_VOL function getSavedVolumeGain() { let saved = DEFAULT_VOL; try { saved = GM_getValue(storageKey, DEFAULT_VOL); } catch (e) { console.warn('GM_getValue 不可用,使用默认音量'); } return Math.min(MAX_GAIN, Math.max(0, saved)); } // ---------- 转换函数:滑块位置 (0~1) <-> 增益值 (0~MAX_GAIN) ---------- function sliderToGain(x) { if (x <= 0.6) { // 线性映射:0 -> 0, 0.6 -> 2.0 return x * (2.0 / 0.6); // 等价于 x * (10/3) } else { // 线性映射:0.6 -> 2.0, 1 -> 6.0 return 10 * x - 4; // 由 2 + (x-0.6)*( (6-2)/(1-0.6) ) 推导得出 } } function gainToSlider(gain) { if (gain <= 2.0) { // 增益 0~2.0 对应滑块 0~0.6 return gain * (0.6 / 2.0); // 等价于 gain * 0.3 } else { // 增益 2.0~6.0 对应滑块 0.6~1 return (gain + 4) / 10; } } // 确保所有媒体元素都已连接(尝试连接未连接或失败的) function ensureAllMediaConnected() { document.querySelectorAll('audio, video').forEach(media => { const info = mediaMap.get(media); if (!info || info.status === 'failed') { connectMedia(media); } }); // 额外同步一次保存的音量,确保所有媒体都得到应用 const savedGain = getSavedVolumeGain(); setVolume(savedGain); } // 强制重新连接所有媒体(清空现有映射,重新连接) function reconnectAllMedia() { mediaMap = new WeakMap(); // 清空映射,旧节点会被垃圾回收 document.querySelectorAll('audio, video').forEach(media => connectMedia(media)); } // ---------- 核心:连接单个媒体元素 ---------- function connectMedia(media) { if (mediaMap.has(media)) return; if (media.readyState < 1) { media.addEventListener('loadedmetadata', () => connectMedia(media), { once: true }); return; } try { if (!audioContext) { audioContext = new (window.AudioContext || window.webkitAudioContext)(); document.addEventListener('visibilitychange', () => { if (document.hidden) { // 页面隐藏:挂起 AudioContext(如果正在运行) // if (audioContext && audioContext.state === 'running') { // audioContext.suspend(); // } } else { // 页面重新可见 if (audioContext) { if (audioContext.state === 'suspended') { // 尝试恢复 audioContext.resume() .then(() => { console.log('✅ AudioContext resumed'); // 恢复后确保所有媒体连接正常 ensureAllMediaConnected(); }) .catch(err => { console.warn('⚠️ AudioContext resume failed, attempting to reconnect', err); // 如果恢复失败,尝试重建 reconnectAllMedia(); }); } else if (audioContext.state === 'closed') { // Context 被意外关闭,重建 console.warn('AudioContext closed, recreating'); audioContext = null; reconnectAllMedia(); } else { // 已经是 running,但为确保安全,仍检查连接 ensureAllMediaConnected(); } } else { // 没有 AudioContext,创建并连接 reconnectAllMedia(); } } }); } const track = audioContext.createMediaElementSource(media); const gainNode = audioContext.createGain(); // 获取保存的音量,并应用到 gainNode const savedGain = getSavedVolumeGain(); gainNode.gain.value = savedGain; // gainNode.gain.value = DEFAULT_VOL; track.connect(gainNode); gainNode.connect(audioContext.destination); mediaMap.set(media, { status: 'connected', gainNode }); console.log('✅ 音量放大器: 成功连接', media); if (audioContext.state === 'suspended') audioContext.resume().catch(() => {}); } catch (err) { console.warn('⚠️ 音量放大器: 连接失败,降级为volume控制', err); mediaMap.set(media, { status: 'failed' }); // 应用保存的音量到 volume 属性(限制在 0~1) const savedGain = getSavedVolumeGain(); media.volume = Math.min(1, Math.max(0, savedGain)); } } // ---------- 设置所有媒体的音量(接收增益值 gain)---------- function setVolume(gain) { const volForVolumeProp = Math.min(1, Math.max(0, gain)); document.querySelectorAll('audio, video').forEach(media => { const info = mediaMap.get(media); if (info && info.status === 'connected' && info.gainNode) { try { info.gainNode.gain.value = gain; } catch (e) { console.warn('增益节点失效,尝试重新连接', media); mediaMap.delete(media); connectMedia(media); } } media.volume = volForVolumeProp; if (!info) connectMedia(media); }); } // ---------- 创建悬浮按钮 ---------- function createButton() { if (btn) return; btn = document.createElement('div'); btn.id = BUTTON_ID; btn.innerHTML = '🔊'; // 音量图标 btn.style.cssText = ` position: fixed; width: 20px; height: 20px; background: #ffffff; color: blue; border-radius: 50%; box-shadow: 0 2px 10px rgba(0,0,0,0.3); display: flex; align-items: center; justify-content: center; font-size: 16px; cursor: pointer; z-index: 100000; user-select: none; transition: background 0.2s, transform 0.1s; `; btn.addEventListener('mouseenter', () => btn.style.background = '#ffffff'); btn.addEventListener('mouseleave', () => btn.style.background = '#ffffff'); // 加载保存的位置 const savedX = GM_getValue('btnX', 10); const savedY = GM_getValue('btnY', 10); btn.style.left = savedX + 'px'; btn.style.top = savedY + 'px'; document.body.appendChild(btn); makeDraggable(btn); } // ---------- 将面板定位在按钮附近 ---------- function positionPanelNearButton() { if (!btn || !panel) return; const btnRect = btn.getBoundingClientRect(); const panelWidth = panel.offsetWidth; const panelHeight = panel.offsetHeight; const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; // 默认位置:按钮正下方,左边缘与按钮对齐 let top = btnRect.bottom + 5; let left = btnRect.left; // 如果下方空间不足,放在按钮上方 if (top + panelHeight > viewportHeight) { top = btnRect.top - panelHeight - 5; } // 如果上方也不足,则强制放在底部(但这种情况极少) if (top < 0) { top = Math.max(5, viewportHeight - panelHeight - 5); } // 水平边界检查:防止右侧溢出 if (left + panelWidth > viewportWidth) { left = viewportWidth - panelWidth - 5; } // 防止左侧溢出 if (left < 0) { left = 5; } panel.style.top = top + 'px'; panel.style.left = left + 'px'; } // ---------- 使元素可拖动 ---------- function makeDraggable(el) { let offsetX, offsetY, isDragging = false, startX, startY; const dragThreshold = 5; // 拖动阈值(像素) el.addEventListener('mousedown', (e) => { e.preventDefault(); startX = e.clientX; startY = e.clientY; isDragging = false; // 尚未开始拖动 const rect = el.getBoundingClientRect(); offsetX = e.clientX - rect.left; offsetY = e.clientY - rect.top; const onMouseMove = (e) => { e.preventDefault(); const dx = e.clientX - startX; const dy = e.clientY - startY; if (!isDragging && (Math.abs(dx) > dragThreshold || Math.abs(dy) > dragThreshold)) { isDragging = true; } if (isDragging) { let newLeft = e.clientX - offsetX; let newTop = e.clientY - offsetY; newLeft = Math.max(0, Math.min(window.innerWidth - el.offsetWidth, newLeft)); newTop = Math.max(0, Math.min(window.innerHeight - el.offsetHeight, newTop)); el.style.left = newLeft + 'px'; el.style.top = newTop + 'px'; // 如果面板当前可见,同步移动面板 if (panel && panel.style.display === 'block') { positionPanelNearButton(); } } }; const onMouseUp = (e) => { document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mouseup', onMouseUp); document.body.style.userSelect = ''; // 恢复文本选择 if (!isDragging) { // 没有拖动,视为点击 togglePanel(); } else { // 拖动结束,保存位置 GM_setValue('btnX', parseInt(el.style.left)); GM_setValue('btnY', parseInt(el.style.top)); } }; document.addEventListener('mousemove', onMouseMove); document.addEventListener('mouseup', onMouseUp); document.body.style.userSelect = 'none'; // 防止拖动时选中文本 }); } // ---------- 创建音量面板 ---------- function createPanel() { if (panel) return; panel = document.createElement('div'); panel.id = PANEL_ID; // const initialSliderPos = gainToSlider(DEFAULT_VOL); const savedGain = getSavedVolumeGain(); // 读取保存的音量 const initialSliderPos = gainToSlider(savedGain); // 计算滑块位置 panel.innerHTML = `