// ==UserScript== // @name YouTube 弹幕姬 | 实时翻译 // @namespace youtube-danmaku // @version 1.7.0 // @description 将 YouTube 直播聊天转换为视频上的滚动弹幕,支持实时翻译、朗读 // @tag 直播, 油管, 弹幕, youtube, 翻译 // @author KaKa // @license MIT // @match https://www.youtube.com/* // @match https://m.youtube.com/* // @exclude https://www.youtube.com/live_chat* // @exclude https://m.youtube.com/live_chat* // @noframes // @grant GM_addStyle // @grant GM_setValue // @grant GM_getValue // @grant GM_xmlhttpRequest // @grant GM_setClipboard // @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 // @run-at document-idle // @icon https://www.google.com/s2/favicons?sz=64&domain=youtube.com // ==/UserScript== (function () { 'use strict'; const LOCALES = { zh: { danmakuToggle: '弹幕开关', danmakuOn: '关闭弹幕', danmakuOff: '开启弹幕', settingsToggle: '弹幕设置', panelTitle: '弹幕设置', panelClose: '关闭', scrollSpeed: '滚动速度', fontSize: '字体大小', danmakuOpacity: '弹幕透明度', fontFamily: '字体', scrollArea: '滚动区域', translateEnable: '启用翻译', translateInChat: '在聊天区显示译文', translateInChatBadge: '译', chatActionTranslate: '翻译', chatActionRetranslate: '重新翻译', chatActionSpeak: '朗读', chatActionDanmaku: '发送弹幕', chatActionTranslating: '翻译中…', chatActionTranslateFailed: '翻译失败', chatActionFavorite: '收藏', chatActionOpenProfile: '打开主页', favoritesTitle: '收藏弹幕', favoritesAdd: '添加', favoritesAddPlaceholder: '输入要收藏的消息…', favoritesEmpty: '暂无收藏', favoritesEmptyText: '请输入内容', favoritesAdded: '已加入收藏', favoritesExists: '已在收藏中', favoritesFull: '收藏已满(最多 80 条)', 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: '暂无收藏', fsChatPlaceholder: '发送聊天消息…', fsChatSend: '发送', fsChatClose: '关闭', fsChatNeedLogin: '请先登录 YouTube', fsChatSlowMode: '慢速模式,请稍后再试', fsChatDuplicate: '不能发送重复消息', fsChatRateLimit: '发送过于频繁,请稍后再试', fsChatForbidden: '当前无法发送消息', fsChatDisabled: '直播聊天已关闭', fsChatPreviewOnly: '预览模式无法发送', fsChatFailed: '发送失败,请稍后重试', fsChatSent: '已发送', chatClear: '清空聊天', translateProvider: '翻译引擎', translateProviderAuto: '自动切换', translateProviderMicrosoft: 'Microsoft 翻译', translateProviderGoogle: 'Google 翻译', translateProviderVolcengine: '火山翻译', translateProviderCaiyun: '彩云小译', translateProviderTencent: '腾讯交互翻译', translateTarget: '目标语言', translateSpeed: '翻译速度', translateSpeedUnit: '条/秒', 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: '打开发送者主页', blockKeywords: '屏蔽关键词', blockKeywordHint: '原文和译文都会屏蔽', blockKeywordPlaceholder: '输入关键词', blockKeywordAdd: '添加', blockKeywordRemove: '移除关键词', 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: '当前浏览器不支持语音朗读', tooltipSpeak: '朗读这条弹幕', superchatPinned: 'Super Chat 悬停', superchatPosition: '悬停位置', superchatDuration: '悬停时间', superchatDurationFollowYoutube: '悬停时间跟随 YouTube 聊天栏', superchatDurationFollowYoutubeHint: '开启后按直播聊天 ticker 倒计时消失;找不到 ticker 时用上方手动时间', superchatAnimation: '悬停动画', superchatTop: '顶部居中', superchatTopLeft: '左上', superchatBottom: '底部居中', superchatBottomLeft: '左下', superchatBottomRight: '右下', superchatCompact: '精简模式', superchatOverlayDanmaku: '悬停可遮挡滚动弹幕', superchatFade: '淡入淡出', superchatSlide: '滑入', superchatPop: '弹出', superchatShine: '闪光', superchatClose: '关闭悬停弹幕', superchatDetailClose: '关闭', liveWatching: '人在看', liveTotalViews: '累计观看', historyToggle: '下载聊天记录', historyPanelTitle: '下载聊天记录', historyHint: '直播中导出当前聊天框里已显示的消息;回放则读取完整聊天回放。聊天需嵌在页面右侧。关键词和用户名留空表示不筛选,多个用逗号分隔。', historyKeywords: '内容关键词', historyKeywordsHint: '只保留包含这些词的消息', historyUsernames: '用户名', historyUsernamesHint: '只保留这些用户的消息', historySuperchat: '包含 Super Chat', historyRange: '时间范围', historyNoDuration: '还没读到视频时长,将导出全部时间。', historyFormat: '导出格式', historyDownload: '下载', historyPause: '暂停', historyResume: '继续', historyCancel: '取消', historyDownloading: '正在读取聊天…', historyPaused: '已暂停', historyCancelled: '已取消下载。', historyProgress: '已读取', historyDone: '已导出', historyNone: '没有可下载的聊天。直播请确认右侧聊天区已有消息;回放请确认保留了聊天且嵌在页面右侧。', historyFailed: '读取聊天失败,请稍后重试。', historyEmpty: '筛选后没有消息。', pipOpen: '增强画中画', pipClose: '关闭增强画中画', pipTitle: '增强画中画', pipNeedBrowser: '增强画中画需要 Chrome 或 Edge 116 及以上', pipNoVideo: '没有可用的播放器画面', pipCaptureFailed: '无法捕获画面', pipOpenFailed: '无法打开画中画窗口', pipReload: '刷新画面', pipReloaded: '画面已刷新', pipReloadFailed: '刷新失败,请检查视频页画面', pipDanmaku: '弹幕开关', pipSettings: '弹幕设置', pipSettingsHint: '已在 YouTube 页面打开设置', pipSend: '发送消息', pipBack: '回到视频页', pipReplayNoSend: '回放页不能发送聊天', 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', translateInChat: 'Show translation in chat', translateInChatBadge: 'TR', chatActionTranslate: 'Translate', chatActionRetranslate: 'Retranslate', chatActionSpeak: 'Read aloud', chatActionDanmaku: 'Send as danmaku', chatActionTranslating: 'Translating…', chatActionTranslateFailed: 'Translation failed', chatActionFavorite: 'Favorite', chatActionOpenProfile: 'Open profile', favoritesTitle: 'Favorite chats', favoritesAdd: 'Add', favoritesAddPlaceholder: 'Enter a message to save…', favoritesEmpty: 'No favorites yet', favoritesEmptyText: 'Enter some text', favoritesAdded: 'Added to favorites', favoritesExists: 'Already in favorites', favoritesFull: 'Favorites full (max 80)', favoritesSend: 'Send', favoritesRemove: 'Remove', favoritesSent: 'Sent', favoritesShortcutNone: 'No shortcut', favoritesScopeGlobal: 'All channels', favoritesScopeChannel: 'This channel · {channel}', favoritesMakeGlobal: 'Global', favoritesMakeChannel: 'Channel', favoritesMakeGlobalHint: 'Available on every channel', favoritesMakeChannelHint: 'Only on this channel', favoritesScopedGlobal: 'Set to all channels', favoritesScopedChannel: 'Set to this channel only', favoritesNeedChannel: 'Could not detect this channel', favoritesWheel: 'Wheel mode', favoritesWheelStart: 'Start', favoritesWheelStop: 'Stop', favoritesWheelIdle: 'Idle', favoritesWheelRunning: 'Sent {sent}/{max} · {left}s left', favoritesWheelStopped: 'Wheel mode stopped', favoritesWheelMinInterval: 'Min interval (sec)', favoritesWheelMaxInterval: 'Max interval (sec)', favoritesWheelMaxCount: 'Max messages', favoritesWheelMaxDuration: 'Max duration (sec)', fsChatFavorites: 'Favorites', fsChatFavoritesEmpty: 'No favorites', fsChatPlaceholder: 'Send a message…', fsChatSend: 'Send', fsChatClose: 'Close', fsChatNeedLogin: 'Sign in to YouTube first', fsChatSlowMode: 'Slow mode — try again shortly', fsChatDuplicate: 'Duplicate message blocked', fsChatRateLimit: 'Sending too fast — slow down', fsChatForbidden: 'You cannot send messages right now', fsChatDisabled: 'Live chat is disabled', fsChatPreviewOnly: 'Preview mode cannot send', fsChatFailed: 'Failed to send. Try again.', fsChatSent: 'Sent', chatClear: 'Clear chat', 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', 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', blockKeywords: 'Blocked keywords', blockKeywordHint: 'Matches the original text and the translation', blockKeywordPlaceholder: 'Add a keyword', blockKeywordAdd: 'Add', blockKeywordRemove: 'Remove keyword', 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, length uses the translated text. 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', tooltipSpeak: 'Read this danmaku', superchatPinned: 'Pin Super Chats', superchatPosition: 'Pin position', superchatDuration: 'Pin time', superchatDurationFollowYoutube: 'Match YouTube chat ticker time', superchatDurationFollowYoutubeHint: 'Dismiss with the live-chat ticker countdown; falls back to the slider above', superchatAnimation: 'Pin animation', superchatTop: 'Top center', superchatTopLeft: 'Top left', superchatBottom: 'Bottom center', superchatBottomLeft: 'Bottom left', superchatBottomRight: 'Bottom right', superchatCompact: 'Compact mode', superchatOverlayDanmaku: 'Pins may cover scrolling danmaku', superchatFade: 'Fade', superchatSlide: 'Slide', superchatPop: 'Pop', superchatShine: 'Shine', superchatClose: 'Dismiss pinned Super Chat', superchatDetailClose: 'Close', liveWatching: 'watching', liveTotalViews: 'Total views', historyToggle: 'Download chat history', historyPanelTitle: 'Download chat history', historyHint: 'On a live stream, exports messages currently shown in the docked chat. On a replay, reads the full saved chat replay. Keep chat docked. Leave keywords and usernames empty to skip those filters. Separate multiple values with commas.', historyKeywords: 'Keywords', historyKeywordsHint: 'Keep messages that contain these words', historyUsernames: 'Usernames', historyUsernamesHint: 'Keep messages from these users', historySuperchat: 'Include Super Chat', historyRange: 'Time range', historyNoDuration: 'Video length is not ready yet. The export will include the full timeline.', historyFormat: 'Export format', historyDownload: 'Download', historyPause: 'Pause', historyResume: 'Resume', historyCancel: 'Cancel', historyDownloading: 'Reading chat…', historyPaused: 'Paused', historyCancelled: 'Download cancelled.', historyProgress: 'Loaded', historyDone: 'Exported', historyNone: 'No chat to download. On live, wait until messages appear in the docked chat. On a replay, open an archived live with chat saved and keep that chat docked.', historyFailed: 'Could not read the chat. Try again later.', historyEmpty: 'No messages match these filters.', 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 watch page', pipDanmaku: 'Danmaku', pipSettings: 'Danmaku settings', pipSettingsHint: 'Settings opened on the YouTube page', pipSend: 'Send a message', pipBack: 'Back to the video', pipReplayNoSend: 'Chat cannot be sent on a replay', pipAspectLock: 'Lock aspect ratio', pipAspectUnlock: 'Stretch to fill', }, ja: { danmakuToggle: '弾幕の切替', danmakuOn: '弾幕をオフ', danmakuOff: '弾幕をオン', settingsToggle: '弾幕設定', panelTitle: '弾幕設定', panelClose: '閉じる', scrollSpeed: 'スクロール速度', fontSize: 'フォントサイズ', danmakuOpacity: '弾幕の透明度', fontFamily: 'フォント', scrollArea: '表示エリア', translateEnable: '翻訳を有効にする', translateInChat: 'チャットに訳文を表示', translateInChatBadge: '訳', chatActionTranslate: '翻訳', chatActionRetranslate: '再翻訳', chatActionSpeak: '読み上げ', chatActionDanmaku: '弾幕で表示', chatActionTranslating: '翻訳中…', chatActionTranslateFailed: '翻訳に失敗しました', chatActionFavorite: 'お気に入り', chatActionOpenProfile: 'プロフィールを開く', favoritesTitle: 'お気に入りチャット', favoritesAdd: '追加', favoritesAddPlaceholder: '保存するメッセージを入力…', favoritesEmpty: 'お気に入りはまだありません', favoritesEmptyText: '内容を入力してください', favoritesAdded: 'お気に入りに追加しました', favoritesExists: 'すでに登録済みです', favoritesFull: 'お気に入りがいっぱいです(最大 80)', 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: 'お気に入りなし', fsChatPlaceholder: 'チャットを送信…', fsChatSend: '送信', fsChatClose: '閉じる', fsChatNeedLogin: '先に YouTube にログインしてください', fsChatSlowMode: 'スローモードです。しばらくしてから再試行', fsChatDuplicate: '重複メッセージは送れません', fsChatRateLimit: '送信が速すぎます', fsChatForbidden: '現在メッセージを送れません', fsChatDisabled: 'ライブチャットは無効です', fsChatPreviewOnly: 'プレビューでは送信できません', fsChatFailed: '送信に失敗しました。再試行してください。', fsChatSent: '送信しました', chatClear: 'チャットをクリア', translateProvider: '翻訳エンジン', translateProviderAuto: '自動', translateProviderMicrosoft: 'Microsoft Translator', translateProviderGoogle: 'Google 翻訳', translateProviderVolcengine: 'Volcengine', translateProviderCaiyun: '彩雲(Caiyun)', translateProviderTencent: '腾讯 Transmart', translateTarget: '翻訳先言語', translateSpeed: '翻訳速度', translateSpeedUnit: '件/秒', scrollAreaFull: '全画面', scrollAreaTopHalf: '上半分', scrollAreaBottomHalf: '下半分', scrollAreaTopQuarter: '上部 1/4', fontSystem: 'システム既定', 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: '中国語(簡体字)', langZhTW: '中国語(繁体字)', langEn: '英語', langJa: '日本語', langKo: '韓国語', langEs: 'スペイン語', langFr: 'フランス語', langDe: 'ドイツ語', langRu: 'ロシア語', langPt: 'ポルトガル語', tooltipCopy: 'チャット入力欄にコピー', tooltipOpenProfile: '送信者のプロフィールを開く', blockKeywords: 'ブロックするキーワード', blockKeywordHint: '原文と訳文の両方に適用されます', blockKeywordPlaceholder: 'キーワードを入力', blockKeywordAdd: '追加', blockKeywordRemove: 'キーワードを削除', speechToggle: '弾幕読み上げ', speechPanelTitle: '弾幕読み上げ', speechEnable: '読み上げを有効にする', speechOn: '読み上げオン', speechMute: '読み上げミュート', speechOff: '読み上げオフ', speechTest: '試し読み', speechTestText: 'これはテストです ABC', speechPlay: '設定速度で再生', speechPlayFast: '变速モードで再生', speechVolume: '音量', speechPitch: '高さ', speechVoice: '声', speechVoiceLang: '言語', speechVoiceDefault: 'デフォルト', speechRate: '読み上げ速度', speechPace: '变速モード', speechMaxSeconds: '1件の上限', speechSeconds: '秒', speechPaceHint: '上限時間を超えるときは自動で速くします。翻訳がオンのときは訳文の長さを使います。繰り返しは最大3回まで。記号だけ、絵文字だけ、数字だけは読み上げません。', speechBuffer: '追いかける件数', speechBufferHint: 'この件数より古い弾幕は捨てて、読み上げが遅れ続けないようにします', speechUnsupported: 'このブラウザは音声読み上げに対応していません', tooltipSpeak: 'この弾幕を読み上げる', superchatPinned: 'Super Chat を固定', superchatPosition: '固定位置', superchatDuration: '固定時間', superchatDurationFollowYoutube: 'YouTube チャット欄の表示時間に合わせる', superchatDurationFollowYoutubeHint: 'ティッカーのカウントダウンに合わせて消えます。見つからない場合は上の手動時間を使用', superchatAnimation: '固定アニメーション', superchatTop: '上部中央', superchatTopLeft: '左上', superchatBottom: '下部中央', superchatBottomLeft: '左下', superchatBottomRight: '右下', superchatCompact: 'コンパクト固定(アバター+名前+金額)', superchatOverlayDanmaku: '固定表示が流れる弾幕に重ねられる', superchatFade: 'フェード', superchatSlide: 'スライド', superchatPop: 'ポップ', superchatShine: 'シャイン', superchatClose: '固定スーパーチャットを閉じる', superchatDetailClose: '閉じる', liveWatching: '人が視聴中', liveTotalViews: '総再生回数', historyToggle: 'チャット履歴をダウンロード', historyPanelTitle: 'チャット履歴をダウンロード', historyHint: '配信中はドック中のチャットに表示されているメッセージを書き出します。アーカイブでは保存済みリプレイを読み取ります。チャットはページ右側にドックしたままにしてください。キーワードとユーザー名が空なら、その条件では絞り込みません。複数はカンマで区切ります。', historyKeywords: 'キーワード', historyKeywordsHint: 'これらの語を含むメッセージだけ残す', historyUsernames: 'ユーザー名', historyUsernamesHint: 'これらのユーザーのメッセージだけ残す', historySuperchat: 'Super Chat を含める', historyRange: '時間範囲', historyNoDuration: '動画の長さがまだ分かりません。全時間を書き出します。', historyFormat: '書き出し形式', historyDownload: 'ダウンロード', historyPause: '一時停止', historyResume: '再開', historyCancel: 'キャンセル', historyDownloading: 'チャットを読み取り中…', historyPaused: '一時停止中', historyCancelled: 'ダウンロードをキャンセルしました。', historyProgress: '読み取り済み', historyDone: '書き出し完了', historyNone: 'ダウンロードできるチャットがありません。配信中は右側のチャットにメッセージが出てから、アーカイブではチャットが残っている配信を開き、チャットをページ右側にドックしてください。', historyFailed: 'チャットを読み取れませんでした。しばらくしてからもう一度試してください。', historyEmpty: '条件に合うメッセージがありません。', pipOpen: '拡張ピクチャーインピクチャー', pipClose: '拡張ピクチャーインピクチャーを閉じる', pipTitle: '拡張ピクチャーインピクチャー', pipNeedBrowser: '拡張ピクチャーインピクチャーには Chrome または Edge 116 以降が必要です', pipNoVideo: '再生できる映像がありません', pipCaptureFailed: '映像をキャプチャできません', pipOpenFailed: 'ピクチャーインピクチャーを開けません', pipReload: '映像を更新', pipReloaded: '映像を更新しました', pipReloadFailed: '更新できませんでした。動画ページの映像を確認してください', pipDanmaku: '弾幕の切替', pipSettings: '弾幕設定', pipSettingsHint: 'YouTube ページで設定を開きました', pipSend: 'メッセージを送信', pipBack: '動画ページに戻る', pipReplayNoSend: 'アーカイブではチャットを送信できません', pipAspectLock: '縦横比を固定', pipAspectUnlock: '引き伸ばして埋める', }, }; function detectLocale() { const langs = navigator.languages?.length ? navigator.languages : [navigator.language]; for (const lang of langs) { if (lang && /^zh(-|$)/i.test(lang)) return 'zh'; if (lang && /^ja(-|$)/i.test(lang)) return 'ja'; } return 'en'; } const LOCALE = detectLocale(); const STRINGS = LOCALES[LOCALE]; function t(key) { return STRINGS[key] || LOCALES.en[key] || key; } 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 getSuperchatPositionOptions() { return [ { label: t('superchatTop'), value: 'top' }, { label: t('superchatTopLeft'), value: 'top-left' }, { 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 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 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() { if (LOCALE === 'zh') return 'zh-CN'; if (LOCALE === 'ja') return 'ja'; return 'en'; } const SCROLL_AREA_BOTTOM_RESERVE = 56; const DEFAULT_SETTINGS = { enabled: true, speed: 5, fontSize: 22, opacity: 100, fontFamily: 'system-ui, sans-serif', scrollArea: 'full', translateEnabled: false, translateInChat: false, translateProvider: 'microsoft', translateTargetLang: getDefaultTranslateTargetLang(), translateSpeed: 3, blockKeywords: [], speechEnabled: false, speechVolume: 80, speechPitch: 100, speechVoice: '', speechLang: '', speechRate: 100, speechPace: true, speechMaxSeconds: 4, speechBuffer: 5, superchatPinned: false, superchatPosition: 'top', superchatDuration: 15, /** When true, pin lifetime follows the YouTube live-chat ticker countdown. */ superchatDurationFollowYoutube: false, superchatAnimation: 'slide', /** Compact pin: avatar + name + amount; click opens a centered detail card. */ superchatCompact: false, /** When true, pinned Super Chats may cover scrolling danmaku (no lane reserve). */ superchatOverlayDanmaku: true, }; const SUPERCHAT_MAX_PINNED = 10; const SUPERCHAT_POSITIONS = ['top', 'top-left', 'bottom', 'bottom-left', 'bottom-right']; const SCROLL_AREAS = ['full', 'top-half', 'bottom-half', 'top-quarter']; const SELECTORS = { player: 'ytd-watch-flexy #movie_player, ytd-player#ytd-player #movie_player', playerControls: 'ytd-watch-flexy #movie_player .ytp-right-controls, ytd-player#ytd-player #movie_player .ytp-right-controls', playerChrome: '.ytp-chrome-bottom', chatFrame: 'ytd-live-chat-frame#chat iframe, iframe#chatframe', chatHost: 'ytd-live-chat-frame#chat', chatItems: 'yt-live-chat-item-list-renderer #items, #items.yt-live-chat-item-list-renderer', chatMessage: [ 'yt-live-chat-text-message-renderer', 'yt-live-chat-paid-message-renderer', 'yt-live-chat-paid-sticker-renderer', 'yt-live-chat-membership-item-renderer', ].join(', '), chatInput: '#input.yt-live-chat-text-input-field-renderer, yt-live-chat-text-input-field-renderer #input[contenteditable]', }; const TRANSLATE_PROVIDERS = ['auto', 'microsoft', 'google', 'volcengine', 'caiyun', 'tencent']; const STORAGE_KEY = 'ydm_settings_v1'; const LIVE_MESSAGE_TAIL = 8; const WARMUP_MS = 2500; const MAX_DANMAKU_QUEUE = 24; const MAX_TRANSLATE_QUEUE = 32; const NAME_COLORS = [ '#ff8a80', '#ffd180', '#ffff8d', '#ccff90', '#b9f6ca', '#84ffff', '#80d8ff', '#82b1ff', '#b388ff', '#ea80fc', '#f8bbd0', '#ff9e80', '#ffab91', '#c5e1a5', ]; function loadSettings() { try { const saved = GM_getValue(STORAGE_KEY, null); if (saved && typeof saved === 'object') { const merged = { ...DEFAULT_SETTINGS, ...saved }; if (merged.scrollArea === 'half') { merged.scrollArea = 'top-half'; } if (!TRANSLATE_PROVIDERS.includes(merged.translateProvider)) { merged.translateProvider = DEFAULT_SETTINGS.translateProvider; } merged.blockKeywords = normalizeBlockKeywords(merged.blockKeywords); merged.opacity = clamp(Number(merged.opacity) || DEFAULT_SETTINGS.opacity, 10, 100); if (typeof merged.speechEnabled !== 'boolean') { merged.speechEnabled = merged.speechMode === 'on' || merged.speechMode === 'mute'; if (merged.speechMode === 'mute') merged.speechVolume = 0; } delete merged.speechMode; delete merged.speechFastest; 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; merged.speechMaxSeconds = clamp(finiteNumber(merged.speechMaxSeconds, DEFAULT_SETTINGS.speechMaxSeconds), 1, 15); merged.speechBuffer = clamp(finiteNumber(merged.speechBuffer, DEFAULT_SETTINGS.speechBuffer), 1, 25); merged.speechVoice = typeof merged.speechVoice === 'string' ? merged.speechVoice : ''; merged.speechLang = typeof merged.speechLang === 'string' ? merged.speechLang : ''; merged.superchatPinned = !!merged.superchatPinned; if (merged.superchatPosition === 'topleft') merged.superchatPosition = 'top-left'; if (merged.superchatPosition === 'bottomRight' || merged.superchatPosition === 'bottom_right') { merged.superchatPosition = 'bottom-right'; } if (merged.superchatPosition === 'bottom_left') merged.superchatPosition = 'bottom-left'; if (!SUPERCHAT_POSITIONS.includes(merged.superchatPosition)) merged.superchatPosition = 'top'; if (!SCROLL_AREAS.includes(merged.scrollArea)) merged.scrollArea = 'full'; if (!['fade', 'slide', 'pop', 'shine'].includes(merged.superchatAnimation)) merged.superchatAnimation = 'slide'; merged.superchatDuration = clamp(finiteNumber(merged.superchatDuration, DEFAULT_SETTINGS.superchatDuration), 2, 300); merged.superchatDurationFollowYoutube = !!merged.superchatDurationFollowYoutube; merged.superchatCompact = !!merged.superchatCompact; if (typeof merged.superchatOverlayDanmaku !== 'boolean') { merged.superchatOverlayDanmaku = DEFAULT_SETTINGS.superchatOverlayDanmaku; } else { merged.superchatOverlayDanmaku = !!merged.superchatOverlayDanmaku; } return merged; } } catch (_) { /* ignore */ } return { ...DEFAULT_SETTINGS, blockKeywords: [] }; } function saveSettings(settings) { GM_setValue(STORAGE_KEY, settings); } function normalizeBlockKeywords(value) { if (!Array.isArray(value)) return []; const seen = new Set(); const words = []; for (const item of value) { if (typeof item !== 'string') continue; const word = item.trim().slice(0, 40); if (!word) continue; const key = word.toLowerCase(); if (seen.has(key)) continue; seen.add(key); words.push(word); if (words.length >= 50) break; } return words; } function messageMatchesBlock(text, keywords) { const haystack = String(text || '').toLowerCase(); if (!haystack || !keywords?.length) return false; return keywords.some((word) => { const needle = String(word || '').trim().toLowerCase(); return needle.length > 0 && haystack.includes(needle); }); } function clamp(value, min, max) { return Math.min(max, Math.max(min, value)); } function finiteNumber(value, fallback) { const number = Number(value); return Number.isFinite(number) ? number : fallback; } function isPageActive() { return !document.hidden && document.visibilityState === 'visible'; } function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } function createSvg(markup) { const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); const viewBox = markup.match(/\bviewBox="([^"]*)"/i); if (viewBox) svg.setAttribute('viewBox', viewBox[1]); svg.setAttribute('aria-hidden', 'true'); const tags = markup.match(/<(?:path|circle|rect|polygon)\b[^>]*\/?>/gi) || []; for (const tag of tags) { const name = tag.match(/^<([\w:-]+)/)[1]; const shape = document.createElementNS('http://www.w3.org/2000/svg', name); for (const attr of tag.matchAll(/([\w:-]+)="([^"]*)"/g)) { shape.setAttribute(attr[1], attr[2]); } svg.appendChild(shape); } return svg; } function appendMarkup(parent, markup) { const parts = markup.match(/]*>[\s\S]*?<\/span>|/gi) || []; for (const part of parts) { if (/^/i); if (svgMarkup) span.appendChild(createSvg(svgMarkup[0])); parent.appendChild(span); continue; } parent.appendChild(createSvg(part)); } } 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 getFullscreenElement() { return document.fullscreenElement || document.webkitFullscreenElement || document.mozFullScreenElement || null; } /** * Mount host for fullscreen overlays — mirrors twitch-danmaku: * append to the native Fullscreen API element (or YouTube `#movie_player` * with `ytp-fullscreen`), never `.html5-video-container` (video compositor * can paint over siblings there even with a high z-index). */ function getFullscreenMount() { const player = getPlayerElement() || document.querySelector('#movie_player, .html5-video-player'); const native = getFullscreenElement(); if (native) { // Prefer the player shell when it is (inside) the FS tree so absolute // bottom/left are relative to the video frame, not a page-sized ancestor. if (player && (native === player || native.contains(player))) return player; return native; } if (player?.classList?.contains('ytp-fullscreen') || player?.classList?.contains('ytp-fullscreen-display')) { return player; } return document.querySelector('#movie_player.ytp-fullscreen, .html5-video-player.ytp-fullscreen'); } function isWatchFullscreen() { return !!getFullscreenMount(); } function getDanmakuMount(item) { return item.closest('.ydm-danmaku-layer')?.parentElement || null; } function getPlayerElement() { return document.querySelector(SELECTORS.player); } function getChatDocument() { const iframe = document.querySelector(SELECTORS.chatFrame); if (iframe) { try { const doc = iframe.contentDocument; if (doc?.querySelector(SELECTORS.chatItems)) return doc; } catch (_) { return null; } return null; } const host = document.querySelector(SELECTORS.chatHost); if (host?.querySelector(SELECTORS.chatItems)) return document; return null; } async function waitForChatList(shouldAbort, timeout = 20000) { const started = Date.now(); while (Date.now() - started < timeout) { if (shouldAbort?.()) return null; const list = getChatDocument()?.querySelector(SELECTORS.chatItems) || null; if (list) return list; await delay(300); } return null; } function isNeutralColor(color) { const match = String(color).match(/rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)/i); if (!match) return false; const channels = match.slice(1, 4).map(Number); return Math.max(...channels) - Math.min(...channels) < 24; } function colorFromName(name) { const text = name || '?'; let hash = 2166136261; for (let i = 0; i < text.length; i += 1) { hash ^= text.charCodeAt(i); hash = Math.imul(hash, 16777619); } return NAME_COLORS[(hash >>> 0) % NAME_COLORS.length]; } function safeCssColor(color) { const value = String(color || '').trim(); if (/^#[0-9a-f]{3,8}$/i.test(value)) return value; if (/^rgba?\(\s*(?:\d{1,3}\s*,\s*){2}\d{1,3}(?:\s*,\s*(?:0|1|0?\.\d+))?\s*\)$/i.test(value)) { return value; } return '#ffffff'; } function getUsernameColor(msgEl, username) { const type = (msgEl.getAttribute('author-type') || '').toLowerCase(); if (type === 'owner') return '#ffd600'; if (type === 'moderator') return '#5e84f1'; const nameEl = msgEl.querySelector('#author-name'); if (nameEl) { const color = getComputedStyle(nameEl).color; if (color && !isNeutralColor(color)) return color; } return colorFromName(username); } function getAuthorExternalChannelId(msgEl) { if (!msgEl) return ''; const readers = [ () => msgEl.authorExternalChannelId, () => msgEl.getAttribute?.('author-external-channel-id'), () => msgEl.dataset?.authorExternalChannelId, () => msgEl.data?.authorExternalChannelId, () => msgEl.__data?.authorExternalChannelId, () => msgEl.__data?.data?.authorExternalChannelId, () => msgEl.__dataHost?.data?.authorExternalChannelId, ]; for (const read of readers) { try { const value = String(read() || '').trim(); if (value) return value; } catch (_) { /* ignore polymer access errors */ } } return ''; } /** Author label without membership / badge noise inside #author-name. */ function getAuthorDisplayName(msgEl) { const nameEl = msgEl?.querySelector?.('#author-name'); if (!nameEl) return ''; const clone = nameEl.cloneNode(true); clone.querySelectorAll?.( '#chip-badges, #chat-badges, yt-live-chat-author-badge-renderer, img, svg', ).forEach((node) => node.remove()); return String(clone.textContent || '').replace(/\s+/g, ' ').trim(); } function extractYouTubeHandle(name) { const text = String(name || '').trim(); if (!text) return ''; const match = text.match(/^@([\w.-]{2,})/) || text.match(/(?:^|\s)@([\w.-]{2,})/); return match ? match[1] : ''; } /** * Channel page for a live-chat row. Prefer @handle when the chip shows one, * otherwise /channel/UC… from the renderer. Works for any click target inside * the message — not only the avatar. */ function getYouTubeProfileUrl(msgEl) { if (!msgEl) return null; const name = getAuthorDisplayName(msgEl); const handle = extractYouTubeHandle(name); if (handle) return `https://www.youtube.com/@${encodeURIComponent(handle)}`; const anchors = msgEl.querySelectorAll?.( 'a[href*="/@"], a[href*="/channel/"], a[href*="/c/"], a[href*="youtube.com/"]', ) || []; for (const anchor of anchors) { const href = anchor.getAttribute('href'); if (!href || href.startsWith('javascript:')) continue; try { const url = new URL(href, 'https://www.youtube.com'); if (url.protocol === 'https:' && /(^|\.)youtube\.com$/.test(url.hostname)) { return url.href; } } catch (_) { /* ignore */ } } const channelId = getAuthorExternalChannelId(msgEl); if (channelId) return `https://www.youtube.com/channel/${encodeURIComponent(channelId)}`; return null; } /** Remove any avatar/name profile links we previously injected. */ function stripInjectedAuthorProfileLinks(msgEl) { if (!msgEl) return; msgEl.querySelectorAll('a[data-ydm-author-link="true"], a[data-ydm-author-photo="true"]').forEach((link) => { const parent = link.parentElement; if (!parent) { link.remove(); return; } while (link.firstChild) parent.insertBefore(link.firstChild, link); link.remove(); }); } /** No-op kept for call sites — profile entry is the chat context menu. */ function ensureAuthorProfileLink(msgEl) { stripInjectedAuthorProfileLinks(msgEl); } function isWatchPage() { if (document.documentElement.dataset.ydmPreview === 'true') return true; const path = location.pathname; return path === '/watch' || /^\/live\/[^/]+/.test(path); } function pageKey() { if (!isWatchPage()) return ''; if (document.documentElement.dataset.ydmPreview === 'true') return 'preview'; const videoId = new URLSearchParams(location.search).get('v'); if (videoId) return videoId; const live = location.pathname.match(/^\/live\/([^/]+)/); return live ? live[1] : location.pathname; } async function copyTextToClipboard(text) { if (!text) return false; if (typeof GM_setClipboard === 'function') { try { GM_setClipboard(text, 'text'); return true; } catch (_) { /* ignore */ } } try { await navigator.clipboard.writeText(text); return true; } catch (_) { return false; } } async function setChatInputText(text) { if (!text) return false; const doc = getChatDocument(); const win = doc?.defaultView; const input = doc?.querySelector(SELECTORS.chatInput); if (!doc || !win || !input) { return copyTextToClipboard(text); } win.focus(); input.focus(); const selection = win.getSelection(); const range = doc.createRange(); range.selectNodeContents(input); selection?.removeAllRanges(); selection?.addRange(range); let inserted = false; try { inserted = doc.execCommand('insertText', false, text); } catch (_) { inserted = false; } const current = (input.innerText || input.textContent || '').replace(/\u00a0/g, ' ').trim(); if (!inserted || current !== text.trim()) { input.innerText = text; input.dispatchEvent(new InputEvent('input', { bubbles: true, cancelable: true, inputType: 'insertText', data: text, })); input.dispatchEvent(new Event('input', { bubbles: true })); } const endRange = doc.createRange(); endRange.selectNodeContents(input); endRange.collapse(false); selection?.removeAllRanges(); selection?.addRange(endRange); return true; } const TabAntiFreeze = { active: false, records: [], timer: null, }; function tabAntiFreezeStopEvent(event) { // Synthetic events are how we tell the page it is visible again. // Only the browser's real hide/show events should be swallowed. if (!event.isTrusted) return; event.stopImmediatePropagation(); } function tabAntiFreezeDefine(doc, key, getter) { const own = Object.getOwnPropertyDescriptor(doc, key) || null; Object.defineProperty(doc, key, { configurable: true, enumerable: true, get: getter, }); return own; } function tabAntiFreezePatchDocument(doc) { if (!doc || TabAntiFreeze.records.some((record) => record.doc === doc)) return; const saved = {}; try { saved.hidden = tabAntiFreezeDefine(doc, 'hidden', () => false); saved.visibilityState = tabAntiFreezeDefine(doc, 'visibilityState', () => 'visible'); if ('webkitHidden' in doc) { saved.webkitHidden = tabAntiFreezeDefine(doc, 'webkitHidden', () => false); } if ('webkitVisibilityState' in doc) { saved.webkitVisibilityState = tabAntiFreezeDefine(doc, 'webkitVisibilityState', () => 'visible'); } } catch (error) { console.warn('[YDM] Tab anti-freeze: could not patch visibility', error); return; } const win = doc.defaultView; const savedFocus = typeof win?.hasFocus === 'function' ? win.hasFocus : null; if (win && savedFocus) { try { win.hasFocus = () => true; } catch (_) { /* ignore */ } } const handler = tabAntiFreezeStopEvent; doc.addEventListener('visibilitychange', handler, true); doc.addEventListener('webkitvisibilitychange', handler, true); win?.addEventListener('visibilitychange', handler, true); win?.addEventListener('webkitvisibilitychange', handler, true); TabAntiFreeze.records.push({ doc, saved, win, savedFocus, handler }); try { doc.dispatchEvent(new Event('visibilitychange')); } catch (_) { /* ignore */ } } function tabAntiFreezeChatDocument() { const iframe = document.querySelector(SELECTORS.chatFrame); if (!iframe) return null; try { return iframe.contentDocument || null; } catch (_) { return null; } } function tabAntiFreezeCollect() { const docs = []; try { const pageDoc = (typeof unsafeWindow !== 'undefined' && unsafeWindow.document) || document; if (pageDoc) docs.push(pageDoc); } catch (_) { /* sandbox may hide unsafeWindow */ } if (document && !docs.includes(document)) docs.push(document); const chatDoc = tabAntiFreezeChatDocument(); if (chatDoc && !docs.includes(chatDoc)) docs.push(chatDoc); return docs; } function tabAntiFreezeEnable() { TabAntiFreeze.active = true; for (const doc of tabAntiFreezeCollect()) tabAntiFreezePatchDocument(doc); if (!TabAntiFreeze.timer) { TabAntiFreeze.timer = setInterval(() => { if (!TabAntiFreeze.active) return; for (const doc of tabAntiFreezeCollect()) tabAntiFreezePatchDocument(doc); }, 1000); } } function tabAntiFreezeRestore(record) { const { doc, saved, win, savedFocus, handler } = record; try { doc.removeEventListener('visibilitychange', handler, true); doc.removeEventListener('webkitvisibilitychange', handler, true); } catch (_) { /* detached document */ } try { win?.removeEventListener('visibilitychange', handler, true); win?.removeEventListener('webkitvisibilitychange', handler, true); } catch (_) { /* detached window */ } if (win && savedFocus) { try { win.hasFocus = savedFocus; } catch (_) { /* ignore */ } } for (const key of Object.keys(saved)) { try { if (saved[key]) Object.defineProperty(doc, key, saved[key]); else delete doc[key]; } catch (_) { try { delete doc[key]; } catch (_) { /* ignore */ } } } try { doc.dispatchEvent(new Event('visibilitychange')); } catch (_) { /* detached */ } } function tabAntiFreezeDisable() { if (!TabAntiFreeze.active && !TabAntiFreeze.records.length) return; clearInterval(TabAntiFreeze.timer); TabAntiFreeze.timer = null; const records = TabAntiFreeze.records.splice(0); for (const record of records) tabAntiFreezeRestore(record); TabAntiFreeze.active = false; } function tabAntiFreezeIsEnabled() { return TabAntiFreeze.active; } function syncTabAntiFreeze(isPipOpen) { if (isPipOpen?.()) tabAntiFreezeEnable(); else tabAntiFreezeDisable(); } const YDM_STYLE_ID = 'ydm-style-root'; const YDM_CHAT_STYLE_ID = 'ydm-style-chat-frame'; const YDM_CSS = ` .ydm-danmaku-layer { pointer-events: none; overflow: hidden; } .ydm-danmaku-item { position: absolute; left: 100%; white-space: nowrap; font-weight: 700; line-height: 1.2; pointer-events: auto; cursor: default; user-select: none; 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: ydm-scroll var(--ydm-duration, 8s) linear forwards; will-change: transform; } .ydm-emote { display: inline-block; width: 1.15em; height: 1.15em; margin: 0 0.08em; vertical-align: -0.2em; overflow: hidden; pointer-events: none; user-select: none; } .ydm-emote img { width: 100%; height: 100%; object-fit: contain; font-size: 0; pointer-events: none; } .ydm-danmaku-item.ydm-paused { animation-play-state: paused; z-index: 20; cursor: pointer; } .ydm-danmaku-item.ydm-paused.ydm-click-locked { z-index: 30; } .ydm-superchat-layer { pointer-events: none; display: none; flex-direction: column; align-items: center; justify-content: flex-start; gap: 6px; overflow: visible; box-sizing: border-box; } .ydm-superchat-layer.ydm-sc-bottom { justify-content: flex-end; } .ydm-superchat-layer.ydm-sc-align-left { align-items: flex-start; } .ydm-superchat-layer.ydm-sc-align-right { align-items: flex-end; } .ydm-superchat-item { position: relative; display: inline-flex; align-items: center; flex: 0 0 auto; gap: 6px; box-sizing: border-box; width: fit-content; max-width: min(720px, calc(100% - 24px)); padding: 5px 28px 5px 6px; border-radius: 999px; font-weight: 500; line-height: 1.25; white-space: nowrap; overflow: hidden; pointer-events: auto; cursor: pointer; user-select: none; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35); animation-duration: var(--ydm-sc-duration, 6s); animation-timing-function: ease; animation-fill-mode: forwards; } .ydm-superchat-item.ydm-sc-compact { gap: 5px; max-width: none; /* Extra right padding so the close control does not sit on the amount. */ padding: 3px 30px 3px 3px; border-radius: 999px; } .ydm-superchat-item.ydm-sc-compact .ydm-sc-avatar { width: 1.55em; height: 1.55em; } .ydm-superchat-item.ydm-sc-compact .ydm-sc-username-compact { flex: 0 1 auto; max-width: 6.5em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.72em; font-weight: 600; opacity: 0.95; } .ydm-superchat-item.ydm-sc-compact .ydm-sc-amount-badge { font-size: 0.72em; font-weight: 800; letter-spacing: 0.02em; padding: 0.12em 0.55em; border-radius: 999px; background: rgba(0, 0, 0, 0.22); max-width: 8.5em; overflow: hidden; text-overflow: ellipsis; } .ydm-superchat-item.ydm-sc-compact .ydm-sc-close { top: 50%; right: 4px; transform: translateY(-50%); width: 20px; height: 20px; } .ydm-superchat-item.ydm-sc-detail-open { outline: 2px solid rgba(255, 255, 255, 0.85); outline-offset: 2px; } .ydm-sc-close { position: absolute; top: 2px; right: 2px; z-index: 3; width: 22px; height: 22px; margin: 0; padding: 0; border: none; border-radius: 999px; 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; } .ydm-superchat-item:hover .ydm-sc-close, .ydm-superchat-item.ydm-paused .ydm-sc-close, .ydm-superchat-item:focus-within .ydm-sc-close { opacity: 1; pointer-events: auto; } .ydm-sc-close:hover { background: rgba(255, 255, 255, 0.22); color: #ffffff; } .ydm-sc-close:active { background: rgba(255, 255, 255, 0.3); } .ydm-sc-close svg { width: 14px; height: 14px; display: block; pointer-events: none; fill: currentColor; } .ydm-sc-avatar { width: 1.45em; height: 1.45em; border-radius: 50%; object-fit: cover; flex: 0 0 auto; background: rgba(0, 0, 0, 0.2); } .ydm-sc-username { flex: 0 0 auto; max-width: 7.5em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; opacity: 0.92; font-size: 0.78em; font-weight: 500; } .ydm-sc-amount { flex: 0 0 auto; font-size: 0.78em; font-weight: 700; letter-spacing: 0.01em; white-space: nowrap; } .ydm-sc-message-clip { flex: 0 1 auto; min-width: 0; overflow: hidden; max-width: 100%; } .ydm-sc-message { display: inline-block; white-space: nowrap; font-size: 0.92em; font-weight: 500; will-change: transform; } .ydm-superchat-item.ydm-sc-marquee .ydm-sc-message { animation: ydm-sc-marquee var(--ydm-sc-marquee-duration, 4s) linear 0.45s infinite alternate; } .ydm-superchat-item.ydm-paused .ydm-sc-message { animation-play-state: paused; } @keyframes ydm-sc-marquee { 0%, 8% { transform: translateX(0); } 92%, 100% { transform: translateX(var(--ydm-sc-marquee-distance, 0)); } } .ydm-superchat-item.ydm-sc-fade, .ydm-superchat-item.ydm-sc-shine { animation-name: ydm-sc-fade; } .ydm-superchat-item.ydm-sc-slide { animation-name: ydm-sc-slide; } .ydm-superchat-layer.ydm-sc-bottom .ydm-superchat-item.ydm-sc-slide { animation-name: ydm-sc-slide-up; } .ydm-superchat-item.ydm-sc-pop { animation-name: ydm-sc-pop; } .ydm-superchat-item.ydm-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: ydm-sc-shine-sweep 1.15s ease 0.25s; pointer-events: none; } .ydm-superchat-item.ydm-paused, .ydm-superchat-item.ydm-paused.ydm-click-locked { animation-play-state: paused; z-index: 20; } @keyframes ydm-sc-fade { 0% { opacity: 0; } 12% { opacity: 1; } 88% { opacity: 1; } 100% { opacity: 0; } } @keyframes ydm-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 ydm-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 ydm-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 ydm-sc-shine-sweep { from { transform: translateX(-130%); } to { transform: translateX(130%); } } .ydm-sc-detail-backdrop { position: absolute; inset: 0; z-index: 100030; background: rgba(0, 0, 0, 0.45); pointer-events: auto; cursor: pointer; } .ydm-sc-detail-card { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); z-index: 100031; box-sizing: border-box; width: min(420px, calc(100% - 48px)); max-height: min(70%, calc(100% - 48px)); overflow: auto; padding: 14px 16px 12px; border-radius: 14px; box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45); pointer-events: auto; cursor: default; display: flex; flex-direction: column; gap: 10px; animation: ydm-sc-detail-in 0.18s ease-out; } @keyframes ydm-sc-detail-in { from { opacity: 0; transform: translate(-50%, -46%) scale(0.96); } to { opacity: 1; transform: translate(-50%, -50%) scale(1); } } .ydm-sc-detail-header { display: flex; align-items: center; gap: 10px; min-width: 0; } .ydm-sc-detail-avatar { width: 40px; height: 40px; border-radius: 50%; object-fit: cover; flex: 0 0 auto; background: rgba(0, 0, 0, 0.2); } .ydm-sc-detail-meta { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 2px; } .ydm-sc-detail-username { font-size: 15px; font-weight: 700; line-height: 1.25; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .ydm-sc-detail-amount { font-size: 13px; font-weight: 800; opacity: 0.95; } .ydm-sc-detail-close { flex: 0 0 auto; width: 28px; height: 28px; margin: 0 0 0 auto; padding: 0; border: none; border-radius: 999px; display: inline-flex; align-items: center; justify-content: center; background: rgba(0, 0, 0, 0.2); color: inherit; cursor: pointer; } .ydm-sc-detail-close:hover { background: rgba(0, 0, 0, 0.32); } .ydm-sc-detail-close svg { width: 16px; height: 16px; display: block; fill: currentColor; } .ydm-sc-detail-body { font-size: 15px; font-weight: 500; line-height: 1.45; white-space: pre-wrap; word-break: break-word; max-height: 12em; overflow: auto; } .ydm-sc-detail-body .ydm-emote { vertical-align: -0.2em; } .ydm-sc-detail-translation { font-size: 13px; line-height: 1.4; opacity: 0.92; padding: 8px 10px; border-radius: 8px; background: rgba(0, 0, 0, 0.22); white-space: pre-wrap; word-break: break-word; } .ydm-sc-detail-translation.is-error { opacity: 0.8; } .ydm-sc-detail-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 2px; } .ydm-sc-detail-action { display: inline-flex; align-items: center; gap: 5px; margin: 0; padding: 6px 10px; border: none; border-radius: 8px; background: rgba(0, 0, 0, 0.22); color: inherit; font: inherit; font-size: 12px; font-weight: 600; cursor: pointer; line-height: 1.2; } .ydm-sc-detail-action:hover { background: rgba(0, 0, 0, 0.34); } .ydm-sc-detail-action:disabled { opacity: 0.55; cursor: wait; } .ydm-sc-detail-action svg { width: 14px; height: 14px; display: block; fill: currentColor; flex: 0 0 auto; } .ydm-click-lock-backdrop { position: absolute; inset: 0; z-index: 25; pointer-events: auto; background: transparent; } .ydm-danmaku-tooltip { display: flex; flex-direction: column; align-items: center; pointer-events: auto; filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.45)); max-width: min(280px, calc(100vw - 16px)); overflow: hidden; } .ydm-danmaku-tooltip-bubble { background: rgba(15, 15, 15, 0.96); border: 1px solid rgba(255, 255, 255, 0.12); border-radius: 8px; padding: 6px 8px; max-width: min(280px, 100%); min-height: 0; overflow-y: auto; user-select: text; } .ydm-danmaku-tooltip-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 6px; } .ydm-danmaku-tooltip-header--solo { justify-content: flex-end; } .ydm-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; } a.ydm-danmaku-tooltip-name { cursor: pointer; } a.ydm-danmaku-tooltip-name:hover { text-decoration: underline; } .ydm-danmaku-tooltip-actions { display: flex; flex-shrink: 0; gap: 2px; } .ydm-danmaku-tooltip-copy, .ydm-danmaku-tooltip-speak { 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: #e5e5e5; cursor: pointer; } .ydm-danmaku-tooltip-copy:hover, .ydm-danmaku-tooltip-speak:hover { background: rgba(255, 255, 255, 0.12); color: #fff; } .ydm-danmaku-tooltip-copy svg, .ydm-danmaku-tooltip-speak svg { width: clamp(14px, 3.5vw, 16px); height: clamp(14px, 3.5vw, 16px); fill: currentColor; display: block; } .ydm-danmaku-tooltip-original { margin-top: 4px; color: #aaa; font-size: 11px; line-height: 1.3; word-break: break-word; } .ydm-danmaku-tooltip-tail { width: 6px; height: 6px; flex-shrink: 0; background: rgba(15, 15, 15, 0.96); transform: rotate(45deg); } .ydm-danmaku-tooltip--above .ydm-danmaku-tooltip-tail { margin: -3px auto 0; border-right: 1px solid rgba(255, 255, 255, 0.12); border-bottom: 1px solid rgba(255, 255, 255, 0.12); } .ydm-danmaku-tooltip--below .ydm-danmaku-tooltip-bubble { order: 2; } .ydm-danmaku-tooltip--below .ydm-danmaku-tooltip-tail { order: 1; margin: 0 auto -3px; border-top: 1px solid rgba(255, 255, 255, 0.12); border-left: 1px solid rgba(255, 255, 255, 0.12); } @keyframes ydm-scroll { from { transform: translateX(var(--ydm-start, 0px)); } to { transform: translateX(calc(-1 * var(--ydm-travel, 100vw))); } } .ytp-right-controls > .ydm-controls-wrap { display: inline-flex !important; align-items: center !important; height: 100% !important; flex: 0 0 auto !important; margin: 0 !important; padding: 0 !important; } .ytp-time-display.ytp-live .ytp-time-wrapper.ydm-live-time-wrapper { white-space: nowrap; max-width: none; padding-right: 2px; } .ytp-time-display.ytp-live .ytp-time-wrapper.ydm-live-views-wrapper { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; max-width: none; margin-left: 2px; vertical-align: top; } .ytp-time-display.ytp-live .ytp-time-wrapper.ydm-live-views-wrapper[hidden] { display: none !important; } .ydm-live-views-icon { display: inline-flex; align-items: center; justify-content: center; line-height: 0; } .ydm-live-views-icon svg { width: 18px; height: 18px; fill: currentColor; display: block; } .ydm-live-views-count { font-variant-numeric: tabular-nums; letter-spacing: 0.01em; } .ydm-control-btn { display: inline-flex; align-items: center; justify-content: center; width: 40px; height: 100%; min-height: 36px; padding: 0; border: none; background: transparent; color: #fff; cursor: pointer; opacity: 0.9; flex-shrink: 0; } .ydm-control-btn:hover, .ydm-control-btn.ydm-active { opacity: 1; } .ydm-control-btn svg { width: 22px; height: 22px; fill: currentColor; display: block; } .ydm-danmaku-btn .ydm-icon { display: none; line-height: 0; } .ydm-danmaku-btn.ydm-danmaku-enabled .ydm-icon-on, .ydm-danmaku-btn.ydm-danmaku-disabled .ydm-icon-off { display: block; } .ydm-speech-btn .ydm-icon { display: none; line-height: 0; } .ydm-speech-btn.ydm-speech-on .ydm-icon-on, .ydm-speech-btn.ydm-speech-mute .ydm-icon-mute, .ydm-speech-btn.ydm-speech-off .ydm-icon-off { display: block; } .ydm-speech-btn.ydm-speech-mute { opacity: 0.7; } .ydm-speech-btn.ydm-speech-off { opacity: 0.45; } .ydm-danmaku-btn.ydm-danmaku-disabled { opacity: 0.45; } .ydm-balloon { background: #212121; color: #f1f1f1; border-radius: 12px; box-shadow: 0 4px 24px rgba(0, 0, 0, 0.45); font-family: Roboto, Arial, sans-serif; font-size: 14px; overflow: hidden; } .ydm-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); } .ydm-balloon-title { font-size: 14px; font-weight: 600; line-height: 1.4; } .ydm-balloon-close { background: none; border: none; color: #ff4e45; font-size: 13px; font-weight: 600; cursor: pointer; padding: 4px 6px; border-radius: 4px; } .ydm-balloon-close:hover { background: rgba(255, 78, 69, 0.12); } .ydm-balloon-body { padding: 4px 0 8px; max-height: min(60vh, 420px); overflow-y: auto; } .ydm-balloon-body--speech { max-height: min(70vh, 520px); } .ydm-balloon-section { padding: 6px 12px 0; color: #aaa; font-size: 12px; font-weight: 600; } .ydm-speech-modes { display: flex; gap: 8px; padding: 8px 12px; } .ydm-speech-mode { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 4px; padding: 8px 4px; border: 1px solid #3a3a3a; border-radius: 8px; background: #0f0f0f; color: #aaa; cursor: pointer; font-size: 12px; line-height: 1.2; } .ydm-speech-mode svg { width: 22px; height: 22px; fill: currentColor; display: block; } .ydm-speech-mode:hover, .ydm-speech-mode.ydm-active { color: #fff; border-color: #ff4e45; background: rgba(255, 78, 69, 0.12); } .ydm-speech-test { display: flex; gap: 6px; } .ydm-speech-test .ydm-balloon-input { flex: 1; min-width: 0; } .ydm-speech-play { flex-shrink: 0; min-width: 36px; border: none; border-radius: 8px; background: #3a3a3a; color: #f1f1f1; cursor: pointer; font-size: 12px; } .ydm-speech-play:hover { background: #4a4a4a; } .ydm-speech-note { margin: 0; padding: 0 12px 8px; color: #aaa; font-size: 12px; line-height: 1.4; } .ydm-balloon-row { padding: 8px 12px; } .ydm-balloon-row--toggle { display: flex; align-items: center; justify-content: space-between; } .ydm-balloon-row--field { display: flex; flex-direction: column; gap: 6px; } .ydm-balloon-field-header { display: flex; align-items: center; justify-content: space-between; } .ydm-balloon-label { color: #f1f1f1; font-size: 13px; line-height: 1.4; } .ydm-balloon-value { color: #aaa; font-size: 12px; } .ydm-balloon-range { width: 100%; accent-color: #ff4e45; height: 4px; } .ydm-balloon-select, .ydm-balloon-input { width: 100%; padding: 6px 8px; border: 1px solid #3a3a3a; border-radius: 8px; background: #0f0f0f; color: #f1f1f1; font-size: 13px; } .ydm-keyword-hint { color: #aaa; font-size: 12px; line-height: 1.4; } .ydm-keyword-add { display: flex; gap: 6px; } .ydm-keyword-add-btn { flex-shrink: 0; border: none; border-radius: 8px; background: #3a3a3a; color: #f1f1f1; padding: 0 10px; cursor: pointer; } .ydm-keyword-add-btn:hover { background: #4a4a4a; } .ydm-keyword-list { display: flex; flex-wrap: wrap; gap: 6px; } .ydm-keyword-list:empty { display: none; } .ydm-keyword-chip { display: inline-flex; align-items: center; gap: 2px; max-width: 100%; padding: 2px 2px 2px 8px; border-radius: 999px; background: #3a3a3a; color: #f1f1f1; font-size: 12px; } .ydm-keyword-chip > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .ydm-keyword-remove { width: 18px; height: 18px; padding: 0; border: none; border-radius: 50%; background: transparent; color: #aaa; cursor: pointer; line-height: 18px; } .ydm-keyword-remove:hover { color: #fff; background: rgba(255, 255, 255, 0.12); } .ydm-balloon-divider { height: 1px; margin: 4px 12px; background: rgba(255, 255, 255, 0.08); } .ydm-history-status { margin: 0; padding: 4px 12px 8px; color: #aaa; font-size: 12px; line-height: 1.45; min-height: 1.45em; } .ydm-history-status:empty { display: none; } .ydm-history-status--error { color: #ff8a80; } .ydm-history-actions { display: flex; flex-wrap: wrap; gap: 8px; margin: 0 12px 12px; } .ydm-history-download, .ydm-history-pause, .ydm-history-resume, .ydm-history-cancel { border: none; border-radius: 8px; padding: 8px 12px; font-weight: 600; cursor: pointer; } .ydm-history-download { flex: 1 1 100%; background: #ff4e45; color: #fff; } .ydm-history-pause, .ydm-history-resume { flex: 1 1 auto; background: #3ea6ff; color: #0f0f0f; } .ydm-history-cancel { flex: 1 1 auto; background: #272727; color: #fff; } .ydm-history-download:disabled, .ydm-history-pause:disabled, .ydm-history-resume:disabled, .ydm-history-cancel:disabled { opacity: 0.6; cursor: default; } .ydm-history-download[hidden], .ydm-history-pause[hidden], .ydm-history-resume[hidden], .ydm-history-cancel[hidden] { display: none; } .ydm-switch { position: relative; width: 40px; height: 22px; flex-shrink: 0; } .ydm-switch input { opacity: 0; width: 0; height: 0; } .ydm-switch-slider { position: absolute; inset: 0; background: #3a3a3a; border-radius: 11px; cursor: pointer; transition: background 0.2s; } .ydm-switch-slider::before { content: ''; position: absolute; width: 16px; height: 16px; left: 3px; bottom: 3px; background: #fff; border-radius: 50%; transition: transform 0.2s; } .ydm-switch input:checked + .ydm-switch-slider { background: #ff4e45; } .ydm-switch input:checked + .ydm-switch-slider::before { transform: translateX(18px); } .ydm-fav-panel { max-height: min(80vh, 580px); display: flex; flex-direction: column; } .ydm-fav-add-row { display: flex; gap: 8px; padding: 10px 12px 0; } .ydm-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; } .ydm-fav-add-btn, .ydm-fav-item-send, .ydm-fav-wheel-toggle { border: none; border-radius: 4px; background: #ff4e45; color: #fff; font-size: 12px; font-weight: 600; padding: 0 10px; height: 32px; cursor: pointer; white-space: nowrap; } .ydm-fav-add-btn:hover, .ydm-fav-item-send:hover, .ydm-fav-wheel-toggle:hover { background: #e53935; } .ydm-fav-wheel-toggle.is-running { background: #eb0400; } .ydm-fav-wheel-toggle.is-running:hover { background: #c50300; } .ydm-fav-list { margin-top: 10px; padding: 0 12px; overflow: auto; max-height: 240px; display: flex; flex-direction: column; gap: 8px; } .ydm-fav-empty { color: #adadb8; font-size: 12px; padding: 12px 0; text-align: center; } .ydm-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); } .ydm-fav-item.is-global { border-color: rgba(255, 78, 69, 0.45); } .ydm-fav-item-meta { font-size: 11px; font-weight: 600; color: #adadb8; } .ydm-fav-item.is-global .ydm-fav-item-meta { color: #ff8a80; } .ydm-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; } .ydm-fav-item-tools { display: flex; gap: 6px; align-items: center; } .ydm-fav-item-shortcut { height: 28px; border-radius: 4px; border: 1px solid rgba(255, 255, 255, 0.14); background: #18181b; color: #efeff1; font-size: 12px; } .ydm-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; } .ydm-fav-item-scope:hover { background: #ff4e45; } .ydm-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; } .ydm-fav-item-remove:hover { background: #ad1c1c; } .ydm-fav-status { min-height: 16px; margin: 6px 12px 0; color: #ff8280; font-size: 12px; } .ydm-fav-status.is-ok { color: #00f593; } .ydm-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; } .ydm-fav-wheel-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; } .ydm-fav-wheel-title { font-size: 13px; font-weight: 700; } .ydm-fav-wheel-grid { display: grid; grid-template-columns: 1fr; gap: 6px; } .ydm-fav-wheel-field { display: flex; align-items: center; justify-content: space-between; gap: 8px; color: #adadb8; font-size: 12px; } .ydm-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; } .ydm-fav-wheel-status { color: #adadb8; font-size: 12px; } .ydm-fs-chat { position: absolute !important; left: 50% !important; bottom: max(72px, 10%) !important; transform: translateX(-50%) !important; z-index: 100020 !important; width: min(560px, calc(100% - 48px)); max-width: calc(100% - 24px); display: flex !important; flex-direction: column; gap: 6px; pointer-events: auto !important; visibility: visible !important; opacity: 1 !important; font-family: "YouTube Sans", Roboto, Arial, sans-serif; } .ydm-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); } .ydm-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: #ff4e45; } .ydm-fs-chat-input::placeholder { color: #adadb8; } .ydm-fs-chat-input:focus { border-color: #ff4e45; box-shadow: 0 0 0 1px #ff4e45; } .ydm-fs-chat-fav-wrap { position: relative; flex-shrink: 0; } .ydm-fs-chat-fav, .ydm-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; } .ydm-fs-chat-fav { background: #3a3a3d; } .ydm-fs-chat-fav:hover, .ydm-fs-chat-fav.is-open { background: #53535f; } .ydm-fs-chat-fav svg, .ydm-fs-chat-send svg { width: 16px; height: 16px; display: block; flex-shrink: 0; } .ydm-fs-chat-send { background: #ff4e45; } .ydm-fs-chat-send:hover:not(:disabled) { background: #e53935; } .ydm-fs-chat-send:disabled { opacity: 0.45; cursor: default; } .ydm-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); } .ydm-fs-chat-fav-empty { padding: 10px 8px; color: #adadb8; font-size: 12px; line-height: 1.4; text-align: center; } .ydm-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%; } .ydm-fs-chat-fav-item:hover { background: rgba(255, 78, 69, 0.28); } .ydm-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; font-size: 13px; font-weight: 600; } .ydm-fs-chat-close:hover { background: #c50300; } .ydm-fs-chat-meta { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 0 4px; min-height: 16px; } .ydm-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; } .ydm-fs-chat-status:empty { display: none; } .ydm-fs-chat-status.is-ok { color: #00f593; } /* ---- chat frame / overlays (also injected into live chat iframe) ---- */ .ydm-chat-translation { display: flex; align-items: flex-start; gap: 6px; box-sizing: border-box; width: 100%; margin: 3px 0 1px; padding: 3px 0 2px 8px; border-left: 2px solid rgba(230, 33, 23, 1); background: rgba(155, 155, 155, 0.4); color: #fff; font-size: 12.5px; line-height: 1.4; font-weight: 400; font-style: italic; white-space: normal; word-break: break-word; } .ydm-chat-translation-label { flex: 0 0 auto; margin-top: 1px; padding: 0 5px; border-radius: 3px; background: rgba(230, 33, 23, 1); color: #fff; font-size: 10px; font-weight: 700; font-style: normal; letter-spacing: 0.02em; line-height: 1.5; text-transform: uppercase; } .ydm-chat-translation-text { flex: 1 1 auto; min-width: 0; color: #0f0f0f; } .ydm-chat-translation--pending .ydm-chat-translation-text { color: #606060; font-style: italic; } .ydm-chat-translation--error .ydm-chat-translation-text { color: #b71c1c; } .ydm-chat-menu { position: fixed; z-index: 2147483646; min-width: 160px; padding: 6px 0; border-radius: 8px; background: #212121; box-shadow: 0 4px 18px rgba(0, 0, 0, 0.45); color: #fff; font-family: Roboto, Arial, sans-serif; font-size: 13px; } .ydm-chat-menu-item { display: flex; align-items: center; gap: 10px; width: 100%; margin: 0; padding: 8px 14px; border: none; background: transparent; color: inherit; cursor: pointer; text-align: left; } .ydm-chat-menu-item:hover { background: rgba(255, 255, 255, 0.1); } .ydm-chat-menu-item svg { width: 18px; height: 18px; fill: currentColor; flex: 0 0 auto; } .ydm-sc-action-panel { display: flex; flex-wrap: wrap; gap: 6px; box-sizing: border-box; width: 100%; margin: 6px 0 2px; padding: 6px; border-radius: 8px; background: rgba(15, 15, 15, 0.72); flex-shrink: 0; position: relative; z-index: 2; } /* Paid sticker cards: panel sits after #card so it never steals author-name width. */ yt-live-chat-paid-sticker-renderer > .ydm-sc-action-panel, yt-live-chat-paid-message-renderer > .ydm-sc-action-panel { margin: 4px 8px 8px; width: auto; } .ydm-sc-action-btn { display: inline-flex; align-items: center; gap: 6px; margin: 0; padding: 6px 10px; border: none; border-radius: 16px; background: rgba(255, 255, 255, 0.12); color: #fff; font-size: 12px; font-weight: 600; line-height: 1.2; cursor: pointer; } .ydm-sc-action-btn:hover { background: rgba(255, 255, 255, 0.2); } .ydm-sc-action-btn:disabled { opacity: 0.55; cursor: default; } .ydm-sc-action-btn svg { width: 16px; height: 16px; fill: currentColor; flex: 0 0 auto; } .ydm-chat-clear-wrap { display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; margin: 0 2px 0 0; } .ydm-chat-clear-btn, .ydm-fav-btn { display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; box-sizing: border-box; width: 40px; height: 40px; margin: 0; padding: 8px; border: none; border-radius: 50%; background: transparent; color: var(--yt-live-chat-header-button-color, var(--yt-spec-text-secondary, #aaa)); cursor: pointer; transition: background-color 0.15s ease, color 0.15s ease; } .ydm-chat-clear-btn:hover { background-color: var(--yt-spec-text-primary, #f1f1f1); } .ydm-fav-btn { color: var(--yt-spec-text-secondary, #aaa); } .ydm-fav-btn:hover { background: var(--yt-spec-badge-chip-background, rgba(255, 255, 255, 0.1)); color: #ffca5f; } .ydm-chat-clear-btn svg, .ydm-fav-btn svg { width: 24px; height: 24px; display: block; pointer-events: none; fill: currentColor; } .ydm-pip-btn.ydm-active { color: #ff4e45; } .ydm-pip-page-toast { position: absolute; left: 50%; bottom: 72px; transform: translateX(-50%); z-index: 80; max-width: min(440px, calc(100% - 24px)); padding: 8px 14px; border-radius: 8px; background: rgba(0, 0, 0, 0.82); color: #fff; font: 13px/1.35 Roboto, Arial, sans-serif; text-align: center; opacity: 0; pointer-events: none; transition: opacity 0.2s ease; } body > .ydm-pip-page-toast { position: fixed; bottom: 24px; z-index: 10050; } .ydm-pip-page-toast.is-show { opacity: 1; } `; function injectStyles() { GM_addStyle(YDM_CSS); } /** Inject the same stylesheet into the live chat iframe document (Trusted Types safe). */ function ensureChatDocStyles(doc) { if (!doc) return; let style = doc.getElementById(YDM_CHAT_STYLE_ID); if (!style) { style = doc.createElement('style'); style.id = YDM_CHAT_STYLE_ID; (doc.head || doc.documentElement).appendChild(style); } if (style.textContent !== YDM_CSS) style.textContent = YDM_CSS; } /** @deprecated Use ensureChatDocStyles */ function ensureChatTranslateStyles(doc) { ensureChatDocStyles(doc); } const ICON_DANMAKU_ON = ``; const ICON_DANMAKU_OFF = ``; const ICON_SETTINGS = ``; const ICON_COPY = ``; const ICON_SPEECH_ON = ``; const ICON_SPEECH_MUTE = ``; const ICON_SPEECH_OFF = ``; const ICON_DOWNLOAD = ``; const ICON_EYE = ``; const ICON_TRANSLATE = ``; 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_FAVORITE = ``; const ICON_CLOSE = ``; const ICON_PROFILE = ``; const ICON_PIP = ``; const ICON_PIP_RELOAD = ``; const ICON_PIP_ASPECT_UNLOCK = ``; const ICON_PIP_ASPECT_LOCK = ``; 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; } } 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 .filter((part) => part?.type === 'text' && part.text) .map((part) => part.text) .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; } function appendDanmakuContent(item, message, parts) { if (!Array.isArray(parts) || !parts.length) { item.textContent = message || ''; return; } for (const part of parts) { if (part?.type === 'emote') { const src = safeEmoteSrc(part.src); const fallback = emoteDisplayText(part); const title = fallback || (part.shortcode && !isEmoteShortcode(fallback) ? part.shortcode : '') || String(part.alt || '').trim(); if (!src) { if (fallback) item.appendChild(document.createTextNode(fallback)); continue; } const slot = document.createElement('span'); slot.className = 'ydm-emote'; slot.style.display = 'inline-block'; slot.style.width = '1.15em'; slot.style.height = '1.15em'; const img = document.createElement('img'); // Empty alt so a broken/late-loading image does not flash ":clapping_hands:". img.alt = ''; if (title && !isEmoteShortcode(title)) img.title = title; else if (fallback) img.title = fallback; img.src = src; img.draggable = false; img.referrerPolicy = 'no-referrer'; img.style.width = '100%'; img.style.height = '100%'; img.style.fontSize = '0'; img.addEventListener('error', () => { img.remove(); if (fallback) slot.textContent = fallback; else if (!slot.childNodes.length) slot.remove(); }, { once: true }); slot.appendChild(img); item.appendChild(slot); continue; } if (part?.text) { // Drop pure shortcode leftovers if any slipped into text parts. if (EMOTE_SHORTCODE_ONLY_RE.test(part.text)) continue; item.appendChild(document.createTextNode(part.text)); } } if (!item.childNodes.length) item.textContent = message || ''; } function normalizeScAmountKey(value) { return String(value || '').replace(/\s+/g, '').toLowerCase(); } function normalizeScAvatarKey(value) { return String(value || '').replace(/=s\d+.*$/i, '').split('?')[0]; } function readTickerDurationSec(el) { if (!el) return 0; const readers = [ () => el.durationSec, () => el.fullDurationSec, () => el.__data?.durationSec, () => el.__data?.fullDurationSec, () => el.__data?.data?.durationSec, () => el.__data?.data?.fullDurationSec, () => el.data?.durationSec, () => el.data?.fullDurationSec, ]; for (const read of readers) { try { const value = Number(read()); if (Number.isFinite(value) && value > 0) return value; } catch (_) { /* polymer access may throw */ } } return 0; } function readTickerAmountText(el) { if (!el) return ''; const readers = [ () => el.__data?.data?.amount?.simpleText, () => el.__data?.amount?.simpleText, () => el.data?.amount?.simpleText, () => el.querySelector?.('#diagonal-amount, #amount')?.textContent, ]; for (const read of readers) { try { const value = String(read() || '').replace(/\s+/g, ' ').trim(); if (value) return value; } catch (_) { /* ignore */ } } return ''; } function findYoutubePaidTicker(payload) { const doc = typeof getChatDocument === 'function' ? getChatDocument() : null; if (!doc) return null; const amountKey = normalizeScAmountKey(payload?.amount); const avatarKey = normalizeScAvatarKey(payload?.avatarUrl); const nodes = [ ...doc.querySelectorAll( 'yt-live-chat-ticker-paid-message-item-renderer, yt-live-chat-ticker-paid-sticker-item-renderer', ), ]; let fallback = null; for (const el of nodes) { if (el._ydmBoundPin) continue; const elAmount = normalizeScAmountKey(readTickerAmountText(el) || el.textContent); const elAvatar = normalizeScAvatarKey( el.querySelector?.('#author-photo img, yt-img-shadow#author-photo img')?.currentSrc || el.querySelector?.('#author-photo img, yt-img-shadow#author-photo img')?.getAttribute?.('src') || '', ); const amountMatch = amountKey && elAmount && (elAmount === amountKey || elAmount.includes(amountKey) || amountKey.includes(elAmount)); const avatarMatch = avatarKey && elAvatar && elAvatar === avatarKey; if (amountMatch && avatarMatch) return el; if (amountMatch && !fallback) fallback = el; if (!amountKey && avatarMatch && !fallback) fallback = el; } return fallback; } class DanmakuEngine { constructor(getSettings) { this.getSettings = getSettings; this.laneManager = new LaneManager(); this.layer = null; this.superchatLayer = null; this.pinnedItems = new Set(); this.superchatQueue = []; this.playerEl = null; this.overlayEl = null; this.resizeObserver = null; this.controlsObserver = null; this.controlsHeight = SCROLL_AREA_BOTTOM_RESERVE; this.detailCard = null; this.detailBackdrop = null; this.detailSourceItem = null; this.boundUpdate = () => this.updateLayout(); } insertLayer() { const overlay = this.overlayEl || this.playerEl; overlay.appendChild(this.layer); if (this.superchatLayer) overlay.appendChild(this.superchatLayer); } async mount() { this.playerEl = await waitForElement(SELECTORS.player); if (!this.playerEl) { console.warn('[YDM] Video player not found'); return; } this.overlayEl = this.playerEl.querySelector('.html5-video-container') || this.playerEl; if (getComputedStyle(this.overlayEl).position === 'static') { this.overlayEl.style.position = 'relative'; } this.layer = document.createElement('div'); this.layer.className = 'ydm-danmaku-layer'; this.layer.setAttribute('aria-hidden', 'true'); this.superchatLayer = document.createElement('div'); this.superchatLayer.className = 'ydm-superchat-layer'; this.superchatLayer.setAttribute('aria-hidden', 'true'); this.insertLayer(); this.resizeObserver = new ResizeObserver(this.boundUpdate); this.resizeObserver.observe(this.playerEl); const chrome = this.playerEl.querySelector(SELECTORS.playerChrome); if (chrome) { this.controlsObserver = new ResizeObserver(this.boundUpdate); this.controlsObserver.observe(chrome); } 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(); this.closeSuperchatDetail(); hideDanmakuTooltip(); this.layer?.remove(); this.superchatLayer?.remove(); this.layer = null; this.superchatLayer = null; this.pinnedItems = new Set(); this.superchatQueue = []; this.playerEl = null; this.overlayEl = null; } getLaneHeight() { return this.getSettings().fontSize + 8; } getItemOpacity() { return clamp(this.getSettings().opacity ?? 100, 10, 100) / 100; } getBottomReserve() { const player = this.playerEl; const controls = player?.querySelector(SELECTORS.playerChrome); const autohide = player?.classList.contains('ytp-autohide'); if (controls && !autohide) { const height = controls.getBoundingClientRect().height; if (height > 8) this.controlsHeight = Math.ceil(height) + 4; } return this.controlsHeight; } getPinnedRowHeight() { if (this.getSettings().superchatCompact) { return Math.max(28, Math.round(this.getSettings().fontSize * 0.9) + 10); } return this.getLaneHeight() + 12; } getPinnedRowGap() { return 6; } getMaxPinnedSuperchats(playerHeight = this.playerEl?.clientHeight || 480) { const row = this.getPinnedRowHeight() + this.getPinnedRowGap(); const topPad = 16; const usable = Math.max(row, playerHeight - this.getBottomReserve() - topPad); return clamp(Math.floor(usable / row), 1, SUPERCHAT_MAX_PINNED); } getPinnedBandHeight(count = this.pinnedItems.size) { if (count <= 0) return 0; const gap = this.getPinnedRowGap(); const row = this.getPinnedRowHeight(); let measured = 0; let total = 0; for (const el of this.pinnedItems) { const height = el?.offsetHeight || 0; if (height > 0) { total += height; measured += 1; } } if (measured > 0) { total += Math.max(0, count - measured) * row; total += Math.max(0, count - 1) * gap; // Extra room so slide-in / shadows are not clipped at the top edge. return total + 16; } return count * row + Math.max(0, count - 1) * gap + 16; } resolveSuperchatPlacement() { const raw = String(this.getSettings().superchatPosition || 'top'); const map = { top: { edge: 'top', align: 'center' }, 'top-left': { edge: 'top', align: 'left' }, topleft: { edge: 'top', align: 'left' }, bottom: { edge: 'bottom', align: 'center' }, 'bottom-left': { edge: 'bottom', align: 'left' }, bottom_left: { edge: 'bottom', align: 'left' }, 'bottom-right': { edge: 'bottom', align: 'right' }, bottomRight: { edge: 'bottom', align: 'right' }, bottom_right: { edge: 'bottom', align: 'right' }, }; return map[raw] || map.top; } getPinnedBandRect(playerHeight) { if (!this.pinnedItems.size || !this.getSettings().superchatPinned) return null; let band = this.getPinnedBandHeight(); const { edge, align } = this.resolveSuperchatPlacement(); const maxHeight = Math.max( this.getPinnedRowHeight() + 16, playerHeight - this.getBottomReserve() - 4, ); // Never grow past the player; max-count already limits how many we mount. band = Math.min(band, maxHeight); const top = edge === 'bottom' ? Math.max(0, playerHeight - this.getBottomReserve() - band) : 4; return { pos: edge, align, top, height: band }; } clearPinnedSuperchats() { this.closeSuperchatDetail(); this.superchatQueue = []; for (const item of this.pinnedItems) { item._ydmClearFallback?.(); item._ydmTickerCleanup?.(); if (item._ydmTickerEl && item._ydmTickerEl._ydmBoundPin === item) { delete item._ydmTickerEl._ydmBoundPin; } item.remove(); } this.pinnedItems.clear(); } getScrollAreaRect(playerRect) { const laneHeight = this.getLaneHeight(); const h = playerRect.height; const usable = Math.max(laneHeight, h - this.getBottomReserve()); let area; switch (this.getSettings().scrollArea) { case 'top-half': area = { top: 0, height: Math.max(laneHeight, usable * 0.5) }; break; case 'bottom-half': { const height = Math.max(laneHeight, usable * 0.5); area = { top: Math.max(0, usable - height), height }; break; } case 'top-quarter': area = { top: 0, height: Math.max(laneHeight, usable * 0.25) }; break; default: area = { top: 0, height: usable }; break; } // When overlay is on, pinned Super Chats may cover scrolling lines. if (this.getSettings().superchatOverlayDanmaku) return area; const band = this.getPinnedBandRect(h); if (!band) return area; const areaBottom = area.top + area.height; const bandBottom = band.top + band.height; if (bandBottom <= area.top || band.top >= areaBottom) return area; if (band.pos === 'top') { const nextTop = Math.max(area.top, bandBottom + 4); return { top: nextTop, height: Math.max(laneHeight, areaBottom - nextTop) }; } const nextBottom = Math.min(areaBottom, band.top - 4); return { top: area.top, height: Math.max(laneHeight, nextBottom - area.top) }; } migrateDanmakuItems(items, oldRect, newWidth, newHeight) { const oldWidth = oldRect.width || 1; const oldHeight = oldRect.height || 1; for (const item of items) { if (!item.isConnected) continue; const top = parseFloat(item.style.top) || 0; item.style.top = `${(top / oldHeight) * newHeight}px`; const oldTravel = parseFloat(item.style.getPropertyValue('--ydm-travel')) || oldWidth; const oldDuration = parseFloat(item.style.getPropertyValue('--ydm-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._ydmClearRemoveTimer?.(); const wasPaused = item.classList.contains('ydm-paused'); if (wasPaused) item.classList.remove('ydm-paused'); item.style.setProperty('--ydm-travel', `${newTravel}px`); item.style.setProperty('--ydm-start', `${-progress * newTravel}px`); item.style.setProperty('--ydm-duration', `${remaining}ms`); item.style.animation = 'none'; void item.offsetWidth; item.style.animation = ''; if (wasPaused) item.classList.add('ydm-paused'); item._ydmScheduleRemove?.(remaining); } } updateLayout() { if (!this.layer || !this.playerEl) return; const settings = this.getSettings(); if (!settings.superchatPinned || !settings.enabled) { this.clearPinnedSuperchats(); } const rect = this.playerEl.getBoundingClientRect(); const area = this.getScrollAreaRect(rect); const oldRect = this.layer.getBoundingClientRect(); const items = [...this.layer.querySelectorAll('.ydm-danmaku-item')]; const shouldMigrate = items.length > 0; if (shouldMigrate) hideDanmakuTooltip(); if (this.layer.parentElement !== this.overlayEl) this.insertLayer(); Object.assign(this.layer.style, { position: 'absolute', top: `${area.top}px`, left: '0', width: '100%', height: `${area.height}px`, zIndex: '2', }); const newWidth = this.layer.clientWidth; const newHeight = this.layer.clientHeight; if (shouldMigrate && ( Math.abs(oldRect.width - newWidth) > 1 || Math.abs(oldRect.height - newHeight) > 1 )) { this.migrateDanmakuItems(items, oldRect, newWidth, newHeight); } const laneHeight = this.getLaneHeight(); this.laneManager.reset(Math.floor(newHeight / laneHeight), laneHeight); this.layer.style.display = this.getSettings().enabled ? 'block' : 'none'; this.layoutSuperchat(rect); const opacity = this.getItemOpacity(); for (const item of items) { if (item.isConnected) item.style.opacity = String(opacity); } } layoutSuperchat(playerRect) { if (!this.superchatLayer) return; const band = this.getPinnedBandRect(playerRect.height); if (!band) { this.superchatLayer.style.display = 'none'; return; } const alignItems = band.align === 'left' ? 'flex-start' : band.align === 'right' ? 'flex-end' : 'center'; this.superchatLayer.classList.toggle('ydm-sc-bottom', band.pos === 'bottom'); this.superchatLayer.classList.toggle('ydm-sc-align-left', band.align === 'left'); this.superchatLayer.classList.toggle('ydm-sc-align-right', band.align === 'right'); // Sit above scrolling danmaku when overlay is enabled. const zIndex = this.getSettings().superchatOverlayDanmaku ? '6' : '4'; Object.assign(this.superchatLayer.style, { display: 'flex', flexDirection: 'column', alignItems, // Keep the oldest (top) pins visible; newer pins stack downward / upward. justifyContent: band.pos === 'bottom' ? 'flex-end' : 'flex-start', gap: `${this.getPinnedRowGap()}px`, position: 'absolute', top: `${band.top}px`, left: '0', width: '100%', height: `${band.height}px`, zIndex, // Visible so entrance animation / multi-row stacks are not clipped. overflow: 'visible', boxSizing: 'border-box', padding: band.align === 'center' ? '8px 0 4px' : '8px 10px 4px', pointerEvents: 'none', }); } notifyPipComment(payload) { if (!this.onComment || !payload) return; const parts = Array.isArray(payload.parts) ? payload.parts : []; const hasEmote = parts.some((part) => part?.type === 'emote' && part.src); if (!payload.message && !hasEmote) return; try { this.onComment(payload); } catch (error) { console.warn('[YDM] PiP danmaku hook failed', error); } } render(payload) { const settings = this.getSettings(); if (payload.superchat && settings.superchatPinned) { this.queueSuperchat(payload); if (settings.enabled) this.notifyPipComment(payload); return; } this.renderScrolling(payload); } queueSuperchat(payload) { if (!this.superchatLayer || !this.getSettings().enabled) return; if (this.pinnedItems.size >= this.getMaxPinnedSuperchats()) { this.superchatQueue.push(payload); const maxQueued = Math.max(6, this.getMaxPinnedSuperchats() * 2); if (this.superchatQueue.length > maxQueued) this.superchatQueue.shift(); return; } this.mountSuperchat(payload); } drainSuperchatQueue() { if (!this.superchatLayer || !this.getSettings().enabled || !this.getSettings().superchatPinned) { this.superchatQueue = []; return; } while (this.superchatQueue.length && this.pinnedItems.size < this.getMaxPinnedSuperchats()) { const next = this.superchatQueue.shift(); if (!next) break; this.mountSuperchat(next, { skipLayout: true }); } this.updateLayout(); } mountSuperchat(payload, { skipLayout = false } = {}) { const { username, message, parts, bodyParts, amount, usernameColor, profileUrl, avatarUrl, originalMessage, translated, superchatStyle, } = payload || {}; const settings = this.getSettings(); const compact = !!settings.superchatCompact; const rawParts = Array.isArray(bodyParts) && bodyParts.length ? bodyParts : (Array.isArray(parts) ? parts : []); const displayParts = stripAmountFromParts(rawParts, amount); const hasEmote = displayParts.some((part) => part.type === 'emote' && part.src); const bodyText = stripAmountFromText( displayParts.map((part) => ( part?.type === 'emote' ? (part.alt ? ` ${part.alt} ` : ' ') : (part?.text || '') )).join('').replace(/\s+/g, ' ').trim() || String(message || ''), amount, ); if (!this.superchatLayer || (!bodyText && !hasEmote && !username && !amount)) return; const animation = ['fade', 'slide', 'pop', 'shine'].includes(settings.superchatAnimation) ? settings.superchatAnimation : 'slide'; const followYoutube = !!settings.superchatDurationFollowYoutube; const manualDuration = clamp(Number(settings.superchatDuration) || 15, 2, 300); const ticker = followYoutube ? findYoutubePaidTicker({ amount, avatarUrl }) : null; const tickerDuration = ticker ? readTickerDurationSec(ticker) : 0; const followTicker = !!(followYoutube && ticker?.isConnected); // Prefer ticker durationSec; if following ticker without a readable value, keep // the pin opaque until the ticker leaves (long CSS lifetime + removal observer). const duration = followTicker && tickerDuration > 0 ? clamp(tickerDuration, 2, 3600) : followTicker ? Math.max(manualDuration, 120) : manualDuration; // Match YouTube SC card scale: slightly smaller than scrolling danmaku. const baseSize = clamp(Math.round(settings.fontSize * 0.82), 13, 28); const item = document.createElement('div'); item.className = `ydm-superchat-item ydm-sc-${animation}${compact ? ' ydm-sc-compact' : ''}`; item.style.setProperty('--ydm-sc-duration', `${duration}s`); item.style.background = safeCssColor(superchatStyle?.background || '#e53935'); item.style.color = safeCssColor(superchatStyle?.color || '#ffffff'); item.style.fontSize = `${baseSize}px`; item.style.fontFamily = settings.fontFamily; item.style.zIndex = String(10 + this.pinnedItems.size); item.style.pointerEvents = 'auto'; const detailPayload = { username: username || '', message: message || '', bodyText, displayParts, amount: amount || '', usernameColor, profileUrl: profileUrl || '', avatarUrl: avatarUrl || '', originalMessage: originalMessage || message || bodyText || '', translated: !!translated, superchatStyle: superchatStyle || null, }; item._ydmScPayload = detailPayload; const avatarSrc = safeEmoteSrc(avatarUrl); if (avatarSrc) { const avatar = document.createElement('img'); avatar.className = 'ydm-sc-avatar'; avatar.src = avatarSrc; avatar.alt = ''; avatar.draggable = false; avatar.referrerPolicy = 'no-referrer'; item.appendChild(avatar); } if (username) { const name = document.createElement('span'); name.className = compact ? 'ydm-sc-username ydm-sc-username-compact' : 'ydm-sc-username'; name.textContent = username; name.title = username; name.style.color = safeCssColor(superchatStyle?.color || '#ffffff'); item.appendChild(name); } if (amount) { const amt = document.createElement('span'); amt.className = compact ? 'ydm-sc-amount ydm-sc-amount-badge' : 'ydm-sc-amount'; amt.textContent = amount; amt.style.color = safeCssColor(superchatStyle?.color || '#ffffff'); item.appendChild(amt); } let messageClip = null; let messageTrack = null; if (!compact && (bodyText || hasEmote)) { messageClip = document.createElement('span'); messageClip.className = 'ydm-sc-message-clip'; messageTrack = document.createElement('span'); messageTrack.className = 'ydm-sc-message'; messageTrack.style.color = safeCssColor(superchatStyle?.bodyColor || superchatStyle?.color || '#ffffff'); appendDanmakuContent(messageTrack, bodyText, displayParts); messageClip.appendChild(messageTrack); item.appendChild(messageClip); } const closeBtn = document.createElement('button'); closeBtn.type = 'button'; closeBtn.className = 'ydm-sc-close'; closeBtn.setAttribute('aria-label', t('superchatClose')); closeBtn.setAttribute('title', t('superchatClose')); appendMarkup(closeBtn, ICON_CLOSE); item.appendChild(closeBtn); this.superchatLayer.appendChild(item); this.pinnedItems.add(item); if (!skipLayout) { this.updateLayout(); // Remeasure after paint so multi-row stacks get a tall enough band. requestAnimationFrame(() => this.updateLayout()); } if (!compact) this.armSuperchatMarquee(item, messageClip, messageTrack, duration); requestSpeech(speechSourceText(bodyText || message, displayParts)); let fallback = null; const armFallback = (ms) => { clearTimeout(fallback); fallback = setTimeout(() => this.finishSuperchat(item), ms); }; item._ydmArmFallback = armFallback; item._ydmClearFallback = () => clearTimeout(fallback); item._ydmTickerCleanup = null; if (followTicker) { ticker._ydmBoundPin = item; item._ydmTickerEl = ticker; const host = ticker.parentElement || ticker.ownerDocument?.body; if (host) { const observer = new MutationObserver(() => { if (ticker.isConnected) return; observer.disconnect(); clearTimeout(fallback); this.finishSuperchat(item); }); observer.observe(host, { childList: true, subtree: true }); item._ydmTickerCleanup = () => { observer.disconnect(); if (ticker._ydmBoundPin === item) delete ticker._ydmBoundPin; }; } } // When following YouTube without a readable durationSec, wait on ticker removal // and keep a generous fallback so CSS animation does not dismiss early. const lifetimeMs = followTicker && !(tickerDuration > 0) ? Math.max(manualDuration, 120) * 1000 : (duration + 2) * 1000; armFallback(lifetimeMs); item.addEventListener('animationend', (event) => { if (event.target !== item) return; if (event.animationName === 'ydm-sc-shine-sweep' || event.animationName === 'ydm-sc-marquee') return; // Ticker-synced pins dismiss when the ticker leaves (or fallback fires). if (followTicker) return; clearTimeout(fallback); this.finishSuperchat(item); }); closeBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); clearTimeout(fallback); this.finishSuperchat(item); }); if (compact) { item.addEventListener('click', (e) => { if (e.target.closest('.ydm-sc-close')) return; e.preventDefault(); e.stopPropagation(); this.openSuperchatDetail(item); }); item.addEventListener('pointerenter', () => clearTimeout(fallback)); item.addEventListener('pointerleave', () => { if (this.detailSourceItem === item) return; armFallback(followTicker ? lifetimeMs : 20000); }); } else { attachDanmakuInteraction(item, { username, usernameColor, profileUrl, originalMessage, translated, onPause: () => clearTimeout(fallback), onResume: () => armFallback(followTicker ? lifetimeMs : 20000), }); } } closeSuperchatDetail() { const source = this.detailSourceItem; this.detailBackdrop?.remove(); this.detailCard?.remove(); this.detailBackdrop = null; this.detailCard = null; this.detailSourceItem = null; if (source?.isConnected) { source.classList.remove('ydm-sc-detail-open', 'ydm-paused'); source._ydmArmFallback?.(20000); } } openSuperchatDetail(item) { if (!item?.isConnected) return; const payload = item._ydmScPayload; if (!payload) return; this.closeSuperchatDetail(); hideDanmakuTooltip?.(); item._ydmClearFallback?.(); item.classList.add('ydm-sc-detail-open', 'ydm-paused'); this.detailSourceItem = item; const host = this.playerEl || this.overlayEl || document.body; if (getComputedStyle(host).position === 'static') host.style.position = 'relative'; const backdrop = document.createElement('div'); backdrop.className = 'ydm-sc-detail-backdrop'; backdrop.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); this.closeSuperchatDetail(); }); const card = document.createElement('div'); card.className = 'ydm-sc-detail-card'; card.setAttribute('role', 'dialog'); card.setAttribute('aria-modal', 'true'); const bg = safeCssColor(payload.superchatStyle?.background || '#e53935'); const fg = safeCssColor(payload.superchatStyle?.color || '#ffffff'); const bodyFg = safeCssColor(payload.superchatStyle?.bodyColor || fg); card.style.background = bg; card.style.color = fg; const header = document.createElement('div'); header.className = 'ydm-sc-detail-header'; const avatarSrc = safeEmoteSrc(payload.avatarUrl); if (avatarSrc) { const avatar = document.createElement('img'); avatar.className = 'ydm-sc-detail-avatar'; avatar.src = avatarSrc; avatar.alt = ''; avatar.draggable = false; avatar.referrerPolicy = 'no-referrer'; header.appendChild(avatar); } const meta = document.createElement('div'); meta.className = 'ydm-sc-detail-meta'; if (payload.username) { const name = document.createElement('div'); name.className = 'ydm-sc-detail-username'; name.textContent = payload.username; meta.appendChild(name); } if (payload.amount) { const amt = document.createElement('div'); amt.className = 'ydm-sc-detail-amount'; amt.textContent = payload.amount; meta.appendChild(amt); } header.appendChild(meta); const closeBtn = document.createElement('button'); closeBtn.type = 'button'; closeBtn.className = 'ydm-sc-detail-close'; closeBtn.setAttribute('aria-label', t('superchatDetailClose')); closeBtn.setAttribute('title', t('superchatDetailClose')); appendMarkup(closeBtn, ICON_CLOSE); closeBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); this.closeSuperchatDetail(); }); header.appendChild(closeBtn); const body = document.createElement('div'); body.className = 'ydm-sc-detail-body'; body.style.color = bodyFg; if (payload.bodyText || payload.displayParts?.length) { appendDanmakuContent(body, payload.bodyText, payload.displayParts); } else { body.textContent = payload.originalMessage || ''; } const translation = document.createElement('div'); translation.className = 'ydm-sc-detail-translation'; translation.hidden = true; const actions = document.createElement('div'); actions.className = 'ydm-sc-detail-actions'; const makeAction = (icon, label, onClick) => { const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'ydm-sc-detail-action'; appendMarkup(btn, icon); const span = document.createElement('span'); span.textContent = label; btn.appendChild(span); btn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); onClick(btn); }); actions.appendChild(btn); return btn; }; const settings = this.getSettings(); makeAction(ICON_TRANSLATE, t('chatActionTranslate'), async (btn) => { if (btn.disabled) return; btn.disabled = true; translation.hidden = false; translation.textContent = t('chatActionTranslating'); translation.classList.remove('is-error'); try { const source = payload.bodyText || payload.originalMessage || ''; const outcome = await translateMessagesManual( source, settings.translateTargetLang, settings.translateProvider, { bypassCache: false }, ); const text = String(outcome || '').trim(); if (!text || text === source) { translation.textContent = t('chatActionTranslateFailed'); translation.classList.add('is-error'); return; } translation.textContent = text; translation.dataset.translated = text; } catch (_) { translation.textContent = t('chatActionTranslateFailed'); translation.classList.add('is-error'); } finally { btn.disabled = false; } }); makeAction(ICON_SPEECH_ON, t('chatActionSpeak'), () => { const spoken = translation.dataset.translated || payload.bodyText || payload.originalMessage || ''; speakTextNow(spoken); }); makeAction(ICON_FAVORITE, t('chatActionFavorite'), () => { const text = String(payload.bodyText || payload.originalMessage || '').trim(); if (text) addFavoriteText(text); }); if (payload.profileUrl) { makeAction(ICON_PROFILE, t('chatActionOpenProfile'), () => { window.open(payload.profileUrl, '_blank', 'noopener,noreferrer'); }); } card.append(header, body, translation, actions); card.addEventListener('click', (e) => e.stopPropagation()); card.addEventListener('pointerdown', (e) => e.stopPropagation()); const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); this.closeSuperchatDetail(); } }; document.addEventListener('keydown', onKey, true); const prevRemove = card.remove.bind(card); card.remove = () => { document.removeEventListener('keydown', onKey, true); prevRemove(); }; host.append(backdrop, card); this.detailBackdrop = backdrop; this.detailCard = card; } armSuperchatMarquee(item, clip, track, pinDuration) { if (!item || !clip || !track) return; const measure = () => { if (!item.isConnected || !clip.isConnected || !track.isConnected) return; const overflow = track.scrollWidth - clip.clientWidth; if (overflow <= 4) { item.classList.remove('ydm-sc-marquee'); track.style.removeProperty('--ydm-sc-marquee-distance'); track.style.removeProperty('--ydm-sc-marquee-duration'); return; } // Keep avatar/amount fixed; scroll only the message text inside the clip. const scrollSeconds = clamp(overflow / 36, 2.5, Math.max(3, pinDuration - 0.8)); item.classList.add('ydm-sc-marquee'); track.style.setProperty('--ydm-sc-marquee-distance', `-${Math.ceil(overflow)}px`); track.style.setProperty('--ydm-sc-marquee-duration', `${scrollSeconds}s`); }; requestAnimationFrame(() => requestAnimationFrame(measure)); } finishSuperchat(item) { if (!item) return; if (!this.pinnedItems.has(item) && !item.isConnected) return; if (this.detailSourceItem === item) this.closeSuperchatDetail(); item._ydmClearFallback?.(); item._ydmTickerCleanup?.(); item._ydmTickerCleanup = null; if (item._ydmTickerEl && item._ydmTickerEl._ydmBoundPin === item) { delete item._ydmTickerEl._ydmBoundPin; } item._ydmTickerEl = null; this.pinnedItems.delete(item); item.remove(); if (this.superchatQueue.length && this.getSettings().superchatPinned && this.getSettings().enabled) { this.drainSuperchatQueue(); return; } this.updateLayout(); } renderScrolling({ username, message, parts, usernameColor, profileUrl, originalMessage, translated }) { const settings = this.getSettings(); const hasEmote = Array.isArray(parts) && parts.some((part) => part.type === 'emote' && part.src); if (!settings.enabled || (!message && !hasEmote)) return; this.notifyPipComment({ username, message, parts, usernameColor, profileUrl, originalMessage, translated }); if (!this.layer) return; const layerWidth = this.layer.clientWidth || 800; const speed = clamp(settings.speed, 1, 10); const baseDuration = 30000; const laneHeight = this.getLaneHeight(); const item = document.createElement('div'); item.className = 'ydm-danmaku-item'; appendDanmakuContent(item, message, parts); item.style.top = '0'; item.style.color = safeCssColor(usernameColor); item.style.fontSize = `${settings.fontSize}px`; item.style.fontFamily = settings.fontFamily; item.style.opacity = String(this.getItemOpacity()); item.style.visibility = 'hidden'; item.style.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); item.style.top = `${lane * laneHeight}px`; item.style.visibility = ''; item.style.setProperty('--ydm-travel', `${travel}px`); item.style.setProperty('--ydm-start', '0px'); item.style.setProperty('--ydm-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._ydmClearRemoveTimer = () => clearTimeout(removeTimer); item._ydmScheduleRemove = scheduleRemove; const onPause = () => { pausedAt = Date.now(); clearTimeout(removeTimer); }; const onResume = () => { if (pausedAt) { totalPaused += Date.now() - pausedAt; pausedAt = 0; } scheduleRemove(); }; requestSpeech(speechSourceText(message, parts)); attachDanmakuInteraction(item, { username, usernameColor, profileUrl, originalMessage, translated, onPause, onResume, }); 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 = 'ydm-click-lock-backdrop'; clickLockBackdrop.addEventListener('pointerdown', (e) => { const otherDanmaku = document.elementsFromPoint(e.clientX, e.clientY) .find((el) => (el.classList?.contains('ydm-danmaku-item') || el.classList?.contains('ydm-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); window.removeEventListener('scroll', tooltipRepositionHandler, true); document.removeEventListener('fullscreenchange', tooltipRepositionHandler); document.removeEventListener('webkitfullscreenchange', tooltipRepositionHandler); tooltipRepositionHandler = null; } removeClickLockBackdrop(); const resumeFn = activeTooltipItem?._ydmResume; activeTooltipItem?.classList.remove('ydm-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?.('.ydm-danmaku-item, .ydm-superchat-item') === activeTooltipItem) return; if (clickLockBackdrop?.contains(e.target)) return; hideDanmakuTooltip(); }, true); } function getVideoBounds() { const player = getPlayerElement(); const video = player?.querySelector('video.html5-main-video') || player; const rect = video?.getBoundingClientRect(); if (!rect || rect.width < 16 || rect.height < 16) return null; return rect; } function getTooltipHost() { return getFullscreenElement() || document.body; } function positionDanmakuTooltip(item, tooltip) { const bounds = getVideoBounds(); if (!bounds) return; const host = getTooltipHost(); if (tooltip.parentElement !== host) host.appendChild(tooltip); const margin = 8; const gap = 6; tooltip.style.position = 'fixed'; tooltip.style.zIndex = '2147483646'; tooltip.style.maxWidth = `${Math.max(96, bounds.width - margin * 2)}px`; tooltip.style.maxHeight = `${Math.max(32, bounds.height - margin * 2)}px`; const itemRect = item.getBoundingClientRect(); const tipHeight = tooltip.offsetHeight; const tipWidth = tooltip.offsetWidth; const spaceAbove = itemRect.top - bounds.top - margin; const spaceBelow = bounds.bottom - itemRect.bottom - margin; const needed = tipHeight + gap; const placement = spaceAbove >= needed && (spaceBelow < needed || spaceAbove >= spaceBelow) ? 'above' : 'below'; tooltip.classList.toggle('ydm-danmaku-tooltip--above', placement === 'above'); tooltip.classList.toggle('ydm-danmaku-tooltip--below', placement === 'below'); let left = itemRect.left + itemRect.width / 2 - tipWidth / 2; let top = placement === 'below' ? itemRect.bottom + gap : itemRect.top - tipHeight - gap; const minLeft = bounds.left + margin; const maxLeft = bounds.right - tipWidth - margin; const minTop = bounds.top + margin; const maxTop = bounds.bottom - tipHeight - margin; left = clamp(left, minLeft, Math.max(minLeft, maxLeft)); top = clamp(top, minTop, Math.max(minTop, maxTop)); tooltip.style.left = `${left}px`; tooltip.style.top = `${top}px`; } function bindTooltipReposition(item, tooltip) { tooltipRepositionHandler = () => positionDanmakuTooltip(item, tooltip); window.addEventListener('resize', tooltipRepositionHandler); window.addEventListener('scroll', tooltipRepositionHandler, true); 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('ydm-paused')) doResume(); }, MAX_DANMAKU_PAUSE_MS); }; const doResume = () => { clearHoverResumeTimer(); clearMaxPauseTimer(); if (!item.classList.contains('ydm-paused')) { pauseReason = null; item.classList.remove('ydm-click-locked'); return; } pauseReason = null; item.classList.remove('ydm-paused', 'ydm-click-locked'); if (hoverPausedItem === item) hoverPausedItem = null; if (activeTooltipItem === item) { removeClickLockBackdrop(); activeTooltip?.remove(); activeTooltip = null; activeTooltipItem = null; } onResume?.(); }; item._ydmResume = () => doResume(); item._ydmResumeHover = () => { 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._ydmResumeHover?.(); } hoverPausedItem = item; if (!item.classList.contains('ydm-paused')) { pauseReason = 'hover'; item.classList.add('ydm-paused'); onPause?.(); } else if (pauseReason !== 'hover') { pauseReason = 'hover'; } armMaxPauseTimer(); }; const pauseForClick = () => { clearHoverResumeTimer(); if (hoverPausedItem && hoverPausedItem !== item) { hoverPausedItem._ydmResumeHover?.(); } hoverPausedItem = null; pauseReason = 'click'; item.classList.add('ydm-click-locked'); if (!item.classList.contains('ydm-paused')) { item.classList.add('ydm-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.ydmId = item.dataset.ydmId || String(Date.now() + Math.random()); const mount = getDanmakuMount(item); if (!mount) { doResume(); return; } showClickLockBackdrop(mount); const tooltip = document.createElement('div'); tooltip.className = 'ydm-danmaku-tooltip ydm-danmaku-tooltip--above'; tooltip.dataset.itemId = item.dataset.ydmId; const bubble = document.createElement('div'); bubble.className = 'ydm-danmaku-tooltip-bubble'; const header = document.createElement('div'); header.className = 'ydm-danmaku-tooltip-header'; if (username) { const nameRow = document.createElement(profileUrl ? 'a' : 'span'); nameRow.className = 'ydm-danmaku-tooltip-name'; nameRow.textContent = username; nameRow.style.color = safeCssColor(usernameColor || '#ff4e45'); if (profileUrl) { nameRow.href = profileUrl; nameRow.target = '_blank'; nameRow.rel = 'noopener noreferrer'; nameRow.setAttribute('aria-label', t('tooltipOpenProfile')); nameRow.addEventListener('click', (e) => e.stopPropagation()); } header.appendChild(nameRow); } else { header.classList.add('ydm-danmaku-tooltip-header--solo'); } const actions = document.createElement('div'); actions.className = 'ydm-danmaku-tooltip-actions'; const spokenText = (item.textContent || messageText || '').trim(); if (spokenText) { const speakBtn = document.createElement('button'); speakBtn.type = 'button'; speakBtn.className = 'ydm-danmaku-tooltip-speak'; speakBtn.setAttribute('aria-label', t('tooltipSpeak')); appendMarkup(speakBtn, ICON_SPEECH_ON); speakBtn.addEventListener('click', (e) => { e.stopPropagation(); e.preventDefault(); speakTextNow(spokenText); }); actions.appendChild(speakBtn); } if (messageText) { const copyBtn = document.createElement('button'); copyBtn.type = 'button'; copyBtn.className = 'ydm-danmaku-tooltip-copy'; copyBtn.setAttribute('aria-label', t('tooltipCopy')); appendMarkup(copyBtn, ICON_COPY); copyBtn.addEventListener('click', async (e) => { e.stopPropagation(); e.preventDefault(); await Promise.all([ setChatInputText(messageText), copyTextToClipboard(messageText), ]); hideDanmakuTooltip(); }); actions.appendChild(copyBtn); } if (actions.childElementCount) header.appendChild(actions); bubble.appendChild(header); if (showOriginal) { const originalRow = document.createElement('div'); originalRow.className = 'ydm-danmaku-tooltip-original'; originalRow.textContent = truncateText(originalMessage); bubble.appendChild(originalRow); } const tail = document.createElement('div'); tail.className = 'ydm-danmaku-tooltip-tail'; tooltip.append(bubble, tail); tooltip.addEventListener('click', (e) => e.stopPropagation()); tooltip.addEventListener('pointerdown', (e) => e.stopPropagation()); getTooltipHost().appendChild(tooltip); positionDanmakuTooltip(item, tooltip); bindTooltipReposition(item, tooltip); activeTooltip = tooltip; activeTooltipItem = item; requestAnimationFrame(() => { if (activeTooltip === tooltip) positionDanmakuTooltip(item, tooltip); }); }; item.addEventListener('pointerdown', (e) => { e.stopPropagation(); }); item.addEventListener('mouseenter', () => { pauseForHover(); }); item.addEventListener('mouseleave', () => { if (pauseReason === 'click') return; scheduleHoverResume(); }); item.addEventListener('click', (e) => { e.stopPropagation(); e.preventDefault(); 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'), 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 }; } const EMOTE_SHORTCODE = /:[a-zA-Z0-9_+-]{1,64}:/g; function isolateEmoteShortcodes(text) { const kept = []; const isolated = String(text || '').replace(EMOTE_SHORTCODE, (shortcode) => { const token = `⟦S${kept.length}⟧`; kept.push({ token, shortcode }); return token; }); return { text: isolated, kept }; } function restoreEmoteShortcodes(text, kept) { if (!text || !kept?.length) return text; let restored = text; for (const { token, shortcode } of kept) { restored = restored.split(token).join(shortcode); } return restored; } 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 clearCachedTranslation(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: 'youtube-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); } async function translateBatch(texts, targetLang, preferred) { if (!texts.length) return { texts: [] }; 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(); 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); // Prefer the selected engine first, but always allow failover to other ready engines. const sequence = !preferred || preferred === 'auto' ? autoSequence(ready) : [preferred, ...ready.filter((id) => id !== preferred)].filter((id) => ready.includes(id)); if (!sequence.length) return waitForProviders(usable, false); 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 withoutEmoteTokens(text) { return String(text || '').replace(/⟦[ETS]\d+⟧/g, ' '); } function shouldSkipTranslation(text) { const trimmed = withoutEmoteTokens(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 needsMachineTranslation(text) { return !!withoutEmoteTokens(text).trim(); } 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', options = {}) { const results = new Array(texts.length); const pending = []; const bypassCache = !!options.bypassCache; texts.forEach((text, index) => { if (bypassCache) clearCachedTranslation(text, targetLang); else { const cached = getCachedTranslation(text, targetLang); if (cached != null) { results[index] = cached; return; } } const isolated = isolateEmoteShortcodes(text); if (shouldSkipTranslation(isolated.text) || text.length > 500) { results[index] = text; return; } const prepared = prepareDictionaryText(isolated.text, targetLang); const restore = (value) => restoreEmoteShortcodes( restoreDictionaryText(value, prepared.placeholders), isolated.kept, ); if (prepared.fullyMapped || !needsMachineTranslation(prepared.preparedText)) { results[index] = restore(prepared.preparedText); return; } pending.push({ index, original: text, preparedText: prepared.preparedText, placeholders: prepared.placeholders, kept: isolated.kept, }); }); 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 = restoreEmoteShortcodes( restoreDictionaryText(machine, item.placeholders), item.kept, )?.trim() || item.original; results[item.index] = restored; // Avoid caching no-op / echo results so retranslate can switch engines. if (cacheable && restored !== item.original) { setCachedTranslation(item.original, targetLang, restored); } } }); return { texts: results }; } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } /** * Manual single-message translate with provider failover. * Retries with `auto` when the preferred engine fails or returns an unchanged echo. */ async function translateMessagesManual(text, targetLang, preferred = 'microsoft', options = {}) { const source = String(text || '').trim(); if (!source) return ''; const providers = preferred && preferred !== 'auto' ? [preferred, 'auto', 'auto'] : ['auto', 'auto']; let last = ''; for (let i = 0; i < providers.length; i += 1) { const bypassCache = options.bypassCache || i > 0; const outcome = await translateMessages([source], targetLang, providers[i], { bypassCache }); if (outcome?.retryAfter) { await sleep(Math.min(Math.max(50, outcome.retryAfter), 1200)); continue; } const rendered = outcome?.texts?.[0]?.trim() || ''; if (rendered && rendered !== source) return rendered; last = rendered || last; } return last; } 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, 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, 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); } takeJobs(speed) { const jobs = []; let chars = 0; while (this.queue.length && jobs.length < Math.min(speed, TRANSLATE_MAX_BATCH)) { const next = this.queue[0]; if (jobs.length > 0 && chars + next.text.length > TRANSLATE_MAX_BATCH_CHARS) break; chars += next.text.length; jobs.push(this.queue.shift()); } return jobs; } deliver(jobs, texts) { if (!this.isActive()) return; jobs.forEach((job, index) => { const translated = typeof texts[index] === 'string' ? texts[index] : job.text; try { job.callback(translated, job.text); } catch { /* ignore */ } }); } 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(); try { const outcome = await translateMessages( jobs.map((job) => job.text), 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.text)); 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, outcome.texts || []); this.pace(jobs.length, speed, startedAt); } catch { if (epoch !== this.epoch || !this.isActive()) return; this.deliver(jobs, jobs.map((job) => job.text)); 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; } const EMOTE_HOST = /(^|\.)(ggpht\.com|googleusercontent\.com|ytimg\.com|youtube\.com|gstatic\.com)$/i; const EMOTE_SHORTCODE_RE = /^:[^:\s]+:$/; const EMOTE_SHORTCODE_ONLY_RE = /^\s*(:[^:\s]+:\s*)+$/; function isEmoteShortcode(value) { return EMOTE_SHORTCODE_RE.test(String(value || '').trim()); } /** Recover Unicode from YouTube gaming emoji URLs like emoji_u1f44f / emoji_u1f468_200d_1f4bb. */ function unicodeFromEmoteSrc(src) { const href = String(src || ''); const match = href.match(/emoji_u([0-9a-f_]+)\./i); if (!match) return ''; try { const cps = match[1].split('_').map((hex) => parseInt(hex, 16)).filter((n) => Number.isFinite(n)); if (!cps.length) return ''; return String.fromCodePoint(...cps); } catch (_) { return ''; } } function readEmoteAlt(node) { const candidates = [ node.getAttribute('alt'), node.getAttribute('shared-tooltip-text'), node.getAttribute('title'), node.getAttribute('aria-label'), ].map((value) => String(value || '').trim()).filter(Boolean); // Prefer real Unicode / labels over :shortcode: (other userscripts often overwrite alt). const unicode = candidates.find((value) => !isEmoteShortcode(value)); return unicode || candidates[0] || ''; } function safeEmoteSrc(src) { if (!src) return ''; try { const url = new URL(src, 'https://www.youtube.com'); if (url.protocol !== 'https:') return ''; if (!EMOTE_HOST.test(url.hostname)) return ''; return url.href; } catch (_) { return ''; } } function readEmoteSrc(node) { if (!node) return ''; const direct = safeEmoteSrc( node.currentSrc || node.getAttribute('src') || node.getAttribute('data-src') || node.getAttribute('data-thumb') || '', ); if (direct) return direct; const srcset = String(node.getAttribute('srcset') || ''); const first = srcset.split(',')[0]?.trim().split(/\s+/)[0] || ''; return safeEmoteSrc(first); } function isEmoteImage(node) { if (node.tagName !== 'IMG') return false; if (node.closest('#author-photo, #author-name, yt-live-chat-author-chip')) return false; const src = readEmoteSrc(node); if (!src) return false; if ( node.id === 'emoji' || (typeof node.id === 'string' && node.id.startsWith('emoji')) || node.classList.contains('emoji') || node.classList.contains('small-emoji') || node.hasAttribute('data-emoji-id') || node.hasAttribute('shared-tooltip-text') ) { return true; } return !!node.closest('#message, #sticker, #purchase-description'); } function buildEmotePart(node) { const src = readEmoteSrc(node); const rawAlt = readEmoteAlt(node); const emoji = (!isEmoteShortcode(rawAlt) && rawAlt && !/^https?:/i.test(rawAlt)) ? rawAlt : unicodeFromEmoteSrc(src); const shortcode = isEmoteShortcode(rawAlt) ? rawAlt : (isEmoteShortcode(node.getAttribute('shared-tooltip-text')) ? String(node.getAttribute('shared-tooltip-text')).trim() : ''); // Display label: Unicode when possible; never prefer shortcode for rendering. const alt = emoji || shortcode || rawAlt || ''; return { type: 'emote', alt, src, emoji: emoji || '', shortcode }; } function pushTextPart(parts, text) { if (!text) return; const last = parts[parts.length - 1]; if (last?.type === 'text') last.text += text; else parts.push({ type: 'text', text }); } function collectParts(root) { if (!root) return []; const parts = []; const walk = (node) => { if (node.nodeType === Node.TEXT_NODE) { const text = node.textContent || ''; // Some clients / copy-fix scripts leave trailing ":shortcode:" text beside imgs. if (EMOTE_SHORTCODE_ONLY_RE.test(text)) return; pushTextPart(parts, text); return; } if (node.nodeType !== Node.ELEMENT_NODE) return; const id = node.id; if (id === 'author-name' || id === 'timestamp' || id === 'chat-badges' || id === 'author-photo') return; const tag = node.tagName; if (tag === 'YT-LIVE-CHAT-AUTHOR-CHIP' || tag === 'YT-ICON') return; if (tag === 'IMG' && isEmoteImage(node)) { parts.push(buildEmotePart(node)); return; } if (tag === 'IMG') { const alt = readEmoteAlt(node); if (alt && !isEmoteShortcode(alt)) pushTextPart(parts, ` ${alt} `); return; } for (const child of node.childNodes) walk(child); }; walk(root); return parts; } function emoteDisplayText(part) { if (!part || part.type !== 'emote') return ''; const emoji = String(part.emoji || '').trim() || unicodeFromEmoteSrc(part.src); if (emoji) return emoji; const alt = String(part.alt || '').trim(); if (alt && !isEmoteShortcode(alt)) return alt; return ''; } function partsToText(parts) { return parts.map((part) => { if (part.type !== 'emote') return part.text; const text = emoteDisplayText(part); return text ? text : ''; }).join('').replace(/\s+/g, ' ').trim(); } /** Favorites store sendable text: Unicode when possible, else YouTube :shortcode:. */ function partsToFavoriteText(parts) { return parts.map((part) => { if (part.type !== 'emote') return part.text; return emoteDisplayText(part) || String(part.shortcode || '').trim() || ''; }).join('').replace(/\s+/g, ' ').trim(); } function shieldEmoteParts(parts) { const emotes = []; let text = ''; for (const part of parts) { if (part.type === 'emote') { text += ` ⟦E${emotes.length}⟧ `; emotes.push(part); } else { text += part.text; } } return { text: text.replace(/\s+/g, ' ').trim(), emotes }; } function restoreEmoteParts(translated, emotes) { if (!emotes.length) return [{ type: 'text', text: translated }]; const parts = []; const pattern = /⟦E(\d+)⟧/g; let last = 0; let found = 0; let match = pattern.exec(translated); while (match) { const chunk = translated.slice(last, match.index); if (chunk) parts.push({ type: 'text', text: chunk }); const emote = emotes[Number(match[1])]; if (emote) { parts.push(emote); found += 1; } last = match.index + match[0].length; match = pattern.exec(translated); } const tail = translated.slice(last); if (tail) parts.push({ type: 'text', text: tail }); if (found !== emotes.length) return null; return parts; } function readPurchaseAmount(msgEl) { return msgEl?.querySelector?.('#purchase-amount, #purchase-amount-chip')?.textContent?.replace(/\s+/g, ' ').trim() || ''; } function escapeRegExp(value) { return String(value || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } function stripAmountFromText(text, amount) { let s = String(text || '').replace(/\s+/g, ' ').trim(); if (!s) return ''; if (amount) { const exact = escapeRegExp(amount.trim()); s = s.replace(new RegExp(`^${exact}\\s*`, 'i'), '').trim(); } // Fallback: leading currency / number tokens left by translators. s = s.replace(/^[¥$€£₹₩¥]\s*[\d,]+(?:\.\d+)?\s*(?:[A-Z]{3})?\s*/i, '').trim(); s = s.replace(/^[\d,]+(?:\.\d+)?\s*(?:USD|CNY|JPY|EUR|GBP|HKD|TWD|KRW|CAD|AUD)?\s*/i, '').trim(); return s; } function stripAmountFromParts(parts, amount) { if (!Array.isArray(parts) || !parts.length) return []; const out = []; let strippedLeading = false; for (const part of parts) { if (!strippedLeading && part?.type === 'text') { strippedLeading = true; const cleaned = stripAmountFromText(part.text, amount); if (cleaned) out.push({ type: 'text', text: cleaned }); continue; } out.push(part); } return out; } function isMembershipElement(msgEl) { const tag = msgEl?.tagName || ''; return tag === 'YT-LIVE-CHAT-MEMBERSHIP-ITEM-RENDERER' || /MEMBERSHIP-ITEM/i.test(tag); } /** Paid Super Chat / sticker only — membership & gifts stay on the scroll pipeline. */ function isPinnedChatElement(msgEl) { if (!msgEl) return false; const tag = msgEl.tagName || ''; if (isMembershipElement(msgEl)) return false; return tag === 'YT-LIVE-CHAT-PAID-MESSAGE-RENDERER' || tag === 'YT-LIVE-CHAT-PAID-STICKER-RENDERER' || /PAID-MESSAGE|PAID-STICKER|TICKER-PAID/i.test(tag) || !!msgEl.querySelector?.('#purchase-amount, #purchase-amount-chip'); } function readMembershipBadge(msgEl) { const badge = msgEl?.querySelector?.( 'yt-live-chat-author-badge-renderer[type="member"], #chat-badges yt-live-chat-author-badge-renderer', ); return ( badge?.getAttribute?.('shared-tooltip-text') || badge?.getAttribute?.('aria-label') || badge?.querySelector?.('img')?.getAttribute?.('alt') || '' ).replace(/\s+/g, ' ').trim(); } function extractMessageParts(msgEl, { includeAmount = true } = {}) { const parts = []; const amount = readPurchaseAmount(msgEl); if (includeAmount && amount) pushTextPart(parts, `${amount} `); const messageEl = msgEl.querySelector('#message'); const messageParts = messageEl ? collectParts(messageEl) : []; const membership = isMembershipElement(msgEl); // Membership cards often put the welcome text in #header-subtext while #message is empty. if (membership || !messageParts.length) { parts.push(...collectParts(msgEl.querySelector('#header-primary-text, #primary-text'))); parts.push(...collectParts(msgEl.querySelector('#header-subtext'))); } if (messageParts.length) { parts.push(...messageParts); } else if (!membership) { const sticker = msgEl.querySelector('#sticker img, img.yt-live-chat-paid-sticker-renderer'); if (sticker && !parts.some((part) => part.src && part.src === safeEmoteSrc(sticker.getAttribute('src')))) { if (isEmoteImage(sticker)) parts.push(...collectParts(sticker)); else pushTextPart(parts, sticker.getAttribute('alt') || ''); } } return parts; } function readChatMessage(msgEl) { if (!msgEl || msgEl.hasAttribute('is-deleted')) return null; const membership = isMembershipElement(msgEl); const purchaseAmount = readPurchaseAmount(msgEl); const membershipBadge = membership ? readMembershipBadge(msgEl) : ''; // Reuse the amount badge slot for membership labels (e.g. "New member"). const amount = purchaseAmount || membershipBadge; const parts = extractMessageParts(msgEl, { includeAmount: !!purchaseAmount }); const bodyParts = extractMessageParts(msgEl, { includeAmount: false }); const rawMessage = partsToText(parts); if (!rawMessage && !parts.some((part) => part.type === 'emote') && !amount && !membership) { return null; } const message = dedupeRepeatedMessage(rawMessage) || rawMessage; const username = (typeof getAuthorDisplayName === 'function' ? getAuthorDisplayName(msgEl) : msgEl.querySelector('#author-name')?.textContent?.replace(/\s+/g, ' ').trim()) || ''; const usernameColor = getUsernameColor(msgEl, username); const profileUrl = getYouTubeProfileUrl(msgEl); const avatarUrl = safeEmoteSrc( msgEl.querySelector('#author-photo img, yt-img-shadow#author-photo img')?.currentSrc || msgEl.querySelector('#author-photo img, yt-img-shadow#author-photo img')?.getAttribute('src') || '', ); const superchat = isPinnedChatElement(msgEl); return { message, parts, bodyParts, amount, username, usernameColor, profileUrl, avatarUrl, membership, superchat, superchatStyle: superchat ? readSuperchatStyle(msgEl) : null, }; } function parseChatMessage(msgEl) { if (!msgEl || msgEl.classList.contains('ydm-processed')) return null; const parsed = readChatMessage(msgEl); if (!parsed) return null; msgEl.classList.add('ydm-processed'); return parsed; } function readCssVarColor(el, name) { if (!el) return ''; try { return usableCssColor(getComputedStyle(el).getPropertyValue(name)?.trim()); } catch (_) { return ''; } } function readSuperchatStyle(msgEl) { const header = msgEl.querySelector('#header') || msgEl.querySelector('#card'); const content = msgEl.querySelector('#content'); const headerStyle = header ? getComputedStyle(header) : null; const contentStyle = content ? getComputedStyle(content) : null; const fallbackBg = '#e53935'; const background = readCssVarColor(msgEl, '--yt-live-chat-paid-message-primary-container-color') || readCssVarColor(msgEl, '--yt-live-chat-paid-message-header-background-color') || usableCssColor(headerStyle?.backgroundColor) || fallbackBg; const bodyBackground = readCssVarColor(msgEl, '--yt-live-chat-paid-message-secondary-container-color') || readCssVarColor(msgEl, '--yt-live-chat-paid-message-background-color') || usableCssColor(contentStyle?.backgroundColor) || background; const color = readCssVarColor(msgEl, '--yt-live-chat-paid-message-header-color') || usableCssColor(headerStyle?.color) || '#ffffff'; const bodyColor = readCssVarColor(msgEl, '--yt-live-chat-paid-message-color') || usableCssColor(contentStyle?.color) || color; return { background, bodyBackground, color, bodyColor }; } function usableCssColor(value) { if (!value || value === 'transparent' || value === 'rgba(0, 0, 0, 0)') return ''; return value; } function markExistingMessages(chatRoot) { chatRoot.querySelectorAll(SELECTORS.chatMessage).forEach((el) => { el.classList.add('ydm-processed'); ensureAuthorProfileLink(el); }); } 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 += 1) { const node = messages[i]; if (node === msgEl || node.contains(msgEl) || msgEl.contains(node)) return true; } return false; } function findChatTranslationMount(msgEl) { const messageContainer = msgEl.querySelector('#message-container'); if (messageContainer) return { parent: messageContainer.parentElement, after: messageContainer }; const message = msgEl.querySelector('#message'); if (message) return { parent: message.parentElement, after: message }; const content = msgEl.querySelector('#content'); if (content) return { parent: content, after: null }; return { parent: msgEl, after: null }; } function removeChatTranslation(msgEl) { if (!msgEl) return; msgEl.querySelectorAll('.ydm-chat-translation').forEach((node) => node.remove()); } function clearAllChatTranslations() { const doc = getChatDocument(); if (!doc) return; doc.querySelectorAll('.ydm-chat-translation').forEach((node) => node.remove()); } function getChatTranslationText(msgEl) { return msgEl?.querySelector('.ydm-chat-translation-text')?.textContent?.replace(/\s+/g, ' ').trim() || ''; } function hasChatTranslation(msgEl) { const el = msgEl?.querySelector?.('.ydm-chat-translation'); if (!el) return false; if (el.classList.contains('ydm-chat-translation--pending')) return false; if (el.classList.contains('ydm-chat-translation--error')) return false; return !!getChatTranslationText(msgEl); } function hasChatTranslationError(msgEl) { return !!msgEl?.querySelector?.('.ydm-chat-translation--error'); } function ensureChatTranslationEl(msgEl) { const doc = msgEl.ownerDocument; ensureChatTranslateStyles(doc); let el = msgEl.querySelector('.ydm-chat-translation'); if (el) return el; el = doc.createElement('div'); el.className = 'ydm-chat-translation'; el.setAttribute('dir', 'auto'); const label = doc.createElement('span'); label.className = 'ydm-chat-translation-label'; label.textContent = t('translateInChatBadge'); const text = doc.createElement('span'); text.className = 'ydm-chat-translation-text'; el.append(label, text); const mount = findChatTranslationMount(msgEl); if (!mount.parent) return null; if (mount.after?.parentElement === mount.parent) mount.after.after(el); else mount.parent.appendChild(el); return el; } function applyChatTranslation(msgEl, translatedText, originalText) { if (!msgEl?.isConnected) return; const translated = String(translatedText || '').replace(/\s+/g, ' ').trim(); const original = String(originalText || '').replace(/\s+/g, ' ').trim(); if (!translated || translated === original) { removeChatTranslation(msgEl); return; } const el = ensureChatTranslationEl(msgEl); if (!el) return; el.classList.remove('ydm-chat-translation--pending', 'ydm-chat-translation--error'); const textEl = el.querySelector('.ydm-chat-translation-text'); if (textEl) textEl.textContent = translated; } function setChatTranslationStatus(msgEl, status, text) { if (!msgEl?.isConnected) return; const el = ensureChatTranslationEl(msgEl); if (!el) return; el.classList.toggle('ydm-chat-translation--pending', status === 'pending'); el.classList.toggle('ydm-chat-translation--error', status === 'error'); const textEl = el.querySelector('.ydm-chat-translation-text'); if (textEl) textEl.textContent = text || ''; } function closeChatOverlayUi(doc) { if (!doc) return; doc.querySelectorAll('.ydm-chat-menu, .ydm-sc-action-panel').forEach((node) => node.remove()); } function createChatActionButton(doc, { icon, label, onClick }) { const btn = doc.createElement('button'); btn.type = 'button'; const iconWrap = doc.createElement('span'); appendMarkup(iconWrap, icon); const text = doc.createElement('span'); text.textContent = label; btn.append(iconWrap, text); btn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); onClick?.(e); }); btn.addEventListener('pointerdown', (e) => e.stopPropagation()); return btn; } function showChatContextMenu(doc, clientX, clientY, items) { closeChatOverlayUi(doc); ensureChatTranslateStyles(doc); const menu = doc.createElement('div'); menu.className = 'ydm-chat-menu'; menu.addEventListener('click', (e) => e.stopPropagation()); menu.addEventListener('contextmenu', (e) => e.preventDefault()); for (const item of items) { const btn = createChatActionButton(doc, item); btn.className = 'ydm-chat-menu-item'; menu.appendChild(btn); } (doc.body || doc.documentElement).appendChild(menu); const width = menu.offsetWidth || 160; const height = menu.offsetHeight || 40; const viewW = doc.defaultView?.innerWidth || width; const viewH = doc.defaultView?.innerHeight || height; const left = Math.max(8, Math.min(clientX, viewW - width - 8)); const top = Math.max(8, Math.min(clientY, viewH - height - 8)); Object.assign(menu.style, { left: `${left}px`, top: `${top}px` }); return menu; } function showSuperchatActionPanel(msgEl, items) { const doc = msgEl.ownerDocument; ensureChatTranslateStyles(doc); msgEl.querySelectorAll('.ydm-sc-action-panel').forEach((node) => node.remove()); const panel = doc.createElement('div'); panel.className = 'ydm-sc-action-panel'; panel.addEventListener('click', (e) => e.stopPropagation()); for (const item of items) { const btn = createChatActionButton(doc, item); btn.className = 'ydm-sc-action-btn'; panel.appendChild(btn); } // Paid stickers / message-less SC: `#content` is a tight horizontal flex — // mounting the panel inside squeezes the author name out of view. const isPaidSticker = msgEl.tagName === 'YT-LIVE-CHAT-PAID-STICKER-RENDERER'; const purchaseDesc = msgEl.querySelector('#purchase-description'); const hasBodyText = !!( msgEl.querySelector('#message')?.textContent?.trim() || (purchaseDesc && !purchaseDesc.hasAttribute('hidden') && purchaseDesc.textContent?.trim()) ); const card = msgEl.querySelector('#card'); if ((isPaidSticker || !hasBodyText) && card?.parentElement) { card.after(panel); return panel; } const mount = findChatTranslationMount(msgEl); const translation = msgEl.querySelector('.ydm-chat-translation'); if (translation) translation.after(panel); else if (mount.after?.parentElement === mount.parent) mount.after.after(panel); else if (mount.parent) mount.parent.appendChild(panel); else msgEl.appendChild(panel); return panel; } function pageWin() { return typeof unsafeWindow !== 'undefined' ? unsafeWindow : window; } function currentChannelId() { const page = pageWin(); const fromPlayer = page.ytInitialPlayerResponse?.videoDetails?.channelId; if (fromPlayer) return String(fromPlayer); try { const fromCfg = page.ytcfg?.get?.('CHANNEL_ID') || page.ytcfg?.data_?.CHANNEL_ID; if (fromCfg) return String(fromCfg); } catch (_) { /* ignore */ } const link = document.querySelector( 'ytd-video-owner-renderer a[href*="/channel/"], #owner #channel-name a[href*="/channel/"]', ); const match = link?.getAttribute('href')?.match(/\/channel\/([^/?#]+)/); return match?.[1] || ''; } function currentChannelLabel() { const page = pageWin(); const author = page.ytInitialPlayerResponse?.videoDetails?.author; if (author) return String(author).trim(); const name = document.querySelector( 'ytd-video-owner-renderer #channel-name a, #owner #channel-name yt-formatted-string', )?.textContent?.replace(/\s+/g, ' ').trim(); return name || currentChannelId(); } function sanitizeChannelKey(value) { const text = String(value || '').trim(); if (!text) return null; if (/^UC[\w-]{20,}$/i.test(text)) return text; return text.replace(/^@+/, '').slice(0, 64) || null; } function isLiveWatchMode() { if (document.documentElement.dataset.ydmPreview === 'true') return true; const page = pageWin(); if (page.ytInitialPlayerResponse?.videoDetails?.isLive) return true; if (page.ytInitialPlayerResponse?.videoDetails?.isLiveContent) return true; return !!document.querySelector('.ytp-live-badge, .ytp-live'); } const SEND_PARAMS_CACHE = { key: '', params: '', at: 0 }; /** Matches kusaanko/YouTubeLiveChat Util.generateClientMessageId() — 26 chars from [a-zA-Z-]. */ const CLIENT_MESSAGE_ID_ALPHABET = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-'; const CLIENT_MESSAGE_ID_STATE = { base: '', counter: 0 }; function generateClientMessageIdBase() { let out = ''; const alphabet = CLIENT_MESSAGE_ID_ALPHABET; const len = alphabet.length; if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') { const buf = new Uint8Array(26); crypto.getRandomValues(buf); for (let i = 0; i < 26; i += 1) out += alphabet[buf[i] % len]; return out; } for (let i = 0; i < 26; i += 1) { out += alphabet[Math.floor(Math.random() * len)]; } return out; } /** * clientMessageId = base(26) + counter, same as YouTubeLiveChat.getPayloadToSendMessage. * @see https://github.com/kusaanko/YouTubeLiveChat */ function createClientMessageId() { if (!CLIENT_MESSAGE_ID_STATE.base) { CLIENT_MESSAGE_ID_STATE.base = generateClientMessageIdBase(); CLIENT_MESSAGE_ID_STATE.counter = 0; } if (CLIENT_MESSAGE_ID_STATE.counter >= Number.MAX_SAFE_INTEGER - 1) { CLIENT_MESSAGE_ID_STATE.counter = 0; } return CLIENT_MESSAGE_ID_STATE.base + CLIENT_MESSAGE_ID_STATE.counter++; } function walkFindSendParams(node, depth = 0, seen = new Set()) { if (!node || depth > 14) return ''; if (typeof node !== 'object') return ''; if (seen.has(node)) return ''; seen.add(node); try { const endpoint = node.sendLiveChatMessageEndpoint || node.sendLiveChatMessageCommand; if (endpoint?.params) return String(endpoint.params); if (node.commandMetadata?.webCommandMetadata?.apiUrl?.includes?.('send_message') && node.params) { return String(node.params); } } catch (_) { /* ignore */ } if (Array.isArray(node)) { for (const item of node) { const found = walkFindSendParams(item, depth + 1, seen); if (found) return found; } return ''; } for (const value of Object.values(node)) { const found = walkFindSendParams(value, depth + 1, seen); if (found) return found; } return ''; } function readSendParamsFromSources() { const page = pageWin(); const chatDoc = getChatDocument(); const chatWin = chatDoc?.defaultView; const sources = [ chatWin?.ytInitialData, chatWin?.ytcfg?.data_, page.ytInitialData, page.ytcfg?.data_, ]; for (const source of sources) { const found = walkFindSendParams(source); if (found) return found; } return ''; } function getLiveChatSendParams(force = false) { const key = pageKey() || location.href; if (!force && SEND_PARAMS_CACHE.params && SEND_PARAMS_CACHE.key === key && Date.now() - SEND_PARAMS_CACHE.at < 120000) { return SEND_PARAMS_CACHE.params; } const params = readSendParamsFromSources(); if (params) { SEND_PARAMS_CACHE.key = key; SEND_PARAMS_CACHE.params = params; SEND_PARAMS_CACHE.at = Date.now(); } return params; } function readCookieValue(name) { try { const match = document.cookie.match(new RegExp(`(?:^|; )${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}=([^;]*)`)); return match ? decodeURIComponent(match[1]) : ''; } catch (_) { return ''; } } async function sha1Hex(text) { const data = new TextEncoder().encode(text); if (crypto?.subtle?.digest) { const buf = await crypto.subtle.digest('SHA-1', data); return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, '0')).join(''); } return ''; } /** SAPISIDHASH Authorization as in kusaanko/YouTubeLiveChat.getHeader(). */ async function buildSapisidAuthorization() { const sapisid = readCookieValue('SAPISID') || readCookieValue('__Secure-3PAPISID') || readCookieValue('__Secure-1PAPISID'); if (!sapisid) return ''; const time = Math.floor(Date.now() / 1000); const origin = 'https://www.youtube.com'; const digest = await sha1Hex(`${time} ${sapisid} ${origin}`); if (!digest) return ''; return `SAPISIDHASH ${time}_${digest}`; } function readDatasyncId(win) { try { const raw = win?.ytcfg?.get?.('DATASYNC_ID') || win?.ytcfg?.data_?.DATASYNC_ID || ''; return String(raw).split('||')[0] || ''; } catch (_) { return ''; } } function getInnertubeSendConfig() { const page = pageWin(); const chatWin = getChatDocument()?.defaultView; const windows = [chatWin, page].filter(Boolean); for (const win of windows) { const ytcfg = win.ytcfg; if (!ytcfg?.get) continue; let key = ''; let context = null; try { key = ytcfg.get('INNERTUBE_API_KEY') || ''; context = ytcfg.get('INNERTUBE_CONTEXT') || null; } catch (_) { continue; } if (!key || !context) continue; return { win, key, context, clientName: String(ytcfg.get('INNERTUBE_CONTEXT_CLIENT_NAME') || context?.client?.clientName || '1'), clientVersion: String( context?.client?.clientVersion || ytcfg.get('INNERTUBE_CLIENT_VERSION') || '', ), datasyncId: readDatasyncId(win) || readDatasyncId(page), fetchFn: typeof win.fetch === 'function' ? win.fetch.bind(win) : page.fetch?.bind(page), }; } return null; } function buildSendContext(baseContext, datasyncId) { let context; try { context = JSON.parse(JSON.stringify(baseContext || {})); } catch (_) { context = { client: { clientName: 'WEB' } }; } if (!context.client || typeof context.client !== 'object') context.client = {}; if (!context.client.clientName) context.client.clientName = 'WEB'; if (!context.user || typeof context.user !== 'object') context.user = {}; if (datasyncId) context.user.onBehalfOfUser = datasyncId; if (typeof context.user.lockedSafetyMode !== 'boolean') context.user.lockedSafetyMode = false; return context; } function normalizeYouTubeSendCode(raw) { const code = String(raw || '').trim().toLowerCase(); if (!code) return ''; if (code.includes('login') || code.includes('auth') || code.includes('unauthenticated')) return 'not_logged_in'; if (code.includes('slow')) return 'slow_mode'; if (code.includes('duplicate')) return 'duplicate'; if (code.includes('rate') || code.includes('too_many')) return 'rate_limit'; if (code.includes('forbidden') || code.includes('permission')) return 'forbidden'; if (code.includes('live_chat_disabled') || code.includes('chat_disabled')) return 'chat_disabled'; if (code.includes('empty')) return 'empty_message'; return code || 'send_failed'; } function chatSendErrorMessage(error) { const code = normalizeYouTubeSendCode(error?.code || error?.message); switch (code) { case 'not_logged_in': return t('fsChatNeedLogin'); case 'no_channel': case 'no_params': return t('favoritesNeedChannel'); case 'slow_mode': return t('fsChatSlowMode'); case 'duplicate': return t('fsChatDuplicate'); case 'rate_limit': return t('fsChatRateLimit'); case 'forbidden': return t('fsChatForbidden'); case 'chat_disabled': return t('fsChatDisabled'); case 'empty_message': return t('favoritesEmptyText'); case 'preview': return t('fsChatPreviewOnly'); default: break; } const msg = String(error?.userMessage || '').trim(); return msg || t('fsChatFailed'); } function createSendError(code, userMessage = '', details = null) { const error = new Error(code || 'send_failed'); error.code = code || 'send_failed'; error.userMessage = userMessage || ''; error.details = details; return error; } async function sendViaDomFallback(text) { const ok = await setChatInputText(text); if (!ok) throw createSendError('send_failed'); const doc = getChatDocument(); const btn = doc?.querySelector( '#send-button button:not([disabled]), yt-button-renderer#send-button button:not([disabled]), #send-button', ); if (!btn || btn.disabled || btn.getAttribute('aria-disabled') === 'true') { throw createSendError('send_failed', t('fsChatFailed')); } btn.click(); return { via: 'dom' }; } async function sendYouTubeLiveChatMessage(message) { const text = String(message || '').trim(); if (!text) throw createSendError('empty_message'); if (document.documentElement.dataset.ydmPreview === 'true') { throw createSendError('preview'); } const config = getInnertubeSendConfig(); const params = getLiveChatSendParams(true); if (config?.key && config.context && params && config.fetchFn) { try { const context = buildSendContext(config.context, config.datasyncId); const authorization = await buildSapisidAuthorization(); const headers = { 'Content-Type': 'application/json', 'X-Youtube-Client-Name': config.clientName, 'X-Youtube-Client-Version': config.clientVersion, Origin: 'https://www.youtube.com', 'X-Origin': 'https://www.youtube.com', }; if (authorization) headers.Authorization = authorization; const response = await config.fetchFn( `https://www.youtube.com/youtubei/v1/live_chat/send_message?prettyPrint=false&key=${encodeURIComponent(config.key)}`, { method: 'POST', credentials: 'same-origin', headers, body: JSON.stringify({ context, params, clientMessageId: createClientMessageId(), richMessage: { textSegments: [{ text }], }, }), }, ); const data = await response.json().catch(() => null); if (!response.ok) { const apiCode = data?.error?.status || data?.error?.message || `http_${response.status}`; throw createSendError(normalizeYouTubeSendCode(apiCode) || 'send_failed', data?.error?.message || '', data); } const actions = data?.actions || data?.continuationContents || data; const errText = JSON.stringify(data || {}).match(/"errorMessage(?:Text)?"\s*:\s*"([^"]+)"/); if (errText?.[1]) { throw createSendError('send_failed', errText[1], data); } if (data?.error) { throw createSendError( normalizeYouTubeSendCode(data.error.status || data.error.message) || 'send_failed', data.error.message || '', data, ); } return { via: 'api', data: actions }; } catch (error) { if (error?.code && error.code !== 'send_failed') throw error; // Fall through to DOM send when API params/session are stale. } } return sendViaDomFallback(text); } const FAVORITES_STORAGE_KEY = 'ydm_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 sanitizeFavoriteItem(raw) { if (!raw || typeof raw !== 'object') return null; const text = String(raw.text || '').trim().slice(0, FAVORITE_TEXT_LIMIT); if (!text) return null; let channel = null; if (Object.prototype.hasOwnProperty.call(raw, 'channel')) { channel = raw.channel == null || raw.channel === '' ? null : sanitizeChannelKey(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; } 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, channelId) { if (!item) return false; if (!item.channel) return true; const channel = sanitizeChannelKey(channelId); return !!channel && item.channel === channel; } function listFavoritesForChannel(state, channelId) { const items = Array.isArray(state?.items) ? state.items : []; return items.filter((item) => favoriteVisibleForChannel(item, channelId)); } function addFavoriteText(text, { shortcut = null, channel = undefined } = {}) { const state = loadFavoritesState(); const currentChannel = sanitizeChannelKey( channel === undefined ? currentChannelId() : channel, ); 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 : (sanitizeChannelKey(currentChannelId()) || 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 ChatActions { constructor({ getSettings, danmakuEmitter, isActive, favoritesPanel = null }) { this.getSettings = getSettings; this.danmakuEmitter = danmakuEmitter; this.isActive = isActive; this.favoritesPanel = favoritesPanel; this.chatRoot = null; this.doc = null; this.panelObserver = null; this.boundContextMenu = (e) => this.handleContextMenu(e); this.boundClick = (e) => this.handleClick(e); this.boundDocClick = (e) => this.handleDocumentClick(e); this.busy = new WeakSet(); } bind(chatRoot) { this.unbind(); if (!chatRoot) return; this.chatRoot = chatRoot; this.doc = chatRoot.ownerDocument; ensureChatTranslateStyles(this.doc); this.chatRoot.addEventListener('contextmenu', this.boundContextMenu, true); this.chatRoot.addEventListener('click', this.boundClick, true); this.doc.addEventListener('click', this.boundDocClick, true); this.panelObserver = new MutationObserver(() => this.syncOpenSuperchatPanels()); this.panelObserver.observe(this.doc.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['hidden', 'aria-hidden', 'opened', 'open', 'selected'], }); this.syncOpenSuperchatPanels(); } unbind() { this.panelObserver?.disconnect(); this.panelObserver = null; if (this.chatRoot) { this.chatRoot.removeEventListener('contextmenu', this.boundContextMenu, true); this.chatRoot.removeEventListener('click', this.boundClick, true); } if (this.doc) { this.doc.removeEventListener('click', this.boundDocClick, true); closeChatOverlayUi(this.doc); } this.chatRoot = null; this.doc = null; } stop() { this.unbind(); } handleDocumentClick(e) { if (!this.doc) return; if (e.target?.closest?.('.ydm-chat-menu, .ydm-sc-action-panel')) return; // Keep panels attached to open Super Chat cards; only close floating menus. this.doc.querySelectorAll('.ydm-chat-menu').forEach((node) => node.remove()); } findMessage(target) { return target?.closest?.(SELECTORS.chatMessage) || null; } isSuperchatElement(msgEl) { return isPinnedChatElement(msgEl); } profileMenuItem(msgEl, { closeMenu = true } = {}) { // Resolve at click time so Polymer channel ids / @handles are available // for any target inside the message row (not only the avatar). return { icon: ICON_PROFILE, label: t('chatActionOpenProfile'), onClick: () => { if (closeMenu) closeChatOverlayUi(this.doc); const href = getYouTubeProfileUrl(msgEl); if (!href) return; window.open(href, '_blank', 'noopener,noreferrer'); }, }; } handleContextMenu(e) { if (!this.isActive()) return; const msgEl = this.findMessage(e.target); if (!msgEl || !this.doc) return; e.preventDefault(); e.stopPropagation(); const translated = hasChatTranslation(msgEl); const items = [ { icon: ICON_TRANSLATE, label: translated ? t('chatActionRetranslate') : t('chatActionTranslate'), onClick: () => { closeChatOverlayUi(this.doc); this.translateMessage(msgEl).catch(() => {}); }, }, { icon: ICON_SPEECH_ON, label: t('chatActionSpeak'), onClick: () => { closeChatOverlayUi(this.doc); this.speakMessage(msgEl); }, }, { icon: ICON_DANMAKU_ON, label: t('chatActionDanmaku'), onClick: () => { closeChatOverlayUi(this.doc); this.sendDanmaku(msgEl); }, }, { icon: ICON_FAVORITE, label: t('chatActionFavorite'), onClick: () => { closeChatOverlayUi(this.doc); this.favoriteMessage(msgEl); }, }, ]; items.push(this.profileMenuItem(msgEl)); showChatContextMenu(this.doc, e.clientX, e.clientY, items); } handleClick(e) { if (!this.isActive()) return; if (e.button !== 0) return; if (e.target?.closest?.('.ydm-chat-menu, .ydm-sc-action-panel, .ydm-chat-translation')) return; const msgEl = this.findMessage(e.target); if (!msgEl || !this.isSuperchatElement(msgEl)) return; e.preventDefault(); e.stopPropagation(); this.openSuperchatPanel(msgEl); } openSuperchatPanel(msgEl) { if (!this.doc || !msgEl?.isConnected) return; const translated = hasChatTranslation(msgEl); const items = [ { icon: ICON_TRANSLATE, label: translated ? t('chatActionRetranslate') : t('chatActionTranslate'), onClick: () => { this.translateMessage(msgEl).catch(() => {}); }, }, { icon: ICON_SPEECH_ON, label: t('chatActionSpeak'), onClick: () => this.speakMessage(msgEl), }, { icon: ICON_DANMAKU_ON, label: t('chatActionDanmaku'), onClick: () => this.sendDanmaku(msgEl), }, { icon: ICON_FAVORITE, label: t('chatActionFavorite'), onClick: () => this.favoriteMessage(msgEl), }, ]; items.push(this.profileMenuItem(msgEl, { closeMenu: false })); showSuperchatActionPanel(msgEl, items); } findOpenSuperchatCards() { if (!this.doc) return []; // History / detail popups (not the regular inline chat list). const selectors = [ '#chat-messages yt-live-chat-paid-message-renderer', '#chat-messages yt-live-chat-paid-sticker-renderer', 'yt-live-chat-banner-manager yt-live-chat-paid-message-renderer', 'yt-live-chat-banner-manager yt-live-chat-paid-sticker-renderer', 'tp-yt-paper-dialog yt-live-chat-paid-message-renderer', 'tp-yt-paper-dialog yt-live-chat-paid-sticker-renderer', 'yt-live-chat-ticker-paid-message-item-renderer[aria-selected="true"] yt-live-chat-paid-message-renderer', 'yt-live-chat-ticker-paid-message-item-renderer[selected] yt-live-chat-paid-message-renderer', 'yt-live-chat-ticker-paid-message-item-renderer #card:not([hidden]) yt-live-chat-paid-message-renderer', ]; const found = []; const seen = new Set(); for (const selector of selectors) { this.doc.querySelectorAll(selector).forEach((node) => { const msg = node.matches?.(SELECTORS.chatMessage) ? node : node.querySelector?.(SELECTORS.chatMessage) || node; if (!msg || seen.has(msg) || !msg.isConnected) return; if (!this.isSuperchatElement(msg)) return; const style = this.doc.defaultView?.getComputedStyle?.(msg); if (style && (style.display === 'none' || style.visibility === 'hidden')) return; seen.add(msg); found.push(msg); }); } return found; } syncOpenSuperchatPanels() { if (!this.doc || !this.isActive()) return; for (const msgEl of this.findOpenSuperchatCards()) { if (msgEl.querySelector('.ydm-sc-action-panel')) continue; this.openSuperchatPanel(msgEl); } } async translateMessage(msgEl) { if (!msgEl?.isConnected || this.busy.has(msgEl)) return; const parsed = readChatMessage(msgEl); if (!parsed) return; const settings = this.getSettings(); const sourceParts = parsed.superchat ? (parsed.bodyParts || parsed.parts) : parsed.parts; const sourceText = partsToText(sourceParts) || parsed.message; const shielded = shieldEmoteParts(sourceParts); const retryAfterFailure = hasChatTranslationError(msgEl) || hasChatTranslation(msgEl); this.busy.add(msgEl); setChatTranslationStatus(msgEl, 'pending', t('chatActionTranslating')); try { const outcome = await translateMessagesManual( shielded.text, settings.translateTargetLang, settings.translateProvider, { bypassCache: retryAfterFailure }, ); if (!msgEl.isConnected) return; const rendered = outcome?.trim() ? outcome : ''; if (!rendered || rendered === shielded.text || rendered === sourceText) { setChatTranslationStatus(msgEl, 'error', t('chatActionTranslateFailed')); return; } const parts = stripAmountFromParts( restoreEmoteParts(rendered, shielded.emotes) || [{ type: 'text', text: rendered }], parsed.amount, ); const message = partsToText(parts) || stripAmountFromText(rendered, parsed.amount); if (!message || message === sourceText) { setChatTranslationStatus(msgEl, 'error', t('chatActionTranslateFailed')); return; } applyChatTranslation(msgEl, message, sourceText); if (msgEl.querySelector('.ydm-sc-action-panel')) this.openSuperchatPanel(msgEl); } catch (_) { if (msgEl.isConnected) { setChatTranslationStatus(msgEl, 'error', t('chatActionTranslateFailed')); } } finally { this.busy.delete(msgEl); } } speakMessage(msgEl) { const parsed = readChatMessage(msgEl); if (!parsed) return; const translated = getChatTranslationText(msgEl); speakTextNow(translated || parsed.message); } favoriteMessage(msgEl) { const parsed = readChatMessage(msgEl); const text = String( partsToFavoriteText(parsed?.bodyParts || parsed?.parts || []) || parsed?.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; } addFavoriteText(text); } sendDanmaku(msgEl) { if (!this.danmakuEmitter || !this.isActive()) return; const settings = this.getSettings(); if (!settings.enabled) return; const parsed = readChatMessage(msgEl); if (!parsed) return; if (messageMatchesBlock(parsed.message, settings.blockKeywords)) return; const translated = getChatTranslationText(msgEl); const amount = parsed.amount || ''; const bodyParts = translated ? stripAmountFromParts([{ type: 'text', text: translated }], amount) : (parsed.bodyParts || parsed.parts); const bodyText = partsToText(bodyParts) || stripAmountFromText(translated || parsed.message, amount); const message = amount ? `${amount} ${bodyText}`.trim() : (translated || parsed.message); if (messageMatchesBlock(parsed.message, settings.blockKeywords)) return; if (messageMatchesBlock(message, settings.blockKeywords) || messageMatchesBlock(bodyText, settings.blockKeywords)) return; const textParts = amount ? [{ type: 'text', text: `${amount} ` }, ...bodyParts] : bodyParts; this.danmakuEmitter.enqueue({ username: parsed.username, usernameColor: parsed.usernameColor, profileUrl: parsed.profileUrl, avatarUrl: parsed.avatarUrl || '', originalMessage: parsed.message, parts: textParts, bodyParts, message, amount, translated: !!translated && translated !== parsed.message, superchat: !!parsed.superchat, superchatStyle: parsed.superchatStyle || null, }); } } class ChatObserver { constructor({ getSettings, danmakuEmitter, translateQueue, isActive, favoritesPanel = null }) { this.getSettings = getSettings; this.danmakuEmitter = danmakuEmitter; this.translateQueue = translateQueue; this.isActive = isActive; this.favoritesPanel = favoritesPanel; this.observer = null; this.chatRoot = null; this.readyAt = 0; this.stopped = false; this.starting = false; this.nextTryAt = 0; this.seenIds = new Set(); this.chatActions = new ChatActions({ getSettings, danmakuEmitter, isActive, favoritesPanel, }); } setFavoritesPanel(panel) { this.favoritesPanel = panel; if (this.chatActions) this.chatActions.favoritesPanel = panel; } isConnected() { return !!this.chatRoot?.isConnected; } async start() { if (this.starting || this.isConnected()) return; if (Date.now() < this.nextTryAt) return; this.starting = true; this.stopped = false; this.observer?.disconnect(); this.observer = null; this.chatActions?.unbind(); try { const chatRoot = await waitForChatList(() => this.stopped); if (this.stopped) return; if (!chatRoot) { this.nextTryAt = Date.now() + 4000; console.warn('[YDM] Live chat list not found'); return; } this.chatRoot = chatRoot; markExistingMessages(this.chatRoot); this.readyAt = Date.now(); this.chatActions.bind(this.chatRoot); this.observer = new MutationObserver((mutations) => { if (!this.isActive()) return; for (const mutation of mutations) { if (mutation.type === 'characterData') { const parent = mutation.target.parentElement; if (parent) this.handleNode(parent); continue; } for (const node of mutation.addedNodes) { if (node.nodeType === Node.TEXT_NODE) { if (node.parentElement) this.handleNode(node.parentElement); } else if (node.nodeType === Node.ELEMENT_NODE) { this.handleNode(node); } } } }); this.observer.observe(this.chatRoot, { childList: true, subtree: true, characterData: true, }); console.log('[YDM] Chat observer started'); } finally { this.starting = false; } } stop() { this.stopped = true; this.observer?.disconnect(); this.observer = null; this.chatActions?.stop(); this.chatRoot = null; this.seenIds.clear(); } remember(msgEl) { const id = msgEl.id; if (!id) return; this.seenIds.add(id); if (this.seenIds.size > 2000) { const oldest = this.seenIds.values().next().value; this.seenIds.delete(oldest); } } shouldEmit(msgEl) { if (!this.isActive()) return false; if (msgEl.classList.contains('ydm-processed')) return false; if (msgEl.id && this.seenIds.has(msgEl.id)) { msgEl.classList.add('ydm-processed'); return false; } if (Date.now() - this.readyAt < WARMUP_MS) { msgEl.classList.add('ydm-processed'); this.remember(msgEl); return false; } if (!isLiveMessage(msgEl, this.chatRoot)) { msgEl.classList.add('ydm-processed'); this.remember(msgEl); return false; } return true; } handleNode(node) { if (node.nodeType !== Node.ELEMENT_NODE) return; const found = []; if (node.matches?.(SELECTORS.chatMessage)) found.push(node); if (node.querySelectorAll) found.push(...node.querySelectorAll(SELECTORS.chatMessage)); const parentMessage = node.closest?.(SELECTORS.chatMessage); if (parentMessage) found.push(parentMessage); const seen = new Set(); for (const msgEl of found) { if (seen.has(msgEl)) continue; seen.add(msgEl); ensureAuthorProfileLink(msgEl); if (!this.shouldEmit(msgEl)) continue; const parsed = parseChatMessage(msgEl); if (!parsed) continue; this.remember(msgEl); this.emitDanmaku(parsed, msgEl); } } emitDanmaku(parsed, msgEl) { const settings = this.getSettings(); if (!this.isActive()) return; const wantDanmaku = !!settings.enabled; const wantChatTranslate = !!settings.translateEnabled && !!settings.translateInChat; if (!wantDanmaku && !wantChatTranslate) return; if (messageMatchesBlock(parsed.message, settings.blockKeywords)) { removeChatTranslation(msgEl); return; } // Super Chat: translate body only so the amount badge is not duplicated. const sourceParts = parsed.superchat ? (parsed.bodyParts || parsed.parts) : parsed.parts; const shielded = shieldEmoteParts(sourceParts); const base = { username: parsed.username, usernameColor: parsed.usernameColor, profileUrl: parsed.profileUrl, avatarUrl: parsed.avatarUrl || '', originalMessage: parsed.message, parts: parsed.parts, bodyParts: parsed.bodyParts || parsed.parts, amount: parsed.amount || '', superchat: !!parsed.superchat, superchatStyle: parsed.superchatStyle || null, }; if (!settings.translateEnabled) { removeChatTranslation(msgEl); if (wantDanmaku) { this.danmakuEmitter.enqueue({ ...base, message: parsed.message, translated: false, }); } return; } this.translateQueue.enqueue(shielded.text, (translated, original) => { if (!this.isActive()) return; const latest = this.getSettings(); const rendered = translated?.trim() ? translated : original; const bodyParts = stripAmountFromParts( restoreEmoteParts(rendered, shielded.emotes) || sourceParts, parsed.amount, ); const bodyText = partsToText(bodyParts) || stripAmountFromText(rendered, parsed.amount); const message = parsed.amount ? `${parsed.amount} ${bodyText}`.trim() : bodyText; const parts = parsed.amount ? [{ type: 'text', text: `${parsed.amount} ` }, ...bodyParts] : bodyParts; if (messageMatchesBlock(parsed.message, latest.blockKeywords) || messageMatchesBlock(message, latest.blockKeywords) || messageMatchesBlock(bodyText, latest.blockKeywords)) { removeChatTranslation(msgEl); return; } if (latest.translateEnabled && latest.translateInChat) { applyChatTranslation(msgEl, bodyText, partsToText(sourceParts) || parsed.message); } else { removeChatTranslation(msgEl); } if (!latest.enabled) return; const didTranslate = bodyText !== (partsToText(sourceParts) || ''); this.danmakuEmitter.enqueue({ ...base, parts, bodyParts, amount: parsed.amount || '', message, translated: didTranslate, }); }); } } const HISTORY_PAGE_LIMIT = 4000; function pageWindow() { return typeof unsafeWindow !== 'undefined' ? unsafeWindow : window; } function currentVideoId() { try { const url = new URL(location.href); const fromQuery = url.searchParams.get('v'); if (fromQuery) return fromQuery; } catch (_) { /* ignore */ } const live = location.pathname.match(/^\/live\/([^/]+)/); return live?.[1] || 'youtube'; } function currentDurationSeconds() { const video = document.querySelector('video.html5-main-video'); if (video && Number.isFinite(video.duration) && video.duration > 0) return video.duration; const seconds = Number(pageWindow().ytInitialPlayerResponse?.videoDetails?.lengthSeconds); return Number.isFinite(seconds) && seconds > 0 ? seconds : 0; } function chatIframeSrc() { const iframe = document.querySelector(SELECTORS.chatFrame); return iframe?.src || ''; } function liveChatRenderer() { return pageWindow().ytInitialData?.contents?.twoColumnWatchNextResults?.conversationBar?.liveChatRenderer || null; } function isLiveChatMode() { const src = chatIframeSrc(); if (src.includes('live_chat_replay')) return false; if (src.includes('live_chat')) return true; const bar = liveChatRenderer(); if (bar && typeof bar.isReplay === 'boolean') return bar.isReplay === false; return !!document.querySelector('.ytp-live-badge'); } function readReplayContinuation() { const src = chatIframeSrc(); if (src.includes('live_chat_replay')) { try { const token = new URL(src, location.origin).searchParams.get('continuation'); if (token) return token; } catch (_) { /* ignore */ } } const bar = liveChatRenderer(); const token = bar?.continuations?.[0]?.reloadContinuationData?.continuation; if (!token) return ''; if (bar.isReplay === false) return ''; return token; } function readChatTimestampText(msgEl) { return msgEl.querySelector('#timestamp')?.textContent?.replace(/\s+/g, ' ').trim() || ''; } function parseChatTimestamp(text) { const cleaned = String(text || '').replace(/\s+/g, ' ').trim(); if (!cleaned) return { time: '', offsetMs: null }; // Live chat wall clock, e.g. "9:40 PM" / "9:40:12 PM" const wall = cleaned.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?\s*([AP]M)$/i); if (wall) { let hours = Number(wall[1]); const minutes = Number(wall[2]); const seconds = Number(wall[3] || 0); const ampm = wall[4].toUpperCase(); if (hours >= 1 && hours <= 12 && minutes <= 59 && seconds <= 59) { if (ampm === 'PM' && hours < 12) hours += 12; if (ampm === 'AM' && hours === 12) hours = 0; return { time: cleaned, offsetMs: ((hours * 3600) + (minutes * 60) + seconds) * 1000, }; } } // Replay / video offset, e.g. "1:02:03" or "12:34" const video = cleaned.match(/^(?:(\d+):)?(\d{1,2}):(\d{2})$/); if (video) { const hours = Number(video[1] || 0); const minutes = Number(video[2]); const seconds = Number(video[3]); if (minutes <= 59 && seconds <= 59) { return { time: cleaned, offsetMs: ((hours * 3600) + (minutes * 60) + seconds) * 1000, }; } } return { time: cleaned, offsetMs: null }; } function historyTypeFromEl(msgEl) { const tag = msgEl.tagName; if (tag === 'YT-LIVE-CHAT-PAID-MESSAGE-RENDERER' || tag === 'YT-LIVE-CHAT-PAID-STICKER-RENDERER') { return 'superchat'; } if (tag === 'YT-LIVE-CHAT-MEMBERSHIP-ITEM-RENDERER') return 'member'; return 'message'; } function parseDomHistoryItem(msgEl, index) { if (!msgEl || msgEl.hasAttribute('is-deleted')) return null; const author = msgEl.querySelector('#author-name')?.textContent?.replace(/\s+/g, ' ').trim() || ''; const amount = msgEl.querySelector('#purchase-amount, #purchase-amount-chip') ?.textContent?.replace(/\s+/g, ' ').trim() || ''; const messageEl = msgEl.querySelector('#message'); let message = messageEl ? partsToText(collectParts(messageEl)) : ''; if (!message || msgEl.tagName === 'YT-LIVE-CHAT-MEMBERSHIP-ITEM-RENDERER') { const primary = partsToText(collectParts(msgEl.querySelector('#header-primary-text, #primary-text'))); const sub = partsToText(collectParts(msgEl.querySelector('#header-subtext'))); const headerText = [primary, sub].filter(Boolean).join(' ').replace(/\s+/g, ' ').trim(); message = [headerText, message].filter(Boolean).join(' ').replace(/\s+/g, ' ').trim(); } if (!author && !message && !amount) return null; const stamp = parseChatTimestamp(readChatTimestampText(msgEl)); return { id: msgEl.id || '', offsetMs: stamp.offsetMs == null ? index : stamp.offsetMs, time: stamp.time, author, message, amount, type: historyTypeFromEl(msgEl), }; } async function collectLiveChatFromDom({ onProgress, shouldAbort, shouldPause } = {}) { await waitHistoryControl(shouldPause, shouldAbort); let list = getChatDocument()?.querySelector(SELECTORS.chatItems) || null; if (!list) { list = await waitForChatList(() => shouldAbort?.(), 8000); } if (!list) { const error = new Error('no-history'); error.code = 'no-history'; throw error; } const nodes = [...list.querySelectorAll(SELECTORS.chatMessage)]; const items = []; const seen = new Set(); for (let i = 0; i < nodes.length; i += 1) { if (i % 25 === 0) { await waitHistoryControl(shouldPause, shouldAbort); onProgress?.(items.length); } const item = parseDomHistoryItem(nodes[i], i); if (!item) continue; const key = item.id || `${item.offsetMs}:${item.author}:${item.message}:${item.amount}`; if (seen.has(key)) continue; seen.add(key); items.push(item); } onProgress?.(items.length); return items; } function runsToText(node) { if (!node) return ''; if (typeof node.simpleText === 'string') return node.simpleText; if (!Array.isArray(node.runs)) return ''; return node.runs.map((run) => { if (run?.text) return run.text; const emoji = run?.emoji; return emoji?.shortcuts?.[0] || emoji?.emojiId || ''; }).join(''); } function parseHistoryRenderer(renderer, type, offsetMs) { if (!renderer) return null; const author = runsToText(renderer.authorName).replace(/\s+/g, ' ').trim(); const message = (runsToText(renderer.message) || runsToText(renderer.headerSubtext) || '').replace(/\s+/g, ' ').trim(); const amount = runsToText(renderer.purchaseAmountText).replace(/\s+/g, ' ').trim(); if (!author && !message && !amount) return null; return { id: renderer.id || '', offsetMs, author, message, amount, type, }; } function parseHistoryAction(action, fallbackOffset) { const replay = action?.replayChatItemAction; const offsetMs = Number(replay?.videoOffsetTimeMsec ?? fallbackOffset) || 0; const nested = replay?.actions || [action]; const items = []; for (const entry of nested) { const item = entry?.addChatItemAction?.item; if (!item) continue; const parsed = parseHistoryRenderer(item.liveChatTextMessageRenderer, 'message', offsetMs) || parseHistoryRenderer(item.liveChatPaidMessageRenderer, 'superchat', offsetMs) || parseHistoryRenderer(item.liveChatPaidStickerRenderer, 'superchat', offsetMs) || parseHistoryRenderer(item.liveChatMembershipItemRenderer, 'member', offsetMs); if (parsed) items.push(parsed); } return items; } async function postReplayContinuation(token, offsetMs) { const page = pageWindow(); const key = page.ytcfg?.get?.('INNERTUBE_API_KEY'); const context = page.ytcfg?.get?.('INNERTUBE_CONTEXT'); if (!key || !context || typeof page.fetch !== 'function') { const error = new Error('failed'); error.code = 'failed'; throw error; } const response = await page.fetch( `https://www.youtube.com/youtubei/v1/live_chat/get_live_chat_replay?prettyPrint=false&key=${encodeURIComponent(key)}`, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ context, continuation: token, currentPlayerState: { playerOffsetMs: String(Math.max(0, Math.floor(offsetMs || 0))) }, }), }, ); if (!response.ok) { const error = new Error('failed'); error.code = 'failed'; throw error; } return response.json(); } async function waitHistoryControl(shouldPause, shouldAbort) { while (shouldPause?.()) { if (shouldAbort?.()) { const error = new Error('aborted'); error.code = 'aborted'; throw error; } await delay(120); } if (shouldAbort?.()) { const error = new Error('aborted'); error.code = 'aborted'; throw error; } } async function fetchLiveChatHistory({ onProgress, shouldAbort, shouldPause, startMs = 0, endMs = Infinity, } = {}) { if (isLiveChatMode()) { return collectLiveChatFromDom({ onProgress, shouldAbort, shouldPause }); } let token = readReplayContinuation(); if (!token) { await waitForElement(SELECTORS.chatFrame, document, 8000); token = readReplayContinuation(); } if (!token) { const error = new Error('no-history'); error.code = 'no-history'; throw error; } const items = []; const seen = new Set(); const seenTokens = new Set(); let offset = Math.max(0, startMs); let pages = 0; while (token && pages < HISTORY_PAGE_LIMIT) { await waitHistoryControl(shouldPause, shouldAbort); if (seenTokens.has(token)) break; seenTokens.add(token); pages += 1; const data = await postReplayContinuation(token, offset); await waitHistoryControl(shouldPause, shouldAbort); const live = data?.continuationContents?.liveChatContinuation; if (!live) break; let pageMax = 0; for (const action of live.actions || []) { for (const item of parseHistoryAction(action, offset)) { pageMax = Math.max(pageMax, item.offsetMs); if (item.offsetMs < startMs || item.offsetMs > endMs) continue; const key = item.id || `${item.offsetMs}:${item.author}:${item.message}:${item.amount}`; if (seen.has(key)) continue; seen.add(key); items.push(item); } } onProgress?.(items.length); const next = live.continuations?.[0]?.liveChatReplayContinuationData; if (!next?.continuation) break; if (Number.isFinite(endMs) && pageMax > endMs) break; token = next.continuation; offset = pageMax || offset; await delay(60); } items.sort((a, b) => a.offsetMs - b.offsetMs); return items; } function splitHistoryTerms(value) { return String(value || '') .split(/[,,\n]/) .map((item) => item.trim().toLowerCase()) .filter(Boolean); } function filterLiveChatHistory(items, { keywords, usernames, includeSuperchat }) { const words = splitHistoryTerms(keywords); const names = splitHistoryTerms(usernames); return items.filter((item) => { if (item.type === 'superchat' && !includeSuperchat) return false; const message = `${item.amount} ${item.message}`.toLowerCase(); const author = item.author.toLowerCase(); if (words.length && !words.some((word) => message.includes(word))) return false; if (names.length && !names.some((name) => author.includes(name))) return false; return true; }); } function formatHistoryClock(offsetMs) { const total = Math.max(0, Math.floor(Number(offsetMs) / 1000)); const hours = Math.floor(total / 3600); const minutes = Math.floor((total % 3600) / 60); const seconds = total % 60; const mm = String(minutes).padStart(2, '0'); const ss = String(seconds).padStart(2, '0'); return hours > 0 ? `${hours}:${mm}:${ss}` : `${mm}:${ss}`; } function historyItemTime(item) { if (item?.time) return item.time; return formatHistoryClock(item?.offsetMs); } function formatHistoryRangeLabel(seconds) { return formatHistoryClock(Math.max(0, seconds) * 1000); } function csvCell(value) { const text = String(value ?? ''); if (/[",\n]/.test(text)) return `"${text.replace(/"/g, '""')}"`; return text; } function exportLiveChatText(items, format, videoId) { if (format === 'json') { return JSON.stringify({ videoId, url: location.href, exportedAt: new Date().toISOString(), count: items.length, messages: items.map((item) => ({ offsetMs: item.offsetMs, time: historyItemTime(item), author: item.author, message: item.message, type: item.type, amount: item.amount || undefined, })), }, null, 2); } if (format === 'csv') { const rows = ['offset_ms,time,author,message,type,amount']; for (const item of items) { rows.push([ item.offsetMs, historyItemTime(item), item.author, item.message, item.type, item.amount || '', ].map(csvCell).join(',')); } return `\uFEFF${rows.join('\n')}`; } return items.map((item) => { const amount = item.amount ? ` (${item.amount})` : ''; return `[${historyItemTime(item)}] ${item.author}${amount}: ${item.message}`; }).join('\n'); } function downloadLiveChatFile(items, format, videoId) { const ext = format === 'json' || format === 'csv' ? format : 'txt'; const mime = format === 'json' ? 'application/json' : format === 'csv' ? 'text/csv' : 'text/plain'; const blob = new Blob([exportLiveChatText(items, ext, videoId)], { type: `${mime};charset=utf-8` }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = `livechat_${videoId}.${ext}`; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1500); } const VIEWERS_POLL_MS = 15000; let metadataContinuation = ''; let metadataVideoId = ''; function livePageWindow() { return typeof unsafeWindow !== 'undefined' ? unsafeWindow : window; } function liveVideoId() { try { const fromQuery = new URL(location.href).searchParams.get('v'); if (fromQuery) return fromQuery; } catch (_) { /* ignore */ } const live = location.pathname.match(/^\/live\/([^/]+)/); return live?.[1] || ''; } function parseCompactCount(text) { const raw = String(text || '').replace(/\u00a0/g, ' ').replace(/\s+/g, ' ').trim(); if (!raw) return null; const match = raw.replace(/,/g, '').match(/(\d+(?:\.\d+)?)\s*([KkMm万萬億bB百千])?/); if (!match) return null; let value = Number(match[1]); if (!Number.isFinite(value)) return null; const unit = match[2] || ''; if (/[Kk]/.test(unit)) value *= 1e3; else if (/[Mm]/.test(unit)) value *= 1e6; else if (/[Bb]/.test(unit)) value *= 1e9; else if (unit === '千') value *= 1e3; else if (unit === '万' || unit === '萬') value *= 1e4; else if (unit === '億') value *= 1e8; else if (unit === '百') value *= 1e2; return Math.round(value); } function runsOrText(node) { if (!node) return ''; if (typeof node.simpleText === 'string') return node.simpleText; if (typeof node === 'string') return node; if (!Array.isArray(node.runs)) return ''; return node.runs.map((run) => run?.text || '').join(''); } function walkForVideoViewCount(node, depth = 0) { if (!node || typeof node !== 'object' || depth > 14) return null; if (node.videoViewCountRenderer) return node.videoViewCountRenderer; if (Array.isArray(node)) { for (const item of node) { const found = walkForVideoViewCount(item, depth + 1); if (found) return found; } return null; } for (const value of Object.values(node)) { if (value && typeof value === 'object') { const found = walkForVideoViewCount(value, depth + 1); if (found) return found; } } return null; } function readViewCountRendererFromData() { const page = livePageWindow(); const roots = [ page.ytInitialData, page.ytInitialPlayerResponse, ].filter(Boolean); for (const root of roots) { const renderer = walkForVideoViewCount(root); if (renderer) return renderer; } return null; } function readViewersFromDom() { const nodes = document.querySelectorAll( 'ytd-video-view-count-renderer .view-count, ytd-video-view-count-renderer span.yt-formatted-string, #info-container #info span.yt-formatted-string, ytd-watch-metadata #info span.yt-formatted-string', ); for (const node of nodes) { const text = node.textContent?.replace(/\s+/g, ' ').trim() || ''; if (!text) continue; if (/watching|視聴|在看|watching now/i.test(text)) { const count = parseCompactCount(text); if (count != null) return { concurrent: count, concurrentText: text }; } } return { concurrent: null, concurrentText: '' }; } function readTotalViewsFromPlayer() { const details = livePageWindow().ytInitialPlayerResponse?.videoDetails; const raw = details?.viewCount; const total = Number(raw); return Number.isFinite(total) && total >= 0 ? total : null; } function isCurrentlyLive() { if (document.querySelector('.ytp-time-display.ytp-live, .ytp-live-badge')) return true; const details = livePageWindow().ytInitialPlayerResponse?.videoDetails; if (details?.isLive) return true; const micro = livePageWindow().ytInitialPlayerResponse?.microformat?.playerMicroformatRenderer; if (micro?.liveBroadcastDetails?.isLiveNow) return true; const renderer = readViewCountRendererFromData(); return !!renderer?.isLive; } function readViewersFromPageData() { const renderer = readViewCountRendererFromData(); let concurrent = null; let concurrentText = ''; if (renderer) { concurrentText = runsOrText(renderer.viewCount) || runsOrText(renderer.shortViewCount) || ''; if (renderer.isLive || /watching|視聴|在看/i.test(concurrentText)) { concurrent = parseCompactCount(concurrentText); } } const total = readTotalViewsFromPlayer(); return { concurrent, concurrentText, total }; } async function postInnertube(path, body) { const page = livePageWindow(); const key = page.ytcfg?.get?.('INNERTUBE_API_KEY'); const context = page.ytcfg?.get?.('INNERTUBE_CONTEXT'); if (!key || !context || typeof page.fetch !== 'function') return null; try { const response = await page.fetch( `https://www.youtube.com/youtubei/v1/${path}?prettyPrint=false&key=${encodeURIComponent(key)}`, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ context, ...body }), }, ); if (!response.ok) return null; return response.json(); } catch (_) { return null; } } function extractViewersFromRenderer(renderer) { if (!renderer) return { concurrent: null, concurrentText: '' }; const concurrentText = runsOrText(renderer.viewCount) || runsOrText(renderer.shortViewCount) || ''; const concurrent = (renderer.isLive || /watching|視聴|在看/i.test(concurrentText)) ? parseCompactCount(concurrentText) : parseCompactCount(concurrentText); return { concurrent, concurrentText }; } function extractViewersFromNext(data) { return extractViewersFromRenderer(walkForVideoViewCount(data)); } function extractTotalFromPlayer(data) { const total = Number(data?.videoDetails?.viewCount); return Number.isFinite(total) && total >= 0 ? total : null; } function extractContinuation(data) { if (!data || typeof data !== 'object') return ''; if (typeof data.continuation === 'string' && data.continuation) return data.continuation; const items = data.continuations || data.actions || []; if (Array.isArray(items)) { for (const item of items) { const token = item?.nextContinuationData?.continuation || item?.reloadContinuationData?.continuation || item?.timedContinuationData?.continuation || item?.continuation; if (typeof token === 'string' && token) return token; } } return ''; } function extractFromUpdatedMetadata(data) { let concurrent = null; let concurrentText = ''; let total = null; const list = Array.isArray(data?.actions) ? data.actions : []; for (const action of list) { const viewership = action?.updateViewershipAction?.viewCount || action?.updateViewershipAction || action?.viewCount; const renderer = viewership?.videoViewCountRenderer || walkForVideoViewCount(viewership) || walkForVideoViewCount(action); if (renderer) { const extracted = extractViewersFromRenderer(renderer); if (extracted.concurrent != null) { concurrent = extracted.concurrent; concurrentText = extracted.concurrentText || concurrentText; } else if (extracted.concurrentText) { concurrentText = extracted.concurrentText; concurrent = parseCompactCount(extracted.concurrentText); } } const viewCountText = action?.updateViewershipAction?.viewCountText || action?.viewCountText || runsOrText(action?.updateViewershipAction?.viewCount); if (!concurrentText && viewCountText) { concurrentText = String(viewCountText); concurrent = parseCompactCount(concurrentText); } } // Some payloads expose top-level view count fields (live = watching now). if (concurrent == null) { const topText = data?.viewCountText || runsOrText(data?.viewCount); if (topText) { concurrentText = String(topText); concurrent = parseCompactCount(concurrentText); } else if (data?.viewCount != null && (typeof data.viewCount === 'string' || typeof data.viewCount === 'number')) { concurrent = parseCompactCount(String(data.viewCount)); } } if (data?.isLive === false && concurrent != null) { total = concurrent; concurrent = null; } return { concurrent, concurrentText, total, continuation: extractContinuation(data), }; } async function fetchUpdatedMetadata(videoId) { if (!videoId) return null; if (metadataVideoId !== videoId) { metadataVideoId = videoId; metadataContinuation = ''; } const body = metadataContinuation ? { continuation: metadataContinuation } : { videoId }; const data = await postInnertube('updated_metadata', body); if (!data && metadataContinuation) { metadataContinuation = ''; return postInnertube('updated_metadata', { videoId }); } return data; } async function fetchLiveViewers(videoId) { const live = isCurrentlyLive(); const fallback = readViewersFromPageData(); const fromDom = readViewersFromDom(); let concurrent = null; let concurrentText = ''; let total = null; if (videoId) { const [metaData, playerData] = await Promise.all([ fetchUpdatedMetadata(videoId), postInnertube('player', { videoId, contentCheckOk: true, racyCheckOk: true, }), ]); if (metaData) { const meta = extractFromUpdatedMetadata(metaData); if (meta.continuation) metadataContinuation = meta.continuation; if (meta.concurrent != null) { concurrent = meta.concurrent; concurrentText = meta.concurrentText || concurrentText; } if (meta.total != null) total = meta.total; } // If metadata didn't yield concurrent, try a fresh next response. if (concurrent == null) { const nextData = await postInnertube('next', { videoId }); if (nextData) { const extracted = extractViewersFromNext(nextData); if (extracted.concurrent != null) { concurrent = extracted.concurrent; concurrentText = extracted.concurrentText || concurrentText; } } } if (playerData) { const playerTotal = extractTotalFromPlayer(playerData); if (playerTotal != null) total = playerTotal; try { const page = livePageWindow(); if (page.ytInitialPlayerResponse?.videoDetails && playerData.videoDetails?.viewCount != null) { page.ytInitialPlayerResponse.videoDetails.viewCount = String(playerData.videoDetails.viewCount); } } catch (_) { /* ignore */ } } } // Last resort: current DOM / initial page snapshot (may be stale). if (concurrent == null) { concurrent = fromDom.concurrent ?? fallback.concurrent; concurrentText = fromDom.concurrentText || fallback.concurrentText || concurrentText; } if (total == null) total = fallback.total; return { live, concurrent, concurrentText, total, }; } function formatCountNumber(count) { const n = Number(count); if (!Number.isFinite(n) || n < 0) return ''; try { return new Intl.NumberFormat(undefined, { notation: n >= 10000 ? 'compact' : 'standard', maximumFractionDigits: 1 }).format(n); } catch (_) { if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`; if (n >= 1e4) return `${(n / 1e3).toFixed(1)}K`; return String(Math.round(n)); } } function formatLiveConcurrentLabel(count) { if (count == null) return 'Live'; return `Live · ${formatCountNumber(count)} ${t('liveWatching')}`; } class SettingsPanel { constructor({ settings, onChange, onClose }) { this.settings = settings; this.onChange = onChange; this.onClose = onClose; this.balloon = null; this.anchor = null; this.isOpen = false; this.boundReposition = () => this.reposition(); this.boundOutsideClick = (e) => this.handleOutsideClick(e); } toggle(anchor) { if (this.isOpen && this.anchor === anchor) this.close(); else this.open(anchor); } open(anchor) { this.close(); this.anchor = anchor; this.isOpen = true; this.render(); this.reposition(); requestAnimationFrame(() => this.reposition()); window.addEventListener('resize', this.boundReposition); document.addEventListener('fullscreenchange', this.boundReposition); document.addEventListener('webkitfullscreenchange', this.boundReposition); document.addEventListener('scroll', this.boundReposition, true); setTimeout(() => document.addEventListener('click', this.boundOutsideClick, true), 0); } close() { const wasOpen = this.isOpen; 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(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) { const row = document.createElement('div'); row.className = 'ydm-balloon-row ydm-balloon-row--toggle'; const labelEl = document.createElement('span'); labelEl.className = 'ydm-balloon-label'; labelEl.textContent = label; const switchWrap = document.createElement('label'); switchWrap.className = 'ydm-switch'; const input = document.createElement('input'); input.type = 'checkbox'; input.checked = !!this.settings[key]; const slider = document.createElement('span'); slider.className = 'ydm-switch-slider'; input.addEventListener('change', () => { this.updateSetting(key, input.checked); }); switchWrap.append(input, slider); row.append(labelEl, switchWrap); return row; } createRange(label, key, min, max, step, format) { const row = document.createElement('div'); row.className = 'ydm-balloon-row ydm-balloon-row--field'; const header = document.createElement('div'); header.className = 'ydm-balloon-field-header'; const labelEl = document.createElement('span'); labelEl.className = 'ydm-balloon-label'; labelEl.textContent = label; const valueSpan = document.createElement('span'); valueSpan.className = 'ydm-balloon-value'; valueSpan.textContent = format(this.settings[key]); header.append(labelEl, valueSpan); const input = document.createElement('input'); input.type = 'range'; input.className = 'ydm-balloon-range'; input.min = String(min); input.max = String(max); input.step = String(step); input.value = String(this.settings[key]); input.addEventListener('input', () => { const val = Number(input.value); valueSpan.textContent = format(val); this.updateSetting(key, val); }); row.append(header, input); return row; } createSelect(label, key, options) { const row = document.createElement('div'); row.className = 'ydm-balloon-row ydm-balloon-row--field'; const labelEl = document.createElement('div'); labelEl.className = 'ydm-balloon-label'; labelEl.textContent = label; const select = document.createElement('select'); select.className = 'ydm-balloon-select'; for (const opt of options) { const option = document.createElement('option'); option.value = opt.value; option.textContent = opt.label; if (opt.value === this.settings[key]) option.selected = true; select.appendChild(option); } select.addEventListener('change', () => { this.updateSetting(key, select.value); }); row.append(labelEl, select); return row; } render() { this.balloon = document.createElement('div'); this.balloon.className = 'ydm-balloon'; this.balloon.setAttribute('role', 'dialog'); this.balloon.setAttribute('aria-label', t('panelTitle')); this.balloon.addEventListener('click', (e) => e.stopPropagation()); this.balloon.addEventListener('pointerdown', (e) => e.stopPropagation()); const header = document.createElement('div'); header.className = 'ydm-balloon-header'; const title = document.createElement('div'); title.className = 'ydm-balloon-title'; title.textContent = t('panelTitle'); const closeBtn = document.createElement('button'); closeBtn.type = 'button'; closeBtn.className = 'ydm-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 = 'ydm-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'), 'opacity', 10, 100, 5, (v) => `${v}%`), this.createSelect(t('fontFamily'), 'fontFamily', getFontOptions()), this.createSelect(t('scrollArea'), 'scrollArea', getScrollAreaOptions()), this.createDivider(), this.createToggle(t('superchatPinned'), 'superchatPinned'), this.createSelect(t('superchatPosition'), 'superchatPosition', getSuperchatPositionOptions()), this.createToggle(t('superchatCompact'), 'superchatCompact'), this.createToggle(t('superchatOverlayDanmaku'), 'superchatOverlayDanmaku'), this.createRange(t('superchatDuration'), 'superchatDuration', 2, 300, 1, (v) => `${v} ${t('speechSeconds')}`), this.createToggle(t('superchatDurationFollowYoutube'), 'superchatDurationFollowYoutube'), this.createSelect(t('superchatAnimation'), 'superchatAnimation', getSuperchatAnimationOptions()), this.createDivider(), this.createKeywordBlock(), this.createDivider(), this.createToggle(t('translateEnable'), 'translateEnabled'), this.createToggle(t('translateInChat'), 'translateInChat'), 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.balloon.append(header, body); const mountTarget = getFullscreenElement() || document.body; mountTarget.appendChild(this.balloon); } createKeywordBlock() { const row = document.createElement('div'); row.className = 'ydm-balloon-row ydm-balloon-row--field'; const label = document.createElement('div'); label.className = 'ydm-balloon-label'; label.textContent = t('blockKeywords'); const hint = document.createElement('div'); hint.className = 'ydm-keyword-hint'; hint.textContent = t('blockKeywordHint'); const form = document.createElement('div'); form.className = 'ydm-keyword-add'; const input = document.createElement('input'); input.type = 'text'; input.className = 'ydm-balloon-input'; input.maxLength = 40; input.placeholder = t('blockKeywordPlaceholder'); input.setAttribute('aria-label', t('blockKeywords')); const addBtn = document.createElement('button'); addBtn.type = 'button'; addBtn.className = 'ydm-keyword-add-btn'; addBtn.textContent = t('blockKeywordAdd'); const list = document.createElement('div'); list.className = 'ydm-keyword-list'; const renderList = () => { list.replaceChildren(); for (const word of this.settings.blockKeywords || []) { const chip = document.createElement('span'); chip.className = 'ydm-keyword-chip'; const text = document.createElement('span'); text.textContent = word; const remove = document.createElement('button'); remove.type = 'button'; remove.className = 'ydm-keyword-remove'; remove.setAttribute('aria-label', `${t('blockKeywordRemove')} ${word}`); remove.textContent = '×'; remove.addEventListener('click', (e) => { e.stopPropagation(); const next = (this.settings.blockKeywords || []).filter((item) => item !== word); this.updateSetting('blockKeywords', next); renderList(); }); chip.append(text, remove); list.appendChild(chip); } }; const addWord = () => { const word = input.value.trim().slice(0, 40); if (!word) return; const current = this.settings.blockKeywords || []; if (current.some((item) => item.toLowerCase() === word.toLowerCase())) { input.value = ''; return; } if (current.length >= 50) return; this.updateSetting('blockKeywords', [...current, word]); input.value = ''; renderList(); }; addBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); addWord(); }); input.addEventListener('keydown', (e) => { e.stopPropagation(); if (e.key !== 'Enter') return; e.preventDefault(); addWord(); }); form.append(input, addBtn); row.append(label, hint, form, list); renderList(); return row; } createDivider() { const div = document.createElement('div'); div.className = 'ydm-balloon-divider'; return div; } } class SpeechPanel { constructor({ settings, onChange, onClose }) { this.settings = settings; this.onChange = onChange; this.onClose = onClose; this.balloon = null; this.anchor = null; this.isOpen = false; this.langSelect = null; this.voiceSelect = null; this.boundReposition = () => this.reposition(); this.boundOutsideClick = (e) => this.handleOutsideClick(e); this.boundVoices = () => this.fillVoices(); } toggle(anchor) { if (this.isOpen && this.anchor === anchor) this.close(); else this.open(anchor); } open(anchor) { this.close(); this.anchor = anchor; this.isOpen = true; this.render(); this.reposition(); requestAnimationFrame(() => this.reposition()); window.speechSynthesis?.addEventListener('voiceschanged', this.boundVoices); window.addEventListener('resize', this.boundReposition); document.addEventListener('fullscreenchange', this.boundReposition); document.addEventListener('webkitfullscreenchange', this.boundReposition); document.addEventListener('scroll', this.boundReposition, true); setTimeout(() => document.addEventListener('click', this.boundOutsideClick, true), 0); } close() { const wasOpen = this.isOpen; this.isOpen = false; this.anchor = null; this.balloon?.remove(); this.balloon = null; this.langSelect = null; this.voiceSelect = null; window.speechSynthesis?.removeEventListener('voiceschanged', this.boundVoices); window.removeEventListener('resize', this.boundReposition); document.removeEventListener('fullscreenchange', this.boundReposition); document.removeEventListener('webkitfullscreenchange', this.boundReposition); document.removeEventListener('scroll', this.boundReposition, true); document.removeEventListener('click', this.boundOutsideClick, true); if (wasOpen) this.onClose?.(); } handleOutsideClick(e) { if (!this.balloon || !this.isOpen) return; if (this.balloon.contains(e.target)) return; if (this.anchor?.contains(e.target)) return; this.close(); } reposition() { if (!this.balloon || !this.anchor) return; const mountTarget = getFullscreenElement() || document.body; if (this.balloon.parentElement !== mountTarget) { mountTarget.appendChild(this.balloon); } const rect = this.anchor.getBoundingClientRect(); const width = 320; const gap = 8; const height = this.balloon.offsetHeight; let left = rect.right - width; let top = rect.top - height - gap; left = clamp(left, 8, window.innerWidth - width - 8); if (top < 8) top = rect.bottom + gap; Object.assign(this.balloon.style, { position: 'fixed', left: `${left}px`, top: `${top}px`, width: `${width}px`, zIndex: '100000', }); } updateSetting(key, value) { this.settings[key] = value; saveSettings(this.settings); this.onChange(this.settings); if ((key === 'speechEnabled' && !value) || (key === 'speechVolume' && Number(value) <= 0)) { cancelSpeech(); } } createToggle(label, key) { const row = document.createElement('div'); row.className = 'ydm-balloon-row ydm-balloon-row--toggle'; const labelEl = document.createElement('span'); labelEl.className = 'ydm-balloon-label'; labelEl.textContent = label; const switchWrap = document.createElement('label'); switchWrap.className = 'ydm-switch'; const input = document.createElement('input'); input.type = 'checkbox'; input.checked = !!this.settings[key]; const slider = document.createElement('span'); slider.className = 'ydm-switch-slider'; input.addEventListener('change', () => { this.updateSetting(key, input.checked); }); switchWrap.append(input, slider); row.append(labelEl, switchWrap); return row; } render() { this.balloon = document.createElement('div'); this.balloon.className = 'ydm-balloon ydm-speech-panel'; const header = document.createElement('div'); header.className = 'ydm-balloon-header'; const title = document.createElement('div'); title.className = 'ydm-balloon-title'; title.textContent = t('speechPanelTitle'); const closeBtn = document.createElement('button'); closeBtn.type = 'button'; closeBtn.className = 'ydm-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 = 'ydm-balloon-body ydm-balloon-body--speech'; if (!speechSupported()) { const note = document.createElement('p'); note.className = 'ydm-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 = 'ydm-balloon-row ydm-balloon-row--field'; const label = document.createElement('span'); label.className = 'ydm-balloon-label'; label.textContent = t('speechTest'); const controls = document.createElement('div'); controls.className = 'ydm-speech-test'; const input = document.createElement('input'); input.type = 'text'; input.className = 'ydm-balloon-input'; input.value = t('speechTestText'); const play = document.createElement('button'); play.type = 'button'; play.className = 'ydm-speech-play'; play.textContent = '▶'; 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 = 'ydm-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 = 'ydm-balloon-row ydm-balloon-row--field'; const langLabel = document.createElement('span'); langLabel.className = 'ydm-balloon-label'; langLabel.textContent = t('speechVoiceLang'); this.langSelect = document.createElement('select'); this.langSelect.className = 'ydm-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 = 'ydm-balloon-label'; voiceLabel.textContent = t('speechVoice'); this.voiceSelect = document.createElement('select'); this.voiceSelect.className = 'ydm-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 = 'ydm-balloon-row ydm-balloon-row--field'; const header = document.createElement('div'); header.className = 'ydm-balloon-field-header'; const labelEl = document.createElement('span'); labelEl.className = 'ydm-balloon-label'; labelEl.textContent = label; const valueSpan = document.createElement('span'); valueSpan.className = 'ydm-balloon-value'; valueSpan.textContent = format(this.settings[key]); header.append(labelEl, valueSpan); const input = document.createElement('input'); input.type = 'range'; input.className = 'ydm-balloon-range'; input.min = String(min); input.max = String(max); input.step = String(step); input.value = String(this.settings[key]); input.addEventListener('input', () => { const val = Number(input.value); valueSpan.textContent = format(val); this.updateSetting(key, val); }); row.append(header, input); return row; } createSection(text) { const el = document.createElement('div'); el.className = 'ydm-balloon-section'; el.textContent = text; return el; } createHint(text) { const el = document.createElement('p'); el.className = 'ydm-speech-note'; el.textContent = text; return el; } createDivider() { const el = document.createElement('div'); el.className = 'ydm-balloon-divider'; return el; } } class HistoryPanel { constructor({ onClose }) { this.onClose = onClose; this.balloon = null; this.anchor = null; this.isOpen = false; this.busy = false; this.abort = false; this.paused = false; this.progressCount = 0; this.keywords = ''; this.usernames = ''; this.includeSuperchat = true; this.format = 'json'; this.startSec = 0; this.endSec = 0; this.statusEl = null; this.downloadBtn = null; this.pauseBtn = null; this.resumeBtn = null; this.cancelBtn = null; this.boundReposition = () => this.reposition(); this.boundOutsideClick = (e) => this.handleOutsideClick(e); } toggle(anchor) { if (this.isOpen && this.anchor === anchor) this.close(); else this.open(anchor); } open(anchor) { this.close(); this.anchor = anchor; this.isOpen = true; const duration = currentDurationSeconds(); if (!this.endSec || this.endSec > duration) this.endSec = duration; 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.abort = true; this.paused = false; this.busy = false; this.isOpen = false; this.anchor = null; this.balloon?.remove(); this.balloon = null; this.statusEl = null; this.downloadBtn = null; this.pauseBtn = null; this.resumeBtn = null; this.cancelBtn = 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(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 = 340; 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, isError) { if (!this.statusEl) return; this.statusEl.textContent = text; this.statusEl.classList.toggle('ydm-history-status--error', !!isError); } setProgressStatus() { const count = this.progressCount; if (this.paused) { this.setStatus( count > 0 ? `${t('historyPaused')} · ${t('historyProgress')} ${count}` : t('historyPaused'), false, ); return; } this.setStatus( count > 0 ? `${t('historyProgress')} ${count}` : t('historyDownloading'), false, ); } updateActionButtons() { const downloading = this.busy && !this.paused; const paused = this.busy && this.paused; if (this.downloadBtn) this.downloadBtn.hidden = this.busy; if (this.pauseBtn) this.pauseBtn.hidden = !downloading; if (this.resumeBtn) this.resumeBtn.hidden = !paused; if (this.cancelBtn) this.cancelBtn.hidden = !this.busy; } pauseDownload() { if (!this.busy || this.paused || this.abort) return; this.paused = true; this.setProgressStatus(); this.updateActionButtons(); } resumeDownload() { if (!this.busy || !this.paused || this.abort) return; this.paused = false; this.setProgressStatus(); this.updateActionButtons(); } cancelDownload() { if (!this.busy || this.abort) return; this.abort = true; this.paused = false; this.setStatus(t('historyCancelled'), false); this.updateActionButtons(); } render() { this.balloon = document.createElement('div'); this.balloon.className = 'ydm-balloon ydm-history-panel'; const header = document.createElement('div'); header.className = 'ydm-balloon-header'; const title = document.createElement('div'); title.className = 'ydm-balloon-title'; title.textContent = t('historyPanelTitle'); const closeBtn = document.createElement('button'); closeBtn.type = 'button'; closeBtn.className = 'ydm-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 = 'ydm-balloon-body ydm-balloon-body--speech'; const hint = document.createElement('p'); hint.className = 'ydm-speech-note'; hint.textContent = t('historyHint'); body.append( hint, this.createField(t('historyKeywords'), t('historyKeywordsHint'), 'keywords'), this.createField(t('historyUsernames'), t('historyUsernamesHint'), 'usernames'), this.createSuperchatToggle(), this.createTimeRange(), this.createFormat(), ); this.statusEl = document.createElement('p'); this.statusEl.className = 'ydm-history-status'; const actions = document.createElement('div'); actions.className = 'ydm-history-actions'; this.downloadBtn = document.createElement('button'); this.downloadBtn.type = 'button'; this.downloadBtn.className = 'ydm-history-download'; this.downloadBtn.textContent = t('historyDownload'); this.downloadBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); this.startDownload(); }); this.pauseBtn = document.createElement('button'); this.pauseBtn.type = 'button'; this.pauseBtn.className = 'ydm-history-pause'; this.pauseBtn.textContent = t('historyPause'); this.pauseBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); this.pauseDownload(); }); this.resumeBtn = document.createElement('button'); this.resumeBtn.type = 'button'; this.resumeBtn.className = 'ydm-history-resume'; this.resumeBtn.textContent = t('historyResume'); this.resumeBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); this.resumeDownload(); }); this.cancelBtn = document.createElement('button'); this.cancelBtn.type = 'button'; this.cancelBtn.className = 'ydm-history-cancel'; this.cancelBtn.textContent = t('historyCancel'); this.cancelBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); this.cancelDownload(); }); actions.append(this.downloadBtn, this.pauseBtn, this.resumeBtn, this.cancelBtn); body.append(this.statusEl, actions); this.updateActionButtons(); 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); } createField(label, placeholder, key) { const row = document.createElement('div'); row.className = 'ydm-balloon-row ydm-balloon-row--field'; const labelEl = document.createElement('span'); labelEl.className = 'ydm-balloon-label'; labelEl.textContent = label; const input = document.createElement('input'); input.type = 'text'; input.className = 'ydm-balloon-input'; input.placeholder = placeholder; input.value = this[key]; input.addEventListener('input', () => { this[key] = input.value; }); row.append(labelEl, input); return row; } createSuperchatToggle() { const row = document.createElement('div'); row.className = 'ydm-balloon-row ydm-balloon-row--toggle'; const labelEl = document.createElement('span'); labelEl.className = 'ydm-balloon-label'; labelEl.textContent = t('historySuperchat'); const switchWrap = document.createElement('label'); switchWrap.className = 'ydm-switch'; const input = document.createElement('input'); input.type = 'checkbox'; input.checked = this.includeSuperchat; const slider = document.createElement('span'); slider.className = 'ydm-switch-slider'; input.addEventListener('change', () => { this.includeSuperchat = input.checked; }); switchWrap.append(input, slider); row.append(labelEl, switchWrap); return row; } createTimeRange() { const duration = currentDurationSeconds(); const row = document.createElement('div'); row.className = 'ydm-balloon-row ydm-balloon-row--field'; const labelEl = document.createElement('span'); labelEl.className = 'ydm-balloon-label'; labelEl.textContent = t('historyRange'); row.appendChild(labelEl); if (!duration) { const note = document.createElement('p'); note.className = 'ydm-speech-note'; note.textContent = t('historyNoDuration'); row.appendChild(note); return row; } if (!this.endSec) this.endSec = duration; const value = document.createElement('span'); value.className = 'ydm-balloon-value'; const updateLabel = () => { value.textContent = `${formatHistoryRangeLabel(this.startSec)} – ${formatHistoryRangeLabel(this.endSec)}`; }; updateLabel(); const header = document.createElement('div'); header.className = 'ydm-balloon-field-header'; header.append(labelEl, value); const start = this.createRangeInput(0, duration, this.startSec, (next) => { this.startSec = Math.min(next, this.endSec); start.value = String(this.startSec); updateLabel(); }); const end = this.createRangeInput(0, duration, this.endSec, (next) => { this.endSec = Math.max(next, this.startSec); end.value = String(this.endSec); updateLabel(); }); row.replaceChildren(header, start, end); return row; } createRangeInput(min, max, value, onInput) { const input = document.createElement('input'); input.type = 'range'; input.className = 'ydm-balloon-range'; input.min = String(min); input.max = String(Math.max(min, Math.floor(max))); input.step = '1'; input.value = String(Math.floor(value)); input.addEventListener('input', () => onInput(Number(input.value))); return input; } createFormat() { const row = document.createElement('div'); row.className = 'ydm-balloon-row ydm-balloon-row--field'; const labelEl = document.createElement('span'); labelEl.className = 'ydm-balloon-label'; labelEl.textContent = t('historyFormat'); const select = document.createElement('select'); select.className = 'ydm-balloon-select'; for (const format of ['json', 'csv', 'txt']) { const option = document.createElement('option'); option.value = format; option.textContent = format.toUpperCase(); if (format === this.format) option.selected = true; select.appendChild(option); } select.addEventListener('change', () => { this.format = select.value; }); row.append(labelEl, select); return row; } async startDownload() { if (this.busy) return; this.busy = true; this.abort = false; this.paused = false; this.progressCount = 0; this.updateActionButtons(); this.setStatus(t('historyDownloading'), false); const duration = currentDurationSeconds(); const startMs = duration ? this.startSec * 1000 : 0; const endMs = duration ? this.endSec * 1000 : Infinity; try { const fetched = await fetchLiveChatHistory({ startMs, endMs, shouldAbort: () => this.abort || !this.isOpen, shouldPause: () => this.paused && this.isOpen && !this.abort, onProgress: (count) => { this.progressCount = count; if (!this.abort && this.isOpen) this.setProgressStatus(); }, }); if (this.abort || !this.isOpen) { if (this.isOpen && this.abort) this.setStatus(t('historyCancelled'), false); return; } const filtered = filterLiveChatHistory(fetched, { keywords: this.keywords, usernames: this.usernames, includeSuperchat: this.includeSuperchat, }); if (!filtered.length) { this.setStatus(fetched.length ? t('historyEmpty') : t('historyNone'), true); return; } downloadLiveChatFile(filtered, this.format, currentVideoId()); this.setStatus(`${t('historyDone')} ${filtered.length}`, false); } catch (err) { if (this.abort || err?.code === 'aborted') { if (this.isOpen) this.setStatus(t('historyCancelled'), false); return; } this.setStatus(err?.code === 'no-history' ? t('historyNone') : t('historyFailed'), true); } finally { this.busy = false; this.paused = false; this.abort = false; this.updateActionButtons(); } } } 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); this.bindChatKeyDown(); } bindChatKeyDown() { const doc = getChatDocument(); if (!doc || doc === document) return; if (this.chatKeyDoc && this.chatKeyDoc !== doc) { this.chatKeyDoc.removeEventListener('keydown', this.boundKeyDown, true); } this.chatKeyDoc = doc; doc.addEventListener('keydown', this.boundKeyDown, true); } destroy() { this.stopWheel(false); this.close(); document.removeEventListener('keydown', this.boundKeyDown, true); this.chatKeyDoc?.removeEventListener('keydown', this.boundKeyDown, true); this.chatKeyDoc = null; 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 sanitizeChannelKey(currentChannelId?.() || ''); } visibleItems() { return listFavoritesForChannel(this.state, this.currentChannel()); } createButton(doc = document) { const btn = doc.createElement('button'); btn.type = 'button'; btn.className = 'ydm-fav-btn'; btn.setAttribute('data-ydm-favorites', 'true'); btn.setAttribute('aria-label', t('favoritesTitle')); btn.setAttribute('title', t('favoritesTitle')); appendMarkup(btn, ICON_FAVORITE); btn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); this.toggle(btn); }); return btn; } async mount() { if (this.wrap?.isConnected || this.btn?.isConnected) return; const doc = getChatDocument() || document; ensureChatTranslateStyles(doc); const host = await waitForElement('#picker-buttons', doc, 12000); if (!host) { clearTimeout(this.retryTimer); this.retryTimer = setTimeout(() => this.mount(), 1200); return; } if (doc.querySelector('[data-ydm-favorites="true"], [data-ydm-favorites-wrap="true"]')) return; this.btn = this.createButton(doc); this.wrap = this.btn; host.appendChild(this.btn); this.bindChatKeyDown(); } 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(); } anchorRectInPage() { const rect = this.anchor.getBoundingClientRect(); const ownerWin = this.anchor.ownerDocument?.defaultView; if (!ownerWin || ownerWin === window) return rect; const frameEl = ownerWin.frameElement; if (!frameEl) return rect; const fr = frameEl.getBoundingClientRect(); return { left: rect.left + fr.left, right: rect.right + fr.left, top: rect.top + fr.top, bottom: rect.bottom + fr.top, width: rect.width, height: rect.height, }; } reposition() { if (!this.balloon || !this.anchor) return; const mountTarget = getFullscreenElement() || document.body; if (this.balloon.parentElement !== mountTarget) mountTarget.appendChild(this.balloon); const rect = this.anchorRectInPage(); 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 = 'ydm-balloon ydm-fav-panel'; balloon.addEventListener('click', (e) => e.stopPropagation()); const header = document.createElement('div'); header.className = 'ydm-balloon-header'; const title = document.createElement('div'); title.className = 'ydm-balloon-title'; title.textContent = t('favoritesTitle'); const closeBtn = document.createElement('button'); closeBtn.type = 'button'; closeBtn.className = 'ydm-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 = 'ydm-fav-add-row'; const addInput = document.createElement('input'); addInput.type = 'text'; addInput.className = 'ydm-fav-add-input'; addInput.maxLength = FAVORITE_TEXT_LIMIT; addInput.placeholder = t('favoritesAddPlaceholder'); const addBtn = document.createElement('button'); addBtn.type = 'button'; addBtn.className = 'ydm-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 = 'ydm-fav-list'; const status = document.createElement('div'); status.className = 'ydm-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 = 'ydm-fav-wheel'; const head = document.createElement('div'); head.className = 'ydm-fav-wheel-head'; const label = document.createElement('div'); label.className = 'ydm-fav-wheel-title'; label.textContent = t('favoritesWheel'); this.wheelToggleBtn = document.createElement('button'); this.wheelToggleBtn.type = 'button'; this.wheelToggleBtn.className = 'ydm-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 = 'ydm-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 = 'ydm-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('.ydm-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 = 'ydm-fav-wheel-status'; box.append(head, grid, this.wheelStatusEl); return box; } renderList() { if (!this.listEl) return; this.listEl.replaceChildren(); const items = this.visibleItems(); if (!items.length) { const empty = document.createElement('div'); empty.className = 'ydm-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 = 'ydm-fav-item'; row.dataset.id = item.id; row.classList.toggle('is-global', !item.channel); const meta = document.createElement('div'); meta.className = 'ydm-fav-item-meta'; const channelLabel = item.channel === this.currentChannel() ? (currentChannelLabel() || item.channel) : item.channel; meta.textContent = item.channel ? t('favoritesScopeChannel').replace('{channel}', channelLabel) : t('favoritesScopeGlobal'); const text = document.createElement('textarea'); text.className = 'ydm-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 = 'ydm-fav-item-tools'; const shortcut = document.createElement('select'); shortcut.className = 'ydm-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 = 'ydm-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 = 'ydm-fav-item-send'; sendBtn.textContent = t('favoritesSend'); sendBtn.addEventListener('click', () => this.sendFavorite(item)); const removeBtn = document.createElement('button'); removeBtn.type = 'button'; removeBtn.className = 'ydm-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 sendYouTubeLiveChatMessage(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 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 ChatClearButton { constructor({ onClear } = {}) { this.onClear = onClear; this.wrap = null; this.btn = null; this.retryTimer = null; } resetChatScroller(doc) { const offset = doc.querySelector( 'yt-live-chat-item-list-renderer #item-offset, #item-offset.yt-live-chat-item-list-renderer', ); if (offset) { // YouTube virtualizes with a tall #item-offset; wipe must collapse it or // new lines stay stuck in a tiny visible band. offset.style.height = '0px'; offset.style.minHeight = '0px'; offset.style.transform = 'translateY(0px)'; } const scroller = doc.querySelector( 'yt-live-chat-item-list-renderer #item-scroller, #item-scroller.yt-live-chat-item-list-renderer', ); const stickBottom = () => { if (!scroller) return; scroller.scrollTop = Math.max(0, scroller.scrollHeight - scroller.clientHeight); }; stickBottom(); requestAnimationFrame(() => { if (offset && (!offset.children.length || offset.scrollHeight < 8)) { offset.style.height = '0px'; offset.style.transform = 'translateY(0px)'; } else if (offset) { offset.style.height = `${Math.max(offset.scrollHeight, 0)}px`; offset.style.transform = 'translateY(0px)'; } stickBottom(); }); const list = doc.querySelector('yt-live-chat-item-list-renderer'); if (!list) return; try { if (typeof list.scrollToBottom === 'function') list.scrollToBottom(); if ('atBottom' in list) list.atBottom = true; if ('isAtBottom' in list) list.isAtBottom = true; if ('stickToBottom' in list) list.stickToBottom = true; if ('activeItems' in list && Array.isArray(list.activeItems)) list.activeItems = []; if ('visibleItems' in list && Array.isArray(list.visibleItems)) list.visibleItems = []; list.setAttribute('at-bottom', ''); list.removeAttribute('has-user-scrolled'); list.dispatchEvent?.(new Event('scroll')); if (typeof list.notifyPath === 'function') list.notifyPath('items'); if (typeof list.requestUpdate === 'function') list.requestUpdate(); } catch (_) { /* ignore host API differences */ } } clearLiveChat() { const doc = getChatDocument() || document; const items = doc.querySelector(SELECTORS.chatItems); let removed = 0; if (items) { const nodes = [...items.querySelectorAll(SELECTORS.chatMessage)]; removed = nodes.length; nodes.forEach((node) => node.remove()); // Drop leftover empty wrappers that can keep a tall offset box. [...items.children].forEach((child) => { if (!child.matches?.(SELECTORS.chatMessage) && !child.querySelector?.(SELECTORS.chatMessage)) { if (child.id === 'item-offset') return; if (!child.children?.length && !child.textContent?.trim()) child.remove(); } }); } else { doc.querySelectorAll(SELECTORS.chatMessage).forEach((node) => { node.remove(); removed += 1; }); } this.resetChatScroller(doc); this.onClear?.(removed); return removed; } createButton(doc = document) { const btn = doc.createElement('button'); btn.type = 'button'; btn.className = 'ydm-chat-clear-btn'; btn.setAttribute('data-ydm-chat-clear', 'true'); btn.setAttribute('aria-label', t('chatClear')); btn.setAttribute('title', t('chatClear')); appendMarkup(btn, ICON_CHAT_CLEAR); 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 doc = getChatDocument() || document; ensureChatTranslateStyles(doc); if (doc.querySelector('[data-ydm-chat-clear="true"]')) return; const header = await waitForElement( '#chat-messages > yt-live-chat-header-renderer, yt-live-chat-header-renderer', doc, 12000, ); if (!header) return; const wrap = doc.createElement('div'); wrap.className = 'ydm-chat-clear-wrap'; wrap.setAttribute('data-ydm-chat-clear-wrap', 'true'); this.btn = this.createButton(doc); wrap.appendChild(this.btn); this.wrap = wrap; const closeBtn = header.querySelector('#close-button'); if (closeBtn) closeBtn.before(wrap); else header.appendChild(wrap); } refreshLabel() { if (!this.btn) return; this.btn.setAttribute('title', t('chatClear')); this.btn.setAttribute('aria-label', 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.boundKeyUp = (e) => this.onKeyUp(e); this.boundFsChange = () => this.onFullscreenChange(); this.boundPointerDown = (e) => this.onPointerDownOutside(e); this.playerObserver = null; } attach() { // Prefer window capture so we run before YouTube player handlers that call // stopImmediatePropagation on document/player keydown. window.addEventListener('keydown', this.boundKeyDown, true); window.addEventListener('keyup', this.boundKeyUp, true); document.addEventListener('pointerdown', this.boundPointerDown, true); document.addEventListener('fullscreenchange', this.boundFsChange); document.addEventListener('webkitfullscreenchange', this.boundFsChange); this.watchPlayerFullscreenClass(); } watchPlayerFullscreenClass() { this.playerObserver?.disconnect(); const player = getPlayerElement(); if (!player) { clearTimeout(this.playerWatchTimer); this.playerWatchTimer = setTimeout(() => this.watchPlayerFullscreenClass(), 1500); return; } this.playerObserver = new MutationObserver(() => this.onFullscreenChange()); this.playerObserver.observe(player, { attributes: true, attributeFilter: ['class'] }); } destroy() { this.close(); window.removeEventListener('keydown', this.boundKeyDown, true); window.removeEventListener('keyup', this.boundKeyUp, true); document.removeEventListener('pointerdown', this.boundPointerDown, true); document.removeEventListener('fullscreenchange', this.boundFsChange); document.removeEventListener('webkitfullscreenchange', this.boundFsChange); this.playerObserver?.disconnect(); this.playerObserver = null; clearTimeout(this.playerWatchTimer); } 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() { // Match twitch-danmaku: only while actually fullscreen (native or ytp-fullscreen). if (!getFullscreenMount()) return false; if (!isLiveWatchMode()) return false; return !!currentChannelId() || document.documentElement.dataset.ydmPreview === 'true'; } stopEvent(e) { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); } isEnterKey(e) { return e.key === 'Enter' || e.code === 'Enter' || e.keyCode === 13; } onKeyDown(e) { if (e.isComposing) return; if (e.key === 'Escape' && this.root) { if (this.favOpen) { this.stopEvent(e); this.closeFavorites(); return; } } if (!this.isEnterKey(e)) return; if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return; this._enterHandledAt = 0; if (this.root) { if (e.target === this.input || this.root.contains(e.target)) { this.stopEvent(e); this._enterHandledAt = Date.now(); this.submit(); } return; } if (!this.canOpen()) return; if (this.isEditableTarget(e.target)) return; this.stopEvent(e); this._enterHandledAt = Date.now(); this.open(); } onKeyUp(e) { if (e.isComposing) return; if (!this.isEnterKey(e)) return; if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return; if (this._enterHandledAt && Date.now() - this._enterHandledAt < 800) return; if (this.root) return; if (!this.canOpen()) return; if (this.isEditableTarget(e.target)) return; this.stopEvent(e); this.open(); } onPointerDownOutside(e) { if (!this.root) return; if (Date.now() < (this._ignoreOutsideUntil || 0)) return; if (this.root.contains(e.target)) return; if (this.input?.value.trim()) return; this.close(); } onFullscreenChange() { if (!getFullscreenMount()) 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, currentChannelId?.() || ''); } open() { if (this.root) { this.input?.focus(); return; } // Same strategy as twitch-danmaku: mount on the fullscreen host itself // (native FS element / #movie_player), not the video container. const mount = getFullscreenMount(); if (!mount) return; if (getComputedStyle(mount).position === 'static') { mount.style.position = 'relative'; } const root = document.createElement('div'); root.className = 'ydm-fs-chat'; root.setAttribute('role', 'dialog'); root.setAttribute('aria-label', t('fsChatPlaceholder')); // Inline fallback (Twitch uses CSS z-index 100020 on the FS host). Object.assign(root.style, { position: 'absolute', left: '50%', bottom: 'max(72px, 10%)', transform: 'translateX(-50%)', zIndex: '100020', width: 'min(560px, calc(100% - 48px))', maxWidth: 'calc(100% - 24px)', pointerEvents: 'auto', display: 'flex', flexDirection: 'column', gap: '6px', visibility: 'visible', opacity: '1', }); const row = document.createElement('div'); row.className = 'ydm-fs-chat-row'; const input = document.createElement('input'); input.type = 'text'; input.className = 'ydm-fs-chat-input'; input.maxLength = 500; input.autocomplete = 'off'; input.spellcheck = true; input.placeholder = t('fsChatPlaceholder'); const favWrap = document.createElement('div'); favWrap.className = 'ydm-fs-chat-fav-wrap'; const favBtn = document.createElement('button'); favBtn.type = 'button'; favBtn.className = 'ydm-fs-chat-fav'; favBtn.setAttribute('aria-label', t('fsChatFavorites')); favBtn.setAttribute('aria-expanded', 'false'); appendMarkup(favBtn, ICON_FAVORITE); const favLabel = document.createElement('span'); favLabel.textContent = t('fsChatFavorites'); favBtn.appendChild(favLabel); const favList = document.createElement('div'); favList.className = 'ydm-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 = 'ydm-fs-chat-send'; sendBtn.setAttribute('aria-label', t('fsChatSend')); appendMarkup(sendBtn, ICON_CHAT_SEND); const sendLabel = document.createElement('span'); sendLabel.textContent = t('fsChatSend'); sendBtn.appendChild(sendLabel); const closeBtn = document.createElement('button'); closeBtn.type = 'button'; closeBtn.className = 'ydm-fs-chat-close'; closeBtn.setAttribute('aria-label', t('fsChatClose')); closeBtn.textContent = t('fsChatClose'); const meta = document.createElement('div'); meta.className = 'ydm-fs-chat-meta'; const status = document.createElement('div'); status.className = 'ydm-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.sendBtn = sendBtn; this.closeBtn = closeBtn; this.favBtn = favBtn; this.favList = favList; this.statusEl = status; this._ignoreOutsideUntil = Date.now() + 500; this.renderFavorites(); requestAnimationFrame(() => { this.input?.focus(); // Keep composer as the topmost child of the player shell. if (this.root?.parentElement === mount) mount.appendChild(this.root); }); } toggleFavorites() { this.favOpen = !this.favOpen; if (this.favList) this.favList.hidden = !this.favOpen; this.favBtn?.setAttribute('aria-expanded', this.favOpen ? 'true' : 'false'); this.favBtn?.classList.toggle('is-open', this.favOpen); if (this.favOpen) this.renderFavorites(); } closeFavorites() { this.favOpen = false; if (this.favList) this.favList.hidden = true; this.favBtn?.setAttribute('aria-expanded', 'false'); this.favBtn?.classList.remove('is-open'); } renderFavorites() { if (!this.favList) return; this.favList.replaceChildren(); const items = this.listFavoriteItems(); if (!items.length) { const empty = document.createElement('div'); empty.className = 'ydm-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 = 'ydm-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 sendYouTubeLiveChatMessage(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 = 'ydmPipAspectLocked'; const PIP_CHAT_MAX_LENGTH = 200; 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.reloading = 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.sourceReloadTimer = null; this.boundPlay = () => this.syncPlayback(); this.boundPause = () => this.syncPlayback(); this.boundRate = () => this.syncPlayback(); this.boundResize = () => this.resetLanes(); this.boundPageHide = () => this.cleanup(); this.boundKeyDown = (event) => this.onKeyDown(event); this.boundComment = (payload) => this.renderComment(payload); this.boundSourceEmptied = () => this.scheduleSourceReload(); this.aspectLocked = this.loadAspectLocked(); } loadAspectLocked() { try { return !!GM_getValue(PIP_ASPECT_LOCKED_KEY, false); } catch (_) { return false; } } saveAspectLocked() { try { GM_setValue(PIP_ASPECT_LOCKED_KEY, this.aspectLocked); } catch (_) { /* 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; this.setIcon(this.pipWindow.document, btn, 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() { const candidates = []; try { if (typeof unsafeWindow !== 'undefined' && unsafeWindow) candidates.push(unsafeWindow); } catch (_) { /* sandbox may refuse unsafeWindow */ } candidates.push(window); for (const win of candidates) { try { if (typeof win.documentPictureInPicture?.requestWindow === 'function') return win; } catch (_) { /* ignore */ } } return candidates[0] || 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 (_) { /* fall through to a fresh capture */ } } const capture = video?.captureStream || video?.mozCaptureStream; if (typeof capture !== 'function') { try { const proto = this.pageWindow().HTMLVideoElement?.prototype; const pageCapture = proto?.captureStream || proto?.mozCaptureStream; if (typeof pageCapture === 'function') return pageCapture.call(video); } catch (_) { return null; } return null; } return capture.call(video); } findSourceVideo() { const root = getPlayerElement() || document.querySelector('#movie_player, .html5-video-player'); if (!root) return null; const videos = [...root.querySelectorAll('video')]; const main = videos.find((video) => video.classList.contains('html5-main-video')); if (main && main.videoWidth > 0) return main; const area = (video) => (video.videoWidth || 0) * (video.videoHeight || 0); const pool = videos.filter((video) => video !== main); const playing = pool .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 = pool .filter((video) => video.videoWidth > 0) .sort((a, b) => area(b) - area(a)); if (sized.length) return sized[0]; return main || 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 (_) { /* window may already be closing */ } } return; } if (!this.isSupported()) { this.showPageToast(t('pipNeedBrowser')); return; } const source = this.findSourceVideo(); if (!source?.videoWidth || !this.canCapture(source)) { this.showPageToast(t('pipNoVideo')); return; } let stream = null; try { stream = this.captureSource(source); } catch (_) { 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 (_) { 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 (_) { /* 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 (_) { /* focus is best-effort */ } } canCapture(video) { if (!video) return false; if (typeof video.captureStream === 'function' || typeof video.mozCaptureStream === 'function') return true; if (this.isMediaStream(video.srcObject)) return true; try { const proto = this.pageWindow().HTMLVideoElement?.prototype; return typeof proto?.captureStream === 'function' || typeof proto?.mozCaptureStream === 'function'; } catch (_) { return false; } } close(focusOpener = false) { const source = this.sourceVideo; this.cleanup(); if (focusOpener) { try { window.focus(); } catch (_) { /* opener may be gone */ } try { source?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); } catch (_) { /* 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; clearTimeout(this.sourceReloadTimer); this.sourceReloadTimer = 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.reloading = false; this.sourceVideo = null; if (win && !win.closed) { try { win.close(); } catch (_) { /* already closing */ } } this.onStateChange?.(); } stopStream(stream) { try { stream?.getTracks?.().forEach((track) => track.stop()); } catch (_) { /* 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); source.addEventListener('ratechange', this.boundRate); source.addEventListener('emptied', this.boundSourceEmptied); } unbindSourceSync() { const source = this.sourceVideo; if (!source) return; source.removeEventListener('play', this.boundPlay); source.removeEventListener('pause', this.boundPause); source.removeEventListener('ratechange', this.boundRate); source.removeEventListener('emptied', this.boundSourceEmptied); } syncPlayback() { const source = this.sourceVideo; const pipVideo = this.pipVideo; if (!source || !pipVideo) return; const rate = Number(source.playbackRate); if (Number.isFinite(rate) && rate > 0) pipVideo.playbackRate = rate; if (source.paused) pipVideo.pause(); else pipVideo.play().catch(() => {}); } scheduleSourceReload() { if (!this.isOpen() || this.reloading) return; if (Date.now() - (this.lastSourceReload || 0) < 1500) return; clearTimeout(this.sourceReloadTimer); this.sourceReloadTimer = setTimeout(() => { this.lastSourceReload = Date.now(); if (this.isOpen()) this.reloadVideo({ quiet: true }); }, 600); } 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 }); if (!context) return; 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 (_) { /* 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 (_) { /* 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); const reserveBottom = settings.scrollArea === 'top-half' || settings.scrollArea === 'top-quarter' ? 0 : (this.inputOpen ? (this.nodes?.input?.offsetHeight || 52) + 4 : 0); const usable = Math.max(laneHeight, height - reserveBottom); let top = 0; let areaHeight = usable; if (settings.scrollArea === 'top-half') areaHeight = Math.max(laneHeight, usable * 0.5); else if (settings.scrollArea === 'bottom-half') { areaHeight = Math.max(laneHeight, usable * 0.5); top = Math.max(0, usable - areaHeight); } else if (settings.scrollArea === 'top-quarter') { areaHeight = Math.max(laneHeight, usable * 0.25); } return { top, 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 = Array.isArray(payload?.parts) && payload.parts.length ? payload.parts : (payload?.message ? [{ text: payload.message }] : []); if (!parts.length) return; const doc = this.pipWindow.document; const layer = this.nodes.layer; const item = doc.createElement('div'); item.className = 'ydm-pip-dm'; this.appendParts(doc, item, parts); if (!item.childNodes.length && payload?.message) { item.textContent = payload.message; } if (!item.childNodes.length) { item.remove(); return; } const opacity = clamp(Number(settings.opacity) || 100, 10, 100) / 100; item.style.color = safeCssColor(payload.usernameColor); 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 = `${(metrics.top || 0) + lane * metrics.laneHeight}px`; item.style.left = `${layerWidth}px`; item.style.setProperty('--ydm-travel', `${travel}px`); item.style.setProperty('--ydm-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) continue; if (part.type === 'emote') { const src = safeEmoteSrc(part.src); const fallback = emoteDisplayText(part) || String(part.name || '').trim(); if (src) { const img = doc.createElement('img'); img.className = 'ydm-pip-emote'; img.src = src; img.alt = ''; img.draggable = false; img.referrerPolicy = 'no-referrer'; img.addEventListener('error', () => { if (fallback) img.replaceWith(doc.createTextNode(fallback)); else img.remove(); }, { once: true }); item.appendChild(img); continue; } if (fallback) item.appendChild(doc.createTextNode(fallback)); continue; } const text = String(part.text || part.value || ''); if (!text || EMOTE_SHORTCODE_ONLY_RE.test(text)) continue; item.appendChild(doc.createTextNode(text)); } } mountDocument(pipWindow) { const doc = pipWindow.document; doc.documentElement.lang = document.documentElement.lang || 'zh-CN'; doc.title = t('pipTitle'); 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 = 'ydm-pip-wrap'; const main = doc.createElement('div'); main.id = 'ydm-pip-main'; const back = this.makeButton(doc, 'ydm-pip-back', t('pipBack'), t('pipBack')); back.textContent = t('pipBack'); const buttons = doc.createElement('div'); buttons.id = 'ydm-pip-btns'; const reloadBtn = this.makeButton(doc, 'ydm-pip-reload', t('pipReload'), t('pipReload'), ICON_PIP_RELOAD); const aspectBtn = this.makeButton(doc, 'ydm-pip-aspect', t('pipAspectLock'), t('pipAspectLock'), ICON_PIP_ASPECT_UNLOCK); const danmakuBtn = this.makeButton(doc, 'ydm-pip-danmaku-btn', t('pipDanmaku'), t('pipDanmaku'), ICON_DANMAKU_ON); const settingsBtn = this.makeButton(doc, 'ydm-pip-settings', t('pipSettings'), t('pipSettings'), ICON_SETTINGS); const sendBtn = this.makeButton(doc, 'ydm-pip-send', t('pipSend'), t('pipSend'), ICON_CHAT_SEND); buttons.append(reloadBtn, aspectBtn, danmakuBtn, settingsBtn, sendBtn); const video = doc.createElement('video'); video.id = 'ydm-pip-video'; video.autoplay = true; video.muted = true; video.playsInline = true; video.setAttribute('playsinline', ''); const layer = doc.createElement('div'); layer.id = 'ydm-pip-danmaku'; const toast = doc.createElement('div'); toast.id = 'ydm-pip-toast'; const input = doc.createElement('div'); input.id = 'ydm-pip-input'; const field = doc.createElement('input'); field.id = 'ydm-pip-field'; field.type = 'text'; field.maxLength = PIP_CHAT_MAX_LENGTH; field.autocomplete = 'off'; field.spellcheck = true; field.placeholder = this.sendBlockReason() || t('fsChatPlaceholder'); const favWrap = doc.createElement('div'); favWrap.className = 'ydm-pip-fav-wrap'; const favBtn = this.makeButton(doc, 'ydm-pip-fav', t('fsChatFavorites'), t('fsChatFavorites'), ICON_FAVORITE); const favList = doc.createElement('div'); favList.id = 'ydm-pip-fav-list'; favList.hidden = true; favWrap.append(favBtn, favList); const submit = this.makeButton(doc, 'ydm-pip-submit', t('fsChatSend'), t('fsChatSend')); submit.textContent = t('fsChatSend'); const status = doc.createElement('div'); status.id = 'ydm-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('#ydm-pip-btns, #ydm-pip-back')) 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; const textButton = id === 'ydm-pip-back' || id === 'ydm-pip-submit'; button.className = textButton ? id : (id === 'ydm-pip-fav' ? id : 'ydm-pip-btn'); button.setAttribute('aria-label', label); button.title = title; if (html) this.setIcon(doc, button, html); return button; } setIcon(doc, button, markup) { const svg = createSvg(markup); button.replaceChildren(doc.adoptNode(svg)); } windowCss() { return ` html, body { margin: 0; width: 100%; height: 100%; overflow: hidden; background: #000; color-scheme: dark; } #ydm-pip-wrap, #ydm-pip-main { position: relative; width: 100%; height: 100%; overflow: hidden; } #ydm-pip-video { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: fill; background: #000; z-index: 1; } #ydm-pip-wrap.is-aspect-locked #ydm-pip-video { object-fit: contain; } #ydm-pip-danmaku { position: absolute; inset: 0; overflow: hidden; pointer-events: none; z-index: 2; } .ydm-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: ydm-pip-scroll var(--ydm-duration, 8s) linear forwards; } .ydm-pip-emote { height: 1.15em; width: auto; vertical-align: -0.2em; margin: 0 0.08em; pointer-events: none; } @keyframes ydm-pip-scroll { from { transform: translateX(0); } to { transform: translateX(calc(-1 * var(--ydm-travel, 100vw))); } } #ydm-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; } #ydm-pip-wrap:hover #ydm-pip-btns { left: 10px; } .ydm-pip-btn, #ydm-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; } .ydm-pip-btn svg, #ydm-pip-fav svg { width: 18px; height: 18px; fill: currentColor; display: block; } .ydm-pip-btn:hover, #ydm-pip-fav:hover { background: rgba(0,0,0,.82); } #ydm-pip-danmaku-btn.is-off { opacity: 0.45; } #ydm-pip-danmaku-btn.is-off::after { content: ""; position: absolute; width: 22px; height: 2px; background: #fff; transform: rotate(-40deg); } #ydm-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 Roboto, Arial, sans-serif; cursor: pointer; white-space: nowrap; transition: right 0.3s ease; } #ydm-pip-wrap:hover #ydm-pip-back { right: 10px; } #ydm-pip-back:hover { background: rgba(0,0,0,.88); } #ydm-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 Roboto, Arial, sans-serif; opacity: 0; pointer-events: none; transition: opacity .2s; white-space: nowrap; max-width: calc(100% - 24px); } #ydm-pip-toast.is-show { opacity: 1; } #ydm-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: Roboto, Arial, sans-serif; } #ydm-pip-input.is-active { display: flex; } #ydm-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: #181818; color: #f1f1f1; font-size: 14px; outline: none; } #ydm-pip-field:focus { border-color: #ff4e45; } #ydm-pip-fav { flex-shrink: 0; } .ydm-pip-fav-wrap { position: relative; flex-shrink: 0; } #ydm-pip-fav-list { position: absolute; left: 0; bottom: calc(100% + 6px); width: 220px; max-height: 180px; overflow: auto; background: #181818; border: 1px solid rgba(255,255,255,.14); border-radius: 6px; } .ydm-pip-fav-item, .ydm-pip-fav-empty { display: block; width: 100%; box-sizing: border-box; text-align: left; background: transparent; color: #f1f1f1; border: 0; padding: 8px 10px; font-size: 13px; } .ydm-pip-fav-item { cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .ydm-pip-fav-item:hover { background: rgba(255,255,255,.08); } .ydm-pip-fav-empty { color: #aaa; } #ydm-pip-submit { flex-shrink: 0; height: 34px; padding: 0 12px; border: 0; border-radius: 6px; background: #ff4e45; color: #fff; font-weight: 700; cursor: pointer; } #ydm-pip-submit:hover:not(:disabled) { background: #e53935; } #ydm-pip-submit:disabled { opacity: 0.55; cursor: default; } #ydm-pip-status { flex: 1 0 100%; min-height: 0; color: #ff8a80; font-size: 12px; line-height: 1.35; } #ydm-pip-status:empty { display: none; } #ydm-pip-status.is-ok { color: #7dffb3; } `; } 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 = this.sendBlockReason() || t('fsChatPlaceholder'); this.pipWindow.requestAnimationFrame(() => this.nodes?.field?.focus()); } this.resetLanes(); } async reloadVideo({ quiet = false } = {}) { const source = this.sourceVideo; const pipVideo = this.pipVideo; if (this.reloading) return; if (!source || !pipVideo || !this.canCapture(source)) { if (!quiet) this.showToast(t('pipReloadFailed')); return; } this.reloading = true; try { if (!quiet && source.paused) { try { await source.play(); } catch (_) { /* a paused source can still be recaptured */ } } let stream = null; try { stream = this.captureSource(source); } catch (_) { if (!quiet) this.showToast(t('pipReloadFailed')); return; } if (!stream) { if (!quiet) this.showToast(t('pipReloadFailed')); return; } const previous = pipVideo.srcObject; pipVideo.srcObject = stream; pipVideo.muted = true; if (previous && previous !== stream) this.stopStream(previous); this.syncPlayback(); try { this.pipWindow?.focus(); } catch (_) { /* focus is best-effort */ } if (!quiet) this.showToast(t('pipReloaded')); } finally { this.reloading = false; } } openSettings() { try { window.focus(); } catch (_) { /* opener focus can fail */ } try { this.sourceVideo?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); } catch (_) { /* 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('ydm-pip-page-toast'); if (!toast) { toast = document.createElement('div'); toast.id = 'ydm-pip-page-toast'; toast.className = 'ydm-pip-page-toast'; const host = getPlayerElement() || document.body || document.documentElement; host.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('ydm-pip-page-toast')?.remove(); } sendBlockReason() { const src = typeof chatIframeSrc === 'function' ? chatIframeSrc() : ''; if (src.includes('live_chat_replay')) return t('pipReplayNoSend'); if (typeof isLiveChatMode === 'function' && isLiveChatMode()) return ''; if (src.includes('live_chat')) return ''; try { const details = pageWin().ytInitialPlayerResponse?.videoDetails; if (details?.isLive) return ''; if (details?.isLiveContent) return t('pipReplayNoSend'); } catch (_) { /* ignore */ } if (typeof isLiveWatchMode === 'function' && isLiveWatchMode()) return ''; return t('fsChatDisabled'); } listFavoriteItems() { if (this.favoritesPanel?.visibleItems) { this.favoritesPanel.state = loadFavoritesState(); return this.favoritesPanel.visibleItems(); } return listFavoritesForChannel(loadFavoritesState(), currentChannelId?.() || ''); } 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 = 'ydm-pip-fav-empty'; empty.textContent = t('fsChatFavoritesEmpty'); list.appendChild(empty); return; } items.forEach((item) => { const button = doc.createElement('button'); button.type = 'button'; button.className = 'ydm-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 = String(item.text || '').slice(0, PIP_CHAT_MAX_LENGTH); 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; } const blocked = this.sendBlockReason(); if (blocked) { this.setStatus(blocked, false); return; } this.sending = true; if (this.nodes.submit) this.nodes.submit.disabled = true; this.closeFavorites(); this.setStatus(''); try { await sendYouTubeLiveChatMessage(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, onTogglePanel, onToggleSpeech, onToggleHistory, onTogglePip, getSettings, isPanelOpen, isSpeechPanelOpen, isHistoryPanelOpen, isPipOpen, }) { this.onToggleDanmaku = onToggleDanmaku; 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.speechBtn = null; this.historyBtn = null; this.settingsBtn = null; this.pipBtn = null; this.controlsGroup = null; this.mounting = false; } createBtn(className, label, html) { const btn = document.createElement('button'); btn.type = 'button'; btn.className = `ydm-control-btn ${className}`; btn.setAttribute('aria-label', label); appendMarkup(btn, html); btn.addEventListener('pointerdown', (e) => { e.stopPropagation(); }); btn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); }); return btn; } async mount() { if (this.mounting || this.wrap?.isConnected) return; this.mounting = true; try { this.controlsGroup = await waitForElement(SELECTORS.playerControls, document, 15000); if (!this.controlsGroup || this.wrap?.isConnected) return; if (this.controlsGroup.querySelector('.ydm-controls-wrap')) return; this.wrap = document.createElement('div'); this.wrap.className = 'ydm-controls-wrap'; this.danmakuBtn = this.createBtn('ydm-danmaku-btn', t('danmakuToggle'), ` ${ICON_DANMAKU_ON} ${ICON_DANMAKU_OFF} `); this.danmakuBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); this.onToggleDanmaku(); }); this.speechBtn = this.createBtn('ydm-speech-btn', t('speechToggle'), ` ${ICON_SPEECH_ON} ${ICON_SPEECH_MUTE} ${ICON_SPEECH_OFF} `); this.speechBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); this.onToggleSpeech(this.speechBtn); }); this.historyBtn = this.createBtn('ydm-history-btn', t('historyToggle'), ICON_DOWNLOAD); this.historyBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); this.onToggleHistory(this.historyBtn); }); this.settingsBtn = this.createBtn('ydm-settings-btn', t('settingsToggle'), ICON_SETTINGS); this.settingsBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); this.onTogglePanel(this.settingsBtn); }); this.pipBtn = this.createBtn('ydm-pip-btn', t('pipOpen'), ICON_PIP); this.pipBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); this.onTogglePip?.(); }); this.wrap.append( this.danmakuBtn, this.settingsBtn, this.speechBtn, this.historyBtn, this.pipBtn, ); const first = this.controlsGroup.firstChild; if (first) this.controlsGroup.insertBefore(this.wrap, first); else this.controlsGroup.appendChild(this.wrap); this.updateDanmakuState(); this.updateSpeechState(); this.updateHistoryState(); this.updatePanelState(); this.updatePipState(); } finally { this.mounting = false; } } remount() { if (this.wrap?.isConnected) return; const existing = document.querySelector('.ydm-controls-wrap'); if (existing?.isConnected && existing.querySelector('.ydm-danmaku-btn') && existing.querySelector('.ydm-speech-btn') && existing.querySelector('.ydm-history-btn') && existing.querySelector('.ydm-pip-btn')) { this.wrap = existing; this.danmakuBtn = existing.querySelector('.ydm-danmaku-btn'); this.speechBtn = existing.querySelector('.ydm-speech-btn'); this.historyBtn = existing.querySelector('.ydm-history-btn'); this.settingsBtn = existing.querySelector('.ydm-settings-btn'); this.pipBtn = existing.querySelector('.ydm-pip-btn'); existing.querySelector('.ydm-chat-expand-control-btn')?.remove(); this.updateSpeechState(); this.updateHistoryState(); this.updatePipState(); return; } existing?.remove(); this.wrap = null; this.danmakuBtn = null; this.speechBtn = null; this.historyBtn = null; this.settingsBtn = null; this.pipBtn = null; this.mount(); } updateDanmakuState() { if (!this.danmakuBtn) return; const enabled = this.getSettings().enabled; this.danmakuBtn.classList.toggle('ydm-danmaku-enabled', enabled); this.danmakuBtn.classList.toggle('ydm-danmaku-disabled', !enabled); this.danmakuBtn.title = enabled ? t('danmakuOn') : t('danmakuOff'); this.danmakuBtn.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('ydm-speech-on', current === 'on'); this.speechBtn.classList.toggle('ydm-speech-mute', current === 'mute'); this.speechBtn.classList.toggle('ydm-speech-off', current === 'off'); this.speechBtn.classList.toggle('ydm-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('ydm-active', open); this.historyBtn.title = t('historyToggle'); this.historyBtn.setAttribute('aria-expanded', open ? 'true' : 'false'); } updatePanelState() { if (!this.settingsBtn) return; this.settingsBtn.classList.toggle('ydm-active', this.isPanelOpen()); this.settingsBtn.setAttribute('aria-expanded', this.isPanelOpen() ? 'true' : 'false'); this.updateSpeechState(); this.updateHistoryState(); this.updatePipState(); } updatePipState() { if (!this.pipBtn) return; const open = !!this.isPipOpen?.(); this.pipBtn.classList.toggle('ydm-active', open); const label = open ? t('pipClose') : t('pipOpen'); this.pipBtn.title = label; this.pipBtn.setAttribute('aria-label', label); this.pipBtn.setAttribute('aria-pressed', open ? 'true' : 'false'); } destroy() { this.wrap?.remove(); this.wrap = null; this.danmakuBtn = null; this.speechBtn = null; this.historyBtn = null; this.settingsBtn = null; this.pipBtn = null; this.mounting = false; } } class LiveViewersBadge { constructor() { this.timer = null; this.mounting = false; this.wrapper = null; this.display = null; this.viewsWrap = null; this.countEl = null; this.lastVideoId = ''; this.originalWrapperText = ''; this.boundVisibility = () => { if (!document.hidden) this.refresh().catch(() => {}); }; } async start() { document.addEventListener('visibilitychange', this.boundVisibility); await this.mount(); await this.refresh(); this.clearTimer(); this.timer = setInterval(() => { this.refresh().catch(() => {}); }, VIEWERS_POLL_MS); } stop() { document.removeEventListener('visibilitychange', this.boundVisibility); this.clearTimer(); this.teardownUi(); } clearTimer() { if (this.timer) clearInterval(this.timer); this.timer = null; } remount() { if (this.viewsWrap?.isConnected && this.wrapper?.isConnected) return; this.teardownUi(false); this.mount().then(() => this.refresh()).catch(() => {}); } async mount() { if (this.mounting) return; this.mounting = true; try { let display = document.querySelector('#movie_player .ytp-time-display.ytp-live'); if (!display && isCurrentlyLive()) { display = await waitForElement('#movie_player .ytp-time-display.ytp-live', document, 8000); } if (!display?.isConnected) return; this.display = display; this.wrapper = display.querySelector('.ytp-time-wrapper:not(.ydm-live-views-wrapper)'); if (!this.wrapper) return; if (!this.originalWrapperText) { this.originalWrapperText = this.wrapper.textContent?.trim() || 'Live'; } let viewsWrap = display.querySelector('.ydm-live-views-wrapper'); if (!viewsWrap) { viewsWrap = this.wrapper.cloneNode(false); viewsWrap.classList.add('ydm-live-views-wrapper'); viewsWrap.removeAttribute('id'); viewsWrap.setAttribute('aria-label', t('liveTotalViews')); viewsWrap.title = t('liveTotalViews'); const iconWrap = document.createElement('span'); iconWrap.className = 'ydm-live-views-icon'; appendMarkup(iconWrap, ICON_EYE); const count = document.createElement('span'); count.className = 'ydm-live-views-count'; viewsWrap.append(iconWrap, count); this.wrapper.after(viewsWrap); } this.viewsWrap = viewsWrap; this.countEl = viewsWrap.querySelector('.ydm-live-views-count'); } finally { this.mounting = false; } } teardownUi(restoreText = true) { if (restoreText && this.wrapper?.isConnected && this.originalWrapperText) { this.wrapper.textContent = this.originalWrapperText; } this.viewsWrap?.remove(); this.viewsWrap = null; this.countEl = null; this.wrapper = null; this.display = null; this.originalWrapperText = ''; } applyCounts({ live, concurrent, total }) { if (!live) { this.teardownUi(true); return; } if (!this.wrapper?.isConnected || !this.viewsWrap?.isConnected) { this.mount().then(() => { if (this.wrapper?.isConnected) this.applyCounts({ live, concurrent, total }); }).catch(() => {}); return; } this.wrapper.textContent = formatLiveConcurrentLabel(concurrent); this.wrapper.classList.add('ydm-live-time-wrapper'); if (total == null) { this.viewsWrap.hidden = true; if (this.countEl) this.countEl.textContent = ''; return; } this.viewsWrap.hidden = false; const formatted = formatCountNumber(total); if (this.countEl) this.countEl.textContent = formatted; this.viewsWrap.title = `${t('liveTotalViews')}: ${formatted}`; this.viewsWrap.setAttribute('aria-label', `${t('liveTotalViews')}: ${formatted}`); } async refresh() { if (!isWatchPage()) return; if (!isCurrentlyLive()) { this.applyCounts({ live: false, concurrent: null, total: null }); return; } if (!this.wrapper?.isConnected || !this.viewsWrap?.isConnected) { await this.mount(); } const videoId = liveVideoId(); if (videoId && videoId !== this.lastVideoId) { this.lastVideoId = videoId; this.originalWrapperText = this.wrapper?.textContent?.trim() || 'Live'; } const data = await fetchLiveViewers(videoId); this.applyCounts(data); } } let settings = loadSettings(); let danmaku = null; let danmakuEmitter = null; let translateQueue = null; let chatObserver = null; let favoritesPanel = null; let fullscreenChat = null; let chatClearButton = null; let settingsPanel = null; let speechPanel = null; let historyPanel = null; let controlButton = null; let enhancedPip = null; let liveViewersBadge = null; let stylesInjected = false; let pageActive = isPageActive(); let generation = 0; let navKey = null; let reconciling = false; function getSettings() { return settings; } function isBackgroundKept() { if (document.documentElement.dataset.ydmPreview === 'true') return true; return !!enhancedPip?.isOpen(); } function isActive() { if (!isWatchPage()) return false; if (isBackgroundKept()) return true; return pageActive; } function syncBackgroundKeepAlive() { const open = !!enhancedPip?.isOpen(); syncTabAntiFreeze(() => open); pageActive = open || isPageActive(); } function onSettingsChange(newSettings) { settings = newSettings; danmaku?.updateLayout(); controlButton?.updateDanmakuState(); controlButton?.updateSpeechState(); enhancedPip?.syncSettings(); if (!settings.translateEnabled) { translateQueue?.clear(); clearAllChatTranslations(); } else if (!settings.translateInChat) { clearAllChatTranslations(); } } function toggleDanmaku() { settings.enabled = !settings.enabled; saveSettings(settings); onSettingsChange(settings); } function handleVisibilityChange() { if (document.documentElement.dataset.ydmPreview === 'true') { pageActive = true; return; } const wasActive = isActive(); pageActive = isPageActive(); syncBackgroundKeepAlive(); if (wasActive && !isActive()) { danmakuEmitter?.clear(); translateQueue?.clear(); cancelSpeech(); } } function cleanupLegacyChatCollapseUi() { document.documentElement.classList.remove('ydm-chat-collapsed'); document.querySelectorAll( '[data-ydm-chat-collapse="true"], [data-ydm-chat-collapse-wrap="true"], [data-ydm-chat-expand="true"]', ).forEach((el) => el.remove()); document.querySelector('ytd-live-chat-frame[data-ydm-collapsed]')?.removeAttribute('data-ydm-collapsed'); } function teardown() { generation += 1; chatObserver?.stop(); favoritesPanel?.destroy(); fullscreenChat?.destroy(); enhancedPip?.destroy(); chatClearButton?.destroy(); cleanupLegacyChatCollapseUi(); danmaku?.destroy(); controlButton?.destroy(); danmakuEmitter?.clear(); translateQueue?.clear(); settingsPanel?.close(); speechPanel?.close(); historyPanel?.close(); liveViewersBadge?.stop(); cancelSpeech(); tabAntiFreezeDisable(); chatObserver = null; favoritesPanel = null; fullscreenChat = null; chatClearButton = null; danmaku = null; danmakuEmitter = null; translateQueue = null; controlButton = null; enhancedPip = null; liveViewersBadge = null; } async function boot() { if (!isWatchPage()) return; const gen = ++generation; settings = loadSettings(); pageActive = isPageActive(); if (!stylesInjected) { injectStyles(); stylesInjected = true; } ensureSpeechSpeaker(getSettings); const engine = new DanmakuEngine(getSettings); const emitter = new DanmakuEmitter(engine, getSettings, isActive); const queue = new TranslateQueue(getSettings, isActive); favoritesPanel = new FavoritesPanel(); favoritesPanel.attach(); fullscreenChat = new FullscreenChatInput({ favoritesPanel }); fullscreenChat.attach(); chatClearButton = new ChatClearButton({ onClear: () => { chatObserver?.seenIds?.clear?.(); }, }); const observer = new ChatObserver({ getSettings, danmakuEmitter: emitter, translateQueue: queue, isActive, favoritesPanel, }); 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(), }); } const controls = new ControlButton({ onToggleDanmaku: toggleDanmaku, onTogglePanel: (anchor) => { speechPanel.close(); historyPanel.close(); settingsPanel.toggle(anchor); controls.updatePanelState(); }, onToggleSpeech: (anchor) => { settingsPanel.close(); historyPanel.close(); speechPanel.toggle(anchor); controls.updatePanelState(); }, onToggleHistory: (anchor) => { settingsPanel.close(); speechPanel.close(); historyPanel.toggle(anchor); controls.updatePanelState(); }, getSettings, isPanelOpen: () => settingsPanel.isOpen, isSpeechPanelOpen: () => speechPanel.isOpen, isHistoryPanelOpen: () => historyPanel.isOpen, onTogglePip: () => enhancedPip?.toggle(), isPipOpen: () => !!enhancedPip?.isOpen(), }); await engine.mount(); if (gen !== generation) { engine.destroy(); return; } danmaku = engine; danmakuEmitter = emitter; translateQueue = queue; chatObserver = observer; controlButton = controls; enhancedPip = new EnhancedPip({ getSettings, danmaku: engine, favoritesPanel, onToggleDanmaku: toggleDanmaku, onOpenSettings: () => { speechPanel?.close(); historyPanel?.close(); const anchor = controlButton?.settingsBtn || controlButton?.wrap; if (anchor && !settingsPanel?.isOpen) settingsPanel.open(anchor); controlButton?.updatePanelState(); }, onStateChange: () => { syncBackgroundKeepAlive(); controlButton?.updatePipState(); }, }); liveViewersBadge = new LiveViewersBadge(); await observer.start(); if (gen !== generation) return; await controls.mount(); if (gen !== generation) return; await liveViewersBadge.start(); if (gen !== generation) return; await favoritesPanel.mount(); if (gen !== generation) return; await chatClearButton.mount(); if (gen !== generation) return; cleanupLegacyChatCollapseUi(); if (gen !== generation) return; syncBackgroundKeepAlive(); console.log('[YDM] YouTube Danmaku initialized'); } async function reconcile() { if (reconciling) return; const key = pageKey(); if (key === navKey) { if (!key) return; controlButton?.remount(); liveViewersBadge?.remount(); favoritesPanel?.remount(); chatClearButton?.remount(); if (chatObserver && !chatObserver.isConnected()) { chatObserver.start().catch((err) => console.warn('[YDM] Chat restart failed:', err)); } return; } reconciling = true; navKey = key; try { teardown(); if (key) await boot(); } catch (err) { console.error('[YDM] Reinit failed:', err); navKey = null; } finally { reconciling = false; } } function watchNavigation() { document.addEventListener('yt-navigate-finish', () => { setTimeout(() => { reconcile().catch((err) => console.error('[YDM] Reinit failed:', err)); }, 50); }); setInterval(() => { reconcile().catch((err) => console.error('[YDM] Reinit failed:', err)); }, 1500); } document.addEventListener('visibilitychange', handleVisibilityChange); navKey = pageKey(); if (navKey) { boot().catch((err) => { console.error('[YDM] Init failed:', err); navKey = null; }); } watchNavigation(); })();