Greasy Fork

Greasy Fork is available in English.

音量控制器

悬浮按钮可拖动,点击显示/隐藏音量面板;支持0-600%非线性调节

当前为 2026-03-22 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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();
    });
})();