// ==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.2.1
// @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 MIT
// @noframes
// @run-at document-idle
// ==/UserScript==
(function () {
'use strict';
const NS = 'HuyaLiveOptimizer';
const FALLBACK_VERSION = '2.2.1';
const VERSION = (() => {
try {
if (typeof GM_info !== 'undefined' && GM_info?.scriptMeta?.version) return String(GM_info.scriptMeta.version);
} catch (e) {
}
return FALLBACK_VERSION;
})();
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 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--"]',
fansBadgeItem: '[class*="BadgeItem--"]',
fansBadgeImg: 'img[class*="Floor--"]',
fansBadgeKind: '[class*="CustomBadge--"]',
// 弹幕颜色面板只在可见时渲染;同一 portal 下有多个面板,要按标题认领
fansBarragePortal: '.J_PortalChatPanelRoot',
fansBarrageHead: '[class*="PanelHd--"]',
fansBarrageItem: '[class*="item--"]',
// 触发按钮:给它的原生 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 typeof raw === 'string' ? JSON.parse(raw) : raw;
} catch (e) {
warn('store.get 失败', e);
return fallback;
}
},
set(key, value) {
try {
GM_setValue(key, JSON.stringify(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 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)}
`;
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;
}
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() : '';
}
isCurrentQuality(target) {
return PlayerOptimizer.normalize(this.currentQualityText()) === PlayerOptimizer.normalize(target);
}
static nameOf($, li) {
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.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');
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;
if (this.isTheaterOn()) {
this.theaterDone = true;
return true;
}
const $ = page$();
if (!$) return false;
const $btn = $(SEL.theaterBtn);
if (!$btn.length) return false;
if ($btn.hasClass(SEL.theaterOn)) {
this.theaterDone = true;
return true;
}
$btn.click();
await sleep(350);
this.theaterDone = this.isTheaterOn();
if (this.theaterDone) log('theater ok');
else warn('theater click did not stick');
return this.theaterDone;
}
async run() {
if (this.running) return;
this.running = true;
try {
await waitForJQuery(15000);
await waitFor(() => queryOne(SEL.theaterBtn), {
timeout: 15000,
interval: 500,
label: 'theater btn'
});
await waitFor(() => queryAll(SEL.qualityList).length > 0, {
timeout: 5000,
interval: 500,
label: 'quality list'
}).catch(() => null);
this.unlockQuality();
try {
await this.switchQuality();
} catch (e) {
warn('switchQuality', e);
}
await sleep(800);
await this.enterTheater();
log('done', {
quality: this.currentQualityText(),
theater: this.theaterDone,
qualityDone: this.qualityDone
});
} catch (e) {
warn('run error', e);
} finally {
this.running = false;
}
}
startWatchdog() {
setTimeout(() => this.run(), 1000);
let n = 0;
const t = setInterval(() => {
n += 1;
if (n > 3) {
clearInterval(t);
return;
}
if (this.running) return;
const theaterOk = !this.settings.autoTheater || this.theaterDone || this.isTheaterOn();
if (theaterOk && this.qualityDone) {
clearInterval(t);
return;
}
this.run();
}, 2000);
const onNav = () => {
this.theaterDone = false;
this.qualityDone = false;
setTimeout(() => this.run(), 900);
};
window.addEventListener('popstate', onNav);
let href = location.href;
let p = 0;
const nav = setInterval(() => {
p += 1;
if (p > 40) {
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) =>
`