Jellyfin 播放器增强功能 (弹幕+倍速)

为Jellyfin播放器添加弹幕支持和倍速播放功能。

// ==UserScript==
// @name         Jellyfin 播放器增强功能 (弹幕+倍速)
// @namespace    https://lers.site
// @homepage     https://github.com/1412150209/Jellyfin-Player-Enhancements
// @version      1.5
// @description  为Jellyfin播放器添加弹幕支持和倍速播放功能。
// @author       lers梦貘
// @supportURL   https://github.com/1412150209/Jellyfin-Player-Enhancements/issues
// @include      http://*:8096/web/*
// @include      https://*:8096/web/*
// @grant        GM_xmlhttpRequest
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/danmaku.min.js
// @license      MIT
// ==/UserScript==

(function () {
    "use strict";

    // ================================================================
    // 全局状态管理和配置
    // ================================================================
    const DANMAKU_CONSTANTS = {
        LOG_LEVEL: "debug",
        DANMAKU_CONTAINER_ZINDEX: 999999,
        DEFAULT_CONFIG: {
            enabled: true,
            speed: 144,
            fontSize: 20,
            opacity: 0.8,
            mode: "rtl",
            color: "#ffffff",
        },
    };

    const GLOBAL_STATE = {
        danmakuInstance: null,
        configMenu: null,
        currentDanmuConfig: loadDanmuConfig(),
        currentSpeedConfig: loadSpeedConfig(),
        observers: new Set(),
        currentUrl: location.href,
        keydownListener: null,
        keyupListener: null,
        activeVideoElement: null,
        hasDanmakuData: false,
        isScriptActive: false,
    };

    const LOG_COLORS = {
        debug: "color: #666; background: transparent;",
        info: "color: #0099ff; background: transparent;",
        warn: "color: #ff9933; background: transparent;",
        error: "color: #ff3300; background: transparent; font-weight: bold;",
    };

    const logger = {
        debug: (...args) =>
        DANMAKU_CONSTANTS.LOG_LEVEL === "debug" &&
        console.debug(`%c[DMU][DEBUG] ${args[0]}`, LOG_COLORS.debug, ...args.slice(1)),
        info: (...args) =>
        ["debug", "info"].includes(DANMAKU_CONSTANTS.LOG_LEVEL) &&
        console.info(`%c[DMU][INFO] ${args[0]}`, LOG_COLORS.info, ...args.slice(1)),
        warn: (...args) =>
        ["debug", "info", "warn"].includes(DANMAKU_CONSTANTS.LOG_LEVEL) &&
        console.warn(`%c[DMU][WARN] ${args[0]}`, LOG_COLORS.warn, ...args.slice(1)),
        error: (...args) =>
        console.error(`%c[DMU][ERROR] ${args[0]}`, LOG_COLORS.error, ...args.slice(1)),
    };

    // ================================================================
    // 工具函数
    // ================================================================

    /**
     * 防抖函数
     * @param {Function} func 要执行的函数
     * @param {number} delay 延迟时间 (ms)
     * @returns {Function} 防抖后的函数
     */
    function debounce(func, delay) {
        let timeout;
        return function(...args) {
            clearTimeout(timeout);
            timeout = setTimeout(() => func.apply(this, args), delay);
        };
    }

    function loadDanmuConfig() {
        const config = localStorage.getItem("danmuConfig");
        return config ? JSON.parse(config) : DANMAKU_CONSTANTS.DEFAULT_CONFIG;
    }

    function saveDanmuConfig() {
        localStorage.setItem("danmuConfig", JSON.stringify(GLOBAL_STATE.currentDanmuConfig));
        logger.debug("弹幕配置已保存", GLOBAL_STATE.currentDanmuConfig);
    }

    function loadSpeedConfig() {
        const config = localStorage.getItem("speedConfig");
        return config ? JSON.parse(config) : { targetRate: 2, currentQuickRate: 1.0 };
    }

    function saveSpeedConfig() {
        localStorage.setItem("speedConfig", JSON.stringify(GLOBAL_STATE.currentSpeedConfig));
    }

    function request(url) {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: "GET",
                url: location.origin + url,
                onload: resolve,
                onerror: reject,
            });
        });
    }

    function getMediaIdFromUrl(url) {
        try {
            const urlParams = new URL(url).searchParams;
            return (
                urlParams.get("mediaSourceId") ||
                url.match(/(?:mediaSourceId|MediaSources)[=/]([a-f0-9]{20,})/i)?.[1]
            );
        } catch (e) {
            throw new Error(`URL解析失败: ${e}`);
        }
    }

    function showFloatingMessage(message) {
        const styleId = 'jellyfin-floating-message-style';
        if (!document.getElementById(styleId)) {
            const style = document.createElement('style');
            style.id = styleId;
            style.textContent = `
              .jellyfin-floating-message {
                position: fixed;
                top: 10%;
                left: 50%;
                transform: translateX(-50%);
                background: rgba(0, 0, 0, 0.8);
                color: white;
                padding: 8px 16px;
                border-radius: 4px;
                z-index: 2147483647;
                pointer-events: none;
                font-size: 1.1em;
                text-align: center;
                box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
                opacity: 0;
                transition: opacity 0.3s ease;
              }
            `;
            document.head.appendChild(style);
        }

        const existingMessages = document.querySelectorAll('.jellyfin-floating-message');
        existingMessages.forEach(el => el.remove());

        const messageEl = document.createElement('div');
        messageEl.className = 'jellyfin-floating-message';
        messageEl.textContent = message;

        const fullscreenElement = document.fullscreenElement ||
              document.webkitFullscreenElement ||
              document.mozFullScreenElement ||
              document.msFullscreenElement;

        const targetContainer = fullscreenElement || document.body;
        targetContainer.appendChild(messageEl);

        setTimeout(() => {
            messageEl.style.opacity = '1'
        }, 50);

        setTimeout(() => {
            messageEl.style.opacity = '0';
            setTimeout(() => {
                if (messageEl.parentElement) {
                    messageEl.remove();
                }
            }, 300);
        }, 2000);
    }

    function isInInputElement(event) {
        const target = event.target;
        return (
            target.tagName === 'INPUT' ||
            target.tagName === 'TEXTAREA' ||
            target.tagName === 'SELECT' ||
            target.isContentEditable ||
            target.closest('[contenteditable="true"]')
        );
    }

    // ================================================================
    // 弹幕功能核心
    // ================================================================
    async function fetchDanmakuData(mediaSourceId) {
        logger.debug(`开始获取弹幕数据,mediaSourceId: ${mediaSourceId}`);
        try {
            const apiEndpoint = `/api/danmu/${mediaSourceId}/raw`;
            logger.info(`开始请求弹幕数据,端点: ${apiEndpoint}`);

            const timeoutPromise = new Promise((_, reject) =>
                                               setTimeout(() => reject(new Error("请求超时")), 5000)
                                              );

            const response = await Promise.race([
                request(apiEndpoint),
                timeoutPromise,
            ]);

            logger.debug(`收到响应状态: ${response.status}`, {
                status: response.status,
                length: response.responseText?.length,
            });

            if (response.status !== 200) {
                throw new Error(`HTTP ${response.status} - ${response.statusText}`);
            }

            if (!response.responseText?.trim()) {
                logger.warn("收到空响应内容,可能无弹幕");
                return null;
            }

            return response.responseText;
        } catch (error) {
            logger.error("弹幕数据获取失败:", {
                error: error.message,
                mediaSourceId,
                stack: error.stack,
            });
            return null;
        }
    }

    function parseDanmakuData(xmlData) {
        logger.debug("开始解析弹幕XML数据...");
        try {
            const sanitizedXml = xmlData
            .replace(/[\x00-\x1F\x7F]/g, "")
            .replace(/&(?!(amp|lt|gt|quot|apos));/g, "&");

            const parser = new DOMParser();
            const doc = parser.parseFromString(sanitizedXml, "text/xml");

            const errorNode = doc.querySelector("parsererror");
            if (errorNode) {
                throw new Error(`XML解析错误: ${errorNode.textContent.slice(0, 100)}`);
            }

            const danmuNodes = doc.getElementsByTagName("d");
            logger.info(`解析到有效弹幕节点: ${danmuNodes.length}`);
            if (danmuNodes.length === 0) return null;

            return Array.from(danmuNodes)
                .map((node, index) => {
                try {
                    const params = (node.getAttribute("p") || "").split(",").map(parseFloat);
                    const [time = 0, , , colorValue = GLOBAL_STATE.currentDanmuConfig.color] = params;

                    return {
                        text: node.textContent?.trim() || "[空弹幕内容]",
                        time: Math.max(0, time),
                        mode: GLOBAL_STATE.currentDanmuConfig.mode,
                        style: {
                            fontSize: `${GLOBAL_STATE.currentDanmuConfig.fontSize}px`,
                            color: /^#[0-9A-F]{6}$/i.test(colorValue) ? colorValue : GLOBAL_STATE.currentDanmuConfig.color,
                        },
                    };
                } catch (nodeError) {
                    logger.warn("弹幕节点解析异常,跳过处理:", {
                        error: nodeError.message,
                        rawText: node.outerHTML.slice(0, 100),
                        index,
                    });
                    return null;
                }
            })
                .filter(Boolean);
        } catch (parseError) {
            logger.error("XML解析严重错误:", {
                error: parseError.message,
                stack: parseError.stack,
                sampleData: xmlData.slice(0, 200),
            });
            return null;
        }
    }

    function createDanmakuContainer(video) {
        logger.debug("创建弹幕容器...");
        const old = video.parentNode.querySelectorAll(".danmaku-container");
        if (old.length !== 0) {
            old.forEach((container) => container.remove());
        }

        const container = document.createElement("div");
        container.className = "danmaku-container";
        container.style.cssText = `
              position: absolute;
              top: 0;
              left: 0;
              width: 100%;
              height: 100%;
              z-index: ${DANMAKU_CONSTANTS.DANMAKU_CONTAINER_ZINDEX};
              pointer-events: none;
        `;

        const wrapper = video.closest(".videoPlayerContainer") || video.parentElement;
        if (!wrapper) {
            logger.error("未找到视频容器元素");
            return null;
        }

        if (window.getComputedStyle(wrapper).position === "static") {
            wrapper.style.position = "relative";
        }
        wrapper.appendChild(container);
        logger.info("弹幕容器创建成功");
        return container;
    }

    function handleResize() {
        if (GLOBAL_STATE.danmakuInstance) {
            GLOBAL_STATE.danmakuInstance.resize();
        }
    }

    function updateDanmuVisibility() {
        if (GLOBAL_STATE.danmakuInstance) {
            GLOBAL_STATE.currentDanmuConfig.enabled ? GLOBAL_STATE.danmakuInstance.show() : GLOBAL_STATE.danmakuInstance.hide();
        }
    }

    function toggleDanmaku() {
        GLOBAL_STATE.currentDanmuConfig.enabled = !GLOBAL_STATE.currentDanmuConfig.enabled;
        saveDanmuConfig();
        updateDanmuVisibility();
        showFloatingMessage(`弹幕已${GLOBAL_STATE.currentDanmuConfig.enabled ? "开启" : "关闭"}`);
    }

    function createDanmuConfigMenu() {
        logger.debug("创建弹幕设置菜单...");
        const oldBackdrop = document.querySelector(".danmu-menu-backdrop");
        const oldMenu = document.querySelector(".danmu-menu-container");
        if (oldBackdrop) oldBackdrop.remove();
        if (oldMenu) oldMenu.remove();

        const backdrop = document.createElement("div");
        backdrop.className = "danmu-menu-backdrop MuiBackdrop-root";
        backdrop.style.cssText = `
            position: fixed;
            top: 0;
            left: 0;
            width: 100vw;
            height: 100vh;
            background-color: rgba(0, 0, 0, 0.5);
            z-index: 999998;
            opacity: 0;
            transition: opacity 225ms cubic-bezier(0.4, 0, 0.2, 1) 0ms;
            pointer-events: none;
        `;

        GLOBAL_STATE.configMenu = document.createElement("div");
        GLOBAL_STATE.configMenu.className = "MuiPaper-root MuiMenu-paper MuiPaper-elevation8 danmu-menu-container";
        GLOBAL_STATE.configMenu.style.cssText = `
            min-width: 280px;
            padding: 8px 0;
            position: fixed;
            z-index: 999999;
            opacity: 0;
            transform: translateY(-10px);
            border: 1px black solid;
            background-color: rgba(0, 0, 0, 0.9);
            transition:
                opacity 225ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,
                transform 225ms cubic-bezier(0.4, 0, 0.2, 1) 0ms;
            color: #fff;
            border-radius: 4px;
        `;
        GLOBAL_STATE.configMenu.innerHTML = `
            <div class="MuiList-root MuiList-padding">
                <div class="MuiListItem-root MuiListItem-gutters" style="padding: 8px 16px;">
                    <div class="MuiTypography-root MuiTypography-subtitle1" style="flex-grow:1; display:flex; align-items:center;">
                        <i class="material-icons MuiListItemIcon-root" style="margin-right:8px; color: inherit;">tune</i>
                        弹幕设置
                    </div>
                </div>
                <hr class="MuiDivider-root" style="margin: 8px 0; border: 0; border-top: 1px solid rgba(255, 255, 255, 0.12);">
                <div class="MuiListItem-root" style="display: block; padding: 8px 16px;">
                    <div class="MuiListItemText-root">
                        <span class="MuiTypography-root MuiTypography-body2">速度 (<span id="danmuSpeedValue">${GLOBAL_STATE.currentDanmuConfig.speed}</span>)</span>
                        <div class="MuiSlider-root MuiSlider-colorPrimary" style="margin-top: 8px;">
                            <input id="danmuSpeed" type="range"
                                min="50" max="600" value="${GLOBAL_STATE.currentDanmuConfig.speed}"
                                class="MuiSlider-track MuiSlider-colorPrimary"
                                style="width: 100%; -webkit-appearance: none; height: 4px; background: rgba(255, 255, 255, 0.3); border-radius: 2px; outline: none;">
                        </div>
                    </div>
                </div>
                <div class="MuiListItem-root" style="display: block; padding: 8px 16px;">
                    <div class="MuiListItemText-root">
                        <span class="MuiTypography-root MuiTypography-body2">字号 (<span id="danmuSizeValue">${GLOBAL_STATE.currentDanmuConfig.fontSize}</span>)</span>
                        <div class="MuiSlider-root MuiSlider-colorPrimary" style="margin-top: 8px;">
                            <input id="danmuSize" type="range"
                                min="12" max="36" value="${GLOBAL_STATE.currentDanmuConfig.fontSize}"
                                class="MuiSlider-track MuiSlider-colorPrimary"
                                style="width: 100%; -webkit-appearance: none; height: 4px; background: rgba(255, 255, 255, 0.3); border-radius: 2px; outline: none;">
                        </div>
                    </div>
                </div>
                <div class="MuiListItem-root" style="padding: 8px 16px; display:flex; align-items:center; justify-content: space-between;">
                    <span class="MuiTypography-root MuiTypography-body2">颜色</span>
                    <input id="danmuColor" type="color"
                        value="${GLOBAL_STATE.currentDanmuConfig.color}"
                        class="MuiInput-input"
                        style="height: 36px; padding: 4px; border: none; background: transparent; border-radius: 4px; cursor: pointer;">
                </div>
            </div>
        `;
        document.body.appendChild(backdrop);
        document.body.appendChild(GLOBAL_STATE.configMenu);
        logger.debug("弹幕设置菜单创建并注入到DOM");

        const applyDanmuChanges = debounce(() => {
            saveDanmuConfig();
            logger.debug("应用弹幕设置变更(防抖)", GLOBAL_STATE.currentDanmuConfig);
            if (GLOBAL_STATE.danmakuInstance) {
                // 直接更新实例属性,避免重新注入
                GLOBAL_STATE.danmakuInstance.speed = GLOBAL_STATE.currentDanmuConfig.speed;
                GLOBAL_STATE.danmakuInstance.comments.forEach(comment => {
                    comment.style.fontSize = `${GLOBAL_STATE.currentDanmuConfig.fontSize}px`;
                    comment.style.color = GLOBAL_STATE.currentDanmuConfig.color;
                });
                GLOBAL_STATE.danmakuInstance.start();
            }
        }, 200);

        backdrop.addEventListener('click', hideDanmuMenu);
        GLOBAL_STATE.configMenu.querySelector("#danmuSpeed").addEventListener("input", (e) => {
            e.preventDefault();
            GLOBAL_STATE.currentDanmuConfig.speed = parseInt(e.target.value);
            document.getElementById("danmuSpeedValue").textContent = GLOBAL_STATE.currentDanmuConfig.speed;
            applyDanmuChanges();
        });
        GLOBAL_STATE.configMenu.querySelector("#danmuSize").addEventListener("input", (e) => {
            e.preventDefault();
            GLOBAL_STATE.currentDanmuConfig.fontSize = parseInt(e.target.value);
            document.getElementById("danmuSizeValue").textContent = GLOBAL_STATE.currentDanmuConfig.fontSize;
            applyDanmuChanges();
        });
        GLOBAL_STATE.configMenu.querySelector("#danmuColor").addEventListener("input", (e) => {
            e.preventDefault();
            GLOBAL_STATE.currentDanmuConfig.color = e.target.value;
            applyDanmuChanges();
        });
        // 绑定点击事件,阻止冒泡
        GLOBAL_STATE.configMenu.addEventListener('click', (e) => e.stopPropagation());
    }

    function showDanmuMenu(button) {
        if (!GLOBAL_STATE.configMenu) {
            createDanmuConfigMenu();
        }
        const rect = button.getBoundingClientRect();
        const viewportHeight = window.innerHeight;
        let top = rect.top - GLOBAL_STATE.configMenu.offsetHeight - 10;
        if (top < 0) {
            top = rect.bottom + 10;
        }

        GLOBAL_STATE.configMenu.style.left = `${rect.left}px`;
        GLOBAL_STATE.configMenu.style.top = `${top}px`;

        setTimeout(() => {
            GLOBAL_STATE.configMenu.style.opacity = "1";
            GLOBAL_STATE.configMenu.style.transform = "translateY(0)";
            document.querySelector('.danmu-menu-backdrop').style.opacity = "1";
            document.querySelector('.danmu-menu-backdrop').style.pointerEvents = "all";
        }, 10);
        logger.debug("显示弹幕设置菜单");
    }

    function hideDanmuMenu() {
        if (GLOBAL_STATE.configMenu) {
            GLOBAL_STATE.configMenu.style.opacity = "0";
            GLOBAL_STATE.configMenu.style.transform = "translateY(-10px)";
        }
        const backdrop = document.querySelector('.danmu-menu-backdrop');
        if (backdrop) {
            backdrop.style.opacity = "0";
            backdrop.style.pointerEvents = "none";
        }
        logger.debug("隐藏弹幕设置菜单");
    }

    async function injectDanmaku(video) {
        logger.debug("开始注入弹幕...");
        if (!video) return;
        const src = video.src;
        if (!src || src === "about:blank") {
            logger.warn("无效的视频源,跳过弹幕注入");
            return;
        }

        try {
            const mediaSourceId = getMediaIdFromUrl(src);
            const danmakuData = await fetchDanmakuData(mediaSourceId);

            if (!danmakuData) {
                showFloatingMessage("暂未找到弹幕");
                GLOBAL_STATE.hasDanmakuData = false;
                logger.info("未找到弹幕数据,不注入弹幕和按钮");
                return;
            }

            const comments = parseDanmakuData(danmakuData);
            if (!comments || comments.length === 0) {
                showFloatingMessage("解析无有效弹幕");
                GLOBAL_STATE.hasDanmakuData = false;
                logger.info("解析结果为空,不注入弹幕和按钮");
                return;
            }

            GLOBAL_STATE.hasDanmakuData = true;

            if (GLOBAL_STATE.danmakuInstance) {
                window.removeEventListener("resize", handleResize);
                GLOBAL_STATE.danmakuInstance.destroy();
                GLOBAL_STATE.danmakuInstance = null;
            }

            const container = createDanmakuContainer(video);
            if (!container) {
                logger.error("弹幕容器创建失败");
                return;
            }

            GLOBAL_STATE.danmakuInstance = new Danmaku({
                container: container,
                media: video,
                comments: comments.map((comment) => ({
                    ...comment,
                    mode: GLOBAL_STATE.currentDanmuConfig.mode,
                    style: {
                        fontSize: `${GLOBAL_STATE.currentDanmuConfig.fontSize}px`,
                        color: GLOBAL_STATE.currentDanmuConfig.color,
                        textShadow: '-1px -1px #000, -1px 1px #000, 1px -1px #000, 1px 1px #000'
                    },
                })),
                engine: "DOM",
                speed: GLOBAL_STATE.currentDanmuConfig.speed,
            });

            window.addEventListener("resize", handleResize);
            updateDanmuVisibility();
            logger.info("弹幕引擎初始化完成");
        } catch (e) {
            logger.error("弹幕引擎初始化失败:", e);
            GLOBAL_STATE.hasDanmakuData = false;
        }
    }

    // ================================================================
    // 倍速功能核心
    // ================================================================
    function bindSpeedControls(video) {
        if (GLOBAL_STATE.keydownListener) {
            document.removeEventListener("keydown", GLOBAL_STATE.keydownListener, true);
        }
        if (GLOBAL_STATE.keyupListener) {
            document.removeEventListener("keyup", GLOBAL_STATE.keyupListener, true);
        }

        const key = "ArrowRight";
        const increaseKey = "Equal";
        const decreaseKey = "Minus";
        const quickIncreaseKey = "BracketRight";
        const quickDecreaseKey = "BracketLeft";
        const resetSpeedKey = "KeyP";
        let keyDownTime = 0;
        let originalRate = 1.0; // 修复:将原始速度设为初始值
        let isSpeedUp = false;

        const { targetRate, currentQuickRate } = GLOBAL_STATE.currentSpeedConfig;

        GLOBAL_STATE.keydownListener = (e) => {
            if (isInInputElement(e)) return;
            logger.debug(`键盘按下: ${e.code}`);

            if (e.code === key) {
                e.preventDefault();
                e.stopImmediatePropagation();
                if (!keyDownTime) {
                    keyDownTime = Date.now();
                }

                if (!isSpeedUp && (Date.now() - keyDownTime) > 300) {
                    isSpeedUp = true;
                    originalRate = video.playbackRate;
                    video.playbackRate = GLOBAL_STATE.currentSpeedConfig.targetRate;
                    showFloatingMessage(`开始 ${GLOBAL_STATE.currentSpeedConfig.targetRate} 倍速播放`);
                    logger.info(`长按 "${key}" -> 切换至 ${GLOBAL_STATE.currentSpeedConfig.targetRate}x 倍速,原始速度为 ${originalRate}x`);
                }
            }
            if (e.code === quickIncreaseKey) {
                e.preventDefault();
                e.stopImmediatePropagation();
                GLOBAL_STATE.currentSpeedConfig.currentQuickRate = GLOBAL_STATE.currentSpeedConfig.currentQuickRate === 1.0 ? 1.5 : GLOBAL_STATE.currentSpeedConfig.currentQuickRate + 0.5;
                video.playbackRate = GLOBAL_STATE.currentSpeedConfig.currentQuickRate;
                showFloatingMessage(`当前播放速度:${GLOBAL_STATE.currentSpeedConfig.currentQuickRate}x`);
                saveSpeedConfig();
                logger.info(`快捷加速 "${quickIncreaseKey}" -> 当前速度 ${video.playbackRate}x`);
            }
            if (e.code === quickDecreaseKey) {
                e.preventDefault();
                e.stopImmediatePropagation();
                if (GLOBAL_STATE.currentSpeedConfig.currentQuickRate > 0.5) {
                    GLOBAL_STATE.currentSpeedConfig.currentQuickRate -= 0.5;
                    video.playbackRate = GLOBAL_STATE.currentSpeedConfig.currentQuickRate;
                    showFloatingMessage(`当前播放速度:${GLOBAL_STATE.currentSpeedConfig.currentQuickRate}x`);
                    saveSpeedConfig();
                    logger.info(`快捷减速 "${quickDecreaseKey}" -> 当前速度 ${video.playbackRate}x`);
                }
            }
            if (e.code === resetSpeedKey) {
                e.preventDefault();
                e.stopImmediatePropagation();
                GLOBAL_STATE.currentSpeedConfig.currentQuickRate = 1.0;
                video.playbackRate = 1.0;
                showFloatingMessage("恢复正常播放速度");
                saveSpeedConfig();
                logger.info(`重置速度 "${resetSpeedKey}" -> 恢复至 1.0x`);
            }
            if (e.code === increaseKey) {
                e.preventDefault();
                e.stopImmediatePropagation();
                GLOBAL_STATE.currentSpeedConfig.targetRate += 0.5;
                showFloatingMessage(`下次倍速:${GLOBAL_STATE.currentSpeedConfig.targetRate}`);
                saveSpeedConfig();
                logger.info(`提高预设倍速 "${increaseKey}" -> 下次倍速为 ${GLOBAL_STATE.currentSpeedConfig.targetRate}x`);
            }
            if (e.code === decreaseKey) {
                e.preventDefault();
                e.stopImmediatePropagation();
                if (GLOBAL_STATE.currentSpeedConfig.targetRate > 0.5) {
                    GLOBAL_STATE.currentSpeedConfig.targetRate -= 0.5;
                    showFloatingMessage(`下次倍速:${GLOBAL_STATE.currentSpeedConfig.targetRate}`);
                    saveSpeedConfig();
                    logger.info(`降低预设倍速 "${decreaseKey}" -> 下次倍速为 ${GLOBAL_STATE.currentSpeedConfig.targetRate}x`);
                } else {
                    showFloatingMessage("倍速已达到最小值 0.5");
                    logger.warn(`降低预设倍速失败,已是最小值`);
                }
            }
        };

        GLOBAL_STATE.keyupListener = (e) => {
            if (isInInputElement(e)) return;

            if (e.code === key) {
                e.preventDefault();
                e.stopImmediatePropagation();
                const pressTime = Date.now() - keyDownTime;
                logger.debug(`键盘松开: ${e.code}, 按下时长: ${pressTime}ms`);

                if (pressTime < 300) {
                    video.currentTime += 5;
                    showFloatingMessage(`快进 5s`);
                    logger.info("短按 -> 快进 5s");
                }
                if (isSpeedUp) {
                    video.playbackRate = originalRate;
                    showFloatingMessage(`恢复 ${originalRate} 倍速播放`);
                    isSpeedUp = false;
                    logger.info(`长按松开 -> 恢复 ${originalRate}x 倍速`);
                }
                keyDownTime = 0;
            }
        };

        document.addEventListener("keydown", GLOBAL_STATE.keydownListener, true);
        document.addEventListener("keyup", GLOBAL_STATE.keyupListener, true);
        logger.info("倍速快捷键已绑定");
    }

    // ================================================================
    // 使用说明弹窗
    // ================================================================
    function showHelpDialog() {
        logger.debug("显示使用说明弹窗...");
        const existingBackdrop = document.querySelector(".jellyfin-help-dialog-backdrop");
        if (existingBackdrop) {
            existingBackdrop.remove();
        }

        const backdrop = document.createElement("div");
        backdrop.className = "jellyfin-help-dialog-backdrop";
        backdrop.style.cssText = `
            position: fixed;
            top: 0;
            left: 0;
            width: 100vw;
            height: 100vh;
            background-color: rgba(0, 0, 0, 0.7);
            z-index: 2147483647;
            display: flex;
            align-items: center;
            justify-content: center;
        `;

        const dialog = document.createElement("div");
        dialog.className = "jellyfin-help-dialog-content";
        dialog.style.cssText = `
            background-color: #2e2e2e;
            color: #fff;
            padding: 24px;
            border-radius: 8px;
            max-width: 500px;
            width: 90%;
            max-height: 80vh;
            overflow-y: auto;
            position: relative;
            box-shadow: 0px 11px 15px -7px rgba(0,0,0,0.2), 0px 24px 38px 3px rgba(0,0,0,0.14), 0px 9px 46px 8px rgba(0,0,0,0.12);
        `;

        dialog.innerHTML = `
            <div style="padding-bottom: 16px;">
                <h2 style="margin: 0; font-size: 1.5em;">脚本使用说明</h2>
            </div>
            <div style="padding: 0;">
                <p>本脚本为 Jellyfin 播放器添加了弹幕和倍速增强功能。</p>
                <h4 style="margin-top: 16px;">弹幕功能</h4>
                <p>弹幕功能基于<a href="https://github.com/cxfksword/jellyfin-plugin-danmu" target="_blank" style="color: yellow; text-decoration: none;">jellyfin-plugin-danmu</a>插件,请先安装该插件</p>
                <ul style="list-style-type: disc; margin: 8px 0 0 20px; padding: 0;">
                    <li style="margin-bottom: 8px;color: red">如果当前视频未能成功获取到弹幕,则不会有以下按钮</li>
                    <li style="margin-bottom: 8px;">点击播放器右下角弹幕按钮 <i class="material-icons" style="font-size: 1em; vertical-align: middle;">closed_caption</i> 切换弹幕开关。</li>
                    <li style="margin-bottom: 8px;">点击设置按钮 <i class="material-icons" style="font-size: 1em; vertical-align: middle;">tune</i> 打开弹幕设置菜单。</li>
                    <li>在设置菜单中可以调整弹幕速度、字号和颜色。</li>
                </ul>
                <h4 style="margin-top: 16px;">倍速功能</h4>
                <p>倍速播放仅在键盘操作时生效,不会影响界面上的播放速度显示。</p>
                <ul style="list-style-type: disc; margin: 8px 0 0 20px; padding: 0;">
                    <li style="margin-bottom: 8px;"><kbd>→</kbd> (长按): 切换至倍速播放 (当前 ${GLOBAL_STATE.currentSpeedConfig.targetRate} 倍)。松开恢复正常。</li>
                    <li style="margin-bottom: 8px;"><kbd>→</kbd> (短按): 快进 5 秒。</li>
                    <li style="margin-bottom: 8px;"><kbd>[</kbd> : 减慢当前播放速度 0.5 倍。</li>
                    <li style="margin-bottom: 8px;"><kbd>]</kbd> : 加快当前播放速度 0.5 倍。</li>
                    <li style="margin-bottom: 8px;"><kbd>-</kbd> : 减小长按 <kbd>→</kbd> 键的预设倍速。</li>
                    <li style="margin-bottom: 8px;"><kbd>=</kbd> : 增大长按 <kbd>→</kbd> 键的预设倍速。</li>
                    <li><kbd>P</kbd> : 恢复正常播放速度。</li>
                </ul>
                <div style="padding-top: 24px; text-align: right;">
                    <button class="jellyfin-dialog-close-btn" style="background-color: #00a4dc; color: #fff; padding: 8px 20px; border: none; border-radius: 4px; cursor: pointer; font-size: 1em;">
                        关闭
                    </button>
                </div>
            </div>
        `;
        backdrop.appendChild(dialog);
        document.body.appendChild(backdrop);

        const closeButton = dialog.querySelector(".jellyfin-dialog-close-btn");
        closeButton.addEventListener("click", () => {
            logger.debug("关闭使用说明弹窗");
            backdrop.remove();
        });
        backdrop.addEventListener("click", (e) => {
            if (e.target === backdrop) {
                logger.debug("点击遮罩,关闭使用说明弹窗");
                backdrop.remove();
            }
        });
    }

    // ================================================================
    // UI 按钮注入
    // ================================================================
    function injectControls(buttonContainer) {
        logger.debug("尝试注入控制按钮...");
        const old = buttonContainer.querySelector(".jellyfin-enhancer-controls");
        if (old) {
            old.remove();
        }

        const btnGroup = document.createElement("div");
        btnGroup.className = "MuiButtonGroup-root MuiButtonGroup-textPrimary jellyfin-enhancer-controls";
        btnGroup.style.cssText = "display: flex; gap: 4px; margin-left: 8px;";
        btnGroup.style.backgroudColor = "transparent"

        // 仅在有弹幕数据时注入弹幕相关按钮
        if (GLOBAL_STATE.hasDanmakuData) {
            logger.info("已检测到弹幕数据,注入弹幕控制按钮");
            const toggleBtn = document.createElement("button");
            toggleBtn.className = `btnUserRating autoSize paper-icon-button-light jellyfin-enhancer-btn`;
            toggleBtn.title = "弹幕开关";
            toggleBtn.innerHTML = `
                <span class="MuiIcon-root material-icons" style="color: white;">${GLOBAL_STATE.currentDanmuConfig.enabled ? 'closed_caption' : 'closed_caption_off'}</span>
            `;
            toggleBtn.addEventListener("click", function() {
                toggleDanmaku();
                const icon = this.querySelector('.material-icons');
                icon.textContent = GLOBAL_STATE.currentDanmuConfig.enabled ? 'closed_caption' : 'closed_caption_off';
            });

            const configBtn = document.createElement("button");
            configBtn.className = "btnUserRating autoSize paper-icon-button-light jellyfin-enhancer-btn";
            configBtn.title = "弹幕设置";
            configBtn.innerHTML = `
                <span class="MuiIcon-root material-icons" style="color:white;">tune</span>
            `;
            configBtn.addEventListener("click", function(e) {
                e.stopPropagation();
                createDanmuConfigMenu();
                showDanmuMenu(this);
            });
            btnGroup.appendChild(toggleBtn);
            btnGroup.appendChild(configBtn);
        } else {
            logger.info("未检测到弹幕数据,跳过弹幕控制按钮注入");
        }

        const helpBtn = document.createElement("button");
        helpBtn.className = "btnUserRating autoSize paper-icon-button-light jellyfin-enhancer-btn";
        helpBtn.title = "使用说明";
        helpBtn.innerHTML = `
            <span class="MuiIcon-root material-icons" style="color:white;">help_outline</span>
        `;
        helpBtn.addEventListener("click", function(e) {
            e.stopPropagation();
            showHelpDialog();
        });
        btnGroup.appendChild(helpBtn);


        const subtitlesBtn = buttonContainer.querySelector('.btnSubtitles');
        if (subtitlesBtn) {
            subtitlesBtn.after(btnGroup);
            logger.info("控制按钮已注入在字幕按钮后");
        } else {
            const volumeButtons = buttonContainer.querySelector('.volumeButtons');
            if (volumeButtons) {
                buttonContainer.insertBefore(btnGroup, volumeButtons);
                logger.info("控制按钮已注入在音量按钮前");
            } else {
                buttonContainer.appendChild(btnGroup);
                logger.warn("未找到合适的注入位置,控制按钮已追加到按钮容器末尾。");
            }
        }
    }

    // ================================================================
    // 主入口和观察器
    // ================================================================
    function cleanup() {
        if (!GLOBAL_STATE.isScriptActive) return;
        logger.info("正在执行清理...");
        if (GLOBAL_STATE.keydownListener) {
            document.removeEventListener("keydown", GLOBAL_STATE.keydownListener, true);
            GLOBAL_STATE.keydownListener = null;
        }
        if (GLOBAL_STATE.keyupListener) {
            document.removeEventListener("keyup", GLOBAL_STATE.keyupListener, true);
            GLOBAL_STATE.keyupListener = null;
        }
        GLOBAL_STATE.observers.forEach((observer) => {
            if (observer && observer.disconnect) observer.disconnect();
        });
        GLOBAL_STATE.observers.clear();
        if (GLOBAL_STATE.danmakuInstance) {
            window.removeEventListener("resize", handleResize);
            GLOBAL_STATE.danmakuInstance.destroy();
            GLOBAL_STATE.danmakuInstance = null;
        }
        hideDanmuMenu();
        const oldControls = document.querySelector(".jellyfin-enhancer-controls");
        if (oldControls) oldControls.remove();
        GLOBAL_STATE.isScriptActive = false;
        GLOBAL_STATE.activeVideoElement = null;
        GLOBAL_STATE.hasDanmakuData = false;
        logger.info("脚本环境已清理完成");
    }

    async function init() {
        if (GLOBAL_STATE.isScriptActive) {
            logger.debug("脚本已激活,跳过初始化。");
            return;
        }

        logger.info("开始初始化脚本...");
        cleanup();

        let video = null;
        try {
            video = await new Promise((resolve, reject) => {
                const check = () => document.querySelector("video.htmlvideoplayer");
                let count = 0;
                const interval = setInterval(() => {
                    const v = check();
                    if (v && v.readyState >= 1) {
                        clearInterval(interval);
                        resolve(v);
                    } else if (count++ > 50) { // 5秒超时
                        clearInterval(interval);
                        reject(new Error("视频元素查找超时"));
                    }
                }, 100);
            });
            GLOBAL_STATE.activeVideoElement = video;
            GLOBAL_STATE.isScriptActive = true;
            logger.info("找到视频元素,准备注入增强功能:", video);
        } catch (e) {
            logger.warn("未找到视频元素,等待下一次机会:", e.message);
            return;
        }

        bindSpeedControls(video);
        await injectDanmaku(video); // 等待弹幕注入完成,以确定是否有弹幕数据

        const controlBarObserver = new MutationObserver((mutations, observer) => {
            const buttonContainer = document.querySelector(".videoOsdBottom .buttons.focuscontainer-x");
            if (buttonContainer) {
                injectControls(buttonContainer);
                observer.disconnect();
                GLOBAL_STATE.observers.delete(observer);
            }
        });
        controlBarObserver.observe(document.body, { childList: true, subtree: true });
        GLOBAL_STATE.observers.add(controlBarObserver);

        const videoSrcObserver = new MutationObserver((mutations) => {
            if (mutations.some(m => m.attributeName === "src")) {
                logger.info("视频 src 变化,重新加载弹幕");
                injectDanmaku(video);
            }
        });
        videoSrcObserver.observe(video, { attributes: true, attributeFilter: ["src"] });
        GLOBAL_STATE.observers.add(videoSrcObserver);

        const videoParent = video.parentElement;
        const videoRemovalObserver = new MutationObserver((mutations) => {
            for (const mutation of mutations) {
                for (const removedNode of mutation.removedNodes) {
                    if (removedNode === video) {
                        logger.info("视频元素被移除,执行清理。");
                        cleanup();
                        videoRemovalObserver.disconnect();
                        GLOBAL_STATE.observers.delete(videoRemovalObserver);
                        return;
                    }
                }
            }
        });
        videoRemovalObserver.observe(videoParent, { childList: true });
        GLOBAL_STATE.observers.add(videoRemovalObserver);
    }

    function watchUrlChanges() {
        let lastUrl = location.href;
        new MutationObserver(() => {
            const url = location.href;
            if (url !== lastUrl) {
                lastUrl = url;
                logger.info("URL 变化,重新初始化。");
                cleanup();
                if (url.includes("/web/#/video")) {
                    setTimeout(init, 500);
                }
            }
        }).observe(document.body, { childList: true, subtree: true });

        if (lastUrl.includes("/web/#/video")) {
            setTimeout(init, 500);
        }
    }

    logger.info("Jellyfin 增强脚本已加载");
    watchUrlChanges();
})();