// ==UserScript==
// @name Twitch 弹幕
// @namespace https://github.com/saka1zum1/twitch-danmaku
// @version 1.9.0
// @description 将 Twitch 直播评论转换为视频弹幕,支持实时翻译和复制
// @tag 直播, 弹幕, twitch, 翻译
// @author KaKa
// @license MIT
// @match https://www.twitch.tv/*
// @match https://m.twitch.tv/*
// @grant GM_addStyle
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_setClipboard
// @grant GM_xmlhttpRequest
// @connect translate.googleapis.com
// @connect edge.microsoft.com
// @connect api-edge.cognitive.microsofttranslator.com
// @connect transmart.qq.com
// @connect translate.volcengine.com
// @connect api.interpreter.caiyunai.com
// @connect gql.twitch.tv
// @connect irc-ws.chat.twitch.tv
// @run-at document-idle
// @icon https://www.google.com/s2/favicons?sz=64&domain=twitch.com
// ==/UserScript==
(function () {
'use strict';
const LOCALES = {
zh: {
danmakuToggle: '弹幕开关',
danmakuOn: '关闭弹幕',
danmakuOff: '开启弹幕',
settingsToggle: '弹幕设置',
panelTitle: '弹幕设置',
panelClose: '关闭',
scrollSpeed: '滚动速度',
fontSize: '字体大小',
danmakuOpacity: '弹幕透明度',
fontFamily: '字体',
scrollArea: '滚动区域',
translateEnable: '启用翻译',
translateInChat: '聊天室显示译文',
translateInChatHint: '在聊天消息下方显示译文,使用 Twitch 紫色样式与原文区分。关闭后只影响聊天室,不影响弹幕译文。',
translateBadge: '译',
translateProvider: '翻译引擎',
translateProviderAuto: '自动切换',
translateProviderMicrosoft: 'Microsoft 翻译',
translateProviderGoogle: 'Google 翻译',
translateProviderVolcengine: '火山翻译',
translateProviderCaiyun: '彩云小译',
translateProviderTencent: '腾讯交互翻译',
translateTarget: '目标语言',
translateSpeed: '翻译速度',
translateSpeedUnit: '条/秒',
blockKeywords: '关键词屏蔽',
blockKeywordsHint: '原文或译文包含关键词时不显示',
blockKeywordsPlaceholder: '输入关键词',
blockKeywordsAdd: '添加',
blockKeywordsRemove: '移除',
blockKeywordsEmpty: '暂无关键词',
speechToggle: '弹幕朗读',
speechPanelTitle: '弹幕朗读',
speechEnable: '启用朗读',
speechOn: '朗读已开启',
speechMute: '朗读已静音',
speechOff: '朗读已关闭',
speechTest: '试读',
speechTestText: '这是一条测试弹幕 ABC',
speechPlay: '按设定速度试读',
speechPlayFast: '按变速模式试读',
speechVolume: '音量',
speechPitch: '音高',
speechVoice: '声音',
speechVoiceLang: '语言',
speechVoiceDefault: '默认',
speechRate: '朗读速度',
speechPace: '变速模式',
speechMaxSeconds: '单条最长',
speechSeconds: '秒',
speechPaceHint: '超出最长朗读时间时自动加快。开启翻译后按译文朗读。重复内容最多读 3 遍,纯符号、纯表情和纯数字不朗读。',
speechBuffer: '保留条数',
speechBufferHint: '超过这个数量时丢掉更早的弹幕,避免朗读一直追不上',
speechUnsupported: '当前浏览器不支持语音朗读',
historyToggle: '下载聊天记录',
historyPanelTitle: '下载聊天记录',
historyKeyword: '内容关键词',
historyKeywordPlaceholder: '关键词,逗号分隔',
historyKeywordHint: '只导出包含这些词的消息。留空表示不过滤。',
historyUsername: '用户名',
historyUsernamePlaceholder: '用户名,逗号分隔',
historyUsernameHint: '匹配登录名或显示名。留空表示不过滤。',
historyIncludeSuperchat: '包含 Super Chat',
historyIncludeSuperchatHint: 'Super Chat 指 Bits Cheers。关闭后不导出这些消息。',
historyRange: '时间范围',
historyRangeUnknown: '未能读取时长,将下载整段聊天。',
historyLiveHint: '直播页会导出当前聊天面板或 IRC 缓冲中的消息,不是完整历史。检测到 FrankerFaceZ 时自动改用 IRC。',
historyLiveReady: '已就绪:可导出当前聊天面板或 IRC 缓冲中的消息。',
historyFormat: '导出格式',
historyFormatJson: 'JSON',
historyFormatCsv: 'CSV',
historyFormatTxt: 'TXT',
historyDownload: '下载',
historyPause: '暂停',
historyResume: '继续',
historyCancel: '取消',
historyLoading: '正在读取回放…',
historyNeedSource: '请打开直播页,或打开回放页(网址中包含 /videos/)。',
historyMissing: '找不到这个回放,或聊天记录已不可用。',
historyFailed: '下载失败,请稍后再试。',
historyEmpty: '没有可导出的聊天记录。',
historyNoMatch: '没有符合筛选条件的聊天记录。',
historyProgress: '已获取 {count} 条',
historyPaused: '已暂停,已获取 {count} 条',
historyDone: '已导出 {count} 条',
historyCancelled: '已取消',
chatMenuTranslate: '翻译',
chatMenuRetranslate: '重新翻译',
chatMenuSpeak: '朗读',
chatMenuResend: '重新发送弹幕',
superchatPinned: 'Super Chat 悬停',
superchatPosition: '悬停位置',
superchatDuration: '悬停时间',
superchatAnimation: '悬停动画',
superchatTop: '顶部',
superchatBottom: '底部',
superchatFade: '淡入淡出',
superchatSlide: '滑入',
superchatPop: '弹出',
superchatShine: '闪光',
scrollAreaFull: '全屏',
scrollAreaTopHalf: '上半屏',
scrollAreaTopQuarter: '顶部 1/4',
fontSystem: '系统默认',
fontYaHei: '微软雅黑',
fontSimSun: '宋体',
fontSimHei: '黑体',
fontArial: 'Arial',
fontRoboto: 'Roboto',
fontImpact: 'Impact',
fontGeorgia: 'Georgia',
fontTimes: 'Times New Roman',
fontVerdana: 'Verdana',
fontTahoma: 'Tahoma',
fontTrebuchet: 'Trebuchet MS',
fontCourier: 'Courier New',
fontConsolas: 'Consolas',
fontComicSans: 'Comic Sans MS',
fontSerif: 'Serif 衬线',
fontSansSerif: 'Sans-serif 无衬线',
fontMonospace: 'Monospace 等宽',
langZhCN: '简体中文',
langZhTW: '繁体中文',
langEn: 'English',
langJa: '日本語',
langKo: '한국어',
langEs: 'Español',
langFr: 'Français',
langDe: 'Deutsch',
langRu: 'Русский',
langPt: 'Português',
tooltipCopy: '复制到聊天框',
tooltipOpenProfile: '打开发送者主页',
},
en: {
danmakuToggle: 'Danmaku toggle',
danmakuOn: 'Turn off danmaku',
danmakuOff: 'Turn on danmaku',
settingsToggle: 'Danmaku settings',
panelTitle: 'Danmaku Settings',
panelClose: 'Close',
scrollSpeed: 'Scroll speed',
fontSize: 'Font size',
danmakuOpacity: 'Danmaku opacity',
fontFamily: 'Font',
scrollArea: 'Scroll area',
translateEnable: 'Enable translation',
translateInChat: 'Show translation in chat',
translateInChatHint: 'Show the translation under each chat line in Twitch purple so it stands out from the original. Turning this off only hides chat translations; danmaku translations are unchanged.',
translateBadge: 'TL',
translateProvider: 'Translation engine',
translateProviderAuto: 'Auto',
translateProviderMicrosoft: 'Microsoft Translator',
translateProviderGoogle: 'Google Translate',
translateProviderVolcengine: 'Volcengine',
translateProviderCaiyun: 'Caiyun',
translateProviderTencent: 'Tencent Transmart',
translateTarget: 'Target language',
translateSpeed: 'Translation speed',
translateSpeedUnit: '/sec',
blockKeywords: 'Block keywords',
blockKeywordsHint: 'Hidden when the original or translation contains a keyword',
blockKeywordsPlaceholder: 'Keyword',
blockKeywordsAdd: 'Add',
blockKeywordsRemove: 'Remove',
blockKeywordsEmpty: 'No keywords',
speechToggle: 'Read danmaku',
speechPanelTitle: 'Read danmaku',
speechEnable: 'Read danmaku aloud',
speechOn: 'Reading is on',
speechMute: 'Reading is muted',
speechOff: 'Reading is off',
speechTest: 'Trial',
speechTestText: 'This is a test ABC',
speechPlay: 'Play at set speed',
speechPlayFast: 'Play with pace mode',
speechVolume: 'Volume',
speechPitch: 'Pitch',
speechVoice: 'Voice',
speechVoiceLang: 'Language',
speechVoiceDefault: 'Default',
speechRate: 'Reading speed',
speechPace: 'Pace mode',
speechMaxSeconds: 'Max per line',
speechSeconds: 'sec',
speechPaceHint: 'Speed up when a line would exceed the max time. With translation on, the translated text is read. Repeated phrases are read at most 3 times. Symbols, emotes, and numbers alone are skipped.',
speechBuffer: 'Keep latest',
speechBufferHint: 'Older lines beyond this count are dropped so reading can catch up',
speechUnsupported: 'Speech synthesis is not available in this browser',
historyToggle: 'Download chat history',
historyPanelTitle: 'Download chat history',
historyKeyword: 'Keyword',
historyKeywordPlaceholder: 'Keywords, comma separated',
historyKeywordHint: 'Export only messages that contain one of these words. Leave empty to keep all.',
historyUsername: 'Username',
historyUsernamePlaceholder: 'Usernames, comma separated',
historyUsernameHint: 'Matches login or display name. Leave empty to keep all.',
historyIncludeSuperchat: 'Include Super Chats',
historyIncludeSuperchatHint: 'Super Chat means Bits cheers. Turn this off to leave them out.',
historyRange: 'Time range',
historyRangeUnknown: 'Duration is unavailable, so the full chat will be downloaded.',
historyLiveHint: 'On a live page, export collects messages from the chat panel or the IRC buffer — not the full stream history. FrankerFaceZ triggers the IRC path automatically.',
historyLiveReady: 'Ready to export messages from the chat panel or IRC buffer.',
historyFormat: 'Format',
historyFormatJson: 'JSON',
historyFormatCsv: 'CSV',
historyFormatTxt: 'TXT',
historyDownload: 'Download',
historyPause: 'Pause',
historyResume: 'Resume',
historyCancel: 'Cancel',
historyLoading: 'Loading VOD…',
historyNeedSource: 'Open a live channel page, or a VOD page (the URL contains /videos/).',
historyMissing: 'This VOD was not found, or its chat is no longer available.',
historyFailed: 'Download failed. Try again later.',
historyEmpty: 'No chat available to export.',
historyNoMatch: 'No messages match these filters.',
historyProgress: 'Collected {count}',
historyPaused: 'Paused, collected {count}',
historyDone: 'Exported {count}',
historyCancelled: 'Cancelled',
chatMenuTranslate: 'Translate',
chatMenuRetranslate: 'Re-translate',
chatMenuSpeak: 'Read aloud',
chatMenuResend: 'Send as danmaku',
superchatPinned: 'Pin Super Chats',
superchatPosition: 'Pin position',
superchatDuration: 'Pin time',
superchatAnimation: 'Pin animation',
superchatTop: 'Top',
superchatBottom: 'Bottom',
superchatFade: 'Fade',
superchatSlide: 'Slide',
superchatPop: 'Pop',
superchatShine: 'Shine',
scrollAreaFull: 'Full screen',
scrollAreaTopHalf: 'Top half',
scrollAreaTopQuarter: 'Top quarter',
fontSystem: 'System default',
fontYaHei: 'Microsoft YaHei',
fontSimSun: 'SimSun',
fontSimHei: 'SimHei',
fontArial: 'Arial',
fontRoboto: 'Roboto',
fontImpact: 'Impact',
fontGeorgia: 'Georgia',
fontTimes: 'Times New Roman',
fontVerdana: 'Verdana',
fontTahoma: 'Tahoma',
fontTrebuchet: 'Trebuchet MS',
fontCourier: 'Courier New',
fontConsolas: 'Consolas',
fontComicSans: 'Comic Sans MS',
fontSerif: 'Serif',
fontSansSerif: 'Sans-serif',
fontMonospace: 'Monospace',
langZhCN: 'Chinese (Simplified)',
langZhTW: 'Chinese (Traditional)',
langEn: 'English',
langJa: 'Japanese',
langKo: 'Korean',
langEs: 'Spanish',
langFr: 'French',
langDe: 'German',
langRu: 'Russian',
langPt: 'Portuguese',
tooltipCopy: 'Copy to chat',
tooltipOpenProfile: 'Open sender profile',
},
};
function detectLocale() {
const langs = navigator.languages?.length ? navigator.languages : [navigator.language];
for (const lang of langs) {
if (lang && /^zh(-|$)/i.test(lang)) return 'zh';
}
return 'en';
}
const LOCALE = detectLocale();
const STRINGS = LOCALES[LOCALE];
function t(key) {
return STRINGS[key] || LOCALES.en[key] || key;
}
function fill(key, vars) {
let text = t(key);
for (const [name, value] of Object.entries(vars || {})) {
text = text.replaceAll(`{${name}}`, String(value));
}
return text;
}
function getFontOptions() {
return [
{ label: t('fontSystem'), value: 'system-ui, sans-serif' },
{ label: t('fontYaHei'), value: '"Microsoft YaHei", sans-serif' },
{ label: t('fontSimSun'), value: 'SimSun, serif' },
{ label: t('fontSimHei'), value: 'SimHei, sans-serif' },
{ label: t('fontArial'), value: 'Arial, Helvetica, sans-serif' },
{ label: t('fontRoboto'), value: 'Roboto, sans-serif' },
{ label: t('fontVerdana'), value: 'Verdana, Geneva, sans-serif' },
{ label: t('fontTahoma'), value: 'Tahoma, Geneva, sans-serif' },
{ label: t('fontTrebuchet'), value: '"Trebuchet MS", sans-serif' },
{ label: t('fontGeorgia'), value: 'Georgia, serif' },
{ label: t('fontTimes'), value: '"Times New Roman", Times, serif' },
{ label: t('fontCourier'), value: '"Courier New", Courier, monospace' },
{ label: t('fontConsolas'), value: 'Consolas, monospace' },
{ label: t('fontImpact'), value: 'Impact, Haettenschweiler, sans-serif' },
{ label: t('fontComicSans'), value: '"Comic Sans MS", cursive, sans-serif' },
{ label: t('fontSerif'), value: 'serif' },
{ label: t('fontSansSerif'), value: 'sans-serif' },
{ label: t('fontMonospace'), value: 'monospace' },
];
}
function getScrollAreaOptions() {
return [
{ label: t('scrollAreaFull'), value: 'full' },
{ label: t('scrollAreaTopHalf'), value: 'top-half' },
{ label: t('scrollAreaTopQuarter'), value: 'top-quarter' },
];
}
function getSuperchatPositionOptions() {
return [
{ label: t('superchatTop'), value: 'top' },
{ label: t('superchatBottom'), value: 'bottom' },
];
}
function getSuperchatAnimationOptions() {
return [
{ label: t('superchatFade'), value: 'fade' },
{ label: t('superchatSlide'), value: 'slide' },
{ label: t('superchatPop'), value: 'pop' },
{ label: t('superchatShine'), value: 'shine' },
];
}
function getTranslateProviderOptions() {
return [
{ label: t('translateProviderAuto'), value: 'auto' },
{ label: t('translateProviderMicrosoft'), value: 'microsoft' },
{ label: t('translateProviderGoogle'), value: 'google' },
{ label: t('translateProviderVolcengine'), value: 'volcengine' },
{ label: t('translateProviderCaiyun'), value: 'caiyun' },
{ label: t('translateProviderTencent'), value: 'tencent' },
];
}
function getTranslateLangOptions() {
return [
{ label: t('langZhCN'), value: 'zh-CN' },
{ label: t('langZhTW'), value: 'zh-TW' },
{ label: t('langEn'), value: 'en' },
{ label: t('langJa'), value: 'ja' },
{ label: t('langKo'), value: 'ko' },
{ label: t('langEs'), value: 'es' },
{ label: t('langFr'), value: 'fr' },
{ label: t('langDe'), value: 'de' },
{ label: t('langRu'), value: 'ru' },
{ label: t('langPt'), value: 'pt' },
];
}
function getDefaultTranslateTargetLang() {
return LOCALE === 'zh' ? 'zh-CN' : 'en';
}
const SCROLL_AREA_BOTTOM_RESERVE = 56;
const DEFAULT_SETTINGS = {
enabled: true,
speed: 5,
fontSize: 22,
danmakuOpacity: 100,
fontFamily: 'system-ui, sans-serif',
scrollArea: 'full',
translateEnabled: false,
translateInChat: true,
translateProvider: 'microsoft',
translateTargetLang: getDefaultTranslateTargetLang(),
translateSpeed: 3,
blockedKeywords: [],
speechEnabled: false,
speechVolume: 80,
speechPitch: 100,
speechVoice: '',
speechLang: '',
speechRate: 100,
speechPace: true,
speechMaxSeconds: 4,
speechBuffer: 5,
superchatPinned: false,
superchatPosition: 'top',
superchatDuration: 15,
superchatAnimation: 'slide',
};
const SELECTORS = {
chatScroller: '[data-a-target="chat-scroller"], .video-chat__message-list-wrapper',
chatMessage: '[data-a-target="chat-message-container"], .chat-line__message, [data-test-selector="chat-message"], .vod-message',
chatText: 'span[data-a-target="chat-message-text"], span.text-fragment',
chatUsername: '[data-a-target="chat-message-username"], .chat-author__display-name, .chat-line__username',
chatMessageBody: '[data-a-target="chat-line-message-body"], .chat-line__message-body, .video-chat__message',
videoPlayer: '.video-player__container',
videoOverlay: '.video-player__overlay',
playerControlsRoot: '[data-a-target="player-controls"], .player-controls',
playerControls: '.video-player__default-player .player-controls__right-control-group, .player-controls__right-control-group',
chatInput: 'textarea[data-a-target="chat-input"]',
chatInputSlate: '[data-a-target="chat-input"][contenteditable="true"], .chat-wysiwyg-input__editor[contenteditable="true"]',
chatInputEditable: '[data-a-target="chat-input"][contenteditable="true"], [data-a-target="chat-input-text"]',
};
const TRANSLATE_PROVIDERS = ['auto', 'microsoft', 'google', 'volcengine', 'caiyun', 'tencent'];
// Global Twitch cheermote prefixes + partner custom "*Cheer" suffixes.
const CHEER_EMOTE_PREFIXES = [
'4Head', 'Anon', 'BibleThumb', 'BitBoss', 'bday', 'Cheer', 'Charity', 'Corgo', 'cheerwal',
'DansGame', 'DoodleCheer', 'EleGiggle', 'FrankerZ', 'FailFish', 'Goal', 'HeyGuys',
'HolidayCheer', 'Kappa', 'Kreygasm', 'MrDestructoid', 'Muxy', 'NotLikeThis', 'Party',
'Pride', 'PJSalt', 'RIPCheer', 'Scoops', 'ShowLove', 'Shamrock', 'SeemsGood', 'SwiftRage',
'Streamlabs', 'TriHard', 'uni', 'VoHiYo',
];
const CHEER_PREFIX_PATTERN = CHEER_EMOTE_PREFIXES.map((prefix) => prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|');
const BITS_TOKEN_PATTERN = `(?:^|\\s)(?:[A-Za-z][\\w]*?(?:Cheer|cheer)|(?:${CHEER_PREFIX_PATTERN}))([1-9]\\d{0,6})(?=\\s|$)`;
const STORAGE_KEY = 'tbc_settings_v1';
const LIVE_MESSAGE_TAIL = 8;
const WARMUP_MS = 2500;
const MAX_DANMAKU_QUEUE = 24;
const MAX_TRANSLATE_QUEUE = 32;
function loadSettings() {
try {
const saved = GM_getValue(STORAGE_KEY, null);
if (saved && typeof saved === 'object') {
const merged = { ...DEFAULT_SETTINGS, ...saved };
if (merged.scrollArea === 'half') {
merged.scrollArea = 'top-half';
}
if (!TRANSLATE_PROVIDERS.includes(merged.translateProvider)) {
merged.translateProvider = DEFAULT_SETTINGS.translateProvider;
}
merged.blockedKeywords = sanitizeBlockedKeywords(merged.blockedKeywords);
merged.translateEnabled = !!merged.translateEnabled;
merged.translateInChat = merged.translateInChat !== false;
merged.danmakuOpacity = clamp(Number(merged.danmakuOpacity) || DEFAULT_SETTINGS.danmakuOpacity, 10, 100);
merged.speechEnabled = !!merged.speechEnabled;
merged.speechVolume = clamp(finiteNumber(merged.speechVolume, DEFAULT_SETTINGS.speechVolume), 0, 100);
merged.speechPitch = clamp(finiteNumber(merged.speechPitch, DEFAULT_SETTINGS.speechPitch), 0, 200);
merged.speechRate = clamp(finiteNumber(merged.speechRate, DEFAULT_SETTINGS.speechRate), 50, 200);
merged.speechPace = merged.speechPace !== false;
merged.speechMaxSeconds = clamp(finiteNumber(merged.speechMaxSeconds, DEFAULT_SETTINGS.speechMaxSeconds), 1, 15);
merged.speechBuffer = clamp(finiteNumber(merged.speechBuffer, DEFAULT_SETTINGS.speechBuffer), 1, 25);
merged.speechVoice = typeof merged.speechVoice === 'string' ? merged.speechVoice : '';
merged.speechLang = typeof merged.speechLang === 'string' ? merged.speechLang : '';
merged.superchatPinned = !!merged.superchatPinned;
if (!['top', 'bottom'].includes(merged.superchatPosition)) merged.superchatPosition = 'top';
if (!['fade', 'slide', 'pop', 'shine'].includes(merged.superchatAnimation)) merged.superchatAnimation = 'slide';
merged.superchatDuration = clamp(finiteNumber(merged.superchatDuration, DEFAULT_SETTINGS.superchatDuration), 2, 300);
return merged;
}
} catch (_) {
/* ignore */
}
return { ...DEFAULT_SETTINGS };
}
function saveSettings(settings) {
GM_setValue(STORAGE_KEY, settings);
}
function finiteNumber(value, fallback) {
const number = Number(value);
return Number.isFinite(number) ? number : fallback;
}
const BLOCKED_KEYWORD_LIMIT = 50;
const BLOCKED_KEYWORD_LENGTH = 40;
function sanitizeBlockedKeywords(value) {
if (!Array.isArray(value)) return [];
const seen = new Set();
const keywords = [];
for (const item of value) {
const keyword = String(item || '').trim().slice(0, BLOCKED_KEYWORD_LENGTH);
const key = keyword.toLowerCase();
if (!keyword || seen.has(key)) continue;
seen.add(key);
keywords.push(keyword);
if (keywords.length >= BLOCKED_KEYWORD_LIMIT) break;
}
return keywords;
}
function isKeywordBlocked(text, keywords) {
const haystack = String(text || '').toLowerCase();
if (!haystack || !keywords?.length) return false;
return keywords.some((keyword) => {
const needle = String(keyword || '').trim().toLowerCase();
return needle && haystack.includes(needle);
});
}
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
}
function isPageActive() {
return !document.hidden && document.visibilityState === 'visible';
}
function waitForElement(selector, root = document, timeout = 30000) {
return new Promise((resolve) => {
const existing = root.querySelector(selector);
if (existing) {
resolve(existing);
return;
}
const observer = new MutationObserver(() => {
const el = root.querySelector(selector);
if (el) {
observer.disconnect();
resolve(el);
}
});
observer.observe(root, { childList: true, subtree: true });
if (timeout > 0) {
setTimeout(() => {
observer.disconnect();
resolve(null);
}, timeout);
}
});
}
function getUsernameColor(element) {
const colorEl = element.querySelector('[style*="color"]') ||
element.querySelector('.chat-author__display-name') ||
element.querySelector('[data-a-target="chat-message-username"]');
if (!colorEl) return '#ffffff';
const style = getComputedStyle(colorEl);
return style.color && style.color !== 'rgba(0, 0, 0, 0)' ? style.color : '#ffffff';
}
function getFullscreenElement() {
return document.fullscreenElement ||
document.webkitFullscreenElement ||
document.mozFullScreenElement;
}
function getDanmakuMount(item) {
return item.closest('.tbc-danmaku-layer, .tbc-superchat-layer')?.parentElement || null;
}
function getTwitchProfileUrl(usernameEl, displayName) {
const link = usernameEl?.closest('a') || (usernameEl?.tagName === 'A' ? usernameEl : null);
const href = link?.getAttribute('href');
if (href) {
try {
const url = new URL(href, 'https://www.twitch.tv');
const path = url.pathname.replace(/\/+$/, '');
if (path.length > 1) return `https://www.twitch.tv${path}`;
} catch (_) {
/* ignore */
}
}
const login = displayName?.trim();
if (!login) return null;
return `https://www.twitch.tv/${encodeURIComponent(login.toLowerCase())}`;
}
function getEditorPlainText(editor) {
return (editor.innerText || editor.textContent || '')
.replace(/\u200B/g, '')
.replace(/\uFEFF/g, '')
.trim();
}
function selectAllInEditor(editor) {
editor.focus();
const range = document.createRange();
range.selectNodeContents(editor);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
}
function dispatchEditorBeforeInput(editor, inputType, data) {
editor.dispatchEvent(new InputEvent('beforeinput', {
bubbles: true,
cancelable: true,
inputType,
data: data ?? null,
}));
}
function tryPasteIntoEditor(editor, text) {
selectAllInEditor(editor);
try {
const dataTransfer = new DataTransfer();
dataTransfer.setData('text/plain', text);
editor.dispatchEvent(new ClipboardEvent('paste', {
bubbles: true,
cancelable: true,
clipboardData: dataTransfer,
}));
if (getEditorPlainText(editor) === text) return true;
} catch (_) {
/* ignore */
}
selectAllInEditor(editor);
dispatchEditorBeforeInput(editor, 'insertReplacementText', text);
if (getEditorPlainText(editor) === text) return true;
selectAllInEditor(editor);
document.execCommand('delete', false, null);
dispatchEditorBeforeInput(editor, 'insertText', text);
if (getEditorPlainText(editor) === text) return true;
selectAllInEditor(editor);
document.execCommand('insertText', false, text);
return getEditorPlainText(editor) === text;
}
function findSlateChatEditor() {
return document.querySelector(SELECTORS.chatInputSlate);
}
function findLegacyChatInput() {
const direct = document.querySelector(SELECTORS.chatInput);
if (direct) return direct;
const host = document.querySelector('[data-a-target="chat-input"]');
if (!host) return null;
if (host.tagName === 'TEXTAREA' || host.tagName === 'INPUT') return host;
return host.querySelector('textarea, input[type="text"]');
}
function dispatchReactInput(element, value) {
const tracker = element._valueTracker;
if (tracker) {
tracker.setValue(element.value);
}
const prototype = element instanceof HTMLTextAreaElement
? HTMLTextAreaElement.prototype
: HTMLInputElement.prototype;
const setter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set;
if (setter) {
setter.call(element, value);
} else {
element.value = value;
}
element.dispatchEvent(new InputEvent('input', {
bubbles: true,
cancelable: true,
inputType: 'insertText',
data: value,
}));
}
function setLegacyChatInputText(textarea, text) {
textarea.focus();
textarea.setSelectionRange(0, textarea.value.length);
if (document.execCommand('insertText', false, text)) {
textarea.setSelectionRange(text.length, text.length);
} else {
dispatchReactInput(textarea, text);
textarea.setSelectionRange(text.length, text.length);
}
textarea.dispatchEvent(new Event('change', { bubbles: true }));
return true;
}
async function setChatInputText(text) {
if (!text) return false;
const slateEditor = findSlateChatEditor();
if (slateEditor) {
slateEditor.focus();
selectAllInEditor(slateEditor);
try {
if (typeof GM_setClipboard === 'function') {
GM_setClipboard(text, 'text');
} else {
await navigator.clipboard.writeText(text);
}
document.execCommand('paste', false, null);
if (getEditorPlainText(slateEditor) === text) return true;
} catch (_) {
/* clipboard API unavailable */
}
if (tryPasteIntoEditor(slateEditor, text)) return true;
return getEditorPlainText(slateEditor).length > 0;
}
const textarea = findLegacyChatInput();
if (textarea) {
return setLegacyChatInputText(textarea, text);
}
return false;
}
const SPEECH_GAP_MS = 250;
let speechSpeaker = null;
class DanmakuSpeaker {
constructor(getSettings) {
this.getSettings = getSettings;
this.queue = [];
this.token = 0;
this.busy = false;
this.timer = null;
this.supported = typeof window.speechSynthesis !== 'undefined';
this.keepAlive = null;
if (!this.supported) return;
this.keepAlive = setInterval(() => {
const synth = window.speechSynthesis;
if (!synth?.speaking || synth.paused) return;
synth.pause();
synth.resume();
}, 10000);
}
destroy() {
clearInterval(this.keepAlive);
this.keepAlive = null;
this.cancel();
}
canAutoSpeak() {
const settings = this.getSettings();
return !!settings.speechEnabled && Number(settings.speechVolume) > 0;
}
rateFor(text, paced) {
const settings = this.getSettings();
const base = clamp(Number(settings.speechRate) || 100, 50, 200) / 100;
const usePace = paced ?? !!settings.speechPace;
if (!usePace) return base;
const maxSeconds = clamp(Number(settings.speechMaxSeconds) || 4, 1, 15);
const estimate = estimateSpeechSeconds(text, base);
if (estimate <= maxSeconds) return base;
return clamp(base * (estimate / maxSeconds), base, 10);
}
listVoices() {
if (!this.supported) return [];
return window.speechSynthesis.getVoices();
}
findVoice() {
const voices = this.listVoices();
if (!voices.length) return null;
const settings = this.getSettings();
if (settings.speechVoice) {
const picked = voices.find((voice) => voice.voiceURI === settings.speechVoice || voice.name === settings.speechVoice);
if (picked) return picked;
}
const lang = String(settings.speechLang || navigator.language || '').toLowerCase();
const prefix = lang.slice(0, 2);
return voices.find((voice) => voice.default && voice.lang.toLowerCase().startsWith(prefix))
|| voices.find((voice) => prefix && voice.lang.toLowerCase().startsWith(prefix))
|| voices.find((voice) => voice.default)
|| voices[0];
}
request(text) {
if (!this.supported || document.hidden || !this.canAutoSpeak()) return;
const spoken = prepareSpeechText(text);
if (!spoken) return;
this.queue.push(spoken);
if (!this.busy) this.pump();
}
speakNow(text) {
const spoken = prepareSpeechText(text);
if (!spoken || !this.supported) return;
if (this.busy && this.queue.length) this.queue.shift();
this.token += 1;
const token = this.token;
clearTimeout(this.timer);
this.busy = true;
window.speechSynthesis.cancel();
this.timer = setTimeout(() => {
if (token !== this.token) return;
this.play(spoken, this.rateFor(spoken), token, () => this.pump());
}, 60);
}
preview(text, paced) {
const spoken = limitSpeechRepeats(normalizeSpeechText(text));
if (!spoken || !this.supported) return;
this.token += 1;
const token = this.token;
clearTimeout(this.timer);
this.busy = true;
window.speechSynthesis.cancel();
this.timer = setTimeout(() => {
if (token !== this.token) return;
this.play(spoken, this.rateFor(spoken, paced), token, () => this.pump());
}, 60);
}
pump() {
if (!this.supported || !this.canAutoSpeak()) {
this.queue = [];
this.busy = false;
return;
}
const buffer = clamp(Number(this.getSettings().speechBuffer) || 5, 1, 25);
if (this.queue.length > buffer) this.queue = this.queue.slice(-buffer);
if (!this.queue.length) {
this.busy = false;
return;
}
const text = this.queue[0];
const rate = this.rateFor(text);
this.busy = true;
this.token += 1;
const token = this.token;
this.play(text, rate, token, () => {
if (this.queue[0] === text) this.queue.shift();
this.timer = setTimeout(() => this.pump(), SPEECH_GAP_MS);
});
}
play(text, rate, token, onDone) {
if (token !== this.token || !this.supported) return;
const settings = this.getSettings();
const utterance = new SpeechSynthesisUtterance(text);
utterance.volume = clamp(Number(settings.speechVolume), 0, 100) / 100;
utterance.pitch = clamp(Number(settings.speechPitch), 0, 200) / 100;
utterance.rate = clamp(rate, 0.1, 10);
const voice = this.findVoice();
if (voice) {
utterance.voice = voice;
utterance.lang = voice.lang;
}
let settled = false;
const finish = (event) => {
if (settled || token !== this.token) return;
if (event?.type === 'error' && (event.error === 'interrupted' || event.error === 'canceled')) return;
settled = true;
onDone?.();
};
utterance.addEventListener('end', finish);
utterance.addEventListener('error', finish);
window.speechSynthesis.resume();
window.speechSynthesis.speak(utterance);
}
cancel() {
this.token += 1;
this.queue = [];
this.busy = false;
clearTimeout(this.timer);
this.timer = null;
if (this.supported) window.speechSynthesis.cancel();
}
}
function normalizeSpeechText(text) {
return String(text || '').replace(/\s+/g, ' ').trim();
}
function speechSourceText(message, parts) {
if (Array.isArray(parts) && parts.length) {
return parts.map((part) => {
if (part?.type === 'text') return part.value || part.text || '';
if (part?.type === 'emote' && /cheer/i.test(part.name || part.alt || '')) return part.name || part.alt || '';
return '';
}).filter(Boolean).join(' ');
}
return message || '';
}
function limitSpeechRepeats(text, maxRepeats = 3) {
let out = String(text || '');
const maxUnit = Math.floor(out.length / (maxRepeats + 1));
for (let len = maxUnit; len >= 1; len -= 1) {
const pattern = new RegExp(`([\\s\\S]{${len}})\\1{${maxRepeats},}`, 'g');
out = out.replace(pattern, (_match, unit) => unit.repeat(maxRepeats));
}
return out.replace(/(\S+)(?:\s+\1){3,}/g, (_match, token) => Array(maxRepeats).fill(token).join(' '));
}
function hasReadableSpeech(text) {
const stripped = String(text || '')
.replace(/\p{Extended_Pictographic}/gu, '')
.replace(/\p{N}/gu, '')
.replace(/\p{P}/gu, '')
.replace(/\p{S}/gu, '')
.replace(/\s+/g, '');
return stripped.length > 0;
}
function prepareSpeechText(text) {
const spoken = limitSpeechRepeats(normalizeSpeechText(text));
return hasReadableSpeech(spoken) ? spoken : '';
}
function estimateSpeechSeconds(text, rate) {
let units = 0;
for (const ch of String(text || '')) {
if (/\s/.test(ch)) continue;
units += /[\u3040-\u30ff\u3400-\u9fff\uf900-\ufaff\uac00-\ud7af]/.test(ch) ? 1 : 0.45;
}
return (units * 0.24) / (rate || 1);
}
function ensureSpeechSpeaker(getSettings) {
if (!speechSpeaker) speechSpeaker = new DanmakuSpeaker(getSettings);
return speechSpeaker;
}
function requestSpeech(text) {
speechSpeaker?.request(text);
}
function speakTextNow(text) {
speechSpeaker?.speakNow(text);
}
function previewSpeech(text, fast) {
speechSpeaker?.preview(text, fast);
}
function cancelSpeech() {
speechSpeaker?.cancel();
}
function listSpeechVoices() {
return speechSpeaker?.listVoices() || [];
}
function speechSupported() {
return !!speechSpeaker?.supported;
}
const avatarCache = new Map();
function letterAvatarDataUri(name, color) {
const letter = String(name || '?').trim().charAt(0).toUpperCase() || '?';
const fill = String(color || '#9147ff').replace(/[<>&"']/g, '');
const svg = ``;
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
}
function cachedAvatar(login, displayName, color) {
const key = String(login || displayName || '').toLowerCase();
if (key && avatarCache.has(key)) return avatarCache.get(key);
return letterAvatarDataUri(displayName || login, color);
}
function rememberAvatar(login, url) {
const key = String(login || '').toLowerCase();
if (!key || !url) return;
avatarCache.set(key, url);
}
function fetchTwitchAvatar(login) {
const key = String(login || '').trim().toLowerCase();
if (!key) return Promise.resolve('');
if (avatarCache.has(key) && !String(avatarCache.get(key)).startsWith('data:')) {
return Promise.resolve(avatarCache.get(key));
}
return new Promise((resolve) => {
GM_xmlhttpRequest({
method: 'POST',
url: 'https://gql.twitch.tv/gql',
headers: {
'Client-ID': 'kd1unb4b3q4t58fwlpcbzcbnm76a8fp',
'Content-Type': 'application/json',
},
data: JSON.stringify({
query: `query{user(login:${JSON.stringify(key)}){profileImageURL(width:70)}}`,
}),
timeout: 8000,
onload: (res) => {
try {
const url = JSON.parse(res.responseText)?.data?.user?.profileImageURL || '';
if (url) {
rememberAvatar(key, url);
resolve(url);
return;
}
} catch {
/* ignore */
}
resolve('');
},
onerror: () => resolve(''),
ontimeout: () => resolve(''),
});
});
}
class LaneManager {
constructor() {
this.lanes = [];
this.laneHeight = 28;
}
reset(laneCount, laneHeight) {
this.laneHeight = laneHeight;
this.lanes = Array.from({ length: Math.max(1, laneCount) }, () => 0);
}
acquire(durationMs) {
const now = Date.now();
let bestLane = 0;
let bestTime = Infinity;
for (let i = 0; i < this.lanes.length; i++) {
if (this.lanes[i] <= now) {
this.lanes[i] = now + durationMs * 0.6;
return i;
}
if (this.lanes[i] < bestTime) {
bestTime = this.lanes[i];
bestLane = i;
}
}
this.lanes[bestLane] = now + durationMs * 0.6;
return bestLane;
}
}
function safeCssColor(value, fallback) {
const color = String(value || '').trim();
if (!color || /[;{}]|url\s*\(/i.test(color)) return fallback;
return color;
}
class DanmakuEngine {
constructor(getSettings) {
this.getSettings = getSettings;
this.laneManager = new LaneManager();
this.layer = null;
this.superchatLayer = null;
this.pinnedItems = [];
this.superchatQueue = [];
this.maxPinned = 4;
this.playerEl = null;
this.overlayEl = null;
this.controlsObserver = null;
this.resizeObserver = null;
this.boundUpdate = () => this.updateLayout();
}
async mount() {
this.playerEl = await waitForElement(SELECTORS.videoPlayer);
this.overlayEl = this.playerEl.querySelector(SELECTORS.videoOverlay) || this.playerEl;
this.layer = document.createElement('div');
this.layer.className = 'tbc-danmaku-layer';
this.layer.setAttribute('aria-hidden', 'true');
this.superchatLayer = document.createElement('div');
this.superchatLayer.className = 'tbc-superchat-layer';
this.superchatLayer.setAttribute('aria-hidden', 'true');
this.overlayEl.append(this.layer, this.superchatLayer);
if (getComputedStyle(this.overlayEl).position === 'static') {
this.overlayEl.style.position = 'relative';
}
this.resizeObserver = new ResizeObserver(this.boundUpdate);
this.resizeObserver.observe(this.playerEl);
const controlsEl = this.playerEl.querySelector(SELECTORS.playerControlsRoot);
if (controlsEl) {
this.controlsObserver = new ResizeObserver(this.boundUpdate);
this.controlsObserver.observe(controlsEl);
}
window.addEventListener('resize', this.boundUpdate);
document.addEventListener('fullscreenchange', this.boundUpdate);
document.addEventListener('webkitfullscreenchange', this.boundUpdate);
this.updateLayout();
}
destroy() {
window.removeEventListener('resize', this.boundUpdate);
document.removeEventListener('fullscreenchange', this.boundUpdate);
document.removeEventListener('webkitfullscreenchange', this.boundUpdate);
this.resizeObserver?.disconnect();
this.controlsObserver?.disconnect();
hideDanmakuTooltip();
this.layer?.remove();
this.superchatLayer?.remove();
this.layer = null;
this.superchatLayer = null;
this.pinnedItems = [];
this.superchatQueue = [];
}
getBottomReserve() {
const controls = this.playerEl?.querySelector(SELECTORS.playerControlsRoot);
if (controls) {
const height = controls.getBoundingClientRect().height;
if (height > 0) return Math.ceil(height) + 8;
}
return SCROLL_AREA_BOTTOM_RESERVE;
}
getPinnedItemHeight() {
return Math.max(44, this.getSettings().fontSize + 28);
}
getPinnedBandHeight() {
const count = Math.max(1, this.pinnedItems.filter((item) => item?.isConnected).length);
const gap = 6;
return count * this.getPinnedItemHeight() + (count - 1) * gap + 8;
}
getPinnedBandRect(playerHeight) {
const live = this.pinnedItems.filter((item) => item?.isConnected);
this.pinnedItems = live;
if (!live.length || !this.getSettings().superchatPinned || !this.getSettings().enabled) return null;
const band = this.getPinnedBandHeight();
const pos = this.getSettings().superchatPosition === 'bottom' ? 'bottom' : 'top';
const top = pos === 'bottom'
? Math.max(0, playerHeight - this.getBottomReserve() - band)
: 8;
return { pos, top, height: band };
}
getScrollAreaRect(playerRect) {
const settings = this.getSettings();
const h = playerRect.height;
const laneHeight = settings.fontSize + 6;
const bottomReserve = this.getBottomReserve();
let area;
switch (settings.scrollArea) {
case 'top-half':
area = { top: 0, height: Math.max(laneHeight, h * 0.5) };
break;
case 'top-quarter':
area = { top: 0, height: Math.max(laneHeight, h * 0.25) };
break;
default:
area = { top: 0, height: Math.max(laneHeight, h - bottomReserve) };
break;
}
const band = this.getPinnedBandRect(h);
if (!band) return area;
const areaBottom = area.top + area.height;
const bandBottom = band.top + band.height;
if (bandBottom <= area.top || band.top >= areaBottom) return area;
if (band.pos === 'top') {
const nextTop = Math.max(area.top, bandBottom + 4);
return { top: nextTop, height: Math.max(laneHeight, areaBottom - nextTop) };
}
const nextBottom = Math.min(areaBottom, band.top - 4);
return { top: area.top, height: Math.max(laneHeight, nextBottom - area.top) };
}
migrateDanmakuItems(items, oldRect, newWidth, newHeight) {
const oldWidth = oldRect.width || 1;
const oldHeight = oldRect.height || 1;
for (const item of items) {
if (!item.isConnected) continue;
const top = parseFloat(item.style.top) || 0;
item.style.top = `${(top / oldHeight) * newHeight}px`;
const oldTravel = parseFloat(item.style.getPropertyValue('--tbc-travel')) || oldWidth;
const oldDuration = parseFloat(item.style.getPropertyValue('--tbc-duration')) || 8000;
const matrix = new DOMMatrixReadOnly(getComputedStyle(item).transform);
const progress = oldTravel > 0 ? clamp(-matrix.m41 / oldTravel, 0, 1) : 0;
const itemWidth = item.offsetWidth;
const newTravel = newWidth + itemWidth;
const newDuration = (newTravel / oldTravel) * oldDuration;
const remaining = Math.max(0, newDuration * (1 - progress));
item._tbcClearRemoveTimer?.();
const wasPaused = item.classList.contains('tbc-paused');
if (wasPaused) {
item.classList.remove('tbc-paused');
}
item.style.setProperty('--tbc-travel', `${newTravel}px`);
item.style.setProperty('--tbc-start', `${-progress * newTravel}px`);
item.style.setProperty('--tbc-duration', `${remaining}ms`);
item.style.animation = 'none';
void item.offsetWidth;
item.style.animation = '';
if (wasPaused) {
item.classList.add('tbc-paused');
}
item._tbcScheduleRemove?.(remaining);
}
}
updateLayout() {
if (!this.layer || !this.playerEl) return;
const settings = this.getSettings();
if (!settings.superchatPinned || !settings.enabled) {
this.superchatQueue = [];
this.pinnedItems.forEach((item) => item.remove());
this.pinnedItems = [];
}
const fsEl = getFullscreenElement();
const anchor = fsEl || this.playerEl;
const rect = anchor.getBoundingClientRect();
const area = this.getScrollAreaRect(rect);
const oldParent = this.layer.parentElement;
const oldRect = this.layer.getBoundingClientRect();
const items = [...this.layer.querySelectorAll('.tbc-danmaku-item')];
const shouldMigrate = items.length > 0;
if (shouldMigrate) {
hideDanmakuTooltip();
}
if (fsEl && fsEl.contains(this.playerEl)) {
if (this.layer.parentElement !== fsEl) {
fsEl.appendChild(this.layer);
}
Object.assign(this.layer.style, {
position: 'fixed',
top: `${rect.top + area.top}px`,
left: `${rect.left}px`,
width: `${rect.width}px`,
height: `${area.height}px`,
zIndex: '9999',
});
} else {
if (this.layer.parentElement !== this.overlayEl) {
this.overlayEl.appendChild(this.layer);
}
Object.assign(this.layer.style, {
position: 'absolute',
top: `${area.top}px`,
left: '0',
width: '100%',
height: `${area.height}px`,
zIndex: '4',
});
}
const newWidth = this.layer.clientWidth;
const newHeight = this.layer.clientHeight;
if (shouldMigrate && (
oldParent !== this.layer.parentElement
|| Math.abs(oldRect.width - newWidth) > 1
|| Math.abs(oldRect.height - newHeight) > 1
)) {
this.migrateDanmakuItems(items, oldRect, newWidth, newHeight);
}
const laneHeight = settings.fontSize + 6;
const laneCount = Math.floor(newHeight / laneHeight);
this.laneManager.reset(laneCount, laneHeight);
this.layer.style.display = settings.enabled ? 'block' : 'none';
this.layer.style.setProperty(
'--tbc-opacity',
String(clamp(Number(settings.danmakuOpacity) || 100, 10, 100) / 100),
);
this.layoutSuperchat(rect, !!(fsEl && fsEl.contains(this.playerEl)));
}
layoutSuperchat(playerRect, fullscreen) {
if (!this.superchatLayer) return;
const parent = fullscreen ? (getFullscreenElement() || this.overlayEl) : this.overlayEl;
if (parent && this.superchatLayer.parentElement !== parent) parent.appendChild(this.superchatLayer);
const band = this.getPinnedBandRect(playerRect.height);
if (!band) {
this.superchatLayer.style.display = 'none';
return;
}
this.superchatLayer.classList.toggle('tbc-sc-bottom', band.pos === 'bottom');
Object.assign(this.superchatLayer.style, fullscreen ? {
display: 'flex',
position: 'fixed',
top: `${playerRect.top + band.top}px`,
left: `${playerRect.left}px`,
width: `${playerRect.width}px`,
height: `${band.height}px`,
zIndex: '10000',
} : {
display: 'flex',
position: 'absolute',
top: `${band.top}px`,
left: '0',
width: '100%',
height: `${band.height}px`,
zIndex: '5',
});
}
render(payload) {
const settings = this.getSettings();
if (payload.superchat && settings.superchatPinned) {
this.queueSuperchat(payload);
return;
}
this.renderScrolling(payload);
}
queueSuperchat(payload) {
if (!this.superchatLayer || !this.getSettings().enabled) return;
this.pinnedItems = this.pinnedItems.filter((item) => item?.isConnected);
if (this.pinnedItems.length >= this.maxPinned) {
this.superchatQueue.push(payload);
if (this.superchatQueue.length > 8) this.superchatQueue.shift();
return;
}
this.mountSuperchat(payload);
}
truncateName(name, max = 12) {
const text = String(name || '').trim();
if (text.length <= max) return text;
return `${text.slice(0, Math.max(1, max - 1))}…`;
}
mountSuperchat(payload) {
const {
username,
message,
parts,
usernameColor,
profileUrl,
originalMessage,
translated,
superchatStyle,
superchatMeta,
avatarUrl,
login,
} = payload;
const settings = this.getSettings();
const hasEmote = Array.isArray(parts) && parts.some((part) => part.type === 'emote' && part.src);
if (!this.superchatLayer || (!message && !hasEmote)) return;
const animation = ['fade', 'slide', 'pop', 'shine'].includes(settings.superchatAnimation)
? settings.superchatAnimation
: 'slide';
const duration = clamp(Number(settings.superchatDuration) || 15, 2, 300);
const meta = superchatMeta || null;
const style = meta?.style || superchatStyle || {
background: 'linear-gradient(135deg, #9147ff 0%, #772ce8 100%)',
color: '#ffffff',
accent: '#bf94ff',
};
const item = document.createElement('div');
item.className = `tbc-superchat-item tbc-sc-${animation}`;
item.style.setProperty('--tbc-sc-duration', `${duration}s`);
item.style.setProperty('--tbc-sc-accent', safeCssColor(style.accent, '#bf94ff'));
item.style.background = style.background || safeCssColor(style.background, '#9147ff');
item.style.color = safeCssColor(style.color, '#ffffff');
item.style.fontSize = `${Math.max(14, settings.fontSize - 2)}px`;
item.style.fontFamily = settings.fontFamily;
const avatar = document.createElement('img');
avatar.className = 'tbc-sc-avatar';
avatar.alt = '';
avatar.draggable = false;
avatar.src = avatarUrl || cachedAvatar(login || username, username, usernameColor);
const main = document.createElement('div');
main.className = 'tbc-sc-main';
const head = document.createElement('div');
head.className = 'tbc-sc-head';
const nameEl = document.createElement('span');
nameEl.className = 'tbc-sc-name';
nameEl.textContent = this.truncateName(username || login || 'User');
nameEl.title = username || login || '';
head.appendChild(nameEl);
if (meta?.amountLabel) {
const badge = document.createElement('span');
badge.className = 'tbc-sc-badge';
badge.textContent = meta.amountLabel;
head.appendChild(badge);
}
const body = document.createElement('div');
body.className = 'tbc-sc-body';
const track = document.createElement('div');
track.className = 'tbc-sc-body-track';
this.appendParts(track, parts?.length ? parts : [{ type: 'text', value: message }]);
body.appendChild(track);
main.append(head, body);
item.append(avatar, main);
this.superchatLayer.appendChild(item);
this.pinnedItems.push(item);
this.updateLayout();
requestAnimationFrame(() => this.armSuperchatMarquee(body, track));
requestSpeech(speechSourceText(message, parts));
const loginKey = String(login || '').toLowerCase();
if (loginKey && !avatarUrl) {
fetchTwitchAvatar(loginKey).then((url) => {
if (!url || !item.isConnected) return;
avatar.src = url;
});
}
let fallback = null;
const armFallback = (ms) => {
clearTimeout(fallback);
fallback = setTimeout(() => this.finishSuperchat(item), ms);
};
armFallback((duration + 2) * 1000);
item.addEventListener('animationend', (event) => {
if (event.target !== item) return;
if (/shine-sweep|marquee/i.test(event.animationName || '')) return;
clearTimeout(fallback);
this.finishSuperchat(item);
});
attachDanmakuInteraction(item, {
username,
usernameColor,
profileUrl,
originalMessage,
translated,
onPause: () => clearTimeout(fallback),
onResume: () => armFallback(20000),
});
}
armSuperchatMarquee(body, track) {
if (!body || !track) return;
const overflow = track.scrollWidth - body.clientWidth;
if (overflow <= 8) return;
track.classList.add('tbc-sc-marquee');
track.style.setProperty('--tbc-sc-marquee-distance', `${overflow + 16}px`);
track.style.setProperty('--tbc-sc-marquee-duration', `${Math.max(4, overflow / 28)}s`);
}
finishSuperchat(item) {
if (!item) return;
const index = this.pinnedItems.indexOf(item);
if (index >= 0) this.pinnedItems.splice(index, 1);
item.remove();
while (this.pinnedItems.length < this.maxPinned) {
const next = this.superchatQueue.shift();
if (!next || !this.getSettings().superchatPinned || !this.getSettings().enabled) break;
this.mountSuperchat(next);
}
this.updateLayout();
}
appendParts(item, parts) {
for (const part of parts) {
if (part.type === 'emote' && part.src) {
const img = document.createElement('img');
img.className = 'tbc-emote';
img.src = part.src;
img.alt = part.name || '';
img.title = part.name || '';
img.draggable = false;
item.appendChild(img);
continue;
}
const text = part.type === 'emote' ? (part.name || '') : (part.value || '');
if (text) item.appendChild(document.createTextNode(text));
}
}
renderScrolling({ username, message, parts, usernameColor, profileUrl, originalMessage, translated }) {
const settings = this.getSettings();
const displayParts = parts?.length
? parts
: (message ? [{ type: 'text', value: message }] : []);
if (!settings.enabled || !this.layer || !displayParts.length) return;
const layerWidth = this.layer.clientWidth || 800;
const speed = clamp(settings.speed, 1, 10);
const baseDuration = 30000;
const laneHeight = settings.fontSize + 6;
const item = document.createElement('div');
item.className = 'tbc-danmaku-item';
this.appendParts(item, displayParts);
item.style.cssText = `
top: 0;
color: ${usernameColor || '#efeff1'};
font-size: ${settings.fontSize}px;
font-family: ${settings.fontFamily};
visibility: hidden;
animation: none;
`;
this.layer.appendChild(item);
const itemWidth = item.offsetWidth;
const travel = layerWidth + itemWidth;
const durationMs = Math.round((travel / layerWidth) * (baseDuration / speed));
const lane = this.laneManager.acquire(durationMs);
const top = lane * laneHeight;
item.style.top = `${top}px`;
item.style.visibility = '';
item.style.setProperty('--tbc-travel', `${travel}px`);
item.style.setProperty('--tbc-start', '0px');
item.style.setProperty('--tbc-duration', `${durationMs}ms`);
void item.offsetWidth;
item.style.animation = '';
const startAt = Date.now();
let pausedAt = 0;
let totalPaused = 0;
let removeTimer = null;
const scheduleRemove = (remainingMs = durationMs) => {
const elapsed = Date.now() - startAt - totalPaused;
const remaining = remainingMs ?? Math.max(0, durationMs - elapsed);
clearTimeout(removeTimer);
removeTimer = setTimeout(() => item.remove(), remaining + 500);
};
item._tbcClearRemoveTimer = () => clearTimeout(removeTimer);
item._tbcScheduleRemove = scheduleRemove;
const onPause = () => {
pausedAt = Date.now();
clearTimeout(removeTimer);
};
const onResume = () => {
if (pausedAt) {
totalPaused += Date.now() - pausedAt;
pausedAt = 0;
}
scheduleRemove();
};
attachDanmakuInteraction(item, {
username,
usernameColor,
profileUrl,
originalMessage,
translated,
onPause,
onResume,
});
requestSpeech(speechSourceText(message, parts));
scheduleRemove();
item.addEventListener('animationend', () => {
clearTimeout(removeTimer);
item.remove();
}, { once: true });
}
}
const MAX_DANMAKU_PAUSE_MS = 15000;
const RESUME_DELAY_MS = 300;
let activeTooltip = null;
let activeTooltipItem = null;
let hoverPausedItem = null;
let clickLockBackdrop = null;
let tooltipRepositionHandler = null;
let tooltipDismissBound = false;
function removeClickLockBackdrop() {
clickLockBackdrop?.remove();
clickLockBackdrop = null;
}
function showClickLockBackdrop(mount) {
removeClickLockBackdrop();
if (!mount) return;
if (getComputedStyle(mount).position === 'static') {
mount.style.position = 'relative';
}
clickLockBackdrop = document.createElement('div');
clickLockBackdrop.className = 'tbc-click-lock-backdrop';
clickLockBackdrop.addEventListener('pointerdown', (e) => {
const otherDanmaku = document.elementsFromPoint(e.clientX, e.clientY)
.find((el) => (el.classList?.contains('tbc-danmaku-item') || el.classList?.contains('tbc-superchat-item')) && el !== activeTooltipItem);
hideDanmakuTooltip();
if (otherDanmaku) {
requestAnimationFrame(() => {
otherDanmaku.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
});
}
}, true);
mount.appendChild(clickLockBackdrop);
}
function hideDanmakuTooltip() {
if (tooltipRepositionHandler) {
window.removeEventListener('resize', tooltipRepositionHandler);
document.removeEventListener('fullscreenchange', tooltipRepositionHandler);
document.removeEventListener('webkitfullscreenchange', tooltipRepositionHandler);
tooltipRepositionHandler = null;
}
removeClickLockBackdrop();
const resumeFn = activeTooltipItem?._tbcResume;
activeTooltipItem?.classList.remove('tbc-click-locked');
activeTooltip?.remove();
activeTooltip = null;
activeTooltipItem = null;
resumeFn?.();
}
function ensureTooltipDismissListener() {
if (tooltipDismissBound) return;
tooltipDismissBound = true;
document.addEventListener('pointerdown', (e) => {
if (!activeTooltip) return;
if (activeTooltip.contains(e.target)) return;
if (e.target.closest('.tbc-danmaku-item, .tbc-superchat-item') === activeTooltipItem) return;
if (clickLockBackdrop?.contains(e.target)) return;
hideDanmakuTooltip();
}, true);
}
function positionDanmakuTooltip(item, tooltip, mount) {
if (!mount) return;
const margin = 8;
const gap = 6;
const bounds = mount.getBoundingClientRect();
const itemRect = item.getBoundingClientRect();
const tipRect = tooltip.getBoundingClientRect();
const tipHeight = tipRect.height;
const tipWidth = tipRect.width;
const spaceAbove = itemRect.top - bounds.top - margin;
const spaceBelow = bounds.bottom - itemRect.bottom - margin;
const needed = tipHeight + gap;
let placement = 'above';
if (spaceAbove < needed && spaceBelow >= needed) {
placement = 'below';
} else if (spaceAbove < needed && spaceBelow < needed) {
placement = spaceBelow > spaceAbove ? 'below' : 'above';
}
tooltip.classList.toggle('tbc-danmaku-tooltip--above', placement === 'above');
tooltip.classList.toggle('tbc-danmaku-tooltip--below', placement === 'below');
let left = itemRect.left - bounds.left + itemRect.width / 2 - tipWidth / 2;
left = clamp(left, margin, Math.max(margin, bounds.width - tipRect.width - margin));
let top;
if (placement === 'below') {
top = itemRect.bottom - bounds.top + gap;
} else {
top = itemRect.top - bounds.top - tipHeight - gap;
}
Object.assign(tooltip.style, {
position: 'absolute',
left: `${left}px`,
top: `${top}px`,
zIndex: '100001',
});
}
function bindTooltipReposition(item, tooltip, mount) {
tooltipRepositionHandler = () => positionDanmakuTooltip(item, tooltip, mount);
window.addEventListener('resize', tooltipRepositionHandler);
document.addEventListener('fullscreenchange', tooltipRepositionHandler);
document.addEventListener('webkitfullscreenchange', tooltipRepositionHandler);
}
function attachDanmakuInteraction(item, {
username, usernameColor, profileUrl, originalMessage, translated, onPause, onResume,
}) {
let hoverResumeTimer = null;
let maxPauseTimer = null;
let pauseReason = null;
const clearHoverResumeTimer = () => {
clearTimeout(hoverResumeTimer);
hoverResumeTimer = null;
};
const clearMaxPauseTimer = () => {
clearTimeout(maxPauseTimer);
maxPauseTimer = null;
};
const armMaxPauseTimer = () => {
clearMaxPauseTimer();
maxPauseTimer = setTimeout(() => {
if (item.classList.contains('tbc-paused')) doResume();
}, MAX_DANMAKU_PAUSE_MS);
};
const doResume = () => {
clearHoverResumeTimer();
clearMaxPauseTimer();
if (!item.classList.contains('tbc-paused')) {
pauseReason = null;
item.classList.remove('tbc-click-locked');
return;
}
pauseReason = null;
item.classList.remove('tbc-paused', 'tbc-click-locked');
if (hoverPausedItem === item) hoverPausedItem = null;
if (activeTooltipItem === item) {
removeClickLockBackdrop();
activeTooltip?.remove();
activeTooltip = null;
activeTooltipItem = null;
}
onResume?.();
};
item._tbcResume = () => doResume();
item._tbcResumeHover = () => {
if (pauseReason !== 'hover') return;
doResume();
};
const scheduleHoverResume = () => {
if (pauseReason === 'click') return;
clearHoverResumeTimer();
hoverResumeTimer = setTimeout(() => {
if (pauseReason === 'hover') doResume();
}, RESUME_DELAY_MS);
};
const pauseForHover = () => {
if (activeTooltipItem) return;
if (pauseReason === 'click') return;
clearHoverResumeTimer();
if (hoverPausedItem && hoverPausedItem !== item) {
hoverPausedItem._tbcResumeHover?.();
}
hoverPausedItem = item;
if (!item.classList.contains('tbc-paused')) {
pauseReason = 'hover';
item.classList.add('tbc-paused');
onPause?.();
} else if (pauseReason !== 'hover') {
pauseReason = 'hover';
}
armMaxPauseTimer();
};
const pauseForClick = () => {
clearHoverResumeTimer();
if (hoverPausedItem && hoverPausedItem !== item) {
hoverPausedItem._tbcResumeHover?.();
}
hoverPausedItem = null;
pauseReason = 'click';
item.classList.add('tbc-click-locked');
if (!item.classList.contains('tbc-paused')) {
item.classList.add('tbc-paused');
onPause?.();
}
armMaxPauseTimer();
};
const showTooltip = () => {
const messageText = originalMessage || item.textContent;
const showOriginal = translated && originalMessage && originalMessage !== item.textContent;
if (!username && !showOriginal && !messageText) return;
ensureTooltipDismissListener();
hideDanmakuTooltip();
pauseForClick();
item.dataset.tbcId = item.dataset.tbcId || String(Date.now() + Math.random());
const mount = getDanmakuMount(item);
if (!mount) {
doResume();
return;
}
showClickLockBackdrop(mount);
const tooltip = document.createElement('div');
tooltip.className = 'tbc-danmaku-tooltip tbc-danmaku-tooltip--above';
tooltip.dataset.itemId = item.dataset.tbcId;
const bubble = document.createElement('div');
bubble.className = 'tbc-danmaku-tooltip-bubble';
const header = document.createElement('div');
header.className = 'tbc-danmaku-tooltip-header';
if (username) {
const nameRow = document.createElement('a');
nameRow.className = 'tbc-danmaku-tooltip-name';
nameRow.href = profileUrl || `https://www.twitch.tv/${encodeURIComponent(username.toLowerCase())}`;
nameRow.target = '_blank';
nameRow.rel = 'noopener noreferrer';
nameRow.setAttribute('aria-label', t('tooltipOpenProfile'));
nameRow.style.color = usernameColor || '#a970ff';
nameRow.textContent = username;
nameRow.addEventListener('click', (e) => e.stopPropagation());
header.appendChild(nameRow);
} else {
header.classList.add('tbc-danmaku-tooltip-header--solo');
}
if (messageText) {
const copyBtn = document.createElement('button');
copyBtn.type = 'button';
copyBtn.className = 'tbc-danmaku-tooltip-copy';
copyBtn.setAttribute('aria-label', t('tooltipCopy'));
copyBtn.innerHTML = ICON_COPY;
copyBtn.addEventListener('click', async (e) => {
e.stopPropagation();
await setChatInputText(messageText);
hideDanmakuTooltip();
});
header.appendChild(copyBtn);
}
bubble.appendChild(header);
if (showOriginal) {
const originalRow = document.createElement('div');
originalRow.className = 'tbc-danmaku-tooltip-original';
originalRow.textContent = truncateText(originalMessage);
bubble.appendChild(originalRow);
}
const tail = document.createElement('div');
tail.className = 'tbc-danmaku-tooltip-tail';
tooltip.append(bubble, tail);
tooltip.addEventListener('click', (e) => e.stopPropagation());
mount.appendChild(tooltip);
positionDanmakuTooltip(item, tooltip, mount);
bindTooltipReposition(item, tooltip, mount);
activeTooltip = tooltip;
activeTooltipItem = item;
requestAnimationFrame(() => {
if (activeTooltip === tooltip) positionDanmakuTooltip(item, tooltip, mount);
});
};
item.addEventListener('mouseenter', () => {
pauseForHover();
});
item.addEventListener('mouseleave', () => {
if (pauseReason === 'click') return;
scheduleHoverResume();
});
item.addEventListener('click', (e) => {
e.stopPropagation();
showTooltip();
});
item.addEventListener('animationend', () => {
clearHoverResumeTimer();
clearMaxPauseTimer();
if (activeTooltipItem === item) hideDanmakuTooltip();
}, { once: true });
}
class DanmakuEmitter {
constructor(engine, getSettings, isActive) {
this.engine = engine;
this.getSettings = getSettings;
this.isActive = isActive;
this.queue = [];
this.timer = null;
}
enqueue(payload) {
if (!this.isActive()) return;
if (this.queue.length >= MAX_DANMAKU_QUEUE) {
this.queue.shift();
}
this.queue.push(payload);
this.schedule();
}
clear() {
this.queue = [];
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
}
schedule() {
if (this.timer || !this.isActive()) return;
const speed = clamp(this.getSettings().speed, 1, 10);
const intervalMs = Math.max(120, Math.round(550 / speed));
this.timer = setTimeout(() => {
this.timer = null;
const item = this.queue.shift();
if (item) this.engine.render(item);
if (this.queue.length && this.isActive()) this.schedule();
}, intervalMs);
}
}
function dictEntry(zhCN, zhTW, en) {
return { 'zh-CN': zhCN, 'zh-TW': zhTW, en };
}
// Case-sensitive keys — emotes and slang must match exact casing.
const TWITCH_DICTIONARY = {
PogChamp: dictEntry('(神操作)', '(神操作)', 'PogChamp'),
PepeHands: dictEntry('(哭哭)', '(哭哭)', 'PepeHands'),
OMEGALUL: dictEntry('(笑死)', '(笑死)', 'OMEGALUL'),
PepeLaugh: dictEntry('(大笑)', '(大笑)', 'PepeLaugh'),
PauseChamp: dictEntry('(等等)', '(等等)', 'PauseChamp'),
monkaS: dictEntry('(紧张)', '(緊張)', 'monkaS'),
monkaW: dictEntry('(紧张)', '(緊張)', 'monkaW'),
KEKW: dictEntry('(爆笑)', '(爆笑)', 'KEKW'),
LULW: dictEntry('(笑)', '(笑)', 'LULW'),
LUL: dictEntry('(笑)', '(笑)', 'LUL'),
Sadge: dictEntry('(难受)', '(難受)', 'Sadge'),
Pepega: dictEntry('(有点蠢)', '(有點蠢)', 'Pepega'),
Kappa: dictEntry('(反讽)', '(反諷)', 'Kappa'),
Copium: dictEntry('自我安慰', '自我安慰', 'Copium'),
Hopium: dictEntry('抱有幻想', '抱有幻想', 'Hopium'),
Based: dictEntry('真敢说', '真敢說', 'Based'),
Cringe: dictEntry('尴尬', '尷尬', 'Cringe'),
PogU: dictEntry('(太神了)', '(太神了)', 'PogU'),
Pog: dictEntry('(太神了)', '(太神了)', 'Pog'),
EZ: dictEntry('轻松', '輕鬆', 'EZ'),
Clap: dictEntry('(鼓掌)', '(鼓掌)', 'Clap'),
NPC: dictEntry('工具人', '工具人', 'NPC'),
ICANT: dictEntry('我做不到', '我做不到', 'ICANT'),
W: dictEntry('神', '神', 'W'),
L: dictEntry('拉胯', '拉胯', 'L'),
F: dictEntry('F', 'F', 'F'),
o7: dictEntry('o7', 'o7', 'o7'),
lol: dictEntry('哈哈', '哈哈', 'lol'),
LMFAO: dictEntry('笑死我了', '笑死我了', 'LMFAO'),
lmao: dictEntry('笑死我了', '笑死我了', 'lmao'),
lmfao: dictEntry('笑死我了', '笑死我了', 'lmfao'),
rofl: dictEntry('笑翻', '笑翻', 'rofl'),
kek: dictEntry('笑', '笑', 'kek'),
gg: dictEntry('GG', 'GG', 'gg'),
ggs: dictEntry('GG', 'GG', 'ggs'),
wp: dictEntry('打得不错', '打得不错', 'wp'),
gl: dictEntry('祝好运', '祝好運', 'gl'),
hf: dictEntry('玩得开心', '玩得開心', 'hf'),
nt: dictEntry('好尝试', '好嘗試', 'nt'),
ns: dictEntry('好枪', '好槍', 'ns'),
brb: dictEntry('马上回来', '馬上回來', 'brb'),
afk: dictEntry('离开了', '離開了', 'afk'),
bbl: dictEntry('待会儿回来', '待會兒回來', 'bbl'),
gtg: dictEntry('得走了', '得走了', 'gtg'),
ty: dictEntry('谢谢', '謝謝', 'ty'),
thx: dictEntry('谢谢', '謝謝', 'thx'),
np: dictEntry('不客气', '不客氣', 'np'),
nw: dictEntry('不客气', '不客氣', 'nw'),
imo: dictEntry('我觉得', '我覺得', 'imo'),
imho: dictEntry('依我看', '依我看', 'imho'),
tbh: dictEntry('老实说', '老實說', 'tbh'),
ngl: dictEntry('说实话', '說實話', 'ngl'),
rn: dictEntry('现在', '現在', 'rn'),
idk: dictEntry('不知道', '不知道', 'idk'),
ikr: dictEntry('确实', '確實', 'ikr'),
smh: dictEntry('无语', '無語', 'smh'),
omg: dictEntry('天呐', '天呐', 'omg'),
omfg: dictEntry('我的天', '我的天', 'omfg'),
wtf: dictEntry('什么鬼', '什麼鬼', 'wtf'),
rip: dictEntry('RIP', 'RIP', 'rip'),
ez: dictEntry('轻松', '輕鬆', 'ez'),
diff: dictEntry('差距', '差距', 'diff'),
};
const PLACEHOLDER_PREFIX = '⟦T';
const PLACEHOLDER_SUFFIX = '⟧';
let dictionaryPattern = null;
function escapeRegExp(text) {
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function getDictionaryPattern() {
if (dictionaryPattern) return dictionaryPattern;
const keys = Object.keys(TWITCH_DICTIONARY).sort((a, b) => b.length - a.length);
const body = keys.map(escapeRegExp).join('|');
dictionaryPattern = new RegExp(`(? a.start - b.start || b.end - a.end);
const selected = [];
let cursor = 0;
for (const item of matches) {
if (item.start < cursor) continue;
selected.push(item);
cursor = item.end;
}
return selected;
}
function prepareDictionaryText(text, targetLang) {
const matches = collectDictionaryMatches(text);
if (matches.length === 0) {
return { preparedText: text, placeholders: [], fullyMapped: false };
}
const placeholders = [];
let preparedText = '';
let cursor = 0;
for (let i = 0; i < matches.length; i += 1) {
const { start, end, term } = matches[i];
preparedText += text.slice(cursor, start);
const token = `${PLACEHOLDER_PREFIX}${i}${PLACEHOLDER_SUFFIX}`;
const replacement = lookupDictionary(term, targetLang) || term;
placeholders.push({ token, replacement });
preparedText += token;
cursor = end;
}
preparedText += text.slice(cursor);
const withoutPlaceholders = preparedText.replace(
new RegExp(`${escapeRegExp(PLACEHOLDER_PREFIX)}\\d+${escapeRegExp(PLACEHOLDER_SUFFIX)}`, 'g'),
'',
);
const fullyMapped = matches.length > 0 && !withoutPlaceholders.trim();
return { preparedText, placeholders, fullyMapped };
}
function restoreDictionaryText(text, placeholders) {
if (!text || placeholders.length === 0) return text;
let restored = text;
for (const { token, replacement } of placeholders) {
restored = restored.split(token).join(replacement);
}
return restored;
}
const MS_LANG_MAP = {
'zh-CN': 'zh-Hans',
'zh-TW': 'zh-Hant',
en: 'en',
ja: 'ja',
ko: 'ko',
es: 'es',
fr: 'fr',
de: 'de',
ru: 'ru',
pt: 'pt',
};
const TENCENT_LANG_MAP = {
'zh-CN': 'zh',
'zh-TW': 'zh-TW',
en: 'en',
ja: 'ja',
ko: 'ko',
es: 'es',
fr: 'fr',
de: 'de',
ru: 'ru',
pt: 'pt',
};
const TRANSLATE_TIMEOUT_MS = 8000;
const TRANSLATE_MAX_BATCH = 10;
const TRANSLATE_MAX_BATCH_CHARS = 1600;
const PROVIDER_GAP_MS = 1000;
const PRIMARY_PROVIDERS = ['microsoft', 'google'];
const ALL_PROVIDERS = ['microsoft', 'google', 'volcengine', 'caiyun', 'tencent'];
const MAX_PROVIDER_ATTEMPTS = 2;
const HARD_COOLDOWNS_MS = [5000, 15000, 40000, 60000];
const SOFT_COOLDOWNS_MS = [2000, 5000, 15000, 30000];
const TRANSLATION_CACHE_LIMIT = 200;
const TRANSLATION_CACHE_TTL_MS = 10 * 60 * 1000;
const VOLC_LANG_MAP = {
'zh-CN': 'zh',
'zh-TW': 'zh-Hant',
en: 'en',
ja: 'ja',
ko: 'ko',
es: 'es',
fr: 'fr',
de: 'de',
ru: 'ru',
pt: 'pt',
};
const CAIYUN_LANG_MAP = {
'zh-CN': 'auto2zh',
'zh-TW': 'auto2zh-Hant',
en: 'auto2en',
ja: 'auto2ja',
ko: 'auto2ko',
es: 'auto2es',
fr: 'auto2fr',
de: 'auto2de',
ru: 'auto2ru',
pt: 'auto2pt',
};
const CAIYUN_TOKEN = '3975l6lr5pcbvidl6jl2';
const translationCache = new Map();
const providerState = {};
const unsupportedTargets = new Set();
const disabledProviders = new Set();
let msAuthCache = { token: '', expiresAt: 0, inflight: null };
let tencentClientKey = '';
let primaryCursor = 0;
for (const id of ALL_PROVIDERS) {
providerState[id] = { nextAt: 0, cooldownUntil: 0, failures: 0 };
}
function translationCacheKey(text, targetLang) {
return `${targetLang}\n${text}`;
}
function getCachedTranslation(text, targetLang) {
const key = translationCacheKey(text, targetLang);
const hit = translationCache.get(key);
if (!hit) return null;
if (Date.now() - hit.at > TRANSLATION_CACHE_TTL_MS) {
translationCache.delete(key);
return null;
}
translationCache.delete(key);
translationCache.set(key, hit);
return hit.value;
}
function setCachedTranslation(text, targetLang, value) {
if (!value) return;
const key = translationCacheKey(text, targetLang);
if (translationCache.has(key)) translationCache.delete(key);
translationCache.set(key, { value, at: Date.now() });
while (translationCache.size > TRANSLATION_CACHE_LIMIT) {
const oldest = translationCache.keys().next().value;
translationCache.delete(oldest);
}
}
function forgetCachedTranslation(text, targetLang) {
translationCache.delete(translationCacheKey(text, targetLang));
}
function gmRequest(options) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
timeout: TRANSLATE_TIMEOUT_MS,
...options,
onload: (res) => resolve(res),
onerror: () => reject(new Error('network error')),
ontimeout: () => reject(new Error('timeout')),
});
});
}
function isRateLimited(res) {
const status = Number(res?.status) || 0;
if (status === 429 || status === 403 || status === 503) return true;
const body = String(res?.responseText || '');
return /<\s*html/i.test(body) && /sorry|too many|rate limit|quota/i.test(body);
}
function parseJwtExp(token) {
try {
const payload = JSON.parse(atob(token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')));
return payload.exp * 1000;
} catch {
return Date.now() + 9 * 60 * 1000;
}
}
function toMicrosoftLang(code) {
return MS_LANG_MAP[code] || code;
}
function getTencentClientKey() {
if (!tencentClientKey) {
const uuid = crypto.randomUUID
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
tencentClientKey = `browser-chrome-122.0.0-Windows_10-${uuid}`;
}
return tencentClientKey;
}
function detectScript(text) {
if (/\p{Script=Hangul}/u.test(text)) return 'ko';
if (/[\u3040-\u30ff]/.test(text)) return 'ja';
if (/\p{Script=Cyrillic}/u.test(text)) return 'ru';
if (/\p{Script=Han}/u.test(text)) return 'zh';
if (/\p{Script=Arabic}/u.test(text)) return 'ar';
return 'latin';
}
async function getMicrosoftAuthToken() {
const now = Date.now();
if (msAuthCache.token && msAuthCache.expiresAt > now + 30000) {
return msAuthCache.token;
}
if (msAuthCache.inflight) return msAuthCache.inflight;
msAuthCache.inflight = (async () => {
try {
const res = await gmRequest({
method: 'GET',
url: 'https://edge.microsoft.com/translate/auth',
});
if (isRateLimited(res)) {
const error = new Error('auth rate limited');
error.rateLimited = true;
throw error;
}
const token = res.responseText?.trim();
if (!res.status || res.status >= 400 || !token) throw new Error('empty auth token');
msAuthCache.token = token;
msAuthCache.expiresAt = parseJwtExp(token);
return token;
} finally {
msAuthCache.inflight = null;
}
})();
return msAuthCache.inflight;
}
async function microsoftRequest(texts, targetLang) {
const token = await getMicrosoftAuthToken();
const url = `https://api-edge.cognitive.microsofttranslator.com/translate?api-version=3.0&to=${encodeURIComponent(targetLang)}`;
const res = await gmRequest({
method: 'POST',
url,
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: JSON.stringify(texts.map((text) => ({ Text: text }))),
});
if (res?.status === 401) return { ok: false, unauthorized: true, rateLimited: false };
if (isRateLimited(res)) return { ok: false, rateLimited: true };
if (!res || res.status !== 200) return { ok: false, rateLimited: false };
const json = JSON.parse(res.responseText);
if (!Array.isArray(json) || json.length !== texts.length) {
return { ok: false, rateLimited: false };
}
return {
ok: true,
texts: json.map((item) => item?.translations?.[0]?.text || ''),
};
}
async function microsoftTranslate(texts, targetLang) {
const to = toMicrosoftLang(targetLang);
let result = await microsoftRequest(texts, to);
if (result.unauthorized) {
msAuthCache.token = '';
msAuthCache.expiresAt = 0;
result = await microsoftRequest(texts, to);
}
if (result.unauthorized) return { ok: false, rateLimited: false };
return result;
}
function parseGoogleBatch(json, count) {
if (!Array.isArray(json) || json.length !== count) return null;
const texts = [];
for (const item of json) {
if (typeof item === 'string') {
texts.push(item);
continue;
}
if (Array.isArray(item) && typeof item[0] === 'string') {
texts.push(item[0]);
continue;
}
return null;
}
return texts;
}
async function googleTranslate(texts, targetLang) {
const query = texts.map((text) => `q=${encodeURIComponent(text)}`).join('&');
const url = `https://translate.googleapis.com/translate_a/t?client=dict-chrome-ex&sl=auto&tl=${encodeURIComponent(targetLang)}&${query}`;
const res = await gmRequest({ method: 'GET', url });
if (isRateLimited(res)) return { ok: false, rateLimited: true };
if (!res || res.status !== 200) return { ok: false, rateLimited: false };
const json = JSON.parse(res.responseText);
const parsed = parseGoogleBatch(json, texts.length);
if (!parsed) return { ok: false, rateLimited: false };
return { ok: true, texts: parsed };
}
async function tencentRequest(texts, targetLang) {
const res = await gmRequest({
method: 'POST',
url: 'https://transmart.qq.com/api/imt',
headers: {
'Content-Type': 'application/json',
Origin: 'https://transmart.qq.com',
Referer: 'https://transmart.qq.com/',
},
data: JSON.stringify({
header: {
fn: 'auto_translation',
client_key: getTencentClientKey(),
},
type: 'plain',
model_category: 'normal',
source: {
lang: 'auto',
text_list: texts,
},
target: { lang: targetLang },
}),
});
if (isRateLimited(res)) return { ok: false, rateLimited: true };
if (!res || res.status !== 200) return { ok: false, rateLimited: false };
const json = JSON.parse(res.responseText);
const code = json?.header?.ret_code;
if (code === 'succ' && Array.isArray(json.auto_translation) && json.auto_translation.length === texts.length) {
return {
ok: true,
texts: json.auto_translation.map((item) => (item == null ? '' : String(item))),
};
}
const message = String(json?.message || '');
if (/unrecognized target language/i.test(message)) {
return { ok: false, rateLimited: false, unsupported: true };
}
const rateLimited = code === 'Auth-Failed' || /frequent|limit|quota/i.test(message);
return { ok: false, rateLimited };
}
async function tencentTranslate(texts, targetLang) {
const to = TENCENT_LANG_MAP[targetLang];
if (!to) return { ok: false, rateLimited: false, unsupported: true };
if (unsupportedTargets.has(`tencent:${targetLang}`)) {
return { ok: false, rateLimited: false, unsupported: true };
}
const buckets = new Map();
texts.forEach((text, index) => {
const script = detectScript(text);
if (!buckets.has(script)) buckets.set(script, []);
buckets.get(script).push({ text, index });
});
const output = texts.map(() => '');
let translated = 0;
for (const items of buckets.values()) {
const result = await tencentRequest(items.map((item) => item.text), to);
if (result.unsupported) unsupportedTargets.add(`tencent:${targetLang}`);
if (!result.ok) {
if (!translated) return result;
break;
}
result.texts.forEach((value, index) => {
output[items[index].index] = value;
if (value && value.trim()) translated += 1;
});
}
if (!translated) return { ok: false, rateLimited: false };
return { ok: true, texts: output };
}
async function volcengineRequest(texts, targetLang) {
const res = await gmRequest({
method: 'POST',
url: 'https://translate.volcengine.com/crx/translate/v1/',
headers: { 'Content-Type': 'application/json' },
data: JSON.stringify({
target_language: targetLang,
text: texts.map((text) => text.replace(/\r?\n/g, ' ')).join('\n'),
}),
});
if (isRateLimited(res)) return { ok: false, rateLimited: true };
if (!res || res.status !== 200) return { ok: false, rateLimited: false };
const json = JSON.parse(res.responseText);
if (json?.base_resp && Number(json.base_resp.status_code) !== 0) {
const detail = `${json.base_resp.status_message || ''}`;
return { ok: false, rateLimited: /limit|quota|frequent/i.test(detail) };
}
const parts = String(json?.translation ?? '').split('\n');
if (parts.length !== texts.length) return { ok: false, rateLimited: false };
return { ok: true, texts: parts };
}
async function volcengineTranslate(texts, targetLang) {
const to = VOLC_LANG_MAP[targetLang];
if (!to) return { ok: false, rateLimited: false, unsupported: true };
const buckets = new Map();
texts.forEach((text, index) => {
const script = detectScript(text);
if (!buckets.has(script)) buckets.set(script, []);
buckets.get(script).push({ text, index });
});
const output = texts.map(() => '');
let translated = 0;
for (const items of buckets.values()) {
const result = await volcengineRequest(items.map((item) => item.text), to);
if (!result.ok) {
if (!translated) return result;
break;
}
result.texts.forEach((value, index) => {
output[items[index].index] = value;
if (value && value.trim()) translated += 1;
});
}
if (!translated) return { ok: false, rateLimited: false };
return { ok: true, texts: output };
}
async function caiyunTranslate(texts, targetLang) {
const transType = CAIYUN_LANG_MAP[targetLang];
if (!transType) return { ok: false, rateLimited: false, unsupported: true };
const res = await gmRequest({
method: 'POST',
url: 'https://api.interpreter.caiyunai.com/v1/translator',
headers: {
'Content-Type': 'application/json',
'X-Authorization': `token:${CAIYUN_TOKEN}`,
},
data: JSON.stringify({
source: texts,
trans_type: transType,
request_id: 'twitch-danmaku',
detect: true,
}),
});
if (res?.status === 401) return { ok: false, rateLimited: false, disabled: true };
if (res?.status === 409) return { ok: false, rateLimited: false, unsupported: true };
if (isRateLimited(res)) return { ok: false, rateLimited: true };
if (!res || res.status !== 200) return { ok: false, rateLimited: false };
const json = JSON.parse(res.responseText);
if (!Array.isArray(json?.target) || json.target.length !== texts.length) {
return { ok: false, rateLimited: false };
}
return {
ok: true,
texts: json.target.map((item) => (item == null ? '' : String(item))),
};
}
const PROVIDER_CALLS = {
microsoft: microsoftTranslate,
google: googleTranslate,
volcengine: volcengineTranslate,
caiyun: caiyunTranslate,
tencent: tencentTranslate,
};
function noteProviderFailure(id, rateLimited) {
const state = providerState[id];
state.failures += 1;
const table = rateLimited ? HARD_COOLDOWNS_MS : SOFT_COOLDOWNS_MS;
const delay = table[Math.min(state.failures - 1, table.length - 1)];
state.cooldownUntil = Date.now() + delay;
if (rateLimited) {
console.warn('[TBC] translation provider cooling down:', id);
}
}
function providerReadyAt(id) {
const state = providerState[id];
return Math.max(state.nextAt, state.cooldownUntil);
}
function waitForProviders(ids, failed) {
const now = Date.now();
const wait = ids.reduce((soonest, id) => Math.min(soonest, providerReadyAt(id) - now), Infinity);
return {
retryAfter: Math.max(50, Math.min(Number.isFinite(wait) ? wait : 1000, 60000)),
failed,
};
}
function autoSequence(ready) {
const primaryReady = PRIMARY_PROVIDERS.filter((id) => ready.includes(id));
if (!primaryReady.length) return ready.slice();
const id = primaryReady[primaryCursor % primaryReady.length];
primaryCursor = (primaryCursor + 1) % primaryReady.length;
return [id, ...ready.filter((candidate) => candidate !== id)];
}
function markProviderUsed(id, startedAt) {
const elapsed = Date.now() - startedAt;
providerState[id].nextAt = Date.now() + Math.max(0, PROVIDER_GAP_MS - elapsed);
}
function isPreviewPage() {
return typeof document !== 'undefined'
&& document.documentElement?.dataset?.tbcPreview === 'true';
}
function previewTranslateText(text, targetLang) {
const source = String(text || '').trim();
if (!source) return source;
const toZh = /^zh/i.test(String(targetLang || ''));
const hasHan = /[\u4e00-\u9fff]/.test(source);
if (toZh) {
if (hasHan) return source;
return `〔演示译文〕${source}`;
}
if (hasHan) return `[Demo TL] ${source}`;
return source;
}
async function translateBatch(texts, targetLang, preferred) {
if (!texts.length) return { texts: [] };
// Demo pages cannot call real translation APIs; return marked text so UI can be verified.
if (isPreviewPage()) {
return { texts: texts.map((text) => previewTranslateText(text, targetLang)) };
}
const order = preferred && preferred !== 'auto'
? [preferred, ...ALL_PROVIDERS.filter((id) => id !== preferred)]
: ALL_PROVIDERS.slice();
const supported = order.filter((id) => (
!disabledProviders.has(id) && !unsupportedTargets.has(`${id}:${targetLang}`)
));
const candidates = supported.length ? supported : order;
const now = Date.now();
if (preferred && preferred !== 'auto' && candidates[0] === preferred) {
const pinned = providerState[preferred];
if (pinned.cooldownUntil <= now && pinned.nextAt > now) {
return waitForProviders([preferred], false);
}
}
const usable = candidates.filter((id) => providerState[id].cooldownUntil <= now);
if (!usable.length) return waitForProviders(candidates, true);
const ready = usable.filter((id) => providerState[id].nextAt <= now);
if (!ready.length) return waitForProviders(usable, false);
const sequence = !preferred || preferred === 'auto'
? autoSequence(ready)
: [preferred, ...ready.filter((id) => id !== preferred)].filter((id) => ready.includes(id));
const flushStarted = Date.now();
const attemptLimit = Math.min(MAX_PROVIDER_ATTEMPTS, sequence.length);
for (let attempt = 0; attempt < attemptLimit; attempt += 1) {
if (attempt > 0 && Date.now() - flushStarted > TRANSLATE_TIMEOUT_MS) break;
const id = sequence[attempt];
const startedAt = Date.now();
let result;
try {
result = await PROVIDER_CALLS[id](texts, targetLang);
} catch (err) {
result = { ok: false, rateLimited: !!err?.rateLimited };
}
markProviderUsed(id, startedAt);
if (result?.disabled) {
disabledProviders.add(id);
continue;
}
if (result?.unsupported) unsupportedTargets.add(`${id}:${targetLang}`);
if (result?.ok && Array.isArray(result.texts) && result.texts.length === texts.length) {
providerState[id].failures = 0;
return { texts: result.texts };
}
noteProviderFailure(id, !!result?.rateLimited);
}
return { retryAfter: 800, failed: true };
}
const TOOLTIP_ORIGINAL_MAX = 64;
function shouldSkipTranslation(text) {
const trimmed = text.trim();
if (!trimmed) return true;
if (/^[\d\s+\-*/%=^#@&|<>[\]{}\\]+$/u.test(trimmed)) return true;
if (/^[\p{P}\p{S}\s\d]+$/u.test(trimmed)) return true;
return false;
}
function translatableCore(text) {
if (!text) return '';
const core = String(text).trim();
if (!core || core.length > 500 || shouldSkipTranslation(core)) return '';
return core;
}
function needsMachineTranslation(text) {
const stripped = text.replace(/⟦T\d+⟧/g, '').trim();
return !!stripped;
}
function truncateText(text, max = TOOLTIP_ORIGINAL_MAX) {
if (!text || text.length <= max) return text;
return `${text.slice(0, max - 1)}…`;
}
function preparedGroupKey(preparedText, placeholders) {
return `${preparedText}\0${placeholders.map((item) => item.replacement).join('\0')}`;
}
async function translateMessages(texts, targetLang, provider = 'microsoft') {
const results = new Array(texts.length);
const pending = [];
texts.forEach((text, index) => {
const cached = getCachedTranslation(text, targetLang);
if (cached != null) {
results[index] = cached;
return;
}
if (shouldSkipTranslation(text) || text.length > 500) {
results[index] = text;
return;
}
const prepared = prepareDictionaryText(text, targetLang);
if (prepared.fullyMapped || !needsMachineTranslation(prepared.preparedText)) {
results[index] = restoreDictionaryText(prepared.preparedText, prepared.placeholders);
return;
}
pending.push({
index,
original: text,
preparedText: prepared.preparedText,
placeholders: prepared.placeholders,
});
});
if (!pending.length) return { texts: results };
const groups = new Map();
for (const item of pending) {
const key = preparedGroupKey(item.preparedText, item.placeholders);
if (!groups.has(key)) {
groups.set(key, { preparedText: item.preparedText, items: [] });
}
groups.get(key).items.push(item);
}
const unique = [...groups.values()];
const outcome = await translateBatch(
unique.map((group) => group.preparedText),
targetLang,
provider,
);
if (outcome.retryAfter) return outcome;
unique.forEach((group, groupIndex) => {
const machine = outcome.texts[groupIndex]?.trim()
? outcome.texts[groupIndex]
: group.preparedText;
const cacheable = !!outcome.texts[groupIndex]?.trim();
for (const item of group.items) {
const restored = restoreDictionaryText(machine, item.placeholders)?.trim() || item.original;
results[item.index] = restored;
if (cacheable) setCachedTranslation(item.original, targetLang, restored);
}
});
return { texts: results };
}
const TRANSLATE_SINGLE_GRACE_MS = 220;
const TRANSLATE_BURST_WINDOW_MS = 1000;
const TRANSLATE_GIVE_UP_ATTEMPTS = 2;
class TranslateQueue {
constructor(getSettings, isActive) {
this.getSettings = getSettings;
this.isActive = isActive;
this.queue = [];
this.inflight = false;
this.timer = null;
this.timerDelay = null;
this.epoch = 0;
this.nextSlotAt = 0;
this.collectStartedAt = 0;
}
currentSpeed() {
return clamp(Number(this.getSettings().translateSpeed) || 1, 1, 10);
}
enqueue(text, parts, callback) {
if (!this.isActive()) return;
const settings = this.getSettings();
if (!settings.translateEnabled) return;
while (this.queue.length >= MAX_TRANSLATE_QUEUE) {
this.queue.shift();
}
if (!this.collectStartedAt) this.collectStartedAt = Date.now();
this.queue.push({
text,
parts: parts?.length ? parts : [{ type: 'text', value: text }],
callback,
attempts: 0,
});
this.schedule();
}
clearTimer() {
if (this.timer) clearTimeout(this.timer);
this.timer = null;
this.timerDelay = null;
}
armTimer(delay) {
this.clearTimer();
this.timerDelay = delay;
this.timer = setTimeout(() => {
this.timer = null;
this.timerDelay = null;
this.flush();
}, delay);
}
schedule() {
if (this.inflight || !this.isActive()) return;
if (!this.getSettings().translateEnabled) return;
if (this.queue.length === 0) {
this.collectStartedAt = 0;
this.clearTimer();
return;
}
const delay = this.computeDelay();
if (delay === 0 && this.timer && this.timerDelay === 0) return;
this.armTimer(delay);
}
computeDelay() {
const now = Date.now();
const speed = this.currentSpeed();
const slotWait = Math.max(0, this.nextSlotAt - now);
const started = this.collectStartedAt || now;
if (this.queue.length >= speed) return slotWait;
const grace = speed <= 1 || this.queue.length < 2
? (speed <= 1 ? 0 : TRANSLATE_SINGLE_GRACE_MS)
: TRANSLATE_BURST_WINDOW_MS;
const collectWait = Math.max(0, started + grace - now);
return Math.max(slotWait, collectWait);
}
pieceCount(parts) {
return parts.reduce((count, part) => (
part.type === 'text' && translatableCore(part.value) ? count + 1 : count
), 0);
}
takeJobs(speed) {
const jobs = [];
let chars = 0;
let pieces = 0;
while (this.queue.length && jobs.length < Math.min(speed, TRANSLATE_MAX_BATCH)) {
const next = this.queue[0];
const nextPieces = this.pieceCount(next.parts);
if (jobs.length > 0 && (
chars + next.text.length > TRANSLATE_MAX_BATCH_CHARS
|| pieces + nextPieces > TRANSLATE_MAX_BATCH
)) break;
chars += next.text.length;
pieces += nextPieces;
jobs.push(this.queue.shift());
}
return jobs;
}
deliver(jobs, partLists) {
if (!this.isActive()) return;
jobs.forEach((job, index) => {
const parts = partLists[index] || job.parts;
try {
job.callback(parts, job.text);
} catch {
/* ignore */
}
});
}
stitch(jobs, translations, refs) {
const outputs = jobs.map((job) => job.parts.map((part) => ({ ...part })));
refs.forEach((ref, index) => {
const part = outputs[ref.jobIndex][ref.partIndex];
const match = String(part.value).match(/^(\s*)([\s\S]*?)(\s*)$/);
const translated = translations[index] || match[2];
outputs[ref.jobIndex][ref.partIndex] = {
type: 'text',
value: `${match[1]}${translated}${match[3]}`,
};
});
return outputs;
}
pace(count, speed, startedAt) {
const spent = Date.now() - startedAt;
const budget = Math.round(count * (1000 / speed));
const nextAt = Date.now() + Math.max(0, budget - spent);
this.nextSlotAt = Math.max(this.nextSlotAt, nextAt);
}
async flush() {
if (this.inflight || this.queue.length === 0 || !this.isActive()) {
this.schedule();
return;
}
const settings = this.getSettings();
if (!settings.translateEnabled) return;
const epoch = this.epoch;
const speed = this.currentSpeed();
const jobs = this.takeJobs(speed);
if (!jobs.length) return;
this.collectStartedAt = this.queue.length ? Date.now() : 0;
this.inflight = true;
const startedAt = Date.now();
const refs = [];
const pieces = [];
jobs.forEach((job, jobIndex) => {
job.parts.forEach((part, partIndex) => {
const core = part.type === 'text' ? translatableCore(part.value) : '';
if (!core) return;
refs.push({ jobIndex, partIndex });
pieces.push(core);
});
});
try {
if (!pieces.length) {
this.deliver(jobs, jobs.map((job) => job.parts));
this.pace(jobs.length, speed, startedAt);
return;
}
const outcome = await translateMessages(
pieces,
settings.translateTargetLang,
settings.translateProvider,
);
if (epoch !== this.epoch || !this.isActive()) return;
if (outcome.retryAfter) {
const failed = !!outcome.failed;
if (failed) {
for (const job of jobs) job.attempts += 1;
}
const exhausted = failed && jobs.every((job) => job.attempts >= TRANSLATE_GIVE_UP_ATTEMPTS);
const blocked = failed && outcome.retryAfter > 1500;
if (exhausted || blocked) {
this.deliver(jobs, jobs.map((job) => job.parts));
this.pace(jobs.length, speed, startedAt);
} else {
this.queue = jobs.concat(this.queue);
while (this.queue.length > MAX_TRANSLATE_QUEUE) this.queue.pop();
this.collectStartedAt = Date.now() - TRANSLATE_BURST_WINDOW_MS;
this.nextSlotAt = Math.max(this.nextSlotAt, Date.now() + outcome.retryAfter);
}
return;
}
this.deliver(jobs, this.stitch(jobs, outcome.texts || [], refs));
this.pace(jobs.length, speed, startedAt);
} catch {
if (epoch !== this.epoch || !this.isActive()) return;
this.deliver(jobs, jobs.map((job) => job.parts));
this.pace(jobs.length, speed, startedAt);
} finally {
if (epoch === this.epoch) this.inflight = false;
if (this.queue.length && epoch === this.epoch) this.schedule();
}
}
clear() {
this.epoch += 1;
this.queue = [];
this.collectStartedAt = 0;
this.nextSlotAt = 0;
this.clearTimer();
this.inflight = false;
}
}
const MAX_REPEAT_SEGMENTS = 3;
function dedupeRepeatedMessage(text) {
const trimmed = text.trim();
if (!trimmed) return text;
const tokens = trimmed.split(/\s+/);
if (tokens.length <= MAX_REPEAT_SEGMENTS) return text;
const maxUnitLen = Math.floor(tokens.length / (MAX_REPEAT_SEGMENTS + 1));
for (let unitLen = 1; unitLen <= maxUnitLen; unitLen += 1) {
if (tokens.length % unitLen !== 0) continue;
const repeatCount = tokens.length / unitLen;
if (repeatCount <= MAX_REPEAT_SEGMENTS) continue;
const unit = tokens.slice(0, unitLen);
let matched = true;
for (let i = 1; i < repeatCount; i += 1) {
for (let j = 0; j < unitLen; j += 1) {
if (tokens[i * unitLen + j] !== unit[j]) {
matched = false;
break;
}
}
if (!matched) break;
}
if (matched) {
const kept = [];
for (let i = 0; i < MAX_REPEAT_SEGMENTS; i += 1) {
kept.push(...unit);
}
return kept.join(' ');
}
}
return text;
}
function extractEmoteName(node) {
if (!node) return '';
return node.getAttribute?.('data-emote-name')
|| node.alt
|| node.getAttribute?.('title')
|| node.dataset?.alt
|| '';
}
function pickEmoteSrc(img) {
const srcset = img.getAttribute('srcset') || '';
const candidates = srcset.split(',')
.map((item) => item.trim().split(/\s+/)[0])
.filter(Boolean);
const preferred = candidates.find((url) => /\/2\.0(?:$|[?#])/.test(url))
|| candidates[candidates.length - 1]
|| img.getAttribute('src')
|| '';
try {
const url = new URL(preferred, location.href);
if (url.protocol !== 'https:') return '';
url.hash = '';
return url.href;
} catch {
return '';
}
}
function partsToPlain(parts) {
let out = '';
for (const part of parts) {
if (part.type === 'emote') {
if (out && !/\s$/.test(out)) out += ' ';
out += part.name || '';
continue;
}
out += part.value || '';
}
return out.trim();
}
function mergeTextParts(parts) {
const merged = [];
for (const part of parts) {
if (part.type === 'text') {
if (!part.value) continue;
const last = merged[merged.length - 1];
if (last?.type === 'text') last.value += part.value;
else merged.push({ type: 'text', value: part.value });
continue;
}
if (part.name || part.src) merged.push(part);
}
return merged;
}
function dedupeParts(parts) {
const units = [];
for (const part of parts) {
if (part.type === 'emote') {
units.push(part.name || '');
continue;
}
const words = String(part.value || '').trim().split(/\s+/).filter(Boolean);
units.push(...words);
}
const original = units.join(' ');
const deduped = dedupeRepeatedMessage(original);
if (!original || deduped === original) return mergeTextParts(parts);
const kept = deduped.split(/\s+/).filter(Boolean);
const rebuilt = [];
let cursor = 0;
const visit = (part) => {
if (cursor >= kept.length) return;
if (part.type === 'emote') {
if ((part.name || '') === kept[cursor]) {
if (rebuilt.length) rebuilt.push({ type: 'text', value: ' ' });
rebuilt.push(part);
cursor += 1;
}
return;
}
const words = String(part.value || '').trim().split(/\s+/).filter(Boolean);
words.forEach((word) => {
if (cursor >= kept.length || word !== kept[cursor]) return;
if (rebuilt.length) rebuilt.push({ type: 'text', value: ' ' });
rebuilt.push({ type: 'text', value: word });
cursor += 1;
});
};
parts.forEach(visit);
return cursor === kept.length ? mergeTextParts(rebuilt) : mergeTextParts(parts);
}
function extractMessageParts(msgEl) {
const root = msgEl.querySelector(SELECTORS.chatMessageBody);
if (!root) return [];
const parts = [];
const pushText = (text) => {
if (!text) return;
const last = parts[parts.length - 1];
if (last?.type === 'text') last.value += text;
else parts.push({ type: 'text', value: text });
};
const pushEmote = (img) => {
const name = extractEmoteName(img).trim();
const src = pickEmoteSrc(img);
if (!name && !src) return;
parts.push({ type: 'emote', name, src });
};
const walk = (node) => {
if (node.nodeType === Node.TEXT_NODE) {
pushText(node.textContent);
return;
}
if (node.nodeType !== Node.ELEMENT_NODE) return;
if (node.matches('.chat-badge, [data-a-target="chat-badge"], .chat-line__icons, .chat-line__username, [data-a-target="chat-message-username"], .video-chat__message-author, .vod-message__header, .video-chat__message-menu')) {
return;
}
// VOD chat puts a lone ":" separator before the message body text.
if (!node.querySelector('img, [data-a-target="emote-name"], [data-a-target="chat-message-text"]')
&& node.textContent?.trim() === ':') {
return;
}
if (node.tagName === 'IMG') {
if (node.classList.contains('chat-badge') || node.closest('[data-a-target="chat-badge"]')) return;
pushEmote(node);
return;
}
if (node.matches('.chat-line__message--emote-button, [data-a-target="emote-name"]')) {
const img = node.querySelector('img');
if (img) pushEmote(img);
return;
}
for (const child of node.childNodes) walk(child);
};
for (const child of root.childNodes) walk(child);
return dedupeParts(parts);
}
function usableCssColor(value) {
if (!value || value === 'transparent' || value === 'rgba(0, 0, 0, 0)') return '';
return value;
}
function isChatBadgeImage(img) {
return !!img?.closest?.('.chat-badge, [data-a-target="chat-badge"], .chat-line__icons');
}
function bitsFromText(text) {
const source = String(text || '');
if (!source) return 0;
const matcher = new RegExp(BITS_TOKEN_PATTERN, 'gi');
let total = 0;
let match = matcher.exec(source);
while (match) {
total += Number(match[1]) || 0;
match = matcher.exec(source);
}
return total;
}
function bitsFromImageAsset(img) {
if (!img || isChatBadgeImage(img)) return 0;
const alt = img.getAttribute('alt') || '';
const emoteName = img.getAttribute('data-emote-name') || img.getAttribute('title') || '';
const labeled = `${alt} ${emoteName}`.trim();
const fromLabel = bitsFromText(labeled) || bitsFromText(` ${labeled}`);
if (fromLabel > 0) return fromLabel;
const src = img.getAttribute('src') || img.getAttribute('srcset') || '';
// cloudfront actions/.../tier/scale | static-cdn bits/{theme}/{type}/{prefix}/{tier}
const fromSrc = src.match(/\/actions\/[^/\s]+\/(?:dark|light)\/(?:animated|static)\/(\d+)\//i)
|| src.match(/\/bits\/(?:dark|light)\/(?:animated|static)\/[^/\s]+\/(\d+)(?:\/|[.?#]|$)/i)
|| src.match(/\/bits\/[^/\s]+\/[^/\s]+\/(?:dark|light)\/(?:animated|static)\/(\d+)\//i);
return fromSrc ? (Number(fromSrc[1]) || 0) : 0;
}
function hasCheer(msgEl) {
if (!msgEl) return false;
const root = msgEl.querySelector(SELECTORS.chatMessageBody) || msgEl;
if (bitsFromText(root.textContent || '') > 0) return true;
return [...root.querySelectorAll('img')].some((img) => bitsFromImageAsset(img) > 0);
}
function paidBackground(msgEl) {
const nodes = [msgEl, msgEl.querySelector('.chat-line__message-container')].filter(Boolean);
for (const node of nodes) {
const color = usableCssColor(node.style?.backgroundColor);
if (color) return color;
const styled = node.querySelector?.('[style*="background"]');
const nested = usableCssColor(styled?.style?.backgroundColor);
if (nested) return nested;
}
return '';
}
function cheerTierStyle(bits) {
// Twitch cheermote tiers: 1 gray, 100 purple, 1000 teal, 5000 blue, 10000 red, 100000 gold.
// Bits card presets like 300/1000/5000/10000 are amounts, not separate color thresholds.
const amount = Math.max(0, Number(bits) || 0);
if (amount >= 100000) {
return { background: 'linear-gradient(135deg, #ffb400 0%, #ff7300 100%)', color: '#1a1200', accent: '#ffb400' };
}
if (amount >= 10000) {
return { background: 'linear-gradient(135deg, #f43021 0%, #ff6b6b 100%)', color: '#ffffff', accent: '#f43021' };
}
if (amount >= 5000) {
return { background: 'linear-gradient(135deg, #0099fe 0%, #00d4ff 100%)', color: '#ffffff', accent: '#0099fe' };
}
if (amount >= 1000) {
return { background: 'linear-gradient(135deg, #1db2a5 0%, #24dbcc 100%)', color: '#ffffff', accent: '#1db2a5' };
}
if (amount >= 100) {
return { background: 'linear-gradient(135deg, #9c3ee8 0%, #bf94ff 100%)', color: '#ffffff', accent: '#9c3ee8' };
}
return { background: 'linear-gradient(135deg, #7a7a7a 0%, #9b9b9b 100%)', color: '#ffffff', accent: '#c5c5c5' };
}
function subscribeStyle() {
return { background: 'linear-gradient(135deg, #8205b4 0%, #c13cff 100%)', color: '#ffffff', accent: '#bf94ff' };
}
function extractBitsAmount(msgEl, message = '', parts = []) {
let total = bitsFromText(message);
if (total > 0) return total;
// Emote names in parsed parts (Cheer300 / Kappa100 / partnerFooCheer1000).
total = bitsFromText(
(Array.isArray(parts) ? parts : [])
.map((part) => (part?.type === 'emote' ? part.name : part?.value) || '')
.join(' '),
);
if (total > 0) return total;
if (!msgEl) return 0;
const root = msgEl.querySelector(SELECTORS.chatMessageBody) || msgEl;
for (const img of root.querySelectorAll('img')) {
total += bitsFromImageAsset(img);
}
return total;
}
function isSubscribeNotice(msgEl) {
if (!msgEl) return false;
if (msgEl.closest('.user-notice-line, [class*="user-notice"], [data-a-target*="user-notice"], [data-test-selector*="user-notice"]')) {
return true;
}
if (msgEl.querySelector('.user-notice-line, [data-a-target="user-notice-line"], [data-a-target*="sub-message"]')) {
return true;
}
const text = (msgEl.textContent || '').replace(/\s+/g, ' ');
return /subscribed|resubscribed|gifted\s+\d+\s+sub|is gifting|gift sub|prime gaming|订阅|訂閱|ギフト/i.test(text);
}
function readChatAvatarUrl(msgEl) {
const img = msgEl.querySelector(
'img.chat-line__message--avatar, img.chatter-avatar, .chat-author__image img, img[src*="jtv_user_pictures"], img[src*="user-default-pictures"]',
);
if (!img) return '';
try {
const url = new URL(img.getAttribute('src') || '', location.href);
if (url.protocol !== 'https:') return '';
url.hash = '';
return url.href;
} catch {
return '';
}
}
function readSuperchatMeta(msgEl, message = '', parts = []) {
const pinned = !!msgEl.closest('[data-test-selector="pinned-chat"], [data-test-selector="pinned-message"], [class*="pinned-chat"], [class*="paid-pinned"]');
const bits = extractBitsAmount(msgEl, message, parts);
const cheer = bits > 0 || hasCheer(msgEl);
const subscribe = isSubscribeNotice(msgEl);
const background = paidBackground(msgEl);
if (!cheer && !pinned && !background && !subscribe && bits <= 0) return null;
let kind = 'pin';
let style;
let amountLabel = '';
if (bits > 0 || cheer) {
kind = 'cheer';
style = cheerTierStyle(bits || 1);
amountLabel = `${bits || 1} Bits`;
} else if (subscribe) {
kind = 'sub';
style = subscribeStyle();
amountLabel = 'Sub';
} else {
style = {
background: background || '#9147ff',
color: '#ffffff',
accent: '#bf94ff',
};
}
if (background && kind === 'pin') {
style = { ...style, background };
}
const textNode = msgEl.querySelector('[data-a-target="chat-message-text"], .text-fragment');
const color = usableCssColor(textNode && getComputedStyle(textNode).color) || style.color;
return {
kind,
bits: bits || 0,
amountLabel,
avatarUrl: readChatAvatarUrl(msgEl),
style: {
background: style.background,
color: color || style.color,
accent: style.accent || '#bf94ff',
},
};
}
function readSuperchatStyle(msgEl) {
return readSuperchatMeta(msgEl)?.style || null;
}
function snapshotChatMessage(msgEl) {
if (!msgEl) return null;
const parts = extractMessageParts(msgEl);
let message = partsToPlain(parts);
const usernameEl = msgEl.querySelector(SELECTORS.chatUsername);
const username = usernameEl?.textContent?.trim() || '';
const login = (
msgEl.getAttribute('data-a-user')
|| usernameEl?.getAttribute('data-a-user')
|| username
).trim().toLowerCase();
// System / notice lines may put text outside the normal body selector.
if (!message) {
const notice = msgEl.querySelector('[data-a-target="chat-line-message-body"], .chat-line__message-body, .user-notice-line');
message = (notice?.textContent || msgEl.textContent || '').replace(/\s+/g, ' ').trim();
}
if (!message && !isSubscribeNotice(msgEl)) return null;
const finalParts = parts.length ? parts : (message ? [{ type: 'text', value: message }] : []);
const meta = readSuperchatMeta(msgEl, message, finalParts);
return {
message,
parts: finalParts,
username: username || login,
login,
usernameColor: getUsernameColor(msgEl),
profileUrl: getTwitchProfileUrl(usernameEl, username || login),
avatarUrl: meta?.avatarUrl || readChatAvatarUrl(msgEl),
superchat: !!meta,
superchatStyle: meta?.style || null,
superchatMeta: meta,
};
}
function parseChatMessage(msgEl) {
if (msgEl.classList.contains('tbc-processed')) return null;
if (msgEl.closest('.tbc-processed')) return null;
const snapshot = snapshotChatMessage(msgEl);
if (!snapshot) return null;
msgEl.classList.add('tbc-processed');
return snapshot;
}
function markExistingMessages(chatRoot) {
chatRoot.querySelectorAll(SELECTORS.chatMessage).forEach((el) => {
el.classList.add('tbc-processed');
});
}
function isLiveMessage(msgEl, chatRoot) {
const messages = chatRoot.querySelectorAll(SELECTORS.chatMessage);
if (!messages.length) return true;
const tailStart = Math.max(0, messages.length - LIVE_MESSAGE_TAIL);
for (let i = tailStart; i < messages.length; i++) {
const node = messages[i];
if (node === msgEl || node.contains(msgEl) || msgEl.contains(node)) {
return true;
}
}
return false;
}
function clearChatTranslations(root = document) {
root.querySelectorAll?.('.tbc-chat-translation').forEach((node) => node.remove());
}
function removeChatTranslation(msgEl) {
msgEl?.querySelectorAll?.('.tbc-chat-translation').forEach((node) => node.remove());
}
function normalizeCompareText(text) {
return String(text || '').replace(/\s+/g, ' ').trim().toLowerCase();
}
function applyChatTranslation(msgEl, translatedText, originalText) {
if (!msgEl?.isConnected) return false;
const translated = String(translatedText || '').replace(/\s+/g, ' ').trim();
if (!translated) {
removeChatTranslation(msgEl);
return false;
}
if (normalizeCompareText(translated) === normalizeCompareText(originalText)) {
removeChatTranslation(msgEl);
return false;
}
let row = msgEl.querySelector(':scope > .tbc-chat-translation');
if (!row) {
row = msgEl.querySelector('.tbc-chat-translation');
}
if (!row) {
row = document.createElement('div');
row.className = 'tbc-chat-translation';
row.setAttribute('data-a-target', 'tbc-chat-translation');
const badge = document.createElement('span');
badge.className = 'tbc-chat-translation-badge';
badge.textContent = t('translateBadge');
const text = document.createElement('span');
text.className = 'tbc-chat-translation-text';
row.append(badge, text);
const body = msgEl.querySelector(SELECTORS.chatMessageBody);
if (body?.parentElement) {
body.insertAdjacentElement('afterend', row);
} else {
msgEl.appendChild(row);
}
}
const textEl = row.querySelector('.tbc-chat-translation-text');
if (textEl) textEl.textContent = translated;
const badgeEl = row.querySelector('.tbc-chat-translation-badge');
if (badgeEl) badgeEl.textContent = t('translateBadge');
return true;
}
const TWITCH_GQL_URL = 'https://gql.twitch.tv/gql';
const TWITCH_GQL_CLIENT_ID = 'kd1unb4b3q4t58fwlpcbzcbnm76a8fp';
const VOD_COMMENTS_HASH = 'b70a3591ff0f4e0313d126c6a1502d79a1c02baebb288227c582044aa76adf6a';
const LIVE_CHAT_CONTAINER = '[data-test-selector="chat-scrollable-area__message-container"], .chat-scrollable-area__message-container';
const LIVE_CHAT_MESSAGE = '.chat-line__message, [data-a-target="chat-line-message"], [data-a-target="chat-message-container"]';
const CHANNEL_PATH_BLOCKLIST = /^(videos?|directory|settings|subscriptions|inventory|wallet|downloads|search|prime|popout|turbo|products|drops|u|p)$/i;
function historyError(code, message) {
const error = new Error(message || code);
error.code = code;
return error;
}
function currentVodId() {
const demoId = typeof window !== 'undefined' ? window.__TBC_DEMO_VOD_ID__ : '';
if (demoId) return String(demoId).replace(/\D/g, '');
const match = location.pathname.match(/\/(?:videos|video)\/(\d+)/i);
return match ? match[1] : '';
}
function currentChannelLogin() {
const demoLogin = typeof window !== 'undefined' ? window.__TBC_DEMO_CHANNEL__ : '';
if (demoLogin) return String(demoLogin).trim().toLowerCase();
const parts = location.pathname.split('/').filter(Boolean);
if (!parts.length) return '';
const first = parts[0].toLowerCase();
if (CHANNEL_PATH_BLOCKLIST.test(first)) return '';
if (first === 'moderator' || first === 'embed') return (parts[1] || '').toLowerCase();
return first;
}
function findLiveChatContainer() {
return document.querySelector(LIVE_CHAT_CONTAINER);
}
function readLiveChannelInfo() {
const login = currentChannelLogin();
if (!login || currentVodId()) return null;
const hasDomChat = !!findLiveChatContainer();
const ffz = typeof detectFrankerFaceZ === 'function' && detectFrankerFaceZ();
if (!hasDomChat && !ffz) return null;
const titleEl = document.querySelector('[data-a-target="stream-title"], h2[data-a-target="stream-title"]');
const nameEl = document.querySelector('[data-a-target="user-channel-header-name"]');
const title = titleEl?.textContent?.trim()
|| document.title.replace(/\s*[-–|]\s*Twitch\s*$/i, '').trim();
return {
id: `live-${login}`,
title: title || login,
createdAt: '',
lengthSeconds: 0,
channel: nameEl?.textContent?.trim() || login,
login,
live: true,
source: ffz && !hasDomChat ? 'live-irc' : 'live-dom',
};
}
function playerDurationSeconds() {
const video = document.querySelector('video');
const duration = Number(video?.duration);
if (!Number.isFinite(duration) || duration <= 0 || duration === Infinity) return 0;
return Math.floor(duration);
}
function formatChatClock(seconds) {
const total = Math.max(0, Math.floor(Number(seconds) || 0));
const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60);
const secs = total % 60;
const pad = (value) => String(value).padStart(2, '0');
return hours > 0 ? `${hours}:${pad(minutes)}:${pad(secs)}` : `${minutes}:${pad(secs)}`;
}
function fileClock(seconds) {
const total = Math.max(0, Math.floor(Number(seconds) || 0));
const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60);
const secs = total % 60;
const pad = (value) => String(value).padStart(2, '0');
return `${pad(hours)}h${pad(minutes)}m${pad(secs)}s`;
}
function splitFilterTerms(text) {
return String(text || '')
.split(/[,,]/)
.map((part) => part.trim().toLowerCase())
.filter(Boolean);
}
function twitchGql(body) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'POST',
url: TWITCH_GQL_URL,
headers: {
'Client-ID': TWITCH_GQL_CLIENT_ID,
'Content-Type': 'application/json',
},
data: JSON.stringify(body),
timeout: 20000,
onload: (res) => {
const status = Number(res?.status) || 0;
if (status < 200 || status >= 300) {
reject(historyError('failed', `HTTP ${status}`));
return;
}
try {
resolve(JSON.parse(res.responseText));
} catch {
reject(historyError('failed', 'invalid json'));
}
},
onerror: () => reject(historyError('failed', 'network')),
ontimeout: () => reject(historyError('failed', 'timeout')),
});
});
}
function waitMs(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function waitWhilePaused(shouldPause, shouldCancel) {
while (shouldPause?.()) {
if (shouldCancel?.()) throw historyError('cancel');
await waitMs(120);
}
if (shouldCancel?.()) throw historyError('cancel');
}
async function fetchVodInfo(videoId) {
const id = String(videoId || '').replace(/\D/g, '');
if (!id) return null;
const payload = await twitchGql({
query: `query{video(id:"${id}"){title,createdAt,lengthSeconds,owner{displayName,login}}}`,
});
const video = payload?.data?.video;
if (!video) return null;
return {
id,
title: video.title || '',
createdAt: video.createdAt || '',
lengthSeconds: Number(video.lengthSeconds) || 0,
channel: video.owner?.displayName || video.owner?.login || '',
login: video.owner?.login || '',
};
}
function commentText(node) {
const fragments = node?.message?.fragments;
if (!Array.isArray(fragments)) return '';
return fragments.map((fragment) => fragment?.text || '').join('');
}
function commentBits(node, text) {
const spent = Number(node?.message?.bitsSpent);
if (Number.isFinite(spent) && spent > 0) return spent;
return bitsFromText(text);
}
function normalizeVodComment(node) {
const message = commentText(node).replace(/\s+/g, ' ').trim();
const bits = commentBits(node, message);
return {
id: node.id,
offsetSeconds: Number(node.contentOffsetSeconds) || 0,
createdAt: node.createdAt || '',
userId: node.commenter?.id || '',
login: node.commenter?.login || '',
displayName: (node.commenter?.displayName || node.commenter?.login || '').trim(),
message,
color: node.message?.userColor || '',
bits,
superchat: bits > 0,
};
}
function commentMatchesFilters(comment, keywords, usernames, includeSuperchat) {
if (!includeSuperchat && comment.superchat) return false;
if (keywords.length) {
const haystack = comment.message.toLowerCase();
if (!keywords.some((keyword) => haystack.includes(keyword))) return false;
}
if (usernames.length) {
const login = comment.login.toLowerCase();
const displayName = comment.displayName.toLowerCase();
if (!usernames.some((name) => login.includes(name) || displayName.includes(name))) return false;
}
return true;
}
function normalizeDomComment(msgEl, index) {
const parts = extractMessageParts(msgEl);
const message = partsToPlain(parts).replace(/\s+/g, ' ').trim();
if (!message) return null;
const usernameEl = msgEl.querySelector(SELECTORS.chatUsername);
const displayName = usernameEl?.textContent?.trim() || '';
const login = (
msgEl.getAttribute('data-a-user')
|| usernameEl?.getAttribute('data-a-user')
|| displayName
).trim().toLowerCase();
if (!login && !displayName) return null;
const bits = extractBitsAmount(msgEl, message, parts);
const superchatStyle = readSuperchatStyle(msgEl);
const superchat = bits > 0 || !!superchatStyle;
const id = msgEl.id || `live-${index}-${login}-${message.slice(0, 32)}`;
return {
id,
offsetSeconds: index,
createdAt: '',
userId: '',
login,
displayName: displayName || login,
message,
color: getUsernameColor(msgEl),
bits,
superchat,
};
}
async function collectLiveIrcChat({
comments: sourceComments = [],
keywords = '',
usernames = '',
includeSuperchat = true,
onProgress,
shouldCancel,
shouldPause,
}) {
const keywordTerms = splitFilterTerms(keywords);
const usernameTerms = splitFilterTerms(usernames);
const comments = [];
let scanned = 0;
for (let i = 0; i < sourceComments.length; i++) {
if (i % 40 === 0) {
await waitWhilePaused(shouldPause, shouldCancel);
}
const comment = sourceComments[i];
if (!comment?.message) continue;
scanned += 1;
if (!commentMatchesFilters(comment, keywordTerms, usernameTerms, includeSuperchat)) continue;
comments.push(comment);
if (comments.length % 20 === 0) {
onProgress?.({ count: comments.length, scanned, offset: i, start: 0, end: sourceComments.length });
}
}
onProgress?.({
count: comments.length,
scanned,
offset: sourceComments.length,
start: 0,
end: sourceComments.length,
});
return { comments, scanned, start: 0, end: comments.length ? comments.length - 1 : 0, source: 'live-irc' };
}
async function collectLiveDomChat({
keywords = '',
usernames = '',
includeSuperchat = true,
onProgress,
shouldCancel,
shouldPause,
ircComments = null,
}) {
if (Array.isArray(ircComments)) {
return collectLiveIrcChat({
comments: ircComments,
keywords,
usernames,
includeSuperchat,
onProgress,
shouldCancel,
shouldPause,
});
}
const container = findLiveChatContainer();
if (!container) throw historyError('missing');
const keywordTerms = splitFilterTerms(keywords);
const usernameTerms = splitFilterTerms(usernames);
const nodes = [...container.querySelectorAll(LIVE_CHAT_MESSAGE)];
const comments = [];
const seen = new Set();
let scanned = 0;
for (let i = 0; i < nodes.length; i++) {
if (i % 40 === 0) {
await waitWhilePaused(shouldPause, shouldCancel);
}
const comment = normalizeDomComment(nodes[i], i);
if (!comment) continue;
scanned += 1;
if (seen.has(comment.id)) continue;
seen.add(comment.id);
if (!commentMatchesFilters(comment, keywordTerms, usernameTerms, includeSuperchat)) continue;
comments.push(comment);
if (comments.length % 20 === 0) {
onProgress?.({ count: comments.length, scanned, offset: i, start: 0, end: nodes.length });
}
}
onProgress?.({ count: comments.length, scanned, offset: nodes.length, start: 0, end: nodes.length });
return { comments, scanned, start: 0, end: comments.length ? comments.length - 1 : 0, source: 'live-dom' };
}
async function downloadVodChat({
videoId,
start = 0,
end = Infinity,
keywords = '',
usernames = '',
includeSuperchat = true,
onProgress,
shouldCancel,
shouldPause,
}) {
const id = String(videoId || '').replace(/\D/g, '');
if (!id) throw historyError('missing');
const rangeStart = Math.max(0, Number(start) || 0);
const selectedEnd = Number.isFinite(Number(end)) ? Math.max(rangeStart, Number(end)) : Infinity;
const rangeEnd = Number.isFinite(selectedEnd) ? selectedEnd + 1 : Infinity;
const keywordTerms = splitFilterTerms(keywords);
const usernameTerms = splitFilterTerms(usernames);
const comments = [];
const seen = new Set();
let cursor = '';
let first = true;
let latest = rangeStart - 1;
let scanned = 0;
let errorCount = 0;
let nullCount = 0;
let pages = 0;
while (latest < rangeEnd) {
await waitWhilePaused(shouldPause, shouldCancel);
if (++pages > 8000) throw historyError('failed', 'too many pages');
let payload;
try {
payload = await twitchGql({
operationName: 'VideoCommentsByOffsetOrCursor',
variables: first
? { videoID: id, contentOffsetSeconds: Math.floor(rangeStart) }
: { videoID: id, cursor },
extensions: { persistedQuery: { version: 1, sha256Hash: VOD_COMMENTS_HASH } },
});
errorCount = Math.max(0, errorCount - 0.1);
} catch (error) {
if (++errorCount > 8) throw historyError('failed', error.message);
await waitMs(1000 * errorCount);
continue;
}
const video = payload?.data?.video;
const edges = video?.comments?.edges;
if (!video || !edges) {
if (++nullCount > 8) throw historyError(video ? 'failed' : 'missing');
await waitMs(150 * nullCount);
continue;
}
nullCount = Math.max(0, nullCount - 0.1);
if (!edges.length) break;
for (const edge of edges) {
const node = edge?.node;
const offset = Number(node?.contentOffsetSeconds);
if (Number.isFinite(offset) && offset > latest) latest = offset;
if (!node?.id || !node.commenter || seen.has(node.id)) continue;
if (!Number.isFinite(offset) || offset < rangeStart || offset >= rangeEnd) continue;
seen.add(node.id);
scanned += 1;
const comment = normalizeVodComment(node);
if (!comment.message && !comment.superchat) continue;
if (!commentMatchesFilters(comment, keywordTerms, usernameTerms, includeSuperchat)) continue;
comments.push(comment);
}
onProgress?.({
count: comments.length,
scanned,
offset: Math.max(rangeStart, latest),
start: rangeStart,
end: rangeEnd,
});
if (!video.comments.pageInfo?.hasNextPage) break;
const nextCursor = edges[edges.length - 1]?.cursor;
if (!nextCursor || nextCursor === cursor) break;
cursor = nextCursor;
first = false;
}
comments.sort((a, b) => a.offsetSeconds - b.offsetSeconds || String(a.createdAt).localeCompare(String(b.createdAt)));
return { comments, scanned, start: rangeStart, end: selectedEnd };
}
function csvCell(value) {
const text = String(value ?? '');
if (/[",\n\r]/.test(text)) return `"${text.replace(/"/g, '""')}"`;
return text;
}
function buildChatExport(format, video, result) {
const comments = result.comments || [];
const start = result.start || 0;
const isLive = video?.source === 'live-dom' || video?.live || result?.source === 'live-dom';
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const endLabel = Number.isFinite(result.end) ? fileClock(result.end) : 'end';
const base = isLive
? `twitch-chat-live-${video.login || video.id}-${stamp}`
: `twitch-chat-${video.id}-${fileClock(start)}-${endLabel}`;
const kind = ['csv', 'txt', 'json'].includes(format) ? format : 'json';
if (kind === 'csv') {
const header = ['offset_seconds', 'timestamp', 'created_at', 'login', 'display_name', 'user_id', 'message', 'bits', 'superchat'];
const lines = comments.map((comment, index) => [
isLive ? index : comment.offsetSeconds,
isLive ? '' : formatChatClock(comment.offsetSeconds),
comment.createdAt,
comment.login,
comment.displayName,
comment.userId,
comment.message,
comment.bits,
comment.superchat ? '1' : '0',
].map(csvCell).join(','));
return {
filename: `${base}.csv`,
mime: 'text/csv;charset=utf-8',
text: `\uFEFF${header.join(',')}\n${lines.join('\n')}\n`,
};
}
if (kind === 'txt') {
const lines = comments.map((comment, index) => {
const bits = comment.superchat ? ` [Bits ${comment.bits}]` : '';
const prefix = isLive ? `#${index + 1}` : `[${formatChatClock(comment.offsetSeconds)}]`;
return `${prefix} ${comment.displayName}${bits}: ${comment.message}`;
});
return {
filename: `${base}.txt`,
mime: 'text/plain;charset=utf-8',
text: `${lines.join('\n')}\n`,
};
}
return {
filename: `${base}.json`,
mime: 'application/json;charset=utf-8',
text: `${JSON.stringify({
video: {
id: video.id,
title: video.title,
channel: video.channel,
login: video.login,
createdAt: video.createdAt,
lengthSeconds: video.lengthSeconds,
source: video.source || (isLive ? 'live-dom' : 'vod-gql'),
},
range: isLive ? null : {
start,
end: Number.isFinite(result.end) ? result.end : null,
},
count: comments.length,
comments,
}, null, 2)}\n`,
};
}
function saveChatFile(filename, text, mime) {
const blob = new Blob([text], { type: mime });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.style.display = 'none';
document.body.appendChild(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(url), 1500);
}
function detectFrankerFaceZ() {
if (typeof window === 'undefined' || typeof document === 'undefined') return false;
if (window.FrankerFaceZ || window.ffz || window.ffz_from_cdn) return true;
if (document.documentElement?.classList?.contains('ffz')) return true;
if (document.getElementById('ffz-script') || document.querySelector('script[src*="frankerfacez"]')) return true;
if (document.querySelector('.ffz-topbar, #ffz-menu-button, [class*="ffz--"], [data-a-target="ffz-control"]')) return true;
return false;
}
function waitForFrankerFaceZ(timeoutMs = 1500) {
if (detectFrankerFaceZ()) return Promise.resolve(true);
return new Promise((resolve) => {
const started = Date.now();
const timer = setInterval(() => {
if (detectFrankerFaceZ()) {
clearInterval(timer);
resolve(true);
return;
}
if (Date.now() - started >= timeoutMs) {
clearInterval(timer);
resolve(false);
}
}, 120);
});
}
const TWITCH_IRC_URL = 'wss://irc-ws.chat.twitch.tv:443';
const IRC_RECONNECT_BASE_MS = 1200;
const IRC_RECONNECT_MAX_MS = 15000;
function randomJustinFanNick() {
return `justinfan${Math.floor(80000 + Math.random() * 10000)}`;
}
function parseIrcTags(raw) {
const tags = {};
if (!raw) return tags;
for (const part of raw.split(';')) {
const eq = part.indexOf('=');
if (eq === -1) {
tags[part] = true;
continue;
}
tags[part.slice(0, eq)] = part.slice(eq + 1);
}
return tags;
}
function unescapeIrcTag(value) {
return String(value || '')
.replace(/\\:/g, ';')
.replace(/\\s/g, ' ')
.replace(/\\\\/g, '\\')
.replace(/\\r/g, '\r')
.replace(/\\n/g, '\n');
}
function parseIrcEmoteParts(message, emotesTag) {
if (!emotesTag || !message) return [{ type: 'text', value: message }];
const ranges = [];
for (const entry of emotesTag.split('/')) {
if (!entry) continue;
const [id, positions] = entry.split(':');
if (!id || !positions) continue;
for (const span of positions.split(',')) {
const [startRaw, endRaw] = span.split('-');
const start = Number(startRaw);
const end = Number(endRaw);
if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) continue;
ranges.push({ id, start, end });
}
}
if (!ranges.length) return [{ type: 'text', value: message }];
ranges.sort((a, b) => a.start - b.start);
const parts = [];
let cursor = 0;
for (const range of ranges) {
if (range.start > cursor) {
parts.push({ type: 'text', value: message.slice(cursor, range.start) });
}
const name = message.slice(range.start, range.end + 1);
parts.push({
type: 'emote',
name,
src: `https://static-cdn.jtvnw.net/emoticons/v2/${encodeURIComponent(range.id)}/default/dark/1.0`,
});
cursor = range.end + 1;
}
if (cursor < message.length) {
parts.push({ type: 'text', value: message.slice(cursor) });
}
return parts.length ? parts : [{ type: 'text', value: message }];
}
function bitsFromIrcTags(tags, message) {
const spent = Number(tags.bits);
if (Number.isFinite(spent) && spent > 0) return spent;
return bitsFromText(message);
}
const IRC_SUB_MSG_IDS = new Set([
'sub', 'resub', 'subgift', 'anonsubgift', 'submysterygift',
'giftpaidupgrade', 'anongiftpaidupgrade', 'primepaidupgrade',
]);
function buildIrcPayload({ channel, login, displayName, color, message, tags, bits = 0, subscribe = false }) {
const parts = parseIrcEmoteParts(message, tags.emotes);
let meta = null;
if (bits > 0) {
const style = cheerTierStyle(bits);
meta = {
kind: 'cheer',
bits,
amountLabel: `${bits} Bits`,
avatarUrl: '',
style,
};
} else if (subscribe) {
const style = subscribeStyle();
meta = {
kind: 'sub',
bits: 0,
amountLabel: 'Sub',
avatarUrl: '',
style,
};
}
return {
channel,
login,
username: displayName,
usernameColor: color,
profileUrl: login ? `https://www.twitch.tv/${encodeURIComponent(login)}` : null,
avatarUrl: '',
message,
parts,
originalMessage: message,
superchat: !!meta,
superchatStyle: meta?.style || null,
superchatMeta: meta,
bits,
};
}
function normalizeIrcPrivmsg(line) {
// @tags :nick!user@host PRIVMSG #channel :message
const match = line.match(/^(?:@(\S+)\s+)?:(\S+?)(?:!(\S+?))?(?:@\S+)?\s+PRIVMSG\s+#(\S+)\s+:([\s\S]*)$/i);
if (!match) return null;
const tags = parseIrcTags(match[1] || '');
const login = (match[3] || match[2] || tags.login || '').toLowerCase();
const channel = String(match[4] || '').toLowerCase();
const message = match[5] || '';
if (!message.trim()) return null;
const displayName = unescapeIrcTag(tags['display-name']) || login;
const color = unescapeIrcTag(tags.color) || '#ffffff';
const bits = bitsFromIrcTags(tags, message);
return buildIrcPayload({
channel,
login,
displayName,
color,
message,
tags,
bits,
});
}
function normalizeIrcUsernotice(line) {
const match = line.match(/^(?:@(\S+)\s+)?:tmi\.twitch\.tv\s+USERNOTICE\s+#(\S+)(?:\s+:([\s\S]*))?$/i);
if (!match) return null;
const tags = parseIrcTags(match[1] || '');
const msgId = String(tags['msg-id'] || '').toLowerCase();
if (!IRC_SUB_MSG_IDS.has(msgId)) return null;
const channel = String(match[2] || '').toLowerCase();
const login = String(tags.login || '').toLowerCase();
const displayName = unescapeIrcTag(tags['display-name']) || login;
const color = unescapeIrcTag(tags.color) || '#ffffff';
const system = unescapeIrcTag(tags['system-msg'] || '');
const body = (match[3] || '').trim();
const message = [system, body].filter(Boolean).join(' — ') || system || 'Subscribed';
return buildIrcPayload({
channel,
login,
displayName,
color,
message,
tags,
subscribe: true,
});
}
function normalizeIrcLine(line) {
if (/\sPRIVMSG\s/i.test(line)) return normalizeIrcPrivmsg(line);
if (/\sUSERNOTICE\s/i.test(line)) return normalizeIrcUsernotice(line);
return null;
}
class TwitchIrcChatSource {
constructor({ getChannel, onMessage, isActive }) {
this.getChannel = getChannel;
this.onMessage = onMessage;
this.isActive = isActive;
this.socket = null;
this.channel = '';
this.nick = '';
this.wanted = false;
this.reconnectAttempt = 0;
this.reconnectTimer = null;
this.pingTimer = null;
}
start() {
this.wanted = true;
this.connect();
}
stop() {
this.wanted = false;
this.clearReconnect();
this.clearPing();
try {
this.socket?.close();
} catch {
/* ignore */
}
this.socket = null;
this.channel = '';
}
clearReconnect() {
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
clearPing() {
if (this.pingTimer) clearInterval(this.pingTimer);
this.pingTimer = null;
}
scheduleReconnect() {
if (!this.wanted) return;
this.clearReconnect();
const delay = Math.min(
IRC_RECONNECT_MAX_MS,
IRC_RECONNECT_BASE_MS * (2 ** Math.min(this.reconnectAttempt, 4)),
);
this.reconnectAttempt += 1;
this.reconnectTimer = setTimeout(() => this.connect(), delay);
}
connect() {
if (!this.wanted) return;
const channel = String(this.getChannel?.() || '').trim().toLowerCase().replace(/^#/, '');
if (!channel) {
this.scheduleReconnect();
return;
}
this.clearReconnect();
this.clearPing();
try {
this.socket?.close();
} catch {
/* ignore */
}
this.channel = channel;
this.nick = randomJustinFanNick();
let socket;
try {
socket = new WebSocket(TWITCH_IRC_URL);
} catch (error) {
console.warn('[TBC] IRC WebSocket unavailable:', error);
this.scheduleReconnect();
return;
}
this.socket = socket;
socket.addEventListener('open', () => {
if (this.socket !== socket || !this.wanted) return;
this.reconnectAttempt = 0;
socket.send('CAP REQ :twitch.tv/tags twitch.tv/commands');
socket.send('PASS SCHMOOPIIE');
socket.send(`NICK ${this.nick}`);
socket.send(`JOIN #${this.channel}`);
this.pingTimer = setInterval(() => {
if (this.socket === socket && socket.readyState === WebSocket.OPEN) {
socket.send('PING :tmi.twitch.tv');
}
}, 4 * 60 * 1000);
console.log(`[TBC] IRC connected (#${this.channel})`);
});
socket.addEventListener('message', (event) => {
if (this.socket !== socket) return;
this.handlePayload(String(event.data || ''));
});
socket.addEventListener('close', () => {
if (this.socket !== socket) return;
this.socket = null;
this.clearPing();
if (this.wanted) this.scheduleReconnect();
});
socket.addEventListener('error', () => {
try {
socket.close();
} catch {
/* ignore */
}
});
}
handlePayload(payload) {
const lines = payload.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
for (const line of lines) {
if (line.startsWith('PING ')) {
this.socket?.send(`PONG ${line.slice(5)}`);
continue;
}
if (!/\sPRIVMSG\s/i.test(line) && !/\sUSERNOTICE\s/i.test(line)) continue;
if (this.isActive && !this.isActive()) continue;
const parsed = normalizeIrcLine(line);
if (!parsed) continue;
if (parsed.channel && this.channel && parsed.channel !== this.channel) continue;
try {
this.onMessage?.(parsed);
} catch (error) {
console.warn('[TBC] IRC message handler failed:', error);
}
}
}
}
class ChatObserver {
constructor({ getSettings, danmakuEmitter, translateQueue, isActive }) {
this.getSettings = getSettings;
this.danmakuEmitter = danmakuEmitter;
this.translateQueue = translateQueue;
this.isActive = isActive;
this.observer = null;
this.chatRoot = null;
this.readyAt = 0;
this.isVodChat = false;
this.mode = 'dom';
this.ffzActive = false;
this.ircSource = null;
this.ircBuffer = [];
}
async start() {
this.stop();
this.readyAt = Date.now();
this.ffzActive = await waitForFrankerFaceZ(1600);
const vodId = currentVodId();
const channel = currentChannelLogin();
const preferIrc = !vodId && !!channel && this.ffzActive;
if (preferIrc) {
this.mode = 'irc';
this.isVodChat = false;
this.startIrc(channel);
console.log('[TBC] Chat source: IRC (FrankerFaceZ detected)');
return;
}
this.mode = 'dom';
this.chatRoot = await waitForElement(SELECTORS.chatScroller, document, this.ffzActive ? 4000 : 30000);
if (!this.chatRoot) {
if (!vodId && channel) {
this.mode = 'irc';
this.isVodChat = false;
this.startIrc(channel);
console.warn('[TBC] Chat scroller missing; falling back to IRC');
return;
}
console.warn('[TBC] Chat scroller not found');
return;
}
markExistingMessages(this.chatRoot);
this.isVodChat = !!this.chatRoot.closest('.video-chat')
|| this.chatRoot.classList.contains('video-chat__message-list-wrapper')
|| !!this.chatRoot.querySelector('.vod-message');
this.observer = new MutationObserver((mutations) => {
if (!this.isActive()) return;
for (const mut of mutations) {
for (const node of mut.addedNodes) {
if (node.nodeType !== Node.ELEMENT_NODE) continue;
this.handleNode(node);
}
}
});
this.observer.observe(this.chatRoot, { childList: true, subtree: true });
console.log(`[TBC] Chat observer started (${this.isVodChat ? 'vod' : 'live'}${this.ffzActive ? ', ffz-dom' : ''})`);
}
startIrc(fallbackChannel) {
this.ircBuffer = [];
this.ircSource = new TwitchIrcChatSource({
getChannel: () => currentChannelLogin() || fallbackChannel || '',
isActive: this.isActive,
onMessage: (parsed) => this.handleIrcMessage(parsed),
});
this.ircSource.start();
}
stop() {
this.observer?.disconnect();
this.observer = null;
this.chatRoot = null;
this.isVodChat = false;
this.mode = 'dom';
this.ircSource?.stop();
this.ircSource = null;
this.ircBuffer = [];
}
shouldEmit(msgEl) {
if (!this.isActive()) return false;
if (Date.now() - this.readyAt < WARMUP_MS) {
msgEl.classList.add('tbc-processed');
return false;
}
if (!isLiveMessage(msgEl, this.chatRoot)) {
msgEl.classList.add('tbc-processed');
return false;
}
return true;
}
handleNode(node) {
const candidates = node.matches?.(SELECTORS.chatMessage)
? [node]
: Array.from(node.querySelectorAll?.(SELECTORS.chatMessage) || []);
for (const msgEl of candidates) {
if (!this.shouldEmit(msgEl)) continue;
const parsed = parseChatMessage(msgEl);
if (!parsed) continue;
this.emitDanmaku(parsed, msgEl);
}
}
handleIrcMessage(parsed) {
if (!this.isActive()) return;
if (Date.now() - this.readyAt < WARMUP_MS) return;
this.pushIrcBuffer(parsed);
this.emitDanmaku({
username: parsed.username,
usernameColor: parsed.usernameColor,
profileUrl: parsed.profileUrl,
avatarUrl: parsed.avatarUrl || '',
login: parsed.login || '',
message: parsed.message,
parts: parsed.parts,
originalMessage: parsed.originalMessage,
superchat: !!parsed.superchat,
superchatStyle: parsed.superchatStyle || null,
superchatMeta: parsed.superchatMeta || null,
}, null);
}
pushIrcBuffer(parsed) {
this.ircBuffer.push({
id: `irc-${Date.now()}-${this.ircBuffer.length}-${parsed.login}`,
offsetSeconds: this.ircBuffer.length,
createdAt: new Date().toISOString(),
userId: '',
login: parsed.login || '',
displayName: parsed.username || parsed.login || '',
message: parsed.message || '',
color: parsed.usernameColor || '',
bits: Number(parsed.bits) || 0,
superchat: !!parsed.superchat,
});
while (this.ircBuffer.length > 500) this.ircBuffer.shift();
}
getIrcHistoryComments() {
return this.ircBuffer.slice();
}
emitDanmaku(parsed, msgEl) {
const settings = this.getSettings();
if (!this.isActive()) return;
if (isKeywordBlocked(parsed.message, settings.blockedKeywords)) return;
const base = {
username: parsed.username,
usernameColor: parsed.usernameColor,
profileUrl: parsed.profileUrl,
avatarUrl: parsed.avatarUrl || '',
login: parsed.login || '',
originalMessage: parsed.message,
superchat: !!parsed.superchat,
superchatStyle: parsed.superchatStyle || null,
superchatMeta: parsed.superchatMeta || null,
};
const hasTranslatableText = parsed.parts.some((part) => (
part.type === 'text' && translatableCore(part.value)
));
// Avoid injecting translations into FFZ-managed chat DOM.
const allowChatTranslation = !!msgEl && !this.ffzActive && this.mode === 'dom';
if (!settings.translateEnabled || !hasTranslatableText) {
this.danmakuEmitter.enqueue({
...base,
message: parsed.message,
parts: parsed.parts,
translated: false,
});
return;
}
this.translateQueue.enqueue(parsed.message, parsed.parts, (parts) => {
if (!this.isActive()) return;
const latest = this.getSettings();
const message = partsToPlain(parts) || parsed.message;
if (isKeywordBlocked(parsed.message, latest.blockedKeywords)) return;
if (isKeywordBlocked(message, latest.blockedKeywords)) return;
if (latest.translateEnabled && latest.translateInChat && allowChatTranslation) {
applyChatTranslation(msgEl, message, parsed.message);
} else if (msgEl) {
removeChatTranslation(msgEl);
}
this.danmakuEmitter.enqueue({
...base,
message,
parts,
translated: message !== parsed.message,
});
});
}
}
function injectStyles() {
GM_addStyle(`
.tbc-danmaku-layer {
pointer-events: none;
overflow: hidden;
}
.tbc-danmaku-item {
position: absolute;
left: 100%;
white-space: nowrap;
opacity: var(--tbc-opacity, 1);
font-weight: 600;
pointer-events: auto;
cursor: default;
text-shadow:
1px 1px 2px rgba(0, 0, 0, 0.9),
-1px -1px 2px rgba(0, 0, 0, 0.9),
0 0 4px rgba(0, 0, 0, 0.8);
animation: tbc-scroll var(--tbc-duration, 8s) linear forwards;
will-change: transform;
}
.tbc-danmaku-item img.tbc-emote {
display: inline-block !important;
height: 1.2em !important;
width: auto !important;
min-height: 1.5em !important;
max-height: 1.5em !important;
max-width: 6em !important;
object-fit: contain !important;
object-position: center !important;
vertical-align: -0.22em !important;
margin: 0 0.1em !important;
pointer-events: none !important;
user-select: none !important;
}
.tbc-danmaku-item.tbc-paused {
animation-play-state: paused;
z-index: 20;
cursor: pointer;
pointer-events: auto;
}
.tbc-danmaku-item.tbc-paused.tbc-click-locked {
z-index: 100002;
}
.tbc-click-lock-backdrop {
position: absolute;
inset: 0;
z-index: 100000;
pointer-events: auto;
background: transparent;
}
.tbc-danmaku-tooltip {
display: flex;
flex-direction: column;
align-items: center;
pointer-events: auto;
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.4));
max-width: calc(100% - 16px);
}
.tbc-danmaku-tooltip-bubble {
background: rgba(24, 24, 27, 0.95);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
padding: 6px 8px;
max-width: min(280px, 100%);
max-height: min(40vh, 240px);
overflow-y: auto;
}
.tbc-danmaku-tooltip-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 6px;
}
.tbc-danmaku-tooltip-header--solo {
justify-content: flex-end;
}
.tbc-danmaku-tooltip-name {
flex: 1;
min-width: 0;
font-size: 12px;
font-weight: 600;
line-height: 1.3;
word-break: break-word;
text-decoration: none;
cursor: pointer;
}
.tbc-danmaku-tooltip-name:hover {
text-decoration: underline;
}
.tbc-danmaku-tooltip-copy {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: clamp(24px, 6vw, 28px);
height: clamp(24px, 6vw, 28px);
padding: 0;
border: none;
border-radius: 4px;
background: transparent;
color: #adadb8;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.tbc-danmaku-tooltip-copy:hover {
background: rgba(255, 255, 255, 0.12);
color: #efeff1;
}
.tbc-danmaku-tooltip-copy svg {
width: clamp(14px, 3.5vw, 16px);
height: clamp(14px, 3.5vw, 16px);
fill: currentColor;
display: block;
}
.tbc-danmaku-tooltip-original {
margin-top: 4px;
color: #adadb8;
font-size: 11px;
line-height: 1.3;
word-break: break-word;
}
.tbc-danmaku-tooltip-tail {
width: 6px;
height: 6px;
flex-shrink: 0;
background: rgba(24, 24, 27, 0.95);
transform: rotate(45deg);
}
.tbc-danmaku-tooltip--above .tbc-danmaku-tooltip-tail {
margin: -3px auto 0;
border-right: 1px solid rgba(255, 255, 255, 0.1);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.tbc-danmaku-tooltip--below .tbc-danmaku-tooltip-bubble {
order: 2;
}
.tbc-danmaku-tooltip--below .tbc-danmaku-tooltip-tail {
order: 1;
margin: 0 auto -3px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
border-left: 1px solid rgba(255, 255, 255, 0.1);
}
@keyframes tbc-scroll {
from { transform: translateX(var(--tbc-start, 0)); }
to { transform: translateX(calc(-1 * var(--tbc-travel, 100vw))); }
}
.tbc-controls-wrap {
display: inline-flex;
align-items: center;
gap: 2px;
margin-left: 2px;
}
.tbc-control-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
border: none;
border-radius: 4px;
background: transparent;
color: #efeff1;
cursor: pointer;
transition: background 0.15s, opacity 0.15s;
flex-shrink: 0;
}
.tbc-control-btn:hover,
.tbc-control-btn.tbc-active {
background: rgba(255, 255, 255, 0.15);
}
.tbc-control-btn svg {
width: 20px;
height: 20px;
fill: currentColor;
display: block;
}
.tbc-danmaku-btn .tbc-icon {
display: none;
line-height: 0;
}
.tbc-danmaku-btn.tbc-danmaku-enabled .tbc-icon-on,
.tbc-danmaku-btn.tbc-danmaku-disabled .tbc-icon-off {
display: block;
}
.tbc-danmaku-btn.tbc-danmaku-disabled {
opacity: 0.55;
}
.tbc-speech-btn .tbc-icon {
display: none;
line-height: 0;
}
.tbc-speech-btn.tbc-speech-on .tbc-icon-on,
.tbc-speech-btn.tbc-speech-mute .tbc-icon-mute,
.tbc-speech-btn.tbc-speech-off .tbc-icon-off {
display: block;
}
.tbc-speech-btn.tbc-speech-mute,
.tbc-speech-btn.tbc-speech-off {
opacity: 0.55;
}
.tbc-superchat-layer {
pointer-events: none;
display: none;
flex-direction: column;
align-items: center;
justify-content: flex-start;
gap: 6px;
overflow: visible;
padding: 4px 0;
box-sizing: border-box;
}
.tbc-superchat-layer.tbc-sc-bottom {
justify-content: flex-end;
}
.tbc-superchat-item {
position: relative;
display: flex;
align-items: center;
gap: 10px;
max-width: min(720px, calc(100% - 24px));
min-width: min(280px, calc(100% - 24px));
padding: 8px 14px 8px 8px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.18);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
font-weight: 700;
line-height: 1.25;
overflow: hidden;
pointer-events: auto;
cursor: pointer;
animation-duration: var(--tbc-sc-duration, 6s);
animation-timing-function: ease;
animation-fill-mode: forwards;
}
.tbc-sc-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
background: rgba(0, 0, 0, 0.25);
border: 2px solid rgba(255, 255, 255, 0.35);
}
.tbc-sc-main {
min-width: 0;
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
.tbc-sc-head {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.tbc-sc-name {
font-size: 0.85em;
font-weight: 800;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 10em;
opacity: 0.95;
}
.tbc-sc-badge {
flex-shrink: 0;
padding: 1px 7px;
border-radius: 999px;
background: rgba(0, 0, 0, 0.28);
border: 1px solid rgba(255, 255, 255, 0.35);
color: inherit;
font-size: 0.72em;
font-weight: 800;
letter-spacing: 0.02em;
line-height: 1.4;
}
.tbc-sc-body {
min-width: 0;
overflow: hidden;
max-width: 100%;
}
.tbc-sc-body-track {
display: inline-flex;
align-items: center;
white-space: nowrap;
will-change: transform;
}
.tbc-sc-body-track.tbc-sc-marquee {
animation: tbc-sc-marquee var(--tbc-sc-marquee-duration, 8s) linear infinite;
}
.tbc-superchat-item.tbc-paused .tbc-sc-body-track.tbc-sc-marquee {
animation-play-state: paused;
}
@keyframes tbc-sc-marquee {
0%, 12% { transform: translateX(0); }
88%, 100% { transform: translateX(calc(-1 * var(--tbc-sc-marquee-distance, 0px))); }
}
.tbc-chat-menu {
position: fixed;
z-index: 100050;
min-width: 168px;
padding: 6px;
border-radius: 8px;
background: #18181b;
border: 1px solid rgba(255, 255, 255, 0.12);
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.45);
display: flex;
flex-direction: column;
gap: 2px;
}
.tbc-chat-menu-item {
appearance: none;
border: none;
background: transparent;
color: #efeff1;
text-align: left;
padding: 8px 10px;
border-radius: 6px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.tbc-chat-menu-item:hover {
background: rgba(145, 71, 255, 0.7);
}
.tbc-superchat-item.tbc-sc-fade,
.tbc-superchat-item.tbc-sc-shine {
animation-name: tbc-sc-fade;
}
.tbc-superchat-item.tbc-sc-slide {
animation-name: tbc-sc-slide;
}
.tbc-superchat-layer.tbc-sc-bottom .tbc-superchat-item.tbc-sc-slide {
animation-name: tbc-sc-slide-up;
}
.tbc-superchat-item .tbc-emote {
height: 1.5em !important;
max-height: 1.5em !important;
vertical-align: -0.2em !important;
}
.tbc-superchat-item.tbc-sc-pop {
animation-name: tbc-sc-pop;
}
.tbc-superchat-item.tbc-sc-shine::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(110deg, transparent 28%, rgba(255, 255, 255, 0.55) 50%, transparent 72%);
transform: translateX(-130%);
animation: tbc-sc-shine-sweep 1.15s ease 0.25s;
pointer-events: none;
}
.tbc-superchat-item.tbc-paused,
.tbc-superchat-item.tbc-paused.tbc-click-locked,
.tbc-superchat-item.tbc-paused::after {
animation-play-state: paused;
}
.tbc-superchat-item.tbc-paused,
.tbc-superchat-item.tbc-paused.tbc-click-locked {
z-index: 20;
}
@keyframes tbc-sc-fade {
0% { opacity: 0; }
12% { opacity: 1; }
88% { opacity: 1; }
100% { opacity: 0; }
}
@keyframes tbc-sc-slide {
0% { opacity: 0; transform: translateY(-14px); }
14% { opacity: 1; transform: translateY(0); }
86% { opacity: 1; transform: translateY(0); }
100% { opacity: 0; transform: translateY(10px); }
}
@keyframes tbc-sc-slide-up {
0% { opacity: 0; transform: translateY(14px); }
14% { opacity: 1; transform: translateY(0); }
86% { opacity: 1; transform: translateY(0); }
100% { opacity: 0; transform: translateY(-10px); }
}
@keyframes tbc-sc-pop {
0% { opacity: 0; transform: scale(0.7); }
16% { opacity: 1; transform: scale(1); }
86% { opacity: 1; transform: scale(1); }
100% { opacity: 0; transform: scale(0.92); }
}
@keyframes tbc-sc-shine-sweep {
from { transform: translateX(-130%); }
to { transform: translateX(130%); }
}
.tbc-balloon-body--speech {
max-height: min(70vh, 520px);
}
.tbc-speech-test {
display: flex;
gap: 6px;
}
.tbc-balloon-input {
flex: 1;
min-width: 0;
padding: 6px 8px;
border: 1px solid #3a3a3d;
border-radius: 4px;
background: #0e0e10;
color: #efeff1;
font-size: 13px;
}
.tbc-speech-play {
flex-shrink: 0;
min-width: 36px;
border: none;
border-radius: 4px;
background: #3a3a3d;
color: #efeff1;
cursor: pointer;
font-size: 12px;
}
.tbc-speech-note {
margin: 0;
padding: 0 12px 8px;
color: #adadb8;
font-size: 12px;
line-height: 1.4;
}
.tbc-balloon {
background: #26262c;
color: #efeff1;
border-radius: 6px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
font-family: "Inter", "Segoe UI", system-ui, sans-serif;
font-size: 14px;
overflow: hidden;
}
.tbc-balloon-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 12px 8px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.tbc-balloon-title {
font-size: 14px;
font-weight: 600;
line-height: 1.4;
}
.tbc-balloon-close {
background: none;
border: none;
color: #9147ff;
font-size: 13px;
font-weight: 600;
cursor: pointer;
padding: 4px 6px;
border-radius: 4px;
}
.tbc-balloon-close:hover {
background: rgba(145, 71, 255, 0.12);
}
.tbc-balloon-body {
padding: 4px 0 8px;
max-height: min(60vh, 420px);
overflow-y: auto;
}
.tbc-balloon-row {
padding: 8px 12px;
}
.tbc-balloon-row--toggle {
display: flex;
align-items: center;
justify-content: space-between;
}
.tbc-balloon-row--field {
display: flex;
flex-direction: column;
gap: 6px;
}
.tbc-balloon-field-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.tbc-balloon-label {
color: #efeff1;
font-size: 13px;
line-height: 1.4;
}
.tbc-balloon-value {
color: #adadb8;
font-size: 12px;
}
.tbc-balloon-range {
width: 100%;
accent-color: #9147ff;
height: 4px;
}
.tbc-balloon-select {
width: 100%;
padding: 6px 8px;
border: 1px solid #3a3a3d;
border-radius: 4px;
background: #0e0e10;
color: #efeff1;
font-size: 13px;
}
.tbc-balloon-divider {
height: 1px;
margin: 4px 12px;
background: rgba(255, 255, 255, 0.08);
}
.tbc-chat-translation {
display: flex;
align-items: flex-start;
gap: 8px;
margin-top: 5px;
padding: 6px 10px 6px 8px;
border: 1px solid rgba(145, 71, 255, 0.55);
border-left: 3px solid #9147ff;
border-radius: 4px;
background:rgba(27, 16, 48, 0.8);
font-size: 13px;
line-height: 1.45;
max-width: 100%;
box-sizing: border-box;
}
.tbc-chat-translation-badge {
flex-shrink: 0;
margin-top: 1px;
padding: 1px 6px;
border-radius: 3px;
background: #9147ff;
color: #ffffff;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.04em;
line-height: 1.45;
user-select: none;
}
.tbc-chat-translation-text {
color: #f3eeff;
font-weight: 600;
word-break: break-word;
}
.tbc-keyword-hint,
.tbc-keyword-empty {
color: #adadb8;
font-size: 12px;
line-height: 1.4;
}
.tbc-setting-hint {
margin: -2px 0 0;
padding: 0 12px 6px;
color: #adadb8;
font-size: 12px;
line-height: 1.4;
}
.tbc-keyword-form {
display: flex;
gap: 6px;
}
.tbc-keyword-input {
flex: 1;
min-width: 0;
padding: 6px 8px;
border: 1px solid #3a3a3d;
border-radius: 4px;
background: #0e0e10;
color: #efeff1;
font-size: 13px;
}
.tbc-keyword-add {
flex-shrink: 0;
border: none;
border-radius: 4px;
background: #9147ff;
color: #fff;
padding: 6px 10px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.tbc-keyword-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
max-height: 96px;
overflow-y: auto;
}
.tbc-keyword {
display: inline-flex;
align-items: center;
max-width: 100%;
padding: 2px 2px 2px 8px;
border-radius: 999px;
background: rgba(145, 71, 255, 0.16);
color: #efeff1;
font-size: 12px;
}
.tbc-keyword-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 180px;
}
.tbc-keyword-remove {
border: none;
background: transparent;
color: #adadb8;
cursor: pointer;
font-size: 14px;
line-height: 1;
padding: 0 6px;
}
.tbc-switch {
position: relative;
width: 40px;
height: 22px;
flex-shrink: 0;
}
.tbc-switch input {
opacity: 0;
width: 0;
height: 0;
}
.tbc-switch-slider {
position: absolute;
inset: 0;
background: #3a3a3d;
border-radius: 11px;
cursor: pointer;
transition: background 0.2s;
}
.tbc-switch-slider::before {
content: '';
position: absolute;
width: 16px;
height: 16px;
left: 3px;
bottom: 3px;
background: #fff;
border-radius: 50%;
transition: transform 0.2s;
}
.tbc-switch input:checked + .tbc-switch-slider {
background: #9147ff;
}
.tbc-switch input:checked + .tbc-switch-slider::before {
transform: translateX(18px);
}
.tbc-balloon-body--history {
max-height: min(70vh, 560px);
}
.tbc-history-meta,
.tbc-history-hint,
.tbc-history-status {
margin: 0;
color: #adadb8;
font-size: 12px;
line-height: 1.4;
}
.tbc-history-meta {
padding: 8px 12px 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tbc-history-status {
min-height: 1.4em;
padding-top: 8px;
}
.tbc-history-status--error {
color: #ff8280;
}
.tbc-history-status--ok {
color: #00f593;
}
.tbc-history-range {
position: relative;
height: 28px;
}
.tbc-history-track,
.tbc-history-fill {
position: absolute;
top: 12px;
height: 4px;
border-radius: 2px;
}
.tbc-history-track {
left: 0;
right: 0;
background: #3a3a3d;
}
.tbc-history-fill {
background: #9147ff;
}
.tbc-history-range input[type="range"] {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 28px;
margin: 0;
background: none;
pointer-events: none;
appearance: none;
-webkit-appearance: none;
}
.tbc-history-range input[type="range"]::-webkit-slider-runnable-track {
height: 4px;
background: transparent;
}
.tbc-history-range input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
pointer-events: auto;
width: 14px;
height: 14px;
margin-top: -5px;
border: 2px solid #9147ff;
border-radius: 50%;
background: #fff;
cursor: pointer;
}
.tbc-history-range input[type="range"]::-moz-range-track {
height: 4px;
background: transparent;
border: none;
}
.tbc-history-range input[type="range"]::-moz-range-thumb {
pointer-events: auto;
width: 14px;
height: 14px;
border: 2px solid #9147ff;
border-radius: 50%;
background: #fff;
cursor: pointer;
}
.tbc-history-actions {
padding: 4px 12px 8px;
}
.tbc-history-buttons {
display: flex;
gap: 8px;
}
.tbc-history-download,
.tbc-history-pause,
.tbc-history-resume,
.tbc-history-cancel {
flex: 1;
border: none;
border-radius: 4px;
font-size: 13px;
font-weight: 600;
padding: 8px 12px;
cursor: pointer;
color: #fff;
}
.tbc-history-download,
.tbc-history-resume {
background: #9147ff;
}
.tbc-history-pause {
background: #3a3a3d;
}
.tbc-history-cancel {
background: #ad1c1c;
}
.tbc-history-download:disabled {
opacity: 0.45;
cursor: default;
}
`);
}
const ICON_DANMAKU_ON = ``;
const ICON_DANMAKU_OFF = ``;
const ICON_SETTINGS = ``;
const ICON_COPY = ``;
const ICON_SPEECH_ON = ``;
const ICON_SPEECH_MUTE = ``;
const ICON_SPEECH_OFF = ``;
const ICON_DOWNLOAD = ``;
class SettingsPanel {
constructor({ settings, onChange, onClose }) {
this.settings = settings;
this.onChange = onChange;
this.onClose = onClose;
this.balloon = null;
this.anchor = null;
this.isOpen = false;
this.boundReposition = () => this.reposition();
this.boundOutsideClick = (e) => this.handleOutsideClick(e);
}
toggle(anchor) {
if (this.isOpen && this.anchor === anchor) {
this.close();
} else {
this.open(anchor);
}
}
open(anchor) {
this.close();
this.anchor = anchor;
this.isOpen = true;
this.render();
this.reposition();
requestAnimationFrame(() => this.reposition());
window.addEventListener('resize', this.boundReposition);
document.addEventListener('fullscreenchange', this.boundReposition);
document.addEventListener('webkitfullscreenchange', this.boundReposition);
document.addEventListener('scroll', this.boundReposition, true);
setTimeout(() => document.addEventListener('click', this.boundOutsideClick, true), 0);
}
close() {
this.isOpen = false;
this.anchor = null;
this.balloon?.remove();
this.balloon = null;
window.removeEventListener('resize', this.boundReposition);
document.removeEventListener('fullscreenchange', this.boundReposition);
document.removeEventListener('webkitfullscreenchange', this.boundReposition);
document.removeEventListener('scroll', this.boundReposition, true);
document.removeEventListener('click', this.boundOutsideClick, true);
this.onClose?.();
}
handleOutsideClick(e) {
if (!this.balloon || !this.isOpen) return;
if (this.balloon.contains(e.target)) return;
if (this.anchor?.contains(e.target)) return;
this.close();
}
reposition() {
if (!this.balloon || !this.anchor) return;
const mountTarget = getFullscreenElement() || document.body;
if (this.balloon.parentElement !== mountTarget) {
mountTarget.appendChild(this.balloon);
}
const rect = this.anchor.getBoundingClientRect();
const width = 300;
const gap = 8;
const height = this.balloon.offsetHeight;
let left = rect.right - width;
let top = rect.top - height - gap;
left = clamp(left, 8, window.innerWidth - width - 8);
if (top < 8) {
top = rect.bottom + gap;
}
Object.assign(this.balloon.style, {
position: 'fixed',
left: `${left}px`,
top: `${top}px`,
width: `${width}px`,
zIndex: '100000',
});
}
updateSetting(key, value) {
this.settings[key] = value;
saveSettings(this.settings);
this.onChange(this.settings);
}
createToggle(label, key, options = {}) {
const row = document.createElement('div');
row.className = 'tbc-balloon-row tbc-balloon-row--toggle';
const labelEl = document.createElement('span');
labelEl.className = 'tbc-balloon-label';
labelEl.textContent = label;
const switchWrap = document.createElement('label');
switchWrap.className = 'tbc-switch';
const input = document.createElement('input');
input.type = 'checkbox';
input.checked = !!this.settings[key];
const slider = document.createElement('span');
slider.className = 'tbc-switch-slider';
input.addEventListener('change', () => {
this.updateSetting(key, input.checked);
options.onChange?.(input.checked);
});
switchWrap.append(input, slider);
row.append(labelEl, switchWrap);
return row;
}
createHint(text) {
const hint = document.createElement('p');
hint.className = 'tbc-setting-hint';
hint.textContent = text;
return hint;
}
syncTranslateDependentRows() {
const enabled = !!this.settings.translateEnabled;
if (this.translateInChatRow) this.translateInChatRow.hidden = !enabled;
if (this.translateInChatHint) this.translateInChatHint.hidden = !enabled;
}
createRange(label, key, min, max, step, format) {
const row = document.createElement('div');
row.className = 'tbc-balloon-row tbc-balloon-row--field';
const header = document.createElement('div');
header.className = 'tbc-balloon-field-header';
const labelEl = document.createElement('span');
labelEl.className = 'tbc-balloon-label';
labelEl.textContent = label;
const valueSpan = document.createElement('span');
valueSpan.className = 'tbc-balloon-value';
valueSpan.textContent = format(this.settings[key]);
header.append(labelEl, valueSpan);
const input = document.createElement('input');
input.type = 'range';
input.className = 'tbc-balloon-range';
input.min = String(min);
input.max = String(max);
input.step = String(step);
input.value = String(this.settings[key]);
input.addEventListener('input', () => {
const val = Number(input.value);
valueSpan.textContent = format(val);
this.updateSetting(key, val);
});
row.append(header, input);
return row;
}
createSelect(label, key, options) {
const row = document.createElement('div');
row.className = 'tbc-balloon-row tbc-balloon-row--field';
const labelEl = document.createElement('div');
labelEl.className = 'tbc-balloon-label';
labelEl.textContent = label;
const select = document.createElement('select');
select.className = 'tbc-balloon-select';
for (const opt of options) {
const option = document.createElement('option');
option.value = opt.value;
option.textContent = opt.label;
if (opt.value === this.settings[key]) option.selected = true;
select.appendChild(option);
}
select.addEventListener('change', () => {
this.updateSetting(key, select.value);
});
row.append(labelEl, select);
return row;
}
createKeywordBlock() {
const row = document.createElement('div');
row.className = 'tbc-balloon-row tbc-balloon-row--field';
const labelEl = document.createElement('div');
labelEl.className = 'tbc-balloon-label';
labelEl.textContent = t('blockKeywords');
const hint = document.createElement('div');
hint.className = 'tbc-keyword-hint';
hint.textContent = t('blockKeywordsHint');
const form = document.createElement('div');
form.className = 'tbc-keyword-form';
const input = document.createElement('input');
input.type = 'text';
input.className = 'tbc-keyword-input';
input.maxLength = 40;
input.placeholder = t('blockKeywordsPlaceholder');
input.setAttribute('aria-label', t('blockKeywords'));
const addBtn = document.createElement('button');
addBtn.type = 'button';
addBtn.className = 'tbc-keyword-add';
addBtn.textContent = t('blockKeywordsAdd');
const list = document.createElement('div');
list.className = 'tbc-keyword-list';
const commit = () => {
const incoming = input.value.split(/[,,]/).map((item) => item.trim()).filter(Boolean);
if (!incoming.length) return;
const next = sanitizeBlockedKeywords([...(this.settings.blockedKeywords || []), ...incoming]);
input.value = '';
this.updateSetting('blockedKeywords', next);
this.renderKeywordList(list);
};
addBtn.addEventListener('click', (e) => {
e.stopPropagation();
commit();
});
input.addEventListener('keydown', (e) => {
e.stopPropagation();
if (e.key === 'Enter') {
e.preventDefault();
commit();
}
});
form.append(input, addBtn);
row.append(labelEl, hint, form, list);
this.renderKeywordList(list);
return row;
}
renderKeywordList(list) {
list.replaceChildren();
const keywords = this.settings.blockedKeywords || [];
if (!keywords.length) {
const empty = document.createElement('div');
empty.className = 'tbc-keyword-empty';
empty.textContent = t('blockKeywordsEmpty');
list.appendChild(empty);
return;
}
keywords.forEach((keyword, index) => {
const chip = document.createElement('span');
chip.className = 'tbc-keyword';
const text = document.createElement('span');
text.className = 'tbc-keyword-text';
text.textContent = keyword;
text.title = keyword;
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'tbc-keyword-remove';
removeBtn.setAttribute('aria-label', `${t('blockKeywordsRemove')} ${keyword}`);
removeBtn.textContent = '×';
removeBtn.addEventListener('click', (e) => {
e.stopPropagation();
const next = (this.settings.blockedKeywords || []).filter((_, itemIndex) => itemIndex !== index);
this.updateSetting('blockedKeywords', next);
this.renderKeywordList(list);
});
chip.append(text, removeBtn);
list.appendChild(chip);
});
}
createDivider() {
const div = document.createElement('div');
div.className = 'tbc-balloon-divider';
return div;
}
render() {
this.balloon = document.createElement('div');
this.balloon.className = 'tbc-balloon';
this.balloon.setAttribute('role', 'dialog');
this.balloon.setAttribute('aria-label', t('panelTitle'));
const header = document.createElement('div');
header.className = 'tbc-balloon-header';
const title = document.createElement('div');
title.className = 'tbc-balloon-title';
title.textContent = t('panelTitle');
const closeBtn = document.createElement('button');
closeBtn.type = 'button';
closeBtn.className = 'tbc-balloon-close';
closeBtn.setAttribute('aria-label', t('panelClose'));
closeBtn.textContent = t('panelClose');
closeBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.close();
});
header.append(title, closeBtn);
const body = document.createElement('div');
body.className = 'tbc-balloon-body';
body.append(
this.createRange(t('scrollSpeed'), 'speed', 1, 10, 1, (v) => String(v)),
this.createRange(t('fontSize'), 'fontSize', 14, 40, 1, (v) => `${v}px`),
this.createRange(t('danmakuOpacity'), 'danmakuOpacity', 10, 100, 5, (v) => `${v}%`),
this.createSelect(t('fontFamily'), 'fontFamily', getFontOptions()),
this.createSelect(t('scrollArea'), 'scrollArea', getScrollAreaOptions()),
this.createDivider(),
this.createToggle(t('superchatPinned'), 'superchatPinned'),
this.createSelect(t('superchatPosition'), 'superchatPosition', getSuperchatPositionOptions()),
this.createRange(t('superchatDuration'), 'superchatDuration', 2, 300, 1, (v) => `${v} ${t('speechSeconds')}`),
this.createSelect(t('superchatAnimation'), 'superchatAnimation', getSuperchatAnimationOptions()),
this.createDivider(),
this.createToggle(t('translateEnable'), 'translateEnabled', {
onChange: () => this.syncTranslateDependentRows(),
}),
this.createSelect(t('translateProvider'), 'translateProvider', getTranslateProviderOptions()),
this.createSelect(t('translateTarget'), 'translateTargetLang', getTranslateLangOptions()),
this.createRange(t('translateSpeed'), 'translateSpeed', 1, 10, 1, (v) => `${v} ${t('translateSpeedUnit')}`),
);
this.translateInChatRow = this.createToggle(t('translateInChat'), 'translateInChat');
this.translateInChatHint = this.createHint(t('translateInChatHint'));
body.append(
this.translateInChatRow,
this.translateInChatHint,
this.createDivider(),
this.createKeywordBlock(),
);
this.syncTranslateDependentRows();
this.balloon.append(header, body);
this.balloon.addEventListener('click', (e) => e.stopPropagation());
const mountTarget = getFullscreenElement() || document.body;
mountTarget.appendChild(this.balloon);
}
}
class SpeechPanel {
constructor({ settings, onChange, onClose }) {
this.settings = settings;
this.onChange = onChange;
this.onClose = onClose;
this.balloon = null;
this.anchor = null;
this.isOpen = false;
this.langSelect = null;
this.voiceSelect = null;
this.boundReposition = () => this.reposition();
this.boundOutsideClick = (e) => this.handleOutsideClick(e);
this.boundVoices = () => this.fillVoices();
}
toggle(anchor) {
if (this.isOpen && this.anchor === anchor) this.close();
else this.open(anchor);
}
open(anchor) {
this.close();
this.anchor = anchor;
this.isOpen = true;
this.render();
this.reposition();
requestAnimationFrame(() => this.reposition());
window.speechSynthesis?.addEventListener('voiceschanged', this.boundVoices);
window.addEventListener('resize', this.boundReposition);
document.addEventListener('fullscreenchange', this.boundReposition);
document.addEventListener('webkitfullscreenchange', this.boundReposition);
document.addEventListener('scroll', this.boundReposition, true);
setTimeout(() => document.addEventListener('click', this.boundOutsideClick, true), 0);
}
close() {
const wasOpen = this.isOpen;
this.isOpen = false;
this.anchor = null;
this.balloon?.remove();
this.balloon = null;
this.langSelect = null;
this.voiceSelect = null;
window.speechSynthesis?.removeEventListener('voiceschanged', this.boundVoices);
window.removeEventListener('resize', this.boundReposition);
document.removeEventListener('fullscreenchange', this.boundReposition);
document.removeEventListener('webkitfullscreenchange', this.boundReposition);
document.removeEventListener('scroll', this.boundReposition, true);
document.removeEventListener('click', this.boundOutsideClick, true);
if (wasOpen) this.onClose?.();
}
handleOutsideClick(e) {
if (!this.balloon || !this.isOpen) return;
if (this.balloon.contains(e.target)) return;
if (this.anchor?.contains(e.target)) return;
this.close();
}
reposition() {
if (!this.balloon || !this.anchor) return;
const mountTarget = getFullscreenElement() || document.body;
if (this.balloon.parentElement !== mountTarget) {
mountTarget.appendChild(this.balloon);
}
const rect = this.anchor.getBoundingClientRect();
const width = 320;
const gap = 8;
const height = this.balloon.offsetHeight;
let left = rect.right - width;
let top = rect.top - height - gap;
left = clamp(left, 8, window.innerWidth - width - 8);
if (top < 8) top = rect.bottom + gap;
Object.assign(this.balloon.style, {
position: 'fixed',
left: `${left}px`,
top: `${top}px`,
width: `${width}px`,
zIndex: '100000',
});
}
updateSetting(key, value) {
this.settings[key] = value;
saveSettings(this.settings);
this.onChange(this.settings);
if ((key === 'speechEnabled' && !value) || (key === 'speechVolume' && Number(value) <= 0)) {
cancelSpeech();
}
}
createToggle(label, key) {
const row = document.createElement('div');
row.className = 'tbc-balloon-row tbc-balloon-row--toggle';
const labelEl = document.createElement('span');
labelEl.className = 'tbc-balloon-label';
labelEl.textContent = label;
const switchWrap = document.createElement('label');
switchWrap.className = 'tbc-switch';
const input = document.createElement('input');
input.type = 'checkbox';
input.checked = !!this.settings[key];
const slider = document.createElement('span');
slider.className = 'tbc-switch-slider';
input.addEventListener('change', () => {
this.updateSetting(key, input.checked);
});
switchWrap.append(input, slider);
row.append(labelEl, switchWrap);
return row;
}
render() {
this.balloon = document.createElement('div');
this.balloon.className = 'tbc-balloon tbc-speech-panel';
const header = document.createElement('div');
header.className = 'tbc-balloon-header';
const title = document.createElement('div');
title.className = 'tbc-balloon-title';
title.textContent = t('speechPanelTitle');
const closeBtn = document.createElement('button');
closeBtn.type = 'button';
closeBtn.className = 'tbc-balloon-close';
closeBtn.textContent = t('panelClose');
closeBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.close();
});
header.append(title, closeBtn);
const body = document.createElement('div');
body.className = 'tbc-balloon-body tbc-balloon-body--speech';
if (!speechSupported()) {
const note = document.createElement('p');
note.className = 'tbc-speech-note';
note.textContent = t('speechUnsupported');
body.appendChild(note);
this.balloon.append(header, body);
(getFullscreenElement() || document.body).appendChild(this.balloon);
return;
}
body.append(
this.createToggle(t('speechEnable'), 'speechEnabled'),
this.createTest(),
this.createDivider(),
this.createRange(t('speechVolume'), 'speechVolume', 0, 100, 5, (v) => `${v}%`),
this.createRange(t('speechPitch'), 'speechPitch', 0, 200, 10, (v) => `${v}%`),
this.createVoices(),
this.createDivider(),
this.createRange(t('speechRate'), 'speechRate', 50, 200, 10, (v) => `${v}%`),
this.createToggle(t('speechPace'), 'speechPace'),
this.createRange(t('speechMaxSeconds'), 'speechMaxSeconds', 1, 15, 1, (v) => `${v} ${t('speechSeconds')}`),
this.createHint(t('speechPaceHint')),
this.createDivider(),
this.createRange(t('speechBuffer'), 'speechBuffer', 1, 25, 1, (v) => String(v)),
this.createHint(t('speechBufferHint')),
);
this.balloon.append(header, body);
this.balloon.addEventListener('click', (e) => e.stopPropagation());
this.balloon.addEventListener('pointerdown', (e) => e.stopPropagation());
(getFullscreenElement() || document.body).appendChild(this.balloon);
this.fillVoices();
}
createTest() {
const row = document.createElement('div');
row.className = 'tbc-balloon-row tbc-balloon-row--field';
const label = document.createElement('span');
label.className = 'tbc-balloon-label';
label.textContent = t('speechTest');
const controls = document.createElement('div');
controls.className = 'tbc-speech-test';
const input = document.createElement('input');
input.type = 'text';
input.className = 'tbc-balloon-input';
input.value = t('speechTestText');
const play = document.createElement('button');
play.type = 'button';
play.className = 'tbc-speech-play';
play.textContent = '\u25B6';
play.setAttribute('aria-label', t('speechPlay'));
play.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
previewSpeech(input.value, false);
});
const fast = document.createElement('button');
fast.type = 'button';
fast.className = 'tbc-speech-play';
fast.textContent = '▶▶';
fast.setAttribute('aria-label', t('speechPlayFast'));
fast.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
previewSpeech(input.value, true);
});
controls.append(input, play, fast);
row.append(label, controls);
return row;
}
createVoices() {
const row = document.createElement('div');
row.className = 'tbc-balloon-row tbc-balloon-row--field';
const langLabel = document.createElement('span');
langLabel.className = 'tbc-balloon-label';
langLabel.textContent = t('speechVoiceLang');
this.langSelect = document.createElement('select');
this.langSelect.className = 'tbc-balloon-select';
this.langSelect.addEventListener('change', () => {
const lang = this.langSelect.value;
this.updateSetting('speechLang', lang);
const voices = this.voicesForLang(lang);
const next = voices[0]?.voiceURI || '';
this.updateSetting('speechVoice', next);
this.fillVoices();
});
const voiceLabel = document.createElement('span');
voiceLabel.className = 'tbc-balloon-label';
voiceLabel.textContent = t('speechVoice');
this.voiceSelect = document.createElement('select');
this.voiceSelect.className = 'tbc-balloon-select';
this.voiceSelect.addEventListener('change', () => {
this.updateSetting('speechVoice', this.voiceSelect.value);
});
row.append(langLabel, this.langSelect, voiceLabel, this.voiceSelect);
return row;
}
voicesForLang(lang) {
const voices = listSpeechVoices();
if (!lang) return voices;
const wanted = lang.toLowerCase();
return voices.filter((voice) => voice.lang.toLowerCase() === wanted);
}
fillVoices() {
if (!this.langSelect || !this.voiceSelect) return;
const voices = listSpeechVoices();
const langs = [];
for (const voice of voices) {
if (voice.lang && !langs.includes(voice.lang)) langs.push(voice.lang);
}
langs.sort();
const lang = this.settings.speechLang || '';
this.langSelect.replaceChildren();
this.langSelect.appendChild(new Option(t('speechVoiceDefault'), ''));
for (const code of langs) {
this.langSelect.appendChild(new Option(code, code));
}
this.langSelect.value = langs.includes(lang) ? lang : '';
const visible = this.voicesForLang(this.langSelect.value);
this.voiceSelect.replaceChildren();
this.voiceSelect.appendChild(new Option(t('speechVoiceDefault'), ''));
for (const voice of visible) {
const option = new Option(`${voice.name} (${voice.lang})`, voice.voiceURI);
this.voiceSelect.appendChild(option);
}
const selected = this.settings.speechVoice || '';
this.voiceSelect.value = [...this.voiceSelect.options].some((option) => option.value === selected) ? selected : '';
}
createRange(label, key, min, max, step, format) {
const row = document.createElement('div');
row.className = 'tbc-balloon-row tbc-balloon-row--field';
const header = document.createElement('div');
header.className = 'tbc-balloon-field-header';
const labelEl = document.createElement('span');
labelEl.className = 'tbc-balloon-label';
labelEl.textContent = label;
const valueSpan = document.createElement('span');
valueSpan.className = 'tbc-balloon-value';
valueSpan.textContent = format(this.settings[key]);
header.append(labelEl, valueSpan);
const input = document.createElement('input');
input.type = 'range';
input.className = 'tbc-balloon-range';
input.min = String(min);
input.max = String(max);
input.step = String(step);
input.value = String(this.settings[key]);
input.addEventListener('input', () => {
const val = Number(input.value);
valueSpan.textContent = format(val);
this.updateSetting(key, val);
});
row.append(header, input);
return row;
}
createSection(text) {
const el = document.createElement('div');
el.className = 'tbc-balloon-section';
el.textContent = text;
return el;
}
createHint(text) {
const el = document.createElement('p');
el.className = 'tbc-speech-note';
el.textContent = text;
return el;
}
createDivider() {
const el = document.createElement('div');
el.className = 'tbc-balloon-divider';
return el;
}
}
class HistoryPanel {
constructor({ onClose, getIrcComments }) {
this.onClose = onClose;
this.getIrcComments = getIrcComments;
this.balloon = null;
this.anchor = null;
this.isOpen = false;
this.job = 0;
this.loadToken = 0;
this.state = 'idle';
this.paused = false;
this.lastCount = 0;
this.video = null;
this.videoId = '';
this.mode = null;
this.duration = 0;
this.start = 0;
this.end = 0;
this.boundReposition = () => this.reposition();
this.boundOutsideClick = (event) => this.handleOutsideClick(event);
}
toggle(anchor) {
if (this.isOpen && this.anchor === anchor) this.close();
else this.open(anchor);
}
open(anchor) {
this.close();
this.anchor = anchor;
this.isOpen = true;
this.render();
this.reposition();
requestAnimationFrame(() => this.reposition());
window.addEventListener('resize', this.boundReposition);
document.addEventListener('fullscreenchange', this.boundReposition);
document.addEventListener('webkitfullscreenchange', this.boundReposition);
document.addEventListener('scroll', this.boundReposition, true);
setTimeout(() => document.addEventListener('click', this.boundOutsideClick, true), 0);
this.loadVideo();
}
close() {
const wasOpen = this.isOpen;
this.job += 1;
this.loadToken += 1;
this.state = 'idle';
this.paused = false;
this.lastCount = 0;
this.isOpen = false;
this.anchor = null;
this.balloon?.remove();
this.balloon = null;
window.removeEventListener('resize', this.boundReposition);
document.removeEventListener('fullscreenchange', this.boundReposition);
document.removeEventListener('webkitfullscreenchange', this.boundReposition);
document.removeEventListener('scroll', this.boundReposition, true);
document.removeEventListener('click', this.boundOutsideClick, true);
if (wasOpen) this.onClose?.();
}
handleOutsideClick(event) {
if (!this.balloon || !this.isOpen) return;
if (this.balloon.contains(event.target)) return;
if (this.anchor?.contains(event.target)) return;
if (this.state !== 'idle') return;
this.close();
}
reposition() {
if (!this.balloon || !this.anchor) return;
const mountTarget = getFullscreenElement() || document.body;
if (this.balloon.parentElement !== mountTarget) mountTarget.appendChild(this.balloon);
const rect = this.anchor.getBoundingClientRect();
const width = 360;
const gap = 8;
const height = this.balloon.offsetHeight;
let left = rect.right - width;
let top = rect.top - height - gap;
left = clamp(left, 8, window.innerWidth - width - 8);
if (top < 8) top = rect.bottom + gap;
Object.assign(this.balloon.style, {
position: 'fixed',
left: `${left}px`,
top: `${top}px`,
width: `${width}px`,
zIndex: '100000',
});
}
setStatus(text, kind) {
if (!this.statusEl) return;
this.statusEl.textContent = text || '';
this.statusEl.classList.toggle('tbc-history-status--error', kind === 'error');
this.statusEl.classList.toggle('tbc-history-status--ok', kind === 'ok');
}
setDownloadState(state) {
this.state = state;
const idle = state === 'idle';
const running = state === 'running';
const paused = state === 'paused';
this.downloadBtn.hidden = !idle;
this.pauseBtn.hidden = !running;
this.resumeBtn.hidden = !paused;
this.cancelBtn.hidden = idle;
this.downloadBtn.disabled = !this.video;
for (const input of this.balloon.querySelectorAll('input, select')) {
if (input.closest('.tbc-history-actions')) continue;
input.disabled = !idle || (input.type === 'range' && !this.duration);
}
}
async loadVideo() {
const token = ++this.loadToken;
this.video = null;
this.videoId = currentVodId();
this.mode = null;
this.duration = 0;
this.metaEl.textContent = '';
this.setDownloadState('idle');
this.downloadBtn.disabled = true;
if (!this.videoId) {
const liveInfo = readLiveChannelInfo();
if (token !== this.loadToken) return;
if (!liveInfo) {
this.syncRange();
this.setStatus(t('historyNeedSource'), 'error');
return;
}
this.mode = 'live';
this.video = liveInfo;
this.duration = 0;
this.start = 0;
this.end = 0;
this.metaEl.textContent = liveInfo.channel || liveInfo.login;
this.metaEl.title = liveInfo.title || liveInfo.login;
this.syncRange();
this.setDownloadState('idle');
this.setStatus(t('historyLiveReady'), '');
return;
}
this.mode = 'vod';
this.setStatus(t('historyLoading'), '');
try {
const info = await fetchVodInfo(this.videoId);
if (token !== this.loadToken) return;
if (!info) {
this.syncRange();
this.setStatus(t('historyMissing'), 'error');
return;
}
this.video = info;
this.duration = info.lengthSeconds || playerDurationSeconds();
this.start = 0;
this.end = this.duration;
this.metaEl.textContent = info.title || info.id;
this.metaEl.title = info.title || '';
this.syncRange();
this.setDownloadState('idle');
this.setStatus('', '');
} catch {
if (token !== this.loadToken) return;
this.syncRange();
this.setStatus(t('historyFailed'), 'error');
}
}
syncRange() {
if (this.mode === 'live') {
this.rangeHeader.hidden = true;
this.rangeWrap.hidden = true;
this.rangeUnknown.hidden = true;
this.liveHint.hidden = false;
return;
}
this.rangeHeader.hidden = false;
this.liveHint.hidden = true;
const known = this.duration > 0;
this.rangeWrap.hidden = !known;
this.rangeUnknown.hidden = known || !this.video;
if (!known) return;
this.startInput.max = String(this.duration);
this.endInput.max = String(this.duration);
this.startInput.value = String(this.start);
this.endInput.value = String(this.end);
this.updateRange();
}
updateRange() {
if (!this.duration) return;
const startPct = (this.start / this.duration) * 100;
const endPct = (this.end / this.duration) * 100;
this.rangeFill.style.left = `${startPct}%`;
this.rangeFill.style.width = `${Math.max(0, endPct - startPct)}%`;
this.rangeLabel.textContent = `${formatChatClock(this.start)} – ${formatChatClock(this.end)}`;
}
selectedFilters() {
return {
keywords: this.keywordInput.value,
usernames: this.usernameInput.value,
includeSuperchat: this.superchatInput.checked,
format: this.formatSelect.value,
};
}
pauseDownload() {
if (this.state !== 'running') return;
this.paused = true;
this.setDownloadState('paused');
this.setStatus(fill('historyPaused', { count: this.lastCount }), '');
this.reposition();
}
resumeDownload() {
if (this.state !== 'paused') return;
this.paused = false;
this.setDownloadState('running');
this.setStatus(fill('historyProgress', { count: this.lastCount }), '');
}
cancelDownload() {
if (this.state === 'idle') return;
this.job += 1;
this.paused = false;
this.setDownloadState('idle');
this.setStatus(t('historyCancelled'), '');
}
async startDownload() {
if (!this.video || this.state !== 'idle') return;
const token = ++this.job;
const filters = this.selectedFilters();
this.paused = false;
this.lastCount = 0;
this.setDownloadState('running');
this.setStatus(fill('historyProgress', { count: 0 }), '');
try {
const progress = ({ count }) => {
if (token !== this.job) return;
this.lastCount = count;
if (!this.paused) {
this.setStatus(fill('historyProgress', { count }), '');
this.reposition();
}
};
const controls = {
keywords: filters.keywords,
usernames: filters.usernames,
includeSuperchat: filters.includeSuperchat,
shouldCancel: () => token !== this.job,
shouldPause: () => token === this.job && this.paused,
onProgress: progress,
};
const ircComments = this.mode === 'live' && typeof this.getIrcComments === 'function'
? this.getIrcComments()
: null;
const result = this.mode === 'live'
? await collectLiveDomChat({
...controls,
ircComments: Array.isArray(ircComments) ? ircComments : null,
})
: await downloadVodChat({
videoId: this.video.id,
start: this.duration ? this.start : 0,
end: this.duration ? this.end : Infinity,
...controls,
});
if (token !== this.job) return;
if (!result.comments.length) {
this.setStatus(result.scanned ? t('historyNoMatch') : t('historyEmpty'), 'error');
return;
}
const file = buildChatExport(filters.format, this.video, result);
saveChatFile(file.filename, file.text, file.mime);
this.setStatus(fill('historyDone', { count: result.comments.length }), 'ok');
} catch (error) {
if (token !== this.job || error?.code === 'cancel') return;
const missingKey = this.mode === 'live' ? 'historyEmpty' : 'historyMissing';
this.setStatus(error?.code === 'missing' ? t(missingKey) : t('historyFailed'), 'error');
} finally {
if (token === this.job) {
this.paused = false;
this.setDownloadState('idle');
}
}
}
render() {
this.balloon = document.createElement('div');
this.balloon.className = 'tbc-balloon tbc-history-panel';
const header = document.createElement('div');
header.className = 'tbc-balloon-header';
const title = document.createElement('div');
title.className = 'tbc-balloon-title';
title.textContent = t('historyPanelTitle');
const closeBtn = document.createElement('button');
closeBtn.type = 'button';
closeBtn.className = 'tbc-balloon-close';
closeBtn.textContent = t('panelClose');
closeBtn.addEventListener('click', (event) => {
event.stopPropagation();
this.close();
});
header.append(title, closeBtn);
const body = document.createElement('div');
body.className = 'tbc-balloon-body tbc-balloon-body--history';
this.metaEl = document.createElement('p');
this.metaEl.className = 'tbc-history-meta';
body.append(
this.metaEl,
this.createTextField('historyKeyword', 'keywordInput', 'historyKeywordPlaceholder', 'historyKeywordHint'),
this.createTextField('historyUsername', 'usernameInput', 'historyUsernamePlaceholder', 'historyUsernameHint'),
this.createSuperchatToggle(),
this.createRangeField(),
this.createFormatField(),
this.createActions(),
);
this.balloon.append(header, body);
this.balloon.addEventListener('click', (event) => event.stopPropagation());
this.balloon.addEventListener('pointerdown', (event) => event.stopPropagation());
(getFullscreenElement() || document.body).appendChild(this.balloon);
}
createTextField(labelKey, field, placeholderKey, hintKey) {
const row = document.createElement('div');
row.className = 'tbc-balloon-row tbc-balloon-row--field';
const label = document.createElement('span');
label.className = 'tbc-balloon-label';
label.textContent = t(labelKey);
const input = document.createElement('input');
input.type = 'text';
input.className = 'tbc-balloon-input';
input.placeholder = t(placeholderKey);
input.autocomplete = 'off';
this[field] = input;
const hint = document.createElement('p');
hint.className = 'tbc-history-hint';
hint.textContent = t(hintKey);
row.append(label, input, hint);
return row;
}
createSuperchatToggle() {
const row = document.createElement('div');
row.className = 'tbc-balloon-row tbc-balloon-row--field';
const line = document.createElement('div');
line.className = 'tbc-balloon-row--toggle';
line.style.padding = '0';
const label = document.createElement('span');
label.className = 'tbc-balloon-label';
label.textContent = t('historyIncludeSuperchat');
const switchWrap = document.createElement('label');
switchWrap.className = 'tbc-switch';
const input = document.createElement('input');
input.type = 'checkbox';
input.checked = true;
const slider = document.createElement('span');
slider.className = 'tbc-switch-slider';
switchWrap.append(input, slider);
line.append(label, switchWrap);
const hint = document.createElement('p');
hint.className = 'tbc-history-hint';
hint.textContent = t('historyIncludeSuperchatHint');
this.superchatInput = input;
row.append(line, hint);
return row;
}
createRangeField() {
const row = document.createElement('div');
row.className = 'tbc-balloon-row tbc-balloon-row--field';
this.rangeHeader = document.createElement('div');
this.rangeHeader.className = 'tbc-balloon-field-header';
const label = document.createElement('span');
label.className = 'tbc-balloon-label';
label.textContent = t('historyRange');
this.rangeLabel = document.createElement('span');
this.rangeLabel.className = 'tbc-balloon-value';
this.rangeHeader.append(label, this.rangeLabel);
this.rangeWrap = document.createElement('div');
this.rangeWrap.className = 'tbc-history-range';
this.rangeWrap.hidden = true;
const track = document.createElement('div');
track.className = 'tbc-history-track';
this.rangeFill = document.createElement('div');
this.rangeFill.className = 'tbc-history-fill';
track.appendChild(this.rangeFill);
this.startInput = document.createElement('input');
this.startInput.type = 'range';
this.startInput.min = '0';
this.startInput.value = '0';
this.startInput.addEventListener('input', () => {
this.start = Math.min(Number(this.startInput.value), this.end);
this.startInput.value = String(this.start);
this.updateRange();
});
this.endInput = document.createElement('input');
this.endInput.type = 'range';
this.endInput.min = '0';
this.endInput.value = '0';
this.endInput.addEventListener('input', () => {
this.end = Math.max(Number(this.endInput.value), this.start);
this.endInput.value = String(this.end);
this.updateRange();
});
this.rangeWrap.append(track, this.startInput, this.endInput);
this.rangeUnknown = document.createElement('p');
this.rangeUnknown.className = 'tbc-history-hint';
this.rangeUnknown.textContent = t('historyRangeUnknown');
this.rangeUnknown.hidden = true;
this.liveHint = document.createElement('p');
this.liveHint.className = 'tbc-history-hint';
this.liveHint.textContent = t('historyLiveHint');
this.liveHint.hidden = true;
row.append(this.rangeHeader, this.rangeWrap, this.rangeUnknown, this.liveHint);
return row;
}
createFormatField() {
const row = document.createElement('div');
row.className = 'tbc-balloon-row tbc-balloon-row--field';
const label = document.createElement('span');
label.className = 'tbc-balloon-label';
label.textContent = t('historyFormat');
this.formatSelect = document.createElement('select');
this.formatSelect.className = 'tbc-balloon-select';
for (const [value, key] of [['json', 'historyFormatJson'], ['csv', 'historyFormatCsv'], ['txt', 'historyFormatTxt']]) {
this.formatSelect.appendChild(new Option(t(key), value));
}
row.append(label, this.formatSelect);
return row;
}
createActionButton(className, label, onClick) {
const button = document.createElement('button');
button.type = 'button';
button.className = className;
button.textContent = label;
button.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
onClick();
});
return button;
}
createActions() {
const row = document.createElement('div');
row.className = 'tbc-history-actions';
const buttons = document.createElement('div');
buttons.className = 'tbc-history-buttons';
this.downloadBtn = this.createActionButton('tbc-history-download', t('historyDownload'), () => this.startDownload());
this.pauseBtn = this.createActionButton('tbc-history-pause', t('historyPause'), () => this.pauseDownload());
this.resumeBtn = this.createActionButton('tbc-history-resume', t('historyResume'), () => this.resumeDownload());
this.cancelBtn = this.createActionButton('tbc-history-cancel', t('historyCancel'), () => this.cancelDownload());
this.pauseBtn.hidden = true;
this.resumeBtn.hidden = true;
this.cancelBtn.hidden = true;
this.downloadBtn.disabled = true;
this.statusEl = document.createElement('p');
this.statusEl.className = 'tbc-history-status';
buttons.append(this.downloadBtn, this.pauseBtn, this.resumeBtn, this.cancelBtn);
row.append(buttons, this.statusEl);
return row;
}
}
class ChatContextMenu {
constructor({ getSettings, danmakuEmitter, isActive }) {
this.getSettings = getSettings;
this.danmakuEmitter = danmakuEmitter;
this.isActive = isActive;
this.menu = null;
this.targetEl = null;
this.boundContext = (event) => this.handleContextMenu(event);
this.boundClose = (event) => this.handleOutside(event);
this.boundKey = (event) => {
if (event.key === 'Escape') this.close();
};
this.attachedRoot = null;
}
attach(root = document) {
this.detach();
this.attachedRoot = root;
root.addEventListener('contextmenu', this.boundContext, true);
}
detach() {
this.close();
this.attachedRoot?.removeEventListener('contextmenu', this.boundContext, true);
this.attachedRoot = null;
}
findMessageEl(node) {
if (!node || node.nodeType !== Node.ELEMENT_NODE) {
node = node?.parentElement;
}
return node?.closest?.(SELECTORS.chatMessage) || null;
}
handleContextMenu(event) {
if (!this.isActive?.()) return;
const msgEl = this.findMessageEl(event.target);
if (!msgEl) return;
const snapshot = snapshotChatMessage(msgEl);
if (!snapshot?.message) return;
event.preventDefault();
event.stopPropagation();
this.open(event.clientX, event.clientY, msgEl, snapshot);
}
handleOutside(event) {
if (!this.menu) return;
if (this.menu.contains(event.target)) return;
this.close();
}
close() {
this.menu?.remove();
this.menu = null;
this.targetEl = null;
document.removeEventListener('click', this.boundClose, true);
document.removeEventListener('keydown', this.boundKey, true);
window.removeEventListener('scroll', this.boundClose, true);
window.removeEventListener('resize', this.boundClose, true);
}
open(x, y, msgEl, snapshot) {
this.close();
this.targetEl = msgEl;
this.menu = document.createElement('div');
this.menu.className = 'tbc-chat-menu';
this.menu.setAttribute('role', 'menu');
const hasTranslation = !!msgEl.querySelector('.tbc-chat-translation');
this.menu.append(
this.createItem(hasTranslation ? t('chatMenuRetranslate') : t('chatMenuTranslate'), () => {
this.runTranslate(msgEl, snapshot, true);
}),
this.createItem(t('chatMenuSpeak'), () => {
this.runSpeak(snapshot);
}),
this.createItem(t('chatMenuResend'), () => {
this.runResend(msgEl, snapshot, false);
}),
);
const mount = getFullscreenElement() || document.body;
mount.appendChild(this.menu);
const rect = this.menu.getBoundingClientRect();
const left = clamp(x, 8, window.innerWidth - rect.width - 8);
const top = clamp(y, 8, window.innerHeight - rect.height - 8);
Object.assign(this.menu.style, {
left: `${left}px`,
top: `${top}px`,
});
setTimeout(() => {
document.addEventListener('click', this.boundClose, true);
document.addEventListener('keydown', this.boundKey, true);
window.addEventListener('scroll', this.boundClose, true);
window.addEventListener('resize', this.boundClose, true);
}, 0);
}
createItem(label, onClick) {
const button = document.createElement('button');
button.type = 'button';
button.className = 'tbc-chat-menu-item';
button.setAttribute('role', 'menuitem');
button.textContent = label;
button.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
this.close();
onClick();
});
return button;
}
async translateParts(snapshot, bustCache = false) {
const settings = this.getSettings();
const parts = snapshot.parts?.length
? snapshot.parts.map((part) => ({ ...part }))
: [{ type: 'text', value: snapshot.message }];
const refs = [];
const pieces = [];
parts.forEach((part, index) => {
const core = part.type === 'text' ? translatableCore(part.value) : '';
if (!core) return;
if (bustCache) forgetCachedTranslation(core, settings.translateTargetLang);
refs.push(index);
pieces.push(core);
});
if (!pieces.length) return { parts, message: snapshot.message, translated: false };
let provider = settings.translateProvider || 'auto';
let outcome = await translateMessages(pieces, settings.translateTargetLang, provider);
if (outcome.retryAfter && provider !== 'auto') {
outcome = await translateMessages(pieces, settings.translateTargetLang, 'auto');
}
if (outcome.retryAfter || !Array.isArray(outcome.texts)) {
return { parts, message: snapshot.message, translated: false, failed: true };
}
refs.forEach((partIndex, i) => {
const part = parts[partIndex];
const match = String(part.value).match(/^(\s*)([\s\S]*?)(\s*)$/);
const translated = outcome.texts[i] || match[2];
parts[partIndex] = {
type: 'text',
value: `${match[1]}${translated}${match[3]}`,
};
});
const message = partsToPlain(parts) || snapshot.message;
return { parts, message, translated: message !== snapshot.message, failed: false };
}
async runTranslate(msgEl, snapshot, bustCache) {
const result = await this.translateParts(snapshot, bustCache);
if (result.failed) return;
applyChatTranslation(msgEl, result.message, snapshot.message);
}
runSpeak(snapshot) {
const settings = this.getSettings();
if (!speechSupported()) return;
const source = speechSourceText(snapshot.message, snapshot.parts);
// Explicit menu action: use panel voice/rate/volume, even if auto-speak is off.
if (Number(settings.speechVolume) <= 0) return;
speakTextNow(source);
}
async runResend(msgEl, snapshot, forceTranslate) {
const settings = this.getSettings();
let message = snapshot.message;
let parts = snapshot.parts;
let translated = false;
const shouldTranslate = forceTranslate || settings.translateEnabled;
if (shouldTranslate) {
const result = await this.translateParts(snapshot, forceTranslate);
if (!result.failed) {
message = result.message;
parts = result.parts;
translated = result.translated;
if (settings.translateInChat) {
applyChatTranslation(msgEl, message, snapshot.message);
}
}
}
this.danmakuEmitter?.enqueue({
username: snapshot.username,
usernameColor: snapshot.usernameColor,
profileUrl: snapshot.profileUrl,
avatarUrl: snapshot.avatarUrl,
originalMessage: snapshot.message,
message,
parts,
translated,
superchat: !!snapshot.superchat,
superchatStyle: snapshot.superchatStyle,
superchatMeta: snapshot.superchatMeta,
});
}
}
class ControlButton {
constructor({ onToggleDanmaku, onTogglePanel, onToggleSpeech, onToggleHistory, getSettings, isPanelOpen, isSpeechPanelOpen, isHistoryPanelOpen }) {
this.onToggleDanmaku = onToggleDanmaku;
this.onTogglePanel = onTogglePanel;
this.onToggleSpeech = onToggleSpeech;
this.onToggleHistory = onToggleHistory;
this.getSettings = getSettings;
this.isPanelOpen = isPanelOpen;
this.isSpeechPanelOpen = isSpeechPanelOpen;
this.isHistoryPanelOpen = isHistoryPanelOpen;
this.wrap = null;
this.danmakuBtn = null;
this.speechBtn = null;
this.historyBtn = null;
this.settingsBtn = null;
this.controlsGroup = null;
this.retryTimer = null;
}
createBtn(className, label, html) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = `tbc-control-btn ${className}`;
btn.setAttribute('aria-label', label);
btn.innerHTML = html;
btn.addEventListener('click', (e) => {
e.stopPropagation();
});
return btn;
}
async mount() {
this.controlsGroup = await waitForElement(SELECTORS.playerControls);
if (!this.controlsGroup || this.controlsGroup.querySelector('.tbc-controls-wrap')) return;
this.wrap = document.createElement('div');
this.wrap.className = 'tbc-controls-wrap';
this.danmakuBtn = this.createBtn('tbc-danmaku-btn', t('danmakuToggle'), `
${ICON_DANMAKU_ON}
${ICON_DANMAKU_OFF}
`);
this.danmakuBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.onToggleDanmaku();
});
this.speechBtn = this.createBtn('tbc-speech-btn', t('speechToggle'), `
${ICON_SPEECH_ON}
${ICON_SPEECH_MUTE}
${ICON_SPEECH_OFF}
`);
this.speechBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.onToggleSpeech(this.speechBtn);
});
this.settingsBtn = this.createBtn('tbc-settings-btn', t('settingsToggle'), ICON_SETTINGS);
this.settingsBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.onTogglePanel(this.settingsBtn);
});
this.historyBtn = this.createBtn('tbc-history-btn', t('historyToggle'), ICON_DOWNLOAD);
this.historyBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.onToggleHistory(this.historyBtn);
});
this.wrap.append(this.danmakuBtn, this.settingsBtn, this.speechBtn, this.historyBtn);
this.controlsGroup.appendChild(this.wrap);
this.updateDanmakuState();
this.updateSpeechState();
this.updatePanelState();
}
remount() {
if (this.wrap?.isConnected && this.wrap.querySelector('.tbc-speech-btn') && this.wrap.querySelector('.tbc-history-btn')) return;
this.wrap?.remove();
this.wrap = null;
this.danmakuBtn = null;
this.speechBtn = null;
this.historyBtn = null;
this.settingsBtn = null;
clearTimeout(this.retryTimer);
this.retryTimer = setTimeout(() => this.mount(), 500);
}
updateDanmakuState() {
if (!this.danmakuBtn) return;
const enabled = this.getSettings().enabled;
this.danmakuBtn.classList.toggle('tbc-danmaku-enabled', enabled);
this.danmakuBtn.classList.toggle('tbc-danmaku-disabled', !enabled);
this.danmakuBtn.title = enabled ? t('danmakuOn') : t('danmakuOff');
}
updateSpeechState() {
if (!this.speechBtn) return;
const settings = this.getSettings();
const enabled = !!settings.speechEnabled;
const muted = enabled && Number(settings.speechVolume) <= 0;
const current = !enabled ? 'off' : muted ? 'mute' : 'on';
this.speechBtn.classList.toggle('tbc-speech-on', current === 'on');
this.speechBtn.classList.toggle('tbc-speech-mute', current === 'mute');
this.speechBtn.classList.toggle('tbc-speech-off', current === 'off');
this.speechBtn.classList.toggle('tbc-active', !!this.isSpeechPanelOpen?.());
const label = current === 'on' ? t('speechOn') : current === 'mute' ? t('speechMute') : t('speechOff');
this.speechBtn.title = label;
this.speechBtn.setAttribute('aria-pressed', enabled ? 'true' : 'false');
this.speechBtn.setAttribute('aria-expanded', this.isSpeechPanelOpen?.() ? 'true' : 'false');
}
updateHistoryState() {
if (!this.historyBtn) return;
const open = !!this.isHistoryPanelOpen?.();
this.historyBtn.classList.toggle('tbc-active', open);
this.historyBtn.title = t('historyToggle');
this.historyBtn.setAttribute('aria-expanded', open ? 'true' : 'false');
}
updatePanelState() {
if (!this.settingsBtn) return;
this.settingsBtn.classList.toggle('tbc-active', this.isPanelOpen());
this.settingsBtn.setAttribute('aria-expanded', this.isPanelOpen() ? 'true' : 'false');
this.updateSpeechState();
this.updateHistoryState();
}
destroy() {
clearTimeout(this.retryTimer);
this.wrap?.remove();
this.wrap = null;
this.danmakuBtn = null;
this.speechBtn = null;
this.historyBtn = null;
this.settingsBtn = null;
}
}
let settings = loadSettings();
let danmaku = null;
let danmakuEmitter = null;
let translateQueue = null;
let chatObserver = null;
let chatContextMenu = null;
let settingsPanel = null;
let speechPanel = null;
let historyPanel = null;
let controlButton = null;
let stylesInjected = false;
let navPath = location.pathname;
let pageActive = isPageActive();
function getSettings() {
return settings;
}
function isActive() {
if (document.documentElement.dataset.tbcPreview === 'true') return true;
return pageActive;
}
function onSettingsChange(newSettings) {
settings = newSettings;
danmaku?.updateLayout();
controlButton?.updateDanmakuState();
controlButton?.updateSpeechState();
if (!settings.translateEnabled) {
translateQueue?.clear();
clearChatTranslations();
} else if (!settings.translateInChat) {
clearChatTranslations();
}
if (!settings.speechEnabled || Number(settings.speechVolume) <= 0) {
cancelSpeech();
}
}
function toggleDanmaku() {
settings.enabled = !settings.enabled;
saveSettings(settings);
onSettingsChange(settings);
}
function handleVisibilityChange() {
if (document.documentElement.dataset.tbcPreview === 'true') {
pageActive = true;
return;
}
const wasActive = pageActive;
pageActive = isPageActive();
if (wasActive && !pageActive) {
danmakuEmitter?.clear();
translateQueue?.clear();
cancelSpeech();
}
}
function teardown() {
chatObserver?.stop();
chatContextMenu?.detach();
danmaku?.destroy();
controlButton?.destroy();
danmakuEmitter?.clear();
translateQueue?.clear();
clearChatTranslations();
settingsPanel?.close();
speechPanel?.close();
historyPanel?.close();
cancelSpeech();
chatObserver = null;
chatContextMenu = null;
danmaku = null;
danmakuEmitter = null;
translateQueue = null;
controlButton = null;
}
async function boot() {
settings = loadSettings();
pageActive = isPageActive();
if (!stylesInjected) {
injectStyles();
stylesInjected = true;
}
ensureSpeechSpeaker(getSettings);
danmaku = new DanmakuEngine(getSettings);
danmakuEmitter = new DanmakuEmitter(danmaku, getSettings, isActive);
translateQueue = new TranslateQueue(getSettings, isActive);
chatObserver = new ChatObserver({
getSettings,
danmakuEmitter,
translateQueue,
isActive,
});
chatContextMenu = new ChatContextMenu({
getSettings,
danmakuEmitter,
isActive,
});
chatContextMenu.attach(document);
if (!settingsPanel) {
settingsPanel = new SettingsPanel({
settings,
onChange: onSettingsChange,
onClose: () => controlButton?.updatePanelState(),
});
} else {
settingsPanel.settings = settings;
}
if (!speechPanel) {
speechPanel = new SpeechPanel({
settings,
onChange: onSettingsChange,
onClose: () => controlButton?.updateSpeechState(),
});
} else {
speechPanel.settings = settings;
}
if (!historyPanel) {
historyPanel = new HistoryPanel({
onClose: () => controlButton?.updateHistoryState(),
getIrcComments: () => (
chatObserver?.mode === 'irc' ? chatObserver.getIrcHistoryComments() : null
),
});
} else {
historyPanel.getIrcComments = () => (
chatObserver?.mode === 'irc' ? chatObserver.getIrcHistoryComments() : null
);
}
controlButton = new ControlButton({
onToggleDanmaku: toggleDanmaku,
onTogglePanel: (anchor) => {
speechPanel.close();
historyPanel.close();
settingsPanel.toggle(anchor);
controlButton.updatePanelState();
},
onToggleSpeech: (anchor) => {
settingsPanel.close();
historyPanel.close();
speechPanel.toggle(anchor);
controlButton.updatePanelState();
},
onToggleHistory: (anchor) => {
settingsPanel.close();
speechPanel.close();
historyPanel.toggle(anchor);
controlButton.updatePanelState();
},
getSettings,
isPanelOpen: () => settingsPanel.isOpen,
isSpeechPanelOpen: () => speechPanel.isOpen,
isHistoryPanelOpen: () => historyPanel.isOpen,
});
// Mount player UI first so FFZ/chat delays cannot block controls.
await Promise.all([
danmaku.mount(),
controlButton.mount(),
]);
await chatObserver.start();
console.log(`[TBC] Twitch Danmaku initialized${chatObserver.ffzActive ? ' (FFZ compat)' : ''}`);
}
function watchNavigation() {
setInterval(() => {
if (location.pathname !== navPath) {
navPath = location.pathname;
teardown();
boot().catch((err) => console.error('[TBC] Reinit failed:', err));
} else {
controlButton?.remount();
}
}, 1500);
}
document.addEventListener('visibilitychange', handleVisibilityChange);
boot().catch((err) => console.error('[TBC] Init failed:', err));
watchNavigation();
})();