Greasy Fork

Greasy Fork is available in English.

Meta 社交媒体音量控制大师 (FB, IG, Threads)

将 Meta 平台的视频默认音量设置为 10%,并在视频上增加一个方便调整音量的滑块界面。

当前为 2025-11-22 提交的版本,查看 最新版本

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Meta 社交媒體音量控制大師 (FB, IG, Threads)
// @name:zh-TW   Meta 社交媒體音量控制大師 (FB, IG, Threads)
// @name:zh-CN   Meta 社交媒体音量控制大师 (FB, IG, Threads)
// @name:en      Meta Media Volume Master (FB, IG, Threads)
// @name:ja      Meta メディア音量マスター (FB, IG, Threads)
// @namespace    http://tampermonkey.net/
// @version      2.1
// @description  將 Meta 平台的影片預設音量設定為 10%,並在影片上增加一個方便調整音量的滑桿介面。
// @description:zh-TW 將 Meta 平台的影片預設音量設定為 10%,並在影片上增加一個方便調整音量的滑桿介面。
// @description:zh-CN 将 Meta 平台的视频默认音量设置为 10%,并在视频上增加一个方便调整音量的滑块界面。
// @description:en Sets the default volume of videos on Meta platforms (FB, IG, Threads) to 10% and adds a convenient volume slider overlay.
// @description:ja Metaプラットフォーム(FB、IG、Threads)の動画のデフォルト音量を10%に設定し、音量を調整しやすいスライダーオーバーレイを追加します。
// @author       You
// @match        *://*.facebook.com/*
// @match        *://*.instagram.com/*
// @match        *://*.threads.net/*
// @grant        none
// @run-at       document-start
// ==/UserScript==

(function() {
    'use strict';

    // ==========================================================
    // 設定區域
    // ==========================================================
    const CONFIG = {
        defaultVolume: 0.1, // 預設音量 10%
        showLogs: true,     // 是否顯示除錯紀錄
        ui: {
            opacityIdle: 0.3,   // 平常的透明度 (0~1)
            opacityHover: 1.0,  // 滑鼠移上去的透明度
            positionTop: '10px',
            positionRight: '10px', // 改為靠右設定
            color: '#ffffff',   // 文字與圖示顏色
            bgColor: 'rgba(0, 0, 0, 0.6)' // 背景顏色
        }
    };
    // ==========================================================

    function log(msg) {
        if (CONFIG.showLogs) {
            console.log(`[音量控制] ${msg}`);
        }
    }

    // 注入 CSS 樣式
    function injectStyles() {
        const styleId = 'meta-volume-fixer-style';
        if (document.getElementById(styleId)) return;

        const css = `
            .mvf-overlay {
                position: absolute;
                top: ${CONFIG.ui.positionTop};
                right: ${CONFIG.ui.positionRight};
                z-index: 9999;
                background-color: ${CONFIG.ui.bgColor};
                padding: 4px 8px;
                border-radius: 20px;
                display: flex;
                align-items: center;
                gap: 8px;
                opacity: ${CONFIG.ui.opacityIdle};
                transition: opacity 0.2s ease;
                font-family: system-ui, -apple-system, sans-serif;
                pointer-events: auto; /* 確保可以點擊 */
                cursor: default;
            }
            .mvf-overlay:hover {
                opacity: ${CONFIG.ui.opacityHover};
            }
            .mvf-slider {
                -webkit-appearance: none;
                width: 80px;
                height: 4px;
                background: rgba(255, 255, 255, 0.3);
                border-radius: 2px;
                outline: none;
                cursor: pointer;
            }
            .mvf-slider::-webkit-slider-thumb {
                -webkit-appearance: none;
                appearance: none;
                width: 12px;
                height: 12px;
                border-radius: 50%;
                background: #fff;
                cursor: pointer;
                transition: transform 0.1s;
            }
            .mvf-slider::-webkit-slider-thumb:hover {
                transform: scale(1.2);
            }
            .mvf-text {
                color: ${CONFIG.ui.color};
                font-size: 12px;
                font-weight: bold;
                min-width: 32px;
                text-align: right;
                user-select: none;
            }
        `;

        const style = document.createElement('style');
        style.id = styleId;
        style.textContent = css;
        document.head.appendChild(style);
    }

    /**
     * 建立音量控制 UI
     * @param {HTMLVideoElement} video
     */
    function createVolumeUI(video) {
        // 檢查是否已經添加過 UI
        const parent = video.parentNode;
        if (parent.querySelector('.mvf-overlay')) return;

        // 確保父層容器有定位屬性,這樣 absolute 才能相對於影片定位
        const parentStyle = window.getComputedStyle(parent);
        if (parentStyle.position === 'static') {
            parent.style.position = 'relative';
        }

        const container = document.createElement('div');
        container.className = 'mvf-overlay';
        
        // 為了防止點擊穿透導致影片暫停/播放,我們阻擋 click 事件冒泡
        container.addEventListener('click', (e) => e.stopPropagation());
        container.addEventListener('dblclick', (e) => e.stopPropagation());

        const slider = document.createElement('input');
        slider.type = 'range';
        slider.className = 'mvf-slider';
        slider.min = '0';
        slider.max = '1';
        slider.step = '0.01';
        slider.value = video.volume;

        const text = document.createElement('span');
        text.className = 'mvf-text';
        text.textContent = Math.round(video.volume * 100) + '%';

        // 事件:滑動滑桿改變音量
        slider.addEventListener('input', (e) => {
            const val = parseFloat(e.target.value);
            video.volume = val;
            text.textContent = Math.round(val * 100) + '%';
            
            // 標記為使用者手動調整,防止腳本後續干涉
            video.dataset.userManualSet = 'true';
            
            // 特別處理:如果使用者手動拉到 100%,我們給予特殊標記,允許它保持 100%
            if (val === 1) {
                video.dataset.userMaxVolume = 'true';
            } else {
                video.dataset.userMaxVolume = 'false';
            }
        });

        // 事件:監聽影片音量變化 (同步 UI)
        video.addEventListener('volumechange', () => {
            slider.value = video.volume;
            text.textContent = Math.round(video.volume * 100) + '%';
        });

        container.appendChild(slider);
        container.appendChild(text);
        
        // 將 UI 插入到影片的父層容器中
        parent.appendChild(container);
    }

    /**
     * 設定單個影片元素的音量與 UI
     * @param {HTMLVideoElement} videoElement
     */
    function adjustVolume(videoElement) {
        // 1. 確保 UI 存在
        createVolumeUI(videoElement);

        // 2. 音量控制邏輯
        if (videoElement.dataset.volumeAdjusted === 'true') {
            // 如果這是網站自動重置為 100%,且使用者沒有手動允許 100%,則強制壓回
            if (videoElement.volume === 1 && videoElement.dataset.userMaxVolume !== 'true') {
                 videoElement.volume = CONFIG.defaultVolume;
                 log('偵測到音量被重置為 100%,再次強制降低。');
            }
            return;
        }

        // 初次發現影片,設定預設音量
        videoElement.volume = CONFIG.defaultVolume;
        videoElement.dataset.volumeAdjusted = 'true';
        log(`已將新發現的影片音量設定為 ${CONFIG.defaultVolume * 100}%`);

        // 添加監聽器:防止網站本身的腳本在影片開始播放瞬間將音量重置為 1
        videoElement.addEventListener('volumechange', (event) => {
            // 只有當音量變為 1.0 (100%) 且 使用者沒有手動將滑桿拉到最底 時,才進行攔截
            if (videoElement.volume === 1 && videoElement.dataset.userMaxVolume !== 'true') {
                videoElement.volume = CONFIG.defaultVolume;
                log('攔截到網站嘗試將音量重置為 100%,已駁回。');
            }
        });
    }

    /**
     * 處理頁面上現有的和未來新增的影片
     */
    function observePage() {
        injectStyles();

        // 1. 建立 MutationObserver
        const observer = new MutationObserver((mutations) => {
            mutations.forEach((mutation) => {
                mutation.addedNodes.forEach((node) => {
                    if (node.nodeName === 'VIDEO') {
                        adjustVolume(node);
                    } else if (node.nodeType === 1) {
                        const videos = node.querySelectorAll('video');
                        videos.forEach(adjustVolume);
                    }
                });
            });
        });

        observer.observe(document.body, {
            childList: true,
            subtree: true
        });

        // 2. 處理現有影片
        document.querySelectorAll('video').forEach(adjustVolume);
    }

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', observePage);
    } else {
        observePage();
    }

})();