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.5
// @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/*
// @match        *://*.threads.com/*
// @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: 2147483647; /* 使用 CSS 允許的最大整數值,確保在最上層 */
                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 !important; /* 強制接收滑鼠事件,防止被透明層擋住 */
                cursor: default;
                isolation: isolate; /* 建立新的堆疊環境 */
            }
            .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;
                pointer-events: auto !important;
            }
            .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;
                pointer-events: auto !important;
            }
            .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) {
        const parent = video.parentNode;
        let container = parent.querySelector('.mvf-overlay');
        
        if (!container) {
            // 確保父層容器有定位屬性
            const parentStyle = window.getComputedStyle(parent);
            if (parentStyle.position === 'static') {
                parent.style.position = 'relative';
            }

            container = document.createElement('div');
            container.className = 'mvf-overlay';
            
            // 阻擋事件冒泡:確保點擊滑桿不會觸發影片暫停/播放
            // 增加更多事件類型以確保萬無一失
            const stopPropagation = (e) => {
                e.stopPropagation();
                // 某些網站可能會監聽 capture 階段,這裡不做 preventDefault 以免影響滑桿拖曳
            };
            
            container.addEventListener('click', stopPropagation);
            container.addEventListener('dblclick', stopPropagation);
            container.addEventListener('mousedown', stopPropagation);
            container.addEventListener('mouseup', stopPropagation);
            container.addEventListener('touchstart', stopPropagation);
            container.addEventListener('touchend', stopPropagation);
            container.addEventListener('pointerdown', stopPropagation);
            container.addEventListener('pointerup', stopPropagation);

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

            container.appendChild(slider);
            container.appendChild(text);
            parent.appendChild(container);

            // 綁定滑桿事件
            slider.addEventListener('input', (e) => {
                const val = parseFloat(e.target.value);
                video.volume = val;
                text.textContent = Math.round(val * 100) + '%';
                
                // 標記為使用者透過我們的 UI 手動調整
                video.dataset.userManualSet = 'true';
                // 如果使用者自己拉到 100%,標記為允許最大音量
                video.dataset.userMaxVolume = (val === 1) ? 'true' : 'false';
            });
        }

        // 更新 UI 狀態
        const slider = container.querySelector('.mvf-slider');
        const text = container.querySelector('.mvf-text');
        
        if (slider && text) {
            // 只有當數值真的不同時才更新,避免循環觸發
            if (Math.abs(slider.value - video.volume) > 0.01) {
                slider.value = video.volume;
                text.textContent = Math.round(video.volume * 100) + '%';
            }
        }
    }

    /**
     * 核心邏輯:強制設定安全音量
     * @param {HTMLVideoElement} video
     * @param {string} reason
     */
    function enforceSafeVolume(video, reason) {
        // 如果使用者已經透過我們的滑桿手動設定過,且設定值不是 100% (或者允許 100%),則尊重使用者
        if (video.dataset.userManualSet === 'true') {
            // 例外:如果使用者之前設定了音量,但網站突然把它變成了 100%,而使用者沒有允許 100%
            if (video.volume === 1 && video.dataset.userMaxVolume !== 'true') {
                 log(`[${reason}] 雖然曾手動調整,但偵測到異常 100% 重置,強制修正。`);
                 video.volume = CONFIG.defaultVolume;
            }
            return;
        }

        // 執行強制設定
        if (video.volume > CONFIG.defaultVolume) {
            video.volume = CONFIG.defaultVolume;
            log(`[${reason}] 強制將音量從 ${Math.round(video.volume * 100)}% 修正為 ${CONFIG.defaultVolume * 100}%`);
            createVolumeUI(video); // 同步 UI
        }
    }

    /**
     * 設定單個影片元素的音量與 UI
     * @param {HTMLVideoElement} videoElement
     */
    function adjustVolume(videoElement) {
        // 1. 初始化設定
        if (!videoElement.dataset.mvfInitialized) {
            videoElement.dataset.mvfInitialized = 'true';
            
            // 記錄上一次的靜音狀態
            videoElement.dataset.lastMuted = videoElement.muted;

            // 事件 1:音量數值改變
            videoElement.addEventListener('volumechange', () => {
                // 同步 UI
                createVolumeUI(videoElement);

                // 檢查是否剛解除靜音 (Unmute)
                const currentMuted = videoElement.muted;
                const lastMuted = videoElement.dataset.lastMuted === 'true';

                // 如果剛剛是靜音,現在變成了有聲 (Unmute 事件)
                if (lastMuted && !currentMuted) {
                    log('偵測到解除靜音 (Unmute),執行安全音量檢查。');
                    // 解除靜音時,如果不是使用者手動透過滑桿設定的,強制回到預設值
                    // 這是解決「一般影片」按下喇叭會爆音的關鍵
                    if (videoElement.dataset.userManualSet !== 'true') {
                        videoElement.volume = CONFIG.defaultVolume;
                    }
                }
                
                // 更新靜音狀態記錄
                videoElement.dataset.lastMuted = currentMuted;

                // 防爆音檢查:如果音量過大且非手動設定
                if (videoElement.volume > CONFIG.defaultVolume && videoElement.dataset.userManualSet !== 'true') {
                    // 給一點緩衝,避免浮點數誤差,例如 0.1000001
                    if (videoElement.volume > CONFIG.defaultVolume + 0.01) {
                         // 針對一般影片:如果在非手動模式下音量突然變大,強制壓回
                         // 注意:這會讓「原生」音量控制條難以調大音量,強迫使用者使用我們的滑桿
                         // 但這是解決「預設過大」最有效的方法
                         videoElement.volume = CONFIG.defaultVolume;
                         log('攔截到非手動的音量變大,已駁回。');
                    }
                }
                
                // 針對 100% 的絕對防禦 (針對網站腳本強制重置)
                if (videoElement.volume === 1 && videoElement.dataset.userMaxVolume !== 'true') {
                    videoElement.volume = CONFIG.defaultVolume;
                }
            });

            // 事件 2:開始播放 (針對動態牆回收機制 & 自動播放)
            videoElement.addEventListener('play', () => {
                // 播放瞬間再次確認
                enforceSafeVolume(videoElement, 'Play Event');
            });

            // 事件 3:載入新來源 (針對動態牆回收機制)
            videoElement.addEventListener('loadstart', () => {
                log('偵測到影片來源變更 (Loadstart),重置狀態。');
                
                // 重置所有使用者手動標記
                videoElement.dataset.userManualSet = 'false';
                videoElement.dataset.userMaxVolume = 'false';
                videoElement.dataset.lastMuted = videoElement.muted; // 重置靜音狀態
                
                // 強制設定
                setTimeout(() => {
                    videoElement.volume = CONFIG.defaultVolume;
                    createVolumeUI(videoElement);
                }, 0);
            });
        }

        // 2. 確保 UI 存在
        createVolumeUI(videoElement);
        
        // 3. 初次執行檢查
        enforceSafeVolume(videoElement, 'Init');
    }

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

        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
        });

        document.querySelectorAll('video').forEach(adjustVolume);
    }

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

})();