您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
在Jellyfin播放器OSD中添加选集功能,支持快速切换剧集
// ==UserScript== // @name Jellyfin 播放器选集功能 // @namespace https://github.com/guiyuanyuanbao/Jellyfin-InPlayerEpisodePreview // @version 1.0 // @description 在Jellyfin播放器OSD中添加选集功能,支持快速切换剧集 // @author guiyuanyuanbao // @license MIT // @match *://*/*/web/index.html // @match *://*/web/index.html // @match *://*/*/web/ // @match *://*/web/ // @run-at document-idle // @grant none // @supportURL https://github.com/guiyuanyuanbao/Jellyfin-InPlayerEpisodePreview/issues // @homepageURL https://github.com/guiyuanyuanbao/Jellyfin-InPlayerEpisodePreview // ==/UserScript== (function () { 'use strict'; if (!document.querySelector('meta[name="application-name"]') || document.querySelector('meta[name="application-name"]').content !== 'Jellyfin') { return; } // 配置参数 const config = { checkInterval: 200, uiQueryStr: '.btnVideoOsdSettings', mediaContainerQueryStr: "div[data-type='video-osd']", mediaQueryStr: 'video', maxRetries: 50 }; // 全局变量 let currentItemId = ''; let currentSeriesId = ''; let episodeList = []; let isInitialized = false; let isNewJellyfin = true; let lastCheckedItemId = ''; // 记录上次检查的项目ID let isEpisodeType = null; // 缓存当前内容是否为剧集类型 // 拦截XMLHttpRequest获取当前播放项目ID const originalOpen = XMLHttpRequest.prototype.open; XMLHttpRequest.prototype.open = function (_, url) { this.addEventListener('load', function () { if (url.endsWith('PlaybackInfo')) { try { const res = JSON.parse(this.responseText); currentItemId = res.MediaSources[0].Id; console.log('[选集插件] 获取到当前项目ID:', currentItemId); } catch (e) { console.error('[选集插件] 解析PlaybackInfo失败:', e); } } }); originalOpen.apply(this, arguments); }; // 检测Jellyfin版本 const compareVersions = (version1, version2) => { if (typeof version1 !== 'string') return -1; if (typeof version2 !== 'string') return 1; const v1 = version1.split('.').map(Number); const v2 = version2.split('.').map(Number); for (let i = 0; i < Math.max(v1.length, v2.length); i++) { const n1 = v1[i] || 0; const n2 = v2[i] || 0; if (n1 > n2) return 1; if (n1 < n2) return -1; } return 0; }; // 等待API客户端就绪 const waitForApiClient = () => { return new Promise((resolve) => { const checkApiClient = () => { if (window.ApiClient && ApiClient.getCurrentUserId) { isNewJellyfin = compareVersions(ApiClient._appVersion, '10.10.0') >= 0; resolve(); } else { setTimeout(checkApiClient, 100); } }; checkApiClient(); }); }; // 创建选集按钮 function createEpisodeButton() { const button = document.createElement('button'); button.className = 'paper-icon-button-light'; button.setAttribute('is', 'paper-icon-button-light'); button.setAttribute('title', '选集'); button.setAttribute('id', 'episodeSelector'); const icon = document.createElement('span'); icon.className = 'xlargePaperIconButton material-icons'; icon.textContent = 'format_list_bulleted'; button.appendChild(icon); button.onclick = showEpisodeModal; return button; } // 获取当前播放项目信息 async function getCurrentItemInfo() { try { await waitForApiClient(); if (!currentItemId) { console.log('[选集插件] 当前项目ID为空,等待获取...'); return null; } const userId = ApiClient.getCurrentUserId(); const itemInfo = await ApiClient.getItem(userId, currentItemId); console.log('[选集插件] 当前用户ID:', userId); console.log('[选集插件] 当前项目ID:', currentItemId); console.log('[选集插件] 获取到项目信息:', itemInfo); return itemInfo; } catch (error) { console.error('[选集插件] 获取项目信息失败:', error); return null; } } // 获取系列中的所有剧集 async function getSeriesEpisodes(seriesId) { try { await waitForApiClient(); const userId = ApiClient.getCurrentUserId(); const query = { ParentId: seriesId, IncludeItemTypes: 'Episode', Recursive: true, SortBy: 'ParentIndexNumber,IndexNumber', SortOrder: 'Ascending', Fields: 'Overview,PrimaryImageAspectRatio,ParentId,IndexNumber,ParentIndexNumber' }; const result = await ApiClient.getItems(userId, query); console.log('[选集插件] 获取到剧集列表:', result.Items); return result.Items || []; } catch (error) { console.error('[选集插件] 获取剧集列表失败:', error); return []; } } // 创建加载遮罩 - 使用已有的spinner元素 function createLoadingOverlay() { const overlay = document.createElement('div'); overlay.id = 'episodeLoadingOverlay'; overlay.style.cssText = ` position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.9); backdrop-filter: blur(10px); z-index: 9999999; display: flex; flex-direction: column; justify-content: center; align-items: center; transition: opacity 0.3s ease; `; // 查找并克隆现有的spinner元素 let spinner = document.querySelector('div.docspinner.mdl-spinner'); let spinnerElement; if (spinner) { // 克隆现有的spinner spinnerElement = spinner.cloneNode(true); spinnerElement.style.opacity = '1'; spinnerElement.style.visibility = 'visible'; spinnerElement.style.display = 'block'; spinnerElement.classList.add('mdlSpinnerActive'); } else { // 找不到spinner时的备用方案 spinnerElement = document.createElement('div'); spinnerElement.className = 'docspinner mdl-spinner mdlSpinnerActive'; spinnerElement.setAttribute('dir', 'ltr'); // 创建spinner内部结构 for (let i = 0; i < 4; i++) { const circle = document.createElement('div'); circle.className = 'mdl-spinner__layer mdl-spinner__layer-' + (i + 1); const clipContainer = document.createElement('div'); clipContainer.className = 'mdl-spinner__circle-clipper mdl-spinner__left'; const circle1 = document.createElement('div'); circle1.className = 'mdl-spinner__circle'; clipContainer.appendChild(circle1); const gapPatch = document.createElement('div'); gapPatch.className = 'mdl-spinner__gap-patch'; const circle2 = document.createElement('div'); circle2.className = 'mdl-spinner__circle'; gapPatch.appendChild(circle2); const clipperRight = document.createElement('div'); clipperRight.className = 'mdl-spinner__circle-clipper mdl-spinner__right'; const circle3 = document.createElement('div'); circle3.className = 'mdl-spinner__circle'; clipperRight.appendChild(circle3); circle.appendChild(clipContainer); circle.appendChild(gapPatch); circle.appendChild(clipperRight); spinnerElement.appendChild(circle); } } // 设置spinner样式 spinnerElement.style.width = '60px'; spinnerElement.style.height = '60px'; const textContainer = document.createElement('div'); textContainer.style.cssText = ` position: absolute; top: 55vh; left: 0; right: 0; text-align: center; `; const loadingText = document.createElement('div'); loadingText.className = 'loading-text'; loadingText.style.cssText = ` color: #fff; font-size: 18px; font-weight: 500; `; loadingText.textContent = '正在切换剧集...'; const subText = document.createElement('div'); subText.style.cssText = ` color: rgba(255, 255, 255, 0.7); font-size: 14px; margin-top: 8px; `; subText.textContent = '请稍候'; textContainer.appendChild(loadingText); textContainer.appendChild(subText); overlay.appendChild(spinnerElement); overlay.appendChild(textContainer); document.body.appendChild(overlay); return overlay; } // 移除加载遮罩 function removeLoadingOverlay() { const overlay = document.getElementById('episodeLoadingOverlay'); if (overlay) { overlay.style.opacity = '0'; setTimeout(() => { if (overlay.parentNode) { overlay.parentNode.removeChild(overlay); } }, 300); } } // 播放指定剧集 async function playEpisode(episodeId) { let loadingOverlay = null; let retryAttempt = 0; const maxMainRetries = 2; // 主要重试次数 const attemptPlayEpisode = async (attemptNumber = 1) => { try { await waitForApiClient(); if (episodeId === currentItemId) { console.log('[选集插件] 已经是当前剧集,无需跳转'); return true; } console.log(`[选集插件] 第${attemptNumber}次尝试播放剧集:`, episodeId); // 获取serverId const serverId = ApiClient.serverId() || ApiClient._serverInfo?.Id || ''; if (!serverId) { console.error('[选集插件] 无法获取serverId'); throw new Error('无法获取服务器信息'); } // 保存当前页面状态 const originalHash = window.location.hash; const originalTitle = document.title; // 删除弹幕容器元素(如果存在) const danmakuContainer = document.getElementById('danmakuCtr'); if (danmakuContainer) { danmakuContainer.remove(); console.log('[选集插件] 已删除弹幕容器元素'); } // 构造详情页路由 const detailRoute = `#/details?id=${episodeId}&context=home&serverId=${serverId}`; console.log('[选集插件] 准备跳转到详情页:', detailRoute); // 更新加载遮罩文本 const loadingText = document.querySelector('#episodeLoadingOverlay .loading-text'); if (loadingText) { loadingText.textContent = attemptNumber > 1 ? `正在重试切换剧集 (${attemptNumber}/${maxMainRetries + 1})...` : '正在切换剧集...'; } // 执行跳转并播放 const performJump = () => { return new Promise((resolve, reject) => { // 隐藏主要内容区域 const mainContent = document.querySelector('.mainAnimatedPage') || document.querySelector('main') || document.querySelector('.pageContainer') || document.querySelector('[data-role="page"]'); let originalDisplay = ''; if (mainContent) { originalDisplay = mainContent.style.display; mainContent.style.display = 'none'; } // 跳转到详情页 window.location.hash = detailRoute; // 等待详情页加载并查找播放按钮 let retryCount = 0; const maxRetries = 35; // 增加重试次数 const retryInterval = 200; // 减少重试间隔 const waitForPlayButton = () => { // 扩展播放按钮选择器 const playButtonSelectors = [ 'button.btnPlay[data-action="resume"]', 'button.btnPlay.detailButton', 'button[title="播放"]', 'button[title="Play"]', '.btnPlay', 'button[data-action="play"]', '.detailButton.btnPlay', '.itemDetailPage .btnPlay', '[data-role="button"][title="播放"]' ]; let playButton = null; for (const selector of playButtonSelectors) { const buttons = document.querySelectorAll(selector); for (const btn of buttons) { // 检查按钮是否可见且可点击 if (btn.offsetParent !== null && !btn.disabled && getComputedStyle(btn).visibility !== 'hidden') { playButton = btn; break; } } if (playButton) break; } if (playButton) { console.log('[选集插件] 找到播放按钮,准备点击:', playButton); // 恢复主要内容显示(如果还在详情页) if (mainContent && window.location.hash.includes('details')) { mainContent.style.display = originalDisplay; } // 模拟点击播放按钮 try { // 先尝试触发focus和mousedown事件 playButton.focus(); playButton.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); playButton.click(); playButton.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); console.log('[选集插件] 播放按钮已点击'); // 等待一段时间后验证是否切换成功 setTimeout(() => { // 检查当前播放的内容是否已切换 const currentPlayingId = getCurrentPlayingItemId(); if (currentPlayingId === episodeId) { console.log('[选集插件] 剧集切换成功,当前播放:', currentPlayingId); resolve(true); } else { console.warn('[选集插件] 播放按钮已点击但剧集未切换,可能需要重试'); // 检查是否进入了播放页面 if (window.location.hash.includes('video') || document.querySelector('video')) { console.log('[选集插件] 已进入播放页面,认为切换成功'); resolve(true); } else { reject(new Error('播放按钮点击后未成功切换剧集')); } } }, 2000); } catch (clickError) { console.error('[选集插件] 点击播放按钮失败:', clickError); reject(new Error('点击播放按钮失败')); } } else if (retryCount < maxRetries) { retryCount++; console.log(`[选集插件] 未找到播放按钮,重试 ${retryCount}/${maxRetries}`); setTimeout(waitForPlayButton, retryInterval); } else { console.error('[选集插件] 超时未找到播放按钮'); // 恢复页面状态 if (mainContent) { mainContent.style.display = originalDisplay; } window.location.hash = originalHash; document.title = originalTitle; reject(new Error('在详情页未找到播放按钮,可能是页面加载异常')); } }; // 延迟开始查找播放按钮,给页面加载时间 setTimeout(waitForPlayButton, 1000); }); }; // 执行跳转 const jumpResult = await performJump(); return jumpResult; } catch (error) { console.error(`[选集插件] 第${attemptNumber}次尝试失败:`, error); throw error; } }; try { console.log('[选集插件] 准备播放剧集:', episodeId); // 显示加载遮罩 loadingOverlay = createLoadingOverlay(); // 尝试播放剧集,如果失败则重试 let success = false; let lastError = null; for (retryAttempt = 1; retryAttempt <= maxMainRetries + 1; retryAttempt++) { try { success = await attemptPlayEpisode(retryAttempt); if (success) { console.log(`[选集插件] 第${retryAttempt}次尝试成功`); break; } } catch (error) { lastError = error; console.warn(`[选集插件] 第${retryAttempt}次尝试失败:`, error.message); // 如果不是最后一次尝试,等待后重试 if (retryAttempt < maxMainRetries + 1) { console.log(`[选集插件] 准备进行第${retryAttempt + 1}次重试`); await new Promise(resolve => setTimeout(resolve, 1000)); } } } if (success) { // 延迟移除遮罩,确保播放开始 setTimeout(() => { removeLoadingOverlay(); // 显示成功提示 showNotification('剧集切换成功', 'success'); }, 1500); } else { throw lastError || new Error('所有重试均失败'); } } catch (error) { console.error('[选集插件] 播放剧集最终失败:', error); removeLoadingOverlay(); // 根据错误类型和重试次数给出不同的提示 let errorMessage = '切换剧集失败'; let suggestion = ''; if (error.message.includes('服务器信息')) { errorMessage = '无法连接到服务器'; suggestion = '请检查网络连接'; } else if (error.message.includes('播放按钮')) { errorMessage = retryAttempt > 1 ? `多次尝试后仍找不到播放按钮 (已重试${retryAttempt - 1}次)` : '找不到播放按钮'; suggestion = '页面可能加载异常,请手动刷新页面后重试'; } else if (error.message.includes('详情页')) { errorMessage = '详情页加载失败'; suggestion = '请检查剧集是否存在或稍后重试'; } else if (error.message.includes('未成功切换')) { errorMessage = '播放按钮响应异常'; suggestion = '请尝试手动点击播放按钮或刷新页面'; } const retryInfo = retryAttempt > 1 ? `\n\n已尝试次数:${retryAttempt}次` : ''; alert(`${errorMessage}\n\n建议:${suggestion}\n\n其他解决方案:\n1. 刷新页面后重试\n2. 检查网络连接\n3. 确认剧集访问权限${retryInfo}`); // 显示失败通知 showNotification(`剧集切换失败:${errorMessage}`, 'error'); } }; // 获取当前播放项目ID的辅助函数 function getCurrentPlayingItemId() { try { // 尝试从多个可能的位置获取当前播放ID if (window.currentPlayingItem) { return window.currentPlayingItem.Id; } if (window.playbackManager && window.playbackManager.currentItem) { return window.playbackManager.currentItem().Id; } return currentItemId; // 回退到全局变量 } catch (e) { return currentItemId; } } // 创建通知函数 function showNotification(message, type = 'info') { const notification = document.createElement('div'); notification.style.cssText = ` position: fixed; top: 20px; right: 20px; padding: 12px 20px; border-radius: 6px; color: white; font-size: 14px; font-weight: 500; z-index: 9999999; max-width: 300px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); transition: all 0.3s ease; ${type === 'success' ? 'background: linear-gradient(130deg, #a95bc2, #00a4db);' : type === 'error' ? 'background: linear-gradient(135deg, #f44336, #d32f2f);' : 'background: linear-gradient(135deg, #2196F3, #1976D2);'} `; notification.textContent = message; document.body.appendChild(notification); // 自动移除通知 setTimeout(() => { notification.style.opacity = '0'; notification.style.transform = 'translateX(100%)'; setTimeout(() => { if (notification.parentNode) { notification.parentNode.removeChild(notification); } }, 300); }, 3000); } // 创建模态框 function createModal(id, title, contentHtml) { const modal = document.createElement('div'); modal.id = id; modal.className = 'dialogContainer'; modal.style.zIndex = '1000000'; modal.innerHTML = ` <div class="dialog" style="width: 90%; max-width: 1200px; max-height: 80vh; padding: 20px; border-radius: 8px; background: rgba(24, 24, 24, 0.95); backdrop-filter: blur(10px);"> <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; padding-bottom: 15px; border-bottom: 1px solid rgba(255, 255, 255, 0.1);"> <h2 style="color: #fff; margin: 0; font-size: 24px; font-weight: 600;"> ${title} </h2> <button id="close${id}" style="background: none; border: none; color: #fff; font-size: 24px; cursor: pointer; padding: 5px; border-radius: 4px;" title="关闭"> ✕ </button> </div> <div style="max-height: 60vh; overflow-y: auto; padding-right: 10px;" class="episodes-container"> ${contentHtml} </div> <div style="display: flex; justify-content: center; margin-top: 20px; padding-top: 15px; border-top: 1px solid rgba(255, 255, 255, 0.1);"> <button id="cancel${id}" class="raised button-cancel block btnCancel formDialogFooterItem emby-button"> 关闭 </button> </div> </div> `; document.body.appendChild(modal); // 添加样式 const style = document.createElement('style'); style.textContent = ` .episode-item:hover { background: linear-gradient(135deg, rgba(169, 91, 194, 0.2), rgba(0, 164, 219, 0.2)) !important; border-color: rgba(169, 91, 194, 0.4) !important; transform: translateY(-2px); box-shadow: 0 4px 12px rgba(169, 91, 194, 0.3); } .current-episode { position: relative; } .current-episode::before { content: '▶'; position: absolute; right: 8px; top: 8px; color: #a95bc2; font-size: 16px; font-weight: bold; } .episodes-container::-webkit-scrollbar { width: 8px; } .episodes-container::-webkit-scrollbar-track { background: rgba(255, 255, 255, 0.1); border-radius: 4px; } .episodes-container::-webkit-scrollbar-thumb { background: linear-gradient(180deg, #a95bc2, #00a4db); border-radius: 4px; } #close${id}:hover { background: rgba(255, 255, 255, 0.1) !important; } `; document.head.appendChild(style); // 绑定事件 document.getElementById(`close${id}`).onclick = () => closeModal(id); document.getElementById(`cancel${id}`).onclick = () => closeModal(id); } // 关闭模态框 function closeModal(id) { const modal = document.getElementById(id); if (modal) { if (modal._handleEscape) { document.removeEventListener('keydown', modal._handleEscape); } // 清理样式 const style = document.head.querySelector('style:last-of-type'); if (style && style.textContent.includes('.episode-item:hover')) { document.head.removeChild(style); } document.body.removeChild(modal); } } // 创建侧边栏选集面板(替代原来的模态框) function createEpisodeSidebar(id, title, episodesBySeasons, currentItemId) { // 防止创建重复的侧边栏 if (document.getElementById(id)) { return document.getElementById(id); } const sidebar = document.createElement('div'); sidebar.id = id; sidebar.className = 'episodeSidebar'; sidebar.style.cssText = ` position: fixed; top: 0; right: 0; width: 400px; max-width: 90%; height: 100vh; background: rgba(18, 18, 20, 0.95); backdrop-filter: blur(15px); z-index: 1000000; display: flex; flex-direction: column; box-shadow: -5px 0 25px rgba(0, 0, 0, 0.5); transform: translateX(100%); transition: transform 0.3s ease-in-out; overflow: hidden; `; // 创建头部 const header = document.createElement('div'); header.style.cssText = ` padding: 16px; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid rgba(255, 255, 255, 0.1); min-height: 60px; `; const titleEl = document.createElement('h2'); titleEl.textContent = title; titleEl.style.cssText = ` color: #fff; margin: 0; font-size: 20px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; `; const closeButton = document.createElement('button'); closeButton.innerHTML = '✕'; closeButton.style.cssText = ` background: none; border: none; color: #fff; font-size: 20px; cursor: pointer; padding: 5px; border-radius: 4px; `; closeButton.onclick = () => closeEpisodeSidebar(id); header.appendChild(titleEl); header.appendChild(closeButton); sidebar.appendChild(header); // 创建季节选择栏 const seasonContainer = document.createElement('div'); seasonContainer.className = 'season-selector'; seasonContainer.style.cssText = ` display: flex; overflow-x: auto; padding: 12px 16px; background: rgba(0, 0, 0, 0.2); border-bottom: 1px solid rgba(255, 255, 255, 0.05); scrollbar-width: thin; scrollbar-color: rgba(169, 91, 194, 0.5) rgba(0, 0, 0, 0.1); `; // 获取季节列表 const seasons = Object.keys(episodesBySeasons).sort((a, b) => Number(a) - Number(b)); // 确定当前剧集所在的季 let currentSeason = null; for (const season in episodesBySeasons) { if (episodesBySeasons[season].some(episode => episode.Id === currentItemId)) { currentSeason = season; break; } } // 如果找不到当前季,默认使用第一季 const activeSeason = currentSeason || seasons[0]; console.log('[选集插件] 当前季:', activeSeason); // 创建季节按钮 seasons.forEach(season => { const seasonButton = document.createElement('button'); seasonButton.textContent = `第${season}季`; seasonButton.dataset.season = season; seasonButton.className = 'season-button'; seasonButton.style.cssText = ` padding: 8px 16px; margin-right: 8px; border: none; border-radius: 6px; background: ${season === activeSeason ? 'linear-gradient(135deg, #a95bc2, #00a4db)' : 'rgba(255, 255, 255, 0.1)'}; color: white; font-weight: ${season === activeSeason ? 'bold' : 'normal'}; cursor: pointer; white-space: nowrap; flex-shrink: 0; transition: all 0.2s ease; `; seasonButton.onclick = function() { // 更新所有按钮样式 document.querySelectorAll('.season-button').forEach(btn => { btn.style.background = 'rgba(255, 255, 255, 0.1)'; btn.style.fontWeight = 'normal'; }); // 设置当前按钮样式 this.style.background = 'linear-gradient(135deg, #a95bc2, #00a4db)'; this.style.fontWeight = 'bold'; // 显示对应季的剧集 showSeasonEpisodes(this.dataset.season); }; seasonContainer.appendChild(seasonButton); }); sidebar.appendChild(seasonContainer); // 创建剧集列表容器 const episodesContainer = document.createElement('div'); episodesContainer.className = 'episodes-container'; episodesContainer.style.cssText = ` flex: 1; overflow-y: auto; padding: 16px; `; sidebar.appendChild(episodesContainer); // 函数:显示指定季的剧集 function showSeasonEpisodes(season) { const episodes = episodesBySeasons[season] || []; episodesContainer.innerHTML = ''; episodes.forEach(episode => { const isCurrentEpisode = episode.Id === currentItemId; const episodeItem = document.createElement('div'); episodeItem.className = `episode-item ${isCurrentEpisode ? 'current-episode' : ''}`; episodeItem.dataset.episodeId = episode.Id; episodeItem.style.cssText = ` padding: 12px; margin-bottom: 10px; border-radius: 6px; background: ${isCurrentEpisode ? 'linear-gradient(135deg, rgba(169, 91, 194, 0.3), rgba(0, 164, 219, 0.3))' : 'rgba(255, 255, 255, 0.05)'}; border: 1px solid ${isCurrentEpisode ? 'rgba(169, 91, 194, 0.6)' : 'rgba(255, 255, 255, 0.1)'}; cursor: pointer; transition: all 0.2s ease; position: relative; `; // 集数和标题 const titleElement = document.createElement('div'); titleElement.style.cssText = ` display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; `; const episodeNumber = document.createElement('span'); episodeNumber.style.cssText = ` color: #fff; font-weight: 600; font-size: 15px; `; episodeNumber.textContent = episode.IndexNumber ? `第${episode.IndexNumber}集` : '特别篇'; const episodeTitle = document.createElement('span'); episodeTitle.style.cssText = ` color: rgba(255, 255, 255, 0.85); font-size: 15px; margin-left: 8px; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; `; episodeTitle.textContent = episode.Name || '未命名'; const duration = document.createElement('span'); duration.style.cssText = ` color: rgba(255, 255, 255, 0.5); font-size: 13px; `; duration.textContent = episode.RunTimeTicks ? formatRuntime(episode.RunTimeTicks) : ''; titleElement.appendChild(episodeNumber); titleElement.appendChild(episodeTitle); titleElement.appendChild(duration); episodeItem.appendChild(titleElement); // 剧集简介 if (episode.Overview) { const overview = document.createElement('div'); overview.style.cssText = ` color: rgba(255, 255, 255, 0.6); font-size: 13px; margin-top: 6px; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; line-height: 1.4; `; overview.textContent = episode.Overview; episodeItem.appendChild(overview); } // 当前播放指示器 if (isCurrentEpisode) { const indicator = document.createElement('div'); indicator.style.cssText = ` position: absolute; right: 12px; top: 12px; width: 0; height: 0; border-left: 8px solid #a95bc2; border-top: 6px solid transparent; border-bottom: 6px solid transparent; filter: drop-shadow(1px 1px 2px rgba(169, 91, 194, 0.5)); `; episodeItem.appendChild(indicator); } // 点击事件 episodeItem.onclick = function() { const episodeId = this.dataset.episodeId; if (episodeId && episodeId !== currentItemId) { // 关闭侧边栏并播放 closeEpisodeSidebar(id); playEpisode(episodeId); } }; episodesContainer.appendChild(episodeItem); }); // 自动滚动到当前剧集 setTimeout(() => { const currentEpisodeElement = episodesContainer.querySelector('.current-episode'); if (currentEpisodeElement) { currentEpisodeElement.scrollIntoView({ behavior: 'smooth', block: 'center' }); } }, 100); } // 初始化显示当前季的剧集(而非第一季) showSeasonEpisodes(activeSeason); // 将当前季节的按钮滚动到可见位置 setTimeout(() => { const activeButton = seasonContainer.querySelector(`[data-season="${activeSeason}"]`); if (activeButton) { activeButton.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' }); } }, 100); document.body.appendChild(sidebar); // 添加样式 const style = document.createElement('style'); style.textContent = ` @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } .episodeSidebar .episodes-container::-webkit-scrollbar { width: 5px; } .episodeSidebar .episodes-container::-webkit-scrollbar-track { background: rgba(0, 0, 0, 0.1); } .episodeSidebar .episodes-container::-webkit-scrollbar-thumb { background: rgba(169, 91, 194, 0.5); border-radius: 5px; } .episodeSidebar .season-selector::-webkit-scrollbar { height: 5px; } .episodeSidebar .season-selector::-webkit-scrollbar-track { background: rgba(0, 0, 0, 0.1); } .episodeSidebar .season-selector::-webkit-scrollbar-thumb { background: rgba(169, 91, 194, 0.5); border-radius: 5px; } .episode-item:hover { background: linear-gradient(135deg, rgba(169, 91, 194, 0.2), rgba(0, 164, 219, 0.2)) !important; transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); } `; document.head.appendChild(style); // ESC键关闭 const handleEscape = (e) => { if (e.key === 'Escape') { closeEpisodeSidebar(id); } }; document.addEventListener('keydown', handleEscape); sidebar._handleEscape = handleEscape; // 添加点击外部关闭功能 const handleOutsideClick = (e) => { // 检查点击是否在侧边栏外部 if (sidebar && !sidebar.contains(e.target)) { closeEpisodeSidebar(id); } }; // 延迟添加点击事件,避免创建侧边栏时的点击立即关闭它 setTimeout(() => { document.addEventListener('click', handleOutsideClick); sidebar._handleOutsideClick = handleOutsideClick; }, 300); // 延迟显示侧边栏(添加动画效果) setTimeout(() => { sidebar.style.transform = 'translateX(0)'; }, 50); return sidebar; } // 关闭侧边栏 function closeEpisodeSidebar(id) { const sidebar = document.getElementById(id); if (!sidebar) return; // 添加关闭动画 sidebar.style.transform = 'translateX(100%)'; // 移除事件监听器 if (sidebar._handleEscape) { document.removeEventListener('keydown', sidebar._handleEscape); } // 移除点击外部关闭的事件监听器 if (sidebar._handleOutsideClick) { document.removeEventListener('click', sidebar._handleOutsideClick); } // 等待动画完成后移除 setTimeout(() => { const style = document.head.querySelector('style:last-of-type'); if (style && style.textContent.includes('.episode-item:hover')) { document.head.removeChild(style); } sidebar.parentNode?.removeChild(sidebar); }, 300); } // 显示选集侧边栏(替代原来的模态框) async function showEpisodeModal() { // 检查是否已存在侧边栏 if (document.getElementById('episodeModal')) { return; } console.log('[选集插件] 显示选集侧边栏'); // 获取当前项目信息 const currentItem = await getCurrentItemInfo(); if (!currentItem) { alert('无法获取当前播放信息'); return; } // 确定系列ID let seriesId = currentItem.SeriesId; if (!seriesId) { alert('当前项目不是剧集,无法显示选集列表'); return; } // 获取剧集列表 const episodes = await getSeriesEpisodes(seriesId); if (episodes.length === 0) { alert('未找到相关剧集'); return; } // 按季分组剧集 const episodesBySeasons = {}; episodes.forEach(episode => { const seasonNumber = episode.ParentIndexNumber || 1; if (!episodesBySeasons[seasonNumber]) { episodesBySeasons[seasonNumber] = []; } episodesBySeasons[seasonNumber].push(episode); }); // 创建侧边栏 createEpisodeSidebar( 'episodeModal', `选择剧集 - ${currentItem.SeriesName || currentItem.Name}`, episodesBySeasons, currentItemId ); } // 格式化运行时间 function formatRuntime(ticks) { const minutes = Math.floor(ticks / 600000000); const hours = Math.floor(minutes / 60); const remainingMinutes = minutes % 60; if (hours > 0) { return `${hours}:${remainingMinutes.toString().padStart(2, '0')}`; } else { return `${minutes}分钟`; } } // 初始化UI async function initUI() { // 页面未加载 let uiAnchor = document.getElementsByClassName('pause'); if (!uiAnchor || !uiAnchor[0]) { return; } // 检查是否已经存在选集按钮或容器,避免重复添加 if (document.getElementById('episodeSelector') || document.getElementById('episodeSelectorCtr')) { console.log('[选集插件] 选集按钮已存在,跳过初始化'); return; } console.log('[选集插件] 初始化UI'); // 先清理可能存在的旧元素 const existingButtons = document.querySelectorAll('#episodeSelector'); const existingContainers = document.querySelectorAll('#episodeSelectorCtr'); existingButtons.forEach(btn => btn.remove()); existingContainers.forEach(container => container.remove()); // 检查当前项目ID是否变化,避免重复请求 if (currentItemId !== lastCheckedItemId) { // 记录当前检查的项目ID lastCheckedItemId = currentItemId; // 重置类型缓存 isEpisodeType = null; // 获取当前项目信息并检查类型 const currentItem = await getCurrentItemInfo(); if (!currentItem) { console.log('[选集插件] 无法获取当前项目信息,跳过初始化'); return; } // 缓存当前内容类型 isEpisodeType = (currentItem.Type === 'Episode'); console.log('[选集插件] 内容类型检查结果:', isEpisodeType ? '剧集' : '非剧集'); } // 使用缓存的类型结果,避免重复检查 if (!isEpisodeType) { console.log('[选集插件] 当前项目不是剧集类型,不显示选集按钮'); return; } // 弹幕按钮容器div let uiEle = null; document.querySelectorAll(config.uiQueryStr).forEach(function (element) { if (element.offsetParent != null) { uiEle = element; } }); if (uiEle == null) { return; } // 再次检查是否已经存在选集按钮或容器,防止异步过程中被添加 if (document.getElementById('episodeSelector') || document.getElementById('episodeSelectorCtr')) { console.log('[选集插件] 选集按钮已在异步过程中创建,跳过初始化'); return; } let parent = uiEle.parentNode; console.log('[选集插件] 找到UI锚点:', uiEle, parent); let menubar = document.createElement('div'); menubar.id = 'episodeSelectorCtr'; parent.insertBefore(menubar, uiEle.previousSibling); // 选集按钮 menubar.appendChild(createEpisodeButton()); isInitialized = true; console.log('[选集插件] UI初始化完成'); } // 检查是否在播放页面 function isPlaybackPage() { return document.querySelector(config.mediaQueryStr) && document.querySelector(config.mediaContainerQueryStr); } // 防抖函数,避免短时间内多次执行 function debounce(func, wait) { let timeout; return function(...args) { clearTimeout(timeout); timeout = setTimeout(() => func.apply(this, args), wait); }; } // 主循环,等待页面加载完成后初始化 function checkAndInit() { if (isPlaybackPage()) { // 清理冗余按钮,确保不会有多个按钮 const containers = document.querySelectorAll('#episodeSelectorCtr'); if (containers.length > 1) { console.log(`[选集插件] 发现多个选集按钮容器(${containers.length}),清理冗余`); // 保留第一个,删除其他的 for (let i = 1; i < containers.length; i++) { containers[i].remove(); } } initUI(); } else { // 重置初始化状态,因为可能切换到了非播放页面 isInitialized = false; // 清理已创建的UI元素 const existingContainers = document.querySelectorAll('#episodeSelectorCtr'); existingContainers.forEach(container => { container.remove(); }); } } // 使用防抖版本的checkAndInit const debouncedCheckAndInit = debounce(checkAndInit, 150); // 等待页面加载完成后初始化 const waitForElement = (selector) => { return new Promise((resolve) => { const observer = new MutationObserver(() => { const element = document.querySelector(selector); if (element) { observer.disconnect(); resolve(element); } }); observer.observe(document.body, { childList: true, subtree: true }); }); }; // 主程序启动 waitForElement('.htmlvideoplayer').then(async () => { await waitForApiClient(); // 等待获取itemId if (isNewJellyfin) { let retry = 0; while (!currentItemId && retry < config.maxRetries) { await new Promise((resolve) => setTimeout(resolve, 200)); retry++; } } // 立即尝试初始化一次 setTimeout(() => { checkAndInit(); // 使用直接版本,第一次初始化 }, 1000); // 定期检查 setInterval(debouncedCheckAndInit, config.checkInterval); // 监听页面变化 const observer = new MutationObserver((mutations) => { let shouldCheck = false; mutations.forEach((mutation) => { if (mutation.type === 'childList') { mutation.addedNodes.forEach((node) => { if (node.nodeType === 1) { // 检测到视频播放器或OSD变化 if (node.querySelector && (node.querySelector(config.mediaQueryStr) || node.querySelector(config.mediaContainerQueryStr) || node.querySelector(config.uiQueryStr))) { shouldCheck = true; } } }); } }); if (shouldCheck) { setTimeout(debouncedCheckAndInit, 100); // 使用防抖版本 } }); // 开始观察 observer.observe(document.body, { childList: true, subtree: true }); console.log('[选集插件] 插件已加载'); }); })();