Greasy Fork

Greasy Fork is available in English.

YouTube B站弹幕播放器

在 YouTube 视频上显示 B站视频弹幕 [ 油管 | Bilibili | 弹幕]

当前为 2025-06-19 提交的版本,查看 最新版本

// ==UserScript==
// @name         YouTube B站弹幕播放器
// @namespace    https://github.com/ZBpine/bilibili-danmaku-download/
// @version      1.6.0
// @description  在 YouTube 视频上显示 B站视频弹幕 [ 油管 | Bilibili | 弹幕]
// @author       ZBpine
// @match        https://www.youtube.com/*
// @match        https://www.bilibili.com/*
// @grant        unsafeWindow
// @grant        GM_xmlhttpRequest
// @connect      api.bilibili.com
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/minimal/protobuf.min.js
// @license      MIT
// @run-at       document-end
// ==/UserScript==

(async () => {
    'use strict';
    if (window.top !== window.self) {
        console.warn('不是顶层窗口,跳过弹幕播放器');
        console.log(window.top, window.self);
        return;
    }
    class DanmakuControlPanel {
        constructor(dmPlayer, biliUtil, dmStore) {
            this.panelId = 'dm-panel';
            this.isBilibili = location.hostname.includes('bilibili.com');
            this.dmPlayer = dmPlayer;
            this.biliUtil = biliUtil;
            this.dmStore = dmStore;
            this.dmPlayerCall = (methodName, ...args) => {
                if (this.dmPlayer && typeof this.dmPlayer[methodName] === 'function') {
                    return this.dmPlayer[methodName](...args);
                }
            }
            this.videoId = null;
            this.data = {};
        }
        bindHotkey() {
            if (this.hotkeyBound) return;
            this.hotkeyBound = true;

            document.addEventListener('keydown', (e) => {
                const target = e.target;
                const isTyping = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable;
                if (isTyping) return;

                const key = e.key.toLowerCase();
                if (key === 'd') {
                    if (this.toggleBtn) {
                        this.toggleBtn.click();
                    }
                } else if (key === 's') {
                    if (this.searchBtn) {
                        this.searchBtn.click();
                    }
                }
            });
        }
        getCurrentVideoId() {
            if (this.isBilibili) {
                const bvidMatch = location.href.match(/BV[a-zA-Z0-9]+/);
                if (bvidMatch) return bvidMatch[0];
                const epidMatch = location.href.match(/ep(\d+)/);
                if (epidMatch) return 'ep' + epidMatch[1];
            } else {
                return new URLSearchParams(location.search).get('v');
            }
            return null;
        }
        observeVideoChange() {
            let href = null;
            const updateVideoId = () => {
                if (location.href === href) return;
                href = location.href;
                const newId = this.getCurrentVideoId();
                if (newId && newId !== this.videoId) {
                    console.log(`[🎬 检测到视频变化] ${this.videoId} → ${newId}`);
                    this.dmPlayerCall('clear');
                    setTimeout(() => this.update(newId), 100);
                }
            }
            updateVideoId();
            const observer = new MutationObserver(updateVideoId);
            observer.observe(document.body, { childList: true, subtree: true });
        }
        showTip(message, { duration = 3000 } = {}) {
            const dark = !this.isBilibili;
            const tip = document.createElement('div');
            tip.textContent = message;
            Object.assign(tip.style, {
                position: 'fixed',
                bottom: '20px',
                right: '20px',
                padding: '10px 14px',
                borderRadius: '6px',
                boxShadow: '0 2px 8px rgba(0, 0, 0, 0.3)',
                fontSize: '14px',
                zIndex: 9999,
                whiteSpace: 'pre-line',
                opacity: '0',
                transition: 'opacity 0.3s ease',
                background: dark ? 'rgba(50, 50, 50, 0.9)' : '#f0f0f0',
                color: dark ? '#fff' : '#000',
                border: dark ? '1px solid #444' : '1px solid #ccc'
            });
            document.body.appendChild(tip);
            requestAnimationFrame(() => {
                tip.style.opacity = '1';
            });
            setTimeout(() => {
                tip.style.opacity = '0';
                tip.addEventListener('transitionend', () => tip.remove());
            }, duration);
            console.log('[💡tip]', message);
        }
        init() {
            if (document.getElementById(this.panelId)) return;

            const buttonStyle = {
                padding: '6px',
                background: '#555',
                color: 'white',
                border: 'none',
                borderRadius: '4px',
                cursor: 'pointer',
                fontSize: '14px',
                width: '100%'
            };
            if (this.isBilibili) {
                buttonStyle.background = '#eee';
                buttonStyle.color = 'black';
                this.dmPlayer.logStyle.style = 'background: #00a2d8; color: white; padding: 2px 6px; border-radius: 3px;';
                this.dmPlayer.logStyle.errorStyle = 'background: #ff4d4f; color: white; padding: 2px 6px; border-radius: 3px;';
            }
            this.dmPlayerCall('setOptions', this.dmStore.get('settings', {}));

            this.searchBtn = document.createElement('button');
            this.searchBtn.textContent = '🔍';
            this.searchBtn.title = '搜索弹幕';
            this.searchBtn.onclick = () => this.onSearch();
            Object.assign(this.searchBtn.style, buttonStyle);

            this.configBtn = document.createElement('button');
            this.configBtn.textContent = '⚙️';
            this.configBtn.title = '打开设置';
            Object.assign(this.configBtn.style, buttonStyle);
            this.configBtn.onclick = () => this.showConfigPanel();

            this.toggleBtn = document.createElement('button');
            this.toggleBtn.textContent = '✅';
            this.toggleBtn.title = '开关弹幕';
            Object.assign(this.toggleBtn.style, buttonStyle);
            this.toggleBtn.onclick = () => {
                if (!this.dmPlayer) return;
                this.dmPlayer.toggle();
                this.toggleBtn.textContent = this.dmPlayer.showing ? '✅' : '🚫';
            };

            this.loadBtn = document.createElement('button');
            this.loadBtn.id = 'dm-btn-load';
            Object.assign(this.loadBtn.style, buttonStyle);
            this.loadCtl = {
                setLoad: () => {
                    this.loadBtn.textContent = '📂';
                    this.loadBtn.title = '载入弹幕文件';
                    this.fileInput.value = '';
                    this.loadBtn.onclick = () => this.fileInput.click();
                },
                setSave: () => {
                    this.loadBtn.textContent = '💾';
                    this.loadBtn.title = '保存到浏览器本地存储';
                    this.loadBtn.onclick = () => {
                        this.dmStore.setCache(this.videoId, this.data[this.videoId]);
                        this.showTip('✅ 已保存到本地');
                        this.loadCtl.setClear();
                    };
                },
                setClear: () => {
                    this.loadBtn.textContent = '🗑️';
                    this.loadBtn.title = '释放浏览器本地存储';
                    this.loadBtn.onclick = () => {
                        this.dmStore.removeCache(this.videoId);
                        this.showTip('🗑️ 已移除本地存储');
                        this.loadCtl.setLoad();
                    };
                }
            }
            const fileInput = document.createElement('input');
            fileInput.type = 'file';
            fileInput.accept = '.json,.xml';
            fileInput.style.display = 'none';
            fileInput.id = 'dm-input-file';
            fileInput.onchange = (e) => {
                const file = e.target.files[0];
                if (!file) return;
                const reader = new FileReader();
                reader.onload = (e) => {
                    try {
                        const text = e.target.result.trim();
                        let data;
                        if (text.startsWith('<')) {
                            // XML 文件
                            const danmakuData = this.biliUtil.parseDanmakuXml(text);
                            data = {
                                danmakuData,
                                videoData: { title: file.name }
                            };
                        } else {
                            const json = JSON.parse(e.target.result);
                            if (Array.isArray(json)) {
                                // 纯弹幕数组
                                data = {
                                    danmakuData: json,
                                    videoData: { title: file.name }
                                };
                            } else if (json.danmakuData) {
                                // 完整结构
                                data = json;
                            } else {
                                throw new Error('不支持的 JSON 格式');
                            }
                        }
                        this.loadDanmakuSuccess(data);
                        this.loadCtl.setSave();
                    } catch (err) {
                        this.dmPlayer.logTagError('❌ 加载失败', err);
                        this.showTip('❌ 加载失败:' + err.message);
                    }
                };
                reader.readAsText(file);
            };
            this.fileInput = fileInput;
            document.body.appendChild(fileInput);

            const panel = document.createElement('div');
            panel.id = this.panelId;
            Object.assign(panel.style, {
                position: 'fixed',
                width: '80px',
                left: '-90px',
                bottom: '40px',
                zIndex: 10000,
                transition: 'left 0.3s ease-in-out, opacity 0.3s ease',
                opacity: '0.2',
                background: '#333',
                borderRadius: '0px 20px 20px 0px',
                padding: '10px',
                paddingRight: '20px',
                display: 'grid',
                gridTemplateColumns: '36px 36px',
                gridAutoRows: '32px',
                gap: '6px'
            });
            if (this.isBilibili) {
                panel.style.background = '#ccc';
            }
            panel.addEventListener('mouseenter', () => {
                panel.style.left = '0px';
                panel.style.opacity = '1';
            });
            panel.addEventListener('mouseleave', () => {
                panel.style.left = '-90px';
                panel.style.opacity = '0.2';
            });

            panel.append(this.searchBtn, this.loadBtn, this.configBtn, this.toggleBtn);
            document.body.appendChild(panel);
            this.bindHotkey();
            this.dmPlayer.init();
        }
        update(videoId) {
            if (!videoId) return;
            this.videoId = videoId;
            this.dmPlayer.update();
            this.dmPlayer.logTag(`当前视频:${videoId}`);

            if (this.data[videoId]) {
                this.dmPlayer.load(this.data[videoId].danmakuData);
                return;
            }
            // 检查是否已有存储的弹幕
            const saved = this.dmStore.getCache(videoId);
            if (saved) {
                try {
                    this.loadDanmakuSuccess(saved);
                    this.showTip(`📦 自动载入本地弹幕`);
                    this.loadCtl.setClear();
                } catch (err) {
                    this.showTip('❌ 本地弹幕解析失败:' + err.message);
                    this.dmPlayer.logTagError('❌ 本地弹幕解析失败:', err);
                    this.dmStore.removeCache(videoId);
                    this.loadCtl.setLoad();
                }
            } else {
                this.loadCtl.setLoad();
            }
        }
        loadDanmakuSuccess(data) {
            this.applyAlignment(data);
            this.data[this.videoId] = data;
            this.dmPlayer.load(data.danmakuData);

            const count = data.danmakuData.length;
            const title = data.videoData?.title || '(未知标题)';
            const time = data.fetchtime ?
                new Date(data.fetchtime * 1000).toLocaleString('zh-CN', { hour12: false }) : '(未知)';
            this.showTip(`🎉 成功载入:\n🎬 ${title}\n💬 共 ${count} 条弹幕\n🕒 抓取时间:${time}`);
        }
        applyAlignment(data) {
            if (!data?.alignment || !Array.isArray(data.alignment) || !data.danmakuData) return;
            this.dmPlayer.domAdapter.injectStyle('dmplayer-danmaku-mark', `
                @keyframes dmplayer-animate-mark {
                    0%   { opacity: 0; }
                    5%  { opacity: 0.6; }
                    95%  { opacity: 0.4; }
                    100% { opacity: 0; }
                }
                .dmplayer-danmaku-mark {
                    left: 10px;
                    top: 10px;
                    animation-name: dmplayer-animate-mark;
                    animation-timing-function: cubic-bezier(0,1,1,0) !important;
                }`
            );
            const alignments = data.alignment.slice().sort((a, b) => (a.source?.start || 0) - (b.source?.start || 0));
            const newDanmakus = [];
            if (!data.danmakuDataOrigin) data.danmakuDataOrigin = data.danmakuData;
            const danmakus = data.danmakuDataOrigin;

            let lastSEnd = 0;
            let lastTEnd = 0;
            for (let i = 0; i <= alignments.length; i++) {
                const align = alignments[i];
                if (!align) continue;
                const { source, target, mode, comment } = align;

                const sStart = source.start;
                const sEnd = source.end;
                const tStart = target.start;
                const tEnd = target.end;
                const sDuration = sEnd - sStart;
                const tDuration = tEnd - tStart;

                for (const d of danmakus) {
                    const time = d.progress;
                    if (time >= lastSEnd && time < sStart) {
                        const newTime = time - lastSEnd + lastTEnd;
                        newDanmakus.push({ ...d, progress: Math.round(newTime) });
                    } else if (time >= sStart && time < sEnd) {
                        let newTime = null;
                        if (mode === 'map') {
                            const ratio = (time - sStart) / sDuration;
                            newTime = tStart + ratio * tDuration;
                        } else {
                            newTime = time - sStart + tStart;
                            if (newTime < tStart || newTime >= tEnd) continue;
                        }
                        newDanmakus.push({ ...d, progress: Math.round(newTime) });
                    }
                }
                if (comment) {
                    const commentId = Date.now() * 1000 + i
                    newDanmakus.push({
                        content: `${comment}`,
                        progress: Math.round(tStart),
                        type: 'mark',
                        duration: tEnd - tStart,
                        fontsize: 32,
                        color: 0xffffff,
                        ctime: Math.floor(Date.now() / 1000),
                        pool: 0,
                        midHash: 'system',
                        id: commentId,
                        idStr: String(commentId),
                        weight: 10
                    });
                }
                lastSEnd = sEnd;
                lastTEnd = tEnd;
            }
            for (const d of danmakus) {
                const time = d.progress;
                if (time >= lastSEnd) {
                    const newTime = time - lastSEnd + lastTEnd;
                    newDanmakus.push({ ...d, progress: Math.round(newTime) });
                }
            }
            data.danmakuData = newDanmakus;
        }
        async onSearch() {
            try {
                const selected = await this.showSearchSelector();
                if (!selected) return;

                const searchServer = async (bvid) => {
                    const server = this.dmStore.get('server');
                    if (server) {
                        try {
                            const res = await fetch(`${server}/video?bvid=${bvid}&source=${selected.source}`);
                            const data = await res.json();
                            return data;
                        } catch (e) {
                            this.showTip('⚠ 请检查服务器是否正确');
                            console.warn('❌ 远程弹幕获取失败:', e);
                        }
                    }
                    return null;
                }
                let data = null;
                const bvid = selected.bvid;
                const serverData = await searchServer(bvid);
                if (serverData) {
                    data = serverData;
                    this.loadDanmakuSuccess(data);
                    this.loadCtl.setSave();
                    return;
                }
                const videoData = await this.biliUtil.getVideoData(bvid);
                const cid = videoData.cid;
                let danmakuData = await this.biliUtil.getDanmakuXml(cid);
                data = {
                    bvid, cid, videoData, danmakuData,
                    fetchtime: Math.floor(Date.now() / 1000)
                };
                this.loadDanmakuSuccess(data);
                this.loadCtl.setSave();
                danmakuData = await this.biliUtil.getDanmakuFull(data);
                if (danmakuData.length === data.danmakuData.length) return;
                data = {
                    bvid, cid, videoData, danmakuData,
                    fetchtime: Math.floor(Date.now() / 1000)
                };
                this.loadDanmakuSuccess(data);
                this.loadCtl.setSave();
            } catch (err) {
                this.showTip(`❌ 请求失败:${err.message}`);
                this.dmPlayer.logTagError('❌ 请求失败:', err);
            }
        }
        async showSearchSelector() {
            let initialKeyword = document.title.replace(' - YouTube', '');
            if (this.isBilibili) {
                initialKeyword = document.title.replace(/[-_–—|]+.*?(bilibili|哔哩哔哩).*/gi, '').trim();
            }
            let resolveFn;
            const returnPromise = new Promise((resolve) => {
                resolveFn = resolve;
            });
            const overlay = this.createStyledEl('overlay');

            const panel = this.createStyledEl('panel');

            const titleEl = document.createElement('div');
            titleEl.textContent = '选择一个视频以载入弹幕:';
            titleEl.style.fontWeight = 'bold';
            titleEl.style.fontSize = '16px';

            const input = this.createStyledEl('input');
            input.type = 'text';
            input.value = initialKeyword;

            const resultsBox = document.createElement('div');
            resultsBox.style.display = 'flex';
            resultsBox.style.flexDirection = 'column';
            resultsBox.style.gap = '6px';

            const cleanup = (result = null) => {
                overlay.remove();
                resolveFn(result);
            };

            document.addEventListener('keydown', function escClose(e) {
                if (e.key === 'Escape') {
                    cleanup();
                    document.removeEventListener('keydown', escClose);
                }
            });

            overlay.onclick = (e) => {
                if (e.target === overlay) cleanup();
            };

            const formatCount = (n) => {
                n = parseInt(n || '0');
                if (isNaN(n)) return '0';
                if (n >= 1e8) return (n / 1e8).toFixed(1) + '亿';
                if (n >= 1e4) return (n / 1e4).toFixed(1) + '万';
                return n.toString();
            };
            const normalizeTimeStr = (duration) => {
                if (typeof duration === 'number' && !isNaN(duration)) {
                    // duration 是秒数,直接格式化为 h:mm:ss
                    const totalSeconds = duration;
                    const hours = Math.floor(totalSeconds / 3600);
                    const minutes = Math.floor((totalSeconds % 3600) / 60);
                    const seconds = totalSeconds % 60;
                    if (hours > 0) {
                        return `${hours}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
                    }
                    else {
                        return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
                    }
                }
                if (typeof duration === 'string' && /^\d+:\d{1,2}$/.test(duration)) {
                    const [min, sec] = duration.split(':').map(Number);
                    if (isNaN(min) || isNaN(sec)) return duration; // 原样返回不合法值
                    if (min > 99) {
                        const hours = Math.floor(min / 60);
                        const minutes = min % 60;
                        return `${hours}:${String(minutes).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
                    } else {
                        return `${String(min).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
                    }
                }
                return duration; // 不合法或未知格式,原样返回
            };
            const searchVideosByKeyword = async (keyword) => {
                const server = this.dmStore.get('server');
                if (server) {
                    try {
                        const res = await fetch(`${server}/search?keyword=${encodeURIComponent(keyword)}&type=video`);
                        const list = await res.json();
                        if (Array.isArray(list)) return list;
                    } catch (e) {
                        this.showTip('⚠ 请检查服务器是否正确');
                        console.warn('❌ 远程搜索失败:', e);
                    }
                }
                try {
                    const list = await this.biliUtil.searchVideo(keyword);
                    list.forEach(item => item.source = 'bilibili');
                    return list;
                } catch (e) {
                    console.error('❌ GM搜索失败:', e);
                    throw new Error('搜索失败');
                }
            };
            const renderResults = async (keyword) => {
                resultsBox.textContent = '🔍 搜索中...';
                try {
                    const list = await searchVideosByKeyword(keyword);
                    if (!Array.isArray(list) || list.length === 0) {
                        resultsBox.textContent = '❌ 没有找到相关视频';
                        return;
                    }
                    resultsBox.textContent = '';

                    // ➤ 按来源分组
                    const localResults = list.filter(item => item.source === 'local');
                    const onlineResults = list.filter(item => item.source !== 'local');

                    // ➤ 渲染分组函数
                    const renderGroup = (titleText, groupList) => {
                        if (groupList.length === 0) return;

                        const titleRow = document.createElement('div');
                        titleRow.textContent = titleText;
                        Object.assign(titleRow.style, {
                            fontWeight: 'bold',
                            marginTop: '10px',
                            marginBottom: '4px',
                            borderBottom: '1px solid #ccc',
                            paddingBottom: '4px'
                        });
                        resultsBox.appendChild(titleRow);

                        groupList.forEach(item => {
                            const row = document.createElement('div');
                            Object.assign(row.style, {
                                padding: '8px 10px',
                                borderRadius: '6px',
                                cursor: 'pointer',
                                background: '#f8f8f8',
                                display: 'flex',
                                flexDirection: 'column',
                                gap: '4px'
                            });
                            row.addEventListener('mouseenter', () => row.style.background = '#e0e0e0');
                            row.addEventListener('mouseleave', () => row.style.background = '#f8f8f8');

                            const titleLine = document.createElement('div');
                            titleLine.textContent = `📺 ${item.title.replace(/<[^>]+>/g, '')}`
                            titleLine.style.fontWeight = '500';

                            const infoLine = document.createElement('div');
                            Object.assign(infoLine.style, {
                                display: 'flex',
                                gap: '12px',
                                fontSize: '12px',
                                color: '#666',
                                flexWrap: 'wrap'
                            });
                            const author = document.createElement('span');
                            author.textContent = `👤 ${item.author || 'UP未知'}`;
                            const play = document.createElement('span');
                            play.textContent = `👁 ${formatCount(item.play)}`;
                            const danmu = document.createElement('span');
                            danmu.textContent = `💬 ${formatCount(item.video_review)}`;
                            const duration = document.createElement('span');
                            if (item.duration) {
                                duration.textContent = `🕒 ${normalizeTimeStr(item.duration)}`;
                            }
                            const link = document.createElement('a');
                            link.href = `https://www.bilibili.com/video/${item.bvid}`;
                            link.textContent = '🔗 打开';
                            link.target = '_blank';
                            Object.assign(link.style, {
                                fontSize: '12px',
                                color: '#1a73e8',
                                textDecoration: 'none'
                            });
                            link.addEventListener('click', e => e.stopPropagation());

                            infoLine.append(author, play, danmu, duration, link);

                            row.onclick = () => cleanup(item);
                            row.appendChild(titleLine);
                            row.appendChild(infoLine);
                            resultsBox.appendChild(row);
                        });
                    };
                    // ➤ 渲染两组
                    renderGroup('📦 本地弹幕:', localResults);
                    renderGroup('🌐 B站视频:', onlineResults);

                } catch (e) {
                    resultsBox.textContent = `❌ 搜索失败:${e.message}`;
                }
            };
            input.addEventListener('keydown', (e) => {
                if (e.key === 'Enter') {
                    const kw = input.value.trim();
                    if (kw) renderResults(kw);
                }
            });
            panel.append(titleEl, input, resultsBox);
            overlay.appendChild(panel);
            document.body.appendChild(overlay);

            await renderResults(initialKeyword);

            return returnPromise;
        }
        showConfigPanel() {
            const existing = document.getElementById('dm-config-panel');
            if (existing) existing.remove();

            const overlay = this.createStyledEl('overlay');
            overlay.id = 'dm-config-panel';
            overlay.onclick = (e) => {
                if (e.target === overlay) overlay.remove();
            };

            const panel = this.createStyledEl('panel');

            const title = document.createElement('div');
            title.textContent = '⚙️ 设置';
            title.style.fontSize = '18px';
            title.style.fontWeight = 'bold';
            panel.appendChild(title);
            // 标签按钮容器
            const tabButtons = document.createElement('div');
            Object.assign(tabButtons.style, {
                display: 'flex',
                gap: '6px',
                borderBottom: '1px solid #ccc',
                margin: '10px 0'
            });
            panel.appendChild(tabButtons);
            // 页面内容容器
            const tabContent = document.createElement('div');
            tabContent.style.marginBottom = '20px';
            panel.appendChild(tabContent);
            // 标签切换函数
            const tabPages = {};
            const switchTab = (tabName) => {
                for (const [name, page] of Object.entries(tabPages)) {
                    page.style.display = (name === tabName) ? 'flex' : 'none';
                }
                for (const btn of tabButtons.children) {
                    btn.style.fontWeight = (btn.dataset.tab === tabName) ? 'bold' : 'normal';
                    btn.style.borderBottom = (btn.dataset.tab === tabName) ? '2px solid #0077cc' : 'none';
                }
            };
            const createTab = (name, labelText, createContent) => {
                const btn = document.createElement('button');
                btn.textContent = labelText;
                btn.dataset.tab = name;
                Object.assign(btn.style, {
                    background: 'none',
                    border: 'none',
                    padding: '8px 12px',
                    cursor: 'pointer',
                    fontSize: '14px'
                });
                btn.onclick = () => switchTab(name);
                tabButtons.appendChild(btn);

                const page = document.createElement('div');
                page.style.display = 'none';
                page.style.flexDirection = 'column';
                page.style.gap = '6px';
                tabPages[name] = page;
                tabContent.appendChild(page);
                createContent(page);
                return page;
            };

            const createLabeledButtonRow = (labelText, buttonObj) => {
                const row = document.createElement('div');
                row.style.display = 'flex';
                row.style.justifyContent = 'space-between';
                row.style.alignItems = 'center';

                const label = document.createElement('div');
                label.textContent = labelText;
                label.style.fontWeight = 'bold';
                label.style.fontSize = '16px'
                label.style.margin = '10px 0'
                row.appendChild(label);

                if (!buttonObj) return row
                const button = document.createElement('button');
                Object.assign(button, buttonObj);
                Object.assign(button.style, {
                    width: '130px',
                    height: '28px',
                    fontSize: '14px',
                    border: '1px solid #ccc',
                    borderRadius: '4px',
                    background: '#f0f0f0',
                    cursor: 'pointer',
                    flexShrink: '0'
                });
                row.appendChild(button);
                return row;
            };
            const createContralRow = (labelText, key, options, desc) => {
                const keyPath = `settings.${key}`;
                const wrapper = document.createElement('div');
                Object.assign(wrapper.style, {
                    display: 'flex',
                    height: '36px',
                    alignItems: 'center',
                    flexDirection: 'row',
                    gap: '18px'
                });
                const controlRow = document.createElement('div');
                Object.assign(controlRow.style, {
                    display: 'flex',
                    alignItems: 'center',
                    gap: '6px'
                });
                const label = document.createElement('div');
                label.textContent = labelText;
                Object.assign(label.style, {
                    fontWeight: 'bold',
                    flexShrink: '0'
                });
                wrapper.append(label);
                const input = document.createElement('input');
                Object.assign(input, options);
                if (options.type === 'checkbox') {
                    input.checked = this.dmStore.get(keyPath, this.dmPlayer.options[key].value);
                    Object.assign(input.style, {
                        width: '20px',
                        height: '20px',
                        cursor: 'pointer'
                    });
                    input.onchange = () => {
                        const val = input.checked;
                        this.dmStore.set(keyPath, val);
                        this.dmPlayerCall('setOptions', val, key);
                        this.showTip(`✅ 已保存 ${labelText}:${val ? '开启' : '关闭'}`);
                    };
                    controlRow.append(input);
                } else if (options.type === 'number') {
                    input.value = this.dmStore.get(keyPath, this.dmPlayer.options[key].value);
                    Object.assign(input.style, {
                        width: '60px',
                        height: '20px',
                        padding: '0',
                        textAlign: 'center',
                        fontSize: '14px'
                    });
                    const saveBtn = document.createElement('button');
                    saveBtn.textContent = '💾 保存';
                    Object.assign(saveBtn.style, {
                        width: '80px',
                        height: '28px',
                        fontSize: '14px',
                        cursor: 'pointer'
                    });
                    saveBtn.onclick = () => {
                        const val = Number.isInteger(Number(options.step)) ? parseInt(input.value) : parseFloat(input.value);
                        if (!isNaN(val) && val >= options.min && val <= options.max) {
                            this.dmStore.set(keyPath, val);
                            this.dmPlayerCall('setOptions', val, key);
                            this.showTip(`✅ 已保存 ${labelText}:${val}`);
                        } else {
                            this.showTip('❌ 输入不合法');
                        }
                    };
                    controlRow.append(input, saveBtn);
                }
                wrapper.append(controlRow);
                if (desc) {
                    const descEl = document.createElement('div');
                    descEl.textContent = desc;
                    Object.assign(descEl.style, {
                        fontSize: '12px',
                        color: '#666',
                        marginLeft: 'auto'
                    });
                    wrapper.append(descEl);
                }
                return wrapper;
            };
            const createSelect = (list, getName = n => n) => {
                const select = document.createElement('select');
                list.forEach(n => {
                    const option = document.createElement('option');
                    option.value = String(n);
                    option.textContent = String(getName(n));
                    select.appendChild(option);
                })
                return select;
            };

            createTab('display', '📺 弹幕显示', (page) => {
                page.appendChild(createLabeledButtonRow('📺 弹幕显示设置', {
                    textContent: '👁️ 预览',
                    onmousedown: () => overlay.style.opacity = '0',
                    onmouseup: () => overlay.style.opacity = '1',
                    onmouseleave: () => overlay.style.opacity = '1'
                }));
                page.appendChild(createContralRow(
                    '🌫️ 不透明度',
                    'opacity',
                    { type: 'number', min: 0.1, max: 1.0, step: 0.1 },
                    '设置弹幕透明度(0.1 ~ 1.0)越小越透明'
                ));
                page.appendChild(createContralRow(
                    '📐 显示区域',
                    'displayArea',
                    { type: 'number', min: 0.1, max: 1.0, step: 0.1 },
                    '允许弹幕占屏幕高度范围,1.0 全屏'
                ));
                page.appendChild(createContralRow(
                    '🚀 弹幕速度',
                    'speed',
                    { type: 'number', min: 3, max: 9, step: 1 },
                    '影响弹幕持续时间以及滚动弹幕的速度'
                ));
                page.appendChild(createContralRow(
                    '🔁 合并重复',
                    'mergeRepeats',
                    { type: 'checkbox' },
                    '是否合并内容相同且时间接近的弹幕'
                ));
                page.appendChild(createContralRow(
                    '🔀 允许重叠',
                    'overlap',
                    { type: 'checkbox' },
                    '开启则允许弹幕重叠,否则丢弃会重叠的弹幕'
                ));

                // --- 弹幕阴影设置模块 ---
                let shadowConfig = this.dmPlayer.options?.shadowEffect?.value ||
                    [{ type: 0, offset: 1, radius: 1, repeat: 1 }];
                const shadowHeader = createLabeledButtonRow('🌑 弹幕阴影设置', {
                    textContent: '💾 保存', onclick: () => {
                        this.dmStore.set('settings.shadowEffect', shadowConfig);
                        this.dmPlayerCall('setOptions', shadowConfig, 'shadowEffect');
                    }
                });
                page.appendChild(shadowHeader);
                // 预设选择
                const presetSelect = createSelect(['重墨', '描边', '45°投影', '自定义']);
                Object.assign(presetSelect.style, {
                    fontSize: '14px',
                    padding: '4px 8px'
                });
                page.appendChild(presetSelect);
                // 默认配置项
                const presets = {
                    '重墨': [{ type: 0, offset: 1, radius: 1, repeat: 1 }],
                    '描边': [{ type: 1, offset: 0, radius: 1, repeat: 3 }],
                    '45°投影': [
                        { type: 1, offset: 0, radius: 1, repeat: 1 },
                        { type: 2, offset: 1, radius: 2, repeat: 1 }
                    ]
                };
                const formArea = document.createElement('div');
                page.appendChild(formArea);

                const addBtn = document.createElement('button');
                addBtn.textContent = '➕ 添加阴影项';
                Object.assign(addBtn.style, {
                    width: '120px',
                    padding: '4px',
                    cursor: 'pointer'
                });
                page.appendChild(addBtn);

                const label = (text) => {
                    const span = document.createElement('span');
                    span.textContent = text;
                    span.style.fontWeight = 'bold';
                    return span;
                };
                const renderConfigItems = (configList) => {
                    formArea.replaceChildren();
                    configList.forEach((cfg, index) => {
                        const row = document.createElement('div');
                        Object.assign(row.style, {
                            display: 'flex',
                            gap: '6px',
                            alignItems: 'center',
                            marginBottom: '4px',
                            border: '1px solid #ccc'
                        });
                        const range = (start, end) => Array.from({ length: end - start + 1 }, (_, i) => start + i);

                        const typeSel = createSelect(range(0, 2), n => ['重墨', '描边', '45°投影'][n]);
                        typeSel.value = String(cfg.type);
                        typeSel.onchange = () => configList[index].type = parseInt(typeSel.value);

                        const offsetSel = createSelect(range(-1, 10), n => n === -1 ? '递增' : `${n}px`);
                        offsetSel.value = String(cfg.offset);
                        offsetSel.onchange = () => configList[index].offset = parseInt(offsetSel.value);

                        const radiusSel = createSelect(range(-1, 10), n => n === -1 ? '递增' : `${n}px`);
                        radiusSel.value = String(cfg.radius);
                        radiusSel.onchange = () => configList[index].radius = parseInt(radiusSel.value);

                        const repeatSel = createSelect(range(1, 10));
                        repeatSel.value = String(cfg.repeat || 1);
                        repeatSel.onchange = () => configList[index].repeat = parseInt(repeatSel.value);

                        const del = document.createElement('button');
                        del.textContent = '删除';
                        del.onclick = () => {
                            configList.splice(index, 1);
                            renderConfigItems(configList);
                        };
                        row.append(
                            label('类型:'), typeSel,
                            label('偏移:'), offsetSel,
                            label('半径:'), radiusSel,
                            label('重复:'), repeatSel,
                            del
                        );
                        formArea.appendChild(row);
                    });
                };
                addBtn.onclick = () => {
                    shadowConfig.push({ type: 0, offset: 1, radius: 1, repeat: 1 });
                    renderConfigItems(shadowConfig);
                };
                presetSelect.onchange = () => {
                    const val = presetSelect.value;
                    if (val === '自定义') {
                        renderConfigItems(shadowConfig);
                        addBtn.style.display = '';
                    } else {
                        shadowConfig = JSON.parse(JSON.stringify(presets[val])); // 深拷贝
                        renderConfigItems([]);
                        addBtn.style.display = 'none';
                    }
                };
                // 自动判断并选中 preset
                let matchedPreset = '自定义'; // 默认自定义
                if (Array.isArray(shadowConfig)) {
                    for (const key of Object.keys(presets)) {
                        const preset = presets[key];
                        const same = preset.length === shadowConfig.length &&
                            preset.every((item, i) =>
                                item.type === shadowConfig[i].type &&
                                item.offset === shadowConfig[i].offset &&
                                item.radius === shadowConfig[i].radius &&
                                item.repeat === shadowConfig[i].repeat
                            );
                        if (same) {
                            matchedPreset = key;
                            break;
                        }
                    }
                }
                presetSelect.value = matchedPreset;
                presetSelect.onchange();
            });

            createTab('cache', '📦 缓存管理', (page) => {
                const cacheHeader = createLabeledButtonRow('📦 本地缓存弹幕', {
                    textContent: '🧹 清空所有缓存', onclick: () => {
                        if (confirm('确定要清空所有本地缓存弹幕吗?')) {
                            this.dmStore.clearAllCache();
                            cacheList.textContent = '📭 所有缓存已清除';
                            this.showTip('🧹 所有弹幕缓存已清空');
                        }
                    }
                });
                const cacheList = document.createElement('div');
                cacheList.style.display = 'flex';
                cacheList.style.flexDirection = 'column';
                cacheList.style.gap = '8px';
                const cache = this.dmStore.get('cache', {});
                const keys = Object.keys(cache);
                if (keys.length === 0) {
                    cacheList.textContent = '📭 当前没有缓存弹幕';
                } else {
                    keys.forEach(videoId => {
                        try {
                            const json = cache[videoId];
                            const title = json.videoData?.title;
                            const row = document.createElement('div');
                            row.style.display = 'flex';
                            row.style.justifyContent = 'space-between';
                            row.style.alignItems = 'center';

                            const label = document.createElement('div');

                            const idLine = document.createElement('a');
                            idLine.textContent = `[▶️ ${videoId}]`;
                            if (this.isBilibili) {
                                idLine.href = videoId.startsWith('ep')
                                    ? `https://www.bilibili.com/bangumi/play/${videoId}`
                                    : `https://www.bilibili.com/video/${videoId}`;
                            } else {
                                idLine.href = `https://www.youtube.com/watch?v=${videoId}`;
                            }
                            idLine.target = '_blank';
                            Object.assign(idLine.style, {
                                fontSize: '13px',
                                color: '#1a73e8',
                                textDecoration: 'none',
                                marginBottom: '2px',
                                whiteSpace: 'nowrap'
                            });
                            const titleLine = document.createElement('div');
                            titleLine.textContent = `${title}`;
                            titleLine.style.fontWeight = '500';

                            label.appendChild(idLine);
                            label.appendChild(titleLine);

                            const delBtn = document.createElement('button');
                            delBtn.textContent = '🗑 删除';
                            delBtn.style.cursor = 'pointer';

                            delBtn.onclick = () => {
                                this.dmStore.removeCache(videoId);
                                row.remove();
                                this.showTip(`🗑 已删除缓存:${title}`);
                            };

                            row.appendChild(label);
                            row.appendChild(delBtn);
                            cacheList.appendChild(row);
                        } catch (err) { this.dmPlayer.logTagError(err); }
                    });
                }
                page.appendChild(cacheHeader);
                page.appendChild(cacheList);
            });

            createTab('server', '🌐 服务器设置', (page) => {
                const serverHeader = createLabeledButtonRow('🌐 服务器地址:', {
                    textContent: '💾 保存', onclick: () => {
                        this.dmStore.set('server', serverInput.value.trim());
                        this.showTip('✅ 地址已保存');
                    }
                });

                const serverInput = this.createStyledEl('input');
                serverInput.value = this.dmStore.get('server', '');
                page.appendChild(serverHeader);
                page.appendChild(serverInput);
            });

            if (this.data[this.videoId]?.danmakuData) {
                createTab('alignment', '🎯 视频对齐', (page) => {
                    let alignData = this.data[this.videoId].alignment || [];
                    const parseTimeToMs = (text) => {
                        if (!text.includes(':')) return 0;
                        const [min, sec] = text.trim().split(':');
                        return Math.round((parseInt(min) * 60 + parseFloat(sec)) * 1000);
                    };
                    const formatMsToTime = (ms) => {
                        const min = Math.floor(ms / 60000);
                        const sec = (ms % 60000) / 1000;
                        return `${min}:${sec}`;
                    };
                    const container = document.createElement('div');
                    container.style.display = 'flex';
                    container.style.flexDirection = 'column';
                    container.style.gap = '8px';
                    const render = () => {
                        container.replaceChildren();
                        alignData.forEach((entry, index) => {
                            const row = document.createElement('div');
                            row.style.display = 'flex';
                            row.style.flexDirection = 'column';
                            row.style.border = '1px solid #ccc';
                            row.style.padding = '10px';
                            row.style.gap = '6px';

                            const createRow = (widgets) => {
                                const wrapper = document.createElement('div');
                                wrapper.style.display = 'flex';
                                wrapper.style.alignItems = 'center';
                                wrapper.style.gap = '6px';
                                widgets.forEach(widget => wrapper.appendChild(widget));
                                return wrapper;
                            };
                            const createInput = (placeholder, value, width, onInput) => {
                                const input = document.createElement('input');
                                input.placeholder = placeholder;
                                input.value = value;
                                input.style.width = width + 'px';
                                input.onchange = () => onInput(input.value);
                                return input;
                            };

                            // 源视频输入
                            const sourceStart = createInput('开始时间', formatMsToTime(entry.source?.start || 0), 80, val => {
                                entry.source = entry.source || {};
                                entry.source.start = parseTimeToMs(val);
                            });
                            const sourceEnd = createInput('结束时间', formatMsToTime(entry.source?.end || 0), 80, val => {
                                entry.source = entry.source || {};
                                entry.source.end = parseTimeToMs(val);
                            });

                            // 目标视频输入
                            const targetStart = createInput('开始时间', formatMsToTime(entry.target?.start || 0), 80, val => {
                                entry.target = entry.target || {};
                                entry.target.start = parseTimeToMs(val);
                            });
                            const targetEnd = createInput('结束时间', formatMsToTime(entry.target?.end || 0), 80, val => {
                                entry.target = entry.target || {};
                                entry.target.end = parseTimeToMs(val);
                            });

                            const modeSelect = createSelect(['map', 'shift'], opt => opt === 'map' ? '映射' : '顺移');
                            modeSelect.value = entry.mode || 'shift';
                            modeSelect.onchange = () => {
                                entry.mode = modeSelect.value;
                            };

                            const commentInput = createInput('附言', entry.comment || '', 200, val => {
                                entry.comment = val;
                            });

                            const delBtn = document.createElement('button');
                            delBtn.textContent = '🗑 删除';
                            delBtn.style.cursor = 'pointer';
                            delBtn.onclick = () => {
                                alignData.splice(index, 1);
                                render();
                            };

                            const cLabel = (text) => {
                                const label = document.createElement('div');
                                label.textContent = text;
                                return label;
                            }
                            row.appendChild(createRow([cLabel('原视频:'), sourceStart, cLabel('→'), sourceEnd]));
                            row.appendChild(createRow([cLabel('现视频:'), targetStart, cLabel('→'), targetEnd]));
                            row.appendChild(createRow([modeSelect, commentInput, delBtn]));

                            container.appendChild(row);
                        });
                    };

                    const createButton = (text, onclick) => {
                        const Btn = document.createElement('button');
                        Btn.textContent = text;
                        Object.assign(Btn.style, {
                            width: '120px',
                            padding: '4px',
                            cursor: 'pointer'
                        });
                        Btn.onclick = onclick;
                        return Btn;
                    };
                    const buttonRow = document.createElement('div');
                    buttonRow.style.display = 'flex';
                    buttonRow.style.justifyContent = 'space-between';
                    buttonRow.style.alignItems = 'center';
                    buttonRow.style.marginTop = '10px';
                    buttonRow.appendChild(createButton('➕ 添加对齐片段', () => {
                        alignData.push({
                            source: { start: 0, end: 0 },
                            target: { start: 0, end: 0 },
                            mode: 'shift',
                            comment: ''
                        });
                        render();
                    }));
                    buttonRow.appendChild(createButton('📋 粘贴设置', async () => {
                        try {
                            const text = await navigator.clipboard.readText();
                            const parsed = JSON.parse(text);

                            if (!Array.isArray(parsed)) throw new Error('剪贴板内容不是有效的数组');
                            const isValid = parsed.every(item =>
                                item.source && item.target && item.mode
                            );
                            if (!isValid) throw new Error('剪贴板内容不是有效的对齐数据');

                            alignData = parsed;
                            render();
                            this.showTip('📋 成功粘贴对齐设置');
                        } catch (err) {
                            this.dmPlayer.logTagError('❌ 粘贴失败:', err);
                            this.showTip('❌ 粘贴失败:' + err.message);
                        }
                    }));
                    buttonRow.appendChild(createButton('📋 复制设置', () => {
                        const json = JSON.stringify(alignData, null);
                        navigator.clipboard.writeText(json).then(() => {
                            this.showTip('✅ 已复制所有对齐设置');
                        }).catch(() => {
                            this.showTip('❌ 复制失败');
                        });
                    }));
                    buttonRow.appendChild(createButton('💾 保存', () => {
                        const cached = this.dmStore.getCache(this.videoId);
                        if (cached) {
                            cached.alignment = alignData;
                            this.dmStore.setCache(this.videoId, cached);
                        }
                        const data = this.data[this.videoId]
                        data.alignment = alignData;
                        this.applyAlignment(data);
                        this.dmPlayer.load(data.danmakuData);
                        this.showTip('✅ 对齐设置已保存');
                    }));

                    render();

                    const desc = document.createElement('div');
                    Object.assign(desc.style, {
                        fontSize: '13px',
                        lineHeight: '1.6',
                        background: '#f9f9f9',
                        padding: '10px',
                        border: '1px solid #ccc',
                        borderRadius: '6px',
                        marginBottom: '10px'
                    });
                    const addLine = (text, isBold = false, isCode = false) => {
                        const line = document.createElement('div');
                        if (isCode) {
                            const code = document.createElement('code');
                            code.textContent = text;
                            line.appendChild(code);
                        } else {
                            line.textContent = isBold ? `• ${text}` : text;
                            if (isBold) line.style.fontWeight = 'bold';
                        }
                        desc.appendChild(line);
                    };
                    addLine('⚠️ 对齐设置说明:');
                    desc.appendChild(document.createElement('hr'));
                    addLine('当原视频和新视频的时间段不一致(如删减/增加片段)时,可通过设置对齐项同步弹幕。');
                    addLine('映射:将原时间段线性映射到新时间段。', true);
                    addLine('顺移:平移时间,超出新时间段的丢弃。', true);
                    addLine('附言:可插入一条大弹幕提示观众。', true);
                    addLine('时间格式为 分:秒 或 分:秒.毫秒', false);

                    page.appendChild(desc);
                    page.appendChild(container);
                    page.appendChild(buttonRow);
                });
            }

            switchTab('display');
            overlay.appendChild(panel);
            document.body.appendChild(overlay);
        }
        createStyledEl(type) {
            const el = document.createElement(type === 'input' ? 'input' : 'div');
            // 样式模板
            const styles = {
                overlay: {
                    position: 'fixed',
                    top: '0', left: '0', right: '0', bottom: '0',
                    background: 'rgba(0, 0, 0, 0.4)',
                    zIndex: 10001,
                    display: 'flex',
                    alignItems: 'center',
                    justifyContent: 'center'
                },
                panel: {
                    background: '#fff',
                    width: '500px',
                    maxHeight: '80vh',
                    overflowY: 'auto',
                    padding: '20px',
                    borderRadius: '8px',
                    boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
                    fontSize: '14px',
                    fontFamily: 'sans-serif',
                    display: 'flex',
                    flexDirection: 'column',
                    gap: '16px'
                },
                input: {
                    padding: '6px 10px',
                    fontSize: '14px',
                    border: '1px solid #ccc',
                    borderRadius: '4px',
                    width: '100%',
                    boxSizing: 'border-box'
                }
            };
            if (styles[type]) Object.assign(el.style, styles[type]);
            return el;
        }
    }

    let dmPanel;
    try {
        const path = 'https://cdn.jsdelivr.net/gh/ZBpine/[email protected]/tampermonkey/';
        const { BiliDanmakuPlayer } = await import(path + 'BiliDanmakuPlayer.js');
        const { BiliProtobufParser } = await import(path + 'BiliProtobufParser.js');
        const { BiliClientGM } = await import(path + 'BiliClientGM.js');
        const { BiliAPI } = await import(path + 'BiliAPI.js');

        const dmPlayer = new BiliDanmakuPlayer();
        const pbParser = new BiliProtobufParser(window.protobuf);
        const biliClient = new BiliClientGM(GM_xmlhttpRequest);
        await biliClient.init();
        class BiliUtil extends BiliAPI {
            constructor(client, pbParser) {
                super(client);
                this.pbParser = pbParser;
            }
            parseDanmakuXml(xml) {
                const danmakus = [];
                const regex = /<d p="([^"]+)">([^<]*)<\/d>/g;
                let match;
                while ((match = regex.exec(xml)) !== null) {
                    const p = match[1].split(",");
                    if (p.length < 8) continue;
                    danmakus.push({
                        progress: Math.trunc(parseFloat(p[0]) * 1000),
                        mode: parseInt(p[1]),
                        fontsize: parseInt(p[2]),
                        color: parseInt(p[3]),
                        ctime: parseInt(p[4]),
                        pool: parseInt(p[5]),
                        midHash: p[6],
                        id: p[7],
                        idStr: p[7],
                        weight: p[8] ? parseInt(p[8]) : 0,
                        content: match[2].trim()
                    });
                }
                return danmakus;
            }
            async getDanmakuXml(cid) {
                const xml = await super.getDanmakuXml(cid);
                return this.parseDanmakuXml(xml);
            }
            async getDanmakuFull(data) {
                let danmakuList = data.danmakuData || [];
                try {
                    console.time("获取Protobuf弹幕 总耗时");
                    const danmakuDict = {};
                    danmakuList.forEach(d => danmakuDict[d.idStr] = d);

                    const { cid, aid, duration } = data.videoData;
                    const pbViewBuf = await this.getDanmakuPbWebView(cid, aid, duration);
                    const pbView = this.pbParser.parseTry(pbViewBuf, 'DmWebViewReply');
                    const pageSize = pbView?.dmSge?.pageSize / 1000 || 360;
                    const segCount = Math.floor(duration / pageSize) + 1;
                    for (let segIndex = 1; segIndex <= segCount; segIndex++) {
                        const segBuf = await this.getDanmakuPbSeg(cid, aid, segIndex);
                        const segData = this.pbParser.parseTry(segBuf, 'DmSegMobileReply');
                        if (!segData || !segData.elems || segData.elems.length === 0) continue;
                        for (const elem of segData.elems) {
                            const dmid = elem.idStr;
                            if (danmakuDict[dmid]) {
                                Object.assign(danmakuDict[dmid], elem);
                            } else {
                                danmakuDict[dmid] = { ...elem };
                            }
                        }
                    }
                    danmakuList = Object.values(danmakuDict);
                    console.timeEnd("获取Protobuf弹幕 总耗时");
                } catch (e) {
                    console.warn('获取protobuf弹幕数据失败:', e);
                }
                return danmakuList;
            }
        }
        const biliUtil = new BiliUtil(biliClient, pbParser);
        unsafeWindow.dmPlayer = dmPlayer;
        unsafeWindow.dmPlayer.biliUtil = biliUtil;
        const dmStore = {
            key: 'dm-player',
            getConfig() {
                return JSON.parse(localStorage.getItem(this.key) || '{}');
            },
            setConfig(obj) {
                localStorage.setItem(this.key, JSON.stringify(obj));
            },
            get(key, def) {
                const cfg = this.getConfig();
                return key.split('.').reduce((o, k) => (o || {})[k], cfg) ?? def;
            },
            set(key, value) {
                const cfg = this.getConfig();
                const keys = key.split('.');
                let obj = cfg;
                for (let i = 0; i < keys.length - 1; i++) {
                    obj[keys[i]] = obj[keys[i]] || {};
                    obj = obj[keys[i]];
                }
                obj[keys.at(-1)] = value;
                this.setConfig(cfg);
            },
            removeCache(videoId) {
                const cfg = this.getConfig();
                if (cfg.cache) delete cfg.cache[videoId];
                this.setConfig(cfg);
            },
            getCache(videoId) {
                return this.getConfig().cache?.[videoId];
            },
            setCache(videoId, json) {
                const cfg = this.getConfig();
                cfg.cache = cfg.cache || {};
                cfg.cache[videoId] = json;
                this.setConfig(cfg);
            },
            clearAllCache() {
                const cfg = this.getConfig();
                delete cfg.cache;
                this.setConfig(cfg);
            }
        };
        dmPanel = new DanmakuControlPanel(dmPlayer, biliUtil, dmStore);
    } catch (err) {
        console.error('加载失败:', err);
    }

    /*
    * chromium的浏览器会自动关闭AdblockPlus拦截Youtube的广告
    * 于是AdblockPlus推出了实验性广告拦截
    * 方法是隐藏原本的视频,插入一个可以阻拦广告的iframe视频
    * https://developers.google.com/youtube/iframe_api_reference?hl=zh-tw
    * 以下为解决办法
    */
    function wrapYTPlayer(player) {
        const PlayerState = unsafeWindow.YT?.PlayerState;
        return {
            get currentTime() {
                return player.getCurrentTime?.() ?? player.playerInfo?.currentTime ?? 0;
            },
            set currentTime(val) {
                player.seekTo?.(val, true);
            },
            get duration() {
                return player.getDuration?.() ?? player.playerInfo?.duration ?? 0;
            },
            get playbackRate() {
                return player.getPlaybackRate?.() ?? player.playerInfo?.playbackRate ?? 1;
            },
            set playbackRate(val) {
                player.setPlaybackRate?.(val);
            },
            get paused() {
                return (player.getPlayerState?.() ?? player.playerInfo?.playerState) === PlayerState?.PAUSED;
            },
            get ended() {
                return (player.getPlayerState?.() ?? player.playerInfo?.playerState) === PlayerState?.ENDED;
            },
            get volume() {
                return (player.getVolume?.() ?? player.playerInfo?.volume ?? 100) / 100;
            },
            set volume(val) {
                player.setVolume?.(Math.max(0, Math.min(1, val)) * 100);
            },
            get muted() {
                return player.isMuted?.() ?? player.playerInfo?.muted ?? false;
            },
            set muted(val) {
                if (val) player.mute?.();
                else player.unMute?.();
            },
            play() {
                player.playVideo?.();
            },
            pause() {
                player.pauseVideo?.();
            }
        };
    }
    function transformIframeDOMAdapter(domAdapter) {
        if (!domAdapter) return;
        if (unsafeWindow.iframePlayer) {
            domAdapter.backup ??= {
                getVideoWrapper: domAdapter.getVideoWrapper,
                bindVideoEvent: domAdapter.bindVideoEvent,
                videoGetter: Object.getOwnPropertyDescriptor(DanmakuDOMAdapter.prototype, 'video')
            };
            domAdapter.getVideoWrapper = function () {
                const iframe = document.querySelector('iframe#yt-haven-embed-player');
                return iframe.parentElement;
            }
            domAdapter.bindVideoEvent = function () {
                domAdapter._resizeObserver = new ResizeObserver(() => {
                    domAdapter.player.resize?.();
                });
                const iframe = document.querySelector('iframe#yt-haven-embed-player');
                domAdapter._resizeObserver.observe(iframe);

                if (domAdapter._isIframeBound) return;
                domAdapter._isIframeBound = true;
                unsafeWindow.iframePlayer.addEventListener('onStateChange', (event) => {
                    const state = event.data;
                    const PlayerState = unsafeWindow.YT.PlayerState;
                    console.log('[iframe 播放器] 播放状态改变', Object.keys(PlayerState).find(k => PlayerState[k] === state) || state);
                    if (state === PlayerState.PLAYING) {
                        domAdapter.player.play?.();
                    } else if (state === PlayerState.PAUSED) {
                        domAdapter.player.pause?.();
                    } else if (state === PlayerState.CUED) {
                        domAdapter.player.resize?.();
                    }
                });
            }
            Object.defineProperty(domAdapter, 'video', {
                get() {
                    return wrapYTPlayer(unsafeWindow.iframePlayer);
                },
                configurable: true
            });
        } else {
            if (domAdapter.backup) {
                Object.assign(domAdapter, domAdapter.backup);
                if (domAdapter.backup.videoGetter) {
                    Object.defineProperty(domAdapter, 'video', domAdapter.backup.videoGetter);
                }
            }
            delete domAdapter._isIframeBound;
        }
    }
    function observeIframePlayer() {
        let player = null;
        const setupPlayer = async (iframe) => {
            if (!iframe || typeof unsafeWindow.YT?.Player !== 'function') return;
            if (iframe.dataset.ytBound) return;
            iframe.dataset.ytBound = '1';
            console.log('[iframe 播放器] 尝试绑定');
            player = new unsafeWindow.YT.Player(iframe, {
                events: {
                    onReady: () => {
                        try {
                            unsafeWindow.iframePlayer = player;
                            if (dmPanel.dmPlayer) {
                                transformIframeDOMAdapter(dmPanel.dmPlayer.domAdapter);
                                dmPanel.dmPlayer.init?.();
                                dmPanel.dmPlayer.paused = false;
                            }
                            console.log('[iframe 播放器] 已绑定');
                        } catch (e) {
                            console.error('[iframe 播放器] 绑定失败', e);
                        }
                    }
                }
            });
        };
        const destroyPlayer = () => {
            if (player && typeof player.destroy === 'function') {
                player.destroy();
            }
            unsafeWindow.iframePlayer = null;
            player = null;
            transformIframeDOMAdapter(dmPanel.dmPlayer?.domAdapter);
            dmPanel.dmPlayer?.init?.();
            console.log('[iframe 播放器] 被移除');
        };
        const observer = new MutationObserver(() => {
            const iframe = document.querySelector('iframe#yt-haven-embed-player');
            if (iframe && iframe !== unsafeWindow.iframePlayer?.getIframe()) {
                setupPlayer(iframe);
            } else if (!iframe && unsafeWindow.iframePlayer) {
                destroyPlayer();
            }
        });
        observer.observe(document.body, { childList: true, subtree: true });

        // 初始检查
        const existing = document.querySelector('iframe#yt-haven-embed-player');
        if (existing) setupPlayer(existing);
    }
    function loadYouTubeIframeAPI(callback) {
        if (unsafeWindow.YT && typeof unsafeWindow.YT.Player === 'function') {
            callback?.();
            return;
        }
        unsafeWindow.onYouTubeIframeAPIReady = () => {
            if (unsafeWindow.YT && typeof unsafeWindow.YT.Player === 'function') {
                console.log('[YT] Iframe API loaded');
                callback?.();
            } else {
                console.warn('[YT] Iframe API load failure');
            }
        };
        let scriptUrl = 'https://www.youtube.com/iframe_api';
        try {
            // 创建 Trusted Types 策略
            const policy = window.trustedTypes?.createPolicy?.('youtube-api-policy', {
                createScriptURL: (url) => url
            });
            if (policy) {
                scriptUrl = policy.createScriptURL(scriptUrl);
            }
        } catch (e) {
            console.warn('[YT] Trusted Types policy creation failed:', e);
        }
        const tag = document.createElement('script');
        tag.src = scriptUrl;
        tag.id = 'iframe-api-script';
        tag.async = true;
        // 插入 script 标签
        const firstScriptTag = document.getElementsByTagName('script')[0];
        if (firstScriptTag) {
            firstScriptTag.parentNode.insertBefore(tag, firstScriptTag);
        } else {
            document.head.appendChild(tag);
        }
    }
    if (!dmPanel.isBilibili) loadYouTubeIframeAPI(() => { observeIframePlayer() });


    async function waitForVideo(timeout = 10000) {
        const start = Date.now();
        return new Promise((resolve, reject) => {
            const check = () => {
                const video = document.querySelector('video');
                if (video) {
                    console.log('🎥 检测到 <video> 元素');
                    resolve(video);
                } else if (Date.now() - start >= timeout) {
                    reject(new Error('⏰ 超时:未检测到 <video> 元素'));
                } else {
                    requestAnimationFrame(check);
                }
            };
            check();
        });
    }
    try {
        await waitForVideo();
        dmPanel.init();
        dmPanel.observeVideoChange();
    } catch (err) {
        console.warn(err);
    }
})();