// ==UserScript== // @name 虎牙直播优化器 | HuyaLiveOptimizer // @namespace https://github.com/mks155 // @homepageURL https://github.com/mks155/HuyaLiveOptimizer // @icon https://raw.githubusercontent.com/mks155/HuyaLiveOptimizer/main/docs/icon.svg // @version 2.3.4 // @description 进直播间自动解锁画质扫码限制、秒切最高/指定清晰度、一键进入观影模式;画面弹幕悬停可 +1 复读,发送框 ↑↓ 翻历史。设置全站生效,安装即用 | Auto unlock quality, switch to 4K/50M, theater mode, screen-danmaku +1, send history // @author mks155 // @copyright 2025, mks155 (https://github.com/mks155) // @match *://*.huya.com/* // @grant unsafeWindow // @grant GM_getValue // @grant GM_setValue // @license Apache-2.0 // @noframes // @run-at document-idle // ==/UserScript== (function () { 'use strict'; const NS = 'HuyaLiveOptimizer'; const VERSION = (() => { try { const script = (typeof GM_info !== 'undefined' && GM_info.script) || null; if (script && script.version) return String(script.version); const meta = (typeof GM_info !== 'undefined' && (GM_info.scriptMeta || script?.meta)) || null; const v = meta?.version || meta?.json?.version; if (v) return String(v); const src = script?.code || document.currentScript?.textContent || ''; const m = String(src).match(/@version\s+(\S+)/); if (m) return m[1]; } catch (e) { console.warn(`[${NS}]`, '解析版本号失败,回退 0.0.0', e); } return '0.0.0'; })(); const STORAGE_KEY = 'huya_optimizer'; const MAX_HISTORY = 50; /** 全站画质档位(高 → 低)。默认最高;用户选的档房间没有则回退最高。 */ const STANDARD_QUALITIES = [ '4K', '2K', '蓝光50M', '蓝光30M', '蓝光20M', '蓝光15M', '蓝光10M', '蓝光8M', '蓝光4M', '超清', '流畅' ]; const QUALITY_AUTO = ''; const FANS_BADGE_DELAY = 15000; const FANS_CHECKIN_DELAY = 30000; const FANS_BADGE_RETRY = 6000; const FANS_BADGE_MAX_TRIES = 12; const FANS_CAP_MISS_LIMIT = 3; // 播放器起播可能很慢,一轮 attempt 自己就要十几秒, // 所以重试按墙钟时间算,不按 watchdog 的 tick 数算 const PLAYER_RETRY_BUDGET = 60000; const PLAYER_RETRY_GAP = 2500; // 落定后在这两个时间点复核:影院切换理论上可能重建播放器把画质打回默认 const PLAYER_SETTLE_DELAYS = [4000, 12000]; // 落定复核最多补几轮,防止「复核→重跑→再复核」无限循环 const PLAYER_SETTLE_MAX_FIX = 2; const THEME_MODES = ['auto', 'light', 'dark']; const THEME_LABEL = { auto: '跟随浏览器', light: '白天', dark: '夜晚' }; const THEME_ICON = { auto: '', light: '', dark: '' }; const DEFAULT_SETTINGS = { targetQuality: QUALITY_AUTO, autoTheater: true, enableScreenPlusOne: true, enableDanmakuHistory: true, enableFansBadge: true, enableFansCheckIn: true, // auto=跟随浏览器 / light=白天 / dark=夜晚 themeMode: 'auto' }; const SEL = { theaterBtn: '#player-fullpage-btn', theaterOn: 'player-narrowpage', qualityList: '.player-videotype-list li', qualityCurrent: '.player-videotype-cur', giftRightUl: '.player-gift-right ul', nobleBtn: '#player-noble-btn', danmuWrap: '#danmuwrap, #player-danmu-wrap, .danmu-wrap', danmuItem: ['.danmu-item', '.player-danmu-item'], sendBtn: '#msg_send_bt', // 粉丝团面板:靠 TT.event.emit('FAN_CLUB_OPEN', tab, host) 打开,不是点出来的 fansPanel: '[class*="FanClubBd--"]', // 弹幕颜色面板有两套并存: // 粉丝团房间是 React portal(标题「粉丝弹幕」,靠 onMouseEnter 触发); // 赛事房间没有粉丝团,走普通 DOM 的 #J-room-club-color(标题「彩色弹幕」,靠 jQuery mouseover 触发)。 fansBarragePortal: '.J_PortalChatPanelRoot', fansBarrageHead: '[class*="PanelHd--"]', fansBarrageItem: '[class*="item--"]', fansClubColorPanel: '#J-room-club-color', fansClubColorList: '#J-color-list-club > li', // 触发按钮:两套实现监听的事件不同,mouseover 和 mouseenter 都要派 fansBarrageTrigger: '#J-room-chat-color', inputCandidates: [ '#pub_msg_input', '.chat-room__input input[type="text"]', '.chat-room__input input:not([type="button"]):not([type="submit"])', '.chat-room__input textarea', '.chat-room__input [contenteditable="true"]', '.chat-speaker input', '.chat-speaker [contenteditable="true"]', '#chatRoom input[type="text"]', '#chatRoom [contenteditable="true"]' ] }; // ---------- storage ---------- const store = { get(key, fallback) { try { const raw = GM_getValue(key, null); if (raw == null) return fallback; return raw; } catch (e) { warn('store.get 失败', e); return fallback; } }, set(key, value) { try { GM_setValue(key, value); return true; } catch (e) { warn('store.set 失败', e); return false; } } }; function loadStore() { const saved = store.get(STORAGE_KEY, null); return saved && typeof saved === 'object' ? saved : {}; } function saveStore(patch) { const next = Object.assign(loadStore(), patch); store.set(STORAGE_KEY, next); return next; } function loadSettings() { const data = loadStore(); return { targetQuality: data.targetQuality ?? DEFAULT_SETTINGS.targetQuality, autoTheater: data.autoTheater ?? DEFAULT_SETTINGS.autoTheater, enableScreenPlusOne: data.enableScreenPlusOne ?? DEFAULT_SETTINGS.enableScreenPlusOne, enableDanmakuHistory: data.enableDanmakuHistory ?? DEFAULT_SETTINGS.enableDanmakuHistory, enableFansBadge: data.enableFansBadge ?? DEFAULT_SETTINGS.enableFansBadge, enableFansCheckIn: data.enableFansCheckIn ?? DEFAULT_SETTINGS.enableFansCheckIn, themeMode: THEME_MODES.includes(data.themeMode) ? data.themeMode : DEFAULT_SETTINGS.themeMode }; } function saveSettings(s) { saveStore({ targetQuality: s.targetQuality, autoTheater: s.autoTheater, enableScreenPlusOne: s.enableScreenPlusOne, enableDanmakuHistory: s.enableDanmakuHistory, enableFansBadge: s.enableFansBadge, enableFansCheckIn: s.enableFansCheckIn, themeMode: s.themeMode }); } function loadHistory() { const list = loadStore().danmakuHistory; return Array.isArray(list) ? list.filter((x) => typeof x === 'string' && x.trim()) : []; } 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 copyText(text) { const ta = document.createElement('textarea'); ta.value = text; ta.setAttribute('readonly', ''); ta.style.cssText = 'position:fixed;left:-9999px;top:0;opacity:0'; document.body.appendChild(ta); ta.select(); ta.setSelectionRange(0, ta.value.length); let ok; try { ok = document.execCommand('copy'); } catch (e) { ok = false; } ta.remove(); if (!ok) { try { navigator.clipboard.writeText(text); ok = true; } catch (e) { ok = false; } } return ok; } // ---------- 举报:走官方视频弹幕黑条 ---------- // 官方在 #danmudiv 上用 jQuery 委托监听 mousedown 且 which===3(右键,不是 contextmenu), // 命中后弹 #player-danmu-report 黑条(复制/举报);uid/nick/msg 都存在元素的 jQuery data 上, // 点黑条里的「举报」才 trigger('reportMessage', {uid,nick,msg})。 // 所以不自造 payload,只把官方黑条唤出来点它那一项 —— uid 是真的,弹窗也是官方的。 // 黑条定位读的是元素的 css('top') 与 transform,不看事件坐标,弹幕飘走也不影响。 const DANMU_BAR = '#player-danmu-report'; // 黑条只对这三类节点生效 function isOfficialDanmu(el) { return !!el.closest('#danmudiv, #danmudiv2') && !!el.closest('.danmu-item, .danmu-tv-item-big, .danmu-tv-item-small'); } // 页面 realm 直接 new MouseEvent 并冒泡,官方是 jQuery 委托监听才能收到。 // 不复用 fireMouse:它兜底分支会把 bubbles/cancelable 写死成 false,失败还是静默的。 function pageMouse(el, type, init) { const view = el.ownerDocument.defaultView; const ev = new view.MouseEvent(type, { bubbles: true, cancelable: true, view, clientX: 0, clientY: 0, ...init, }); el.dispatchEvent(ev); return ev; } function centerOf(el) { const b = el.getBoundingClientRect(); return { clientX: Math.round(b.left + b.width / 2), clientY: Math.round(b.top + b.height / 2), }; } async function reportDanmu(item) { const bar = queryOne(DANMU_BAR); if (!bar) return { ok: false, reason: '官方黑条不存在' }; if (!item || !isOfficialDanmu(item)) return { ok: false, reason: '这不是官方弹幕节点' }; // 右键由官方按 which===3 判定,button:2 会被 jQuery 归一化成 3 pageMouse(item, 'mousedown', { ...centerOf(item), button: 2, buttons: 2 }); await sleep(120); if (getComputedStyle(bar).display === 'none') return { ok: false, reason: '黑条没弹出' }; const label = ((bar.querySelector('span') || {}).textContent || '').trim(); const btn = [...bar.querySelectorAll('span')].find((e) => (e.textContent || '').trim() === '举报'); if (!btn) { bar.style.display = 'none'; // 房管看到的是「禁言」,官方没给举报 return { ok: false, reason: label === '禁言' ? '你是房管,官方只给禁言' : '黑条里没有举报' }; } const at = centerOf(btn); pageMouse(btn, 'mousedown', { ...at, button: 0, buttons: 1 }); pageMouse(btn, 'mouseup', { ...at, button: 0, buttons: 0 }); pageMouse(btn, 'click', { ...at, button: 0, detail: 1 }); await sleep(150); return { ok: true, via: 'danmu-bar' }; } function waitFor(fn, { timeout = 15000, interval = 250, label = 'cond' } = {}) { return new Promise((resolve, reject) => { const start = Date.now(); const tick = () => { let ok; 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 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 []; } } const page$ = () => unsafeWindow.$ || unsafeWindow.jQuery || null; function waitForJQuery(timeout = 10000) { return waitFor(() => page$(), { timeout, interval: 200, label: 'jQuery' }); } function escapeHtml(str) { return String(str) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } function setNativeValue(el, value) { if (!el) return false; if (el.isContentEditable) { el.focus(); 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(HTMLInputElement.prototype, 'value') || Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value'); 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 ''; if (el.isContentEditable) return (el.textContent || '').trim(); return (el.value || '').trim(); } function findDanmakuInput() { return queryOne(SEL.inputCandidates); } function findSendBtn() { return queryOne([SEL.sendBtn, '.btn-sendMsg', '.chat-room__input .btn-sendMsg']); } function scheduleIdle(fn, timeout = 4000) { if (typeof requestIdleCallback === 'function') { requestIdleCallback(() => fn(), { timeout }); } else { setTimeout(fn, Math.min(timeout, 2500)); } } // ---------- styles ---------- function injectStyles() { if (document.getElementById('hlo-styles')) return; const style = document.createElement('style'); style.id = 'hlo-styles'; style.textContent = ` ul.player-gift-right{width:max-content!important;min-width:228px;display:flex!important;flex-wrap:nowrap!important;align-items:flex-start} ul.player-gift-right>li{float:none!important;flex:0 0 auto!important} #hlo-settings-btn{cursor:pointer!important;user-select:none;color:#f80!important} #hlo-settings-btn:hover{color:#ffaa33!important} #hlo-settings-btn i{width:24px;height:24px;display:inline-block;margin-top:8px;background:none!important;line-height:0;transition:filter .15s ease} #hlo-settings-btn:hover i{filter:brightness(1.18)} #hlo-settings-btn i svg{display:block;width:24px;height:24px;border-radius:5px;overflow:hidden} #hlo-settings-btn p{margin:0;font-size:12px;line-height:18px;text-align:center;color:inherit;white-space:nowrap} #hlo-settings-backdrop{position:fixed;inset:0;z-index:2147483645;background:transparent} #hlo-settings-panel{ --hlo-bg:#1f1f23;--hlo-fg:#eee;--hlo-title:#fff;--hlo-border:#3a3a40;--hlo-muted:#bbb;--hlo-faint:#888; --hlo-sep:#34343a;--hlo-weak:#6e6e78;--hlo-sel-bg:#2a2a30;--hlo-sel-fg:#eee;--hlo-sel-br:#444; --hlo-ghost-bg:#333;--hlo-ghost-fg:#ddd;--hlo-shadow:0 8px 28px rgba(0,0,0,.55);--hlo-btn:#f80; position:fixed;z-index:2147483646;width:280px;box-sizing:border-box; background:var(--hlo-bg);color:var(--hlo-fg);border:1px solid var(--hlo-border);border-radius:8px; box-shadow:var(--hlo-shadow);padding:12px 14px 14px; font:13px/1.5 inherit;pointer-events:auto!important } #hlo-settings-panel[data-theme="light"]{ --hlo-bg:#fff;--hlo-fg:#222;--hlo-title:#111;--hlo-border:#d8d8dc;--hlo-muted:#555;--hlo-faint:#888; --hlo-sep:#e6e6ea;--hlo-weak:#9a9aa2;--hlo-sel-bg:#f2f2f5;--hlo-sel-fg:#222;--hlo-sel-br:#ccc; --hlo-ghost-bg:#ececf0;--hlo-ghost-fg:#333;--hlo-shadow:0 8px 28px rgba(0,0,0,.18);--hlo-btn:#f80; } #hlo-settings-panel *{pointer-events:auto!important} #hlo-settings-panel .hlo-head{display:flex;align-items:flex-start;gap:8px;margin:0 0 10px} #hlo-settings-panel h3{flex:1;min-width:0;margin:0;font-size:14px;font-weight:600;color:var(--hlo-title)} #hlo-settings-panel .hlo-head-right{display:flex;align-items:center;gap:2px;flex:0 0 auto} #hlo-settings-panel .ver{font-size:10px;color:var(--hlo-weak);white-space:nowrap} #hlo-settings-panel #hlo-theme{ flex:0 0 auto;width:22px;height:22px;padding:0;border:0;background:none!important;border-radius:4px; color:var(--hlo-muted);cursor:pointer;line-height:0;transition:color .15s,background .15s } #hlo-settings-panel #hlo-theme:hover{color:var(--hlo-title);background:var(--hlo-ghost-bg)} #hlo-settings-panel #hlo-theme svg[data-mode]{display:none} #hlo-settings-panel #hlo-theme svg[data-active="1"]{display:block} #hlo-settings-panel label.row{display:flex;align-items:center;gap:8px;margin:8px 0;cursor:pointer} #hlo-settings-panel select{ width:100%;box-sizing:border-box;margin-top:4px;padding:6px 8px; border-radius:4px;border:1px solid var(--hlo-sel-br);background:var(--hlo-sel-bg);color:var(--hlo-sel-fg);font-size:13px } #hlo-settings-panel .field{margin-bottom:8px} #hlo-settings-panel .field-label{color:var(--hlo-muted);font-size:12px} #hlo-settings-panel .actions{display:flex;gap:8px;margin-top:12px} #hlo-settings-panel button{flex:1;padding:7px 0;border:none;border-radius:4px;cursor:pointer;font-size:13px} #hlo-settings-panel .btn-primary{background:var(--hlo-btn);color:#111;font-weight:600} #hlo-settings-panel .btn-ghost{background:var(--hlo-ghost-bg);color:var(--hlo-ghost-fg)} #hlo-settings-panel .hint{margin-top:8px;color:var(--hlo-faint);font-size:11px} #hlo-settings-panel .credits{margin:10px 0 -6px;padding-top:8px;border-top:1px solid var(--hlo-sep);color:var(--hlo-weak);font-size:11px;text-align:center} #hlo-settings-panel .credits .ver{margin-bottom:3px} #hlo-settings-panel .credits a{color:var(--hlo-btn);text-decoration:underline} #hlo-settings-panel .credits a:hover{color:#ffc45c} #hlo-danmu-freeze{ position:fixed;z-index:2147483001;display:flex;align-items:center;gap:8px; pointer-events:auto!important;max-width:min(90vw,640px); padding:2px 6px;border-radius:6px;background:rgba(0,0,0,.55); user-select:none;-webkit-user-select:none } #hlo-danmu-freeze .hlo-freeze-text{ color:#fff;font-size:16px;font-weight:700;line-height:24px; text-shadow:#222 1px 0 1px,#222 0 1px 1px; white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:420px; pointer-events:none;user-select:none } #hlo-danmu-freeze .hlo-plus1{ flex:0 0 auto;min-width:40px;height:26px;padding:0 10px;border:none;border-radius:13px; background:linear-gradient(180deg,#ff9a1f,#f80);color:#111;font-size:12px;font-weight:700; line-height:26px;cursor:pointer;box-shadow:0 1px 6px rgba(0,0,0,.4);white-space:nowrap } #hlo-danmu-freeze .hlo-plus1:hover{filter:brightness(1.08)} #hlo-danmu-freeze .hlo-dmbtn{ flex:0 0 auto;min-width:40px;height:26px;padding:0 10px;border:none;border-radius:13px; background:#4a4a52;color:#eee;font-size:12px;font-weight:700; line-height:26px;cursor:pointer;white-space:nowrap; box-shadow:0 1px 6px rgba(0,0,0,.4) } #hlo-danmu-freeze .hlo-dmbtn:hover{filter:brightness(1.12)} #hlo-danmu-freeze .hlo-dmbtn-report{background:linear-gradient(180deg,#ff9a1f,#f80);color:#111} #hlo-danmu-freeze .hlo-dmbtn[disabled]{opacity:.55;cursor:default;filter:none} `; document.head.appendChild(style); } function optimizerIconSvg() { return ( '' ); } function extractDanmuText(item) { if (!item) return ''; const spans = item.getElementsByTagName('span'); for (let i = 0; i < spans.length; i++) { const t = (spans[i].textContent || '').trim(); if (t && t.length <= 60) return t; } return (item.textContent || '').trim().slice(0, 60); } function closestDanmuItem(el) { if (!el || typeof el.closest !== 'function') return null; for (const sel of SEL.danmuItem) { const found = el.closest(sel); if (found) return found; } return null; } // ---------- player optimizer ---------- class PlayerOptimizer { constructor(settings) { this.settings = settings; this.theaterDone = false; this.qualityDone = false; this.running = false; this.targetText = ''; this.settleTimers = []; this.settleFixes = 0; this.warnedNoVideo = false; } isValidQuality(q) { return !!(q && q !== 'null' && q !== 'undefined' && q !== 'false' && String(q).trim()); } unlockQuality() { const $ = page$(); if (!$) { warn('jQuery missing, skip unlock'); return false; } try { const $list = $(SEL.qualityList); if (!$list.length) return false; let changed = 0; $list.each((_, li) => { const data = $(li).data('data'); if (data && data.status !== 0) { data.status = 0; changed += 1; } }); log(`unlock items=${$list.length} changed=${changed}`); return true; } catch (e) { warn('unlock failed', e); return false; } } currentQualityText() { const el = queryOne(SEL.qualityCurrent); return el ? (el.textContent || '').trim() : ''; } // 列表上带 on 的那项才是真选中的;标签 .player-videotype-cur 在播放器没起播时会滞后 selectedQualityText() { const on = queryOne('.player-videotype-list li.on'); if (!on) return ''; const $ = page$(); if ($) { try { const d = $(on).data('data') || {}; if (d.sDisplayName) return String(d.sDisplayName).trim(); } catch (e) { } } // 后缀实测有三种:「请先登录」「请先登陆」「扫码即享」(赛事/蓝光档用后者)。 // 正常都靠 sDisplayName 绕开,这里兜底也要剥干净,否则和标签比不上就算成没切上去。 return (on.textContent || '') .replace(/请先登录|请先登陆|扫码即享/g, '') .trim(); } // 起播前清晰度列表是占位的(只有「超清|高清」这种),点了也不生效,必须等 video 真的有数据 isPlayerReady() { const v = queryOne(['#player-container video', '#player-vdieobox video', 'video']); return !!v && (v.readyState >= 2 || v.videoWidth > 0); } // 页面里一个 video 都没有,说明是电台之类不推视频的房间, // 再等多久都没用,必须认出来直接跳过,否则每轮白等 15s hasVideoElement() { return !!queryOne(['#player-container video', '#player-vdieobox video', 'video']); } // 占位列表的每一项都没有站侧数据(实测 data('data') 是 {}), // 真实列表每项都带 sDisplayName / status。拿占位列表去解析目标只会选到「超清」, // 所以在拿到真实列表之前一律视为不可用。 realQualityList() { const $ = page$(); if (!$) return null; const $list = $(SEL.qualityList); if (!$list.length) return null; let real = false; $list.each((_, li) => { const d = $(li).data('data'); if (d && (d.sDisplayName != null || d.status != null)) real = true; }); return real ? $list : null; } // 两个信号打架(标签=高清 而 li.on=超清)就是起播前的占位状态, // 这时任何一边都不能当准 —— 之前用 || 合并会在这里误判成「已经是超清了」, // 于是连点都不点就收工,最高画质永远上不去。 isCurrentQuality(target) { const n = PlayerOptimizer.normalize(target); const label = PlayerOptimizer.normalize(this.currentQualityText()); const selected = PlayerOptimizer.normalize(this.selectedQualityText()); if (label && selected && label !== selected) return false; return label === n || selected === n; } static nameOf($, li) { // 未解锁时渲染文本会带「请先登录」后缀,sDisplayName 才是干净的档位名 const d = (li && $(li).data('data')) || {}; if (d.sDisplayName) return String(d.sDisplayName).trim(); const $span = $(li).find('span').first(); return ($span.length ? $span.text() : $(li).text()).trim(); } static normalize(name) { return String(name == null ? '' : name) .replace(/[\s\u00a0\u3000]/g, '') .toUpperCase(); } resolveTarget($, $list) { const nameOf = (li) => PlayerOptimizer.nameOf($, li); const pick = (name) => { const want = PlayerOptimizer.normalize(name); if (!want) return null; let hit = null; $list.each((_, li) => { if (!hit && PlayerOptimizer.normalize(nameOf(li)) === want) hit = li; }); return hit; }; if (this.isValidQuality(this.settings.targetQuality)) { const el = pick(this.settings.targetQuality); if (el) return { el, text: this.settings.targetQuality }; warn(`quality "${this.settings.targetQuality}" not in room → highest`); } for (const q of STANDARD_QUALITIES) { const el = pick(q); if (el) return { el, text: q }; } const first = $list.get(0); return { el: first, text: first ? nameOf(first) : '' }; } async waitForQuality(target, timeout = 8000) { try { await waitFor(() => this.isCurrentQuality(target), { timeout, interval: 250, label: `quality ${target}` }); return true; } catch (e) { warn(`quality timeout cur=${this.selectedQualityText() || '-'} label=${this.currentQualityText() || '-'} want=${target}`); return false; } } async switchQuality() { const $ = page$(); if (!$) throw new Error('jQuery not ready'); // 只认真实列表:占位列表里没有蓝光/4K,解析出来的目标必然是「超清」 const $list = this.realQualityList(); if (!$list) throw new Error('quality list not ready'); this.unlockQuality(); const { el, text } = this.resolveTarget($, $list); if (!el || !text) throw new Error('target quality missing'); // 记下目标:站点若把画质改回去,落定复核要拿它跟当前实际档位比, // 而不是信 qualityDone 这个标志位(标志位不会自己变回 false) this.targetText = text; if (this.isCurrentQuality(text)) { this.qualityDone = true; return true; } $(el).click(); this.qualityDone = await this.waitForQuality(text, 5000); return this.qualityDone; } isTheaterOn() { const $ = page$(); if ($) { const $btn = $(SEL.theaterBtn); if ($btn.length && $btn.hasClass(SEL.theaterOn)) return true; } const btn = queryOne(SEL.theaterBtn); return !!(btn && btn.classList.contains(SEL.theaterOn)); } async enterTheater() { if (!this.settings.autoTheater) return true; // 自动进观影只在进入页面的最初几秒负责一次:进成就置 theaterDone, // 之后(落定复核 / 换画质补跑)都不再点它,用户手动退出也就不会被拽回去 if (this.theaterDone) return true; if (this.isTheaterOn()) { this.theaterDone = true; return true; } const $ = page$(); if (!$) return false; const btn = await waitFor(() => queryOne(SEL.theaterBtn), { timeout: 4000, interval: 300, label: 'theater btn' }) .then(() => queryOne(SEL.theaterBtn)) .catch(() => null); if (!btn) return false; if (this.isTheaterOn()) { this.theaterDone = true; return true; } if ($) $(btn).click(); else btn.click(); // 切影院要重建播放器,等固定 350ms 常常判成没切上,这里等真生效 this.theaterDone = await waitFor(() => this.isTheaterOn(), { timeout: 6000, interval: 250, label: 'theater on' }) .then(() => true) .catch(() => false); if (this.theaterDone) log('theater ok'); else warn('theater click did not stick'); return this.theaterDone; } // 两样是否真的都到位了。画质要比当前实际档位,不能只看 qualityDone 标志位, // 站点把画质改回去时那个标志位仍是 true。 settled() { // 观影只看 theaterDone:进过一次就算完成,之后不再维持;画质要比当前实际档位 const theaterOk = !this.settings.autoTheater || this.theaterDone; const qualityOk = this.targetText ? this.isCurrentQuality(this.targetText) : this.qualityDone; return theaterOk && qualityOk; } // 一轮尝试:等就绪 → 解锁 → 切清晰度 → 进影院 async attempt() { if (!page$()) return false; // 没起播就切清晰度必然失败,起播前清晰度列表只是占位的 if (!this.isPlayerReady()) { if (this.hasVideoElement()) { await waitFor(() => this.isPlayerReady(), { timeout: 15000, interval: 500, label: 'player ready' }).catch(() => null); } else if (!this.warnedNoVideo) { this.warnedNoVideo = true; warn('player not streaming:页面里没有 video 元素,可能不是视频直播间,跳过清晰度与影院'); } } // 等真实清晰度列表:起播前那份只有「超清|高清」,拿去解析目标只会锁死在超清 await waitFor(() => this.realQualityList(), { timeout: 8000, interval: 400, label: 'quality list' }).catch(() => null); this.unlockQuality(); try { await this.switchQuality(); } catch (e) { warn('switchQuality', e); } await sleep(600); await this.enterTheater(); return this.settled(); } async run() { if (this.running) return; this.running = true; try { // 超时不该把整个 run 带走,交给下面的循环继续重试 await waitForJQuery(15000).catch(() => warn('jQuery not ready, 继续重试')); // 重试必须在 run 内部按墙钟时间做。之前是靠 watchdog 的 20 个 tick, // 而一轮 attempt 自己就要十几秒,tick 在真正重试之前就被耗光了: // 播放器起得慢时第一轮还在跑,定时器就已经 clearInterval 放弃。 const until = Date.now() + PLAYER_RETRY_BUDGET; let rounds = 0; do { rounds += 1; if (await this.attempt()) break; await sleep(PLAYER_RETRY_GAP); } while (Date.now() < until); log('done', { 轮数: rounds, quality: this.selectedQualityText() || this.currentQualityText(), theater: this.theaterDone, qualityDone: this.qualityDone }); this.scheduleSettleCheck(); } catch (e) { warn('run error', e); } finally { this.running = false; } } // 影院切换理论上可能重建播放器把画质打回默认,落定后再核几轮,掉了就补一轮。 // run() 结尾还会再排一次复核,不封顶就变成「复核→重跑→再复核」的无限链。 scheduleSettleCheck() { this.clearSettleTimers(); if (this.settleFixes >= PLAYER_SETTLE_MAX_FIX) return; for (const d of PLAYER_SETTLE_DELAYS) { const id = setTimeout(() => { this.settleTimers.splice(this.settleTimers.indexOf(id), 1); if (this.running) return; if (!this.settled()) { this.settleFixes += 1; warn( `settle check:画质或影院没保住,补一轮(${this.settleFixes}/${PLAYER_SETTLE_MAX_FIX})`, { 当前画质: this.selectedQualityText() || this.currentQualityText(), 目标画质: this.targetText || '-', 影院: this.isTheaterOn() } ); this.run(); } }, d); this.settleTimers.push(id); } } clearSettleTimers() { this.settleTimers.forEach((id) => clearTimeout(id)); this.settleTimers = []; } startWatchdog() { setTimeout(() => this.run(), 800); // 只负责换房间后重来;轮询式重试已经收进 run() 内部按墙钟时间做了 const onNav = () => { this.theaterDone = false; this.qualityDone = false; this.targetText = ''; this.settleFixes = 0; this.warnedNoVideo = false; this.clearSettleTimers(); setTimeout(() => this.run(), 900); }; window.addEventListener('popstate', onNav); let href = location.href; let p = 0; const nav = setInterval(() => { p += 1; if (p > 75) { clearInterval(nav); return; } if (location.href !== href) { href = location.href; onNav(); } }, 4000); } updateSettings(s) { this.settings = s; } } // ---------- settings UI ---------- class SettingsUI { constructor(optimizer, settings, onChange) { this.optimizer = optimizer; this.settings = settings; this.onChange = onChange; this.panel = null; this.backdrop = null; } injectButton() { if (document.getElementById('hlo-settings-btn')) return true; const noble = queryOne(SEL.nobleBtn); const ul = noble?.closest('ul') || queryOne(SEL.giftRightUl); if (!ul) return false; const btn = document.createElement('li'); btn.id = 'hlo-settings-btn'; btn.title = '虎牙直播优化器 设置(全站)'; btn.innerHTML = `${optimizerIconSvg()}
设置
`; btn.addEventListener('mousedown', (e) => e.stopPropagation()); btn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); this.toggle(btn); }); if (noble && noble.parentElement === ul) noble.insertAdjacentElement('afterend', btn); else ul.appendChild(btn); return true; } watchButton() { if (this.injectButton()) return; let n = 0; const t = setInterval(() => { n += 1; if (this.injectButton() || n > 25) clearInterval(t); }, 1800); } close() { if (this.themeMq && this.themeMqHandler) { try { if (typeof this.themeMq.removeEventListener === 'function') { this.themeMq.removeEventListener('change', this.themeMqHandler); } else if (typeof this.themeMq.removeListener === 'function') { this.themeMq.removeListener(this.themeMqHandler); } } catch (e) { } } this.themeMq = null; this.themeMqHandler = null; this.panel?.remove(); this.backdrop?.remove(); this.panel = null; this.backdrop = null; } toggle(anchor) { if (this.panel) this.close(); else this.open(anchor); } open(anchor) { this.close(); const backdrop = document.createElement('div'); backdrop.id = 'hlo-settings-backdrop'; backdrop.addEventListener('mousedown', (e) => { e.preventDefault(); e.stopPropagation(); this.close(); }); document.body.appendChild(backdrop); this.backdrop = backdrop; const panel = document.createElement('div'); panel.id = 'hlo-settings-panel'; panel.innerHTML = this.formHtml(); ['mousedown', 'mouseup', 'click', 'contextmenu', 'wheel'].forEach((type) => { panel.addEventListener(type, (e) => e.stopPropagation()); }); document.body.appendChild(panel); this.panel = panel; this.applyTheme(panel, this.settings.themeMode); const rect = anchor?.getBoundingClientRect?.() || { left: window.innerWidth - 320, top: window.innerHeight - 160 }; const w = 280; const h = panel.offsetHeight || 280; const left = Math.min(Math.max(8, rect.left - w + 40), window.innerWidth - w - 8); let top = Math.max(8, (rect.top || 0) - h - 12); if (top < 8) top = Math.min((rect.bottom || 100) + 12, window.innerHeight - h - 8); panel.style.left = `${left}px`; panel.style.top = `${top}px`; this.bind(panel); } formHtml() { const current = this.settings.targetQuality || QUALITY_AUTO; const opts = [QUALITY_AUTO, ...STANDARD_QUALITIES] .map((q) => { const label = q === QUALITY_AUTO ? '最高画质(默认)' : q; const sel = q === current ? ' selected' : ''; return ``; }) .join(''); const mode = THEME_MODES.includes(this.settings.themeMode) ? this.settings.themeMode : 'auto'; const icons = THEME_MODES.map((m) => `