// ==UserScript==
// @name YouTube聊天室增強 YouTube Chat Enhancement
// @name:zh-tw YouTube聊天室增強
// @name:en YouTube Chat Enhancement
// @namespace http://tampermonkey.net/
// @version 19.5
// @description 多色自動化著色用戶;非原生封鎖用戶;UI操作和功能選擇自由度;移除礙眼置頂;清理/標示洗版;發言次數統計;強化@體驗等
// @description:zh-tw 多色自動化著色用戶;非原生封鎖用戶;UI操作和功能選擇自由度;移除礙眼置頂;清理/標示洗版;發言次數統計;強化@體驗等
// @description:en Multi-color automated user highlighting;Non-native user blocking;Flexible UI operations and feature selection;Removal of distracting pinned messages;Spam cleanup/flagging;Message count statistics;Improved @mention experience
// @match *://www.youtube.com/live_chat*
// @grant GM_registerMenuCommand
// @grant GM_unregisterMenuCommand
// @grant GM_addStyle
// @license MIT
// ==/UserScript==
(function(){'use strict';
const LANG = {
'zh-TW': {
buttons: {'封鎖':'封鎖','編輯':'編輯','刪除':'刪除','清除':'清除'},
tooltips: {
flag: '臨時模式: 切換至臨時用戶上色,開啟時上色不儲存',
pin: '清除置頂: 開啟/關閉自動移除置頂訊息',
highlight: mode => `高亮模式: ${mode} (雙擊切換模式)`,
block: mode => `封鎖模式: ${mode} (雙擊切換模式)`,
mention: mode => `提及高亮: ${mode} (雙擊切換模式)`,
spam: mode => `洗版過濾: ${mode} (雙擊切換模式)`,
counter: '留言計數: 顯示/隱藏用戶留言計數',
clearConfirm: '確定清除所有設定?',
clearButton: '確認'
}
},
'en': {
buttons: {'封鎖':'Block','編輯':'Edit','刪除':'Delete','清除':'Clear'},
tooltips: {
flag: 'Temporary mode: Switch to temporary user coloring, colors are not saved when enabled',
pin: 'Pin removal: Toggle auto-remove pinned messages',
highlight: mode => `Highlight mode: ${mode} (Double-click to switch)`,
block: mode => `Block mode: ${mode} (Double-click to switch)`,
mention: mode => `Mention highlight: ${mode} (Double-click to switch)`,
spam: mode => `Spam filter: ${mode} (Double-click to switch)`,
counter: 'Message counter: Show/hide user message counts',
clearConfirm: 'Confirm reset all settings?',
clearButton: 'Confirm'
}
}
};
const currentLang = navigator.language.startsWith('zh') ? 'zh-TW' : 'en';
// 顏色選項對照表 (16進位色碼)
const COLOR_OPTIONS = {
"淺藍":"#5FA6E8", "藍色":"#2463D1", "深藍":"#0000FF", "紫色":"#FF00FF",
"淺綠":"#98FB98", "綠色":"#00FF00", "深綠":"#006400", "青色":"#00FFFF",
"粉紅":"#FFC0CB", "淺紅":"#F08080", "紅色":"#FF0000", "深紅":"#8B0000",
"橙色":"#FFA500", "金色":"#FFD700", "灰色":"#BDBDBD", "深灰":"#404040"
};
// 系統常數設定 (單位:毫秒)
const MENU_AUTO_CLOSE_DELAY = 30000, // 選單自動關閉延遲
THROTTLE_DELAY = 150, // 節流控制延遲
TEMP_USER_EXPIRE_TIME = 40000, // 臨時用戶資料過期時間
MAX_MESSAGE_CACHE_SIZE = 400, // 最大訊息快取數量
CLEANUP_INTERVAL = 40000, // 系統清理間隔
SPAM_CHECK_INTERVAL = 500, // 垃圾訊息檢查間隔
FLAG_DURATION = 60000, // 標記用戶持續時間
MESSAGE_CACHE_LIMIT = 400, // 訊息快取上限
DOUBLE_CLICK_DELAY = 350, // 雙擊判定間隔
PIN_CHECK_INTERVAL = 60000; // 置頂訊息檢查間隔
const HIGHLIGHT_MODES = { BOTH:0, NAME_ONLY:1, MESSAGE_ONLY:2 },
SPAM_MODES = { MARK:0, REMOVE:1 },
BLOCK_MODES = { MARK:0, HIDE:1 };
let userColorSettings = JSON.parse(localStorage.getItem('userColorSettings')) || {},
blockedUsers = JSON.parse(localStorage.getItem('blockedUsers')) || [],
currentMenu = null,
menuTimeoutId = null,
featureSettings = JSON.parse(localStorage.getItem('featureSettings')) || {
pinEnabled: false,
highlightEnabled: false,
blockEnabled: false,
buttonsVisible: false,
mentionHighlightEnabled: false,
spamFilterEnabled: false,
counterEnabled: false,
spamMode: SPAM_MODES.MARK,
blockMode: BLOCK_MODES.MARK,
flagMode: false
},
highlightSettings = JSON.parse(localStorage.getItem('highlightSettings')) || {
defaultMode: HIGHLIGHT_MODES.BOTH,
tempMode: HIGHLIGHT_MODES.BOTH
},
tempUsers = JSON.parse(localStorage.getItem('tempUsers')) || {},
flaggedUsers = {},
lastTempUserCleanupTime = Date.now(),
userMessageCounts = {},
lastSpamCheckTime = 0,
lastClickTime = 0,
clickCount = 0,
pinRemoved = false,
lastPinCheckTime = 0;
const userColorCache = new Map(),
blockedUsersSet = new Set(blockedUsers),
tempUserCache = new Map(),
styleCache = new WeakMap();
class LRUCache {
constructor(limit) {
this.limit = limit;
this.cache = new Map();
}
has(key) { return this.cache.has(key); }
get(key) {
const value = this.cache.get(key);
if (value) {
this.cache.delete(key);
this.cache.set(key, value);
}
return value;
}
set(key, value) {
if (this.cache.has(key)) this.cache.delete(key);
else if (this.cache.size >= this.limit) {
const oldestKey = this.cache.keys().next().value;
this.cache.delete(oldestKey);
}
this.cache.set(key, value);
}
delete(key) { this.cache.delete(key); }
clear() { this.cache.clear(); }
}
const messageCache = new LRUCache(MESSAGE_CACHE_LIMIT),
processedMessages = new LRUCache(MAX_MESSAGE_CACHE_SIZE * 2);
const style = document.createElement('style');
style.textContent = `:root{--highlight-color:inherit;--flagged-color:#FF0000}
.ytcm-menu{position:fixed;background-color:white;border:1px solid black;padding:5px;z-index:9999;box-shadow:2px 2px 5px rgba(0,0,0,0.2);border-radius:5px}.ytcm-color-item{cursor:pointer;padding:0;border-radius:3px;margin:2px;border:1px solid #ddd;transition:transform 0.1s;min-width:40px;height:25px}.ytcm-color-item:hover{transform:scale(1.1);box-shadow:0 0 5px rgba(0,0,0,0.3)}.ytcm-list-item{cursor:pointer;padding:5px;background-color:#f0f0f0;border-radius:3px;margin:2px}.ytcm-button{cursor:pointer;padding:5px 8px;margin:5px 2px 0 2px;border-radius:3px;border:1px solid #ccc;background-color:#f8f8f8}.ytcm-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:5px}.ytcm-button-row{display:flex;justify-content:space-between;margin-top:5px}.ytcm-flex-wrap{display:flex;flex-wrap:wrap;gap:5px;margin-bottom:10px}.ytcm-control-panel{position:fixed;left:0;bottom:75px;z-index:9998;display:flex;flex-direction:column;gap:8px;padding:0}.ytcm-control-btn{padding:5px 0;cursor:pointer;text-align:left;min-width:20px;font-size:14px;font-weight:bold;color:white;-webkit-text-stroke:1px black;text-shadow:none;background:none;border:none;margin:0}.ytcm-control-btn.active{-webkit-text-stroke:1px black}.ytcm-control-btn.inactive{-webkit-text-stroke:1px red}.ytcm-toggle-btn{padding:5px 0;cursor:pointer;text-align:left;min-width:20px;font-size:14px;font-weight:bold;color:white;-webkit-text-stroke:1px black;text-shadow:none;background:none;border:none;margin:0}.ytcm-main-buttons{display:${featureSettings.buttonsVisible?'flex':'none'};flex-direction:column;gap:8px}.ytcm-message-count{font-size:0.6em;opacity:0.7;margin-left:3px;display:inline-block}[data-blocked="true"][data-block-mode="mark"] #message{text-decoration:line-through!important;font-style:italic!important}[data-blocked="true"][data-block-mode="hide"]{display:none!important}[data-spam="true"] #message{text-decoration:line-through!important}[data-flagged="true"]{--flagged-opacity:1}[data-highlight="name"] #author-name,[data-highlight="both"] #author-name{color:var(--highlight-color)!important;font-weight:bold!important}[data-highlight="message"] #message,[data-highlight="both"] #message{color:var(--highlight-color)!important;font-weight:bold!important}[data-flagged="true"] #author-name,[data-flagged="true"] #message{color:var(--flagged-color)!important;font-weight:bold!important;font-style:italic!important;opacity:var(--flagged-opacity,1)}`;document.head.appendChild(style);
document.head.appendChild(style);
function initializeCaches() {
Object.entries(userColorSettings).forEach(([user, color]) => userColorCache.set(user, color));
Object.entries(tempUsers).forEach(([user, data]) => tempUserCache.set(user, data));
Object.entries(flaggedUsers).forEach(([user, expireTime]) => {
if (expireTime > Date.now()) updateAllMessages(user);
});
}
function updateAllMessages(userName) {
const messages = Array.from(document.querySelectorAll('yt-live-chat-text-message-renderer'))
.filter(msg => {
const nameElement = msg.querySelector('#author-name');
return nameElement
&& nameElement.textContent.trim() === userName
&& msg.style.display !== 'none';
});
messages.forEach(msg => {
processedMessages.delete(msg);
styleCache.delete(msg);
processMessage(msg, true);
});
}
function createControlPanel() {
const panel = document.createElement('div');
panel.className = 'ytcm-control-panel';
const mainButtons = document.createElement('div');
mainButtons.className = 'ytcm-main-buttons';
const buttons = [
{
text: '臨',
className: `ytcm-control-btn ${featureSettings.flagMode ? 'active' : 'inactive'}`,
title: LANG[currentLang].tooltips.flag,
onClick: () => handleButtonClick('臨', () => {
featureSettings.flagMode = !featureSettings.flagMode;
updateButtonState('臨', featureSettings.flagMode);
localStorage.setItem('featureSettings', JSON.stringify(featureSettings));
})
},
{
text: '頂',
className: `ytcm-control-btn ${featureSettings.pinEnabled ? 'active' : 'inactive'}`,
title: LANG[currentLang].tooltips.pin,
onClick: () => handleButtonClick('頂', () => {
featureSettings.pinEnabled = !featureSettings.pinEnabled;
pinRemoved = false;
updateButtonState('頂', featureSettings.pinEnabled);
localStorage.setItem('featureSettings', JSON.stringify(featureSettings));
})
},
{
text: '亮',
className: `ytcm-control-btn ${featureSettings.highlightEnabled ? 'active' : 'inactive'}`,
title: LANG[currentLang].tooltips.highlight(getHighlightModeName(highlightSettings.defaultMode)),
onClick: () => handleButtonClick(
'亮',
() => {
featureSettings.highlightEnabled = !featureSettings.highlightEnabled;
updateButtonState('亮', featureSettings.highlightEnabled);
updateButtonTitle('亮', LANG[currentLang].tooltips.highlight(getHighlightModeName(highlightSettings.defaultMode)));
localStorage.setItem('featureSettings', JSON.stringify(featureSettings));
updateAllMessages();
},
() => {
highlightSettings.defaultMode = (highlightSettings.defaultMode + 1) % 3;
updateButtonTitle('亮', LANG[currentLang].tooltips.highlight(getHighlightModeName(highlightSettings.defaultMode)));
localStorage.setItem('highlightSettings', JSON.stringify(highlightSettings));
updateAllMessages();
}
)
},
{
text: '封',
className: `ytcm-control-btn ${featureSettings.blockEnabled ? 'active' : 'inactive'}`,
title: LANG[currentLang].tooltips.block(
featureSettings.blockMode === BLOCK_MODES.MARK
? (currentLang === 'zh-TW' ? '標記' : 'Mark')
: (currentLang === 'zh-TW' ? '清除' : 'Clear')
),
onClick: () => handleButtonClick(
'封',
() => {
featureSettings.blockEnabled = !featureSettings.blockEnabled;
updateButtonState('封', featureSettings.blockEnabled);
updateButtonTitle('封', LANG[currentLang].tooltips.block(
featureSettings.blockMode === BLOCK_MODES.MARK
? (currentLang === 'zh-TW' ? '標記' : 'Mark')
: (currentLang === 'zh-TW' ? '清除' : 'Clear')
));
localStorage.setItem('featureSettings', JSON.stringify(featureSettings));
updateAllMessages();
},
() => {
featureSettings.blockMode = (featureSettings.blockMode + 1) % 2;
updateButtonTitle('封', LANG[currentLang].tooltips.block(
featureSettings.blockMode === BLOCK_MODES.MARK
? (currentLang === 'zh-TW' ? '標記' : 'Mark')
: (currentLang === 'zh-TW' ? '清除' : 'Clear')
));
localStorage.setItem('featureSettings', JSON.stringify(featureSettings));
updateAllMessages();
}
)
},
{
text: '@',
className: `ytcm-control-btn ${featureSettings.mentionHighlightEnabled ? 'active' : 'inactive'}`,
title: LANG[currentLang].tooltips.mention(getHighlightModeName(highlightSettings.tempMode)),
onClick: () => handleButtonClick(
'@',
() => {
featureSettings.mentionHighlightEnabled = !featureSettings.mentionHighlightEnabled;
updateButtonState('@', featureSettings.mentionHighlightEnabled);
updateButtonTitle('@', LANG[currentLang].tooltips.mention(getHighlightModeName(highlightSettings.tempMode)));
localStorage.setItem('featureSettings', JSON.stringify(featureSettings));
if (!featureSettings.mentionHighlightEnabled) {
tempUsers = {};
tempUserCache.clear();
localStorage.setItem('tempUsers', JSON.stringify(tempUsers));
}
updateAllMessages();
},
() => {
highlightSettings.tempMode = (highlightSettings.tempMode + 1) % 3;
updateButtonTitle('@', LANG[currentLang].tooltips.mention(getHighlightModeName(highlightSettings.tempMode)));
localStorage.setItem('highlightSettings', JSON.stringify(highlightSettings));
updateAllMessages();
}
)
},
{
text: '洗',
className: `ytcm-control-btn ${featureSettings.spamFilterEnabled ? 'active' : 'inactive'}`,
title: LANG[currentLang].tooltips.spam(
featureSettings.spamMode === SPAM_MODES.MARK
? (currentLang === 'zh-TW' ? '標記' : 'Mark')
: (currentLang === 'zh-TW' ? '清除' : 'Clear')
),
onClick: () => handleButtonClick(
'洗',
() => {
featureSettings.spamFilterEnabled = !featureSettings.spamFilterEnabled;
updateButtonState('洗', featureSettings.spamFilterEnabled);
updateButtonTitle('洗', LANG[currentLang].tooltips.spam(
featureSettings.spamMode === SPAM_MODES.MARK
? (currentLang === 'zh-TW' ? '標記' : 'Mark')
: (currentLang === 'zh-TW' ? '清除' : 'Clear')
));
localStorage.setItem('featureSettings', JSON.stringify(featureSettings));
if (!featureSettings.spamFilterEnabled) messageCache.clear();
updateAllMessages();
},
() => {
featureSettings.spamMode = (featureSettings.spamMode + 1) % 2;
updateButtonTitle('洗', LANG[currentLang].tooltips.spam(
featureSettings.spamMode === SPAM_MODES.MARK
? (currentLang === 'zh-TW' ? '標記' : 'Mark')
: (currentLang === 'zh-TW' ? '清除' : 'Clear')
));
localStorage.setItem('featureSettings', JSON.stringify(featureSettings));
updateAllMessages();
}
)
},
{
text: '數',
className: `ytcm-control-btn ${featureSettings.counterEnabled ? 'active' : 'inactive'}`,
title: LANG[currentLang].tooltips.counter,
onClick: () => handleButtonClick('數', () => {
featureSettings.counterEnabled = !featureSettings.counterEnabled;
updateButtonState('數', featureSettings.counterEnabled);
localStorage.setItem('featureSettings', JSON.stringify(featureSettings));
if (!featureSettings.counterEnabled) {
document.querySelectorAll('.ytcm-message-count').forEach(el => el.remove());
} else {
updateAllMessages();
}
})
}
];
buttons.forEach(btn => {
const button = document.createElement('div');
button.className = btn.className;
button.textContent = btn.text;
button.title = btn.title;
button.dataset.action = btn.text;
button.addEventListener('click', btn.onClick);
mainButtons.appendChild(button);
});
const toggleBtn = document.createElement('div');
toggleBtn.className = 'ytcm-toggle-btn';
toggleBtn.textContent = '☑';
toggleBtn.title = currentLang === 'zh-TW' ? '顯示/隱藏控制按鈕' : 'Show/Hide Controls';
toggleBtn.addEventListener('click', () => {
featureSettings.buttonsVisible = !featureSettings.buttonsVisible;
mainButtons.style.display = featureSettings.buttonsVisible ? 'flex' : 'none';
localStorage.setItem('featureSettings', JSON.stringify(featureSettings));
});
panel.appendChild(mainButtons);
panel.appendChild(toggleBtn);
document.body.appendChild(panel);
return panel;
}
function handleButtonClick(btnText, toggleAction, modeAction) {
const now = Date.now();
if (now - lastClickTime < DOUBLE_CLICK_DELAY) {
clickCount++;
if (clickCount === 2 && modeAction) {
modeAction();
clickCount = 0;
}
} else {
clickCount = 1;
setTimeout(() => {
if (clickCount === 1) toggleAction();
clickCount = 0;
}, DOUBLE_CLICK_DELAY);
}
lastClickTime = now;
}
function getHighlightModeName(mode) {
switch (mode) {
case HIGHLIGHT_MODES.BOTH:
return currentLang === 'zh-TW' ? "全部高亮" : "Both";
case HIGHLIGHT_MODES.NAME_ONLY:
return currentLang === 'zh-TW' ? "僅暱稱" : "Name Only";
case HIGHLIGHT_MODES.MESSAGE_ONLY:
return currentLang === 'zh-TW' ? "僅對話" : "Message Only";
default:
return "";
}
}
function updateButtonState(btnText, isActive) {
const btn = document.querySelector(`.ytcm-control-btn[data-action="${btnText}"]`);
if (btn) btn.className = `ytcm-control-btn ${isActive ? 'active' : 'inactive'}`;
}
function updateButtonTitle(btnText, title) {
const btn = document.querySelector(`.ytcm-control-btn[data-action="${btnText}"]`);
if (btn) {
btn.title = title;
}
}
function throttle(func, limit) {
let lastFunc;
let lastRan;
return function() {
const context = this;
const args = arguments;
if (!lastRan) {
func.apply(context, args);
lastRan = Date.now();
} else {
clearTimeout(lastFunc);
lastFunc = setTimeout(function() {
if ((Date.now() - lastRan) >= limit) {
func.apply(context, args);
lastRan = Date.now();
}
}, limit - (Date.now() - lastRan));
}
};
}
function cleanupProcessedMessages() {
requestIdleCallback(() => {
const allMessages = new Set(document.querySelectorAll('yt-live-chat-text-message-renderer'));
const toDelete = [];
processedMessages.cache.forEach((_, msg) => {
if (!allMessages.has(msg)) {
toDelete.push(msg);
}
});
toDelete.forEach(msg => {
processedMessages.delete(msg);
styleCache.delete(msg);
});
});
}
function processMentionedUsers(messageText, authorName, authorColor) {
if (!featureSettings.mentionHighlightEnabled || !authorColor) return;
const mentionRegex = /@([^\s].*?(?=\s|$|@|[\u200b]))/g;
let match;
const mentionedUsers = new Set();
while ((match = mentionRegex.exec(messageText)) !== null) {
const mentionedUser = match[1].trim();
if (mentionedUser) {
mentionedUsers.add(mentionedUser);
}
}
if (mentionedUsers.size !== 1) return;
const mentionedUser = Array.from(mentionedUsers)[0];
const allUsers = Array.from(document.querySelectorAll('#author-name'));
const existingUsers = allUsers.map(el => el.textContent.trim());
const isExistingUser = existingUsers.some(user => user.toLowerCase() === mentionedUser.toLowerCase());
if (isExistingUser && !userColorCache.has(mentionedUser) && !tempUserCache.has(mentionedUser)) {
tempUsers[mentionedUser] = {
color: authorColor,
expireTime: Date.now() + TEMP_USER_EXPIRE_TIME
};
tempUserCache.set(mentionedUser, {
color: authorColor,
expireTime: Date.now() + TEMP_USER_EXPIRE_TIME
});
updateAllMessages(mentionedUser);
localStorage.setItem('tempUsers', JSON.stringify(tempUsers));
}
}
function cleanupExpiredTempUsers() {
const now = Date.now();
if (now - lastTempUserCleanupTime < CLEANUP_INTERVAL) return;
lastTempUserCleanupTime = now;
let changed = false;
for (const [user, data] of tempUserCache.entries()) {
if (data.expireTime <= now) {
tempUserCache.delete(user);
if (tempUsers.hasOwnProperty(user)) {
delete tempUsers[user];
}
changed = true;
updateAllMessages(user);
}
}
if (changed) {
localStorage.setItem('tempUsers', JSON.stringify(tempUsers));
}
}
function cleanupExpiredFlags() {
const now = Date.now();
let changed = false;
for (const user in flaggedUsers) {
if (flaggedUsers[user] <= now) {
delete flaggedUsers[user];
changed = true;
updateAllMessages(user);
}
}
}
function removePinnedMessage() {
if (!featureSettings.pinEnabled) return;
const now = Date.now();
if (now - lastPinCheckTime < PIN_CHECK_INTERVAL) return;
lastPinCheckTime = now;
requestAnimationFrame(() => {
const pinnedMessage = document.querySelector('yt-live-chat-banner-renderer');
if (pinnedMessage) {
pinnedMessage.style.display = 'none';
}
});
}
function closeMenu() {
if (currentMenu) {
document.body.removeChild(currentMenu);
currentMenu = null;
clearTimeout(menuTimeoutId);
}
}
function createColorMenu(targetElement, event) {
closeMenu();
const menu = document.createElement('div');
menu.className = 'ytcm-menu';
menu.style.top = `${event.clientY}px`;
menu.style.left = `${event.clientX}px`;
menu.style.width = '220px';
const colorGrid = document.createElement('div');
colorGrid.className = 'ytcm-grid';
Object.entries(COLOR_OPTIONS).forEach(([colorName, colorValue]) => {
const colorItem = document.createElement('div');
colorItem.className = 'ytcm-color-item';
colorItem.title = colorName;
colorItem.style.backgroundColor = colorValue;
colorItem.addEventListener('click', () => {
if (targetElement.type === 'user') {
userColorSettings[targetElement.name] = colorValue;
userColorCache.set(targetElement.name, colorValue);
updateAllMessages(targetElement.name);
localStorage.setItem('userColorSettings', JSON.stringify(userColorSettings));
}
else if (targetElement.type === 'temp') {
tempUsers[targetElement.name] = {
color: colorValue,
expireTime: Date.now() + TEMP_USER_EXPIRE_TIME
};
tempUserCache.set(targetElement.name, {
color: colorValue,
expireTime: Date.now() + TEMP_USER_EXPIRE_TIME
});
updateAllMessages(targetElement.name);
}
closeMenu();
});
colorGrid.appendChild(colorItem);
});
const buttonRow = document.createElement('div');
buttonRow.className = 'ytcm-button-row';
const buttons = [
{
text: LANG[currentLang].buttons.封鎖,
className: 'ytcm-button',
onClick: () => {
if (targetElement.type === 'user') {
blockedUsers.push(targetElement.name);
blockedUsersSet.add(targetElement.name);
localStorage.setItem('blockedUsers', JSON.stringify(blockedUsers));
updateAllMessages(targetElement.name);
}
closeMenu();
}
},
{
text: LANG[currentLang].buttons.編輯,
className: 'ytcm-button',
onClick: (e) => {
e.stopPropagation();
createEditMenu(targetElement, event);
}
},
{
text: LANG[currentLang].buttons.刪除,
className: 'ytcm-button',
onClick: () => {
const userName = targetElement.name;
let foundInList = false;
if (userColorSettings[userName]) {
delete userColorSettings[userName];
userColorCache.delete(userName);
foundInList = true;
}
if (blockedUsersSet.has(userName)) {
blockedUsers = blockedUsers.filter(u => u !== userName);
blockedUsersSet.delete(userName);
localStorage.setItem('blockedUsers', JSON.stringify(blockedUsers));
foundInList = true;
}
if (tempUsers[userName]) {
delete tempUsers[userName];
tempUserCache.delete(userName);
localStorage.setItem('tempUsers', JSON.stringify(tempUsers));
foundInList = true;
}
if (flaggedUsers[userName]) {
delete flaggedUsers[userName];
localStorage.setItem('flaggedUsers', JSON.stringify(flaggedUsers));
foundInList = true;
}
localStorage.setItem('userColorSettings', JSON.stringify(userColorSettings));
const messages = Array.from(document.querySelectorAll('yt-live-chat-text-message-renderer')).filter(msg => {
const nameElement = msg.querySelector('#author-name');
return nameElement && nameElement.textContent.trim() === userName;
});
messages.forEach(msg => {
if (foundInList) {
msg.removeAttribute('data-highlight');
msg.removeAttribute('data-flagged');
msg.removeAttribute('data-blocked');
msg.removeAttribute('data-spam');
msg.style.removeProperty('--highlight-color');
msg.style.removeProperty('--flagged-color');
msg.querySelector('.ytcm-message-count')?.remove();
} else {
msg.style.display = 'none';
}
});
closeMenu();
}
},
{
text: LANG[currentLang].buttons.清除,
className: 'ytcm-button',
onClick: () => {
const confirmMenu = document.createElement('div');
confirmMenu.className = 'ytcm-menu';
confirmMenu.style.top = `${event.clientY}px`;
confirmMenu.style.left = `${event.clientX}px`;
const confirmText = document.createElement('div');
confirmText.textContent = LANG[currentLang].tooltips.clearConfirm;
const confirmButton = document.createElement('button');
confirmButton.className = 'ytcm-button';
confirmButton.textContent = LANG[currentLang].tooltips.clearButton;
confirmButton.addEventListener('click', () => {
localStorage.removeItem('userColorSettings');
localStorage.removeItem('blockedUsers');
localStorage.removeItem('featureSettings');
localStorage.removeItem('highlightSettings');
localStorage.removeItem('tempUsers');
localStorage.removeItem('flaggedUsers');
window.location.reload();
});
confirmMenu.appendChild(confirmText);
confirmMenu.appendChild(confirmButton);
document.body.appendChild(confirmMenu);
setTimeout(() => {
if (document.body.contains(confirmMenu)) document.body.removeChild(confirmMenu);
}, 5000);
}
}
];
buttons.forEach(btn => {
const button = document.createElement('button');
button.className = btn.className;
button.textContent = btn.text;
button.addEventListener('click', btn.onClick);
buttonRow.appendChild(button);
});
menu.appendChild(colorGrid);
menu.appendChild(buttonRow);
document.body.appendChild(menu);
currentMenu = menu;
menuTimeoutId = setTimeout(closeMenu, MENU_AUTO_CLOSE_DELAY);
}
function createEditMenu(targetElement, event) {
closeMenu();
const menu = document.createElement('div');
menu.className = 'ytcm-menu';
menu.style.top = '10px';
menu.style.left = '10px';
menu.style.width = '90%';
menu.style.maxHeight = '80vh';
menu.style.overflowY = 'auto';
const closeButton = document.createElement('button');
closeButton.className = 'ytcm-button';
closeButton.textContent = currentLang === 'zh-TW' ? '關閉' : 'Close';
closeButton.style.width = '100%';
closeButton.style.marginBottom = '10px';
closeButton.addEventListener('click', closeMenu);
menu.appendChild(closeButton);
const importExportRow = document.createElement('div');
importExportRow.className = 'ytcm-button-row';
const exportButton = document.createElement('button');
exportButton.className = 'ytcm-button';
exportButton.textContent = currentLang === 'zh-TW' ? '匯出設定' : 'Export';
exportButton.addEventListener('click', () => {
const data = {
userColorSettings,
blockedUsers,
featureSettings,
highlightSettings,
tempUsers: JSON.parse(localStorage.getItem('tempUsers')) || {}
};
const blob = new Blob([JSON.stringify(data)], {type: 'application/json'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'yt_chat_settings.json';
a.click();
URL.revokeObjectURL(url);
});
const importButton = document.createElement('input');
importButton.type = 'file';
importButton.className = 'ytcm-button';
importButton.accept = '.json';
importButton.style.width = '100px';
importButton.addEventListener('change', (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
try {
const data = JSON.parse(event.target.result);
localStorage.setItem('userColorSettings', JSON.stringify(data.userColorSettings));
localStorage.setItem('blockedUsers', JSON.stringify(data.blockedUsers));
localStorage.setItem('featureSettings', JSON.stringify(data.featureSettings));
localStorage.setItem('highlightSettings', JSON.stringify(data.highlightSettings));
localStorage.setItem('tempUsers', JSON.stringify(data.tempUsers));
window.location.reload();
} catch (err) {
alert(currentLang === 'zh-TW' ? '檔案格式錯誤' : 'Invalid file format');
}
};
reader.readAsText(file);
});
importExportRow.appendChild(exportButton);
importExportRow.appendChild(importButton);
menu.appendChild(importExportRow);
const blockedUserList = document.createElement('div');
blockedUserList.textContent = currentLang === 'zh-TW' ? '封鎖用戶名單:' : 'Blocked Users:';
blockedUserList.className = 'ytcm-flex-wrap';
blockedUsers.forEach(user => {
const userItem = document.createElement('div');
userItem.className = 'ytcm-list-item';
userItem.textContent = user;
userItem.addEventListener('click', () => {
blockedUsers = blockedUsers.filter(u => u !== user);
blockedUsersSet.delete(user);
localStorage.setItem('blockedUsers', JSON.stringify(blockedUsers));
userItem.remove();
updateAllMessages(user);
});
blockedUserList.appendChild(userItem);
});
menu.appendChild(blockedUserList);
const coloredUserList = document.createElement('div');
coloredUserList.textContent = currentLang === 'zh-TW' ? '被上色用戶名單:' : 'Colored Users:';
coloredUserList.className = 'ytcm-flex-wrap';
Object.keys(userColorSettings).forEach(user => {
const userItem = document.createElement('div');
userItem.className = 'ytcm-list-item';
userItem.textContent = user;
userItem.addEventListener('click', () => {
delete userColorSettings[user];
userColorCache.delete(user);
localStorage.setItem('userColorSettings', JSON.stringify(userColorSettings));
userItem.remove();
updateAllMessages(user);
});
coloredUserList.appendChild(userItem);
});
menu.appendChild(coloredUserList);
document.body.appendChild(menu);
currentMenu = menu;
menuTimeoutId = setTimeout(closeMenu, MENU_AUTO_CLOSE_DELAY);
}
function checkForSpam(msg) {
if (!featureSettings.spamFilterEnabled) return;
const nameElement = msg.querySelector('#author-name');
if (!nameElement) return;
const userName = nameElement.textContent.trim();
if (userColorCache.has(userName) || tempUserCache.has(userName) || flaggedUsers[userName]) return;
const messageElement = msg.querySelector('#message');
if (!messageElement) return;
const textNodes = Array.from(messageElement.childNodes)
.filter(node => node.nodeType === Node.TEXT_NODE && !node.parentElement.classList.contains('emoji'));
const messageText = textNodes
.map(node => node.textContent.trim())
.join(' ');
if (messageCache.has(messageText)) {
if (featureSettings.spamMode === SPAM_MODES.MARK) {
msg.setAttribute('data-spam', 'true');
messageElement.style.textDecoration = 'line-through';
} else {
msg.style.display = 'none';
}
return;
}
messageCache.set(messageText, true);
}
function updateMessageCounter(msg) {
if (!featureSettings.counterEnabled) return;
const nameElement = msg.querySelector('#author-name');
if (!nameElement) return;
const userName = nameElement.textContent.trim();
if (!userMessageCounts[userName]) userMessageCounts[userName] = 0;
userMessageCounts[userName]++;
const existingCounter = msg.querySelector('.ytcm-message-count');
if (existingCounter) existingCounter.remove();
const counterSpan = document.createElement('span');
counterSpan.className = 'ytcm-message-count';
counterSpan.textContent = userMessageCounts[userName];
const messageElement = msg.querySelector('#message');
if (messageElement) messageElement.appendChild(counterSpan);
}
function processMessage(msg, isInitialLoad = false) {
if (styleCache.has(msg)) return;
const authorName = msg.querySelector('#author-name');
const messageElement = msg.querySelector('#message');
if (!authorName || !messageElement) return;
const userName = authorName.textContent.trim();
if (featureSettings.spamFilterEnabled) checkForSpam(msg);
msg.removeAttribute('data-spam');
if (featureSettings.blockEnabled && blockedUsersSet.has(userName)) {
msg.setAttribute('data-blocked', 'true');
msg.setAttribute('data-block-mode', featureSettings.blockMode === BLOCK_MODES.MARK ? 'mark' : 'hide');
if (featureSettings.blockMode === BLOCK_MODES.HIDE) {
msg.style.display = 'none';
}
styleCache.set(msg, true);
return;
}
if (msg.hasAttribute('data-blocked')) {
styleCache.set(msg, true);
return;
}
msg.removeAttribute('data-highlight');
msg.removeAttribute('data-flagged');
if (featureSettings.flagMode && flaggedUsers[userName]) {
msg.setAttribute('data-flagged', 'true');
msg.style.setProperty('--highlight-color', userColorCache.get(userName) || COLOR_OPTIONS.紅色);
}
if (featureSettings.highlightEnabled && (tempUserCache.has(userName) || userColorCache.get(userName))) {
const color = tempUserCache.has(userName)
? tempUserCache.get(userName).color
: userColorCache.get(userName);
const mode = tempUserCache.has(userName)
? highlightSettings.tempMode
: highlightSettings.defaultMode;
msg.style.setProperty('--highlight-color', color);
if (mode !== HIGHLIGHT_MODES.BOTH &&
mode !== HIGHLIGHT_MODES.NAME_ONLY &&
mode !== HIGHLIGHT_MODES.MESSAGE_ONLY) return;
msg.setAttribute('data-highlight',
mode === HIGHLIGHT_MODES.BOTH ? 'both'
: mode === HIGHLIGHT_MODES.NAME_ONLY ? 'name'
: 'message');
}
updateMessageCounter(msg);
if (featureSettings.mentionHighlightEnabled) {
const textNodes = Array.from(messageElement.childNodes)
.filter(node => node.nodeType === Node.TEXT_NODE && !node.parentElement.classList.contains('emoji'));
const messageText = textNodes
.map(node => node.textContent.trim())
.join(' ');
processMentionedUsers(
messageText,
userName,
tempUserCache.has(userName) ? tempUserCache.get(userName).color : userColorCache.get(userName)
);
}
styleCache.set(msg, true);
}
function highlightMessages(mutations) {
cleanupProcessedMessages();
const messages = [];
mutations.forEach(mutation => {
mutation.addedNodes.forEach(node => {
if (node.nodeType === 1 &&
node.matches('yt-live-chat-text-message-renderer') &&
!processedMessages.has(node)) {
messages.push(node);
processedMessages.set(node, true);
}
});
});
if (messages.length === 0) {
const allMessages = Array.from(document.querySelectorAll('yt-live-chat-text-message-renderer'))
.slice(-MAX_MESSAGE_CACHE_SIZE);
allMessages.forEach(msg => {
if (!processedMessages.has(msg)) {
messages.push(msg);
processedMessages.set(msg, true);
}
});
}
requestAnimationFrame(() => {
messages.forEach(msg => processMessage(msg));
cleanupExpiredFlags();
cleanupExpiredTempUsers();
removePinnedMessage();
});
}
function handleClick(event) {
if (event.button !== 0) return;
const msgElement = event.target.closest('yt-live-chat-text-message-renderer');
if (!msgElement) return;
const messageElement = msgElement.querySelector('#message');
if (messageElement) {
const rect = messageElement.getBoundingClientRect();
const isRightEdge = event.clientX > rect.right - 30;
if (isRightEdge) return;
}
event.stopPropagation();
event.preventDefault();
if (currentMenu && !currentMenu.contains(event.target)) closeMenu();
const authorName = msgElement.querySelector('#author-name');
const authorImg = msgElement.querySelector('#author-photo img');
if (authorImg && authorImg.contains(event.target)) {
if (event.ctrlKey) {
const URL = authorName?.parentNode?.parentNode?.parentNode?.data?.authorExternalChannelId ||
authorName?.parentNode?.parentNode?.parentNode?.parentNode?.parentNode?.parentNode?.parentNode?.parentNode?.data?.authorExternalChannelId;
URL && window.open("https://www.youtube.com/channel/" + URL + "/about", "_blank");
} else {
if (featureSettings.flagMode) {
createColorMenu({ type: 'temp', name: authorName.textContent.trim() }, event);
} else {
createColorMenu({ type: 'user', name: authorName.textContent.trim() }, event);
}
}
}
if (authorName && authorName.contains(event.target)) {
const inputField = document.querySelector('yt-live-chat-text-input-field-renderer [contenteditable]');
if (inputField) {
setTimeout(() => {
const userName = authorName.textContent.trim();
const mentionText = `@${userName}\u2009`;
const range = document.createRange();
const selection = window.getSelection();
range.selectNodeContents(inputField);
range.collapse(false);
selection.removeAllRanges();
selection.addRange(range);
inputField.focus();
document.execCommand('insertText', false, mentionText);
range.setStartAfter(inputField.lastChild);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
}, 200);
}
}
}
function overrideNativeListeners() {
document.querySelectorAll('yt-live-chat-text-message-renderer').forEach(msg => {
msg.addEventListener('click', handleClick, { capture: true });
});
}
function init() {
initializeCaches();
overrideNativeListeners();
const observer = new MutationObserver(mutations => {
highlightMessages(mutations);
document.querySelectorAll('yt-live-chat-text-message-renderer:not([data-ytcm-handled])').forEach(msg => {
msg.setAttribute('data-ytcm-handled', 'true');
msg.addEventListener('click', handleClick, { capture: true });
if (!processedMessages.has(msg)) {
processedMessages.set(msg, true);
processMessage(msg, true);
}
});
});
const chatContainer = document.querySelector('#chat');
if (chatContainer) {
observer.observe(chatContainer, { childList: true, subtree: true });
const existingMessages = Array.from(chatContainer.querySelectorAll('yt-live-chat-text-message-renderer'));
existingMessages.forEach(msg => {
msg.setAttribute('data-ytcm-handled', 'true');
msg.addEventListener('click', handleClick, { capture: true });
if (!processedMessages.has(msg)) {
processedMessages.set(msg, true);
processMessage(msg, true);
}
});
}
const controlPanel = createControlPanel();
return () => {
observer.disconnect();
if (controlPanel) controlPanel.remove();
closeMenu();
};
}
let cleanup = init();
const checkChatContainer = setInterval(() => {
if (document.querySelector('#chat') && !cleanup) {
cleanup = init();
}
}, 1000);
window.addEventListener('beforeunload', () => {
clearInterval(checkChatContainer);
cleanup?.();
});
})();