// ==UserScript==
// @name YouTube — Auto Switch Live Chat
// @name:zh-CN YouTube — 自动切换直播聊天室
// @name:zh-TW YouTube — 自動切換即時聊天室
// @name:ja YouTube — ライブチャット自動切替
// @name:ko YouTube — 실시간 채팅 자동 전환
// @name:es YouTube — Cambio automático de chat en vivo
// @name:fr YouTube — Commutateur automatique de chat en direct
// @name:de YouTube — Automatischer Live-Chat-Wechsel
// @name:it YouTube — Cambio automatico della chat dal vivo
// @name:ru YouTube — Автоматическое переключение чата
// @name:pt YouTube — Alternância automática de chat ao vivo
// @name:nl YouTube — Automatisch Live Chat Schakelen
// @namespace greasyfork-Auto Switch Live Chat
// @version 1.3.9
// @description Automatically switch YouTube live chat to "All Messages" and supports Replay Chat. Optimized for high discoverability and low performance impact.
// @description:zh-CN 自动将 YouTube 直播聊天切换到「所有消息」並支援重播聊天室。已優化可發現性與效能。
// @description:zh-TW 自動將 YouTube 即時聊天切換到「所有訊息」並支援重播聊天室。已優化可發現性與效能。
// @description:ja YouTubeライブチャットを自動で「すべてのチャット」に切替、リプレイチャット対応。検出性とパフォーマンスを最適化。
// @description:ko YouTube 실시간 채팅을 자동으로 "모든 메시지"로 전환하며 다시보기 채팅 지원. 감지 및 성능 최적화.
// @description:es Cambia automáticamente el chat en vivo de YouTube a "Todos los mensajes" y soporta Chat de Repetición. Optimizado para alta detección y bajo impacto de rendimiento.
// @description:fr Bascule automatiquement le chat en direct de YouTube sur "Tous les messages" et supporte Chat de Rediffusion. Optimisé pour une haute détectabilité et un faible impact sur les performances.
// @description:de Schaltet den YouTube Live-Chat automatisch auf "Alle Nachrichten" um und unterstützt Replay-Chat. Optimiert für hohe Auffindbarkeit und geringe Leistungsauswirkungen.
// @description:it Passa automaticamente la chat dal vivo di YouTube a "Tutti i messaggi" e supporta Chat di Replay. Ottimizzato per l'alta rilevabilità e il basso impatto sulle prestazioni.
// @description:ru Автоматически переключает чат YouTube на "Все сообщения" и поддерживает чат повтора. Оптимизирован для высокой обнаруживаемости и низкого воздействия на производительность.
// @description:pt Alterna automaticamente o chat ao vivo do YouTube para "Todas as mensagens" e suporta o chat de repetição. Otimizado para alta detecção e baixo impacto no desempenho.
// @description:nl Schakelt YouTube live chat automatisch naar "Alle berichten" en ondersteunt Replay Chat. Geoptimaliseerd voor hoge vindbaarheid en lage prestatie-impact.
// @license MIT
// @author 凝流
// @match https://www.youtube.com/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=youtube.com
// @grant none
// @run-at document-idle
// ==/UserScript==
(function () {
'use strict';
// ==================== 配置 / Configuration ====================
// - 腳本運作的參數配置 (Script behavior parameters)
const CONFIG = {
MAX_RETRIES: 25,
RETRY_INTERVAL: 1000,
OBSERVER_TIMEOUT: 6000, // 用於所有等待超時 (Used for all waiting timeouts)
SPA_DELAY: 300, // 僅用於初始掃描 (Only for initial scan)
DEBUG: true // 設為 true 可查看詳細日誌 (Set to true for detailed logs)
};
// ==================== 多語言關鍵字 / Multi-language Keywords ====================
// - 原始關鍵字 (Raw keywords)
const _KEYWORDS_RAW = {
// 點擊開啟選單的按鈕文字 (Text for button to open the menu)
button: [
// --- Live Chat Keywords ---
'top chat', 'top-chat', // English
'重點', '直播聊天', // Traditional Chinese
'重点', '直播聊天室', '重要消息', // Simplified Chinese
'トップチャット', 'チャット', // Japanese
'주요 채팅', // Korean
'chat destacado', 'principal', // Spanish
'Principais mensagens', // Portuguese
'Интересные сообщения', // Russian
'Top Chat', // General
'Messaggi principali', // Italian
'Top-Chat', 'Top-Chat-Meldungen', // German
// --- Replay Chat Keywords ---
'top replay chat', 'top messages replay', // English
'重播熱門聊天室訊息', // Traditional Chinese
'重播热门聊天室消息', '重播重要消息', // Simplified Chinese
'上位のチャットのリプレイ', // Japanese
'주요 다시보기 채팅', '다시보기 주요 메시지', // Korean
'chat destacado de repetición', 'mensajes destacados de repetición', // Spanish
"revoir l'essentiel du chat", // French
'top-chat-wiedergabe', 'top-chat-nachrichten-wiedergabe', // German
'chat di replay', 'messaggi principali di replay', // Italian
'только интересные сообщения', // Russian
'principais mensagens de reprise', 'chat de reprise', // Portuguese
],
// 選單中「顯示所有訊息」的選項文字 (Menu option text for "All Messages")
menu: [
// --- Live Chat Keywords ---
'all messages', 'live chat', // English
'所有訊息', '即時聊天', '聊天室', // Traditional Chinese
'所有消息', '实时聊天', // Simplified Chinese
'すべてのチャット', 'チャット', // Japanese
'실시간 채팅', // Korean
'Chat en directo', 'todos los mensajes', // Spanish
'Chat em direto', // Portuguese
'все сообщения', 'Чат', // Russian
'Chat en direct', // French
'alle chats', // Dutch
'Chat live', // General
'Alle Nachrichten', // German
'tutti i messaggi', // Italian
// --- Replay Chat Keywords ---
'live chat replay', 'all messages replay', // English
'聊天重播', '你可以查看所有訊息', // Traditional Chinese
'你可以查看所有消息', // Simplified Chinese
'チャットのリプレイ', // Japanese
'실시간 채팅 다시보기', '모든 메시지 다시보기', // Korean
'chat en directo de repetición', 'todos los mensajes de repetición', // Spanish
'replay du chat en direct', // French
'alle nachrichten wiedergabe', 'live-chat-wiedergabe', // German
'tutti i messaggi di replay', // Italian
'запись чата', // Russian
'todas as mensagens de reprise', 'chat ao vivo de reprise', // Portuguese
],
// 排除關鍵字(避免誤點選單中相同的關鍵字) (Exclude keywords to avoid misclicks)
exclude: [
// --- Live Chat Exclude ---
'top chat', // English
'重點', '重要消息', // Traditional/Simplified Chinese
'chat destacado', // Spanish
'лучший чат', 'Интересные сообщения', // Russian
'トップチャット', // Japanese
'Top Chat', // General
'Principais mensagens', // Portuguese
'Messaggi principali', // Italian
'주요 채팅', // Korean
'Top-Chat', 'Top-Chat-Meldungen', // German
// --- Replay Chat Exclude ---
'top replay chat', 'top messages replay', // English
'重播熱門聊天室訊息', // Traditional Chinese
'重播重要消息', // Simplified Chinese
'上位のチャットのリプレイ', // Japanese
'주요 다시보기 채팅', // Korean
'chat destacado de repetición', // Spanish
"revoir l'essentiel du chat", // French
'top-chat-wiedergabe', // German
'messaggi principali di replay', // Italian
'только интересные сообщения', // Russian
'principais mensagens de reprise', // Portuguese
]
};
// - 正規化工具 (Normalization utility)
const normalize = (str) => {
if (!str) return '';
// 統一處理大小寫、變音符號、各種空白字元、引號和破折號
return String(str)
.toLowerCase()
.normalize('NFKD') // 處理變音符號
.replace(/[\u0300-\u036f]/g, '') // 移除重音 (例如法語 l'essentiel)
.replace(/[\s\u00A0\u200B\u2009\u202F\uFEFF]+/g, '') // 移除各類空白字元
.replace(/[’'`"“”«»\-–—‑]+/g, ''); // 移除常見引號與各類破折號
};
// - 預先正規化的關鍵字 (Pre-normalized keywords for performance)
const KEYWORDS = {
button_norm: _KEYWORDS_RAW.button.map(normalize),
menu_norm: _KEYWORDS_RAW.menu.map(normalize),
exclude_norm: _KEYWORDS_RAW.exclude.map(normalize)
};
// ==================== 狀態管理 / State Management ====================
class StateManager {
constructor() {
this.currentNavId = Date.now();
this.processedFrames = new Map();
this.processedDocs = new Map();
this.activeObservers = new Set();
this.activeTimers = new Set();
}
reset() {
this.activeObservers.forEach(obs => {
try {
obs.disconnect();
} catch (e) {}
});
this.activeObservers.clear();
this.activeTimers.forEach(timer => {
try {
clearTimeout(timer);
} catch (e) {}
});
this.activeTimers.clear();
this.processedFrames.clear();
this.processedDocs.clear();
this.currentNavId = Date.now();
}
addObserver(observer) {
this.activeObservers.add(observer);
return observer;
}
addTimer(callback, delay) {
const timer = setTimeout(() => {
this.activeTimers.delete(timer);
try {
callback();
} catch (e) {
console.error('[YT Chat] Timer callback error:', e);
}
}, delay);
this.activeTimers.add(timer);
return timer;
}
clearTimer(timer) {
if (this.activeTimers.has(timer)) {
this.activeTimers.delete(timer);
clearTimeout(timer);
}
}
isProcessed(element, isFrame = false) {
if (!element) return true;
const map = isFrame ? this.processedFrames : this.processedDocs;
return map.has(element);
}
markProcessed(element, isFrame = false) {
if (!element) return;
const map = isFrame ? this.processedFrames : this.processedDocs;
map.set(element, this.currentNavId);
}
isValidNavId(navId) {
return navId === this.currentNavId;
}
}
const state = new StateManager();
// ==================== 日誌系統 / Logging System ====================
const LOG_PREFIX = '[YT Chat]';
const LOG_STYLE = {
init: 'color: #4CAF50; font-weight: bold',
success: 'color: #1E88E5',
warn: 'color: #FF9800',
error: 'color: #F44336'
};
function log(type, msgFn) {
if (!CONFIG.DEBUG && type !== 'error') return;
const style = LOG_STYLE[type] || '';
const message = typeof msgFn === 'function' ? msgFn() : msgFn;
console.log(`%c${LOG_PREFIX}`, style, message);
}
// ==================== 核心函數 / Core Functions ====================
// - 模擬點擊事件 (Simulate a click event robustly)
function simulateClick(element) {
if (!element) return false;
// 使用元素所在 document 的 window 作為 view
const view = element.ownerDocument?.defaultView || window;
// 1. 嘗試原生 click()
try {
if (typeof element.click === 'function') {
element.click();
return true;
}
} catch (e) {
// Fallback below
}
// 2. 嘗試 MouseEvent 模擬
try {
const evt = new view.MouseEvent('click', { bubbles: true, cancelable: true });
return element.dispatchEvent(evt);
} catch (e) {
return false;
}
}
// - 等待元素出現 (Wait for an element to appear)
function waitForElement(doc, selector, timeout, navId) {
return new Promise(resolve => {
if (!state.isValidNavId(navId)) {
resolve(null);
return;
}
const existing = doc.querySelector(selector);
if (existing) {
log('init', () => `✓ Found element: ${selector}`);
resolve(existing);
return;
}
log('init', () => `⏳ Waiting for element: ${selector}...`);
let resolved = false;
const observer = new MutationObserver(() => {
if (resolved) return;
if (!state.isValidNavId(navId)) {
resolved = true;
observer.disconnect();
log('warn', () => `⚠ Navigation changed while waiting for: ${selector}`);
resolve(null);
return;
}
const el = doc.querySelector(selector);
if (el) {
resolved = true;
state.clearTimer(timerId);
observer.disconnect();
log('init', () => `✓ Found element: ${selector}`);
resolve(el);
}
});
const timerId = state.addTimer(() => {
if (!resolved) {
resolved = true;
observer.disconnect();
log('warn', () => `⚠ Timeout waiting for element: ${selector}`);
resolve(null);
}
}, timeout);
// - doc.body 重試機制 (Retry mechanism for doc.body)
function attemptObserve() {
if (!state.isValidNavId(navId) || resolved) return;
if (doc.body) {
state.addObserver(observer);
observer.observe(doc.body, { childList: true, subtree: true });
} else {
log('warn', '⚠ Document body not yet available, retrying observer attachment...');
state.addTimer(attemptObserve, 100); // Short retry
}
}
attemptObserve();
});
}
// - 根據關鍵字點擊按鈕 (Click a button based on keywords)
function clickByKeywords(container, normalizedKeywords, type) {
if (!container) {
log('error', `✗ Container is null for ${type} click.`);
return false;
}
const elements = container.querySelectorAll(
'button, tp-yt-paper-button, [role="button"]'
);
for (const el of elements) {
const textSources = [
el.innerText,
el.textContent,
el.getAttribute('aria-label'),
el.title,
el.dataset.tooltip,
el.getAttribute('data-tooltip-text')
];
const text = textSources.find(t => t && String(t).trim())?.toString() || '';
const normalizedText = normalize(text);
if (normalizedText && normalizedKeywords.some(k => normalizedText.includes(k))) {
log('init', () => `✓ Clicking ${type} button: "${text.trim()}"`);
if (simulateClick(el)) {
return true;
} else {
log('error', `✗ Failed to click ${type} button via simulation.`);
return false;
}
}
}
log('warn', () => `⚠ ${type} button not found.`);
return false;
}
// - 監聽並點擊選單項目 (Observe and click a menu item)
// ** v1.3.7 優化:使用 MutationObserver 替換輪詢 **
function pollMenuItems(container, normalizedKeywords, normalizedExcludeKeywords, timeout, navId) {
return new Promise(resolve => {
if (!container) {
log('error', '✗ Menu container is null.');
resolve(false);
return;
}
let resolved = false;
let observer; // 宣告在外部,以便在 checkAndClick 中斷開
let timerId; // 宣告在外部,以便在 checkAndClick 中清除
// --- 核心檢查及點擊邏輯 (Core Check and Click Logic) ---
const checkAndClick = () => {
if (resolved) return;
// 1. SPA 健壯性檢查
if (!state.isValidNavId(navId)) {
resolved = true;
if (observer) observer.disconnect();
state.clearTimer(timerId);
log('warn', () => `⚠ Observer aborted: navigation changed.`);
resolve(false);
return;
}
// 2. 搜尋所有可能的選單項目
const items = container.querySelectorAll(
'tp-yt-paper-item, yt-live-chat-menu-item-renderer, [role="menuitem"]'
);
for (const item of items) {
const textSources = [
item.innerText,
item.textContent,
item.getAttribute('aria-label'),
item.title,
item.getAttribute('data-tooltip-text')
];
const text = textSources.find(t => t && String(t).trim())?.toString() || '';
const normalizedText = normalize(text);
if (!normalizedText) continue;
// 排除檢查 (Exclude check)
if (normalizedExcludeKeywords.some(k => normalizedText.includes(k))) {
continue;
}
// 目標檢查 (Target check)
if (normalizedKeywords.some(k => normalizedText.includes(k))) {
const isSelected =
item.hasAttribute('selected') ||
item.getAttribute('aria-selected') === 'true' ||
item.getAttribute('aria-checked') === 'true' ||
item.classList.contains('iron-selected') ||
item.classList.contains('selected');
if (isSelected) {
log('success', () => `✓ Already selected: "${text.trim()}"`);
} else {
// 點擊目標 (Click target)
log('success', () => `✓ Switched to: "${text.trim()}"`);
simulateClick(item);
}
// **清理與解決 (Cleanup and Resolve)**:無論是否已選中,任務都已完成。
resolved = true;
if (observer) observer.disconnect();
state.clearTimer(timerId);
resolve(true);
return;
}
}
};
// --- 核心檢查及點擊邏輯結束 ---
// 3. 設定超時計時器 (Set timeout timer)
timerId = state.addTimer(() => {
if (!resolved) {
resolved = true;
if (observer) observer.disconnect(); // 超時時停止觀察者
log('error', '✗ Menu item not found (Observer Timeout).');
resolve(false);
}
}, timeout);
// 4. 設定 MutationObserver (Set MutationObserver)
observer = state.addObserver(new MutationObserver(checkAndClick));
// 5. 開始監聽 (Start observing)
observer.observe(container, { childList: true, subtree: true });
// 6. 立即執行一次,以防選單項目在監聽開始前已經存在
checkAndClick();
});
}
// - 執行聊天切換 (Perform the chat switch)
async function switchChat(doc, navId) {
if (!state.isValidNavId(navId)) {
log('warn', () => `⚠ switchChat aborted: navigation changed.`);
return;
}
const isIframe = (doc !== document);
if (state.isProcessed(doc, false)) {
log('init', () => isIframe ? '→ iframe already processed.' : '→ Document already processed.');
return;
}
state.markProcessed(doc, false);
log('init', () => isIframe ?
`▶ Starting chat switch in iframe.` :
`▶ Starting chat switch in main page.`);
const header = await waitForElement(
doc,
'yt-live-chat-header-renderer',
CONFIG.OBSERVER_TIMEOUT,
navId
);
if (!header) {
log('error', '✗ Chat header not found.');
return;
}
// 點擊開關按鈕 (Click switch button)
if (!clickByKeywords(header, KEYWORDS.button_norm, 'switch')) {
log('error', '✗ Could not click switch button.');
return;
}
// 等待選單容器
const menu = await waitForElement(
doc,
'[role="menu"], [role="listbox"]',
CONFIG.OBSERVER_TIMEOUT,
navId
);
if (!menu) {
log('error', '✗ Menu failed to appear.');
return;
}
// 監聽並點擊選單項目 (Observe and click menu item)
await pollMenuItems(
menu,
KEYWORDS.menu_norm,
KEYWORDS.exclude_norm,
CONFIG.OBSERVER_TIMEOUT, // 使用 OBSERVER_TIMEOUT 作為選單出現的超時時間
navId
);
}
// - 初始化 iframe (Initialize an iframe)
function initIframe(iframe, navId) {
if (!state.isValidNavId(navId)) return;
if (state.isProcessed(iframe, true)) return;
state.markProcessed(iframe, true);
log('init', () => `→ Initializing iframe.`);
let retries = 0;
function tryAccess() {
if (!state.isValidNavId(navId)) {
log('warn', () => `⚠ tryAccess aborted: navigation changed.`);
return;
}
if (retries++ >= CONFIG.MAX_RETRIES) {
log('error', () => `✗ Max retries (${CONFIG.MAX_RETRIES}) reached for iframe.`);
return;
}
let doc;
try {
doc = iframe.contentDocument || iframe.contentWindow?.document;
} catch (e) {
if (e.name === 'SecurityError') {
log('error', `✗ iframe blocked by Same-Origin Policy. Script cannot access content.`);
// SecurityError is fatal; do not retry.
return;
} else {
log('warn', () => `⚠ iframe access error (retry ${retries}/${CONFIG.MAX_RETRIES}): ${e.message}`);
state.addTimer(tryAccess, CONFIG.RETRY_INTERVAL);
}
return;
}
if (!doc || doc.readyState === 'loading') {
log('warn', () => `⚠ iframe not ready (retry ${retries}/${CONFIG.MAX_RETRIES}).`);
state.addTimer(tryAccess, CONFIG.RETRY_INTERVAL);
return;
}
switchChat(doc, navId);
}
tryAccess();
}
// ==================== 啟動腳本 / Script Initialization (修改處) ====================
// - 處理頁面加載/導航 (Handle page load/navigation)
function startProcessing() {
const navId = state.currentNavId;
// 腳本啟動日誌 (Script startup log)
log('init', '═══════════════════════════════════════');
log('init', 'YouTube Auto Switch Live Chat v1.3.8 (Optimized)'); // <-- 日誌版本更新
log('init', '═══════════════════════════════════════');
const isLiveChatPage = location.pathname.startsWith('/live_chat');
// 檢查當前是否在影片觀看頁面 (這是 Live Chat Iframe 的容器)
const isVideoPage = document.querySelector('ytd-watch-flexy, ytd-watch-grid');
// --- 【效能優化:快速退出檢查 (Quick Exit Check)】---
// 如果既不是 live_chat 頁面,也不是影片觀看頁面 (如首頁、訂閱頁),則立即退出
if (!isLiveChatPage && !isVideoPage) {
log('warn', () => 'Quick Exit: Not on a video page or live chat page. Aborting processing to save performance.');
return;
}
// --- 【快速退出檢查結束】---
// 處理內嵌聊天室頁面 (Handle embedded chat page)
if (isLiveChatPage) {
log('init', () => `→ Embedded Chat Page detected. Running switchChat on document.`);
switchChat(document, navId);
return;
}
// 處理主要觀看頁面 (Handle main video page) - 只有在 isVideoPage 為 true 時才會執行到這裡
log('init', () => `→ Main Video Page detected. Starting MutationObserver.`);
const observer = state.addObserver(new MutationObserver(mutations => {
if (!state.isValidNavId(navId)) {
observer.disconnect();
return;
}
// 監聽新加入的 iframe (Observe for newly added iframes)
mutations.forEach(mutation => {
mutation.addedNodes.forEach(node => {
if (node.nodeName === 'IFRAME') {
const src = node.src || '';
const srcdoc = node.srcdoc || '';
if (src.includes('live_chat') || srcdoc.includes('live_chat')) {
log('init', () => '→ New chat iframe detected.');
initIframe(node, navId);
}
} else if (node.querySelectorAll) {
// 檢查新增節點的子樹中是否包含 iframe
node.querySelectorAll('iframe[src*="live_chat"], iframe[srcdoc*="live_chat"]')
.forEach(iframe => {
log('init', () => '→ Chat iframe detected in added node.');
initIframe(iframe, navId);
});
}
});
});
}));
// 決定觀察的目標 (Determine observe target)
// 優先監聽聊天容器,如果找不到則監聽 body
const chatContainer = document.querySelector('#chat, #chat-container, #chatframe');
const observeTarget = chatContainer || document.body;
if (observeTarget && state.isValidNavId(navId)) {
// 監聽聊天容器或 body,以捕捉 iframe 的出現
observer.observe(observeTarget, { childList: true, subtree: true });
}
// 初始掃描 (Initial scan for existing iframes)
state.addTimer(() => {
if (!state.isValidNavId(navId)) return;
log('init', () => `→ Initial scan for existing iframes.`);
document.querySelectorAll('iframe[src*="live_chat"], iframe[srcdoc*="live_chat"]')
.forEach(iframe => initIframe(iframe, navId));
}, CONFIG.SPA_DELAY);
}
// - 腳本初始化 (Script Initialization)
function init() {
// 初始執行 (Initial execution)
startProcessing();
// 監聽 SPA 導航 (Listen for SPA navigation)
// 使用旗標避免重複綁定 (Use flag to prevent duplicate binding)
if (!window._ytAutoChatBound) {
window._ytAutoChatBound = true;
window.addEventListener('yt-navigate-finish', () => {
log('init', () => '→ SPA navigation detected.');
state.reset(); // 重置狀態
startProcessing(); // 立即重新執行
});
}
}
init();
})();