收起左侧

飞牛电脑web端弹幕脚本

1
回复
24
查看
[ 复制链接 ]

0

主题

1

回帖

0

牛值

江湖小虾

飞牛影视 弹幕增强版

为飞牛影视播放器叠加 Canvas 弹幕层,支持自动匹配、手动搜索、时间偏移等功能。


功能一览

弹幕显示

  • Canvas 自绘弹幕 — 独立 Canvas 层叠加在原播放器上方,不侵入播放器内部逻辑
  • GPU 加速渲染 — 弹幕文字预渲染为离屏纹理,绘制时走 drawImage(GPU 合成),不走 fillText(CPU 光栅化)
  • 绝对时间定位 — 基于 performance.now() 驱动,60/120/144Hz 自适应,无累积浮点误差,弹幕移动丝滑
  • DPR 适配 — 高分屏(Retina)下文字清晰不模糊
  • 文字描边 — 默认开启黑色描边,在亮色画面下也能看清弹幕

弹幕加载

  • 自动匹配 — 进入播放页后,根据视频标题和集数自动从弹幕库匹配并加载弹幕
  • 手动搜索 — 弹幕面板支持按动漫名称搜索,选择剧集后手动加载
  • 换集自动加载 — 拦截飞牛播放器的换集请求(/play/info),换集后自动清空旧弹幕并重新匹配新集弹幕
  • 时间偏移 — 弹幕整体提前或延后 0 ~ 30 秒,适配音画不同步的情况

遮挡控制

  • 行碰撞避免 — 自动检测每行右边缘空余空间,优先放入空行,避免弹幕大面积重叠
  • 密度调节 — 按百分比随机过滤弹幕,调低后显示更稀疏
  • 同屏上限 — 限制屏幕上同时存在的弹幕条数,超出后新弹幕自动跳过
  • 显示区域 — 弹幕只在播放器顶部指定百分比高度内飞行,不遮挡字幕区域

前置准备

1. 安装弹幕后端(danmu-api)

弹幕数据由Danmu API提供,以 Docker 方式部署。

在你的 NAS 或服务器上创建 docker-compose.yml

services:
  danmu-api:
    image: logvar/danmu-api:latest
    ports:
      - "9321:9321"
    volumes:
      - ./config:/app/config    # config 目录下可创建 .env 配置文件,修改后热更新
      - ./.cache:/app/.cache    # 缓存目录,数据持久化
    restart: unless-stopped

2. 安装 Tampermonkey(篡改猴) 浏览器扩展

本教程不做概述,请自行寻找安装方法。

3. 安装脚本

第一步:修改 IP 地址

打开脚本文件,找到顶部这两个常量,将 192.168.10.252 替换为你自己的 NAS IP:

javascript

javascriptconst FNIU  = 'http://192.168.10.252:5666';   // ← 飞牛影视地址(含端口)
const DMAPI = 'http://192.168.10.252:9321';    // ← danmu-api 地址(默认 9321 端口)

同时修改脚本头部的 @match 行:

javascript

javascript// @match        http://<你的NAS-IP>:5666/*

如果飞牛影视使用了其他端口,也需要一并修改。

第二步:导入脚本

  1. 1.点击浏览器右上角的 Tampermonkey 图标
  2. 2.选择 "添加新脚本"(或 "Create a new script")
  3. 3.清空编辑器中的默认内容
  4. 4.将修改好 IP 的完整脚本粘贴进去
  5. 5.按 Ctrl + S 保存

使用方法

基本流程

  1. 1.打开飞牛影视,随意进入一个视频播放页
  2. 2.脚本会自动注入,在播放器右上角出现 「弹幕」 按钮
  3. 3.脚本会自动尝试匹配当前视频的弹幕,状态提示显示在播放器左下角
  4. 4.匹配成功后弹幕自动开始播放

自动匹配失败时

  1. 1.点击右上角 「弹幕」 按钮,打开弹幕面板
  2. 2.在搜索框中输入动漫名称(默认已填入当前视频标题),点击搜索
  3. 3.在搜索结果中找到正确的动漫,点击展开剧集列表
  4. 4.点击对应集数,弹幕即刻加载

调整设置

  1. 1.点击 「弹幕」 按钮打开面板
  2. 2.切换到 「⚙ 设置」 标签页
  3. 3.此时播放器上会出现蓝色虚线标注弹幕活动区域
  4. 4.拖动滑块或点击开关,所有修改即时生效
  5. 5.关闭面板后设置自动保存

时间偏移

如果弹幕和画面不同步:

  • 弹幕比画面快 → 调大偏移量(正数,如 +2s),弹幕整体延后
  • 弹幕比画面慢 → 调小偏移量(负数,如 -2s),弹幕整体提前
  • 每次调 0.5~1 秒逐步微调,直到对齐

使用效果

5538b67f-e93a-44c5-a6af-9d82ad841229.png

常见问题

Q: 弹幕按钮不出现

  • 确认 Tampermonkey 中脚本已启用(开关为绿色)
  • 确认 @match 地址与飞牛影视的实际访问地址一致(含协议 http/https 和端口)
  • 刷新页面后等待播放器加载完成(脚本使用 MutationObserver 监听,播放器出现后自动注入)

Q: 自动匹配失败

  • 确认 danmu-api 服务正常运行(浏览器直接访问 http://<NAS-IP>:9321 看是否有响应)
  • 确认脚本中 DMAPI 地址正确
  • 弹幕库可能没有收录该视频,请使用手动搜索

Q: 换集后弹幕没有更新

  • 脚本通过拦截 /v/api/v1/play/info 请求检测换集
  • 如果飞牛影视使用了其他 API 路径加载视频信息,可能无法自动检测
  • 此时手动搜索加载即可

Q: 弹幕还是有点 卡

  • 确认浏览器开启了硬件加速(Chrome → 设置 → 系统 → 使用硬件加速模式)
  • 尝试降低「同屏上限」和「弹幕密度」
  • 将「文字描边」关闭可略微减少渲染开销

Q: 面板在播放器外面,看不到

  • 面板定位基于播放器容器,在窄屏或小窗模式下可能超出范围
  • 尝试全屏播放或扩大浏览器窗口

Q: 感觉剧情和弹幕对不上

  • 自动匹配的时候左下角会显示匹配的集数,有可能会匹配错误,需要手动搜索指定
收藏
送赞
分享

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

x

0

主题

1

回帖

0

牛值

江湖小虾

昨天 22:10 楼主 显示全部楼层
// ==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();
})();

您需要登录后才可以回帖 登录 | 立即注册

本版积分规则