Greasy Fork

Greasy Fork is available in English.

音量控制器

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         音量控制器
// @namespace    http://tampermonkey.net/
// @version      5.0
// @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 = 3000; // 定期重试连接失败的媒体(毫秒)
    const BUTTON_ID = 'volume-controller-btn';
    const PANEL_ID = 'volume-controller-panel';
    // ===================

    let audioContext = null;
    const mediaMap = new WeakMap();
    let btn = null; // 悬浮按钮元素
    let panel = null; // 音量面板元素
    let isPanelVisible = false; // 当前面板显示状态(仅记录,实际由按钮切换)

    // ---------- 转换函数:滑块位置 (0~1) <-> 增益值 (0~MAX_GAIN) ----------
    function sliderToGain(x) {
        if (x <= 0.5) return x * 2;
        else return 1 + (x - 0.5) * (MAX_GAIN - 1) * 2;
    }
    function gainToSlider(gain) {
        if (gain <= 1) return gain / 2;
        else return 0.5 + (gain - 1) / ((MAX_GAIN - 1) * 2);
    }

    // ---------- 核心:连接单个媒体元素 ----------
    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.state === 'running') audioContext.suspend();
                    else if (!document.hidden && audioContext.state === 'suspended') audioContext.resume().catch(() => {});
                });
            }
            const track = audioContext.createMediaElementSource(media);
            const gainNode = audioContext.createGain();
            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' });
        }
    }

    // ---------- 设置所有媒体的音量(接收增益值 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);
        panel.innerHTML = `
            <div style="margin-bottom:5px; font-weight:bold;">全局音量 (0-600%,左半0-100%)</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(DEFAULT_VOL * 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: 99999;
            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);
        });
        slider.addEventListener('mousedown', (e) => {
            if (audioContext && audioContext.state === 'suspended') {
                audioContext.resume().catch(() => {});
            }
        });
    }

    // ---------- 切换面板显示/隐藏 ----------
    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();
    });
})();