// ==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('[选集插件] 插件已加载');
});
})();