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.8
// @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; /* 確保在最上層 */
                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');
        
        // 阻擋事件冒泡並阻止預設行為
        const stopAndPrevent = (e) => {
            e.stopPropagation();
            // 阻止預設行為,防止點擊/拖曳滑桿時觸發影片暫停/播放
            e.preventDefault(); 
        };

        if (!container) {
            // 確保父層容器有定位屬性
            const parentStyle = window.getComputedStyle(parent);
            if (parentStyle.position === 'static') {
                parent.style.position = 'relative';
            }

            container = document.createElement('div');
            container.className = 'mvf-overlay';
            
            
            // 對容器綁定通用事件 (點擊、雙擊、滑鼠抬起/放下)
            container.addEventListener('click', stopAndPrevent);
            container.addEventListener('dblclick', stopAndPrevent);
            container.addEventListener('mouseup', stopAndPrevent);
            container.addEventListener('touchend', stopAndPrevent);
            container.addEventListener('pointerup', stopAndPrevent);


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

            // V2.8 修正:將最關鍵的 'mousedown' 和 'touchstart' 直接綁定到滑桿和文字,以最高優先級阻止事件穿透。
            slider.addEventListener('mousedown', stopAndPrevent);
            slider.addEventListener('touchstart', stopAndPrevent);
            slider.addEventListener('pointerdown', stopAndPrevent);
            
            text.addEventListener('mousedown', stopAndPrevent);
            text.addEventListener('touchstart', stopAndPrevent);
            text.addEventListener('pointerdown', stopAndPrevent);


            // 綁定滑桿事件
            slider.addEventListener('input', (e) => {
                const val = parseFloat(e.target.value);
                video.volume = val;
                text.textContent = Math.round(val * 100) + '%';
                
                // 標記為使用者透過我們的 UI 手動調整
                video.dataset.userManualSet = 'true';
                video.dataset.lastVolume = val; // V2.6 新增: 記錄使用者透過自訂滑桿設定的音量
                // 如果使用者自己拉到 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% 暴音。
        if (video.dataset.userManualSet === 'true') {
            const lastVolume = parseFloat(video.dataset.lastVolume) || CONFIG.defaultVolume;

            // 例外:如果網站試圖重置為 100%,且使用者未允許 Max Volume,則將其還原至手動設定值
            if (video.volume === 1 && video.dataset.userMaxVolume !== 'true') {
                 log(`[${reason}] 偵測到異常 100% 重置 (手動模式),強制還原至 ${Math.round(lastVolume * 100)}%。`);
                 video.volume = lastVolume;
            }
            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;
            
            // V2.6 新增: 初始化 lastVolume。如果沒設定過,預設為 0.1
            if (typeof videoElement.dataset.lastVolume === 'undefined') {
                videoElement.dataset.lastVolume = CONFIG.defaultVolume;
            }


            // 事件 1:音量數值改變
            videoElement.addEventListener('volumechange', () => {
                // 同步 UI (這必須先執行,確保 UI 反映當前真實音量)
                createVolumeUI(videoElement);

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

                // 如果剛剛是靜音,現在變成了有聲 (Unmute 事件)
                if (lastMuted && !currentMuted) {
                    // 若網站自動重置,且使用者沒有手動設定過,強制回到預設值 0.1
                    if (videoElement.dataset.userManualSet !== 'true') {
                        videoElement.volume = CONFIG.defaultVolume;
                        log('偵測到解除靜音 (Unmute),強制回到預設音量。');
                    }
                }
                
                // 更新靜音狀態記錄
                videoElement.dataset.lastMuted = currentMuted;

                
                // 針對 100% 的絕對防禦 (網站腳本強制重置)
                if (videoElement.volume === 1) {
                    if (videoElement.dataset.userMaxVolume !== 'true') {
                        // 判斷要還原到預設值 (非手動模式) 還是上次手動設定值 (手動模式)
                        const resetVolume = (videoElement.dataset.userManualSet === 'true')
                            ? parseFloat(videoElement.dataset.lastVolume) || CONFIG.defaultVolume
                            : CONFIG.defaultVolume;
                            
                        videoElement.volume = resetVolume;
                        log(`攔截到 100% 強制重置 (無論是 Loop 或網站腳本),已還原至 ${Math.round(resetVolume * 100)}%。`);
                    }
                }
            });

            // 事件 2:開始播放 (針對動態牆回收機制 & 自動播放)
            videoElement.addEventListener('play', () => {
                // 播放瞬間再次確認 (主要針對非手動模式下的音量初始化)
                enforceSafeVolume(videoElement, 'Play Event');
            });

            // 事件 3:載入新來源 (針對動態牆回收機制)
            videoElement.addEventListener('loadstart', () => {
                log('偵測到影片來源變更 (Loadstart),重置狀態。');
                
                // 重置所有使用者手動標記,讓新影片從 0.1 開始
                videoElement.dataset.userManualSet = 'false';
                videoElement.dataset.userMaxVolume = 'false';
                
                // 確保 lastVolume 被設定,以便在 loadstart 時 enforceSafeVolume 可以正確初始化
                videoElement.dataset.lastVolume = CONFIG.defaultVolume;
                videoElement.dataset.lastMuted = videoElement.muted; // 重置靜音狀態
                
                // 強制設定
                setTimeout(() => {
                    enforceSafeVolume(videoElement, 'Loadstart Init');
                }, 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();
    }

})();