Greasy Fork is available in English.
悬浮按钮可拖动,点击显示/隐藏音量面板;支持0-600%非线性调节
// ==UserScript== // @name 音量控制器 // @namespace http://tampermonkey.net/ // @version 5.2 // @description 悬浮按钮可拖动,点击显示/隐藏音量面板;支持0-600%非线性调节 // @author [email protected] // @match *://*/* // @run-at document-start // @grant GM_setValue // @grant GM_getValue // @license MIT // ==/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 = ` <div style="margin-bottom:5px; font-weight:bold;">全局音量 (0-600%,非线性)</div> <input type="range" id="global-gain-slider" min="0" max="1" step="0.001" value="${initialSliderPos}" style="cursor:grab;"> <span id="global-gain-display">${Math.round(savedGain * 100)}%</span> `; panel.style.cssText = ` cursor:default; position: fixed; top: 60px; /* 默认在按钮下方,可通过拖动改变位置?如果需要可添加独立拖动 */ left: 10px; background: white; padding: 10px; border: 1px solid #ccc; border-radius: 5px; box-shadow: 0 2px 10px rgba(0,0,0,0.2); z-index: 100000; font-family: sans-serif; display: none; /* 默认隐藏 */ `; document.body.appendChild(panel); // 在 panel 创建并插入到 document.body 之后,添加点击事件 panel.addEventListener('click', (e) => { // 如果点击的是滑块(<input type="range">),不处理,让滑块正常工作 if (e.target.tagName === 'INPUT' && e.target.type === 'range') { return; // 注意:这里不需要阻止冒泡,因为滑块本身不触发隐藏即可 } // 否则隐藏面板 panel.style.display = 'none'; isPanelVisible = false; // 更新状态变量 e.stopPropagation(); // 阻止事件冒泡到按钮,避免按钮的切换逻辑干扰 }); const slider = document.getElementById('global-gain-slider'); const display = document.getElementById('global-gain-display'); slider.addEventListener('input', (e) => { const x = parseFloat(e.target.value); const gain = sliderToGain(x); display.textContent = Math.round(gain * 100) + '%'; setVolume(gain); saveVolumeGain(gain); // 新增:保存音量 }); slider.addEventListener('mousedown', (e) => { if (audioContext && audioContext.state === 'suspended') { audioContext.resume().catch(() => {}); } }); // 在滑块绑定后添加: setVolume(savedGain); } // ---------- 切换面板显示/隐藏 ---------- function togglePanel() { if (!panel) return; isPanelVisible = !isPanelVisible; panel.style.display = isPanelVisible ? 'block' : 'none'; if (isPanelVisible) { positionPanelNearButton(); // 显示时定位 } } // ---------- 更新UI的显示状态(根据有无媒体)---------- function updateUIVisibility() { const hasMedia = document.querySelectorAll('audio, video').length > 0; if (hasMedia) { if (!btn) createButton(); if (!panel) createPanel(); // 按钮始终显示 btn.style.display = 'flex'; // 面板的显示由按钮控制,但若媒体消失,我们强制隐藏面板 if (!hasMedia) { panel.style.display = 'none'; isPanelVisible = false; } } else { if (btn) btn.style.display = 'none'; if (panel) { panel.style.display = 'none'; isPanelVisible = false; } } } // ---------- 监听媒体元素的增减 ---------- const observer = new MutationObserver(() => { document.querySelectorAll('audio, video').forEach(media => { if (!mediaMap.has(media)) connectMedia(media); }); updateUIVisibility(); }); // 初始化 if (document.body) { observer.observe(document.body, { childList: true, subtree: true }); document.querySelectorAll('audio, video').forEach(media => connectMedia(media)); updateUIVisibility(); } else { window.addEventListener('DOMContentLoaded', () => { observer.observe(document.body, { childList: true, subtree: true }); document.querySelectorAll('audio, video').forEach(media => connectMedia(media)); updateUIVisibility(); }); } // 定期重试连接失败的媒体 setInterval(() => { document.querySelectorAll('audio, video').forEach(media => { const info = mediaMap.get(media); if (!info || info.status === 'failed') connectMedia(media); }); updateUIVisibility(); }, CHECK_INTERVAL); // window.addEventListener('resize', () => { if (panel && panel.style.display === 'block') { positionPanelNearButton(); } }); // 清理 window.addEventListener('beforeunload', () => { observer.disconnect(); if (audioContext) audioContext.close(); }); })();