// ==UserScript==
// @name 飞牛影视 弹幕增强版 (Canvas 自绘)
// @namespace http://tampermonkey.net/
// @version 1.3.0
// @description 绝对时间定位消除抖动,自动隐藏UI,换集自动加载,时间偏移,触屏弹幕暂停
// @author Coffee
// @match http://192.168.10.252:5666/*
// @grant GM_xmlhttpRequest
// @connect 192.168.10.252
// @run-at document-end
// ==/UserScript==
(function () {
'use strict';
const FNIU = 'http://192.168.10.252:5666';
const DMAPI = 'http://192.168.10.252:9321';
const PSEL = 'div.xgplayer';
const STORE = 'fn_dm_cfg_v72';
const FONT = '"PingFang SC","Microsoft YaHei","Helvetica Neue",sans-serif';
/* ═══════════════════ 持久化配置 ═══════════════════ */
const DEF = {
on: true, opacity: 0.85, area: 35, fontSize: 22,
speed: 1, outline: true, density: 100, maxActive: 40, offset: 0
};
let cfg = { ...DEF };
try { Object.assign(cfg, JSON.parse(localStorage.getItem(STORE) || '{}')); } catch {}
const save = () => localStorage.setItem(STORE, JSON.stringify(cfg));
/* ═══════════════════ 全局状态 ═══════════════════ */
let cvs, ctx, lW, lH;
let allDm = [], active = [], eIdx = 0, rowData = [];
let vid, raf;
let injected = false, showGuide = false;
let playTime = 0, lastFrame = 0, lastDpr = 0;
const texCache = new Map();
let sBar, sTxt, sSub, cntEl;
let uiEls = [];
let lastEpGuid = '';
let rawDmBuf = null;
let dmDurBuf = 0;
let tooltipEl = null;
/* ═══════════════════ DOM ═══════════════════ */
function $(tag, html, css) {
const e = document.createElement(tag);
if (html != null) e.innerHTML = html;
if (css) e.style.cssText = css;
return e;
}
/* ═══════════════════ HTTP / API ═══════════════════ */
function gmFetch(url, o = {}) {
return new Promise((ok, no) => {
GM_xmlhttpRequest({
method: o.method || 'GET', url,
headers: o.headers || {}, data: o.data || null, timeout: 30000,
onload: r => { try { ok(JSON.parse(r.responseText)); } catch { no(r.responseText); } },
*: no, ontimeout: () => no('timeout')
});
});
}
const api = {
search: kw => gmFetch(`${DMAPI}/api/v2/search/anime?keyword=${encodeURIComponent(kw)}`),
match: fn => gmFetch(`${DMAPI}/api/v2/match`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
data: JSON.stringify({ fileName: fn })
}),
bangumi: id => gmFetch(`${DMAPI}/api/v2/bangumi/${id}`),
async comment(cid) {
const r = await gmFetch(`${DMAPI}/api/v2/comment/${cid}?format=json&duration=true`);
return {
comments: r.comments || (Array.isArray(r) ? r : []),
videoDuration: r.videoDuration || 0
};
}
};
/* ═══════════════════ 飞牛 Play Info ═══════════════════ */
function getGuid() { const m = location.pathname.match(/\/v\/video\/([a-f0-9]+)/i); return m ? m[1] : null; }
async function getInfo() {
const g = getGuid(); if (!g) return null;
const r = await gmFetch(`${FNIU}/v/api/v1/play/info`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
data: JSON.stringify({ item_guid: g })
});
if (r.code !== 0 || !r.data?.item) return null;
const t = r.data.item;
lastEpGuid = t.guid || '';
return {
title: t.tv_title || t.title, season: t.season_number || 1,
episode: t.episode_number || 1, isSeries: !!t.tv_title
};
}
/* ═══════════════════ 弹幕解析 ═══════════════════ */
function parse(raw) {
if (!Array.isArray(raw)) return [];
return raw.map(it => {
try {
if (!it) return null;
const p = String(it.p || '').split(',');
const time = parseFloat(p[0]); if (isNaN(time)) return null;
const mode = parseInt(p[1] || '1');
const ci = parseInt(p[2] || '16777215');
const text = String(it.m || '').trim(); if (!text) return null;
return { text, color: '#' + ci.toString(16).padStart(6, '0'), type: mode === 4 ? 1 : mode === 5 ? 2 : 0, time, w: 0 };
} catch { return null; }
}).filter(Boolean).sort((a, b) => a.time - b.time);
}
function measureAll() {
ctx.save(); ctx.font = `bold ${cfg.fontSize}px ${FONT}`;
allDm.forEach(d => d.w = ctx.measureText(d.text).width);
active.forEach(d => d.w = ctx.measureText(d.text).width);
ctx.restore();
}
function applyLoad() {
if (!rawDmBuf) return;
allDm = parse(rawDmBuf);
// 时长比例缩放:弹幕库时长 vs 本地视频时长
if (dmDurBuf > 0 && vid && vid.duration > 0 && Math.abs(vid.duration - dmDurBuf) > 5) {
const ratio = vid.duration / dmDurBuf;
if (ratio > 0.5 && ratio < 2.0) {
console.log(`[弹幕] 时长修正: 本地 ${vid.duration.toFixed(0)}s / 弹幕库 ${dmDurBuf.toFixed(0)}s = ${ratio.toFixed(4)}`);
allDm.forEach(d => d.time *= ratio);
allDm.sort((a, b) => a.time - b.time);
}
}
active = []; eIdx = 0; rowData = []; texCache.clear();
measureAll(); refreshCnt();
if (vid) onSeek();
}
function loadDm(raw, danmakuDuration) {
rawDmBuf = raw;
dmDurBuf = danmakuDuration || 0;
applyLoad();
}
function clearDm() { allDm = []; active = []; eIdx = 0; rowData = []; texCache.clear(); rawDmBuf = null; dmDurBuf = 0; refreshCnt(); }
function refreshCnt() { if (cntEl) cntEl.textContent = allDm.length ? ` (${allDm.length})` : ''; }
/* ═══════════════════ 纹理预渲染 (drawImage = GPU 合成) ═══════════════════ */
function getTex(text, color) {
const dpr = window.devicePixelRatio || 1;
if (dpr !== lastDpr) { texCache.clear(); lastDpr = dpr; }
const key = `${text}\x00${color}\x00${cfg.fontSize}\x00${cfg.outline}`;
let e = texCache.get(key);
if (e) return e;
const fs = cfg.fontSize;
const sw = cfg.outline ? Math.max(2, fs / 10) : 0;
const pad = Math.ceil(sw / 2) + 1;
const tc = document.createElement('canvas');
const t = tc.getContext('2d');
t.font = `bold ${fs}px ${FONT}`;
const tw = t.measureText(text).width;
const cW = Math.ceil(tw) + pad * 2;
const cH = Math.ceil(fs * 1.35) + pad * 2;
tc.width = cW * dpr; tc.height = cH * dpr;
t.scale(dpr, dpr);
t.font = `bold ${fs}px ${FONT}`;
t.textBaseline = 'top';
if (cfg.outline) {
t.strokeStyle = 'rgba(0,0,0,0.7)'; t.lineWidth = sw;
t.lineJoin = 'round'; t.strokeText(text, pad, pad);
}
t.fillStyle = color; t.fillText(text, pad, pad);
e = { c: tc, w: cW, h: cH, pad };
texCache.set(key, e);
if (texCache.size > 600) texCache.delete(texCache.keys().next().value);
return e;
}
/* ═══════════════════ Canvas 初始化 ═══════════════════ */
function initCvs(container, video) {
const c = $('canvas', null, 'position:absolute;top:0;left:0;width:100%;height:100%;z-index:10;pointer-events:none;will-change:transform;');
container.style.position = container.style.position || 'relative';
container.appendChild(c);
cvs = c; ctx = c.getContext('2d', { willReadFrequently: false, alpha: true }); vid = video;
function fit() {
const r = container.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
lW = r.width; lH = r.height;
c.width = lW * dpr; c.height = lH * dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
fit(); new ResizeObserver(fit).observe(container);
window.addEventListener('resize', fit);
video.addEventListener('play', () => { lastFrame = 0; });
video.addEventListener('pause', () => {});
video.addEventListener('ended', () => {});
video.addEventListener('seeked', onSeek);
video.addEventListener('loadedmetadata', () => {
playTime = video.currentTime;
// metadata 加载后重新应用时长缩放(首次加载时 vid.duration 可能还是 NaN)
if (rawDmBuf && dmDurBuf > 0) applyLoad();
});
video.addEventListener('ratechange', () => { lastFrame = 0; });
raf = requestAnimationFrame(tick);
}
function onSeek() {
if (!vid) return;
active = []; rowData = []; lastFrame = 0;
playTime = vid.currentTime;
const t = vid.currentTime - cfg.offset;
let lo = 0, hi = allDm.length;
while (lo < hi) { const m = (lo + hi) >> 1; allDm[m].time <= t ? lo = m + 1 : hi = m; }
eIdx = lo;
}
/* ═══════════════════ 主循环 — 绝对时间定位,无累积误差 ═══════════════════ */
function tick(ts) {
const dt = lastFrame ? Math.min((ts - lastFrame) / 1000, 0.05) : 0;
lastFrame = ts;
const playing = vid && !vid.paused && !vid.ended;
if (playing) {
playTime += dt * (vid.playbackRate || 1);
// 大跳校准(切集/长时间缓冲后追帧)
if (Math.abs(playTime - vid.currentTime) > 1.5) playTime = vid.currentTime;
}
if (playing && cfg.on && allDm.length && vid.currentTime > 0) emitNew();
pruneActive();
draw();
raf = requestAnimationFrame(tick);
}
/* ═══════════════════ 发射(vid.currentTime 同步 + 偏移)═════════════════ */
function emitNew() {
// 正数 offset = 延后,负数 = 提前
const ct = vid.currentTime - cfg.offset;
while (eIdx < allDm.length && allDm[eIdx].time <= ct + 0.2) {
const dm = allDm[eIdx++];
if (Math.random() * 100 >= cfg.density) continue;
if (active.length >= cfg.maxActive) continue;
fire(dm);
}
}
function fire(dm) {
const fs = cfg.fontSize, lh = fs * 1.5;
const maxR = Math.floor(lH * cfg.area / 100 / lh);
if (maxR <= 0) return;
if (dm.type === 0) {
const r = findRow(dm.w, maxR);
if (r < 0) return;
const spd = (120 + Math.random() * 50) * cfg.speed;
rowData[r] = { born: playTime, spd, w: dm.w };
active.push({ text: dm.text, color: dm.color, w: dm.w, startX: lW + 5, y: r * lh + fs, spd, fixed: false, born: playTime, time: dm.time, paused: false, highlight: false });
} else {
const bound = lH * cfg.area / 100;
const y = dm.type === 1 ? Math.max(fs * 2, bound - fs * 2) : fs * 1.5;
active.push({ text: dm.text, color: dm.color, w: dm.w, x: (lW - dm.w) / 2, y, fixed: true, born: playTime, life: 4, time: dm.time, paused: false, highlight: false });
}
}
function findRow(nw, maxR) {
let best = -1, bestClr = -1;
for (let r = 0; r < maxR; r++) {
const rd = rowData[r];
if (!rd) return r;
const elapsed = playTime - rd.born;
if (elapsed < 0) continue;
const rightEdge = (lW + 5) - elapsed * rd.spd + rd.w;
if (rightEdge < 0) { rowData[r] = null; return r; }
const clr = lW - rightEdge;
if (clr > nw + 40 && clr > bestClr) { bestClr = clr; best = r; }
}
return best;
}
function pruneActive() {
active = active.filter(d => {
if (d.paused) return true;
if (d.fixed) return (playTime - d.born) < d.life;
const x = d.startX - (playTime - d.born) * d.spd;
return x > -(d.w + 30);
});
}
/* ═══════════════════ 绘制 — drawImage 拷贝 GPU 纹理 ═══════════════════ */
function draw() {
ctx.clearRect(0, 0, lW, lH);
if (cfg.on && active.length) {
ctx.save(); ctx.globalAlpha = cfg.opacity;
for (const d of active) {
let x;
if (d.paused) {
x = d.pau**;
} else if (d.fixed) {
if ((playTime - d.born) > d.life) continue;
x = d.x;
} else {
x = d.startX - (playTime - d.born) * d.spd;
if (x < -(d.w + 10) || x > lW + 10) continue;
}
const tex = getTex(d.text, d.color);
// 暂停高亮
if (d.highlight) {
const p = 4;
ctx.fillStyle = 'rgba(79,140,255,0.25)';
ctx.beginPath();
if (ctx.roundRect) {
ctx.roundRect(Math.round(x) - p, Math.round(d.y) - p, tex.w + p * 2, tex.h + p * 2, 6);
} else {
ctx.rect(Math.round(x) - p, Math.round(d.y) - p, tex.w + p * 2, tex.h + p * 2);
}
ctx.fill();
}
ctx.drawImage(tex.c, Math.round(x) - tex.pad, Math.round(d.y) - tex.pad);
}
ctx.restore();
}
if (showGuide) {
const gy = lH * cfg.area / 100;
ctx.save();
ctx.fillStyle = 'rgba(79,140,255,0.06)'; ctx.fillRect(0, 0, lW, gy);
ctx.globalAlpha = 0.5; ctx.strokeStyle = '#4f8cff'; ctx.lineWidth = 1.5;
ctx.setLineDash([8, 5]); ctx.beginPath(); ctx.moveTo(0, gy); ctx.lineTo(lW, gy); ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = '#4f8cff'; ctx.globalAlpha = 0.8; ctx.font = `12px ${FONT}`; ctx.textBaseline = 'top';
ctx.fillText(`弹幕区域 ${cfg.area}%`, 10, gy + 6);
ctx.restore();
}
}
/* ═══════════════════ 自动隐藏 ═══════════════════ */
function setUIVis(vis) {
uiEls.forEach(el => {
el.style.opacity = vis ? '1' : '0';
el.style.pointerEvents = vis ? (el._dmPE || 'none') : 'none';
});
}
function setupAutoHide(container) {
let timer;
let onUI = false;
function show() {
if (onUI) return;
setUIVis(true);
clearTimeout(timer);
timer = setTimeout(() => setUIVis(false), 3000);
}
function hide() {
if (onUI) return;
clearTimeout(timer);
setUIVis(false);
}
container.addEventListener('mousemove', show);
container.addEventListener('mouseleave', hide);
let ctrlObserved = false;
function tryObserveCtrl() {
if (ctrlObserved) return;
const ctrl = container.querySelector('.xgplayer-controls');
if (!ctrl) return;
ctrlObserved = true;
const check = () => { if (!onUI) parseFloat(getComputedStyle(ctrl).opacity) > 0.1 ? show() : hide(); };
ctrl.addEventListener('transitionend', check);
new MutationObserver(check).observe(ctrl, { attributes: true, attributeFilter: ['class', 'style'] });
}
new MutationObserver(tryObserveCtrl).observe(container, { childList: true });
tryObserveCtrl();
uiEls.forEach(el => {
el.addEventListener('mouseenter', () => { onUI = true; clearTimeout(timer); });
el.addEventListener('mouseleave', () => { onUI = false; timer = setTimeout(() => setUIVis(false), 3000); });
});
show();
}
/* ═══════════════════ 触摸弹幕交互 ═══════════════════ */
function setupDanmakuTouch(container) {
tooltipEl = $('div', null,
'position:absolute;z-index:999998;background:rgba(0,0,0,.85);color:#fff;' +
'padding:6px 14px;border-radius:8px;font-size:14px;font-weight:bold;' +
'pointer-events:none;opacity:0;transition:opacity .3s;white-space:nowrap;' +
'backdrop-filter:blur(8px);letter-spacing:0.5px;');
container.appendChild(tooltipEl);
container.addEventListener('click', (e) => {
// 跳过 UI 元素上的点击
for (const el of uiEls) { if (el.contains(e.target)) return; }
if (!cfg.on || !active.length || !vid) return;
const rect = container.getBoundingClientRect();
const cx = e.clientX - rect.left;
const cy = e.clientY - rect.top;
const fs = cfg.fontSize;
// 倒序遍历(后渲染的在上层,优先命中)
for (let i = active.length - 1; i >= 0; i--) {
const d = active[i];
if (d.paused) continue;
let x;
if (d.fixed) {
if ((playTime - d.born) > d.life) continue;
x = d.x;
} else {
x = d.startX - (playTime - d.born) * d.spd;
}
// 命中检测(加 4px 容差方便触屏)
if (cx >= x - 4 && cx <= x + d.w + 4 && cy >= d.y - 4 && cy <= d.y + fs * 1.35 + 4) {
// 暂停这条弹幕
d.paused = true;
d.pau** = x;
d.pauseTime = playTime;
d.highlight = true;
// 显示时间戳
const t = d.time;
const min = Math.floor(t / 60);
const sec = Math.floor(t % 60);
tooltipEl.textContent = `${String(min).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
tooltipEl.style.left = Math.min(Math.max(x, 0), lW - 80) + 'px';
tooltipEl.style.top = Math.max(d.y - 30, 0) + 'px';
tooltipEl.style.opacity = '1';
// 3 秒后恢复
setTimeout(() => {
d.paused = false;
d.highlight = false;
// 补偿暂停期间 lost 的 born 偏移,让弹幕位置连续
d.born += (playTime - d.pauseTime);
tooltipEl.style.opacity = '0';
}, 3000);
e.stopPropagation();
return;
}
}
});
}
/* ═══════════════════ 换集检测(拦截 XHR + fetch)═════════════════ */
function setupEpWatch() {
function handle(item) {
if (!item) return;
const guid = item.guid || '';
if (guid === lastEpGuid) return;
lastEpGuid = guid;
const info = {
title: item.tv_title || item.title, season: item.season_number || 1,
episode: item.episode_number || 1, isSeries: !!item.tv_title
};
clearDm();
if (vid) { playTime = vid.currentTime; lastFrame = 0; }
autoMatch(info);
}
const _open = XMLHttpRequest.prototype.open;
const _send = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (method, url) { this._fnU = url; return _open.apply(this, arguments); };
XMLHttpRequest.prototype.send = function (body) {
if (this._fnU && typeof this._fnU === 'string' && this._fnU.includes('/v/api/v1/play/info')) {
this.addEventListener('load', function () {
try { const r = JSON.parse(this.responseText); if (r.code === 0 && r.data?.item) handle(r.data.item); } catch {}
});
}
return _send.apply(this, arguments);
};
if (window.fetch) {
const _f = window.fetch;
window.fetch = function (input, init) {
const url = typeof input === 'string' ? input : input?.url || '';
if (url.includes('/v/api/v1/play/info')) {
return _f.apply(this, arguments).then(resp => {
resp.clone().json().then(d => { if (d.code === 0 && d.data?.item) handle(d.data.item); }).catch(() => {});
return resp;
});
}
return _f.apply(this, arguments);
};
}
}
/* ═══════════════════ 状态提示 ═══════════════════ */
function flash(msg, err, sub) {
if (!sBar) return;
sTxt.textContent = msg; sTxt.style.color = err ? '#ff6666' : '#fff';
sSub.textContent = sub || '';
clearTimeout(sBar._t); sBar.style.opacity = '1';
sBar._t = setTimeout(() => sBar.style.opacity = '0', 8000);
}
function mkStatus(c) {
sBar = $('div', null, 'position:absolute;left:20px;bottom:60px;z-index:99999;background:rgba(0,0,0,.75);padding:10px 18px;border-radius:10px;font-size:14px;pointer-events:none;transition:opacity .4s;opacity:0;backdrop-filter:blur(6px);display:flex;flex-direction:column;gap:4px;');
sTxt = $('div', null, 'font-weight:500;color:#fff;');
sSub = $('div', null, 'font-size:12px;color:#aaa;');
sBar.append(sTxt, sSub); c.appendChild(sBar);
}
/* ═══════════════════ 主面板 ═══════════════════ */
function mkPanel(c, info) {
const btn = $('div', '弹幕',
'position:absolute;top:14px;right:70px;z-index:999999;background:rgba(20,20,20,.85);backdrop-filter:blur(10px);color:#fff;border-radius:10px;padding:8px 16px;cursor:pointer;font-size:14px;transition:opacity .3s,transform .2s;border:1px solid rgba(255,255,255,.08);box-shadow:0 2px 12px rgba(0,0,0,.4);pointer-events:auto;');
btn._dmPE = 'auto';
cntEl = $('span', '', 'font-size:12px;color:#aaa;margin-left:4px;');
btn.appendChild(cntEl);
btn.onmouseenter = () => btn.style.transform = 'scale(1.05)';
btn.onmouseleave = () => btn.style.transform = 'scale(1)';
const pn = $('div', null,
'position:absolute;top:58px;right:70px;width:400px;max-width:calc(100% - 20px);height:560px;max-height:calc(100% - 70px);z-index:999999;background:rgba(18,18,18,.95);backdrop-filter:blur(20px);border-radius:16px;overflow:hidden;border:1px solid rgba(255,255,255,.08);box-shadow:0 10px 40px rgba(0,0,0,.6);pointer-events:none;display:none;flex-direction:column;transition:opacity .3s;');
pn._dmPE = 'auto';
const tabBar = $('div', null, 'display:flex;border-bottom:1px solid rgba(255,255,255,.08);flex-shrink:0;');
const tabS = $('div', '弹幕搜索', 'flex:1;text-align:center;padding:14px;cursor:pointer;font-size:15px;font-weight:bold;color:#fff;border-bottom:2px solid #4f8cff;');
const tabG = $('div', '⚙ 设置', 'flex:1;text-align:center;padding:14px;cursor:pointer;font-size:15px;font-weight:bold;color:#888;');
tabBar.append(tabS, tabG);
const vS = $('div', null, 'flex:1;overflow:hidden;display:flex;flex-direction:column;');
const vG = $('div', null, 'flex:1;overflow-y:auto;display:none;padding:16px;box-sizing:border-box;');
buildSearch(vS, info); buildSettings(vG);
function sw(t) {
const isS = t === 's';
tabS.style.color = isS ? '#fff' : '#888'; tabS.style.borderBottom = isS ? '2px solid #4f8cff' : 'none';
tabG.style.color = isS ? '#888' : '#fff'; tabG.style.borderBottom = isS ? 'none' : '2px solid #4f8cff';
vS.style.display = isS ? 'flex' : 'none'; vG.style.display = isS ? 'none' : 'block';
showGuide = !isS;
}
tabS.* = () => sw('s'); tabG.* = () => sw('g');
pn.append(tabBar, vS, vG);
btn.* = () => {
const vis = pn.style.display === 'none';
pn.style.display = vis ? 'flex' : 'none';
showGuide = vis && tabG.style.color === 'rgb(255, 255, 255)';
};
c.append(btn, pn);
uiEls.push(btn, pn);
}
/* ─── 搜索 Tab ─── */
function buildSearch(root, info) {
const wrap = $('div', null, 'padding:14px;display:flex;gap:10px;flex-shrink:0;');
const inp = document.createElement('input');
inp.placeholder = '输入动漫名称';
inp.style.cssText = 'flex:1;background:#2b2b2b;border:none;outline:none;color:#fff;border-radius:10px;padding:12px;font-size:14px;';
const go = $('button', '搜索', 'background:#4f8cff;border:none;color:#fff;border-radius:10px;padding:0 18px;cursor:pointer;font-size:14px;');
wrap.append(inp, go);
const tip = $('div', null, 'padding:0 14px 8px;color:#999;font-size:13px;flex-shrink:0;');
const list = $('div', null, 'flex:1;overflow-y:auto;padding:0 10px 10px;');
root.append(wrap, tip, list);
async function doSearch(kw) {
if (!kw) return;
list.innerHTML = '<div style="color:#999;padding:20px;text-align:center;">搜索中...</div>';
try {
const r = await api.search(kw); const animes = r.animes || [];
list.innerHTML = '';
if (!animes.length) { list.innerHTML = '<div style="color:#999;padding:20px;text-align:center;">无结果</div>'; return; }
for (const a of animes) {
const it = $('div', null, 'background:#252525;border-radius:12px;margin-bottom:10px;padding:14px;cursor:pointer;transition:background .2s;');
it.onmouseenter = () => it.style.background = '#303030';
it.onmouseleave = () => it.style.background = '#252525';
it.innerHTML = `<div style="color:#fff;font-size:15px;font-weight:bold;">${a.animeTitle || a.title || a.name}</div>`;
it.* = async () => {
list.innerHTML = '<div style="color:#999;padding:20px;text-align:center;">加载剧集...</div>';
const bg = await api.bangumi(a.animeId || a.id);
const eps = (bg.bangumi?.episodes || bg.episodes || bg.data?.episodes || [])
.sort((x, y) => (parseInt(x.episodeNumber) || 0) - (parseInt(y.episodeNumber) || 0));
list.innerHTML = '';
if (!eps.length) { list.innerHTML = '<div style="color:#999;padding:20px;text-align:center;">暂无剧集</div>'; return; }
for (const ep of eps) {
const lb = `S${ep.seasonId?.split('-').pop() || '?'} E${ep.episodeNumber || '?'}`;
const ed = $('div', `<div style="color:#fff;font-size:14px;">${lb}</div>`, 'background:#252525;border-radius:10px;margin-bottom:8px;padding:12px;cursor:pointer;');
ed.* = async () => {
ed.innerHTML = '<div style="color:#4f8cff">加载中...</div>';
const desc = `${a.animeTitle || a.title} ${lb}`; flash('正在加载弹幕…', false, desc);
try {
const result = await api.comment(ep.episodeId);
if (result.comments.length) {
clearDm();
loadDm(result.comments, result.videoDuration);
flash(`加载成功,${result.comments.length} 条弹幕`, false, desc);
} else flash('该集暂无弹幕', true, desc);
} catch { flash('加载失败', true, desc); }
finally { ed.innerHTML = `<div style="color:#fff;font-size:14px;">${lb}</div>`; }
};
list.appendChild(ed);
}
};
list.appendChild(it);
}
} catch { list.innerHTML = '<div style="color:#ff6666;padding:20px;text-align:center;">搜索失败</div>'; }
}
go.* = () => doSearch(inp.value.trim());
inp.addEventListener('keydown', e => { if (e.key === 'Enter') doSearch(inp.value.trim()); });
if (info) {
inp.value = info.title;
tip.innerHTML = info.isSeries
? `当前:${info.title} S${String(info.season).padStart(2, '0')}E${String(info.episode).padStart(2, '0')}`
: `当前:${info.title}`;
}
}
/* ─── 设置 Tab ─── */
function buildSettings(root) {
function row(label, el) {
const r = $('div', null, 'display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;');
r.append($('span', label, 'color:#ccc;font-size:13px;min-width:70px;'), el); return r;
}
function sld(min, max, step, val, fmt, cb) {
const s = document.createElement('input');
s.type = 'range'; s.min = min; s.max = max; s.step = step; s.value = val;
s.style.cssText = 'flex:1;margin:0 10px;accent-color:#4f8cff;height:6px;';
const v = $('span', fmt(val), 'color:#fff;font-size:13px;min-width:52px;text-align:right;');
s.oninput = () => { v.textContent = fmt(+s.value); cb(+s.value); };
const w = $('div', null, 'display:flex;align-items:center;flex:1;'); w.append(s, v); return w;
}
function tgl(get, flip) {
const t = $('div', null, 'width:42px;height:24px;border-radius:12px;cursor:pointer;transition:background .3s;position:relative;flex-shrink:0;');
const d = $('div', null, 'width:18px;height:18px;border-radius:50%;background:#fff;position:absolute;top:3px;transition:left .3s;');
t.appendChild(d);
const u = () => { t.style.background = get() ? '#4f8cff' : '#444'; d.style.left = get() ? '21px' : '3px'; };
t.* = () => { flip(); u(); }; u(); return t;
}
const clr = $('button', '清除当前弹幕', 'width:100%;background:#333;border:none;color:#ccc;border-radius:8px;padding:10px;cursor:pointer;font-size:13px;margin-top:8px;');
clr.onmouseenter = () => clr.style.background = '#444';
clr.onmouseleave = () => clr.style.background = '#333';
clr.* = () => { clearDm(); flash('弹幕已清除'); };
root.append(
$('div', '显示', 'color:#666;font-size:11px;margin-bottom:10px;text-transform:uppercase;letter-spacing:1px;'),
row('弹幕开关', tgl(() => cfg.on, () => { cfg.on = !cfg.on; save(); })),
row('透明度', sld(0, 1, 0.05, cfg.opacity, v => v.toFixed(2), v => { cfg.opacity = v; save(); })),
row('显示区域', sld(10, 80, 5, cfg.area, v => v + '%', v => { cfg.area = v; save(); })),
row('文字描边', tgl(() => cfg.outline, () => { cfg.outline = !cfg.outline; save(); texCache.clear(); })),
$('div', '密度', 'color:#666;font-size:11px;margin:6px 0 10px;text-transform:uppercase;letter-spacing:1px;'),
row('字号', sld(14, 40, 1, cfg.fontSize, v => v + 'px', v => { cfg.fontSize = v; save(); texCache.clear(); measureAll(); })),
row('滚动速度', sld(0.5, 2.5, 0.1, cfg.speed, v => v.toFixed(1) + 'x', v => { cfg.speed = v; save(); })),
row('弹幕密度', sld(10, 100, 5, cfg.density, v => v + '%', v => { cfg.density = v; save(); })),
row('同屏上限', sld(10, 80, 5, cfg.maxActive, v => v + '条', v => { cfg.maxActive = v; save(); })),
$('div', '时间', 'color:#666;font-size:11px;margin:6px 0 10px;text-transform:uppercase;letter-spacing:1px;'),
row('偏移量', sld(-30, 30, 0.5, cfg.offset, v => (v > 0 ? '+' : '') + v.toFixed(1) + 's', v => { cfg.offset = v; save(); })),
$('div', '正数=延后 负数=提前', 'color:#555;font-size:11px;margin:-8px 0 14px;padding-left:70px;'),
clr
);
}
/* ═══════════════════ 自动匹配 ═══════════════════ */
async function autoMatch(info) {
if (!info) return;
const fn = info.isSeries
? `${info.title} S${String(info.season).padStart(2, '0')}E${String(info.episode).padStart(2, '0')}`
: info.title;
flash('正在自动匹配…', false, fn);
try {
const m = await api.match(fn);
if (!m.success || !m.isMatched) { flash('自动匹配失败', true, fn); return; }
const f = m.matches?.[0]; if (!f) { flash('无匹配结果', true, fn); return; }
const desc = `${f.animeTitle || '?'} ${f.episodeTitle || '?'}`;
const result = await api.comment(f.episodeId);
if (result.comments.length) {
clearDm();
loadDm(result.comments, result.videoDuration);
flash(`匹配成功,${result.comments.length} 条弹幕`, false, desc);
} else flash('匹配到但无弹幕', true, desc);
} catch { flash('匹配异常', true); }
}
/* ═══════════════════ 注入 ═══════════════════ */
async function inject() {
if (injected) return;
const container = document.querySelector(PSEL);
if (!container) return;
const video = container.querySelector('video');
if (!video) { setTimeout(inject, 500); return; }
injected = true;
initCvs(container, video);
const info = await getInfo();
mkStatus(container);
mkPanel(container, info);
setupAutoHide(container);
setupDanmakuTouch(container);
setupEpWatch();
autoMatch(info);
}
new MutationObserver(() => { if (document.querySelector(PSEL) && !injected) inject(); }).observe(document.body, { childList: true, subtree: true });
inject();
})();