// ==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.3
// @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 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 = [];
}
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) {
}
}
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);
}
isCurrentQuality(target) {
const n = PlayerOptimizer.normalize(target);
return PlayerOptimizer.normalize(this.currentQualityText()) === n ||
PlayerOptimizer.normalize(this.selectedQualityText()) === 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');
const $list = $(SEL.qualityList);
if (!$list.length) throw new Error('quality list missing');
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()) {
await waitFor(() => this.isPlayerReady(), {
timeout: 15000,
interval: 500,
label: 'player ready'
}).catch(() => null);
}
await waitFor(() => queryAll(SEL.qualityList).length > 0, {
timeout: 4000,
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;
}
}
// 影院切换理论上可能重建播放器把画质打回默认,落定后再核几轮,掉了就补一轮
scheduleSettleCheck() {
this.clearSettleTimers();
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()) {
warn('settle check: 画质或影院没保住,补一轮', {
当前画质: 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.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) =>
`