您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
为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(); })();