// ==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 约定), 所以只能由这里覆盖。样式单独一张