// ==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.9 // @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 // @downloadURL none // ==/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(); }; // V2.9: 新增在 Capturing 階段就阻止事件的函數 const stopCapture = (el, eventName) => { // true 表示在 Capturing 階段執行監聽器 el.addEventListener(eventName, stopAndPrevent, true); }; 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('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.9 修正核心:在 Capturing 階段就阻止最關鍵的事件 // 確保 Meta 網站的 Capturing 監聽器無法執行其預設行為 (暫停/播放) // 1. 容器 (Overlay) 阻止點擊事件 stopCapture(container, 'click'); // 2. 滑桿 (Slider) 阻止按下滑鼠和觸控事件 stopCapture(slider, 'mousedown'); stopCapture(slider, 'touchstart'); stopCapture(slider, 'pointerdown'); // 3. 文字 (Text) 阻止按下滑鼠和觸控事件 stopCapture(text, 'mousedown'); stopCapture(text, 'touchstart'); stopCapture(text, 'pointerdown'); // 綁定滑桿事件 (冒泡階段,這是正常的 UI 互動) 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(); } })();