// ==UserScript== // @name Jellyfin播放按钮优化 // @version 0.6.0 // @description 劫持电影库页面原有的"随机播放"按钮:改为一次 API 请求随机取一部电影并自动播放,不重扫库;新增隐藏播放全部按钮功能;模式可在脚本猫面板菜单中切换。 // @author ShuiYun // @match *://*/web/index.html* // @run-at document-idle // @grant GM_registerMenuCommand // @grant GM_unregisterMenuCommand // @license AGPL-3.0 // ==/UserScript== (function () { 'use strict'; // 顶层防御:任何未预期错误都打印出来,便于定位;局部错误不影响其他功能 try { // 沙箱中访问页面全局:ScriptCat 半沙箱,unsafeWindow 不可用时退回 window const pageWin = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window; console.log('[JFRP] 脚本已加载 URL:', pageWin.location.href, 'hash:', pageWin.location.hash); // ========== 配置 ========== // 只在目标页面(hash 路由)劫持原按钮;第一版只支持电影页,#/tv 后续扩展 const TARGET_HASHES = ['#/movies', '#!/movies']; // 详情页路由:10.9+ 与 modern UI 用 /details,10.8 旧版是 /itemdetails.html const DETAILS_ROUTE = '/details?id='; // 原有"随机播放"按钮的选择器:legacy 库页/详情页为 .btnShuffle // (10.11 库页是 btnPlayAll/btnShuffle 兄弟;10.8 老版库页是 btnPlay/btnShuffle)。 // 现代 UI(10.9+/master)库页的 MUI ShuffleButton 无 class,仅 title,见下方按钮级判断。 const SHUFFLE_SELECTORS = ['.btnShuffle', 'button[data-testid="shuffle"]', '.shuffleButton']; // 库页"播放全部"按钮:10.11+ 为 .btnPlayAll;10.8 老版库页为 .btnPlay, // 但 .btnPlay 可能与其他页面(详情页)冲突,故只限 .itemsViewSettingsContainer 内。 // 注意:不能使用"随机按钮左侧兄弟"这类选择器来限制——播放全部确实在随机左侧, // 形如 ".btnPlayAll + .btnShuffle" 的规则会命中随机按钮本身,把随机也藏掉。 const HIDE_PLAYALL_KEY = 'jfrp-hide-playall'; // '1'=隐藏(默认),'0'=显示 const PLAYALL_CSS = '.btnPlayAll, .itemsViewSettingsContainer .btnPlay'; // ========== 模式(脚本猫面板菜单中切换,localStorage 记忆) ========== // 'play' 随机后自动播放;'details' 随机后只打开详情页 const MODE_KEY = 'jfrp-random-mode'; let currentMode = 'play'; function loadMode() { try { const v = pageWin.localStorage.getItem(MODE_KEY); if (v === 'play' || v === 'details') currentMode = v; } catch (e) { console.warn('[JFRP] 读取模式失败(保持默认):', e); } } function saveMode(mode) { currentMode = mode === 'details' ? 'details' : 'play'; try { pageWin.localStorage.setItem(MODE_KEY, currentMode); } catch (e) { console.warn('[JFRP] 保存模式失败:', e); } } // ---- 隐藏"播放全部"按钮(默认开,菜单开关,localStorage 记忆) ---- let hidePlayAll = true; // 默认删除按钮 const STYLE_ID = 'jfrp-hide-playall-style'; function loadHidePlayAll() { try { const v = pageWin.localStorage.getItem(HIDE_PLAYALL_KEY); if (v === '0') hidePlayAll = false; else if (v === '1') hidePlayAll = true; } catch (e) { console.warn('[JFRP] 读取隐藏开关失败(保持默认):', e); } } function saveHidePlayAll(v) { hidePlayAll = !!v; try { pageWin.localStorage.setItem(HIDE_PLAYALL_KEY, hidePlayAll ? '1' : '0'); } catch (e) { console.warn('[JFRP] 保存隐藏开关失败:', e); } } // 构建隐藏样式文本:直接按 class 隐藏播放全部。 // 10.11+ 库页是 .btnPlayAll;10.8 老版库页是 .btnPlay(限 itemsViewSettingsContainer)。 function buildHidePlayAllCss() { return PLAYALL_CSS + ' { display: none !important; }'; } // 注入/移除隐藏样式(幂等)。legacy 库页是客户端路由,style 常驻 head, // 切换媒体库时持续生效,无需 MutationObserver。 function applyHidePlayAll() { const el = pageWin.document.getElementById(STYLE_ID); if (hidePlayAll) { if (!el) { const s = pageWin.document.createElement('style'); s.id = STYLE_ID; s.textContent = buildHidePlayAllCss(); pageWin.document.head.appendChild(s); console.log('[JFRP] 已注入隐藏"播放全部"样式:', s.textContent); } } else if (el) { el.remove(); } } // ========== 工具 ========== // 等待任一选择器匹配的元素出现且可见(非 .hide),用于详情页播放按钮 // legacy 页面按钮:.mainDetailButtons [data-action="play"] // React 版页面按钮:.btnPlayOrResume(不同版本结构不同,全部探测) function waitForAny(selectors, timeoutMs) { return new Promise((resolve, reject) => { const start = Date.now(); const timer = setInterval(() => { for (const sel of selectors) { const el = document.querySelector(sel); if (el && !el.classList.contains('hide')) { clearInterval(timer); resolve(el); } } if (Date.now() - start > (timeoutMs || 10000)) { clearInterval(timer); reject(new Error('等待超时: ' + selectors.join(', '))); } }, 200); }); } // 从当前 URL hash 解析媒体库 ID(兼容 legacy 的 parentId 与 modern 的 topParentId) function getParentIdFromUrl() { const hash = pageWin.location.hash || ''; const qIndex = hash.indexOf('?'); if (qIndex < 0) return null; const params = new URLSearchParams(hash.slice(qIndex + 1)); return params.get('parentId') || params.get('topParentId'); } function isTargetPage() { return TARGET_HASHES.some((h) => (pageWin.location.hash || '').startsWith(h)); } // ========== 核心:随机取一部电影并跳转详情页 ========== // autoplay=true 自动点击播放(模式一);false 只打开详情页(模式二) async function randomMovie(autoplay) { const apiClient = pageWin.ApiClient; if (!apiClient) { alert('未找到 Jellyfin ApiClient,请确认已登录后重试'); return; } const userId = apiClient.getCurrentUserId(); // 与电影库页面同一组过滤参数 + SortBy=Random + Limit=1: // 服务端 ORDER BY RANDOM() 一次只回一部,毫秒级,不重扫库 const query = { SortBy: 'Random', SortOrder: 'Ascending', IncludeItemTypes: 'Movie', Recursive: true, Filters: 'IsNotFolder', Limit: 1, StartIndex: 0, }; const parentId = getParentIdFromUrl(); if (parentId) query.ParentId = parentId; try { const result = await apiClient.getItems(userId, query); const item = result.Items && result.Items[0]; if (!item) { alert('库中没有可播放的电影'); return; } console.log('[JFRP] 随机选中:', item.Name, item.Id, '模式:', autoplay ? '播放' : '详情'); // 导航到详情页 pageWin.location.hash = DETAILS_ROUTE + encodeURIComponent(item.Id); // 模式一:等待播放按钮可见后自动点击(优先从头播放,其次继续播放) if (autoplay) { const playBtn = await waitForAny([ '.mainDetailButtons [data-action="play"]', '.btnPlayOrResume[data-action="play"]', '.mainDetailButtons [data-action="resume"]', '.btnPlayOrResume[data-action="resume"]', ]); playBtn.click(); } } catch (e) { console.error('[JFRP] 随机播放失败:', e); // ---- 失败诊断,用于定位详情页结构 ---- console.log('[JFRP] 诊断 hash:', pageWin.location.hash); console.log('[JFRP] 诊断 .mainDetailButtons 数量:', document.querySelectorAll('.mainDetailButtons').length); console.log('[JFRP] 诊断 .btnPlayOrResume 数量:', document.querySelectorAll('.btnPlayOrResume').length); console.log('[JFRP] 诊断 播放按钮:', [...document.querySelectorAll('[data-action]')] .map((el) => el.tagName + '.' + el.className + ' action=' + el.dataset.action + ' hide=' + el.classList.contains('hide')) .join(' | ') || '(无 data-action 元素)'); alert('随机播放失败: ' + (e && e.message ? e.message : e)); } } // ========== 劫持原有"随机播放"按钮 ========== // 原实现把捕获监听器逐个绑在按钮元素上,靠 MutationObserver 在按钮重建后重绑; // 但 React 重建按钮与重绑之间存在时间差,窗口内点击会落到 Jellyfin 原逻辑 // (表现为"有时候失效"),且 querySelector 只劫持第一个按钮,其余穿透。 // 改为 document 捕获阶段委托拦截:任何点击都先经过 document 捕获层, // 此时 React 根容器/按钮上的处理器尚未触发,命中随机按钮即彻底掐断原逻辑, // 与按钮如何重建、存在几个都无关,消除竞态。closest() 同时覆盖 // 按钮内部的 svg/span 图标与页面上所有同款按钮。 const SHUFFLE_SELECTOR = SHUFFLE_SELECTORS.join(', '); // 判定元素是否"随机播放"按钮本身(legacy 或现代 UI)。 // 只判断元素自身,不做祖先扫描——祖先(如库页按钮行、页面根)含 shuffle 图标 // 会把任意点击误判为随机按钮,导致"点哪里都触发随机播放"。 function isShuffleButtonEl(el) { if (!(el instanceof Element)) return false; // legacy/旧 class 选择器命中 if (el.closest(SHUFFLE_SELECTOR)) return true; // 现代 UI:MUI ShuffleButton 无 class,title="Shuffle"(英文默认)。 // 只匹配按钮元素本身的 title,不查后代。 if (el.tagName === 'BUTTON') { const title = (el.getAttribute && el.getAttribute('title')) || ''; if (title === 'Shuffle' || title === '随机播放') return true; } return false; } function onShuffleClick(e) { if (!isTargetPage()) return; // 非电影库页面:保持 Jellyfin 原功能 const target = e.target instanceof Element ? e.target : null; if (!target) return; const shuffleEl = target.closest(SHUFFLE_SELECTOR); if (!shuffleEl && !isShuffleButtonEl(target)) return; e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); console.log('[JFRP] 拦截随机播放点击,执行快速随机'); randomMovie(currentMode === 'play'); } document.addEventListener('click', onShuffleClick, true); // ========== 启动 ========== loadMode(); loadHidePlayAll(); applyHidePlayAll(); // 根据开关注入/移除隐藏样式 // ========== ScriptCat 面板菜单:一条命令,点击切换模式(设置面板) ========== let menuId = null; // 菜单文字随当前模式变化:注销旧命令,注册新命令 function registerMenuCommand() { if (typeof GM_registerMenuCommand === 'undefined') { console.warn('[JFRP] 当前环境不支持 GM_registerMenuCommand,模式切换不可用'); return; } if (menuId != null && typeof GM_unregisterMenuCommand !== 'undefined') { try { GM_unregisterMenuCommand(menuId); } catch (e) { /* 忽略 */ } } const isPlay = currentMode === 'play'; menuId = GM_registerMenuCommand( isPlay ? '随机模式:播放(点击切换为详情)' : '随机模式:详情(点击切换为播放)', () => toggleMode() ); console.log('[JFRP] 菜单命令已注册,当前模式:', currentMode); } // 点击菜单命令 = 切换模式 function toggleMode() { saveMode(currentMode === 'play' ? 'details' : 'play'); alert('已切换模式: ' + (currentMode === 'play' ? '播放(自动播放)' : '详情(只打开详情页)')); registerMenuCommand(); // 更新菜单文字 } // 隐藏"播放全部"按钮开关(独立于随机模式命令) let hideMenuId = null; function registerHideMenuCommand() { if (typeof GM_registerMenuCommand === 'undefined') { console.warn('[JFRP] 当前环境不支持 GM_registerMenuCommand,隐藏开关不可用'); return; } if (hideMenuId != null && typeof GM_unregisterMenuCommand !== 'undefined') { try { GM_unregisterMenuCommand(hideMenuId); } catch (e) { /* 忽略 */ } } hideMenuId = GM_registerMenuCommand( hidePlayAll ? '隐藏播放全部:开(点击关闭)' : '隐藏播放全部:关(点击开启)', () => toggleHidePlayAll() ); } // 点击菜单命令 = 切换隐藏状态,立即应用/恢复按钮 function toggleHidePlayAll() { saveHidePlayAll(!hidePlayAll); applyHidePlayAll(); alert(hidePlayAll ? '已隐藏"播放全部"按钮' : '已恢复"播放全部"按钮'); registerHideMenuCommand(); // 更新菜单文字 } try { registerMenuCommand(); } catch (e) { console.error('[JFRP] 菜单注册失败:', e); } try { registerHideMenuCommand(); } catch (e) { console.error('[JFRP] 隐藏开关菜单注册失败:', e); } } catch (e) { console.error('[JFRP] 脚本执行出错:', e); } })();