// ==UserScript== // @name 抖音直播优化器 | DouyinLiveOptimizer // @namespace https://github.com/mks155 // @homepageURL https://github.com/mks155/DouyinLiveOptimizer // @icon https://raw.githubusercontent.com/mks155/DouyinLiveOptimizer/refs/heads/main/docs/icon.svg // @version 1.0.0 // @description 首页默认静音、进房自动切最高画质、画面弹幕悬停 +1、弹幕输入框 ↑↓ 翻历史 | Auto mute, auto best quality, on-video danmaku +1, send history on live.douyin.com // @author mks155 // @copyright 2026, mks155 (https://github.com/mks155) // @match *://live.douyin.com/* // @grant unsafeWindow // @grant GM_getValue // @grant GM_setValue // @license Apache-2.0 // @noframes // @run-at document-start // ==/UserScript== (function () { 'use strict'; /** 统一标识:日志前缀、DOM id 前缀、配置存储键,都用它 */ const NS = 'DouyinLiveOptimizer'; // 顶层只取引用,解析推迟到使用时 const SELF_SRC = (document.currentScript && document.currentScript.textContent) || ''; const META = (typeof GM_info !== 'undefined' && GM_info.script) || {}; const _metaCache = new Map(); /** 版本号/图标取自元数据:先用 GM_info,其余引擎解析脚本文本 */ function meta(key) { if (_metaCache.has(key)) return _metaCache.get(key); let v = ''; if (key in META) { v = META[key] || ''; } else { const m = SELF_SRC && new RegExp('^[ \\t]*//[ \\t]*@' + key + '[ \\t]+(.+?)[ \\t]*$', 'm').exec(SELF_SRC); v = m ? m[1] : ''; } _metaCache.set(key, v); return v; } const MAX_HISTORY = 50; const QUALITY_AUTO = ''; // 画质档位从高到低;按名字匹配,不依赖面板顺序 const QUALITY_RANKS = ['原画', '蓝光', '超清', '高清', '标清', '流畅']; // 未登录时高档位带「登录即享」,点了只弹登录框 const QUALITY_LOCK_HINTS = /登录即享|请先登录|请先登陆|扫码|开通/i; // 配色档位:auto 跟随系统,light/dark 强制指定 const THEME_MODES = ['auto', 'light', 'dark']; const THEME_LABEL = { auto: '跟随系统', light: '浅色(白天)', dark: '深色(夜晚)' }; const THEME_ICON = { auto: '', light: '', dark: '' }; const DEFAULT_SETTINGS = { // 只作用于 https://live.douyin.com/ 首页(首页大卡片播放器会自动出声) muteOnHome: true, targetQuality: QUALITY_AUTO, enableScreenPlusOne: true, enableDanmakuHistory: true, themeMode: 'auto' }; // ---------- 深浅模式:驱动官方主题 ---------- // 持久化 cookie:theme / manual_theme;DOM 标记: +
+ meta[theme-color]。 // 官方按账号设置重刷时不接管其 DOM API,只事后纠正;用户点官方控件才反向同步配置。 const THEME_COOKIE = 'theme'; const THEME_MANUAL_COOKIE = 'manual_theme'; // 官方加载后会按账号设置重刷主题,轮询要跟上 const THEME_WATCH_MS = 700; function readCookie(name) { const m = document.cookie.match(new RegExp('(?:^|;\\s*)' + name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '=([^;]*)')); return m ? decodeURIComponent(m[1]) : ''; } function writeCookie(name, value) { try { document.cookie = name + '=' + encodeURIComponent(value) + '; path=/; domain=.douyin.com; max-age=' + 400 * 24 * 3600; } catch (e) { } } function clearCookie(name) { try { document.cookie = name + '=; path=/; domain=.douyin.com; max-age=0'; } catch (e) { } } class OfficialTheme { constructor(settings) { this.settings = settings; this.mq = null; this.mqHandler = null; this.timer = 0; this.domObserver = null; this.watched = new WeakSet(); this.reassertAt = 0; this.reassertCount = 0; this.reassertTotal = 0; } update(s) { this.settings = s; this.apply(); } /** auto 读系统主题,light/dark 直接判定 */ isDark() { const mode = this.settings.themeMode; if (mode === 'light') return false; if (mode === 'dark') return true; try { return window.matchMedia('(prefers-color-scheme: dark)').matches; } catch (e) { return true; } } /** 与官方主题模块一致的 DOM 标记 */ paint(isDark) { try { const html = document.documentElement; const body = document.body; const dark = document.getElementById('dark'); if (isDark) { html.setAttribute('dark', 'true'); body.setAttribute('theme-mode', 'dark'); } else { html.removeAttribute('dark'); if (body.hasAttribute('theme-mode')) body.removeAttribute('theme-mode'); if (dark) dark.removeAttribute('id'); } const meta = document.querySelector('meta[name="theme-color"]'); if (meta) meta.setAttribute('content', isDark ? '#161823' : '#ffffff'); localStorage.setItem('ELECTRON_THEME_KEY', isDark ? 'DARK' : ''); } catch (e) { } } /** 官方只有浅/深两档;auto 时 manual_theme 留空,表示用户没手动选过 */ target() { const v = this.isDark() ? 'dark' : 'light'; return { theme: v, manual: this.settings.themeMode === 'auto' ? '' : v }; } /** 只写不一致的项,避免 cookie 每轮都被写 */ writeTarget() { const t = this.target(); if (readCookie(THEME_COOKIE) !== t.theme) writeCookie(THEME_COOKIE, t.theme); if (t.manual) { if (readCookie(THEME_MANUAL_COOKIE) !== t.manual) writeCookie(THEME_MANUAL_COOKIE, t.manual); } else if (readCookie(THEME_MANUAL_COOKIE)) { clearCookie(THEME_MANUAL_COOKIE); } } /** DOM 是否已符合脚本档位 */ domMatches() { const wantDark = this.isDark(); const htmlDark = document.documentElement.hasAttribute('dark'); const bodyDark = !!(document.body && document.body.getAttribute('theme-mode') === 'dark'); return htmlDark === wantDark && bodyDark === wantDark; } apply() { this.paint(this.isDark()); this.writeTarget(); this.watchSystem(); this.installDomHooks(); } /** * 官方会按服务端账号设置重刷 html[dark] / body[theme-mode],来源拦不住, * 只做事后纠正(不改写页面 DOM API)并限频。document-start 时元素可能为 null,故谁出现挂谁。 */ installDomHooks() { if (!this.domObserver) { this.domObserver = new MutationObserver(() => this.reassert()); } const watch = (el) => { if (!el || this.watched.has(el)) return false; this.watched.add(el); try { this.domObserver.observe(el, { attributes: true, attributeFilter: ['dark', 'theme-mode'] }); } catch (e) { return false; } return true; }; let installed = watch(document.documentElement); if (watch(document.body)) installed = true; if (installed) this.reassert(); // 元素还没出来就重试,挂齐即停 if (!(document.documentElement && document.body) && !this.hookTimer) { let n = 0; const tick = () => { this.hookTimer = 0; this.installDomHooks(); if (document.documentElement && document.body) return; if (++n > 300) return; this.hookTimer = setTimeout(tick, 10); }; this.hookTimer = setTimeout(tick, 10); } } /** 纠正官方写歪的主题标记:每秒最多 8 次、累计 200 次,防止反复触发拖死主线程 */ reassert() { if (this.domMatches()) return; if (this.reassertTotal >= 200) { if (this.timer) { clearInterval(this.timer); this.timer = 0; } this.domObserver?.disconnect(); this.domObserver = null; log('主题纠正次数过多,停止强制,交给官方自己控制'); return; } const now = Date.now(); if (now - this.reassertAt > 1000) { this.reassertAt = now; this.reassertCount = 0; } if (++this.reassertCount > 8) return; this.reassertTotal += 1; this.paint(this.isDark()); } /** auto 档跟随系统主题变化 */ watchSystem() { if (this.settings.themeMode !== 'auto') { this.unwatchSystem(); return; } if (this.mq) return; try { this.mq = window.matchMedia('(prefers-color-scheme: dark)'); this.mqHandler = () => this.apply(); if (this.mq.addEventListener) this.mq.addEventListener('change', this.mqHandler); else if (this.mq.addListener) this.mq.addListener(this.mqHandler); } catch (e) { } } unwatchSystem() { if (!this.mq) return; try { if (this.mq.removeEventListener) this.mq.removeEventListener('change', this.mqHandler); else if (this.mq.removeListener) this.mq.removeListener(this.mqHandler); } catch (e) { } this.mq = null; this.mqHandler = null; } /** 用户在官方 UI 改了主题 → 同步脚本配置 */ adopt(mode) { if (mode === this.settings.themeMode) return; this.settings.themeMode = mode; saveSettings(this.settings); this.onModeChanged?.(mode); log('官方主题已变更,脚本配置同步为', mode); this.apply(); } /** 用 document 捕获阶段事件委托识别官方主题控件(hover 才渲染、可能在 portal 里),用户真点了才同步 */ watchOfficialToggle() { if (this.toggleBound) return; this.toggleBound = true; const LABEL = /^(深色模式|浅色模式|经典模式|深浅模式|背景设置|夜间模式|白天模式|主题)$/; const modeOf = (el) => { let node = el; for (let i = 0; i < 4 && node; i++) { if (node.nodeType === 1) { const label = ((node.getAttribute('title') || '') + ' ' + (node.getAttribute('aria-label') || '')).trim(); const text = (node.textContent || '').trim(); const hit = LABEL.test(label) ? label : text.length <= 8 && LABEL.test(text) ? text : ''; if (hit) { if (/深色|夜间/.test(hit)) return 'dark'; if (/浅色|白天/.test(hit)) return 'light'; return ''; // 经典模式/背景设置之类,脚本没有对应档位,不猜 } } node = node.parentElement; } return ''; }; document.addEventListener( 'click', (e) => { const target = e.target && e.target.nodeType === 1 ? e.target : e.target && e.target.parentElement; if (!target) return; const mode = modeOf(target); if (mode) this.adopt(mode); }, true ); } /** 脚本是主题的唯一主:轮询只把 DOM 保持在脚本档位,不按 cookie 反推用户意图 */ reconcile() { this.installDomHooks(); if (!this.domMatches()) this.paint(this.isDark()); } startWatch() { if (this.timer) return; this.timer = setInterval(() => this.reconcile(), THEME_WATCH_MS); } } // 页面选择器(live.douyin.com): // 画质档位 [data-e2e="quality"],下拉 [data-e2e="quality-selector"] // 发送框/按钮 .webcast-chatroom___textarea / .webcast-chatroom___send-btn // 画面弹幕画在 canvas 上,没有 DOM 节点。 const SEL = { qualityLabel: '[data-e2e="quality"]', qualityList: '[data-e2e="quality-selector"]', // 登录态聊天框是 contenteditable,容器里还有表情搜索 input,可编辑区域必须排前面 inputCandidates: [ '.webcast-chatroom___textarea', '.webcast-chatroom___input-container [contenteditable="true"]', '.webcast-chatroom___input-container textarea', '.webcast-chatroom___input-container input:not([type="search"])', '[data-e2e="room-chat-input"]', '[data-e2e="chat-input"]' ], sendBtnCandidates: ['.webcast-chatroom___send-btn', '.webcast-chatroom___enter'] }; // ---------- storage ---------- const store = { get(key, fallback) { try { const raw = GM_getValue(key, null); return raw == null ? fallback : raw; } catch (e) { return fallback; } }, set(key, value) { try { GM_setValue(key, value); return true; } catch (e) { return false; } } }; function loadStore() { const raw = store.get(NS, null); // 部分脚本管理器会把对象序列化成 JSON 字符串 if (typeof raw === 'string') { try { return JSON.parse(raw) || {}; } catch (e) { return {}; } } return raw && typeof raw === 'object' ? raw : {}; } function saveStore(patch) { const next = Object.assign(loadStore(), patch); store.set(NS, next); return next; } function loadSettings() { const d = loadStore(); return { muteOnHome: d.muteOnHome ?? DEFAULT_SETTINGS.muteOnHome, targetQuality: d.targetQuality ?? DEFAULT_SETTINGS.targetQuality, enableScreenPlusOne: d.enableScreenPlusOne ?? DEFAULT_SETTINGS.enableScreenPlusOne, enableDanmakuHistory: d.enableDanmakuHistory ?? DEFAULT_SETTINGS.enableDanmakuHistory, themeMode: THEME_MODES.includes(d.themeMode) ? d.themeMode : DEFAULT_SETTINGS.themeMode }; } function saveSettings(s) { saveStore({ muteOnHome: s.muteOnHome, targetQuality: s.targetQuality, enableScreenPlusOne: s.enableScreenPlusOne, enableDanmakuHistory: s.enableDanmakuHistory, themeMode: s.themeMode }); } function loadHistory() { const list = loadStore().danmakuHistory; if (Array.isArray(list)) return list.filter((x) => typeof x === 'string' && x.trim()); // 历史兼容:也可能以 JSON 文本形式存储 if (typeof list === 'string') { try { const arr = JSON.parse(list); if (Array.isArray(arr)) return arr.filter((x) => typeof x === 'string' && x.trim()); } catch (e) { } } return []; } function saveHistory(list) { saveStore({ danmakuHistory: list.slice(-MAX_HISTORY) }); } // ---------- utils ---------- const log = (...a) => console.log(`[${NS}]`, ...a); const warn = (...a) => console.warn(`[${NS}]`, ...a); const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); function queryOne(selectors, root = document) { const list = Array.isArray(selectors) ? selectors : [selectors]; for (const sel of list) { try { const el = root.querySelector(sel); if (el) return el; } catch (e) { } } return null; } function queryAll(selector, root = document) { try { return Array.from(root.querySelectorAll(selector)); } catch (e) { return []; } } function escapeHtml(str) { return String(str) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } function waitFor(fn, { timeout = 8000, interval = 250, label = 'cond' } = {}) { return new Promise((resolve, reject) => { const start = Date.now(); const tick = () => { let ok = false; try { ok = !!fn(); } catch (e) { ok = false; } if (ok) return resolve(true); if (Date.now() - start >= timeout) return reject(new Error(`timeout: ${label}`)); setTimeout(tick, interval); }; tick(); }); } function setNativeValue(el, value) { if (!el) return false; if (el.isContentEditable) { el.focus(); // contenteditable 改 textContent 会被编辑器状态机清空,走 execCommand 才有 input 事件 let ok = false; try { if (typeof document.execCommand === 'function') { const sel = window.getSelection(); const range = document.createRange(); range.selectNodeContents(el); sel.removeAllRanges(); sel.addRange(range); ok = document.execCommand('insertText', false, value); } } catch (e) { } if (!ok) { el.textContent = value; el.dispatchEvent(new InputEvent('input', { bubbles: true, cancelable: true, inputType: 'insertText', data: value })); } el.dispatchEvent(new Event('change', { bubbles: true })); return true; } const desc = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(el), 'value') || Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value') || Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value'); // React 受控组件只认原型上的原生 setter if (desc?.set) desc.set.call(el, value); else el.value = value; el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); return true; } function readInputValue(el) { if (!el) return ''; const raw = el.isContentEditable ? el.textContent || '' : el.value || ''; // 编辑器会在末尾塞零宽空格,trim 去不掉,先清掉再判空 return raw.replace(/[\u200B-\u200D\uFEFF]/g, '').trim(); } /** React 组件的 onClick 必须直接调用;el.click() 派发的事件 React 收不到 */ function reactClick(el) { if (!el) return false; const key = Object.keys(el).find((k) => k.startsWith('__reactProps') || k.startsWith('__reactInternalInstance')); if (!key) return false; let props = el[key]; if (!props || typeof props !== 'object') { // 旧版 fiber:从 __reactInternalInstance 往上取 memoizedProps let fiber = el[key]; let depth = 0; while (fiber && depth < 6) { props = fiber.memoizedProps || {}; if (props && typeof props.onClick === 'function') break; fiber = fiber.return; depth += 1; } } const fn = props && props.onClick; if (typeof fn !== 'function') return false; try { fn({ type: 'click', preventDefault() {}, stopPropagation() {}, isDefaultPrevented: () => false, isPropagationStopped: () => false, bubbles: true, cancelable: true, currentTarget: el, target: el, nativeEvent: { isTrusted: true, stopPropagation() {}, preventDefault() {} } }); return true; } catch (e) { warn('reactClick 调用出错', e); return false; } } function scheduleIdle(fn, timeout = 4000) { if (typeof requestIdleCallback === 'function') requestIdleCallback(() => fn(), { timeout }); else setTimeout(fn, Math.min(timeout, 2500)); } function findDanmakuInput() { return queryOne(SEL.inputCandidates); } function findSendBtn() { return queryOne(SEL.sendBtnCandidates); } // SPA 站:进出直播间不刷新页面,功能靠 URL 轮询在路由变化时重跑 function isHome() { return location.hostname === 'live.douyin.com' && location.pathname === '/'; } function isRoom() { return location.hostname === 'live.douyin.com' && location.pathname !== '/'; } // ---------- 功能 1:首页默认静音 ---------- // 只在首页静音,每个媒体元素只处理一次;用户手动开声后不再强制 const mutedOnce = new WeakSet(); let muteObserver = null; function muteMedia(el) { if (!el || mutedOnce.has(el)) return false; mutedOnce.add(el); try { el.muted = true; } catch (e) { return false; } return true; } function muteExistingMedia() { let n = 0; queryAll('video, audio').forEach((el) => { if (muteMedia(el)) n += 1; }); return n; } function stopMute() { muteObserver?.disconnect(); muteObserver = null; } function startHomeMute() { if (!isHome()) { stopMute(); return; } const n = muteExistingMedia(); log(`首页静音:本次处理 ${n} 个媒体元素`); // 首页是无限滚动卡片流,新出现的播放器也要静音 if (muteObserver) return; try { muteObserver = new MutationObserver((records) => { for (const rec of records) { rec.addedNodes.forEach((node) => { if (!(node instanceof Element)) return; if (node.matches?.('video, audio')) muteMedia(node); queryAll('video, audio', node).forEach(muteMedia); }); } }); muteObserver.observe(document.documentElement, { childList: true, subtree: true }); } catch (e) { warn('首页静音监听失败', e); } } // ---------- 功能 2:自动最高画质 ---------- function currentQuality() { return (queryOne(SEL.qualityLabel)?.textContent || '').trim(); } function readQualityTiers() { const list = queryOne(SEL.qualityList); if (!list) return []; return queryAll(':scope > div', list) .map((el) => { const raw = (el.textContent || '').replace(/\s+/g, ' ').trim(); // 「原画登录即享」→「原画」 const name = (raw.replace(QUALITY_LOCK_HINTS, '') || raw).trim(); return { el, raw, name, locked: QUALITY_LOCK_HINTS.test(raw), selected: /background/.test(el.firstElementChild?.getAttribute?.('style') || '') }; }) .filter((t) => t.name); } function rankOf(name) { const i = QUALITY_RANKS.findIndex((q) => name.includes(q)); return i < 0 ? QUALITY_RANKS.length : i; } class QualityOptimizer { constructor(settings) { this.settings = settings; this.targetText = ''; this.done = false; this.running = false; } updateSettings(s) { this.settings = s; this.done = false; this.targetText = ''; } pickTarget(tiers) { const usable = tiers.filter((t) => !t.locked); if (!usable.length) return null; const want = (this.settings.targetQuality || '').trim(); if (want) { const hit = usable.find((t) => t.name.includes(want)); if (hit) return hit; warn(`画质「${want}」本房间不可用,回退最高可用档`); } // 面板顺序不可靠,按档位名再兜一次底 return usable.reduce((best, t) => (best === null || rankOf(t.name) < rankOf(best.name) ? t : best), null); } isSettled() { return this.done && !!this.targetText && currentQuality().includes(this.targetText); } async attempt() { const tiers = await waitFor(() => readQualityTiers().length, { timeout: 8000, interval: 400, label: 'quality list' }) .then(readQualityTiers) .catch(() => readQualityTiers()); if (!tiers.length) return false; const target = this.pickTarget(tiers); if (!target) { // 全是锁定档就别点了,点只会弹登录框 warn('所有画质档都需登录,跳过自动切换', tiers.map((t) => t.name)); this.done = true; return true; } this.targetText = target.name; if (target.selected || currentQuality().includes(target.name)) { log(`画质已是最高可用档:${target.name}`); this.done = true; return true; } // 下拉面板常驻 DOM(收起时宽度 0),可直接调 React onClick if (!reactClick(target.el)) target.el.click(); log(`请求切换画质:${target.name}(当前 ${currentQuality() || '?'})`); const ok = await waitFor(() => currentQuality().includes(target.name), { timeout: 6000, interval: 300, label: `quality ${target.name}` }) .then(() => true) .catch(() => false); this.done = ok; if (ok) log(`画质已切到:${target.name}`); else warn(`画质未切上:想要 ${target.name},现在 ${currentQuality() || '?'}`); return ok; } async run() { if (this.running) return; this.running = true; try { const budget = Date.now() + 30000; let rounds = 0; do { rounds += 1; if (await this.attempt()) break; await sleep(2500); } while (Date.now() < budget && !this.isSettled()); log('画质处理结束', { 轮数: rounds, 画质: currentQuality() || '?', 目标: this.targetText || '-' }); } catch (e) { warn('画质处理出错', e); } finally { this.running = false; } } } // ---------- 功能 3 & 4:弹幕 +1 / 发送历史 ---------- const historyStore = { list: loadHistory(), index: -1, draft: '', push(text) { const v = (text || '').trim(); if (!v) return; // 只跟最后一条去重,不然复读同一句会被当重复丢掉 if (this.list[this.list.length - 1] === v) { this.index = -1; this.draft = ''; return; } this.list.push(v); if (this.list.length > MAX_HISTORY) this.list = this.list.slice(-MAX_HISTORY); saveHistory(this.list); this.index = -1; this.draft = ''; } }; function sendDanmaku(text) { const value = (text || '').trim(); if (!value) return false; const input = findDanmakuInput(); if (!input) { warn('发送框没找到(未登录时抖音不渲染聊天框)'); return false; } input.focus(); setNativeValue(input, value); // 等编辑器内容真正回填后再点发送 let tries = 0; const tick = () => { const cur = readInputValue(input); if (cur === value || (tries >= 6 && cur.includes(value))) { const btn = findSendBtn(); if (btn) { if (!reactClick(btn)) btn.click(); } else { input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', keyCode: 13, bubbles: true })); } historyStore.push(value); return; } tries += 1; if (tries < 20) setTimeout(tick, 50); else warn('弹幕回填失败,未发送:' + value); }; setTimeout(tick, 50); return true; } class DanmakuHistory { constructor(settings) { this.settings = settings; this.bound = false; // 最近一次输入的内容,发送成功时入历史 this.pending = ''; // 轮询到的输入框内容,用来发现「被清空」= 发送成功 this.seen = ''; this.timer = 0; } update(s) { this.settings = s; } isInput(el) { if (!el) return false; const tag = el.tagName; if (tag !== 'INPUT' && tag !== 'TEXTAREA' && !el.isContentEditable) return false; // 顶部搜索框不是聊天输入框,别把它当弹幕框 if (el.closest?.('[data-e2e="searchbar-input"], [data-e2e="searchbar"]')) return false; if (tag === 'INPUT' && el.type === 'search') return false; // 已知的聊天输入框 if (el.closest?.('.webcast-chatroom___input-container')) return true; if (el.matches?.(SEL.inputCandidates.join(','))) return true; // 兜底:聊天区域内的可编辑元素 if (el.closest?.('.webcast-chatroom, [class*="chatroom"], [class*="chat-room"]')) return true; return findDanmakuInput() === el; } caretEnd(el) { try { if (el.isContentEditable) { const r = document.createRange(); r.selectNodeContents(el); r.collapse(false); const s = window.getSelection(); s.removeAllRanges(); s.addRange(r); } else if (el.setSelectionRange) { const n = el.value.length; el.setSelectionRange(n, n); } el.focus(); } catch (e) { } } /** 发送成功,入历史。el 传用户真正输入的那个元素,别重新查 DOM 取错对象 */ commit(el) { if (this.settings.enableDanmakuHistory === false) return; const v = (el ? readInputValue(el) : '') || this.pending || readInputValue(findDanmakuInput()); this.pending = ''; historyStore.push(v); } /** 输入框被清空 = 发送成功(React 直接改 value、不触发 input,只能轮询) */ startWatch() { if (this.timer) return; this.timer = setInterval(() => { if (this.settings.enableDanmakuHistory === false) return; const el = findDanmakuInput(); if (!el) { this.seen = ''; return; } const cur = readInputValue(el); if (this.seen && !cur) { historyStore.push(this.seen); this.pending = ''; } this.seen = cur; }, 250); } bind() { if (this.bound) return; this.bound = true; // --- 记录「待发送的内容」--- document.addEventListener( 'input', (e) => { if (this.settings.enableDanmakuHistory === false) return; if (!this.isInput(e.target)) return; const v = readInputValue(e.target); if (v) { this.pending = v; this.seen = v; } else { // 用户自己删空的,那不是发送,别记进历史 this.pending = ''; this.seen = ''; } }, true ); // --- 出口 1:回车发送 --- // 输入法上屏的回车 isComposing 为 true,但抖音照样发出,不能跳过 document.addEventListener( 'keydown', (e) => { if (e.key !== 'Enter' || e.shiftKey) return; if (!this.isInput(e.target)) return; this.commit(e.target); }, true ); // --- 出口 2:输入法上屏 --- document.addEventListener( 'compositionend', (e) => { if (!this.isInput(e.target)) return; this.commit(e.target); }, true ); // --- 出口 3:点发送按钮 --- document.addEventListener( 'click', (e) => { const t = e.target; if (!t?.closest) return; if (!t.closest(SEL.sendBtnCandidates.join(','))) return; this.commit(findDanmakuInput()); }, true ); // --- 出口 4:输入框被清空(万能兜底)--- this.startWatch(); // --- 上下键翻历史 --- document.addEventListener( 'keydown', (e) => { if (this.settings.enableDanmakuHistory === false) return; if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return; const input = e.target; if (!this.isInput(input)) return; // 中文输入法组字期间,上下键是选词不是翻历史,抢了就打不出字 if (e.isComposing || input.composing) return; if (!historyStore.list.length) return; e.preventDefault(); e.stopPropagation(); if (e.key === 'ArrowUp') { if (historyStore.index === -1) { historyStore.draft = readInputValue(input); historyStore.index = historyStore.list.length - 1; } else if (historyStore.index > 0) { historyStore.index -= 1; } } else { if (historyStore.index === -1) return; if (historyStore.index < historyStore.list.length - 1) { historyStore.index += 1; } else { // 翻到最新一条之后再往下,回到刚才没翻之前的内容 historyStore.index = -1; setNativeValue(input, historyStore.draft); this.caretEnd(input); return; } } setNativeValue(input, historyStore.list[historyStore.index] || ''); this.caretEnd(input); }, true ); } } // ---------- 画面弹幕:worker 注入 + 命中层 ---------- // 画面弹幕由 CanvasDanmakuPlugin 画在 OffscreenCanvas 上、worker 渲染,主线程拿不到文本和坐标; // 站方用 Blob(importScripts) 建 worker,劫持 Blob 注入补丁,hook fillText 后经 BroadcastChannel 回传。 const BC_NAME = '__dlo_danmaku_v1__'; const WORKER_PATCH = ` ;try{ var __ch = new BroadcastChannel(${JSON.stringify(BC_NAME)}); var __P = OffscreenCanvasRenderingContext2D.prototype; var __of = __P.fillText, __os = __P.strokeText, __oc = __P.clearRect; var __ctxs = [], __timer = 0; /* 被钉住的弹幕:主线程指定坐标后画在固定位置,其它弹幕照常飘 */ var __pin = null, __pinCtx = null, __pinFont = null, __pinColor = null; function __report(txt, x, y){ var arr = null, i; for(i=0;i<__ctxs.length;i++){ if(__ctxs[i].c === this){ arr = __ctxs[i].a; break; } } if(!arr){ arr = []; __ctxs.push({c:this, a:arr}); } var cv = this.canvas || null; var w = 0; try{ w = this.measureText(txt).width || 0; }catch(e){} arr.push([txt, x, y, this.font, (cv&&cv.width)||0, (cv&&cv.height)||0, w]); if(!__timer) __timer = setTimeout(__flush, 0); } var __flush = function(){ __timer = 0; var out = []; try{ for(var i=0;i<__ctxs.length;i++){ var a = __ctxs[i].a; for(var j=0;j