// ==UserScript== // @name Twitch 弹幕姬 | 实时翻译 // @namespace https://github.com/saka1zum1/twitch-danmaku // @version 2.0.2 // @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: '启用翻译', translateToggle: '实时翻译', translateOn: '翻译已开启', translateOff: '翻译已关闭', translateInChat: '聊天室显示译文', translateBadge: '译', translateProvider: '翻译引擎', translateProviderAuto: '自动切换', translateProviderMicrosoft: 'Microsoft 翻译', translateProviderGoogle: 'Google 翻译', translateProviderVolcengine: '火山翻译', translateProviderCaiyun: '彩云小译', translateProviderTencent: '腾讯交互翻译', translateTarget: '目标语言', translateSpeed: '翻译速度', translateSpeedUnit: '条/秒', blockKeywords: '关键词屏蔽', blockKeywordsHint: '原文或译文包含关键词时不显示', blockKeywordsPlaceholder: '输入关键词', blockKeywordsAdd: '添加', blockKeywordsRemove: '移除', blockKeywordsEmpty: '暂无关键词', blockUsers: '屏蔽用户列表', blockUsersHint: '使用 Twitch 原生屏蔽。可在此查看、添加或取消屏蔽。', blockUsersPlaceholder: '用户名', blockUsersAdd: '添加', blockUsersRemove: '取消屏蔽', blockUsersEmpty: '暂无屏蔽用户', blockUsersLoading: '正在加载屏蔽列表…', blockUsersRefresh: '刷新', blockUsersNeedLogin: '请先登录 Twitch', blockUsersLoadFailed: '无法加载屏蔽列表', blockUsersAddFailed: '添加屏蔽失败', blockUsersRemoveFailed: '取消屏蔽失败', chatMenuBlockNeedLogin: '请先登录 Twitch', chatMenuBlockDone: '已通过 Twitch 屏蔽该用户', chatMenuBlockFailed: 'Twitch 屏蔽失败,请稍后重试', chatMenuBlockNotFound: '找不到该用户', 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: '重新发送弹幕', chatMenuBlockUser: '屏蔽该用户', chatMenuFavorite: '收藏', favoritesTitle: '收藏弹幕', favoritesAdd: '添加', favoritesAddPlaceholder: '输入要收藏的消息…', favoritesEmpty: '暂无收藏', favoritesEmptyText: '请输入内容', favoritesAdded: '已加入收藏', favoritesExists: '已在收藏中', favoritesFull: '收藏已满(最多 40 条)', favoritesSend: '发送', favoritesRemove: '移除', favoritesSent: '已发送', favoritesShortcutNone: '无快捷键', favoritesScopeGlobal: '全站可用', favoritesScopeChannel: '本直播间 · {channel}', favoritesMakeGlobal: '全站', favoritesMakeChannel: '本房', favoritesMakeGlobalHint: '设为所有直播间可用', favoritesMakeChannelHint: '仅当前直播间可用', favoritesScopedGlobal: '已设为全站可用', favoritesScopedChannel: '已设为仅本直播间', favoritesNeedChannel: '无法识别当前直播间', favoritesWheel: '独轮车模式', favoritesWheelStart: '开始', favoritesWheelStop: '停止', favoritesWheelIdle: '未运行', favoritesWheelRunning: '已发送 {sent}/{max} · 剩余 {left}s', favoritesWheelStopped: '独轮车已停止', favoritesWheelMinInterval: '最短间隔(秒)', favoritesWheelMaxInterval: '最长间隔(秒)', favoritesWheelMaxCount: '最多条数', favoritesWheelMaxDuration: '最长持续(秒)', fsChatFavorites: '收藏', fsChatFavoritesEmpty: '暂无收藏', superchatPinned: 'Super Chat 悬停', superchatPosition: '悬停位置', superchatDuration: '悬停时间', superchatAnimation: '悬停动画', superchatTop: '顶部居中', superchatBottom: '底部居中', superchatTopLeft: '左上角', superchatTopRight: '右上角', superchatBottomLeft: '左下角', superchatBottomRight: '右下角', superchatFade: '淡入淡出', superchatSlide: '滑入', superchatPop: '弹出', superchatShine: '闪光', superchatClose: '关闭悬停弹幕', superchatHideSubscribe: '屏蔽订阅悬停', superchatCompact: '精简悬停显示', superchatMaxWidth: '悬停最大宽度', superchatCardClose: '关闭 Super Chat 卡片', superchatOverlayDanmaku: '悬停遮挡滚动弹幕', superchatDurationByAmount: '悬停时长随金额调整', fsChatPlaceholder: '发送聊天消息…', fsChatSend: '发送', fsChatClose: '关闭', fsChatNeedLogin: '请先登录 Twitch', fsChatNeedFollow: '需要先关注主播才能发言', fsChatNeedFollowTime: '关注时长不足,暂时无法发言', fsChatNeedSub: '仅订阅者可发言', fsChatNeedVerified: '需要验证账号才能发言', fsChatNeedPhone: '需要验证手机号才能发言', fsChatSlowMode: '慢速模式,请稍后再试', fsChatEmoteOnly: '当前为仅表情模式', fsChatUniqueMode: '消息需保持唯一', fsChatAutomod: '消息被 Automod 拦截审核', fsChatTimedOut: '你当前处于禁言状态', fsChatBanned: '你已被此直播间封禁', fsChatDuplicate: '不能发送重复消息', fsChatRateLimit: '发送过于频繁,请稍后再试', fsChatFailed: '发送失败,请稍后重试', fsChatSent: '已发送', scrollAreaFull: '全屏', scrollAreaTopHalf: '上半屏', scrollAreaBottomHalf: '下半屏', 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: '打开发送者主页', chatClear: '清空聊天', pipOpen: '增强画中画', pipClose: '关闭增强画中画', pipTitle: '增强画中画', pipNeedBrowser: '增强画中画需要 Chrome 或 Edge 116 及以上', pipNoVideo: '没有可用的播放器画面', pipCaptureFailed: '无法捕获画面', pipOpenFailed: '无法打开画中画窗口', pipReload: '刷新画面', pipReloaded: '画面已刷新', pipReloadFailed: '刷新失败,请检查直播页视频', pipDanmaku: '弹幕开关', pipSettings: '弹幕设置', pipSettingsHint: '已在直播页打开设置', pipSend: '发送消息', pipBack: '回到直播页', pipVodNoSend: '回放页不能发送聊天', pipAspectLock: '锁定纵横比', pipAspectUnlock: '拉伸铺满', }, 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', translateToggle: 'Live translation', translateOn: 'Translation on', translateOff: 'Translation off', translateInChat: 'Show translation in chat', 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', blockUsers: 'Blocked users', blockUsersHint: 'Uses Twitch’s native block. View, add, or remove blocked users here.', blockUsersPlaceholder: 'Username', blockUsersAdd: 'Add', blockUsersRemove: 'Unblock', blockUsersEmpty: 'No blocked users', blockUsersLoading: 'Loading blocked users…', blockUsersRefresh: 'Refresh', blockUsersNeedLogin: 'Log in to Twitch first', blockUsersLoadFailed: 'Could not load blocked users', blockUsersAddFailed: 'Failed to block user', blockUsersRemoveFailed: 'Failed to unblock user', chatMenuBlockNeedLogin: 'Log in to Twitch first', chatMenuBlockDone: 'User blocked on Twitch', chatMenuBlockFailed: 'Twitch block failed. Try again later', chatMenuBlockNotFound: 'User not found', 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', chatMenuBlockUser: 'Block this user', chatMenuFavorite: 'Favorite', favoritesTitle: 'Favorite chats', favoritesAdd: 'Add', favoritesAddPlaceholder: 'Type a message to save…', favoritesEmpty: 'No favorites yet', favoritesEmptyText: 'Enter some text', favoritesAdded: 'Added to favorites', favoritesExists: 'Already in favorites', favoritesFull: 'Favorites full (max 40)', favoritesSend: 'Send', favoritesRemove: 'Remove', favoritesSent: 'Sent', favoritesShortcutNone: 'No key', favoritesScopeGlobal: 'Site-wide', favoritesScopeChannel: 'This channel · {channel}', favoritesMakeGlobal: 'Global', favoritesMakeChannel: 'Channel', favoritesMakeGlobalHint: 'Available in every channel', favoritesMakeChannelHint: 'Only this channel', favoritesScopedGlobal: 'Now site-wide', favoritesScopedChannel: 'Now channel-only', favoritesNeedChannel: 'Could not detect the current channel', favoritesWheel: 'Wheel mode', favoritesWheelStart: 'Start', favoritesWheelStop: 'Stop', favoritesWheelIdle: 'Idle', favoritesWheelRunning: 'Sent {sent}/{max} · {left}s left', favoritesWheelStopped: 'Wheel stopped', favoritesWheelMinInterval: 'Min interval (s)', favoritesWheelMaxInterval: 'Max interval (s)', favoritesWheelMaxCount: 'Max messages', favoritesWheelMaxDuration: 'Max duration (s)', fsChatFavorites: 'Favorites', fsChatFavoritesEmpty: 'No favorites', superchatPinned: 'Pin Super Chats', superchatPosition: 'Pin position', superchatDuration: 'Pin time', superchatAnimation: 'Pin animation', superchatTop: 'Top center', superchatBottom: 'Bottom center', superchatTopLeft: 'Top left', superchatTopRight: 'Top right', superchatBottomLeft: 'Bottom left', superchatBottomRight: 'Bottom right', superchatFade: 'Fade', superchatSlide: 'Slide', superchatPop: 'Pop', superchatShine: 'Shine', superchatClose: 'Dismiss Super Chat', superchatHideSubscribe: 'Hide subscription pins', superchatCompact: 'Compact pin layout', superchatMaxWidth: 'Pin max width', superchatCardClose: 'Close Super Chat card', superchatOverlayDanmaku: 'Pin over scrolling danmaku', superchatDurationByAmount: 'Pin time scales with amount', fsChatPlaceholder: 'Send a message…', fsChatSend: 'Chat', fsChatClose: 'Close', fsChatNeedLogin: 'Log in to Twitch first', fsChatNeedFollow: 'Follow the channel to chat', fsChatNeedFollowTime: 'Follow longer before you can chat', fsChatNeedSub: 'Subscribers only', fsChatNeedVerified: 'Verify your account to chat', fsChatNeedPhone: 'Verify a phone number to chat', fsChatSlowMode: 'Slow mode — try again shortly', fsChatEmoteOnly: 'Emote-only mode', fsChatUniqueMode: 'Message must be unique', fsChatAutomod: 'Held by AutoMod for review', fsChatTimedOut: 'You are timed out', fsChatBanned: 'You are banned from this chat', fsChatDuplicate: 'Duplicate message blocked', fsChatRateLimit: 'Sending too fast — slow down', fsChatFailed: 'Failed to send. Try again.', fsChatSent: 'Sent', scrollAreaFull: 'Full screen', scrollAreaTopHalf: 'Top half', scrollAreaBottomHalf: 'Bottom 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', chatClear: 'Clear chat', pipOpen: 'Enhanced picture-in-picture', pipClose: 'Close enhanced picture-in-picture', pipTitle: 'Enhanced PiP', pipNeedBrowser: 'Enhanced picture-in-picture needs Chrome or Edge 116 or newer', pipNoVideo: 'No player video is available', pipCaptureFailed: 'Could not capture the video', pipOpenFailed: 'Could not open the picture-in-picture window', pipReload: 'Refresh video', pipReloaded: 'Video refreshed', pipReloadFailed: 'Refresh failed. Check the video on the live page', pipDanmaku: 'Danmaku', pipSettings: 'Danmaku settings', pipSettingsHint: 'Settings opened on the Twitch page', pipSend: 'Send a message', pipBack: 'Back to stream', pipVodNoSend: 'Chat cannot be sent on a VOD page', pipAspectLock: 'Lock aspect ratio', pipAspectUnlock: 'Stretch to fill', }, }; 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('scrollAreaBottomHalf'), value: 'bottom-half' }, { label: t('scrollAreaTopQuarter'), value: 'top-quarter' }, ]; } function getSuperchatPositionOptions() { return [ { label: t('superchatTop'), value: 'top' }, { label: t('superchatTopLeft'), value: 'top-left' }, { label: t('superchatTopRight'), value: 'top-right' }, { label: t('superchatBottom'), value: 'bottom' }, { label: t('superchatBottomLeft'), value: 'bottom-left' }, { label: t('superchatBottomRight'), value: 'bottom-right' }, ]; } 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', superchatHideSubscribe: false, superchatCompact: false, superchatMaxWidth: 420, superchatOverlayDanmaku: true, superchatDurationByAmount: false, }; const SELECTORS = { chatScroller: '[data-a-target="chat-scroller"], .video-chat__message-list-wrapper', chatMessage: '[data-a-target="chat-message-container"], [data-a-target="chat-line-message"], .chat-line__message, [data-test-selector="chat-message"], [data-test-selector="user-notice-line"], .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, .chatter-name', chatMessageBody: '[data-a-target="chat-line-message-body"], .chat-line__message-body, .video-chat__message, [data-test-selector="user-notice-line"]', 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"]', chatHeader: '.stream-chat-header, [class*="stream-chat-header"]', chatViewerListBtn: '[data-test-selector="chat-viewer-list"]', chatMessageContainer: '[data-test-selector="chat-scrollable-area__message-container"], .chat-scrollable-area__message-container', chatInputActions: '[data-tbc-chat-input-actions]', bitsButton: '[data-a-target="bits-button"]', emotePickerButton: '[data-a-target="emote-picker-button"]', chatScrollableArea: '.chat-list--default .scrollable-area, .scrollable-area', }; 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 SCROLL_AREAS = ['full', 'top-half', 'bottom-half', 'top-quarter']; const SUPERCHAT_POSITIONS = ['top', 'bottom', 'top-left', 'top-right', 'bottom-left', 'bottom-right']; function resolveSuperchatPlacement(value) { const key = String(value || 'top').toLowerCase().replace(/_/g, '-'); const map = { top: { vertical: 'top', horizontal: 'center' }, bottom: { vertical: 'bottom', horizontal: 'center' }, 'top-left': { vertical: 'top', horizontal: 'left' }, topleft: { vertical: 'top', horizontal: 'left' }, 'top-right': { vertical: 'top', horizontal: 'right' }, topright: { vertical: 'top', horizontal: 'right' }, 'bottom-left': { vertical: 'bottom', horizontal: 'left' }, bottomleft: { vertical: 'bottom', horizontal: 'left' }, 'bottom-right': { vertical: 'bottom', horizontal: 'right' }, bottomright: { vertical: 'bottom', horizontal: 'right' }, }; return map[key] || map.top; } function resolveSuperchatDuration(settings, meta) { const base = clamp(finiteNumber(settings?.superchatDuration, DEFAULT_SETTINGS.superchatDuration), 2, 300); if (!settings?.superchatDurationByAmount || !meta) return base; let bonus = 0; if (meta.kind === 'cheer' && meta.bits) { const bits = Math.max(1, Number(meta.bits) || 1); bonus = Math.log10(bits) * 6; } else if (meta.kind === 'sub') { bonus = 8; } else if (meta.kind === 'pin') { bonus = 5; } return clamp(Math.round(base + bonus), 2, 300); } const SUPERCHAT_MAX_PINS = 10; 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 (!SCROLL_AREAS.includes(merged.scrollArea)) { merged.scrollArea = DEFAULT_SETTINGS.scrollArea; } if (!TRANSLATE_PROVIDERS.includes(merged.translateProvider)) { merged.translateProvider = DEFAULT_SETTINGS.translateProvider; } merged.blockedKeywords = sanitizeBlockedKeywords(merged.blockedKeywords); delete merged.blockedUsers; 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; const scPos = String(merged.superchatPosition || 'top').toLowerCase().replace(/_/g, '-'); merged.superchatPosition = SUPERCHAT_POSITIONS.includes(scPos) ? scPos : 'top'; if (!['fade', 'slide', 'pop', 'shine'].includes(merged.superchatAnimation)) merged.superchatAnimation = 'slide'; merged.superchatDuration = clamp(finiteNumber(merged.superchatDuration, DEFAULT_SETTINGS.superchatDuration), 2, 300); merged.superchatHideSubscribe = !!merged.superchatHideSubscribe; merged.superchatCompact = !!merged.superchatCompact; merged.superchatMaxWidth = clamp( finiteNumber(merged.superchatMaxWidth, DEFAULT_SETTINGS.superchatMaxWidth), 240, 720, ); merged.superchatOverlayDanmaku = merged.superchatOverlayDanmaku !== false; merged.superchatDurationByAmount = !!merged.superchatDurationByAmount; 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 TabAntiFreeze = { active: false, captureHandler: null, saved: null, }; function tabAntiFreezeBlockVisibilityEvent(event) { event.stopImmediatePropagation(); } function tabAntiFreezeSaveDescriptor(object, key) { const descriptor = Object.getOwnPropertyDescriptor(object, key); if (descriptor) TabAntiFreeze.saved[key] = descriptor; } function tabAntiFreezeEnable() { if (TabAntiFreeze.active) return; TabAntiFreeze.saved = {}; tabAntiFreezeSaveDescriptor(document, 'hidden'); tabAntiFreezeSaveDescriptor(document, 'visibilityState'); tabAntiFreezeSaveDescriptor(document, 'webkitVisibilityState'); try { Object.defineProperty(document, 'hidden', { configurable: true, enumerable: true, get() { return false; }, }); Object.defineProperty(document, 'visibilityState', { configurable: true, enumerable: true, get() { return 'visible'; }, }); Object.defineProperty(document, 'webkitVisibilityState', { configurable: true, enumerable: true, get() { return 'visible'; }, }); } catch (error) { console.warn('[TBC] Tab anti-freeze: could not patch visibility', error); TabAntiFreeze.saved = null; return; } TabAntiFreeze.saved.hasFocus = document.hasFocus; document.hasFocus = () => true; TabAntiFreeze.captureHandler = tabAntiFreezeBlockVisibilityEvent; document.addEventListener('visibilitychange', TabAntiFreeze.captureHandler, true); TabAntiFreeze.active = true; try { document.dispatchEvent(new Event('visibilitychange')); } catch { /* ignore */ } } function tabAntiFreezeRestoreProperty(object, key) { const saved = TabAntiFreeze.saved?.[key]; if (!saved) return; try { Object.defineProperty(object, key, saved); } catch { try { delete object[key]; } catch { /* ignore */ } } } function tabAntiFreezeDisable() { if (!TabAntiFreeze.active) return; if (TabAntiFreeze.captureHandler) { document.removeEventListener('visibilitychange', TabAntiFreeze.captureHandler, true); TabAntiFreeze.captureHandler = null; } if (TabAntiFreeze.saved) { tabAntiFreezeRestoreProperty(document, 'hidden'); tabAntiFreezeRestoreProperty(document, 'visibilityState'); tabAntiFreezeRestoreProperty(document, 'webkitVisibilityState'); if (TabAntiFreeze.saved.hasFocus) { document.hasFocus = TabAntiFreeze.saved.hasFocus; } } TabAntiFreeze.saved = null; TabAntiFreeze.active = false; } function tabAntiFreezeIsEnabled() { return TabAntiFreeze.active; } function syncTabAntiFreeze(isPipOpen) { if (isPipOpen?.()) tabAntiFreezeEnable(); else tabAntiFreezeDisable(); } 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; } 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; } } let activeSuperchatCard = null; function appendSuperchatCardParts(container, 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; container.appendChild(img); continue; } const text = part.type === 'emote' ? (part.name || '') : (part.value || ''); if (text) container.appendChild(document.createTextNode(text)); } } function closeSuperchatDetailCard() { const card = activeSuperchatCard; activeSuperchatCard = null; if (!card) return; card.onDismiss?.(); card.root?.remove(); } function openSuperchatDetailCard(mount, payload, helpers = {}) { if (!mount || !payload) return; closeSuperchatDetailCard(); const { getSettings, favoritesPanel, chatContextMenu, onDismiss, } = helpers; const settings = getSettings?.() || {}; const { username, message, parts, usernameColor, originalMessage, superchatMeta, avatarUrl, login, profileUrl, } = payload; const meta = superchatMeta || null; const style = meta?.style || payload.superchatStyle || { background: 'linear-gradient(135deg, #9147ff 0%, #772ce8 100%)', color: '#ffffff', accent: '#bf94ff', }; const displayParts = parts?.length ? parts : (message ? [{ type: 'text', value: message }] : []); if (getComputedStyle(mount).position === 'static') { mount.style.position = 'relative'; } const root = document.createElement('div'); root.className = 'tbc-sc-card-root'; root.setAttribute('role', 'dialog'); root.setAttribute('aria-modal', 'true'); const backdrop = document.createElement('button'); backdrop.type = 'button'; backdrop.className = 'tbc-sc-card-backdrop'; backdrop.setAttribute('aria-label', t('superchatCardClose')); const card = document.createElement('div'); card.className = 'tbc-sc-card'; card.style.background = style.background || '#9147ff'; card.style.color = safeCssColor(style.color, '#ffffff'); card.style.setProperty('--tbc-sc-accent', safeCssColor(style.accent, '#bf94ff')); card.style.fontFamily = settings.fontFamily || 'system-ui, sans-serif'; const header = document.createElement('div'); header.className = 'tbc-sc-card-header'; const avatar = document.createElement('img'); avatar.className = 'tbc-sc-card-avatar'; avatar.alt = ''; avatar.draggable = false; avatar.src = avatarUrl || cachedAvatar(login || username, username, usernameColor); const headText = document.createElement('div'); headText.className = 'tbc-sc-card-headtext'; const nameEl = document.createElement('div'); nameEl.className = 'tbc-sc-card-name'; nameEl.textContent = username || login || 'User'; headText.appendChild(nameEl); if (meta) { const metaRow = document.createElement('div'); metaRow.className = 'tbc-sc-meta tbc-sc-card-meta'; if (meta.kind === 'cheer' && meta.bits) { const amount = document.createElement('span'); amount.className = 'tbc-sc-amount'; amount.textContent = meta.amountLabel || `${meta.bits} Bits`; metaRow.append(createCheerTierBadgeEl(meta), amount); } else if (meta.amountLabel) { const badge = document.createElement('span'); badge.className = 'tbc-sc-badge'; badge.textContent = meta.amountLabel; metaRow.appendChild(badge); } headText.appendChild(metaRow); } const closeBtn = document.createElement('button'); closeBtn.type = 'button'; closeBtn.className = 'tbc-sc-card-close'; closeBtn.setAttribute('aria-label', t('superchatCardClose')); closeBtn.innerHTML = typeof ICON_CLOSE === 'string' ? ICON_CLOSE : '×'; header.append(avatar, headText, closeBtn); const body = document.createElement('div'); body.className = 'tbc-sc-card-body'; appendSuperchatCardParts(body, displayParts); const translation = document.createElement('div'); translation.className = 'tbc-sc-card-translation'; translation.hidden = true; const actions = document.createElement('div'); actions.className = 'tbc-sc-card-actions'; const translateBtn = document.createElement('button'); translateBtn.type = 'button'; translateBtn.className = 'tbc-sc-card-action'; translateBtn.textContent = t('chatMenuTranslate'); const speakBtn = document.createElement('button'); speakBtn.type = 'button'; speakBtn.className = 'tbc-sc-card-action'; speakBtn.textContent = t('chatMenuSpeak'); if (!speechSupported()) speakBtn.disabled = true; const favoriteBtn = document.createElement('button'); favoriteBtn.type = 'button'; favoriteBtn.className = 'tbc-sc-card-action'; favoriteBtn.textContent = t('chatMenuFavorite'); const profileBtn = document.createElement('button'); profileBtn.type = 'button'; profileBtn.className = 'tbc-sc-card-action'; profileBtn.textContent = t('tooltipOpenProfile'); const blockBtn = document.createElement('button'); blockBtn.type = 'button'; blockBtn.className = 'tbc-sc-card-action'; blockBtn.textContent = t('chatMenuBlockUser'); const blockLogin = String(login || username || '').trim().replace(/^@+/, '').toLowerCase(); if (!blockLogin) blockBtn.disabled = true; actions.append(profileBtn, blockBtn, translateBtn, speakBtn, favoriteBtn); card.append(header, body, translation, actions); root.append(backdrop, card); mount.appendChild(root); const loginKey = String(login || username || '').toLowerCase().replace(/^@+/, ''); if (loginKey && (!avatarUrl || String(avatarUrl).startsWith('data:'))) { fetchTwitchAvatar(loginKey).then((url) => { if (!url || !root.isConnected) return; avatar.src = url; }); } const snapshot = { message: originalMessage || message, parts: displayParts.map((part) => ({ ...part })), username, login, }; let translatedOnce = false; translateBtn.addEventListener('click', async (e) => { e.preventDefault(); e.stopPropagation(); if (!chatContextMenu?.translateParts) return; translateBtn.disabled = true; const result = await chatContextMenu.translateParts(snapshot, translatedOnce); translateBtn.disabled = false; if (result.failed) return; translatedOnce = true; translateBtn.textContent = t('chatMenuRetranslate'); if (result.translated) { translation.hidden = false; translation.textContent = result.message; snapshot.message = result.message; snapshot.parts = result.parts; } }); speakBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); if (!speechSupported() || Number(settings.speechVolume) <= 0) return; const source = translation.hidden ? speechSourceText(snapshot.message, snapshot.parts) : (translation.textContent || snapshot.message); speakTextNow(source); }); profileBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); const url = profileUrl || (blockLogin ? `https://www.twitch.tv/${encodeURIComponent(blockLogin)}` : ''); if (url) window.open(url, '_blank', 'noopener,noreferrer'); }); blockBtn.addEventListener('click', async (e) => { e.preventDefault(); e.stopPropagation(); if (!blockLogin || !chatContextMenu?.runBlockUser) return; await chatContextMenu.runBlockUser(blockLogin); }); favoriteBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); const text = String(originalMessage || message || '').trim(); if (!text) return; if (favoritesPanel?.addFromText) { favoritesPanel.addFromText(text); if (!favoritesPanel.isOpen && favoritesPanel.btn) { favoritesPanel.open(favoritesPanel.btn); } return; } const result = addFavoriteText(text); if (result.full) console.warn(`[TBC] ${t('favoritesFull')}`); else if (result.added) console.info(`[TBC] ${t('favoritesAdded')}`); }); const dismiss = () => closeSuperchatDetailCard(); backdrop.addEventListener('click', dismiss); closeBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); dismiss(); }); card.addEventListener('click', (e) => e.stopPropagation()); activeSuperchatCard = { root, onDismiss: typeof onDismiss === 'function' ? onDismiss : null, }; } 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._superchatHelpers = () => ({ getSettings: this.getSettings }); this.laneManager = new LaneManager(); this.layer = null; this.superchatLayer = null; this.pinnedItems = []; this.superchatQueue = []; this.maxPinned = SUPERCHAT_MAX_PINS; this.playerEl = null; this.overlayEl = null; this.controlsObserver = null; this.resizeObserver = null; this.boundUpdate = () => this.updateLayout(); } setSuperchatHelpers(factory) { if (typeof factory === 'function') this._superchatHelpers = factory; } 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(); closeSuperchatDetailCard(); 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; } estimatePinnedItemHeight() { const settings = this.getSettings(); if (settings.superchatCompact) { return Math.max(32, settings.fontSize + 10); } return Math.max(48, settings.fontSize + 20); } getPinnedBandHeight() { const live = this.pinnedItems.filter((item) => item?.isConnected); const gap = 6; const layerPad = 10; if (!live.length) { return this.estimatePinnedItemHeight() + layerPad; } let total = layerPad; live.forEach((item, index) => { const h = item.offsetHeight || this.estimatePinnedItemHeight(); total += h + (index > 0 ? gap : 0); }); return total; } 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 placement = resolveSuperchatPlacement(this.getSettings().superchatPosition); const bottomReserve = this.getBottomReserve(); const contentHeight = this.getPinnedBandHeight(); let bandHeight = contentHeight; const maxBand = Math.max(this.estimatePinnedItemHeight(), playerHeight - bottomReserve - 12); bandHeight = Math.min(bandHeight, maxBand); let top; if (placement.vertical === 'bottom') { top = Math.max(0, playerHeight - bottomReserve - bandHeight); } else { top = 4; const maxTopBand = playerHeight - bottomReserve - top - 4; bandHeight = Math.min(bandHeight, maxTopBand); } return { ...placement, top, height: bandHeight, contentHeight, maxBand }; } 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 'bottom-half': area = { top: h * 0.5, height: Math.max(laneHeight, h * 0.5 - bottomReserve), }; 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; } if (settings.superchatOverlayDanmaku === false && settings.superchatPinned && settings.enabled) { const band = this.getPinnedBandRect(h); if (band?.height) { if (band.vertical === 'top') { area.top += band.height; area.height = Math.max(laneHeight, area.height - band.height); } else if (band.vertical === 'bottom') { area.height = Math.max(laneHeight, area.height - band.height); } } } return area; } 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 settings = this.getSettings(); 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.vertical === 'bottom'); this.superchatLayer.classList.toggle('tbc-sc-align-left', band.horizontal === 'left'); this.superchatLayer.classList.toggle('tbc-sc-align-right', band.horizontal === 'right'); const needsPinScroll = band.contentHeight > band.height + 2; this.superchatLayer.classList.toggle('tbc-sc-scrollable', needsPinScroll); const layerSize = needsPinScroll ? { height: `${band.height}px`, maxHeight: '' } : { height: 'auto', maxHeight: `${band.maxBand}px` }; Object.assign(this.superchatLayer.style, fullscreen ? { display: 'flex', position: 'fixed', top: `${playerRect.top + band.top}px`, left: `${playerRect.left}px`, width: `${playerRect.width}px`, ...layerSize, zIndex: '10000', } : { display: 'flex', position: 'absolute', top: `${band.top}px`, left: '0', width: '100%', ...layerSize, zIndex: settings.superchatOverlayDanmaku === false ? '4' : '5', }); } render(payload) { const settings = this.getSettings(); if (payload.superchat && shouldSuppressSubscribeSuperchat(settings, payload)) return; if (payload.superchat && settings.superchatPinned) { this.queueSuperchat(payload); return; } this.renderScrolling(payload); } buildSuperchatMetaRow(meta, options = {}) { const badgeBeforeAmount = !!options.badgeBeforeAmount; const metaRow = document.createElement('div'); metaRow.className = 'tbc-sc-meta'; if (!meta) return metaRow; if (meta.kind === 'cheer' && meta.bits) { const amount = document.createElement('span'); amount.className = 'tbc-sc-amount'; amount.textContent = meta.amountLabel || `${meta.bits} Bits`; const tier = createCheerTierBadgeEl(meta); if (badgeBeforeAmount) { metaRow.append(tier, amount); } else { metaRow.append(amount, tier); } } else if (meta.amountLabel) { const badge = document.createElement('span'); badge.className = 'tbc-sc-badge'; badge.textContent = meta.amountLabel; metaRow.appendChild(badge); } return metaRow; } 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 > this.maxPinned * 2) 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 meta = superchatMeta || null; const duration = resolveSuperchatDuration(settings, meta); const compact = !!settings.superchatCompact; const maxWidth = clamp(Number(settings.superchatMaxWidth) || 420, 240, 720); 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}${compact ? ' tbc-sc-compact' : ''}`; item.style.setProperty('--tbc-sc-duration', `${duration}s`); item.style.maxWidth = `min(${maxWidth}px, calc(100% - 24px))`; if (compact) item.style.minWidth = 'unset'; 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'); const baseFont = Math.max(14, settings.fontSize - 2); item.style.fontSize = compact ? `${Math.max(12, settings.fontSize - 5)}px` : `${baseFont}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); let main = null; let body = null; let track = null; if (compact) { const compactMain = document.createElement('div'); compactMain.className = 'tbc-sc-compact-main'; const nameEl = document.createElement('span'); nameEl.className = 'tbc-sc-compact-name'; const displayName = username || login || 'User'; nameEl.textContent = this.truncateName(displayName, 10); nameEl.title = displayName; const metaRow = this.buildSuperchatMetaRow(meta, { badgeBeforeAmount: true }); compactMain.append(nameEl, metaRow); item.append(avatar, compactMain); } else { const metaRow = this.buildSuperchatMetaRow(meta); 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'; const displayName = username || login || 'User'; nameEl.textContent = this.truncateName(displayName, 18); nameEl.title = displayName; head.appendChild(nameEl); if (metaRow.childElementCount) head.appendChild(metaRow); body = document.createElement('div'); body.className = 'tbc-sc-body'; 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); } const closeBtn = document.createElement('button'); closeBtn.type = 'button'; closeBtn.className = 'tbc-sc-close'; closeBtn.setAttribute('aria-label', t('superchatClose')); closeBtn.innerHTML = ICON_CLOSE; item.appendChild(closeBtn); item._tbcPayload = payload; this.superchatLayer.appendChild(item); this.pinnedItems.push(item); this.updateLayout(); requestAnimationFrame(() => this.updateLayout()); if (!compact && body && track) { requestAnimationFrame(() => this.armSuperchatMarquee(body, track)); } if (!compact) requestSpeech(speechSourceText(message, parts)); const loginKey = String(login || username || '').toLowerCase().replace(/^@+/, ''); // Always resolve a real profile image via ViewerCard when possible (letter avatars are fallback only). if (loginKey && (!avatarUrl || String(avatarUrl).startsWith('data:'))) { 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); }); closeBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); clearTimeout(fallback); if (typeof hideDanmakuTooltip === 'function' && activeTooltipItem === item) { hideDanmakuTooltip(); } this.finishSuperchat(item); }); const pausePin = () => clearTimeout(fallback); const resumePin = () => armFallback(20000); if (compact) { item.addEventListener('click', (e) => { if (e.target.closest('.tbc-sc-close')) return; e.preventDefault(); e.stopPropagation(); pausePin(); item.classList.add('tbc-paused'); const mount = getDanmakuMount(item) || this.overlayEl || this.playerEl; openSuperchatDetailCard(mount, payload, { ...this._superchatHelpers(), onDismiss: () => { item.classList.remove('tbc-paused'); resumePin(); }, }); }); } else { attachDanmakuInteraction(item, { username, usernameColor, profileUrl, originalMessage, translated, onPause: pausePin, onResume: resumePin, }); } } 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(payload) { const { username, message, parts, usernameColor, profileUrl, originalMessage, translated, } = payload || {}; const settings = this.getSettings(); const displayParts = parts?.length ? parts : (message ? [{ type: 'text', value: message }] : []); if (!settings.enabled || !displayParts.length) return; if (typeof this.onComment === 'function') { try { this.onComment(payload); } catch (error) { console.error('[TBC] PiP danmaku failed:', error); } } if (!this.layer) 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'), www: dictEntry('笑死', '笑死', 'www'), stream: dictEntry('直播', '直播', 'stream'), }; 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 (bitsFromImageAsset(img) > 0 || isCheerTierImageSrc(src)) return; 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; if (bitsFromImageAsset(node) > 0) 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 bitsFromPlainSuffix(text) { const match = String(text || '').match(/(?:^|\s)([1-9]\d{0,6})\s+bits\s*$/i); return match ? (Number(match[1]) || 0) : 0; } function stripCheerAmountSuffixText(text) { return String(text || '').replace(/(?:\s*[·•\-–—]\s*|\s+)([1-9]\d{0,6})\s+bits\s*$/i, '').trim(); } function stripCheerAmountSuffixParts(parts) { const list = Array.isArray(parts) ? parts.slice() : []; for (let i = list.length - 1; i >= 0; i -= 1) { if (list[i].type !== 'text') continue; const next = stripCheerAmountSuffixText(list[i].value); if (next === list[i].value) return list; if (!next) list.splice(i, 1); else list[i] = { ...list[i], value: next }; break; } return list; } function isCheerTierImageSrc(src) { const value = String(src || ''); if (!value) return false; return /\/actions\/[^/\s]+\/(?:dark|light)\/(?:animated|static)\/\d+\//i.test(value) || /\/bits\/(?:dark|light)\/(?:animated|static)\/[^/\s]+\/\d+(?:\/|[.?#]|$)/i.test(value); } 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' }; } // Sub / resub / gift / Prime — not watch-streak / raid / follow notices. // Allow markup splits like: 已通过 Prime 订阅 const SUBSCRIBE_NOTICE_RE = /(?:已通过[\s\S]{0,24}Prime[\s\S]{0,12}订阅|已订阅第\s*\d+\s*层级|订阅了|续订了|已經訂閱|續訂|已訂閱|訂閱了|subscribed(?:\s+with\s+Prime)?|resubscribed|gifted\s+\d+|is gifting|gift\s*sub|prime\s+gaming|サブスク|ギフト)/i; function subscribeAmountLabel(text) { const raw = String(text || ''); if (/prime/i.test(raw)) return 'Prime'; const tier = raw.match(/(?:第\s*(\d+)\s*层级|tier\s*(\d+)|subscribed at Tier\s*(\d+))/i); if (tier) return `Tier ${tier[1] || tier[2] || tier[3]}`; if (/gift|赠送|ギフト|gifting/i.test(raw)) return 'Gift'; return 'Sub'; } function cheerTierBadgeLabel(bits) { const amount = Math.max(0, Number(bits) || 0); if (amount >= 100000) return '100K'; if (amount >= 10000) return '10K'; if (amount >= 5000) return '5K'; if (amount >= 1000) return '1K'; if (amount >= 100) return '100'; return '1'; } function cheerTierIconAmount(bits) { const amount = Math.max(0, Number(bits) || 0); if (amount >= 100000) return 100000; if (amount >= 10000) return 10000; if (amount >= 5000) return 5000; if (amount >= 1000) return 1000; if (amount >= 100) return 100; return 1; } function cheerTierIconUrl(bits) { const tier = cheerTierIconAmount(bits); return `https://d3aqoihi2n8ty8.cloudfront.net/actions/cheer/dark/animated/${tier}/1.gif`; } function readCheerTierIconFromMessage(msgEl) { if (!msgEl) return ''; const root = msgEl.querySelector(SELECTORS.chatMessageBody) || msgEl; for (const img of root.querySelectorAll('img')) { if (bitsFromImageAsset(img) <= 0) continue; const src = pickEmoteSrc(img); if (src) return src; } return ''; } function createCheerTierBadgeEl(meta) { const tierWrap = document.createElement('span'); tierWrap.className = 'tbc-sc-badge tbc-sc-tier'; const img = document.createElement('img'); img.className = 'tbc-sc-tier-img'; img.src = meta?.tierIconUrl || cheerTierIconUrl(meta?.bits || 1); img.alt = meta?.tierLabel || cheerTierBadgeLabel(meta?.bits || 1); img.draggable = false; tierWrap.appendChild(img); return tierWrap; } function readUserNoticeSystemText(msgEl) { if (!msgEl) return ''; const clone = msgEl.cloneNode(true); clone.querySelectorAll( '[data-a-target="chat-resubscription-message__custom-message"], .chat-line__message, [data-a-target="chat-line-message"]', ).forEach((node) => node.remove()); return (clone.textContent || '').replace(/\s+/g, ' ').trim(); } function readUserNoticeCustomParts(msgEl) { const customRoot = msgEl?.querySelector?.( '[data-a-target="chat-resubscription-message__custom-message"]', ); if (!customRoot) return []; const line = customRoot.querySelector('[data-a-target="chat-line-message"], .chat-line__message') || customRoot; return extractMessageParts(line); } function bitsFromCheerImageLabels(msgEl) { if (!msgEl) return 0; const root = msgEl.querySelector(SELECTORS.chatMessageBody) || msgEl; let total = 0; for (const img of root.querySelectorAll('img')) { const src = pickEmoteSrc(img); if (bitsFromImageAsset(img) <= 0 && !isCheerTierImageSrc(src)) continue; const label = extractEmoteName(img).trim(); const fromLabel = bitsFromText(label) || bitsFromText(` ${label}`); if (fromLabel > 0) total += fromLabel; } return total; } function extractBitsAmount(msgEl, message = '', parts = []) { const fromParts = bitsFromText( (Array.isArray(parts) ? parts : []) .map((part) => (part?.type === 'emote' ? part.name : part?.value) || '') .join(' '), ); if (fromParts > 0) return fromParts; const fromMessage = bitsFromText(message); if (fromMessage > 0) return fromMessage; const fromSuffix = bitsFromPlainSuffix(message); if (fromSuffix > 0) return fromSuffix; const fromCheerLabels = bitsFromCheerImageLabels(msgEl); if (fromCheerLabels > 0) return fromCheerLabels; return 0; } function isSubscribeNotice(msgEl, textHint = '') { if (!msgEl && !textHint) return false; // Always prefer full notice textContent — custom resub body ("Hii") must not veto detection. const systemText = readUserNoticeSystemText(msgEl); const fullText = String(msgEl?.textContent || '').replace(/\s+/g, ' ').trim(); const text = systemText || fullText || String(textHint || '').replace(/\s+/g, ' ').trim(); return !!text && SUBSCRIBE_NOTICE_RE.test(text); } function readNoticeActor(msgEl, message = '') { const usernameEl = msgEl?.querySelector?.(SELECTORS.chatUsername); let username = usernameEl?.textContent?.trim() || ''; let login = ( msgEl?.getAttribute?.('data-a-user') || usernameEl?.getAttribute?.('data-a-user') || '' ).trim().toLowerCase(); if (!username) { const text = String(message || msgEl?.textContent || '').replace(/\s+/g, ' ').trim(); const match = text.match(/^@?([A-Za-z0-9_]{3,25})\s+(?:已通过|已订阅|订阅了|续订|subscribed|resubscribed|is gifting)/i); if (match) username = match[1]; } if (!login && username) login = username.toLowerCase(); return { username, login, usernameEl }; } 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, message); 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 = subscribeAmountLabel(message || msgEl?.textContent || ''); } 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; const tierLabel = kind === 'cheer' ? cheerTierBadgeLabel(bits || 1) : (kind === 'sub' ? amountLabel : ''); const tierIconUrl = kind === 'cheer' ? (readCheerTierIconFromMessage(msgEl) || cheerTierIconUrl(bits || 1)) : ''; return { kind, bits: bits || 0, amountLabel, tierLabel, tierIconUrl, 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 systemText = readUserNoticeSystemText(msgEl); const subscribe = isSubscribeNotice(msgEl, systemText); const customParts = subscribe ? readUserNoticeCustomParts(msgEl) : []; const parts = customParts.length ? customParts : extractMessageParts(msgEl); let message = partsToPlain(parts); // System / notice lines may put text outside the normal body selector. if (!message) { if (subscribe && systemText) { message = systemText; } else { const notice = msgEl.querySelector( '[data-a-target="chat-line-message-body"], .chat-line__message-body, [data-test-selector="user-notice-line"], .user-notice-line', ); message = (notice?.textContent || msgEl.textContent || '').replace(/\s+/g, ' ').trim(); } } const actor = readNoticeActor(msgEl, systemText || message); let { username, login, usernameEl } = actor; // Nested resub chat-line may carry a better login/color than the notice header. if (subscribe) { const nestedUser = msgEl.querySelector( '[data-a-target="chat-resubscription-message__custom-message"] [data-a-target="chat-message-username"]', ); if (nestedUser) { username = nestedUser.textContent?.trim() || username; login = (nestedUser.getAttribute('data-a-user') || username || login).trim().toLowerCase(); usernameEl = nestedUser; } } if (!message && !subscribe) return null; // Avoid duplicating the name in the Super Chat body for "user 已订阅…" notices. if (username && message && !customParts.length) { const escaped = username.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const stripped = message.replace(new RegExp(`^@?${escaped}\\s+`, 'i'), '').trim(); if (stripped) message = stripped; } // Resub with empty custom body: still show the system notice text. if (subscribe && !message && systemText) { let noticeBody = systemText; if (username) { const escaped = username.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); noticeBody = noticeBody.replace(new RegExp(`^@?${escaped}\\s+`, 'i'), '').trim() || noticeBody; } message = noticeBody; } let finalParts = parts.length ? parts : (message ? [{ type: 'text', value: message }] : []); const meta = readSuperchatMeta(msgEl, systemText || message, finalParts); if (meta?.kind === 'cheer') { message = stripCheerAmountSuffixText(message); finalParts = stripCheerAmountSuffixParts(finalParts); } return { message, parts: finalParts.length ? finalParts : (message ? [{ type: 'text', value: message }] : []), username: username || login, login, usernameColor: getUsernameColor(msgEl) || (usernameEl ? getComputedStyle(usernameEl).color : ''), 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; // Nested resub/chat body is owned by the parent user-notice-line Super Chat path. const isNotice = msgEl.matches?.('[data-test-selector="user-notice-line"], .user-notice-line'); if ( !isNotice && msgEl.closest?.('[data-test-selector="user-notice-line"], .user-notice-line, [data-a-target="chat-resubscription-message__custom-message"]') ) { msgEl.classList.add('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, { force = false } = {}) { root.querySelectorAll?.('.tbc-chat-translation').forEach((node) => { if (!force && node.getAttribute('data-tbc-manual-tl') === 'true') return; node.remove(); }); } function removeChatTranslation(msgEl, { force = false } = {}) { msgEl?.querySelectorAll?.('.tbc-chat-translation').forEach((node) => { if (!force && node.getAttribute('data-tbc-manual-tl') === 'true') return; node.remove(); }); } function hasManualChatTranslation(msgEl) { return !!msgEl?.querySelector?.('.tbc-chat-translation[data-tbc-manual-tl="true"]'); } function readChatTranslationText(msgEl) { return String(msgEl?.querySelector?.('.tbc-chat-translation-text')?.textContent || '') .replace(/\s+/g, ' ') .trim(); } function normalizeCompareText(text) { return String(text || '').replace(/\s+/g, ' ').trim().toLowerCase(); } function applyChatTranslation(msgEl, translatedText, originalText, options = {}) { if (!msgEl?.isConnected) return false; const manual = !!options.manual; const translated = String(translatedText || '').replace(/\s+/g, ' ').trim(); if (!translated) { removeChatTranslation(msgEl, { force: manual }); return false; } if (normalizeCompareText(translated) === normalizeCompareText(originalText)) { removeChatTranslation(msgEl, { force: manual }); return false; } // Auto pipeline must not overwrite a context-menu translation. if (!manual && hasManualChatTranslation(msgEl)) return true; 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); } } if (manual) row.setAttribute('data-tbc-manual-tl', 'true'); else row.removeAttribute('data-tbc-manual-tl'); 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`, tierLabel: cheerTierBadgeLabel(bits), tierIconUrl: cheerTierIconUrl(bits), avatarUrl: '', style, }; } else if (subscribe) { const style = subscribeStyle(); const plan = String(tags['msg-param-sub-plan'] || '').toLowerCase(); let amountLabel = 'Sub'; if (plan === 'prime') amountLabel = 'Prime'; else if (plan === '2000') amountLabel = 'Tier 2'; else if (plan === '3000') amountLabel = 'Tier 3'; else if (plan === '1000') amountLabel = 'Tier 1'; else if (/gift/i.test(String(tags['msg-id'] || ''))) amountLabel = 'Gift'; meta = { kind: 'sub', bits: 0, amountLabel, 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); } } } } const TWITCH_WEB_CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko'; const TWITCH_GQL_ENDPOINT = 'https://gql.twitch.tv/gql'; const TWITCH_GQL_HASH = { blockedUsersWithDetails: '031dd899a18968df9fe917bfb1293508f0c106c385283792c4620f8c994e95a7', blockedUserDetail: 'f1e276239a93802a4c533616dcb9f79d5e9b439e8b3cafb782be297b99d2d413', blockUser: 'c58b580efdc4187fa30c144c57425d7051602c848069be705f07e8d590a12515', unblockUser: 'f1b612a6df709cfa5fea630b20c78c9ba7f4c0be386bfd51486eaf02afbfbf92', // Captured from twitch.tv ViewerCard open (profileImageURL on targetUser). viewerCard: '2c6cc05878bf2f007613fe807512546000401362a2d7d87ab20ebf259960a29e', }; function getCookieValue(name) { const match = document.cookie.match(new RegExp(`(?:^|;\\s*)${name}=([^;]*)`)); return match ? decodeURIComponent(match[1]) : ''; } function getTwitchAuthToken() { return getCookieValue('auth-token') || ''; } function getTwitchDeviceId() { return getCookieValue('unique_id') || getCookieValue('unique_id_durable') || localStorage.getItem('twilight.device_id') || ''; } function normalizeTwitchLogin(value) { return String(value || '').trim().replace(/^@+/, '').toLowerCase(); } function formatTwitchBlockedAt(iso) { if (!iso) return ''; const date = new Date(iso); if (Number.isNaN(date.getTime())) return String(iso); try { return date.toLocaleString(); } catch { return date.toISOString(); } } function twitchGqlRequest(payload) { const token = getTwitchAuthToken(); if (!token) { return Promise.reject(Object.assign(new Error('not_logged_in'), { code: 'not_logged_in' })); } const headers = { Accept: '*/*', 'Client-ID': TWITCH_WEB_CLIENT_ID, Authorization: `OAuth ${token}`, 'Content-Type': 'text/plain;charset=UTF-8', }; const deviceId = getTwitchDeviceId(); if (deviceId) headers['X-Device-Id'] = deviceId; return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'POST', url: TWITCH_GQL_ENDPOINT, headers, data: JSON.stringify(payload), timeout: 15000, onload: (res) => { if (res.status < 200 || res.status >= 300) { reject(Object.assign(new Error(`http_${res.status}`), { code: 'http_error', status: res.status, body: res.responseText, })); return; } try { resolve(JSON.parse(res.responseText)); } catch (error) { reject(Object.assign(error || new Error('bad_json'), { code: 'bad_json' })); } }, onerror: () => reject(Object.assign(new Error('network'), { code: 'network' })), ontimeout: () => reject(Object.assign(new Error('timeout'), { code: 'timeout' })), }); }); } function gqlPersisted(operationName, variables, sha256Hash) { return { operationName, variables, extensions: { persistedQuery: { version: 1, sha256Hash, }, }, }; } function unwrapGqlPayload(data) { return Array.isArray(data) ? data[0] : data; } function assertNoGqlErrors(payload, fallbackCode = 'gql_error') { if (payload?.errors?.length) { const error = new Error(payload.errors[0]?.message || fallbackCode); error.code = 'gql_error'; error.details = payload.errors; throw error; } } async function fetchTwitchBlockedUsers() { const data = await twitchGqlRequest( gqlPersisted('CommonHooks_BlockedUsersWithDetails', {}, TWITCH_GQL_HASH.blockedUsersWithDetails), ); const payload = unwrapGqlPayload(data); assertNoGqlErrors(payload, 'list_failed'); const edges = payload?.data?.currentUser?.blockedUsersWithMetadata?.edges; if (!Array.isArray(edges)) return []; return edges.map((edge) => { const node = edge?.node || {}; return { id: node.id ? String(node.id) : '', login: normalizeTwitchLogin(node.login), displayName: String(node.displayName || node.login || '').trim(), profileImageURL: String(node.profileImageURL || '').trim(), blockedAt: edge?.blockedAt || '', blockedAtLabel: formatTwitchBlockedAt(edge?.blockedAt), }; }).filter((user) => user.id || user.login); } async function resolveTwitchUserId(login) { const normalized = normalizeTwitchLogin(login); if (!normalized) return ''; const data = await twitchGqlRequest([ gqlPersisted('BlockedUserDetail', { login: normalized }, TWITCH_GQL_HASH.blockedUserDetail), ]); const payload = unwrapGqlPayload(data); assertNoGqlErrors(payload, 'lookup_failed'); const user = payload?.data?.user; return user?.id ? String(user.id) : ''; } async function blockTwitchUser(login, { sourceContext = 'CHAT' } = {}) { const targetUserID = await resolveTwitchUserId(login); if (!targetUserID) { const error = new Error('user_not_found'); error.code = 'user_not_found'; throw error; } const data = await twitchGqlRequest([ gqlPersisted( 'CommonHooks_BlockUser', { input: { targetUserID, sourceContext } }, TWITCH_GQL_HASH.blockUser, ), ]); const payload = unwrapGqlPayload(data); assertNoGqlErrors(payload, 'block_failed'); return { targetUserID, login: normalizeTwitchLogin(login) }; } async function unblockTwitchUserById(targetUserID) { const id = String(targetUserID || '').trim(); if (!id) { const error = new Error('user_not_found'); error.code = 'user_not_found'; throw error; } const data = await twitchGqlRequest([ gqlPersisted( 'CommonHooks_UnblockUser', { input: { targetUserID: id } }, TWITCH_GQL_HASH.unblockUser, ), ]); const payload = unwrapGqlPayload(data); assertNoGqlErrors(payload, 'unblock_failed'); return { targetUserID: id }; } async function unblockTwitchUser(login) { const targetUserID = await resolveTwitchUserId(login); if (!targetUserID) { const error = new Error('user_not_found'); error.code = 'user_not_found'; throw error; } await unblockTwitchUserById(targetUserID); return { targetUserID, login: normalizeTwitchLogin(login) }; } const avatarCache = new Map(); let cachedViewerChannel = { login: '', id: '' }; function letterAvatarDataUri(name, color) { const letter = String(name || '?').trim().charAt(0).toUpperCase() || '?'; const fill = String(color || '#9147ff').replace(/[<>&"']/g, ''); const svg = `${letter}`; 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); } async function resolveViewerChannelContext() { const login = normalizeTwitchLogin(currentChannelLogin?.() || ''); if (!login) return { login: '', id: '' }; if (cachedViewerChannel.login === login && cachedViewerChannel.id) { return cachedViewerChannel; } try { const id = await resolveTwitchUserId(login); if (id) { cachedViewerChannel = { login, id: String(id) }; return cachedViewerChannel; } } catch (_) { /* ignore */ } return { login, id: '' }; } function fetchTwitchAvatarViaUserQuery(login) { const key = String(login || '').trim().toLowerCase(); if (!key) return Promise.resolve(''); return new Promise((resolve) => { GM_xmlhttpRequest({ method: 'POST', url: 'https://gql.twitch.tv/gql', headers: { 'Client-ID': typeof TWITCH_WEB_CLIENT_ID !== 'undefined' ? TWITCH_WEB_CLIENT_ID : 'kimne78kx3ncx6brgo4mv6wki5h1ko', '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(''), }); }); } async function fetchTwitchAvatarViaViewerCard(login) { const key = normalizeTwitchLogin(login); if (!key) return ''; if (typeof twitchGqlRequest !== 'function' || typeof gqlPersisted !== 'function') return ''; const channel = await resolveViewerChannelContext(); if (!channel.login) return ''; try { const data = await twitchGqlRequest([ gqlPersisted( 'ViewerCard', { channelID: channel.id || '', channelLogin: channel.login, hasChannelID: !!channel.id, giftRecipientLogin: key, withStandardGifting: true, badgeSourceChannelID: channel.id || '', badgeSourceChannelLogin: channel.login, }, TWITCH_GQL_HASH.viewerCard, ), ]); const payload = unwrapGqlPayload(data); const url = payload?.data?.targetUser?.profileImageURL || ''; if (url) { rememberAvatar(key, url); return url; } } catch (_) { /* fall through */ } return ''; } 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 fetchTwitchAvatarViaViewerCard(key) .then((url) => url || fetchTwitchAvatarViaUserQuery(key)) .catch(() => fetchTwitchAvatarViaUserQuery(key)); } const SEND_CHAT_MESSAGE_HASH = '0435464292cf380ed4b3d905e4edcb73078362e82c06367a5b2181c76c822fa2'; let cachedChatChannel = { login: '', id: '' }; function createChatNonce() { if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { return crypto.randomUUID().replace(/-/g, ''); } if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') { const bytes = new Uint8Array(16); crypto.getRandomValues(bytes); return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); } return `${Date.now().toString(16)}${Math.random().toString(16).slice(2, 10)}`; } async function resolveLiveChannelId() { const login = currentChannelLogin(); if (!login) { const error = new Error('no_channel'); error.code = 'no_channel'; throw error; } if (cachedChatChannel.login === login && cachedChatChannel.id) { return cachedChatChannel.id; } const id = await resolveTwitchUserId(login); if (!id) { const error = new Error('channel_not_found'); error.code = 'channel_not_found'; throw error; } cachedChatChannel = { login, id }; return id; } /** Normalize Twitch drop / restriction codes to stable keys. */ function normalizeChatSendCode(raw) { const code = String(raw || '') .trim() .toLowerCase() .replace(/-/g, '_') .replace(/\s+/g, '_'); if (!code) return ''; if ( code.includes('follower') || code === 'followers_only' || code === 'followers_only_mode' || code === 'followers_only_zero' || code === 'msg_followersonly' || code === 'msg_followersonly_zero' ) { // Followed but not long enough — treat as follow-time gate. if ( code.includes('followed') || code.includes('duration') || code.includes('minutes') || code === 'msg_followersonly_followed' ) { return 'followers_only_time'; } return 'followers_only'; } if (code.includes('sub') && (code.includes('only') || code.includes('subscriber'))) { return 'subscribers_only'; } if ( code.includes('verified_phone') || code.includes('require_verified_phone') || code.includes('requires_verified_phone') || code === 'msg_requires_verified_phone_number' ) { return 'verified_phone'; } if ( code === 'verified_only' || code === 'verified_account' || code === 'msg_verified_email' || (code.includes('verified') && !code.includes('phone')) ) { return 'verified_only'; } if (code.includes('slow')) return 'slow_mode'; if (code.includes('emote')) return 'emote_only'; if (code.includes('r9k') || code.includes('unique')) return 'unique_mode'; if (code.includes('automod')) return 'automod_held'; if (code.includes('timed_out') || code.includes('timeout')) return 'timed_out'; if (code.includes('banned') || code.includes('suspended')) return 'banned'; if (code.includes('duplicate')) return 'duplicate'; if (code.includes('ratelimit') || code.includes('rate_limit')) return 'rate_limit'; if (code === 'not_logged_in') return 'not_logged_in'; if (code === 'no_channel' || code === 'channel_not_found' || code === 'empty_message') return code; return code || 'send_failed'; } function readDropReasonCode(drop) { // Twitch web GQL returns dropReason as a string enum, e.g. "REQUIRES_VERIFIED_PHONE_NUMBER". // Helix-style payloads may use { code, message }. if (!drop) return ''; if (typeof drop === 'string') return drop; if (typeof drop === 'object') return drop.code || drop.errorCode || ''; return ''; } function readDropReasonMessage(drop) { if (!drop || typeof drop !== 'object') return ''; return String(drop.message || '').trim(); } function extractChatSendFailure(result) { if (!result || typeof result !== 'object') { return { code: 'send_failed', userMessage: '' }; } const drop = result.dropReason ?? result.drop_reason ?? null; const errObj = result.error && typeof result.error === 'object' ? result.error : null; const rawCode = result.errorCode || readDropReasonCode(drop) || errObj?.code || (typeof result.error === 'string' ? result.error : '') || ''; const userMessage = String( readDropReasonMessage(drop) || errObj?.message || result.errorMessage || (typeof result.message === 'string' ? result.message : '') || '', ).trim(); const isSent = result.isSent ?? result.is_sent; const hasMessageId = !!( result.message?.id || result.messageID || result.message_id ); // Explicit success. if (isSent === true || hasMessageId) { if (!rawCode && drop == null && !errObj) return null; } const dropped = isSent === false || drop != null || !!rawCode || !!errObj || result.message === null; if (!dropped) return null; return { code: normalizeChatSendCode(rawCode) || 'send_failed', userMessage, }; } function createChatSendError(failure, details) { const error = new Error(failure?.code || 'send_failed'); error.code = failure?.code || 'send_failed'; error.userMessage = failure?.userMessage || ''; error.details = details; return error; } /** Localized status text for chat send failures (favorites / fullscreen / etc.). */ function chatSendErrorMessage(error) { const code = normalizeChatSendCode(error?.code || error?.message); switch (code) { case 'not_logged_in': return t('fsChatNeedLogin'); case 'no_channel': case 'channel_not_found': return t('favoritesNeedChannel'); case 'followers_only': return t('fsChatNeedFollow'); case 'followers_only_time': return t('fsChatNeedFollowTime'); case 'subscribers_only': return t('fsChatNeedSub'); case 'verified_only': return t('fsChatNeedVerified'); case 'verified_phone': return t('fsChatNeedPhone'); case 'slow_mode': return t('fsChatSlowMode'); case 'emote_only': return t('fsChatEmoteOnly'); case 'unique_mode': return t('fsChatUniqueMode'); case 'automod_held': return t('fsChatAutomod'); case 'timed_out': return t('fsChatTimedOut'); case 'banned': return t('fsChatBanned'); case 'duplicate': return t('fsChatDuplicate'); case 'rate_limit': return t('fsChatRateLimit'); case 'empty_message': return t('favoritesEmptyText'); default: break; } const twitchMsg = String(error?.userMessage || '').trim(); if (twitchMsg) return twitchMsg; return t('fsChatFailed'); } async function sendTwitchChatMessage(message) { const text = String(message || '').trim(); if (!text) { const error = new Error('empty_message'); error.code = 'empty_message'; throw error; } const channelID = await resolveLiveChannelId(); const nonce = createChatNonce(); const data = await twitchGqlRequest([ gqlPersisted( 'sendChatMessage', { input: { channelID, message: text, nonce, replyParentMessageID: null, }, }, SEND_CHAT_MESSAGE_HASH, ), ]); const payload = unwrapGqlPayload(data); assertNoGqlErrors(payload, 'send_failed'); const result = payload?.data?.sendChatMessage; if (!result) { throw createChatSendError({ code: 'send_failed', userMessage: '' }, payload); } const failure = extractChatSendFailure(result); if (failure) { throw createChatSendError(failure, result); } return { channelID, nonce, result }; } const IGNORED_CHAT_BOTS = new Set([ 'streamelements', 'streamelement', 'nightbot', 'moobot', 'fossabot', 'streamlabs', 'wizebot', 'botrix', 'botrixlive', 'coeabot', 'phantombot', 'stay_hydrated_bot', 'soundalerts', 'streamholics', 'commanderroot', ]); const CHAT_DIVIDER_TEXT_RE = /^(全新|新消息|新訊息|消息分隔|訊息分隔|new messages?|welcome to the chat(?: room)?!?|chat cleared|room modes?)$/i; const PURE_LINK_TOKEN_RE = /^(?:https?:\/\/|www\.)\S+$/i; function normalizeChatActor(value) { return String(value || '').trim().replace(/^@+/, '').toLowerCase(); } function isIgnoredChatBot(login, username) { const actors = [login, username].map(normalizeChatActor).filter(Boolean); return actors.some((name) => IGNORED_CHAT_BOTS.has(name)); } function isUserNoticeLine(msgEl) { return !!msgEl?.matches?.('[data-test-selector="user-notice-line"], .user-notice-line'); } function hasUserAuthoredChatBody(msgEl) { if (!msgEl) return false; const customRoot = msgEl.querySelector('[data-a-target="chat-resubscription-message__custom-message"]'); const chatRoot = customRoot || msgEl; const body = chatRoot.querySelector( '[data-a-target="chat-line-message-body"], .chat-line__message-body, [data-a-target="chat-message-container"]', ) || chatRoot; if (body.querySelector('[data-a-target="chat-message-text"], .text-fragment')?.textContent?.trim()) { return true; } if (body.querySelector('img[data-emote-name], img.chat-line__message--emote, .chat-line__message--emote')) { return true; } return false; } function isPaidSuperChatNotice(snapshot) { const meta = snapshot?.superchatMeta; return !!snapshot?.superchat && !!meta && (meta.kind === 'cheer' || meta.kind === 'sub'); } function lacksUserLiveChatContent(snapshot, msgEl = null) { if (!snapshot) return true; if (isPaidSuperChatNotice(snapshot)) return false; if (msgEl && hasUserAuthoredChatBody(msgEl)) return false; const plain = String(snapshot.message || '').replace(/\s+/g, ' ').trim(); const hasRenderableParts = Array.isArray(snapshot.parts) && snapshot.parts.some((part) => ( (part.type === 'text' && String(part.value || '').trim()) || (part.type === 'emote' && (part.name || part.src)) )); if (hasRenderableParts && !isUserNoticeLine(msgEl)) return false; if (msgEl && isUserNoticeLine(msgEl)) return true; if (!msgEl) return !plain; return !plain && !hasRenderableParts; } function isPureLinkMessage(text) { const cleaned = String(text || '') .replace(/[\u200B-\u200D\uFEFF]/g, '') .trim(); if (!cleaned) return false; const tokens = cleaned.split(/\s+/).filter(Boolean); if (!tokens.length) return false; return tokens.every((token) => PURE_LINK_TOKEN_RE.test(token)); } function isChatDividerMessage(msgEl, snapshot = null) { if (msgEl) { if (msgEl.matches?.('.chat-line__status, [data-a-target="chat-welcome-message"], [data-test-selector="chat-line-status"]')) { return true; } if (msgEl.closest?.('.chat-line__status, [data-a-target="chat-welcome-message"], [class*="chat-line-status"]')) { return true; } if (msgEl.querySelector?.('.chat-line__status, [data-a-target="chat-welcome-message"]')) { return true; } if (msgEl.matches?.('[class*="separator"], [class*="divider"], [data-test-selector*="separator"]') || msgEl.querySelector?.('[class*="chat-separator"], [class*="message-separator"]')) { return true; } const hasUsername = !!msgEl.querySelector?.(SELECTORS.chatUsername); const login = normalizeChatActor(snapshot?.login || msgEl.getAttribute?.('data-a-user')); if (!hasUsername && !login) return true; } const text = String(snapshot?.message || msgEl?.textContent || '') .replace(/\s+/g, ' ') .trim(); return !!text && CHAT_DIVIDER_TEXT_RE.test(text); } function shouldSuppressSubscribeSuperchat(settings, snapshot) { if (!settings?.superchatHideSubscribe) return false; const meta = snapshot?.superchatMeta; return !!snapshot?.superchat && meta?.kind === 'sub'; } function shouldIgnoreChatPayload(snapshot, msgEl = null, settings = null) { if (!snapshot) return true; if (shouldSuppressSubscribeSuperchat(settings, snapshot)) return true; if (isIgnoredChatBot(snapshot.login, snapshot.username)) return true; if (isPaidSuperChatNotice(snapshot)) { if (isPureLinkMessage(snapshot.message)) return true; return false; } if (lacksUserLiveChatContent(snapshot, msgEl)) return true; if (isChatDividerMessage(msgEl, snapshot)) return true; if (isPureLinkMessage(snapshot.message)) return true; return false; } const FAVORITES_STORAGE_KEY = 'tbc_favorites_v1'; const FAVORITES_LIMIT = 80; const FAVORITE_TEXT_LIMIT = 500; const DEFAULT_FAVORITES_STATE = { items: [], wheel: { minIntervalSec: 3, maxIntervalSec: 20, maxCount: 30, maxDurationSec: 300, }, }; const WHEEL_MIN_INTERVAL_FLOOR = 1.5; function createFavoriteId() { return `fav-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; } function sanitizeFavoriteShortcut(value) { if (value === null || value === undefined || value === '') return null; const digit = String(value).trim(); if (!/^[0-9]$/.test(digit)) return null; return digit; } function sanitizeFavoriteChannel(value) { const login = normalizeTwitchLogin ? normalizeTwitchLogin(value) : String(value || '').trim().replace(/^@+/, '').toLowerCase(); return login || null; } function sanitizeFavoriteItem(raw) { if (!raw || typeof raw !== 'object') return null; const text = String(raw.text || '').trim().slice(0, FAVORITE_TEXT_LIMIT); if (!text) return null; // null channel => site-wide; string => channel-scoped. // Legacy items without `channel` are treated as global for backward compatibility. let channel = null; if (Object.prototype.hasOwnProperty.call(raw, 'channel')) { channel = raw.channel == null || raw.channel === '' ? null : sanitizeFavoriteChannel(raw.channel); } return { id: String(raw.id || createFavoriteId()), text, shortcut: sanitizeFavoriteShortcut(raw.shortcut), channel, }; } function sanitizeFavoritesState(raw) { const base = { items: [], wheel: { ...DEFAULT_FAVORITES_STATE.wheel }, }; if (!raw || typeof raw !== 'object') return base; const seen = new Set(); const items = []; for (const entry of Array.isArray(raw.items) ? raw.items : []) { const item = sanitizeFavoriteItem(entry); if (!item || seen.has(item.id)) continue; seen.add(item.id); items.push(item); if (items.length >= FAVORITES_LIMIT) break; } // Shortcuts must be unique within the same scope (global or a channel). const used = new Map(); for (const item of items) { if (!item.shortcut) continue; const scope = item.channel || '__global__'; const key = `${scope}:${item.shortcut}`; if (used.has(key)) item.shortcut = null; else used.set(key, item.id); } const wheelRaw = raw.wheel && typeof raw.wheel === 'object' ? raw.wheel : {}; let minIntervalSec = clamp( finiteNumber(wheelRaw.minIntervalSec, DEFAULT_FAVORITES_STATE.wheel.minIntervalSec), WHEEL_MIN_INTERVAL_FLOOR, 600, ); let maxIntervalSec = clamp( finiteNumber(wheelRaw.maxIntervalSec, DEFAULT_FAVORITES_STATE.wheel.maxIntervalSec), WHEEL_MIN_INTERVAL_FLOOR, 600, ); if (minIntervalSec > maxIntervalSec) { const swap = minIntervalSec; minIntervalSec = maxIntervalSec; maxIntervalSec = swap; } return { items, wheel: { minIntervalSec, maxIntervalSec, maxCount: clamp(finiteNumber(wheelRaw.maxCount, DEFAULT_FAVORITES_STATE.wheel.maxCount), 1, 500), maxDurationSec: clamp(finiteNumber(wheelRaw.maxDurationSec, DEFAULT_FAVORITES_STATE.wheel.maxDurationSec), 10, 7200), }, }; } function loadFavoritesState() { try { return sanitizeFavoritesState(GM_getValue(FAVORITES_STORAGE_KEY, null)); } catch (_) { return sanitizeFavoritesState(null); } } function saveFavoritesState(state) { const next = sanitizeFavoritesState(state); GM_setValue(FAVORITES_STORAGE_KEY, next); return next; } function favoriteVisibleForChannel(item, channelLogin) { if (!item) return false; if (!item.channel) return true; const channel = sanitizeFavoriteChannel(channelLogin); return !!channel && item.channel === channel; } function listFavoritesForChannel(state, channelLogin) { const items = Array.isArray(state?.items) ? state.items : []; return items.filter((item) => favoriteVisibleForChannel(item, channelLogin)); } function addFavoriteText(text, { shortcut = null, channel = undefined } = {}) { const state = loadFavoritesState(); const currentChannel = sanitizeFavoriteChannel( channel === undefined ? currentChannelLogin?.() : channel, ); // Default: bind to current live channel. Fallback to global when channel unknown. const item = sanitizeFavoriteItem({ text, shortcut, channel: channel === null ? null : currentChannel, }); if (!item) return { state, item: null, added: false }; if (item.shortcut) { state.items.forEach((entry) => { if (entry.shortcut !== item.shortcut) return; const sameScope = (entry.channel || null) === (item.channel || null); if (sameScope) entry.shortcut = null; }); } const existing = state.items.find((entry) => ( entry.text === item.text && (entry.channel || null) === (item.channel || null) )); if (existing) { if (item.shortcut) existing.shortcut = item.shortcut; return { state: saveFavoritesState(state), item: existing, added: false }; } if (state.items.length >= FAVORITES_LIMIT) { return { state, item: null, added: false, full: true }; } state.items.unshift(item); return { state: saveFavoritesState(state), item, added: true }; } function setFavoriteGlobal(itemId, global = true) { const state = loadFavoritesState(); const item = state.items.find((entry) => entry.id === itemId); if (!item) return { state, item: null }; item.channel = global ? null : (sanitizeFavoriteChannel(currentChannelLogin?.()) || item.channel); if (item.shortcut) { state.items.forEach((entry) => { if (entry.id === item.id || entry.shortcut !== item.shortcut) return; const sameScope = (entry.channel || null) === (item.channel || null); if (sameScope) entry.shortcut = null; }); } return { state: saveFavoritesState(state), item }; } 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 = []; this.ircBackgroundOnly = false; this.recentEmitKeys = new Map(); } isDuplicateEmit(login, message) { const text = String(message || '').trim(); if (!text) return false; const key = `${String(login || '').toLowerCase()}|${text}`.slice(0, 240); const now = Date.now(); const prev = this.recentEmitKeys.get(key); if (prev && now - prev < 3500) return true; this.recentEmitKeys.set(key, now); if (this.recentEmitKeys.size > 200) { for (const [k, ts] of this.recentEmitKeys) { if (now - ts > 4000) this.recentEmitKeys.delete(k); } } return false; } 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, { backgroundOnly = false } = {}) { if (this.ircSource) return; this.ircBackgroundOnly = !!backgroundOnly; 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 = []; this.ircBackgroundOnly = false; this.recentEmitKeys.clear(); } ensureBackgroundIrc() { if (currentVodId()) return; const channel = currentChannelLogin(); if (!channel || this.ircSource) return; this.startIrc(channel, { backgroundOnly: this.mode === 'dom' }); console.log('[TBC] IRC started for background / PiP chat'); } maybeStopBackgroundIrc() { if (!this.ircSource || !this.ircBackgroundOnly) return; if (enhancedPip?.isOpen?.()) return; if (document.hidden) return; this.ircSource.stop(); this.ircSource = null; this.ircBackgroundOnly = false; } 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; if (shouldIgnoreChatPayload(parsed, msgEl, this.getSettings())) continue; this.emitDanmaku(parsed, msgEl); } } handleIrcMessage(parsed) { if (!this.isActive()) return; if (Date.now() - this.readyAt < WARMUP_MS) return; this.pushIrcBuffer(parsed); const payload = { 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, }; if (shouldIgnoreChatPayload(payload, null, this.getSettings())) return; this.emitDanmaku(payload, 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(); } clearIrcHistory() { this.ircBuffer = []; } emitDanmaku(parsed, msgEl) { const settings = this.getSettings(); if (!this.isActive()) return; if (this.isDuplicateEmit(parsed.login || parsed.username, parsed.message)) 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; const manualTl = hasManualChatTranslation(msgEl); if (latest.translateEnabled && latest.translateInChat && allowChatTranslation && !manualTl) { applyChatTranslation(msgEl, message, parsed.message); } else if (msgEl && !manualTl) { 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-translate-btn .tbc-icon { display: none; line-height: 0; } .tbc-translate-btn.tbc-translate-on .tbc-icon-on, .tbc-translate-btn.tbc-translate-off .tbc-icon-off { display: block; } .tbc-translate-btn.tbc-translate-off { opacity: 0.55; } /* Cloned InjectLayout keeps Twitch button chrome; fallback is for demo / missing shell. */ .tbc-chat-clear-wrap { flex-shrink: 0; } .tbc-chat-clear-btn svg { pointer-events: none; } .tbc-chat-clear-btn--fallback { display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; box-sizing: border-box; width: 32px; height: 32px; margin: 0; padding: 0; border: none; border-radius: 0.4rem; background: transparent; color: var(--color-fill-button-icon, var(--color-text-base, #efeff1)); cursor: pointer; transition: background-color 0.1s ease, color 0.1s ease; } .tbc-chat-clear-btn--fallback:hover { background-color: var(--color-background-interactable-hover, rgba(83, 83, 95, 0.48)); color: var(--color-fill-button-icon-hover, var(--color-text-base, #efeff1)); } .tbc-chat-clear-btn--fallback:active { background-color: var(--color-background-interactable-active, rgba(83, 83, 95, 0.7)); } .tbc-chat-clear-btn--fallback svg { width: 20px; height: 20px; fill: currentColor; display: block; pointer-events: none; } .tbc-fav-btn { display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; box-sizing: border-box; width: 32px; height: 32px; margin: 0; padding: 0; border: none; border-radius: 0.4rem; background: transparent; color: var(--color-fill-button-icon, var(--color-text-base, #efeff1)); cursor: pointer; transition: background-color 0.1s ease, color 0.1s ease; } .tbc-fav-btn:hover { background-color: var(--color-background-interactable-hover, rgba(83, 83, 95, 0.48)); color: #ffca5f; } .tbc-fav-btn:active { background-color: var(--color-background-interactable-active, rgba(83, 83, 95, 0.7)); } .tbc-fav-btn svg { width: 20px; height: 20px; fill: currentColor; display: block; pointer-events: none; } .tbc-fav-panel { max-height: min(80vh, 580px); display: flex; flex-direction: column; } .tbc-fav-add-row { display: flex; gap: 8px; padding: 10px 12px 0; } .tbc-fav-add-input { flex: 1; min-width: 0; height: 32px; box-sizing: border-box; border-radius: 4px; border: 1px solid rgba(255, 255, 255, 0.16); background: #18181b; color: #efeff1; padding: 0 10px; font-size: 13px; } .tbc-fav-add-btn, .tbc-fav-item-send, .tbc-fav-wheel-toggle { border: none; border-radius: 4px; background: #9147ff; color: #fff; font-size: 12px; font-weight: 600; padding: 0 10px; height: 32px; cursor: pointer; white-space: nowrap; } .tbc-fav-add-btn:hover, .tbc-fav-item-send:hover, .tbc-fav-wheel-toggle:hover { background: #772ce8; } .tbc-fav-wheel-toggle.is-running { background: #eb0400; } .tbc-fav-wheel-toggle.is-running:hover { background: #c50300; } .tbc-fav-list { margin-top: 10px; padding: 0 12px; overflow: auto; max-height: 240px; display: flex; flex-direction: column; gap: 8px; } .tbc-fav-empty { color: #adadb8; font-size: 12px; padding: 12px 0; text-align: center; } .tbc-fav-wrap { flex-shrink: 0; } .tbc-fav-item { display: flex; flex-direction: column; gap: 6px; padding: 8px; border-radius: 6px; background: rgba(0, 0, 0, 0.25); border: 1px solid rgba(255, 255, 255, 0.08); } .tbc-fav-item.is-global { border-color: rgba(145, 71, 255, 0.45); } .tbc-fav-item-meta { font-size: 11px; font-weight: 600; color: #adadb8; } .tbc-fav-item.is-global .tbc-fav-item-meta { color: #bf94ff; } .tbc-fav-item-text { width: 100%; box-sizing: border-box; resize: vertical; min-height: 32px; height: 32px; border-radius: 4px; border: 1px solid rgba(255, 255, 255, 0.14); background: #18181b; color: #efeff1; padding: 6px 8px; font: inherit; font-size: 13px; } .tbc-fav-item-tools { display: flex; gap: 6px; align-items: center; } .tbc-fav-item-shortcut { height: 28px; border-radius: 4px; border: 1px solid rgba(255, 255, 255, 0.14); background: #18181b; color: #efeff1; font-size: 12px; } .tbc-fav-item-scope { height: 28px; border: none; border-radius: 4px; background: #3a3a3d; color: #efeff1; font-size: 12px; font-weight: 600; padding: 0 10px; cursor: pointer; } .tbc-fav-item-scope:hover { background: #9147ff; } .tbc-fav-item-remove { margin-left: auto; height: 28px; border: none; border-radius: 4px; background: #3a3a3d; color: #efeff1; font-size: 12px; font-weight: 600; padding: 0 10px; cursor: pointer; } .tbc-fav-item-remove:hover { background: #ad1c1c; } .tbc-fav-status { min-height: 16px; margin: 6px 12px 0; color: #ff8280; font-size: 12px; } .tbc-fav-status.is-ok { color: #00f593; } .tbc-fav-wheel { margin-top: 10px; padding: 10px 12px 12px; border-top: 1px solid rgba(255, 255, 255, 0.08); display: flex; flex-direction: column; gap: 8px; } .tbc-fav-wheel-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; } .tbc-fav-wheel-title { font-size: 13px; font-weight: 700; } .tbc-fav-wheel-grid { display: grid; grid-template-columns: 1fr; gap: 6px; } .tbc-fav-wheel-field { display: flex; align-items: center; justify-content: space-between; gap: 8px; color: #adadb8; font-size: 12px; } .tbc-fav-wheel-field input { width: 88px; height: 28px; box-sizing: border-box; border-radius: 4px; border: 1px solid rgba(255, 255, 255, 0.14); background: #18181b; color: #efeff1; padding: 0 8px; font-size: 12px; } .tbc-fav-wheel-status { color: #adadb8; font-size: 12px; } .tbc-superchat-layer { pointer-events: none; display: none; flex-direction: column; align-items: center; justify-content: flex-start; align-content: flex-start; gap: 6px; overflow: visible; padding: 6px 0 4px; box-sizing: border-box; min-height: 0; } .tbc-superchat-layer.tbc-sc-scrollable { overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; pointer-events: auto; scrollbar-width: thin; } .tbc-superchat-layer.tbc-sc-bottom { justify-content: flex-end; } .tbc-superchat-layer.tbc-sc-align-left { align-items: flex-start; padding-left: 8px; } .tbc-superchat-layer.tbc-sc-align-right { align-items: flex-end; padding-right: 8px; } .tbc-superchat-item { position: relative; display: flex; align-items: flex-start; gap: 10px; flex-shrink: 0; min-width: min(280px, calc(100% - 24px)); padding: 7px 28px 7px 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-close { position: absolute; top: 2px; right: 2px; z-index: 3; width: 24px; height: 24px; margin: 0; padding: 0; border: none; border-radius: 9000px; display: inline-flex; align-items: center; justify-content: center; background: transparent; color: #ffffff; cursor: pointer; opacity: 0; pointer-events: none; transition: opacity 0.15s ease, background-color 0.1s ease, color 0.1s ease; } .tbc-superchat-item:hover .tbc-sc-close, .tbc-superchat-item.tbc-paused .tbc-sc-close, .tbc-superchat-item:focus-within .tbc-sc-close { opacity: 1; pointer-events: auto; } .tbc-sc-close:hover { background: rgba(255, 255, 255, 0.2); color: #ffffff; } .tbc-sc-close:active { background: rgba(255, 255, 255, 0.28); } .tbc-sc-close svg { width: 16px; height: 16px; display: block; pointer-events: none; fill: currentColor; } .tbc-sc-avatar { width: 32px; height: 32px; 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); margin-top: 1px; } .tbc-sc-main { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 3px; } .tbc-sc-head { display: flex; align-items: center; gap: 8px; min-width: 0; flex-wrap: nowrap; } .tbc-sc-meta { display: inline-flex; align-items: center; flex-wrap: nowrap; gap: 6px; flex-shrink: 0; margin-left: auto; } .tbc-sc-amount { 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); font-size: 0.72em; font-weight: 800; letter-spacing: 0.02em; line-height: 1.4; white-space: nowrap; } .tbc-sc-tier { min-width: 2.2em; text-align: center; } .tbc-superchat-item.tbc-sc-compact { min-width: unset; max-width: min(360px, calc(100% - 24px)); padding: 4px 34px 4px 5px; gap: 6px; line-height: 1.2; border-radius: 10px; cursor: pointer; flex-shrink: 0; align-items: center; } .tbc-superchat-item.tbc-sc-compact .tbc-sc-avatar { width: 24px; height: 24px; border-width: 1px; margin-top: 0; } .tbc-sc-compact-main { min-width: 0; flex: 1; display: flex; align-items: center; gap: 6px; } .tbc-sc-compact-name { min-width: 0; flex: 0 1 auto; max-width: 6.5em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.88em; font-weight: 800; opacity: 0.96; } .tbc-superchat-item.tbc-sc-compact .tbc-sc-meta { margin-left: 0; flex-shrink: 0; gap: 4px; } .tbc-superchat-item.tbc-sc-compact .tbc-sc-badge, .tbc-superchat-item.tbc-sc-compact .tbc-sc-amount { padding: 0 5px; font-size: 0.68em; line-height: 1.35; } .tbc-superchat-item.tbc-sc-compact .tbc-sc-badge.tbc-sc-tier { padding: 0; min-width: 0; } .tbc-superchat-item.tbc-sc-compact .tbc-sc-tier-img { height: 1.1em; max-width: 2.35em; } .tbc-superchat-item.tbc-sc-compact .tbc-sc-close { top: 50%; right: 3px; width: 22px; height: 22px; transform: translateY(-50%); } .tbc-superchat-item.tbc-sc-compact .tbc-sc-close svg { width: 14px; height: 14px; } .tbc-sc-name { font-size: 0.85em; font-weight: 800; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; flex: 1 1 auto; 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-badge.tbc-sc-tier { padding: 0; background: transparent; border: none; line-height: 0; } .tbc-sc-tier-img { display: block; height: 1.45em; width: auto; max-width: 2.85em; object-fit: contain; } .tbc-sc-body { min-width: 0; overflow: hidden; max-width: 100%; line-height: 1.3; min-height: 1.3em; } .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; } .tbc-sc-card-root { position: absolute; inset: 0; z-index: 10020; display: flex; align-items: center; justify-content: center; padding: 16px; box-sizing: border-box; pointer-events: auto; } .tbc-sc-card-backdrop { position: absolute; inset: 0; margin: 0; padding: 0; border: none; background: rgba(0, 0, 0, 0.55); cursor: pointer; } .tbc-sc-card { position: relative; z-index: 1; width: min(520px, calc(100% - 24px)); max-height: min(70vh, 420px); overflow: auto; border-radius: 14px; border: 1px solid rgba(255, 255, 255, 0.22); box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45); padding: 14px 14px 12px; box-sizing: border-box; } .tbc-sc-card-header { display: flex; align-items: flex-start; gap: 10px; margin-bottom: 10px; } .tbc-sc-card-avatar { width: 44px; height: 44px; border-radius: 50%; object-fit: cover; flex-shrink: 0; border: 2px solid rgba(255, 255, 255, 0.35); } .tbc-sc-card-headtext { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 4px; } .tbc-sc-card-name { font-size: 15px; font-weight: 800; line-height: 1.25; word-break: break-word; } .tbc-sc-card-meta { align-self: flex-start; } .tbc-sc-card-close { flex-shrink: 0; width: 32px; height: 32px; margin: 0; padding: 0; border: none; border-radius: 999px; background: rgba(0, 0, 0, 0.25); color: inherit; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; } .tbc-sc-card-close:hover { background: rgba(255, 255, 255, 0.18); } .tbc-sc-card-close svg { width: 18px; height: 18px; fill: currentColor; } .tbc-sc-card-body { font-size: 15px; font-weight: 600; line-height: 1.45; word-break: break-word; white-space: pre-wrap; } .tbc-sc-card-translation { margin-top: 10px; padding: 8px 10px; border-radius: 8px; background: rgba(0, 0, 0, 0.22); border: 1px solid rgba(255, 255, 255, 0.15); font-size: 14px; line-height: 1.45; white-space: pre-wrap; } .tbc-sc-card-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; } .tbc-sc-card-action { appearance: none; border: 1px solid rgba(255, 255, 255, 0.28); background: rgba(0, 0, 0, 0.22); color: inherit; border-radius: 8px; padding: 7px 12px; font-size: 13px; font-weight: 700; cursor: pointer; } .tbc-sc-card-action:hover:not(:disabled) { background: rgba(255, 255, 255, 0.16); } .tbc-sc-card-action:disabled { opacity: 0.45; cursor: not-allowed; } @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-block-users-header { display: flex; align-items: center; justify-content: space-between; gap: 8px; } .tbc-block-users-header .tbc-balloon-label { margin: 0; } .tbc-block-users-refresh { flex-shrink: 0; border: none; border-radius: 4px; background: #3a3a3d; color: #efeff1; padding: 4px 8px; font-size: 12px; cursor: pointer; } .tbc-block-users-refresh:hover { background: #4a4a4d; } .tbc-block-users-refresh:disabled, .tbc-block-user-remove:disabled { opacity: 0.55; cursor: default; } .tbc-block-users-status { color: #adadb8; font-size: 12px; line-height: 1.4; } .tbc-block-users-list { display: flex; flex-direction: column; gap: 6px; max-height: 180px; overflow-y: auto; } .tbc-block-user { display: flex; align-items: center; gap: 8px; padding: 6px 8px; border-radius: 6px; background: rgba(255, 255, 255, 0.04); } .tbc-block-user-avatar { width: 28px; height: 28px; border-radius: 50%; object-fit: cover; background: #3a3a3d; flex-shrink: 0; } .tbc-block-user-meta { flex: 1; min-width: 0; } .tbc-block-user-name { font-size: 13px; font-weight: 600; color: #efeff1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .tbc-block-user-date { margin-top: 2px; font-size: 11px; color: #adadb8; } .tbc-block-user-remove { flex-shrink: 0; border: none; border-radius: 4px; background: transparent; color: #bf94ff; padding: 4px 6px; font-size: 12px; cursor: pointer; } .tbc-block-user-remove:hover { background: rgba(145, 71, 255, 0.2); } .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; } .tbc-fs-chat { position: absolute; left: 50%; bottom: max(72px, 10%); transform: translateX(-50%); z-index: 100020; width: min(560px, calc(100% - 48px)); display: flex; flex-direction: column; gap: 6px; pointer-events: auto; font-family: Roobert, Inter, "Helvetica Neue", Helvetica, Arial, sans-serif; } .tbc-fs-chat-row { display: flex; align-items: stretch; gap: 8px; padding: 8px; border-radius: 8px; background: rgba(14, 14, 16, 0.92); border: 1px solid rgba(255, 255, 255, 0.14); box-shadow: 0 10px 36px rgba(0, 0, 0, 0.5); backdrop-filter: blur(10px); } .tbc-fs-chat-input { flex: 1; min-width: 0; box-sizing: border-box; height: 40px; padding: 0 12px; border-radius: 6px; border: 1px solid rgba(255, 255, 255, 0.16); background: #18181b; color: #efeff1; font-size: 14px; line-height: 40px; outline: none; caret-color: #bf94ff; } .tbc-fs-chat-input::placeholder { color: #adadb8; } .tbc-fs-chat-input:focus { border-color: #9147ff; box-shadow: 0 0 0 1px #9147ff; } .tbc-fs-chat-fav-wrap { position: relative; flex-shrink: 0; } .tbc-fs-chat-fav, .tbc-fs-chat-send { flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-width: 72px; height: 40px; padding: 0 14px; border: none; border-radius: 6px; color: #ffffff; font-size: 13px; font-weight: 600; cursor: pointer; transition: background 0.15s ease; } .tbc-fs-chat-fav { background: #3a3a3d; } .tbc-fs-chat-fav:hover, .tbc-fs-chat-fav.is-open { background: #53535f; } .tbc-fs-chat-fav:active { background: #2e2e33; } .tbc-fs-chat-fav svg, .tbc-fs-chat-send svg { width: 16px; height: 16px; display: block; flex-shrink: 0; } .tbc-fs-chat-send { background: #9147ff; } .tbc-fs-chat-send:hover:not(:disabled) { background: #772ce8; } .tbc-fs-chat-send:active:not(:disabled) { background: #5c16c5; } .tbc-fs-chat-send:disabled { opacity: 0.45; cursor: default; } .tbc-fs-chat-fav-list { position: absolute; left: 0; bottom: calc(100% + 6px); z-index: 2; box-sizing: border-box; width: max(220px, 100%); max-width: min(360px, 70vw); max-height: 220px; overflow-x: hidden; overflow-y: auto; padding: 6px; border-radius: 8px; border: 1px solid rgba(255, 255, 255, 0.14); background: rgba(14, 14, 16, 0.96); box-shadow: 0 10px 28px rgba(0, 0, 0, 0.55); backdrop-filter: blur(10px); } .tbc-fs-chat-fav-empty { padding: 10px 8px; color: #adadb8; font-size: 12px; line-height: 1.4; text-align: center; } .tbc-fs-chat-fav-item { display: block; width: 100%; box-sizing: border-box; margin: 0; padding: 8px 10px; border: none; border-radius: 6px; background: transparent; color: #efeff1; font-size: 13px; line-height: 1.35; text-align: left; cursor: pointer; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; max-width: 100%; } .tbc-fs-chat-fav-item:hover { background: rgba(145, 71, 255, 0.28); } .tbc-fs-chat-fav-item:active { background: rgba(145, 71, 255, 0.4); } .tbc-fs-chat-close { flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center; width: 40px; height: 40px; min-width: 72px; padding: 0 14px; border: none; border-radius: 6px; background: #eb0400; color: #ffffff; cursor: pointer; transition: background 0.15s ease, filter 0.15s ease; } .tbc-fs-chat-close:hover { background: #c50300; filter: brightness(1.05); } .tbc-fs-chat-close:active { background: #a80300; } .tbc-fs-chat-close svg { width: 16px; height: 16px; display: block; pointer-events: none; } .tbc-fs-chat-meta { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 0 4px; min-height: 16px; } .tbc-fs-chat-status { flex: 1; min-width: 0; max-width: 60%; color: #ff8280; font-size: 12px; line-height: 1.35; text-align: left; word-break: break-word; } .tbc-fs-chat-status:empty { display: none; } .tbc-fs-chat-status.is-ok { color: #00f593; } .tbc-pip-page-toast { position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%); z-index: 100000; max-width: min(420px, calc(100% - 24px)); padding: 10px 14px; border-radius: 8px; background: rgba(14, 14, 16, 0.94); color: #efeff1; border: 1px solid rgba(255, 255, 255, 0.14); font-size: 13px; line-height: 1.4; opacity: 0; pointer-events: none; transition: opacity 0.15s ease; } .tbc-pip-page-toast.is-show { opacity: 1; } `); } 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_TRANSLATE_ON = ``; const ICON_TRANSLATE_OFF = ``; const ICON_DOWNLOAD = ``; const ICON_CHAT_CLEAR_PATH = 'M360 184h-8c4.4 0 8-3.6 8-8v8h304v-8c0 4.4 3.6 8 8 8h-8v72h72v-80c0-35.3-28.7-64-64-64H352c-35.3 0-64 28.7-64 64v80h72v-72zm504 72H160c-17.7 0-32 14.3-32 32v32c0 4.4 3.6 8 8 8h60.4l24.7 523c1.6 34.1 29.8 61 63.9 61h454c34.2 0 62.3-26.8 63.9-61l24.7-523H888c4.4 0 8-3.6 8-8v-32c0-17.7-14.3-32-32-32zM731.3 840H292.7l-24.2-512h487l-24.2 512z'; const ICON_CHAT_CLEAR = ``; const ICON_CHAT_SEND = ``; const ICON_CLOSE = ``; const ICON_FAVORITE = ``; const ICON_PIP = ``; const ICON_PIP_ASPECT_UNLOCK = ``; const ICON_PIP_ASPECT_LOCK = ``; 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); this.uiBindings = {}; } syncFromSettings() { if (!this.isOpen || !this.balloon) return; for (const [key, binding] of Object.entries(this.uiBindings)) { const value = this.settings[key]; if (binding.type === 'checkbox') { binding.el.checked = !!value; } else if (binding.type === 'range') { binding.el.value = String(value); if (binding.format) binding.valueEl.textContent = binding.format(value); } else if (binding.type === 'select') { binding.el.value = String(value); } } this.syncTranslateDependentRows(); } 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; this.twitchBlockInput = null; this.twitchBlockAddBtn = null; this.twitchBlockStatus = null; this.twitchBlockList = null; this.twitchBlockRefreshBtn = null; this.twitchBlockBusy = false; 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); }); this.uiBindings[key] = { type: 'checkbox', el: input }; 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; } 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); }); this.uiBindings[key] = { type: 'range', el: input, valueEl: valueSpan, format }; 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); }); this.uiBindings[key] = { type: 'select', el: select }; row.append(labelEl, select); return row; } createTwitchBlockUsersRow() { const row = document.createElement('div'); row.className = 'tbc-balloon-row tbc-balloon-row--field'; const header = document.createElement('div'); header.className = 'tbc-block-users-header'; const labelEl = document.createElement('div'); labelEl.className = 'tbc-balloon-label'; labelEl.textContent = t('blockUsers'); const refreshBtn = document.createElement('button'); refreshBtn.type = 'button'; refreshBtn.className = 'tbc-block-users-refresh'; refreshBtn.textContent = t('blockUsersRefresh'); refreshBtn.addEventListener('click', (e) => { e.stopPropagation(); this.loadTwitchBlockedUsers(); }); header.append(labelEl, refreshBtn); const hint = document.createElement('div'); hint.className = 'tbc-keyword-hint'; hint.textContent = t('blockUsersHint'); 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 = 25; input.placeholder = t('blockUsersPlaceholder'); input.setAttribute('aria-label', t('blockUsers')); const addBtn = document.createElement('button'); addBtn.type = 'button'; addBtn.className = 'tbc-keyword-add'; addBtn.textContent = t('blockUsersAdd'); const status = document.createElement('div'); status.className = 'tbc-block-users-status'; const list = document.createElement('div'); list.className = 'tbc-block-users-list'; this.twitchBlockInput = input; this.twitchBlockAddBtn = addBtn; this.twitchBlockStatus = status; this.twitchBlockList = list; this.twitchBlockRefreshBtn = refreshBtn; const commit = async () => { const login = normalizeTwitchLogin(input.value); if (!login || this.twitchBlockBusy) return; this.setTwitchBlockBusy(true); this.setTwitchBlockStatus(''); try { await blockTwitchUser(login, { sourceContext: 'SETTINGS' }); input.value = ''; await this.loadTwitchBlockedUsers({ silent: true }); } catch (error) { const code = error?.code || ''; if (code === 'not_logged_in') this.setTwitchBlockStatus(t('blockUsersNeedLogin')); else if (code === 'user_not_found') this.setTwitchBlockStatus(t('chatMenuBlockNotFound')); else this.setTwitchBlockStatus(t('blockUsersAddFailed')); } finally { this.setTwitchBlockBusy(false); } }; 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(header, hint, form, status, list); this.loadTwitchBlockedUsers(); return row; } setTwitchBlockStatus(text) { if (!this.twitchBlockStatus) return; this.twitchBlockStatus.textContent = text || ''; this.twitchBlockStatus.hidden = !text; } setTwitchBlockBusy(busy) { this.twitchBlockBusy = !!busy; if (this.twitchBlockInput) this.twitchBlockInput.disabled = !!busy; if (this.twitchBlockAddBtn) this.twitchBlockAddBtn.disabled = !!busy; if (this.twitchBlockRefreshBtn) this.twitchBlockRefreshBtn.disabled = !!busy; this.twitchBlockList?.querySelectorAll('button').forEach((btn) => { btn.disabled = !!busy; }); } async loadTwitchBlockedUsers({ silent = false } = {}) { if (!this.twitchBlockList) return; if (!silent) { this.twitchBlockList.replaceChildren(); this.setTwitchBlockStatus(t('blockUsersLoading')); } this.setTwitchBlockBusy(true); try { const users = await fetchTwitchBlockedUsers(); this.renderTwitchBlockedUsers(users); if (!silent) this.setTwitchBlockStatus(''); } catch (error) { this.twitchBlockList.replaceChildren(); const code = error?.code || ''; this.setTwitchBlockStatus( code === 'not_logged_in' ? t('blockUsersNeedLogin') : t('blockUsersLoadFailed'), ); } finally { this.setTwitchBlockBusy(false); } } renderTwitchBlockedUsers(users) { if (!this.twitchBlockList) return; this.twitchBlockList.replaceChildren(); if (!users.length) { const empty = document.createElement('div'); empty.className = 'tbc-keyword-empty'; empty.textContent = t('blockUsersEmpty'); this.twitchBlockList.appendChild(empty); return; } users.forEach((user) => { const item = document.createElement('div'); item.className = 'tbc-block-user'; const avatar = document.createElement('img'); avatar.className = 'tbc-block-user-avatar'; avatar.alt = ''; avatar.width = 28; avatar.height = 28; avatar.loading = 'lazy'; avatar.referrerPolicy = 'no-referrer'; avatar.src = user.profileImageURL || ''; const meta = document.createElement('div'); meta.className = 'tbc-block-user-meta'; const name = document.createElement('div'); name.className = 'tbc-block-user-name'; name.textContent = user.displayName || user.login; name.title = user.login ? `${user.displayName || user.login} (@${user.login})` : (user.displayName || ''); const date = document.createElement('div'); date.className = 'tbc-block-user-date'; date.textContent = user.blockedAtLabel || ''; meta.append(name, date); const removeBtn = document.createElement('button'); removeBtn.type = 'button'; removeBtn.className = 'tbc-block-user-remove'; removeBtn.textContent = t('blockUsersRemove'); removeBtn.setAttribute( 'aria-label', `${t('blockUsersRemove')} ${user.displayName || user.login}`, ); removeBtn.addEventListener('click', async (e) => { e.stopPropagation(); if (this.twitchBlockBusy) return; this.setTwitchBlockBusy(true); this.setTwitchBlockStatus(''); try { if (user.id) await unblockTwitchUserById(user.id); else await unblockTwitchUser(user.login); await this.loadTwitchBlockedUsers({ silent: true }); } catch (error) { const code = error?.code || ''; this.setTwitchBlockStatus( code === 'not_logged_in' ? t('blockUsersNeedLogin') : t('blockUsersRemoveFailed'), ); } finally { this.setTwitchBlockBusy(false); } }); item.append(avatar, meta, removeBtn); this.twitchBlockList.appendChild(item); }); } 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.uiBindings = {}; 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.createToggle(t('superchatOverlayDanmaku'), 'superchatOverlayDanmaku'), this.createToggle(t('superchatHideSubscribe'), 'superchatHideSubscribe'), this.createToggle(t('superchatCompact'), 'superchatCompact'), this.createRange(t('superchatMaxWidth'), 'superchatMaxWidth', 240, 720, 10, (v) => `${v}px`), this.createSelect(t('superchatPosition'), 'superchatPosition', getSuperchatPositionOptions()), this.createRange(t('superchatDuration'), 'superchatDuration', 2, 300, 1, (v) => `${v} ${t('speechSeconds')}`), this.createToggle(t('superchatDurationByAmount'), 'superchatDurationByAmount'), 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'); body.append( this.translateInChatRow, this.createDivider(), this.createKeywordBlock(), this.createDivider(), this.createTwitchBlockUsersRow(), ); 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(); this.uiBindings = {}; } syncFromSettings() { if (!this.isOpen || !this.balloon) return; for (const [key, binding] of Object.entries(this.uiBindings)) { const value = this.settings[key]; if (binding.type === 'checkbox') { binding.el.checked = !!value; } else if (binding.type === 'range') { binding.el.value = String(value); if (binding.format) binding.valueEl.textContent = binding.format(value); } } 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); }); this.uiBindings[key] = { type: 'checkbox', el: input }; switchWrap.append(input, slider); row.append(labelEl, switchWrap); return row; } render() { this.uiBindings = {}; 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); }); this.uiBindings[key] = { type: 'range', el: input, valueEl: valueSpan, format }; 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 FavoritesPanel { constructor() { this.state = loadFavoritesState(); this.balloon = null; this.anchor = null; this.btn = null; this.wrap = null; this.isOpen = false; this.sending = false; this.wheel = { running: false, timer: null, statusTimer: null, sent: 0, startedAt: 0, endsAt: 0, lastId: '', failStreak: 0, }; this.retryTimer = null; this.boundKeyDown = (e) => this.onKeyDown(e); this.boundReposition = () => this.reposition(); this.boundOutsideClick = (e) => this.handleOutsideClick(e); this.listEl = null; this.statusEl = null; this.wheelStatusEl = null; this.wheelToggleBtn = null; } attach() { document.addEventListener('keydown', this.boundKeyDown, true); } destroy() { this.stopWheel(false); this.close(); document.removeEventListener('keydown', this.boundKeyDown, true); clearTimeout(this.retryTimer); this.wrap?.remove(); if (this.btn && this.btn !== this.wrap) this.btn.remove(); this.wrap = null; this.btn = null; this.anchor = null; } remount() { if (this.wrap?.isConnected || this.btn?.isConnected) return; this.wrap?.remove(); if (this.btn && this.btn !== this.wrap) this.btn.remove(); this.wrap = null; this.btn = null; clearTimeout(this.retryTimer); this.retryTimer = setTimeout(() => this.mount(), 500); } currentChannel() { return normalizeTwitchLogin(currentChannelLogin?.() || ''); } visibleItems() { return listFavoritesForChannel(this.state, this.currentChannel()); } /** * Only accept the shared Bits + Emote toolbar host (Twitch `cedEhw`). * The host must contain both buttons under two distinct children. */ findInputActionsHost() { const bitsBtn = document.querySelector(SELECTORS.bitsButton); const emoteBtn = document.querySelector(SELECTORS.emotePickerButton); if (!bitsBtn || !emoteBtn) return { host: null, bitsBtn, emoteBtn }; const bitsPath = new Set(); for (let node = bitsBtn; node; node = node.parentElement) bitsPath.add(node); let host = null; for (let node = emoteBtn; node; node = node.parentElement) { if (bitsPath.has(node)) { host = node; break; } } if (!host || host === document.body || host === document.documentElement) { return { host: null, bitsBtn, emoteBtn }; } if (!host.contains(bitsBtn) || !host.contains(emoteBtn)) { return { host: null, bitsBtn, emoteBtn }; } const children = Array.from(host.children || []); const bitsChild = children.find((child) => child.contains(bitsBtn)); const emoteChild = children.find((child) => child.contains(emoteBtn)); // Reject oversized ancestors where both controls sit under the same child. if (!bitsChild || !emoteChild || bitsChild === emoteChild) { return { host: null, bitsBtn, emoteBtn }; } return { host, bitsBtn, emoteBtn }; } createButton() { const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'tbc-fav-btn'; btn.setAttribute('data-a-target', 'tbc-favorites-button'); btn.setAttribute('data-tbc-favorites', 'true'); btn.setAttribute('aria-label', t('favoritesTitle')); btn.setAttribute('title', t('favoritesTitle')); btn.innerHTML = ICON_FAVORITE; btn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); this.toggle(btn); }); return btn; } createFromBitsShell(bitsBtn) { const shell = bitsBtn.closest('[class*="InjectLayout"]') || bitsBtn.parentElement; if (!shell?.parentElement) return null; const wrap = shell.cloneNode(true); wrap.classList.add('tbc-fav-wrap'); wrap.setAttribute('data-tbc-favorites-wrap', 'true'); wrap.removeAttribute('id'); const btn = wrap.querySelector('button') || wrap.querySelector('[role="button"]'); if (!btn) return null; btn.classList.add('tbc-fav-btn'); btn.type = 'button'; btn.setAttribute('data-a-target', 'tbc-favorites-button'); btn.setAttribute('data-tbc-favorites', 'true'); btn.setAttribute('aria-label', t('favoritesTitle')); btn.setAttribute('title', t('favoritesTitle')); btn.removeAttribute('data-test-selector'); btn.removeAttribute('id'); const svg = btn.querySelector('svg'); if (svg) svg.outerHTML = ICON_FAVORITE; else btn.innerHTML = ICON_FAVORITE; btn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); this.toggle(btn); }); return { wrap, btn, host: shell.parentElement, after: shell }; } async mount() { if (this.wrap?.isConnected || this.btn?.isConnected) return; await waitForElement( `${SELECTORS.bitsButton}, ${SELECTORS.emotePickerButton}`, document, 12000, ); if (document.querySelector('[data-tbc-favorites="true"], [data-tbc-favorites-wrap="true"]')) return; const { host, bitsBtn, emoteBtn } = this.findInputActionsHost(); // Refuse to mount unless Bits + Emote both live in the same toolbar host. if (!host || !bitsBtn || !emoteBtn) { clearTimeout(this.retryTimer); this.retryTimer = setTimeout(() => this.mount(), 1200); return; } const created = this.createFromBitsShell(bitsBtn); if (created?.wrap && created.host === host) { this.wrap = created.wrap; this.btn = created.btn; if (created.after?.nextSibling) { host.insertBefore(created.wrap, created.after.nextSibling); } else { host.appendChild(created.wrap); } return; } this.btn = this.createButton(); this.wrap = this.btn; host.appendChild(this.btn); } isEditableTarget(target) { if (!target || target === document.body || target === document.documentElement) return false; const el = target.nodeType === 1 ? target : target.parentElement; if (!el) return false; const tag = el.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true; if (el.isContentEditable) return true; return !!el.closest?.('input, textarea, select, [contenteditable="true"], [role="textbox"]'); } onKeyDown(e) { if (e.isComposing || e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return; if (!/^[0-9]$/.test(e.key)) return; if (this.isEditableTarget(e.target)) return; if (this.isOpen && this.balloon?.contains(e.target)) return; const channel = this.currentChannel(); const visible = listFavoritesForChannel(this.state, channel); // Prefer channel-scoped shortcut over a colliding global one. const item = visible.find((entry) => entry.shortcut === e.key && entry.channel === channel) || visible.find((entry) => entry.shortcut === e.key && !entry.channel); if (!item) return; e.preventDefault(); e.stopPropagation(); this.sendFavorite(item); } toggle(anchor) { if (this.isOpen && this.anchor === anchor) this.close(); else this.open(anchor || this.btn); } open(anchor) { this.close(); this.state = loadFavoritesState(); this.anchor = anchor || this.btn; 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() { const wasOpen = this.isOpen; this.isOpen = false; this.anchor = null; this.balloon?.remove(); this.balloon = null; this.listEl = null; this.statusEl = null; this.wheelStatusEl = null; this.wheelToggleBtn = 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.refreshWheelControls(); } handleOutsideClick(event) { if (!this.balloon || !this.isOpen) return; if (this.balloon.contains(event.target)) return; if (this.anchor?.contains?.(event.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 = 380; const gap = 8; const height = this.balloon.offsetHeight || 320; let left = rect.right - width; let top = rect.top - height - gap; left = clamp(left, 8, window.innerWidth - width - 8); if (top < 8) top = Math.min(window.innerHeight - height - 8, rect.bottom + gap); Object.assign(this.balloon.style, { position: 'fixed', left: `${left}px`, top: `${Math.max(8, top)}px`, width: `${width}px`, zIndex: '100040', }); } setStatus(message, ok = false) { if (!this.statusEl) return; this.statusEl.textContent = message || ''; this.statusEl.classList.toggle('is-ok', !!ok && !!message); } persist() { this.state = saveFavoritesState(this.state); return this.state; } addFromText(text) { const result = addFavoriteText(text); this.state = result.state; if (result.full) { this.setStatus(t('favoritesFull')); return false; } if (!result.item) { this.setStatus(t('favoritesEmptyText')); return false; } if (this.isOpen) this.renderList(); this.setStatus(result.added ? t('favoritesAdded') : t('favoritesExists'), true); return true; } render() { const balloon = document.createElement('div'); balloon.className = 'tbc-balloon tbc-fav-panel'; balloon.addEventListener('click', (e) => e.stopPropagation()); const header = document.createElement('div'); header.className = 'tbc-balloon-header'; const title = document.createElement('div'); title.className = 'tbc-balloon-title'; title.textContent = t('favoritesTitle'); 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', () => this.close()); header.append(title, closeBtn); const addRow = document.createElement('div'); addRow.className = 'tbc-fav-add-row'; const addInput = document.createElement('input'); addInput.type = 'text'; addInput.className = 'tbc-fav-add-input'; addInput.maxLength = FAVORITE_TEXT_LIMIT; addInput.placeholder = t('favoritesAddPlaceholder'); const addBtn = document.createElement('button'); addBtn.type = 'button'; addBtn.className = 'tbc-fav-add-btn'; addBtn.textContent = t('favoritesAdd'); const doAdd = () => { const text = addInput.value.trim(); if (!text) return; if (this.addFromText(text)) addInput.value = ''; }; addBtn.addEventListener('click', doAdd); addInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); doAdd(); } }); addRow.append(addInput, addBtn); this.listEl = document.createElement('div'); this.listEl.className = 'tbc-fav-list'; const status = document.createElement('div'); status.className = 'tbc-fav-status'; this.statusEl = status; const wheelBox = this.renderWheelSection(); balloon.append(header, addRow, this.listEl, status, wheelBox); (getFullscreenElement() || document.body).appendChild(balloon); this.balloon = balloon; this.renderList(); this.refreshWheelControls(); } renderWheelSection() { const box = document.createElement('div'); box.className = 'tbc-fav-wheel'; const head = document.createElement('div'); head.className = 'tbc-fav-wheel-head'; const label = document.createElement('div'); label.className = 'tbc-fav-wheel-title'; label.textContent = t('favoritesWheel'); this.wheelToggleBtn = document.createElement('button'); this.wheelToggleBtn.type = 'button'; this.wheelToggleBtn.className = 'tbc-fav-wheel-toggle'; this.wheelToggleBtn.addEventListener('click', () => { if (this.wheel.running) this.stopWheel(true); else this.startWheel(); }); head.append(label, this.wheelToggleBtn); const grid = document.createElement('div'); grid.className = 'tbc-fav-wheel-grid'; const fields = [ ['minIntervalSec', t('favoritesWheelMinInterval'), 1.5, 600], ['maxIntervalSec', t('favoritesWheelMaxInterval'), 1.5, 600], ['maxCount', t('favoritesWheelMaxCount'), 1, 500], ['maxDurationSec', t('favoritesWheelMaxDuration'), 10, 7200], ]; fields.forEach(([key, name, min, max]) => { const field = document.createElement('label'); field.className = 'tbc-fav-wheel-field'; const span = document.createElement('span'); span.textContent = name; const input = document.createElement('input'); input.type = 'number'; input.min = String(min); input.max = String(max); input.step = key.includes('Interval') ? '0.5' : '1'; input.value = String(this.state.wheel[key]); input.addEventListener('change', () => { const value = clamp(Number(input.value) || min, min, max); this.state.wheel[key] = value; if (this.state.wheel.minIntervalSec > this.state.wheel.maxIntervalSec) { if (key === 'minIntervalSec') this.state.wheel.maxIntervalSec = value; else this.state.wheel.minIntervalSec = value; } this.persist(); this.state = loadFavoritesState(); Array.from(grid.querySelectorAll('.tbc-fav-wheel-field')).forEach((fieldEl, idx) => { const fieldInput = fieldEl.querySelector('input'); const fieldKey = fields[idx]?.[0]; if (fieldInput && fieldKey) fieldInput.value = String(this.state.wheel[fieldKey]); }); }); field.append(span, input); grid.appendChild(field); }); this.wheelStatusEl = document.createElement('div'); this.wheelStatusEl.className = 'tbc-fav-wheel-status'; box.append(head, grid, this.wheelStatusEl); return box; } renderList() { if (!this.listEl) return; this.listEl.innerHTML = ''; const items = this.visibleItems(); if (!items.length) { const empty = document.createElement('div'); empty.className = 'tbc-fav-empty'; empty.textContent = t('favoritesEmpty'); this.listEl.appendChild(empty); return; } items.forEach((item) => { this.listEl.appendChild(this.createItemRow(item)); }); } createItemRow(item) { const row = document.createElement('div'); row.className = 'tbc-fav-item'; row.dataset.id = item.id; row.classList.toggle('is-global', !item.channel); const meta = document.createElement('div'); meta.className = 'tbc-fav-item-meta'; meta.textContent = item.channel ? t('favoritesScopeChannel').replace('{channel}', item.channel) : t('favoritesScopeGlobal'); const text = document.createElement('textarea'); text.className = 'tbc-fav-item-text'; text.rows = 2; text.maxLength = FAVORITE_TEXT_LIMIT; text.value = item.text; text.addEventListener('change', () => { const next = text.value.trim().slice(0, FAVORITE_TEXT_LIMIT); if (!next) { text.value = item.text; return; } item.text = next; this.persist(); }); const tools = document.createElement('div'); tools.className = 'tbc-fav-item-tools'; const shortcut = document.createElement('select'); shortcut.className = 'tbc-fav-item-shortcut'; const none = document.createElement('option'); none.value = ''; none.textContent = t('favoritesShortcutNone'); shortcut.appendChild(none); for (let i = 0; i <= 9; i += 1) { const opt = document.createElement('option'); opt.value = String(i); opt.textContent = String(i); shortcut.appendChild(opt); } shortcut.value = item.shortcut || ''; shortcut.addEventListener('change', () => { const value = sanitizeFavoriteShortcut(shortcut.value); if (value) { this.state.items.forEach((entry) => { if (entry.id === item.id || entry.shortcut !== value) return; const sameScope = (entry.channel || null) === (item.channel || null); if (sameScope) entry.shortcut = null; }); } item.shortcut = value; this.persist(); this.renderList(); }); const scopeBtn = document.createElement('button'); scopeBtn.type = 'button'; scopeBtn.className = 'tbc-fav-item-scope'; scopeBtn.textContent = item.channel ? t('favoritesMakeGlobal') : t('favoritesMakeChannel'); scopeBtn.title = item.channel ? t('favoritesMakeGlobalHint') : t('favoritesMakeChannelHint'); scopeBtn.addEventListener('click', () => { const channel = this.currentChannel(); if (item.channel) { const result = setFavoriteGlobal(item.id, true); this.state = result.state; this.renderList(); this.setStatus(t('favoritesScopedGlobal'), true); return; } if (!channel) { this.setStatus(t('favoritesNeedChannel')); return; } item.channel = channel; this.persist(); this.renderList(); this.setStatus(t('favoritesScopedChannel'), true); }); const sendBtn = document.createElement('button'); sendBtn.type = 'button'; sendBtn.className = 'tbc-fav-item-send'; sendBtn.textContent = t('favoritesSend'); sendBtn.addEventListener('click', () => this.sendFavorite(item)); const removeBtn = document.createElement('button'); removeBtn.type = 'button'; removeBtn.className = 'tbc-fav-item-remove'; removeBtn.textContent = t('favoritesRemove'); removeBtn.addEventListener('click', () => { this.state.items = this.state.items.filter((entry) => entry.id !== item.id); this.persist(); this.renderList(); }); tools.append(shortcut, scopeBtn, sendBtn, removeBtn); row.append(meta, text, tools); return row; } async sendFavorite(item) { const text = String(item?.text || '').trim(); if (!text || this.sending) return false; this.sending = true; try { await sendTwitchChatMessage(text); this.setStatus(t('favoritesSent'), true); return true; } catch (error) { this.setStatus(chatSendErrorMessage(error)); return false; } finally { this.sending = false; } } refreshWheelControls() { if (this.wheelToggleBtn) { this.wheelToggleBtn.textContent = this.wheel.running ? t('favoritesWheelStop') : t('favoritesWheelStart'); this.wheelToggleBtn.classList.toggle('is-running', this.wheel.running); } if (!this.wheelStatusEl) return; if (!this.wheel.running) { this.wheelStatusEl.textContent = t('favoritesWheelIdle'); return; } const leftSec = Math.max(0, Math.ceil((this.wheel.endsAt - Date.now()) / 1000)); this.wheelStatusEl.textContent = t('favoritesWheelRunning') .replace('{sent}', String(this.wheel.sent)) .replace('{max}', String(this.state.wheel.maxCount)) .replace('{left}', String(leftSec)); } startWheel() { this.state = loadFavoritesState(); if (!this.visibleItems().length) { this.setStatus(t('favoritesEmpty')); return; } this.stopWheel(false); this.wheel.running = true; this.wheel.sent = 0; this.wheel.lastId = ''; this.wheel.failStreak = 0; this.wheel.startedAt = Date.now(); this.wheel.endsAt = this.wheel.startedAt + this.state.wheel.maxDurationSec * 1000; this.refreshWheelControls(); clearInterval(this.wheel.statusTimer); this.wheel.statusTimer = setInterval(() => { if (!this.wheel.running) return; this.refreshWheelControls(); }, 1000); // Send first message immediately (same as 独轮车.js), then cool down. this.scheduleWheelTick(0); } stopWheel(showStatus) { clearTimeout(this.wheel.timer); clearInterval(this.wheel.statusTimer); this.wheel.timer = null; this.wheel.statusTimer = null; const wasRunning = this.wheel.running; this.wheel.running = false; this.wheel.failStreak = 0; this.refreshWheelControls(); if (showStatus && wasRunning) this.setStatus(t('favoritesWheelStopped'), true); } scheduleWheelTick(delayMs) { clearTimeout(this.wheel.timer); this.wheel.timer = setTimeout(() => this.runWheelTick(), Math.max(0, delayMs)); } pickWheelItem(pool) { if (!pool.length) return null; if (pool.length === 1) return pool[0]; // Avoid sending the same favorite twice in a row when possible. const choices = this.wheel.lastId ? pool.filter((item) => item.id !== this.wheel.lastId) : pool; const list = choices.length ? choices : pool; return list[Math.floor(Math.random() * list.length)]; } nextWheelDelay(ok) { const minMs = Math.max(1500, Number(this.state.wheel.minIntervalSec) * 1000); const maxMs = Math.max(minMs, Number(this.state.wheel.maxIntervalSec) * 1000); let delay = minMs + Math.random() * (maxMs - minMs); // Back off after failures so Twitch rate limits / network blips cool down. if (!ok && this.wheel.failStreak > 0) { delay += Math.min(15000, this.wheel.failStreak * 2000); } return delay; } async runWheelTick() { if (!this.wheel.running) return; this.state = loadFavoritesState(); if (Date.now() >= this.wheel.endsAt || this.wheel.sent >= this.state.wheel.maxCount) { this.stopWheel(true); return; } const pool = this.visibleItems(); if (!pool.length) { this.stopWheel(true); return; } const item = this.pickWheelItem(pool); const ok = await this.sendFavorite(item); if (ok) { this.wheel.sent += 1; this.wheel.lastId = item?.id || ''; this.wheel.failStreak = 0; } else { this.wheel.failStreak += 1; if (this.wheel.failStreak >= 5) { this.stopWheel(true); return; } } this.refreshWheelControls(); if (!this.wheel.running) return; if (Date.now() >= this.wheel.endsAt || this.wheel.sent >= this.state.wheel.maxCount) { this.stopWheel(true); return; } this.scheduleWheelTick(this.nextWheelDelay(ok)); } } class ChatContextMenu { constructor({ getSettings, danmakuEmitter, isActive, favoritesPanel }) { this.getSettings = getSettings; this.danmakuEmitter = danmakuEmitter; this.isActive = isActive; this.favoritesPanel = favoritesPanel; 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'); const login = normalizeTwitchLogin(snapshot.login || snapshot.username); this.menu.append( this.createItem(hasTranslation ? t('chatMenuRetranslate') : t('chatMenuTranslate'), () => { this.runTranslate(msgEl, snapshot, true); }), this.createItem(t('chatMenuSpeak'), () => { this.runSpeak(msgEl, snapshot); }), this.createItem(t('chatMenuResend'), () => { this.runResend(msgEl, snapshot, false); }), this.createItem(t('chatMenuFavorite'), () => { this.runFavorite(snapshot); }), ); if (login) { this.menu.append( this.createItem(t('chatMenuBlockUser'), () => { this.runBlockUser(login); }), ); } 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 runBlockUser(login) { try { await blockTwitchUser(login, { sourceContext: 'CHAT' }); console.info(`[TBC] ${t('chatMenuBlockDone')}: ${login}`); } catch (error) { const code = error?.code || ''; if (code === 'not_logged_in') { console.warn(`[TBC] ${t('chatMenuBlockNeedLogin')}`); return; } if (code === 'user_not_found') { console.warn(`[TBC] ${t('chatMenuBlockNotFound')}: ${login}`); return; } console.warn(`[TBC] ${t('chatMenuBlockFailed')}`, error); } } runFavorite(snapshot) { const text = String(snapshot?.message || '').trim(); if (!text) return; if (this.favoritesPanel?.addFromText) { this.favoritesPanel.addFromText(text); if (!this.favoritesPanel.isOpen && this.favoritesPanel.btn) { this.favoritesPanel.open(this.favoritesPanel.btn); } return; } const result = addFavoriteText(text); if (result.full) { console.warn(`[TBC] ${t('favoritesFull')}`); return; } if (result.added) console.info(`[TBC] ${t('favoritesAdded')}`); else if (result.item) console.info(`[TBC] ${t('favoritesExists')}`); } async runTranslate(msgEl, snapshot, bustCache) { // Context-menu translate is always available; does not require settings.translateEnabled. const result = await this.translateParts(snapshot, bustCache); if (result.failed) return; applyChatTranslation(msgEl, result.message, snapshot.message, { manual: true }); } runSpeak(msgEl, snapshot) { const settings = this.getSettings(); if (!speechSupported()) return; // Prefer the on-screen chat translation when the user already translated this line. const translated = readChatTranslationText(msgEl); const source = translated || 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 || forceTranslate) { applyChatTranslation(msgEl, message, snapshot.message, { manual: !!forceTranslate, }); } } } 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, }); } } const CHAT_CLEAR_MESSAGE_SELECTOR = [ '.chat-line__message', '[data-a-target="chat-line-message"]', '[data-a-target="chat-message-container"]', '.user-notice-line', '[data-a-target="chat-welcome-message"]', '.chat-line__status', ].join(', '); class ChatClearButton { constructor({ onClear } = {}) { this.onClear = onClear; this.wrap = null; this.btn = null; this.retryTimer = null; } findNativeInjectLayout() { const viewerBtn = document.querySelector(SELECTORS.chatViewerListBtn); if (!viewerBtn) return null; // Twitch wraps each header icon button in its own InjectLayout — clone that shell. const wrap = viewerBtn.closest('[class*="InjectLayout"]') || viewerBtn.parentElement; if (!wrap?.parentElement) return null; return { wrap, host: wrap.parentElement }; } clearLiveChat() { const containers = document.querySelectorAll(SELECTORS.chatMessageContainer); let removed = 0; containers.forEach((container) => { container.querySelectorAll(CHAT_CLEAR_MESSAGE_SELECTOR).forEach((node) => { node.remove(); removed += 1; }); }); if (!containers.length) { document.querySelectorAll('[data-a-target="chat-scroller"]').forEach((scroller) => { scroller.querySelectorAll(CHAT_CLEAR_MESSAGE_SELECTOR).forEach((node) => { node.remove(); removed += 1; }); }); } this.onClear?.(removed); return removed; } applyClearIcon(btn) { const svg = btn.querySelector('svg'); if (!svg) { btn.insertAdjacentHTML('beforeend', ICON_CHAT_CLEAR); return; } svg.setAttribute('viewBox', '0 0 1024 1024'); svg.setAttribute('fill', 'currentColor'); svg.removeAttribute('stroke'); svg.removeAttribute('stroke-width'); svg.setAttribute('aria-hidden', 'true'); while (svg.firstChild) svg.removeChild(svg.firstChild); const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); path.setAttribute('d', ICON_CHAT_CLEAR_PATH); svg.appendChild(path); } applyLabel(btn, label = t('chatClear')) { btn.setAttribute('title', label); btn.setAttribute('data-a-target', 'tbc-chat-clear'); btn.setAttribute('data-tbc-chat-clear', 'true'); btn.removeAttribute('data-test-selector'); btn.removeAttribute('id'); } createFromInjectLayout(nativeWrap) { const wrap = nativeWrap.cloneNode(true); wrap.classList.add('tbc-chat-clear-wrap'); wrap.setAttribute('data-tbc-chat-clear-wrap', 'true'); wrap.removeAttribute('id'); const btn = wrap.querySelector('button') || wrap.querySelector('[role="button"]'); if (!btn) return null; btn.classList.add('tbc-chat-clear-btn'); this.applyClearIcon(btn); this.applyLabel(btn); btn.type = 'button'; btn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); this.clearLiveChat(); }); return { wrap, btn }; } createFallbackButton() { const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'tbc-chat-clear-btn tbc-chat-clear-btn--fallback'; btn.innerHTML = ICON_CHAT_CLEAR; this.applyLabel(btn); btn.setAttribute('title', t('chatClear')); btn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); this.clearLiveChat(); }); return btn; } async mount() { if (this.wrap?.isConnected || this.btn?.isConnected) { this.refreshLabel(); return; } const header = await waitForElement(SELECTORS.chatHeader, document, 12000); if (!header) return; if (header.querySelector('[data-tbc-chat-clear-wrap="true"], [data-tbc-chat-clear="true"]')) return; const native = this.findNativeInjectLayout(); if (native?.wrap && native.host) { const created = this.createFromInjectLayout(native.wrap); if (created) { this.wrap = created.wrap; this.btn = created.btn; native.host.appendChild(this.wrap); return; } } // Preview / missing community button: plain styled control in the header actions row. const actions = header.querySelector(':scope > div:last-child') || header; this.btn = this.createFallbackButton(); this.wrap = this.btn; actions.appendChild(this.btn); } refreshLabel() { if (!this.btn) return; this.applyLabel(this.btn); if (this.btn.classList.contains('tbc-chat-clear-btn--fallback')) { this.btn.setAttribute('title', t('chatClear')); } } remount() { if (this.wrap?.isConnected || this.btn?.isConnected) { this.refreshLabel(); return; } this.wrap?.remove(); this.btn?.remove(); this.wrap = null; this.btn = null; clearTimeout(this.retryTimer); this.retryTimer = setTimeout(() => this.mount(), 500); } destroy() { clearTimeout(this.retryTimer); this.wrap?.remove(); if (this.btn && this.btn !== this.wrap) this.btn.remove(); this.wrap = null; this.btn = null; } } class FullscreenChatInput { constructor({ favoritesPanel } = {}) { this.favoritesPanel = favoritesPanel || null; this.root = null; this.input = null; this.sendBtn = null; this.closeBtn = null; this.favBtn = null; this.favList = null; this.statusEl = null; this.sending = false; this.favOpen = false; this.boundKeyDown = (e) => this.onKeyDown(e); this.boundFsChange = () => this.onFullscreenChange(); this.boundPointerDown = (e) => this.onPointerDownOutside(e); } attach() { document.addEventListener('keydown', this.boundKeyDown, true); document.addEventListener('pointerdown', this.boundPointerDown, true); document.addEventListener('fullscreenchange', this.boundFsChange); document.addEventListener('webkitfullscreenchange', this.boundFsChange); } destroy() { this.close(); document.removeEventListener('keydown', this.boundKeyDown, true); document.removeEventListener('pointerdown', this.boundPointerDown, true); document.removeEventListener('fullscreenchange', this.boundFsChange); document.removeEventListener('webkitfullscreenchange', this.boundFsChange); } isEditableTarget(target) { if (!target || target === document.body || target === document.documentElement) return false; const el = target.nodeType === 1 ? target : target.parentElement; if (!el) return false; const tag = el.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true; if (el.isContentEditable) return true; return !!el.closest?.('input, textarea, select, [contenteditable="true"], [role="textbox"]'); } canOpen() { if (!getFullscreenElement()) return false; if (currentVodId()) return false; return !!currentChannelLogin(); } stopEvent(e) { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); } onKeyDown(e) { if (e.isComposing) return; if (e.key === 'Escape' && this.root) { if (this.favOpen) { this.stopEvent(e); this.closeFavorites(); return; } } if (e.key !== 'Enter') return; if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return; if (this.root) { if (e.target === this.input || this.root.contains(e.target)) { this.stopEvent(e); this.submit(); } return; } if (!this.canOpen()) return; if (this.isEditableTarget(e.target)) return; this.stopEvent(e); this.open(); } onPointerDownOutside(e) { if (!this.root) return; if (this.root.contains(e.target)) return; if (this.input?.value.trim()) return; this.close(); } onFullscreenChange() { if (!getFullscreenElement()) this.close(); } setStatus(message, ok = false) { if (!this.statusEl) return; this.statusEl.textContent = message || ''; this.statusEl.classList.toggle('is-ok', !!ok && !!message); } listFavoriteItems() { if (this.favoritesPanel?.visibleItems) { this.favoritesPanel.state = loadFavoritesState(); return this.favoritesPanel.visibleItems(); } const state = loadFavoritesState(); return listFavoritesForChannel(state, currentChannelLogin?.() || ''); } open() { if (this.root) { this.input?.focus(); return; } const mount = getFullscreenElement(); if (!mount) return; if (getComputedStyle(mount).position === 'static') { mount.style.position = 'relative'; } const root = document.createElement('div'); root.className = 'tbc-fs-chat'; root.setAttribute('role', 'dialog'); root.setAttribute('aria-label', t('fsChatPlaceholder')); const row = document.createElement('div'); row.className = 'tbc-fs-chat-row'; const input = document.createElement('input'); input.type = 'text'; input.className = 'tbc-fs-chat-input'; input.maxLength = 500; input.autocomplete = 'off'; input.spellcheck = true; input.placeholder = t('fsChatPlaceholder'); const favWrap = document.createElement('div'); favWrap.className = 'tbc-fs-chat-fav-wrap'; const favBtn = document.createElement('button'); favBtn.type = 'button'; favBtn.className = 'tbc-fs-chat-fav'; favBtn.setAttribute('aria-label', t('fsChatFavorites')); favBtn.setAttribute('aria-expanded', 'false'); favBtn.innerHTML = `${ICON_FAVORITE}${t('fsChatFavorites')}`; const favList = document.createElement('div'); favList.className = 'tbc-fs-chat-fav-list'; favList.hidden = true; favList.setAttribute('role', 'listbox'); favList.setAttribute('aria-label', t('fsChatFavorites')); favWrap.append(favBtn, favList); const sendBtn = document.createElement('button'); sendBtn.type = 'button'; sendBtn.className = 'tbc-fs-chat-send'; sendBtn.setAttribute('aria-label', t('fsChatSend')); sendBtn.innerHTML = `${ICON_CHAT_SEND}${t('fsChatSend')}`; const closeBtn = document.createElement('button'); closeBtn.type = 'button'; closeBtn.className = 'tbc-fs-chat-close'; closeBtn.setAttribute('aria-label', t('fsChatClose')); closeBtn.textContent = t('fsChatClose'); const meta = document.createElement('div'); meta.className = 'tbc-fs-chat-meta'; const status = document.createElement('div'); status.className = 'tbc-fs-chat-status'; row.append(input, favWrap, sendBtn, closeBtn); meta.append(status); root.append(row, meta); const trap = (e) => { e.stopPropagation(); }; root.addEventListener('keydown', trap); root.addEventListener('keyup', trap); root.addEventListener('keypress', trap); root.addEventListener('mousedown', trap); root.addEventListener('pointerdown', trap); root.addEventListener('click', trap); favBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); this.toggleFavorites(); }); sendBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); this.submit(); }); closeBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); this.close(); }); mount.appendChild(root); this.root = root; this.input = input; this.favBtn = favBtn; this.favList = favList; this.sendBtn = sendBtn; this.closeBtn = closeBtn; this.statusEl = status; this.favOpen = false; requestAnimationFrame(() => { input.focus(); }); } toggleFavorites() { if (this.favOpen) this.closeFavorites(); else this.openFavorites(); } openFavorites() { if (!this.favList || !this.favBtn) return; this.renderFavorites(); this.favList.hidden = false; this.favBtn.setAttribute('aria-expanded', 'true'); this.favBtn.classList.add('is-open'); this.favOpen = true; } closeFavorites() { if (!this.favList || !this.favBtn) return; this.favList.hidden = true; this.favList.replaceChildren(); this.favBtn.setAttribute('aria-expanded', 'false'); this.favBtn.classList.remove('is-open'); this.favOpen = false; } renderFavorites() { if (!this.favList) return; this.favList.replaceChildren(); const items = this.listFavoriteItems(); if (!items.length) { const empty = document.createElement('div'); empty.className = 'tbc-fs-chat-fav-empty'; empty.textContent = t('fsChatFavoritesEmpty'); this.favList.appendChild(empty); return; } items.forEach((item) => { const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'tbc-fs-chat-fav-item'; btn.setAttribute('role', 'option'); btn.title = item.text; btn.textContent = item.text; btn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); if (this.input) { this.input.value = item.text; this.input.focus(); const len = this.input.value.length; this.input.setSelectionRange(len, len); } this.closeFavorites(); }); this.favList.appendChild(btn); }); } close() { this.closeFavorites(); this.root?.remove(); this.root = null; this.input = null; this.sendBtn = null; this.closeBtn = null; this.favBtn = null; this.favList = null; this.statusEl = null; this.sending = false; this.favOpen = false; } async submit() { if (!this.input || this.sending) return; const text = this.input.value.trim(); if (!text) { this.setStatus(''); return; } this.sending = true; if (this.sendBtn) this.sendBtn.disabled = true; this.closeFavorites(); this.setStatus(''); try { await sendTwitchChatMessage(text); this.input.value = ''; this.setStatus(t('fsChatSent'), true); setTimeout(() => { if (this.statusEl?.textContent === t('fsChatSent')) this.setStatus(''); }, 1200); } catch (error) { this.setStatus(chatSendErrorMessage(error)); } finally { this.sending = false; if (this.sendBtn) this.sendBtn.disabled = false; this.input?.focus(); } } } const PIP_ASPECT_LOCKED_KEY = 'tbcPipAspectLocked'; class EnhancedPip { constructor({ getSettings, danmaku, favoritesPanel, onToggleDanmaku, onOpenSettings, onStateChange } = {}) { this.getSettings = getSettings; this.danmaku = danmaku || null; this.favoritesPanel = favoritesPanel || null; this.onToggleDanmaku = onToggleDanmaku; this.onOpenSettings = onOpenSettings; this.onStateChange = onStateChange; this.pipWindow = null; this.sourceVideo = null; this.pipVideo = null; this.laneManager = new LaneManager(); this.opening = false; this.inputOpen = false; this.favOpen = false; this.sending = false; this.nodes = null; this.keepAliveVideo = null; this.keepAliveHandle = null; this.keepAliveTimer = null; this.closedTimer = null; this.pageToastTimer = null; this.toastTimer = null; this.statusTimer = null; this.boundPlay = () => this.syncPlayback(); this.boundPause = () => this.syncPlayback(); this.boundResize = () => this.resetLanes(); this.boundPageHide = () => this.cleanup(); this.boundKeyDown = (event) => this.onKeyDown(event); this.boundComment = (payload) => this.renderComment(payload); this.aspectLocked = this.loadAspectLocked(); } loadAspectLocked() { try { return !!GM_getValue(PIP_ASPECT_LOCKED_KEY, false); } catch (error) { return false; } } saveAspectLocked() { try { GM_setValue(PIP_ASPECT_LOCKED_KEY, this.aspectLocked); } catch (error) { /* storage unavailable */ } } toggleAspectLock() { this.aspectLocked = !this.aspectLocked; this.saveAspectLocked(); this.applyAspectLock(); } applyAspectLock() { const wrap = this.nodes?.wrap; if (wrap) wrap.classList.toggle('is-aspect-locked', this.aspectLocked); const btn = this.nodes?.aspectBtn; if (!btn) return; btn.innerHTML = this.aspectLocked ? ICON_PIP_ASPECT_LOCK : ICON_PIP_ASPECT_UNLOCK; const label = t(this.aspectLocked ? 'pipAspectUnlock' : 'pipAspectLock'); btn.title = label; btn.setAttribute('aria-label', label); } pageWindow() { try { if (typeof unsafeWindow !== 'undefined' && unsafeWindow?.documentPictureInPicture) { return unsafeWindow; } } catch (error) { // The sandbox may refuse unsafeWindow; the page window is the fallback. } return window; } isSupported() { const api = this.pageWindow().documentPictureInPicture; return typeof api?.requestWindow === 'function'; } isOpen() { return !!(this.pipWindow && !this.pipWindow.closed); } isMediaStream(value) { return !!value && typeof value.getVideoTracks === 'function' && typeof value.getTracks === 'function'; } captureSource(video) { const existing = video?.srcObject; if (this.isMediaStream(existing)) { try { const clone = typeof existing.clone === 'function' ? existing.clone() : null; if (this.isMediaStream(clone) && clone.getVideoTracks().length) return clone; } catch (error) { // Fall through to a fresh capture of a decoded element. } } if (typeof video?.captureStream !== 'function') return null; return video.captureStream(); } findSourceVideo() { const root = document.querySelector(SELECTORS.videoPlayer); if (!root) return null; const videos = [...root.querySelectorAll('video')]; const area = (video) => (video.videoWidth || 0) * (video.videoHeight || 0); const playing = videos .filter((video) => video.videoWidth > 0 && !video.paused && video.readyState >= 2) .sort((a, b) => area(b) - area(a)); if (playing.length) return playing[0]; const sized = videos .filter((video) => video.videoWidth > 0) .sort((a, b) => area(b) - area(a)); if (sized.length) return sized[0]; return videos[0] || null; } toggle() { if (this.opening) return; if (this.isOpen()) { this.close(true); return; } this.open(); } async open() { if (this.opening || this.isOpen()) { if (this.isOpen()) { try { this.pipWindow.focus(); } catch (error) { /* window may already be closing */ } } return; } if (!this.isSupported()) { this.showPageToast(t('pipNeedBrowser')); return; } const source = this.findSourceVideo(); if (!source || (typeof source.captureStream !== 'function' && !this.isMediaStream(source.srcObject))) { this.showPageToast(t('pipNoVideo')); return; } let stream = null; try { stream = this.captureSource(source); } catch (error) { this.showPageToast(t('pipCaptureFailed')); return; } if (!stream) { this.showPageToast(t('pipCaptureFailed')); return; } this.opening = true; let pipWindow = null; try { pipWindow = await this.pageWindow().documentPictureInPicture.requestWindow({ width: 670, height: 380, }); } catch (error) { this.stopStream(stream); this.showPageToast(t('pipOpenFailed')); return; } finally { this.opening = false; } if (!pipWindow) { this.stopStream(stream); this.showPageToast(t('pipOpenFailed')); return; } this.pipWindow = pipWindow; this.sourceVideo = source; this.mountDocument(pipWindow); const pipVideo = this.nodes.video; this.pipVideo = pipVideo; pipVideo.srcObject = stream; pipVideo.muted = true; try { await pipVideo.play(); } catch (error) { /* autoplay can reject until the next frame */ } this.dimSource(true); this.bindSourceSync(source); this.startKeepAlive(source); this.attachDanmaku(); this.bindCleanup(pipWindow); this.syncSettings(); this.syncPlayback(); this.onStateChange?.(); try { pipWindow.focus(); } catch (error) { /* focus is best-effort */ } } close(focusOpener = false) { const source = this.sourceVideo; this.cleanup(); if (focusOpener) { try { window.focus(); } catch (error) { /* opener may be gone */ } try { source?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); } catch (error) { /* not laid out */ } } } destroy() { this.close(false); this.clearPageToast(); } cleanup() { if (!this.pipWindow && !this.sourceVideo && !this.pipVideo) return; const win = this.pipWindow; const pipVideo = this.pipVideo; this.pipWindow = null; this.pipVideo = null; this.detachDanmaku(); this.stopKeepAlive(); this.unbindSourceSync(); this.dimSource(false); clearInterval(this.closedTimer); this.closedTimer = null; clearTimeout(this.toastTimer); this.toastTimer = null; clearTimeout(this.statusTimer); this.statusTimer = null; if (win) { win.removeEventListener('pagehide', this.boundPageHide); win.removeEventListener('resize', this.boundResize); win.document?.removeEventListener('keydown', this.boundKeyDown, true); } this.stopStream(pipVideo?.srcObject); if (pipVideo) pipVideo.srcObject = null; this.nodes = null; this.inputOpen = false; this.favOpen = false; this.sending = false; this.sourceVideo = null; if (win && !win.closed) { try { win.close(); } catch (error) { /* already closing */ } } this.onStateChange?.(); } stopStream(stream) { try { stream?.getTracks?.().forEach((track) => track.stop()); } catch (error) { // Stopping a capture track must not break the source video. } } dimSource(hidden) { const video = this.sourceVideo; if (!video) return; if (hidden) { video.style.setProperty('opacity', '0.01', 'important'); video.style.setProperty('pointer-events', 'none', 'important'); return; } video.style.removeProperty('opacity'); video.style.removeProperty('pointer-events'); } bindSourceSync(source) { source.addEventListener('play', this.boundPlay); source.addEventListener('pause', this.boundPause); } unbindSourceSync() { const source = this.sourceVideo; if (!source) return; source.removeEventListener('play', this.boundPlay); source.removeEventListener('pause', this.boundPause); } syncPlayback() { const source = this.sourceVideo; const pipVideo = this.pipVideo; if (!source || !pipVideo) return; if (source.paused) pipVideo.pause(); else pipVideo.play().catch(() => {}); } startKeepAlive(video) { this.stopKeepAlive(); if (!video) return; const canvas = document.createElement('canvas'); canvas.width = 2; canvas.height = 2; const context = canvas.getContext('2d', { willReadFrequently: true }); this.keepAliveVideo = video; const tick = () => { if (!this.isOpen() || this.keepAliveVideo !== video) return; try { if (video.readyState >= 2) context.drawImage(video, 0, 0, 2, 2); } catch (error) { // A tainted frame should not stop the keep-alive loop. } if (typeof video.requestVideoFrameCallback === 'function') { this.keepAliveHandle = video.requestVideoFrameCallback(tick); } else { this.keepAliveTimer = setTimeout(tick, 200); } }; tick(); } stopKeepAlive() { const video = this.keepAliveVideo; if (video && this.keepAliveHandle != null && typeof video.cancelVideoFrameCallback === 'function') { try { video.cancelVideoFrameCallback(this.keepAliveHandle); } catch (error) { /* already cancelled */ } } clearTimeout(this.keepAliveTimer); this.keepAliveHandle = null; this.keepAliveTimer = null; this.keepAliveVideo = null; } bindCleanup(pipWindow) { pipWindow.addEventListener('pagehide', this.boundPageHide); clearInterval(this.closedTimer); this.closedTimer = setInterval(() => { if (this.pipWindow?.closed) this.cleanup(); }, 1000); } attachDanmaku() { if (!this.danmaku) return; this.danmaku.onComment = this.boundComment; } detachDanmaku() { if (this.danmaku?.onComment === this.boundComment) this.danmaku.onComment = null; } syncSettings() { if (!this.isOpen()) return; this.applyDanmakuVisibility(); this.resetLanes(); } applyDanmakuVisibility() { const layer = this.nodes?.layer; const button = this.nodes?.danmakuBtn; if (!layer) return; const enabled = !!this.getSettings().enabled; layer.style.visibility = enabled ? '' : 'hidden'; button?.classList.toggle('is-off', !enabled); if (button) { button.title = enabled ? t('danmakuOn') : t('danmakuOff'); button.setAttribute('aria-pressed', enabled ? 'true' : 'false'); } } resetLanes() { if (!this.isOpen()) return; const metrics = this.laneMetrics(); this.laneManager.reset(metrics.count, metrics.laneHeight); } laneMetrics() { const settings = this.getSettings(); const win = this.pipWindow; const main = this.nodes?.main; const height = main?.clientHeight || win?.innerHeight || 1; const laneHeight = Math.max(14, (Number(settings.fontSize) || 22) + 6); let areaHeight = height; if (settings.scrollArea === 'top-half') areaHeight = height * 0.5; else if (settings.scrollArea === 'bottom-half') { const reserve = this.inputOpen ? (this.nodes?.input?.offsetHeight || 52) + 4 : 0; areaHeight = Math.max(laneHeight, height * 0.5 - reserve); } else if (settings.scrollArea === 'top-quarter') areaHeight = height * 0.25; else { const reserve = this.inputOpen ? (this.nodes?.input?.offsetHeight || 52) + 4 : 0; areaHeight = Math.max(laneHeight, height - reserve); } return { laneHeight, count: Math.max(1, Math.floor(areaHeight / laneHeight)), }; } renderComment(payload) { if (!this.isOpen() || !this.nodes?.layer) return; const settings = this.getSettings(); if (!settings.enabled) return; const parts = payload?.parts?.length ? payload.parts : (payload?.message ? [{ type: 'text', value: payload.message }] : []); if (!parts.length) return; const doc = this.pipWindow.document; const layer = this.nodes.layer; const item = doc.createElement('div'); item.className = 'tbc-pip-dm'; this.appendParts(doc, item, parts); const opacity = clamp(Number(settings.danmakuOpacity) || 100, 10, 100) / 100; item.style.color = payload.usernameColor || '#efeff1'; item.style.fontSize = `${settings.fontSize || 22}px`; item.style.fontFamily = settings.fontFamily || 'sans-serif'; item.style.opacity = String(opacity); item.style.visibility = 'hidden'; item.style.animation = 'none'; layer.appendChild(item); const layerWidth = layer.clientWidth || this.pipWindow.innerWidth || 670; const itemWidth = item.offsetWidth; const speed = clamp(Number(settings.speed) || 5, 1, 10); const travel = layerWidth + itemWidth; const durationMs = Math.round((travel / Math.max(1, layerWidth)) * (30000 / speed)); const metrics = this.laneMetrics(); if (!this.laneManager.lanes.length) this.laneManager.reset(metrics.count, metrics.laneHeight); const lane = this.laneManager.acquire(durationMs); item.style.top = `${lane * metrics.laneHeight}px`; item.style.left = `${layerWidth}px`; item.style.setProperty('--tbc-travel', `${travel}px`); item.style.setProperty('--tbc-duration', `${durationMs}ms`); item.style.visibility = ''; item.addEventListener('animationend', () => item.remove(), { once: true }); void item.offsetWidth; item.style.animation = ''; } appendParts(doc, item, parts) { for (const part of parts) { if (part.type === 'emote' && part.src) { const img = doc.createElement('img'); img.className = 'tbc-pip-emote'; img.src = part.src; img.alt = part.name || ''; img.draggable = false; item.appendChild(img); continue; } const text = part.type === 'emote' ? (part.name || '') : (part.value || ''); if (text) item.appendChild(doc.createTextNode(text)); } } mountDocument(pipWindow) { const doc = pipWindow.document; doc.documentElement.lang = document.documentElement.lang || 'zh-CN'; doc.head.replaceChildren(); doc.body.replaceChildren(); const style = doc.createElement('style'); style.textContent = this.windowCss(); doc.head.appendChild(style); const wrap = doc.createElement('div'); wrap.id = 'tbc-pip-wrap'; const main = doc.createElement('div'); main.id = 'tbc-pip-main'; const back = this.makeButton(doc, 'tbc-pip-back', t('pipBack'), t('pipBack')); back.textContent = t('pipBack'); const buttons = doc.createElement('div'); buttons.id = 'tbc-pip-btns'; const reloadBtn = this.makeButton(doc, 'tbc-pip-reload', t('pipReload'), t('pipReload'), this.iconReload()); const aspectBtn = this.makeButton( doc, 'tbc-pip-aspect', t('pipAspectLock'), t('pipAspectLock'), ICON_PIP_ASPECT_UNLOCK, ); const danmakuBtn = this.makeButton(doc, 'tbc-pip-danmaku-btn', t('pipDanmaku'), t('pipDanmaku'), ICON_DANMAKU_ON); const settingsBtn = this.makeButton(doc, 'tbc-pip-settings', t('pipSettings'), t('pipSettings'), ICON_SETTINGS); const sendBtn = this.makeButton(doc, 'tbc-pip-send', t('pipSend'), t('pipSend'), ICON_CHAT_SEND); buttons.append(reloadBtn, aspectBtn, danmakuBtn, settingsBtn, sendBtn); const video = doc.createElement('video'); video.id = 'tbc-pip-video'; video.autoplay = true; video.muted = true; video.playsInline = true; video.setAttribute('playsinline', ''); const layer = doc.createElement('div'); layer.id = 'tbc-pip-danmaku'; const toast = doc.createElement('div'); toast.id = 'tbc-pip-toast'; const input = doc.createElement('div'); input.id = 'tbc-pip-input'; const field = doc.createElement('input'); field.id = 'tbc-pip-field'; field.type = 'text'; field.maxLength = 500; field.autocomplete = 'off'; field.spellcheck = true; field.placeholder = currentVodId() ? t('pipVodNoSend') : t('fsChatPlaceholder'); const favWrap = doc.createElement('div'); favWrap.className = 'tbc-pip-fav-wrap'; const favBtn = this.makeButton(doc, 'tbc-pip-fav', t('fsChatFavorites'), t('fsChatFavorites'), ICON_FAVORITE); const favList = doc.createElement('div'); favList.id = 'tbc-pip-fav-list'; favList.hidden = true; favWrap.append(favBtn, favList); const submit = this.makeButton(doc, 'tbc-pip-submit', t('fsChatSend'), t('fsChatSend')); submit.textContent = t('fsChatSend'); const status = doc.createElement('div'); status.id = 'tbc-pip-status'; input.append(field, favWrap, submit, status); main.append(back, buttons, video, layer, toast); wrap.append(main, input); doc.body.appendChild(wrap); this.nodes = { wrap, main, back, reloadBtn, aspectBtn, danmakuBtn, settingsBtn, sendBtn, video, layer, toast, input, field, favBtn, favList, submit, status, }; const stop = (event) => event.stopPropagation(); reloadBtn.addEventListener('click', (event) => { stop(event); this.reloadVideo(); }); aspectBtn.addEventListener('click', (event) => { stop(event); this.toggleAspectLock(); }); danmakuBtn.addEventListener('click', (event) => { stop(event); this.onToggleDanmaku?.(); }); settingsBtn.addEventListener('click', (event) => { stop(event); this.openSettings(); }); sendBtn.addEventListener('click', (event) => { stop(event); this.setInputOpen(true); }); back.addEventListener('click', (event) => { stop(event); this.close(true); }); favBtn.addEventListener('click', (event) => { event.preventDefault(); stop(event); this.toggleFavorites(); }); submit.addEventListener('click', (event) => { event.preventDefault(); stop(event); this.submit(); }); main.addEventListener('click', (event) => { if (event.target.closest('#tbc-pip-btns, #tbc-pip-back, #tbc-pip-input')) return; this.setInputOpen(false); }); pipWindow.addEventListener('resize', this.boundResize); doc.addEventListener('keydown', this.boundKeyDown, true); this.applyAspectLock(); this.resetLanes(); pipWindow.requestAnimationFrame(() => this.resetLanes()); } makeButton(doc, id, label, title, html) { const button = doc.createElement('button'); button.type = 'button'; button.id = id; button.className = id === 'tbc-pip-back' || id === 'tbc-pip-submit' || id === 'tbc-pip-fav' ? id : 'tbc-pip-btn'; if (id === 'tbc-pip-fav' || id === 'tbc-pip-submit' || id === 'tbc-pip-back') { button.className = id; } button.setAttribute('aria-label', label); button.title = title; if (html) button.innerHTML = html; return button; } iconReload() { return ''; } windowCss() { return ` html, body { margin: 0; width: 100%; height: 100%; overflow: hidden; background: #000; } #tbc-pip-wrap, #tbc-pip-main { position: relative; width: 100%; height: 100%; overflow: hidden; } #tbc-pip-video { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: fill; background: #000; z-index: 1; } #tbc-pip-wrap.is-aspect-locked #tbc-pip-video { object-fit: contain; } #tbc-pip-danmaku { position: absolute; inset: 0; overflow: hidden; pointer-events: none; z-index: 2; } .tbc-pip-dm { position: absolute; white-space: nowrap; font-weight: 700; line-height: 1.2; pointer-events: none; will-change: transform; text-shadow: 1px 1px 2px rgba(0,0,0,.9), -1px -1px 2px rgba(0,0,0,.9), 0 0 4px rgba(0,0,0,.8); animation: tbc-pip-scroll var(--tbc-duration, 8s) linear forwards; } .tbc-pip-emote { height: 1.2em; width: auto; vertical-align: -0.22em; margin: 0 0.1em; pointer-events: none; } @keyframes tbc-pip-scroll { from { transform: translateX(0); } to { transform: translateX(calc(-1 * var(--tbc-travel, 100vw))); } } #tbc-pip-btns { position: absolute; top: 50%; left: -50px; transform: translateY(-50%); display: flex; flex-direction: column; gap: 8px; z-index: 5; transition: left 0.3s ease; } #tbc-pip-wrap:hover #tbc-pip-btns { left: 10px; } .tbc-pip-btn, #tbc-pip-fav { position: relative; width: 36px; height: 36px; padding: 0; box-sizing: border-box; border: 2px solid #fff; border-radius: 50%; display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,.58); color: #fff; cursor: pointer; } .tbc-pip-btn svg, #tbc-pip-fav svg { width: 18px; height: 18px; fill: currentColor; display: block; } .tbc-pip-btn:hover, #tbc-pip-fav:hover { background: rgba(0,0,0,.82); } #tbc-pip-danmaku-btn.is-off { opacity: 0.45; } #tbc-pip-danmaku-btn.is-off::after { content: ""; position: absolute; width: 22px; height: 2px; background: #fff; transform: rotate(-40deg); } #tbc-pip-back { position: absolute; top: 10px; right: -180px; z-index: 6; padding: 6px 12px; border-radius: 6px; border: 1px solid rgba(255,255,255,.35); background: rgba(0,0,0,.65); color: #fff; font: 600 14px/1.25 sans-serif; cursor: pointer; white-space: nowrap; transition: right 0.3s ease; } #tbc-pip-wrap:hover #tbc-pip-back { right: 10px; } #tbc-pip-back:hover { background: rgba(0,0,0,.88); } #tbc-pip-toast { position: absolute; left: 50%; top: 16px; transform: translateX(-50%); z-index: 7; padding: 6px 12px; border-radius: 6px; background: rgba(0,0,0,.72); color: #fff; font: 13px/1.3 sans-serif; opacity: 0; pointer-events: none; transition: opacity .2s; } #tbc-pip-toast.is-show { opacity: 1; } #tbc-pip-input { display: none; position: absolute; left: 0; right: 0; bottom: 0; z-index: 8; box-sizing: border-box; gap: 8px; padding: 8px 10px; align-items: center; flex-wrap: wrap; background: rgba(14,14,16,.94); border-top: 1px solid rgba(255,255,255,.12); font-family: sans-serif; } #tbc-pip-input.is-active { display: flex; } #tbc-pip-field { flex: 1; min-width: 0; height: 34px; box-sizing: border-box; padding: 0 10px; border-radius: 6px; border: 1px solid rgba(255,255,255,.16); background: #18181b; color: #efeff1; font-size: 14px; outline: none; } #tbc-pip-field:focus { border-color: #9147ff; } #tbc-pip-fav { flex-shrink: 0; } .tbc-pip-fav-wrap { position: relative; flex-shrink: 0; } #tbc-pip-fav-list { position: absolute; left: 0; bottom: calc(100% + 6px); width: 220px; max-height: 180px; overflow: auto; background: #18181b; border: 1px solid rgba(255,255,255,.14); border-radius: 6px; } .tbc-pip-fav-item, .tbc-pip-fav-empty { display: block; width: 100%; box-sizing: border-box; text-align: left; background: transparent; color: #efeff1; border: 0; padding: 8px 10px; font-size: 13px; } .tbc-pip-fav-item { cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .tbc-pip-fav-item:hover { background: rgba(255,255,255,.08); } .tbc-pip-fav-empty { color: #adadb8; } #tbc-pip-submit { flex-shrink: 0; height: 34px; padding: 0 12px; border: 0; border-radius: 6px; background: #9147ff; color: #fff; font-weight: 700; cursor: pointer; } #tbc-pip-submit:disabled { opacity: 0.55; cursor: default; } #tbc-pip-status { flex: 1 0 100%; min-height: 0; color: #ff8280; font-size: 12px; line-height: 1.35; } #tbc-pip-status:empty { display: none; } #tbc-pip-status.is-ok { color: #00f593; } `; } onKeyDown(event) { if (!this.isOpen() || event.isComposing) return; if (event.key === 'Escape') { if (this.favOpen) { event.preventDefault(); this.closeFavorites(); return; } if (this.inputOpen) { event.preventDefault(); this.setInputOpen(false); } return; } if (event.key !== 'Enter' || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return; const field = this.nodes?.field; if (field && this.pipWindow.document.activeElement === field) { event.preventDefault(); this.submit(); return; } event.preventDefault(); this.setInputOpen(true); } setInputOpen(open) { if (!this.nodes?.input) return; this.inputOpen = !!open; this.nodes.input.classList.toggle('is-active', this.inputOpen); if (!this.inputOpen) this.closeFavorites(); if (this.inputOpen) { this.nodes.field.placeholder = currentVodId() ? t('pipVodNoSend') : t('fsChatPlaceholder'); this.pipWindow.requestAnimationFrame(() => this.nodes?.field?.focus()); } this.resetLanes(); } async reloadVideo() { const source = this.sourceVideo; const pipVideo = this.pipVideo; if (!source || !pipVideo || (typeof source.captureStream !== 'function' && !this.isMediaStream(source.srcObject))) { this.showToast(t('pipReloadFailed')); return; } try { if (source.paused) await source.play(); } catch (error) { // Keep going; a paused source can still be recaptured. } let stream = null; try { stream = this.captureSource(source); } catch (error) { this.showToast(t('pipReloadFailed')); return; } if (!stream) { this.showToast(t('pipReloadFailed')); return; } const previous = pipVideo.srcObject; pipVideo.srcObject = stream; pipVideo.muted = true; if (previous && previous !== stream) this.stopStream(previous); if (source.paused) pipVideo.pause(); else { try { await pipVideo.play(); } catch (error) { /* the next source play event retries */ } } try { this.pipWindow?.focus(); } catch (error) { /* focus is best-effort */ } this.showToast(t('pipReloaded')); } openSettings() { try { window.focus(); } catch (error) { /* opener focus can fail */ } try { this.sourceVideo?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); } catch (error) { /* not laid out */ } this.onOpenSettings?.(); this.showToast(t('pipSettingsHint')); } showToast(message) { const toast = this.nodes?.toast; if (!toast) return; toast.textContent = message || ''; toast.classList.add('is-show'); clearTimeout(this.toastTimer); this.toastTimer = setTimeout(() => toast.classList.remove('is-show'), 2000); } showPageToast(message) { let toast = document.getElementById('tbc-pip-page-toast'); if (!toast) { toast = document.createElement('div'); toast.id = 'tbc-pip-page-toast'; toast.className = 'tbc-pip-page-toast'; (document.body || document.documentElement).appendChild(toast); } toast.textContent = message || ''; toast.classList.add('is-show'); clearTimeout(this.pageToastTimer); this.pageToastTimer = setTimeout(() => toast.classList.remove('is-show'), 2800); } clearPageToast() { clearTimeout(this.pageToastTimer); document.getElementById('tbc-pip-page-toast')?.remove(); } listFavoriteItems() { if (this.favoritesPanel?.visibleItems) { this.favoritesPanel.state = loadFavoritesState(); return this.favoritesPanel.visibleItems(); } return listFavoritesForChannel(loadFavoritesState(), currentChannelLogin?.() || ''); } toggleFavorites() { if (this.favOpen) this.closeFavorites(); else this.openFavorites(); } openFavorites() { const list = this.nodes?.favList; const button = this.nodes?.favBtn; if (!list || !button) return; this.renderFavorites(); list.hidden = false; button.setAttribute('aria-expanded', 'true'); this.favOpen = true; } closeFavorites() { const list = this.nodes?.favList; const button = this.nodes?.favBtn; if (list) { list.hidden = true; list.replaceChildren(); } button?.setAttribute('aria-expanded', 'false'); this.favOpen = false; } renderFavorites() { const list = this.nodes?.favList; if (!list) return; const doc = this.pipWindow.document; list.replaceChildren(); const items = this.listFavoriteItems(); if (!items.length) { const empty = doc.createElement('div'); empty.className = 'tbc-pip-fav-empty'; empty.textContent = t('fsChatFavoritesEmpty'); list.appendChild(empty); return; } items.forEach((item) => { const button = doc.createElement('button'); button.type = 'button'; button.className = 'tbc-pip-fav-item'; button.title = item.text; button.textContent = item.text; button.addEventListener('click', (event) => { event.preventDefault(); event.stopPropagation(); const field = this.nodes?.field; if (field) { field.value = item.text; field.focus(); const len = field.value.length; field.setSelectionRange(len, len); } this.closeFavorites(); }); list.appendChild(button); }); } setStatus(message, ok = false) { const status = this.nodes?.status; if (!status) return; status.textContent = message || ''; status.classList.toggle('is-ok', !!ok && !!message); } async submit() { const field = this.nodes?.field; if (!field || this.sending) return; const text = field.value.trim(); if (!text) { this.setStatus(''); return; } if (currentVodId()) { this.setStatus(t('pipVodNoSend'), false); return; } this.sending = true; if (this.nodes.submit) this.nodes.submit.disabled = true; this.closeFavorites(); this.setStatus(''); try { await sendTwitchChatMessage(text); field.value = ''; this.setStatus(t('fsChatSent'), true); clearTimeout(this.statusTimer); this.statusTimer = setTimeout(() => { if (this.nodes?.status?.textContent === t('fsChatSent')) this.setStatus(''); }, 1200); } catch (error) { this.setStatus(chatSendErrorMessage(error), false); } finally { this.sending = false; if (this.nodes?.submit) this.nodes.submit.disabled = false; field.focus(); } } } class ControlButton { constructor({ onToggleDanmaku, onToggleTranslate, onTogglePanel, onToggleSpeech, onToggleHistory, onTogglePip, getSettings, isPanelOpen, isSpeechPanelOpen, isHistoryPanelOpen, isPipOpen, }) { this.onToggleDanmaku = onToggleDanmaku; this.onToggleTranslate = onToggleTranslate; this.onTogglePanel = onTogglePanel; this.onToggleSpeech = onToggleSpeech; this.onToggleHistory = onToggleHistory; this.onTogglePip = onTogglePip; this.getSettings = getSettings; this.isPanelOpen = isPanelOpen; this.isSpeechPanelOpen = isSpeechPanelOpen; this.isHistoryPanelOpen = isHistoryPanelOpen; this.isPipOpen = isPipOpen; this.wrap = null; this.danmakuBtn = null; this.translateBtn = null; this.speechBtn = null; this.historyBtn = null; this.settingsBtn = null; this.pipBtn = 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.translateBtn = this.createBtn('tbc-translate-btn', t('translateToggle'), ` ${ICON_TRANSLATE_ON} ${ICON_TRANSLATE_OFF} `); this.translateBtn.addEventListener('click', (e) => { e.stopPropagation(); this.onToggleTranslate?.(); }); 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.pipBtn = this.createBtn('tbc-pip-btn', t('pipOpen'), ICON_PIP); this.pipBtn.addEventListener('click', (e) => { e.stopPropagation(); this.onTogglePip?.(); }); this.wrap.append(this.danmakuBtn, this.translateBtn, this.settingsBtn, this.speechBtn, this.historyBtn, this.pipBtn); this.controlsGroup.appendChild(this.wrap); this.updateDanmakuState(); this.updateTranslateState(); this.updateSpeechState(); this.updatePanelState(); this.updatePipState(); } remount() { if (this.wrap?.isConnected && this.wrap.querySelector('.tbc-translate-btn') && this.wrap.querySelector('.tbc-speech-btn') && this.wrap.querySelector('.tbc-history-btn') && this.wrap.querySelector('.tbc-pip-btn')) return; this.wrap?.remove(); this.wrap = null; this.danmakuBtn = null; this.translateBtn = null; this.speechBtn = null; this.historyBtn = null; this.settingsBtn = null; this.pipBtn = 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'); } updateTranslateState() { if (!this.translateBtn) return; const enabled = !!this.getSettings().translateEnabled; this.translateBtn.classList.toggle('tbc-translate-on', enabled); this.translateBtn.classList.toggle('tbc-translate-off', !enabled); this.translateBtn.title = enabled ? t('translateOn') : t('translateOff'); this.translateBtn.setAttribute('aria-pressed', enabled ? 'true' : 'false'); } 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'); } updatePipState() { if (!this.pipBtn) return; const open = !!this.isPipOpen?.(); this.pipBtn.classList.toggle('tbc-active', open); this.pipBtn.title = open ? t('pipClose') : t('pipOpen'); this.pipBtn.setAttribute('aria-pressed', 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.updateTranslateState(); this.updateSpeechState(); this.updateHistoryState(); } destroy() { clearTimeout(this.retryTimer); this.wrap?.remove(); this.wrap = null; this.danmakuBtn = null; this.translateBtn = null; this.speechBtn = null; this.historyBtn = null; this.settingsBtn = null; this.pipBtn = null; } } let settings = loadSettings(); let danmaku = null; let danmakuEmitter = null; let translateQueue = null; let chatObserver = null; let chatContextMenu = null; let chatClearButton = null; let favoritesPanel = null; let fullscreenChat = null; let enhancedPip = 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 isBackgroundChatKept() { if (document.documentElement.dataset.tbcPreview === 'true') return true; if (enhancedPip?.isOpen()) return true; return false; } function isActive() { if (isBackgroundChatKept()) return true; return pageActive; } function syncBackgroundChat() { syncTabAntiFreeze(() => enhancedPip?.isOpen()); if (isBackgroundChatKept()) { chatObserver?.ensureBackgroundIrc?.(); } else { chatObserver?.maybeStopBackgroundIrc?.(); } if (isBackgroundChatKept() && !pageActive) { pageActive = true; } } function onSettingsChange(newSettings) { settings = newSettings; danmaku?.updateLayout(); controlButton?.updateDanmakuState(); controlButton?.updateTranslateState(); controlButton?.updateSpeechState(); settingsPanel?.syncFromSettings(); speechPanel?.syncFromSettings(); enhancedPip?.syncSettings(); syncBackgroundChat(); if (document.documentElement.dataset.tbcPreview === 'true') { window.dispatchEvent(new CustomEvent('tbc-settings-updated', { detail: settings })); } 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 toggleTranslate() { settings.translateEnabled = !settings.translateEnabled; saveSettings(settings); onSettingsChange(settings); } function toggleSpeechEnabled() { settings.speechEnabled = !settings.speechEnabled; saveSettings(settings); onSettingsChange(settings); } function handleVisibilityChange() { if (document.documentElement.dataset.tbcPreview === 'true') { pageActive = true; return; } const wasChatActive = isActive(); pageActive = isPageActive(); syncBackgroundChat(); const nowChatActive = isActive(); if (wasChatActive && !nowChatActive) { danmakuEmitter?.clear(); translateQueue?.clear(); cancelSpeech(); } } function teardown() { chatObserver?.stop(); chatContextMenu?.detach(); chatClearButton?.destroy(); favoritesPanel?.destroy(); fullscreenChat?.destroy(); enhancedPip?.destroy(); danmaku?.destroy(); controlButton?.destroy(); danmakuEmitter?.clear(); translateQueue?.clear(); clearChatTranslations(document, { force: true }); settingsPanel?.close(); speechPanel?.close(); historyPanel?.close(); cancelSpeech(); tabAntiFreezeDisable(); chatObserver = null; chatContextMenu = null; chatClearButton = null; favoritesPanel = null; fullscreenChat = null; enhancedPip = 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, }); favoritesPanel = new FavoritesPanel(); favoritesPanel.attach(); chatContextMenu = new ChatContextMenu({ getSettings, danmakuEmitter, isActive, favoritesPanel, }); chatContextMenu.attach(document); danmaku.setSuperchatHelpers(() => ({ getSettings, favoritesPanel, chatContextMenu, })); chatClearButton = new ChatClearButton({ onClear: () => chatObserver?.clearIrcHistory?.(), }); fullscreenChat = new FullscreenChatInput({ favoritesPanel }); fullscreenChat.attach(); enhancedPip = new EnhancedPip({ getSettings, danmaku, favoritesPanel, onToggleDanmaku: toggleDanmaku, onOpenSettings: () => { speechPanel?.close(); historyPanel?.close(); const anchor = controlButton?.settingsBtn || controlButton?.wrap; if (anchor && !settingsPanel?.isOpen) settingsPanel.open(anchor); controlButton?.updatePanelState(); }, onStateChange: () => { syncBackgroundChat(); controlButton?.updatePipState(); }, }); 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, onToggleTranslate: toggleTranslate, 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, onTogglePip: () => enhancedPip?.toggle(), isPipOpen: () => !!enhancedPip?.isOpen(), }); // Mount player UI first so FFZ/chat delays cannot block controls. await Promise.all([ danmaku.mount(), controlButton.mount(), favoritesPanel.mount(), ]); await chatClearButton.mount(); await chatObserver.start(); window.__tbcApplySettings = onSettingsChange; window.__tbcGetSettings = getSettings; window.__tbcToggleTranslate = toggleTranslate; window.__tbcToggleSpeech = toggleSpeechEnabled; syncBackgroundChat(); 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(); chatClearButton?.remount(); favoritesPanel?.remount(); } }, 1500); } document.addEventListener('visibilitychange', handleVisibilityChange); boot().catch((err) => console.error('[TBC] Init failed:', err)); watchNavigation(); })();