// ==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.5.2
// @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 = 20;
/** 全站画质档位(高 → 低)。默认最高;用户选的档房间没有则回退最高。 */
const STANDARD_QUALITIES = [
'4K',
'2K',
'蓝光50M',
'蓝光30M',
'蓝光20M',
'蓝光15M',
'蓝光10M',
'蓝光8M',
'蓝光4M',
'超清',
'流畅'
];
const QUALITY_AUTO = '';
// 全局重试次数:所有需要重试的地方都用这一个值
const MAX_TRIES = 3;
const FANS_BADGE_DELAY = 15000;
const FANS_CHECKIN_DELAY = 30000;
const FANS_BADGE_RETRY = 6000;
// 播放器起播可能很慢,一轮 attempt 自己就要十几秒,
// 所以除了 MAX_TRIES 还留一个墙钟兜底,不靠 watchdog 的 tick 数算
const PLAYER_RETRY_BUDGET = 60000;
const PLAYER_RETRY_GAP = 2500;
// 落定后在这两个时间点复核画质:站侧可能在起播过程中改回默认档。
// 实测影院切换不再重建播放器(进出各 3 次画质都没掉),所以复核只看画质。
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',
// 弹幕颜色面板有两套并存:
// 粉丝团房间是 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));
}
}
/* ---------- 整站暗色 ----------
虎牙没有内置主题(实测 html 上没有主题 class、CSS 里也没有 dark/night/skin 约定),
所以只能由这里覆盖。样式单独一张