将 JAVDB 传统前端完全重构为 Emby 风格的现代化样式结构并且提供更强大的数据收藏统计管理的功能脚本。支持全局皮肤开关与原生页面兼容共存,Emby+毛玻璃+液态玻璃三种视觉风格。番号详情页播放支持 JAVDB 官方影片播放与外部多源站点选择与检测。同时对用户在 JAVDB 清单中的作品建立本地数据库机制形成收藏夹,用于解决 JAVDB 没有更进一步的收藏统计管理等功能。
// ==UserScript==
// @name JavdbEmbySkin
// @namespace com.local.javdbemby
// @version 7.331
// @connect jdforrepam.com
// @connect c0.jdbstatic.com
// @connect jdbstatic.com
// @connect api.github.com
// @connect gitee.com
// @connect *
// @description 将 JAVDB 传统前端完全重构为 Emby 风格的现代化样式结构并且提供更强大的数据收藏统计管理的功能脚本。支持全局皮肤开关与原生页面兼容共存,Emby+毛玻璃+液态玻璃三种视觉风格。番号详情页播放支持 JAVDB 官方影片播放与外部多源站点选择与检测。同时对用户在 JAVDB 清单中的作品建立本地数据库机制形成收藏夹,用于解决 JAVDB 没有更进一步的收藏统计管理等功能。
// @author hy3 + deepseek-v4flash + gemini3.8flash
// @icon data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAIAAAD8GO2jAAAHeklEQVR42pWU+VMb9x2G+UOCPW7dTpp2pkk7iSHOOMSNJ0d9FDvGTlwyBSywkMxhc5hL6BY3Rpgj5haYy4RwCHMfkgAJxOpaaXe192olIcBNJ22TnzurlbCMyTSeeWfnO6PR87yfz+584w6C7PMgux/0hQLM7i9IyE/HZs/P7PvokNuzDzh3t4AAgQZYKujjQwZ8ZNzzIBsK+HZfn77nZ557sR+cyH8s4D/1pr3hebfyG8vAKEvjgTCaT1wowIRep3IYTe/7qB/c6E9O7Ptp417f9L5Ov6fTr9+pfNbwCHE7fSQWYAg+cbuvSQ+x9D5L/9uN/bgDHfTPHPTP7Ov0vMAkKB+XVzusZhKDWQr7P4Jj0NH637uQn1zYfph+0D+zF6aHeqeNgooxqWZn04gjboZE/TT+s4Jji/PPA4b60Yk+H1t6id437W//1pBRNlqp2jKtopCLxr0shfkpPO6XFN9lYwROz3+3PPs6Pb+cCP3xOFmvM6RzAoth2etxUjjiIzGWwiKC0M+h/XSQpYIstctSuz6KewEbwL8WLDw9pNPv9k37v/mWahiAJa2G9LLhcoV5bQlxOygsKjh+1yzXmkcHeIEvKlixHIwu7uv0uzp9sGeKbR8jG/phSYvrXh0nKFVsri7CoJ3EYIZAjxHwlQMxCfoidF6wt2Le5endk77WUaKm11Pa7MyvdebXGjLKR0oU6yvzMGjjBT7yZcER9BH6oSDQNRHsmmAejWDqLrC40ZFf48irduTXGAUVIyUK0/Ic5LKRaIwgdhuxiUUHGZIT+Ki9ZbOv7SmjHUaVHa6CekdetT2vio/hNjeBaXHW4wRIFIoIAq9wX9RnI9wgQwYYIsiQnGDJTD98gsjanfdqD9GHguESeayAIdCXBH6WZFkiNn6W5H7iLxaG4IZY2sQ13bF0W16VLVdjy9UYBZJYAU14OUH02iNYPuwxObxYuIGWNhBp2yEXCIc/GG9XDD+QGRefeZwA4YVoPCzg/8kyhC8c1kdgFLgOzU0Afb0bDWOWTiti4EaJCoKL65CkBYiiOXqOBshRA3fVRoFkqFhqnJ8JCzwRgT+K9jE4w2AmaHYRGtdtaBvmyvOHvsrQfSYaTG5ZkHsxkA3fLYE5g6e8mUPnqIEoGhCrALHKlFk5VCQ1zOvdjh0ciQpi6cvgxKJnvHDoH+KeFFHP9czOyzdbzl3Vnsno+FzWJ0YgB0th7PSyu7SJJx7GGo4pSzpUWBkWWHHEQ+FIRMAwGMNgO4hh0tGfo7sp7klJb7mY2vJxyqMPPq3/w7maUx9W/epKzbvNT+Qk4qFHZ90PHh6h74QTEczp3XYrjrgpLCxgGIymOUGPqV49dV/YfVXQcWnaMGBzrYsfp5yrOXVWc4KL+kRyWcKOeQ3VfXdEAITp2yKlSSgfLJDECmjCG0fTnICkkdLJdEHnxbT2z240n5s2Dvpp3Edhg/NtF+v+xAsuFJ3u7K53PR4Co4IdscoqUlpFyu1sxXa2wiRS9BWUGeb04KsCN279uv/8F9r3/9b43oWa300ZnlAYEp7Ru2leTFL8NlEZf77w16rqfGtdB/igEQijeS6fLaHClF/1+H6xYT4iIDGYxqMChHCk9CReqH0zqer0WdXJybUBCkMIL2RamStSZ3ACRfzl0j+W5KSty5vAkqZYdJgu38yUPRNVdqlVpqXZ8FcUK6AwmvTmPrnxgebk+8r4RGX8d6s60Lbd2qK6UpyYUH4iQRafII2/mvtnaX7WlqTJUVC3JVRY7sgtd+T8wZwl3xCpBoRFvc2Nm6uLHifACVCYwpE4js4J0L55bVL16cSwIFv7hVB2/cOy35ypfIOjy+L/In3zypfv1AhFROvwdraSp/MxZyk2BHLDvaoG4d2nfZ3WDQMM2iICDIljKIwfwgMBWW3JCTJOkKjgKnNnOVf/rORUsvC9W5fPj4hL2N6pWPpmpmIzU7EuUuoE95tkFfOTY/btTcTteCGITEBhFOE1mGZSVZ+cKTmRIOXQHF0a/+79Ny4L3rn2eULljVvs0wV7Yf1h8Qg9Sz4lkmju3tW1aTdWFkC71etx4l4PicIkBr8Q0CSKez1G01x5nfjj7LeTRG8lid9KuH7yk0u/v3Xpo4qbt8D2QeLR0BH6mqBSnyPVZAq1SunC5BhgWYdcNhR2EbwAjREwJEbhCAq5HFbz9PiQRlkozkjJzfiyIi2tN7uAGpwhW0fMWQpzVoRuzJA8Sy3UfZ2jFGTVVZRODfdvmVZBu5XfD/HqBAyFMQRKojAKuVz2bYtpZWV2ak3SAGn76e4J+4OHGwL5ZqZ8/bZ0MbV4JPlO15X06qt/l2ULG+UVk8P9FsOyC9iCQTsKu3Cvh/BCEQETRvtILgyB0oSXRGEMBiGXDbRbF9q7um7ntiend10T6FKE3dcELX9Nrbv0lTolVZKWriku6NbWz0+MbRlXnICFWw7k4utHBCgcx5AROi+IODAYRzwo5IJcto2Vhac9He1qRW1enjpHrC7MrykpalbJdG3ambGh9eU5wGICbdswaPN6nBgCcvXRyAtAYdf/AEaRr4AjFgVYAAAAAElFTkSuQmCC
// @homepageURL https://github.com/9E307/JavdbEmbySkin
// @match *://javdb.com/*
// @match *://*.javdb.com/*
// @match *://javdb*.com/*
// @match *://*.javdb*.com/*
// @match https://jdforrepam.com/*
// @match https://*.jdforrepam.com/*
// @include *://*javdb*.com/*
// @include *://*jdforrepam.com/*
// @grant GM_xmlhttpRequest
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_registerMenuCommand
// @grant GM_setClipboard
// @run-at document-idle
// @license MIT
// ==/UserScript==
(function () {
'use strict';
// 严格域名守卫:仅允许在 JAVDB 主站/镜像站及官方域名生效,防止在含有 javdb 关键词的外部网址(如 GitHub 项目/搜索结果页)误注入
if (!/(?:^|\.)(?:javdb[a-z0-9]*|jdforrepam)\.com$/i.test(location.hostname)) {
return;
}
// 鉴权路由严格避让守卫:在登录、注册、找回密码等关键认证页面上,坚决不进行任何 DOM 重构或头信息篡改,
// 100% 保持原生 Rails Form POST 导航、CSRF Token 与同源 Referer 标头完整,杜绝登录重定向死循环。
if (/^\/(login|user_sessions|users\/(?:sign_in|sign_up|new|password))/i.test(location.pathname)) {
try {
const m = document.querySelector('meta[name="referrer"][content="no-referrer"]');
if (m && m.parentNode) m.parentNode.removeChild(m);
} catch (e) {}
return;
}
/* =======================================================================
* 元素级 Referrer 免疫守卫:根治 c0.jdbstatic.com 等图床 403 Forbidden 防盗链阻断
* 严禁篡改 document-level <meta name="referrer">,杜绝破坏 Rails 表单 POST、CSRF 与会话校验;
* 仅针对常规图片元素 (img) 施加 element-level referrerpolicy="no-referrer",并显式排除验证码图片。
* ===================================================================== */
function ensureImageNoReferrer() {
try {
// 自愈清理:若页面存在之前注入或残留的 no-referrer meta 标签,就地拔除以恢复同源 Referer
const meta = document.querySelector('meta[name="referrer"][content="no-referrer"]');
if (meta && meta.parentNode) meta.parentNode.removeChild(meta);
// 确保页面所有图片 referrerpolicy 均为 no-referrer,杜绝防盗链 403 阻断(排除图形验证码)
const imgs = document.images || document.querySelectorAll('img');
for (let i = 0; i < imgs.length; i++) {
const img = imgs[i];
if (img.classList && (img.classList.contains('rucaptcha-image') || img.closest('.rucaptcha-image, #rucaptcha'))) {
continue;
}
if (img.getAttribute('referrerpolicy') !== 'no-referrer') {
img.setAttribute('referrerpolicy', 'no-referrer');
img.referrerPolicy = 'no-referrer';
}
}
} catch (e) {}
}
ensureImageNoReferrer();
var VERSION = (typeof GM_info !== 'undefined' && GM_info.script && GM_info.script.version) ? GM_info.script.version : '7.331';
var tabHome = null, tabFav = null, favPanel = null;
var tabGallery = null, galleryPanel = null;
var tabTop250 = null, top250Panel = null;
var scoreMemCache = new Map(); // 全局共享评分缓存,供通用评分提取与各榜单/详情联动
function log(m) { try { console.log('[JAVDB→Emby] ' + m); } catch (e) {} }
log('脚本已注入页面');
window.__javdbEmbyVersion = VERSION;
/* =======================================================================
* 全局基础工具函数(HTML转义与安全实体属性解析)
* ===================================================================== */
function escapeHtml(s) {
return String(s == null ? '' : s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')
.replace(/"/g,'"').replace(/'/g,''');
}
function escapeAttr(s) { return escapeHtml(s); }
function safeHref(url, fallback) {
if (!url || typeof url !== 'string') return fallback || '#';
const clean = url.trim();
// 严格防御:杜绝任何 javascript:、vbscript:、data:text/html 等伪协议代码执行
if (/^\s*(?:javascript|vbscript|data):/i.test(clean)) return fallback || '#';
if (/^(?:https?:\/\/|\/|#|magnet:)/i.test(clean)) return clean;
return fallback || '#';
}
function toAbsUrl(u, base) {
if (!u || typeof u !== 'string') return '';
const clean = u.trim();
try { return new URL(clean, base || location.href).href; }
catch (e) { return clean.startsWith('http') ? clean : (location.origin + (clean.startsWith('/') ? '' : '/') + clean); }
}
function getBackupFileName() {
const d = new Date();
const pad = function (n) { return String(n).padStart(2, '0'); };
return 'JavdbEmbySkin_fav_backup_' + d.getFullYear() + pad(d.getMonth() + 1) + pad(d.getDate()) + '_' +
pad(d.getHours()) + pad(d.getMinutes()) + pad(d.getSeconds()) + '.json';
}
function isJavdbUserLoggedIn() {
try {
const nav = document.querySelector('nav.navbar.is-fixed-top.main-nav, .navbar');
if (nav) {
const hasLogout = !!nav.querySelector('a[href*="/logout"], a[href*="/sign_out"]');
const hasUserMenu = !!nav.querySelector('a[href*="/users/profile"], a[href*="/users/lists"], a[href*="/users/collection_actors"]');
const hasLogoutText = /登出|logout|sign\.out/i.test(nav.textContent || '');
if (hasLogout || hasUserMenu || hasLogoutText) return true;
const hasLoginLink = !!nav.querySelector('a[href*="/login"], a[href*="/sign_in"], a[href*="/users/sign_in"]');
if (hasLoginLink) return false;
}
if (document.cookie && /(?:^|;\s*)(?:remember_user_token|_javdb_session|_jdb_session)\s*=/i.test(document.cookie)) return true;
if (document.getElementById('save-list-button')) return true;
} catch (e) {}
return false;
}
function isJavdbVipUser() {
try {
if (!isJavdbUserLoggedIn()) return false;
const nav = document.querySelector('nav.navbar.is-fixed-top.main-nav, .navbar');
if (nav) {
// 仅在用户账号个人信息菜单区域检测真实的 VIP 徽章,排除顶栏针对非VIP展示的「购买/升级VIP」推广链接
const userMenu = nav.querySelector('.navbar-end, .user-menu, .navbar-item.has-dropdown');
if (userMenu) {
const vipBadge = userMenu.querySelector('.vip, .badge-vip, .tag.is-warning, [class*="vip"]');
if (vipBadge && !vipBadge.closest('a[href*="/vip"], a[href*="/plans"]')) {
if (/vip/i.test(vipBadge.textContent || vipBadge.className || '')) return true;
}
}
}
if (localStorage.getItem('javdb_is_vip') === '1') return true;
} catch (e) {}
return false;
}
function getEntityText(val) {
if (!val) return '';
if (typeof val === 'string') return val.trim();
if (typeof val === 'object') {
if (val.text && typeof val.text === 'string') return val.text.trim();
if (val.name && typeof val.name === 'string') return val.name.trim();
if (val.title && typeof val.title === 'string') return val.title.trim();
}
return '';
}
function getActorName(a) {
if (!a) return '';
if (typeof a === 'string') return a.trim();
if (typeof a === 'object') {
if (a.name && typeof a.name === 'string') return a.name.trim();
if (a.text && typeof a.text === 'string') return a.text.trim();
}
return '';
}
function getMovieScore(m) {
if (!m) return 0;
if (typeof scoreMemCache !== 'undefined' && scoreMemCache && typeof scoreMemCache.has === 'function' && scoreMemCache.has(m.id)) {
const sc = scoreMemCache.get(m.id);
if (sc && typeof sc.score === 'number' && !isNaN(sc.score)) return sc.score;
}
if (m.rating && typeof m.rating === 'object') {
if (typeof m.rating.score === 'number' && !isNaN(m.rating.score)) return m.rating.score;
if (m.rating.text) {
const match = String(m.rating.text).match(/([\d\.]+)/);
if (match) {
const s = parseFloat(match[1]);
if (!isNaN(s)) return s;
}
}
}
if (m.score != null) {
const s = parseFloat(m.score);
if (!isNaN(s)) return s;
}
if (m.ratingValue != null) {
const s = parseFloat(m.ratingValue);
if (!isNaN(s)) return s;
}
return 0;
}
const STORAGE_KEY = 'javdbEmbyEnabled';
let enabled = (localStorage.getItem(STORAGE_KEY) !== '0');
let skinEl = null;
let detailBuilt = false;
const buildSettingsPanel = function () {
if (typeof buildFullSettingsDialog === 'function') buildFullSettingsDialog();
};
/* =======================================================================
* 规避 JAVDB 服务端 302 重定向至 /plans/ypay 的 VIP 拦截墙(仅非会员且开启绕过限制时生效)
* ===================================================================== */
if (location.pathname.startsWith('/plans')) {
if (isPluginEnabled('reviewListInfinite') && !isJavdbVipUser()) {
const isTop = location.href.includes('top') || document.referrer.includes('top');
const target = isTop
? '/advanced_search?handleTop=1&handleType=all&type_value=&page=1'
: '/advanced_search?handlePlayback=1&period=daily';
location.replace(target);
return;
}
}
function isDetailPage() { return /^\/v\//.test(location.pathname); }
// 从详情页 h2 标题提取实体名候选:演员/导演等详情页常把多个写法(中/日/别名)放在同行或分行,
// 采集入库时通常只保留当前一个名字;若不拆分直接当整体匹配会全员落空。
// 按空白/逗号/换行/中点拆分 token,过滤维度标签与「N 部影片」等噪声,返回候选名数组。
var ENTITY_NAME_NOISE = /部影片|影片|作品|部作品|出演|関連|相关|排行|人気|^\d+$/;
var ENTITY_DIM_LABELS = ['导演','片商','系列','演员','導演','製片商','演員','製片'];
function extractEntityNames(titleEl) {
if (!titleEl) return [];
var lines = (titleEl.textContent || '').split(/[\n\r]+/).map(function (s) { return s.trim(); }).filter(Boolean);
var names = [];
lines.forEach(function (line) {
if (ENTITY_DIM_LABELS.indexOf(line) !== -1) return; // 跳过维度标签行
if (ENTITY_NAME_NOISE.test(line)) return; // 跳过「3 部影片」等噪声行
line.split(/[\s,,、・]+/).forEach(function (tok) {
tok = tok.trim();
if (tok && !ENTITY_NAME_NOISE.test(tok) && names.indexOf(tok) === -1) names.push(tok);
});
});
return names;
}
// 整名版行级解析:与 extractEntityNames 同样跳过维度标签行(「導演/片商/系列」等)与噪声行,
// 但每行作为整名保留、不做行内符号拆分——用于导演/片商/系列:
// 名称含空格/顿号/逗号时整名匹配,不被误拆成多个筛选条件(按需求只对演员应用符号拆分)。
function extractEntityNamesWhole(titleEl) {
if (!titleEl) return [];
var lines = (titleEl.textContent || '').split(/[\n\r]+/).map(function (s) { return s.trim(); }).filter(Boolean);
var names = [];
lines.forEach(function (line) {
if (ENTITY_DIM_LABELS.indexOf(line) !== -1) return; // 跳过维度标签行(关键:页面 h2 首行是「導演」等标签)
if (ENTITY_NAME_NOISE.test(line)) return;
var t = line.replace(/[\s·]*[\((]\d+[\))]\s*$/, '').replace(/\s+/g, ' ').trim();
if (t && names.indexOf(t) === -1) names.push(t);
});
return names;
}
// 演员/导演/片商/系列/番号组 详情页:在「收藏/取消收藏」按钮右侧插入「去收藏夹筛选」按钮。
// 实际注入逻辑统一在 ensureDetailFilterBtn(观察器轮询也走它),此处仅作兼容入口。
function injectDetailFilterBtn() {
try { ensureDetailFilterBtn(); } catch (e) {}
}
// 片商页候选生成(三形态并集,作 OR 精确匹配):
// ① 整行名 —— “S1 NO.1 STYLE” 这类含空格的完整片名可整名命中;
// ② 逗号/顿号分段 —— “KMP, ケイ・エム・プロデュース” 按逗号拆出完整别名段;
// 段内空格与「・」保留:日文名内部的间隔号不是别名分隔符,绝不能再拆;
// ③ 传统符号拆分(空格/・也拆)兜底 —— 兼容空格分隔多别名的旧形态。
// 另剥离行首「片商/系列/導演」等标签前缀(标签与名字同行时不污染候选)。
function extractMakerNames(titleEl) {
var names = [];
function push(s) {
s = String(s || '').replace(/^(片商|系列|導演|导演|演員|演员|番號|番号)[::]?\s+/, '')
.replace(/[\s·]*[\((]\d+[\))]\s*$/, '').replace(/\s+/g, ' ').trim();
if (s && names.indexOf(s) === -1) names.push(s);
}
var lines = ((titleEl && titleEl.textContent) || '').split(/[\n\r]+/).map(function (s) { return s.trim(); }).filter(Boolean);
lines.forEach(function (line) {
if (ENTITY_DIM_LABELS.indexOf(line) !== -1) return;
if (ENTITY_NAME_NOISE.test(line)) return;
push(line); // ① 整行
line.split(/[,,、]+/).forEach(push); // ② 逗号/顿号分段(保留空格与・)
});
extractEntityNames(titleEl).forEach(push); // ③ 传统拆分兜底
return names;
}
// 实体备注键(单数命名空间,与 FAVUI 的 workHasNote/workNoteText 查询键严格一致):
// 演员页 /actors/{slug} → actor:{slug};片商 /makers/{slug} → maker:{slug};系列 /series/{slug} → series:{slug}
// (作品级备注 key = code:{番号},在详情页注入。历史版本曾误用复数 'actors:' 存键导致查询落空,已统一为单数)
const NOTE_KEY_BY_SEG = { actors: 'actor', makers: 'maker', series: 'series' };
function detailEntityKey(typeKey) {
const seg = location.pathname.split('/').filter(Boolean);
if (!seg[1]) return null;
const ns = NOTE_KEY_BY_SEG[typeKey] || typeKey;
return ns + ':' + decodeURIComponent(seg[1]);
}
// 备注按钮(演员/片商/系列页入口;作品详情页的备注按钮在番号行的复制按钮旁,另行注入):
// 点击弹出小窗编辑,内容存 IndexedDB(随导出携带,不存 localStorage)
function ensureDetailNoteBtn(parent, typeKey, name) {
const key = detailEntityKey(typeKey);
if (!key) return false;
const btn = document.createElement('a');
btn.id = 'emby-detail-note-btn';
btn.className = 'button';
btn.innerHTML = '<span class="material-symbols-outlined" style="font-size:1em;vertical-align:-2px;margin-right:.25em;">history_edu</span>备注';
btn.style.cssText += ';display:inline-flex;align-items:center;white-space:nowrap;cursor:pointer;text-decoration:none;';
btn.addEventListener('click', function (e) {
e.preventDefault();
openNoteModal(key, name || '');
});
parent.appendChild(btn);
return true;
}
// 备注编辑小窗(复用 efav-modal 弹窗样式;保存/删除后广播 emby-fav-changed 让收藏夹刷新备注索引)
function openNoteModal(key, name) {
let mask = document.getElementById('emby-note-mask');
if (mask) mask.remove();
mask = document.createElement('div');
mask.id = 'emby-note-mask';
mask.className = 'efav-modal-mask';
const box = document.createElement('div');
box.className = 'efav-modal';
const h3 = document.createElement('h3');
h3.innerHTML = '<span class="material-symbols-outlined">history_edu</span>备注' + (name ? ' · ' + escapeHtml(name) : '');
box.appendChild(h3);
const ta = document.createElement('textarea');
ta.placeholder = '填写任意内容(如:强推 / 待看 / 优先级…),保存后可在收藏夹「只看有备注」或按备注内容搜索';
ta.style.cssText = 'display:block;width:100%;min-height:9em;box-sizing:border-box;background:rgba(255,255,255,.06);' +
'color:var(--e-text,#eaeaea);border:1px solid rgba(255,255,255,.12);border-radius:.35em;padding:.6em .7em;' +
'font-size:.9em;line-height:1.5;outline:none;resize:vertical;font-family:inherit;';
box.appendChild(ta);
const foot = document.createElement('div');
foot.className = 'foot'; // 复用 .efav-modal .foot 右对齐按钮组样式
function mkNb(txt, accent) {
const b = document.createElement('button');
b.className = 'efav-btn' + (accent ? ' accent' : '');
b.textContent = txt;
return b;
}
const delBtn = mkNb('删除备注', false);
const cancelBtn = mkNb('取消', false);
const saveBtn = mkNb('保存', true);
foot.appendChild(delBtn); foot.appendChild(cancelBtn); foot.appendChild(saveBtn);
box.appendChild(foot);
mask.appendChild(box);
mask.addEventListener('click', function (e) { if (e.target === mask) mask.remove(); });
document.body.appendChild(mask);
requestAnimationFrame(function () { mask.classList.add('open'); });
FAV.dbGetNote(key).then(function (n) { if (n && n.content && !ta.value.trim()) ta.value = n.content; ta.focus(); })
.catch(function () { ta.focus(); });
cancelBtn.addEventListener('click', function () { mask.remove(); });
saveBtn.addEventListener('click', function () {
FAV.dbPutNote({ key: key, name: name || '', content: ta.value.trim(), updatedAt: Date.now() }).then(function () {
mask.remove();
showToast('备注已保存');
try { syncWorkNoteBtn(key, !!ta.value.trim()); } catch (e) {}
if (detailNoteCache) detailNoteCache[key] = { key: key, name: name || '', content: ta.value.trim(), updatedAt: Date.now() };
try {
document.dispatchEvent(new CustomEvent('emby-fav-changed', { bubbles: true }));
window.dispatchEvent(new CustomEvent('emby-fav-changed', { bubbles: true }));
} catch (e) {}
}).catch(function (e) { showToast('备注保存失败:' + (e && e.message)); });
});
delBtn.addEventListener('click', function () {
FAV.dbDeleteNote(key).then(function () {
mask.remove();
showToast('备注已删除');
try { syncWorkNoteBtn(key, false); } catch (e) {}
if (detailNoteCache) delete detailNoteCache[key];
try {
document.dispatchEvent(new CustomEvent('emby-fav-changed', { bubbles: true }));
window.dispatchEvent(new CustomEvent('emby-fav-changed', { bubbles: true }));
} catch (e) {}
}).catch(function (e) { showToast('备注删除失败:' + (e && e.message)); });
});
}
// 详情页作品备注按钮点亮态同步(key 匹配当前页按钮才更新,防 SPA 换页串台)
function syncWorkNoteBtn(key, has) {
const wb = document.getElementById('emby-work-note-btn');
if (wb && key && key === wb.getAttribute('data-note-key')) wb.classList.toggle('has-note', !!has);
}
/* ====== 详情页实体备注小气泡:悬停演员头像/片商/系列名,有备注则以气泡显示,移出即隐 ====== */
let noteTipEl = null;
let detailNoteCache = null; // {key:note} 详情页备注缓存(buildDetailRoot 预热;备注弹窗保存/删除即时更新)
function primeDetailNotes() {
FAV.dbAllNotes().then(function (rows) {
var m = {}; (rows || []).forEach(function (n) { if (n && n.key) m[n.key] = n; });
detailNoteCache = m;
}).catch(function () { detailNoteCache = {}; });
}
// 命中带备注或女优卡片的悬停目标:作品备注按钮、演员头像 chip、片商/系列 元数据 chip
function noteTipTarget(el) {
var nb = el && el.closest ? el.closest('.em-note-btn') : null;
if (nb) {
var bk = nb.getAttribute('data-note-key') || '';
return bk ? { el: nb, key: bk } : null;
}
var chip = el && el.closest ? el.closest('.emby-actor-chip, .em-meta-chip') : null;
if (!chip) return null;
var href = chip.getAttribute('href') || '';
var m = href.match(/\/actors\/([^\/\?\#]+)/) || href.match(/\/makers\/([^\/\?\#]+)/) || href.match(/\/series\/([^\/\?\#]+)/);
if (!m) return null;
var ns = /\/actors\//.test(href) ? 'actor' : (/\/makers\//.test(href) ? 'maker' : 'series');
var sid = (ns === 'actor' && m[1]) ? m[1] : '';
return { el: chip, key: ns + ':' + decodeURIComponent(m[1]), starId: sid };
}
let hideTipTimer = null;
async function showNoteTip(target) {
if (!target) return;
if (hideTipTimer) { clearTimeout(hideTipTimer); hideTipTimer = null; }
const isActor = target.key && target.key.startsWith('actor:');
const n = (detailNoteCache && detailNoteCache[target.key]) ? detailNoteCache[target.key] : null;
const hasNote = !!(n && n.content);
// 如果不是演员且没有备注,普通实体(片商/系列)才跳过;演员 Chip 必定展示!
if (!isActor && !hasNote) return;
let actorData = null;
const actorName = isActor ? (target.el.getAttribute('data-actor-name') || (target.el.querySelector('.nm') ? target.el.querySelector('.nm').textContent : '')).trim() : '';
const starId = isActor ? (target.starId || '') : '';
if (isActor && isPluginEnabled('actressInfo') && actorName) {
try {
actorData = await ActressService.getActressData(actorName);
} catch (e) {}
}
if (!noteTipEl) {
noteTipEl = document.createElement('div');
noteTipEl.id = 'emby-note-tip';
noteTipEl.className = 'emby-note-tip';
noteTipEl.addEventListener('mouseenter', function () {
if (hideTipTimer) { clearTimeout(hideTipTimer); hideTipTimer = null; }
});
noteTipEl.addEventListener('mouseleave', function () {
hideNoteTip(160);
});
document.body.appendChild(noteTipEl);
}
if (isActor) {
// 方案 1:一体化分层精美悬浮卡片(全覆盖任何演员)
const isFav = (typeof FavoriteActressManager !== 'undefined') && (
FavoriteActressManager.isFavorite(actorName) ||
(starId && FavoriteActressManager.isFavorite(starId)) ||
(actorData && actorData.primaryKey && FavoriteActressManager.isFavorite(actorData.primaryKey))
);
if (isFav && target && target.el) {
const ava = target.el.querySelector('.ava');
if (ava) ava.classList.add('is-favorite');
}
let headerHtml = '<div class="ap-header">';
headerHtml += '<span class="ap-name">' + escapeHtml(actorName || '演员') + '</span>';
if (actorData && actorData.status) {
const isRetire = actorData.status === '引退';
headerHtml += '<span class="ap-status ' + (isRetire ? 'retire' : 'active') + '">' + escapeHtml(actorData.status) + '</span>';
} else {
headerHtml += '<span class="ap-status actor">演员</span>';
}
if (starId) {
headerHtml += '<button class="ap-fav-btn' + (isFav ? ' active' : '') + '" type="button" title="' + (isFav ? '已在 JAVDB 收藏(点击取消收藏)' : '点击同步收藏至 JAVDB 账号') + '">' +
'<span class="ap-fav-heart">' + (isFav ? '♥' : '♡') + '</span> ' +
'<span class="ap-fav-text">' + (isFav ? '已收藏' : '收藏') + '</span>' +
'</button>';
} else if (isFav) {
headerHtml += '<span class="ap-fav-badge favorited" title="已在 JAVDB 收藏该演员"><span class="ap-fav-heart">♥</span> 已收藏</span>';
}
headerHtml += '</div>';
let tagsHtml = '';
if (actorData) {
const tags = [];
if (actorData.age) tags.push(actorData.age);
if (actorData.birthday) tags.push(actorData.birthday);
if (actorData.height) tags.push(actorData.height);
if (actorData.braSize) tags.push(actorData.braSize);
if (actorData.threeSizeText) tags.push(actorData.threeSizeText);
if (tags.length) {
tagsHtml = '<div class="ap-tags">' + tags.map(function (t) { return '<span class="ap-tag">' + escapeHtml(t) + '</span>'; }).join('') + '</div>';
}
}
if (!tagsHtml) {
tagsHtml = '<div class="ap-tags empty"><span class="ap-tag-sub">暂无生平资料</span></div>';
}
let noteHtml = '';
if (hasNote) {
noteHtml = '<div class="ap-note-box" title="点击编辑个人备注">' +
'<div class="ap-note-title"><span class="material-symbols-outlined" style="font-size:14px;vertical-align:-2px;">edit_note</span> 个人备注' +
'<span style="margin-left:auto;font-size:10.5px;color:#ffb84d;">编辑</span></div>' +
'<div class="ap-note-content">' + escapeHtml(n.content) + '</div>' +
'</div>';
} else {
noteHtml = '<div class="ap-note-box empty" title="点击添加个人备注">' +
'<div class="ap-note-prompt"><span class="material-symbols-outlined" style="font-size:14px;vertical-align:-2px;">note_add</span> 点击添加个人备注…</div>' +
'</div>';
}
noteTipEl.innerHTML = headerHtml + tagsHtml + noteHtml;
// 绑定卡片交互:真正向 JAVDB 穿透发送网络请求,同步增删云端收藏并点亮/熄灭红心角标
const favBtn = noteTipEl.querySelector('.ap-fav-btn');
if (favBtn && starId) {
favBtn.addEventListener('click', async function (e) {
e.preventDefault();
e.stopPropagation();
if (favBtn._busy) return;
favBtn._busy = true;
const oldText = favBtn.querySelector('.ap-fav-text').textContent;
const isCurrentlyFav = favBtn.classList.contains('active');
favBtn.querySelector('.ap-fav-text').textContent = isCurrentlyFav ? '取消中…' : '收藏中…';
favBtn.style.opacity = '0.65';
try {
const newFav = await FavoriteActressManager.remoteToggleFavorite(actorName, starId);
favBtn.classList.toggle('active', newFav);
favBtn.querySelector('.ap-fav-heart').textContent = newFav ? '♥' : '♡';
favBtn.querySelector('.ap-fav-text').textContent = newFav ? '已收藏' : '收藏';
favBtn.setAttribute('title', newFav ? '已在 JAVDB 收藏(点击取消收藏)' : '点击同步收藏至 JAVDB 账号');
// 同步点亮/熄灭当前页面所有对应演员 Chip 头像红心
document.querySelectorAll('.emby-actor-chip').forEach(function (c) {
const chName = (c.getAttribute('data-actor-name') || (c.querySelector('.nm') ? c.querySelector('.nm').textContent : '')).trim();
const chHref = c.getAttribute('href') || '';
const chSid = (chHref.match(/\/actors\/([^\/\?\#]+)/) || [])[1];
if (chName === actorName || (starId && chSid === starId)) {
const ava = c.querySelector('.ava');
if (ava) ava.classList.toggle('is-favorite', newFav);
}
});
showToast(newFav ? ('✅ 已成功将 ' + (actorName || '演员') + ' 收藏至 JAVDB 账号') : ('已从 JAVDB 取消收藏 ' + (actorName || '演员')));
} catch (err) {
favBtn.querySelector('.ap-fav-text').textContent = oldText;
showToast('❌ JAVDB 收藏操作失败: ' + (err && err.message));
} finally {
favBtn._busy = false;
favBtn.style.opacity = '';
}
});
}
const noteBox = noteTipEl.querySelector('.ap-note-box');
if (noteBox) {
noteBox.addEventListener('click', function (e) {
e.preventDefault();
e.stopPropagation();
hideNoteTip(0);
if (typeof openNoteModal === 'function') {
openNoteModal(target.key, actorName);
}
});
}
} else {
// 普通实体(片商/系列/作品备注)保持纯净气泡
noteTipEl.innerHTML = '<div style="font-size:12px;line-height:1.45;color:#ffe8cc;word-break:break-word;">' + escapeHtml(n.content) + '</div>';
}
noteTipEl.classList.remove('show');
noteTipEl.style.left = '0px'; noteTipEl.style.top = '0px';
const r = target.el.getBoundingClientRect();
const tw = noteTipEl.offsetWidth, th = noteTipEl.offsetHeight;
let x = Math.max(8, Math.min(r.left + r.width / 2 - tw / 2, window.innerWidth - tw - 8));
let y = r.top - th - 10;
if (y < 8) y = r.bottom + 10; // 上方空间不够放下方
noteTipEl.style.left = x + 'px';
noteTipEl.style.top = y + 'px';
void noteTipEl.offsetHeight;
noteTipEl.classList.add('show');
}
function hideNoteTip(delay) {
if (hideTipTimer) clearTimeout(hideTipTimer);
const ms = typeof delay === 'number' ? delay : 180;
if (ms <= 0) {
if (noteTipEl) noteTipEl.classList.remove('show');
} else {
hideTipTimer = setTimeout(function () {
if (noteTipEl) noteTipEl.classList.remove('show');
}, ms);
}
}
var noteTipBound = false;
function bindNoteTips() {
if (noteTipBound) return;
noteTipBound = true;
document.addEventListener('mouseover', function (e) {
var t = noteTipTarget(e.target);
if (t) {
showNoteTip(t);
} else {
if (noteTipEl && (noteTipEl === e.target || noteTipEl.contains(e.target))) {
if (hideTipTimer) { clearTimeout(hideTipTimer); hideTipTimer = null; }
} else {
hideNoteTip(180);
}
}
});
document.addEventListener('mouseout', function (e) {
var t = noteTipTarget(e.target);
if (t && !(e.relatedTarget && (t.el.contains(e.relatedTarget) || (noteTipEl && noteTipEl.contains(e.relatedTarget))))) {
hideNoteTip(180);
}
});
}
// 重试观察器:javdb 演员页会反复 rebuild(SPA 抖动),单次 setTimeout 可能被清掉,
// 用轮询确保稳定后按钮最终注入;注入成功且未被 javdb rebuild 移除才停。
let detailBtnWatcher = null;
let detailBtnTries = 0;
// 公告兜底扫描(querySelectorAll('body *') 全 DOM 扫描较重)每个路由只跑一次;
// 路由变化时在 observeMutations 的 900ms 轮询里重置
let announceFallbackDone = false;
function startDetailFilterBtnWatcher() {
if (detailBtnWatcher) return;
detailBtnTries = 0;
detailBtnWatcher = setInterval(function () {
try {
if (!enabled) { clearInterval(detailBtnWatcher); detailBtnWatcher = null; return; }
detailBtnTries++;
// javdb rebuild 会移除我们注入的按钮,所以每次都重新确保(存在且挂在正确父级才视为成功)
const ok = ensureDetailFilterBtn();
if (ok) { clearInterval(detailBtnWatcher); detailBtnWatcher = null; return; }
if (detailBtnTries > 60) { clearInterval(detailBtnWatcher); detailBtnWatcher = null; } // 兜底:约36s仍失败则放弃
} catch (e) {}
}, 600);
}
function stopDetailFilterBtnWatcher() {
if (detailBtnWatcher) { clearInterval(detailBtnWatcher); detailBtnWatcher = null; }
}
// 返回 true 表示按钮已正确注入且未被移除;false 表示需重试
function ensureDetailFilterBtn() {
// video_codes = 番号组页(如 /video_codes/ABC)→ codePrefix 前缀筛选维度
const TYPE_MAP = { actors: 'actors', directors: 'directors', makers: 'makers', series: 'series', video_codes: 'codePrefix' };
const seg = location.pathname.split('/').filter(Boolean);
const dim = TYPE_MAP[seg[0]];
if (!dim) return false;
const favBtn = document.querySelector('.button-collect, .button-uncollect');
if (!favBtn) return false;
const titleEl = document.querySelector('h2.title.is-4, h1.title, .title.is-4') || document.querySelector('h1, h2');
// 名称候选:演员页保留多写法拆分(h2 常把中/日/别名放同行,采集入库只存其一);
// 其它维度(导演/片商/系列/番号组)一律整名单条件——系列名天然含空格/顿号/逗号时,
// 拆分会被误解析成多个条件导致收藏夹筛不出作品(按需求只对演员应用符号识别拆分)。
let names = [];
if (seg[0] === 'actors') {
// 演员:符号拆分多候选(中/日/别名同行)
names = extractEntityNames(titleEl);
} else if (seg[0] === 'makers') {
// 片商:三形态并集候选(整行名 / 逗号分段名 / 传统拆分兜底,详见 extractMakerNames)
names = extractMakerNames(titleEl);
} else if (seg[0] === 'video_codes') {
// 番号组页标题结构是「番號 ABC」(标签+前缀,常分行),从标题剥标签易误取到「番號」;
// URL 段 /video_codes/{PREFIX} 本身就是权威前缀,直接取它
const pfx = decodeURIComponent(seg[1] || '');
if (pfx) names = [pfx];
} else {
// 导演/片商/系列:行级解析跳过「導演」等标签行后,每行整名保留(不拆分)
names = extractEntityNamesWhole(titleEl);
}
if (!names.length) return false;
const parent = favBtn.closest('.control') || favBtn.parentElement;
if (!parent) return false;
const existing = document.getElementById('emby-detail-filter-btn');
const existingNote = document.getElementById('emby-detail-note-btn');
// 已存在且父级正确且备注按钮就位 → 成功
if (existing && existing.parentElement === parent && (!favBtn || (existingNote && existingNote.parentElement === parent))) return true;
// 否则清理旧的(可能父级被 rebuild)后重建
if (existing && existing.parentElement) existing.parentElement.removeChild(existing);
if (document.getElementById('emby-detail-filter-btn')) return false;
// 备注按钮同样可能因 rebuild 挂在旧父级上,一并清理
const oldNote = document.getElementById('emby-detail-note-btn');
if (oldNote && oldNote.parentElement) oldNote.parentElement.removeChild(oldNote);
parent.style.display = 'flex';
parent.style.gap = '.5em';
parent.style.alignItems = 'center';
const btn = document.createElement('a');
btn.id = 'emby-detail-filter-btn';
btn.className = 'button';
btn.textContent = '去收藏夹筛选';
btn.style.cssText += ';display:inline-flex;align-items:center;white-space:nowrap;cursor:pointer;text-decoration:none;';
btn.addEventListener('click', function (e) {
e.preventDefault();
if (FAVUI && FAVUI.filterByDetail) FAVUI.filterByDetail(dim, names);
});
parent.appendChild(btn);
// 备注按钮:演员/片商/系列页(与筛选按钮同行;作品级备注在详情页番号行内,另行注入)
if (seg[0] === 'actors' || seg[0] === 'makers' || seg[0] === 'series') {
try { ensureDetailNoteBtn(parent, seg[0], names[0] || ''); } catch (e) {}
}
return true;
}
// 瀑布流错落比例:基于字符串哈希的稳定伪随机,区间 [0.6, 1.12]
// 返回 aspect-ratio 的「宽/高」值,越小越竖 → 高度越高,形成高低错落。
function masonryAspect(key, fallback) {
let h = 0;
const s = key || String(fallback);
for (let i = 0; i < s.length; i++) { h = (h * 31 + s.charCodeAt(i)) >>> 0; }
const f = (h % 1000) / 1000; // 0~0.999
return 0.6 + f * 0.52; // 0.6 ~ 1.12
}
// 星星评分渲染器(金色立体星星 + 灰色底星)
function renderStars(score) {
const s = Math.min(5, Math.max(0, Math.round(parseFloat(score) || 0)));
let html = '';
for (let i = 0; i < s; i++) html += '<i class="icon-star" style="color:#f59e0b;margin-right:1px;"></i>';
for (let i = s; i < 5; i++) html += '<i class="icon-star gray" style="color:#64748b;margin-right:1px;"></i>';
return html;
}
/* =======================================================================
* Emby 设计令牌与皮肤 CSS
*
* 以下设计令牌来自真实 Emby 网页端抓取(emby.staryuki.com):
* - 顶栏:position:fixed, 渐变 linear-gradient(rgba(0,0,0,.5), transparent)
* - 卡片阴影:box-shadow: 0 8px 10px rgb(0 0 0 / 15%)
* - 卡片悬停:scale(1.1), transition cubic-bezier(0, 1.75, .25, 1) 1.5s
* - 卡片文字浮层:rgba(0,0,0,.5), border-radius:.3em, hover opacity 0→1
* - 毛玻璃导航:backdrop-filter: blur(10px)
* - 抽屉宽度:16.5em(桌面端)
* - 背景:纯黑 #000
*
* 以下选择器均来自真实 javdb DOM 抓取:
* - 首页网格: .movie-list > .item > .box > [.cover>img, .video-title, .score, .meta, .tags]
* - 详情页: .video-detail > [h2.title, img.video-cover, .panel-block>.value]
* - 导航栏: nav.navbar.is-fixed-top.is-black.main-nav
* - 下载按钮: a.button.is-info.is-outlined[href="#magnet-links"]
* ===================================================================== */
function buildEmbyCSS(accent) {
return `
@import url('https://fonts.googleapis.com/icon?family=Material+Icons');
/* Material Symbols(填充态):新图标(crown/cards_star/file_json 等)不在经典 Material Icons 字体内 */
@import url('https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,[email protected],400,1,0&display=block');
/* ====== 全局变量 —— 来自真实 Emby 抓取 ====== */
html.emby-skin {
--e-bg:#16181d; --e-card:#1c1f26; --e-hover:#23272f;
--e-text:#eaeaea; --e-text2:rgba(255,255,255,.7); --e-text3:rgba(255,255,255,.45);
--e-accent:${accent}; --e-accent-d:${accent}dd; --e-accent-l:${accent}26;
--e-r:.2em; /* 真实 Emby 圆角 */
--e-drawer-w:16.5em; /* 真实 Emby 抽屉宽度 */
--e-header-h:4.327em; /* 真实 Emby --header-height */
/* 设置面板可调变量(默认值,运行时由 JS 覆盖) */
--e-blur:4px; /* 封面背景高斯模糊强度 */
--e-cover-w:340px; /* 详情页 hero 海报宽(封面框架大小,默认中) */
--e-card-w:300px; /* 网格卡片宽(封面框架大小,默认中) */
--e-cols:5; /* 瀑布流列数 */
}
/* ====== 过渡动画 ======
* 【旧实现的两个问题】
* ① 性能:html.emby-skin body * 给页面里【每一个】元素都挂 8 条 transition。
* 几百张卡片时每次样式重算都要遍历整棵 DOM 树,是首页/收藏夹滚动与筛选卡顿的主要来源。
* ② 误覆盖:该规则特异性 (0,5,2) 高于卡片自身的 html.emby-skin .item .box (0,3,1),
* 把卡牌 3D 倾斜声明的 transform .18s 强行拉成 .3s —— 跟随鼠标的倾斜手感发黏。
*
* 【新实现:拆成两条】
* ① 形变窗口(html.emby-morphing,仅皮肤开关那 ~480ms):临时恢复全局过渡,
* 完整保留原本「整页溶解」的观感;窗口结束即随 class 移除而失效,不留常驻开销。
* ② 常驻:只对会随主题/状态变色的结构表面挂「颜色类」过渡。
* transform / margin / padding 等几何属性交还给各组件自己声明
* (.item .box、.emby-masonry .item、.emby-home-tab、.emby-row .item 等均已自带)。
* 注意:已自带 transition 的元素(#emby-drawer / #emby-search-modal / .emby-panel)
* 采用「在各自规则里补属性」的方式,绝不用更高特异性的规则覆盖,否则会抹掉
* 抽屉滑出、搜索弹窗淡入等既有动画。
*
* 形变窗口内同样排除原版 Fancybox 灯箱的整个子树:全局过渡会强行覆盖其开合
* 动画,导致预览图放大时「闪烁一下再展示」。 */
/* 两个全屏渐变遮罩各自声明了更长的缓动(.45s cubic-bezier / 1s ease),
* 排除在外以免被本规则的 .35s 覆盖,否则遮罩淡入淡出会变短变生硬 */
html.emby-morphing body {
transition: background-color .4s ease, color .4s ease !important;
}
/* 常驻颜色/边框过渡:仅覆盖自身没有 transition 声明的结构表面 */
html.emby-skin body,
html.emby-skin #emby-topbar,
html.emby-skin #emby-settings-panel,
html.emby-skin #emby-favorites-panel,
html.emby-skin #emby-gallery-panel,
html.emby-skin .efav-modal {
transition: background-color .4s ease, color .4s ease, border-color .4s ease,
box-shadow .3s ease, border-radius .35s ease;
}
/* ====== 皮肤切换过渡遮罩(解决瞬间切换的生硬感与卡顿感知) ======
* 切换时在 <html> 上加 emby-morphing 类,触发全屏半透明渐变层 + 内容淡入淡出,
* 用 opacity 过渡掩盖 DOM 重排过程,用户感知为「平滑变形」而非卡顿。 */
.emby-morph-overlay {
position:fixed; inset:0; z-index:2147483646;
background:var(--e-bg,#000); pointer-events:none;
opacity:0; transition:opacity .45s cubic-bezier(.22,1,.36,1);
}
html.emby-morphing .emby-morph-overlay { opacity:1; }
/* 切换期间内容区额外缓动(配合遮罩形成溶解效果) */
html.emby-morphing body { opacity:.92 !important; }
html.emby-skin { background:var(--e-bg,#16181d) !important; }
/* 融合模式:关闭 scroll-anchoring,防止收起/展开重排时浏览器自动调整 scrollY 导致来回跳 */
html.emby-skin.cover-fusion { overflow-anchor:none !important; }
html.emby-skin body {
background:var(--e-bg) !important; color:var(--e-text) !important;
font-family:"Segoe UI",Roboto,system-ui,Arial,"Microsoft YaHei","Noto Sans SC",sans-serif !important;
padding-top:var(--e-header-h) !important;
overflow-x:hidden !important;
}
/* ====== 隐藏 javdb 原生元素 ====== */
html.emby-skin nav.navbar.is-fixed-top.main-nav { display:none !important; }
html.emby-skin .full[style*="display:block"] { display:none !important; }
/* 隐藏原版搜索栏(Emby 顶栏已集成搜索),消除详情页顶部多余留白 */
html.emby-skin #search-bar-container { display:none !important; }
/* 隐藏 javdb 主页「最新網址」公告栏(仅在 EMBY 皮肤下生效,关闭即恢复) */
html.emby-skin .emby-hide-announce { display:none !important; }
/* ====== Emby 顶部栏 —— 真实 skinHeader 样式 ======
* 真实 Emby: position:fixed, 渐变 linear-gradient(rgba(0,0,0,.5), transparent)
* 而非实色背景条。内容在栏下方滚动可见。 */
#emby-topbar {
position:fixed; top:0; left:0; right:0; height:var(--e-header-h); z-index:2147483000;
/* 上下渐变遮罩横条:顶部接近实色深灰,向下渐隐到透明,融入页面背景,
* 取代原本底部那条突兀的硬白线(分隔感由渐变自然过渡承担)。 */
background-image:linear-gradient(to bottom, rgba(22,24,29,.92), rgba(22,24,29,0));
background-color:unset;
display:flex; align-items:flex-start; flex-wrap:wrap;
padding-top:1.2em; padding-left:0; padding-right:0;
box-sizing:border-box;
}
#emby-topbar::before {
/* 渐变遮罩横条已取代底部硬白线,这里不再画分隔线 */
content:none;
}
#emby-topbar .hb { background:none; border:none; color:#ccc; cursor:pointer;
font-size:1.6em; margin-right:.8em; padding:.25em; border-radius:50%;
display:flex; align-items:center; transition:background .2s,color .2s;
margin-top:-.05em;
}
#emby-topbar .hb:hover { background:rgba(255,255,255,.12); color:#fff; }
#emby-topbar .logo { font-weight:800; letter-spacing:.12em; color:var(--e-accent);
font-size:1.08em; text-transform:uppercase; margin-right:1.5em; user-select:none;
margin-top:.15em;
}
/* 隐藏 javdb 原生磁链下载/顶部跳转入口:
* ① 右下角浮动蓝框:磁链下载(material-scroll-magnets) + 顶部跳转(material-scrolltop)
* ② 详情页信息面板「下載」按钮 a.button.is-info.is-outlined[href="#magnet-links"](与详情页 JS 隐藏双保险)
* 注:脚本自身的回顶按钮 #emby-backtop 保留不动;皮肤关闭(removeEmbyDOM)时本规则随 skinEl 移除即恢复原生显示 */
html.emby-skin a.material-scroll-magnets,
html.emby-skin a.material-scrolltop,
html.emby-skin a.button.is-info.is-outlined[href="#magnet-links"] { display:none !important; }
/* 回顶按钮:右下角浮动,滚动超过阈值才出现(默认隐藏);尺寸与左下角切换按钮一致(56px 圆形) */
#emby-backtop {
position:fixed; right:26px; bottom:26px; z-index:2147483200;
width:56px; height:56px; border-radius:50%;
display:flex; align-items:center; justify-content:center;
background:var(--e-accent,#00a4dc); color:#000; cursor:pointer; border:2px solid rgba(255,255,255,.15);
box-shadow:0 4px 18px rgba(0,0,0,.55);
opacity:0; transform:translateY(1em) scale(.8); pointer-events:none;
transition:opacity .25s, transform .25s, background .35s ease, border-color .3s ease;
}
#emby-backtop.show { opacity:1; transform:translateY(0) scale(1); pointer-events:auto; }
#emby-backtop:hover { transform:scale(1.08); box-shadow:0 6px 24px rgba(0,0,0,.65); }
#emby-backtop .material-icons, #emby-backtop .material-symbols-outlined { font-size:26px; }
/* JAVDB logo(克隆自原生 navbar 的内联 SVG),点击回主页 */
#emby-topbar .logo-javdb { display:flex; align-items:center; text-decoration:none;
margin-right:1.5em; margin-top:.15em; }
#emby-topbar .logo-javdb svg { height:30px; width:auto; display:block; }
#emby-topbar .logo-javdb span { font-weight:800; letter-spacing:.12em; font-size:1.08em;
color:var(--e-accent); text-transform:uppercase; }
#emby-topbar .spacer { flex:1; }
#emby-topbar #emby-search-trigger { flex:0 0 auto; margin-top:-.05em; cursor:pointer; }
#emby-topbar input {
background:rgba(0,0,0,.38); border:1px solid rgba(255,255,255,.08); color:#ddd;
border-radius:var(--e-r); padding:.45em .85em; width:100%; outline:none;
font-size:.9em; transition:border-color .2s,box-shadow .2s; cursor:pointer;
}
#emby-topbar input::placeholder { color:var(--e-text3); }
#emby-topbar input:focus { border-color:var(--e-accent); box-shadow:0 0 0 2px var(--e-accent-l); }
/* ====== LOGO 悬停滑出横向视图切换按钮(.logo-nav-wrap > .emby-topnav) ======
* 鼠标放到 LOGO 上时,横向按钮组从 logo 右侧滑出(横向排布,不受顶栏高度限制);
* 移开自动收回。按钮直接复用 .emby-home-tab 全套样式(与首页标签栏视觉一致)。
* 按钮由 HOME_TAB_DEFS 动态生成,后期新增视图自动出现。 */
#emby-topbar .logo-nav-wrap { position:relative; display:flex; align-items:center; }
#emby-topbar .emby-topnav {
position:absolute; left:100%; top:50%;
transform:translateY(-50%) translateX(-.5em);
display:flex; align-items:center; gap:.35em; white-space:nowrap;
opacity:0; visibility:hidden; pointer-events:none; z-index:60;
transition:opacity .18s ease, transform .18s ease, visibility .18s;
}
#emby-topbar .logo-nav-wrap:hover .emby-topnav,
#emby-topbar .logo-nav-wrap.show .emby-topnav {
opacity:1; visibility:visible; pointer-events:auto;
transform:translateY(-50%) translateX(.15em);
}
#emby-topbar .emby-topnav .emby-home-tab {
padding:.32em 1.05em; font-size:.82em; border-radius:999px;
backdrop-filter:none !important;
-webkit-backdrop-filter:none !important;
filter:none !important;
}
#emby-topbar .emby-topnav .emby-home-tab .material-symbols-outlined { font-size:1em; margin-right:.3em; vertical-align:-2px; }
@media (max-width: 900px) {
/* 窄屏:顶栏空间有限,只显示图标 + 首个字,避免挤压搜索框 */
#emby-topbar .emby-topnav .emby-home-tab { padding:.32em .8em; font-size:.78em; }
}
/* ====== 风格栈定义 ======
* glass(毛玻璃):半透明 + 较强模糊 + 渐变(即原 liquid 观感,按需求挪过来)
* liquid(液态玻璃):三层结构 —— 折射微光渐变 + 多层 inset 边缘高光(specular) + 饱和增强 + 顶部高光条
*/
/* —— glass —— */
html.emby-skin.emby-style-glass #emby-topbar {
backdrop-filter: blur(22px) saturate(1.8) brightness(1.04);
-webkit-backdrop-filter: blur(22px) saturate(1.8) brightness(1.04);
background: linear-gradient(180deg, rgba(255,255,255,.14), rgba(255,255,255,.03) 70%, transparent);
border-bottom: 1px solid rgba(255,255,255,.16);
}
html.emby-skin.emby-style-glass #emby-search-modal {
backdrop-filter: blur(14px) saturate(1.8) brightness(1.05) !important;
-webkit-backdrop-filter: blur(14px) saturate(1.8) brightness(1.05) !important;
background: transparent !important;
}
html.emby-skin.emby-style-glass #emby-search-modal .search-panel,
html.emby-skin.emby-style-glass #emby-search-modal .search-bar-wrap {
background: linear-gradient(135deg, rgba(255,255,255,.12) 0%, rgba(255,255,255,.03) 100%), rgba(20,24,36,.55) !important;
backdrop-filter: blur(26px) saturate(1.9) brightness(1.06) !important;
-webkit-backdrop-filter: blur(26px) saturate(1.9) brightness(1.06) !important;
border: 1px solid rgba(255,255,255,.22) !important;
box-shadow: 0 16px 45px rgba(0,0,0,.5), inset 0 1px 1px rgba(255,255,255,.35) !important;
border-radius: 12px;
}
html.emby-skin.emby-style-glass #emby-drawer {
backdrop-filter: blur(5px) saturate(1.8) brightness(1.04) !important;
-webkit-backdrop-filter: blur(5px) saturate(1.8) brightness(1.04) !important;
background: linear-gradient(135deg, rgba(255,255,255,.12) 0%, rgba(255,255,255,.03) 100%), rgba(20,24,36,.30) !important;
border-right: 1px solid rgba(255,255,255,.2) !important;
box-shadow: 0 16px 45px rgba(0,0,0,.35), inset -1px 0 0 rgba(255,255,255,.15) !important;
}
html.emby-skin.emby-style-glass #emby-drawer .nav a,
html.emby-skin.emby-style-glass #emby-drawer .drawer-group-header,
html.emby-skin.emby-style-glass #emby-drawer .drawer-sub-link,
html.emby-skin.emby-style-glass #emby-drawer .acct-item,
html.emby-skin.emby-style-glass #emby-drawer .acct-user,
html.emby-skin.emby-style-glass #emby-drawer .brand .brand-link {
color: #ffffff !important;
font-weight: 600 !important;
text-shadow: 0 1px 2px rgba(0,0,0,.85);
}
html.emby-skin.emby-style-glass .efav-modal,
html.emby-skin.emby-style-glass .efav-panel,
html.emby-skin.emby-style-glass #emby-compact-bar {
backdrop-filter: blur(26px) saturate(1.9) brightness(1.05) !important;
-webkit-backdrop-filter: blur(26px) saturate(1.9) brightness(1.05) !important;
background: linear-gradient(135deg, rgba(255,255,255,.12) 0%, rgba(255,255,255,.03) 100%), rgba(20,24,36,.32) !important;
border: 1px solid rgba(255,255,255,.2) !important;
box-shadow: 0 14px 40px rgba(0,0,0,.4), inset 0 1px 1px rgba(255,255,255,.3) !important;
}
html.emby-skin.emby-style-glass #emby-drawer-scrim {
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
background: rgba(0,0,0,.25) !important;
}
/* —— liquid(液态玻璃) —— 原厂 childrentime/liquid-glass 规范:透光折射晶体 + 多层边缘高光 + 顶部高光条 */
html.emby-skin.emby-style-liquid #emby-topbar,
html.emby-skin.emby-style-liquid .efav-panel,
html.emby-skin.emby-style-liquid #emby-compact-bar {
/* 层1 原厂规范:0.25px 亚像素微平滑抗锯齿 + 柔和通透增亮 + 8%三棱镜微色彩,绝非毛玻璃重模糊!透视内容 100% 锐利清晰 */
backdrop-filter: blur(0.25px) brightness(1.04) saturate(1.08);
-webkit-backdrop-filter: blur(0.25px) brightness(1.04) saturate(1.08);
background:
linear-gradient(180deg, rgba(255,255,255,.07) 0%, rgba(255,255,255,.01) 75%, transparent 100%) !important;
border: 1px solid rgba(255,255,255,.22);
/* 层2 边缘高光(specular):内外多层 inset 模拟玻璃倒角反光 */
box-shadow:
inset 0 1.5px 0 rgba(255,255,255,.55),
inset 0 -1px 0 rgba(255,255,255,.08),
0 8px 24px rgba(0,0,0,.35) !important;
}
html.emby-skin.emby-style-liquid #emby-drawer {
background:
linear-gradient(180deg, rgba(255,255,255,.07) 0%, rgba(255,255,255,.01) 75%, transparent 100%),
rgba(16,20,32,.25) !important;
backdrop-filter: blur(1.6px) brightness(1.04) saturate(1.15) !important;
-webkit-backdrop-filter: blur(1.6px) brightness(1.04) saturate(1.15) !important;
border-right: 1px solid rgba(255,255,255,.22) !important;
box-shadow:
inset 0 1.5px 0 rgba(255,255,255,.6),
inset -1.5px 0 0 rgba(255,255,255,.2),
0 12px 40px rgba(0,0,0,.45) !important;
overflow-y: auto !important;
overflow-x: hidden !important;
}
html.emby-skin.emby-style-liquid #emby-drawer .nav a,
html.emby-skin.emby-style-liquid #emby-drawer .drawer-group-header,
html.emby-skin.emby-style-liquid #emby-drawer .drawer-sub-link,
html.emby-skin.emby-style-liquid #emby-drawer .acct-item,
html.emby-skin.emby-style-liquid #emby-drawer .acct-user,
html.emby-skin.emby-style-liquid #emby-drawer .brand,
html.emby-skin.emby-style-liquid #emby-drawer .brand .brand-link {
color: #ffffff !important;
font-weight: 600 !important;
text-shadow: 0 1px 3px rgba(0,0,0,.95), 0 0 8px rgba(0,0,0,.7);
transition: all .2s ease;
}
html.emby-skin.emby-style-liquid #emby-drawer .nav a:hover,
html.emby-skin.emby-style-liquid #emby-drawer .drawer-group-header:hover,
html.emby-skin.emby-style-liquid #emby-drawer .drawer-sub-link:hover,
html.emby-skin.emby-style-liquid #emby-drawer .acct-item:hover {
background: rgba(255,255,255,.15) !important;
color: #fff !important;
}
html.emby-skin.emby-style-liquid #emby-drawer .nav a.sel,
html.emby-skin.emby-style-liquid #emby-drawer .drawer-sub-link.sel {
background: rgba(255,255,255,.22) !important;
color: #fff !important;
border-left: 3px solid var(--e-accent, #00a4dc);
}
html.emby-skin.emby-style-liquid #emby-drawer .drawer-group.has-sel > .drawer-group-header .mi {
color: var(--e-accent, #00a4dc) !important;
}
/* 顶部 specular 亮条(液态玻璃标志性高光) */
html.emby-skin.emby-style-liquid #emby-topbar::after,
html.emby-skin.emby-style-liquid #emby-drawer::after,
html.emby-skin.emby-style-liquid .efav-modal::after,
html.emby-skin.emby-style-liquid .efav-panel::after,
html.emby-skin.emby-style-liquid #emby-compact-bar::after {
content:''; position:absolute; left:8%; right:8%; top:0; height:2px; border-radius:2px;
background:linear-gradient(90deg, transparent, rgba(255,255,255,.85), transparent);
opacity:.7; pointer-events:none;
}
html.emby-skin.emby-style-liquid #emby-topbar {
border-bottom-color: rgba(255,255,255,.22);
contain: none !important;
will-change: backdrop-filter;
transform: translateZ(0);
}
html.emby-skin.emby-style-liquid #emby-search-modal {
backdrop-filter: blur(14px) saturate(1.2) brightness(0.92) !important;
-webkit-backdrop-filter: blur(14px) saturate(1.2) brightness(0.92) !important;
background: rgba(0,0,0,.32) !important;
}
html.emby-skin.emby-style-liquid #emby-search-modal .search-panel,
html.emby-skin.emby-style-liquid #emby-search-modal .search-bar-wrap {
background:
linear-gradient(180deg, rgba(255,255,255,.08) 0%, rgba(255,255,255,.02) 60%, transparent 100%),
rgba(16,20,32,.50) !important;
backdrop-filter: blur(14px) brightness(1.04) saturate(1.1) !important;
-webkit-backdrop-filter: blur(14px) brightness(1.04) saturate(1.1) !important;
border: 1px solid rgba(255,255,255,.24) !important;
box-shadow:
inset 0 1.5px 0 rgba(255,255,255,.65),
inset 0 -1px 0 rgba(255,255,255,.12),
0 20px 50px rgba(0,0,0,.55) !important;
overflow: hidden;
border-radius: 12px;
}
html.emby-skin.emby-style-liquid #emby-drawer-scrim {
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
background: rgba(0,0,0,.25) !important;
}
/* —— 毛玻璃:筛选弹窗内部元素(胶囊按钮/开关/标签) —— */
html.emby-skin.emby-style-glass .efav-opt {
background: rgba(255,255,255,.08); backdrop-filter: blur(14px) saturate(1.6);
border:1px solid rgba(255,255,255,.18); color:var(--e-text);
}
html.emby-skin.emby-style-glass .efav-opt:hover { background: rgba(255,255,255,.16); border-color: rgba(255,255,255,.32); color:#fff; }
html.emby-skin.emby-style-glass .efav-opt.on {
background: var(--e-accent); border-color: transparent; color:#fff;
box-shadow: 0 2px 10px rgba(0,164,220,.4);
}
html.emby-skin.emby-style-glass .efav-btn {
background: rgba(255,255,255,.08); backdrop-filter: blur(14px) saturate(1.6);
border:1px solid rgba(255,255,255,.18); color:var(--e-text);
}
html.emby-skin.emby-style-glass .efav-btn.accent {
background: var(--e-accent); border-color: transparent; color:#fff;
}
html.emby-skin.emby-style-glass .efav-btn:hover { background: rgba(255,255,255,.16); color:#fff; }
/* —— 液态玻璃:筛选弹窗内部元素(胶囊按钮/开关/标签)也跟随液态玻璃,而非毛玻璃 —— */
html.emby-skin.emby-style-liquid .efav-opt {
background: linear-gradient(135deg, rgba(255,255,255,.16), rgba(255,255,255,.05) 60%, rgba(150,140,255,.12));
border:1px solid rgba(255,255,255,.26); color:#fff;
box-shadow: inset 0 1px 0 rgba(255,255,255,.4), inset 0 -1px 0 rgba(255,255,255,.1);
}
html.emby-skin.emby-style-liquid .efav-opt:hover { background: linear-gradient(135deg, rgba(255,255,255,.24), rgba(255,255,255,.09) 60%, rgba(150,140,255,.18)); }
html.emby-skin.emby-style-liquid .efav-opt.on {
background: linear-gradient(135deg, color-mix(in srgb, var(--e-accent,#00a4dc) 80%, #fff), color-mix(in srgb, var(--e-accent,#00a4dc) 50%, #1a1a2a));
border-color:transparent; color:#fff;
box-shadow: inset 0 1px 0 rgba(255,255,255,.5), 0 2px 10px rgba(0,0,0,.3);
}
html.emby-skin.emby-style-liquid .efav-btn {
background: linear-gradient(135deg, rgba(255,255,255,.14), rgba(255,255,255,.05) 60%, rgba(150,140,255,.1));
border:1px solid rgba(255,255,255,.24); color:var(--e-text);
}
html.emby-skin.emby-style-liquid .efav-btn.accent {
background: linear-gradient(135deg, color-mix(in srgb, var(--e-accent,#00a4dc) 80%, #fff), color-mix(in srgb, var(--e-accent,#00a4dc) 50%, #1a1a2a));
border-color:transparent; color:#fff;
}
html.emby-skin.emby-style-liquid .efav-btn:hover { background: linear-gradient(135deg, rgba(255,255,255,.22), rgba(255,255,255,.09) 60%, rgba(150,140,255,.18)); color:#fff; }
/* —— 筛选弹窗内滚动条:透明(去除白底) —— */
html.emby-skin .efav-opts,
html.emby-skin .efav-modal,
html.emby-skin .efav-panel,
html.emby-skin .efav-browse-body,
html.emby-skin .efav-chips { scrollbar-width:thin; scrollbar-color:rgba(255,255,255,.18) transparent; }
html.emby-skin .efav-opts::-webkit-scrollbar,
html.emby-skin .efav-modal::-webkit-scrollbar,
html.emby-skin .efav-panel::-webkit-scrollbar,
html.emby-skin .efav-browse-body::-webkit-scrollbar,
html.emby-skin .efav-chips::-webkit-scrollbar { width:8px; height:8px; background:transparent; }
html.emby-skin .efav-opts::-webkit-scrollbar-track,
html.emby-skin .efav-modal::-webkit-scrollbar-track,
html.emby-skin .efav-panel::-webkit-scrollbar-track,
html.emby-skin .efav-browse-body::-webkit-scrollbar-track,
html.emby-skin .efav-chips::-webkit-scrollbar-track { background:transparent; }
html.emby-skin .efav-opts::-webkit-scrollbar-thumb,
html.emby-skin .efav-modal::-webkit-scrollbar-thumb,
html.emby-skin .efav-panel::-webkit-scrollbar-thumb,
html.emby-skin .efav-browse-body::-webkit-scrollbar-thumb,
html.emby-skin .efav-chips::-webkit-scrollbar-thumb { background:rgba(255,255,255,.2); border-radius:999px; border:2px solid transparent; background-clip:padding-box; }
html.emby-skin .efav-opts::-webkit-scrollbar-thumb:hover,
html.emby-skin .efav-modal::-webkit-scrollbar-thumb:hover,
html.emby-skin .efav-panel::-webkit-scrollbar-thumb:hover,
html.emby-skin .efav-browse-body::-webkit-scrollbar-thumb:hover,
html.emby-skin .efav-chips::-webkit-scrollbar-thumb:hover { background:rgba(255,255,255,.32); }
/* 全局透明滚动条:主页(window/body) + 收藏夹面板原生滚动条(画廊面板已有独立规则) */
html.emby-skin, html.emby-skin body {
scrollbar-width:thin; scrollbar-color:rgba(255,255,255,.18) transparent;
}
html.emby-skin::-webkit-scrollbar,
html.emby-skin body::-webkit-scrollbar,
html.emby-skin #emby-favorites-panel::-webkit-scrollbar { width:8px; height:8px; background:transparent; }
html.emby-skin::-webkit-scrollbar-track,
html.emby-skin body::-webkit-scrollbar-track,
html.emby-skin #emby-favorites-panel::-webkit-scrollbar-track { background:transparent; }
html.emby-skin::-webkit-scrollbar-thumb,
html.emby-skin body::-webkit-scrollbar-thumb,
html.emby-skin #emby-favorites-panel::-webkit-scrollbar-thumb {
background:rgba(255,255,255,.2); border-radius:999px; border:2px solid transparent; background-clip:padding-box;
}
html.emby-skin::-webkit-scrollbar-thumb:hover,
html.emby-skin body::-webkit-scrollbar-thumb:hover,
html.emby-skin #emby-favorites-panel::-webkit-scrollbar-thumb:hover { background:rgba(255,255,255,.32); }
/* ====== 悬浮按钮(回顶 / 左下切换)按视觉风格联动 ======
* 默认 EMBY:实心强调色(回顶)/ #444(切换未开),白边圆钮(上面已定义)。
* glass / liquid:均不沿用 EMBY 的蓝色,改用各自的玻璃语言。 */
/* —— 回顶按钮 —— */
html.emby-skin.emby-style-glass #emby-backtop,
html.emby-skin.emby-style-glass #javdb-emby-toggle {
background: linear-gradient(135deg, rgba(255,255,255,.22), rgba(255,255,255,.08) 60%, rgba(160,140,255,.14));
border:1px solid rgba(255,255,255,.28);
color:#fff; backdrop-filter: blur(14px) saturate(1.6);
box-shadow:0 4px 18px rgba(0,0,0,.5);
}
html.emby-skin.emby-style-glass #javdb-emby-toggle.on {
background: linear-gradient(135deg, rgba(255,255,255,.42), rgba(255,255,255,.18) 60%, rgba(180,160,255,.26));
border-color:rgba(255,255,255,.5); color:#fff;
}
html.emby-skin.emby-style-liquid #emby-backtop,
html.emby-skin.emby-style-liquid #javdb-emby-toggle {
background:
linear-gradient(135deg, rgba(255,255,255,.24), rgba(255,255,255,.08) 45%, rgba(150,140,255,.16) 100%);
border:1px solid rgba(255,255,255,.3); color:#fff;
box-shadow: inset 0 1px 0 rgba(255,255,255,.6), inset 0 -1px 0 rgba(255,255,255,.12),
inset 1px 0 0 rgba(255,255,255,.2), inset -1px 0 0 rgba(255,255,255,.1), 0 8px 28px rgba(0,0,0,.45);
}
html.emby-skin.emby-style-liquid #emby-backtop.show,
html.emby-skin.emby-style-liquid #javdb-emby-toggle.on {
background: linear-gradient(135deg, rgba(255,255,255,.5), rgba(255,255,255,.22) 45%, rgba(180,160,255,.34) 100%);
border-color:rgba(255,255,255,.55); color:#fff;
box-shadow: inset 0 1px 0 rgba(255,255,255,.8), inset 0 -1px 0 rgba(255,255,255,.16),
inset 1px 0 0 rgba(255,255,255,.28), inset -1px 0 0 rgba(255,255,255,.14), 0 8px 28px rgba(0,0,0,.45);
}
/* ====== 左侧抽屉 —— 真实 mainDrawer 样式 ======
* 真实 Emby: width 16.5em (桌面端 fine-pointer), docked left */
#emby-drawer {
position:fixed; top:0; left:0; bottom:0; width:var(--e-drawer-w); z-index:2147483001;
background:var(--e-bg); border-right:1px solid rgba(255,255,255,.06);
/* 颜色类过渡在此补齐(而非用更高特异性规则覆盖),保住抽屉滑出动画 */
transform:translateX(-100%);
transition:transform .3s cubic-bezier(.22,1,.36,1),
background-color .4s ease, border-color .4s ease;
display:flex; flex-direction:column; overflow-y:auto;
padding-top:var(--e-header-h);
box-shadow:none; /* 真实 drawer-docked: box-shadow none */
}
/* 设置面板:不跟随主题,统一轻微虚化(与 EMBY 经典风格一致,避免重模糊) */
html.emby-skin #emby-settings-panel {
backdrop-filter: blur(10px) saturate(1.3);
background: rgba(18,18,24,.82);
}
html.emby-skin.emby-drawer-open #emby-drawer { transform:translateX(0); }
#emby-drawer .brand {
padding:1.4em 1.4em .8em; color:var(--e-accent); font-weight:800;
font-size:1.25em; letter-spacing:.12em; text-transform:uppercase;
border-bottom:1px solid rgba(255,255,255,.06); user-select:none;
}
/* 抽屉品牌链接(点击回主页) */
#emby-drawer .brand .brand-link { color:inherit; text-decoration:none; display:flex; align-items:center; }
#emby-drawer .brand .brand-link svg { height:28px; width:auto; display:block; max-width:100%; }
#emby-drawer .nav { padding:.6em 0; }
#emby-drawer .nav a {
display:flex; align-items:center; padding:.78em 1.4em;
color:var(--e-text2); text-decoration:none; font-size:.95em;
border-left:3px solid transparent; transition:all .18s ease;
}
#emby-drawer .nav a:hover { background:rgba(255,255,255,.05); color:var(--e-text); }
#emby-drawer .nav a.sel { background:var(--e-accent-l); color:var(--e-accent);
border-left-color:var(--e-accent); font-weight:600; }
#emby-drawer .nav a .mi { margin-right:1em; font-size:1.45em; opacity:.8; }
#emby-drawer .nav a.sel .mi { opacity:1; }
/* 抽屉导航折叠分组与子菜单 */
#emby-drawer .drawer-group { margin:1px 0; }
#emby-drawer .drawer-group-header {
display:flex; align-items:center; width:100%; padding:.78em 1.4em;
color:var(--e-text2); text-decoration:none; font-size:.95em;
border:none; border-left:3px solid transparent; transition:all .18s ease;
cursor:pointer; background:transparent; font-family:inherit;
box-sizing:border-box; outline:none; user-select:none; text-align:left;
}
#emby-drawer .drawer-group-header:hover { background:rgba(255,255,255,.05); color:var(--e-text); }
#emby-drawer .drawer-group.has-sel > .drawer-group-header,
#emby-drawer .drawer-group.open > .drawer-group-header { color:var(--e-text); }
#emby-drawer .drawer-group.has-sel > .drawer-group-header .mi { color:var(--e-accent); opacity:1; }
#emby-drawer .drawer-group-header .mi { margin-right:1em; font-size:1.45em; opacity:.8; }
#emby-drawer .drawer-group-title { flex:1; font-weight:500; }
#emby-drawer .drawer-arrow {
font-size:1.25em; opacity:.6; transition:transform .22s cubic-bezier(.4, 0, .2, 1), opacity .22s ease;
}
#emby-drawer .drawer-group.open .drawer-arrow { transform:rotate(180deg); opacity:.95; }
#emby-drawer .drawer-subnav {
display:none; flex-direction:column; padding:.2em 0 .35em 0;
background:rgba(0,0,0,.18);
}
#emby-drawer .drawer-group.open .drawer-subnav { display:flex; }
#emby-drawer .drawer-sub-link {
display:flex; align-items:center; padding:.62em 1.4em .62em 3.35em;
color:var(--e-text2); text-decoration:none; font-size:.88em;
border-left:3px solid transparent; transition:all .18s ease;
}
#emby-drawer .drawer-sub-link:hover { background:rgba(255,255,255,.06); color:var(--e-text); }
#emby-drawer .drawer-sub-link.sel {
background:var(--e-accent-l); color:var(--e-accent);
border-left-color:var(--e-accent); font-weight:600;
}
/* 抽屉底部:原版账号菜单(从 navbar-end 提取) */
#emby-drawer .acct-menu { border-top:1px solid rgba(255,255,255,.06); padding-top:.3em; }
#emby-drawer .acct-divider {
height:1px; background:rgba(255,255,255,.06); margin:.5em 1.4em;
}
#emby-drawer .acct-item {
display:flex; align-items:center; padding:.65em 1.4em;
color:var(--e-text2); text-decoration:none; font-size:.88em;
border-left:3px solid transparent; transition:all .18s ease; cursor:pointer;
}
#emby-drawer .acct-item:hover { background:rgba(255,255,255,.05); color:var(--e-text); }
#emby-drawer .acct-item .mi { margin-right:1em; font-size:1.25em; opacity:.7; }
/* 账号菜单内部分组分隔线(对应原版下拉菜单的 hr/divider) */
#emby-drawer .acct-sep {
height:1px; background:rgba(255,255,255,.05); margin:.35em 1.4em;
}
/* 用户名分组头(灰色素描图标 + 用户名文本) */
#emby-drawer .acct-user {
display:flex; align-items:center; gap:.55em; padding:.65em 1.4em .35em;
color:var(--e-text); font-weight:600; font-size:.9em;
}
#emby-drawer .acct-user .mi { font-size:1.35em; opacity:.75; }
#emby-drawer .acct-uname {
overflow:hidden; text-overflow:ellipsis; white-space:nowrap; flex:1;
}
#emby-drawer-scrim {
position:fixed; inset:0; background:rgba(0,0,0,.55);
z-index:2147482999; opacity:0; pointer-events:none; transition:opacity .3s ease;
}
html.emby-skin.emby-drawer-open #emby-drawer-scrim { opacity:1; pointer-events:auto; }
/* 风格切换 1 秒遮罩:覆盖整页,淡入淡出盖住重绘 */
.efav-style-morph {
position:fixed; inset:0; z-index:2147483099; pointer-events:none;
background: rgba(18,18,24,.55);
opacity:1;
transition: opacity 1s ease;
}
.efav-style-morph.hide { opacity:0; }
/* ====== 海报卡片网格 —— 真实 Emby cardBox 行为 ======
* 真实 Emby:
* - box-shadow: 0 8px 10px rgb(0 0 0 / 15%)
* - backdropCard:hover → scale(1.1), cubic-bezier(0, 1.75, .25, 1) 1.5s
* - cardText overlay: rgba(0,0,0,.5), border-radius:.3em, hidden→show on hover
* - cardOverlayContainer: background none (自定义主题覆盖) */
html.emby-skin .movie-list {
display:grid !important;
grid-template-columns:repeat(auto-fill, minmax(var(--e-card-w,300px), 1fr)) !important;
gap:1.2em 1em !important;
padding:1.4em 1.6em 3em !important;
max-width:1600px; margin:0 auto !important;
background:transparent !important;
}
html.emby-skin .item {
position:relative; break-inside:avoid;
}
/* 卡片容器 —— 真实 Emby cardBox-touchzoom 阴影 */
html.emby-skin .item .box {
display:flex !important;
flex-direction:column !important;
position:relative !important;
border-radius:var(--e-r) !important;
overflow:hidden !important;
background:var(--e-card) !important;
/* 真实 Emby 卡片阴影 */
box-shadow:0 8px 10px rgb(0 0 0 / 15%) !important;
/* STEAM 风格卡牌 3D 倾斜:高频跟随鼠标更新,过渡要短;preserve-3d 保留透视。
* 注:旧版被全局 body * 规则(特异性 0,5,2 > 本规则 0,3,1)强行拉成 .3s,
* 倾斜跟手感发黏;全局规则已收敛到形变窗口,本条 .18s 现在才真正生效。 */
transition:transform .18s ease, box-shadow .3s ease,
background-color .4s ease, border-color .4s ease !important;
transform-style:preserve-3d;
text-decoration:none !important; color:inherit !important;
height:100% !important;
}
/* 真实 Emby 卡片悬停: scale(1.1) 已由 JS 实时 3D 倾斜替代 */
html.emby-skin .item:hover .box,
html.emby-skin .emby-row .item:hover .box {
box-shadow:0 18px 40px rgb(0 0 0 / 45%), 0 0 0 1px var(--e-accent-l) !important;
z-index:3; position:relative;
}
/* 海报图区域 */
html.emby-skin .item .box .cover {
position:relative !important;
flex:1 1 auto !important;
min-height:0 !important;
overflow:hidden !important;
background:#111 !important;
}
html.emby-skin .item .box .cover img {
width:100% !important; height:100% !important;
object-fit:cover !important;
display:block !important;
aspect-ratio:2 / 3 !important;
transition:transform .35s ease !important;
}
/* ====== STEAM 风格卡牌高光层(替代 Emby 文字/播放浮层) ======
* 鼠标悬停时显示跟随光标的高光,配合 JS 实时 3D 倾斜营造卡牌立体感 */
html.emby-skin .item .box .emby-card-shine,
html.emby-skin .emby-row .item .box .emby-card-shine {
position:absolute; inset:0; pointer-events:none; z-index:3;
border-radius:var(--e-r); opacity:0;
background:radial-gradient(circle at var(--mx,50%) var(--my,50%),
rgba(255,255,255,.38), rgba(255,255,255,0) 30%);
mix-blend-mode:screen; transition:opacity .25s ease;
}
html.emby-skin .item:hover .box .emby-card-shine,
html.emby-skin .emby-row .item:hover .box .emby-card-shine { opacity:1; }
/* 卡片标题区 */
html.emby-skin .item .box .video-title {
padding:.55em .6em .2em !important;
font-size:.82em !important; line-height:1.35 !important;
color:var(--e-text2) !important;
white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
order:1 !important;
transition:color .4s ease; /* 悬停提亮:原依赖全局 body * 规则,改为就近声明 */
}
html.emby-skin .item:hover .box .video-title { color:var(--e-text) !important; }
html.emby-skin .item .box .score {
padding:0 .6em .15em !important;
font-size:.76em !important; color:var(--e-text3) !important;
order:2 !important;
transition:color .4s ease;
}
html.emby-skin .item .box .meta {
padding:0 .6em .4em !important;
font-size:.72em !important; color:var(--e-text3) !important;
order:3 !important;
transition:color .4s ease;
}
html.emby-skin .item .box .tags {
padding:.15em .6em .55em !important;
order:4 !important;
}
html.emby-skin .item .box .tags .tag {
font-size:.68em !important; border-radius:999px !important;
padding:.15em .55em !important; margin-right:.35em !important;
}
/* ====== 详情页 Emby Hero —— 真实 backgroundContainer 样式 ======
* 真实 Emby: .backgroundContainer { position:fixed; inset:0; contain:strict }
* (#emby-detail-bg / #emby-detail-scrim 的实际规则统一在下方「详情页重建布局」段定义) */
/* Hero 容器 */
.emby-detail-hero {
position:relative; z-index:5;
display:flex; gap:2.2em; padding:.6em 0 1em;
align-items:flex-start;
}
/* 左侧海报 */
.emby-detail-hero .poster-wrap {
width:var(--e-cover-w,340px); min-width:var(--e-cover-w,340px); flex-shrink:0;
border-radius:var(--e-r); overflow:hidden;
/* 去黑底,避免放大后有黑边;仅留细边框与阴影提升质感 */
box-shadow:0 12px 30px rgba(0,0,0,.55);
background:transparent;
border:1px solid rgba(255,255,255,.08);
transform:translateY(0);
transition:transform .3s ease;
}
.emby-detail-hero .poster-wrap:hover { transform:scale(1.02); }
.emby-detail-hero .poster-wrap img {
width:100%; height:auto; display:block; aspect-ratio:2/3; object-fit:cover;
}
/* 右侧信息区 */
.emby-detail-hero .info { flex:1; min-width:0; padding-bottom:1em; }
.emby-detail-hero .info h1 {
margin:0 0 .45em; font-size:1.9em; font-weight:700; line-height:1.2;
text-shadow:0 2px 8px rgba(0,0,0,.5);
}
/* 徽章行 */
.emby-detail-hero .info .badges {
display:flex; flex-wrap:wrap; gap:.5em; margin:.5em 0 1.1em;
}
.emby-detail-hero .info .badge {
background:rgba(255,255,255,.1); border:1px solid rgba(255,255,255,.08);
border-radius:999px; padding:.28em .85em; font-size:.78em;
color:var(--e-text2); white-space:nowrap;
}
.emby-detail-hero .info .badge.acc {
background:var(--e-accent); border-color:var(--e-accent);
color:#000; font-weight:700;
}
/* 详情页多源播放选择菜单 —— 方案 B:分体式双功能胶囊键 (Split Pill Button) */
.emby-play-group {
position:relative; display:inline-flex; align-items:center;
vertical-align:middle;
}
.emby-play-split-btn {
display:inline-flex; align-items:center; height:48px;
background:var(--e-accent, #00a4dc); border-radius:999px;
box-shadow:0 4px 14px rgba(0,164,220,.35);
transition:transform .18s ease, box-shadow .18s ease, filter .18s ease;
overflow:hidden; box-sizing:border-box;
min-width:148px; max-width:215px;
}
.emby-play-split-btn:hover {
box-shadow:0 6px 20px rgba(0,164,220,.48);
filter:brightness(1.06);
}
.emby-play-main-action {
flex:1; min-width:0; height:100%;
display:inline-flex; align-items:center; gap:8px;
padding:0 12px 0 16px;
color:#fff !important; text-decoration:none !important;
font-weight:700; font-size:14px; letter-spacing:.02em;
cursor:pointer; user-select:none;
transition:background .15s ease;
white-space:nowrap;
}
.emby-play-main-action:hover {
background:rgba(255,255,255,.12);
}
.emby-play-main-action:active {
background:rgba(0,0,0,.15);
}
.emby-play-btn-logo {
display:inline-flex !important; align-items:center !important; justify-content:center !important;
width:22px !important; height:22px !important; max-width:22px !important; max-height:22px !important;
overflow:hidden !important; flex-shrink:0 !important;
}
.emby-play-btn-logo .emby-site-logo-wrap,
.emby-site-logo-wrap {
width:20px !important; height:20px !important; max-width:20px !important; max-height:20px !important;
border-radius:5px !important; background:rgba(255,255,255,.2); border:1px solid rgba(255,255,255,.3);
box-shadow:0 1px 3px rgba(0,0,0,.2); overflow:hidden !important; flex-shrink:0 !important;
display:inline-flex; align-items:center; justify-content:center;
}
.emby-play-btn-logo .emby-site-logo-fallback {
color:#fff; font-size:10px; font-weight:800;
}
img.emby-site-logo,
.emby-site-logo-wrap img,
.emby-site-logo-wrap svg {
width:100% !important; height:100% !important; max-width:20px !important; max-height:20px !important;
object-fit:contain !important; vertical-align:middle !important;
}
.emby-play-label {
flex:1; min-width:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
}
.emby-play-split-divider {
width:1px; height:22px;
background:rgba(255,255,255,.28);
flex-shrink:0; pointer-events:none;
}
.emby-play-arrow-action {
width:38px; height:100%; border:none; outline:none;
background:transparent; color:#fff;
display:inline-flex; align-items:center; justify-content:center;
cursor:pointer; padding:0; flex-shrink:0;
transition:background .15s ease, color .15s ease;
}
.emby-play-arrow-action:hover {
background:rgba(255,255,255,.18);
}
.emby-play-arrow-action:active {
background:rgba(0,0,0,.2);
}
.emby-play-arrow-icon {
font-size:22px !important; line-height:1;
transform:rotate(-90deg); /* 普通模式默认朝右 ▶ 指示向右横向展开 */
transition:transform .22s cubic-bezier(0.16, 1, 0.3, 1);
}
.emby-play-group.menu-open .emby-play-arrow-icon {
transform:rotate(90deg); /* 普通模式展开后朝左 ◀ 指示向左收起 */
}
/* 混合模式:保持上下方向(闭合朝下 ▼,展开朝上 ▲) */
html.emby-skin.cover-hybrid .emby-play-arrow-icon {
transform:rotate(0deg);
}
html.emby-skin.cover-hybrid .emby-play-group.menu-open .emby-play-arrow-icon {
transform:rotate(180deg);
}
/* 弹窗展开时大幅提升 Hero 与播放按钮容器的层叠上下文,确保面板绝对置顶,绝不被下方预览图/缩略图/海报遮挡 */
.emby-detail-hero:has(.emby-play-group.menu-open),
.emby-detail-hero.has-menu-open {
z-index:2147482500 !important;
}
.emby-play-group.menu-open {
z-index:2147482600 !important;
}
/* 兼容未开启外部播放站点插件时的原生播放按钮 (48px 胶囊形态,自适应宽度且内容严格居中) */
html.emby-skin .emby-play-btn,
html.emby-skin .emby-play-native-btn {
display:inline-flex !important; align-items:center !important; justify-content:center !important; gap:8px !important;
height:48px !important; padding:0 24px !important; border-radius:999px !important;
background:var(--e-accent, #00a4dc) !important; color:#fff !important;
font-weight:700 !important; font-size:14px !important; text-decoration:none !important;
box-shadow:0 4px 14px rgba(0,164,220,.35) !important;
transition:transform .18s ease, box-shadow .18s ease, filter .18s ease !important;
cursor:pointer !important; user-select:none !important; box-sizing:border-box !important;
width:fit-content !important; min-width:auto !important; max-width:none !important;
}
html.emby-skin .emby-play-btn .emby-play-label,
html.emby-skin .emby-play-native-btn .emby-play-label {
text-align:center !important;
}
html.emby-skin .emby-play-btn:hover {
box-shadow:0 6px 20px rgba(0,164,220,.48) !important;
filter:brightness(1.06) !important;
transform:translateY(-1px) !important;
}
/* ====== v4.30 详情信息字段(保留两栏 Hero,按原版 JAVDB 结构竖排,沿用 chip 风格) ====== */
.emby-meta { margin:.7em 0 1.1em; display:flex; flex-direction:column; }
.em-row { display:flex; align-items:center; gap:.7em; font-size:.92em; padding:.26em 0; flex-wrap:wrap; }
.em-k { color:var(--e-text3); font-size:.8em; min-width:2.6em; flex-shrink:0; letter-spacing:.04em; }
.em-v { color:var(--e-text); }
.em-link { color:var(--e-accent); text-decoration:none; }
.em-link:hover { text-decoration:underline; }
.em-copy,
.em-note-btn {
display:inline-flex; align-items:center; justify-content:center; background:none; border:none;
color:var(--e-text3); cursor:pointer; padding:.1em .3em; border-radius:6px; transition:all .18s ease;
}
.em-copy:hover, .em-note-btn:hover { color:var(--e-text); background:rgba(255,255,255,.08); }
.em-copy .material-icons, .em-copy .material-symbols-outlined,
.em-note-btn .material-icons, .em-note-btn .material-symbols-outlined { font-size:1.05em; }
.em-copy.copied { color:#4caf50; }
/* 作品有备注时点亮(橙色),一眼识别 */
.em-note-btn.has-note { color:#ffb84d; }
.em-note-btn.has-note:hover { color:#ffc966; }
/* ====== 女优与实体一体化精美悬浮卡片(方案 1:生平体检 + 现役退役 + 个人备注 + 穿透收藏) ====== */
.emby-note-tip {
position:fixed; z-index:2147483647; pointer-events:auto;
background:rgba(24,24,28,.96); backdrop-filter:blur(18px); -webkit-backdrop-filter:blur(18px);
border:1px solid rgba(255,255,255,.18); border-radius:10px;
box-shadow:0 12px 36px rgba(0,0,0,.7), 0 2px 8px rgba(0,0,0,.4);
padding:12px 14px; max-width:320px; min-width:220px;
color:#eee; font-family:"Segoe UI",Roboto,sans-serif;
opacity:0; visibility:hidden; transform:translateY(6px) scale(.97);
transition:opacity .18s ease, transform .18s ease, visibility .18s ease;
}
.emby-note-tip.show { opacity:1; visibility:visible; transform:translateY(0) scale(1); }
.emby-note-tip .ap-header {
display:flex; align-items:center; gap:8px; margin-bottom:8px;
padding-bottom:6px; border-bottom:1px solid rgba(255,255,255,.1);
}
.emby-note-tip .ap-name {
font-size:14px; font-weight:700; color:#fff;
}
.emby-note-tip .ap-status {
font-size:10px; font-weight:700; padding:1px 6px; border-radius:10px; line-height:1.3;
}
.emby-note-tip .ap-status.active {
background:rgba(34,197,94,.18); color:#4ade80; border:1px solid rgba(34,197,94,.35);
}
.emby-note-tip .ap-status.retire {
background:rgba(156,163,175,.15); color:#9ca3af; border:1px solid rgba(156,163,175,.3);
}
.emby-note-tip .ap-status.actor {
background:rgba(255,255,255,.08); color:#aaa; border:1px solid rgba(255,255,255,.15);
}
.emby-note-tip .ap-fav-btn {
margin-left:auto; font-size:11px; display:inline-flex; align-items:center; gap:4px;
background:rgba(255,255,255,.08); color:#ddd; border:1px solid rgba(255,255,255,.16);
padding:2px 8px; border-radius:12px; cursor:pointer; transition:all .18s ease;
}
.emby-note-tip .ap-fav-btn:hover {
background:rgba(225,29,72,.15); color:#ff4d6d; border-color:rgba(225,29,72,.4);
}
.emby-note-tip .ap-fav-btn.active {
background:rgba(225,29,72,.22); color:#ff4d6d; border-color:rgba(225,29,72,.5);
box-shadow:0 0 8px rgba(225,29,72,.3);
}
.emby-note-tip .ap-fav-badge {
margin-left:auto; font-size:11px; display:inline-flex; align-items:center; gap:3px;
padding:2px 8px; border-radius:12px;
}
.emby-note-tip .ap-fav-badge.favorited {
background:rgba(225,29,72,.22); color:#ff4d6d; border:1px solid rgba(225,29,72,.5);
box-shadow:0 0 8px rgba(225,29,72,.3);
}
.emby-note-tip .ap-fav-heart { font-size:11.5px; }
.emby-note-tip .ap-tags {
display:flex; flex-wrap:wrap; gap:5px; margin-bottom:6px;
}
.emby-note-tip .ap-tags.empty { margin-bottom:4px; }
.emby-note-tip .ap-tag {
font-size:11px; padding:2px 7px; border-radius:4px;
background:rgba(255,255,255,.08); color:var(--e-text2, #ddd);
border:1px solid rgba(255,255,255,.1);
}
.emby-note-tip .ap-tag-sub {
font-size:10.5px; color:#888;
}
.emby-note-tip .ap-note-box {
margin-top:6px; padding:7px 10px; border-radius:6px;
background:linear-gradient(135deg, rgba(255,184,77,.12) 0%, rgba(255,184,77,.04) 100%);
border-left:3px solid #ffb84d; cursor:pointer; transition:all .15s ease;
}
.emby-note-tip .ap-note-box:hover {
background:linear-gradient(135deg, rgba(255,184,77,.18) 0%, rgba(255,184,77,.08) 100%);
}
.emby-note-tip .ap-note-box.empty {
background:rgba(255,255,255,.04); border-left:3px dashed rgba(255,255,255,.2);
}
.emby-note-tip .ap-note-prompt {
font-size:11px; color:#aaa; display:flex; align-items:center; gap:4px;
}
.emby-note-tip .ap-note-title {
font-size:11px; font-weight:700; color:#ffb84d; margin-bottom:3px;
display:flex; align-items:center; gap:4px;
}
.emby-note-tip .ap-note-content {
font-size:12px; line-height:1.45; color:#ffe8cc; word-break:break-word;
}
/* 評分(星级 + 分数文本,操作按钮已移至播放按钮后圆形图标) */
.em-rating { align-items:center; }
.em-stars { display:inline-flex; gap:1px; font-size:1.1em; line-height:1; }
.em-stars .star.full { color:#f5c518; }
.em-stars .star.half { color:#f5c518; opacity:.6; }
.em-stars .star.empty { color:rgba(255,255,255,.2); }
.em-score { color:var(--e-text2); font-size:.88em; margin-left:.25em; }
/* ====== 播放按钮 + 圆形操作按钮组(同一行,垂直居中对齐) ====== */
.emby-btn-group {
display:flex; align-items:center; gap:1em; flex-wrap:wrap;
margin-top:1.2em;
}
/* ====== 圆形操作按钮行(紧跟播放按钮,参考 Emby 截图;高度对齐播放按钮) ====== */
.emby-action-row {
display:inline-flex; align-items:center; gap:.7em;
}
.emby-circle-btn {
display:inline-flex; align-items:center; justify-content:center;
/* 高度与播放按钮对齐:13px 垂直 padding + 15px 内容 = ~44px */
width:48px; height:48px; padding:0;
border-radius:50%;
background:transparent; border:1.5px solid rgba(255,255,255,.28);
color:rgba(255,255,255,.7); cursor:pointer;
transition:background .2s ease, border-color .2s ease, color .2s ease, transform .15s ease;
flex-shrink:0; outline:none; padding:0; position:relative; overflow:hidden;
font-family:inherit;
}
.emby-circle-btn .material-icons, .emby-circle-btn .material-symbols-outlined {
font-size:22px; line-height:1; transition:transform .2s ease;
display:inline-flex; align-items:center; justify-content:center;
}
.emby-circle-btn:hover {
background:rgba(255,255,255,.08); border-color:rgba(255,255,255,.5);
color:rgba(255,255,255,.95); transform:translateY(-1px);
}
.emby-circle-btn:hover .material-icons { transform:scale(1.08); }
.emby-circle-btn:active { transform:translateY(0) scale(.96); }
.emby-circle-btn.active {
background:var(--e-accent-l); border-color:var(--e-accent);
color:var(--e-accent);
}
.emby-circle-btn.active .material-icons { transform:scale(1.05); }
/* 已标记态:按类型用不同颜色 + 实心图标,让重新访问已标记视频时可一眼识别 */
.emby-circle-btn.active[data-type="wishlist"] { color:var(--e-accent); border-color:var(--e-accent); background:rgba(0,164,220,.14); }
.emby-circle-btn.active[data-type="watched"] { color:#4caf50; border-color:#4caf50; background:rgba(76,175,80,.14); }
.emby-circle-btn.active[data-type="list"] { color:#ff5b7f; border-color:#ff5b7f; background:rgba(255,91,127,.14); }
/* 点击涟漪 */
.emby-circle-btn::after {
content:''; position:absolute; inset:0; border-radius:50%;
background:currentColor; opacity:0; transform:scale(.5);
transition:opacity .4s ease, transform .4s ease; pointer-events:none;
}
.emby-circle-btn.rippling::after { opacity:.18; transform:scale(1.4); transition:none; }
/* Toast 提示(操作按钮点击反馈) */
.emby-toast {
position:fixed; bottom:2.5em; left:50%; transform:translateX(-50%) translateY(1em);
background:rgba(0,0,0,.82); color:#fff; border:1px solid rgba(255,255,255,.12);
padding:.6em 1.4em; border-radius:10px; font-size:.85em;
opacity:0; transition:all .3s ease; z-index:2147483647;
pointer-events:none; white-space:nowrap; letter-spacing:.02em;
}
.emby-toast.show { opacity:1; transform:translateX(-50%) translateY(0); }
/* (收藏状态已合并进 list 圆按钮:未收藏=playlist_add,已收藏=红心) */
/* 類別 chips */
.em-cat {
display:inline-block; background:rgba(255,255,255,.09); border:1px solid rgba(255,255,255,.1);
color:var(--e-text2); padding:.2em .7em; border-radius:999px; font-size:.78em;
text-decoration:none; margin:.12em .35em .12em 0; transition:all .18s ease;
}
.em-cat:hover { background:rgba(255,255,255,.15); color:var(--e-text); }
/* 元数据可点 chip(番號/導演/片商/系列 — 与類別标签视觉统一,白色文字) */
.em-meta-chip {
display:inline-flex; align-items:center;
background:rgba(255,255,255,.09); border:1px solid rgba(255,255,255,.1);
color:var(--e-text2); padding:.22em .75em; border-radius:999px; font-size:.85em;
text-decoration:none !important; transition:all .18s ease; white-space:nowrap;
}
.em-meta-chip:hover { background:rgba(255,255,255,.16); border-color:rgba(255,255,255,.2); color:#fff; }
/* 隐藏 javdb 原始详情头部 */
html.emby-skin .emby-hide-original { display:none !important; }
/* soft hide:保持元素在 DOM 中可访问(JS handler 可触发),但视觉不可见。
关键修复:原 left:-9999px + 1px + overflow:hidden 会把内部弹出的「看過」下拉菜单
一起推到屏幕外并裁剪掉,导致点击圆按钮触发原生下拉后「小窗口被遮挡」。
改为停在视口左上角、不裁剪,并让内部弹出的下拉/模态保持可交互。
容器 z-index 抬高:其内部弹出的下拉/模态属于同一层叠上下文,需高于页面正文
(#emby-detail-root 为 z-index:1),否则会被详情页内容盖住。原生浮层真正打开时
EMBY 固定层(顶栏/抽屉)会被 emby-native-overlay-open 隐藏,不会冲突。 */
html.emby-skin .emby-hide-soft {
position:absolute !important; left:0 !important; top:0 !important;
width:0 !important; height:0 !important; overflow:visible !important;
opacity:0 !important; pointer-events:none !important; z-index:2147483600 !important;
}
/* 内部弹出的原生下拉菜单/模态必须可交互,不能被容器的 pointer-events:none 阻断 */
html.emby-skin .emby-hide-soft .dropdown-menu,
html.emby-skin .emby-hide-soft .modal,
html.emby-skin .emby-hide-soft .modal-background,
html.emby-skin .emby-hide-soft .modal-card { pointer-events:auto !important; }
/* 原生弹窗打开时(emby-native-overlay-open),把 0x0+opacity:0 的 soft 容器恢复可见:
否则「看過」标记弹窗、短評規則弹窗虽打开却隐形(opacity 作用于所有后代)。 */
html.emby-native-overlay-open .emby-hide-soft { opacity:1 !important; pointer-events:auto !important; }
/* ====== 详情页重建布局 ====== */
#emby-detail-root { position:relative; z-index:1; }
/* 高斯模糊大封面背景(Emby 精致感关键,fixed 全屏) */
html.emby-skin #emby-detail-bg {
position:fixed; inset:0; z-index:0;
background-size:cover; background-position:center 18%;
/* 模糊强度由设置面板实时调节(默认 4px,可见封面轮廓) */
filter:blur(var(--e-blur,4px)) brightness(.42) saturate(1.3);
transform:scale(1.12);
contain:strict; /* 真实 Emby backgroundContainer 同款(自早期重复规则块合并而来) */
}
html.emby-skin #emby-detail-scrim {
position:fixed; inset:0; z-index:0; pointer-events:none;
background:
linear-gradient(to bottom, rgba(0,0,0,.45) 0%, rgba(0,0,0,.12) 24%, rgba(0,0,0,.12) 68%, var(--e-bg) 100%),
linear-gradient(to right, rgba(0,0,0,.55), transparent 60%);
}
.emby-detail-body { max-width:1500px; margin:0 auto; padding:.8em 2.4em 4em; position:relative; z-index:1; }
/* 区段标题(Emby 风) */
.emby-sec { margin-top:2.6em; }
.emby-sec > h2 {
font-size:1.02em; font-weight:600; letter-spacing:.04em; color:var(--e-text);
margin:0 0 .9em; padding-left:.55em; border-left:.22em solid var(--e-accent);
}
/* 可点标签 chips */
.emby-tags { display:flex; flex-wrap:wrap; gap:.5em; margin:.5em 0 1.1em; }
.emby-tag-chip {
display:inline-flex; align-items:center; text-decoration:none;
background:rgba(255,255,255,.09); border:1px solid rgba(255,255,255,.1);
color:var(--e-text2); padding:.34em .9em; border-radius:999px; font-size:.8em;
transition:all .18s ease;
}
.emby-tag-chip:hover { background:var(--e-accent); color:#000; border-color:var(--e-accent); }
/* 字段前缀(導演 / 片商 / 系列 / 番號 等),用于区分不同标签 */
.emby-tag-chip .lbl { opacity:.6; margin-right:.45em; font-size:.88em; font-weight:400; }
.emby-tag-chip:hover .lbl { opacity:.8; }
/* 番號 强调色 chip */
.emby-tag-chip.acc { background:var(--e-accent); color:#000; border-color:var(--e-accent); font-weight:600; }
.emby-tag-chip.acc .lbl { opacity:.72; }
/* 演员头像 chips(头像 + 名字,可点跳作品页) */
.emby-actors { display:flex; flex-wrap:wrap; gap:1.1em; margin:.3em 0 1.2em; }
.emby-actor-chip {
display:flex; flex-direction:column; align-items:center; width:84px;
text-decoration:none; color:var(--e-text2); transition:transform .2s ease, color .2s ease;
}
.emby-actor-chip:hover { transform:translateY(-3px); color:#fff; }
.emby-actor-chip .ava {
width:72px; height:72px; border-radius:50%; background:#222 center/cover no-repeat;
border:2px solid rgba(255,255,255,.28); box-shadow:0 .25em .7em rgba(0,0,0,.5);
overflow:visible !important; position:relative !important;
transition:border-color .2s ease, box-shadow .2s ease;
}
.emby-actor-chip .ava img { width:100%; height:100%; object-fit:cover; display:block; border-radius:50%; }
.emby-actor-chip:hover .ava { border-color:rgba(255,255,255,.5); box-shadow:0 .3em .9em rgba(0,0,0,.5); }
.emby-actor-chip .nm {
margin-top:.45em; font-size:.78em; text-align:center;
white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:84px;
}
/* 已收藏女优头像右上角纯粹立体小红心徽章(无底圈纯粹小红心) */
.emby-actor-chip .ava.is-favorite::after {
content:"♥"; position:absolute; top:-6px; right:-6px;
background:none !important; border:none !important; border-radius:0 !important;
color:#ef4444; font-size:16px; font-weight:900; line-height:1;
text-shadow:0 1px 3px rgba(0,0,0,.95), 0 0 2px #fff;
z-index:4; pointer-events:none;
filter:drop-shadow(0 2px 5px rgba(239,68,68,.7));
}
/* 横向滚动行(相关影片 / 还出演):紧凑间距 + 更大封面 */
.emby-row { display:flex; gap:.55em; overflow-x:auto; padding-bottom:.8em; scrollbar-width:thin; scrollbar-color:rgba(255,255,255,.12) transparent; }
.emby-row .item { flex:0 0 auto; width:var(--e-rel-w,210px); }
/* 推荐行:去掉原黑边/深色卡片框,与主页一致的无边框纯净卡牌观感 */
html.emby-skin .emby-row .item .box {
height:auto !important;
background:transparent !important;
border:none !important;
box-shadow:0 8px 16px rgb(0 0 0 / 22%) !important;
}
html.emby-skin .emby-row .item .box .cover { background:transparent !important; }
/* 横向滚动条淡化(webkit):默认极淡、hover 略显,避免破坏整体观感 */
html.emby-skin .emby-row::-webkit-scrollbar { height:8px; background:transparent; }
html.emby-skin .emby-row::-webkit-scrollbar-track { background:transparent; }
html.emby-skin .emby-row::-webkit-scrollbar-thumb {
background:rgba(255,255,255,.12); border-radius:8px;
}
html.emby-skin .emby-row::-webkit-scrollbar-thumb:hover { background:rgba(255,255,255,.22); }
/* 原版预览(Fancybox 原生播放/看图控件,移动保留 data-fancybox,可看原站预告片/样图,不再自写灯箱) */
.emby-native-preview { margin-top:2.6em; }
/* 原版灯箱(Fancybox)打开时,隐藏 EMBY 自有的固定层,避免顶栏等高 z-index 盖住灯箱右上角关闭按钮导致无法点击 */
html.emby-fancybox-open #emby-topbar,
html.emby-fancybox-open #emby-drawer,
html.emby-fancybox-open #emby-settings-panel,
html.emby-fancybox-open #javdb-emby-toggle,
html.emby-fancybox-open #emby-compact-bar { display:none !important; }
/* 原版弹窗/下拉守卫:点击「看過」下拉、「存入清單」模态等原生浮层打开时,
隐藏 EMBY 固定层(顶栏/抽屉/设置/开关)并提升原生浮层层级,避免被遮挡/顶栏盖住 */
html.emby-native-overlay-open #emby-topbar,
html.emby-native-overlay-open #emby-drawer,
html.emby-native-overlay-open #emby-settings-panel,
html.emby-native-overlay-open #javdb-emby-toggle,
html.emby-native-overlay-open #emby-compact-bar { display:none !important; }
html.emby-native-overlay-open .modal,
html.emby-native-overlay-open .modal-background,
html.emby-native-overlay-open .modal-card,
html.emby-native-overlay-open .dropdown-menu { z-index:2147483600 !important; }
.emby-preview-title-row {
display: flex;
align-items: center;
gap: 12px;
margin: 0 0 .9em;
}
.emby-native-preview > h2,
.emby-preview-title-row > h2 {
font-size: 1.02em; font-weight: 600; letter-spacing: .04em; color: var(--e-text);
margin: 0; padding-left: .55em; border-left: .22em solid var(--e-accent);
line-height: 1.2;
}
.emby-preview-sniff-btn {
position: static !important;
opacity: 0.85 !important;
visibility: visible !important;
pointer-events: auto !important;
display: inline-flex !important;
align-items: center;
justify-content: center;
width: 2.2rem !important;
height: 2.2rem !important;
padding: 0 !important;
border-radius: 50% !important;
color: #fff !important;
cursor: pointer;
transition: all 0.25s cubic-bezier(0.2, 0, 0.2, 1);
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.35);
box-sizing: border-box;
}
.emby-preview-sniff-btn:hover {
opacity: 1 !important;
transform: scale(1.08);
color: #38bdf8 !important;
border-color: rgba(56, 189, 248, 0.6) !important;
box-shadow: 0 4px 16px rgba(56, 189, 248, 0.35);
}
.emby-native-preview .preview-images {
display:flex !important; gap:.7em; overflow-x:auto; padding-bottom:.6em; scrollbar-width:thin;
scroll-behavior:auto !important; scrollbar-color:rgba(255,255,255,.12) transparent;
}
/* 推荐/预览图横向滚动条淡化(webkit) */
html.emby-skin .emby-native-preview .preview-images::-webkit-scrollbar { height:8px; background:transparent; }
html.emby-skin .emby-native-preview .preview-images::-webkit-scrollbar-track { background:transparent; }
html.emby-skin .emby-native-preview .preview-images::-webkit-scrollbar-thumb {
background:rgba(255,255,255,.12); border-radius:8px;
}
html.emby-skin .emby-native-preview .preview-images::-webkit-scrollbar-thumb:hover { background:rgba(255,255,255,.22); }
/* 修复:原版 Fancybox 打开预览时会给 .tile-images(本节点带该类)加 display:none,
用 !important 强制保持整行预览图可见 */
.emby-native-preview .preview-images .tile-item {
flex:0 0 auto; width:230px; border-radius:.25em; overflow:hidden; display:block !important;
background:#181818; box-shadow:0 .2em .6em rgba(0,0,0,.5);
border:1px solid rgba(255,255,255,.06); cursor:zoom-in;
position:relative;
}
.emby-native-preview .preview-images .tile-item img {
width:100%; height:135px; object-fit:cover; display:block; transition:transform .3s ease;
}
.emby-native-preview .preview-images .tile-item:hover img { transform:scale(1.06); }
/* ====== 预告片容器样式(javdb 原版:.preview-video-container,与 .tile-item 是兄弟关系) ======
原版结构:<a class="preview-video-container" href="/login">
<span>預告片</span> ← 黄色竖标签(需隐藏)
<img class="video-cover" style="width:150px"> ← 封面占位(需统一尺寸)
</a>
目标:与 .tile-item 同尺寸(230×135),隐藏原版黄色标签,加播放按钮覆盖层 + chip 标识 */
html.emby-skin .emby-native-preview .preview-images .preview-video-container {
flex:0 0 auto; width:230px; height:135px; border-radius:.25em; overflow:hidden;
display:block !important; position:relative;
background:#181818; box-shadow:0 .2em .6em rgba(0,0,0,.5);
border:1px solid rgba(255,255,255,.06); cursor:pointer; z-index:1;
}
/* 统一封面图尺寸(覆盖原版 inline style width:150px) */
html.emby-skin .emby-native-preview .preview-images .preview-video-container .video-cover {
width:100% !important; height:135px !important; object-fit:cover; display:block;
transition:transform .3s ease;
}
html.emby-skin .emby-native-preview .preview-images .preview-video-container:hover .video-cover { transform:scale(1.06); }
/* 隐藏原版黄色「預告片」<span> 标签 */
html.emby-skin .emby-native-preview .preview-images .preview-video-container > span { display:none !important; }
/* 播放按钮覆盖层(居中圆形 ▶) */
html.emby-skin .emby-native-preview .preview-images .preview-video-container::before {
content:'▶'; position:absolute; left:50%; top:50%;
transform:translate(-50%,-50%) scale(.9);
width:44px; height:44px; border-radius:50%;
background:rgba(255,255,255,.18); backdrop-filter:blur(6px);
border:1.5px solid rgba(255,255,255,.4);
color:#fff; font-size:15px; line-height:44px; text-align:center;
letter-spacing:-2px; opacity:.88; transition:all .25s ease;
pointer-events:none; z-index:2;
}
html.emby-skin .emby-native-preview .preview-images .preview-video-container:hover::before {
transform:translate(-50%,-50%) scale(1);
background:rgba(255,255,255,.28); border-color:rgba(255,255,255,.6);
opacity:1; box-shadow:0 4px 20px rgba(0,0,0,.45);
}
/* hover 暗化遮罩 */
html.emby-skin .emby-native-preview .preview-images .preview-video-container::after {
content:''; position:absolute; inset:0;
background:rgba(0,0,0,.38); opacity:0; transition:opacity .25s ease;
pointer-events:none; z-index:1; border-radius:.2em;
}
html.emby-skin .emby-native-preview .preview-images .preview-video-container:hover::after { opacity:1; }
/* 右下角「預告片」chip 标识(JS 注入,替代原版黄色竖标签) */
html.emby-skin .emby-native-preview .preview-images .preview-video-container .trailer-label {
position:absolute; right:6px; bottom:6px; z-index:3;
background:rgba(0,0,0,.55); color:rgba(255,255,255,.85);
font-size:.7em; padding:2px 7px; border-radius:4px; pointer-events:none;
backdrop-filter:blur(4px); border:1px solid rgba(255,255,255,.15);
letter-spacing:.5px;
}
/* 横向滚动行:禁用无关过渡,避免滚动回弹/卡顿(相关影片行 + 原版预览行) */
.emby-row .item, .emby-native-preview .preview-images .tile-item { transition:transform .25s ease !important; }
.emby-row, .emby-native-preview .preview-images { scroll-behavior:auto !important; }
/* ====== 原生 磁鏈 / 短評 / 相關清單(保留原生功能,Emby 化样式) ====== */
html.emby-skin .columns[data-controller="movie-tab"] { margin-top:2.6em; }
html.emby-skin .columns[data-controller="movie-tab"] .tabs.no-bottom { border-bottom:1px solid rgba(255,255,255,.08); margin-bottom:1em; }
html.emby-skin .columns[data-controller="movie-tab"] .tabs.no-bottom li a { color:var(--e-text2); border-bottom:2px solid transparent; padding:.6em 1em; display:inline-block; }
html.emby-skin .columns[data-controller="movie-tab"] .tabs.no-bottom li.is-active a { color:#fff; border-bottom-color:var(--e-accent); }
html.emby-skin .columns[data-controller="movie-tab"] .magnet-links { background:transparent; }
html.emby-skin .columns[data-controller="movie-tab"] .magnet-links .item { background:var(--e-card); border:1px solid rgba(255,255,255,.06);
border-radius:var(--e-r); padding:.7em .9em; margin-bottom:.6em; align-items:center; }
html.emby-skin .columns[data-controller="movie-tab"] .magnet-name a { color:var(--e-accent); word-break:break-all; }
html.emby-skin .columns[data-controller="movie-tab"] .magnet-name .name { font-weight:600; }
html.emby-skin .columns[data-controller="movie-tab"] .magnet-name .meta { color:var(--e-text3); font-size:.82em; }
/* 顶部与内容广告位(纯推广 / 磁链评论区推广横幅)在 Emby 皮肤下全面隐藏,保持整洁 */
html.emby-skin .moj-content,
html.emby-skin .moj-container,
html.emby-skin .moj-banner,
html.emby-skin .moj-wrap,
html.emby-skin [class*="moj-"],
html.emby-skin .sda-content,
html.emby-skin .sda-container,
html.emby-skin .sda-banner,
html.emby-skin .sda-wrap,
html.emby-skin [class*="sda-"],
html.emby-skin [class*="-sda"],
html.emby-skin #magnets-content .sda-content,
html.emby-skin #magnets .sda-content,
html.emby-skin .magnet-links .sda-content,
html.emby-skin .columns[data-controller*="movie"] .moj-content,
html.emby-skin .columns[data-controller*="movie"] .sda-content,
html.emby-skin .columns .moj-content,
html.emby-skin .columns .sda-content,
html.emby-skin .top-ads,
html.emby-skin .ad-banner,
html.emby-skin .ads-banner,
html.emby-skin .ads-section,
html.emby-skin .banner-ads,
html.emby-skin [data-ad],
html.emby-skin [class*="ad-banner"],
html.emby-skin [class*="ads-container"],
html.emby-skin a:has(> img[src*="/ads/"]),
html.emby-skin div:has(> a > img[src*="/ads/"]),
html.emby-skin img[src*="/ads/"],
html.emby-skin img[src*="jdbstatic.com/ads/"] {
display: none !important;
}
/* 让原生 .video-detail(含磁鏈/短評/相關清單)显示于固定背景与遮罩之上,
否则会被 z-index:0 的 #emby-detail-bg / #emby-detail-scrim 覆盖而完全不可见 */
html.emby-skin .video-detail { position:relative; z-index:1; }
/* 隐藏原版底部 footer(關於/條款/隱私/廣告投放/RTA) */
html.emby-skin #footer { display:none !important; }
/* ====== 封面框架大小(设置面板可调) ====== */
/* 强制所有双栏封面模式统一以左上角为基准锚点对齐,缩放时海报顶端与标题永久水平平行 */
html.emby-skin:not(.cover-full) .emby-detail-hero {
align-items:flex-start !important;
}
html.emby-skin .emby-detail-hero .poster-wrap {
width:var(--e-cover-w,340px); min-width:var(--e-cover-w,340px);
margin-top:0 !important;
}
html.emby-skin:not(.cover-full):not(.cover-hybrid) .emby-detail-hero .info {
padding-top:0 !important;
}
/* 网格卡片宽由网格轨道 minmax(var(--e-card-w)) 控制(见 .movie-list) */
/* ====== 封面展现方式:裁切 / 全封面 / 融合 ====== */
/* 裁切模式:海报钉住原封面右侧内容,裁掉左侧;原 2:3 竖图在等比框内满铺不受影响 */
html.emby-skin:not(.cover-full):not(.cover-fusion) .emby-detail-hero .poster-wrap img {
object-position:right center;
}
/* 全封面模式(cover-full):大封面置顶居中展示,视觉规格全面同步融合模式大图 */
html.emby-skin.cover-full .emby-detail-hero {
display: block !important;
align-items: stretch;
position: relative !important;
}
html.emby-skin.cover-full .emby-detail-hero .poster-wrap {
width: fit-content !important;
max-width: min(1280px, 94vw) !important;
height: clamp(520px, 80vh, 820px) !important;
margin: 0 auto 2em !important;
display: flex !important; justify-content: center !important; align-items: center !important;
border-radius: var(--e-r);
background: transparent !important;
border: none !important;
box-shadow: none !important;
position: relative !important;
transition: transform .3s ease;
}
html.emby-skin.cover-full .emby-detail-hero .poster-wrap:hover {
transform: none !important;
}
html.emby-skin.cover-full .emby-detail-hero .poster-wrap img {
display: block !important;
width: auto !important;
height: 100% !important;
max-width: 100% !important;
max-height: clamp(520px, 80vh, 820px) !important;
margin: 0 auto !important;
aspect-ratio: auto !important;
object-fit: contain !important;
object-position: center !important;
border-radius: var(--e-r);
border: 1px solid rgba(255,255,255,.12);
box-shadow: 0 20px 55px rgba(0,0,0,.75), 0 0 1px rgba(255,255,255,.15);
}
html.emby-skin.cover-full .emby-detail-hero .info {
max-width: min(1280px, 94vw) !important;
margin: 0 auto !important;
padding: 0 1.2em !important;
padding-bottom: 0 !important;
display: block !important;
position: static !important;
width: auto !important;
box-sizing: border-box;
}
/* ====== 融合模式(cover-fusion v7.257 重构):原版纯正大图 + 标题触顶平滑切换两栏裁切海报 ======
* 1) 顶态:原汁原味完整大封面居中(margin: 0 auto 1.4em),标题与信息区在下方完全舒展(max-width: 1500px);
* 彻底废除 position:absolute 与强制缩小宽度,信息区文字宽度舒展完整,无缩窄、无错位、无文字抖动;
* 悬停在大图上无任何位移漂移(transform: none !important);
* 2) 滚动时机:大图随滚轮自然向上滚动,直到标题滑动到浏览器窗口最上方(y > titleTop - 10)时才切换为裁切模式;
* 3) 平滑两栏过渡:标题触顶瞬间,平滑切换为标准两栏裁切海报布局(360×540 钉住右侧女优主体 + 标题并排),
* 配合 fusionSlideIn 柔和入场动效;80px 宽幅滞后死区(y < titleTop - 80 恢复大图)彻底杜绝死锁与闪烁!
*/
/* 展开顶态:完整大封面居中 + 标题在下方 */
html.emby-skin.cover-fusion:not(.cover-fusion-collapsed) .emby-detail-hero {
display: block !important; align-items: stretch;
position: relative !important;
animation: fusionExpandFade 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
html.emby-skin.cover-fusion:not(.cover-fusion-collapsed) .emby-detail-hero .poster-wrap {
width: fit-content !important;
max-width: min(1280px, 94vw) !important;
height: clamp(520px, 80vh, 820px) !important;
margin: 0 auto 2em !important;
display: flex !important; justify-content: center !important; align-items: center !important;
border-radius: var(--e-r);
background: transparent !important;
border: none !important;
box-shadow: none !important;
position: relative !important;
transition: transform .3s ease;
}
html.emby-skin.cover-fusion:not(.cover-fusion-collapsed) .emby-detail-hero .poster-wrap:hover {
transform: none !important;
}
html.emby-skin.cover-fusion:not(.cover-fusion-collapsed) .emby-detail-hero .poster-wrap img {
display: block !important;
width: auto !important;
height: 100% !important;
max-width: 100% !important;
max-height: clamp(520px, 80vh, 820px) !important;
margin: 0 auto !important;
aspect-ratio: auto !important;
object-fit: contain !important;
object-position: center !important;
border-radius: var(--e-r);
border: 1px solid rgba(255,255,255,.12);
box-shadow: 0 20px 55px rgba(0,0,0,.75), 0 0 1px rgba(255,255,255,.15);
}
html.emby-skin.cover-fusion:not(.cover-fusion-collapsed) .emby-detail-hero .info {
max-width: min(1280px, 94vw) !important;
margin: 0 auto !important;
padding: 0 1.2em !important;
padding-bottom: 0 !important;
display: block !important;
position: static !important;
width: auto !important;
box-sizing: border-box;
}
/* 折叠态:滑动到下方时(标题到达视口顶部)平滑过渡为两栏裁切海报(360×540 钉住右侧内容 + 标题并排) */
html.emby-skin.cover-fusion.cover-fusion-collapsed .emby-detail-hero {
display: flex !important; align-items: flex-start; gap: 1.6em;
margin-top: var(--fusion-shift, 0px) !important;
animation: fusionSlideIn 0.35s cubic-bezier(0.16, 1, 0.3, 1) both;
}
html.emby-skin.cover-fusion.cover-fusion-collapsed .emby-detail-hero .poster-wrap {
width: var(--e-cover-w, 340px) !important; min-width: var(--e-cover-w, 340px) !important;
max-width: var(--e-cover-w, 340px) !important;
height: var(--e-cover-h, 510px) !important;
margin: 0 !important; display: block !important;
border-radius: var(--e-r); overflow: hidden;
box-shadow: 0 12px 36px rgba(0,0,0,.65);
}
html.emby-skin.cover-fusion.cover-fusion-collapsed .emby-detail-hero .poster-wrap:hover {
transform: scale(1.02);
}
html.emby-skin.cover-fusion.cover-fusion-collapsed .emby-detail-hero .poster-wrap img {
position: static !important;
width: 100% !important; height: 100% !important; aspect-ratio: auto !important;
object-fit: cover !important; object-position: right center !important;
border-radius: var(--e-r);
}
html.emby-skin.cover-fusion.cover-fusion-collapsed .emby-detail-hero .info {
flex: 1 1 0%; min-width: 320px;
}
@keyframes fusionSlideIn {
0% { opacity: 0.7; transform: translateY(12px); }
100% { opacity: 1; transform: translateY(0); }
}
@keyframes fusionExpandFade {
0% { opacity: 0.75; }
100% { opacity: 1; }
}
/* ====== 全封面模式紧凑条(v7.88):大图 → 小裁切图 + 固定紧凑条 ======
大图正常滚动离开,标题到达视口顶部时,顶栏下方浮现
“小裁切图 + 标题 + 演员/日期/时长/评分单行并排”的固定紧凑条,淡入淡出。 */
#emby-compact-bar {
position:fixed; top:var(--e-header-h,64px); left:0; right:0;
z-index:2147482995;
display:flex; align-items:center; gap:1.1em;
height:114px; padding:0 1.6em; box-sizing:border-box;
/* EMBY经典:适配顶栏效果,渐变倒置(下深上透),与顶部融合不突兀;
毛玻璃/液态玻璃由专属规则覆盖其背景) */
background-image:linear-gradient(to top, rgba(22,24,29,.92), rgba(22,24,29,0));
background-color:unset;
backdrop-filter:none;
border-bottom:1px solid rgba(255,255,255,.08);
opacity:0; transform:translateY(-14px); pointer-events:none;
transition:opacity .28s ease, transform .28s ease;
}
#emby-compact-bar.show { opacity:1; transform:translateY(0); pointer-events:auto; }
#emby-compact-bar .ecb-poster {
width:78px; height:111px; flex-shrink:0; border-radius:10px; overflow:hidden;
border:1px solid rgba(255,255,255,.16);
box-shadow:0 10px 24px rgba(0,0,0,.45);
background:#101216;
/* STEAM 风格卡牌互动:3D 倾斜 + 过渡;origin 左上 → 放大向右下生长,避免被左缘/顶栏遮挡 */
position:relative; transform-style:preserve-3d;
transform-origin:left top;
transition:transform .18s ease, box-shadow .25s ease;
will-change:transform;
}
#emby-compact-bar .ecb-poster img {
width:100%; height:100%; object-fit:cover; object-position:right center; display:block;
}
/* 悬停:整张卡片(小封面)放大并弹出紧凑条,允许超出边界;配合 JS 3D 倾斜 */
#emby-compact-bar .ecb-poster:hover {
box-shadow:0 18px 40px rgba(0,0,0,.65), 0 0 0 1px rgba(255,255,255,.25);
z-index:5;
}
/* STEAM 风格卡牌高光层(跟随光标位置 --mx/--my,由 attachCard3D 写入) */
#emby-compact-bar .ecb-poster .ecb-shine {
position:absolute; inset:0; pointer-events:none; z-index:3; border-radius:inherit;
opacity:0;
background:radial-gradient(circle at var(--mx,50%) var(--my,50%),
rgba(255,255,255,.38), rgba(255,255,255,0) 30%);
mix-blend-mode:screen; transition:opacity .25s ease;
}
#emby-compact-bar .ecb-poster:hover .ecb-shine { opacity:1; }
#emby-compact-bar .ecb-info {
min-width:0; flex:1;
display:flex; flex-direction:column; justify-content:center; gap:.5em;
}
#emby-compact-bar .ecb-title {
font-size:1.12em; font-weight:600; color:var(--e-text);
white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
}
#emby-compact-bar .ecb-title .ecb-code {
display:inline; margin-right:.65em; padding:0;
font-size:1em; font-weight:700; letter-spacing:.04em;
color:var(--e-accent);
border:none; border-radius:0;
}
#emby-compact-bar .ecb-meta {
display:flex; align-items:center; gap:1.3em; flex-wrap:nowrap;
min-width:0; overflow:hidden; white-space:nowrap;
}
#emby-compact-bar .ecb-m-item {
display:inline-flex; align-items:baseline; gap:.35em;
font-size:.88em; color:var(--e-text2); white-space:nowrap;
}
#emby-compact-bar .ecb-m-item b {
font-size:.78em; font-weight:600; letter-spacing:.05em;
color:var(--e-accent); opacity:.95;
}
#emby-compact-bar .ecb-stars { color:#ffb400; font-size:.92em; letter-spacing:.04em; }
#emby-compact-bar .ecb-score { color:var(--e-text2); }
#emby-compact-bar .ecb-count { color:var(--e-text3); }
/* 文字描边(黑边):仅保留 0.4px 描边保证纯白背景可读;
按需求去掉四方向描影与软投影(叠加后渲染过黑) */
#emby-compact-bar .ecb-title,
#emby-compact-bar .ecb-title .ecb-code,
#emby-compact-bar .ecb-m-item,
#emby-compact-bar .ecb-score,
#emby-compact-bar .ecb-count,
#emby-compact-bar .ecb-stars {
-webkit-text-stroke:.4px rgba(0,0,0,.9);
}
/* 紧凑条已并入上方 glass/liquid 原始效果规则(同一套代码、同一组选择器)。
这里仅做条状布局修剪:去掉左右/顶边线,只保留底部描边(其它效果完全复用原规则)。 */
html.emby-skin.emby-style-glass #emby-compact-bar,
html.emby-skin.emby-style-liquid #emby-compact-bar {
border-left:none; border-right:none; border-top:none;
}
/* ====== 混合模式(Hybrid):参考 Letterboxd 三栏布局 ======
结构(自上而下):
1) 模糊大封面背景 #emby-detail-bg —— 永远在(所有模式通用规则)
2) 清晰大封面 hero —— 完整展示整张封面,仅底部 ~30% 羽化过渡
3) 内容区三栏并排:小海报(左)| 标题+元数据(中)| 操作按钮(右独立)
与融合模式的区别:混合模式不随滚动切换,所有元素同时常驻 */
/* 清晰大封面 hero:默认隐藏,仅混合模式显示;用 <img> 100% 展示原始比例不裁切 */
html.emby-skin .emby-detail-herocover { display:none; }
html.emby-skin.cover-hybrid .emby-detail-herocover {
display:block; position:relative; z-index:1;
width:auto; margin:0 -2.4em; line-height:0;
}
html.emby-skin.cover-hybrid .emby-detail-herocover > img {
display:block; width:100%; height:auto; object-fit:contain;
}
/* 左右羽化:两侧渐隐为深色 */
html.emby-skin.cover-hybrid .emby-detail-herocover::before {
content:''; position:absolute; inset:0; z-index:2; pointer-events:none;
background:linear-gradient(to right,
rgba(14,14,18,1) 0%, rgba(14,14,18,0) 12%,
rgba(14,14,18,0) 88%, rgba(14,14,18,1) 100%);
}
/* 底部羽化:下方 ~30% 渐变过渡(hero 区会上移叠加,羽化需足够深以融合) */
html.emby-skin.cover-hybrid .emby-detail-herocover::after {
content:''; position:absolute; left:0; right:0; bottom:0; height:30%; z-index:2; pointer-events:none;
background:linear-gradient(to bottom,
rgba(14,14,18,0) 0%, rgba(14,14,18,.62) 45%, rgba(14,14,18,1) 100%);
}
/* 三栏内容区:海报 | 信息 | 操作按钮 —— 整体上移叠加大封面底部*/
html.emby-skin.cover-hybrid .emby-detail-hero {
display:flex !important; gap:2.4em; align-items:flex-start;
max-width:1200px; margin:0 auto; position:relative; z-index:3;
padding-top:1.5em;
/* 负 margin 将整个 hero 区上拉,叠加在大封面底部130px层叠 */
margin-top:-130px;
}
/* 左栏:竖版小海报 —— 宽度跟随设置面板「封面框架大小」(--e-cover-w)按比例缩小 */
html.emby-skin.cover-hybrid .emby-detail-hero .poster-wrap {
width:calc(var(--e-cover-w, 440px) * .67) !important;
min-width:calc(var(--e-cover-w, 440px) * .67) !important;
max-width:calc(var(--e-cover-w, 440px) * .67) !important;
border-radius:var(--e-r); overflow:hidden;
flex-shrink:0; position:relative;
box-shadow:0 12px 36px rgba(0,0,0,.68);
border:1px solid rgba(255,255,255,.14);
transition:transform .3s ease;
}
html.emby-skin.cover-hybrid .emby-detail-hero .poster-wrap:hover { transform:translateY(-6px) scale(1.02); }
html.emby-skin.cover-hybrid .emby-detail-hero .poster-wrap img {
width:100% !important; height:auto !important; aspect-ratio:2/3 !important;
object-fit:cover !important; object-position:right center !important;
}
/* 中栏:标题 + 元数据信息(占满剩余空间) */
html.emby-skin.cover-hybrid .emby-detail-hero .info {
flex:1; min-width:0; padding-top:.2em;
}
html.emby-skin.cover-hybrid .emby-detail-hero .info h1 {
margin:0 0 .4em; font-size:1.8em; font-weight:700; line-height:1.24;
text-shadow:0 2px 10px rgba(0,0,0,.55);
}
/* 中栏文字阴影保证可读 */
html.emby-skin.cover-hybrid .emby-detail-hero .info .emby-meta,
html.emby-skin.cover-hybrid .emby-detail-hero .info .emby-actors {
text-shadow:0 1px 6px rgba(0,0,0,.5);
}
/* 右栏:独立操作面板(播放按钮 + 想看/看过/加入清单,在上方居中显示;下方紧随金标荣誉卡) */
html.emby-skin.cover-hybrid .emby-detail-hero .emby-action-panel {
flex:0 0 auto; width:252px !important; flex-shrink:0;
display:flex; flex-direction:column; align-items:center; gap:12px;
padding-top:.2em;
position:relative; z-index:20;
}
html.emby-skin.cover-hybrid .emby-detail-hero .emby-action-panel .emby-btn-group {
flex-direction:column; gap:14px; align-items:center !important; justify-content:center !important;
margin-top:0; padding:14px 12px; border-top:none;
background:rgba(14,14,18,.35);
backdrop-filter:blur(8px);
border:1px solid rgba(255,255,255,.08);
border-radius:var(--e-r);
width:100% !important;
box-sizing:border-box;
position:relative; z-index:100 !important;
}
/* 操作面板内播放按钮居中全宽 */
html.emby-skin.cover-hybrid .emby-detail-hero .emby-action-panel .emby-play-group {
width:100% !important; display:flex !important; justify-content:center !important;
}
html.emby-skin.cover-hybrid .emby-detail-hero .emby-action-panel .emby-btn-group > :first-child {
width:100%; display:flex; justify-content:center;
}
/* 操作面板内的圆形按钮横排居中 */
html.emby-skin.cover-hybrid .emby-detail-hero .emby-action-panel .emby-action-row {
display:flex !important; justify-content:center !important; align-items:center !important; gap:14px !important; width:100% !important;
}
html.emby-skin.cover-hybrid .emby-detail-hero .emby-action-panel .emby-trophy-card {
width:100% !important; max-width:100% !important; margin-bottom:0 !important;
position:relative; z-index:1 !important;
}
/* 演员区分隔线 */
html.emby-skin.cover-hybrid .emby-detail-hero .emby-actors {
margin-top:1em; padding-top:1em;
border-top:1px solid rgba(255,255,255,.08);
}
/* ====== 详情页白色容器背景透明(露出模糊背景图)======
* 依据 F12 抓取的详情页结构:官方App/JavDB公告块 = .message-body(外层 .message),
* 每条磁力链接 = #magnets-content .item,tab 栏 = .tabs。仅这些详情页白底块变透明,
* 蓝按钮/文字保持原样;用 #magnets-content / .message 作详情页特征,不误伤主页卡片。 */
html.emby-skin .message,
html.emby-skin .message-body,
html.emby-skin .tabs,
html.emby-skin #magnets-content .item,
html.emby-skin #magnets-content .columns {
background: transparent !important;
}
/* ====== 详情页短评文字调淡(暗背景反差色,提升阅读体验)======
* 依据 F12 抓取:短评正文 = .review-item .content p,用户名/时间/评分 = .review-title 内文字节点与 .time/.score-stars;
* 用 .review-items 作短评专属容器(主页/收藏夹无),仅调淡文字,按钮(.button)与VIP链接(.has-text-link)保持原色。 */
html.emby-skin .review-items .review-title,
html.emby-skin .review-items .review-title .time,
html.emby-skin .review-items .content,
html.emby-skin .review-items .content p {
color: rgba(255,255,255,.72) !important;
}
html.emby-skin .review-items .content p { line-height: 1.7; }
/* ====== 瀑布流模式 —— 第一性原理重建(v4.22) ======
* 右侧的竖版图窗、高度各异 → 真正的瀑布流。若确需字面右半,可另加开关。 */
html.emby-skin.emby-masonry-large .movie-list.emby-masonry {
display:block !important;
position:relative !important; /* JS 瀑布流:容器承载绝对定位卡片,高度由引擎写入 */
padding:1.4em 1.6em 3em !important;
max-width:1700px; margin:0 auto !important;
background:transparent !important;
overflow:visible !important;
}
/* 卡片绝对定位:left/top 由 WaterfallEngine 以 translate3d 写入(横向顺序,最短列分配);
* width 由引擎按列宽写入。transition 让重排(resize/追加)平滑,不干扰 box 的 3D 倾斜 */
html.emby-skin.emby-masonry-large .movie-list.emby-masonry .item {
position:absolute !important; top:0 !important; left:0 !important;
margin:0 !important; break-inside:auto !important; display:block !important;
transition:transform .25s ease !important;
will-change:transform;
}
html.emby-skin.emby-masonry-large .movie-list.emby-masonry .item .box {
width:100% !important; height:auto !important;
}
/* 封面容器:宽=列宽(固定);高由每张卡 --mz-ar(错落比例)决定 → 高度参差(高低错落) */
html.emby-skin.emby-masonry-large .movie-list.emby-masonry .item .box .cover {
flex:none !important; width:100% !important; height:auto !important;
aspect-ratio:var(--mz-ar,2 / 3) !important;
overflow:hidden !important; position:relative !important;
background:#111 !important;
}
/* 右半显示:图片放大为 2× 列宽、右对齐贴卡牌右缘,object-fit:cover 等比填满 →
可见区恰为封面右半(横向右 50%),纵向居中取景,不变形、无黑边 */
html.emby-skin.emby-masonry-large .movie-list.emby-masonry .item .box .cover img {
position:absolute !important; top:0 !important; right:0 !important;
left:auto !important; bottom:auto !important;
width:200% !important; height:100% !important;
object-fit:cover !important; object-position:right center !important;
transform:none !important; aspect-ratio:auto !important;
}
/* ====== 动态聚焦模式 ======
* 无间距竖版宫格:封面固定右侧裁切,聚焦时放大整个宫格单元,
* 其它单元轻微收缩让出视觉焦点;不使用卡片 3D 或图片内部焦点平移。
*/
html.emby-skin.emby-dynamic-focus .movie-list.emby-dynamic-focus {
display:grid !important; grid-template-columns:repeat(auto-fill,minmax(var(--df-col-w,180px),1fr)) !important;
gap:0 !important; padding:0 !important; max-width:1700px; margin:0 auto !important;
background:transparent !important; overflow:visible !important;
}
html.emby-skin.emby-dynamic-focus .movie-list.emby-dynamic-focus {
grid-template-columns:repeat(auto-fill,minmax(var(--df-col-w,180px),1fr)) !important;
transition:none !important;
}
html.emby-skin.emby-dynamic-focus .movie-list.emby-dynamic-focus .item,
html.emby-skin.emby-dynamic-focus .movie-list.emby-dynamic-focus .item .box {
width:100% !important; height:auto !important; min-width:0 !important;
}
html.emby-skin.emby-dynamic-focus .movie-list.emby-dynamic-focus .item {
position:relative !important; z-index:1; transform:none; transform-origin:center;
transition:transform .28s cubic-bezier(.2,.8,.2,1), z-index 0s linear .28s;
}
html.emby-skin.emby-dynamic-focus .movie-list.emby-dynamic-focus .item .box {
/* transform 不再禁用(7.115):允许 STEAM 风格卡牌 3D 倾斜的 JS 内联 transform 生效;方形无影观感保持 */
border-radius:0 !important; box-shadow:none !important; transition:none !important;
}
html.emby-skin.emby-dynamic-focus .movie-list.emby-dynamic-focus .item .box .cover {
width:100% !important; aspect-ratio:2 / 3 !important; overflow:hidden !important;
position:relative !important; border-radius:0 !important; background:#111 !important;
}
html.emby-skin.emby-dynamic-focus .movie-list.emby-dynamic-focus .item .box .cover img {
position:absolute !important; top:0 !important; right:0 !important; left:auto !important; bottom:auto !important;
width:200% !important; height:100% !important; object-fit:cover !important; object-position:right center !important;
transform:none !important; aspect-ratio:auto !important; transition:none !important;
}
html.emby-skin.emby-dynamic-focus .movie-list.emby-dynamic-focus .item .video-title,
html.emby-skin.emby-dynamic-focus .movie-list.emby-dynamic-focus .item .score,
html.emby-skin.emby-dynamic-focus .movie-list.emby-dynamic-focus .item .meta,
html.emby-skin.emby-dynamic-focus .movie-list.emby-dynamic-focus .item .efav-card-meta,
html.emby-skin.emby-dynamic-focus .movie-list.emby-dynamic-focus .item .tags { display:none !important; }
/* (.emby-card-shine 已移出隐藏列表:动态聚焦 7.115 起同样享受 STEAM 风格卡牌辉光) */
html.emby-skin.emby-dynamic-focus .movie-list.emby-dynamic-focus .item.is-focus {
z-index:5; transform:scale(1.28); transition-delay:0s; /* 7.115 在 1.16 基础上放大 10% */
}
html.emby-skin.emby-dynamic-focus .movie-list.emby-dynamic-focus .item.is-near {
z-index:3; transform:scale(.88); transition-delay:0s;
}
html.emby-skin.emby-dynamic-focus #emby-favorites-panel .movie-list.emby-dynamic-focus,
html.emby-skin.emby-dynamic-focus #emby-top250-panel .movie-list.emby-dynamic-focus {
display:grid !important; gap:0 !important; padding:0 !important;
grid-template-columns:repeat(auto-fill,minmax(var(--df-col-w,180px),1fr)) !important;
}
html.emby-skin.emby-dynamic-focus #emby-favorites-panel .movie-list.emby-dynamic-focus .item .box .cover img,
html.emby-skin.emby-dynamic-focus #emby-top250-panel .movie-list.emby-dynamic-focus .item .box .cover img {
object-position:right center !important;
}
@media (max-width:700px) {
html.emby-skin.emby-dynamic-focus .movie-list.emby-dynamic-focus { grid-template-columns:repeat(auto-fill,minmax(min(var(--df-col-w,180px),34vw),1fr)) !important; }
html.emby-skin.emby-dynamic-focus .movie-list.emby-dynamic-focus .item.is-focus { transform:scale(1.12); }
html.emby-skin.emby-dynamic-focus .movie-list.emby-dynamic-focus .item.is-near { transform:scale(.99); }
}
/* 动态聚焦 · 未裁切大封面气泡预览:
* 弹出时全屏暗色遮罩(#emby-df-scrim, 2998)压暗页面聚焦大图,气泡(2999)在遮罩之上、
* 顶栏(3000)之下;两者均 pointer-events:none,绝不阻挡鼠标。气泡动画 = opacity + scale,
* 跟随光标从所在侧边缘破出;下方黑底信息条复用卡片同款番号/标题样式。 */
#emby-df-scrim {
position:fixed; inset:0; pointer-events:none; z-index:2147482998;
background:rgba(0,0,0,.55);
opacity:0; transition:opacity .28s ease; will-change:opacity;
}
#emby-df-scrim.show { opacity:1; }
#emby-df-preview {
position:fixed; z-index:2147482999; pointer-events:none;
opacity:0; transform:scale(.86);
transition:opacity .16s ease, transform .24s cubic-bezier(.2,.8,.2,1);
will-change:opacity,transform;
}
#emby-df-preview.show { opacity:1; transform:scale(1); }
/* 气泡内容镜像标准卡片信息区:黑底 + 番号强标题 + 标题 + 评分/日期/磁力标签 + 演员/时长。
* 每行独立 ellipsis;标签可换行;数据全盘继承卡片 DOM / 收藏夹数据库。 */
#emby-df-preview .emby-df-preview-info {
background:linear-gradient(180deg, #1d1f27, #14151a);
padding:.62em .8em .68em; font-size:.9em; line-height:1.42;
color:var(--e-text2,#c9ccd4);
border-top:1px solid rgba(255,255,255,.12);
}
#emby-df-preview .emby-df-preview-info .dfp-title {
display:flex; align-items:baseline; gap:.5em;
white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
}
#emby-df-preview .emby-df-preview-info .dfp-title strong {
color:var(--e-accent,#00a4dc); font-weight:700; flex:0 0 auto;
}
#emby-df-preview .emby-df-preview-info .dfp-title span {
overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
}
#emby-df-preview .emby-df-preview-info .dfp-meta {
display:flex; flex-wrap:wrap; align-items:center; gap:.45em .6em;
margin-top:.28em; font-size:.86em; color:var(--e-text3,#9aa0ad);
}
#emby-df-preview .emby-df-preview-info .dfp-stars {
display:inline-flex; align-items:center; gap:1px; color:#ffc857; transform:translateY(1px);
}
#emby-df-preview .emby-df-preview-info .dfp-stars i { font-style:normal; font-size:.92em; }
#emby-df-preview .emby-df-preview-info .dfp-dot { opacity:.45; }
#emby-df-preview .emby-df-preview-info .dfp-tag {
display:inline-block; font-size:.74em; font-weight:600; line-height:1;
padding:.3em .6em; border-radius:999px; white-space:nowrap;
color:#7fe0a2; background:rgba(60,204,120,.12); border:1px solid rgba(80,220,140,.35);
}
#emby-df-preview .emby-df-preview-info .dfp-extra {
display:flex; flex-wrap:wrap; align-items:center; gap:.5em .9em;
margin-top:.3em; font-size:.82em; color:var(--e-text2,#c9ccd4);
border-top:1px dashed rgba(255,255,255,.08); padding-top:.34em;
}
#emby-df-preview .emby-df-preview-info .dfp-extra:empty { display:none; }
#emby-df-preview .emby-df-preview-info .dfp-actor,
#emby-df-preview .emby-df-preview-info .dfp-ser,
#emby-df-preview .emby-df-preview-info .dfp-dir,
#emby-df-preview .emby-df-preview-info .dfp-mak,
#emby-df-preview .emby-df-preview-info .dfp-note {
display:inline-flex; align-items:center; gap:.3em;
overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:100%;
}
/* 带备注的实体:名字橙色标记——备注文本不展示,详情页悬停气泡查看。
演员为逐名标色(共演未备注者不着色);系列/片商整名标色 */
#emby-df-preview .emby-df-preview-info .dfp-actor .noted,
#emby-df-preview .emby-df-preview-info .dfp-ser.noted,
#emby-df-preview .emby-df-preview-info .dfp-mak.noted { color:#ffb84d; }
/* 作品级备注(仅番号本身的):斜体橙色附行末 */
#emby-df-preview .emby-df-preview-info .dfp-note { color:#ffb84d; }
#emby-df-preview .emby-df-preview-info .dfp-note i { font-style:italic; }
#emby-df-preview .emby-df-preview-info .dfp-extra .material-icons,
#emby-df-preview .emby-df-preview-info .dfp-extra .material-symbols-outlined { font-size:1.05em; color:var(--e-accent,#00a4dc); }
#emby-df-preview .emby-df-preview-info .dfp-actor .material-symbols-outlined { color:#f472b6 !important; }
/* 悬停封面预览标题行内的金标奖章(与卡片同款金色皇冠 SVG,悬停逐行显示) */
#emby-df-preview .emby-df-preview-info .dfp-title .efav-award {
display:inline-flex; align-items:center; margin-right:.3em; color:#ffd700; flex:0 0 auto;
}
#emby-df-preview .emby-df-preview-info .dfp-title .efav-award svg {
width:1em; height:1em; vertical-align:-.18em; fill:currentColor;
}
#emby-df-preview .emby-df-preview-inner {
position:relative; border-radius:12px; overflow:hidden; background:#0a0a0d;
box-shadow:0 18px 50px rgba(0,0,0,.55), 0 0 0 1px rgba(255,255,255,.12);
}
#emby-df-preview .emby-df-preview-inner::after {
content:''; position:absolute; inset:0; pointer-events:none;
background:linear-gradient(160deg, rgba(255,255,255,.10), rgba(255,255,255,0) 42%);
}
#emby-df-preview img { display:block; width:100%; height:auto; }
/* ====== 首页视图切换标签栏(首页 / 收藏夹) ======
* pill 样式切换按钮,置于内容区最顶部,仅 EMBY 皮肤主页显示 */
.emby-home-tabs {
display:flex; justify-content:center; padding:.8em 0 .5em;
gap:.8em; user-select:none; margin-bottom:16px;
}
.emby-home-tab {
display:inline-flex; align-items:center; justify-content:center;
padding:.55em 1.76em; border-radius:999px; font-size:.97em; font-weight:600;
color:var(--e-text2); background:rgba(255,255,255,.06);
border:1px solid rgba(255,255,255,.08); cursor:pointer;
transition:all .25s ease; text-decoration:none; white-space:nowrap;
}
.emby-home-tab:hover { background:rgba(255,255,255,.1); color:var(--e-text); }
/* 激活态:强调色填充 + 白字 */
.emby-home-tab.active {
background:var(--e-accent); color:#000; border-color:var(--e-accent);
font-weight:700; box-shadow:0 2px 10px rgba(0,0,0,.2);
}
/* ====== 切换按钮按视觉风格差异化(容器统一无背景,按钮各自成形,与顶栏语言一致) ======
* EMBY:不透明深灰底 + 白边;glass:半透明毛玻璃;liquid:液态玻璃(折射渐变+inset 高光)。
* 容器 .emby-home-tabs 本身不带任何背景/边框,杜绝「玻璃长条」残留。 */
/* —— EMBY(默认):不透明黑灰按钮 —— */
html.emby-skin:not(.emby-style-glass):not(.emby-style-liquid) .emby-home-tab {
background:rgba(42,42,50,.98); border:1px solid rgba(255,255,255,.18); color:#e8e8ee;
}
html.emby-skin:not(.emby-style-glass):not(.emby-style-liquid) .emby-home-tab:hover {
background:rgba(60,60,70,.99); color:#fff;
}
/* —— glass(毛玻璃):半透明模糊,不透出底 —— */
html.emby-skin.emby-style-glass .emby-home-tab {
background:rgba(20,20,30,.5); backdrop-filter:blur(16px) saturate(1.8) brightness(1.04);
border:1px solid rgba(255,255,255,.18); color:#f0f0f5;
}
html.emby-skin.emby-style-glass .emby-home-tab:hover { background:rgba(40,40,55,.6); color:#fff; }
/* —— liquid(液态玻璃):折射微光渐变 + 多层 inset 边缘高光 + 顶部 specular 亮条 —— */
html.emby-skin.emby-style-liquid .emby-home-tab {
background: linear-gradient(135deg, rgba(255,255,255,.18) 0%, rgba(255,255,255,.04) 50%, rgba(160,150,255,.12) 100%);
border: 1px solid rgba(255,255,255,.32); color: #fff;
box-shadow: inset 0 1.5px 0 rgba(255,255,255,.7), inset 0 -1px 0 rgba(255,255,255,.12),
inset 1px 0 0 rgba(255,255,255,.22), inset -1px 0 0 rgba(255,255,255,.12), 0 4px 16px rgba(0,0,0,.35);
position: relative; overflow: hidden;
transition: all .2s ease;
}
/* 液态玻璃标志:顶部 specular 高光条(与 #emby-topbar::after 同源语言) */
html.emby-skin.emby-style-liquid .emby-home-tab::after {
content: ''; position: absolute; left: 12%; right: 12%; top: 1px; height: 2px; border-radius: 2px;
background: linear-gradient(90deg, transparent, rgba(255,255,255,.9), transparent);
opacity: .85; pointer-events: none;
}
html.emby-skin.emby-style-liquid .emby-home-tab:hover {
background: linear-gradient(135deg, rgba(255,255,255,.28) 0%, rgba(255,255,255,.08) 50%, rgba(160,150,255,.18) 100%);
border-color: rgba(255,255,255,.5);
box-shadow: inset 0 2px 0 rgba(255,255,255,.85), 0 6px 20px rgba(0,0,0,.35);
transform: translateY(-1px);
color: #fff;
}
/* 激活态高亮(强调色填充):三风格统一,特异性须 ≥ 上方按风格上色规则(各 0,4,1),
* 且源码靠后,才能盖过它们;否则点击切换后 active 背景被按风格背景覆盖、看不出当前页。 */
html.emby-skin:not(.emby-style-glass):not(.emby-style-liquid) .emby-home-tab.active,
html.emby-skin.emby-style-glass .emby-home-tab.active {
background:var(--e-accent); color:#000; border-color:var(--e-accent);
font-weight:700; box-shadow:0 2px 10px rgba(0,0,0,.2);
}
html.emby-skin.emby-style-liquid .emby-home-tab.active {
background: linear-gradient(135deg, color-mix(in srgb, var(--e-accent,#00a4dc) 75%, #fff), color-mix(in srgb, var(--e-accent,#00a4dc) 50%, rgba(0,0,0,.4))) !important;
color: #fff !important;
border-color: rgba(255,255,255,.6) !important;
font-weight: 700;
box-shadow: inset 0 1.5px 0 rgba(255,255,255,.85), 0 4px 18px rgba(0,164,220,.45) !important;
}
/* TOP250 专属金标胶囊高亮:对齐卡片金标奖章 (#fed368),黑金高反差药丸 */
html.emby-skin .emby-home-tab[data-tab="top250"].active,
html.emby-skin:not(.emby-style-glass):not(.emby-style-liquid) .emby-home-tab[data-tab="top250"].active,
html.emby-skin.emby-style-glass .emby-home-tab[data-tab="top250"].active {
background:#fed368 !important; color:#181200 !important; border-color:#fed368 !important;
font-weight:800 !important; box-shadow:0 2px 12px rgba(254,211,104,.45) !important;
}
html.emby-skin.emby-style-liquid .emby-home-tab[data-tab="top250"].active {
background: linear-gradient(135deg, rgba(254,211,104,.88), rgba(200,150,30,.78)) !important;
color: #181200 !important;
border-color: rgba(255,255,255,.7) !important;
font-weight: 800 !important;
box-shadow: inset 0 1.5px 0 rgba(255,255,255,.9), 0 4px 18px rgba(254,211,104,.45) !important;
}
html.emby-skin .emby-home-tab[data-tab="top250"].active .material-symbols-outlined {
color:#181200 !important;
}
html.emby-skin .emby-home-tab[data-tab="top250"]:not(.active):hover {
border-color:rgba(254,211,104,.5) !important; color:#fed368 !important;
}
html.emby-skin .emby-home-tab[data-tab="top250"]:not(.active):hover .material-symbols-outlined {
color:#fed368 !important;
}
/* 收藏夹 专属绯红胶囊高亮:对齐 TOP250 金标同规格药丸设计 (#cf3d35) */
html.emby-skin .emby-home-tab[data-tab="favorites"].active,
html.emby-skin:not(.emby-style-glass):not(.emby-style-liquid) .emby-home-tab[data-tab="favorites"].active,
html.emby-skin.emby-style-glass .emby-home-tab[data-tab="favorites"].active {
background:#cf3d35 !important; color:#fff !important; border-color:#cf3d35 !important;
font-weight:800 !important; box-shadow:0 2px 14px rgba(207,61,53,.45) !important;
}
html.emby-skin.emby-style-liquid .emby-home-tab[data-tab="favorites"].active {
background: linear-gradient(135deg, rgba(207,61,53,.88), rgba(150,30,25,.78)) !important;
color: #fff !important;
border-color: rgba(255,255,255,.7) !important;
font-weight: 800 !important;
box-shadow: inset 0 1.5px 0 rgba(255,255,255,.9), 0 4px 18px rgba(207,61,53,.45) !important;
}
html.emby-skin .emby-home-tab[data-tab="favorites"].active .material-symbols-outlined {
color:#fff !important;
}
html.emby-skin .emby-home-tab[data-tab="favorites"]:not(.active):hover {
border-color:rgba(207,61,53,.5) !important; color:#cf3d35 !important;
}
html.emby-skin .emby-home-tab[data-tab="favorites"]:not(.active):hover .material-symbols-outlined {
color:#cf3d35 !important;
}
/* 收藏夹未激活时图标专属标识色 */
html.emby-skin .emby-home-tab[data-tab="favorites"]:not(.active) .material-symbols-outlined {
color:#cf3d35;
}
/* TOP250未激活时图标专属标识色 (#fed368 金黄) */
html.emby-skin .emby-home-tab[data-tab="top250"]:not(.active) .material-symbols-outlined {
color:#fed368;
}
/* 全量加载无需显示分页 */
.top250-pagination {
display: none !important;
}
/* 收藏夹视图隐藏封面左上角 #排名 角标(仅在 TOP250 页面显示) */
html.emby-skin #emby-favorites-panel .top250-rank-badge {
display: none !important;
}
/* 预览画廊与 TOP250 随机封面海报墙背景(与收藏夹同款高斯模糊规范) */
html.emby-skin #emby-gallery-panel.show .efav-bg,
html.emby-skin #emby-top250-panel.show .efav-bg {
content:'';
position:fixed; top:0; left:0; right:0; bottom:0; z-index:0; pointer-events:none;
background-size:cover; background-position:center;
filter:blur(var(--e-blur,4px)) brightness(.45) saturate(1.25);
opacity:.35;
}
html.emby-skin #emby-gallery-panel.show > *,
html.emby-skin #emby-top250-panel.show > * {
position:relative; z-index:1;
}
/* ====== 液态玻璃 · V0 式透明凸透镜按钮(7.108) ======
* V0 演示站液态玻璃 = 近乎透明的凸透镜:背景极暗半透明(让背后内容透过),
* JS feDisplacementMap 提供真实的边缘折射扭曲;CSS 仅负责:
* - 极薄暗色底(透出折射后的背景内容)
* - 1px 亮线边框勾勒玻璃轮廓
* - 顶部 2px 高光条(::after 或 inset shadow)
* - 轻微底部内阴影赋予立体感。
* 仅作用于非激活/非开启态:.active 标签与 .on 开关保留各自强调色。 */
html.emby-skin.emby-style-liquid #emby-backtop,
html.emby-skin.emby-style-liquid #javdb-emby-toggle {
background: linear-gradient(135deg, rgba(255,255,255,.5), rgba(255,255,255,.22) 45%, rgba(180,160,255,.34) 100%) !important;
border: 1px solid rgba(255,255,255,.55) !important;
color: #fff !important;
box-shadow: inset 0 1px 0 rgba(255,255,255,.8), inset 0 -1px 0 rgba(255,255,255,.16),
inset 1px 0 0 rgba(255,255,255,.28), inset -1px 0 0 rgba(255,255,255,.14), 0 8px 28px rgba(0,0,0,.45) !important;
}
html.emby-skin.emby-style-liquid #emby-backtop:hover,
html.emby-skin.emby-style-liquid #javdb-emby-toggle:hover {
background: linear-gradient(135deg, rgba(255,255,255,.6), rgba(255,255,255,.28) 45%, rgba(190,170,255,.4)) !important;
border-color: rgba(255,255,255,.75) !important;
box-shadow: inset 0 1.5px 0 #fff, 0 10px 32px rgba(0,0,0,.55) !important;
}
/* 顶栏小按钮:处于顶栏内部,绝不套娃添加 backdrop-filter,保持清爽圆形与其它模式完全一致 */
html.emby-skin.emby-style-liquid #emby-topbar .hb,
html.emby-skin.emby-style-liquid #emby-settings-btn {
background: transparent !important;
border: none !important;
border-radius: 50% !important;
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
box-shadow: none !important;
color: #fff !important;
width: 40px !important;
height: 40px !important;
padding: 0 !important;
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
}
html.emby-skin.emby-style-liquid #emby-topbar .hb:hover,
html.emby-skin.emby-style-liquid #emby-settings-btn:hover {
background: rgba(255,255,255,.14) !important;
border-radius: 50% !important;
box-shadow: none !important;
}
/* 开启/激活态保持强调色,不被上面的凸透镜底色覆盖 */
html.emby-skin.emby-style-liquid #javdb-emby-toggle.on,
html.emby-skin.emby-style-liquid #javdb-emby-toggle.on:hover {
background: linear-gradient(135deg, rgba(255,255,255,.5), rgba(255,255,255,.22) 45%, rgba(180,160,255,.34) 100%);
border-color:rgba(255,255,255,.55); color:#fff;
box-shadow: inset 0 1px 0 rgba(255,255,255,.8), inset 0 -1px 0 rgba(255,255,255,.16),
inset 1px 0 0 rgba(255,255,255,.28), inset -1px 0 0 rgba(255,255,255,.14), 0 8px 28px rgba(0,0,0,.45);
}
/* 收藏夹面板:全新干净全屏视图,覆盖 javdb 原生主页全部内容。
* 仅保留 emby 顶栏(设置/搜索)、抽屉菜单;不显示 javdb 任何原生按钮/界面。
* 后续用解析到的数据单独呈现,仅复用 javdb 图片封面。 */
#emby-favorites-panel {
position:fixed; top:0; left:0; right:0; bottom:0;
z-index:2147482997; /* 高于 javdb 任何原生内容,低于抽屉/顶栏/scrim 与 tab 栏 */
background:transparent; /* 透明:让顶栏玻璃能透出面板内部滚动的封面 + 随机背景(.efav-bg),
与主页(封面在原生文档流、顶栏直接浮其上)玻璃穿透效果一致。
底色由面板内 fixed 的 .efav-bg 提供,故不会变全透 */
display:none; overflow-y:auto; padding:0;
/* 注意:绝不能用 contain:strict/layout/paint —— 它会把内部 position:fixed 的随机背景
* ::before 的包含块变成 panel 本身(而非 viewport),导致背景随面板滚动、且超出面板区域变黑。
* 仅用 box-sizing 控制盒模型即可。 */
box-sizing:border-box;
}
#emby-gallery-panel {
position:fixed; top:0; left:0; right:0; bottom:0;
z-index:2147482996;
background:var(--e-bg, #14161c);
display:none; overflow-y:auto; padding:calc(var(--e-header-h) + 4.8em) 0 0; /* 顶部留出标签栏+顶栏高度,避免首卡与工具栏被放大后的导航挤压 */
box-sizing:border-box;
scrollbar-width:thin; scrollbar-color:rgba(255,255,255,.18) transparent;
}
html.emby-skin #emby-gallery-panel::-webkit-scrollbar { width:8px; background:transparent; }
html.emby-skin #emby-gallery-panel::-webkit-scrollbar-track { background:transparent; }
html.emby-skin #emby-gallery-panel::-webkit-scrollbar-thumb { background:rgba(255,255,255,.2); border-radius:999px; border:2px solid transparent; background-clip:padding-box; }
html.emby-skin #emby-gallery-panel::-webkit-scrollbar-thumb:hover { background:rgba(255,255,255,.32); }
html.emby-skin #emby-gallery-panel.show { display:block; }
/* TOP250 & 热播全屏面板样式(第一性原理:自管全屏覆盖层,与收藏夹/画廊保持绝对一致) */
#emby-top250-panel {
position:fixed; top:0; left:0; right:0; bottom:0;
z-index:2147482997;
background:var(--e-bg, #14161c);
display:none; overflow-y:auto; padding:calc(var(--e-header-h) + 4.6em) 1.4em 3em;
box-sizing:border-box;
scrollbar-width:thin; scrollbar-color:rgba(255,255,255,.18) transparent;
}
html.emby-skin #emby-top250-panel::-webkit-scrollbar { width:8px; background:transparent; }
html.emby-skin #emby-top250-panel::-webkit-scrollbar-thumb { background:rgba(255,255,255,.2); border-radius:999px; }
html.emby-skin #emby-top250-panel.show { display:block; }
html:not(.emby-skin) #emby-top250-panel { display:none !important; }
/* TOP250 & 热播面板内部 UI 规范 */
.top250-wrap { width:100%; max-width:1400px; margin:0 auto; box-sizing:border-box; }
.top250-header { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:12px; margin-bottom:16px; }
.top250-subnav-group { display:inline-flex; align-items:center; gap:8px; background:rgba(255,255,255,.05); padding:4px 6px; border-radius:10px; border:1px solid rgba(255,255,255,.08); }
.top250-subnav-btn { display:inline-flex; align-items:center; gap:6px; background:transparent; border:none; color:var(--e-text2, #94a3b8); font-size:13.5px; font-weight:600; padding:6px 14px; border-radius:8px; cursor:pointer; transition:all .2s; }
.top250-subnav-btn:hover { color:#fff; background:rgba(255,255,255,.08); }
.top250-subnav-btn.active { color:#fff; background:var(--e-accent, #00a4dc); box-shadow:0 2px 10px rgba(0,164,220,.35); }
.top250-header-actions { display:flex; align-items:center; gap:10px; flex-wrap:wrap; }
.top250-filter-box { background:rgba(255,255,255,.03); border:1px solid rgba(255,255,255,.07); border-radius:12px; padding:12px 16px; margin-bottom:18px; }
.top250-filter-row { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:10px; }
.top250-filter-left, .top250-filter-right { display:flex; align-items:center; flex-wrap:wrap; gap:6px; }
.top250-label { font-size:12px; color:var(--e-text3, #64748b); font-weight:600; }
.top250-pill { background:rgba(255,255,255,.06); border:1px solid rgba(255,255,255,.08); color:var(--e-text2, #94a3b8); border-radius:20px; padding:4px 12px; font-size:12px; cursor:pointer; transition:all .15s; user-select:none; }
.top250-pill:hover { background:rgba(255,255,255,.12); color:#fff; }
.top250-pill.active { background:var(--e-accent, #00a4dc); border-color:var(--e-accent, #00a4dc); color:#fff; font-weight:700; box-shadow:0 2px 8px rgba(0,164,220,.3); }
.top250-year-container { display:flex; align-items:center; gap:5px; overflow-x:auto; padding-bottom:3px; scrollbar-width:thin; scrollbar-color:rgba(255,255,255,.15) transparent; }
.top250-year-container::-webkit-scrollbar { height:4px; }
.top250-year-container::-webkit-scrollbar-thumb { background:rgba(255,255,255,.15); border-radius:999px; }
.top250-rank-badge { position:absolute; top:8px; left:8px; z-index:10; font-size:11px; font-weight:800; line-height:1; padding:3px 7px; border-radius:6px; color:#fff; background:rgba(0,0,0,.7); backdrop-filter:blur(4px); box-shadow:0 2px 6px rgba(0,0,0,.4); pointer-events:none; }
.top250-rank-badge.top1 { background:linear-gradient(135deg, #ffd700, #ffae00); color:#1a1300; font-size:12px; padding:3px 8px; box-shadow:0 2px 10px rgba(255,215,0,.4); }
.top250-rank-badge.top2 { background:linear-gradient(135deg, #e2e8f0, #94a3b8); color:#0f172a; font-size:12px; padding:3px 8px; box-shadow:0 2px 10px rgba(226,232,240,.3); }
.top250-rank-badge.top3 { background:linear-gradient(135deg, #f97316, #c2410c); color:#fff; font-size:12px; padding:3px 8px; box-shadow:0 2px 10px rgba(249,115,22,.3); }
.top250-pagination { display:flex; justify-content:center; align-items:center; gap:8px; margin:24px 0 10px; }
.top250-page-btn { background:rgba(255,255,255,.07); border:1px solid rgba(255,255,255,.1); color:var(--e-text2, #94a3b8); border-radius:6px; padding:6px 14px; font-size:13px; font-weight:600; cursor:pointer; transition:all .2s; }
.top250-page-btn:hover:not(.disabled) { background:rgba(255,255,255,.15); color:#fff; }
.top250-page-btn.active { background:var(--e-accent, #00a4dc); border-color:var(--e-accent, #00a4dc); color:#fff; }
.top250-page-btn.disabled { opacity:.4; cursor:not-allowed; }
.top250-cache-badge { font-size:11px; padding:3px 8px; border-radius:4px; background:rgba(34,197,94,.15); color:#4ade80; border:1px solid rgba(34,197,94,.3); display:inline-flex; align-items:center; gap:4px; }
.top250-sync-btn { display:inline-flex; align-items:center; gap:5px; background:rgba(255,255,255,.07); border:1px solid rgba(255,255,255,.12); border-radius:8px; color:var(--e-text2, #94a3b8); font-size:12px; padding:5px 12px; cursor:pointer; transition:all .2s; }
.top250-sync-btn:hover { background:rgba(255,255,255,.14); color:#fff; }
.top250-sync-btn.syncing { pointer-events:none; opacity:.7; }
/* ====== TOP250 风格适配:毛玻璃状态 (emby-style-glass) ====== */
html.emby-skin.emby-style-glass .top250-subnav-group {
background: rgba(20,20,30,.4) !important;
backdrop-filter: blur(20px) saturate(1.8) !important;
-webkit-backdrop-filter: blur(20px) saturate(1.8) !important;
border: 1px solid rgba(255,255,255,.18) !important;
}
html.emby-skin.emby-style-glass .top250-subnav-btn {
color: var(--e-text2, #94a3b8) !important;
}
html.emby-skin.emby-style-glass .top250-subnav-btn:hover {
background: rgba(255,255,255,.12) !important;
color: #fff !important;
}
html.emby-skin.emby-style-glass .top250-subnav-btn.active {
background: var(--e-accent, #00a4dc) !important;
color: #fff !important;
box-shadow: 0 2px 10px rgba(0,164,220,.35) !important;
}
html.emby-skin.emby-style-glass .top250-filter-box {
background: rgba(20,20,30,.45) !important;
backdrop-filter: blur(22px) saturate(1.8) !important;
-webkit-backdrop-filter: blur(22px) saturate(1.8) !important;
border: 1px solid rgba(255,255,255,.18) !important;
}
html.emby-skin.emby-style-glass .top250-pill {
background: rgba(255,255,255,.08) !important;
backdrop-filter: blur(14px) saturate(1.6) !important;
-webkit-backdrop-filter: blur(14px) saturate(1.6) !important;
border: 1px solid rgba(255,255,255,.18) !important;
color: var(--e-text, #eee) !important;
}
html.emby-skin.emby-style-glass .top250-pill:hover {
background: rgba(255,255,255,.16) !important;
border-color: rgba(255,255,255,.32) !important;
color: #fff !important;
}
html.emby-skin.emby-style-glass .top250-pill.active {
background: var(--e-accent, #00a4dc) !important;
border-color: var(--e-accent, #00a4dc) !important;
color: #fff !important;
box-shadow: 0 2px 10px rgba(0,164,220,.4) !important;
}
html.emby-skin.emby-style-glass .top250-sync-btn,
html.emby-skin.emby-style-glass .top250-page-btn {
background: rgba(255,255,255,.08) !important;
backdrop-filter: blur(14px) saturate(1.6) !important;
-webkit-backdrop-filter: blur(14px) saturate(1.6) !important;
border: 1px solid rgba(255,255,255,.18) !important;
color: var(--e-text, #eee) !important;
}
html.emby-skin.emby-style-glass .top250-sync-btn:hover,
html.emby-skin.emby-style-glass .top250-page-btn:hover:not(.disabled) {
background: rgba(255,255,255,.16) !important;
color: #fff !important;
}
/* ====== TOP250 风格适配:液态玻璃状态 (emby-style-liquid) ====== */
html.emby-skin.emby-style-liquid .top250-subnav-group {
background: linear-gradient(135deg, rgba(255,255,255,.14), rgba(255,255,255,.03) 60%, rgba(150,140,255,.1)) !important;
border: 1px solid rgba(255,255,255,.24) !important;
box-shadow: inset 0 1px 0 rgba(255,255,255,.45), inset 0 -1px 0 rgba(255,255,255,.1), 0 4px 16px rgba(0,0,0,.25) !important;
}
html.emby-skin.emby-style-liquid .top250-subnav-btn {
border-radius: 6px !important;
transition: all .2s ease !important;
}
html.emby-skin.emby-style-liquid .top250-subnav-btn:hover {
background: rgba(255,255,255,.15) !important;
color: #fff !important;
}
html.emby-skin.emby-style-liquid .top250-subnav-btn.active {
background: linear-gradient(135deg, color-mix(in srgb, var(--e-accent,#00a4dc) 85%, #fff), color-mix(in srgb, var(--e-accent,#00a4dc) 55%, #1a1a2a)) !important;
box-shadow: inset 0 1px 0 rgba(255,255,255,.6), 0 2px 8px rgba(0,0,0,.3) !important;
color: #fff !important;
}
html.emby-skin.emby-style-liquid .top250-filter-box {
background: linear-gradient(135deg, rgba(255,255,255,.14), rgba(255,255,255,.03) 60%, rgba(150,140,255,.08)) !important;
border: 1px solid rgba(255,255,255,.22) !important;
box-shadow: inset 0 1px 0 rgba(255,255,255,.4), inset 0 -1px 0 rgba(255,255,255,.08), 0 6px 20px rgba(0,0,0,.25) !important;
position: relative;
}
html.emby-skin.emby-style-liquid .top250-filter-box::before {
content: ''; position: absolute; left: 8%; right: 8%; top: 0; height: 2px; border-radius: 2px;
background: linear-gradient(90deg, transparent, rgba(255,255,255,.8), transparent);
opacity: .7; pointer-events: none;
}
html.emby-skin.emby-style-liquid .top250-pill {
background: linear-gradient(135deg, rgba(255,255,255,.16), rgba(255,255,255,.04) 60%, rgba(150,140,255,.1)) !important;
border: 1px solid rgba(255,255,255,.26) !important;
box-shadow: inset 0 1px 0 rgba(255,255,255,.45), inset 0 -1px 0 rgba(255,255,255,.1) !important;
color: #fff !important;
}
html.emby-skin.emby-style-liquid .top250-pill:hover {
background: linear-gradient(135deg, rgba(255,255,255,.25), rgba(255,255,255,.08) 60%, rgba(150,140,255,.18)) !important;
border-color: rgba(255,255,255,.45) !important;
box-shadow: inset 0 1.5px 0 rgba(255,255,255,.6), 0 2px 10px rgba(0,0,0,.25) !important;
}
html.emby-skin.emby-style-liquid .top250-pill.active {
background: linear-gradient(135deg, color-mix(in srgb, var(--e-accent,#00a4dc) 85%, #fff), color-mix(in srgb, var(--e-accent,#00a4dc) 50%, #1a1a2a)) !important;
border-color: transparent !important;
color: #fff !important;
box-shadow: inset 0 1.5px 0 rgba(255,255,255,.6), 0 3px 12px rgba(0,164,220,.45) !important;
}
html.emby-skin.emby-style-liquid .top250-sync-btn,
html.emby-skin.emby-style-liquid .top250-page-btn {
background: linear-gradient(135deg, rgba(255,255,255,.14), rgba(255,255,255,.04) 60%, rgba(150,140,255,.1)) !important;
border: 1px solid rgba(255,255,255,.24) !important;
box-shadow: inset 0 1px 0 rgba(255,255,255,.4), inset 0 -1px 0 rgba(255,255,255,.1) !important;
color: #fff !important;
}
html.emby-skin.emby-style-liquid .top250-sync-btn:hover,
html.emby-skin.emby-style-liquid .top250-page-btn:hover:not(.disabled) {
background: linear-gradient(135deg, rgba(255,255,255,.24), rgba(255,255,255,.08) 60%, rgba(150,140,255,.18)) !important;
border-color: rgba(255,255,255,.4) !important;
color: #fff !important;
}
#emby-gallery-panel .empty-state { color:var(--e-text3); font-size:.95em; text-align:center; padding:2em; }
#emby-gallery-panel .empty-state .material-icons,
#emby-gallery-panel .empty-state .material-symbols-outlined { font-size:4em; opacity:.22; margin-bottom:.5em; display:block; }
/* 画廊渲染样式 */
.efav-gc-list { width:100%; max-width:1400px; margin:0 auto; padding:1.4em 1.2em 4em; }
/* 平铺:番号标题栏 + 封面‖预览并排 */
.efav-gc-card { background:rgba(255,255,255,.04); border:1px solid rgba(255,255,255,.08); border-radius:14px; margin:0 0 1.1em; overflow:hidden; }
.efav-gc-title { display:flex; align-items:center; gap:.7em; padding:.6em .9em; border-bottom:1px solid rgba(255,255,255,.07); font-size:.92em; }
.efav-gc-code { display:inline-flex; align-items:center; font-size:.78em; font-weight:700; line-height:1.4; padding:.18em .6em; border-radius:999px; text-decoration:none;
color:var(--e-accent); background:var(--e-accent-l, rgba(0,164,220,.15)); border:1px solid var(--e-accent-d, rgba(0,164,220,.4)); white-space:nowrap; }
.efav-gc-code:hover { background:var(--e-accent); color:#000; text-decoration:none; }
.efav-gc-name { color:var(--e-text2); opacity:.85; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.efav-gc-body { display:flex; gap:.9em; padding:.9em; align-items:flex-start; }
/* 显示大小最小(60%)时:封面保持原样(与预览区左右并排不动),仅视频预览图区从单行横向滚动
* 改为换行展示(最多两行),优化小尺寸下的空间利用。 */
.gc-compact .efav-gc-previews {
flex-wrap:wrap; overflow:hidden; max-height:calc(380px * var(--gc-size, 1) + .6em);
}
.efav-gc-cover { flex:0 0 30%; max-width:30%; }
.efav-gc-cover img { width:100%; border-radius:8px; display:block; cursor:pointer; }
.efav-gc-previews { flex:1; display:flex; gap:.6em; overflow-x:auto; padding-bottom:.4em; }
.efav-gc-prev { position:relative; flex:0 0 auto; }
.efav-gc-prev img { height:calc(190px * var(--gc-size, 1)); width:auto; border-radius:8px; display:block; cursor:pointer; }
.efav-gc-coveronly { color:var(--e-text3); font-size:.85em; padding:.5em; }
.efav-gc-rm { position:absolute; top:4px; right:4px; width:22px; height:22px; border-radius:50%; background:rgba(0,0,0,.6); color:#fff; font-size:16px; line-height:22px; text-align:center; cursor:pointer; }
.efav-gc-rm:hover { background:var(--e-accent); }
/* 全量:所有图平铺 */
.efav-gc-all { display:flex; flex-wrap:wrap; gap:.7em; }
.efav-gc-allimg { height:calc(200px * var(--gc-size, 1)); width:auto; border-radius:8px; cursor:pointer; object-fit:cover; }
/* 全量/平铺跟随收藏夹「展示布局」:瀑布流,纯 CSS column */
/* 全量瀑布流:列宽跟随 --gc-size(显示大小滑块),使滑块对瀑布流全量也生效(图片 width:100% 占满列 → 列宽变则图变) */
.efav-gc-all.emby-masonry { display:block; position:relative; } /* JS 引擎横向瀑布流(同 .movie-list.emby-masonry) */
.efav-gc-tile.emby-masonry { display:block; column-count:5; column-gap:.7em; }
.efav-gc-tile.emby-masonry .efav-gc-card { break-inside:avoid; margin-bottom:.7em; width:100%; }
.efav-gc-all.emby-masonry .efav-gc-allimg { width:100%; height:auto; position:absolute; top:0; left:0; transition:transform .25s ease; }
/* 收拢:横向 Card Stack(卡牌堆叠)—— 封面主卡在前,预览图叠在后方从右侧露小角;横排;点击展开大预览(限尺寸,不撑满屏) */
.efav-gc-collapse { display:flex; flex-flow:row wrap; align-items:flex-start; gap:1.8em 2.4em; }
.efav-gc-citem { position:relative; transition:opacity .25s ease, filter .25s ease; }
/* 封面主卡(最前,z 最高) */
.efav-gc-cbox { position:relative; display:block; width:calc(190px * var(--gc-size, 1)); cursor:pointer; border-radius:12px;
box-shadow:0 8px 22px rgba(0,0,0,.5); transition:transform .22s ease, box-shadow .22s ease; }
.efav-gc-cbox > img { position:relative; z-index:20; width:100%; display:block; border-radius:12px; }
/* 预览图堆叠层:从封面右侧错位探出小角(横排卡牌堆) */
.efav-gc-stack { position:absolute; left:0; top:0; width:calc(190px * var(--gc-size, 1)); height:100%; pointer-events:none; }
.efav-gc-stackimg { position:absolute; top:0; left:calc(var(--i) * 26px * var(--gc-size, 1)); width:calc(190px * var(--gc-size, 1)); height:100%;
object-fit:cover; border-radius:12px; border:2px solid rgba(0,0,0,.45); box-shadow:0 2px 8px rgba(0,0,0,.4);
pointer-events:auto; cursor:pointer; transition:transform .2s ease, filter .2s ease; z-index:calc(18 - var(--i));
transform:rotate(calc(var(--i) * 3deg)); filter:brightness(.5) saturate(.85); }
.efav-gc-cbox:hover .efav-gc-stackimg { transform:rotate(calc(var(--i) * 3deg)) translateX(12px); filter:brightness(.8) saturate(1); }
/* 展开态:该卡占满整行显示大预览(限尺寸,不撑满屏);堆叠层隐藏 */
.efav-gc-citem.open { flex:1 0 100%; z-index:20; }
.efav-gc-citem.open .efav-gc-stack { display:none; }
.efav-gc-citem.open .efav-gc-cbox { outline:2px solid var(--e-accent); box-shadow:0 12px 34px rgba(0,0,0,.65); }
/* 有展开项时其它封面变暗 */
.efav-gc-collapse.has-open .efav-gc-citem:not(.open) { opacity:.32; filter:saturate(.55); }
/* 展开区:大预览(封面限尺寸居中 + 预览横排) */
.efav-gc-expand { display:none; position:relative; width:100%; margin-top:1em; padding:1em; background:rgba(0,0,0,.28); border:1px solid rgba(255,255,255,.1); border-radius:14px; box-sizing:border-box; text-align:center; }
.efav-gc-citem.open .efav-gc-expand { display:block; }
/* 展开区关闭按钮(右上角 ×) */
.efav-gc-expand-close { position:absolute; top:.4em; right:.5em; z-index:2; width:30px; height:30px; display:inline-flex; align-items:center; justify-content:center; border-radius:50%; background:rgba(0,0,0,.55); color:#fff; font-size:20px; cursor:pointer; transition:background .2s; }
.efav-gc-expand-close:hover { background:var(--e-accent); }
.efav-gc-expand-cover { width:auto; max-width:65%; max-height:52vh; object-fit:contain; border-radius:10px; cursor:pointer; margin:0 auto .7em; }
.efav-gc-brow { display:flex; gap:.5em; overflow-x:auto; padding-bottom:.4em; scrollbar-width:thin; scrollbar-color:rgba(255,255,255,.18) transparent; justify-content:center; }
.efav-gc-brow .efav-gc-expand-prev { height:150px; width:auto; border-radius:8px; cursor:pointer; object-fit:cover; flex:0 0 auto; }
/* 画廊内灯箱 */
.efav-gallery-lb { display:none; position:fixed; inset:0; z-index:2147483647; background:rgba(0,0,0,.92); align-items:center; justify-content:center; cursor:zoom-out; }
.efav-gallery-lb.show { display:flex; }
.efav-gallery-lb img { max-width:94vw; max-height:94vh; border-radius:6px; }
.efav-gallery-lb-close { position:absolute; top:18px; right:22px; color:#fff; font-size:32px; cursor:pointer; }
.efav-gallery-lb-prev, .efav-gallery-lb-next { position:absolute; top:50%; transform:translateY(-50%); color:#fff; font-size:40px; cursor:pointer; background:rgba(0,0,0,.35); border-radius:50%; width:56px; height:56px; display:flex; align-items:center; justify-content:center; z-index:2; }
.efav-gallery-lb-prev { left:18px; }
.efav-gallery-lb-next { right:18px; }
.efav-gallery-lb-fav { position:absolute; top:18px; right:70px; display:inline-flex; align-items:center; justify-content:center; width:44px; height:44px; border:none; border-radius:999px; cursor:pointer; background:rgba(20,22,28,.82); color:#fff; backdrop-filter:blur(8px); box-shadow:0 4px 18px rgba(0,0,0,.45); z-index:2; font-family:'Material Symbols Outlined'; }
.efav-gallery-lb-fav.saved { background:var(--e-accent); }
/* 设置面板分段单选 */
.efav-seg { display:inline-flex; gap:.4em; flex-wrap:wrap; }
.efav-seg label { display:inline-flex; align-items:center; gap:.3em; padding:.3em .7em; border:1px solid rgba(255,255,255,.15); border-radius:999px; cursor:pointer; font-size:.85em; color:var(--e-text2); }
.efav-seg label:has(input:checked) { background:var(--e-accent); color:#fff; border-color:var(--e-accent); }
/* 画廊工具条(与收藏夹 .efav-toolbar 同款);用 #emby-gallery-panel 前缀提高特异性,
* 压过 .efav-toolbar 的 padding:.4em 0 .7em(左右0),确保左右内边距与卡片列表一致、对齐 */
#emby-gallery-panel .efav-gallery-toolbar { max-width:1400px; margin:.8em auto 1.2em; padding:0 1.2em; }
.efav-gallery-toolbar .efav-seg-item { display:inline-flex; align-items:center; gap:.3em; padding:.3em .9em; border:1px solid rgba(255,255,255,.15); border-radius:999px; cursor:pointer; font-size:.85em; color:var(--e-text2); background:rgba(255,255,255,.07); transition:background .2s,color .2s; }
.efav-gallery-toolbar .efav-seg-item:hover { background:rgba(255,255,255,.13); color:var(--e-text); }
.efav-gallery-toolbar .efav-seg-item.active { background:var(--e-accent); color:#fff; border-color:var(--e-accent); }
.efav-gallery-size { display:inline-flex; align-items:center; gap:.4em; margin-left:.6em; padding:.3em .7em; font-size:.85em; color:var(--e-text2); background:rgba(255,255,255,.07); border:1px solid rgba(255,255,255,.09); border-radius:.3em; }
.efav-gallery-size input[type=range] { width:7em; accent-color:var(--e-accent); }
.efav-gallery-size .material-icons, .efav-gallery-size .material-symbols-outlined { opacity:.8; }
.efav-gallery-size .efav-conc-val { min-width:2.6em; text-align:center; font-weight:600; color:var(--e-text); }
/* 画廊番号/标题过滤框 */
.efav-gc-filter { display:inline-flex; align-items:center; gap:.35em; margin-left:.6em; padding:.2em .5em; font-size:.85em; color:var(--e-text2); background:rgba(255,255,255,.07); border:1px solid rgba(255,255,255,.09); border-radius:.3em; }
.efav-gc-filter input { background:transparent; border:none; outline:none; color:var(--e-text); font-size:.9em; width:9em; }
.efav-gc-filter input::placeholder { color:var(--e-text3); }
.efav-gc-filter-clear { background:transparent; border:none; color:var(--e-text3); cursor:pointer; font-size:1.1em; line-height:1; padding:0 .1em; }
.efav-gc-filter-clear:hover { color:var(--e-text); }
/* 随机番号背景:用真实 div 承载(非 ::before 伪元素),position:fixed 钉在 header 下方到屏幕底,
* 固定不随面板滚动。伪元素 fixed 在部分环境/祖先包含块下行为异常,改用真实 div 更稳健。 */
html.emby-skin #emby-favorites-panel.show .efav-bg {
content:'';
position:fixed; top:0; left:0; right:0; bottom:0; z-index:0; pointer-events:none;
background-size:cover; background-position:center;
/* 模糊强度直接跟随设置里的「背景模糊强度」(--e-blur),不单独定义 */
filter:blur(var(--e-blur,4px)) brightness(.45) saturate(1.25);
opacity:.35;
}
html.emby-skin #emby-favorites-panel.show > * { position:relative; z-index:1; }
/* toolbar(含溢出的「全部清单」菜单)必须高于网格容器,否则菜单被封面盖住无法选择。
* 用同特异性(id+)选择器盖过上面 > * 的 z-index:1。 */
html.emby-skin #emby-favorites-panel.show > .efav-toolbar { z-index:50; }
html.emby-skin #emby-favorites-panel.show {
display:flex; align-items:center; justify-content:center; flex-direction:column;
}
#emby-favorites-panel .empty-state {
color:var(--e-text3); font-size:.95em; text-align:center; padding:2em;
}
#emby-favorites-panel .empty-state .material-icons,
#emby-favorites-panel .empty-state .material-symbols-outlined {
font-size:4em; opacity:.22; margin-bottom:.5em; display:block;
}
/* 收藏夹视图:彻底隐藏 javdb 原生主页提供的所有内容/按钮,仅留 emby 顶栏与抽屉。
* 注意:绝不能隐藏 main / .container —— 切换栏(.emby-home-tabs)与收藏夹面板(#emby-favorites-panel)
* 都插入在它们内部,父级一旦 display:none,整棵子树(含切换栏)都不渲染,用户会被困在黑屏、无法返回首页。
* 只隐藏 javdb 原生内容即可;残留的 main/.container 内容会被下方全屏 .emby-fav-view 面板盖住,不会外露。 */
html.emby-skin.emby-fav-view nav.navbar,
html.emby-skin.emby-fav-view nav.sub-header,
html.emby-skin.emby-fav-view .tabs:not(#emby-favorites-panel *):not(#emby-top250-panel *),
html.emby-skin.emby-fav-view .toolbar:not(#emby-favorites-panel *):not(#emby-top250-panel *),
html.emby-skin.emby-fav-view .movie-list,
html.emby-skin.emby-fav-view .movie-list.emby-masonry,
html.emby-skin.emby-fav-view .masonry-list,
html.emby-skin.emby-fav-view .pagination,
html.emby-skin.emby-fav-view nav.pagination,
html.emby-skin.emby-fav-view .section .container > .pagination { display:none !important; }
/* 保险:瀑布流(masonry-large) 下 .movie-list.emby-masonry 有 display:flex!important,
* 需更高特异性才能盖住(同特异性时靠后定义胜,这里显式提特异性双保险) */
html.emby-skin.emby-fav-view.emby-masonry-large .movie-list.emby-masonry { display:none !important; }
html.emby-skin.emby-fav-view body { overflow:hidden; }
/* 收藏夹视图下同时锁死 html 滚动:否则 html 自身仍会滚动(内容高度略超视口),
* 与 #emby-favorites-panel 内部滚动叠加形成「双滚动条」。锁死后滚动完全由面板内部承担,
* 物理上只剩面板一条滚动条。注意:绝不可用 contain:strict 处理(会让内部 fixed 背景失效/黑底)。 */
html.emby-skin.emby-fav-view { overflow:hidden; height:100%; }
html.emby-skin.emby-fav-view body { height:100%; }
/* 收藏夹视图下,把「首页/收藏夹」切换栏固定置顶浮在面板之上,确保可切回首页 */
html.emby-skin.emby-fav-view .emby-home-tabs {
position:fixed; top:calc(var(--e-header-h) + .35em); left:0; right:0; z-index:2147482998;
padding:.45em 0;
transition:transform .3s ease, opacity .3s ease;
}
/* 收藏夹内下滑时隐藏切换栏(上滑复现,与主页滚动收起体验一致) */
html.emby-skin.emby-fav-view.efav-tabs-hide .emby-home-tabs {
transform:translateY(-130%); opacity:0; pointer-events:none;
}
/* ====== 收藏夹与 TOP250 数据功能(v5.00+ / v7.238) ====== */
/* 收藏夹与 TOP250 封面墙直接复用首页 .movie-list/.item/.box 全套样式(同一套 CSS,效果零差异)。
* emby-fav-view 全局隐藏了原生 .movie-list,这里为面板内部开例外: */
html.emby-skin.emby-fav-view #emby-favorites-panel .movie-list,
html.emby-skin.emby-fav-view #emby-top250-panel .movie-list { display:grid !important; }
html.emby-skin.emby-fav-view.emby-masonry-large #emby-favorites-panel .movie-list.emby-masonry,
html.emby-skin.emby-fav-view.emby-masonry-large #emby-top250-panel .movie-list.emby-masonry { display:block !important; }
/* 面板内网格不需要 movie-list 的顶部大留白(面板已有 padding) */
html.emby-skin #emby-favorites-panel .movie-list,
html.emby-skin #emby-top250-panel .movie-list { padding:0 0 3em !important; }
/* 收藏夹与 TOP250 卡片扩展信息行(演员+时长):grid/masonry 直接显示;
* dynamic-focus 布局内隐藏(见上方 .emby-dynamic-focus 隐藏列表),改由大图气泡信息条展示。
* 顺序统一为 order:4,严格排在 cover(0), video-title(1), score(2), meta(3) 之后 */
html.emby-skin .movie-list .box .efav-card-meta {
display:flex; flex-wrap:wrap; align-items:center; gap:.4em .8em;
padding:0 .6em .5em !important; font-size:.76em !important; line-height:1.35;
color:var(--e-text3, #8d7cb3) !important; order:4 !important;
overflow:hidden;
}
html.emby-skin .movie-list .box .efav-card-meta:empty { display:none; }
/* 备注(行内):斜体橙色,附在卡片最后一行(演员行/日期行)末尾;设置「收藏夹显示备注」开关控制 */
html.emby-skin .movie-list .box .efav-card-note {
display:inline-flex; align-items:center; gap:.3em;
color:#ffb84d !important;
max-width:60%; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
vertical-align:bottom;
}
html.emby-skin .movie-list .box .efav-card-note .material-icons,
html.emby-skin .movie-list .box .efav-card-note .material-symbols-outlined { font-size:1.05em; opacity:.85; flex:none; }
html.emby-skin .movie-list .box .efav-card-note i { font-style:italic; }
/* 女优微型头像圆徽徽章(方案 D:微型头像圆徽 + 名字 + 状态微标) */
html.emby-skin .movie-list .box .efav-card-meta .efav-card-actors {
display:inline-flex; flex-wrap:wrap; align-items:center; gap:4px;
overflow:hidden; max-width:100%; min-width:0;
}
html.emby-skin .movie-list .box .efav-card-meta .efav-actor-badge {
display:inline-flex; align-items:center; gap:5px;
padding:2px 8px 2px 2.5px; border-radius:999px;
background:rgba(255,255,255,.06); border:1px solid rgba(255,255,255,.11);
color:#e2e8f0; font-size:11.5px; font-weight:500;
line-height:1; transition:all .18s ease; cursor:pointer;
max-width:100%; box-sizing:border-box; user-select:none;
}
html.emby-skin .movie-list .box .efav-card-meta .efav-actor-badge:hover {
background:rgba(255,255,255,.14); border-color:rgba(255,255,255,.28);
color:#fff; transform:translateY(-1px);
}
html.emby-skin .movie-list .box .efav-card-meta .efav-actor-badge.is-favorite {
background:rgba(207,61,53,.12); border-color:rgba(207,61,53,.35);
color:#ffe4e6;
}
html.emby-skin .movie-list .box .efav-card-meta .efav-actor-badge.is-favorite:hover {
background:rgba(207,61,53,.22); border-color:rgba(207,61,53,.55);
}
html.emby-skin .movie-list .box .efav-card-meta .efav-actor-badge.is-noted .efav-actor-name {
color:#ffb84d; font-weight:600;
}
html.emby-skin .movie-list .box .efav-card-meta .efav-actor-ava-wrap {
width:18px; height:18px; border-radius:50%; overflow:hidden;
flex-shrink:0; display:inline-flex; align-items:center; justify-content:center;
background:#1e222d; box-shadow:0 1px 3px rgba(0,0,0,.4);
position:relative;
}
html.emby-skin .movie-list .box .efav-card-meta .efav-actor-avatar {
width:100%; height:100%; object-fit:cover; display:block; border-radius:50%;
transition:transform .18s ease;
}
html.emby-skin .movie-list .box .efav-card-meta .efav-actor-badge:hover .efav-actor-avatar {
transform:scale(1.12);
}
html.emby-skin .movie-list .box .efav-card-meta .efav-actor-ava-fallback {
width:100%; height:100%; border-radius:50%;
display:inline-flex; align-items:center; justify-content:center;
font-size:9.5px; font-weight:700; color:#fff; text-shadow:0 1px 2px rgba(0,0,0,.5);
}
html.emby-skin .movie-list .box .efav-card-meta .efav-actor-name {
white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
max-width:120px;
}
/* 方案 4:头像层叠堆叠圈徽标与折叠/展开体系 */
html.emby-skin .movie-list .box .efav-card-meta .efav-actor-stack-badge {
display:inline-flex; align-items:center; gap:3px;
padding:2px 7px 2px 3px; border-radius:999px;
background:rgba(255,255,255,.06); border:1px solid rgba(255,255,255,.15);
height:24px; box-sizing:border-box; cursor:pointer; user-select:none;
transition:all .18s ease; white-space:nowrap;
}
html.emby-skin .movie-list .box .efav-card-meta .efav-actor-stack-badge:hover {
background:rgba(244,114,182,.18); border-color:rgba(244,114,182,.55);
transform:translateY(-1px); box-shadow:0 0 10px rgba(244,114,182,.3);
}
html.emby-skin .movie-list .box .efav-card-meta .efav-stack-avatars {
display:inline-flex; align-items:center;
}
html.emby-skin .movie-list .box .efav-card-meta .efav-stack-circle {
width:17px; height:17px; border-radius:50%; border:1.5px solid #141720;
margin-left:-5px; overflow:hidden; position:relative;
display:inline-flex; align-items:center; justify-content:center;
flex-shrink:0; background:#1e293b;
}
html.emby-skin .movie-list .box .efav-card-meta .efav-stack-circle:first-child {
margin-left:0;
}
html.emby-skin .movie-list .box .efav-card-meta .efav-stack-circle img {
width:100%; height:100%; object-fit:cover; display:block;
}
html.emby-skin .movie-list .box .efav-card-meta .efav-stack-circle-fallback {
width:100%; height:100%; border-radius:50%;
display:inline-flex; align-items:center; justify-content:center;
font-size:8.5px; font-weight:700; color:#fff; line-height:1;
}
html.emby-skin .movie-list .box .efav-card-meta .efav-stack-count {
font-size:11px; font-weight:700; color:#fda4af; line-height:1; margin-left:2px;
}
html.emby-skin .movie-list .box .efav-card-meta .efav-actor-stack-badge:hover .efav-stack-count {
color:#f43f5e;
}
html.emby-skin .movie-list .box .efav-card-meta .efav-actor-collapse-badge {
display:none; align-items:center; gap:2px;
padding:2px 7px; border-radius:999px;
background:rgba(255,255,255,.08); border:1px solid rgba(255,255,255,.15);
color:#94a3b8; font-size:11px; height:24px; box-sizing:border-box;
cursor:pointer; user-select:none; transition:all .18s ease; white-space:nowrap;
}
html.emby-skin .movie-list .box .efav-card-meta .efav-actor-collapse-badge:hover {
background:rgba(255,255,255,.18); color:#fff;
}
html.emby-skin .movie-list .box .efav-card-meta .efav-actor-collapse-badge .material-symbols-outlined {
font-size:14px; line-height:1;
}
/* 折叠控制:未展开时隐藏超额演员与收起按钮,限制两行(默认保持整洁) */
html.emby-skin:not(.emby-expand-coact) .movie-list .box .efav-card-meta .efav-card-actors:not(.is-expanded) {
max-height:52px;
overflow:hidden;
}
html.emby-skin:not(.emby-expand-coact) .movie-list .box .efav-card-meta .efav-card-actors:not(.is-expanded) .efav-actor-folded {
display:none !important;
}
html.emby-skin:not(.emby-expand-coact) .movie-list .box .efav-card-meta .efav-card-actors:not(.is-expanded) .efav-actor-collapse-badge {
display:none !important;
}
/* 单卡片点击展开后:展开全部演员,隐藏堆叠徽标,显示收起按钮 */
html.emby-skin .movie-list .box .efav-card-meta .efav-card-actors.is-expanded {
max-height:none !important;
overflow:visible;
}
html.emby-skin .movie-list .box .efav-card-meta .efav-card-actors.is-expanded .efav-actor-stack-badge {
display:none !important;
}
html.emby-skin .movie-list .box .efav-card-meta .efav-card-actors.is-expanded .efav-actor-folded {
display:inline-flex !important;
}
html.emby-skin .movie-list .box .efav-card-meta .efav-card-actors.is-expanded .efav-actor-collapse-badge {
display:inline-flex !important;
}
/* 全局设置开启展开全部共演演员时:所有卡片直接展开全部,隐藏堆叠与收起标 */
html.emby-skin.emby-expand-coact .movie-list .box .efav-card-meta .efav-card-actors {
max-height:none !important;
overflow:visible;
}
html.emby-skin.emby-expand-coact .movie-list .box .efav-card-meta .efav-card-actors .efav-actor-stack-badge,
html.emby-skin.emby-expand-coact .movie-list .box .efav-card-meta .efav-card-actors .efav-actor-collapse-badge {
display:none !important;
}
html.emby-skin.emby-expand-coact .movie-list .box .efav-card-meta .efav-card-actors .efav-actor-folded {
display:inline-flex !important;
}
/* 已访问变色逻辑(用户指定:颜色互换——未看过=柔紫,看过=皮肤原色)
* 范围:主页与收藏夹统一。(:visited 受浏览器隐私限制,只能改颜色类属性) */
html.emby-skin .movie-list .item a.box {
text-decoration:none !important;
}
/* 金标奖章:明黄胶囊药丸 (Pill Badge),微型皇冠 + 粗体 TOP250 + 最佳名次与多标角标 */
html.emby-skin .efav-award-badge,
html.emby-skin .movie-list .item .box .video-title .efav-award-badge {
display:inline-flex !important; align-items:center; gap:2px;
background:#fed368 !important; color:#181200 !important; font-weight:800 !important;
font-size:10px !important; line-height:1 !important;
padding:2px 6px; border-radius:999px; vertical-align:1px; margin-right:4px;
box-shadow:0 1px 3px rgba(0,0,0,.25); text-decoration:none !important;
user-select:none; flex:none;
}
html.emby-skin .efav-award-badge svg {
width:11px; height:11px; fill:currentColor !important; flex:none; vertical-align:-1px;
}
html.emby-skin .efav-award-badge .aw-rank {
font-size:9px; opacity:.85; margin-left:1px; font-weight:700;
}
html.emby-skin .efav-award-badge .aw-more {
background:rgba(0,0,0,.18); color:#181200; border-radius:999px;
padding:1px 3.5px; font-size:8.5px; font-weight:900; line-height:1; margin-left:2px;
}
/* 未访问:柔紫标记「还没看过」 */html.emby-skin .movie-list .item a.box .video-title,
html.emby-skin .movie-list .item a.box .video-title strong {
color:#b39ddb !important;
}
html.emby-skin .movie-list .item a.box .score,
html.emby-skin .movie-list .item a.box .meta {
color:#8d7cb3 !important;
}
/* 已访问:回归皮肤原色(看过的即"正常态") */
html.emby-skin .movie-list .item a.box:visited .video-title,
html.emby-skin .movie-list .item a.box:visited .video-title strong {
color:var(--e-text) !important;
}
html.emby-skin .movie-list .item a.box:visited .score,
html.emby-skin .movie-list .item a.box:visited .meta {
color:var(--e-text3) !important;
}
/* 仅在 .show 时展开为块布局(勿用无 .show 的 !important,否则切回首页时面板残留) */
html.emby-skin #emby-favorites-panel.show.has-ui {
display:block; padding:calc(var(--e-header-h) + 4.6em) 1.4em 3em;
background:#1a1a1a; /* 不透明深灰兜底:盖住面板背后 javdb 原生内容(避免透明穿透显原生按钮却点不到);
随机背景 .efav-bg 在其下层(z:0)有图时覆盖此深灰,关闭随机背景则显示深灰 */
contain:layout style;
}
.efav-toolbar {
display:flex; flex-wrap:wrap; align-items:center; gap:.55em;
padding:.4em 0 .7em; position:relative; z-index:5;
}
.efav-btn {
display:inline-flex; align-items:center; gap:.3em;
padding:.45em .9em; border-radius:.3em; font-size:.82em; font-weight:600;
color:var(--e-text2); background:rgba(255,255,255,.07);
border:1px solid rgba(255,255,255,.09); cursor:pointer; white-space:nowrap;
transition:background .2s,color .2s;
}
.efav-btn:hover { background:rgba(255,255,255,.13); color:var(--e-text); }
.efav-btn.accent { background:var(--e-accent); color:#000; border-color:var(--e-accent); }
.efav-btn .material-icons, .efav-btn .material-symbols-outlined { font-size:1.15em; }
.efav-btn[disabled] { opacity:.4; cursor:not-allowed; }
.efav-toolbar select, .efav-toolbar input[type=text] {
background:rgba(255,255,255,.07); color:var(--e-text);
border:1px solid rgba(255,255,255,.09); border-radius:.3em;
padding:.42em .6em; font-size:.82em; outline:none;
}
.efav-toolbar input[type=text] { width:12em; }
.efav-toolbar input[type=text]:focus { border-color:var(--e-accent); }
.efav-conc { display:inline-flex; align-items:center; gap:.4em; padding:.3em .6em; font-size:.8em; color:var(--e-text); opacity:.9; }
.efav-conc input[type=range] { width:5em; accent-color:var(--e-accent); }
.efav-conc-val { min-width:1em; text-align:center; font-weight:600; }
/* 详情页灯箱收藏按钮(fixed 悬浮右上角,z-index 取最大整数以盖过 fancybox/javdb 画廊查看器) */
.efav-gallery-collect {
position:fixed; right:18px; top:64px; z-index:2147483647;
display:none; align-items:center; gap:.35em;
padding:.5em .9em; border:none; border-radius:999px; cursor:pointer;
background:rgba(20,22,28,.82); color:#fff; backdrop-filter:blur(8px);
box-shadow:0 4px 18px rgba(0,0,0,.45); font:600 14px/1.2 system-ui,sans-serif;
}
.efav-gallery-collect:hover { background:rgba(40,44,54,.95); }
.efav-gallery-collect .material-icons { font-size:18px; vertical-align:-3px; }
.efav-gallery-collect.saved { background:var(--e-accent); }
/* 清单范围下拉(多选) */
.efav-scope { position:relative; }
.efav-scope-menu {
position:absolute; top:calc(100% + .35em); left:0; z-index:40;
min-width:15em; max-height:20em; overflow:auto;
background:var(--e-card); border:1px solid rgba(255,255,255,.1);
border-radius:.35em; box-shadow:0 .8em 2em rgba(0,0,0,.6); padding:.4em;
display:none;
}
.efav-scope-menu.open { display:block; }
.efav-scope-menu label {
display:flex; align-items:center; gap:.5em; padding:.4em .5em;
font-size:.82em; color:var(--e-text2); border-radius:.25em; cursor:pointer;
}
.efav-scope-menu label:hover { background:rgba(255,255,255,.07); color:var(--e-text); }
/* 条件胶囊行 */
.efav-chips { display:flex; flex-wrap:wrap; align-items:center; gap:.45em; padding:.1em 0 .8em; }
.efav-chip {
display:inline-flex; align-items:center; gap:.35em;
background:rgba(0,164,220,.16); border:1px solid rgba(0,164,220,.4);
color:var(--e-text); font-size:.76em; padding:.25em .65em; border-radius:999px;
}
.efav-chip .x { cursor:pointer; opacity:.65; font-weight:700; padding:0 .1em; }
.efav-chip .x:hover { opacity:1; }
.efav-count { font-size:.78em; color:var(--e-text3); margin-left:.3em; }
/* 进度条 */
.efav-progress {
display:none; align-items:center; gap:.8em; padding:.5em .8em; margin-bottom:.8em;
background:rgba(255,255,255,.05); border:1px solid rgba(255,255,255,.08); border-radius:.35em;
}
.efav-progress.show { display:flex; }
.efav-progress .bar { flex:1; height:6px; background:rgba(255,255,255,.1); border-radius:3px; overflow:hidden; }
.efav-progress .bar > i { display:block; height:100%; width:0; background:var(--e-accent); transition:width .3s; }
.efav-progress .txt { font-size:.78em; color:var(--e-text2); white-space:nowrap; }
.efav-progress .warn { color:#ffb84d; }
/* 封面墙(对齐首页 Emby 卡片风格:阴影、悬停高光、3D 倾斜由 JS 注入) */
.efav-grid {
display:grid; grid-template-columns:repeat(auto-fill,minmax(180px,1fr)); gap:1.1em;
}
/* 瀑布流模式(跟随皮肤设置的展示布局,与首页 emby-masonry-large 同款):
* CSS 分栏 + 每卡错落比例 --mz-ar + 封面右半裁切(img 2× 宽右对齐) */
.efav-grid.masonry {
display:block; columns:var(--mz-col-w,220px) auto; column-gap:1.1em;
}
.efav-grid.masonry .efav-card {
width:100%; margin:0 0 1.1em; break-inside:avoid; display:block;
}
.efav-grid.masonry .efav-card .cv {
aspect-ratio:var(--mz-ar,2/3);
}
.efav-grid.masonry .efav-card .cv img {
position:absolute; top:0; right:0; left:auto; bottom:auto;
width:200%; height:100%;
object-fit:cover; object-position:right center;
}
.efav-card {
cursor:pointer; display:block; text-decoration:none; color:inherit;
position:relative; border-radius:.3em; overflow:hidden;
padding-bottom:.55em;
transform-style:preserve-3d; will-change:transform;
transition:transform .35s ease;
}
/* 整卡高光层(与首页 .box 同级效果:覆盖封面+文字整体) */
.efav-card > .emby-card-shine { pointer-events:none; }
.efav-card .cv {
position:relative; width:100%; aspect-ratio:1.49; border-radius:.3em; overflow:hidden;
background:#101010; box-shadow:0 8px 10px rgb(0 0 0 / 15%);
}
.efav-card .cv img {
width:100%; height:100%; object-fit:cover; display:block;
}
/* 三行信息:番号+标题 / 评分 / 日期(对齐 javdb 原版结构) */
.efav-card .l1 {
margin-top:.55em; font-size:.8em; color:var(--e-text);
white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
display:flex; align-items:center; gap:.35em;
}
.efav-card .l1 strong { color:var(--e-text); font-weight:700; }
.efav-card .l2 { font-size:.74em; color:var(--e-text2); margin-top:.15em; min-height:1.1em; }
.efav-card .l3 { font-size:.74em; color:var(--e-text3); margin-top:.1em; }
/* 未补全灰点标识 */
.efav-dot {
display:inline-block; width:.5em; height:.5em; border-radius:50%;
background:rgba(255,255,255,.28); flex:none;
}
/* 已下架角标(404 作品):封面左下角红标 */
.efav-offshelf {
position:absolute; left:.4em; bottom:.4em; z-index:3;
background:rgba(229,57,53,.92); color:#fff;
font-size:.68em; font-weight:700; line-height:1; letter-spacing:.04em;
padding:.22em .45em; border-radius:.25em;
box-shadow:0 1px 3px rgba(0,0,0,.5);
}
.efav-empty { text-align:center; color:var(--e-text3); padding:5em 1em; font-size:.95em; }
.efav-empty .material-icons { font-size:4em; opacity:.22; display:block; margin-bottom:.4em; }
/* 筛选浮窗(大面板) */
.efav-modal-mask {
position:fixed; inset:0; z-index:2147483200;
background:rgba(0,0,0,.6); backdrop-filter:blur(4px);
display:none; align-items:center; justify-content:center; padding:1.5em 1em;
}
.efav-modal-mask.open { display:flex; }
.efav-modal {
width:min(880px,96vw); max-height:86vh; background:var(--e-card); border:1px solid rgba(255,255,255,.09);
border-radius:.45em; box-shadow:0 1.2em 3em rgba(0,0,0,.65); padding:1.2em 1.5em .9em;
display:flex; flex-direction:column; overflow:hidden; position:relative;
}
.efav-modal h3 {
margin:0 0 .8em; font-size:1.05em; color:var(--e-text); display:flex; align-items:center; gap:.5em; flex-shrink:0;
}
.efav-modal h3 .material-icons, .efav-modal h3 .material-symbols-outlined { color:var(--e-accent); }
.efav-modal-close {
margin-left:auto; background:none; border:none; color:var(--e-text3); cursor:pointer;
display:inline-flex; align-items:center; justify-content:center; padding:.2em; border-radius:50%;
transition:color .2s, background .2s; font-size:1.2em; line-height:1;
}
.efav-modal-close:hover { color:#fff; background:rgba(255,255,255,.1); }
/* 独立内部滚动区域 */
.efav-modal-body {
flex:1; overflow-y:auto; overscroll-behavior:contain; padding-right:.4em;
scrollbar-width:thin; scrollbar-color:rgba(255,255,255,.2) transparent;
}
.efav-modal-body::-webkit-scrollbar { width:6px; }
.efav-modal-body::-webkit-scrollbar-thumb { background:rgba(255,255,255,.2); border-radius:3px; }
/* 大项容器与折叠标题 */
.efav-dim { margin-bottom:.9em; }
.efav-dim > .dim-title {
font-size:.82em; color:var(--e-text2); font-weight:700; margin-bottom:.45em;
display:flex; align-items:center; gap:.6em; user-select:none;
}
/* 折叠点击触发器 */
.dim-toggle-btn {
display:inline-flex; align-items:center; gap:.35em; cursor:pointer;
padding:.15em .3em; border-radius:.25em; transition:background .15s, color .15s;
}
.dim-toggle-btn:hover { background:rgba(255,255,255,.08); color:var(--e-text); }
.dim-arrow {
font-size:1.25em !important; line-height:1; transition:transform .2s ease;
color:var(--e-text3);
}
.dim-name { font-size:1em; font-weight:700; }
/* 已选数量提示徽章 (Badge) */
.dim-badge {
display:none; font-size:.78em; font-weight:600; padding:.1em .45em;
border-radius:999px; background:var(--e-accent); color:#000; line-height:1.2;
}
.dim-badge.on { display:inline-block; }
/* 折叠状态行为 */
.efav-dim.collapsed > .dim-title .dim-arrow { transform:rotate(-90deg); }
.efav-dim.collapsed > .dim-title input[type=text] { display:none; }
.efav-dim.collapsed > .efav-opts,
.efav-dim.collapsed > input[type=range] { display:none !important; }
.efav-dim.collapsed { margin-bottom:.45em; }
.efav-dim > .dim-title input[type=text] {
background:rgba(255,255,255,.06); color:var(--e-text); border:1px solid rgba(255,255,255,.1);
border-radius:.25em; padding:.2em .5em; font-size:.9em; width:10em; outline:none;
}
.efav-opts { display:flex; flex-wrap:wrap; gap:.4em; max-height:9.5em; overflow:auto; }
.efav-opt {
font-size:.75em; padding:.28em .7em; border-radius:999px; cursor:pointer; user-select:none;
background:rgba(255,255,255,.06); border:1px solid rgba(255,255,255,.1); color:var(--e-text2);
}
.efav-opt:hover { background:rgba(255,255,255,.12); }
.efav-opt.on { background:var(--e-accent); border-color:var(--e-accent); color:#000; font-weight:700; }
/* 演员头像选项:头像+名字横向排列 */
.efav-opt.efav-opt-avatar { display:inline-flex; align-items:center; gap:.35em; }
.efav-opt-av { width:1.5em; height:1.5em; border-radius:50%; object-fit:cover; flex:none; background:#222; }
/* 大头像:上图下名,块级纵向排列;外层用方角卡片(与番号详情页演员头像一致),
* 不能用基类 .efav-opt 的 border-radius:999px(圆框),否则方图被塞进圆形框里很难看 */
.efav-opt.efav-opt-avatar-large { flex-direction:column; width:5.2em; text-align:center; gap:.3em; padding:.4em .3em; border-radius:.5em; }
.efav-opt.efav-opt-avatar-large .efav-opt-av { width:3.4em; height:3.4em; border-radius:.4em; }
.efav-opt.efav-opt-avatar-large .efav-opt-name { font-size:.7em; line-height:1.15; white-space:normal; }
/* 顶部开关栏(共演筛选 / 演员头像 / 头像尺寸) */
.efav-filter-toggles {
display:flex; gap:1.2em; flex-wrap:wrap; align-items:center; margin-bottom:1em; padding:.7em .9em;
background:rgba(255,255,255,.04); border:1px solid rgba(255,255,255,.08); border-radius:.4em;
}
/* 三行布局:行容器纵向堆叠,行内横向排列(行间距收紧,行间由容器 gap 控制) */
.efav-filter-rows { display:flex; flex-direction:column; gap:.45em; margin-bottom:1em; }
.efav-filter-rows .efav-filter-toggles { margin-bottom:0; }
/* 滑动开关 */
.efav-switch { position:relative; display:inline-flex; width:2.4em; height:1.3em; margin:0 .3em; }
.efav-switch input { position:absolute; opacity:0; width:100%; height:100%; margin:0; cursor:pointer; }
.efav-switch i { position:absolute; inset:0; border-radius:999px; background:rgba(255,255,255,.18); transition:background .2s; pointer-events:none; }
.efav-switch i::after { content:''; position:absolute; top:.15em; left:.15em; width:1em; height:1em; border-radius:50%; background:#fff; transition:transform .2s; }
.efav-switch input:checked + i { background:var(--e-accent); }
.efav-switch input:checked + i::after { transform:translateX(1.1em); }
.efav-size-label { font-size:.75em; color:var(--e-text3); min-width:1em; }
/* 最近发行快捷按钮组 */
.efav-recent { display:inline-flex; align-items:center; gap:.35em; color:var(--e-text2); font-size:.8em; }
.efav-recent .efav-op {
border:1px solid rgba(255,255,255,.14); background:rgba(255,255,255,.05); color:var(--e-text2);
padding:.15em .6em; border-radius:999px; cursor:pointer; font-size:.9em; line-height:1.4; transition:background .15s,color .15s;
}
.efav-recent .efav-op:hover { background:rgba(255,255,255,.16); color:var(--e-text); }
.efav-recent .efav-op.off { opacity:.45; }
.efav-recent .efav-op.on { background:var(--e-accent); border-color:var(--e-accent); color:#000; font-weight:700; }
/* 固定底部操作栏 (Sticky Footer) */
.efav-modal .foot {
display:flex; justify-content:flex-end; align-items:center; gap:.7em;
margin-top:.8em; padding-top:.8em; border-top:1px solid rgba(255,255,255,.08);
flex-shrink:0; background:transparent;
}
.efav-modal .foot button[data-a=clear] { margin-right:auto; }
/* 获取和整理收藏:合并操作入口的纵向大按钮 */
.efav-collect-list { display:flex; flex-direction:column; gap:.7em; }
.efav-collect-item {
display:flex; align-items:center; gap:.9em; width:100%; text-align:left;
padding:.85em 1em; border-radius:.4em; cursor:pointer; color:var(--e-text);
background:rgba(255,255,255,.05); border:1px solid rgba(255,255,255,.1);
transition:background .15s, border-color .15s, transform .1s;
}
.efav-collect-item:hover { background:rgba(255,255,255,.12); border-color:var(--e-accent); }
.efav-collect-item:active { transform:scale(.99); }
.efav-collect-item .ci-ic { font-size:1.5em; color:var(--e-accent); flex:none; }
.efav-collect-item .ci-txt { display:flex; flex-direction:column; gap:.15em; }
.efav-collect-item .ci-txt b { font-size:.95em; }
.efav-collect-item .ci-txt i { font-style:normal; font-size:.78em; color:var(--e-text2); line-height:1.25; }
/* 同步清单选择弹窗:清单勾选列表(新清单在前、可滚动、含数量与新增标记) */
.efav-sync-select-body { display:flex; flex-direction:column; gap:.3em; max-height:58vh; overflow:auto; margin:.4em 0 .2em; }
.efav-sync-item {
display:flex; align-items:center; gap:.6em; padding:.5em .7em; border-radius:.3em;
font-size:.88em; color:var(--e-text); cursor:pointer; user-select:none;
background:rgba(255,255,255,.04); border:1px solid rgba(255,255,255,.07);
}
.efav-sync-item:hover { background:rgba(255,255,255,.10); }
.efav-sync-item input { accent-color:var(--e-accent); flex:none; }
.efav-sync-item .nm { flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.efav-sync-item .meta { flex:none; font-size:.78em; color:var(--e-text2); }
.efav-sync-item.is-new .nm { color:var(--e-accent); font-weight:700; }
.efav-sync-select-foot .sel-info { margin-right:auto; font-size:.82em; color:var(--e-text2); }
.efav-toggle { display:inline-flex; align-items:center; gap:.4em; font-size:.8em; color:var(--e-text2); cursor:pointer; user-select:none; margin-right:auto; }
.efav-toggle input { accent-color: var(--e-accent); }
/* 演员筛选区:高度撑高为其他维度约两倍(其他维度 max-height:9.5em),再额外 +30% */
.efav-dim.efav-dim-actors .efav-opts { max-height:24.7em; }
.efav-dim.efav-dim-actors.efav-av-large .efav-opts { max-height:33.8em; }
/* 收藏的清单浏览器 */
.efav-browse-head { display:flex; align-items:center; gap:.7em; padding:.2em 0 1em; }
.efav-browse-head .ttl { font-size:.95em; color:var(--e-text); font-weight:700; }
.efav-lists-menu { display:flex; flex-direction:column; gap:.35em; max-height:60vh; overflow:auto; }
.efav-lists-menu .itm {
display:flex; justify-content:space-between; align-items:center; gap:1em;
padding:.6em .8em; border-radius:.3em; cursor:pointer; font-size:.85em;
background:rgba(255,255,255,.05); border:1px solid rgba(255,255,255,.07); color:var(--e-text2);
}
.efav-lists-menu .itm:hover { background:rgba(255,255,255,.11); color:var(--e-text); }
.efav-sentinel { height:2em; }
html:not(.emby-skin) .efav-modal-mask { display:none !important; }
/* 关闭态隐藏新增结构 */
html:not(.emby-skin) #emby-detail-root,
html:not(.emby-skin) .emby-sec,
html:not(.emby-skin) .emby-tags,
html:not(.emby-skin) .emby-actors,
html:not(.emby-skin) .emby-row,
html:not(.emby-skin) .emby-native-preview,
html:not(.emby-skin) .emby-home-tabs,
html:not(.emby-skin) #emby-favorites-panel { display:none !important; }
/* ====== 设置按钮(顶部栏齿轮) ====== */
#emby-settings-btn {
background:none; border:none; color:#ccc; cursor:pointer;
font-size:1.5em; margin:0 .4em; padding:.25em; border-radius:50%;
display:flex; align-items:center; transition:background .2s,color .2s,transform .3s;
}
#emby-settings-btn:hover { background:rgba(255,255,255,.12); color:#fff; transform:rotate(35deg); }
/* ====== 快捷设置悬浮面板 ====== */
.jhs-qs-popover {
position:fixed; z-index:2147483650; width:300px; max-height:85vh;
background:var(--e-card, #1c1c1e); border:1px solid rgba(255,255,255,.14);
border-radius:12px; box-shadow:0 12px 36px rgba(0,0,0,.65);
backdrop-filter:blur(16px); -webkit-backdrop-filter:blur(16px);
display:flex; flex-direction:column; overflow:hidden;
opacity:0; pointer-events:none; transform:translateY(6px) scale(.98);
transition:opacity .2s ease, transform .2s ease;
}
.jhs-qs-popover.open { opacity:1; pointer-events:auto; transform:translateY(0) scale(1); }
.jhs-qs-header {
display:flex; align-items:baseline; justify-content:space-between; gap:8px;
padding:12px 16px 10px; border-bottom:1px solid rgba(255,255,255,.08);
background:rgba(255,255,255,.03);
}
.jhs-qs-title { font-size:14px; font-weight:700; color:var(--e-text, #fff); display:flex; align-items:center; gap:6px; }
.jhs-qs-sub { font-size:11px; color:var(--e-text3, #888); }
.jhs-qs-body { flex:1; overflow-y:auto; padding:10px 14px; }
.jhs-qs-section {
background:rgba(255,255,255,.03); border:1px solid rgba(255,255,255,.06);
border-radius:8px; padding:8px 10px; margin-bottom:10px;
}
.jhs-qs-section-title { font-size:10.5px; font-weight:700; color:var(--e-accent, #00a4dc); margin-bottom:6px; letter-spacing:0.06em; text-transform:uppercase; }
.jhs-qs-row { display:flex; align-items:center; justify-content:space-between; gap:10px; padding:4px 0; min-height:28px; }
.jhs-qs-row-stacked { flex-direction:column; align-items:stretch; gap:4px; }
.jhs-qs-label { font-size:12.5px; color:var(--e-text, #eee); flex:1; display:flex; align-items:center; justify-content:space-between; }
.jhs-qs-control { flex-shrink:0; display:flex; align-items:center; }
.jhs-qs-range { display:flex; align-items:center; gap:8px; width:100%; }
.jhs-qs-range input[type=range] { flex:1; height:4px; -webkit-appearance:none; appearance:none; background:rgba(255,255,255,.15); border-radius:2px; outline:none; }
.jhs-qs-range input[type=range]::-webkit-slider-thumb { -webkit-appearance:none; width:14px; height:14px; border-radius:50%; background:var(--e-accent, #00a4dc); cursor:pointer; }
.jhs-qs-range span { min-width:32px; font-size:11px; color:var(--e-accent, #00a4dc); font-weight:700; text-align:right; }
.jhs-qs-footer {
display:flex; align-items:center; gap:8px; padding:10px 14px;
border-top:1px solid rgba(255,255,255,.08); background:rgba(0,0,0,.2);
}
.jhs-qs-btn {
flex:1; padding:6px 10px; border:none; border-radius:6px; font-size:12px; font-weight:600;
cursor:pointer; text-align:center; transition:all .15s ease; user-select:none;
}
.jhs-qs-btn-ghost { background:rgba(255,255,255,.08); color:var(--e-text2, #ccc); }
.jhs-qs-btn-ghost:hover { background:rgba(255,255,255,.14); color:#fff; }
.jhs-qs-btn-primary { background:var(--e-accent, #00a4dc); color:#fff; box-shadow:0 2px 8px rgba(0,164,220,.35); }
.jhs-qs-btn-primary:hover { filter:brightness(1.1); transform:translateY(-1px); }
/* JHS Switch 纯 CSS 滑动开关 */
.mini-switch {
appearance:none; -webkit-appearance:none; width:34px; height:18px;
background:rgba(255,255,255,.18); border-radius:18px; position:relative;
cursor:pointer; outline:none; transition:background .2s; flex-shrink:0;
}
.mini-switch:checked { background:var(--e-accent, #00a4dc); }
.mini-switch::before {
content:""; position:absolute; width:14px; height:14px; border-radius:50%;
background:#fff; top:2px; left:2px; box-shadow:0 1px 3px rgba(0,0,0,.3); transition:transform .2s;
}
.mini-switch:checked::before { transform:translateX(16px); }
/* ====== 居中完整设置大弹窗 ====== */
.jhs-settings-mask {
position:fixed; inset:0; z-index:2147483660;
background:rgba(0,0,0,.65); backdrop-filter:blur(10px); -webkit-backdrop-filter:blur(10px);
display:flex; align-items:center; justify-content:center;
opacity:0; visibility:hidden; transition:opacity .25s ease, visibility .25s ease;
}
.jhs-settings-mask.open { opacity:1; visibility:visible; }
.jhs-settings-dialog {
width:min(860px, 94vw); height:min(640px, 86vh);
background:var(--e-card, #18181b); border:1px solid rgba(255,255,255,.12);
border-radius:12px; box-shadow:0 18px 48px rgba(0,0,0,.7);
display:flex; overflow:hidden; position:relative;
transform:scale(.97) translateY(8px); transition:transform .25s ease;
}
.jhs-settings-mask.open .jhs-settings-dialog { transform:scale(1) translateY(0); }
.jhs-settings-close {
position:absolute; top:12px; right:16px; background:none; border:none;
color:rgba(255,255,255,.5); font-size:22px; cursor:pointer; line-height:1; z-index:10;
transition:color .15s;
}
.jhs-settings-close:hover { color:#fff; }
/* 设置侧边栏 */
.jhs-sidebar {
width:160px; flex-shrink:0; background:rgba(0,0,0,.25);
border-right:1px solid rgba(255,255,255,.08); padding:20px 0;
display:flex; flex-direction:column;
}
.jhs-side-title {
padding:0 18px 12px; font-size:13px; font-weight:800; color:var(--e-accent, #00a4dc);
letter-spacing:0.05em; display:flex; align-items:center; gap:6px;
}
.jhs-side-item {
padding:11px 18px; font-size:13px; color:var(--e-text2, #aaa); cursor:pointer;
border-left:3px solid transparent; transition:all .15s ease; user-select:none;
display:flex; align-items:center; gap:8px;
}
.jhs-side-item:hover { background:rgba(255,255,255,.05); color:var(--e-text, #eee); }
.jhs-side-item.active {
background:rgba(255,255,255,.08); color:#fff; font-weight:700;
border-left-color:var(--e-accent, #00a4dc);
}
/* 内容区 */
.jhs-content {
flex:1; display:flex; flex-direction:column; overflow:hidden;
padding:20px 24px 20px 20px;
}
.jhs-panel { display:none; flex-direction:column; height:100%; overflow-y:auto; }
.jhs-panel.active { display:flex; }
.jhs-panel-title { font-size:16px; font-weight:700; color:var(--e-text, #fff); margin-bottom:14px; display:flex; align-items:center; gap:8px; }
.jhs-item-row {
display:flex; align-items:center; justify-content:space-between; gap:16px;
padding:10px 0; border-bottom:1px solid rgba(255,255,255,.05);
}
.jhs-item-info { flex:1; min-width:0; }
.jhs-item-name { font-size:13px; font-weight:600; color:var(--e-text, #eee); margin-bottom:2px; }
.jhs-item-desc { font-size:11.5px; color:var(--e-text3, #888); line-height:1.3; }
.jhs-input-text {
background:rgba(255,255,255,.06); border:1px solid rgba(255,255,255,.12);
border-radius:6px; color:var(--e-text, #fff); padding:6px 10px; font-size:12.5px;
outline:none; width:100%; transition:border-color .15s;
}
.jhs-input-text:focus { border-color:var(--e-accent, #00a4dc); }
.jhs-btn {
display:inline-flex; align-items:center; justify-content:center; gap:6px;
padding:6px 14px; border-radius:6px; font-size:12.5px; font-weight:600;
cursor:pointer; border:none; transition:all .15s; user-select:none;
}
.jhs-btn-primary { background:var(--e-accent, #00a4dc); color:#fff; }
.jhs-btn-primary:hover { filter:brightness(1.1); }
.jhs-btn-success { background:#22c55e; color:#fff; }
.jhs-btn-success:hover { filter:brightness(1.1); }
.jhs-btn-danger { background:#ef4444; color:#fff; }
.jhs-btn-danger:hover { filter:brightness(1.1); }
.jhs-btn-secondary { background:rgba(255,255,255,.08); color:var(--e-text2, #ccc); border:1px solid rgba(255,255,255,.1); }
.jhs-btn-secondary:hover { background:rgba(255,255,255,.14); color:#fff; }
/* ====== 设置面板与悬浮窗风格适配:毛玻璃状态 (emby-style-glass) ====== */
html.emby-skin.emby-style-glass .jhs-settings-mask,
html.emby-skin.emby-style-glass .efav-modal-mask {
background: transparent !important;
backdrop-filter: blur(14px) saturate(1.8) brightness(1.05) !important;
-webkit-backdrop-filter: blur(14px) saturate(1.8) brightness(1.05) !important;
}
html.emby-skin.emby-style-glass #emby-settings-panel,
html.emby-skin.emby-style-glass .jhs-qs-popover,
html.emby-skin.emby-style-glass .jhs-settings-dialog,
html.emby-skin.emby-style-glass #jhs-wd-list-modal .jhs-settings-dialog,
html.emby-skin.emby-style-glass #top250-login-modal > div {
background: linear-gradient(135deg, rgba(255,255,255,.12) 0%, rgba(255,255,255,.03) 100%), rgba(20,24,36,.32) !important;
backdrop-filter: blur(26px) saturate(1.9) brightness(1.06) !important;
-webkit-backdrop-filter: blur(26px) saturate(1.9) brightness(1.06) !important;
border: 1px solid rgba(255,255,255,.22) !important;
box-shadow: 0 18px 48px rgba(0,0,0,.45), inset 0 1px 1px rgba(255,255,255,.35) !important;
}
html.emby-skin.emby-style-glass .jhs-sidebar {
background: rgba(255,255,255,.04) !important;
border-right: 1px solid rgba(255,255,255,.12) !important;
}
html.emby-skin.emby-style-glass .jhs-settings-dialog .jhs-item-name,
html.emby-skin.emby-style-glass .jhs-settings-dialog .jhs-side-title,
html.emby-skin.emby-style-glass .jhs-settings-dialog .jhs-panel-title,
html.emby-skin.emby-style-glass .jhs-qs-popover .jhs-qs-title {
color: #ffffff !important;
font-weight: 600 !important;
text-shadow: 0 1px 2px rgba(0,0,0,.85);
}
html.emby-skin.emby-style-glass .jhs-qs-header,
html.emby-skin.emby-style-glass .jhs-qs-section {
background: rgba(255,255,255,.05) !important;
border-color: rgba(255,255,255,.1) !important;
}
html.emby-skin.emby-style-glass .jhs-btn-secondary,
html.emby-skin.emby-style-glass .jhs-qs-btn-ghost {
background: rgba(255,255,255,.1) !important;
border: 1px solid rgba(255,255,255,.18) !important;
color: #eee !important;
backdrop-filter: blur(12px) saturate(1.6) !important;
-webkit-backdrop-filter: blur(12px) saturate(1.6) !important;
}
html.emby-skin.emby-style-glass .jhs-btn-secondary:hover,
html.emby-skin.emby-style-glass .jhs-qs-btn-ghost:hover {
background: rgba(255,255,255,.18) !important;
border-color: rgba(255,255,255,.3) !important;
color: #fff !important;
}
html.emby-skin.emby-style-glass .jhs-btn-primary,
html.emby-skin.emby-style-glass .jhs-qs-btn-primary {
background: linear-gradient(135deg, rgba(0,180,240,.7), rgba(0,140,210,.85)) !important;
border: 1px solid rgba(255,255,255,.35) !important;
box-shadow: 0 4px 14px rgba(0,164,220,.35), inset 0 1px 1px rgba(255,255,255,.5) !important;
}
html.emby-skin.emby-style-glass .efav-scope-menu,
html.emby-skin.emby-style-glass .efav-lists-menu {
background: linear-gradient(135deg, rgba(255,255,255,.12), rgba(255,255,255,.03)), rgba(20,24,36,.32) !important;
backdrop-filter: blur(24px) saturate(1.8) !important;
-webkit-backdrop-filter: blur(24px) saturate(1.8) !important;
border: 1px solid rgba(255,255,255,.2) !important;
box-shadow: 0 16px 40px rgba(0,0,0,.45), inset 0 1px 1px rgba(255,255,255,.3) !important;
}
/* ====== 设置面板与悬浮窗风格适配:液态玻璃状态 (emby-style-liquid) ====== */
html.emby-skin.emby-style-liquid .jhs-settings-mask,
html.emby-skin.emby-style-liquid .efav-modal-mask {
background: rgba(0,0,0,.20) !important;
backdrop-filter: blur(8px) saturate(1.2) brightness(0.92) !important;
-webkit-backdrop-filter: blur(8px) saturate(1.2) brightness(0.92) !important;
}
html.emby-skin.emby-style-liquid #emby-settings-panel {
background:
linear-gradient(180deg, rgba(255,255,255,.08) 0%, rgba(255,255,255,.02) 60%, transparent 100%),
rgba(16,20,32,.12) !important;
backdrop-filter: blur(8px) brightness(1.04) saturate(1.1) !important;
-webkit-backdrop-filter: blur(8px) brightness(1.04) saturate(1.1) !important;
border: 1px solid rgba(255,255,255,.24) !important;
}
/* 快捷设置悬浮窗:必须 position: fixed 维持浮动,杜绝被 position: relative 挤出视口;高透亮水晶玻璃与清晰底衬 */
html.emby-skin.emby-style-liquid .jhs-qs-popover {
position: fixed !important;
background:
linear-gradient(180deg, rgba(255,255,255,.08) 0%, rgba(255,255,255,.02) 60%, transparent 100%),
rgba(16,20,32,.28) !important;
backdrop-filter: blur(1.6px) brightness(1.04) saturate(1.15) !important;
-webkit-backdrop-filter: blur(1.6px) brightness(1.04) saturate(1.15) !important;
border: 1px solid rgba(255,255,255,.24) !important;
box-shadow:
inset 0 1.5px 0 rgba(255,255,255,.65),
inset 0 -1px 0 rgba(255,255,255,.12),
inset 1px 0 0 rgba(255,255,255,.2),
inset -1px 0 0 rgba(255,255,255,.12),
0 18px 48px rgba(0,0,0,.45) !important;
overflow: hidden;
color: #f1f5f9 !important;
}
html.emby-skin.emby-style-liquid .jhs-qs-popover::before {
content: ''; position: absolute; left: 6%; right: 6%; top: 0; height: 2px; border-radius: 2px;
background: linear-gradient(90deg, transparent, rgba(255,255,255,.9), transparent);
opacity: .85; pointer-events: none; z-index: 5;
}
html.emby-skin #modal-save-list,
html.emby-skin #modal-comment-warning,
html.emby-skin #top250-login-modal {
position: fixed !important;
inset: 0 !important;
z-index: 2147483640 !important;
}
/* 原生「存入清單」与原版弹窗保全:绝不施加任何主题皮肤,保持 JavDB 原生原貌 */
html.emby-skin #modal-save-list .modal-card,
html.emby-skin #modal-save-list .modal-card-head,
html.emby-skin #modal-save-list .modal-card-body,
html.emby-skin #modal-save-list .modal-card-foot,
html.emby-skin #modal-comment-warning .modal-card,
html.emby-skin #modal-comment-warning .modal-card-head,
html.emby-skin #modal-comment-warning .modal-card-body,
html.emby-skin #modal-comment-warning .modal-card-foot {
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
filter: none !important;
}
html.emby-skin.emby-style-liquid .jhs-settings-dialog,
html.emby-skin.emby-style-liquid #jhs-wd-list-modal .jhs-settings-dialog,
html.emby-skin.emby-style-liquid #top250-login-modal > div,
html.emby-skin.emby-style-liquid .efav-modal {
background:
linear-gradient(180deg, rgba(255,255,255,.08) 0%, rgba(255,255,255,.02) 60%, transparent 100%),
rgba(16,20,32,.12) !important;
backdrop-filter: blur(8px) brightness(1.04) saturate(1.1) !important;
-webkit-backdrop-filter: blur(8px) brightness(1.04) saturate(1.1) !important;
border: 1px solid rgba(255,255,255,.24) !important;
box-shadow:
inset 0 1.5px 0 rgba(255,255,255,.65),
inset 0 -1px 0 rgba(255,255,255,.12),
inset 1px 0 0 rgba(255,255,255,.2),
inset -1px 0 0 rgba(255,255,255,.12),
0 22px 55px rgba(0,0,0,.45) !important;
position: relative;
overflow: hidden;
color: #f1f5f9 !important;
}
html.emby-skin.emby-style-liquid .jhs-settings-dialog::before,
html.emby-skin.emby-style-liquid #jhs-wd-list-modal .jhs-settings-dialog::before,
html.emby-skin.emby-style-liquid #top250-login-modal > div::before,
html.emby-skin.emby-style-liquid .efav-modal::before {
content: ''; position: absolute; left: 6%; right: 6%; top: 0; height: 2px; border-radius: 2px;
background: linear-gradient(90deg, transparent, rgba(255,255,255,.95), transparent);
opacity: .85; pointer-events: none; z-index: 5;
}
html.emby-skin.emby-style-liquid .jhs-sidebar {
background: rgba(0,0,0,.20) !important;
border-right: 1px solid rgba(255,255,255,.14) !important;
}
html.emby-skin.emby-style-liquid .jhs-sidebar .jhs-side-item {
color: #cbd5e1 !important;
}
html.emby-skin.emby-style-liquid .jhs-sidebar .jhs-side-item:hover {
color: #ffffff !important;
background: rgba(255,255,255,.12) !important;
}
html.emby-skin.emby-style-liquid .jhs-side-item.active {
background: rgba(255,255,255,.2) !important;
color: #ffffff !important;
font-weight: 600 !important;
box-shadow: inset 0 1px 0 rgba(255,255,255,.3);
}
/* 液态玻璃模式:局部防眩衬底与双层高对比度文字阴影(Apple visionOS 规范) */
html.emby-skin.emby-style-liquid .jhs-settings-dialog .jhs-item-name,
html.emby-skin.emby-style-liquid .jhs-qs-popover .jhs-qs-item-name,
html.emby-skin.emby-style-liquid .jhs-qs-popover .jhs-qs-label {
color: #ffffff !important;
font-weight: 600 !important;
text-shadow: 0 1px 2px rgba(0,0,0,.95), 0 0 8px rgba(0,0,0,.7);
}
html.emby-skin.emby-style-liquid .jhs-settings-dialog .jhs-item-desc,
html.emby-skin.emby-style-liquid .jhs-qs-popover .jhs-qs-item-desc,
html.emby-skin.emby-style-liquid .jhs-qs-popover .jhs-qs-sub {
color: #e2e8f0 !important;
text-shadow: 0 1px 2px rgba(0,0,0,.85);
}
html.emby-skin.emby-style-liquid .jhs-settings-dialog .jhs-section-title,
html.emby-skin.emby-style-liquid .jhs-qs-popover .jhs-qs-header,
html.emby-skin.emby-style-liquid .jhs-qs-popover .jhs-qs-title {
color: #ffffff !important;
font-weight: 700 !important;
text-shadow: 0 1px 3px rgba(0,0,0,.95), 0 0 10px rgba(0,0,0,.7);
}
html.emby-skin.emby-style-liquid .jhs-qs-popover .jhs-qs-section-title {
color: #38bdf8 !important;
font-weight: 700 !important;
text-shadow: 0 1px 3px rgba(0,0,0,.95);
}
html.emby-skin.emby-style-liquid .jhs-qs-popover .jhs-qs-range span {
color: #38bdf8 !important;
font-weight: 700 !important;
text-shadow: 0 1px 2px rgba(0,0,0,.95);
}
html.emby-skin.emby-style-liquid .jhs-qs-popover select {
background: rgba(0,0,0,.45) !important;
border: 1px solid rgba(255,255,255,.24) !important;
color: #ffffff !important;
}
html.emby-skin.emby-style-liquid .jhs-settings-dialog input[type="text"],
html.emby-skin.emby-style-liquid .jhs-settings-dialog input[type="password"],
html.emby-skin.emby-style-liquid .jhs-settings-dialog select,
html.emby-skin.emby-style-liquid .jhs-settings-dialog .jhs-input-text {
background: rgba(0,0,0,.35) !important;
border: 1px solid rgba(255,255,255,.24) !important;
color: #ffffff !important;
}
html.emby-skin.emby-style-liquid .jhs-qs-header {
background: rgba(255,255,255,.08) !important;
border-bottom: 1px solid rgba(255,255,255,.16) !important;
}
html.emby-skin.emby-style-liquid .jhs-qs-section {
background: rgba(0,0,0,.25) !important;
border: 1px solid rgba(255,255,255,.14) !important;
box-shadow: inset 0 1px 0 rgba(255,255,255,.15);
border-radius: 8px;
}
html.emby-skin.emby-style-liquid .jhs-btn-secondary,
html.emby-skin.emby-style-liquid .jhs-qs-btn-ghost {
background: linear-gradient(135deg, rgba(255,255,255,.16), rgba(255,255,255,.05) 60%, rgba(150,140,255,.12)) !important;
border: 1px solid rgba(255,255,255,.26) !important;
box-shadow: inset 0 1px 0 rgba(255,255,255,.4), inset 0 -1px 0 rgba(255,255,255,.1) !important;
color: #fff !important;
}
html.emby-skin.emby-style-liquid .jhs-btn-secondary:hover,
html.emby-skin.emby-style-liquid .jhs-qs-btn-ghost:hover {
background: linear-gradient(135deg, rgba(255,255,255,.26), rgba(255,255,255,.09) 60%, rgba(150,140,255,.2)) !important;
border-color: rgba(255,255,255,.45) !important;
box-shadow: inset 0 1.5px 0 rgba(255,255,255,.6), 0 4px 12px rgba(0,0,0,.3) !important;
}
html.emby-skin.emby-style-liquid .jhs-btn-primary,
html.emby-skin.emby-style-liquid .jhs-qs-btn-primary {
background: linear-gradient(135deg, color-mix(in srgb, var(--e-accent,#00a4dc) 85%, #fff), color-mix(in srgb, var(--e-accent,#00a4dc) 55%, #1a1a2a)) !important;
border: 1px solid rgba(255,255,255,.45) !important;
box-shadow: inset 0 1.5px 0 rgba(255,255,255,.7), 0 4px 14px rgba(0,164,220,.45) !important;
color: #fff !important;
}
html.emby-skin.emby-style-liquid .efav-scope-menu,
html.emby-skin.emby-style-liquid .efav-lists-menu {
background:
linear-gradient(180deg, rgba(255,255,255,.08) 0%, rgba(255,255,255,.02) 60%, transparent 100%),
rgba(16,20,32,.12) !important;
backdrop-filter: blur(8px) brightness(1.04) saturate(1.1) !important;
-webkit-backdrop-filter: blur(8px) brightness(1.04) saturate(1.1) !important;
border: 1px solid rgba(255,255,255,.24) !important;
box-shadow:
inset 0 1.5px 0 rgba(255,255,255,.65),
inset 0 -1px 0 rgba(255,255,255,.12),
0 18px 45px rgba(0,0,0,.45) !important;
}
html.emby-skin.emby-style-liquid .efav-scope-menu {
position: absolute !important;
top: calc(100% + .35em) !important;
left: 0 !important;
z-index: 50 !important;
max-height: 20em !important;
overflow-y: auto !important;
}
html.emby-skin.emby-style-liquid .efav-lists-menu {
max-height: 60vh !important;
overflow-y: auto !important;
}
html.emby-skin.emby-style-liquid .efav-scope-menu label,
html.emby-skin.emby-style-liquid .efav-lists-menu .itm {
color: #f1f5f9 !important;
text-shadow: 0 1px 2px rgba(0,0,0,.6);
transition: background .15s ease, color .15s ease;
}
html.emby-skin.emby-style-liquid .efav-scope-menu label:hover,
html.emby-skin.emby-style-liquid .efav-lists-menu .itm:hover {
background: rgba(255,255,255,.14) !important;
color: #fff !important;
}
/* 弹窗底部操作栏(清空条件/取消/确定等)背景透明化,消除实色卡片背景残余 */
html.emby-skin.emby-style-glass .efav-modal .foot,
html.emby-skin.emby-style-glass .jhs-qs-footer,
html.emby-skin.emby-style-liquid .efav-modal .foot,
html.emby-skin.emby-style-liquid .jhs-qs-footer {
background: transparent !important;
border-top: 1px solid rgba(255,255,255,.12) !important;
}
/* 详情页元数据药丸与类别标签:液态玻璃折射与晶体高光 */
html.emby-skin.emby-style-liquid .em-meta-chip,
html.emby-skin.emby-style-liquid .em-cat {
background: linear-gradient(135deg, rgba(255,255,255,.16) 0%, rgba(255,255,255,.04) 60%, rgba(160,150,255,.1)) !important;
border: 1px solid rgba(255,255,255,.3) !important;
box-shadow:
inset 0 1px 0 rgba(255,255,255,.6),
inset 0 -1px 0 rgba(255,255,255,.1),
0 4px 12px rgba(0,0,0,.25) !important;
color: #ffffff !important;
font-weight: 600 !important;
text-shadow: 0 1px 2px rgba(0,0,0,.8);
transition: all .2s ease;
}
html.emby-skin.emby-style-liquid .em-meta-chip:hover,
html.emby-skin.emby-style-liquid .em-cat:hover {
background: linear-gradient(135deg, rgba(255,255,255,.26) 0%, rgba(255,255,255,.1) 60%, rgba(160,150,255,.18)) !important;
border-color: rgba(255,255,255,.45) !important;
box-shadow:
inset 0 1.5px 0 rgba(255,255,255,.8),
0 6px 16px rgba(0,0,0,.35) !important;
color: #fff !important;
}
/* 详情页翻译标题气泡 */
.emby-translated-title {
margin:10px 0 14px; padding:10px 14px; border-radius:8px;
background:linear-gradient(135deg, rgba(76,175,80,.14) 0%, rgba(56,142,60,.06) 100%);
border-left:4px solid #4caf50; display:flex; align-items:flex-start; gap:10px;
box-shadow:0 2px 10px rgba(0,0,0,.2);
}
.emby-translated-title .tr-label {
font-size:11px; font-weight:800; background:#4caf50; color:#fff;
border-radius:4px; padding:1px 5px; flex:none; margin-top:2px;
}
.emby-translated-title .tr-text {
font-size:15px; font-weight:600; color:var(--e-text, #e8f5e9); line-height:1.4;
}
/* 女优现役/退役徽章与资料条 */
.jhs-actress-status {
display:inline-flex; align-items:center; justify-content:center;
height:17px; padding:0 6px; border-radius:4px; font-size:10px; font-weight:700;
line-height:1; vertical-align:middle; margin-left:6px; flex:none; user-select:none;
}
.jhs-actress-status.active { background:#22c55e; color:#fff; }
.jhs-actress-status.retire { background:#ef4444; color:#fff; }
.emby-actress-profile-row {
display:flex; flex-wrap:wrap; gap:6px; margin:8px 0 12px; align-items:center;
}
.emby-actress-tag {
background:rgba(255,255,255,.06); border:1px solid rgba(255,255,255,.1);
color:var(--e-text2, #ccc); font-size:11px; border-radius:4px; padding:2px 8px;
}
/* 详情页多源播放下拉菜单 —— 双列横向弹出面板与片源状态 */
.emby-play-menu {
position:absolute; z-index:2147483640;
min-width:380px; max-width:470px;
background:rgba(24,24,27,.96); border:1px solid rgba(255,255,255,.14);
border-radius:12px; box-shadow:0 14px 36px rgba(0,0,0,.7), 0 0 0 1px rgba(255,255,255,.05);
padding:8px; display:none; flex-direction:column;
backdrop-filter:blur(20px) saturate(1.2); -webkit-backdrop-filter:blur(20px) saturate(1.2);
opacity:0; pointer-events:none;
transition:opacity .16s ease, transform .16s cubic-bezier(0.16, 1, 0.3, 1);
}
.emby-play-menu.pos-right {
top:50%; left:calc(100% + 12px);
transform:translateY(-50%) scale(0.96); transform-origin:left center;
}
.emby-play-menu.pos-right.open {
display:flex; opacity:1; pointer-events:auto;
transform:translateY(-50%) scale(1);
}
.emby-play-menu.pos-bottom {
top:calc(100% + 8px); left:0;
transform:translateY(-6px) scale(0.96); transform-origin:top left;
}
.emby-play-menu.pos-bottom.open {
display:flex; opacity:1; pointer-events:auto;
transform:translateY(0) scale(1);
}
/* 向下弹出模式:右对齐适配(混合模式或右侧空间不足时,右边缘对齐按钮右边缘,向左延展展开,彻底消除视口右侧截断) */
.emby-play-menu.pos-bottom.pos-align-right {
left:auto !important; right:0 !important;
transform-origin:top right !important;
}
html.emby-skin.cover-hybrid .emby-play-menu.pos-bottom {
left:auto !important; right:0 !important;
transform-origin:top right !important;
}
.emby-play-menu.open:not(.pos-right):not(.pos-bottom) {
display:flex; opacity:1; pointer-events:auto; top:calc(100% + 8px); left:0;
}
.emby-play-menu-header {
display:flex; flex-direction:column; gap:6px;
padding:2px 4px 6px;
border-bottom:1px solid rgba(255,255,255,.08); margin-bottom:6px;
}
.emby-play-menu-title {
font-size:11px; font-weight:700; color:var(--e-text3, #888); letter-spacing:.5px;
}
.emby-play-group-tabs {
display:flex; align-items:center; gap:4px; overflow-x:auto; padding-bottom:2px;
scrollbar-width:none;
}
.emby-play-group-tabs::-webkit-scrollbar { display:none; }
.emby-group-tab {
border:none; outline:none; background:rgba(255,255,255,.06); color:var(--e-text2, #bbb);
font-size:11px; font-weight:600; padding:2px 9px; border-radius:999px;
cursor:pointer; transition:background .15s, color .15s; white-space:nowrap;
}
.emby-group-tab:hover { background:rgba(255,255,255,.12); color:#fff; }
.emby-group-tab.active {
background:var(--e-accent, #00a4dc); color:#fff; font-weight:700;
box-shadow:0 1px 4px rgba(0,164,220,.35);
}
.emby-play-menu-grid {
display:grid; grid-template-columns:repeat(2, minmax(160px, 1fr));
gap:4px 6px; max-height:380px; overflow-y:auto; padding-right:2px;
}
.emby-play-menu-grid::-webkit-scrollbar { width:4px; }
.emby-play-menu-grid::-webkit-scrollbar-thumb { background:rgba(255,255,255,.2); border-radius:2px; }
.emby-play-menu-item {
display:flex; align-items:center; gap:5px;
padding:6px 7px; font-size:12.5px; color:var(--e-text, #eee);
border-radius:7px; text-decoration:none !important;
transition:background .15s, color .15s; cursor:pointer; user-select:none;
background:rgba(255,255,255,.02); border:1px solid transparent;
box-sizing:border-box; overflow:hidden;
}
.emby-play-menu-item:hover {
background:rgba(255,255,255,.08); color:#fff; border-color:rgba(255,255,255,.06);
}
.emby-play-menu-item.selected {
background:rgba(0,164,220,.18); color:var(--e-accent, #00a4dc); font-weight:600;
border-color:rgba(0,164,220,.3);
}
.emby-play-menu-item.selected .emby-site-name { color:#fff; }
.emby-play-menu-item.is-filtered-out { display:none !important; }
.emby-site-status {
display:inline-flex; align-items:center; justify-content:center;
flex-shrink:0; margin-left:3px;
}
.emby-site-status.status-ok {
width:14px; height:14px; border-radius:50%; background:#22c55e; color:#fff;
font-size:9.5px; font-weight:800; line-height:1;
}
.emby-site-status.status-fail {
width:14px; height:14px; border-radius:50%; background:#ef4444; color:#fff;
font-size:9px; font-weight:800; line-height:1;
}
.emby-site-status.status-loading {
width:12px; height:12px; border-radius:50%;
border:2px solid rgba(255,255,255,.2); border-top-color:var(--e-accent,#00a4dc);
animation:embyStatusSpin .8s linear infinite;
}
@keyframes embyStatusSpin {
0% { transform:rotate(0deg); }
100% { transform:rotate(360deg); }
}
.emby-site-name {
flex:1; min-width:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
cursor:pointer;
}
.emby-site-check {
font-size:15px; color:var(--e-accent, #00a4dc);
opacity:0; transition:opacity .15s; flex-shrink:0; margin-left:auto;
}
.emby-play-menu-item.selected .emby-site-check { opacity:1; }
.emby-site-badge {
font-size:9.5px; padding:1px 4.5px; border-radius:3px;
font-weight:600; letter-spacing:0.3px; flex-shrink:0; line-height:1.2;
border:1px solid transparent; box-sizing:border-box; white-space:nowrap;
}
.emby-site-badge-official {
background:rgba(0,164,220,.2); color:var(--e-accent, #00a4dc);
border-color:rgba(0,164,220,.35);
}
.emby-site-badge-main {
background:rgba(255,107,129,.16); color:#ff6b81;
border-color:rgba(255,107,129,.3);
}
.emby-site-badge-hot {
background:rgba(46,213,115,.16); color:#2ed573;
border-color:rgba(46,213,115,.3);
}
.emby-site-badge-general {
background:rgba(255,165,2,.16); color:#ffa502;
border-color:rgba(255,165,2,.3);
}
.emby-site-badge-trailer {
background:rgba(165,94,234,.16); color:#a55eea;
border-color:rgba(165,94,234,.3);
}
.emby-site-badge-info {
background:rgba(52,152,219,.16); color:#3498db;
border-color:rgba(52,152,219,.3);
}
.emby-site-badge-default {
background:rgba(255,255,255,.08); color:var(--e-text2, #bbb);
border-color:rgba(255,255,255,.1);
}
.emby-play-menu-divider { height:1px; background:rgba(255,255,255,.08); margin:4px 0; }
.emby-play-menu-config {
padding:6px 10px; font-size:11px; color:var(--e-text3, #888);
display:flex; align-items:center; gap:6px; cursor:pointer; border-radius:6px;
transition:background .15s, color .15s;
}
.emby-play-menu-config:hover { background:rgba(255,255,255,.06); color:var(--e-accent, #00a4dc); }
/* 设置面板拖拽排序与分组样式 */
.site-drag-handle {
cursor:grab; user-select:none; color:#666; font-size:18px; display:inline-flex; align-items:center;
transition:color .15s;
}
.site-drag-handle:hover { color:var(--e-accent, #00a4dc); }
.site-sortable-row.is-dragging {
opacity:0.35; background:rgba(0,164,220,.12) !important;
}
.site-sortable-row.drag-over {
border-top:2px solid var(--e-accent, #00a4dc) !important;
}
.site-group-badge {
font-size:10px; background:rgba(255,255,255,.1); padding:1px 5px; border-radius:3px; color:#aaa;
}
/* 番号信息站点一键跳转图标组(JAVBUS / JAVLib / FANZA動画 / javtxt) */
.emby-info-sites {
display:inline-flex; align-items:center; gap:5px;
vertical-align:middle; margin-left:2px; flex-shrink:0;
}
.emby-info-sites-divider {
width:1px; height:14px;
background:rgba(255,255,255,.18);
margin:0 5px 0 3px;
flex-shrink:0; display:inline-block;
}
.emby-info-site-btn {
display:inline-flex; align-items:center; justify-content:center;
width:24px; height:24px; border-radius:5px;
background:rgba(255,255,255,.06); border:1px solid rgba(255,255,255,.12);
box-shadow:0 1px 3px rgba(0,0,0,.3);
transition:transform .18s cubic-bezier(0.16, 1, 0.3, 1), background .18s ease, border-color .18s ease, box-shadow .18s ease;
text-decoration:none !important;
box-sizing:border-box; cursor:pointer; flex-shrink:0;
overflow:hidden; position:relative;
}
.emby-info-site-btn:hover {
background:rgba(255,255,255,.16);
border-color:rgba(255,255,255,.3);
transform:translateY(-1.5px) scale(1.12);
box-shadow:0 4px 10px rgba(0,0,0,.5);
}
.emby-info-site-btn:active {
transform:translateY(0) scale(0.95);
}
.emby-info-site-btn .emby-site-logo-wrap {
width:16px; height:16px; border-radius:3px;
display:inline-flex; align-items:center; justify-content:center;
background:transparent; border:none; box-shadow:none;
}
.emby-info-site-btn .emby-site-logo {
width:100%; height:100%; object-fit:contain;
border-radius:2px;
}
.emby-info-site-btn .emby-site-logo-fallback {
font-size:9px; font-weight:800; line-height:1;
color:var(--e-accent, #00a4dc);
}
.emby-info-site-btn.emby-info-site-javbus:hover {
border-color:rgba(14,165,233,.7);
box-shadow:0 3px 10px rgba(14,165,233,.35);
}
.emby-info-site-btn.emby-info-site-javbus .emby-site-logo-fallback { color:#38bdf8; }
.emby-info-site-btn.emby-info-site-javlib:hover {
border-color:rgba(34,197,94,.7);
box-shadow:0 3px 10px rgba(34,197,94,.35);
}
.emby-info-site-btn.emby-info-site-javlib .emby-site-logo-fallback { color:#4ade80; }
.emby-info-site-btn.emby-info-site-fanza:hover {
border-color:rgba(236,72,153,.7);
box-shadow:0 3px 10px rgba(236,72,153,.35);
}
.emby-info-site-btn.emby-info-site-fanza .emby-site-logo-fallback { color:#f472b6; }
.emby-info-site-btn.emby-info-site-javtxt:hover {
border-color:rgba(245,158,11,.7);
box-shadow:0 3px 10px rgba(245,158,11,.35);
}
.emby-info-site-btn.emby-info-site-javtxt .emby-site-logo-fallback { color:#fbbf24; }
/* 快捷设置下拉选择器与悬停提示 Tooltip */
.mini-switch {
appearance:none; -webkit-appearance:none; width:34px; height:18px;
background:rgba(255,255,255,.18); border-radius:18px; position:relative;
cursor:pointer; outline:none; transition:background .2s; flex-shrink:0;
}
.mini-switch:checked { background:var(--e-accent, #00a4dc); }
.mini-switch::before {
content:""; position:absolute; width:14px; height:14px; border-radius:50%;
background:#fff; top:2px; left:2px; box-shadow:0 1px 3px rgba(0,0,0,.3); transition:transform .2s;
}
.mini-switch:checked::before { transform:translateX(16px); }
.jhs-tooltip-wrap { position:relative; display:inline-flex; align-items:center; }
.jhs-tooltip-wrap .jhs-help-icon {
font-size:13.5px; color:var(--e-text3, #888); margin-left:4px; cursor:help;
transition:color .15s; line-height:1; vertical-align:middle;
}
.jhs-tooltip-wrap:hover .jhs-help-icon, .jhs-help-icon:hover { color:var(--e-accent, #00a4dc); }
.jhs-tip-bubble {
position:absolute; bottom:calc(100% + 6px); left:0; transform:translateY(4px);
background:rgba(20,20,24,.95); color:#eee; border:1px solid rgba(255,255,255,.18);
border-radius:6px; padding:5px 9px; font-size:11px; line-height:1.35; white-space:normal;
width:max-content; max-width:230px; pointer-events:none; opacity:0; visibility:hidden;
box-shadow:0 4px 14px rgba(0,0,0,.55); z-index:9999; transition:opacity .18s ease, transform .18s ease;
}
.jhs-tooltip-wrap:hover .jhs-tip-bubble { opacity:1; visibility:visible; transform:translateY(0); }
.jhs-qs-select {
background:rgba(255,255,255,.08); border:1px solid rgba(255,255,255,.14);
border-radius:6px; color:var(--e-text, #fff); font-size:11.5px;
padding:3px 8px; outline:none; cursor:pointer; width:125px;
}
.jhs-qs-select option { background:#1f1f23; color:#fff; }
/* ====== 搜索弹窗(点击搜索框 → 屏幕正中,内容模糊为背景) ====== */
#emby-search-modal {
position:fixed; inset:0; z-index:2147483700;
display:flex; align-items:center; justify-content:center;
background:rgba(0,0,0,.55);
backdrop-filter:blur(16px) saturate(1.1);
-webkit-backdrop-filter:blur(16px) saturate(1.1);
/* 颜色类过渡在此补齐(而非用更高特异性规则覆盖),保住弹窗淡入动画 */
opacity:0; visibility:hidden;
transition:opacity .3s ease, visibility .3s ease, background-color .4s ease;
}
#emby-search-modal.open { opacity:1; visibility:visible; }
#emby-search-modal .emby-search-inner {
width:min(720px,92vw); transform:scale(.95) translateY(8px);
transition:transform .35s cubic-bezier(.22,1,.36,1);
}
#emby-search-modal.open .emby-search-inner { transform:scale(1) translateY(0); }
#emby-search-modal .emby-search-close {
position:absolute; top:18px; right:24px; background:none; border:none;
color:rgba(255,255,255,.8); font-size:2em; cursor:pointer; line-height:1;
}
#emby-search-modal .emby-search-close:hover { color:#fff; }
/* 克隆原版搜索框:不做样式改动,仅修复 search-type 下拉箭头残留
(去掉原生 select 箭头,仅保留 Bulma 的 ::after 箭头) */
#emby-search-modal .select select {
-webkit-appearance:none !important; -moz-appearance:none !important; appearance:none !important;
background-image:none !important;
}
#emby-search-modal .search-bar-wrap { max-width:none !important; margin:0 auto; }
#emby-search-modal .search-panel { box-shadow:0 1em 3em rgba(0,0,0,.5); }
#emby-search-modal .search-recent-keywords { color:#ddd; }
#emby-search-modal .search-recent-keywords a { color:var(--e-accent,#00a4dc); }
/* ====== 关闭态:所有注入结构不可见 ====== */
html:not(.emby-skin) #emby-topbar,
html:not(.emby-skin) #emby-drawer,
html:not(.emby-skin) #emby-drawer-scrim,
html:not(.emby-skin) #emby-detail-bg,
html:not(.emby-skin) #emby-detail-scrim,
html:not(.emby-skin) .emby-detail-hero,
html:not(.emby-skin) .emby-card-play { display:none !important; }
/* ====== JAVDB 荣誉榜单 金标展示 (v7.261 精细化重构:全实心图标 + 收藏夹同步HUD) ====== */
/* 核心流光掠影动画(高光光线周期性划过卡片与徽章) */
@keyframes goldShimmer {
0% { transform: translateX(-100%); }
100% { transform: translateX(250%); }
}
.shimmer-effect {
position: relative;
overflow: hidden;
}
.shimmer-effect::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent 0%, rgba(255, 255, 255, 0.22) 50%, transparent 100%);
transform: translateX(-100%);
animation: goldShimmer 3.5s infinite cubic-bezier(0.4, 0, 0.2, 1);
pointer-events: none;
z-index: 2;
}
/* T形结构分栏布局 */
.emby-content-split {
display: flex;
gap: 24px;
align-items: flex-start;
width: 100%;
margin-top: .8em;
}
.emby-meta-col {
flex: 1 1 0%;
min-width: 280px;
}
.emby-trophy-col {
width: 252px;
flex-shrink: 0;
z-index: 4;
}
@media (max-width: 960px) {
.emby-content-split {
flex-direction: column;
gap: 16px;
}
.emby-trophy-col {
width: 100%;
}
}
/* 混合模式右侧操作栏适配 (居中播放控件 + 下方放置金标卡) */
html.emby-skin.cover-hybrid .emby-detail-hero .emby-action-panel {
flex: 0 0 auto;
width: 252px !important;
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding-top: .2em;
position: relative;
z-index: 20;
}
html.emby-skin.cover-hybrid .emby-detail-hero .emby-action-panel .emby-btn-group {
flex-direction: column;
gap: 14px;
align-items: center !important;
justify-content: center !important;
margin-top: 0;
padding: 14px 12px;
background: rgba(14,14,18,.35);
backdrop-filter: blur(8px);
border: 1px solid rgba(255,255,255,.08);
border-radius: var(--e-r);
width: 100% !important;
box-sizing: border-box;
position: relative;
z-index: 100 !important;
}
html.emby-skin.cover-hybrid .emby-detail-hero .emby-action-panel .emby-play-group {
width: 100% !important;
display: flex !important;
justify-content: center !important;
}
html.emby-skin.cover-hybrid .emby-detail-hero .emby-action-panel .emby-btn-group > :first-child {
width: 100%;
display: flex;
justify-content: center;
}
html.emby-skin.cover-hybrid .emby-detail-hero .emby-action-panel .emby-action-row {
display: flex !important;
justify-content: center !important;
align-items: center !important;
gap: 14px !important;
width: 100% !important;
}
html.emby-skin.cover-hybrid .emby-detail-hero .emby-action-panel .emby-trophy-card {
width: 100% !important;
max-width: 100% !important;
margin-bottom: 0 !important;
position: relative;
z-index: 1 !important;
}
/* 方案 1: 金标荣誉卡 (Steam 3D 物理倾斜 + 缩小10% + 掠影流光,无光标辉光,全实心图标) */
.emby-trophy-card {
position: relative;
width: 252px;
box-sizing: border-box;
background: linear-gradient(150deg, rgba(28, 24, 16, 0.92) 0%, rgba(18, 17, 13, 0.95) 100%);
border: 1px solid rgba(234, 179, 8, 0.4);
border-radius: 14px;
padding: 13px 14px;
box-shadow: 0 10px 30px -5px rgba(212, 175, 55, 0.28), 0 0 0 1px rgba(255, 215, 0, 0.35);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
color: #fff;
overflow: hidden;
transform-style: preserve-3d;
will-change: transform;
transition: box-shadow 0.25s ease, border-color 0.25s ease;
cursor: default;
user-select: none;
}
.emby-trophy-card:hover {
border-color: rgba(255, 215, 0, 0.7);
box-shadow: 0 14px 36px rgba(0, 0, 0, 0.65), 0 0 26px rgba(246, 195, 67, 0.3);
}
.emby-trophy-header {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 10px;
border-bottom: 1px solid rgba(234, 179, 8, 0.2);
}
.emby-trophy-header-left {
display: flex;
align-items: center;
gap: 8px;
}
.emby-trophy-crown {
font-size: 20px;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
filter: drop-shadow(0 2px 4px rgba(0,0,0,.4));
}
.emby-trophy-crown svg {
width: 22px;
height: 22px;
fill: #f6c343;
}
.emby-trophy-titles {
display: flex;
flex-direction: column;
}
.emby-trophy-main-title {
font-size: 12px;
font-weight: 800;
letter-spacing: .04em;
background: linear-gradient(135deg, #fff2a3 0%, #ffd700 50%, #d4af37 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.emby-trophy-sub-title {
font-size: 9.5px;
color: rgba(246, 195, 67, 0.75);
margin-top: 1px;
}
.emby-trophy-count-badge {
font-size: 9.5px;
font-weight: 700;
color: #f6c343;
background: rgba(246, 195, 67, 0.15);
border: 1px solid rgba(246, 195, 67, 0.3);
padding: 1.5px 6px;
border-radius: 999px;
font-family: monospace;
white-space: nowrap;
}
.emby-trophy-rank-box {
padding: 8px 0 7px;
text-align: center;
border-bottom: 1px solid rgba(234, 179, 8, 0.15);
}
/* 番号受奖铭牌(方案 B:排位区上方受奖主体铭条) */
.emby-trophy-work-plaque {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
margin-bottom: 5px;
}
.emby-trophy-work-line {
width: 22px;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(254, 211, 104, 0.65), transparent);
}
.emby-trophy-work-code {
font-size: 11.5px;
font-weight: 800;
color: #fff;
letter-spacing: .12em;
text-transform: uppercase;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.6);
}
.emby-trophy-rank-label {
font-size: 10.5px;
color: rgba(255, 255, 255, 0.55);
font-weight: 600;
letter-spacing: .02em;
}
.emby-trophy-rank-val {
display: flex;
align-items: baseline;
justify-content: center;
gap: 4px;
margin: 3px 0;
font-family: 'Bodoni MT', 'Didot', 'Didot LT STD', 'Hoefler Text', 'Georgia', 'Times New Roman', serif;
font-feature-settings: "lnum";
}
.emby-trophy-no {
font-size: 16px;
font-weight: 800;
color: #fed368;
letter-spacing: .02em;
filter: drop-shadow(0 2px 6px rgba(254, 211, 104, 0.45));
}
.emby-trophy-num {
font-size: 34px;
font-weight: 900;
line-height: 1;
background: linear-gradient(180deg, #ffffff 0%, #ffebaa 25%, #fed368 60%, #f39c12 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
filter: drop-shadow(0 3px 12px rgba(254, 211, 104, 0.5));
}
.emby-trophy-award-name {
font-size: 11.5px;
font-weight: 700;
color: #fde047;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding: 0 4px;
}
.emby-trophy-records {
padding-top: 10px;
}
.emby-trophy-records-title {
font-size: 10.5px;
font-weight: 700;
color: rgba(255, 255, 255, 0.7);
letter-spacing: .02em;
margin-bottom: 7px;
display: flex;
align-items: center;
}
.emby-trophy-list {
display: flex;
flex-direction: column;
gap: 5px;
}
.emby-trophy-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 5px 8px;
background: rgba(0, 0, 0, 0.4);
border: 1px solid rgba(234, 179, 8, 0.2);
border-radius: 7px;
text-decoration: none !important;
color: inherit !important;
transition: background .18s ease, border-color .18s ease, transform .18s ease;
}
.emby-trophy-item:hover {
background: rgba(246, 195, 67, 0.15);
border-color: rgba(246, 195, 67, 0.5);
transform: translateX(2px);
}
.emby-trophy-item-left {
display: flex;
align-items: center;
min-width: 0;
flex: 1;
margin-right: 6px;
}
.emby-trophy-item-icon,
.emby-trophy-star-icon {
font-size: 15px;
color: #f6c343;
margin-right: 6px;
flex-shrink: 0;
vertical-align: middle;
font-variation-settings: 'FILL' 1, 'wght' 600 !important;
}
.emby-trophy-item-name {
font-size: 11px;
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.emby-trophy-tag {
font-size: 10px;
font-family: monospace;
font-weight: 800;
color: #000;
background: #f6c343;
padding: 1px 5px;
border-radius: 3px;
flex-shrink: 0;
}
.emby-trophy-footer {
margin-top: 10px;
font-size: 9.5px;
color: rgba(246, 195, 67, 0.55);
text-align: center;
font-family: monospace;
}
/* 方案 2: 简约金标条 (精简尺寸,单行容纳3个金标,统一皇冠标识) */
.emby-trophy-bar {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
margin: 6px 0 10px;
background: transparent !important;
border: none !important;
padding: 0 !important;
box-shadow: none !important;
max-width: 100%;
}
.emby-trophy-bar-badge {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 8px;
border-radius: 7px;
background: linear-gradient(to right, #1f1a0e, #2a2211);
border: 1px solid rgba(234, 179, 8, 0.42);
box-shadow: 0 0 12px rgba(255, 215, 0, 0.18), inset 0 1px 1px rgba(255, 255, 255, 0.22);
text-decoration: none !important;
color: inherit !important;
cursor: pointer;
transition: transform .18s ease, border-color .18s ease, box-shadow .18s ease;
flex-shrink: 0;
}
.emby-trophy-bar-badge:hover {
border-color: #ffd700;
transform: translateY(-1px);
box-shadow: 0 0 18px rgba(255, 215, 0, 0.32), inset 0 1px 1px rgba(255, 255, 255, 0.3);
}
.emby-trophy-bar-icon {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 13px;
line-height: 1;
}
.emby-trophy-bar-icon svg {
width: 13px;
height: 13px;
fill: #f6c343;
}
.emby-trophy-bar-name {
font-size: 11px;
font-weight: 700;
color: #fde047;
text-shadow: 0 1px 3px rgba(0,0,0,.5);
white-space: nowrap;
}
.emby-trophy-bar-rank {
font-size: 10px;
font-weight: 900;
font-family: monospace;
color: #000;
background: #eab308;
padding: 1px 5px;
border-radius: 3px;
box-shadow: 0 1px 2px rgba(0,0,0,.35);
white-space: nowrap;
}
/* 方案 4: 紧凑HUD (与收藏夹 .efav-award-badge 完全同步:明黄黑字药丸 + 实心皇冠 + TOP250 + #排名 + 多标+N角标) */
.emby-trophy-hud-badge {
display: inline-flex !important;
align-items: center;
gap: 3px;
margin-left: 8px;
background: #fed368 !important;
color: #181200 !important;
font-weight: 800 !important;
font-size: 10.5px !important;
line-height: 1 !important;
padding: 2.5px 7px;
border-radius: 999px;
border: 1px solid rgba(0, 0, 0, 0.08);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25);
text-decoration: none !important;
vertical-align: middle;
user-select: none;
cursor: pointer;
transition: transform .18s ease, box-shadow .18s ease;
}
.emby-trophy-hud-badge:hover {
transform: translateY(-1px);
box-shadow: 0 3px 8px rgba(254, 211, 104, 0.5);
}
.emby-trophy-hud-icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
}
.emby-trophy-hud-icon svg {
width: 12px;
height: 12px;
fill: currentColor !important;
}
.emby-trophy-hud-name {
font-size: 10.5px;
font-weight: 800;
color: #181200;
letter-spacing: 0.02em;
}
.emby-trophy-hud-badge .aw-rank {
font-size: 9.5px;
opacity: .88;
margin-left: 1px;
font-weight: 700;
color: #181200;
}
.emby-trophy-hud-badge .aw-more {
background: rgba(0, 0, 0, 0.18);
color: #181200;
border-radius: 999px;
padding: 1px 4px;
font-size: 8.5px;
font-weight: 900;
line-height: 1;
margin-left: 2px;
}
/* 显隐控制 (跟随 data-trophy-style) */
html[data-trophy-style="off"] .emby-trophy-card,
html[data-trophy-style="off"] .emby-trophy-bar,
html[data-trophy-style="off"] .emby-trophy-hud-badge {
display: none !important;
}
html[data-trophy-style="bar"] .emby-trophy-card,
html[data-trophy-style="bar"] .emby-trophy-hud-badge {
display: none !important;
}
html[data-trophy-style="hud"] .emby-trophy-card,
html[data-trophy-style="hud"] .emby-trophy-bar {
display: none !important;
}
html[data-trophy-style="card"] .emby-trophy-bar,
html[data-trophy-style="card"] .emby-trophy-hud-badge {
display: none !important;
}
html:not([data-trophy-style="card"]) .emby-trophy-col {
display: none !important;
}
/* ====== 风格适配:毛玻璃状态 (emby-style-glass) ====== */
html.emby-skin.emby-style-glass .emby-trophy-card {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.14) 0%, rgba(255, 255, 255, 0.04) 60%, rgba(246, 195, 67, 0.08) 100%) !important;
backdrop-filter: blur(26px) saturate(1.9) brightness(1.05) !important;
-webkit-backdrop-filter: blur(26px) saturate(1.9) brightness(1.05) !important;
border: 1px solid rgba(255, 255, 255, 0.24) !important;
box-shadow: 0 14px 38px rgba(0, 0, 0, 0.38), inset 0 1px 1px rgba(255, 255, 255, 0.38), 0 0 20px rgba(246, 195, 67, 0.16) !important;
}
html.emby-skin.emby-style-glass .emby-trophy-card:hover {
border-color: rgba(255, 215, 0, 0.65) !important;
box-shadow: 0 18px 46px rgba(0, 0, 0, 0.5), inset 0 1px 1px rgba(255, 255, 255, 0.5), 0 0 28px rgba(246, 195, 67, 0.32) !important;
}
html.emby-skin.emby-style-glass .emby-trophy-header {
border-bottom: 1px solid rgba(255, 255, 255, 0.14) !important;
}
html.emby-skin.emby-style-glass .emby-trophy-count-badge {
background: rgba(255, 255, 255, 0.12) !important;
border: 1px solid rgba(255, 255, 255, 0.25) !important;
color: #fed368 !important;
}
html.emby-skin.emby-style-glass .emby-trophy-rank-box {
border-bottom: 1px solid rgba(255, 255, 255, 0.14) !important;
}
html.emby-skin.emby-style-glass .emby-trophy-work-code {
color: #fff !important;
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.5) !important;
}
html.emby-skin.emby-style-glass .emby-trophy-work-line {
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.65), transparent) !important;
}
html.emby-skin.emby-style-glass .emby-trophy-item {
background: rgba(255, 255, 255, 0.07) !important;
border: 1px solid rgba(255, 255, 255, 0.14) !important;
backdrop-filter: blur(8px) !important;
-webkit-backdrop-filter: blur(8px) !important;
}
html.emby-skin.emby-style-glass .emby-trophy-item:hover {
background: rgba(246, 195, 67, 0.18) !important;
border-color: rgba(246, 195, 67, 0.45) !important;
}
/* ====== 风格适配:毛玻璃状态 (emby-style-glass) - 详情页操作按钮与播放按钮 ====== */
html.emby-skin.emby-style-glass .emby-play-btn,
html.emby-skin.emby-style-glass .emby-play-split-btn {
--btn-logo-rgb: 0, 164, 220;
background: linear-gradient(95deg,
rgba(var(--btn-logo-rgb), 0.48) 0%,
rgba(var(--btn-logo-rgb), 0.24) 30%,
rgba(255, 255, 255, 0.10) 66%,
rgba(255, 255, 255, 0.05) 100%
), rgba(18, 24, 38, 0.58) !important;
backdrop-filter: blur(20px) saturate(1.8) brightness(1.06) !important;
-webkit-backdrop-filter: blur(20px) saturate(1.8) brightness(1.06) !important;
border: 1px solid rgba(255, 255, 255, 0.28) !important;
border-radius: 999px !important;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35), 0 0 20px rgba(var(--btn-logo-rgb), 0.25), inset 0 1px 1.5px rgba(255, 255, 255, 0.5) !important;
transition: all 0.25s cubic-bezier(0.2, 0, 0.2, 1) !important;
}
html.emby-skin.emby-style-glass .emby-play-main-action,
html.emby-skin.emby-style-glass .emby-play-arrow-action {
background: transparent !important;
}
html.emby-skin.emby-style-glass .emby-play-btn:hover,
html.emby-skin.emby-style-glass .emby-play-split-btn:hover {
background: linear-gradient(95deg,
rgba(var(--btn-logo-rgb), 0.62) 0%,
rgba(var(--btn-logo-rgb), 0.32) 34%,
rgba(255, 255, 255, 0.15) 68%,
rgba(255, 255, 255, 0.08) 100%
), rgba(18, 24, 38, 0.68) !important;
border-color: rgba(255, 255, 255, 0.52) !important;
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.45), 0 0 26px rgba(var(--btn-logo-rgb), 0.4), inset 0 1px 2px rgba(255, 255, 255, 0.75) !important;
transform: translateY(-1px) !important;
}
html.emby-skin.emby-style-glass .emby-play-split-divider {
background: rgba(255, 255, 255, 0.32) !important;
}
html.emby-skin.emby-style-glass .emby-circle-btn {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.16) 0%, rgba(255, 255, 255, 0.04) 60%, rgba(160, 140, 255, 0.1) 100%) !important;
backdrop-filter: blur(20px) saturate(1.8) brightness(1.04) !important;
-webkit-backdrop-filter: blur(20px) saturate(1.8) brightness(1.04) !important;
border: 1px solid rgba(255, 255, 255, 0.26) !important;
color: rgba(255, 255, 255, 0.85) !important;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.3), inset 0 1px 1px rgba(255, 255, 255, 0.4) !important;
}
html.emby-skin.emby-style-glass .emby-circle-btn:hover {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.28), rgba(255, 255, 255, 0.08) 60%, rgba(180, 160, 255, 0.2)) !important;
border-color: rgba(255, 255, 255, 0.52) !important;
color: #fff !important;
transform: translateY(-1px) !important;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4), inset 0 1px 1.5px rgba(255, 255, 255, 0.65) !important;
}
html.emby-skin.emby-style-glass .emby-circle-btn.active[data-type="wishlist"] {
background: linear-gradient(135deg, rgba(0, 164, 220, 0.35), rgba(0, 164, 220, 0.12)) !important;
border-color: rgba(0, 195, 255, 0.65) !important;
color: #00e1ff !important;
box-shadow: 0 6px 20px rgba(0, 164, 220, 0.35), inset 0 1px 1px rgba(255, 255, 255, 0.5) !important;
}
html.emby-skin.emby-style-glass .emby-circle-btn.active[data-type="watched"] {
background: linear-gradient(135deg, rgba(76, 175, 80, 0.35), rgba(76, 175, 80, 0.12)) !important;
border-color: rgba(100, 230, 110, 0.65) !important;
color: #72ff77 !important;
box-shadow: 0 6px 20px rgba(76, 175, 80, 0.35), inset 0 1px 1px rgba(255, 255, 255, 0.5) !important;
}
html.emby-skin.emby-style-glass .emby-circle-btn.active[data-type="list"] {
background: linear-gradient(135deg, rgba(255, 91, 127, 0.35), rgba(255, 91, 127, 0.12)) !important;
border-color: rgba(255, 120, 150, 0.65) !important;
color: #ff7e9a !important;
box-shadow: 0 6px 20px rgba(255, 91, 127, 0.35), inset 0 1px 1px rgba(255, 255, 255, 0.5) !important;
}
html.emby-skin.emby-style-glass .emby-play-menu {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.14) 0%, rgba(255, 255, 255, 0.04) 100%), rgba(20, 24, 36, 0.70) !important;
backdrop-filter: blur(26px) saturate(1.9) brightness(1.06) !important;
-webkit-backdrop-filter: blur(26px) saturate(1.9) brightness(1.06) !important;
border: 1px solid rgba(255, 255, 255, 0.22) !important;
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.5), inset 0 1px 1px rgba(255, 255, 255, 0.35) !important;
}
html.emby-skin.emby-style-glass .emby-play-menu .emby-play-menu-title {
color: #ffffff !important;
font-weight: 700 !important;
text-shadow: 0 1px 2px rgba(0,0,0,.85);
}
html.emby-skin.emby-style-glass .emby-play-menu .emby-group-tab {
background: rgba(255, 255, 255, 0.1) !important;
border: 1px solid rgba(255, 255, 255, 0.18) !important;
color: #e2e8f0 !important;
}
html.emby-skin.emby-style-glass .emby-play-menu .emby-group-tab.active {
background: var(--e-accent, #00a4dc) !important;
border-color: var(--e-accent, #00a4dc) !important;
color: #fff !important;
box-shadow: 0 2px 8px rgba(0,164,220,.4) !important;
}
html.emby-skin.emby-style-glass .emby-play-menu-item {
background: rgba(255, 255, 255, 0.08) !important;
border: 1px solid rgba(255, 255, 255, 0.14) !important;
transition: all .2s ease !important;
}
html.emby-skin.emby-style-glass .emby-play-menu-item:hover {
background: rgba(255, 255, 255, 0.18) !important;
border-color: rgba(255, 255, 255, 0.3) !important;
}
html.emby-skin.emby-style-glass .emby-play-menu-item.selected {
background: rgba(0, 164, 220, 0.3) !important;
border-color: rgba(0, 164, 220, 0.7) !important;
}
html.emby-skin.emby-style-glass .emby-play-menu .emby-site-name {
color: #ffffff !important;
font-weight: 600 !important;
text-shadow: 0 1px 2px rgba(0,0,0,.85);
}
html.emby-skin.emby-style-glass .emby-play-menu-config {
color: #cbd5e1 !important;
font-weight: 500 !important;
}
/* 混合模式右侧操作栏按钮组毛玻璃联动 */
html.emby-skin.cover-hybrid.emby-style-glass .emby-detail-hero .emby-action-panel .emby-btn-group,
html.emby-skin.emby-style-glass .emby-detail-hero .emby-action-panel .emby-btn-group {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.12) 0%, rgba(255, 255, 255, 0.03) 100%), rgba(20, 24, 36, 0.35) !important;
backdrop-filter: blur(26px) saturate(1.9) brightness(1.06) !important;
-webkit-backdrop-filter: blur(26px) saturate(1.9) brightness(1.06) !important;
border: 1px solid rgba(255, 255, 255, 0.22) !important;
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.38), inset 0 1px 1.5px rgba(255, 255, 255, 0.4) !important;
}
/* ====== 风格适配:液态玻璃状态 (emby-style-liquid 原厂真折射层规范 - 金标荣誉卡) ====== */
html.emby-skin.emby-style-liquid .emby-trophy-card {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.08) 0%, rgba(255, 255, 255, 0.02) 60%, rgba(200, 220, 255, 0.04) 100%) !important;
backdrop-filter: blur(0.25px) brightness(1.04) saturate(1.08);
-webkit-backdrop-filter: blur(0.25px) brightness(1.04) saturate(1.08);
border: 1px solid rgba(255, 255, 255, 0.24) !important;
color: #fff !important;
box-shadow:
inset 0 1.5px 0 rgba(255, 255, 255, 0.65),
inset 0 -1px 0 rgba(255, 255, 255, 0.1),
inset 1px 0 0 rgba(255, 255, 255, 0.18),
inset -1px 0 0 rgba(255, 255, 255, 0.1),
0 10px 30px rgba(0, 0, 0, 0.35) !important;
position: relative;
}
/* 顶部 specular 亮条(液态玻璃标志性弧光) */
html.emby-skin.emby-style-liquid .emby-trophy-card::before {
content: '';
position: absolute;
left: 8%;
right: 8%;
top: 0;
height: 2px;
border-radius: 2px;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.85), transparent);
opacity: 0.75;
pointer-events: none;
z-index: 3;
}
html.emby-skin.emby-style-liquid .emby-trophy-card:hover {
border-color: rgba(255, 255, 255, 0.45) !important;
box-shadow:
inset 0 1.5px 0 rgba(255, 255, 255, 0.85),
inset 0 -1px 0 rgba(255, 255, 255, 0.16),
inset 1px 0 0 rgba(255, 255, 255, 0.26),
inset -1px 0 0 rgba(255, 255, 255, 0.14),
0 14px 38px rgba(0, 0, 0, 0.45) !important;
}
html.emby-skin.emby-style-liquid .emby-trophy-header {
border-bottom: 1px solid rgba(255, 255, 255, 0.14) !important;
}
html.emby-skin.emby-style-liquid .emby-trophy-count-badge {
font-size: 9.5px;
font-weight: 700;
color: #fed368 !important;
background: rgba(255, 255, 255, 0.14) !important;
border: 1px solid rgba(255, 255, 255, 0.28) !important;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.35);
padding: 1.5px 6px;
border-radius: 999px;
white-space: nowrap;
}
html.emby-skin.emby-style-liquid .emby-trophy-rank-box {
border-bottom: 1px solid rgba(255, 255, 255, 0.14) !important;
}
html.emby-skin.emby-style-liquid .emby-trophy-work-code {
color: #fff !important;
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.5) !important;
}
html.emby-skin.emby-style-liquid .emby-trophy-work-line {
background: linear-gradient(90deg, transparent, rgba(254, 211, 104, 0.75), transparent) !important;
}
html.emby-skin.emby-style-liquid .emby-trophy-num {
filter: drop-shadow(0 3px 14px rgba(254, 211, 104, 0.65)) !important;
}
html.emby-skin.emby-style-liquid .emby-trophy-item {
background: rgba(255, 255, 255, 0.05) !important;
border: 1px solid rgba(255, 255, 255, 0.12) !important;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.15) !important;
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
}
html.emby-skin.emby-style-liquid .emby-trophy-item:hover {
background: rgba(255, 255, 255, 0.10) !important;
border-color: rgba(255, 255, 255, 0.25) !important;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25), 0 0 12px rgba(246, 195, 67, 0.2) !important;
}
/* 混合模式右侧操作栏按钮组:完全透明容器,绝不套娃重复折射 */
html.emby-skin.cover-hybrid.emby-style-liquid .emby-detail-hero .emby-action-panel .emby-btn-group {
background: transparent !important;
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
border: none !important;
box-shadow: none !important;
position: relative;
}
html.emby-skin.cover-hybrid.emby-style-liquid .emby-detail-hero .emby-action-panel .emby-btn-group::before {
display: none !important;
}
/* ====== 风格适配:液态玻璃状态 (emby-style-liquid) - 详情页操作按钮与播放按钮(真水感透光折射) ====== */
html.emby-skin.emby-style-liquid .emby-play-btn,
html.emby-skin.emby-style-liquid .emby-play-split-btn {
--btn-logo-rgb: 0, 164, 220;
background: linear-gradient(135deg,
rgba(255, 255, 255, 0.16) 0%,
rgba(255, 255, 255, 0.04) 50%,
rgba(var(--btn-logo-rgb), 0.14) 100%
), rgba(16, 20, 32, 0.28) !important;
backdrop-filter: blur(0.25px) brightness(1.04) saturate(1.08);
-webkit-backdrop-filter: blur(0.25px) brightness(1.04) saturate(1.08);
border: 1px solid rgba(255, 255, 255, 0.32) !important;
border-radius: 999px !important;
color: #fff !important;
position: relative;
box-shadow:
inset 0 1.5px 0 rgba(255, 255, 255, 0.75),
inset 0 -1px 0 rgba(255, 255, 255, 0.12),
0 6px 22px rgba(0, 0, 0, 0.35),
0 0 18px rgba(var(--btn-logo-rgb), 0.20) !important;
overflow: hidden;
}
/* 关键修复:子元素(链接及按钮)彻底透明,让背景色彩 100% 自由穿透透镜内部 */
html.emby-skin.emby-style-liquid .emby-play-main-action,
html.emby-skin.emby-style-liquid .emby-play-arrow-action {
background: transparent !important;
z-index: 1;
}
html.emby-skin.emby-style-liquid .emby-play-btn::before,
html.emby-skin.emby-style-liquid .emby-play-split-btn::before {
content: '';
position: absolute;
left: 10%;
right: 10%;
top: 0;
height: 2px;
border-radius: 2px;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.95), transparent);
opacity: 0.85;
pointer-events: none;
z-index: 2;
}
html.emby-skin.emby-style-liquid .emby-play-btn:hover,
html.emby-skin.emby-style-liquid .emby-play-split-btn:hover {
background: linear-gradient(135deg,
rgba(255, 255, 255, 0.26) 0%,
rgba(255, 255, 255, 0.08) 50%,
rgba(var(--btn-logo-rgb), 0.22) 100%
), rgba(16, 20, 32, 0.36) !important;
border-color: rgba(255, 255, 255, 0.5) !important;
box-shadow:
inset 0 2px 0 rgba(255, 255, 255, 0.9),
inset 0 -1px 0 rgba(255, 255, 255, 0.18),
0 10px 30px rgba(0, 0, 0, 0.45),
0 0 26px rgba(var(--btn-logo-rgb), 0.35) !important;
transform: translateY(-1px) !important;
}
html.emby-skin.emby-style-liquid .emby-play-split-divider {
background: rgba(255, 255, 255, 0.25) !important;
position: relative;
z-index: 1;
}
html.emby-skin.emby-style-liquid .emby-circle-btn {
/* 清透水滴透镜:背景色彩穿透透镜并柔和适度饱和 */
background: linear-gradient(135deg, rgba(255, 255, 255, 0.14) 0%, rgba(255, 255, 255, 0.03) 60%, rgba(150, 140, 255, 0.1) 100%) !important;
backdrop-filter: blur(0.25px) brightness(1.04) saturate(1.08);
-webkit-backdrop-filter: blur(0.25px) brightness(1.04) saturate(1.08);
border: 1px solid rgba(255, 255, 255, 0.28) !important;
color: #fff !important;
position: relative;
box-shadow:
inset 0 1.5px 0 rgba(255, 255, 255, 0.7),
inset 0 -1px 0 rgba(255, 255, 255, 0.1),
0 6px 20px rgba(0, 0, 0, 0.35) !important;
}
html.emby-skin.emby-style-liquid .emby-circle-btn::before {
content: '';
position: absolute;
left: 15%;
right: 15%;
top: 0;
height: 2px;
border-radius: 2px;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.95), transparent);
opacity: 0.85;
pointer-events: none;
z-index: 2;
}
html.emby-skin.emby-style-liquid .emby-circle-btn:hover {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.24) 0%, rgba(255, 255, 255, 0.06) 50%, rgba(180, 160, 255, 0.18) 100%) !important;
border-color: rgba(255, 255, 255, 0.48) !important;
box-shadow:
inset 0 1.5px 0 rgba(255, 255, 255, 0.85),
inset 0 -1px 0 rgba(255, 255, 255, 0.16),
0 8px 24px rgba(0, 0, 0, 0.4) !important;
transform: translateY(-1px) !important;
}
/* 激活态:微透水光染彩,不掩盖底层透镜折射 */
html.emby-skin.emby-style-liquid .emby-circle-btn.active[data-type="wishlist"] {
background: linear-gradient(135deg, rgba(0, 195, 255, 0.32) 0%, rgba(0, 164, 220, 0.08) 50%, rgba(140, 210, 255, 0.22) 100%) !important;
border-color: rgba(0, 210, 255, 0.6) !important;
color: #00e1ff !important;
box-shadow:
inset 0 1.5px 0 rgba(255, 255, 255, 0.85),
inset 0 -1px 0 rgba(255, 255, 255, 0.18),
0 6px 20px rgba(0, 164, 220, 0.4) !important;
}
html.emby-skin.emby-style-liquid .emby-circle-btn.active[data-type="watched"] {
background: linear-gradient(135deg, rgba(76, 215, 80, 0.32) 0%, rgba(76, 175, 80, 0.08) 50%, rgba(160, 255, 170, 0.22) 100%) !important;
border-color: rgba(100, 240, 120, 0.6) !important;
color: #72ff77 !important;
box-shadow:
inset 0 1.5px 0 rgba(255, 255, 255, 0.85),
inset 0 -1px 0 rgba(255, 255, 255, 0.18),
0 6px 20px rgba(76, 175, 80, 0.4) !important;
}
html.emby-skin.emby-style-liquid .emby-circle-btn.active[data-type="list"] {
background: linear-gradient(135deg, rgba(255, 70, 120, 0.35) 0%, rgba(255, 70, 120, 0.08) 50%, rgba(255, 160, 190, 0.22) 100%) !important;
border-color: rgba(255, 120, 160, 0.65) !important;
color: #ff6e96 !important;
box-shadow:
inset 0 1.5px 0 rgba(255, 255, 255, 0.9),
inset 0 -1px 0 rgba(255, 255, 255, 0.18),
0 8px 24px rgba(255, 60, 110, 0.45) !important;
}
html.emby-skin.emby-style-liquid .emby-play-menu {
background:
linear-gradient(180deg, rgba(255,255,255,.08) 0%, rgba(255,255,255,.02) 60%, transparent 100%),
rgba(16, 20, 32, 0.52) !important;
backdrop-filter: blur(14px) brightness(1.04) saturate(1.1) !important;
-webkit-backdrop-filter: blur(14px) brightness(1.04) saturate(1.1) !important;
border: 1px solid rgba(255, 255, 255, 0.24) !important;
box-shadow:
inset 0 1.5px 0 rgba(255, 255, 255, 0.65),
inset 0 -1px 0 rgba(255, 255, 255, 0.12),
inset 1px 0 0 rgba(255, 255, 255, 0.2),
inset -1px 0 0 rgba(255, 255, 255, 0.12),
0 20px 50px rgba(0, 0, 0, 0.45) !important;
overflow: hidden;
}
html.emby-skin.emby-style-liquid .emby-play-menu::before {
content: ''; position: absolute; left: 8%; right: 8%; top: 0; height: 2px; border-radius: 2px;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.9), transparent);
opacity: 0.85; pointer-events: none; z-index: 5;
}
html.emby-skin.emby-style-liquid .emby-play-menu-title {
color: #ffffff !important;
font-weight: 700 !important;
text-shadow: 0 1px 2px rgba(0,0,0,0.85);
}
html.emby-skin.emby-style-liquid .emby-play-menu .emby-group-tab {
background: rgba(0, 0, 0, 0.35) !important;
border: 1px solid rgba(255, 255, 255, 0.18) !important;
color: var(--e-text2) !important;
border-radius: 999px !important;
}
html.emby-skin.emby-style-liquid .emby-play-menu .emby-group-tab.active {
background: linear-gradient(135deg, color-mix(in srgb, var(--e-accent,#00a4dc) 85%, #fff), color-mix(in srgb, var(--e-accent,#00a4dc) 50%, #1a1a2a)) !important;
border-color: transparent !important;
color: #fff !important;
box-shadow: inset 0 1px 0 rgba(255,255,255,.6), 0 2px 8px rgba(0,0,0,.3) !important;
}
html.emby-skin.emby-style-liquid .emby-play-menu-item {
background: rgba(0, 0, 0, 0.36) !important;
border: 1px solid rgba(255, 255, 255, 0.14) !important;
border-radius: 8px !important;
transition: all .2s ease !important;
}
html.emby-skin.emby-style-liquid .emby-play-menu-item:hover {
background: rgba(255, 255, 255, 0.18) !important;
border-color: rgba(255, 255, 255, 0.35) !important;
box-shadow: inset 0 1px 0 rgba(255,255,255,.35), 0 4px 14px rgba(0,0,0,.3) !important;
}
html.emby-skin.emby-style-liquid .emby-play-menu-item.selected {
background: rgba(0, 164, 220, 0.28) !important;
border-color: rgba(0, 164, 220, 0.65) !important;
}
html.emby-skin.emby-style-liquid .emby-play-menu .emby-site-name {
color: #ffffff !important;
font-weight: 600 !important;
text-shadow: 0 1px 2px rgba(0,0,0,0.85);
}
html.emby-skin.emby-style-liquid .emby-play-menu-config {
color: #cbd5e1 !important;
font-weight: 500 !important;
}
html.emby-skin.emby-style-liquid .emby-toast {
background: linear-gradient(135deg, rgba(255,255,255,.22) 0%, rgba(255,255,255,.05) 55%, rgba(160,150,255,.1) 100%), rgba(16,20,32,.72) !important;
border: 1px solid rgba(255,255,255,.35) !important;
box-shadow:
inset 0 1.5px 0 rgba(255,255,255,.8),
inset 0 -1px 0 rgba(255,255,255,.12),
0 8px 24px rgba(0,0,0,.45) !important;
color: #fff !important;
text-shadow: 0 1px 2px rgba(0,0,0,.8) !important;
}
/* =======================================================================
* v7.308 元数据自定义纠错体系 CSS (斜红线、淡绿荧光笔、行悬停编辑)
* ===================================================================== */
/* 标记删除头像与红斜线:直接作用于 .emby-actor-chip.is-deleted .ava,无须任何包装容器,绝对不改变尺寸变形 */
.emby-actor-chip.is-deleted .ava {
filter: grayscale(100%) opacity(45%) !important;
}
.emby-actor-chip.is-deleted .ava::before {
content: '';
position: absolute;
top: 0; left: 0; width: 100%; height: 100%;
border-radius: 50%;
background: linear-gradient(to top right, transparent calc(50% - 2.2px), #ef4444 calc(50% - 1.8px), #ef4444 calc(50% + 1.8px), transparent calc(50% + 2.2px));
pointer-events: none;
box-shadow: 0 0 6px rgba(239, 68, 68, 0.7);
z-index: 10;
}
.highlighter-green {
background: linear-gradient(120deg, rgba(34, 197, 94, 0.22) 0%, rgba(16, 185, 129, 0.32) 100%);
box-shadow: 0 0 12px rgba(34, 197, 94, 0.2);
border-bottom: 1.5px solid rgba(34, 197, 94, 0.6);
border-radius: 4px;
padding: 1px 5px;
position: relative;
}
.emby-actor-chip.is-deleted .nm {
color: #ef4444 !important;
text-decoration: line-through !important;
text-decoration-color: #ef4444 !important;
text-decoration-thickness: 1.5px !important;
}
.em-row, .emby-actors {
position: relative;
}
.row-hover-edit-btn, .em-meta-edit-btn {
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease, transform 0.2s ease;
transform: translateX(-4px);
margin-left: 8px;
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.14);
border-radius: 6px;
color: #94a3b8;
cursor: pointer;
padding: 2px 8px;
font-size: 11px;
display: inline-flex;
align-items: center;
gap: 4px;
vertical-align: middle;
}
.em-row:hover .row-hover-edit-btn,
.emby-actors:hover .row-hover-edit-btn,
.em-row:hover .em-meta-edit-btn,
.emby-actors:hover .em-meta-edit-btn,
.row-hover-edit-btn.is-active,
.em-meta-edit-btn.is-active {
opacity: 1;
pointer-events: auto;
transform: translateX(0);
}
.row-hover-edit-btn:hover, .em-meta-edit-btn:hover {
background: rgba(34, 197, 94, 0.15);
border-color: rgba(34, 197, 94, 0.4);
color: #4ade80;
}
.em-meta-chip.em-meta-empty,
.em-cat.em-meta-empty {
border: 1px dashed rgba(255,255,255,0.2) !important;
background: rgba(255,255,255,0.03) !important;
color: var(--e-text3) !important;
cursor: pointer;
user-select: none;
font-size: .82em !important;
}
.em-meta-chip.em-meta-empty:hover,
.em-cat.em-meta-empty:hover {
border-color: rgba(34,197,94,0.6) !important;
color: #4ade80 !important;
background: rgba(34,197,94,0.08) !important;
}
.btn-green-pulse:hover {
box-shadow: 0 0 12px rgba(34, 197, 94, 0.5);
}
@keyframes green-btn-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.4); }
50% { box-shadow: 0 0 0 6px rgba(34, 197, 94, 0); }
}
.btn-green-pulse {
animation: green-btn-pulse 2s infinite ease-in-out;
}
.has-custom-tooltip {
position: relative;
}
.has-custom-tooltip:hover .custom-tooltip-box {
opacity: 1;
visibility: visible;
transform: translateX(-50%) translateY(0);
}
.custom-tooltip-box {
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%) translateY(4px);
background: rgba(15, 23, 42, 0.95);
border: 1px solid rgba(34, 197, 94, 0.4);
box-shadow: 0 8px 20px rgba(0,0,0,0.5), 0 0 10px rgba(34, 197, 94, 0.15);
backdrop-filter: blur(8px);
border-radius: 8px;
padding: 6px 10px;
white-space: nowrap;
z-index: 50;
opacity: 0;
visibility: hidden;
transition: opacity 0.15s ease, transform 0.15s ease;
pointer-events: none;
}
.custom-tooltip-box::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border-width: 5px;
border-style: solid;
border-color: rgba(34, 197, 94, 0.4) transparent transparent transparent;
}
.emby-actor-chip {
position: relative !important;
}
.actor-edit-del-btn {
position: absolute;
top: -2px;
right: 4px;
left: auto;
width: 20px;
height: 20px;
border-radius: 50%;
display: none !important;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: bold;
cursor: pointer;
border: 1px solid rgba(255,255,255,0.25);
z-index: 15;
transition: transform 0.15s ease, background 0.15s ease;
line-height: 1;
padding: 0;
}
/* 进入演员编辑状态才在右上角出现删除/排除按钮,平时彻底隐藏绝不遮挡小红心 */
.emby-actors.is-editing .actor-edit-del-btn {
display: flex !important;
}
.actor-edit-del-btn:hover {
transform: scale(1.18);
}
.actor-edit-del-btn.is-del {
background: #ef4444;
color: #fff;
box-shadow: 0 2px 6px rgba(239, 68, 68, 0.6);
}
.actor-edit-del-btn.is-undo {
background: #10b981;
color: #fff;
box-shadow: 0 2px 6px rgba(16, 185, 129, 0.6);
}
/* 演员列表末尾添加/编辑按钮芯片(仅在鼠标滑入演员行时显现,或在作品0演员时始终占位显现,或在进入编辑状态时显现) */
.emby-actor-add-chip {
cursor: pointer !important;
text-decoration: none !important;
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: opacity 0.2s ease, visibility 0.2s ease, transform 0.2s ease !important;
transform: scale(0.92);
}
.emby-actors:hover .emby-actor-add-chip,
.emby-actors.is-editing .emby-actor-add-chip {
opacity: 1 !important;
visibility: visible !important;
pointer-events: auto !important;
transform: scale(1) !important;
}
.emby-actors.has-no-actors .emby-actor-add-chip {
opacity: 1 !important;
visibility: visible !important;
pointer-events: auto !important;
transform: scale(1) !important;
}
.emby-actor-add-chip .ava {
border: 2px dashed rgba(255, 255, 255, 0.22) !important;
background: rgba(255, 255, 255, 0.04) !important;
box-shadow: none !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
color: var(--e-sub, #94a3b8) !important;
font-size: 30px !important;
font-weight: 300 !important;
transition: all 0.22s ease !important;
}
.emby-actor-add-chip .nm {
color: var(--e-sub, #94a3b8) !important;
transition: color 0.22s ease !important;
}
.emby-actor-add-chip:hover .ava {
border-color: rgba(34, 197, 94, 0.85) !important;
background: rgba(34, 197, 94, 0.15) !important;
color: #4ade80 !important;
transform: scale(1.05);
box-shadow: 0 0 12px rgba(34, 197, 94, 0.3) !important;
}
.emby-actor-add-chip:hover .nm {
color: #4ade80 !important;
}
.emby-actor-add-chip.is-active .ava {
border-color: rgba(34, 197, 94, 0.9) !important;
background: rgba(34, 197, 94, 0.2) !important;
color: #4ade80 !important;
}
.emby-actor-add-chip.is-active .nm {
color: #4ade80 !important;
}
/* 演员搜索添加气泡弹窗(宽度缩小到约一半 440px,支持自适应与三大皮肤风格) */
#actor-search-popover {
display: none;
width: 100%;
max-width: 440px;
margin-top: 10px;
padding: 12px 14px;
background: #0f172a;
border: 1px solid #334155;
border-radius: 12px;
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.6);
position: relative;
z-index: 90;
box-sizing: border-box;
}
#actor-search-popover .actor-search-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
#actor-search-popover .actor-search-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 600;
color: #4ade80;
}
#actor-search-popover .actor-search-title .dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #4ade80;
display: inline-block;
}
#actor-search-popover #btn-close-popover {
background: none;
border: none;
color: #94a3b8;
font-size: 12px;
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
transition: color 0.15s ease;
}
#actor-search-popover #btn-close-popover:hover {
color: #f1f5f9;
}
#actor-search-popover .actor-search-input-wrap {
position: relative;
margin-bottom: 8px;
}
#actor-search-popover input#actor-search-inp {
width: 100%;
box-sizing: border-box;
background: #090d16;
border: 1px solid #334155;
border-radius: 8px;
padding: 7px 12px;
font-size: 12px;
color: #fff;
outline: none;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
#actor-search-popover input#actor-search-inp:focus {
border-color: #4ade80;
box-shadow: 0 0 0 2px rgba(74, 222, 128, 0.2);
}
#actor-search-popover #actor-search-res {
max-height: 180px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 4px;
scrollbar-width: thin;
scrollbar-color: rgba(255,255,255,0.15) transparent;
}
#actor-search-popover .actor-search-tip {
color: #64748b;
font-size: 11px;
padding: 8px;
text-align: center;
}
#actor-search-popover .actor-search-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: 6px;
cursor: pointer;
background: rgba(255, 255, 255, 0.03);
border: 1px solid transparent;
transition: background 0.15s ease, border-color 0.15s ease;
}
#actor-search-popover .actor-search-item:hover {
background: rgba(34, 197, 94, 0.18) !important;
border-color: rgba(34, 197, 94, 0.35);
}
#actor-search-popover .actor-search-item.is-direct-add {
background: rgba(34, 197, 94, 0.1);
color: #4ade80;
font-size: 11px;
}
#actor-search-popover .actor-search-item.is-direct-add:hover {
background: rgba(34, 197, 94, 0.25) !important;
}
/* Glass Skin适配 (emby-style-glass) */
html.emby-skin.emby-style-glass #actor-search-popover {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.14) 0%, rgba(255, 255, 255, 0.04) 100%), rgba(20, 24, 36, 0.72) !important;
backdrop-filter: blur(26px) saturate(1.9) brightness(1.06) !important;
-webkit-backdrop-filter: blur(26px) saturate(1.9) brightness(1.06) !important;
border: 1px solid rgba(255, 255, 255, 0.22) !important;
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.5), inset 0 1px 1px rgba(255, 255, 255, 0.35) !important;
}
html.emby-skin.emby-style-glass #actor-search-popover input#actor-search-inp {
background: rgba(0, 0, 0, 0.45) !important;
border: 1px solid rgba(255, 255, 255, 0.2) !important;
color: #fff !important;
backdrop-filter: blur(10px);
}
html.emby-skin.emby-style-glass #actor-search-popover input#actor-search-inp:focus {
border-color: #4ade80 !important;
box-shadow: 0 0 0 2px rgba(74, 222, 128, 0.3) !important;
}
html.emby-skin.emby-style-glass #actor-search-popover .actor-search-item {
background: rgba(255, 255, 255, 0.07) !important;
border: 1px solid rgba(255, 255, 255, 0.1) !important;
}
html.emby-skin.emby-style-glass #actor-search-popover .actor-search-item:hover {
background: rgba(34, 197, 94, 0.24) !important;
border-color: rgba(74, 222, 128, 0.45) !important;
}
/* Liquid Skin适配 (emby-style-liquid) */
html.emby-skin.emby-style-liquid #actor-search-popover {
background:
linear-gradient(180deg, rgba(255,255,255,.08) 0%, rgba(255,255,255,.02) 60%, transparent 100%),
rgba(16, 20, 32, 0.6) !important;
backdrop-filter: blur(16px) brightness(1.04) saturate(1.15) !important;
-webkit-backdrop-filter: blur(16px) brightness(1.04) saturate(1.15) !important;
border: 1px solid rgba(255, 255, 255, 0.24) !important;
box-shadow:
inset 0 1.5px 0 rgba(255, 255, 255, 0.65),
inset 0 -1px 0 rgba(255, 255, 255, 0.12),
inset 1px 0 0 rgba(255, 255, 255, 0.2),
inset -1px 0 0 rgba(255, 255, 255, 0.12),
0 20px 50px rgba(0, 0, 0, 0.5) !important;
overflow: hidden;
}
html.emby-skin.emby-style-liquid #actor-search-popover::before {
content: ''; position: absolute; left: 8%; right: 8%; top: 0; height: 2px; border-radius: 2px;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.9), transparent);
opacity: 0.85; pointer-events: none; z-index: 5;
}
html.emby-skin.emby-style-liquid #actor-search-popover input#actor-search-inp {
background: rgba(0, 0, 0, 0.5) !important;
border: 1px solid rgba(255, 255, 255, 0.18) !important;
color: #fff !important;
border-radius: 8px !important;
box-shadow: inset 0 1px 3px rgba(0,0,0,0.4);
}
html.emby-skin.emby-style-liquid #actor-search-popover input#actor-search-inp:focus {
border-color: #4ade80 !important;
box-shadow: 0 0 0 2px rgba(74, 222, 128, 0.35), inset 0 1px 2px rgba(0,0,0,0.3) !important;
}
html.emby-skin.emby-style-liquid #actor-search-popover .actor-search-item {
background: rgba(0, 0, 0, 0.36) !important;
border: 1px solid rgba(255, 255, 255, 0.14) !important;
border-radius: 8px !important;
}
html.emby-skin.emby-style-liquid #actor-search-popover .actor-search-item:hover {
background: rgba(34, 197, 94, 0.25) !important;
border-color: rgba(74, 222, 128, 0.4) !important;
}
/* =======================================================================
* v7.308 状态快捷标记与预览图 (Cover Status & Preview Modal)
* ===================================================================== */
:root {
--color-unmarked: rgba(0, 0, 0, 0.45);
--color-unmarked-icon: #fff;
--color-watched: #22c55e;
--color-wanted: #ef4444;
--color-modify: #3b82f6;
--color-delete: #64748b;
--color-star: #64748b;
--color-star-filled: #f59e0b;
--color-preview-no: rgba(0, 0, 0, 0.45);
--color-preview-no-icon: #fff;
--color-preview-has: #38bdf8;
--color-preview-failed: #64748b;
--color-preview-failed-icon: #fff;
}
html:not(.emby-skin) .cover-status-buttons,
html:not(.emby-skin) .cover-modal-base,
html:not(.emby-skin) .jdbe-modal-v2 {
display: none !important;
}
.cover-status-buttons {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 25;
pointer-events: none;
}
.cover-ui-wrapper { position: relative; width: 100%; height: 100%; pointer-events: none; }
.csb-outer-container {
position: absolute;
top: 0.5rem;
left: 0.5rem;
opacity: 0;
visibility: hidden;
transition: opacity 0.25s ease, visibility 0.25s ease;
z-index: 26;
pointer-events: auto;
}
.item:hover .csb-outer-container,
.box:hover .csb-outer-container {
opacity: 1;
visibility: visible;
}
.csb-status-container { position: relative; display: inline-block; pointer-events: auto; }
.status-main-icon {
position: relative;
width: 2.2rem;
height: 2.2rem;
border-radius: 50%;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
box-shadow: 0 4px 14px rgba(0,0,0,0.4);
transition: all 0.2s ease;
flex-shrink: 0;
z-index: 28;
font-size: 1rem;
line-height: 1;
border: 1px solid rgba(255,255,255,0.22);
pointer-events: auto;
backdrop-filter: blur(14px) saturate(1.8);
-webkit-backdrop-filter: blur(14px) saturate(1.8);
}
.status-main-icon svg,
.status-main-icon .material-symbols-outlined {
width: 1.25rem;
height: 1.25rem;
font-size: 1.35rem;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
.csb-status-container.show-unmarked .status-main-icon {
background: rgba(18, 22, 34, 0.72);
color: #fff;
}
.csb-status-container.show-watched .status-main-icon {
background: linear-gradient(135deg, #10b981, #059669);
color: #fff;
box-shadow: 0 2px 10px rgba(16, 185, 129, 0.45);
}
.csb-status-container.show-wanted .status-main-icon {
background: linear-gradient(135deg, #3b82f6, #2563eb);
color: #fff;
box-shadow: 0 2px 10px rgba(59, 130, 246, 0.45);
}
html.emby-skin.emby-style-glass .csb-status-container.show-unmarked .status-main-icon {
background: linear-gradient(135deg, rgba(255,255,255,.14) 0%, rgba(255,255,255,.03) 100%), rgba(20,24,36,.45);
border: 1px solid rgba(255,255,255,.26);
}
html.emby-skin.emby-style-liquid .csb-status-container.show-unmarked .status-main-icon {
background: linear-gradient(135deg, rgba(255,255,255,.18) 0%, rgba(255,255,255,.05) 60%, rgba(150,140,255,.14)), rgba(18,22,34,.52);
border: 1px solid rgba(255,255,255,.32);
}
.action-buttons-wrapper {
position: absolute;
left: 1.2rem;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
max-width: 0;
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: max-width 0.25s ease, opacity 0.2s ease, visibility 0s 0.25s;
overflow: hidden;
z-index: 27;
}
.csb-status-container:hover .action-buttons-wrapper {
opacity: 1;
visibility: visible;
max-width: 320px;
pointer-events: auto;
transition: max-width 0.25s ease, opacity 0.2s ease;
}
.action-buttons {
display: flex;
align-items: center;
gap: 0.35rem;
background: rgba(18, 22, 34, 0.85);
backdrop-filter: blur(16px) saturate(1.8);
-webkit-backdrop-filter: blur(16px) saturate(1.8);
padding: 0.32rem 0.65rem 0.32rem 1.65rem;
border-radius: 999px;
white-space: nowrap;
box-shadow: 0 8px 24px rgba(0,0,0,0.45), inset 0 1px 1px rgba(255,255,255,0.18);
border: 1px solid rgba(255,255,255,0.18);
pointer-events: auto;
}
html.emby-skin.emby-style-glass .action-buttons {
background: linear-gradient(135deg, rgba(255,255,255,.14) 0%, rgba(255,255,255,.03) 100%), rgba(20,24,36,.45) !important;
backdrop-filter: blur(24px) saturate(1.9) brightness(1.05) !important;
-webkit-backdrop-filter: blur(24px) saturate(1.9) brightness(1.05) !important;
border: 1px solid rgba(255,255,255,.26) !important;
box-shadow: 0 12px 32px rgba(0,0,0,.45), inset 0 1px 1px rgba(255,255,255,.35) !important;
}
html.emby-skin.emby-style-liquid .action-buttons {
background: linear-gradient(135deg, rgba(255,255,255,.18) 0%, rgba(255,255,255,.05) 60%, rgba(150,140,255,.14)), rgba(18,22,34,.52) !important;
backdrop-filter: blur(28px) saturate(2.0) brightness(1.06) !important;
-webkit-backdrop-filter: blur(28px) saturate(2.0) brightness(1.06) !important;
border: 1px solid rgba(255,255,255,.30) !important;
box-shadow: 0 14px 40px rgba(0,0,0,.5), inset 0 1px 2px rgba(255,255,255,.45) !important;
}
.action-buttons .button {
padding: 0.28rem 0.72rem;
font-size: 0.78rem;
font-weight: 600;
border: 1px solid rgba(255,255,255,0.14);
border-radius: 999px;
background: rgba(255,255,255,0.10) !important;
color: #fff !important;
cursor: pointer;
margin: 0;
transition: all 0.2s ease;
pointer-events: auto;
line-height: 1.2;
}
.action-buttons .button:hover {
background: rgba(255,255,255,0.24) !important;
border-color: rgba(255,255,255,0.38);
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
}
html.emby-skin.emby-style-glass .action-buttons .button {
background: rgba(255,255,255,0.12) !important;
border: 1px solid rgba(255,255,255,0.22) !important;
}
html.emby-skin.emby-style-glass .action-buttons .button:hover {
background: rgba(255,255,255,0.26) !important;
border-color: rgba(255,255,255,0.42) !important;
}
html.emby-skin.emby-style-liquid .action-buttons .button {
background: linear-gradient(135deg, rgba(255,255,255,.18), rgba(255,255,255,.06)) !important;
border: 1px solid rgba(255,255,255,0.26) !important;
}
html.emby-skin.emby-style-liquid .action-buttons .button:hover {
background: linear-gradient(135deg, rgba(255,255,255,.30), rgba(255,255,255,.14)) !important;
border-color: rgba(255,255,255,0.48) !important;
}
.action-buttons .btn-set-wanted:hover { background: linear-gradient(135deg, #3b82f6, #2563eb) !important; border-color: transparent !important; color: #fff !important; }
.action-buttons .btn-set-watched:hover { background: linear-gradient(135deg, #10b981, #059669) !important; border-color: transparent !important; color: #fff !important; }
.action-buttons .btn-modify:hover { background: linear-gradient(135deg, #0ea5e9, #0284c7) !important; border-color: transparent !important; color: #fff !important; }
.action-buttons .btn-delete:hover { background: linear-gradient(135deg, #f43f5e, #e11d48) !important; border-color: transparent !important; color: #fff !important; }
/* 关键:状态切换控制,默认隐藏所有 state,仅显示匹配当前容器状态的 state */
.cover-status-buttons .state {
display: none !important;
}
.csb-status-container.show-unmarked .state-unmarked {
display: block !important;
}
.csb-status-container.show-watched .state-watched {
display: block !important;
}
.csb-status-container.show-wanted .state-wanted {
display: block !important;
}
.star-arc-container { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 29; }
.star-arc-container span { position: absolute; top: 50%; left: 50%; font-size: 0.68rem; color: #64748b; transform-origin: center; text-shadow: 0 0 2px black; }
.star-arc-container span.is-filled { color: #f59e0b !important; }
.star-arc-container span:nth-child(1) { transform: translate(-50%, -50%) rotate(-60deg) translateY(-12px) rotate(60deg); }
.star-arc-container span:nth-child(2) { transform: translate(-50%, -50%) rotate(-30deg) translateY(-12px) rotate(30deg); }
.star-arc-container span:nth-child(3) { transform: translate(-50%, -50%) rotate(0deg) translateY(-12px) rotate(0deg); }
.star-arc-container span:nth-child(4) { transform: translate(-50%, -50%) rotate(30deg) translateY(-12px) rotate(-30deg); }
.star-arc-container span:nth-child(5) { transform: translate(-50%, -50%) rotate(60deg) translateY(-12px) rotate(-60deg); }
/* 右上角预览图图标:默认完全隐藏(鼠标移入卡片时才显示) */
.csb-preview-icon-container {
position: absolute;
top: 0.5rem;
right: 0.5rem;
z-index: 26;
flex-shrink: 0;
width: 2.2rem;
height: 2.2rem;
border-radius: 50%;
display: flex;
justify-content: center;
align-items: center;
color: #fff;
background: rgba(18, 22, 34, 0.72);
backdrop-filter: blur(14px) saturate(1.8);
-webkit-backdrop-filter: blur(14px) saturate(1.8);
border: 1px solid rgba(255,255,255,0.22);
box-shadow: 0 4px 14px rgba(0,0,0,0.35);
transition: opacity 0.25s ease, visibility 0.25s ease, background-color 0.2s ease, transform 0.2s ease;
opacity: 0;
visibility: hidden;
pointer-events: none;
outline: none;
cursor: pointer;
padding: 0;
}
html.emby-skin.emby-style-glass .csb-preview-icon-container {
background: linear-gradient(135deg, rgba(255,255,255,.14) 0%, rgba(255,255,255,.03) 100%), rgba(20,24,36,.42) !important;
backdrop-filter: blur(20px) saturate(1.9) !important;
border: 1px solid rgba(255,255,255,.26) !important;
}
html.emby-skin.emby-style-liquid .csb-preview-icon-container {
background: linear-gradient(135deg, rgba(255,255,255,.18) 0%, rgba(255,255,255,.05) 60%, rgba(150,140,255,.14)), rgba(18,22,34,.52) !important;
backdrop-filter: blur(24px) saturate(2.0) !important;
border: 1px solid rgba(255,255,255,.32) !important;
}
.item:hover .csb-preview-icon-container,
.box:hover .csb-preview-icon-container {
opacity: 0.85;
visibility: visible;
pointer-events: auto;
}
.csb-preview-icon-container:hover {
opacity: 1;
transform: scale(1.08);
background: rgba(255,255,255,0.24) !important;
border-color: rgba(255,255,255,0.45) !important;
color: #38bdf8 !important;
}
.csb-preview-icon-container svg,
.csb-preview-icon-container .material-symbols-outlined {
width: 1.25rem;
height: 1.25rem;
font-size: 1.35rem;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
.csb-preview-icon-container.is-loading svg,
.csb-preview-icon-container.is-loading .material-symbols-outlined { animation: jdbe-flip 1.2s ease-in-out infinite; }
@keyframes jdbe-flip { 0% { transform: scale(1); } 50% { transform: scale(1.2) rotate(180deg); } 100% { transform: scale(1) rotate(360deg); } }
.cover-modal-base {
display: flex;
justify-content: center;
align-items: center;
position: absolute;
z-index: 35 !important;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(14, 18, 28, 0.84);
backdrop-filter: blur(14px) saturate(1.8);
-webkit-backdrop-filter: blur(14px) saturate(1.8);
flex-direction: column;
gap: 0.7rem;
pointer-events: auto;
border-radius: inherit;
padding: 1rem;
box-sizing: border-box;
}
html.emby-skin.emby-style-glass .cover-modal-base {
background: linear-gradient(135deg, rgba(255,255,255,.14) 0%, rgba(255,255,255,.03) 100%), rgba(16,20,30,.74) !important;
backdrop-filter: blur(22px) saturate(1.9) !important;
-webkit-backdrop-filter: blur(22px) saturate(1.9) !important;
box-shadow: inset 0 1px 1px rgba(255,255,255,.25);
}
html.emby-skin.emby-style-liquid .cover-modal-base {
background: linear-gradient(135deg, rgba(255,255,255,.16) 0%, rgba(255,255,255,.04) 60%, rgba(150,140,255,.12)), rgba(14,18,28,.78) !important;
backdrop-filter: blur(26px) saturate(2.0) !important;
-webkit-backdrop-filter: blur(26px) saturate(2.0) !important;
box-shadow: inset 0 1px 2px rgba(255,255,255,.35);
}
.cover-modal-base .button {
padding: 5px 16px;
font-size: 12px;
font-weight: 600;
border-radius: 999px;
border: 1px solid rgba(255,255,255,0.18);
cursor: pointer;
background: rgba(255,255,255,0.12);
color: #fff;
transition: all 0.2s ease;
}
.cover-modal-base .button:hover {
background: rgba(255,255,255,0.22);
border-color: rgba(255,255,255,0.35);
}
.rating-stars { display: flex; justify-content: center; gap: 8px; user-select: none; }
.rating-stars .star-item {
font-size: 26px;
line-height: 1;
color: #f59e0b;
cursor: pointer;
transition: transform 0.15s ease, color 0.15s ease;
display: inline-block;
}
.rating-stars .star-item:hover {
transform: scale(1.28);
}
/* 预览大图画廊模态框 (支持缩放平移与工具栏) */
.jdbe-modal-v2 { position: fixed; inset: 0; z-index: 2147483640; display: flex; align-items: center; justify-content: center; user-select: none; }
.pm-backdrop { position: absolute; inset: 0; background: rgba(0,0,0,0.88); backdrop-filter: blur(8px); }
#pm-main-view {
position: relative;
z-index: 2;
width: 92vw;
max-width: 1280px;
height: 90vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
}
.pm-top-bar {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 8px 18px;
background: rgba(15, 23, 42, 0.75);
backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 40px;
margin-bottom: 8px;
box-shadow: 0 4px 20px rgba(0,0,0,0.4);
box-sizing: border-box;
}
.pm-title {
color: #f1f5f9;
font-size: 15px;
font-weight: 700;
letter-spacing: 0.5px;
display: flex;
align-items: center;
gap: 10px;
}
.pm-title .pm-badge {
font-size: 11px;
font-weight: 600;
padding: 2px 8px;
border-radius: 12px;
background: rgba(56, 189, 248, 0.2);
color: #38bdf8;
border: 1px solid rgba(56, 189, 248, 0.35);
}
.pm-sites-bar {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 6px 18px;
background: rgba(15, 23, 42, 0.72);
backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 20px;
margin-bottom: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
box-sizing: border-box;
}
.pm-sites-label {
font-size: 11px;
font-weight: 600;
color: #94a3b8;
letter-spacing: 0.5px;
white-space: nowrap;
flex-shrink: 0;
}
.pm-site-switchers {
display: flex;
align-items: center;
gap: 8px;
overflow-x: auto;
scrollbar-width: none;
flex: 1;
}
.pm-site-switchers::-webkit-scrollbar {
display: none;
}
.pm-site-btn {
display: inline-flex;
align-items: center;
gap: 6px;
height: 26px;
padding: 0 9px;
border-radius: 13px;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.16);
color: #cbd5e1;
font-size: 11px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.2, 0, 0.2, 1);
outline: none;
white-space: nowrap;
}
.pm-site-btn:hover {
background: rgba(255, 255, 255, 0.2);
border-color: rgba(255, 255, 255, 0.35);
color: #fff;
transform: translateY(-1px);
}
.pm-site-btn.is-active {
background: rgba(56, 189, 248, 0.22);
border-color: #38bdf8;
color: #38bdf8;
box-shadow: 0 0 10px rgba(56, 189, 248, 0.3);
}
.pm-site-btn.is-loading {
opacity: 0.6;
pointer-events: none;
cursor: wait;
}
.pm-site-btn.is-loading .emby-site-logo-wrap {
animation: jdbe-flip 1.2s ease-in-out infinite;
}
.pm-site-btn .emby-site-logo-wrap {
width: 15px;
height: 15px;
border-radius: 3px;
display: inline-flex;
align-items: center;
justify-content: center;
overflow: hidden;
flex-shrink: 0;
}
.pm-site-btn .emby-site-logo {
width: 100%;
height: 100%;
object-fit: contain;
}
.pm-site-btn .emby-site-logo-fallback {
font-size: 9px;
font-weight: 800;
line-height: 1;
color: inherit;
}
@media (max-width: 768px) {
.pm-site-btn .pm-site-name {
display: none;
}
.pm-site-btn {
padding: 0 6px;
}
}
.pm-zoom-controls {
display: flex;
align-items: center;
gap: 6px;
}
.pm-tool-btn {
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.18);
color: #f1f5f9;
min-width: 32px;
height: 32px;
padding: 0 8px;
border-radius: 16px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
line-height: 1;
}
.pm-tool-btn:hover {
background: rgba(255, 255, 255, 0.25);
transform: scale(1.05);
}
.pm-zoom-level {
font-size: 12px;
color: #cbd5e1;
min-width: 46px;
text-align: center;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
.pm-close-btn {
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.18);
color: #fff;
width: 32px;
height: 32px;
border-radius: 50%;
font-size: 16px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.pm-close-btn:hover {
background: rgba(239, 68, 68, 0.6);
border-color: rgba(239, 68, 68, 0.8);
transform: scale(1.08);
}
.pm-viewport {
position: relative;
width: 100%;
flex: 1;
min-height: 0;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
border-radius: 10px;
background: rgba(0, 0, 0, 0.4);
border: 1px solid rgba(255, 255, 255, 0.08);
cursor: default;
}
.pm-viewport.is-zoomable {
cursor: grab;
}
.pm-viewport.is-dragging {
cursor: grabbing !important;
}
#pm-main-image {
max-width: 100%;
max-height: 100%;
object-fit: contain;
border-radius: 6px;
box-shadow: 0 10px 30px rgba(0,0,0,0.8);
transform-origin: center center;
will-change: transform;
pointer-events: auto;
user-select: none;
-webkit-user-drag: none;
}
#pm-gallery-strip {
display: flex;
gap: 8px;
margin-top: 10px;
overflow-x: auto;
max-width: 100%;
padding: 6px 4px;
}
.pm-gallery-thumb {
width: 58px;
height: 58px;
object-fit: cover;
border-radius: 6px;
border: 2px solid transparent;
cursor: pointer;
opacity: 0.55;
transition: all 0.2s;
flex-shrink: 0;
}
.pm-gallery-thumb:hover, .pm-gallery-thumb.is-active {
opacity: 1;
border-color: #38bdf8;
transform: scale(1.06);
}
`;
}
/* =======================================================================
* JAVDB logo(克隆自原生 navbar 内联 SVG,点击回主页)
* ===================================================================== */
// 统一「视图 Tab」定义:首页标签栏(.emby-home-tabs)与顶栏常驻按钮(.emby-topnav)共用。
// 后期新增按钮只需在此数组加一项,两处 UI 自动同步出现。
const HOME_TAB_DEFS = [
{ key: 'home', label: '主页', icon: 'home_max_dots' },
{ key: 'favorites', label: '收藏夹', icon: 'diagnosis' },
{ key: 'gallery', label: '预览画廊', icon: 'local_see' },
{ key: 'top250', label: 'TOP250', icon: 'social_leaderboard' }
];
// 金标皇冠图标:内联 SVG(Material Symbols outlined crown 标准 path)。
// 不用 .material-symbols-outlined 字体字形——Google Fonts 偶发加载失败时字形会退化为字面文本 "crown",
// SVG 不依赖外部字体,任何环境都稳定渲染。
// 金标皇冠图标:实心内联 SVG(Material Symbols crown fill=1 标准实心 path)。
function crownSvgHtml() {
return '<svg viewBox="0 -960 960 960" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">' +
'<path d="M203-160v-60h554v60H203Zm-1-144-53-334q-5 2-9.5 2.5t-9.5.5q-21 0-35.5-14.5T80-685q0-21 14.5-36t35.5-15q21 0 36 15t15 36q0 8-2.5 16t-7.5 14l148 66 141-194q-14-6-22.5-18.5T429-830q0-21 15-35.5t36-14.5q21 0 36 14.5t15 35.5q0 16-8.5 28.5T500-783l141 194 148-66q-5-6-7.5-14t-2.5-16q0-21 15-36t35-15q21 0 36 15t15 36q0 21-15 35.5T829-635q-5 0-9-1t-9-3l-53 335H202Z"/></svg>';
}
// 金标优先级权重计算(数值越小优先级越高)
// 规则:JAVDB影片TOP250 > JAVDB有码影片TOP250 > JAVDB年度影片TOP250(年份从新到旧排) > JAVDB无码影片TOP250 > JAVDBFC2影片TOP250 > JAVDB欧美影片TOP250
function getAwardWeight(award) {
if (!award) return 99999999;
const name = String(award.name || '').trim();
const rank = (typeof award.rank === 'number' && award.rank > 0) ? award.rank : 999;
const isYear = /年度|\d{4}/.test(name);
const isCensored = /有[碼码]/.test(name);
const isUncensored = /無[碼码]|无[碼码]/.test(name);
const isFc2 = /fc2/i.test(name);
const isWestern = /[歐欧]美/.test(name);
// 1. JAVDB 影片TOP250(常驻全站总榜)
if (!isYear && !isCensored && !isUncensored && !isFc2 && !isWestern && /TOP250/i.test(name)) {
return 1000000 + rank;
}
// 2. JAVDB 有码影片TOP250(有码总榜)
if (isCensored) {
return 2000000 + rank;
}
// 3. JAVDB 年度影片TOP250(年份从新到旧排:2025 > 2024 > 2023...)
if (isYear) {
const ym = name.match(/(\d{4})/);
const year = ym ? parseInt(ym[1], 10) : 2000;
const yearDiff = Math.max(0, 2099 - year);
return 3000000 + yearDiff * 1000 + rank;
}
// 4. JAVDB 无码影片TOP250
if (isUncensored) {
return 4000000 + rank;
}
// 5. JAVDB FC2影片TOP250
if (isFc2) {
return 5000000 + rank;
}
// 6. JAVDB 欧美影片TOP250
if (isWestern) {
return 6000000 + rank;
}
// 7. 其他未知金标
return 7000000 + rank;
}
// 作品的最佳金标综合权重(取所含金标中优先级最高者,无金标为 Infinity)
function getVideoBestAwardWeight(v) {
if (!v || !v.awards || !v.awards.length) return Infinity;
let minW = Infinity;
for (let i = 0; i < v.awards.length; i++) {
const w = getAwardWeight(v.awards[i]);
if (w < minW) minW = w;
}
return minW;
}
// 金标胶囊徽章构造(方案 A:微型皇冠 + 粗体 TOP250 + 最高优先级名次 + 多金标 +N 角标)
function buildAwardBadgeHtml(awards) {
if (!awards || !awards.length) return '';
// 按优先级排序金标(最高级别金标排最前)
const sorted = awards.slice().sort(function (a, b) {
return getAwardWeight(a) - getAwardWeight(b);
});
const lines = sorted.map(function (w) {
return (w && w.name ? w.name : '') + (w && w.rank ? ' No.' + w.rank : '');
}).filter(Boolean);
if (!lines.length) return '';
// 优先提取最高等级金标的 rank,若无则取任一有效 rank
let bestRank = null;
if (sorted[0] && typeof sorted[0].rank === 'number' && sorted[0].rank > 0) {
bestRank = sorted[0].rank;
} else {
sorted.forEach(function (w) {
if (w && typeof w.rank === 'number' && w.rank > 0) {
if (bestRank === null || w.rank < bestRank) bestRank = w.rank;
}
});
}
const rankHtml = bestRank !== null ? ('<span class="aw-rank">#' + bestRank + '</span>') : '';
const moreHtml = awards.length > 1 ? ('<span class="aw-more">+' + (awards.length - 1) + '</span>') : '';
return '<span class="efav-award-badge" title="' + escapeAttr(lines.join('\n')) + '">' +
crownSvgHtml() +
'<span>TOP250</span>' +
rankHtml +
moreHtml +
'</span>';
}
function javdbLogoHtml() {
const navBrand = document.querySelector('nav.main-nav .navbar-brand a, nav.navbar .navbar-brand a, .navbar-brand a');
let inner = 'JAVDB';
if (navBrand) {
const svg = navBrand.querySelector('svg');
inner = svg ? navBrand.innerHTML : ('<span>' + (navBrand.textContent.trim() || 'JAVDB') + '</span>');
}
return '<a class="logo logo-javdb" href="/" title="返回 JAVDB 主页">' + inner + '</a>';
}
function javdbDrawerBrandHtml() {
const navBrand = document.querySelector('nav.main-nav .navbar-brand a, nav.navbar .navbar-brand a, .navbar-brand a');
let inner = 'JAVDB';
if (navBrand) {
const svg = navBrand.querySelector('svg');
inner = svg ? navBrand.innerHTML : ('<span>' + (navBrand.textContent.trim() || 'JAVDB') + '</span>');
}
return '<div class="brand"><a class="brand-link" href="/" title="返回 JAVDB 主页">' + inner + '</a></div>';
}
/* =======================================================================
* 原版顶部导航栏提取:从 javdb 原版 navbar 解析指定的核心导航条目
* 包含:类别、排行榜、演员、系列、片商(均支持抽屉折叠子菜单及直达链接)
* ===================================================================== */
function buildSiteNavLinksHtml() {
const nav = document.querySelector('nav.navbar.is-fixed-top.main-nav, nav.main-nav, nav.navbar');
// 默认回退子项定义(当原生 DOM 尚未就绪或在移动端未渲染下拉菜单时兜底)
const TAGS_SUB = [
{ text: '有码', href: '/tags?c10=1' },
{ text: '无码', href: '/tags/uncensored?c10=1' },
{ text: 'FC2', href: '/tags/fc2?c10=1' },
{ text: '欧美', href: '/tags/western?c10=1' },
{ text: '动漫', href: '/tags/anime?c10=1' },
{ text: '全部类别', href: '/tags' }
];
const RANK_SUB = [
{ text: '热播', href: '/rankings/playback' },
{ text: 'TOP250', href: '/rankings/top' },
{ text: '有码', href: '/rankings/movies?p=daily&t=censored' },
{ text: '无码', href: '/rankings/movies?p=daily&t=uncensored' },
{ text: '欧美', href: '/rankings/movies?p=daily&t=western' },
{ text: 'FC2', href: '/rankings/movies?p=daily&t=fc2' },
{ text: 'FANZA成人奖', href: '/rankings/fanza_award' }
];
const ACTOR_SUB = [
{ text: '推荐', href: '/actors' },
{ text: '有码', href: '/actors/censored' },
{ text: '无码', href: '/actors/uncensored' },
{ text: '欧美', href: '/actors/western' }
];
const SERIES_SUB = [
{ text: '全部系列', href: '/series' },
{ text: '有码', href: '/series' },
{ text: '无码', href: '/series/uncensored' },
{ text: '欧美', href: '/series/western' },
{ text: 'LUXU', href: '/video_codes/LUXU' },
{ text: 'ARA', href: '/video_codes/ARA' },
{ text: 'MAAN', href: '/video_codes/MAAN' },
{ text: 'MIUM', href: '/video_codes/MIUM' },
{ text: 'SIRO', href: '/video_codes/SIRO' },
{ text: 'GANA', href: '/video_codes/GANA' }
];
const MAKER_SUB = [
{ text: '全部片商', href: '/makers' },
{ text: '有码', href: '/makers' },
{ text: '无码', href: '/makers/uncensored' },
{ text: '麻豆传媒', href: '/makers/N73g?f=download' }
];
// 从 native nav 中提取分类与子项
function extractNativeGroup(regex, fallbackSub, defaultHref, defaultLabel, icon) {
let groupLabel = defaultLabel;
let groupHref = defaultHref;
let subLinks = [];
if (nav) {
const ddItems = nav.querySelectorAll('.navbar-item.has-dropdown, .has-dropdown, li.has-dropdown');
for (let i = 0; i < ddItems.length; i++) {
const item = ddItems[i];
const link = item.querySelector('.navbar-link, a.nav-link, a') || item;
const text = (link.textContent || '').trim();
if (regex.test(text)) {
groupLabel = text;
const linkHref = link.getAttribute('href');
if (linkHref && !linkHref.startsWith('#') && !linkHref.startsWith('javascript:')) {
groupHref = linkHref;
}
const childLinks = item.querySelectorAll('.navbar-dropdown a, .dropdown a, .dropdown-menu a, a.navbar-item');
childLinks.forEach(function (ca) {
const caText = (ca.textContent || '').trim();
const caHref = ca.getAttribute('href') || '';
if (caText && caHref && caHref !== '#' && !caHref.startsWith('javascript:')) {
subLinks.push({ text: caText, href: caHref });
}
});
break;
}
}
}
if (!subLinks.length) {
subLinks = fallbackSub;
}
return {
key: icon,
label: groupLabel,
icon: icon,
href: groupHref,
children: subLinks
};
}
const tagsGroup = extractNativeGroup(/類別|类别|^tags$/i, TAGS_SUB, '/tags', '类别', 'category');
const rankGroup = extractNativeGroup(/排行榜|榜单|^rankings$/i, RANK_SUB, '/rankings/movies', '排行榜', 'leaderboard');
const actorGroup = extractNativeGroup(/演員|演员|^actors$/i, ACTOR_SUB, '/actors', '演员', 'groups');
const seriesGroup = extractNativeGroup(/系列|^series$/i, SERIES_SUB, '/series', '系列', 'movie_filter');
const makerGroup = extractNativeGroup(/片商|^makers$|^studios$/i, MAKER_SUB, '/makers', '片商', 'domain');
function renderGroupHtml(grp, groupKey) {
let gh = '<div class="drawer-group" data-group="' + groupKey + '">' +
'<button type="button" class="drawer-group-header" aria-expanded="false">' +
'<span class="material-symbols-outlined mi">' + grp.icon + '</span>' +
'<span class="drawer-group-title">' + escapeHtml(grp.label) + '</span>' +
'<span class="material-symbols-outlined drawer-arrow">expand_more</span>' +
'</button>' +
'<div class="drawer-subnav">';
grp.children.forEach(function (sub) {
gh += '<a class="drawer-sub-link" href="' + escapeAttr(safeHref(sub.href)) + '">' +
'<span>' + escapeHtml(sub.text) + '</span>' +
'</a>';
});
gh += '</div></div>';
return gh;
}
let html = '<div class="nav site-nav">';
html += renderGroupHtml(tagsGroup, 'tags');
html += renderGroupHtml(rankGroup, 'rankings');
html += renderGroupHtml(actorGroup, 'actors');
html += renderGroupHtml(seriesGroup, 'series');
html += renderGroupHtml(makerGroup, 'makers');
html += '</div>';
return html;
}
/* =======================================================================
* 原版账号菜单提取:从隐藏的 javdb navbar 中找到用户相关的菜单项,
* 克隆为抽屉导航项(图标+文字),平移到 Emby 抽屉底部。
* 覆盖:用户名/头像、消息、通知、设置等右上角功能入口。
* ===================================================================== */
/* 为账号词条推断合适的 material 图标(灰色矢量字形,绝不使用 emoji)。
* 覆盖 javdb 用户下拉菜单全部已知词条(v4.28 按截图 2 实际菜单逐条匹配)。 */
function inferAcctIcon(text, href) {
const t = text || '';
const h = (href || '').toLowerCase();
// 浏览/观看类
if (/近期瀏覽|最近|recent|history|瀏覽/i.test(t)) return 'history';
if (/想看|願望|wishlist|watch.?later|bookmark/i.test(t)) return 'bookmark';
if (/看過|已看|watched|done|check_circle/i.test(t)) return 'done_all';
// 清单/收藏类
if (/我的清單|片单|清单|list|playlist/i.test(t)) return 'playlist_play';
if (/收藏的演员|收藏演员|actor|star/i.test(t)) return 'person';
if (/收藏的系列|收藏系列|series|collection/i.test(t)) return 'movie_filter';
if (/收藏的片商|收藏片商|studio|賣家|seller/i.test(t)) return 'store';
if (/收藏的導演|收藏導演|director/i.test(t)) return 'videocam';
if (/收藏的番號|收藏番號|code|番号|number/i.test(t)) return 'confirmation_number';
if (/我的收藏|收藏夹|favorite|watchlist/i.test(t) || /favorites/.test(h)) return 'favorite';
// 账户/系统类
if (/通知中心|通知|notif|bell|提醒/i.test(t) || /notifications/.test(h)) return 'notifications';
if (/個人信息|个人资料|资料|profile|account/i.test(t) || /profile$/.test(h)) return 'account_circle';
if (/支付記錄|支付记录|receipt|payment|billing/i.test(t)) return 'receipt';
if (/設置|设置|setting|config|偏好/i.test(t) || /settings/.test(h)) return 'settings';
if (/登出|logout|退出|sign\.out/i.test(t)) return 'logout';
if (/登入|login|sign\.in/i.test(t)) return 'login';
if (/注冊|注册|regist|sign.?up/i.test(t)) return 'person_add';
if (/消息|message|信件|mail|inbox/i.test(t) || /messages/.test(h)) return 'mail';
if (/帮助|help|关于|about/i.test(t)) return 'help';
return 'chevron_right';
}
/* 从 javdb 原版右上角提取「用户账号下拉菜单」完整内容。
*
* 关键修正(v4.28):javdb navbar-end 存在多个下拉菜单(用户账号 / 主题语言等),
* 盲目取第一个 .has-dropdown 会拿到主题设置而非用户菜单。
* 改为:遍历所有下拉,用「含登出/個人信息/收藏/想看/看過等用户特征词条」
* 识别真正的用户菜单;提取其全部子项 + 分隔线分组信息,平移到抽屉。
*/
function buildAccountMenuHtml() {
const nav = document.querySelector('nav.navbar.is-fixed-top.main-nav');
if (!nav) return '';
const end = nav.querySelector('.navbar-end');
if (!end) return '';
let username = '';
const entries = []; // { text, href, icon, isSeparator, dataMethod, dataConfirm, rel }
const seen = new Set();
let _sepIdx = 0; // 分隔线计数器,避免多条分隔线被去重
function push(text, href, isSep, origEl) {
const t = (text || '').trim();
if (!isSep && (!t || t.length > 30)) return;
const h = href || '';
const k = (isSep ? '---sep' + (_sepIdx++) : t + '|' + h);
if (seen.has(k)) return;
seen.add(k);
const dataMethod = origEl ? origEl.getAttribute('data-method') : null;
const dataConfirm = origEl ? origEl.getAttribute('data-confirm') : null;
const rel = origEl ? origEl.getAttribute('rel') : null;
entries.push({ text: t, href: h, isSeparator: !!isSep, dataMethod: dataMethod, dataConfirm: dataConfirm, rel: rel });
}
/* ---- 步骤 1 找到真正的用户账号下拉(非主题/语言下拉) ----
* javdb 用户下拉的特征:包含 "登出" 或 "個人信息" 或 "收藏" 或 "想看" 或 "看過"
* 等用户专属词条。遍历 navbar-end 直接子元素中的所有 .has-dropdown / .is-hoverable,
* 命中第一个即采用。 */
let userDropdownEl = null;
const userMenuKeywords = [/登出/, /個人信息/, /个人信息/, /收藏的/, /想看/, /看過/, /我的清單/, /通知中心/, /支付記錄/, /登入/, /登录/, /註冊/, /注册/];
// 关键:只查 .navbar-end 的直接子元素,避免匹配到嵌套的 .navbar-dropdown 子 div
end.querySelectorAll(':scope > .navbar-item.has-dropdown, :scope > .navbar-item.is-hoverable').forEach(function (ddHost) {
if (userDropdownEl) return; // 已找到
const dd = ddHost.querySelector('.navbar-dropdown, .dropdown-menu, .menu, [role="menu"]');
if (!dd) return;
const ddText = dd.textContent || '';
// 检查是否包含用户菜单特征词
const isUserMenu = userMenuKeywords.some(function (re) { return re.test(ddText); });
if (isUserMenu) {
userDropdownEl = ddHost;
// 提取用户名(下拉触发器的文本)
const trigger = ddHost.querySelector('.navbar-link, a, .dropdown-trigger, button');
if (trigger) {
const u = trigger.textContent.trim();
if (u && u.length <= 24 && !/^[▶\s]+$/.test(u)) username = u;
}
}
});
/* ---- 步骤 2:从用户下拉中提取全部子项(保留分隔线分组结构) ---- */
if (userDropdownEl) {
const dd = userDropdownEl.querySelector('.navbar-dropdown, .dropdown-menu, .menu, [role="menu"]') || userDropdownEl;
// 遍历直接子元素:a=链接项、hr/带 separator 类的 div=分隔线
Array.from(dd.children).forEach(function (child) {
const tag = (child.tagName || '').toLowerCase();
if (tag === 'hr' || child.classList.contains('dropdown-divider') ||
child.classList.contains('separator') || child.classList.contains('menu-divider')) {
push('---', '', true); // 分隔线占位
} else if (tag === 'a' && child.getAttribute('href')) {
push(child.textContent, child.getAttribute('href'), false, child);
} else {
// 兜底:子元素内的链接
child.querySelectorAll('a[href]').forEach(function (a) {
push(a.textContent, a.getAttribute('href'), false, a);
});
}
});
}
/* ---- 步骤 3:兜底 —— 若未找到用户下拉,尝试从 navbar-end 直接收集链接 ---- */
if (!entries.length && !username) {
end.querySelectorAll('a[href]').forEach(function (a) {
// 排除明显的非用户菜单项(logo、搜索等)
if (a.closest('.search-bar') || a.querySelector('img.logo')) return;
const text = (a.textContent || '').trim();
const href = a.getAttribute('href') || '';
// 严格仅提取用户账号/登录注册相关文字链接,绝不收录语言切换、论坛等额外条目
if (/登入|登录|註冊|注册|login|sign|个人|個人|用戶|用户|profile/i.test(text) || /users\/(?:sign_in|sign_up|profile)/i.test(href)) {
push(text, href, false, a);
}
});
}
if (!entries.length && !username) return '';
entries.forEach(function (e) { e.icon = inferAcctIcon(e.text, e.href); });
/* ---- 构建输出 HTML ---- */
let html = '<div class="acct-divider"></div>';
if (username) {
html += '<div class="acct-user">' +
'<span class="material-symbols-outlined mi">account_circle</span>' +
'<span class="acct-uname">' + escapeHtml(username) + '</span></div>';
}
entries.forEach(function (e) {
if (e.isSeparator) {
html += '<div class="acct-sep"></div>';
} else {
const tag = e.href ? 'a' : 'span';
const hrefAttr = e.href ? ' href="' + escapeAttr(safeHref(e.href)) + '"' : '';
const methodAttr = e.dataMethod ? ' data-method="' + escapeAttr(e.dataMethod) + '"' : '';
const confirmAttr = e.dataConfirm ? ' data-confirm="' + escapeAttr(e.dataConfirm) + '"' : '';
const relAttr = e.rel ? ' rel="' + escapeAttr(e.rel) + '"' : '';
const isLogout = /登出|logout|sign\.out/i.test(e.text) || /logout/.test(e.href);
const logoutClass = isLogout ? ' emby-logout-action' : '';
html += '<' + tag + hrefAttr + methodAttr + confirmAttr + relAttr + ' class="acct-item' + logoutClass + '" data-acct="1">' +
'<span class="material-symbols-outlined mi">' + e.icon + '</span>' +
'<span>' + escapeHtml(e.text) + '</span></' + tag + '>';
}
});
return html;
}
/* =======================================================================
* 构建左侧抽屉 + 顶部栏
* ===================================================================== */
function buildChrome() {
if (document.getElementById('emby-topbar')) return;
// 顶部栏
const bar = document.createElement('div');
bar.id = 'emby-topbar';
bar.innerHTML =
'<button class="hb" id="emby-menu-btn" title="菜单" aria-label="菜单">' +
'<span class="material-symbols-outlined">menu</span></button>' +
'<div class="logo-nav-wrap">' + javdbLogoHtml() + '<nav class="emby-topnav"></nav></div>' +
'<span class="spacer"></span>' +
'<button class="hb" id="emby-search-trigger" title="搜索" aria-label="搜索" role="search">' +
'<span class="material-symbols-outlined">search</span></button>' +
'<button id="emby-settings-btn" title="设置" aria-label="设置">' +
'<span class="material-symbols-outlined">settings</span></button>';
document.body.appendChild(bar);
// 顶栏横向视图切换按钮:与首页标签栏同款(复用 .emby-home-tab 样式,视觉完全一致),
// 鼠标放到 LOGO 上时从右侧滑出——移开鼠标后保持 3 秒再收回(防止来不及点击),
// 期间移到按钮上继续停留。按钮由 HOME_TAB_DEFS 驱动,后期新增视图自动出现。
const logoWrap = bar.querySelector('.logo-nav-wrap');
const topNav = bar.querySelector('.emby-topnav');
let navHideTimer = null;
if (logoWrap && topNav) {
logoWrap.addEventListener('mouseenter', function () {
clearTimeout(navHideTimer);
logoWrap.classList.add('show');
});
logoWrap.addEventListener('mouseleave', function (e) {
// 移入容器内(如滑出的按钮)→ 不启动收回计时器,保持显示
const to = e.relatedTarget;
if (to && logoWrap.contains(to)) return;
clearTimeout(navHideTimer);
navHideTimer = setTimeout(function () { logoWrap.classList.remove('show'); }, 3000);
});
HOME_TAB_DEFS.forEach(function (def) {
const a = document.createElement('a');
a.className = 'emby-home-tab';
a.href = '#' + def.key;
a.dataset.tab = def.key;
a.innerHTML = '<span class="material-symbols-outlined" style="font-size:1em;margin-right:.3em;vertical-align:-2px;">' + def.icon + '</span>' + def.label;
a.addEventListener('click', function (e) {
e.preventDefault();
// 点击切换后立即收回(不留 3 秒延迟)
clearTimeout(navHideTimer);
logoWrap.classList.remove('show');
switchTab(def.key);
});
topNav.appendChild(a);
});
}
// 抽屉遮罩
const scrim = document.createElement('div');
scrim.id = 'emby-drawer-scrim';
document.body.appendChild(scrim);
// 抽屉本体
const drawer = document.createElement('div');
drawer.id = 'emby-drawer';
let nh = javdbDrawerBrandHtml();
nh += buildSiteNavLinksHtml();
// ====== 原版账号菜单(从隐藏的原生 navbar 提取:用户名 + 用户名以下词条) ======
const acctHtml = buildAccountMenuHtml();
if (acctHtml) nh += '<div class="nav acct-menu">' + acctHtml + '</div>';
drawer.innerHTML = nh;
document.body.appendChild(drawer);
// 登出专属安全处理管道:确保在 Emby 抽屉内点击「登出」时,
// 100% 正确触发服务端 RESTful DELETE 请求并销毁会话,杜绝误走 GET /logout 导致假退出
drawer.querySelectorAll('.emby-logout-action').forEach(function (logoutBtn) {
logoutBtn.addEventListener('click', function (e) {
e.preventDefault();
e.stopPropagation();
const confirmMsg = logoutBtn.getAttribute('data-confirm') || '確定要退出嗎?';
if (!confirm(confirmMsg)) return;
// 清理本地前端临时缓存的用户认证凭据
try {
localStorage.removeItem('jb_saved_username');
localStorage.removeItem('jb_appAuthorization');
localStorage.removeItem('jhs_appAuthorization');
} catch (err) {}
// 优先直接调用隐藏原生 navbar 中的原生登出链接(由 Rails UJS 托管)
const nativeLogout = document.querySelector('nav.navbar a[href*="/logout"][data-method="delete"], nav.navbar a[data-method="delete"]');
if (nativeLogout && typeof nativeLogout.click === 'function') {
nativeLogout.click();
return;
}
// 兜底方案:直接构造标准 Rails DELETE 表单提交完成服务端注销
const csrfToken = (document.querySelector('meta[name="csrf-token"]') || {}).content || '';
const form = document.createElement('form');
form.method = 'POST';
form.action = '/logout';
form.style.display = 'none';
const methodInput = document.createElement('input');
methodInput.type = 'hidden';
methodInput.name = '_method';
methodInput.value = 'delete';
form.appendChild(methodInput);
if (csrfToken) {
const csrfInput = document.createElement('input');
csrfInput.type = 'hidden';
csrfInput.name = 'authenticity_token';
csrfInput.value = csrfToken;
form.appendChild(csrfInput);
}
document.body.appendChild(form);
form.submit();
});
});
// 折叠菜单点击切换
drawer.querySelectorAll('.drawer-group-header').forEach(function (btn) {
btn.addEventListener('click', function (e) {
e.preventDefault();
e.stopPropagation();
const grp = btn.closest('.drawer-group');
if (grp) {
const isOpen = grp.classList.toggle('open');
btn.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
}
});
});
// 高亮当前路由及自动展开对应折叠组(URL 特异性打分匹配,容错翻页与子路由)
function updateDrawerActiveRoutes() {
let curUrl;
try { curUrl = new URL(location.href); } catch (e) { return; }
const curPath = (curUrl.pathname || '').replace(/\/$/, '').toLowerCase() || '/';
const curQuery = curUrl.searchParams;
drawer.querySelectorAll('.nav a').forEach(function (a) {
a.classList.remove('sel');
});
drawer.querySelectorAll('.drawer-group').forEach(function (grp) {
grp.classList.remove('has-sel');
});
let bestMatch = null;
let maxScore = -1;
drawer.querySelectorAll('.nav a').forEach(function (a) {
const ah = a.getAttribute('href');
if (!ah || ah.startsWith('javascript:') || ah === '#') return;
let targetUrl;
try {
targetUrl = new URL(ah, location.origin);
} catch (e) {
return;
}
const targetPath = (targetUrl.pathname || '').replace(/\/$/, '').toLowerCase() || '/';
let score = -1;
if (targetPath === curPath) {
// 基础路径完全一致
score = 10;
let paramsMatch = true;
let targetParamCount = 0;
targetUrl.searchParams.forEach(function (val, key) {
if (key === 'page' || key === 'cp') return; // 忽略真正翻页参数(保留排行榜周期参数 p=daily/weekly/monthly)
targetParamCount++;
if (curQuery.get(key) !== val) paramsMatch = false;
});
if (!paramsMatch) {
score = 1; // 目标带参数但当前不符,降权
} else if (targetParamCount > 0) {
score += targetParamCount * 10; // 命中特异性查询参数(如 c10=1)大幅加权
}
} else if (targetPath !== '/' && curPath.startsWith(targetPath + '/')) {
// 子路径匹配作为低优先级兜底
score = 5;
}
if (score > maxScore) {
maxScore = score;
bestMatch = a;
}
});
if (bestMatch && maxScore > 0) {
bestMatch.classList.add('sel');
const grp = bestMatch.closest('.drawer-group');
if (grp) {
grp.classList.add('open');
grp.classList.add('has-sel');
const hdr = grp.querySelector('.drawer-group-header');
if (hdr) hdr.setAttribute('aria-expanded', 'true');
}
}
}
// 交互
function toggleDrawer() {
const open = document.documentElement.classList.toggle('emby-drawer-open');
if (open) {
if (typeof LiquidGlass !== 'undefined') {
requestAnimationFrame(function () {
LiquidGlass.ensureApplied(drawer);
});
}
updateDrawerActiveRoutes();
}
}
const menuBtn = document.getElementById('emby-menu-btn');
if (menuBtn) menuBtn.addEventListener('click', toggleDrawer);
scrim.addEventListener('click', function () {
document.documentElement.classList.remove('emby-drawer-open');
});
// 初始高亮与展开
updateDrawerActiveRoutes();
// logo / 品牌名点击:回到 emby 首页视图(清掉收藏夹态,避免整页刷新后仍停在收藏夹)
function bindHomeReset(el) {
if (!el || el.dataset.boundReset) return;
el.dataset.boundReset = '1';
el.addEventListener('click', function () {
try { localStorage.setItem(HOME_TAB_KEY, 'home'); } catch (e) {}
document.documentElement.classList.remove('emby-fav-view');
});
}
bindHomeReset(bar.querySelector('.logo-javdb'));
bindHomeReset(drawer.querySelector('.brand-link'));
// 回顶按钮(主页 + 收藏夹视图通用):滚动超过阈值才显示
const backTop = document.createElement('button');
backTop.id = 'emby-backtop';
backTop.title = '回到顶部';
backTop.setAttribute('aria-label', '回到顶部');
backTop.innerHTML = '<span class="material-symbols-outlined">vertical_align_top</span>';
document.body.appendChild(backTop);
window.__embyBackTop = backTop;
function updateBackTop(st) {
const show = st > 240;
backTop.classList.toggle('show', show);
}
window.__embyUpdateBackTop = updateBackTop;
backTop.addEventListener('click', function () {
// TOP250显示 → 滚TOP250面板;画廊显示 → 滚画廊面板;收藏夹显示 → 滚收藏夹面板;否则滚 window
const tp = document.getElementById('emby-top250-panel');
const gp = document.getElementById('emby-gallery-panel');
const fp = document.getElementById('emby-favorites-panel');
if (tp && tp.classList.contains('show')) tp.scrollTo({ top: 0, behavior: 'smooth' });
else if (gp && gp.classList.contains('show')) gp.scrollTo({ top: 0, behavior: 'smooth' });
else if (fp && fp.classList.contains('show')) fp.scrollTo({ top: 0, behavior: 'smooth' });
else window.scrollTo({ top: 0, behavior: 'smooth' });
});
if (window.__embyScrollHandler) {
window.removeEventListener('scroll', window.__embyScrollHandler);
}
window.__embyScrollHandler = function () {
updateBackTop(window.scrollY || window.pageYOffset || 0);
};
window.addEventListener('scroll', window.__embyScrollHandler, { passive: true });
// 初始(如已滚动则显示)
updateBackTop(window.scrollY || window.pageYOffset || 0);
// 构建搜索弹窗 + 设置面板(幂等)
buildSearchModal();
buildFullSettingsDialog();
// 搜索触发:点击顶部搜索框 → 居中弹窗
const trig = document.getElementById('emby-search-trigger');
if (trig && !trig.dataset.bound) {
trig.dataset.bound = '1';
trig.addEventListener('click', openSearchModal);
}
// 设置齿轮(鼠标悬停浮出快捷面板,点击弹出完整大设置弹窗)
const gear = document.getElementById('emby-settings-btn');
if (gear && !gear.dataset.bound) {
gear.dataset.bound = '1';
gear.addEventListener('click', function (e) {
e.stopPropagation();
hideQuickSettingsPopover();
openSettingsPanel();
});
gear.addEventListener('mouseenter', function () {
showQuickSettingsPopover(gear);
});
gear.addEventListener('mouseleave', function (e) {
const to = e.relatedTarget;
const pop = document.getElementById('emby-quick-settings-popover');
if (to && pop && pop.contains(to)) return;
hideQuickSettingsPopover();
});
}
}
/* =======================================================================
* 设置系统:背景模糊强度 / 封面框架大小 / 展示布局
* 持久化到 localStorage,实时渲染
* ===================================================================== */
/* =======================================================================
* 瀑布流引擎(Pinterest 式,横向顺序)
* 背景:CSS columns 布局是「列优先」填充——第一列填满才进第二列,视觉顺序沿纵向走,
* 与用户「横着看」的阅读习惯相悖(表现为顺序变成竖向一列/竖向流动)。
* 解法:JS 绝对定位瀑布流——依次把卡片放入「当前最短列」:前 N 张横向铺开成首行,
* 后续进入最短列,顺序贴合横向阅读逻辑。
* 适用范围:.movie-list.emby-masonry(首页/收藏夹/浏览清单)+ .efav-gc-all.emby-masonry(画廊全量)
* 重排触发:MutationObserver(分批追加/懒加载/首页无限滚动)、window resize 防抖、
* 容器内图片 load(画廊真实高度)、字体加载完成
* ===================================================================== */
const WaterfallEngine = (function () {
var GAP = 17.6; // 与原 CSS column-gap 1.1em 一致(16px 根字号)
var regs = new Map();
var resizeTimer = null;
var fontsBound = false;
// 可见子项:排除哨兵/隐藏元素(避免循环访问 offsetParent 造成强制重排)
function visibleItems(el) {
return Array.prototype.filter.call(el.children, function (c) {
return c.nodeType === 1 && !c.classList.contains('efav-sentinel') &&
!c.classList.contains('is-filtered-out') && !c.hidden && c.style.display !== 'none';
});
}
// 按容器宽 + 列宽基准计算列数与列宽
function metrics(el) {
var w = el.clientWidth;
var colBase = 220;
// 画廊全量瀑布流:列宽跟随画廊「显示大小」滑块 --gc-size(基准 200px × 缩放),
// 与平铺模式的图片高度 calc(200px * var(--gc-size)) 保持一致——
// 否则滑块只改 --gc-size、引擎却读 --mz-col-w,全量瀑布流下列宽不变、滑块无效
if (el.classList.contains('efav-gc-all')) {
try { var gs = parseFloat(getComputedStyle(el).getPropertyValue('--gc-size')); if (gs > 0.2) colBase = 200 * gs; } catch (e) {}
} else {
try {
var v = parseFloat(getComputedStyle(el).getPropertyValue('--mz-col-w')) ||
parseFloat(document.documentElement.style.getPropertyValue('--mz-col-w')) ||
parseFloat(localStorage.getItem('javdbEmbyCoverSize') || '220');
if (v > 40) colBase = v;
} catch (e) {}
}
var cols = Math.max(1, Math.min(14, Math.round((w + GAP) / (colBase + GAP))));
var colW = Math.max(60, (w - GAP * (cols - 1)) / cols);
return { cols: cols, colW: colW };
}
function layout(el) {
if (!enabled) { unregister(el); return; }
if (!el.isConnected) { unregister(el); return; }
if (!el.clientWidth && !el.clientHeight) return; // 守卫:容器隐藏/脱离渲染树时尺寸为0,跳过计算,杜绝 60px 塌陷
// 守卫:仅瀑布流容器参与 JS 定位。
// 收藏夹面板 grid 可能在瀑布流布局时被 register,切到 grid/dynamic-focus 后
// 即使 observer 残留,容器已无 emby-masonry 类 → 直接卸载并退出,绝不写 inline height
//(否则懒加载追加卡片会触发 layout,把残留总高写进动态聚焦 grid,卡片被拉高)。
if (!el.classList.contains('emby-masonry')) { unregister(el); return; }
var items = visibleItems(el);
if (!items.length) { el.style.height = ''; return; }
var m = metrics(el);
if (!m.cols) return;
// ====== 三趟式布局:写宽度 → 读高度 → 写位移 ======
// 旧实现在单次 forEach 里「写 style.width → 读 offsetHeight」交错:
// 写宽度使布局失效,紧接着读高度就触发一次强制同步布局(forced reflow),
// N 个卡片 = N 次 reflow。数百张卡片时这是筛选/追加卡顿的主要来源。
// 拆成三趟后:全部写宽集中一次 → 一次性重排读完全部高度 → 批量写位移。
// 位移只走合成器(transform 不使布局失效),不会再引发重排。
var i, it, k;
// ① 写宽度(唯一的布局失效源,集中到一趟)
for (i = 0; i < items.length; i++) {
it = items[i];
if (it.style.width !== m.colW + 'px') it.style.width = m.colW + 'px';
}
// ② 读高度:一趟内只读不写,浏览器只需重排一次
var hs = new Array(items.length);
for (i = 0; i < items.length; i++) hs[i] = items[i].offsetHeight || 0;
// ③ 写位移(纯合成器操作,不触发重排)
var tops = new Array(m.cols);
for (i = 0; i < m.cols; i++) tops[i] = 0;
for (i = 0; i < items.length; i++) {
it = items[i];
var h = hs[i];
if (h <= 0) { it.style.transform = ''; continue; } // 未渲染完成的项跳过,后续 mutation 再排
var ci = 0;
for (k = 1; k < m.cols; k++) if (tops[k] < tops[ci]) ci = k; // 找最短列 → 横向铺开
it.style.transform = 'translate3d(' + (ci * (m.colW + GAP)).toFixed(1) + 'px,' + tops[ci].toFixed(1) + 'px,0)';
tops[ci] += h + GAP;
}
var maxTop = 0;
for (var j = 0; j < m.cols; j++) if (tops[j] > maxTop) maxTop = tops[j];
var hh = Math.max(0, maxTop - GAP);
if (el.style.height !== hh + 'px') el.style.height = hh + 'px';
}
function schedule(el) {
if (!enabled) return;
var reg = regs.get(el);
if (!reg || reg.raf) return;
reg.raf = requestAnimationFrame(function () {
reg.raf = 0;
try { layout(el); } catch (e) {}
});
}
function register(el) {
if (!el || regs.has(el)) return;
var reg = { raf: 0, mo: null, imgH: null };
reg.mo = new MutationObserver(function () { schedule(el); });
reg.mo.observe(el, { childList: true, subtree: true });
reg.imgH = function (e) { if (e.target && e.target.tagName === 'IMG') schedule(el); };
el.addEventListener('load', reg.imgH, true);
regs.set(el, reg);
schedule(el);
}
function unregister(el) {
var reg = regs.get(el);
if (!reg) return;
if (reg.mo) reg.mo.disconnect();
if (reg.imgH) el.removeEventListener('load', reg.imgH, true);
if (reg.raf) cancelAnimationFrame(reg.raf);
regs.delete(el);
}
function unregisterAll() {
Array.prototype.slice.call(regs.keys()).forEach(unregister);
}
// 注销某个作用域内(含其自身)的全部已注册容器。
//
// 这是引擎的「销毁契约」:凡是要清空容器 innerHTML、或整块替换 .movie-list 节点的代码,
// 必须在销毁【之前】调用本函数,否则旧容器连同它绑定的 MutationObserver 会一直留在 regs 里。
// (regs 是强引用 Map,且 reg.mo 观察 el 又反向持有 el,换 WeakMap 也无法自动回收。)
//
// v7.144~7.146 连续三版的「inline height 残留」问题,根因之一就是这里缺统一注销口:
// layout() 里那道 `!el.isConnected → unregister` 只是事后自愈,要等下一次 refresh() 才触发,
// 期间死 observer 会持续累积。现在改由销毁方显式注销,自愈退化为兜底。
function unregisterTree(scopeEl) {
if (!scopeEl) return;
Array.prototype.slice.call(regs.keys()).forEach(function (el) {
if (el === scopeEl || scopeEl.contains(el)) unregister(el);
});
}
function refresh() {
regs.forEach(function (r, el) { schedule(el); });
}
// 还原 JS 定位痕迹(切回 grid/dynamic-focus 或关闭皮肤时调用)
// 注意:选择器【不能】依赖 .emby-masonry 类——applyLayout 切换布局时先改类后调本函数,
// 若按类筛选,切走瀑布流后旧列表已无 emby-masonry 类 → 找不到 → inline 定位残留:
// 残留的 list height(瀑布流总高)会把动态聚焦 grid 行高撑爆(卡片被拉成 762px 高)。
// 因此改为清除所有 .movie-list/.item 的 inline 定位(这些 inline 只由本引擎写入,清掉安全)。
function clearInline(scopeEl) {
var items = (scopeEl || document).querySelectorAll('.movie-list .item, .efav-gc-all.emby-masonry .efav-gc-allimg, .efav-gc-all.emby-masonry .efav-gc-card');
for (var i = 0; i < items.length; i++) {
items[i].style.width = '';
items[i].style.transform = '';
}
var lists = (scopeEl || document).querySelectorAll('.movie-list, .efav-gc-all.emby-masonry');
for (var j = 0; j < lists.length; j++) lists[j].style.height = '';
}
// 全局 resize 防抖(后置防抖,确保窗口拖拉停止后的最终尺寸布局精准)
window.addEventListener('resize', function () {
if (resizeTimer) clearTimeout(resizeTimer);
resizeTimer = setTimeout(function () { resizeTimer = null; refresh(); }, 150);
});
// 字体加载完成后高度可能微调(中文标题行高/卡片文字),重排一次
if (!fontsBound && document.fonts && document.fonts.ready) {
fontsBound = true;
document.fonts.ready.then(function () { setTimeout(refresh, 80); });
}
return { register: register, unregister: unregister, unregisterAll: unregisterAll, unregisterTree: unregisterTree, refresh: refresh, clearInline: clearInline, visibleItems: visibleItems, metrics: metrics };
})();
const SET_KEYS = {
blur: 'javdbEmbyBlur',
coverSize: 'javdbEmbyCoverSize',
coverMode: 'javdbEmbyCoverMode',
trophyStyle: 'javdbEmbyTrophyStyle',
layout: 'javdbEmbyLayout',
randomBg: 'javdbEmbyRandomBg',
liquidAmp: 'javdbEmbyLiquidAmp',
detailCoverW: 'javdbEmbyDetailCoverW',
detailCoverWCrop: 'javdbEmbyDetailCoverW_crop',
detailCoverWFusion: 'javdbEmbyDetailCoverW_fusion',
detailCoverWHybrid: 'javdbEmbyDetailCoverW_hybrid',
detailCoverWFull: 'javdbEmbyDetailCoverW_full',
dfPreview: 'javdbEmbyDfPreview',
dfPreviewDelay: 'javdbEmbyDfDelay',
dfPreviewSize: 'javdbEmbyDfSize',
showNotes: 'javdbEmbyShowNotes',
expandCoact: 'javdbEmbyExpandCoact',
style: 'javdbEmbyStyle',
filterCollapsed: 'javdbEmbyFilterCollapsed'
};
// 各封面模式(裁切/融合/混合/全封面)独立尺寸记忆与智能默认值
function getDetailCoverWForMode(mode) {
const m = mode || localStorage.getItem(SET_KEYS.coverMode) || 'fusion';
let key = SET_KEYS.detailCoverWCrop;
if (m === 'fusion') key = SET_KEYS.detailCoverWFusion;
else if (m === 'hybrid') key = SET_KEYS.detailCoverWHybrid;
else if (m === 'full') key = SET_KEYS.detailCoverWFull;
const val = parseInt(localStorage.getItem(key), 10);
if (!isNaN(val) && val >= 200 && val <= 600) return val;
// 各模式独立默认值:混合模式 440px,其它模式(裁切/融合/全封面)统一为 340px
return (m === 'hybrid') ? 440 : 340;
}
function setDetailCoverWForMode(mode, val) {
const m = mode || localStorage.getItem(SET_KEYS.coverMode) || 'fusion';
const defaultVal = (m === 'hybrid') ? 440 : 340;
const v = (!isNaN(val) && val >= 200 && val <= 600) ? val : defaultVal;
let key = SET_KEYS.detailCoverWCrop;
if (m === 'fusion') key = SET_KEYS.detailCoverWFusion;
else if (m === 'hybrid') key = SET_KEYS.detailCoverWHybrid;
else if (m === 'full') key = SET_KEYS.detailCoverWFull;
localStorage.setItem(key, String(v));
localStorage.setItem(SET_KEYS.detailCoverW, String(v)); // 兼容旧全局读取
return v;
}
// 网格卡片/瀑布流列宽默认值(原 COVER_MAP.m[1] 中档;实际值由收藏夹「显示大小」滑块持久化覆盖)
const DEFAULT_CARD_W = '300px';
function applyLayout(layout) {
const on = layout === 'masonry';
const dynamic = layout === 'dynamic-focus';
// 瀑布流仅「大视图」:框架舍弃小/中视图,固定尺寸 + 右半裁切(纯 CSS,见 emby-masonry-large)
document.documentElement.classList.toggle('emby-masonry-large', on);
document.documentElement.classList.toggle('emby-dynamic-focus', dynamic);
document.querySelectorAll('.movie-list').forEach(function (l) {
if (l.closest('#emby-favorites-panel') || l.closest('#emby-top250-panel')) return; // 面板内布局由面板自管
l.classList.toggle('emby-masonry', on);
l.classList.toggle('emby-dynamic-focus', dynamic);
});
// 画廊图片浏览布局跟随收藏夹「展示布局」:仅画廊处于显示态才实时重渲染(显隐走 .show 类,不看 inline style)
const gp = document.getElementById('emby-gallery-panel');
if (gp && gp.classList.contains('show')) { try { FAVUI.renderGallery(gp); } catch (e) { log('画廊布局同步失败: ' + e.message); } }
// 重建网格浮层以适配新布局(右半裁切尺寸由 emby-masonry-large CSS 控制)
restructureGrid();
document.querySelectorAll('#emby-favorites-panel .movie-list, #emby-top250-panel .movie-list').forEach(function (l) {
l.classList.toggle('emby-masonry', on);
l.classList.toggle('emby-dynamic-focus', dynamic);
});
if (dynamic) {
attachDynamicFocusToAll();
} else {
dfPopupHide(); // 切出动态聚焦时收掉可能残留的大封面气泡与遮罩
attachCard3DToAll();
}
// 瀑布流引擎接管:masonry → 注册全部容器并按「最短列」横向重排(替换 CSS columns 的竖向顺序);
// 其它布局 → 卸载引擎并还原 inline 定位痕迹
if (on) {
document.querySelectorAll('.movie-list.emby-masonry').forEach(function (l) { WaterfallEngine.register(l); });
WaterfallEngine.refresh();
} else {
WaterfallEngine.unregisterAll();
WaterfallEngine.clearInline();
}
}
/* ---------- 布局类自愈(ensureLayoutClasses) ----------
* 真实页面中 javdb 的 Turbolinks / 懒加载 / SPA 重渲染会替换 .movie-list 节点,
* 导致切换布局(尤其 dynamic-focus)后新节点没有布局类 —— 表现为:
* 动态聚焦模式「非常宽大、间距很宽、缩放边距失效」,强制刷新才恢复。
* restructureGrid(MutationObserver 500ms 防抖驱动)每次重构时调用本函数,
* 按 localStorage 当前布局把 html 与所有 .movie-list 的类补回,实现自愈。 */
function ensureLayoutClasses() {
if (!enabled) return;
try {
const layout = localStorage.getItem(SET_KEYS.layout) || 'grid';
const on = layout === 'masonry';
const dynamic = layout === 'dynamic-focus';
const html = document.documentElement;
html.classList.toggle('emby-masonry-large', on);
html.classList.toggle('emby-dynamic-focus', dynamic);
document.querySelectorAll('.movie-list').forEach(function (l) {
l.classList.toggle('emby-masonry', on);
l.classList.toggle('emby-dynamic-focus', dynamic);
});
// 瀑布流新节点补注册(幂等;面板内由 FAVUI 自管)
if (on) {
document.querySelectorAll('.movie-list.emby-masonry').forEach(function (l) {
if (!l.closest('#emby-favorites-panel')) { try { WaterfallEngine.register(l); } catch (e) {} }
});
} else {
// 非瀑布流:清掉瀑布流引擎残留的 inline 定位(list height / item width+transform)。
// 这是动态聚焦「卡片被拉成 762px 高」的根治——残留的 list inline height 会把
// 动态聚焦 grid 行高撑爆;切换时与停留页面的 DOM 变化都能自愈。
try { WaterfallEngine.clearInline(); } catch (e) {}
}
} catch (e) { log('ensureLayoutClasses 失败: ' + e.message); }
}
/* =======================================================================
* 液态玻璃真折射层 2.0(深度参考 childrentime/liquid-glass 与 iOS 设计系统)
* - 基于真实物理宽高比的有向距离场 (Aspect-Ratio Aware SDF) 计算透镜折射
* - 采用 smoothStep 非线性缓动,中心内容区域 (SDF << 0) 零失真、100% 锐利清晰
* - 仅在边缘物理倒角带内生成非线性向心/离心位移,并在 Canvas 上编码为 RGBA 颜色搬家指令图
* - SVG feDisplacementMap 负责硬件级像素光学偏折,配合微模糊与高饱和形成水晶透镜感
* - 支持鼠标悬停光感流转微交互 (Pointer Shimmer Interaction)
* - 覆盖全站所有核心面板、大设置窗、快捷悬浮窗、TOP250控件、工具条与胶囊按钮
* ===================================================================== */
const LiquidGlass = (function () {
const TARGETS = [
'#emby-topbar',
'#emby-compact-bar',
'.emby-home-tabs .emby-home-tab',
'#emby-backtop',
'#javdb-emby-toggle',
'#emby-drawer',
'.jhs-qs-popover',
'.jhs-settings-dialog',
'#emby-settings-panel',
'.search-panel',
'#jhs-wd-list-modal',
'#top250-login-modal',
'.efav-modal',
'.efav-panel',
'.efav-scope-menu',
'.efav-lists-menu',
'.emby-trophy-card',
'.emby-play-split-btn',
'.emby-play-btn',
'.emby-circle-btn',
'.emby-play-menu',
'.em-meta-chip',
'.em-cat',
'.emby-toast'
].join(', ');
const registry = []; // {el, svg, filter, feImage, feDisp, canvas, filterId, W, H}
let resizeTimer = null;
// 液态折射强度(基准系数);由设置面板实时调节,默认 0.22
let liquidAmp = 0.22;
// SDF 2D 真实物理有向距离场与表面法线计算 (Childrentime Liquid Glass 算法)
function roundedBoxSDF(x, y, hw, hh, r) {
const qx = Math.abs(x) - hw + r, qy = Math.abs(y) - hh + r;
return Math.min(Math.max(qx, qy), 0) + Math.hypot(Math.max(qx, 0), Math.max(qy, 0)) - r;
}
function getNormal(x, y, hw, hh, r) {
const eps = 0.5;
const nx = roundedBoxSDF(x + eps, y, hw, hh, r) - roundedBoxSDF(x - eps, y, hw, hh, r);
const ny = roundedBoxSDF(x, y + eps, hw, hh, r) - roundedBoxSDF(x, y - eps, hw, hh, r);
const len = Math.hypot(nx, ny) || 1;
return { nx: nx / len, ny: ny / len };
}
// 独立缩放计算器:滑块移动时即时调节硬件 displacement scale,并确保 Chromium 刷新合成器重绘
function updateScale(entry) {
if (!entry || !entry.W || !entry.H || !entry.feDisp || !entry.el) return;
const hw = entry.W / 2;
const hh = entry.H / 2;
const minDim = Math.min(hw, hh);
const isLargePanel = entry.W > 180 && entry.H > 100;
let finalScale;
if (isLargePanel) {
// 大面板/大抽屉/弹窗:折射范围更广,提供充足晶体厚度感
const panelBaseScale = Math.round(12 + liquidAmp * 65);
finalScale = Math.min(panelBaseScale, Math.max(12, minDim * 0.65));
} else {
// 胶囊按钮/小按钮/顶部栏:水滴透镜尺度,随折射滑块平滑响应
const btnBaseScale = Math.round(6 + liquidAmp * 35);
finalScale = Math.min(btnBaseScale, Math.max(6, minDim * 0.95));
}
entry.feDisp.setAttribute('scale', finalScale.toFixed(1));
// 触发 Chromium backdrop-filter GPU 缓存更新:通过滑块连续微幅饱和度参数强制刷新重绘
const sat = (1.08 + (liquidAmp * 0.005) + (Math.round(finalScale) % 2) * 0.0005).toFixed(4);
const isHeavyPanel = entry.el && entry.el.matches && entry.el.matches(
'.emby-play-menu, .jhs-settings-dialog, #emby-settings-panel, .efav-modal, .efav-panel, .efav-scope-menu, .efav-lists-menu'
);
const isTranslucentPanel = entry.el && entry.el.matches && entry.el.matches(
'#emby-drawer, .jhs-qs-popover'
);
const blurVal = isHeavyPanel ? 'blur(14px)' : (isTranslucentPanel ? 'blur(1.6px)' : 'blur(0.25px)');
const newBp = 'url(#' + entry.filterId + ') ' + blurVal + ' brightness(1.04) saturate(' + sat + ')';
entry.el.style.setProperty('backdrop-filter', newBp, 'important');
entry.el.style.setProperty('-webkit-backdrop-filter', newBp, 'important');
}
function setAmp(v) {
if (v === null || v === undefined || v === '') return;
const s = String(v).trim();
const n = parseFloat(s);
if (isNaN(n)) return;
if (s.includes('.') && n <= 0.6) {
liquidAmp = Math.max(0, Math.min(0.44, n));
} else if (n > 0) {
liquidAmp = Math.max(0, Math.min(0.44, (n / 100) * 0.44));
} else {
liquidAmp = 0;
}
registry.forEach(updateScale);
}
// 核心渲染:基于真实物理尺寸生成二维凸透镜微折射位移贴图
function buildMap(entry) {
const el = entry.el;
if (!el || !document.body.contains(el)) return false;
const rect = el.getBoundingClientRect();
const W = Math.max(0, Math.round(el.offsetWidth || rect.width));
const H = Math.max(0, Math.round(el.offsetHeight || rect.height));
if (W <= 10 || H <= 10) return false;
entry.W = W;
entry.H = H;
const hw = W / 2;
const hh = H / 2;
// 读取真实圆角并限制上限
const cs = getComputedStyle(el);
let rPx = parseFloat(cs.borderTopLeftRadius) || 0;
const r = Math.min(rPx, Math.min(hw, hh));
const isLarge = W > 180 || H > 180;
// 光学倒角边缘宽度:小按钮为 6px~14px;大面板为 16px~28px
const bevel = isLarge
? Math.min(28, Math.max(16, Math.min(hw, hh) * 0.12))
: Math.min(14, Math.max(6, Math.min(hw, hh) * 0.35));
// 适度降采样画布尺寸,既保证边缘折射曲率极致丝滑,又保证单次构建耗时 < 1ms
const maxW = W > 400 ? 512 : 240;
const w = Math.max(16, Math.min(maxW, Math.round(W * 0.5)));
const h = Math.max(16, Math.min(240, Math.round(H * 0.5)));
const cv = entry.canvas;
cv.width = w; cv.height = h;
const ctx = cv.getContext('2d');
if (!ctx) return false;
const img = ctx.createImageData(w, h);
const data = img.data;
// 默认中位中性无位移基底 (R=128, G=128, B=0, A=255)
data.fill(128);
for (let i = 2; i < data.length; i += 4) {
data[i] = 0;
data[i + 1] = 255;
}
// 遍历采样网格,仅在边缘倒角带 [-bevel, 0] 计算物理凸透镜向心折射
for (let py = 0; py < h; py++) {
const y = (py / (h - 1) - 0.5) * H;
for (let px = 0; px < w; px++) {
const x = (px / (w - 1) - 0.5) * W;
const d = roundedBoxSDF(x, y, hw, hh, r);
if (d >= -bevel && d <= 0) {
const t = (d + bevel) / bevel;
// smoothStep 三次非线性平滑过渡 (childrentime/liquid-glass)
const sc = t * t * (3 - 2 * t);
const norm = getNormal(x, y, hw, hh, r);
const idx = (py * w + px) * 4;
// 光线穿过凸透镜边缘时向内折射(方向为 -norm)
data[idx] = Math.max(0, Math.min(255, Math.round((-norm.nx * sc * 0.5 + 0.5) * 255)));
data[idx + 1] = Math.max(0, Math.min(255, Math.round((-norm.ny * sc * 0.5 + 0.5) * 255)));
}
}
}
ctx.putImageData(img, 0, 0);
const dataUri = cv.toDataURL();
entry.feImage.setAttributeNS('http://www.w3.org/1999/xlink', 'href', dataUri);
entry.feImage.setAttribute('href', dataUri);
entry.feImage.setAttribute('preserveAspectRatio', 'none');
entry.feImage.setAttribute('x', '0');
entry.feImage.setAttribute('y', '0');
entry.feImage.setAttribute('width', W);
entry.feImage.setAttribute('height', H);
entry.filter.setAttribute('width', W);
entry.filter.setAttribute('height', H);
entry.filter.setAttribute('x', '0');
entry.filter.setAttribute('y', '0');
if (entry.feDisp) {
entry.feDisp.setAttribute('x', '0');
entry.feDisp.setAttribute('y', '0');
entry.feDisp.setAttribute('width', W);
entry.feDisp.setAttribute('height', H);
}
updateScale(entry);
return true;
}
function makeEntry(el) {
if (!el || !document.documentElement.classList.contains('emby-style-liquid')) return null;
if (el.closest && el.closest('.emby-topnav')) return null;
const id = 'lg-' + Math.random().toString(36).slice(2, 9);
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('width', '0'); svg.setAttribute('height', '0');
const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
const filter = document.createElementNS('http://www.w3.org/2000/svg', 'filter');
filter.setAttribute('id', id);
filter.setAttribute('filterUnits', 'userSpaceOnUse');
filter.setAttribute('colorInterpolationFilters', 'sRGB');
filter.setAttribute('x', '0'); filter.setAttribute('y', '0');
const mapName = id + '_map';
const feImage = document.createElementNS('http://www.w3.org/2000/svg', 'feImage');
feImage.setAttribute('id', mapName);
feImage.setAttribute('result', mapName);
feImage.setAttribute('preserveAspectRatio', 'none');
feImage.setAttribute('x', '0');
feImage.setAttribute('y', '0');
const feDisp = document.createElementNS('http://www.w3.org/2000/svg', 'feDisplacementMap');
feDisp.setAttribute('in', 'SourceGraphic');
feDisp.setAttribute('in2', mapName);
feDisp.setAttribute('xChannelSelector', 'R');
feDisp.setAttribute('yChannelSelector', 'G');
feDisp.setAttribute('x', '0');
feDisp.setAttribute('y', '0');
filter.appendChild(feImage); filter.appendChild(feDisp);
defs.appendChild(filter); svg.appendChild(defs);
const canvas = document.createElement('canvas');
const entry = {
el: el, svg: svg, filter: filter, feImage: feImage, feDisp: feDisp, canvas: canvas, filterId: id,
W: 0, H: 0
};
svg.style.cssText = 'position:fixed;top:0;left:0;width:0;height:0;overflow:visible;pointer-events:none;z-index:-1;';
document.body.appendChild(svg);
registry.push(entry);
if (!buildMap(entry)) {
requestAnimationFrame(function () {
if (!entry.W || !entry.H) buildMap(entry);
});
}
return entry;
}
let observer = null;
function ensureApplied(el) {
if (!el || !document.documentElement.classList.contains('emby-style-liquid')) return null;
if (el.closest && el.closest('.emby-topnav')) return null;
const existing = registry.find(function (e) { return e.el === el; });
if (existing) {
const W = Math.max(0, Math.round(el.offsetWidth || el.getBoundingClientRect().width));
const H = Math.max(0, Math.round(el.offsetHeight || el.getBoundingClientRect().height));
if (W > 10 && H > 10 && (existing.W !== W || existing.H !== H)) {
buildMap(existing);
}
return existing;
}
return makeEntry(el);
}
function apply() {
if (!document.documentElement.classList.contains('emby-style-liquid')) return;
clear();
document.querySelectorAll(TARGETS).forEach(function (el) {
if (el && document.body.contains(el)) makeEntry(el);
});
if (!observer) {
observer = new MutationObserver(function (muts) {
if (!document.documentElement.classList.contains('emby-style-liquid')) return;
muts.forEach(function (m) {
if (m.type === 'attributes' && (m.attributeName === 'class' || m.attributeName === 'style')) {
const t = m.target;
if (t.matches && t.matches(TARGETS)) ensureApplied(t);
} else if (m.type === 'childList') {
m.addedNodes.forEach(function (n) {
if (n.nodeType === 1) {
if (n.matches && n.matches(TARGETS)) ensureApplied(n);
if (n.querySelectorAll) n.querySelectorAll(TARGETS).forEach(ensureApplied);
}
});
}
});
});
observer.observe(document.body, { attributes: true, attributeFilter: ['class', 'style'], childList: true, subtree: true });
}
}
let scrollRaf = null;
let lastScrollTick = 0;
let scrollEndTimer = null;
function onScrollInvalidate() {
if (!document.documentElement.classList.contains('emby-style-liquid')) return;
clearTimeout(scrollEndTimer);
scrollEndTimer = setTimeout(function () {
const topbarEntry = registry.find(function (e) { return e.el && e.el.id === 'emby-topbar'; });
if (topbarEntry && topbarEntry.el && topbarEntry.filterId) {
const bp = 'url(#' + topbarEntry.filterId + ') blur(0.25px) brightness(1.04) saturate(1.08)';
topbarEntry.el.style.setProperty('backdrop-filter', bp, 'important');
topbarEntry.el.style.setProperty('-webkit-backdrop-filter', bp, 'important');
}
}, 120);
if (scrollRaf) return;
scrollRaf = requestAnimationFrame(function () {
scrollRaf = null;
const now = Date.now();
if (now - lastScrollTick < 50) return;
lastScrollTick = now;
const tick = (now % 2) * 0.0005;
registry.forEach(function (entry) {
if (!entry || !entry.el || !entry.filterId) return;
if (entry.el.id === 'emby-topbar' || (entry.el.id === 'emby-compact-bar' && entry.el.classList.contains('show'))) {
if (!entry.W || !entry.H) { buildMap(entry); return; }
const sat = (1.08 + tick).toFixed(4);
const bp = 'url(#' + entry.filterId + ') blur(0.25px) brightness(1.04) saturate(' + sat + ')';
entry.el.style.setProperty('backdrop-filter', bp, 'important');
entry.el.style.setProperty('-webkit-backdrop-filter', bp, 'important');
}
});
});
}
window.addEventListener('scroll', onScrollInvalidate, { passive: true, capture: true });
function clear() {
if (observer) { observer.disconnect(); observer = null; }
clearTimeout(scrollEndTimer);
if (scrollRaf) { cancelAnimationFrame(scrollRaf); scrollRaf = null; }
registry.forEach(function (e) {
if (e.svg && e.svg.parentNode) e.svg.parentNode.removeChild(e.svg);
if (e.canvas && e.canvas.parentNode) e.canvas.parentNode.removeChild(e.canvas);
if (e.el) {
e.el.style.removeProperty('backdrop-filter');
e.el.style.removeProperty('-webkit-backdrop-filter');
}
});
registry.length = 0;
try {
document.querySelectorAll(TARGETS).forEach(function (el) {
el.style.removeProperty('backdrop-filter');
el.style.removeProperty('-webkit-backdrop-filter');
});
} catch (err) {}
}
window.addEventListener('resize', function () {
if (!document.documentElement.classList.contains('emby-style-liquid')) return;
clearTimeout(resizeTimer);
resizeTimer = setTimeout(function () {
registry.forEach(function (e) {
const W = Math.max(0, Math.round(e.el.offsetWidth || e.el.getBoundingClientRect().width));
const H = Math.max(0, Math.round(e.el.offsetHeight || e.el.getBoundingClientRect().height));
if (W > 10 && H > 10 && (e.W !== W || e.H !== H)) {
buildMap(e);
}
});
}, 150);
});
return {
apply: apply,
clear: clear,
makeEntry: makeEntry,
ensureApplied: ensureApplied,
setAmp: setAmp,
refresh: function () {
if (document.documentElement.classList.contains('emby-style-liquid')) {
registry.forEach(function (e) {
buildMap(e);
});
}
}
};
})();
// 视觉风格切换:EMBY经典 / 毛玻璃 / 液态玻璃
// - 即时切 class,并显示 1 秒遮罩淡入淡出,避免渲染跳变
// - 三套玻璃栈的 backdrop-filter / 背景层差异大,直接过渡易闪,用短暂遮罩盖住重绘
function applyStyleTheme(theme) {
if (!theme) theme = 'emby';
document.documentElement.classList.remove('emby-style-glass', 'emby-style-liquid');
if (theme === 'glass') document.documentElement.classList.add('emby-style-glass');
else if (theme === 'liquid') document.documentElement.classList.add('emby-style-liquid');
// 液态玻璃:启用真折射层;其它模式清理
if (typeof LiquidGlass !== 'undefined') {
if (theme === 'liquid') {
const laVal = localStorage.getItem(SET_KEYS.liquidAmp);
LiquidGlass.setAmp(laVal !== null ? laVal : 50);
LiquidGlass.apply();
}
else LiquidGlass.clear();
}
// 1 秒遮罩过渡
let mask = document.getElementById('efav-style-morph');
if (!mask) {
mask = document.createElement('div');
mask.id = 'efav-style-morph';
mask.className = 'efav-style-morph';
document.body.appendChild(mask);
}
mask.classList.remove('hide');
clearTimeout(applyStyleTheme._t);
applyStyleTheme._t = setTimeout(function () { mask.classList.add('hide'); }, 1000);
}
// hybrid 模式 → 把操作按钮组(.emby-btn-group)从 .info 移出,放入独立右栏 .emby-action-panel(三栏布局)
// 其他模式(crop/full/fusion) → 保持原始两栏结构,按钮组留在 .info 内(不破坏原设计)
function arrangeHero(coverMode) {
const hero = document.getElementById('emby-detail-hero');
if (!hero) return;
const info = hero.querySelector('.info');
const btnGroup = hero.querySelector('.emby-btn-group');
if (!info || !btnGroup) return;
let panel = hero.querySelector('.emby-action-panel');
const metaCol = info.querySelector('.emby-meta-col') || info;
const trophyCol = info.querySelector('.emby-trophy-col');
const trophyCard = hero.querySelector('.emby-trophy-card');
if (coverMode === 'hybrid') {
if (!panel) {
panel = document.createElement('div');
panel.className = 'emby-action-panel';
hero.appendChild(panel);
}
// 混合模式下:播放按钮控件组在上方,金标荣誉卡在播放控件下方展示
if (btnGroup.parentNode !== panel) panel.appendChild(btnGroup);
if (trophyCard && trophyCard.parentNode !== panel) {
panel.appendChild(trophyCard);
}
if (trophyCol) trophyCol.style.display = 'none';
} else {
if (panel && panel.parentNode) panel.parentNode.removeChild(panel);
if (trophyCol) {
trophyCol.style.display = '';
if (trophyCard && trophyCard.parentNode !== trophyCol) {
trophyCol.appendChild(trophyCard);
}
}
if (btnGroup.parentNode !== metaCol) metaCol.appendChild(btnGroup);
}
if (document.documentElement.classList.contains('emby-style-liquid') && typeof LiquidGlass !== 'undefined') {
try {
if (trophyCard) LiquidGlass.ensureApplied(trophyCard);
if (btnGroup) {
btnGroup.querySelectorAll('.emby-play-split-btn, .emby-play-btn, .emby-circle-btn').forEach(function (el) {
LiquidGlass.ensureApplied(el);
});
}
hero.querySelectorAll('.em-meta-chip, .em-cat').forEach(function (el) {
LiquidGlass.ensureApplied(el);
});
} catch (e) {}
}
}
function applySettings() {
const root = document.documentElement;
const blur = parseInt(localStorage.getItem(SET_KEYS.blur), 10);
root.style.setProperty('--e-blur', (isNaN(blur) ? 4 : blur) + 'px');
// 封面框架大小(设置面板滑块):控制详情页 hero 封面(各封面模式独立记忆与响应)与下方推荐封面。
// --e-card-w / --mz-col-w(收藏夹网格/瀑布流)仍由收藏夹外部工具条滑块驱动,互不干扰。
const curCoverMode = localStorage.getItem(SET_KEYS.coverMode) || 'fusion';
const coverW = getDetailCoverWForMode(curCoverMode);
root.style.setProperty('--e-cover-w', coverW + 'px');
// 融合折叠态/海报盒高度(宽 × 1.5);显式长度供 CSS 使用(calc 不支持乘法,故由 JS 计算)
root.style.setProperty('--e-cover-h', Math.round(coverW * 1.5) + 'px');
// 下方推荐/相关封面宽度跟随(约 0.6 倍,保持当前视觉比例)
root.style.setProperty('--e-rel-w', Math.round(coverW * 0.6) + 'px');
// --e-card-w / --mz-col-w 由滑块控制(网格 / 瀑布流分别对应);若尚未设置则用默认中档
const cardW = localStorage.getItem(SET_KEYS.coverSize);
if (!cardW) {
root.style.setProperty('--e-card-w', DEFAULT_CARD_W);
root.style.setProperty('--mz-col-w', DEFAULT_CARD_W);
} else {
root.style.setProperty('--e-card-w', cardW + 'px');
root.style.setProperty('--mz-col-w', cardW + 'px');
}
// 动态聚焦列宽跟随「显示大小」滑块(滑块是唯一尺寸来源):列数由
// auto-fill minmax(--df-col-w) 自动计算;缺省与滑块默认 300px 保持一致
root.style.setProperty('--df-col-w', (cardW ? cardW : 300) + 'px');
// 封面展现方式:crop=裁切(钉住右侧内容)/ full=全封面(整图置顶 + 内容堆叠)/
// fusion=融合(顶态全封面,滚动收起为裁切)/ hybrid=混合(清晰大封面 hero 占上半屏 + 小海报并排,参考 Letterboxd)
const trophyStyle = localStorage.getItem(SET_KEYS.trophyStyle) || 'card';
root.setAttribute('data-trophy-style', trophyStyle);
const coverMode = localStorage.getItem(SET_KEYS.coverMode) || 'fusion';
root.classList.toggle('cover-full', coverMode === 'full');
root.classList.toggle('cover-fusion', coverMode === 'fusion');
root.classList.toggle('cover-hybrid', coverMode === 'hybrid');
if (coverMode === 'fusion') setupFusionScroll();
else detachFusionScroll();
if (coverMode === 'full') setupFullCoverBar();
else detachFullCoverBar();
arrangeHero(coverMode); // 混合模式才把操作按钮移到独立右栏;其他模式保持两栏
const layout = localStorage.getItem(SET_KEYS.layout) || 'grid';
applyLayout(layout);
const st = localStorage.getItem(SET_KEYS.style) || 'emby';
applyStyleTheme(st);
const expandCoact = localStorage.getItem(SET_KEYS.expandCoact) === '1';
root.classList.toggle('emby-expand-coact', expandCoact);
}
// ====== 融合模式(v7.257 重构 / v7.330 阈值防毒与绝对顶部守卫):原版纯正大图 + 标题触顶平滑切换两栏裁切海报 ======
let fusionScrollHandler = null;
let fusionResizeHandler = null;
let fusionImgLoadAttached = false;
let fusionCachedTitleTop = 0;
let fusionCachedShift = 0;
function measureFusionMetrics() {
const root = document.documentElement;
// 严禁在折叠态下测量几何尺寸!折叠态下两栏并排会导致 infoTop 与 heroTop 重合,从而严重污染 titleTop 缓存
if (root.classList.contains('cover-fusion-collapsed')) return null;
const hero = document.getElementById('emby-detail-hero');
if (!hero) return null;
const info = hero.querySelector('.info');
const heroTop = hero.getBoundingClientRect().top + (window.scrollY || window.pageYOffset || 0);
const infoTop = info ? (info.getBoundingClientRect().top + (window.scrollY || window.pageYOffset || 0)) : 0;
const poster = hero.querySelector('.poster-wrap');
const bigH = (poster && poster.offsetHeight) ? poster.offsetHeight : Math.max(480, Math.round(window.innerHeight * 0.8));
const measuredTitleTop = (infoTop > heroTop + 100) ? infoTop : (heroTop + bigH + 24);
// 展开大封面高度下,标题绝对不可能小于 450px;若异常则施加物理安全保底,彻底杜绝负死区
const titleTop = Math.max(450, Math.round(measuredTitleTop));
const shift = Math.max(300, Math.round(titleTop - heroTop));
return { titleTop, shift };
}
function getFusionThreshold() {
const root = document.documentElement;
// 仅在展开态且未缓存时测量;折叠态严禁重测,直接复用缓存值
if (!root.classList.contains('cover-fusion-collapsed') && fusionCachedTitleTop === 0) {
const m = measureFusionMetrics();
if (m) {
fusionCachedTitleTop = m.titleTop;
fusionCachedShift = m.shift;
}
}
const defaultTop = Math.max(500, Math.round(window.innerHeight * 0.8));
return {
titleTop: fusionCachedTitleTop || defaultTop,
shift: fusionCachedShift || (defaultTop - 80)
};
}
function fusionOnScroll() {
const root = document.documentElement;
if (!root.classList.contains('cover-fusion')) return;
const hero = document.getElementById('emby-detail-hero');
if (!hero) return;
const poster = hero.querySelector('.poster-wrap');
if (!poster) return;
const img = poster.querySelector('img');
if (img && !fusionImgLoadAttached) {
fusionImgLoadAttached = true;
if (!img.complete) {
img.addEventListener('load', function () {
if (!root.classList.contains('cover-fusion-collapsed')) {
const m = measureFusionMetrics();
if (m) {
fusionCachedTitleTop = m.titleTop;
fusionCachedShift = m.shift;
}
}
fusionOnScroll();
}, { once: true });
}
}
const y = window.scrollY || window.pageYOffset || 0;
const isCollapsed = root.classList.contains('cover-fusion-collapsed');
// 绝对顶部守卫:当用户回到页面顶部(y <= 30)时,无条件强制恢复完整大图展开态!
// 杜绝任何因测量缓存污染、滞后死区或液态玻璃回流引起的「顶部大图丢失/空白卡死」现象!
if (y <= 30) {
if (isCollapsed) {
root.classList.remove('cover-fusion-collapsed');
root.style.removeProperty('--fusion-shift');
}
if (fusionCachedTitleTop === 0) {
const m = measureFusionMetrics();
if (m) {
fusionCachedTitleTop = m.titleTop;
fusionCachedShift = m.shift;
}
}
return;
}
const { titleTop, shift } = getFusionThreshold();
// 折叠/展开阈值切换(从标题滑动到浏览器窗口最上方开始切换,80px 宽幅滞后死区防抖):
if (isCollapsed) {
if (y < titleTop - 80) {
root.classList.remove('cover-fusion-collapsed');
root.style.removeProperty('--fusion-shift');
}
} else if (y > titleTop - 10) {
root.style.setProperty('--fusion-shift', shift + 'px');
root.classList.add('cover-fusion-collapsed');
}
}
function setupFusionScroll() {
if (fusionScrollHandler) { fusionOnScroll(); return; }
let ticking = false;
fusionScrollHandler = function () {
if (ticking) return;
ticking = true;
requestAnimationFrame(function () { fusionOnScroll(); ticking = false; });
};
window.addEventListener('scroll', fusionScrollHandler, { passive: true });
fusionResizeHandler = function () {
if (!document.documentElement.classList.contains('cover-fusion-collapsed')) {
const m = measureFusionMetrics();
if (m) {
fusionCachedTitleTop = m.titleTop;
fusionCachedShift = m.shift;
}
}
fusionOnScroll();
};
window.addEventListener('resize', fusionResizeHandler, { passive: true });
fusionOnScroll();
}
function detachFusionScroll() {
if (fusionScrollHandler) {
window.removeEventListener('scroll', fusionScrollHandler);
fusionScrollHandler = null;
}
if (fusionResizeHandler) {
window.removeEventListener('resize', fusionResizeHandler);
fusionResizeHandler = null;
}
fusionImgLoadAttached = false;
fusionCachedTitleTop = 0;
fusionCachedShift = 0;
document.documentElement.classList.remove('cover-fusion-collapsed');
document.documentElement.style.removeProperty('--fusion-shift');
const hero = document.getElementById('emby-detail-hero');
if (hero) {
const poster = hero.querySelector('.poster-wrap');
if (poster) {
poster.style.removeProperty('width');
poster.style.removeProperty('height');
const img = poster.querySelector('img');
if (img) {
img.style.removeProperty('width');
img.style.removeProperty('height');
}
}
}
}
// ====== 全封面模式:大图 → 小裁切图 + 固定紧凑条(v7.86) ======
// 全封面模式下大图整图展示并正常滚动离开;标题到达视口顶部时,
// 顶栏下方浮现“小裁切图 + 标题 + 番号”固定紧凑条,淡入淡出(不影响布局)。
let compactBarHandler = null;
function compactMetrics() {
const hero = document.getElementById('emby-detail-hero');
if (!hero) return null;
const rect = hero.getBoundingClientRect();
const topDoc = rect.top + window.scrollY;
const poster = hero.querySelector('.poster-wrap');
const bigH = (poster && poster.offsetHeight) ? poster.offsetHeight : Math.max(480, Math.round(window.innerHeight * 0.8));
let titleTop = topDoc + bigH + 24; // 标题顶 ≈ 封面底 + 1.4em
const info = hero.querySelector('.info');
if (info) {
const t = info.getBoundingClientRect().top + window.scrollY;
if (t > topDoc) titleTop = t; // 实测优先
}
return { titleTop };
}
function ensureCompactBar(coverUrl, title, code) {
const hero = document.getElementById('emby-detail-hero');
// 无参调用且当前不在详情页:不创建/不更新(避免在首页等页面生成空条)
if (coverUrl === undefined && !hero) return;
let bar = document.getElementById('emby-compact-bar');
if (!bar) {
bar = document.createElement('div');
bar.id = 'emby-compact-bar';
document.body.appendChild(bar);
}
// 无参调用:从当前详情页 DOM 推导(模式切换/脚本热更后自动补建)
if (coverUrl === undefined) {
const img = hero ? hero.querySelector('.poster-wrap img') : null;
const h1 = hero ? hero.querySelector('.info h1') : null;
const fb = document.querySelector('.video-detail .panel-block.first-block .value');
coverUrl = img ? (img.getAttribute('src') || '') : '';
title = h1 ? h1.textContent.trim() : '';
code = fb ? fb.textContent.trim().replace(/\s+/g, '') : '';
}
// 番号未变则不重复重建(滚动时避免无谓 DOM 写入)
if (bar.dataset.code !== String(code || '')) {
// 女优/日期/时长/评分:复用 FAV 解析器从当前详情页取最新数据
let actors = '', date = '', duration = '', rating = '';
try {
const det = FAV.parseDetail(document);
if (det) {
actors = (det.actors || []).map(function (a) { return a.name; }).join('、');
date = det.date || '';
duration = det.duration || '';
if (det.rating) {
// 评分:星号 + 分数 + 评价人数(与详情页一致)
if (det.rating.score) {
rating = '<span class="ecb-stars">' + buildStarHtml(det.rating.score) + '</span>' +
' <span class="ecb-score">' + det.rating.score.toFixed(2) + '分</span>';
} else if (det.rating.text) {
rating = '<span class="ecb-score">' + escapeHtml(det.rating.text) + '</span>';
}
if (det.rating.count) rating += '<span class="ecb-count"> · 由' + det.rating.count + '人評價</span>';
}
}
} catch (e) { log('紧凑条元数据解析失败: ' + e.message); }
let meta = '';
function addMeta(label, val, raw) {
if (!val) return;
meta += '<span class="ecb-m-item"><b>' + escapeHtml(label) + '</b>' + (raw ? val : escapeHtml(val)) + '</span>';
}
addMeta('演员', actors);
addMeta('日期', date);
addMeta('时长', duration);
addMeta('评分', rating, true);
bar.innerHTML = '<div class="ecb-poster"><img src="' + escapeAttr(coverUrl || '') + '" alt=""></div>' +
'<div class="ecb-info">' +
'<div class="ecb-title">' + (code ? '<span class="ecb-code">' + escapeHtml(code) + '</span>' : '') + escapeHtml(title || '') + '</div>' +
(meta ? '<div class="ecb-meta">' + meta + '</div>' : '') +
'</div>';
bar.dataset.code = String(code || '');
bar.classList.remove('show');
}
// STEAM 风格卡牌互动:小封面 3D 倾斜 + 高光(复用 attachCard3D;dataset.tilt 防重复绑定)
const poster = bar.querySelector('.ecb-poster');
if (poster) {
if (!poster.querySelector('.ecb-shine')) {
const sh = document.createElement('span');
sh.className = 'ecb-shine';
poster.appendChild(sh);
}
try { attachCompactPoster3D(poster); } catch (e) { log('紧凑条卡牌互动绑定失败: ' + e.message); }
}
}
function compactBarOnScroll() {
if (!document.documentElement.classList.contains('cover-full')) return;
ensureCompactBar(); // 缺失时自动补建(模式切换/热更后)
const m = compactMetrics();
if (!m) return;
const y = window.scrollY;
const bar = document.getElementById('emby-compact-bar');
if (!bar) return;
const isOn = bar.classList.contains('show');
if (isOn) {
// 往回滚过标题线下方 80px → 淡出紧凑条(80px 滞后防抖)
if (y < m.titleTop - 80) bar.classList.remove('show');
} else if (y > m.titleTop - 12) {
// 标题即将到达视口顶部 → 浮现紧凑条
bar.classList.add('show');
}
}
// 紧凑条小封面专属 STEAM 风格卡牌互动(独立实现,不动全局 attachCard3D):
// 整卡 2 倍放大 + 3D 倾斜 + 光标高光;transform-origin:left top,
// 放大向右下方向生长,避免被浏览器左缘/顶部横条遮挡。
function attachCompactPoster3D(poster) {
if (!poster || poster.dataset.cpTilt) return;
poster.dataset.cpTilt = '1';
poster.style.transformOrigin = 'left top';
let raf = null;
poster.addEventListener('mousemove', function (e) {
const r = poster.getBoundingClientRect();
const px = (e.clientX - r.left) / r.width; // 0~1
const py = (e.clientY - r.top) / r.height; // 0~1
const ry = (px - 0.5) * (TILT_MAX * 2);
const rx = (0.5 - py) * (TILT_MAX * 2);
if (raf) cancelAnimationFrame(raf);
raf = requestAnimationFrame(function () {
poster.style.transform = 'perspective(700px) rotateX(' + rx.toFixed(2) + 'deg) rotateY(' + ry.toFixed(2) + 'deg) scale(2)';
poster.style.setProperty('--mx', (px * 100).toFixed(1) + '%');
poster.style.setProperty('--my', (py * 100).toFixed(1) + '%');
});
});
poster.addEventListener('mouseleave', function () {
if (raf) cancelAnimationFrame(raf);
poster.style.transform = '';
});
}
function setupFullCoverBar() {
ensureCompactBar(); // 确保条存在(模式可能在页面构建后才切换)
if (compactBarHandler) { compactBarOnScroll(); return; }
let ticking = false;
compactBarHandler = function () {
if (ticking) return;
ticking = true;
requestAnimationFrame(function () { compactBarOnScroll(); ticking = false; });
};
window.addEventListener('scroll', compactBarHandler, { passive: true });
window.addEventListener('resize', compactBarHandler, { passive: true });
compactBarOnScroll();
}
function detachFullCoverBar() {
if (compactBarHandler) {
window.removeEventListener('scroll', compactBarHandler);
window.removeEventListener('resize', compactBarHandler);
compactBarHandler = null;
}
const bar = document.getElementById('emby-compact-bar');
if (bar) bar.classList.remove('show');
}
// ====== 原版灯箱(Fancybox)守卫:打开时隐藏 EMBY 固定层,避免盖住灯箱按钮 ======
// 滚动位置修复:在灯箱打开前(pointerdown/click,早于 Fancybox 任何滚动)同步记录用户真实
// 视图位置;关闭灯箱后用「最低 1s 硬保持 + 最长 3s、用户一滚动即释放」的滚动锁,叠加 scroll
// 事件即时纠正,压过 Fancybox 关闭序列(overflow 还原 + 自身滚动恢复,受皮肤 body 样式干扰
// 可能延迟到 >1s)才执行的滚动恢复——其会把页面拉到预览图/顶部,造成关闭后跳走。
let fancyboxGuardHandler = null;
const FANCYBOX_SEL = '.fancybox-container, .fancybox__container';
let fbWasOpen = false;
let fbSavedScroll = null;
let fbHoldActive = false;
// 灯箱未开时,pointerdown/click(capture 阶段,早于 Fancybox 任何滚动)都记录当前滚动位置。
// 灯箱已开时跳过,避免灯箱内翻页/关闭点击覆盖掉原始位置。
function fbCaptureScroll() {
if (!fbWasOpen) fbSavedScroll = window.scrollY || window.pageYOffset || 0;
}
document.addEventListener('pointerdown', fbCaptureScroll, true);
document.addEventListener('click', fbCaptureScroll, true);
function doScrollRestore(y) {
try { window.scrollTo({ top: y, left: 0, behavior: 'instant' }); }
catch (e) { try { window.scrollTo(0, y); } catch (e2) {} }
}
function syncFancyboxGuard() {
const open = !!document.querySelector(FANCYBOX_SEL);
if (open && !fbWasOpen) {
// 兜底:若未通过点击捕获(如键盘/脚本唤起),用打开瞬间位置
if (fbSavedScroll === null) fbSavedScroll = window.scrollY || window.pageYOffset || 0;
document.documentElement.classList.add('emby-fancybox-open');
}
if (!open && fbWasOpen) {
document.documentElement.classList.remove('emby-fancybox-open');
// 关闭后精确还原到用户打开预览前的视图位置(不跳顶、也不强制居中预览图)。
const y = fbSavedScroll;
fbSavedScroll = null;
if (y !== null && y > 0 && !fbHoldActive) {
fbHoldActive = true;
const hardStart = Date.now();
let iv = null;
let done = false;
const finish = function () {
if (done) return; done = true;
window.removeEventListener('scroll', onScroll, true);
window.removeEventListener('wheel', release, true);
window.removeEventListener('touchmove', release, true);
window.removeEventListener('keydown', release, true);
window.removeEventListener('pointerdown', release, true);
if (iv) { clearInterval(iv); iv = null; }
fbHoldActive = false;
window.__fbHoldFinish = null;
};
window.__fbHoldFinish = finish;
// 用户主动滚动(1s 硬保持期后)立即释放,避免与用户抢滚动
const release = function () {
if (Date.now() - hardStart < 1000) return; // 最低 1s 硬保持,覆盖 Fancybox 关闭序列
finish();
};
// scroll 事件即时纠正:Fancybox 任何延迟滚动恢复一发生就被拉回 y
const onScroll = function () {
if (Math.abs((window.scrollY || 0) - y) > 3) doScrollRestore(y);
};
doScrollRestore(y);
requestAnimationFrame(function () { doScrollRestore(y); });
window.addEventListener('scroll', onScroll, true);
window.addEventListener('wheel', release, true);
window.addEventListener('touchmove', release, true);
window.addEventListener('keydown', release, true);
window.addEventListener('pointerdown', release, true);
// 定时器兜底:每 50ms 强制一次,最长 3s 自动结束
let tries = 0;
iv = setInterval(function () {
doScrollRestore(y);
if (++tries >= 60) finish();
}, 50);
}
}
fbWasOpen = open;
}
let fancyboxObserver = null;
function setupFancyboxGuard() {
if (fancyboxGuardHandler) return;
fancyboxGuardHandler = function () { syncFancyboxGuard(); };
fancyboxObserver = new MutationObserver(fancyboxGuardHandler);
fancyboxObserver.observe(document.body, { childList: true, subtree: true });
syncFancyboxGuard();
}
function teardownFancyboxGuard() {
if (fancyboxObserver) { fancyboxObserver.disconnect(); fancyboxObserver = null; }
fancyboxGuardHandler = null;
if (window.__fbHoldFinish) { try { window.__fbHoldFinish(); } catch (e) {} }
document.documentElement.classList.remove('emby-fancybox-open');
}
// ====== 原版弹窗/下拉守卫:看過下拉、存入清單模态等原生浮层打开时,
// 隐藏 EMBY 固定层并提升原生浮层层级,避免被遮挡(事件驱动,零轮询空转) ======
let nativeOverlayGuardHandler = null;
let nativeOverlayObserver = null;
const NATIVE_OVERLAY_SEL = '.modal.is-active, .dropdown.is-active';
function syncNativeOverlayGuard() {
const open = !!document.querySelector(NATIVE_OVERLAY_SEL);
document.documentElement.classList.toggle('emby-native-overlay-open', open);
}
function setupNativeOverlayGuard() {
if (nativeOverlayGuardHandler) return;
nativeOverlayGuardHandler = function () { syncNativeOverlayGuard(); };
nativeOverlayObserver = new MutationObserver(nativeOverlayGuardHandler);
nativeOverlayObserver.observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['class'] });
syncNativeOverlayGuard();
}
function teardownNativeOverlayGuard() {
if (nativeOverlayObserver) { nativeOverlayObserver.disconnect(); nativeOverlayObserver = null; }
nativeOverlayGuardHandler = null;
document.documentElement.classList.remove('emby-native-overlay-open');
}
/* =======================================================================
* 增强插件系统 (Plugin Registry)
* ===================================================================== */
const PLUGIN_REGISTRY = [
{ id: 'blockJavdbTranslate', name: '还原日文原标题', desc: '自动屏蔽 JAVDB 自带的中文翻译,番号详情页始终优先展示纯正日文原标题', default: true },
{ id: 'translate', name: '标题翻译', desc: '调用谷歌官方接口自动将日文标题翻译为中文,并在详情页展示', default: true },
{ id: 'dfPreview', name: '悬停封面大图', desc: '鼠标悬停封面弹出超清大图浮层与详细参数', default: true },
{ id: 'otherSite', name: '外部播放站点', desc: '开启后在番号详情页提供多源跳转播放;关闭时默认直接调用 JAVDB 官方播放(会员免跳转)', default: true },
{ id: 'infoSites', name: '番号信息站点', desc: '开启后在番号后面以网站LOGO形式展示 JAVBUS、JAVLib、FANZA動画、javtxt 快捷跳转入口', default: true },
{ id: 'infiniteScroll', name: '无限加载', desc: '浏览列表时滚动到底部自动加载下一页,免去手动点击翻页', default: true },
{ id: 'reviewListInfinite', name: '绕过JAVDB限制', desc: '免VIP/登录畅享完整评论、相关清单原位展开,以及TOP250与热播榜单全量浏览', default: false },
{ id: 'actressInfo', name: '女优信息与收藏标记', desc: '查询女优现役/退役状态、年龄、身高、三围等体检数据,并控制女优头像红心收藏标记的显示', default: true },
{ id: 'gfriendsAvatar', name: '演员头像高清替换', desc: '开启后优先从高清头像库匹配演员头像(男女演员均支持),未匹配则平滑回退 JAVDB 官方头像', default: true },
{ id: 'quickStatusPreview', name: '状态快捷标记和预览图', desc: '在作品封面提供「想看/已看评分」快捷标记按钮,以及预览大图嗅探抓取与画廊浮层', default: false }
];
function isPluginEnabled(id) {
if (id === 'webdav') return true;
const raw = localStorage.getItem('javdb_plugin_' + id);
if (raw === '0' || raw === 'false') return false;
if (raw === '1' || raw === 'true') return true;
for (let i = 0; i < PLUGIN_REGISTRY.length; i++) {
if (PLUGIN_REGISTRY[i].id === id) return PLUGIN_REGISTRY[i].default;
}
return true;
}
function setPluginEnabled(id, enabled) {
localStorage.setItem('javdb_plugin_' + id, enabled ? '1' : '0');
if (id === 'translate') {
try { syncDetailTranslation(); } catch (e) {}
} else if (id === 'otherSite') {
try { rebuildDetailPlayButton(); } catch (e) {}
} else if (id === 'actressInfo') {
try { if (typeof FavoriteActressManager !== 'undefined') FavoriteActressManager.markBadges(document); } catch (e) {}
} else if (id === 'quickStatusPreview') {
try { syncQuickStatusPreviewUI(); } catch (e) {}
} else if (id === 'gfriendsAvatar') {
try { if (typeof refreshAllAvatarsUI === 'function') refreshAllAvatarsUI(); } catch (e) {}
}
}
/* =======================================================================
* 自动屏蔽 JAVDB 自带翻译,还原日文原标题 (Auto Block JAVDB Translate)
* ===================================================================== */
function autoBlockJavdbTranslate(doc) {
const d = doc || document;
const root = d.querySelector('.video-detail');
if (!root) return;
const originEl = root.querySelector('.origin-title');
const currentEl = root.querySelector('.current-title');
const showBtn = root.querySelector('[data-movie-detail-target="showOriginTitle"]');
if (!originEl || !originEl.textContent.trim()) return;
const isBlocked = isPluginEnabled('blockJavdbTranslate');
const originText = originEl.textContent.trim();
if (isBlocked) {
if (currentEl) {
if (!currentEl.dataset.javdbTranslated && currentEl.textContent.trim() !== originText) {
currentEl.dataset.javdbTranslated = currentEl.textContent.trim();
}
if (currentEl.textContent.trim() !== originText) {
currentEl.textContent = originText;
}
}
if (showBtn && showBtn.style.display !== 'none') {
showBtn.style.display = 'none';
try { showBtn.click(); } catch (e) {}
}
} else {
// 还原 JAVDB 默认翻译与「顯示原標題」按钮
if (currentEl && currentEl.dataset.javdbTranslated) {
currentEl.textContent = currentEl.dataset.javdbTranslated;
}
if (showBtn && showBtn.style.display === 'none') {
showBtn.style.display = '';
}
}
}
/* =======================================================================
* 无限滚动加载下一页 (InfiniteScrollManager - 移植自 JHS-Pro)
* ===================================================================== */
const InfiniteScrollManager = (function () {
let isLoading = false;
let hasMore = true;
let nextUrl = null;
let loaderEl = null;
let scrollBound = false;
let scrollEpoch = 0;
function getNextUrlFromDoc(doc) {
const nextA = (doc || document).querySelector('.pagination-next, .pagination a[rel="next"], a.pagination-link.next');
if (nextA && nextA.getAttribute('href')) {
const h = nextA.getAttribute('href');
try {
return new URL(h, (doc && doc.baseURI) || location.href).href;
} catch (e) {
return h.startsWith('http') ? h : (location.origin + (h.startsWith('/') ? '' : '/') + h);
}
}
return null;
}
function createLoader() {
if (loaderEl && loaderEl.isConnected) return loaderEl;
loaderEl = document.createElement('div');
loaderEl.id = 'jhs-infinite-loader';
loaderEl.className = 'jhs-infinite-loader';
loaderEl.style.cssText = 'text-align:center;padding:18px 0 28px;font-size:13px;color:var(--e-text2,#bbb);cursor:default;user-select:none;';
loaderEl.innerHTML = '<span class="loader-txt">滚动加载更多…</span>';
loaderEl.addEventListener('click', function () {
if (loaderEl.dataset.state === 'error') loadNext();
});
return loaderEl;
}
function setState(state, text) {
if (!loaderEl) return;
loaderEl.dataset.state = state;
const span = loaderEl.querySelector('.loader-txt');
if (state === 'loading') {
loaderEl.innerHTML = '<span class="material-symbols-outlined" style="font-size:16px;vertical-align:-3px;animation:spin 1s linear infinite;margin-right:6px;">progress_activity</span><span class="loader-txt">' + (text || '正在加载下一页…') + '</span>';
loaderEl.style.cursor = 'default';
} else if (state === 'no-more') {
loaderEl.innerHTML = '<span class="loader-txt" style="opacity:.6;">' + (text || '已经到底了') + '</span>';
loaderEl.style.cursor = 'default';
} else if (state === 'error') {
loaderEl.innerHTML = '<span class="loader-txt" style="color:#ef4444;cursor:pointer;">' + (text || '加载失败,点击重试') + '</span>';
loaderEl.style.cursor = 'pointer';
} else {
loaderEl.innerHTML = '<span class="loader-txt">' + (text || '') + '</span>';
loaderEl.style.cursor = 'default';
}
}
async function loadNext() {
if (isLoading || !hasMore || !nextUrl) return;
if (!isPluginEnabled('infiniteScroll')) return;
if (isDetailPage()) return;
const listEl = document.querySelector('.movie-list:not(#emby-favorites-panel .movie-list):not(#emby-top250-panel .movie-list)');
if (!listEl) return;
const reqUrl = location.pathname + location.search;
const reqEpoch = ++scrollEpoch;
isLoading = true;
setState('loading', '正在加载下一页…');
try {
const html = await gmHttp.get(nextUrl);
if (reqEpoch !== scrollEpoch || (location.pathname + location.search) !== reqUrl || isDetailPage() || !document.body.contains(listEl)) return;
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const newItems = doc.querySelectorAll('.movie-list > .item');
if (!newItems || !newItems.length) {
hasMore = false;
setState('no-more', '已经到底了');
return;
}
// 检查重复车牌,防 JAVDB 翻页环回
const existCodes = new Set();
listEl.querySelectorAll('.video-title strong, strong').forEach(function (st) {
existCodes.add((st.textContent || '').trim());
});
let dupCount = 0;
newItems.forEach(function (it) {
const st = it.querySelector('.video-title strong, strong');
if (st && existCodes.has((st.textContent || '').trim())) dupCount++;
});
if (dupCount > 0 && dupCount === newItems.length) {
hasMore = false;
setState('no-more', '已经到底了 (受网站页码限制)');
return;
}
// 追加卡片到当前网格
const frag = document.createDocumentFragment();
newItems.forEach(function (it) {
frag.appendChild(document.importNode(it, true));
});
listEl.appendChild(frag);
// 更新下一页链接与原版分页条
const newNext = getNextUrlFromDoc(doc);
nextUrl = newNext;
hasMore = !!newNext;
const oldPag = document.querySelector('.pagination');
const newPag = doc.querySelector('.pagination');
if (oldPag && newPag) {
oldPag.replaceWith(document.importNode(newPag, true));
}
setState(hasMore ? 'ready' : 'no-more', hasMore ? '' : '已经到底了');
// 触发 Emby 皮肤对新卡片的 3D/高光/瀑布流/预览初始化
if (typeof restructureGrid === 'function') restructureGrid();
if (typeof WaterfallEngine !== 'undefined' && document.documentElement.classList.contains('emby-masonry-large')) {
try { WaterfallEngine.refresh(); } catch (e) {}
}
} catch (err) {
log('无限加载失败: ' + (err.message || err));
setState('error', '加载失败,点击重试');
} finally {
isLoading = false;
}
}
function onScroll() {
if (!isPluginEnabled('infiniteScroll')) return;
if (isLoading || !hasMore || !nextUrl) return;
if (document.documentElement.classList.contains('emby-fav-view')) return; // 收藏夹走自管虚拟滚动
const listEl = document.querySelector('.movie-list:not(#emby-favorites-panel .movie-list)');
if (!listEl) return;
const rect = listEl.getBoundingClientRect();
// 距离底部 800px 时主动预拉取下一页
if (rect.bottom < window.innerHeight + 800) {
loadNext();
}
}
function init() {
scrollEpoch++;
isLoading = false;
if (!isPluginEnabled('infiniteScroll')) return;
if (document.documentElement.classList.contains('emby-fav-view')) return;
const listEl = document.querySelector('.movie-list:not(#emby-favorites-panel .movie-list)');
if (!listEl) return;
nextUrl = getNextUrlFromDoc(document);
hasMore = !!nextUrl;
const loader = createLoader();
if (listEl.parentNode && loader.parentNode !== listEl.parentNode) {
listEl.parentNode.insertBefore(loader, listEl.nextSibling);
}
setState(hasMore ? 'ready' : 'no-more', hasMore ? '' : '已经到底了');
if (!scrollBound) {
scrollBound = true;
let ticking = false;
window.addEventListener('scroll', function () {
if (!ticking) {
ticking = true;
requestAnimationFrame(function () {
onScroll();
ticking = false;
});
}
}, { passive: true });
}
}
return { init: init, loadNext: loadNext };
})();
/* =======================================================================
* 绕过JAVDB限制:评论与相关清单无限加载 (ReviewListInfiniteManager)
* ===================================================================== */
const ReviewListInfiniteManager = (function () {
const API_BASE = 'https://jdforrepam.com/api';
const SALT = '71cf27bb3c0bcdf207b64abecddc970098c7421ee7203b9cdae54478478a199e7d5a6e1a57691123c1a931c057842fb73ba3b3c83bcd69c17ccf174081e3d8aa';
const PAGE_SIZE = 20;
// 内嵌纯 JS MD5 算法(零外部 CDN 依赖)
function coreMd5(string) {
function rotateLeft(lValue, iShiftBits) {
return (lValue << iShiftBits) | (lValue >>> (32 - iShiftBits));
}
function addUnsigned(lX, lY) {
var lX4, lY4, lX8, lY8, lResult;
lX8 = lX & 0x80000000; lY8 = lY & 0x80000000;
lX4 = lX & 0x40000000; lY4 = lY & 0x40000000;
lResult = (lX & 0x3fffffff) + (lY & 0x3fffffff);
if (lX4 & lY4) return lResult ^ 0x80000000 ^ lX8 ^ lY8;
if (lX4 | lY4) {
if (lResult & 0x40000000) return lResult ^ 0xc0000000 ^ lX8 ^ lY8;
return lResult ^ 0x40000000 ^ lX8 ^ lY8;
}
return lResult ^ lX8 ^ lY8;
}
function F(x, y, z) { return (x & y) | (~x & z); }
function G(x, y, z) { return (x & z) | (y & ~z); }
function H(x, y, z) { return x ^ y ^ z; }
function I(x, y, z) { return y ^ (x | ~z); }
function FF(a, b, c, d, x, s, ac) {
a = addUnsigned(a, addUnsigned(addUnsigned(F(b, c, d), x), ac));
return addUnsigned(rotateLeft(a, s), b);
}
function GG(a, b, c, d, x, s, ac) {
a = addUnsigned(a, addUnsigned(addUnsigned(G(b, c, d), x), ac));
return addUnsigned(rotateLeft(a, s), b);
}
function HH(a, b, c, d, x, s, ac) {
a = addUnsigned(a, addUnsigned(addUnsigned(H(b, c, d), x), ac));
return addUnsigned(rotateLeft(a, s), b);
}
function II(a, b, c, d, x, s, ac) {
a = addUnsigned(a, addUnsigned(addUnsigned(I(b, c, d), x), ac));
return addUnsigned(rotateLeft(a, s), b);
}
function convertToWordArray(str) {
var lWordCount;
var lMessageLength = str.length;
var lNumberOfWordsTempOne = lMessageLength + 8;
var lNumberOfWordsTempTwo = (lNumberOfWordsTempOne - (lNumberOfWordsTempOne % 64)) / 64;
var lNumberOfWords = (lNumberOfWordsTempTwo + 1) * 16;
var lWordArray = Array(lNumberOfWords - 1);
var lBytePosition = 0;
var lByteCount = 0;
while (lByteCount < lMessageLength) {
lWordCount = (lByteCount - (lByteCount % 4)) / 4;
lBytePosition = (lByteCount % 4) * 8;
lWordArray[lWordCount] = (lWordArray[lWordCount] | (str.charCodeAt(lByteCount) << lBytePosition));
lByteCount++;
}
lWordCount = (lByteCount - (lByteCount % 4)) / 4;
lBytePosition = (lByteCount % 4) * 8;
lWordArray[lWordCount] = lWordArray[lWordCount] | (0x80 << lBytePosition);
lWordArray[lNumberOfWords - 2] = lMessageLength << 3;
lWordArray[lNumberOfWords - 1] = lMessageLength >>> 29;
return lWordArray;
}
function wordToHex(lValue) {
var WordToHexValue = '', WordToHexValueTemp = '', lByte, lCount;
for (lCount = 0; lCount <= 3; lCount++) {
lByte = (lValue >>> (lCount * 8)) & 255;
WordToHexValueTemp = '0' + lByte.toString(16);
WordToHexValue = WordToHexValue + WordToHexValueTemp.substr(WordToHexValueTemp.length - 2, 2);
}
return WordToHexValue;
}
function utf8Encode(str) {
str = str.replace(/\r\n/g, '\n');
var utftext = '';
for (var n = 0; n < str.length; n++) {
var c = str.charCodeAt(n);
if (c < 128) {
utftext += String.fromCharCode(c);
} else if (c > 127 && c < 2048) {
utftext += String.fromCharCode((c >> 6) | 192);
utftext += String.fromCharCode((c & 63) | 128);
} else {
utftext += String.fromCharCode((c >> 12) | 224);
utftext += String.fromCharCode(((c >> 6) & 63) | 128);
utftext += String.fromCharCode((c & 63) | 128);
}
}
return utftext;
}
var x = Array();
var k, AA, BB, CC, DD, a, b, c, d;
var S11 = 7, S12 = 12, S13 = 17, S14 = 22;
var S21 = 5, S22 = 9, S23 = 14, S24 = 20;
var S31 = 4, S32 = 11, S33 = 16, S34 = 23;
var S41 = 6, S42 = 10, S43 = 15, S44 = 21;
string = utf8Encode(string);
x = convertToWordArray(string);
a = 0x67452301; b = 0xefcdab89; c = 0x98badcfe; d = 0x10325476;
for (k = 0; k < x.length; k += 16) {
AA = a; BB = b; CC = c; DD = d;
a = FF(a, b, c, d, x[k + 0], S11, 0xd76aa478);
d = FF(d, a, b, c, x[k + 1], S12, 0xe8c7b756);
c = FF(c, d, a, b, x[k + 2], S13, 0x242070db);
b = FF(b, c, d, a, x[k + 3], S14, 0xc1bdceee);
a = FF(a, b, c, d, x[k + 4], S11, 0xf57c0faf);
d = FF(d, a, b, c, x[k + 5], S12, 0x4787c62a);
c = FF(c, d, a, b, x[k + 6], S13, 0xa8304613);
b = FF(b, c, d, a, x[k + 7], S14, 0xfd469501);
a = FF(a, b, c, d, x[k + 8], S11, 0x698098d8);
d = FF(d, a, b, c, x[k + 9], S12, 0x8b44f7af);
c = FF(c, d, a, b, x[k + 10], S13, 0xffff5bb1);
b = FF(b, c, d, a, x[k + 11], S14, 0x895cd7be);
a = FF(a, b, c, d, x[k + 12], S11, 0x6b901122);
d = FF(d, a, b, c, x[k + 13], S12, 0xfd987193);
c = FF(c, d, a, b, x[k + 14], S13, 0xa679438e);
b = FF(b, c, d, a, x[k + 15], S14, 0x49b40821);
a = GG(a, b, c, d, x[k + 1], S21, 0xf61e2562);
d = GG(d, a, b, c, x[k + 6], S22, 0xc040b340);
c = GG(c, d, a, b, x[k + 11], S23, 0x265e5a51);
b = GG(b, c, d, a, x[k + 0], S24, 0xe9b6c7aa);
a = GG(a, b, c, d, x[k + 5], S21, 0xd62f105d);
d = GG(d, a, b, c, x[k + 10], S22, 0x2441453);
c = GG(c, d, a, b, x[k + 15], S23, 0xd8a1e681);
b = GG(b, c, d, a, x[k + 4], S24, 0xe7d3fbc8);
a = GG(a, b, c, d, x[k + 9], S21, 0x21e1cde6);
d = GG(d, a, b, c, x[k + 14], S22, 0xc33707d6);
c = GG(c, d, a, b, x[k + 3], S23, 0xf4d50d87);
b = GG(b, c, d, a, x[k + 8], S24, 0x455a14ed);
a = GG(a, b, c, d, x[k + 13], S21, 0xa9e3e905);
d = GG(d, a, b, c, x[k + 2], S22, 0xfcefa3f8);
c = GG(c, d, a, b, x[k + 7], S23, 0x676f02d9);
b = GG(b, c, d, a, x[k + 12], S24, 0x8d2a4c8a);
a = HH(a, b, c, d, x[k + 5], S31, 0xfffa3942);
d = HH(d, a, b, c, x[k + 8], S32, 0x8771f681);
c = HH(c, d, a, b, x[k + 11], S33, 0x6d9d6122);
b = HH(b, c, d, a, x[k + 14], S34, 0xfde5380c);
a = HH(a, b, c, d, x[k + 1], S31, 0xa4beea44);
d = HH(d, a, b, c, x[k + 4], S32, 0x4bdecfa9);
c = HH(c, d, a, b, x[k + 7], S33, 0xf6bb4b60);
b = HH(b, c, d, a, x[k + 10], S34, 0xbebfbc70);
a = HH(a, b, c, d, x[k + 13], S31, 0x289b7ec6);
d = HH(d, a, b, c, x[k + 0], S32, 0xeaa127fa);
c = HH(c, d, a, b, x[k + 3], S33, 0xd4ef3085);
b = HH(b, c, d, a, x[k + 6], S34, 0x4881d05);
a = HH(a, b, c, d, x[k + 9], S31, 0xd9d4d039);
d = HH(d, a, b, c, x[k + 12], S32, 0xe6db99e5);
c = HH(c, d, a, b, x[k + 15], S33, 0x1fa27cf8);
b = HH(b, c, d, a, x[k + 2], S34, 0xc4ac5665);
a = II(a, b, c, d, x[k + 0], S41, 0xf4292244);
d = II(d, a, b, c, x[k + 7], S42, 0x432aff97);
c = II(c, d, a, b, x[k + 14], S43, 0xab9423a7);
b = II(b, c, d, a, x[k + 5], S44, 0xfc93a039);
a = II(a, b, c, d, x[k + 12], S41, 0x655b59c3);
d = II(d, a, b, c, x[k + 3], S42, 0x8f0ccc92);
c = II(c, d, a, b, x[k + 10], S43, 0xffeff47d);
b = II(b, c, d, a, x[k + 1], S44, 0x85845dd1);
a = II(a, b, c, d, x[k + 8], S41, 0x6fa87e4f);
d = II(d, a, b, c, x[k + 15], S42, 0xfe2ce6e0);
c = II(c, d, a, b, x[k + 6], S43, 0xa3014314);
b = II(b, c, d, a, x[k + 13], S44, 0x4e0811a1);
a = II(a, b, c, d, x[k + 4], S41, 0xf7537e82);
d = II(d, a, b, c, x[k + 11], S42, 0xbd3af235);
c = II(c, d, a, b, x[k + 2], S43, 0x2ad7d2bb);
b = II(b, c, d, a, x[k + 9], S44, 0xeb86d391);
a = addUnsigned(a, AA);
b = addUnsigned(b, BB);
c = addUnsigned(c, CC);
d = addUnsigned(d, DD);
}
var temp = wordToHex(a) + wordToHex(b) + wordToHex(c) + wordToHex(d);
return temp.toLowerCase();
}
function getSignature() {
const curr = Math.floor(Date.now() / 1000);
const stored = localStorage.getItem('jb_jdsignature');
if (stored) {
const parts = stored.split('.');
if (parts.length === 3 && (curr - parseInt(parts[0], 10)) <= 300) return stored;
}
const hash = coreMd5(curr + SALT);
const sign = curr + '.lpw6vgqzsp.' + hash;
try { localStorage.setItem('jb_jdsignature', sign); } catch (e) {}
return sign;
}
function fetchData(url) {
return new Promise(function (resolve, reject) {
const headers = {
'jdSignature': getSignature(),
'user-agent': 'Dart/3.5 (dart:io)',
'accept-language': 'zh-TW'
};
if (typeof GM_xmlhttpRequest !== 'undefined') {
GM_xmlhttpRequest({
method: 'GET',
url: url,
headers: headers,
timeout: 15000,
onload: function (resp) {
if (resp.status >= 200 && resp.status < 300) {
try { resolve(JSON.parse(resp.responseText)); }
catch (e) { reject(new Error('JSON 解析失败')); }
} else {
reject(new Error('API 错误: ' + resp.status));
}
},
onerror: function () { reject(new Error('网络请求失败')); },
ontimeout: function () { reject(new Error('请求超时')); }
});
} else {
fetch(url, { headers: headers })
.then(function (r) {
if (!r.ok) throw new Error('API 错误: ' + r.status);
return r.json();
})
.then(resolve)
.catch(reject);
}
});
}
function getDetailSlug() {
const m = window.location.pathname.match(/\/v\/([a-zA-Z0-9]+)/);
return m ? m[1] : null;
}
function ensureReviewInfiniteStyle() {
if (document.getElementById('javdb-review-infinite-style')) return;
const s = document.createElement('style');
s.id = 'javdb-review-infinite-style';
s.textContent = `
.review-item.more, .review-item.list-more {
cursor: pointer; text-align: center; font-weight: bold;
padding: 12px; border-radius: 6px; margin: 16px 0;
transition: all .2s ease; display: block; background: #f5f5f5; color: #888;
}
.review-item.more:hover, .review-item.list-more:hover { background: #e8e8e8; }
`;
document.head.appendChild(s);
}
function renderReviews(reviews, moreNode) {
let html = '';
reviews.forEach(function (r) {
const id = escapeAttr(String(r.id || Math.random().toString(36).substr(2, 9)));
const userName = escapeHtml(r.user_name || (r.user && (r.user.name || r.user.username)) || '用户');
let time = '';
try {
if (r.created_at) {
const d = new Date(typeof r.created_at === 'number' ? r.created_at * 1000 : r.created_at);
if (!isNaN(d.getTime())) time = d.toISOString().slice(0, 10);
}
} catch (e) {}
let stars = '';
if (r.score != null) {
const score = Math.min(5, Math.max(0, Math.round(parseFloat(r.score) || 0)));
stars = '<i class="icon-star" style="color:#ffb000;font-size:14.4px;margin-right:2px;"></i>'.repeat(score) +
'<i class="icon-star gray" style="color:#999;font-size:14.4px;margin-right:2px;"></i>'.repeat(5 - score);
}
html += '<dt class="review-item" id="review-item-' + id + '" style="width:100%;line-height:24px;margin:0;padding:15px 0;display:block;">' +
'<div class="review-title" style="font-size:14.4px;line-height:21.6px;display:flex;align-items:center;margin-bottom:8px;">' +
'<span style="font-weight:600;">' + userName + '</span>' +
(stars ? '<span style="margin:0 8px;display:inline-flex;align-items:center;">' + stars + '</span>' : '') +
'<span style="opacity:.6;font-size:13px;margin-left:auto;">' + time + '</span>' +
'</div>' +
'<div class="content" style="width:100%;line-height:24px;">' +
'<p style="margin:0;white-space:pre-wrap;word-break:break-word;">' + escapeHtml(r.content || '') + '</p>' +
'</div>' +
'</dt>';
});
moreNode.insertAdjacentHTML('beforebegin', html);
}
function setupReviewUnlocker(node) {
const slug = getDetailSlug();
if (!slug) return;
node.innerHTML = '▼ 加载完整评论';
node.className = 'review-item more';
node.style.cursor = 'pointer';
node.style.display = 'block';
// 绑定当前车牌标识,防 SPA 切换状态错乱
node.dataset.currentSlug = slug;
node.dataset.page = '1';
node.dataset.hasSkipped = 'false';
node.onclick = async function (e) {
e.preventDefault();
e.stopPropagation();
if (node.dataset.loading === 'true') return;
node.dataset.loading = 'true';
let currentPage = parseInt(node.dataset.page, 10) || 1;
let hasSkippedNative = node.dataset.hasSkipped === 'true';
node.innerHTML = '正在获取第 ' + currentPage + ' 页...';
node.style.opacity = '.7';
try {
const url = API_BASE + '/v1/movies/' + slug + '/reviews?page=' + currentPage + '&sort_by=hotly&limit=' + PAGE_SIZE;
const res = await fetchData(url);
if (res.success === 1 && res.data && res.data.reviews) {
let reviews = res.data.reviews;
if (!hasSkippedNative) {
const nativeCount = node.parentElement.querySelectorAll('dt.review-item:not(.more)').length;
if (nativeCount > 0) reviews = reviews.slice(nativeCount);
node.dataset.hasSkipped = 'true';
}
if (reviews.length > 0) renderReviews(reviews, node);
currentPage++;
node.dataset.page = String(currentPage);
if (res.data.reviews.length < PAGE_SIZE) {
node.innerHTML = '√ 已加载所有评论';
node.style.color = '#4CAF50';
node.style.cursor = 'default';
node.style.opacity = '1';
node.onclick = null;
} else {
node.innerHTML = '▶ 继续加载 (第 ' + currentPage + ' 页)';
node.style.color = '#f5c518';
node.style.opacity = '1';
}
} else {
throw new Error((res && res.message) || '无数据');
}
} catch (err) {
node.innerHTML = '╳ 加载失败: ' + escapeHtml(err.message || '未知错误') + ' (点击重试)';
node.style.color = '#ff4444';
node.style.opacity = '1';
} finally {
node.dataset.loading = 'false';
}
};
}
function checkReviewButton() {
if (isJavdbVipUser()) return; // VIP 账号拥有官方全量权限,无需使用脚本绕过限制
if (!isPluginEnabled('reviewListInfinite')) return;
if (!isDetailPage()) return;
const slug = getDetailSlug();
let targetNode = document.querySelector('.review-item.more');
let parentList = document.querySelector('dl.review-items') || document.querySelector('dl.movie-review-list') || document.querySelector('.review-list');
if (!parentList) {
const reviewTabPanel = document.querySelector('[data-movie-tab-target="reviews"]');
if (reviewTabPanel) {
parentList = document.createElement('dl');
parentList.className = 'review-items movie-review-list';
reviewTabPanel.appendChild(parentList);
}
}
if (!targetNode && parentList) {
targetNode = document.createElement('dt');
targetNode.className = 'review-item more';
parentList.appendChild(targetNode);
}
if (targetNode && (!targetNode.dataset.hacked || targetNode.dataset.currentSlug !== slug)) {
targetNode.dataset.hacked = 'true';
setupReviewUnlocker(targetNode);
}
}
function renderLists(lists, moreNode) {
let html = '';
lists.forEach(function (item) {
const id = escapeAttr(String(item.id || Math.random().toString(36).substr(2, 9)));
const name = escapeHtml(item.name || '未命名清单');
const url = '/lists/' + encodeURIComponent(String(item.id || ''));
const stats = '视频: ' + (item.movies_count || 0) + ' | 收藏: ' + (item.collections_count || 0) + ' | 查看: ' + (item.views_count || 0);
const time = item.created_at ? new Date(typeof item.created_at === 'number' ? item.created_at * 1000 : item.created_at).toISOString().slice(0, 10) : '';
html += '<dt class="review-item list-item" id="list-item-' + id + '" style="width:100%;line-height:24px;margin:0;padding:15px 0;display:block;">' +
'<div class="review-title" style="font-size:14.4px;line-height:21.6px;display:flex;align-items:center;margin-bottom:8px;justify-content:space-between;">' +
'<a href="' + escapeAttr(url) + '" target="_self" style="font-weight:bold;text-decoration:none;">' + name + '</a>' +
'<span style="opacity:.6;font-size:12px;">' + time + '</span>' +
'</div>' +
'<div class="content" style="width:100%;line-height:24px;font-size:13px;">' +
'<p style="margin:0;opacity:.8;">' + escapeHtml(stats) + '</p>' +
'</div>' +
'</dt>';
});
moreNode.insertAdjacentHTML('beforebegin', html);
}
function loadRelatedLists(container, slug) {
container.innerHTML = '';
const listWrapper = document.createElement('dl');
listWrapper.className = 'review-items movie-review-list';
listWrapper.style.cssText = 'margin: 0; padding: 15px;';
container.appendChild(listWrapper);
const loadMoreNode = document.createElement('dt');
loadMoreNode.className = 'review-item more list-more';
loadMoreNode.style.cursor = 'pointer';
loadMoreNode.style.display = 'block';
loadMoreNode.style.listStyle = 'none';
loadMoreNode.innerHTML = '▼ 加载相关清单';
listWrapper.appendChild(loadMoreNode);
loadMoreNode.dataset.page = '1';
const triggerFetch = async function () {
if (loadMoreNode.dataset.loading === 'true') return;
loadMoreNode.dataset.loading = 'true';
let listCurrentPage = parseInt(loadMoreNode.dataset.page, 10) || 1;
loadMoreNode.innerHTML = '正在获取第 ' + listCurrentPage + ' 页...';
loadMoreNode.style.opacity = '.7';
try {
const url = API_BASE + '/v1/lists/related?movie_id=' + slug + '&page=' + listCurrentPage + '&limit=' + PAGE_SIZE;
const res = await fetchData(url);
let lists = (res.data && res.data.lists) ? res.data.lists : [];
if (lists.length > 0) {
renderLists(lists, loadMoreNode);
} else if (listCurrentPage === 1) {
loadMoreNode.innerHTML = '√ 暂无相关清单';
loadMoreNode.style.color = '#4CAF50';
loadMoreNode.style.cursor = 'default';
loadMoreNode.style.opacity = '1';
loadMoreNode.onclick = null;
return;
}
listCurrentPage++;
loadMoreNode.dataset.page = String(listCurrentPage);
if (lists.length < PAGE_SIZE) {
loadMoreNode.innerHTML = '√ 已加载所有清单';
loadMoreNode.style.color = '#4CAF50';
loadMoreNode.style.cursor = 'default';
loadMoreNode.style.opacity = '1';
loadMoreNode.onclick = null;
} else {
loadMoreNode.innerHTML = '▶ 继续加载 (第 ' + listCurrentPage + ' 页)';
loadMoreNode.style.color = '#f5c518';
loadMoreNode.style.opacity = '1';
}
} catch (err) {
loadMoreNode.innerHTML = '╳ 加载失败: ' + escapeHtml(err.message || '未知错误') + ' (点击重试)';
loadMoreNode.style.color = '#ff4444';
loadMoreNode.style.opacity = '1';
} finally {
loadMoreNode.dataset.loading = 'false';
}
};
loadMoreNode.onclick = function (e) {
e.preventDefault();
e.stopPropagation();
triggerFetch();
};
triggerFetch();
}
function interceptListTab() {
if (isJavdbVipUser()) return; // VIP 账号官方已支持相关清单展开,无需劫持
if (!isPluginEnabled('reviewListInfinite')) return;
if (!isDetailPage()) return;
const listTabLink = document.querySelector('a.list-tab[data-action*="relatedLists"], a.list-tab');
if (!listTabLink || listTabLink.dataset.myIntercepted === 'true') return;
listTabLink.dataset.myIntercepted = 'true';
listTabLink.addEventListener('click', function (e) {
if (!isPluginEnabled('reviewListInfinite')) return;
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
const tabList = listTabLink.closest('ul');
if (tabList) {
tabList.querySelectorAll('li').forEach(function (li) { li.classList.remove('is-active'); });
const parentLi = listTabLink.closest('li');
if (parentLi) parentLi.classList.add('is-active');
const otherTabs = tabList.querySelectorAll('a:not(.list-tab)');
otherTabs.forEach(function (tab) {
if (tab.dataset.restoreBound) return;
tab.dataset.restoreBound = 'true';
tab.addEventListener('click', function () {
const c = document.querySelector('#tabs-container');
if (c) {
const lp = c.querySelector('[data-movie-tab-target="lists"]');
if (lp) lp.style.display = 'none';
const action = tab.dataset.action || '';
const target = action.includes('#') ? action.split('#')[1] : '';
if (target) {
const tp = c.querySelector('[data-movie-tab-target="' + target + '"]');
if (tp) tp.style.display = 'block';
}
}
});
});
}
const container = document.querySelector('#tabs-container');
if (container) {
const panels = container.querySelectorAll('[data-movie-tab-target]');
panels.forEach(function (panel) {
if (panel.dataset.movieTabTarget !== 'lists') {
panel.style.display = 'none';
}
});
const listPanel = container.querySelector('[data-movie-tab-target="lists"]');
if (listPanel) {
listPanel.style.display = 'block';
const slug = getDetailSlug();
if (slug && (!listPanel.dataset.myLoaded || listPanel.dataset.myLoadedSlug !== slug)) {
listPanel.dataset.myLoaded = 'true';
listPanel.dataset.myLoadedSlug = slug;
loadRelatedLists(listPanel, slug);
}
}
}
}, true);
}
function init() {
if (isJavdbVipUser()) return; // VIP 账号拥有官方全量权限,无需使用脚本绕过限制
if (!isPluginEnabled('reviewListInfinite')) return;
if (!isDetailPage()) return;
ensureReviewInfiniteStyle();
checkReviewButton();
interceptListTab();
}
return {
init: init,
checkReviewButton: checkReviewButton,
interceptListTab: interceptListTab,
getSignature: getSignature,
fetchData: fetchData
};
})();
/* =======================================================================
* 通用安全 HTTP 请求客户端 (支持 GM_xmlhttpRequest 跨域无阻,自动回退 fetch)
* ===================================================================== */
const gmHttp = {
request: function (options) {
return new Promise(function (resolve, reject) {
const method = (options.method || 'GET').toUpperCase();
const url = options.url;
const headers = options.headers || {};
const data = options.data || options.body;
const timeout = options.timeout || 15000;
if (typeof GM_xmlhttpRequest !== 'undefined') {
try {
GM_xmlhttpRequest({
method: method,
url: url,
headers: headers,
data: data,
timeout: timeout,
onload: function (res) {
if (res.status >= 200 && res.status < 300) {
resolve(res.responseText);
} else {
reject(new Error('HTTP ' + res.status + ': ' + (res.statusText || 'Request failed')));
}
},
onerror: function (err) { reject(err || new Error('Network error')); },
ontimeout: function () { reject(new Error('Request timeout')); }
});
return;
} catch (e) {}
}
const fetchOpts = { method: method, headers: headers };
if (data && method !== 'GET' && method !== 'HEAD') fetchOpts.body = data;
fetch(url, fetchOpts).then(function (res) {
if (!res.ok) throw new Error('HTTP ' + res.status + ': ' + res.statusText);
return res.text();
}).then(resolve).catch(reject);
});
},
get: function (url, headers) { return this.request({ method: 'GET', url: url, headers: headers }); },
post: function (url, data, headers) { return this.request({ method: 'POST', url: url, data: data, headers: headers }); }
};
/* =======================================================================
* TOP250 与热播榜单管理器 (Top250ViewManager)
* 归属于「绕过JAVDB限制」插件,免 VIP 畅享官方 TOP250 与热播榜单
* ===================================================================== */
const Top250ViewManager = (function () {
const API_BASE = 'https://jdforrepam.com/api';
const APP_AUTH_KEY = 'jhs_appAuthorization';
const DB_NAME = 'javdb_top250_cache';
const DB_VERSION = 1;
const STORE_NAME = 'cache_items';
const state = {
mode: 'playback', // 默认免登录热播榜秒开呈现
// TOP250 参数
handleType: 'all', // 'all' | 'video_type' | 'year'
typeValue: '', // '' | '0' | '1' | '2' | '3' | '2025'
hasCnsub: '', // '' | '1' | '0'
page: 1, // 1..5
// Playback 参数
period: 'daily', // 'daily' | 'weekly' | 'monthly'
filterBy: 'high_score'
};
let cachedMovies = [];
let isFetching = false;
let top250LoadSeq = 0;
let top250FilterState = {
keyword: '',
scoreMax: 5.0,
awards: [],
actors: [],
makers: [],
series: [],
directors: [],
tags: [],
years: [],
coactMode: false,
showActorAvatars: false,
actorAvatarSize: 'small',
hideMaleActors: true
};
function applyTop250RandomBg(panel) {
if (!panel) return;
const on = localStorage.getItem(SET_KEYS.randomBg);
let bgEl = panel.querySelector('.efav-bg');
if (!bgEl) {
bgEl = document.createElement('div');
bgEl.className = 'efav-bg';
panel.insertBefore(bgEl, panel.firstChild);
}
if (on === '0') {
bgEl.style.backgroundImage = 'none';
return;
}
const list = cachedMovies || [];
if (list.length) {
const m = list[Math.floor(Math.random() * list.length)];
const src = updateImgServer(m.coverLarge || m.coverThumb || m.cover_url || m.thumb_url || '');
if (src) bgEl.style.backgroundImage = 'url(' + src + ')';
}
}
let isSyncingAll = false;
let currentContainer = null;
const memCache = new Map();
// IndexedDB 存储支持
function openDB() {
return new Promise(function (resolve, reject) {
if (typeof indexedDB === 'undefined') return reject(new Error('IndexedDB unsupported'));
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = function (e) {
const db = e.target.result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, { keyPath: 'key' });
}
};
req.onsuccess = function (e) { resolve(e.target.result); };
req.onerror = function (e) { reject(e.target.error); };
});
}
async function getCached(key, ttlMs) {
if (memCache.has(key)) {
const item = memCache.get(key);
if (Date.now() - item.time < ttlMs) return item.data;
}
try {
const db = await openDB();
return new Promise(function (resolve) {
const tx = db.transaction(STORE_NAME, 'readonly');
const store = tx.objectStore(STORE_NAME);
const req = store.get(key);
req.onsuccess = function () {
const res = req.result;
if (res && res.data && (Date.now() - res.time < ttlMs)) {
memCache.set(key, { time: res.time, data: res.data });
resolve(res.data);
} else {
resolve(null);
}
};
req.onerror = function () { resolve(null); };
});
} catch (e) {
return null;
}
}
async function setCached(key, data) {
const now = Date.now();
memCache.set(key, { time: now, data: data });
try {
const db = await openDB();
return new Promise(function (resolve) {
const tx = db.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
store.put({ key: key, data: data, time: now });
tx.oncomplete = function () { resolve(true); };
tx.onerror = function () { resolve(false); };
});
} catch (e) {
return false;
}
}
function updateImgServer(str) {
if (!str) return '';
let s = String(str).trim();
// 1. 核心修复:移动端 API 缩略图路径 small_covers 在 c0.jdbstatic.com 会触发 403 Forbidden,必须标准化映射为 thumbs
s = s.replace(/\/small_covers\//g, '/thumbs/');
// 2. 核心修复:历史图床代号 rhe951l4q 替换为可用图床域名
s = s.replace(/https?:\/\/.*?\/rhe951l4q/g, 'https://c0.jdbstatic.com');
// 3. 修复误带 javdb 网站域名的相对图片路径
s = s.replace(/^https?:\/\/(www\.)?javdb\d*\.(com|me|life|party|today|vip)\/(covers|thumbs)/i, 'https://c0.jdbstatic.com/$3');
// 4. 相对路径(如 /covers/... 或 /thumbs/...)补全图床域名
if (s.startsWith('/')) {
if (typeof FAV !== 'undefined' && typeof FAV.resolveImg === 'function') {
return FAV.resolveImg(s);
}
return 'https://c0.jdbstatic.com' + s;
}
return s;
}
function getAuthToken() {
return localStorage.getItem(APP_AUTH_KEY) || '';
}
function getSignature() {
return (typeof ReviewListInfiniteManager !== 'undefined' && ReviewListInfiniteManager.getSignature)
? ReviewListInfiniteManager.getSignature() : '';
}
// 1. 获取热播榜单(纯免登录,仅需单一客户端签名)
async function fetchPlayback(period, bypassCache) {
const cacheKey = 'playback_' + period;
if (!bypassCache) {
const cached = await getCached(cacheKey, 3600000); // 1小时缓存
if (cached && cached.length) return cached;
}
const url = API_BASE + '/v1/rankings/playback?period=' + encodeURIComponent(period) + '&filter_by=high_score';
const sig = getSignature();
// 第一性原理修复:仅传单一 jdSignature,杜绝重复与伪造 UA 触发 HTTP 400
const headers = { 'jdSignature': sig };
const text = await gmHttp.get(url, headers);
const res = JSON.parse(text);
if (res.data && Array.isArray(res.data.movies)) {
await setCached(cacheKey, res.data.movies);
return res.data.movies;
}
if (Array.isArray(res)) {
await setCached(cacheKey, res);
return res;
}
throw new Error(res.message || '获取热播榜失败');
}
// 2. 获取 TOP250 榜单(需普通账号 Token 鉴权)
async function fetchTop(handleType, typeValue, page, bypassCache) {
const cacheKey = 'top_' + handleType + '_' + typeValue + '_p' + page;
if (!bypassCache) {
const cached = await getCached(cacheKey, 86400000); // 24小时持久化缓存
if (cached && cached.length) return { success: 1, data: { movies: cached }, fromCache: true };
}
const url = API_BASE + '/v1/movies/top?start_rank=1&type=' + encodeURIComponent(handleType) +
'&type_value=' + encodeURIComponent(typeValue) +
'&ignore_watched=false&page=' + page + '&limit=50';
const token = getAuthToken();
const sig = getSignature();
const headers = { 'jdSignature': sig };
if (token) headers['authorization'] = 'Bearer ' + token;
const text = await gmHttp.get(url, headers);
const res = JSON.parse(text);
if (res.success === 1 && res.data && Array.isArray(res.data.movies)) {
await setCached(cacheKey, res.data.movies);
}
return res;
}
// 异步评分加载与持久化系统(复用顶层共享 scoreMemCache)
let scoreQueueRunning = false;
const scoreQueue = [];
function queueScoreFetch(movieId) {
if (!movieId || scoreMemCache.has(movieId)) return;
if (!scoreQueue.includes(movieId)) scoreQueue.push(movieId);
processScoreQueue();
}
async function processScoreQueue() {
if (scoreQueueRunning) return;
scoreQueueRunning = true;
try {
// 串行单线程温和拉取评分,杜绝并发轰炸;与 TOP250 后台离线同步打断解耦
while (scoreQueue.length > 0) {
const id = scoreQueue.shift();
try {
const cached = await getCached('movie_score_' + id, 7 * 86400000);
if (cached && typeof cached.score !== 'undefined') {
scoreMemCache.set(id, cached);
applyScoreToDom(id, cached.score);
continue;
}
const url = API_BASE + '/v4/movies/' + encodeURIComponent(id);
// 第一性原理清洗:仅传单一合法签名与可选 Token,杜绝 host 与伪造 UA 触发 HTTP 400
const headers = { 'jdSignature': getSignature() };
const tok = getAuthToken();
if (tok) headers['authorization'] = 'Bearer ' + tok;
const text = await gmHttp.get(url, headers);
const res = JSON.parse(text);
if (res && res.data && res.data.movie) {
const score = res.data.movie.score != null ? parseFloat(res.data.movie.score) : 0;
const watched = res.data.movie.watched_count || 0;
const info = { score: score, watchedCount: watched };
scoreMemCache.set(id, info);
await setCached('movie_score_' + id, info);
applyScoreToDom(id, score);
}
} catch (e) {
const msg = (e && e.message) ? e.message : String(e);
// 遇到 429/403 触发风控熔断,清空队列并中止
if (/429|403|Challenge|Rate limit/i.test(msg)) {
log('processScoreQueue 遇限流风控熔断: ' + msg);
scoreQueue.length = 0;
break;
}
}
await new Promise(function (r) { setTimeout(r, 800 + Math.floor(Math.random() * 400)); });
}
} finally {
scoreQueueRunning = false;
}
}
function applyScoreToDom(id, score) {
const scoreVal = score > 0 ? score.toFixed(1) : '0.0';
const starsHtml = renderStars(scoreVal);
const el = document.getElementById('score_' + id);
if (el) {
el.innerHTML = '<span class="value"><span class="score-stars">' + starsHtml + '</span> ' + scoreVal + '分</span>';
}
const nEl = document.getElementById('n_score_' + id);
if (nEl) {
nEl.innerHTML = '<span class="value"><span class="score-stars">' + starsHtml + '</span> ' + scoreVal + '分</span>';
}
}
// 更新 TOP250 单个卡片为完整信息卡片(直接复用 FAVUI.buildCard,保证与收藏夹 100% 同步,零重复代码)
function updateTopCardInDom(m) {
const listEl = currentContainer ? currentContainer.querySelector('#t250-movie-list') : null;
if (!listEl) return;
const itemEl = listEl.querySelector('[data-movie-id="' + m.id + '"]') || listEl.querySelector('[data-code="' + (m.code || m.number) + '"]');
if (!itemEl) return;
if (typeof FAVUI !== 'undefined' && typeof FAVUI.buildCard === 'function') {
const newCard = FAVUI.buildCard(m);
// 根治瀑布流首部卡片闪烁:保留外层 .item 的几何定位与尺寸,仅平滑替换内部 .box 节点,
// 彻底杜绝无 transform 瞬态落于 (0, 0) 触发重绘闪现的问题
if (itemEl.style.width) newCard.style.width = itemEl.style.width;
if (itemEl.style.transform) newCard.style.transform = itemEl.style.transform;
const oldBox = itemEl.querySelector('.box');
const newBox = newCard.querySelector('.box');
if (oldBox && newBox) {
oldBox.replaceWith(newBox);
} else {
itemEl.replaceWith(newCard);
}
}
}
function normCode(c) {
return String(c || '').trim().toUpperCase().replace(/[\s\-_]+/g, '');
}
function getCurrentBoardAwardName(st) {
if (!st) return 'JavDB 影片TOP250';
if (st.handleType === 'year' && st.typeValue) {
return 'JavDB ' + st.typeValue + '年度TOP250';
}
if (st.handleType === 'video_type') {
if (st.typeValue === '1') return 'JavDB 無碼影片TOP250';
if (st.typeValue === '2') return 'JavDB 歐美影片TOP250';
if (st.typeValue === '3') return 'JavDB FC2影片TOP250';
return 'JavDB 有碼影片TOP250';
}
return 'JavDB 影片TOP250';
}
function mergeMovieAwards(existingAwards, curAward) {
const map = new Map();
function addAw(aw) {
if (!aw || !aw.name) return;
let k = String(aw.name).trim();
// 自动清洗历史残留的粗糙纯 "TOP250" 标,标准化归一到官方 "JavDB 影片TOP250"
if (/^top\s*250$/i.test(k)) k = 'JavDB 影片TOP250';
const r = (typeof aw.rank === 'number' && aw.rank > 0) ? aw.rank : null;
if (!map.has(k)) {
map.set(k, { name: k, rank: r });
} else if (r && (!map.get(k).rank || r < map.get(k).rank)) {
map.get(k).rank = r;
}
}
if (Array.isArray(existingAwards)) existingAwards.forEach(addAw);
if (curAward) addAw(curAward);
return Array.from(map.values());
}
// TOP250 独立缓存层:读取所有已缓存的详情记录 (t250_detail_*),实现与用户真实收藏夹严格物理隔离
async function getAllCachedDetails() {
try {
const db = await openDB();
return new Promise(function (resolve) {
const tx = db.transaction(STORE_NAME, 'readonly');
const store = tx.objectStore(STORE_NAME);
const req = store.getAll();
req.onsuccess = function () {
const res = req.result || [];
const detailMap = new Map();
const now = Date.now();
const TTL = 30 * 86400000; // 30天有效持久化
res.forEach(function (item) {
if (item && item.key && item.key.startsWith('t250_detail_') && item.data) {
if (now - item.time < TTL) {
const k = item.key.replace('t250_detail_', '');
detailMap.set(k, item.data);
detailMap.set(k.toUpperCase(), item.data);
const nc = normCode(k);
if (nc) detailMap.set(nc, item.data);
}
}
});
resolve(detailMap);
};
req.onerror = function () { resolve(new Map()); };
});
} catch (e) {
return new Map();
}
}
// 将单部作品详情存入 TOP250 独立缓存(多索引存储:番号大写、规范化番号、ID)
async function cacheMovieDetail(rec, id) {
if (!rec) return;
const code = (rec.code || rec.number || '').trim();
if (!code && !id) return;
const upperCode = code.toUpperCase();
const normC = normCode(code);
const promises = [];
if (upperCode) promises.push(setCached('t250_detail_' + upperCode, rec));
if (normC) promises.push(setCached('t250_detail_' + normC, rec));
if (id) {
promises.push(setCached('t250_detail_' + id, rec));
promises.push(setCached('t250_detail_' + String(id).toLowerCase(), rec));
}
await Promise.all(promises);
}
// 后台温和全量同步引擎:串行平滑补齐数据存入收藏夹数据库并广播
let topSyncRunning = false;
let topSyncAbort = false;
let topSyncGeneration = 0;
let topSyncCooldown = 0;
function abortSync() {
topSyncAbort = true;
topSyncGeneration++;
topSyncRunning = false;
}
async function syncTop250Details(pendingQueue, reusedCount, cachedMovies, existingMap) {
if (!pendingQueue || !pendingQueue.length) return;
if (topSyncRunning) {
abortSync();
await new Promise(function (r) { setTimeout(r, 50); });
}
if (Date.now() < topSyncCooldown) {
log('TOP250 后台同步处于限流冷却中,暂缓执行以保护 IP');
return;
}
topSyncRunning = true;
topSyncAbort = false;
const myGen = ++topSyncGeneration;
// 自适应安全并发引擎:
// 1. 读取用户全局并发配置 (默认 3,安全上限保护在 4,可在工具栏滑块按节点体质自由调节)
// 2. 采用微批平滑呼吸间隔 (350ms ~ 550ms),吞吐量维持在 4~5 请求/秒,既绝不触发 Cloudflare WAF 突发封锁,又大幅缩短耗时至 30~45 秒
// 3. 严格风控熔断机制:一旦遇到 HTTP 429、403 或 Cloudflare 挑战,所有并发 worker 瞬间集体刹车,严防封禁 IP!
// 4. 离开 TOP250 视图或切换年份即刻中止未跑完的后台拉取
const userConc = Math.max(1, Math.min(4, parseInt(localStorage.getItem('javdbEmbyConcurrency') || '3', 10) || 3));
let syncedCount = 0;
let circuitBroken = false;
let cursor = 0;
const totalPending = pendingQueue.length;
async function worker() {
while (cursor < totalPending) {
if (topSyncAbort || topSyncGeneration !== myGen || circuitBroken || (typeof state !== 'undefined' && state.mode !== 'top')) {
break;
}
const item = pendingQueue[cursor++];
if (!item) break;
try {
const url = API_BASE + '/v4/movies/' + encodeURIComponent(item.id);
// 第一性原理清洗:仅传单一合法签名与可选 Token,彻底杜绝违规 host 触发假风控
const headers = { 'jdSignature': getSignature() };
const tok = getAuthToken();
if (tok) headers['authorization'] = 'Bearer ' + tok;
const text = await gmHttp.get(url, headers);
const res = JSON.parse(text);
if (res && res.data && res.data.movie) {
const mov = res.data.movie;
const code = mov.number || item.number || '';
const upperCode = code.toUpperCase();
const normC = normCode(code);
const existingRec = existingMap.get(upperCode) || (normC ? existingMap.get(normC) : null) || {};
const score = (mov.score != null && mov.score !== '') ? parseFloat(mov.score) : 0;
const watched = mov.watched_count != null ? mov.watched_count : 0;
const sInfo = { score: score, watchedCount: watched };
scoreMemCache.set(item.id, sInfo);
const curAward = { name: getCurrentBoardAwardName(state), rank: item._topRank };
const mergedAwards = mergeMovieAwards(existingRec.awards || (item.itemRef && item.itemRef.awards), curAward);
const record = Object.assign({}, existingRec, {
code: code,
title: mov.origin_title || mov.title || item.title || existingRec.title || '',
date: mov.release_date || item.release_date || existingRec.date || '',
duration: mov.duration ? (mov.duration + ' 分鐘') : (existingRec.duration || ''),
coverThumb: updateImgServer(mov.thumb_url || item.thumb_url || existingRec.coverThumb || ''),
coverLarge: updateImgServer(mov.cover_url || item.cover_url || existingRec.coverLarge || ''),
detailHref: '/v/' + item.id,
rating: {
score: score,
count: watched,
text: (score > 0 ? score.toFixed(1) + '分' : '0.0分') + (watched ? ',由' + watched + '人評價' : '')
},
actors: (mov.actors || []).map(function (a) {
return {
name: a.name || '',
href: '/actors/' + (a.id || ''),
avatar: a.avatar_url || ''
};
}),
maker: mov.maker_name ? { text: mov.maker_name, href: '/makers/' + (mov.maker_id || '') } : (existingRec.maker || null),
series: mov.series_name ? { text: mov.series_name, href: '/series/' + (mov.series_id || '') } : (existingRec.series || null),
director: mov.director_name ? { text: mov.director_name, href: '/directors/' + (mov.director_id || '') } : (existingRec.director || null),
categories: (mov.tags || []).map(function (t) { return { text: t.name, href: '/tags/' + t.id }; }),
awards: mergedAwards,
has_cnsub: !!mov.has_cnsub,
magnets_count: mov.magnets_count || 0,
detailComplete: true,
detailError: null,
detailSyncedAt: Date.now(),
listIds: existingRec.listIds || []
});
// 显式确保用户资产字段 100% 继承,杜绝被覆盖或在孤儿清理中被误删
if (existingRec.customMeta) record.customMeta = existingRec.customMeta;
if (existingRec.reviewStatus) record.reviewStatus = existingRec.reviewStatus;
if (typeof existingRec.userScore === 'number' && existingRec.userScore > 0) record.userScore = existingRec.userScore;
if (existingRec.reviewId) record.reviewId = existingRec.reviewId;
if (existingRec.reviewSyncedAt) record.reviewSyncedAt = existingRec.reviewSyncedAt;
if (typeof existingRec.views !== 'undefined') record.views = existingRec.views;
if (existingRec.notes) record.notes = existingRec.notes;
if (existingRec.customCover) record.customCover = existingRec.customCover;
if (existingRec.addedAt) record.addedAt = existingRec.addedAt;
// 关键安全隔离:仅当该作品原本就属于用户的真实收藏(归属于某个清单或带有想看/看過标记)时,
// 才允许写回收藏夹数据库(同步金标/排名与补全信息)。
// 绝不可将未被用户收藏的 TOP250 榜单作品写入收藏夹 movies 数据库!
const isUserFav = existingRec && (
(Array.isArray(existingRec.listIds) && existingRec.listIds.length > 0) ||
!!existingRec.reviewStatus
);
if (isUserFav) {
try {
if (typeof FAV !== 'undefined' && typeof FAV.dbPutMovie === 'function') {
await FAV.dbPutMovie(record);
}
} catch (e) {}
}
// 核心持久化:无论是否属于用户收藏,一律写入 javdb_top250_cache 独立缓存库,确保跨页面、刷新、切换年份永不丢失!
await cacheMovieDetail(record, item.id);
existingMap.set(upperCode, record);
if (normC) existingMap.set(normC, record);
if (item.itemRef) {
Object.assign(item.itemRef, record);
item.itemRef._topRank = item._topRank;
item.itemRef.awards = mergedAwards;
updateTopCardInDom(item.itemRef);
}
syncedCount++;
// 每完成 5 部在错落瀑布流模式下适当微调布局
if (syncedCount % 5 === 0) {
const layout = localStorage.getItem(SET_KEYS.layout) || 'grid';
if (layout === 'masonry') {
const listEl = currentContainer ? currentContainer.querySelector('#t250-movie-list') : null;
if (listEl) {
try { WaterfallEngine.refresh(); } catch (e) {}
}
}
}
}
} catch (err) {
const errMsg = (err && err.message) ? err.message : String(err);
log('TOP250 单条详情同步异常(' + item.id + '): ' + errMsg);
// 触及风控(429 / 403 / Cloudflare Challenge)立即熔断退出!
if (/429|403|Challenge|Rate limit|Blocked|Cloudflare/i.test(errMsg)) {
circuitBroken = true;
topSyncAbort = true;
topSyncCooldown = Date.now() + 5 * 60 * 1000; // 冷却 5 分钟
showToast('⚠️ 检测到 JAVDB 访问频率限制 (HTTP 403/429),已紧急停止后台同步,以保护您的 IP!');
break;
}
}
// 平滑人机呼吸间隔:350ms ~ 550ms,既不触发 Cloudflare WAF 突发封锁,又兼顾高速吞吐
const waitMs = 350 + Math.floor(Math.random() * 200);
await new Promise(function (r) { setTimeout(r, waitMs); });
}
}
// 启动自适应并发池
const workers = [];
for (let w = 0; w < userConc; w++) {
workers.push(worker());
}
await Promise.all(workers);
if (topSyncGeneration === myGen) {
topSyncRunning = false;
}
if (!circuitBroken && syncedCount > 0) {
showToast('🏆 TOP250 数据同步完成!共 250 部(复用 ' + reusedCount + ' 部,高速平滑补齐 ' + syncedCount + ' 部)');
}
const layout = localStorage.getItem(SET_KEYS.layout) || 'grid';
if (layout === 'masonry') {
const listEl = currentContainer ? currentContainer.querySelector('#t250-movie-list') : null;
if (listEl) {
try {
WaterfallEngine.register(listEl);
WaterfallEngine.refresh();
} catch (e) {}
}
}
}
// 3. 全量获取 TOP250 榜单(共 5 页 250 部,一站式拉取)
async function fetchFullTop(handleType, typeValue, onProgress) {
const allMovies = [];
const uncachedPages = [];
for (let p = 1; p <= 5; p++) {
const cacheKey = 'top_' + handleType + '_' + typeValue + '_p' + p;
const cached = await getCached(cacheKey, 86400000);
if (cached && cached.length) {
allMovies[p - 1] = cached;
} else {
uncachedPages.push(p);
}
}
if (uncachedPages.length === 0 && allMovies.length === 5) {
const flat = allMovies.flat();
return { success: 1, data: { movies: flat }, fromCache: true };
}
if (!getAuthToken()) {
return { success: 0, action: 'JWTVerificationError', message: '请登录账号' };
}
for (let i = 0; i < uncachedPages.length; i++) {
const p = uncachedPages[i];
if (onProgress) onProgress(i + 1, uncachedPages.length);
const res = await fetchTop(handleType, typeValue, p, false);
if (res.success !== 1) return res;
allMovies[p - 1] = (res.data && res.data.movies) || [];
if (i < uncachedPages.length - 1) {
await new Promise(r => setTimeout(r, 600 + Math.floor(Math.random() * 400)));
}
}
const flat = allMovies.flat();
return { success: 1, data: { movies: flat } };
}
// 3. 一键离线缓存全部 250 部影片(组合架构:后台自动入库)
async function syncAllTop250(btnEl) {
if (isSyncingAll) return;
if (!getAuthToken()) {
showLoginModal(function () { syncAllTop250(btnEl); });
return;
}
isSyncingAll = true;
if (btnEl) {
btnEl.classList.add('syncing');
btnEl.textContent = '⏳ 正在缓存 0/5...';
}
try {
for (let p = 1; p <= 5; p++) {
if (btnEl) btnEl.textContent = '⏳ 正在缓存 ' + p + '/5...';
const res = await fetchTop('all', '', p, true);
if (res.success !== 1) {
throw new Error(res.message || '第 ' + p + ' 页缓存失败');
}
await new Promise(r => setTimeout(r, 250)); // 温和延迟,避免限流
}
if (btnEl) {
btnEl.classList.remove('syncing');
btnEl.innerHTML = '<span class="material-symbols-outlined" style="font-size:14px;color:#34d399;">cloud_done</span> 250部已离线缓存';
}
updateCacheBadge();
if (state.mode === 'top') loadData();
} catch (err) {
if (btnEl) {
btnEl.classList.remove('syncing');
btnEl.textContent = '❌ 缓存失败,点击重试';
}
log('TOP250 全量缓存失败: ' + err.message);
} finally {
isSyncingAll = false;
}
}
async function checkFullTopCached() {
for (let p = 1; p <= 5; p++) {
const cached = await getCached('top_all__p' + p, 86400000);
if (!cached || !cached.length) return false;
}
return true;
}
async function loginAccount(username, password) {
const url = API_BASE + '/v1/sessions';
const sig = getSignature();
const boundary = '--dio-boundary-2210433284';
const headers = {
'content-type': 'multipart/form-data; boundary=' + boundary,
'jdSignature': sig
};
const body =
'--' + boundary + '\r\nContent-Disposition: form-data; name="username"\r\n\r\n' + username + '\r\n' +
'--' + boundary + '\r\nContent-Disposition: form-data; name="password"\r\n\r\n' + password + '\r\n' +
'--' + boundary + '\r\nContent-Disposition: form-data; name="device_uuid"\r\n\r\n04b9534d-5118-53de-9f87-2ddded77111e\r\n' +
'--' + boundary + '\r\nContent-Disposition: form-data; name="device_name"\r\n\r\niPhone\r\n' +
'--' + boundary + '\r\nContent-Disposition: form-data; name="device_model"\r\n\r\niPhone\r\n' +
'--' + boundary + '\r\nContent-Disposition: form-data; name="platform"\r\n\r\nios\r\n' +
'--' + boundary + '\r\nContent-Disposition: form-data; name="system_version"\r\n\r\n17.4\r\n' +
'--' + boundary + '\r\nContent-Disposition: form-data; name="app_version"\r\n\r\nofficial\r\n' +
'--' + boundary + '\r\nContent-Disposition: form-data; name="app_version_number"\r\n\r\n1.9.29\r\n' +
'--' + boundary + '\r\nContent-Disposition: form-data; name="app_channel"\r\n\r\nofficial\r\n' +
'--' + boundary + '--\r\n';
const text = await gmHttp.post(url, body, headers);
const res = JSON.parse(text);
if (res.success === 1 && res.data && res.data.token) {
localStorage.setItem(APP_AUTH_KEY, res.data.token);
return res.data.token;
}
throw new Error(res.message || '登录失败');
}
function showLoginModal(onSuccess) {
let modal = document.getElementById('top250-login-modal');
if (modal) modal.remove();
modal = document.createElement('div');
modal.id = 'top250-login-modal';
modal.style.cssText = 'position:fixed;inset:0;z-index:2147483647;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,.75);backdrop-filter:blur(6px);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;';
modal.innerHTML =
'<div style="background:#1e222d;border:1px solid rgba(255,255,255,.15);border-radius:14px;width:90%;max-width:420px;box-shadow:0 12px 36px rgba(0,0,0,.6);color:#e2e8f0;overflow:hidden;">' +
'<div style="padding:16px 20px;border-bottom:1px solid rgba(255,255,255,.1);display:flex;align-items:center;justify-content:space-between;">' +
'<div style="display:flex;align-items:center;gap:8px;font-size:15px;font-weight:700;color:#fff;">' +
'<span class="material-symbols-outlined" style="color:#fed368;font-size:20px;">key</span> 验证 JAVDB 账号凭据' +
'</div>' +
'<button id="t250-login-close" style="background:none;border:none;color:#94a3b8;cursor:pointer;font-size:20px;padding:4px;line-height:1;">✕</button>' +
'</div>' +
'<div style="padding:16px 20px;">' +
'<div style="font-size:12px;color:#94a3b8;margin-bottom:14px;line-height:1.5;">TOP250 榜单接口需移动端鉴权(普通免费 JAVDB 账号即可,无需 VIP)。凭证将保存在本地浏览器中。</div>' +
'<div style="display:flex;gap:10px;margin-bottom:14px;border-bottom:1px solid rgba(255,255,255,.08);padding-bottom:10px;">' +
'<button type="button" id="tab-btn-pwd" style="background:none;border:none;color:#fed368;font-size:13px;font-weight:700;cursor:pointer;padding:4px 8px;border-bottom:2px solid #fed368;">账号密码登录</button>' +
'<button type="button" id="tab-btn-token" style="background:none;border:none;color:#94a3b8;font-size:13px;cursor:pointer;padding:4px 8px;border-bottom:2px solid transparent;">直接粘贴 Token</button>' +
'</div>' +
'<div id="sec-login-pwd">' +
'<div style="margin-bottom:12px;">' +
'<input type="text" id="t250-login-user" class="jhs-input-text" placeholder="用户名 / 注册邮箱" style="width:100%;box-sizing:border-box;padding:9px 12px;background:#141720;border:1px solid rgba(255,255,255,.15);border-radius:8px;color:#fff;font-size:13px;">' +
'</div>' +
'<div style="margin-bottom:14px;">' +
'<input type="password" id="t250-login-pass" class="jhs-input-text" placeholder="密码" style="width:100%;box-sizing:border-box;padding:9px 12px;background:#141720;border:1px solid rgba(255,255,255,.15);border-radius:8px;color:#fff;font-size:13px;">' +
'</div>' +
'<button id="t250-login-submit" style="width:100%;padding:10px;background:#00a4dc;color:#fff;border:none;border-radius:8px;font-size:13.5px;font-weight:700;cursor:pointer;transition:background .2s;">立即登录获取凭证</button>' +
'</div>' +
'<div id="sec-login-token" style="display:none;">' +
'<div style="margin-bottom:14px;">' +
'<textarea id="t250-token-input" class="jhs-input-text" placeholder="粘贴您的 JAVDB 移动端 Bearer Token..." style="width:100%;height:80px;box-sizing:border-box;padding:9px 12px;background:#141720;border:1px solid rgba(255,255,255,.15);border-radius:8px;color:#fff;font-size:12px;resize:none;"></textarea>' +
'</div>' +
'<button id="t250-token-save" style="width:100%;padding:10px;background:#22c55e;color:#fff;border:none;border-radius:8px;font-size:13.5px;font-weight:700;cursor:pointer;">保存 Token 并使用</button>' +
'</div>' +
'<div id="t250-login-err" style="color:#f87171;font-size:12px;margin-top:10px;display:none;"></div>' +
'</div>' +
'</div>';
document.body.appendChild(modal);
const closeModal = function () { modal.remove(); };
modal.querySelector('#t250-login-close').addEventListener('click', closeModal);
modal.addEventListener('click', function (e) { if (e.target === modal) closeModal(); });
const tabPwd = modal.querySelector('#tab-btn-pwd');
const tabTok = modal.querySelector('#tab-btn-token');
const secPwd = modal.querySelector('#sec-login-pwd');
const secTok = modal.querySelector('#sec-login-token');
const errEl = modal.querySelector('#t250-login-err');
tabPwd.addEventListener('click', function () {
tabPwd.style.color = '#fed368'; tabPwd.style.borderBottomColor = '#fed368'; tabPwd.style.fontWeight = '700';
tabTok.style.color = '#94a3b8'; tabTok.style.borderBottomColor = 'transparent'; tabTok.style.fontWeight = '400';
secPwd.style.display = ''; secTok.style.display = 'none'; errEl.style.display = 'none';
});
tabTok.addEventListener('click', function () {
tabTok.style.color = '#fed368'; tabTok.style.borderBottomColor = '#fed368'; tabTok.style.fontWeight = '700';
tabPwd.style.color = '#94a3b8'; tabPwd.style.borderBottomColor = 'transparent'; tabPwd.style.fontWeight = '400';
secTok.style.display = ''; secPwd.style.display = 'none'; errEl.style.display = 'none';
});
const submitBtn = modal.querySelector('#t250-login-submit');
const userIn = modal.querySelector('#t250-login-user');
const passIn = modal.querySelector('#t250-login-pass');
submitBtn.addEventListener('click', async function () {
const u = userIn.value.trim();
const p = passIn.value.trim();
if (!u || !p) {
errEl.textContent = '请输入用户名和密码';
errEl.style.display = 'block';
return;
}
submitBtn.disabled = true;
submitBtn.textContent = '正在验证登录...';
errEl.style.display = 'none';
try {
await loginAccount(u, p);
closeModal();
updateAuthBadge();
if (typeof onSuccess === 'function') onSuccess();
} catch (err) {
errEl.textContent = err.message || '登录异常,请稍候重试';
errEl.style.display = 'block';
submitBtn.disabled = false;
submitBtn.textContent = '立即登录获取凭证';
}
});
const saveTokBtn = modal.querySelector('#t250-token-save');
const tokInput = modal.querySelector('#t250-token-input');
saveTokBtn.addEventListener('click', function () {
const val = (tokInput.value || '').trim();
if (!val) {
errEl.textContent = '请输入有效 Token';
errEl.style.display = 'block';
return;
}
const cleanTok = val.replace(/^Bearer\s+/i, '');
localStorage.setItem(APP_AUTH_KEY, cleanTok);
closeModal();
updateAuthBadge();
if (typeof onSuccess === 'function') onSuccess();
});
}
function renderStars(score) {
const s = Math.min(5, Math.max(0, Math.round(parseFloat(score) || 0)));
let html = '';
for (let i = 0; i < s; i++) html += '<i class="icon-star" style="color:#f59e0b;margin-right:1px;"></i>';
for (let i = s; i < 5; i++) html += '<i class="icon-star gray" style="color:#64748b;margin-right:1px;"></i>';
return html;
}
function getDynamicYears() {
const curYear = new Date().getFullYear();
const yrs = [];
for (let y = curYear; y >= 2008; y--) yrs.push(y);
return yrs;
}
function renderYearButtonsHtml() {
const yrs = getDynamicYears();
let html = '';
yrs.forEach(function (y) {
const active = (state.handleType === 'year' && state.typeValue === String(y)) ? 'active' : '';
html += '<button type="button" class="top250-pill ' + active + '" data-year="' + y + '">' + y + '</button>';
});
return html;
}
// 全量加载 TOP250,无需分页
function getAuthBadgeHtml() {
const token = getAuthToken();
if (token) {
return '<span class="material-symbols-outlined" style="font-size:14px;color:#34d399;">verified_user</span> 已登录凭证 <span style="font-size:10px;opacity:.7;">(点此重登)</span>';
}
return '<span class="material-symbols-outlined" style="font-size:14px;color:#f59e0b;">lock_open</span> 未登录凭证 <span style="font-size:10px;opacity:.7;">(点此登录解锁TOP250)</span>';
}
function updateAuthBadge() {
if (!currentContainer) return;
const b = currentContainer.querySelector('#t250-auth-badge');
if (b) b.innerHTML = getAuthBadgeHtml();
}
async function updateCacheBadge() {
if (!currentContainer) return;
const badgeWrap = currentContainer.querySelector('#t250-cache-status');
if (!badgeWrap) return;
if (state.mode === 'top') {
const isFull = await checkFullTopCached();
if (isFull) {
badgeWrap.innerHTML = '<span class="top250-cache-badge"><span class="material-symbols-outlined" style="font-size:13px;">offline_pin</span> 全量250已缓存</span>';
} else {
badgeWrap.innerHTML = '<button type="button" class="top250-sync-btn" id="t250-sync-btn"><span class="material-symbols-outlined" style="font-size:13px;">sync</span> 离线缓存全量250部</button>';
const sBtn = badgeWrap.querySelector('#t250-sync-btn');
if (sBtn) sBtn.addEventListener('click', function () { syncAllTop250(sBtn); });
}
} else {
badgeWrap.innerHTML = '<span style="font-size:11px;color:#94a3b8;opacity:.8;">⚡ 热播数据按需实时呈现</span>';
}
}
async function loadData() {
const mySeq = ++top250LoadSeq;
if (typeof isPluginEnabled === 'function' && !isPluginEnabled('reviewListInfinite') && !isJavdbVipUser()) {
const listEl = currentContainer ? currentContainer.querySelector('#t250-movie-list') : null;
if (listEl) {
listEl.innerHTML = '<div style="grid-column:1/-1;text-align:center;padding:80px 0;color:#94a3b8;font-size:15px;">「绕过JAVDB限制」插件当前处于禁用状态,请在设置中开启(或登录 JAVDB VIP 会员账号)后再使用 TOP250 与热播榜功能。</div>';
}
return;
}
if (isFetching) return;
isFetching = true;
const listEl = currentContainer ? currentContainer.querySelector('#t250-movie-list') : null;
if (listEl) {
listEl.innerHTML = '<div style="grid-column:1/-1;text-align:center;padding:80px 0;color:#94a3b8;font-size:15px;"><span style="display:inline-block;animation:spin 1s linear infinite;font-size:24px;margin-bottom:8px;">⏳</span><br>正在加载榜单数据...</div>';
}
try {
if (state.mode === 'playback') {
const movies = await fetchPlayback(state.period);
if (mySeq !== top250LoadSeq) return;
cachedMovies = movies || [];
renderMoviesList(cachedMovies);
} else {
// TOP250 未登录引导
if (!getAuthToken()) {
if (mySeq !== top250LoadSeq) return;
if (listEl) {
listEl.innerHTML =
'<div style="grid-column:1/-1;text-align:center;padding:70px 20px;color:#e2e8f0;">' +
'<div style="font-size:18px;font-weight:700;margin-bottom:10px;color:#fed368;">🏆 TOP250 需验证 JAVDB 账号凭据</div>' +
'<div style="font-size:13px;color:#94a3b8;margin-bottom:20px;max-width:480px;margin-left:auto;margin-right:auto;line-height:1.6;">TOP250 榜单由移动端接口提供。输入免费普通 JAVDB 账号或粘贴已有 Token 即可免 VIP 畅享全部 250 部影片。</div>' +
'<div style="display:flex;justify-content:center;gap:12px;">' +
'<button type="button" id="t250-prompt-login-btn" style="background:#00a4dc;color:#fff;border:none;padding:10px 24px;border-radius:8px;font-size:14px;font-weight:700;cursor:pointer;">立即登录 / 粘贴 Token</button>' +
'<button type="button" id="t250-prompt-playback-btn" style="background:rgba(255,255,255,.1);color:#fff;border:none;padding:10px 20px;border-radius:8px;font-size:14px;cursor:pointer;">先看免登录热播榜</button>' +
'</div>' +
'</div>';
const pBtn = listEl.querySelector('#t250-prompt-login-btn');
if (pBtn) pBtn.addEventListener('click', function () { showLoginModal(function () { loadData(); }); });
const pbBtn = listEl.querySelector('#t250-prompt-playback-btn');
if (pbBtn) pbBtn.addEventListener('click', function () { switchMode('playback'); });
}
isFetching = false;
updateAuthBadge();
updateCacheBadge();
return;
}
const res = await fetchFullTop(state.handleType, state.typeValue, function (cur, total) {
if (mySeq !== top250LoadSeq) return;
if (listEl) {
listEl.innerHTML = '<div style="grid-column:1/-1;text-align:center;padding:80px 0;color:#94a3b8;font-size:15px;"><span style="display:inline-block;animation:spin 1s linear infinite;font-size:24px;margin-bottom:8px;">⏳</span><br>正在加载全量 TOP250 (' + cur + '/' + total + ')...</div>';
}
});
if (mySeq !== top250LoadSeq) return;
if (res.success === 1 && res.data && Array.isArray(res.data.movies)) {
cachedMovies = res.data.movies;
cachedMovies.forEach(function (m, idx) { m._topRank = idx + 1; });
// 数据库复用与后台温和同步逻辑
let existingMap = new Map();
try {
if (typeof FAV !== 'undefined' && typeof FAV.dbAllMovies === 'function') {
const allFavMovies = await FAV.dbAllMovies();
if (Array.isArray(allFavMovies)) {
allFavMovies.forEach(function (fm) {
if (fm && fm.code) {
const c = fm.code.trim().toUpperCase();
existingMap.set(c, fm);
existingMap.set(normCode(c), fm);
}
if (fm && fm.detailHref) existingMap.set(fm.detailHref.toLowerCase(), fm);
});
}
}
} catch (dbErr) {
log('读取收藏夹本地数据库失败: ' + dbErr.message);
}
const cachedDetailsMap = await getAllCachedDetails();
let reusedCount = 0;
const pendingQueue = [];
cachedMovies.forEach(function (m, idx) {
const code = (m.number || m.code || '').trim().toUpperCase();
const normC = normCode(code);
const href = m.id ? ('/v/' + m.id).toLowerCase() : '';
const idKey = m.id ? String(m.id).toUpperCase() : '';
const existing = existingMap.get(code) || (normC ? existingMap.get(normC) : null) || (href ? existingMap.get(href) : null) ||
cachedDetailsMap.get(code) || (normC ? cachedDetailsMap.get(normC) : null) ||
(idKey ? cachedDetailsMap.get(idKey) : null) || (m.id ? cachedDetailsMap.get(m.id) : null);
const curAward = { name: getCurrentBoardAwardName(state), rank: idx + 1 };
m._topRank = idx + 1;
if (existing && existing.detailComplete) {
reusedCount++;
Object.assign(m, existing);
m._topRank = idx + 1;
m.awards = mergeMovieAwards(existing.awards, curAward);
existingMap.set(code, existing);
if (normC) existingMap.set(normC, existing);
} else if (existing) {
Object.assign(m, existing);
m._topRank = idx + 1;
m.awards = mergeMovieAwards(existing.awards, curAward);
existingMap.set(code, existing);
if (normC) existingMap.set(normC, existing);
pendingQueue.push({ id: m.id, number: m.number || m.code, _topRank: idx + 1, itemRef: m });
} else {
m.awards = [curAward];
pendingQueue.push({ id: m.id, number: m.number || m.code, _topRank: idx + 1, itemRef: m });
}
if (m.thumb_url) m.thumb_url = updateImgServer(m.thumb_url);
if (m.cover_url) m.cover_url = updateImgServer(m.cover_url);
if (m.coverThumb) m.coverThumb = updateImgServer(m.coverThumb);
if (m.coverLarge) m.coverLarge = updateImgServer(m.coverLarge);
});
renderMoviesList(cachedMovies);
if (pendingQueue.length > 0) {
syncTop250Details(pendingQueue, reusedCount, cachedMovies, existingMap);
} else if (reusedCount > 0) {
showToast('🏆 TOP250 数据已就绪!250 部作品全部复用自本地数据库');
}
} else {
const isAuthError = res.action === 'JWTVerificationError' ||
(res.message && (res.message.includes('登') || res.message.includes('token') || res.message.includes('auth') || res.message.includes('過期')));
if (isAuthError) {
localStorage.removeItem(APP_AUTH_KEY);
if (listEl) {
listEl.innerHTML =
'<div style="grid-column:1/-1;text-align:center;padding:70px 20px;color:#e2e8f0;">' +
'<div style="font-size:18px;font-weight:700;margin-bottom:10px;color:#fed368;">' + escapeHtml(res.message || '登录凭据已失效,请重新验证') + '</div>' +
'<div style="font-size:13px;color:#94a3b8;margin-bottom:20px;max-width:480px;margin-left:auto;margin-right:auto;line-height:1.6;">TOP250 需保持移动端凭据有效。请输入普通 JAVDB 账号或粘贴 Token。</div>' +
'<div style="display:flex;justify-content:center;gap:12px;">' +
'<button type="button" id="t250-prompt-login-btn" style="background:#00a4dc;color:#fff;border:none;padding:10px 24px;border-radius:8px;font-size:14px;font-weight:700;cursor:pointer;">重新登录 / 粘贴 Token</button>' +
'<button type="button" id="t250-prompt-playback-btn" style="background:rgba(255,255,255,.1);color:#fff;border:none;padding:10px 20px;border-radius:8px;font-size:14px;cursor:pointer;">先看免登录热播榜</button>' +
'</div>' +
'</div>';
const pBtn = listEl.querySelector('#t250-prompt-login-btn');
if (pBtn) pBtn.addEventListener('click', function () { showLoginModal(function () { loadData(); }); });
const pbBtn = listEl.querySelector('#t250-prompt-playback-btn');
if (pbBtn) pbBtn.addEventListener('click', function () { switchMode('playback'); });
}
} else {
throw new Error(res.message || '获取TOP250数据失败');
}
}
}
} catch (err) {
if (mySeq !== top250LoadSeq) return;
if (listEl) {
listEl.innerHTML =
'<div style="grid-column:1/-1;text-align:center;padding:60px 20px;color:#f87171;">' +
'<div style="font-size:16px;font-weight:700;margin-bottom:8px;">加载失败: ' + escapeHtml(err.message || '网络异常') + '</div>' +
'<div style="font-size:12px;color:#94a3b8;margin-bottom:16px;">可点击下方按钮重新尝试,或切换至免登录热播榜</div>' +
'<div style="display:flex;justify-content:center;gap:10px;">' +
'<button type="button" id="t250-retry-btn" style="background:rgba(255,255,255,.1);color:#fff;border:none;padding:6px 18px;border-radius:6px;font-size:13px;cursor:pointer;">重试</button>' +
'<button type="button" id="t250-switch-pb-btn" style="background:#00a4dc;color:#fff;border:none;padding:6px 18px;border-radius:6px;font-size:13px;cursor:pointer;">切换免登录热播榜</button>' +
'</div>' +
'</div>';
const rBtn = listEl.querySelector('#t250-retry-btn');
if (rBtn) rBtn.addEventListener('click', function () { loadData(); });
const spbBtn = listEl.querySelector('#t250-switch-pb-btn');
if (spbBtn) spbBtn.addEventListener('click', function () { switchMode('playback'); });
}
} finally {
if (mySeq === top250LoadSeq) {
isFetching = false;
updateAuthBadge();
updateCacheBadge();
}
}
}
// 4. 标准 Emby 卡片渲染(对齐 FAVUI,全面支持错落瀑布流与完整卡片同步)
function renderMoviesList(movies) {
const listEl = currentContainer ? currentContainer.querySelector('#t250-movie-list') : null;
if (!listEl) return;
if (!movies || !movies.length) {
listEl.innerHTML = '<div style="grid-column:1/-1;text-align:center;padding:60px 0;color:#94a3b8;font-size:14px;">未找到符合筛选条件的影片</div>';
return;
}
const layout = localStorage.getItem(SET_KEYS.layout) || 'grid';
listEl.className = 'movie-list' + (layout === 'masonry' ? ' emby-masonry' : '') + (layout === 'dynamic-focus' ? ' emby-dynamic-focus' : '');
let filtered = movies;
if (state.hasCnsub === '1') filtered = filtered.filter(function (m) { return m.has_cnsub; });
else if (state.hasCnsub === '0') filtered = filtered.filter(function (m) { return !m.has_cnsub; });
// TOP250 多维高级筛选 (反转评分筛选为 <= 逻辑,默认 5.0)
if (top250FilterState.keyword) {
const kw = top250FilterState.keyword.toLowerCase().trim();
filtered = filtered.filter(function (m) {
const c = (m.code || m.number || '').toLowerCase();
const t = (m.title || m.origin_title || '').toLowerCase();
return c.indexOf(kw) !== -1 || t.indexOf(kw) !== -1;
});
}
if (top250FilterState.scoreMax != null && top250FilterState.scoreMax < 5.0) {
filtered = filtered.filter(function (m) {
const s = getMovieScore(m);
return s > 0 && s <= top250FilterState.scoreMax;
});
}
if (top250FilterState.awards && top250FilterState.awards.length > 0) {
filtered = filtered.filter(function (m) {
return top250FilterState.awards.some(function (t) {
return (m.awards || []).some(function (x) {
let n = typeof x === 'string' ? x : (x && x.name);
if (/^top\s*250$/i.test(n)) n = 'JavDB 影片TOP250';
return n === t;
});
});
});
}
if (top250FilterState.tags && top250FilterState.tags.length > 0) {
filtered = filtered.filter(function (m) {
const cats = (m.categories || m.tags || []).map(function (c) {
return typeof c === 'string' ? c.trim() : ((c && (c.text || c.name)) || '').trim();
});
return top250FilterState.tags.some(function (t) { return cats.includes(t); });
});
}
if (top250FilterState.years && top250FilterState.years.length > 0) {
filtered = filtered.filter(function (m) {
const y = (m.date || m.release_date || '').slice(0, 4);
return top250FilterState.years.includes(y);
});
}
if (top250FilterState.actors && top250FilterState.actors.length > 0) {
filtered = filtered.filter(function (m) {
const acts = Array.isArray(m.actors) ? m.actors : (m.actor ? (Array.isArray(m.actor) ? m.actor : [m.actor]) : []);
const mActs = acts.map(getActorName).filter(Boolean);
if (top250FilterState.coactMode) {
return top250FilterState.actors.every(function (act) { return mActs.includes(act); });
}
return top250FilterState.actors.some(function (act) { return mActs.includes(act); });
});
}
if (top250FilterState.makers && top250FilterState.makers.length > 0) {
filtered = filtered.filter(function (m) {
const mk = getEntityText(m.maker);
return top250FilterState.makers.includes(mk);
});
}
if (top250FilterState.series && top250FilterState.series.length > 0) {
filtered = filtered.filter(function (m) {
const sr = getEntityText(m.series);
return top250FilterState.series.includes(sr);
});
}
if (top250FilterState.directors && top250FilterState.directors.length > 0) {
filtered = filtered.filter(function (m) {
const dr = getEntityText(m.director);
return top250FilterState.directors.includes(dr);
});
}
applyTop250RandomBg(currentContainer);
if (!filtered.length) {
listEl.innerHTML = '<div style="grid-column:1/-1;text-align:center;padding:60px 0;color:#94a3b8;font-size:14px;">当前筛选条件下无影片</div>';
return;
}
const isPlayback = state.mode === 'playback';
if (isPlayback) {
// 热播榜单保持 JAVDB 原样式与轻量渲染 (保持原样)
let html = '';
filtered.forEach(function (m, idx) {
const rank = m._topRank || (idx + 1);
const rankCls = rank === 1 ? 'top1' : (rank === 2 ? 'top2' : (rank === 3 ? 'top3' : ''));
const coverLargeSrc = updateImgServer(m.coverLarge || m.cover_url || '');
const coverThumbSrc = updateImgServer(m.coverThumb || m.thumb_url || '');
const coverSrc = coverLargeSrc || coverThumbSrc || updateImgServer(m.coverLarge || m.coverThumb || m.cover_url || m.thumb_url || '');
const code = m.code || m.number || '';
const title = m.origin_title || m.title || '';
const href = m.detailHref || ('/v/' + m.id);
const sInfo = scoreMemCache.get(m.id);
const score = sInfo ? sInfo.score : (m.score != null && m.score !== '' ? parseFloat(m.score) : null);
const scoreVal = (score != null && score > 0) ? score.toFixed(1) : '0.0';
const starsHtml = renderStars(scoreVal);
if (!sInfo && (!m.score || parseFloat(m.score) === 0)) {
queueScoreFetch(m.id);
}
const tagsHtml = m.has_cnsub
? '<span class="tag is-warning">含中字磁鏈</span>'
: (m.magnets_count > 0 ? '<span class="tag is-success">含磁鏈</span>' : '<span class="tag is-info">無磁鏈</span>');
const safeMid = escapeAttr(String(m.id || ''));
html +=
'<div class="item" id="' + safeMid + '" data-movie-id="' + safeMid + '" data-code="' + escapeAttr(code) + '">' +
'<a href="' + escapeAttr(href) + '" class="box" title="' + escapeAttr(title) + '">' +
'<div class="top250-rank-badge ' + rankCls + '">' + (rank === 1 ? '👑 ' : '') + '#' + rank + '</div>' +
'<div class="cover">' +
'<img loading="lazy" referrerpolicy="no-referrer" src="' + escapeAttr(coverSrc) + '"' +
(coverLargeSrc ? ' data-large="' + escapeAttr(coverLargeSrc) + '"' : '') +
(coverThumbSrc ? ' data-thumb="' + escapeAttr(coverThumbSrc) + '"' : '') +
' alt="" onerror="this.onerror=null;if(this.dataset.large&&this.src!==this.dataset.large){this.src=this.dataset.large;}else if(this.src.indexOf(\'/small_covers/\')!==-1){this.src=this.src.replace(\'/small_covers/\',\'/thumbs/\');}else if(this.src.indexOf(\'c0.jdbstatic.com\')===-1){this.src=this.src.replace(/https:\\/\\/.*?\\/rhe951l4q/g,\'https://c0.jdbstatic.com\');}">' +
'</div>' +
'<div class="video-title">' +
'<strong>' + escapeHtml(code) + '</strong> ' + escapeHtml(title) +
'</div>' +
'<div class="score" id="score_' + safeMid + '">' +
'<span class="value"><span class="score-stars">' + starsHtml + '</span> ' + scoreVal + '分</span>' +
'</div>' +
'<div class="meta">' + escapeHtml(m.release_date || m.date || '') + '</div>' +
'<div class="tags has-addons">' + tagsHtml + (m.new_magnets ? ' <span class="tag is-info">今日新種</span>' : '') + '</div>' +
'<span class="emby-card-shine"></span>' +
'</a>' +
'</div>';
});
listEl.innerHTML = html;
listEl.querySelectorAll('.item .box').forEach(function (box) {
const itemEl = box.closest('.item');
const code = itemEl ? (itemEl.dataset.code || '') : '';
const img = box.querySelector('.cover img');
const src = img ? (img.getAttribute('src') || '') : '';
box.style.setProperty('--mz-ar', String(masonryAspect(src || code, code)));
if (typeof attachCard3D === 'function') attachCard3D(box);
});
} else {
// TOP250 完整卡片:直接复用 FAVUI.buildCard,保证与收藏夹 100% 同步,零重复代码,天然去除磁链标签
listEl.innerHTML = '';
const frag = document.createDocumentFragment();
filtered.forEach(function (m, idx) {
const rank = m._topRank || (idx + 1);
m._topRank = rank;
if (!m.awards || !m.awards.length) {
m.awards = [{ name: getCurrentBoardAwardName(state), rank: rank }];
}
if (!m.detailHref) m.detailHref = '/v/' + m.id;
if (!m.code) m.code = m.number || '';
if (!m.coverThumb && !m.coverLarge) {
m.coverThumb = updateImgServer(m.thumb_url || m.cover_url || '');
m.coverLarge = updateImgServer(m.cover_url || m.thumb_url || '');
} else {
if (m.coverThumb) m.coverThumb = updateImgServer(m.coverThumb);
if (m.coverLarge) m.coverLarge = updateImgServer(m.coverLarge);
}
if (!m.coverThumb && m.coverLarge) m.coverThumb = m.coverLarge;
if (!m.coverLarge && m.coverThumb) m.coverLarge = m.coverThumb;
if (!m.date) m.date = m.release_date || '';
if (!m.title) m.title = m.origin_title || '';
if (!m.rating && (m.score != null || scoreMemCache.has(m.id))) {
const sInfo = scoreMemCache.get(m.id);
const score = sInfo ? sInfo.score : (parseFloat(m.score) || 0);
const watched = sInfo ? sInfo.watchedCount : (m.watched_count || 0);
m.rating = {
score: score,
count: watched,
text: (score > 0 ? score.toFixed(1) + '分' : '0.0分') + (watched ? ',由' + watched + '人評價' : '')
};
}
if (typeof FAVUI !== 'undefined' && typeof FAVUI.buildCard === 'function') {
frag.appendChild(FAVUI.buildCard(m));
}
});
listEl.appendChild(frag);
}
if (layout === 'masonry') {
try {
WaterfallEngine.register(listEl);
WaterfallEngine.refresh();
} catch (e) {}
} else if (layout === 'dynamic-focus') {
try {
ensureDynamicFocusGrid(listEl);
} catch (e) {}
}
}
function getActiveTop250FilterCount() {
let cnt = 0;
if (top250FilterState.keyword) cnt++;
if (top250FilterState.scoreMax != null && top250FilterState.scoreMax < 5.0) cnt++;
cnt += (top250FilterState.awards || []).length;
cnt += (top250FilterState.actors || []).length;
cnt += (top250FilterState.makers || []).length;
cnt += (top250FilterState.series || []).length;
cnt += (top250FilterState.directors || []).length;
cnt += (top250FilterState.tags || []).length;
cnt += (top250FilterState.years || []).length;
return cnt;
}
function updateTop250FilterBadge() {
if (!currentContainer) return;
const badge = currentContainer.querySelector('#t250-filter-badge');
const btn = currentContainer.querySelector('#t250-filter-btn');
const cnt = getActiveTop250FilterCount();
if (badge) {
badge.textContent = String(cnt);
badge.style.display = cnt > 0 ? 'inline-flex' : 'none';
}
if (btn) {
btn.classList.toggle('active', cnt > 0);
}
}
// TOP250 与画廊/收藏夹共享同一个标准筛选架构 (openFilterModal)
function getFilter() { return top250FilterState; }
function setFilter(f) {
top250FilterState = Object.assign(top250FilterState, f);
updateTop250FilterBadge();
renderMoviesList(cachedMovies);
}
function resetFilter() {
top250FilterState = {
keyword: '',
scoreMax: 5.0,
awards: [],
actors: [],
makers: [],
series: [],
directors: [],
tags: [],
years: [],
coactMode: false,
showActorAvatars: false,
actorAvatarSize: 'small',
hideMaleActors: true
};
updateTop250FilterBadge();
renderMoviesList(cachedMovies);
}
function applySubnavMode(sub, panel) {
if (!panel) panel = currentContainer;
abortSync();
state.mode = sub;
if (panel) {
panel.querySelectorAll('.top250-subnav-btn').forEach(function (b) { b.classList.toggle('active', b.dataset.subnav === sub); });
const topBar = panel.querySelector('#top250-filter-bar');
const playBar = panel.querySelector('#playback-filter-bar');
if (topBar) topBar.style.display = sub === 'top' ? '' : 'none';
if (playBar) playBar.style.display = sub === 'playback' ? '' : 'none';
}
updateCacheBadge();
loadData();
}
function getCachedMovies() {
return cachedMovies;
}
function bindPanelEvents(panel) {
const fBtn = panel.querySelector('#t250-filter-btn');
if (fBtn) {
fBtn.addEventListener('click', function () {
if (typeof FAVUI !== 'undefined' && typeof FAVUI.openFilterModal === 'function') {
FAVUI.openFilterModal('top250');
} else {
log('FAVUI.openFilterModal 尚未就绪');
}
});
}
// 榜单类型切换 (TOP250 vs 热播)
panel.querySelectorAll('.top250-subnav-btn').forEach(function (btn) {
btn.addEventListener('click', function () {
const sub = btn.dataset.subnav;
if (state.mode === sub) return;
applySubnavMode(sub, panel);
});
});
// 账号凭证状态点击
const authBadge = panel.querySelector('#t250-auth-badge');
if (authBadge) {
authBadge.addEventListener('click', function () {
showLoginModal(function () {
if (state.mode === 'top') {
abortSync();
loadData();
}
});
});
}
// TOP250 分类按钮
panel.querySelectorAll('#top250-filter-bar [data-cat]').forEach(function (btn) {
btn.addEventListener('click', function () {
abortSync();
const cat = btn.dataset.cat;
if (cat === 'all') {
state.handleType = 'all';
state.typeValue = '';
} else {
state.handleType = 'video_type';
state.typeValue = cat;
}
state.page = 1;
panel.querySelectorAll('#top250-filter-bar [data-cat]').forEach(function (b) { b.classList.toggle('active', b === btn); });
panel.querySelectorAll('#top250-filter-bar [data-year]').forEach(function (b) { b.classList.remove('active'); });
loadData();
});
});
// 年份筛选按钮
panel.querySelectorAll('#top250-filter-bar [data-year]').forEach(function (btn) {
btn.addEventListener('click', function () {
abortSync();
const yr = btn.dataset.year;
state.handleType = 'year';
state.typeValue = yr;
state.page = 1;
panel.querySelectorAll('#top250-filter-bar [data-cat]').forEach(function (b) { b.classList.remove('active'); });
panel.querySelectorAll('#top250-filter-bar [data-year]').forEach(function (b) { b.classList.toggle('active', b === btn); });
loadData();
});
});
// 字幕筛选按钮 (TOP250 与 Playback)
panel.querySelectorAll('[data-sub]').forEach(function (btn) {
btn.addEventListener('click', function () {
const sVal = btn.dataset.sub;
state.hasCnsub = sVal;
const p = btn.closest('.top250-filter-box');
if (p) {
p.querySelectorAll('[data-sub]').forEach(function (b) { b.classList.toggle('active', b === btn); });
}
renderMoviesList(cachedMovies);
});
});
// Playback 周期按钮
panel.querySelectorAll('#playback-filter-bar [data-period]').forEach(function (btn) {
btn.addEventListener('click', function () {
abortSync();
const pr = btn.dataset.period;
state.period = pr;
panel.querySelectorAll('#playback-filter-bar [data-period]').forEach(function (b) { b.classList.toggle('active', b === btn); });
loadData();
});
});
}
function mount(panel) {
currentContainer = panel;
if (!getAuthToken() && state.mode === 'top') {
state.mode = 'playback';
}
panel.innerHTML =
'<div class="top250-wrap">' +
'<div class="top250-header">' +
'<div class="top250-subnav-group">' +
'<button type="button" class="top250-subnav-btn ' + (state.mode === 'top' ? 'active' : '') + '" data-subnav="top">' +
'<span class="material-symbols-outlined" style="color:#fed368;font-size:18px;">workspace_premium</span> JAVDB 影片 TOP250' +
'</button>' +
'<button type="button" class="top250-subnav-btn ' + (state.mode === 'playback' ? 'active' : '') + '" data-subnav="playback">' +
'<span class="material-symbols-outlined" style="color:#f87171;font-size:18px;">local_fire_department</span> 热播榜单' +
'</button>' +
'</div>' +
'<div class="top250-header-actions">' +
'<div id="t250-cache-status"></div>' +
'<div id="t250-auth-badge" style="cursor:pointer;font-size:12px;color:#94a3b8;display:flex;align-items:center;gap:6px;background:rgba(255,255,255,.05);padding:5px 12px;border-radius:20px;border:1px solid rgba(255,255,255,.08);">' +
getAuthBadgeHtml() +
'</div>' +
'</div>' +
'</div>' +
// TOP250 工具栏
'<div id="top250-filter-bar" class="top250-filter-box" style="' + (state.mode === 'top' ? '' : 'display:none;') + '">' +
'<div class="top250-filter-row">' +
'<div class="top250-filter-left">' +
'<span class="top250-label">类型:</span>' +
'<button type="button" class="top250-pill ' + (state.handleType === 'all' ? 'active' : '') + '" data-cat="all">全部</button>' +
'<button type="button" class="top250-pill ' + (state.handleType === 'video_type' && state.typeValue === '0' ? 'active' : '') + '" data-cat="0">有码</button>' +
'<button type="button" class="top250-pill ' + (state.handleType === 'video_type' && state.typeValue === '1' ? 'active' : '') + '" data-cat="1">无码</button>' +
'<button type="button" class="top250-pill ' + (state.handleType === 'video_type' && state.typeValue === '2' ? 'active' : '') + '" data-cat="2">欧美</button>' +
'<button type="button" class="top250-pill ' + (state.handleType === 'video_type' && state.typeValue === '3' ? 'active' : '') + '" data-cat="3">FC2</button>' +
'</div>' +
'<div class="top250-filter-right">' +
'<button type="button" id="t250-filter-btn" class="top250-pill efav-btn" style="margin-right:10px;"><span class="material-symbols-outlined" style="font-size:15px;vertical-align:-2px;">filter_alt</span> 筛选<span id="t250-filter-badge" class="efav-filter-badge" style="display:none;margin-left:4px;padding:1px 6px;border-radius:10px;background:#fed368;color:#000;font-size:11px;font-weight:700;">0</span></button>' +
'<span class="top250-label">字幕:</span>' +
'<button type="button" class="top250-pill ' + (state.hasCnsub === '' ? 'active' : '') + '" data-sub="">全部</button>' +
'<button type="button" class="top250-pill ' + (state.hasCnsub === '1' ? 'active' : '') + '" data-sub="1">含中字磁链</button>' +
'<button type="button" class="top250-pill ' + (state.hasCnsub === '0' ? 'active' : '') + '" data-sub="0">无字幕</button>' +
'</div>' +
'</div>' +
'<div class="top250-filter-row" style="border-top:1px solid rgba(255,255,255,.05);padding-top:10px;">' +
'<div style="display:flex;align-items:center;gap:6px;width:100%;overflow:hidden;">' +
'<span class="top250-label" style="flex-shrink:0;">年份:</span>' +
'<div class="top250-year-container" id="t250-year-box">' +
renderYearButtonsHtml() +
'</div>' +
'</div>' +
'</div>' +
'</div>' +
// Playback 热播工具栏
'<div id="playback-filter-bar" class="top250-filter-box" style="' + (state.mode === 'playback' ? '' : 'display:none;') + '">' +
'<div class="top250-filter-row">' +
'<div class="top250-filter-left">' +
'<span class="top250-label">排行周期:</span>' +
'<button type="button" class="top250-pill ' + (state.period === 'daily' ? 'active' : '') + '" data-period="daily">日榜</button>' +
'<button type="button" class="top250-pill ' + (state.period === 'weekly' ? 'active' : '') + '" data-period="weekly">周榜</button>' +
'<button type="button" class="top250-pill ' + (state.period === 'monthly' ? 'active' : '') + '" data-period="monthly">月榜</button>' +
'</div>' +
'<div class="top250-filter-right">' +
'<span class="top250-label">字幕:</span>' +
'<button type="button" class="top250-pill ' + (state.hasCnsub === '' ? 'active' : '') + '" data-sub="">全部</button>' +
'<button type="button" class="top250-pill ' + (state.hasCnsub === '1' ? 'active' : '') + '" data-sub="1">含中字磁链</button>' +
'<button type="button" class="top250-pill ' + (state.hasCnsub === '0' ? 'active' : '') + '" data-sub="0">无字幕</button>' +
'</div>' +
'</div>' +
'</div>' +
// 网格容器
'<div id="t250-movie-list" class="movie-list h cols-4 vcols-8"></div>' +
'</div>';
bindPanelEvents(panel);
updateCacheBadge();
loadData();
}
// 5. JAVDB 原生官方页面拦截(仅在原生模式且主动访问收费热播页面时就地解除限制)
function hookNativePage() {
if (typeof isPluginEnabled === 'function' && !isPluginEnabled('reviewListInfinite') && !isJavdbVipUser()) return;
const isPlans = location.pathname.startsWith('/plans');
const isTop = /^\/rankings\/(movies|top)(\/|\?|$)/.test(location.pathname) || (location.pathname.startsWith('/advanced_search') && location.search.includes('handleTop'));
const isPlayback = /^\/rankings\/playback(\/|\?|$)/.test(location.pathname) || (isPlans && document.referrer && document.referrer.includes('playback')) || (location.pathname.startsWith('/advanced_search') && location.search.includes('handlePlayback'));
if (!isTop && !isPlayback && !isPlans) return;
if (enabled) {
state.mode = isPlayback ? 'playback' : 'top';
switchTab('top250');
return;
}
// JAVDB 原生 Bulma 模式:
// TOP250 经典榜单(/rankings/movies)官方自带完整筛选控件(全部/有码/无码/欧美/FC2)与分页,
// 原生模式下必须保留官方原貌与筛选控件,绝不进行 DOM 清理与覆盖!
// 仅当用户访问被官方收费拦截的热播榜单(/rankings/playback 或被 302 到 /plans)时,才就地解除 VIP 限制。
if (!isPlayback) return;
const container = document.querySelector('.section .container') || document.querySelector('main .container') || document.querySelector('.container');
if (!container) return;
let titleEl = container.querySelector('h2.section-title, h1.title, h2.title, .section-title, h2');
if (!titleEl) {
titleEl = document.createElement('h2');
titleEl.className = 'title is-4 section-title';
container.insertBefore(titleEl, container.firstChild);
}
titleEl.textContent = '热播榜单';
titleEl.style.marginBottom = '14px';
container.querySelectorAll('.empty-message, #sort-toggle-btn, .pricing-plans, .pricing-table, form, .box:not(#native-t250-box .box)').forEach(function (el) {
try { el.remove(); } catch (e) {}
});
let next = titleEl.nextElementSibling;
while (next) {
const toRemove = next;
next = next.nextElementSibling;
if (toRemove.id !== 'native-t250-box') {
try { toRemove.remove(); } catch (e) {}
}
}
let nativeBox = container.querySelector('#native-t250-box');
if (nativeBox && nativeBox.dataset.mounted === 'playback') return;
if (!nativeBox) {
nativeBox = document.createElement('div');
nativeBox.id = 'native-t250-box';
container.appendChild(nativeBox);
}
nativeBox.dataset.mounted = 'playback';
renderNativeRankingUI(nativeBox);
}
async function renderNativeRankingUI(nativeBox) {
let nPeriod = 'daily';
let nCachedMovies = [];
function renderToolbar() {
return '<div style="margin-bottom:14px;display:flex;gap:6px;" class="buttons has-addons">' +
'<a class="button is-small ' + (nPeriod === 'daily' ? 'is-info' : '') + '" data-n-pb="daily">日榜</a>' +
'<a class="button is-small ' + (nPeriod === 'weekly' ? 'is-info' : '') + '" data-n-pb="weekly">周榜</a>' +
'<a class="button is-small ' + (nPeriod === 'monthly' ? 'is-info' : '') + '" data-n-pb="monthly">月榜</a>' +
'</div>';
}
nativeBox.innerHTML =
'<div id="n-toolbar">' + renderToolbar() + '</div>' +
'<div class="movie-list h cols-4 vcols-8" id="n-movie-list">正在获取数据...</div>';
async function loadNative() {
const listEl = nativeBox.querySelector('#n-movie-list');
if (listEl) listEl.innerHTML = '<div style="padding:40px;text-align:center;color:#888;">正在获取数据...</div>';
try {
const movies = await fetchPlayback(nPeriod);
nCachedMovies = movies || [];
if (!nCachedMovies.length) {
if (listEl) listEl.innerHTML = '<div style="padding:40px;text-align:center;color:#888;">无数据</div>';
return;
}
let html = '';
nCachedMovies.forEach(function (m, idx) {
const rank = idx + 1;
const rankCls = rank === 1 ? 'top1' : (rank === 2 ? 'top2' : (rank === 3 ? 'top3' : ''));
const coverLargeSrc = updateImgServer(m.cover_url || '');
const coverThumbSrc = updateImgServer(m.thumb_url || '');
const coverSrc = coverLargeSrc || coverThumbSrc || updateImgServer(m.cover_url || m.thumb_url || '');
const sInfo = scoreMemCache.get(m.id);
const score = sInfo ? sInfo.score : (m.score != null && m.score !== '' ? parseFloat(m.score) : null);
const scoreVal = (score != null && score > 0) ? score.toFixed(1) : '0.0';
const starsHtml = renderStars(scoreVal);
if (!sInfo && (!m.score || parseFloat(m.score) === 0)) {
queueScoreFetch(m.id);
}
const tagsHtml = m.has_cnsub
? '<span class="tag is-warning">含中字磁鏈</span>'
: (m.magnets_count > 0 ? '<span class="tag is-success">含磁鏈</span>' : '<span class="tag is-info">無磁鏈</span>');
const safeMid = escapeAttr(String(m.id || ''));
html +=
'<div class="item" id="n_' + safeMid + '">' +
'<a href="/v/' + encodeURIComponent(String(m.id || '')) + '" class="box" title="' + escapeAttr(m.origin_title || m.title || '') + '">' +
'<div class="top250-rank-badge ' + rankCls + '">' + (rank === 1 ? '👑 ' : '') + '#' + rank + '</div>' +
'<div class="cover">' +
'<img loading="lazy" referrerpolicy="no-referrer" src="' + escapeAttr(coverSrc) + '"' +
(coverLargeSrc ? ' data-large="' + escapeAttr(coverLargeSrc) + '"' : '') +
(coverThumbSrc ? ' data-thumb="' + escapeAttr(coverThumbSrc) + '"' : '') +
' alt="" onerror="this.onerror=null;if(this.dataset.large&&this.src!==this.dataset.large){this.src=this.dataset.large;}else if(this.src.indexOf(\'/small_covers/\')!==-1){this.src=this.src.replace(\'/small_covers/\',\'/thumbs/\');}else if(this.src.indexOf(\'c0.jdbstatic.com\')===-1){this.src=this.src.replace(/https:\\/\\/.*?\\/rhe951l4q/g,\'https://c0.jdbstatic.com\');}">' +
'</div>' +
'<div class="video-title">' +
'<strong>' + escapeHtml(m.number || '') + '</strong> ' + escapeHtml(m.origin_title || m.title || '') +
'</div>' +
'<div class="score" id="n_score_' + safeMid + '">' +
'<span class="value"><span class="score-stars">' + starsHtml + '</span> ' + scoreVal + '分</span>' +
'</div>' +
'<div class="meta">' + escapeHtml(m.release_date || '') + '</div>' +
'<div class="tags has-addons">' + tagsHtml + (m.new_magnets ? ' <span class="tag is-info">今日新種</span>' : '') + '</div>' +
'</a>' +
'</div>';
});
if (listEl) listEl.innerHTML = html;
} catch (e) {
if (listEl) listEl.innerHTML = '<div style="padding:40px;text-align:center;color:#f87171;">获取失败: ' + escapeHtml(e.message) + '</div>';
}
}
if (nativeBox._nativeClickHandler) {
nativeBox.removeEventListener('click', nativeBox._nativeClickHandler);
}
nativeBox._nativeClickHandler = function (e) {
const pbBtn = e.target.closest('[data-n-pb]');
if (pbBtn) {
e.preventDefault();
nPeriod = pbBtn.dataset.nPb;
const tb = nativeBox.querySelector('#n-toolbar');
if (tb) tb.innerHTML = renderToolbar();
loadNative();
}
};
nativeBox.addEventListener('click', nativeBox._nativeClickHandler);
loadNative();
}
return {
mount: mount,
loadData: loadData,
hookNativePage: hookNativePage,
showLoginModal: showLoginModal,
abortSync: abortSync,
cacheMovieDetail: cacheMovieDetail,
getAllCachedDetails: getAllCachedDetails,
switchMode: function (m) {
abortSync();
applySubnavMode(m, currentContainer);
},
updateImgServer: updateImgServer,
fetchPlayback: fetchPlayback,
fetchTop: fetchTop,
syncAllTop250: syncAllTop250,
applyTop250RandomBg: applyTop250RandomBg,
getCachedMovies: getCachedMovies,
getFilter: getFilter,
setFilter: setFilter,
resetFilter: resetFilter
};
})();
/* =======================================================================
* 数据统计中心模块 (StatisticsCenter)
* ===================================================================== */
const StatisticsCenter = (function () {
async function collectStats() {
const stats = {
moviesCount: 0,
watchedCount: 0,
wantCount: 0,
fullDetailCount: 0,
fullDetailPercent: 0,
galleryCount: 0,
galleryCovers: 0,
galleryPreviews: 0,
notesCount: 0,
listsCount: 0,
actorsCount: 0,
correctionsCount: 0,
avgScore: '0.0',
scoreDistribution: {
god: 0, // >= 4.5
great: 0, // 4.0 - 4.4
good: 0, // 3.5 - 3.9
normal: 0, // < 3.5
none: 0 // 未评分
},
top250AwardsCount: 0,
topActresses: [], // [{ name, count }]
topMakers: [], // [{ name, count }]
topSeries: [], // [{ name, count }]
topDirectors: [], // [{ name, count }]
yearDistribution: [], // [{ year, count }]
system: {
version: VERSION,
activeTab: localStorage.getItem(HOME_TAB_KEY) || 'home',
skinEnabled: enabled,
dbStatus: '就绪 (IndexedDB)',
tokenPreview: (function () {
const t = localStorage.getItem('jhs_appAuthorization');
if (!t) return '未登录 (免登录模式)';
return t.slice(0, 8) + '...' + t.slice(-6);
})(),
hasSignature: !!localStorage.getItem('jb_jdsignature')
}
};
try {
if (typeof FAV !== 'undefined') {
const [allDataRes, galleryRes, notesRes, genderRes, allMoviesRes] = await Promise.allSettled([
FAV.getAll ? FAV.getAll() : Promise.resolve({ lists: [], videos: [] }),
FAV.dbAllGallery ? FAV.dbAllGallery() : Promise.resolve([]),
FAV.dbAllNotes ? FAV.dbAllNotes() : Promise.resolve([]),
FAV.dbAllActorGenders ? FAV.dbAllActorGenders() : Promise.resolve({}),
FAV.dbAllMovies ? FAV.dbAllMovies() : Promise.resolve([])
]);
const allData = allDataRes.status === 'fulfilled' ? (allDataRes.value || {}) : {};
const rawVideos = Array.isArray(allData.videos) ? allData.videos : [];
// 仅统计真实收藏(在清单中或有想看/看过标记)
const videos = rawVideos.filter(function (v) {
return (Array.isArray(v.listIds) && v.listIds.length > 0) || !!v.reviewStatus;
});
const lists = Array.isArray(allData.lists) ? allData.lists : [];
const gallery = galleryRes.status === 'fulfilled' ? (galleryRes.value || []) : [];
const notes = notesRes.status === 'fulfilled' ? (notesRes.value || []) : [];
const genderMap = genderRes.status === 'fulfilled' ? (genderRes.value || {}) : {};
const allDbMovies = allMoviesRes.status === 'fulfilled' ? (allMoviesRes.value || []) : [];
// 用户订正作品数(含有 customMeta 且有有效修改项)
let correctionsCount = 0;
allDbMovies.forEach(function (m) {
if (!m || !m.customMeta) return;
const cm = m.customMeta;
const hasActors = cm.actors && ((cm.actors.added && cm.actors.added.length > 0) || (cm.actors.deleted && cm.actors.deleted.length > 0));
const hasCategories = cm.categories && ((cm.categories.added && cm.categories.added.length > 0) || (cm.categories.deleted && cm.categories.deleted.length > 0));
if (hasActors || hasCategories || cm.director || cm.maker || cm.series) {
correctionsCount++;
}
});
// 封面统计口径:按已收藏预览图/样图对应的独立作品数量计算(一部作品即一个封面)
const galleryWorkCodes = new Set();
gallery.forEach(function (g) {
const c = g.code || (g.id ? String(g.id).split('_')[0] : '');
if (c) galleryWorkCodes.add(c.toUpperCase());
});
// 统计已看与想看作品数(优先遍历包含所有标记的 allDbMovies,降级为 videos)
let watchedCount = 0;
let wantCount = 0;
const targetMovieList = (allDbMovies && allDbMovies.length) ? allDbMovies : videos;
targetMovieList.forEach(function (v) {
if (!v) return;
if (v.reviewStatus === 'watched') {
watchedCount++;
} else if (v.reviewStatus === 'wishlist' || v.reviewStatus === 'want' || v.reviewStatus === 'wanted') {
wantCount++;
}
});
stats.moviesCount = videos.length;
stats.watchedCount = watchedCount;
stats.wantCount = wantCount;
stats.listsCount = lists.length;
stats.notesCount = notes.length;
stats.galleryCount = gallery.length;
stats.galleryCovers = galleryWorkCodes.size;
stats.galleryPreviews = gallery.length;
stats.correctionsCount = correctionsCount;
let fullCnt = 0;
let totalScore = 0;
let scoredCount = 0;
const actressMap = {}, makerMap = {}, seriesMap = {}, directorMap = {}, yearMap = {};
const actressViewsMap = {};
const uniqueActressesSet = new Set();
let top250Cnt = 0;
videos.forEach(function (v) {
const mk = getEntityText(v.maker);
const sr = getEntityText(v.series);
const dr = getEntityText(v.director);
const mActors = Array.isArray(v.actors) ? v.actors : (v.actor ? (Array.isArray(v.actor) ? v.actor : [v.actor]) : []);
const hasActors = mActors.length > 0;
const isComplete = !!(v.detailComplete || v.isFull || v.detailFetched || (hasActors && mk));
if (isComplete) fullCnt++;
const rawScore = getMovieScore(v);
if (!isNaN(rawScore) && rawScore > 0) {
totalScore += rawScore;
scoredCount++;
if (rawScore >= 4.5) stats.scoreDistribution.god++;
else if (rawScore >= 4.0) stats.scoreDistribution.great++;
else if (rawScore >= 3.5) stats.scoreDistribution.good++;
else stats.scoreDistribution.normal++;
} else {
stats.scoreDistribution.none++;
}
if ((v.awards && Array.isArray(v.awards) && v.awards.length) || v._topRank || v.isTop250) {
top250Cnt++;
}
// 直接依托数据库中演员性别表 (genderMap[slug] === 'M') 剔除男优,纯正统计女优
mActors.forEach(function (a) {
const name = getActorName(a);
if (!name) return;
if (a && typeof a === 'object') {
const slugMatch = (a.href || '').match(/\/actors\/([^\/\?\#]+)/);
const slug = slugMatch ? slugMatch[1] : (a.slug || '');
if (slug && genderMap[slug] === 'M') return;
}
uniqueActressesSet.add(name);
actressMap[name] = (actressMap[name] || 0) + 1;
// 累计常浏览女优热度(作品真实浏览次数 views)
const vViews = (typeof v.views === 'number' && v.views > 0) ? v.views : 0;
if (vViews > 0) {
actressViewsMap[name] = (actressViewsMap[name] || 0) + vViews;
}
});
if (mk) makerMap[mk] = (makerMap[mk] || 0) + 1;
if (sr) seriesMap[sr] = (seriesMap[sr] || 0) + 1;
if (dr) directorMap[dr] = (directorMap[dr] || 0) + 1;
const d = v.date || v.release_date || '';
const yMatch = String(d).match(/(19\d\d|20\d\d)/);
const yr = yMatch ? yMatch[1] : '';
if (yr) yearMap[yr] = (yearMap[yr] || 0) + 1;
});
stats.fullDetailCount = fullCnt;
stats.fullDetailPercent = videos.length ? Math.round((fullCnt / videos.length) * 100) : 0;
stats.avgScore = scoredCount ? (totalScore / scoredCount).toFixed(2) : '0.0';
stats.top250AwardsCount = top250Cnt;
stats.actorsCount = uniqueActressesSet.size;
function toTopList(map, limit) {
return Object.keys(map)
.map(function (k) { return { name: k, count: map[k] }; })
.sort(function (a, b) { return b.count - a.count; })
.slice(0, limit);
}
stats.topActresses = toTopList(actressMap, 10);
stats.topMakers = toTopList(makerMap, 5);
stats.topSeries = toTopList(seriesMap, 5);
stats.topDirectors = toTopList(directorMap, 5);
// 常浏览女优 TOP 5 (浏览量降序)
stats.topFrequentActresses = Object.keys(actressViewsMap)
.filter(function (k) { return actressViewsMap[k] > 0; })
.map(function (k) { return { name: k, count: actressViewsMap[k] }; })
.sort(function (a, b) { return b.count - a.count; })
.slice(0, 5);
stats.yearDistribution = Object.keys(yearMap)
.map(function (k) { return { year: k, count: yearMap[k] }; })
.sort(function (a, b) { return b.year.localeCompare(a.year); })
.slice(0, 6);
}
} catch (e) {
log('统计数据提取异常: ' + e.message);
}
return stats;
}
async function renderPanel(container) {
if (!container) return;
container.innerHTML = '<div style="padding:40px;text-align:center;color:#94a3b8;"><span style="display:inline-block;animation:spin 1s linear infinite;font-size:24px;margin-bottom:8px;">⏳</span><br>正在汇总媒体库与数据库各项统计指标...</div>';
try {
const data = await collectStats();
const sc = data.scoreDistribution;
const totalScored = (data.moviesCount - sc.none) || 1;
function pct(val, tot) {
return tot ? Math.round((val / tot) * 100) : 0;
}
let html = '<div style="display:flex;flex-direction:column;gap:16px;font-family:-apple-system,BlinkMacSystemFont,\"Segoe UI\",Roboto,sans-serif;">';
// 顶栏与刷新按钮
html += '<div style="display:flex;align-items:center;justify-content:space-between;padding-bottom:12px;border-bottom:1px solid rgba(255,255,255,.08);">' +
'<div>' +
'<div style="font-size:16px;font-weight:700;color:#fff;display:flex;align-items:center;gap:8px;">' +
'<span class="material-symbols-outlined" style="color:#d946ef;font-size:22px;">query_stats</span> 媒体库数据统计中心' +
'</div>' +
'<div style="font-size:12px;color:#94a3b8;margin-top:2px;">基于本地 IndexedDB 数据库的收藏资产与画像分析</div>' +
'</div>' +
'<button type="button" id="stat-btn-refresh" class="jhs-btn jhs-btn-secondary" style="font-size:12px;padding:6px 12px;display:flex;align-items:center;gap:4px;">' +
'<span class="material-symbols-outlined" style="font-size:16px;">refresh</span> 重新统计' +
'</button>' +
'</div>';
// 1. 核心 KPI 矩阵 (含观影标记共8项)
html += '<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:10px;">' +
'<div style="background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.08);border-radius:10px;padding:12px;text-align:center;">' +
'<div style="font-size:11px;color:#94a3b8;margin-bottom:4px;">收藏影片总数</div>' +
'<div style="font-size:22px;font-weight:800;color:#00a4dc;">' + data.moviesCount + '</div>' +
'<div style="font-size:11px;color:#64748b;margin-top:2px;">部影片入库</div>' +
'</div>' +
'<div style="background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.08);border-radius:10px;padding:10px 12px;text-align:center;display:flex;flex-direction:column;justify-content:center;">' +
'<div style="font-size:11px;color:#94a3b8;margin-bottom:6px;display:flex;align-items:center;justify-content:center;gap:4px;">' +
'<span class="material-symbols-outlined" style="font-size:13px;color:#10b981;">观影标记</span>' +
'</div>' +
'<div style="display:flex;flex-direction:column;gap:4px;">' +
'<div style="display:flex;align-items:center;justify-content:space-between;padding:3px 8px;background:rgba(16,185,129,.08);border-radius:6px;border:1px solid rgba(16,185,129,.18);">' +
'<span style="font-size:11px;color:#cbd5e1;display:flex;align-items:center;gap:4px;">' +
'<span class="material-symbols-outlined" style="font-size:14px;color:#10b981;">check_circle</span> 已看' +
'</span>' +
'<span style="font-size:13px;font-weight:800;color:#10b981;">' + (data.watchedCount || 0) + ' <span style="font-size:10px;font-weight:400;color:#94a3b8;">部</span></span>' +
'</div>' +
'<div style="display:flex;align-items:center;justify-content:space-between;padding:3px 8px;background:rgba(245,158,11,.08);border-radius:6px;border:1px solid rgba(245,158,11,.18);">' +
'<span style="font-size:11px;color:#cbd5e1;display:flex;align-items:center;gap:4px;">' +
'<span class="material-symbols-outlined" style="font-size:14px;color:#f59e0b;">bookmark</span> 想看' +
'</span>' +
'<span style="font-size:13px;font-weight:800;color:#f59e0b;">' + (data.wantCount || 0) + ' <span style="font-size:10px;font-weight:400;color:#94a3b8;">部</span></span>' +
'</div>' +
'</div>' +
'<div style="font-size:10px;color:#64748b;margin-top:4px;">共 ' + ((data.watchedCount || 0) + (data.wantCount || 0)) + ' 部已标记</div>' +
'</div>' +
'<div style="background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.08);border-radius:10px;padding:12px;text-align:center;">' +
'<div style="font-size:11px;color:#94a3b8;margin-bottom:4px;">详情补全率</div>' +
'<div style="font-size:22px;font-weight:800;color:#10b981;">' + data.fullDetailPercent + '%</div>' +
'<div style="font-size:11px;color:#64748b;margin-top:2px;">' + data.fullDetailCount + ' / ' + data.moviesCount + ' 已补全</div>' +
'</div>' +
'<div style="background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.08);border-radius:10px;padding:12px;text-align:center;">' +
'<div style="font-size:11px;color:#94a3b8;margin-bottom:4px;">预览收藏</div>' +
'<div style="font-size:22px;font-weight:800;color:#f59e0b;">' + data.galleryCount + '</div>' +
'<div style="font-size:11px;color:#64748b;margin-top:2px;">' + data.galleryCovers + ' 封面 · ' + data.galleryPreviews + ' 样图</div>' +
'</div>' +
'<div style="background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.08);border-radius:10px;padding:12px;text-align:center;">' +
'<div style="font-size:11px;color:#94a3b8;margin-bottom:4px;">收录女优</div>' +
'<div style="font-size:22px;font-weight:800;color:#cf3d35;">' + data.actorsCount + '</div>' +
'<div style="font-size:11px;color:#64748b;margin-top:2px;">位专属女优</div>' +
'</div>' +
'<div style="background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.08);border-radius:10px;padding:12px;text-align:center;">' +
'<div style="font-size:11px;color:#94a3b8;margin-bottom:4px;">自定义笔记</div>' +
'<div style="font-size:22px;font-weight:800;color:#38bdf8;">' + data.notesCount + '</div>' +
'<div style="font-size:11px;color:#64748b;margin-top:2px;">条专属备忘</div>' +
'</div>' +
'<div style="background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.08);border-radius:10px;padding:12px;text-align:center;">' +
'<div style="font-size:11px;color:#94a3b8;margin-bottom:4px;">清单数</div>' +
'<div style="font-size:22px;font-weight:800;color:#a78bfa;">' + data.listsCount + '</div>' +
'<div style="font-size:11px;color:#64748b;margin-top:2px;">自建清单</div>' +
'</div>' +
'<div style="background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.08);border-radius:10px;padding:12px;text-align:center;">' +
'<div style="font-size:11px;color:#94a3b8;margin-bottom:4px;">用户订正</div>' +
'<div style="font-size:22px;font-weight:800;color:#22c55e;">' + (data.correctionsCount || 0) + '</div>' +
'<div style="font-size:11px;color:#64748b;margin-top:2px;">部订正作品</div>' +
'</div>' +
'</div>';
// 2. 评分阶梯分布 & 荣誉与年代
html += '<div style="display:grid;grid-template-columns:1.2fr 0.8fr;gap:12px;">' +
'<div style="background:rgba(255,255,255,.03);border:1px solid rgba(255,255,255,.08);border-radius:10px;padding:14px;">' +
'<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;">' +
'<span style="font-size:13px;font-weight:700;color:#fff;display:flex;align-items:center;gap:6px;"><span class="material-symbols-outlined" style="color:#f59e0b;font-size:18px;">star</span> 评分阶梯分布</span>' +
'<span style="font-size:12px;color:#f59e0b;font-weight:700;">均分 ' + data.avgScore + '</span>' +
'</div>' +
'<div style="display:flex;flex-direction:column;gap:8px;font-size:12px;">' +
'<div style="display:flex;align-items:center;gap:8px;"><span style="width:72px;color:#94a3b8;">4.5+ 神作</span><div style="flex:1;height:8px;background:rgba(255,255,255,.06);border-radius:4px;overflow:hidden;"><div style="width:' + pct(sc.god, totalScored) + '%;height:100%;background:#f59e0b;border-radius:4px;"></div></div><span style="width:48px;text-align:right;color:#cbd5e1;">' + sc.god + ' (' + pct(sc.god, totalScored) + '%)</span></div>' +
'<div style="display:flex;align-items:center;gap:8px;"><span style="width:72px;color:#94a3b8;">4.0~4.4 优秀</span><div style="flex:1;height:8px;background:rgba(255,255,255,.06);border-radius:4px;overflow:hidden;"><div style="width:' + pct(sc.great, totalScored) + '%;height:100%;background:#10b981;border-radius:4px;"></div></div><span style="width:48px;text-align:right;color:#cbd5e1;">' + sc.great + ' (' + pct(sc.great, totalScored) + '%)</span></div>' +
'<div style="display:flex;align-items:center;gap:8px;"><span style="width:72px;color:#94a3b8;">3.5~3.9 良好</span><div style="flex:1;height:8px;background:rgba(255,255,255,.06);border-radius:4px;overflow:hidden;"><div style="width:' + pct(sc.good, totalScored) + '%;height:100%;background:#00a4dc;border-radius:4px;"></div></div><span style="width:48px;text-align:right;color:#cbd5e1;">' + sc.good + ' (' + pct(sc.good, totalScored) + '%)</span></div>' +
'<div style="display:flex;align-items:center;gap:8px;"><span style="width:72px;color:#94a3b8;"><3.5 普通</span><div style="flex:1;height:8px;background:rgba(255,255,255,.06);border-radius:4px;overflow:hidden;"><div style="width:' + pct(sc.normal, totalScored) + '%;height:100%;background:#64748b;border-radius:4px;"></div></div><span style="width:48px;text-align:right;color:#cbd5e1;">' + sc.normal + ' (' + pct(sc.normal, totalScored) + '%)</span></div>' +
'</div>' +
'</div>' +
'<div style="background:rgba(255,255,255,.03);border:1px solid rgba(255,255,255,.08);border-radius:10px;padding:14px;">' +
'<div style="font-size:13px;font-weight:700;color:#fff;margin-bottom:10px;display:flex;align-items:center;gap:6px;">' +
'<span class="material-symbols-outlined" style="color:#fed368;font-size:18px;">workspace_premium</span> 榜单荣誉与年代' +
'</div>' +
'<div style="display:flex;align-items:center;justify-content:space-between;background:rgba(254,211,104,.08);border:1px solid rgba(254,211,104,.2);border-radius:8px;padding:10px;margin-bottom:10px;">' +
'<div><div style="font-size:13px;font-weight:700;color:#fed368;">TOP250 金标影片</div><div style="font-size:11px;color:#94a3b8;">收藏库中入选 JAVDB TOP250</div></div>' +
'<div style="font-size:20px;font-weight:800;color:#fed368;">' + data.top250AwardsCount + ' 部</div>' +
'</div>' +
'<div style="display:flex;gap:6px;flex-wrap:wrap;">' +
data.yearDistribution.map(function (y) {
return '<div style="background:rgba(255,255,255,.05);padding:3px 8px;border-radius:6px;font-size:11px;color:#cbd5e1;">' + y.year + '年: <b style="color:#fff;">' + y.count + '</b></div>';
}).join('') +
'</div>' +
'</div>' +
'</div>';
// 3. TOP 排行榜三列网格 (1列: 收藏女优TOP10; 2列中列: 常浏览女优TOP5+导演TOP5; 3列: 片商TOP5+系列TOP5)
html += '<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;">' +
// 1列:收藏女优 TOP 10(纯正排除男优,宝石红高亮)
'<div style="background:rgba(255,255,255,.03);border:1px solid rgba(255,255,255,.08);border-radius:10px;padding:14px;">' +
'<div style="font-size:13px;font-weight:700;color:#fff;margin-bottom:10px;display:flex;align-items:center;gap:6px;">' +
'<span class="material-symbols-outlined" style="color:#cf3d35;font-size:18px;">favorite</span> 收藏女优 TOP 10' +
'</div>' +
'<div style="display:flex;flex-direction:column;gap:6px;">' +
(data.topActresses.length ? data.topActresses.map(function (a, i) {
const color = i === 0 ? '#f59e0b' : (i === 1 ? '#cbd5e1' : (i === 2 ? '#b45309' : '#94a3b8'));
return '<div title="' + escapeAttr(a.name) + '" style="display:flex;align-items:center;justify-content:space-between;padding:4px 8px;background:rgba(255,255,255,.02);border-radius:6px;font-size:12px;cursor:default;">' +
'<span style="color:' + color + ';font-weight:700;width:20px;">#' + (i + 1) + '</span>' +
'<span title="' + escapeAttr(a.name) + '" style="flex:1;color:#f1f5f9;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin:0 6px;">' + escapeHtml(a.name) + '</span>' +
'<span style="color:#cf3d35;font-weight:600;">' + a.count + '部</span>' +
'</div>';
}).join('') : '<div style="color:#64748b;font-size:12px;">暂无女优数据</div>') +
'</div>' +
'</div>' +
// 2列(中列):常浏览女优 TOP 5 置于上方,收藏导演 TOP 5 置于下方
'<div style="background:rgba(255,255,255,.03);border:1px solid rgba(255,255,255,.08);border-radius:10px;padding:14px;display:flex;flex-direction:column;gap:12px;">' +
'<div>' +
'<div style="font-size:13px;font-weight:700;color:#fff;margin-bottom:8px;display:flex;align-items:center;gap:6px;">' +
'<span class="material-symbols-outlined" style="color:#ec4899;font-size:18px;">visibility</span> 常浏览女优 TOP 5' +
'</div>' +
'<div style="display:flex;flex-direction:column;gap:5px;">' +
(data.topFrequentActresses && data.topFrequentActresses.length ? data.topFrequentActresses.map(function (fa, i) {
const color = i === 0 ? '#f59e0b' : (i === 1 ? '#cbd5e1' : (i === 2 ? '#b45309' : '#94a3b8'));
return '<div title="' + escapeAttr(fa.name) + '" style="display:flex;align-items:center;justify-content:space-between;padding:3px 6px;background:rgba(255,255,255,.02);border-radius:6px;font-size:12px;cursor:default;">' +
'<span style="color:' + color + ';font-weight:700;width:20px;">#' + (i + 1) + '</span>' +
'<span title="' + escapeAttr(fa.name) + '" style="flex:1;color:#f1f5f9;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin:0 4px;">' + escapeHtml(fa.name) + '</span>' +
'<span style="color:#ec4899;font-weight:600;">' + fa.count + '次浏览</span>' +
'</div>';
}).join('') : '<div style="color:#64748b;font-size:12px;">暂无浏览记录 (点击卡片进入详情时自动累计)</div>') +
'</div>' +
'</div>' +
'<div>' +
'<div style="font-size:13px;font-weight:700;color:#fff;margin-bottom:8px;display:flex;align-items:center;gap:6px;">' +
'<span class="material-symbols-outlined" style="color:#a78bfa;font-size:18px;">movie_filter</span> 收藏导演 TOP 5' +
'</div>' +
'<div style="display:flex;flex-direction:column;gap:5px;">' +
(data.topDirectors.length ? data.topDirectors.map(function (d, i) {
const color = i === 0 ? '#f59e0b' : (i === 1 ? '#cbd5e1' : (i === 2 ? '#b45309' : '#94a3b8'));
return '<div title="' + escapeAttr(d.name) + '" style="display:flex;align-items:center;justify-content:space-between;padding:3px 6px;background:rgba(255,255,255,.02);border-radius:6px;font-size:12px;cursor:default;">' +
'<span style="color:' + color + ';font-weight:700;width:20px;">#' + (i + 1) + '</span>' +
'<span title="' + escapeAttr(d.name) + '" style="flex:1;color:#f1f5f9;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin:0 4px;">' + escapeHtml(d.name) + '</span>' +
'<span style="color:#a78bfa;font-weight:600;">' + d.count + '部</span>' +
'</div>';
}).join('') : '<div style="color:#64748b;font-size:12px;">暂无导演数据 (可先在收藏夹补全详情)</div>') +
'</div>' +
'</div>' +
'</div>' +
// 3列(右列):片商 TOP 5 & 系列 TOP 5
'<div style="background:rgba(255,255,255,.03);border:1px solid rgba(255,255,255,.08);border-radius:10px;padding:14px;display:flex;flex-direction:column;gap:12px;">' +
'<div>' +
'<div style="font-size:13px;font-weight:700;color:#fff;margin-bottom:8px;display:flex;align-items:center;gap:6px;">' +
'<span class="material-symbols-outlined" style="color:#00a4dc;font-size:18px;">domain</span> 收藏片商 TOP 5' +
'</div>' +
'<div style="display:flex;flex-direction:column;gap:5px;">' +
(data.topMakers.length ? data.topMakers.map(function (m, i) {
const color = i === 0 ? '#f59e0b' : (i === 1 ? '#cbd5e1' : (i === 2 ? '#b45309' : '#94a3b8'));
return '<div title="' + escapeAttr(m.name) + '" style="display:flex;align-items:center;justify-content:space-between;padding:3px 6px;background:rgba(255,255,255,.02);border-radius:6px;font-size:12px;cursor:default;">' +
'<span style="color:' + color + ';font-weight:700;width:20px;">#' + (i + 1) + '</span>' +
'<span title="' + escapeAttr(m.name) + '" style="flex:1;color:#f1f5f9;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin:0 4px;">' + escapeHtml(m.name) + '</span>' +
'<span style="color:#00a4dc;font-weight:600;">' + m.count + '部</span>' +
'</div>';
}).join('') : '<div style="color:#64748b;font-size:12px;">暂无片商数据</div>') +
'</div>' +
'</div>' +
'<div>' +
'<div style="font-size:13px;font-weight:700;color:#fff;margin-bottom:8px;display:flex;align-items:center;gap:6px;">' +
'<span class="material-symbols-outlined" style="color:#10b981;font-size:18px;">view_carousel</span> 收藏系列 TOP 5' +
'</div>' +
'<div style="display:flex;flex-direction:column;gap:5px;">' +
(data.topSeries.length ? data.topSeries.map(function (s, i) {
const color = i === 0 ? '#f59e0b' : (i === 1 ? '#cbd5e1' : (i === 2 ? '#b45309' : '#94a3b8'));
return '<div title="' + escapeAttr(s.name) + '" style="display:flex;align-items:center;justify-content:space-between;padding:3px 6px;background:rgba(255,255,255,.02);border-radius:6px;font-size:12px;cursor:default;">' +
'<span style="color:' + color + ';font-weight:700;width:20px;">#' + (i + 1) + '</span>' +
'<span title="' + escapeAttr(s.name) + '" style="flex:1;color:#f1f5f9;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin:0 4px;">' + escapeHtml(s.name) + '</span>' +
'<span style="color:#10b981;font-weight:600;">' + s.count + '部</span>' +
'</div>';
}).join('') : '<div style="color:#64748b;font-size:12px;">暂无系列数据</div>') +
'</div>' +
'</div>' +
'</div>' +
'</div>';
// 4. 存储与系统状态栏
html += '<div style="background:rgba(255,255,255,.02);border:1px solid rgba(255,255,255,.06);border-radius:8px;padding:10px 14px;display:flex;align-items:center;justify-content:space-between;font-size:11.5px;color:#94a3b8;">' +
'<span>IndexedDB: javdb_emby_db (正常就绪) | 皮肤版本: v' + data.system.version + '</span>' +
'<span>移动端授权: ' + data.system.tokenPreview + '</span>' +
'</div>';
html += '</div>';
container.innerHTML = html;
const rf = container.querySelector('#stat-btn-refresh');
if (rf) rf.addEventListener('click', function () { renderPanel(container); });
} catch (err) {
container.innerHTML = '<div style="padding:30px;text-align:center;color:#ef4444;">统计加载失败: ' + escapeHtml(err.message) + '</div>';
}
}
return {
collectStats: collectStats,
renderPanel: renderPanel,
showModal: function () {
if (typeof openSettingsPanel === 'function') openSettingsPanel('diagnostics');
}
};
})();
const PluginDiagnostics = StatisticsCenter;
/* =======================================================================
* 标题翻译服务 (Google 免费接口 + 本地缓存)
* ===================================================================== */
async function translateText(text, fromLang, toLang) {
if (!text || !text.trim()) return '';
const src = text.trim();
fromLang = fromLang || 'ja';
toLang = toLang || 'zh-CN';
const cacheKey = 'jhs_translate_v2';
let cache = {};
try { cache = JSON.parse(localStorage.getItem(cacheKey) || '{}'); } catch (e) {}
if (cache[src]) return cache[src];
let trans = '';
try {
const apiUrl = 'https://translate-pa.googleapis.com/v1/translate?' + new URLSearchParams({
'params.client': 'gtx',
dataTypes: 'TRANSLATION',
key: 'AIzaSyDLEeFI5OtFBwYBIoK_jj5m32rZK5CkCXA',
'query.sourceLanguage': fromLang,
'query.targetLanguage': toLang,
'query.text': src
});
const res = await gmHttp.get(apiUrl);
const json = JSON.parse(res);
trans = json && json.translation ? json.translation.trim() : '';
} catch (e1) {
try {
const fallbackUrl = 'https://translate.googleapis.com/translate_a/single?client=gtx&sl=' +
encodeURIComponent(fromLang) + '&tl=' + encodeURIComponent(toLang) + '&dt=t&q=' + encodeURIComponent(src);
const res2 = await gmHttp.get(fallbackUrl);
const json2 = JSON.parse(res2);
if (json2 && json2[0] && json2[0][0] && json2[0][0][0]) trans = json2[0][0][0].trim();
} catch (e2) {}
}
if (trans) {
try {
const keys = Object.keys(cache);
if (keys.length > 500) {
for (let i = 0; i < 100 && i < keys.length; i++) delete cache[keys[i]];
}
cache[src] = trans;
localStorage.setItem(cacheKey, JSON.stringify(cache));
} catch (e) {}
}
return trans;
}
/* =======================================================================
* 女优信息与现役/退役检测服务 (jsDelivr CDN 自建库 + 维基百科回退 + 7天缓存)
* ===================================================================== */
const ActressService = {
repoBase: 'https://cdn.jsdelivr.net/gh/lesspem/JAV_info@main/dist/',
repoBaseFallback: 'https://raw.githubusercontent.com/lesspem/JAV_info/main/dist/',
_idxMem: null,
_shardMem: {},
_cacheKey: 'jhs_actress_info_v2',
getLocalCache: function () {
try { return JSON.parse(localStorage.getItem(this._cacheKey) || '{}'); } catch (e) { return {}; }
},
saveLocalCache: function (cache) {
try {
if (cache && typeof cache === 'object') {
const keys = Object.keys(cache);
if (keys.length > 300) {
for (let i = 0; i < 60 && i < keys.length; i++) delete cache[keys[i]];
}
}
localStorage.setItem(this._cacheKey, JSON.stringify(cache));
} catch (e) {}
},
getRepoIndex: async function () {
if (this._idxMem) return this._idxMem;
const ck = 'jhs_repo_index_cache';
try {
const raw = localStorage.getItem(ck);
if (raw) {
const o = JSON.parse(raw);
if (o && o.t && Date.now() - o.t < 6048e5 && o.d) {
this._idxMem = o.d;
return o.d;
}
}
} catch (e) {}
let txt = '';
for (const base of [this.repoBase, this.repoBaseFallback]) {
try { txt = await gmHttp.get(base + 'index.json'); if (txt) break; } catch (e) {}
}
if (!txt) return null;
try {
const d = JSON.parse(txt);
this._idxMem = d;
try { localStorage.setItem(ck, JSON.stringify({ t: Date.now(), d: d })); } catch (e) {}
return d;
} catch (e) { return null; }
},
lookupRepo: async function (name) {
if (!name) return null;
const idx = await this.getRepoIndex();
if (!idx) return null;
const actors = idx.actors || {};
const aliases = idx.aliases || {};
// 原生别名拆分匹配,无需任何手工繁简字表
const names = String(name).split(/[,,\/]/).map(s => s.trim()).filter(Boolean);
if (!names.includes(name.trim())) names.unshift(name.trim());
let key = null;
for (const n of names) {
if (actors.hasOwnProperty(n)) { key = n; break; }
if (aliases.hasOwnProperty(n)) { key = aliases[n]; break; }
}
if (!key) return null;
const sid = String(actors[key]).padStart(2, '0');
let shard = this._shardMem[sid];
if (!shard) {
let txt = '';
for (const base of [this.repoBase, this.repoBaseFallback]) {
try { txt = await gmHttp.get(base + 'shard-' + sid + '.json'); if (txt) break; } catch (e) {}
}
if (!txt) return null;
try { shard = JSON.parse(txt); this._shardMem[sid] = shard; } catch (e) { return null; }
}
const rec = shard[key];
if (!rec) return null;
const b = (rec.birth || '').trim();
let birthday = '', age = '';
const m = /^(\d{4})-(\d{2}|\?\?)-(\d{2}|\?\?)$/.exec(b);
if (m) {
birthday = b.replace(/-/g, '.');
const y = parseInt(m[1], 10);
if (!isNaN(y)) age = (new Date().getFullYear() - y) + '岁';
}
return {
birthday: birthday,
age: age,
height: rec.height ? rec.height + 'cm' : '',
weight: rec.weight ? rec.weight + 'kg' : '',
threeSizeText: rec.bust && rec.waist && rec.hip ? (rec.bust + '-' + rec.waist + '-' + rec.hip) : '',
braSize: rec.cup ? (rec.cup + '罩杯') : '',
activeRaw: rec.active || '',
retireRaw: rec.retire || '',
bodyRetired: false,
primaryKey: key,
url: 'https://ja.wikipedia.org/wiki/' + encodeURIComponent(key)
};
},
lookupWiki: async function (name) {
if (!name) return null;
let targetName = name;
if (targetName === '三上悠亞') targetName = '三上悠亜';
const wikiUrl = 'https://ja.wikipedia.org/wiki/' + encodeURIComponent(targetName);
try {
const html = await gmHttp.get(wikiUrl);
const doc = new DOMParser().parseFromString(html, 'text/html');
const pick = function (sel, fn) {
try {
const el = doc.querySelector(sel);
return el ? fn(el).trim() : '';
} catch (e) { return ''; }
};
const birthday = pick('a[title="誕生日"]', el => el.closest('tr') ? el.closest('tr').querySelector('td').textContent : '');
const ageRaw = pick('th', el => el.textContent.includes('現年齢') ? el.parentNode.querySelector('td').textContent : '');
const age = ageRaw && !isNaN(parseInt(ageRaw, 10)) ? (parseInt(ageRaw, 10) + '岁') : '';
const heightRaw = pick('tr:has(a[title="身長"]) td', el => el.textContent);
const height = heightRaw ? (heightRaw.split(' ')[0] + 'cm') : '';
const activeRaw = pick('th', el => el.textContent.includes('活動期間') ? el.parentNode.querySelector('td').textContent : '');
const retireRaw = pick('th', el => (el.textContent.includes('引退') || el.textContent.includes('退役')) ? el.parentNode.querySelector('td').textContent : '');
const bodyText = doc.querySelector('#mw-content-text') ? doc.querySelector('#mw-content-text').textContent : '';
const bodyRetired = /元AV女優|元セクシー女優|AV引退|AV女優を引退|完全引退/.test(bodyText);
return {
birthday: birthday,
age: age,
height: height,
weight: '',
threeSizeText: '',
braSize: '',
activeRaw: activeRaw,
retireRaw: retireRaw,
bodyRetired: bodyRetired,
url: wikiUrl
};
} catch (e) { return null; }
},
computeStatus: function (data) {
if (!data) return '现役';
const retireRaw = (data.retireRaw || '').trim();
if (retireRaw && retireRaw !== '―' && retireRaw !== '-' && retireRaw !== '?' && retireRaw !== '?') return '引退';
if (data.bodyRetired) return '引退';
const activeRaw = data.activeRaw || '';
if (!activeRaw) return '现役';
if (/引退|退役/.test(activeRaw)) return '引退';
if (/現在|現役|活動中|継続中/.test(activeRaw)) return '现役';
const years = activeRaw.match(/\d{4}/g);
if (!years || years.length < 2) return '现役';
const endYear = parseInt(years[years.length - 1], 10);
if (isNaN(endYear)) return '现役';
const mmMatch = activeRaw.match(new RegExp(endYear + "\\D+?(\\d{1,2})月"));
const endMonth = mmMatch ? (parseInt(mmMatch[1], 10) - 1) : 11;
const endDate = new Date(endYear, endMonth + 1, 0);
const halfYearAgo = new Date();
halfYearAgo.setMonth(halfYearAgo.getMonth() - 6);
return endDate < halfYearAgo ? '引退' : '现役';
},
getActressData: async function (name) {
if (!name) return null;
const cache = this.getLocalCache();
const cached = cache[name];
if (cached && cached._ts && (Date.now() - cached._ts < 7 * 86400000)) return cached;
let data = await this.lookupRepo(name);
if (!data) data = await this.lookupWiki(name);
if (data) {
data.status = this.computeStatus(data);
data._ts = Date.now();
cache[name] = data;
this.saveLocalCache(cache);
return data;
}
return cached || null;
}
};
/* =======================================================================
* 已收藏女优管理器 (FavoriteActressManager - 工业级高可靠抓取与双向索引)
* 1. 采用不可变 starId 为主键 + 演员名字/别名索引双重保障
* 2. 网络层严格携带 credentials: 'same-origin' 并透传反爬/未登录错误诊断
* 3. 演员主页(/actors/{id})即时自愈嗅探已收藏状态与按钮监听,毫秒级自动收录
* 4. 全量深层分页同步(支持多达 300 页海量收藏,彻底解决重度收藏漏爬)
* 5. 自动剔除系统无效词汇(如“刪除”、“有碼”等)并联动本地已知别名索引
* 6. 详情页 Chip 渲染与悬浮卡片统一双向识别,杜绝漏判
* ===================================================================== */
const FavoriteActressManager = (function () {
const STORAGE_KEY = 'javdb_fav_actresses';
const LAST_SYNC_KEY = 'javdb_fav_actresses_sync_time';
const IGNORE_STRINGS = new Set([
'刪除', '取消收藏', '收藏', '全部', '演員', '女優', '有碼', '無碼', '歐美', '男優',
'censored', 'uncensored', 'western', 'rankings', 'actors', 'tags', 'makers', 'series',
'undefined', 'null'
]);
let memFavs = null; // Set of starIds & normalized names
function getFavSet() {
if (memFavs) return memFavs;
memFavs = new Set();
let cleaned = false;
try {
const raw = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
if (Array.isArray(raw)) raw.forEach(item => {
let str = '';
if (typeof item === 'string') str = item.trim();
else if (item && typeof item === 'object') {
if (item.starId) str = String(item.starId).trim();
else if (item.name) str = String(item.name).trim();
}
if (str && !IGNORE_STRINGS.has(str) && str.length >= 2) {
memFavs.add(str);
} else if (str) {
cleaned = true;
}
});
} catch (e) {}
if (cleaned) saveFavSet();
return memFavs;
}
function saveFavSet() {
if (!memFavs) return;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(Array.from(memFavs)));
} catch (e) {}
}
function isFavorite(nameOrId) {
if (!nameOrId) return false;
const s = getFavSet();
const val = String(nameOrId).trim();
if (!val || IGNORE_STRINGS.has(val)) return false;
if (s.has(val)) return true;
// 兼容可能带有的 ♀ ♂ 符号或者空格
const clean = val.replace(/[\s\u2640\u2642]/g, '');
if (clean && s.has(clean)) return true;
// 智能别名联动(例如 田中レモン ↔ 楓カレン / 河北彩花 ↔ 河北彩伽)
if (typeof ActressService !== 'undefined' && ActressService._idxMem) {
const idx = ActressService._idxMem;
if (idx && idx.aliases) {
const primary1 = idx.aliases[val];
if (primary1 && (s.has(primary1) || s.has(primary1.replace(/[\s\u2640\u2642]/g, '')))) return true;
const primary2 = clean ? idx.aliases[clean] : '';
if (primary2 && (s.has(primary2) || s.has(primary2.replace(/[\s\u2640\u2642]/g, '')))) return true;
}
}
return false;
}
function toggleFavorite(name, starId) {
const s = getFavSet();
const current = (starId && isFavorite(starId)) || (name && isFavorite(name));
const willFav = !current;
const keys = [];
if (starId && !IGNORE_STRINGS.has(starId)) keys.push(String(starId).trim());
if (name) {
const n = String(name).trim();
if (!IGNORE_STRINGS.has(n)) {
keys.push(n);
const clean = n.replace(/[\s\u2640\u2642]/g, '');
if (clean && clean !== n && !IGNORE_STRINGS.has(clean)) keys.push(clean);
}
}
keys.forEach(k => {
if (willFav) s.add(k); else s.delete(k);
});
saveFavSet();
// 即时通知当前页面上所有匹配的头像徽章更新
markBadges(document);
return willFav;
}
// 给指定容器内的所有演员 Chip 头像打上红心徽章 (以 starId 为主, 名字为辅;受「女优信息与收藏标记」开关控制)
function markBadges(root) {
const container = root || document;
const showFavHeart = isPluginEnabled('actressInfo');
container.querySelectorAll('.emby-actor-chip').forEach(function (chip) {
const name = (chip.getAttribute('data-actor-name') || (chip.querySelector('.nm') ? chip.querySelector('.nm').textContent : '')).trim();
const href = chip.getAttribute('href') || '';
const m = href.match(/\/actors\/([^\/\?\#]+)/);
const starId = m ? m[1] : '';
const ava = chip.querySelector('.ava');
if (ava) {
const favored = showFavHeart && ((starId && isFavorite(starId)) || (name && isFavorite(name)));
ava.classList.toggle('is-favorite', favored);
}
});
}
// 演员个人主页(/actors/{id})即时嗅探自愈与按钮事件联动
function autoHarvestActorPage() {
const m = location.pathname.match(/^\/actors\/([a-zA-Z0-9_-]+)/);
if (!m) return;
const starId = m[1];
if (!starId || IGNORE_STRINGS.has(starId)) return;
const names = [];
const titleEl = document.querySelector('h2.title strong, .actor-section h2 strong, .actor-info h2 strong, h2.title');
if (titleEl && titleEl.textContent) names.push(titleEl.textContent.trim());
const subTitleEl = document.querySelector('.sub-title, strong.sub-title');
if (subTitleEl && subTitleEl.textContent) {
subTitleEl.textContent.split(/[,,\/]/).forEach(p => names.push(p.trim()));
}
const docTitle = document.title || '';
if (docTitle.includes('|')) {
const t = docTitle.split('|')[0].trim();
if (t && t.length >= 2) names.push(t);
}
const uncollectBtn = document.querySelector('#button-uncollect-actor, a.button-uncollect, a[href*="/uncollect"]');
const collectBtn = document.querySelector('#button-collect-actor, a.button-collect');
if (uncollectBtn) {
// 用户在 JAVDB 上已收藏该演员,毫秒级自动入库
const s = getFavSet();
let changed = false;
if (!s.has(starId)) { s.add(starId); changed = true; }
names.forEach(n => {
const clean = n.replace(/[\s\u2640\u2642]/g, '');
if (clean && clean.length >= 2 && !IGNORE_STRINGS.has(clean)) {
if (!s.has(clean)) { s.add(clean); changed = true; }
}
});
if (changed) saveFavSet();
}
// 绑定原生收藏/取消收藏按钮点击事件
if (collectBtn && !collectBtn._favBound) {
collectBtn._favBound = true;
collectBtn.addEventListener('click', function () {
setTimeout(function () {
const s = getFavSet();
s.add(starId);
names.forEach(n => {
const clean = n.replace(/[\s\u2640\u2642]/g, '');
if (clean && clean.length >= 2 && !IGNORE_STRINGS.has(clean)) s.add(clean);
});
saveFavSet();
markBadges(document);
}, 600);
});
}
if (uncollectBtn && !uncollectBtn._favBound) {
uncollectBtn._favBound = true;
uncollectBtn.addEventListener('click', function () {
setTimeout(function () {
const s = getFavSet();
s.delete(starId);
names.forEach(n => {
const clean = n.replace(/[\s\u2640\u2642]/g, '');
s.delete(clean);
});
saveFavSet();
markBadges(document);
}, 600);
});
}
}
// 从 DOM 中提取演员 starId 与名字入库 (超强容错,兼容 JAVDB 各种卡片结构)
function harvestFromDoc(doc, targetSet) {
const list = doc || document;
let count = 0;
const s = targetSet || getFavSet();
// 1. 定位所有演员卡片容器
const cards = list.querySelectorAll('.box.actor-box, .actor-box, #actors .box, #collection-actors .box, .actors .box');
cards.forEach(function (card) {
const mainA = card.querySelector('a[href^="/actors/"]:not([href*="uncollect"])') || card.querySelector('a[href*="/actors/"]');
if (!mainA) return;
const href = mainA.getAttribute('href') || '';
const m = href.match(/\/actors\/([a-zA-Z0-9_-]+)/);
const starId = m ? m[1] : '';
if (starId && !IGNORE_STRINGS.has(starId) && !s.has(starId)) {
s.add(starId);
count++;
}
// 提取姓名及别名(title 中常包含所有别名,如:"田中檸檬, 田中レモン, 楓カレン, 枫花恋")
const titleAttr = (mainA.getAttribute('title') || '').trim();
const strongText = card.querySelector('strong') ? card.querySelector('strong').textContent.trim() : '';
const candidateStrings = [];
if (titleAttr) candidateStrings.push(titleAttr);
if (strongText) candidateStrings.push(strongText);
candidateStrings.forEach(rawStr => {
const parts = rawStr.split(/[,,\/]/).map(p => p.trim()).filter(Boolean);
parts.forEach(p => {
const clean = p.replace(/[\s\u2640\u2642]/g, '');
if (clean && clean.length >= 2 && !IGNORE_STRINGS.has(clean)) {
if (!s.has(clean)) {
s.add(clean);
count++;
}
}
});
});
});
// 兜底策略:如果是在非标准列表页,安全提取合规演员链接
if (cards.length === 0) {
const links = list.querySelectorAll('a[href*="/actors/"]:not([href*="uncollect"])');
links.forEach(function (a) {
const href = a.getAttribute('href') || '';
const m = href.match(/\/actors\/([a-zA-Z0-9_-]+)/);
const starId = m ? m[1] : '';
if (!starId || IGNORE_STRINGS.has(starId)) return;
if (!s.has(starId)) { s.add(starId); count++; }
const txt = (a.getAttribute('title') || a.textContent || '').trim();
const parts = txt.split(/[,,\/]/).map(p => p.trim()).filter(Boolean);
parts.forEach(p => {
const clean = p.replace(/[\s\u2640\u2642]/g, '');
if (clean && clean.length >= 2 && !IGNORE_STRINGS.has(clean)) {
if (!s.has(clean)) { s.add(clean); count++; }
}
});
});
}
if (!targetSet && count > 0) saveFavSet();
return count;
}
// 真正带 credentials: 'same-origin' 的安全拉取
async function fetchPageHtml(url) {
const res = await fetch(url, {
method: 'GET',
credentials: 'same-origin',
headers: {
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'X-Requested-With': 'XMLHttpRequest'
}
});
if (res.status === 401 || res.status === 403) throw new Error('HTTP ' + res.status + ' 拒绝访问(未登录或需人机验证)');
if (!res.ok) throw new Error('HTTP ' + res.status + ': ' + res.statusText);
const text = await res.text();
const head = text.slice(0, 3000);
if (/cf-challenge|Checking your browser|Just a moment|__cf_chl/i.test(head)) {
throw new Error('遭遇 Cloudflare 人机验证,请在新标签页打开 JAVDB 完成人机验证');
}
if (/<title[^>]*>\s*(登入|Sign in)|\/login|\/sign_in/i.test(head) && /password|密碼/i.test(text)) {
throw new Error('未检测到 JAVDB 登录状态,请先在当前浏览器登录 JAVDB 账号后再同步');
}
return text;
}
// 全量同步抓取已收藏演员(支持多达 300 页海量收藏,友好延时防 429 封禁,双向精准同步)
async function sync(progressCb) {
let page = 1;
let total = 0;
const maxPages = 300;
let nextUrl = '/users/collection_actors';
let lastErr = null;
const onlineSet = new Set();
while (nextUrl && page <= maxPages) {
try {
if (page > 1) {
// 友好延时:每页间隔 1200ms,杜绝高频连击触发 Cloudflare 429/403 风控
await new Promise(r => setTimeout(r, 1200));
}
if (progressCb) progressCb('正在同步第 ' + page + ' 页已收藏女优…');
const html = await fetchPageHtml(nextUrl);
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const added = harvestFromDoc(doc, onlineSet);
total += added;
// 寻找显式下一页链接
const nextA = doc.querySelector('.pagination a.pagination-next, a[rel="next"].pagination-next, a.pagination-next:not([disabled]):not(.is-disabled)');
let foundHref = '';
if (nextA && nextA.getAttribute('href') && !nextA.getAttribute('href').startsWith('javascript:')) {
foundHref = nextA.getAttribute('href');
}
if (foundHref) {
nextUrl = toAbsUrl(foundHref);
page++;
} else {
nextUrl = null;
}
} catch (err) {
lastErr = err;
break;
}
}
if (lastErr && total === 0 && getCount() === 0) {
throw lastErr;
}
// 双向收敛:成功完成全量遍历时,精准同步线上集合,自动清理已在 JAVDB 取消关注的演员
const isComplete = !lastErr && nextUrl === null;
const cur = getFavSet();
// 防灾熔断:如果本地已有较多收藏,而线上抓取数量断崖式下跌(如下降超过 70%),不执行直接覆盖,避免网页临时改版或会话问题导致清空本地收藏
const isCatastrophicDrop = cur.size >= 10 && onlineSet.size < Math.floor(cur.size * 0.3);
if (isComplete && !isCatastrophicDrop && onlineSet.size > 0) {
memFavs = onlineSet;
} else if (onlineSet.size > 0) {
// 若中间某页因网络异常中断、超出最大页数限制或异常收敛,则保守合并未完成的部分
onlineSet.forEach(function (x) { cur.add(x); });
}
// 智能别名扩充:将已收录女优的所有已知别名(如 楓カレン ↔ 田中レモン)自动联动
try {
if (typeof ActressService !== 'undefined' && ActressService.getRepoIndex) {
const idx = await ActressService.getRepoIndex();
if (idx && idx.aliases) {
const s = getFavSet();
const currentList = Array.from(s);
// 1. 如果已收藏主名,将其别名加入
for (const alias in idx.aliases) {
const primary = idx.aliases[alias];
if (s.has(primary) && !s.has(alias)) {
s.add(alias);
}
}
// 2. 如果已收藏别名,将其主名加入
currentList.forEach(name => {
if (idx.aliases[name]) {
const primary = idx.aliases[name];
if (!s.has(primary)) s.add(primary);
}
});
}
}
} catch (e) {}
saveFavSet();
const curCount = getCount();
if (curCount === 0 && total === 0) {
throw new Error('未抓取到任何已收藏演员。请确认你当前账号在 JAVDB 上是否确实有关注的演员');
}
if (isComplete && !isCatastrophicDrop) {
localStorage.setItem(LAST_SYNC_KEY, String(Date.now()));
}
markBadges(document);
return curCount;
}
// 后台静默同步(每 24 小时至多 1 次)
async function autoSync() {
try {
const last = parseInt(localStorage.getItem(LAST_SYNC_KEY) || '0', 10);
const now = Date.now();
if (now - last < 24 * 3600 * 1000) return;
await sync();
} catch (e) {}
}
// 穿透传输:真正向 JAVDB 服务端提交收藏/取消收藏请求,实现账号与本地完全双向同步
async function remoteToggleFavorite(actorName, starId) {
if (!starId || IGNORE_STRINGS.has(starId)) {
throw new Error('未识别到有效演员 ID,无法提交至 JAVDB');
}
const s = getFavSet();
const current = isFavorite(starId) || (actorName && isFavorite(actorName));
const willFav = !current;
const action = willFav ? 'collect' : 'uncollect';
// 提取 JAVDB 页面上的 CSRF Token
const csrfMeta = document.querySelector('meta[name="csrf-token"]');
const csrf = csrfMeta ? csrfMeta.content : '';
const bodyData = new URLSearchParams();
if (csrf) bodyData.append('authenticity_token', csrf);
const res = await fetch('/actors/' + encodeURIComponent(starId) + '/' + action, {
method: 'POST',
credentials: 'same-origin',
headers: {
'X-CSRF-Token': csrf,
'X-Requested-With': 'XMLHttpRequest',
'Accept': '*/*;q=0.5, text/javascript, application/javascript',
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
},
body: bodyData.toString()
});
if (res.status === 401 || res.status === 403) {
throw new Error('未检测到 JAVDB 登录状态,请先登录 JAVDB 账号');
}
if (!res.ok) {
throw new Error('JAVDB 响应异常 (HTTP ' + res.status + ')');
}
// 服务端成功写入,更新本地数据库与视图
const keys = [];
if (starId && !IGNORE_STRINGS.has(starId)) keys.push(String(starId).trim());
if (actorName) {
const n = String(actorName).trim();
if (!IGNORE_STRINGS.has(n)) {
keys.push(n);
const clean = n.replace(/[\s\u2640\u2642]/g, '');
if (clean && clean !== n && !IGNORE_STRINGS.has(clean)) keys.push(clean);
}
}
keys.forEach(k => {
if (willFav) s.add(k); else s.delete(k);
});
saveFavSet();
markBadges(document);
return willFav;
}
function getCount() {
return getFavSet().size;
}
function dump() {
return Array.from(getFavSet());
}
return {
isFavorite: isFavorite,
toggleFavorite: toggleFavorite,
remoteToggleFavorite: remoteToggleFavorite,
markBadges: markBadges,
autoHarvestActorPage: autoHarvestActorPage,
harvestFromDoc: harvestFromDoc,
sync: sync,
autoSync: autoSync,
getCount: getCount,
dump: dump
};
})();
// 暴露到全局,便于用户在控制台自检调试
if (typeof window !== 'undefined') window.FavoriteActressManager = FavoriteActressManager;
/* =======================================================================
* 演员头像高清替换服务 (Gfriends Avatar Service)
* 优先从 gfriends 高清头像库匹配头像(不分男女,有图即用),未匹配则平滑回退 JAVDB 官方头像或默认图
* 使用 IndexedDB (meta 表) 存储倒排索引,内存常驻 Map,TTL 7天,零污染收藏夹数据库
* ===================================================================== */
const GfriendsAvatarService = (function () {
const CDN_BASE = 'https://testingcf.jsdelivr.net/gh/gfriends/gfriends@master';
const FALLBACK_BASE = 'https://raw.githubusercontent.com/gfriends/gfriends/master';
const META_KEY = 'gfriends_actor_index_v3'; // 全量收录无性别限制的高清头像索引
const TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 天缓存
let indexMap = null; // Map<string, string>: actressName -> "folder/filename"
let loadPromise = null;
function buildUrl(base, relPath) {
if (!relPath) return '';
const slashIdx = relPath.indexOf('/');
if (slashIdx === -1) return '';
const folder = relPath.slice(0, slashIdx);
const fileWithQuery = relPath.slice(slashIdx + 1);
const qIdx = fileWithQuery.indexOf('?');
let filename = fileWithQuery;
let query = '';
if (qIdx !== -1) {
filename = fileWithQuery.slice(0, qIdx);
query = fileWithQuery.slice(qIdx);
}
return base.replace(/\/+$/, '') + '/Content/' + encodeURIComponent(folder) + '/' + encodeURIComponent(filename) + query;
}
async function fetchIndex() {
// 1. 尝试从 IndexedDB meta 表读取有效缓存
try {
if (typeof FAV !== 'undefined' && typeof FAV.dbGetMeta === 'function') {
const cached = await FAV.dbGetMeta(META_KEY);
if (cached && cached.time && cached.map && (Date.now() - cached.time < TTL_MS)) {
indexMap = new Map(Object.entries(cached.map));
log('Gfriends 头像索引从 IndexedDB 恢复成功,收录女优: ' + indexMap.size + ' 位');
return indexMap;
}
}
} catch (e) {
log('读取 Gfriends IndexedDB 缓存异常: ' + e.message);
}
// 2. 从 CDN 下载 Filetree.json(jsDelivr testingcf 优先,多节点轮询,GitHub Raw 兜底)
let text = '';
const bases = [
CDN_BASE,
'https://cdn.jsdelivr.net/gh/gfriends/gfriends@master',
'https://fastly.jsdelivr.net/gh/gfriends/gfriends@master',
FALLBACK_BASE
];
for (const b of bases) {
try {
const url = b + '/Filetree.json';
log('正在下载 Gfriends 头像索引树: ' + url);
text = await gmHttp.get(url);
if (text && text.length > 1000) break;
} catch (err) {
log('下载 Gfriends 索引树失败 (' + b + '): ' + err.message);
}
}
if (!text) {
log('Gfriends 头像索引树获取失败,将继续使用官方头像');
return null;
}
try {
const data = JSON.parse(text);
const content = data.Content || {};
const compactObj = {};
for (const folder of Object.keys(content)) {
const files = content[folder];
if (!files || typeof files !== 'object') continue;
for (const rawKey of Object.keys(files)) {
const name = rawKey.replace(/\.(jpg|jpeg|png|webp)$/i, '').trim();
if (name) {
compactObj[name] = folder + '/' + files[rawKey];
}
}
}
indexMap = new Map(Object.entries(compactObj));
log('Gfriends 头像索引构建完成,共收录 ' + indexMap.size + ' 位演员头像');
// 异步存入 IndexedDB
if (typeof FAV !== 'undefined' && typeof FAV.dbSetMeta === 'function') {
FAV.dbSetMeta(META_KEY, { time: Date.now(), map: compactObj }).catch(function (err) {
log('保存 Gfriends 索引至 IndexedDB 异常: ' + err.message);
});
}
return indexMap;
} catch (err) {
log('解析 Gfriends 索引 JSON 失败: ' + err.message);
return null;
}
}
function ensureLoaded() {
if (indexMap) return Promise.resolve(indexMap);
if (!loadPromise) {
loadPromise = fetchIndex().then(function (res) {
loadPromise = null;
if (res) {
try { refreshAllAvatarsUI(); } catch (e) {}
}
return res;
}).catch(function (err) {
loadPromise = null;
return null;
});
}
return loadPromise;
}
// 智能别名与名称规范化匹配
function getAvatarUrl(name, fallbackOfficialUrl) {
if (!isPluginEnabled('gfriendsAvatar')) {
return fallbackOfficialUrl || '';
}
if (!name || typeof name !== 'string') {
return fallbackOfficialUrl || '';
}
const cleanName = name.replace(/[\s\u2640\u2642]/g, '').trim();
if (!cleanName) return fallbackOfficialUrl || '';
if (!indexMap) {
ensureLoaded();
return fallbackOfficialUrl || '';
}
// 1. 直接匹配
let relPath = indexMap.get(cleanName) || indexMap.get(name.trim());
// 2. 别名候选匹配(若有符号分割,如 "枫花恋 / 田中柠檬")
if (!relPath && /[,,\/]/.test(name)) {
const parts = name.split(/[,,\/]/).map(function (p) { return p.replace(/[\s\u2640\u2642]/g, '').trim(); }).filter(Boolean);
for (let i = 0; i < parts.length; i++) {
relPath = indexMap.get(parts[i]);
if (relPath) break;
}
}
// 3. 复用 ActressService 别名映射库进行简繁体与艺名转换(如 三上悠亚 -> 三上悠亜 / 安齋拉拉 -> 安齋らら)
if (!relPath && typeof ActressService !== 'undefined' && ActressService._idxMem && ActressService._idxMem.aliases) {
const aliasTarget = ActressService._idxMem.aliases[cleanName] || ActressService._idxMem.aliases[name.trim()];
if (aliasTarget) {
const cleanAlias = aliasTarget.replace(/[\s\u2640\u2642]/g, '').trim();
relPath = indexMap.get(cleanAlias) || indexMap.get(aliasTarget);
}
}
if (relPath) {
return buildUrl(CDN_BASE, relPath);
}
return fallbackOfficialUrl || '';
}
// 页面启动后预热加载
setTimeout(function () {
if (isPluginEnabled('gfriendsAvatar')) {
ensureLoaded();
}
}, 1200);
return {
ensureLoaded: ensureLoaded,
getAvatarUrl: getAvatarUrl,
buildUrl: buildUrl,
hasLoaded: function () { return !!indexMap; }
};
})();
// 页面上所有女优头像的响应式动态刷新(开关切换/异步索引加载完成时触发)
function refreshAllAvatarsUI() {
const isEnabled = isPluginEnabled('gfriendsAvatar');
document.querySelectorAll('img[data-gfriends-actress]').forEach(function (img) {
const actName = img.getAttribute('data-gfriends-actress');
const offAva = img.getAttribute('data-official-avatar') || '';
if (!actName) return;
if (isEnabled && typeof GfriendsAvatarService !== 'undefined') {
const gfUrl = GfriendsAvatarService.getAvatarUrl(actName, offAva);
if (gfUrl && gfUrl !== offAva && !img.getAttribute('data-gf-failed')) {
img.src = gfUrl;
img.style.display = '';
const pAva = img.closest('.ava, .efav-actor-ava-wrap');
if (pAva) {
pAva.style.backgroundImage = 'url(\'' + encodeURI(gfUrl).replace(/['"()]/g, '') + '\')';
}
if (img.nextElementSibling && img.nextElementSibling.classList.contains('efav-actor-ava-fallback')) {
img.nextElementSibling.style.display = 'none';
}
return;
}
}
// 插件关闭或未在 Gfriends 匹配:回退官方头像
if (offAva) {
img.src = offAva;
img.style.display = '';
const pAva = img.closest('.ava, .efav-actor-ava-wrap');
if (pAva) {
pAva.style.backgroundImage = 'url(\'' + encodeURI(offAva).replace(/['"()]/g, '') + '\')';
}
if (img.nextElementSibling && img.nextElementSibling.classList.contains('efav-actor-ava-fallback')) {
img.nextElementSibling.style.display = 'none';
}
} else {
img.style.display = 'none';
const pAva = img.closest('.ava, .efav-actor-ava-wrap');
if (pAva) {
pAva.style.backgroundImage = '';
}
if (img.nextElementSibling && img.nextElementSibling.classList.contains('efav-actor-ava-fallback')) {
img.nextElementSibling.style.display = 'inline-flex';
}
}
});
}
if (typeof window !== 'undefined') {
window.GfriendsAvatarService = GfriendsAvatarService;
window.refreshAllAvatarsUI = refreshAllAvatarsUI;
}
/* =======================================================================
* 外部播放站点服务 (预设站点 + 用户自定义网址)
* ===================================================================== */
const DEFAULT_PLAY_SITES = [
{ id: '123av', name: '123AV', template: 'https://123av.com/cn/search?keyword={code}', icon: 'https://123av.com/assets/123av/favicon.png', group: '主力', enabled: true },
{ id: 'njavtv', name: 'njavTV', template: 'https://njavtv.com/cn/search/{code_lower}', icon: 'https://njavtv.com/img/favicon.ico', group: '主力', enabled: true },
{ id: 'njav.com', name: 'Njav.Com', template: 'https://www.njav.com/zh/search?q={code_lower}', icon: 'https://www.njav.com/assets/site1/images/favicon.png', group: '主力', enabled: true },
{ id: 'njav', name: 'nJAV', template: 'https://njavtv.blog/search?keyword={code_lower}', icon: 'https://njavtv.blog/images/favicon123av.ico', group: '主力', enabled: true },
{ id: 'missav', name: 'MissAV', template: 'https://missav.live/search/{code}', icon: 'https://missav.live/img/favicon.ico', group: '主流', enabled: true },
{ id: 'jable', name: 'Jable', template: 'https://jable.tv/search/{code}/', icon: 'https://jable.tv/favicon.ico', group: '主流', enabled: true },
{ id: 'netflav', name: 'NetFlav', template: 'https://netflav.com/search?type=title&keyword={code}', icon: 'https://netflav.com/static/assets/favicon.ico', group: '主流', enabled: true },
{ id: 'supjav', name: 'SupJav', template: 'https://supjav.com/?s={code}', icon: 'https://supjav.com/favicon.ico', group: '综合', enabled: true },
{ id: 'bestjavporn', name: 'BestJavPorn', template: 'https://www.bestjavporn.com/zh/search/{code}', icon: 'https://pics.pornfhd.com/bestjavporn/favicon.ico', group: '综合', enabled: true },
{ id: 'javmenu', name: 'JavMenu', template: 'https://javmenu.com/zh/search?wd={code}', icon: 'https://javmenu.com/storage/1/logo.png', group: '综合', enabled: true },
{ id: 'av01', name: 'AV01', template: 'https://www.av01.media/cn/search?q={code}', icon: 'https://www.av01.xyz/static/pwa-192x192.png', group: '综合', enabled: true },
{ id: 'theav', name: 'TheAV', template: 'https://theav.xyz/search/{code}', icon: 'https://theav.xyz/favicon.ico', group: '综合', enabled: true },
{ id: 'javguru', name: 'Jav.Guru', template: 'https://jav.guru/?s={code}', icon: 'https://jav.guru/favicon.ico', group: '其他', enabled: true },
{ id: 'javmost', name: 'JAVMOST', template: 'https://www.javmost.ws/search/{code}/', icon: 'https://www.javmost.ws/favicon.ico', group: '其他', enabled: true },
{ id: 'javtrailers', name: 'JavTrailers', template: 'https://javtrailers.com/search/{code}', icon: 'https://javtrailers.com/favicon.ico', group: '预告', enabled: true },
{ id: 'javfilms', name: 'JavFilms', template: 'https://javfilms.com/zh/search?keywords={code}', icon: 'https://javfilms.com/favicon.ico', group: '预告', enabled: true },
{ id: 'avpreview', name: 'AVPreview', template: 'https://avpreview.com/zh/search?q={code}', icon: 'https://avpreview.com/favicon.ico', group: '预告', enabled: true },
{ id: 'javsubtitled', name: 'JavSubtitled', template: 'https://javsubtitled.com/zh/search?keywords={code}', icon: 'https://icons.duckduckgo.com/ip3/javsubtitled.com.ico', group: '预告', enabled: true },
{ id: '7mmtv', name: '7MMTV', template: 'https://7mmtv.sx/cn/searchform_search/all/index.html?search_keyword={code}&search_type=searchall&op=search', icon: 'https://7mmtv.sx/assets/images/favicon/favicon-32x32.png', group: '综合', enabled: true },
{ id: 'avmates', name: 'AvMates', template: 'https://avmates.com/?s={code}', icon: 'https://avmates.com/wp-content/uploads/2026/05/cropped-avmates-1-1-32x32.png', group: '综合', enabled: true },
{ id: 'hohoj', name: 'HOHOJ', template: 'https://hohoj.tv/search?text={code}', icon: 'https://hohoj.tv/resources/img/small_icon.png', group: '综合', enabled: true },
{ id: 'xszav', name: 'XSZ-AV', template: 'https://cn.xsz-av.com/search/videos/{code}', icon: 'https://cn.xsz-av.com/favicon.ico', group: '综合', enabled: true },
{ id: 'javhdtoday', name: 'JavHD', template: 'https://javhd.today/cn/search/video/?s={code}', icon: 'https://javhd.today/favicon-32x32.png', group: '综合', enabled: true }
];
/* =======================================================================
* 番号信息站点服务 (JAVBUS, JAVLib, FANZA動画, JAVtxt)
* ===================================================================== */
const DEFAULT_INFO_SITES = [
{ id: 'javbus', name: 'JAVBUS', template: 'https://www.javbus.com/{code}', icon: 'https://www.javbus.com/favicon.ico', enabled: true },
{ id: 'javlib', name: 'JAVLib', template: 'https://www.javlibrary.com/cn/vl_searchbyid.php?keyword={code}', icon: 'https://www.javlibrary.com/favicon.ico', enabled: true },
{ id: 'fanza', name: 'FANZA動画', template: 'https://www.dmm.co.jp/search/=/searchstr={code}/', icon: 'https://p.dmm.co.jp/p/favicon.ico', enabled: true },
{ id: 'javtxt', name: 'JAVtxt', template: 'https://javtxt.com/search?type=id&q={code}', icon: 'https://javtxt.com/favicon.ico', enabled: true }
];
function getInfoSiteConfigs() {
try {
const raw = localStorage.getItem('javdb_info_sites');
if (raw) {
const arr = JSON.parse(raw);
if (Array.isArray(arr) && arr.length) {
const defMap = {};
DEFAULT_INFO_SITES.forEach(function (d) { defMap[d.id] = d; });
const existingIds = new Set();
arr.forEach(function (s) {
existingIds.add(s.id);
if (defMap[s.id]) {
if (!s.icon) s.icon = defMap[s.id].icon;
if (s.enabled === undefined) s.enabled = true;
}
});
DEFAULT_INFO_SITES.forEach(function (d) {
if (!existingIds.has(d.id)) {
arr.push(JSON.parse(JSON.stringify(d)));
}
});
return arr;
}
}
} catch (e) {}
return JSON.parse(JSON.stringify(DEFAULT_INFO_SITES));
}
function saveInfoSiteConfigs(configs) {
try { localStorage.setItem('javdb_info_sites', JSON.stringify(configs)); } catch (e) {}
}
function buildInfoSitesHtml(code) {
if (!code) return '';
const sites = getInfoSiteConfigs().filter(function (s) { return s.enabled !== false; });
if (!sites.length) return '';
let html = '<span class="emby-info-sites" id="emby-info-sites">';
html += '<span class="emby-info-sites-divider" title="信息站点"></span>';
sites.forEach(function (s) {
const url = buildSitePlayUrl(s, code);
const safeId = String(s.id).replace(/[^a-zA-Z0-9_-]/g, '_');
const fullTitle = s.name + ' 一键跳转 (' + code + ')';
const iconUrl = s.icon || '';
html +=
'<a class="emby-info-site-btn emby-info-site-' + escapeAttr(safeId) + '" href="' + escapeAttr(url) + '" target="_blank" rel="noopener" title="' + escapeAttr(fullTitle) + '">' +
renderSiteLogoHtml(iconUrl, s.name, s.id) +
'</a>';
});
html += '</span>';
return html;
}
function getPlaySiteConfigs() {
try {
const raw = localStorage.getItem('javdb_play_sites');
if (raw) {
let arr = JSON.parse(raw);
if (Array.isArray(arr) && arr.length) {
// 解耦信息站点:从播放源中过滤掉 javbus 与 group 为「信息」的站点
const beforeLen = arr.length;
arr = arr.filter(function (s) { return s.id !== 'javbus' && s.group !== '信息'; });
let needsResave = (arr.length !== beforeLen);
// 补齐旧缓存可能缺失的 icon / group 字段,自动自愈升级失效旧域名,并合入新增预设站点
const defMap = {};
DEFAULT_PLAY_SITES.forEach(function (d) { defMap[d.id] = d; });
const existingIds = new Set();
arr.forEach(function (s) {
existingIds.add(s.id);
// 自动治愈迁移旧版本可能残留的失效/废弃域名
if (s.template) {
if (s.template.includes('javmost.cx')) {
s.template = s.template.replace('javmost.cx', 'www.javmost.ws');
needsResave = true;
}
if (s.template.includes('missav.ws')) {
s.template = s.template.replace('missav.ws', 'missav.live');
needsResave = true;
}
if (s.template.includes('njavtv.blog/video/')) {
s.template = s.template.replace('njavtv.blog/video/', 'njavtv.blog/search?keyword=');
needsResave = true;
}
}
if (s.icon) {
if (s.icon.includes('javmost.cx')) {
s.icon = s.icon.replace('javmost.cx', 'www.javmost.ws');
needsResave = true;
}
if (s.icon.includes('missav.ws')) {
s.icon = s.icon.replace('missav.ws', 'missav.live');
needsResave = true;
}
if (s.id === 'av01' && (!s.icon || s.icon.includes('av01.media') || s.icon.includes('duckduckgo') || s.icon.includes('favicon.ico'))) {
s.icon = 'https://www.av01.xyz/static/pwa-192x192.png';
needsResave = true;
}
if (s.icon.includes('javsubtitled.com/javsubtitled.ico')) {
s.icon = 'https://icons.duckduckgo.com/ip3/javsubtitled.com.ico';
needsResave = true;
}
if (s.icon.includes('cdn.javmiku.com')) {
s.icon = 'https://jav.guru/favicon.ico';
needsResave = true;
}
}
if (defMap[s.id]) {
if (!s.icon) s.icon = defMap[s.id].icon;
if (!s.group) s.group = defMap[s.id].group;
} else if (!s.group) {
s.group = s.id && s.id.startsWith('custom_') ? '自定义' : '其他';
}
});
// 补充未在本地配置中出现过的默认站点
DEFAULT_PLAY_SITES.forEach(function (d) {
if (!existingIds.has(d.id)) {
arr.push(JSON.parse(JSON.stringify(d)));
needsResave = true;
}
});
if (needsResave) {
savePlaySiteConfigs(arr);
}
return arr;
}
}
} catch (e) {}
return JSON.parse(JSON.stringify(DEFAULT_PLAY_SITES));
}
function savePlaySiteConfigs(configs) {
try { localStorage.setItem('javdb_play_sites', JSON.stringify(configs)); } catch (e) {}
}
function getPreferredPlaySiteId() {
try {
const pref = localStorage.getItem('javdb_preferred_play_site') || '';
if (pref === 'javbus') {
localStorage.removeItem('javdb_preferred_play_site');
return '';
}
return pref;
} catch (e) {
return '';
}
}
function setPreferredPlaySiteId(id) {
try {
if (id) localStorage.setItem('javdb_preferred_play_site', String(id));
else localStorage.removeItem('javdb_preferred_play_site');
} catch (e) {}
}
/* =======================================================================
* 预览图嗅探与抓取站点服务 (javfree, javstore, blogjav, local, 自定义)
* ===================================================================== */
const DEFAULT_PREVIEW_SITES = [
{ id: 'javfree', name: 'JavFree', type: 'builtin', enabled: true, template: 'https://javfree.me/search/{code}', icon: 'https://javfree.me/favicon.ico' },
{ id: 'javstore', name: 'JavStore', type: 'builtin', enabled: true, template: 'https://javstore.net/search?q={code}', icon: 'https://javstore.net/favicon.ico' },
{ id: 'blogjav', name: 'BlogJav', type: 'builtin', enabled: true, template: 'https://blogjav.net/?s={code}', icon: 'https://blogjav.net/wp-content/uploads/cropped-fav-32x32.png' },
{ id: 'local', name: 'JAVDB官方剧照', type: 'builtin', enabled: true, template: '/v/{videoId}', icon: 'https://javdb.com/favicon.ico' }
];
function getPreviewSiteConfigs() {
try {
const raw = localStorage.getItem('javdb_preview_sites');
if (raw) {
let arr = JSON.parse(raw);
if (Array.isArray(arr) && arr.length) {
const defMap = {};
DEFAULT_PREVIEW_SITES.forEach(function (d) { defMap[d.id] = d; });
const existingIds = new Set();
arr.forEach(function (s) {
existingIds.add(s.id);
if (defMap[s.id]) {
if (!s.icon) s.icon = defMap[s.id].icon;
if (s.enabled === undefined) s.enabled = true;
}
});
DEFAULT_PREVIEW_SITES.forEach(function (d) {
if (!existingIds.has(d.id)) {
arr.push(JSON.parse(JSON.stringify(d)));
}
});
return arr;
}
}
} catch (e) {}
return JSON.parse(JSON.stringify(DEFAULT_PREVIEW_SITES));
}
function savePreviewSiteConfigs(configs) {
try { localStorage.setItem('javdb_preview_sites', JSON.stringify(configs)); } catch (e) {}
}
function getSiteInitial(name, id) {
if (id === 'javdb_native' || id === 'javdb') return 'DB';
if (!name) return '▶';
const clean = String(name).replace(/播放|站点|search/gi, '').trim();
if (/^123/i.test(clean)) return '123';
if (/^njav/i.test(clean)) return 'NJ';
if (/^miss/i.test(clean)) return 'M';
if (/^jable/i.test(clean)) return 'J';
if (/^sup/i.test(clean)) return 'SJ';
if (/guru/i.test(clean) || /javguru/i.test(id)) return 'JG';
if (/most/i.test(clean) || /javmost/i.test(id)) return 'JM';
if (/^bus/i.test(clean) || /javbus/i.test(clean) || /javbus/i.test(id)) return 'JB';
if (/^lib/i.test(clean) || /javlib/i.test(clean) || /javlib/i.test(id)) return 'JL';
if (/^fanza/i.test(clean) || /dmm/i.test(clean) || /fanza/i.test(id)) return 'FZ';
if (/^txt/i.test(clean) || /javtxt/i.test(clean) || /javtxt/i.test(id)) return 'JT';
if (/^trail/i.test(clean) || /javtrailers/i.test(clean) || /javtrailers/i.test(id)) return 'TR';
if (/^av01/i.test(clean) || /av01/i.test(id)) return 'AV';
if (/theav/i.test(clean) || /theav/i.test(id)) return 'TA';
if (/preview/i.test(clean) || /avpreview/i.test(id)) return 'AP';
if (/subtitled/i.test(clean) || /javsubtitled/i.test(id)) return 'JS';
if (/^7mm/i.test(clean) || /7mmtv/i.test(id)) return '7M';
if (/avmates/i.test(clean) || /avmates/i.test(id)) return 'AM';
if (/hohoj/i.test(clean) || /hohoj/i.test(id)) return 'HH';
if (/javfree/i.test(clean) || /javfree/i.test(id)) return 'JF';
if (/javstore/i.test(clean) || /javstore/i.test(id)) return 'JS';
if (/blogjav/i.test(clean) || /blogjav/i.test(id)) return 'BJ';
if (/local/i.test(id)) return 'DB';
if (/xsz/i.test(clean) || /xsz/i.test(id)) return 'XS';
if (/javhd/i.test(clean) || /javhd/i.test(id)) return 'HD';
return clean.slice(0, 2).toUpperCase() || '▶';
}
function renderSiteLogoHtml(iconUrl, name, id) {
const initial = escapeHtml(getSiteInitial(name, id));
if (!iconUrl) {
return '<span class="emby-site-logo-wrap"><span class="emby-site-logo-fallback">' + initial + '</span></span>';
}
// 关键防盗链与兜底策略:
// 1. referrerpolicy="no-referrer" 消除向外部站点传递 Referer: javdb.com,破解防盗链/反爬 403;
// 2. 首次 onerror 时自动尝试通过 DuckDuckGo Favicon CDN 兜底;二次失败才平滑回退为纯文字微标。
const errHandler =
"if(!this.dataset.fb){" +
"this.dataset.fb='1';" +
"try{" +
"var h=new URL(this.src).hostname;" +
"if(h&&h.indexOf('duckduckgo')===-1){this.src='https://icons.duckduckgo.com/ip3/'+h+'.ico';return;}" +
"}catch(e){}" +
"}" +
"this.style.display='none';" +
"if(this.nextElementSibling)this.nextElementSibling.style.display='inline-flex';";
return (
'<span class="emby-site-logo-wrap">' +
'<img class="emby-site-logo" src="' + escapeAttr(iconUrl) + '" alt="" loading="lazy" referrerpolicy="no-referrer" ' +
'onerror="' + escapeAttr(errHandler) + '" />' +
'<span class="emby-site-logo-fallback" style="display:none;">' + initial + '</span>' +
'</span>'
);
}
function buildSitePlayUrl(site, code) {
if (!site || !site.template || !code) return '#';
const c = String(code).trim();
const raw = site.template
.replace(/\{code\}/g, encodeURIComponent(c))
.replace(/\{code_lower\}/g, encodeURIComponent(c.toLowerCase()))
.replace(/\{code_upper\}/g, encodeURIComponent(c.toUpperCase()));
return safeHref(raw, '#');
}
function getSiteBadgeClass(group) {
const g = String(group || '').trim();
if (g === '官方') return 'emby-site-badge-official';
if (g === '主力') return 'emby-site-badge-main';
if (g === '主流') return 'emby-site-badge-hot';
if (g === '综合') return 'emby-site-badge-general';
if (g === '预告') return 'emby-site-badge-trailer';
if (g === '信息') return 'emby-site-badge-info';
return 'emby-site-badge-default';
}
// 片源在线检测会话级缓存:Map<"siteId_CODE", { val: boolean, exp: number }>
const siteAvailabilityCache = new Map();
function getCachedSiteAvailability(key) {
const item = siteAvailabilityCache.get(key);
if (!item) return undefined;
if (Date.now() > item.exp) {
siteAvailabilityCache.delete(key);
return undefined;
}
return item.val;
}
function setCachedSiteAvailability(key, val) {
// 成功有效可用状态缓存 5 分钟,失败/不可用状态缓存 1 分钟(避免临时网络抖动导致全天不可用)
const ttl = val ? 300000 : 60000;
siteAvailabilityCache.set(key, { val: val, exp: Date.now() + ttl });
}
async function checkSiteAvailability(site, code, root) {
if (!site || !code) return false;
const cleanCode = String(code).trim();
const cacheKey = (site.id || 'unknown') + '_' + cleanCode.toUpperCase();
const cached = getCachedSiteAvailability(cacheKey);
if (typeof cached === 'boolean') {
return cached;
}
// 1. JAVDB 原生官方播放检测
if (site.id === 'javdb_native' || site.id === 'javdb' || site.isNative) {
const nativePlay = root ? extractNativePlayInfo(root) : null;
const res = !!nativePlay;
setCachedSiteAvailability(cacheKey, res);
return res;
}
// 2. 外部站点 URL 构造与域名自愈
let targetUrl = buildSitePlayUrl(site, cleanCode);
if (!targetUrl || targetUrl === '#') {
setCachedSiteAvailability(cacheKey, false);
return false;
}
if (targetUrl.includes('javmost.cx')) {
targetUrl = targetUrl.replace('javmost.cx', 'www.javmost.ws');
}
if (targetUrl.includes('missav.ws')) {
targetUrl = targetUrl.replace('missav.ws', 'missav.live');
}
// 正则匹配番号(容错横杠连字符)
const escapedCode = cleanCode.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const codeRegex = new RegExp(escapedCode.replace(/[-_]/g, '[-_]?'), 'i');
const reqFn = typeof GM_xmlhttpRequest !== 'undefined'
? GM_xmlhttpRequest
: (typeof gmHttp !== 'undefined' && gmHttp.request ? gmHttp.request.bind(gmHttp) : null);
if (!reqFn) return false;
// 单次探针请求封装(纯净请求头,不显式伪造 Referer 与 User-Agent,交由 Tampermonkey 原生环境发包穿透 Cloudflare)
function sendProbe(url, timeoutMs) {
return new Promise(function (resolve) {
try {
reqFn({
method: 'GET',
url: url,
timeout: timeoutMs || 12000,
headers: {
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'
},
onload: function (resp) {
resolve({
status: resp.status,
text: resp.responseText || '',
finalUrl: resp.finalUrl || url
});
},
onerror: function () { resolve({ status: 0, text: '', error: true }); },
ontimeout: function () { resolve({ status: 0, text: '', timeout: true }); }
});
} catch (e) {
resolve({ status: 0, text: '', error: true });
}
});
}
// 各站点特征判定逻辑
function evaluateResult(siteId, status, txt) {
if (!txt || status >= 400 || status === 0) return false;
// 通用否定关键词排除(只有在页面无番号卡片特征且出现明确未找到提示时才生效)
const hasNegative = /404 Not Found|没有找到相关|未找到视频|未找到相关的影片|No videos found|抱歉,没有找到|Nothing Found|找不到任何內容/i.test(txt);
if (hasNegative && !codeRegex.test(txt)) return false;
// 站点定制判别
if (siteId === '123av') {
return (txt.includes('detail') || txt.includes('/v/') || txt.includes('video') || codeRegex.test(txt)) && codeRegex.test(txt);
} else if (siteId === 'njavtv') {
return codeRegex.test(txt);
} else if (siteId === 'njav.com') {
return codeRegex.test(txt);
} else if (siteId === 'njav') {
return codeRegex.test(txt);
} else if (siteId === 'missav') {
return !txt.includes('Page Not Found') && codeRegex.test(txt);
} else if (siteId === 'jable') {
return txt.includes('info-header') || txt.includes('video-img-box') || txt.includes('detail') || codeRegex.test(txt);
} else if (siteId === 'netflav') {
return (txt.includes('/video?') || txt.includes('grid_0') || codeRegex.test(txt)) && codeRegex.test(txt);
} else if (siteId === 'supjav') {
return (txt.includes('post') || txt.includes('posts') || codeRegex.test(txt)) && codeRegex.test(txt);
} else if (siteId === 'bestjavporn') {
return (txt.includes('thumb-block') || txt.includes('video') || codeRegex.test(txt)) && codeRegex.test(txt);
} else if (siteId === 'javmenu') {
return (txt.includes('video-item') || txt.includes('card') || codeRegex.test(txt)) && codeRegex.test(txt);
} else if (siteId === 'javguru') {
return (txt.includes('inside-article') || txt.includes('imgg') || codeRegex.test(txt)) && codeRegex.test(txt);
} else if (siteId === 'javmost') {
return (txt.includes('card') || txt.includes('myButton') || codeRegex.test(txt)) && codeRegex.test(txt);
} else if (siteId === 'javbus') {
return status === 200 && (txt.includes('bigImage') || txt.includes('movie') || codeRegex.test(txt));
} else {
return status === 200 && codeRegex.test(txt);
}
}
try {
let isAvailable = false;
let shouldCache = false;
// 针对 Jable 实施智能多级探针:先探测模板检索页,若未果尝试正片直链与中文字幕直链
if (site.id === 'jable') {
let resp = await sendProbe(targetUrl, 10000);
if (resp.status === 200 && evaluateResult('jable', resp.status, resp.text)) {
isAvailable = true;
shouldCache = true;
} else {
// 备选探针1:直链 /videos/{code}/
const videoUrl = 'https://jable.tv/videos/' + encodeURIComponent(cleanCode.toLowerCase()) + '/';
let resp2 = await sendProbe(videoUrl, 8000);
if (resp2.status === 200 && evaluateResult('jable', resp2.status, resp2.text)) {
isAvailable = true;
shouldCache = true;
} else {
// 备选探针2:中文字幕直链 /videos/{code}-c/
const subUrl = 'https://jable.tv/videos/' + encodeURIComponent(cleanCode.toLowerCase()) + '-c/';
let resp3 = await sendProbe(subUrl, 8000);
if (resp3.status === 200 && evaluateResult('jable', resp3.status, resp3.text)) {
isAvailable = true;
shouldCache = true;
} else if (resp.status > 0 || resp2.status > 0 || resp3.status > 0) {
shouldCache = true;
}
}
}
} else {
// 其余站点直接基于配置的 targetUrl 探测
const resp = await sendProbe(targetUrl, 12000);
if (resp.status === 200) {
isAvailable = evaluateResult(site.id, resp.status, resp.text);
shouldCache = true;
} else if (resp.status >= 400) {
isAvailable = false;
shouldCache = true;
}
}
if (isAvailable) {
setCachedSiteAvailability(cacheKey, true);
return true;
} else {
if (shouldCache) {
setCachedSiteAvailability(cacheKey, false);
}
return false;
}
} catch (e) {
return false;
}
}
/* =======================================================================
* Git 云端备份与恢复客户端 (支持 GitHub / Gitee 等标准 REST API)
* ===================================================================== */
class GitBackupClient {
constructor(apiUrl, repo, branch, token, folderName) {
const base = (apiUrl || 'https://api.github.com').trim();
this.apiUrl = base.endsWith('/') ? base.slice(0, -1) : base;
this.repo = (repo || '').trim();
this.branch = (branch || 'main').trim();
this.token = (token || '').trim();
this.folderName = (folderName || 'JavdbEmbySkin_backup').replace(/^\/+|\/+$/g, '');
}
isGitee() {
return this.apiUrl.includes('gitee.com');
}
buildHeaders(extra) {
const h = Object.assign({
'Accept': 'application/vnd.github+json',
'User-Agent': 'JAVDB-Emby-Skin-Backup',
'X-GitHub-Api-Version': '2022-11-28'
}, extra || {});
if (this.token) {
h['Authorization'] = this.token.startsWith('Bearer ') ? this.token : ('Bearer ' + this.token);
}
return h;
}
request(method, path, body, extraHeaders) {
const self = this;
return new Promise(function (resolve, reject) {
let url = self.apiUrl + path;
if (self.isGitee() && self.token && !url.includes('access_token=')) {
url += (url.includes('?') ? '&' : '?') + 'access_token=' + encodeURIComponent(self.token);
}
const headers = self.buildHeaders(extraHeaders);
if (body !== undefined && !headers['Content-Type']) {
headers['Content-Type'] = 'application/json;charset=UTF-8';
}
gmHttp.request({
method: method,
url: url,
headers: headers,
data: body !== undefined ? JSON.stringify(body) : undefined,
timeout: 20000
}).then(function (text) {
resolve(text);
}).catch(function (err) {
reject(new Error('Git 请求失败: ' + (err.message || err)));
});
});
}
async testConnection() {
// 验证仓库访问与 Token 权限
const repoPath = '/repos/' + this.repo;
let text;
try {
text = await this.request('GET', repoPath);
} catch (err) {
const msg = String(err.message || err);
if (msg.includes('404') || msg.includes('Not Found')) {
throw new Error('仓库不存在,或当前 Token 没有权限访问该私有仓库(请检查仓库名大小写及精细化令牌的选定仓库)');
}
if (msg.includes('401') || msg.includes('Bad credentials')) {
throw new Error('访问令牌 (Token) 无效或已过期,请重新生成');
}
if (msg.includes('403')) {
throw new Error('API 访问被拒绝 (403),请确认 Token 是否勾选了 Contents (内容) 读写权限');
}
throw err;
}
let info;
try {
info = JSON.parse(text);
} catch (e) {
throw new Error('解析 Git 响应数据失败');
}
if (!info || (!info.id && !info.full_name && !info.name)) {
throw new Error('未获取到有效的仓库信息');
}
return {
name: info.full_name || info.name || this.repo,
private: !!info.private,
defaultBranch: info.default_branch || 'main',
permissions: info.permissions || null
};
}
contentsPath(filename) {
const p = filename ? (this.folderName + '/' + filename) : this.folderName;
const encodedPath = p.split('/').map(encodeURIComponent).join('/');
return '/repos/' + this.repo + '/contents/' + encodedPath + '?ref=' + encodeURIComponent(this.branch);
}
async fetchFileMeta(filename) {
try {
const text = await this.request('GET', this.contentsPath(filename));
const parsed = JSON.parse(text);
if (parsed && (parsed.sha || parsed.type === 'file')) return parsed;
return null;
} catch (e) {
if (e.message && (e.message.includes('404') || e.message.includes('Not Found'))) return null;
throw e;
}
}
async backup(filename, content) {
const meta = await this.fetchFileMeta(filename);
const utf8Bytes = new TextEncoder().encode(content);
let binaryStr = '';
for (let i = 0; i < utf8Bytes.length; i++) binaryStr += String.fromCharCode(utf8Bytes[i]);
const base64Content = btoa(binaryStr);
const body = {
message: 'javdb-backup: ' + filename + ' ' + new Date().toISOString(),
content: base64Content,
branch: this.branch
};
if (meta && meta.sha) body.sha = meta.sha;
const method = this.isGitee() ? (meta ? 'PUT' : 'POST') : 'PUT';
await this.request(method, this.contentsPath(filename), body);
return true;
}
async fetchLastCommitDate(filename) {
try {
const p = filename ? (this.folderName + '/' + filename) : this.folderName;
const commitPath = '/repos/' + this.repo + '/commits?path=' + encodeURIComponent(p) + '&sha=' + encodeURIComponent(this.branch);
const text = await this.request('GET', commitPath);
const list = JSON.parse(text);
if (Array.isArray(list) && list[0] && list[0].commit) {
return list[0].commit.committer?.date || list[0].commit.author?.date || '';
}
} catch (e) {}
return '';
}
async getBackupList() {
let text;
try {
text = await this.request('GET', this.contentsPath());
} catch (e) {
if (e.message && (e.message.includes('404') || e.message.includes('Not Found'))) return [];
throw e;
}
let entries = [];
try {
const parsed = JSON.parse(text);
if (Array.isArray(parsed)) entries = parsed;
} catch (e) {
return [];
}
const files = entries.filter(function (e) { return e.type === 'file' && e.name.endsWith('.json'); });
const self = this;
const items = await Promise.all(files.map(async function (e) {
let date = '';
try { date = await self.fetchLastCommitDate(e.name); } catch (_) {}
return {
fileId: e.name,
name: e.name,
size: e.size || 0,
createTime: date,
sha: e.sha,
download_url: e.download_url
};
}));
items.reverse();
return items;
}
async getFileContent(filename) {
let text = null;
if (!this.isGitee()) {
try {
// GitHub 专属 Raw 媒体类型:直接拉取原生文件文本,彻底避开 1MB 截断与 Base64 编码限制
const rawText = await this.request('GET', this.contentsPath(filename), undefined, {
'Accept': 'application/vnd.github.raw+json, application/vnd.github.raw'
});
if (typeof rawText === 'string' && rawText.trim().startsWith('{')) {
try {
const testObj = JSON.parse(rawText);
// 若返回的是 GitHub Contents API 信封结构(含 content 且 encoding 为 base64),不能直接当正文返回,记录为 text 供后续 base64 解码
if (testObj && typeof testObj.content === 'string' && testObj.encoding === 'base64') {
text = rawText;
} else if (testObj && (testObj.movies || testObj.lists || testObj.version || testObj.app || testObj.config || testObj.notes)) {
return rawText;
}
} catch (_) {
return rawText;
}
}
} catch (_) {}
}
if (!text) {
text = await this.request('GET', this.contentsPath(filename));
}
let parsed = null;
try { parsed = JSON.parse(text); } catch (_) {}
if (parsed && typeof parsed.content === 'string' && parsed.content.trim().length > 0) {
const bin = atob(parsed.content.replace(/\s+/g, ''));
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
return new TextDecoder('utf-8').decode(bytes);
}
if (parsed && parsed.download_url) {
const dlHeaders = {};
if (this.token && !this.isGitee()) {
dlHeaders['Authorization'] = this.token.startsWith('Bearer ') ? this.token : ('Bearer ' + this.token);
}
return await gmHttp.request({ method: 'GET', url: parsed.download_url, headers: dlHeaders });
}
return text;
}
async deleteFile(filename, sha) {
let fileSha = sha;
if (!fileSha) {
const meta = await this.fetchFileMeta(filename);
if (!meta || !meta.sha) throw new Error('文件不存在或已被删除');
fileSha = meta.sha;
}
const body = {
message: 'javdb-delete: ' + filename + ' ' + new Date().toISOString(),
sha: fileSha,
branch: this.branch
};
await this.request('DELETE', this.contentsPath(filename), body);
return true;
}
}
/* =======================================================================
* WebDAV 云端备份与恢复客户端
* ===================================================================== */
class WebDavClient {
constructor(url, username, password) {
const u = (url || '').trim();
this.baseUrl = u ? (u.endsWith('/') ? u : (u + '/')) : '';
this.username = (username || '').trim();
this.password = (password || '').trim();
this.folder = 'JavdbEmbySkin_backup';
}
_authHeaders() {
const h = { 'Depth': '1' };
if (this.username || this.password) {
h['Authorization'] = 'Basic ' + btoa(unescape(encodeURIComponent(this.username + ':' + this.password)));
}
return h;
}
async _request(method, path, headers, data) {
if (!this.baseUrl) throw new Error('请先填写 WebDAV 服务地址');
const encodedPath = (path || '').split('/').map(encodeURIComponent).join('/');
const url = this.baseUrl + encodedPath.replace(/^\//, '');
const reqHeaders = Object.assign(this._authHeaders(), headers || {});
return gmHttp.request({ method: method, url: url, headers: reqHeaders, data: data });
}
async ensureFolder() {
if (!this.baseUrl) throw new Error('请先填写 WebDAV 服务地址');
// 先探测目录是否已存在,避免对已有目录重复调用 MKCOL 产生误报
try {
await this._request('PROPFIND', this.folder, { 'Depth': '0' });
return;
} catch (_) {}
try {
await this._request('MKCOL', this.folder);
} catch (err) {
// 405 Method Not Allowed / 409 Conflict 常表示目录已存在,容错放行
const msg = String(err.message || err);
if (!msg.includes('405') && !msg.includes('409')) throw err;
}
}
async testConnection() {
if (!this.baseUrl || !this.username || !this.password) throw new Error('请先填写完整的 WebDAV 服务配置');
// 简单探测根路径连通性与凭据鉴权
try {
await this._request('PROPFIND', '', { 'Depth': '0' });
} catch (err) {
const msg = String(err.message || err);
if (!this.password) throw new Error('请先填写 WebDAV 密码 / 应用授权码');
if (msg.includes('401') || msg.includes('Unauthorized')) {
throw new Error('用户名或密码/应用授权码错误 (401 Unauthorized)。坚果云等服务请务必使用“应用授权密码”,不可使用网页登录密码');
}
if (msg.includes('403') || msg.includes('Forbidden')) {
throw new Error('访问被拒绝 (403 Forbidden),请检查账号对该目录的读写权限');
}
if (msg.includes('404')) {
throw new Error('WebDAV 服务地址不存在 (404),坚果云请确保地址以 /dav/ 结尾');
}
throw err;
}
await this.ensureFolder();
return true;
}
async uploadBackup(fileName, jsonString) {
if (!this.baseUrl || !this.username || !this.password) throw new Error('请先填写完整的 WebDAV 服务配置');
await this.ensureFolder();
const path = this.folder + '/' + fileName;
await this._request('PUT', path, { 'Content-Type': 'application/json; charset=utf-8' }, jsonString);
}
async getBackupList() {
if (!this.baseUrl || !this.username || !this.password) throw new Error('请先填写完整的 WebDAV 服务配置');
await this.ensureFolder();
const xml = await this._request('PROPFIND', this.folder, { 'Content-Type': 'application/xml' },
'<?xml version="1.0"?><d:propfind xmlns:d="DAV:"><d:prop><d:displayname /><d:getcontentlength /><d:getlastmodified /><d:iscollection /></d:prop></d:propfind>');
const doc = new DOMParser().parseFromString(xml, 'application/xml');
let responses = [];
if (typeof doc.getElementsByTagNameNS === 'function') {
responses = Array.from(doc.getElementsByTagNameNS('*', 'response'));
}
if (!responses.length) {
responses = Array.from(doc.querySelectorAll('response, d\\:response, D\\:response'));
}
function findProp(node, tag) {
if (!node) return null;
const lower = tag.toLowerCase();
const el = node.querySelector(tag + ', d\\:' + tag + ', D\\:' + tag);
if (el) return el;
const stack = [node];
while (stack.length) {
const cur = stack.pop();
for (let i = 0; i < cur.children.length; i++) {
const child = cur.children[i];
const lName = (child.localName || child.nodeName.split(':').pop() || '').toLowerCase();
if (lName === lower) return child;
if (child.children.length) stack.push(child);
}
}
return null;
}
const list = [];
responses.forEach(function (r) {
const hrefNode = findProp(r, 'href');
const href = hrefNode ? (hrefNode.textContent || '') : '';
let name = href.split('/').filter(Boolean).pop() || '';
try { name = decodeURIComponent(name); } catch (_) {}
const isColNode = findProp(r, 'iscollection') || findProp(r, 'collection');
const isCol = !!(isColNode && (isColNode.textContent || '').match(/1|true/i)) || (isColNode && isColNode.localName === 'collection');
if (!name || isCol || !name.endsWith('.json')) return;
const szNode = findProp(r, 'getcontentlength');
const sz = parseInt(szNode ? (szNode.textContent || '0') : '0', 10);
const lmNode = findProp(r, 'getlastmodified');
const lm = lmNode ? (lmNode.textContent || '') : '';
list.push({ name: name, size: sz, lastModified: lm });
});
list.sort((a, b) => b.name.localeCompare(a.name));
return list;
}
async downloadBackup(fileName) {
const path = this.folder + '/' + fileName;
return this._request('GET', path);
}
async deleteBackup(fileName) {
const path = this.folder + '/' + fileName;
await this._request('DELETE', path);
}
}
/* 云端备份文件查看与恢复弹窗 (WebDAV / Git 通用) */
function openCloudBackupListModal(files, client, serviceName) {
let old = document.getElementById('jhs-wd-list-modal');
if (old) old.remove();
const modal = document.createElement('div');
modal.id = 'jhs-wd-list-modal';
modal.className = 'jhs-settings-mask open';
modal.style.zIndex = '2147483680';
const sName = serviceName || '云端';
let rowsHtml = '';
if (!files.length) {
rowsHtml = '<div style="text-align:center;color:#888;padding:30px;">' + sName + ' 暂无历史备份文件</div>';
} else {
files.forEach(function (f, i) {
const sz = f.size ? (f.size > 1048576 ? (f.size / 1048576).toFixed(2) + ' MB' : (f.size / 1024).toFixed(1) + ' KB') : '未知';
rowsHtml +=
'<div class="jhs-backup-row" style="display:flex;align-items:center;justify-content:space-between;padding:10px 14px;border-bottom:1px solid rgba(255,255,255,.06);">' +
'<div>' +
'<div style="font-size:13px;font-weight:600;color:#fff;">' + escapeHtml(f.name) + '</div>' +
'<div style="font-size:11px;color:#888;margin-top:2px;">来源: ' + sName + ' · 大小: ' + sz + ((f.lastModified || f.createTime) ? ' · ' + (f.lastModified || f.createTime).slice(0, 19).replace('T', ' ') : '') + '</div>' +
'</div>' +
'<div style="display:flex;gap:8px;">' +
'<button type="button" class="jhs-btn jhs-btn-success btn-wd-restore" data-name="' + escapeAttr(f.name) + '" style="padding:4px 10px;font-size:11.5px;">恢复导入</button>' +
'<button type="button" class="jhs-btn jhs-btn-secondary btn-wd-dl" data-name="' + escapeAttr(f.name) + '" style="padding:4px 10px;font-size:11.5px;">下载</button>' +
'<button type="button" class="jhs-btn jhs-btn-danger btn-wd-del" data-name="' + escapeAttr(f.name) + '" data-sha="' + escapeAttr(f.sha || '') + '" style="padding:4px 10px;font-size:11.5px;">删除</button>' +
'</div>' +
'</div>';
});
}
modal.innerHTML =
'<div class="jhs-settings-dialog" style="width:min(680px, 92vw);height:min(500px, 80vh);flex-direction:column;">' +
'<div style="display:flex;align-items:center;justify-content:space-between;padding:14px 20px;border-bottom:1px solid rgba(255,255,255,.1);">' +
'<div style="font-size:15px;font-weight:700;color:#fff;"><span class="material-symbols-outlined" style="vertical-align:-3px;">folder_zip</span> ' + sName + ' 备份文件</div>' +
'<button class="jhs-wd-close material-symbols-outlined" style="background:none;border:none;color:#888;cursor:pointer;font-size:20px;">close</button>' +
'</div>' +
'<div style="flex:1;overflow-y:auto;padding:6px 10px;" id="wd-file-container">' + rowsHtml + '</div>' +
'</div>';
document.body.appendChild(modal);
modal.querySelector('.jhs-wd-close').addEventListener('click', () => modal.remove());
modal.addEventListener('click', e => { if (e.target === modal) modal.remove(); });
modal.querySelectorAll('.btn-wd-restore').forEach(function (btn) {
btn.addEventListener('click', async function () {
const name = btn.getAttribute('data-name');
if (!confirm('确定将云端备份「' + name + '」导入并覆盖本地数据库吗?')) return;
btn.textContent = '导入中...';
try {
const jsonStr = client.getFileContent ? await client.getFileContent(name) : await client.downloadBackup(name);
await FAV.importJson(jsonStr);
alert('云端备份已成功恢复!');
location.reload();
} catch (err) {
alert('恢复失败: ' + err.message);
btn.textContent = '恢复导入';
}
});
});
modal.querySelectorAll('.btn-wd-dl').forEach(function (btn) {
btn.addEventListener('click', async function () {
const name = btn.getAttribute('data-name');
btn.textContent = '下载中...';
try {
const jsonStr = client.getFileContent ? await client.getFileContent(name) : await client.downloadBackup(name);
const blob = new Blob([jsonStr], { type: 'application/json;charset=utf-8' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = name;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(function () { URL.revokeObjectURL(a.href); }, 5000);
btn.textContent = '下载';
} catch (err) {
alert('下载失败: ' + err.message);
btn.textContent = '下载';
}
});
});
modal.querySelectorAll('.btn-wd-del').forEach(function (btn) {
btn.addEventListener('click', async function () {
const name = btn.getAttribute('data-name');
const sha = btn.getAttribute('data-sha');
if (!confirm('确定删除云端备份「' + name + '」吗?')) return;
btn.textContent = '删除中...';
try {
if (client.deleteFile) await client.deleteFile(name, sha);
else await client.deleteBackup(name);
const row = btn.closest('.jhs-backup-row') || btn.closest('div[style*="justify-content:space-between"]');
if (row) row.remove();
} catch (err) {
alert('删除失败: ' + err.message);
btn.textContent = '删除';
}
});
});
}
const openWebDavListModal = openCloudBackupListModal;
/* =======================================================================
* 风格两级设置系统
* 1. 鼠标悬停齿轮:快捷悬浮面板 (#emby-quick-settings-popover)
* 2. 点击齿轮/更多设置:居中完整大弹窗 (#jhs-settings-mask)
* ===================================================================== */
let qsHideTimer = null;
function buildQuickSettingsPopover() {
if (document.getElementById('emby-quick-settings-popover')) return;
const pop = document.createElement('div');
pop.id = 'emby-quick-settings-popover';
pop.className = 'jhs-qs-popover';
pop.innerHTML =
'<div class="jhs-qs-header">' +
'<span class="jhs-qs-title"><span class="material-symbols-outlined" style="font-size:16px;">tune</span> 快捷设置</span>' +
'<span class="jhs-qs-sub">改动即时生效</span>' +
'</div>' +
'<div class="jhs-qs-body">' +
'<div class="jhs-qs-section">' +
'<div class="jhs-qs-section-title">增强插件</div>' +
'<div class="jhs-qs-row">' +
'<div class="jhs-qs-label"><span class="jhs-tooltip-wrap">标题翻译<span class="material-symbols-outlined jhs-help-icon">help</span><span class="jhs-tip-bubble">调用谷歌或备用翻译接口,将日文番号标题实时翻译为中文</span></span></div>' +
'<div class="jhs-qs-control"><input type="checkbox" id="qs-plugin-translate" class="mini-switch"></div>' +
'</div>' +
'<div class="jhs-qs-row">' +
'<div class="jhs-qs-label"><span class="jhs-tooltip-wrap">悬停封面大图<span class="material-symbols-outlined jhs-help-icon">help</span><span class="jhs-tip-bubble">鼠标悬停在影片卡片上时弹出超清大图浮层与详细参数</span></span></div>' +
'<div class="jhs-qs-control"><input type="checkbox" id="qs-plugin-dfPreview" class="mini-switch"></div>' +
'</div>' +
'<div class="jhs-qs-row">' +
'<div class="jhs-qs-label"><span class="jhs-tooltip-wrap">外部播放站点<span class="material-symbols-outlined jhs-help-icon">help</span><span class="jhs-tip-bubble">在番号详情页提供多源跳转播放(MissAV / Jable / 123AV / 自定义等)</span></span></div>' +
'<div class="jhs-qs-control"><input type="checkbox" id="qs-plugin-otherSite" class="mini-switch"></div>' +
'</div>' +
'<div class="jhs-qs-row">' +
'<div class="jhs-qs-label"><span class="jhs-tooltip-wrap">无限加载<span class="material-symbols-outlined jhs-help-icon">help</span><span class="jhs-tip-bubble">浏览列表时滚动到底部自动加载下一页内容,免去手动点击翻页</span></span></div>' +
'<div class="jhs-qs-control"><input type="checkbox" id="qs-plugin-infiniteScroll" class="mini-switch"></div>' +
'</div>' +
'<div class="jhs-qs-row">' +
'<div class="jhs-qs-label"><span class="jhs-tooltip-wrap">绕过JAVDB限制<span class="material-symbols-outlined jhs-help-icon">help</span><span class="jhs-tip-bubble">免VIP/登录畅享完整评论、相关清单原位展开,以及TOP250与热播榜单全量浏览</span></span></div>' +
'<div class="jhs-qs-control"><input type="checkbox" id="qs-plugin-reviewListInfinite" class="mini-switch"></div>' +
'</div>' +
'<div class="jhs-qs-row">' +
'<div class="jhs-qs-label"><span class="jhs-tooltip-wrap">状态快捷标记<span class="material-symbols-outlined jhs-help-icon">help</span><span class="jhs-tip-bubble">在作品封面提供「想看/已看评分」快捷标记按钮与多源超清预览大图弹窗</span></span></div>' +
'<div class="jhs-qs-control"><input type="checkbox" id="qs-plugin-quickStatusPreview" class="mini-switch"></div>' +
'</div>' +
'</div>' +
'<div class="jhs-qs-section">' +
'<div class="jhs-qs-section-title">常用布局</div>' +
'<div class="jhs-qs-row">' +
'<div class="jhs-qs-label"><span class="jhs-tooltip-wrap">展示布局<span class="material-symbols-outlined jhs-help-icon">help</span><span class="jhs-tip-bubble">卡片排列模式:经典网格整齐对齐 / 错落瀑布流错落排布 / 动态聚焦居中突出</span></span></div>' +
'<div class="jhs-qs-control">' +
'<select id="qs-layout" class="jhs-qs-select">' +
'<option value="grid">经典网格</option>' +
'<option value="masonry">错落瀑布流</option>' +
'<option value="dynamic-focus">动态聚焦</option>' +
'</select>' +
'</div>' +
'</div>' +
'<div class="jhs-qs-row">' +
'<div class="jhs-qs-label"><span class="jhs-tooltip-wrap">封面展现<span class="material-symbols-outlined jhs-help-icon">help</span><span class="jhs-tip-bubble">封面在卡片内的展现裁切比例:右侧裁切 / 完整全封面 / 融合渐变 / 混合模式</span></span></div>' +
'<div class="jhs-qs-control">' +
'<select id="qs-cover-mode" class="jhs-qs-select">' +
'<option value="crop">右侧裁切</option>' +
'<option value="full">完整全封面</option>' +
'<option value="fusion">融合模式</option>' +
'<option value="hybrid">混合模式</option>' +
'</select>' +
'</div>' +
'</div>' +
'<div class="jhs-qs-row">' +
'<div class="jhs-qs-label"><span class="jhs-tooltip-wrap">金标荣誉<span class="material-symbols-outlined jhs-help-icon">help</span><span class="jhs-tip-bubble">TOP250等上榜作品金标荣誉展示:金标荣誉卡 / 简约金标条 / HUD徽章 / 关闭</span></span></div>' +
'<div class="jhs-qs-control">' +
'<select id="qs-trophy-style" class="jhs-qs-select">' +
'<option value="card">金标荣誉卡</option>' +
'<option value="bar">简约金标条</option>' +
'<option value="hud">HUD徽章</option>' +
'<option value="off">关闭展示</option>' +
'</select>' +
'</div>' +
'</div>' +
'<div class="jhs-qs-row">' +
'<div class="jhs-qs-label"><span class="jhs-tooltip-wrap">收藏夹随机背景<span class="material-symbols-outlined jhs-help-icon">help</span><span class="jhs-tip-bubble">在我的收藏夹界面,随机抽取已收藏作品封面作为全屏沉浸式背景海报</span></span></div>' +
'<div class="jhs-qs-control"><input type="checkbox" id="qs-random-bg" class="mini-switch"></div>' +
'</div>' +
'<div class="jhs-qs-row">' +
'<div class="jhs-qs-label"><span class="jhs-tooltip-wrap">收藏夹显示备注<span class="material-symbols-outlined jhs-help-icon">help</span><span class="jhs-tip-bubble">在收藏夹的影片卡片下方直接展示自定义备注说明</span></span></div>' +
'<div class="jhs-qs-control"><input type="checkbox" id="qs-show-notes" class="mini-switch"></div>' +
'</div>' +
'</div>' +
'<div class="jhs-qs-section">' +
'<div class="jhs-qs-section-title">外观微调</div>' +
'<div class="jhs-qs-row jhs-qs-row-stacked">' +
'<div class="jhs-qs-label"><span class="jhs-tooltip-wrap">背景模糊<span class="material-symbols-outlined jhs-help-icon">help</span><span class="jhs-tip-bubble">调节全站海报背景的高斯模糊半径(像素越高越柔和虚化)</span></span></div>' +
'<div class="jhs-qs-control"><div class="jhs-qs-range"><input type="range" id="qs-blur" min="0" max="80" step="1"><span id="qs-blur-val">4px</span></div></div>' +
'</div>' +
'<div class="jhs-qs-row jhs-qs-row-stacked">' +
'<div class="jhs-qs-label"><span class="jhs-tooltip-wrap">封面框架大小<span class="material-symbols-outlined jhs-help-icon">help</span><span class="jhs-tip-bubble">调节当前封面模式的海报尺寸(裁切/融合各模式自动独立记忆)</span></span></div>' +
'<div class="jhs-qs-control"><div class="jhs-qs-range"><input type="range" id="qs-cover-size" min="240" max="560" step="10"><span id="qs-cover-size-val">340px</span></div></div>' +
'</div>' +
'</div>' +
'</div>' +
'<div class="jhs-qs-footer">' +
'<button type="button" id="qs-btn-reset" class="jhs-qs-btn jhs-qs-btn-ghost">恢复默认</button>' +
'<button type="button" id="qs-btn-more" class="jhs-qs-btn jhs-qs-btn-primary">更多设置</button>' +
'</div>';
document.body.appendChild(pop);
pop.addEventListener('mouseenter', function () { clearTimeout(qsHideTimer); });
pop.addEventListener('mouseleave', function (e) {
clearTimeout(qsHideTimer);
const to = e.relatedTarget;
const gear = document.getElementById('emby-settings-btn');
if (to && gear && (to === gear || gear.contains(to))) return;
qsHideTimer = setTimeout(function () { pop.classList.remove('open'); }, 250);
});
['translate', 'dfPreview', 'otherSite', 'infiniteScroll', 'reviewListInfinite', 'quickStatusPreview'].forEach(function (pid) {
const cb = pop.querySelector('#qs-plugin-' + pid);
if (cb) {
cb.addEventListener('change', function () {
setPluginEnabled(pid, cb.checked);
if (pid === 'dfPreview') {
localStorage.setItem(SET_KEYS.dfPreview, cb.checked ? '1' : '0');
if (!cb.checked) dfPopupHide();
} else if (pid === 'infiniteScroll' && cb.checked) {
if (typeof InfiniteScrollManager !== 'undefined') InfiniteScrollManager.init();
}
syncSettingsControls();
});
}
});
const qLayout = pop.querySelector('#qs-layout');
if (qLayout) {
qLayout.addEventListener('change', function () {
const lo = qLayout.value || 'grid';
localStorage.setItem(SET_KEYS.layout, lo);
applyLayout(lo);
syncSettingsControls();
});
}
const qCoverMode = pop.querySelector('#qs-cover-mode');
if (qCoverMode) {
qCoverMode.addEventListener('change', function () {
const cm = qCoverMode.value || 'fusion';
localStorage.setItem(SET_KEYS.coverMode, cm);
applySettings();
syncSettingsControls();
});
}
const qTrophyStyle = pop.querySelector('#qs-trophy-style');
if (qTrophyStyle) {
qTrophyStyle.addEventListener('change', function () {
const ts = qTrophyStyle.value || 'card';
localStorage.setItem(SET_KEYS.trophyStyle, ts);
applySettings();
syncSettingsControls();
});
}
const qRBg = pop.querySelector('#qs-random-bg');
if (qRBg) {
qRBg.addEventListener('change', function () {
localStorage.setItem(SET_KEYS.randomBg, qRBg.checked ? '1' : '0');
window.dispatchEvent(new CustomEvent('efav-randombg-change'));
syncSettingsControls();
});
}
const qNotes = pop.querySelector('#qs-show-notes');
if (qNotes) {
qNotes.addEventListener('change', function () {
localStorage.setItem(SET_KEYS.showNotes, qNotes.checked ? '1' : '0');
window.dispatchEvent(new CustomEvent('efav-notes-change'));
syncSettingsControls();
});
}
const qBlur = pop.querySelector('#qs-blur'), qBlurV = pop.querySelector('#qs-blur-val');
if (qBlur && qBlurV) {
qBlur.addEventListener('input', function () {
const v = qBlur.value;
qBlurV.textContent = v + 'px';
localStorage.setItem(SET_KEYS.blur, v);
document.documentElement.style.setProperty('--e-blur', v + 'px');
syncSettingsControls();
});
}
const qCover = pop.querySelector('#qs-cover-size'), qCoverV = pop.querySelector('#qs-cover-size-val');
if (qCover && qCoverV) {
qCover.addEventListener('input', function () {
const curMode = localStorage.getItem(SET_KEYS.coverMode) || 'fusion';
const defaultW = (curMode === 'hybrid') ? 440 : 340;
const v = parseInt(qCover.value, 10) || defaultW;
qCoverV.textContent = v + 'px';
setDetailCoverWForMode(curMode, v);
document.documentElement.style.setProperty('--e-cover-w', v + 'px');
document.documentElement.style.setProperty('--e-cover-h', Math.round(v * 1.5) + 'px');
document.documentElement.style.setProperty('--e-rel-w', Math.round(v * 0.6) + 'px');
syncSettingsControls();
});
}
pop.querySelector('#qs-btn-reset').addEventListener('click', resetAllSettings);
// qs-btn-diag 已按要求移除
pop.querySelector('#qs-btn-more').addEventListener('click', function () {
pop.classList.remove('open');
openSettingsPanel('appearance');
});
}
function showQuickSettingsPopover(targetEl) {
buildQuickSettingsPopover();
syncSettingsControls();
const pop = document.getElementById('emby-quick-settings-popover');
if (!pop || !targetEl) return;
clearTimeout(qsHideTimer);
const rect = targetEl.getBoundingClientRect();
const popW = 300;
let left = rect.left;
if (left + popW > window.innerWidth - 12) left = window.innerWidth - popW - 12;
if (left < 12) left = 12;
let top = rect.bottom + 8;
if (top + 400 > window.innerHeight && rect.top > 400) {
top = rect.top - 410;
}
pop.style.left = left + 'px';
pop.style.top = top + 'px';
pop.classList.add('open');
if (typeof LiquidGlass !== 'undefined') {
requestAnimationFrame(function () {
LiquidGlass.ensureApplied(pop);
});
}
}
function hideQuickSettingsPopover() {
clearTimeout(qsHideTimer);
qsHideTimer = setTimeout(function () {
const pop = document.getElementById('emby-quick-settings-popover');
if (pop) pop.classList.remove('open');
}, 250);
}
function buildFullSettingsDialog() {
if (document.getElementById('jhs-settings-mask')) return;
const mask = document.createElement('div');
mask.id = 'jhs-settings-mask';
mask.className = 'jhs-settings-mask';
mask.style.display = 'none';
mask.innerHTML =
'<div class="jhs-settings-dialog">' +
'<button class="jhs-settings-close material-symbols-outlined" title="关闭 (Esc)">close</button>' +
'<div class="jhs-sidebar">' +
'<div class="jhs-side-title"><span class="material-symbols-outlined" style="font-size:16px;">settings</span> 设置中心</div>' +
'<div class="jhs-side-item active" data-tab="appearance"><span class="material-symbols-outlined" style="font-size:16px;">palette</span> 外观与显示</div>' +
'<div class="jhs-side-item" data-tab="plugins"><span class="material-symbols-outlined" style="font-size:16px;">extension</span> 插件管理</div>' +
'<div class="jhs-side-item" data-tab="sites"><span class="material-symbols-outlined" style="font-size:16px;">smart_display</span> 外部网站</div>' +
'<div class="jhs-side-item" data-tab="webdav"><span class="material-symbols-outlined" style="font-size:16px;">cloud_sync</span> 云端备份 (WebDAV/Git)</div>' +
'<div class="jhs-side-item" data-tab="data"><span class="material-symbols-outlined" style="font-size:16px;">database</span> 数据管理</div>' +
'<div class="jhs-side-item" data-tab="diagnostics"><span class="material-symbols-outlined" style="font-size:16px;">query_stats</span> 统计中心</div>' +
'<div class="jhs-side-item" data-tab="meta-correct"><span class="material-symbols-outlined" style="font-size:16px;">edit_note</span> 元数据纠错管理</div>' +
'</div>' +
'<div class="jhs-content">' +
// 1. 外观与显示
'<div class="jhs-panel active" id="panel-appearance">' +
'<div class="jhs-panel-title"><span class="material-symbols-outlined">palette</span> 外观与显示设置</div>' +
'<div class="jhs-item-row">' +
'<div class="jhs-item-info"><div class="jhs-item-name">视觉风格</div><div class="jhs-item-desc">选择整体界面质感主题</div></div>' +
'<div style="width:160px;"><select id="full-style" class="jhs-input-text"><option value="emby">EMBY 经典</option><option value="glass">毛玻璃</option><option value="liquid">液态玻璃</option></select></div>' +
'</div>' +
'<div class="jhs-item-row">' +
'<div class="jhs-item-info"><div class="jhs-item-name">展示布局</div><div class="jhs-item-desc">标准网格、瀑布流或动态聚焦</div></div>' +
'<div style="width:160px;"><select id="full-layout" class="jhs-input-text"><option value="grid">标准网格</option><option value="masonry">瀑布流</option><option value="dynamic-focus">动态聚焦</option></select></div>' +
'</div>' +
'<div class="jhs-item-row">' +
'<div class="jhs-item-info"><div class="jhs-item-name">封面展现方式</div><div class="jhs-item-desc">详情页主海报排版形式</div></div>' +
'<div style="width:160px;"><select id="full-cover-mode" class="jhs-input-text"><option value="crop">右侧裁切</option><option value="full">完整全封面</option><option value="fusion">融合模式</option><option value="hybrid">混合模式</option></select></div>' +
'</div>' +
'<div class="jhs-item-row">' +
'<div class="jhs-item-info"><div class="jhs-item-name">金标荣誉展示</div><div class="jhs-item-desc">TOP250等上榜认证荣誉样式</div></div>' +
'<div style="width:160px;"><select id="full-trophy-style" class="jhs-input-text"><option value="card">金标荣誉卡</option><option value="bar">简约金标条</option><option value="hud">HUD徽章</option><option value="off">关闭展示</option></select></div>' +
'</div>' +
'<div class="jhs-item-row">' +
'<div class="jhs-item-info"><div class="jhs-item-name">背景模糊强度 (<span id="full-blur-val">4px</span>)</div><div class="jhs-item-desc">背景大图毛玻璃模糊半径</div></div>' +
'<div style="width:160px;"><input type="range" id="full-blur" min="0" max="80" step="1" style="width:100%;"></div>' +
'</div>' +
'<div class="jhs-item-row">' +
'<div class="jhs-item-info"><div class="jhs-item-name">封面框架大小 (<span id="full-cover-val">340px</span>)</div><div class="jhs-item-desc">调节海报卡片尺寸(裁切/融合等模式各自独立记忆)</div></div>' +
'<div style="width:160px;"><input type="range" id="full-cover" min="240" max="560" step="10" style="width:100%;"></div>' +
'</div>' +
'<div class="jhs-item-row">' +
'<div class="jhs-item-info"><div class="jhs-item-name">液态折射强度 (<span id="full-liquid-val">50%</span>)</div><div class="jhs-item-desc">仅液态玻璃生效:边缘晶体透镜微折射力度(内置物理安全限幅防撕裂)</div></div>' +
'<div style="width:160px;"><input type="range" id="full-liquid" min="0" max="100" step="1" style="width:100%;"></div>' +
'</div>' +
'<div class="jhs-item-row">' +
'<div class="jhs-item-info"><div class="jhs-item-name">悬停大图延迟 (<span id="full-dfdelay-val">0.8s</span>)</div><div class="jhs-item-desc">鼠标悬停封面等待弹出时长</div></div>' +
'<div style="width:160px;"><input type="range" id="full-dfdelay" min="0.1" max="3" step="0.1" style="width:100%;"></div>' +
'</div>' +
'<div class="jhs-item-row">' +
'<div class="jhs-item-info"><div class="jhs-item-name">悬停大图大小 (<span id="full-dfsize-val">53%</span>)</div><div class="jhs-item-desc">弹出预览气泡占屏幕宽度的比例</div></div>' +
'<div style="width:160px;"><input type="range" id="full-dfsize" min="25" max="85" step="1" style="width:100%;"></div>' +
'</div>' +
'<div class="jhs-item-row">' +
'<div class="jhs-item-info"><div class="jhs-item-name">收藏夹随机背景</div><div class="jhs-item-desc">收藏夹底图使用库内随机番号海报墙</div></div>' +
'<div><input type="checkbox" id="full-randombg" class="mini-switch"></div>' +
'</div>' +
'<div class="jhs-item-row">' +
'<div class="jhs-item-info"><div class="jhs-item-name">收藏夹显示备注</div><div class="jhs-item-desc">卡片展示作品级斜体橙色备注</div></div>' +
'<div><input type="checkbox" id="full-shownotes" class="mini-switch"></div>' +
'</div>' +
'<div class="jhs-item-row">' +
'<div class="jhs-item-info"><div class="jhs-item-name">展开全部共演演员</div><div class="jhs-item-desc">关闭后卡片最多显示两行(默认关闭);开启后展示全部演员</div></div>' +
'<div><input type="checkbox" id="full-expandcoact" class="mini-switch"></div>' +
'</div>' +
'</div>' +
// 2. 插件管理
'<div class="jhs-panel" id="panel-plugins">' +
'<div class="jhs-panel-title"><span class="material-symbols-outlined">extension</span> 增强插件管理</div>' +
'<div style="display:flex;gap:10px;margin-bottom:14px;">' +
'<div style="flex:1;background:rgba(255,255,255,.04);border-radius:8px;padding:10px;text-align:center;"><div id="pm-total" style="font-size:18px;font-weight:bold;color:var(--e-accent);">5</div><div style="font-size:11px;color:#888;">总插件数</div></div>' +
'<div style="flex:1;background:rgba(34,197,94,.08);border-radius:8px;padding:10px;text-align:center;"><div id="pm-enabled" style="font-size:18px;font-weight:bold;color:#22c55e;">5</div><div style="font-size:11px;color:#888;">已启用</div></div>' +
'<div style="flex:1;background:rgba(239,68,68,.08);border-radius:8px;padding:10px;text-align:center;"><div id="pm-disabled" style="font-size:18px;font-weight:bold;color:#ef4444;">0</div><div style="font-size:11px;color:#888;">已禁用</div></div>' +
'</div>' +
'<div id="full-plugin-list"></div>' +
'</div>' +
// 3. 外部网站
'<div class="jhs-panel" id="panel-sites">' +
'<div style="font-size:13px;font-weight:700;color:#fff;margin-bottom:8px;">外部播放站点</div>' +
'<div style="font-size:12px;color:#888;margin-bottom:12px;">在番号详情页提供多源跳转播放。支持按住拖拽把手上下调整顺序、修改网址模板、Logo链接、分组标签或单独启闭。</div>' +
'<div id="full-site-list"></div>' +
'<div style="margin-top:14px;padding:12px;background:rgba(255,255,255,.03);border:1px dashed rgba(255,255,255,.12);border-radius:8px;">' +
'<div style="font-size:12.5px;font-weight:700;margin-bottom:8px;">添加自定义播放站点</div>' +
'<div style="display:flex;gap:8px;margin-bottom:8px;flex-wrap:wrap;">' +
'<input type="text" id="add-site-name" class="jhs-input-text" placeholder="站点名称(如:MissAV)" style="flex:1;min-width:130px;">' +
'<input type="text" id="add-site-tpl" class="jhs-input-text" placeholder="网址模板(如:https://missav.live/{code})" style="flex:2;min-width:200px;">' +
'<input type="text" id="add-site-icon" class="jhs-input-text" placeholder="Logo链接 (可选)" style="flex:1.2;min-width:140px;">' +
'<input type="text" id="add-site-group" class="jhs-input-text" placeholder="分组(如:主力/综合)" style="flex:0.8;min-width:100px;">' +
'</div>' +
'<button type="button" id="btn-add-site" class="jhs-btn jhs-btn-primary"><span class="material-symbols-outlined" style="font-size:14px;">add</span> 添加播放站点</button>' +
'</div>' +
'<div style="margin-top:24px;border-top:1px solid rgba(255,255,255,.08);padding-top:16px;">' +
'<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;">' +
'<div style="font-size:13px;font-weight:700;color:#fff;display:flex;align-items:center;gap:6px;">' +
'<span class="material-symbols-outlined" style="font-size:16px;color:var(--e-accent);">info</span> 番号信息站点(解耦一键直达入口)' +
'</div>' +
'<div><input type="checkbox" id="full-toggle-infosites" class="mini-switch" title="开启/关闭番号信息站点"></div>' +
'</div>' +
'<div style="font-size:12px;color:#888;margin-bottom:12px;">开启后在番号详情页的番号后方以网站 LOGO 呈现各大信息数据库快捷跳转入口。支持按住拖拽把手上下调整顺序、修改网址模板、添加自定义信息站点或单独启闭。</div>' +
'<div id="full-info-site-list"></div>' +
'<div style="margin-top:14px;padding:12px;background:rgba(255,255,255,.03);border:1px dashed rgba(255,255,255,.12);border-radius:8px;">' +
'<div style="font-size:12.5px;font-weight:700;margin-bottom:8px;">添加自定义信息站点</div>' +
'<div style="display:flex;gap:8px;margin-bottom:8px;flex-wrap:wrap;">' +
'<input type="text" id="add-info-site-name" class="jhs-input-text" placeholder="站点名称(如:JavFinder)" style="flex:1;min-width:130px;">' +
'<input type="text" id="add-info-site-tpl" class="jhs-input-text" placeholder="网址模板(如:https://mysite.com/movie/{code})" style="flex:2;min-width:200px;">' +
'<input type="text" id="add-info-site-icon" class="jhs-input-text" placeholder="Logo链接 (可选)" style="flex:1.2;min-width:140px;">' +
'</div>' +
'<button type="button" id="btn-add-info-site" class="jhs-btn jhs-btn-primary"><span class="material-symbols-outlined" style="font-size:14px;">add</span> 添加信息站点</button>' +
'</div>' +
'</div>' +
'<div style="margin-top:24px;border-top:1px solid rgba(255,255,255,.08);padding-top:16px;">' +
'<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;">' +
'<div style="font-size:13px;font-weight:700;color:#fff;display:flex;align-items:center;gap:6px;">' +
'<span class="material-symbols-outlined" style="font-size:16px;color:#38bdf8;">imagesmode</span> 预览图嗅探与抓取站点' +
'</div>' +
'<div><input type="checkbox" id="full-toggle-preview-sites" class="mini-switch" title="开启/关闭状态快捷标记和预览图"></div>' +
'</div>' +
'<div style="font-size:12px;color:#888;margin-bottom:12px;">用于卡片封面快捷抓取预览大图与剧照画廊。按顺序逐个嗅探抓取,支持拖拽调整检索优先级、添加自定义新站点或单独启闭。</div>' +
'<div id="full-preview-site-list"></div>' +
'<div style="margin-top:14px;padding:12px;background:rgba(255,255,255,.03);border:1px dashed rgba(255,255,255,.12);border-radius:8px;">' +
'<div style="font-size:12.5px;font-weight:700;margin-bottom:8px;">添加自定义预览图抓取站点</div>' +
'<div style="display:flex;gap:8px;margin-bottom:8px;flex-wrap:wrap;">' +
'<input type="text" id="add-preview-site-name" class="jhs-input-text" placeholder="站点名称(如:JavStore)" style="flex:1;min-width:130px;">' +
'<input type="text" id="add-preview-site-tpl" class="jhs-input-text" placeholder="网址模板(如:https://javstore.net/search?q={code})" style="flex:2;min-width:200px;">' +
'<input type="text" id="add-preview-site-icon" class="jhs-input-text" placeholder="Logo链接 (可选)" style="flex:1.2;min-width:140px;">' +
'</div>' +
'<button type="button" id="btn-add-preview-site" class="jhs-btn jhs-btn-primary"><span class="material-symbols-outlined" style="font-size:14px;">add</span> 添加预览站点</button>' +
'</div>' +
'</div>' +
'</div>' +
'<div class="jhs-panel" id="panel-webdav">' +
'<div class="jhs-panel-title"><span class="material-symbols-outlined">cloud_sync</span> 云端备份与恢复 (WebDAV & Git)</div>' +
'<div style="font-size:12px;color:#888;margin-bottom:12px;">支持 WebDAV (坚果云/NAS) 与 Git (GitHub/Gitee/GitLab/自建)。全量同步影片、清单、画廊、备注及全部配置。</div>' +
// 备份凭据脱敏控制开关
'<div class="jhs-item-row" style="margin-bottom:14px;padding:10px 14px;background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.08);border-radius:8px;">' +
'<div class="jhs-item-info">' +
'<div class="jhs-item-name" style="display:flex;align-items:center;gap:6px;"><span class="material-symbols-outlined" style="font-size:16px;color:#f59e0b;">vpn_key</span> 备份时同时备份登录凭证与API信息</div>' +
'<div class="jhs-item-desc">勾选后备份中将包含 JAVDB 账号登录令牌 (Authorization)、签名凭据及 WebDAV/Git 访问密钥。建议仅在备份至高度可信的个人私有存储时开启;若关闭,导出的备份文件将自动剔除敏感凭证以防泄露。</div>' +
'</div>' +
'<div>' +
'<input type="checkbox" id="backup-include-auth" class="jhs-switch">' +
'</div>' +
'</div>' +
// WebDAV 子区块
'<div style="padding:14px;background:rgba(255,255,255,.03);border:1px solid rgba(255,255,255,.08);border-radius:8px;margin-bottom:16px;">' +
'<div style="font-size:13px;font-weight:700;color:#38bdf8;margin-bottom:8px;display:flex;align-items:center;gap:6px;"><span class="material-symbols-outlined" style="font-size:16px;">folder_shared</span> WebDAV 云端备份</div>' +
'<div style="margin-bottom:12px;background:rgba(56,189,248,.08);border:1px solid rgba(56,189,248,.22);border-radius:6px;padding:10px 12px;font-size:12px;line-height:1.6;color:#ccc;">' +
'<div style="font-weight:700;color:#38bdf8;margin-bottom:4px;display:flex;align-items:center;gap:4px;"><span class="material-symbols-outlined" style="font-size:15px;">help_outline</span> WebDAV 配置极简指引(以坚果云 / 私有 NAS 为例):</div>' +
'1. <b>坚果云(国内推荐)</b>:<br>' +
' • <b>服务地址</b>:填 <code>https://dav.jianguoyun.com/dav/</code>(注意末尾必须带 <code>/dav/</code>)。<br>' +
' • <b>用户名</b>:填坚果云注册邮箱。<br>' +
' • <b>密码</b>:<b>必须是【应用授权密码】</b>(不能用账号登录密码!坚果云网页版 → 右上角账户名 → 账户信息 → 安全设置 → 第三方应用管理 → 添加应用密码)。<br>' +
'2. <b>私有 NAS / Nextcloud / Alist / InfiniCLOUD</b>:<br>' +
' • 填你的 WebDAV 完整根路径(如 <code>http://192.168.1.100:5005/</code> 或 <code>https://xxx.infinicloud.jp/dav/</code>),输入对应账号与密码即可。<br>' +
'3. <b>自动建目与同步</b>:系统会自动在服务根目录下创建 <code>JavdbEmbySkin_backup</code> 专属文件夹存放所有备份,点【测试 WebDAV 连接】验证无误后即可一键备份与恢复。' +
'</div>' +
'<div class="jhs-item-row"><div class="jhs-item-info"><div class="jhs-item-name">服务地址</div><div class="jhs-item-desc">如:https://dav.jianguoyun.com/dav/</div></div><div style="width:280px;"><input type="text" id="wd-url" class="jhs-input-text" placeholder="https://dav.jianguoyun.com/dav/"></div></div>' +
'<div class="jhs-item-row"><div class="jhs-item-info"><div class="jhs-item-name">用户名 / 账号</div></div><div style="width:280px;"><input type="text" id="wd-user" class="jhs-input-text" placeholder="账号邮箱"></div></div>' +
'<div class="jhs-item-row"><div class="jhs-item-info"><div class="jhs-item-name">密码 / 应用授权码</div><div class="jhs-item-desc">坚果云等必须使用第三方应用授权密码</div></div><div style="width:280px;"><input type="password" id="wd-pass" class="jhs-input-text" placeholder="******"></div></div>' +
'<div style="display:flex;gap:10px;margin-top:12px;">' +
'<button type="button" id="btn-wd-test" class="jhs-btn jhs-btn-secondary"><span class="material-symbols-outlined" style="font-size:15px;">wifi_protected_setup</span> 测试 WebDAV 连接</button>' +
'<button type="button" id="btn-wd-backup" class="jhs-btn jhs-btn-success"><span class="material-symbols-outlined" style="font-size:15px;">cloud_upload</span> 立即备份到 WebDAV</button>' +
'<button type="button" id="btn-wd-list" class="jhs-btn jhs-btn-primary"><span class="material-symbols-outlined" style="font-size:15px;">folder_open</span> 查看 WebDAV 备份</button>' +
'</div>' +
'<div id="wd-status-msg" style="margin-top:10px;font-size:12px;"></div>' +
'</div>' +
// Git 子区块 (GitHub / Gitee 兼容)
'<div style="padding:14px;background:rgba(255,255,255,.03);border:1px solid rgba(255,255,255,.08);border-radius:8px;">' +
'<div style="font-size:13px;font-weight:700;color:#a855f7;margin-bottom:8px;display:flex;align-items:center;gap:6px;"><span class="material-symbols-outlined" style="font-size:16px;">fork_right</span> Git 云端备份 (GitHub / Gitee / 自建)</div>' +
'<div style="margin-bottom:12px;background:rgba(168,85,247,.08);border:1px solid rgba(168,85,247,.22);border-radius:6px;padding:10px 12px;font-size:12px;line-height:1.6;color:#ccc;">' +
'<div style="font-weight:700;color:#c084fc;margin-bottom:4px;display:flex;align-items:center;gap:4px;"><span class="material-symbols-outlined" style="font-size:15px;">help_outline</span> GitHub 精细化令牌 (Fine-grained PAT) 配置极简指引:</div>' +
'1. <b>创建私有仓库</b>:在 GitHub 新建一个 Private 仓库(例如 <code>yourname/JAVDBFAV</code>)。<br>' +
'2. <b>生成访问令牌</b>:右上角头像 → Settings → Developer Settings → Personal access tokens → <b>Fine-grained tokens</b> → Generate new token。<br>' +
'3. <b>指定仓库与权限</b>:<br>' +
' • <b>Repository access</b>:选 <b>Only select repositories</b> 并指定上面创建的私有备份仓库。<br>' +
' • <b>Permissions → Repository permissions</b>:搜索并勾选 <b>内容 (Contents)</b>,访问级别设为 <b>读取和写入 (Read and write)</b>(系统会自动勾选 Metadata 只读,其它权限全部留空)。<br>' +
'4. <b>填入下方配置</b>:复制生成的 <code>github_pat_...</code> 密钥填入【访问令牌】,仓库填 <code>用户名/仓库名</code>,分支填 <code>main</code>,点击测试连接即可。' +
'</div>' +
'<div class="jhs-item-row"><div class="jhs-item-info"><div class="jhs-item-name">API 服务地址</div><div class="jhs-item-desc">默认 https://api.github.com,Gitee 填 https://gitee.com/api/v5</div></div><div style="width:280px;"><input type="text" id="git-url" class="jhs-input-text" placeholder="https://api.github.com"></div></div>' +
'<div class="jhs-item-row"><div class="jhs-item-info"><div class="jhs-item-name">Git 仓库 (owner/repo)</div><div class="jhs-item-desc">例如:yourname/my-javdb-backup</div></div><div style="width:280px;"><input type="text" id="git-repo" class="jhs-input-text" placeholder="owner/repo"></div></div>' +
'<div class="jhs-item-row"><div class="jhs-item-info"><div class="jhs-item-name">仓库分支</div><div class="jhs-item-desc">默认 main 或 master</div></div><div style="width:280px;"><input type="text" id="git-branch" class="jhs-input-text" placeholder="main"></div></div>' +
'<div class="jhs-item-row"><div class="jhs-item-info"><div class="jhs-item-name">访问令牌 (PAT Token)</div><div class="jhs-item-desc">GitHub: Settings → Developer Settings → PAT (精细化令牌勾选 Contents 读写)</div></div><div style="width:280px;"><input type="password" id="git-token" class="jhs-input-text" placeholder="ghp_... 或 gitee token"></div></div>' +
'<div style="display:flex;gap:10px;margin-top:12px;">' +
'<button type="button" id="btn-git-test" class="jhs-btn jhs-btn-secondary"><span class="material-symbols-outlined" style="font-size:15px;">wifi_protected_setup</span> 测试 Git 连接</button>' +
'<button type="button" id="btn-git-backup" class="jhs-btn jhs-btn-success"><span class="material-symbols-outlined" style="font-size:15px;">cloud_upload</span> 立即备份到 Git</button>' +
'<button type="button" id="btn-git-list" class="jhs-btn jhs-btn-primary"><span class="material-symbols-outlined" style="font-size:15px;">folder_open</span> 查看 Git 备份</button>' +
'</div>' +
'<div id="git-status-msg" style="margin-top:10px;font-size:12px;"></div>' +
'</div>' +
'</div>' +
// 6. 插件诊断与自检
'<div class="jhs-panel" id="panel-diagnostics">' +
'<div class="jhs-panel-title"><span class="material-symbols-outlined">query_stats</span> 数据统计中心</div>' +
'<div style="font-size:12px;color:#888;margin-bottom:12px;">实时基于本地 IndexedDB 数据库深度挖掘媒体库收藏资产、补全进度与多维画像分析。</div>' +
'<div id="jhs-diagnostics-panel-container"></div>' +
'</div>' +
// 7. 元数据纠错管理
'<div class="jhs-panel" id="panel-meta-correct">' +
'<div class="jhs-panel-title" style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px;">' +
'<div style="display:flex;align-items:center;gap:6px;"><span class="material-symbols-outlined">edit_note</span> 元数据纠错管理</div>' +
'<button type="button" id="btn-meta-cloud-sync" class="jhs-btn jhs-btn-primary" style="font-size:12px;padding:5px 12px;display:flex;align-items:center;gap:6px;">' +
'<span class="material-symbols-outlined" style="font-size:16px;">sync</span> ⚡ 一键检查列表番号 JAVDB 官方更新并自愈' +
'</button>' +
'</div>' +
'<div style="font-size:12px;color:#888;margin-bottom:12px;">' +
'以表格集中归档已自定义修改的全部番号元数据(剔除错误女优、补充漏录女优、更正类别/片商/系列等)。点击番号可直达详情页;点击一键检查可批量比对 JAVDB 官方最新数据,若官方已纠正收录,将自动清除冗余自定义并完成自愈合流。' +
'</div>' +
'<div id="meta-cloud-sync-banner" style="display:none;margin-bottom:12px;padding:10px 14px;background:rgba(34,197,94,0.08);border:1px solid rgba(34,197,94,0.25);border-radius:8px;font-size:12px;color:#4ade80;align-items:center;justify-content:space-between;">' +
'<span id="meta-cloud-sync-msg">正在比对官方云端数据...</span>' +
'<span id="meta-cloud-sync-progress" style="font-family:monospace;font-weight:700;"></span>' +
'</div>' +
'<div style="display:flex;gap:10px;margin-bottom:12px;">' +
'<input type="text" id="meta-correct-search" class="jhs-input-text" placeholder="搜索已修正番号或作品标题..." style="flex:1;">' +
'<button type="button" id="btn-refresh-meta-correct" class="jhs-btn jhs-btn-secondary"><span class="material-symbols-outlined" style="font-size:14px;">refresh</span> 刷新</button>' +
'</div>' +
'<div id="full-meta-correct-table-container" style="max-height:480px;overflow-x:auto;overflow-y:auto;border:1px solid rgba(255,255,255,0.08);border-radius:8px;background:rgba(0,0,0,0.25);">' +
'</div>' +
'<div style="display:flex;justify-content:space-between;align-items:center;margin-top:8px;font-size:11px;color:#777;">' +
'<span>共记录 <strong id="meta-correct-total-count" style="color:#cbd5e1;">0</strong> 部作品具有本地自定义元数据更正规则</span>' +
'<span>更正数据优先用于收藏夹统计、女优画像与筛选器</span>' +
'</div>' +
'</div>' +
// 5. 数据管理
'<div class="jhs-panel" id="panel-data">' +
'<div class="jhs-panel-title"><span class="material-symbols-outlined">database</span> 数据导入导出与维护</div>' +
'<div class="jhs-item-row" style="margin-bottom:12px;padding:10px 14px;background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.08);border-radius:8px;">' +
'<div class="jhs-item-info">' +
'<div class="jhs-item-name" style="display:flex;align-items:center;gap:6px;"><span class="material-symbols-outlined" style="font-size:16px;color:#f59e0b;">vpn_key</span> 导出时包含登录凭证与API信息</div>' +
'<div class="jhs-item-desc">导出本地 JSON 备份时是否携带 JAVDB 账号登录令牌、签名及云端访问凭据。若需将备份文件分享他人,请保持关闭。</div>' +
'</div>' +
'<div>' +
'<input type="checkbox" id="backup-include-auth-data" class="jhs-switch">' +
'</div>' +
'</div>' +
'<div class="jhs-item-row">' +
'<div class="jhs-item-info"><div class="jhs-item-name">导出本地完整备份</div><div class="jhs-item-desc">将全部影片、清单、备注、配置打包为 JSON 下载到电脑</div></div>' +
'<div><button type="button" id="btn-data-export" class="jhs-btn jhs-btn-secondary"><span class="material-symbols-outlined" style="font-size:15px;">download</span> 导出数据</button></div>' +
'</div>' +
'<div class="jhs-item-row">' +
'<div class="jhs-item-info"><div class="jhs-item-name">导入本地备份</div><div class="jhs-item-desc">选择先前导出的 JSON 文件恢复(支持合并导入与覆盖导入)</div></div>' +
'<div><button type="button" id="btn-data-import" class="jhs-btn jhs-btn-primary"><span class="material-symbols-outlined" style="font-size:15px;">upload</span> 导入数据</button></div>' +
'</div>' +
'<div class="jhs-item-row">' +
'<div class="jhs-item-info"><div class="jhs-item-name">深度清理无归属残留作品</div><div class="jhs-item-desc">扫描并清除未收藏进任何清单且无法同步的残留孤儿数据(如未登录时产生的错误数据或榜单遗留作品)</div></div>' +
'<div><button type="button" id="btn-data-purge" class="jhs-btn jhs-btn-secondary" style="border-color:#f59e0b;color:#f59e0b;"><span class="material-symbols-outlined" style="font-size:15px;">cleaning_services</span> 清理残留</button></div>' +
'</div>' +
'<div class="jhs-item-row" style="margin-top:20px;border-top:1px solid rgba(239,68,68,.2);padding-top:16px;">' +
'<div class="jhs-item-info"><div class="jhs-item-name" style="color:#ef4444;">清空本地所有数据库</div><div class="jhs-item-desc">危险操作:清空 IndexedDB 中的影片、清单、画廊、备注数据</div></div>' +
'<div><button type="button" id="btn-data-clear" class="jhs-btn jhs-btn-danger"><span class="material-symbols-outlined" style="font-size:15px;">delete_forever</span> 清空数据库</button></div>' +
'</div>' +
'</div>' +
'</div>' +
'</div>';
document.body.appendChild(mask);
bindFullDialogEvents(mask);
}
function bindFullDialogEvents(mask) {
// 选项卡切换
const sideItems = mask.querySelectorAll('.jhs-side-item');
sideItems.forEach(function (item) {
item.addEventListener('click', function () {
const tab = item.getAttribute('data-tab');
sideItems.forEach(function (i) { i.classList.remove('active'); });
item.classList.add('active');
mask.querySelectorAll('.jhs-panel').forEach(function (p) { p.classList.remove('active'); });
const target = mask.querySelector('#panel-' + tab);
if (target) {
target.classList.add('active');
if (tab === 'meta-correct') renderFullMetaCorrectList(mask);
if (tab === 'sites') renderFullPreviewSiteList(mask);
}
});
});
// 关闭按钮与 Esc
mask.querySelector('.jhs-settings-close').addEventListener('click', closeSettingsPanel);
mask.addEventListener('click', function (e) { if (e.target === mask) closeSettingsPanel(); });
// 外观控件事件
const fStyle = mask.querySelector('#full-style');
fStyle.addEventListener('change', function () {
localStorage.setItem(SET_KEYS.style, fStyle.value);
applyStyleTheme(fStyle.value);
syncSettingsControls();
});
const fLayout = mask.querySelector('#full-layout');
fLayout.addEventListener('change', function () {
localStorage.setItem(SET_KEYS.layout, fLayout.value);
applyLayout(fLayout.value);
syncSettingsControls();
});
const fCoverMode = mask.querySelector('#full-cover-mode');
fCoverMode.addEventListener('change', function () {
localStorage.setItem(SET_KEYS.coverMode, fCoverMode.value);
applySettings();
syncSettingsControls();
});
const fTrophyStyle = mask.querySelector('#full-trophy-style');
if (fTrophyStyle) {
fTrophyStyle.addEventListener('change', function () {
localStorage.setItem(SET_KEYS.trophyStyle, fTrophyStyle.value);
applySettings();
syncSettingsControls();
});
}
const fBlur = mask.querySelector('#full-blur'), fBlurV = mask.querySelector('#full-blur-val');
fBlur.addEventListener('input', function () {
fBlurV.textContent = fBlur.value + 'px';
localStorage.setItem(SET_KEYS.blur, fBlur.value);
document.documentElement.style.setProperty('--e-blur', fBlur.value + 'px');
syncSettingsControls();
});
const fCover = mask.querySelector('#full-cover'), fCoverV = mask.querySelector('#full-cover-val');
fCover.addEventListener('input', function () {
const curMode = localStorage.getItem(SET_KEYS.coverMode) || 'fusion';
const defaultW = (curMode === 'hybrid') ? 440 : 340;
const v = parseInt(fCover.value, 10) || defaultW;
fCoverV.textContent = v + 'px';
setDetailCoverWForMode(curMode, v);
document.documentElement.style.setProperty('--e-cover-w', v + 'px');
document.documentElement.style.setProperty('--e-cover-h', Math.round(v * 1.5) + 'px');
document.documentElement.style.setProperty('--e-rel-w', Math.round(v * 0.6) + 'px');
syncSettingsControls();
});
const fLiquid = mask.querySelector('#full-liquid'), fLiquidV = mask.querySelector('#full-liquid-val');
fLiquid.addEventListener('input', function () {
const pct = Math.max(0, Math.min(100, parseInt(fLiquid.value, 10) || 0));
fLiquidV.textContent = pct + '%';
localStorage.setItem(SET_KEYS.liquidAmp, String(pct));
if (typeof LiquidGlass !== 'undefined') {
LiquidGlass.setAmp(pct);
}
syncSettingsControls();
});
const fDfDelay = mask.querySelector('#full-dfdelay'), fDfDelayV = mask.querySelector('#full-dfdelay-val');
fDfDelay.addEventListener('input', function () {
const v = Math.round((parseFloat(fDfDelay.value) || 0.8) * 10) / 10;
fDfDelayV.textContent = v.toFixed(1) + 's';
localStorage.setItem(SET_KEYS.dfPreviewDelay, String(v));
syncSettingsControls();
});
const fDfSize = mask.querySelector('#full-dfsize'), fDfSizeV = mask.querySelector('#full-dfsize-val');
fDfSize.addEventListener('input', function () {
const v = parseInt(fDfSize.value, 10) || 53;
fDfSizeV.textContent = v + '%';
localStorage.setItem(SET_KEYS.dfPreviewSize, String(v));
dfPopupHide();
syncSettingsControls();
});
const fRBg = mask.querySelector('#full-randombg');
fRBg.addEventListener('change', function () {
localStorage.setItem(SET_KEYS.randomBg, fRBg.checked ? '1' : '0');
window.dispatchEvent(new CustomEvent('efav-randombg-change'));
syncSettingsControls();
});
const fNotes = mask.querySelector('#full-shownotes');
fNotes.addEventListener('change', function () {
localStorage.setItem(SET_KEYS.showNotes, fNotes.checked ? '1' : '0');
window.dispatchEvent(new CustomEvent('efav-notes-change'));
syncSettingsControls();
});
const fCoact = mask.querySelector('#full-expandcoact');
if (fCoact) {
fCoact.addEventListener('change', function () {
localStorage.setItem(SET_KEYS.expandCoact, fCoact.checked ? '1' : '0');
applySettings();
syncSettingsControls();
});
}
// 外部播放站点管理
mask.querySelector('#btn-add-site').addEventListener('click', function () {
const nameInp = mask.querySelector('#add-site-name');
const tplInp = mask.querySelector('#add-site-tpl');
const iconInp = mask.querySelector('#add-site-icon');
const groupInp = mask.querySelector('#add-site-group');
const name = (nameInp.value || '').trim();
const tpl = (tplInp.value || '').trim();
const icon = (iconInp ? iconInp.value : '').trim();
const group = (groupInp ? groupInp.value : '').trim() || '其他';
if (!name || !tpl) { alert('请填写完整的站点名称与网址模板!'); return; }
const sites = getPlaySiteConfigs();
sites.push({ id: 'custom_' + Date.now(), name: name, template: tpl, icon: icon, group: group, enabled: true });
savePlaySiteConfigs(sites);
nameInp.value = ''; tplInp.value = '';
if (iconInp) iconInp.value = '';
if (groupInp) groupInp.value = '';
renderFullSiteList(mask);
if (enabled && isDetailPage()) restructureDetail(true);
});
// 番号信息站点总开关事件
const masterInfoToggle = mask.querySelector('#full-toggle-infosites');
if (masterInfoToggle) {
masterInfoToggle.addEventListener('change', function () {
setPluginEnabled('infoSites', masterInfoToggle.checked);
renderFullInfoSiteList(mask);
syncSettingsControls();
if (enabled && isDetailPage()) restructureDetail(true);
});
}
// 添加自定义信息站点事件
const addInfoSiteBtn = mask.querySelector('#btn-add-info-site');
if (addInfoSiteBtn) {
addInfoSiteBtn.addEventListener('click', function () {
const nameInp = mask.querySelector('#add-info-site-name');
const tplInp = mask.querySelector('#add-info-site-tpl');
const iconInp = mask.querySelector('#add-info-site-icon');
const name = (nameInp.value || '').trim();
const tpl = (tplInp.value || '').trim();
const icon = (iconInp ? iconInp.value : '').trim();
if (!name || !tpl) { alert('请填写完整的站点名称与网址模板!'); return; }
const sites = getInfoSiteConfigs();
sites.push({ id: 'custom_info_' + Date.now(), name: name, template: tpl, icon: icon, enabled: true });
saveInfoSiteConfigs(sites);
nameInp.value = ''; tplInp.value = '';
if (iconInp) iconInp.value = '';
renderFullInfoSiteList(mask);
if (enabled && isDetailPage()) restructureDetail(true);
});
}
// 状态快捷标记和预览图(预览站点总开关)事件
const masterPreviewToggle = mask.querySelector('#full-toggle-preview-sites');
if (masterPreviewToggle) {
masterPreviewToggle.addEventListener('change', function () {
setPluginEnabled('quickStatusPreview', masterPreviewToggle.checked);
renderFullPreviewSiteList(mask);
syncSettingsControls();
});
}
// 添加自定义预览图抓取站点事件
const addPreviewSiteBtn = mask.querySelector('#btn-add-preview-site');
if (addPreviewSiteBtn) {
addPreviewSiteBtn.addEventListener('click', function () {
const nameInp = mask.querySelector('#add-preview-site-name');
const tplInp = mask.querySelector('#add-preview-site-tpl');
const iconInp = mask.querySelector('#add-preview-site-icon');
const name = (nameInp.value || '').trim();
const tpl = (tplInp.value || '').trim();
const icon = (iconInp ? iconInp.value : '').trim();
if (!name || !tpl) { alert('请填写完整的站点名称与网址模板!'); return; }
const sites = getPreviewSiteConfigs();
sites.push({ id: 'custom_preview_' + Date.now(), name: name, template: tpl, icon: icon, enabled: true });
savePreviewSiteConfigs(sites);
nameInp.value = ''; tplInp.value = '';
if (iconInp) iconInp.value = '';
renderFullPreviewSiteList(mask);
});
}
// 元数据纠错管理事件(搜索、刷新、一键云端检查并自愈)
const metaSearchInp = mask.querySelector('#meta-correct-search');
if (metaSearchInp) {
metaSearchInp.addEventListener('input', function () {
renderFullMetaCorrectList(mask, metaSearchInp.value.trim());
});
}
const btnRefreshMeta = mask.querySelector('#btn-refresh-meta-correct');
if (btnRefreshMeta) {
btnRefreshMeta.addEventListener('click', function () {
renderFullMetaCorrectList(mask, metaSearchInp ? metaSearchInp.value.trim() : '');
});
}
const btnMetaCloudSync = mask.querySelector('#btn-meta-cloud-sync');
if (btnMetaCloudSync) {
btnMetaCloudSync.addEventListener('click', function () {
runBatchCloudCheck(mask);
});
}
// 备份包含凭据开关(云端与本地数据管理双向联动)
const backupAuthToggle = mask.querySelector('#backup-include-auth');
const backupAuthDataToggle = mask.querySelector('#backup-include-auth-data');
const initBackupAuth = (localStorage.getItem('javdb_backup_include_auth') === 'true');
if (backupAuthToggle) backupAuthToggle.checked = initBackupAuth;
if (backupAuthDataToggle) backupAuthDataToggle.checked = initBackupAuth;
function handleBackupAuthChange(val) {
localStorage.setItem('javdb_backup_include_auth', val ? 'true' : 'false');
if (backupAuthToggle) backupAuthToggle.checked = val;
if (backupAuthDataToggle) backupAuthDataToggle.checked = val;
}
if (backupAuthToggle) {
backupAuthToggle.addEventListener('change', function () {
handleBackupAuthChange(backupAuthToggle.checked);
});
}
if (backupAuthDataToggle) {
backupAuthDataToggle.addEventListener('change', function () {
handleBackupAuthChange(backupAuthDataToggle.checked);
});
}
// WebDAV 密码与凭据保存
const wdUrl = mask.querySelector('#wd-url');
const wdUser = mask.querySelector('#wd-user');
const wdPass = mask.querySelector('#wd-pass');
[wdUrl, wdUser, wdPass].forEach(function (inp) {
inp.addEventListener('change', function () {
localStorage.setItem('javdb_webdav_url', (wdUrl.value || '').trim());
localStorage.setItem('javdb_webdav_user', (wdUser.value || '').trim());
localStorage.setItem('javdb_webdav_pass', (wdPass.value || '').trim());
});
});
const statusMsg = mask.querySelector('#wd-status-msg');
function setWdStatus(msg, isErr) {
statusMsg.innerHTML = '<span style="color:' + (isErr ? '#ef4444' : '#22c55e') + '">' + escapeHtml(msg) + '</span>';
}
let isCloudSyncBusy = false;
// WebDAV 严格非空校验与测试
const btnWdTest = mask.querySelector('#btn-wd-test');
if (btnWdTest) {
btnWdTest.addEventListener('click', async function () {
if (isCloudSyncBusy) return;
const u = (wdUrl.value || '').trim();
const user = (wdUser.value || '').trim();
const p = (wdPass.value || '').trim();
localStorage.setItem('javdb_webdav_url', u);
localStorage.setItem('javdb_webdav_user', user);
localStorage.setItem('javdb_webdav_pass', p);
if (!u) { setWdStatus('❌ 请先填写 WebDAV 服务地址(如 https://dav.jianguoyun.com/dav/)', true); return; }
if (!user) { setWdStatus('❌ 请先填写 WebDAV 用户名 / 账号', true); return; }
if (!p) { setWdStatus('❌ 请先填写 WebDAV 密码 / 应用授权码', true); return; }
isCloudSyncBusy = true;
btnWdTest.disabled = true;
setWdStatus('正在连接 WebDAV 服务器并验证凭据...', false);
try {
const client = new WebDavClient(u, user, p);
await client.testConnection();
setWdStatus('✅ WebDAV 连接成功!账号凭据与读写权限正常。', false);
} catch (err) {
setWdStatus('❌ 连接失败: ' + (err.message || err), true);
} finally {
isCloudSyncBusy = false;
btnWdTest.disabled = false;
}
});
}
// WebDAV 立即备份
const btnWdBackup = mask.querySelector('#btn-wd-backup');
if (btnWdBackup) {
btnWdBackup.addEventListener('click', async function () {
if (isCloudSyncBusy) return;
const u = (wdUrl.value || '').trim();
const user = (wdUser.value || '').trim();
const p = (wdPass.value || '').trim();
localStorage.setItem('javdb_webdav_url', u);
localStorage.setItem('javdb_webdav_user', user);
localStorage.setItem('javdb_webdav_pass', p);
if (!u || !user || !p) { setWdStatus('❌ 请先填写完整的 WebDAV 服务地址、用户名及密码', true); return; }
isCloudSyncBusy = true;
btnWdBackup.disabled = true;
setWdStatus('正在导出本地数据库并上传...', false);
try {
if (!FAV || !FAV.exportJson) throw new Error('收藏夹数据模块未就绪');
const jsonStr = await FAV.exportJson();
const client = new WebDavClient(u, user, p);
const fileName = getBackupFileName();
await client.uploadBackup(fileName, jsonStr);
setWdStatus('🎉 备份成功!文件已存为: ' + fileName, false);
} catch (err) {
setWdStatus('❌ 备份失败: ' + (err.message || err), true);
} finally {
isCloudSyncBusy = false;
btnWdBackup.disabled = false;
}
});
}
// WebDAV 查看备份列表
const btnWdList = mask.querySelector('#btn-wd-list');
if (btnWdList) {
btnWdList.addEventListener('click', async function () {
if (isCloudSyncBusy) return;
const u = (wdUrl.value || '').trim();
const user = (wdUser.value || '').trim();
const p = (wdPass.value || '').trim();
if (!u || !user || !p) { setWdStatus('❌ 请先填写完整的 WebDAV 服务地址、用户名及密码', true); return; }
isCloudSyncBusy = true;
btnWdList.disabled = true;
setWdStatus('正在获取 WebDAV 备份列表...', false);
try {
const client = new WebDavClient(u, user, p);
const list = await client.getBackupList();
setWdStatus('已获取到 ' + list.length + ' 个 WebDAV 备份。', false);
openCloudBackupListModal(list, client, 'WebDAV');
} catch (err) {
setWdStatus('❌ 获取列表失败: ' + (err.message || err), true);
} finally {
isCloudSyncBusy = false;
btnWdList.disabled = false;
}
});
}
// Git 配置与事件处理
const gitUrlInp = mask.querySelector('#git-url');
const gitRepoInp = mask.querySelector('#git-repo');
const gitBranchInp = mask.querySelector('#git-branch');
const gitTokenInp = mask.querySelector('#git-token');
const gitStatusMsg = mask.querySelector('#git-status-msg');
if (gitUrlInp) gitUrlInp.value = localStorage.getItem('javdb_git_api_url') || 'https://api.github.com';
if (gitRepoInp) gitRepoInp.value = localStorage.getItem('javdb_git_repo') || '';
if (gitBranchInp) gitBranchInp.value = localStorage.getItem('javdb_git_branch') || 'main';
if (gitTokenInp) gitTokenInp.value = localStorage.getItem('javdb_git_token') || '';
[gitUrlInp, gitRepoInp, gitBranchInp, gitTokenInp].forEach(function (inp) {
if (!inp) return;
inp.addEventListener('change', function () {
localStorage.setItem('javdb_git_api_url', (gitUrlInp.value || '').trim());
localStorage.setItem('javdb_git_repo', (gitRepoInp.value || '').trim());
localStorage.setItem('javdb_git_branch', (gitBranchInp.value || '').trim());
localStorage.setItem('javdb_git_token', (gitTokenInp.value || '').trim());
});
});
function setGitStatus(msg, isErr) {
if (gitStatusMsg) gitStatusMsg.innerHTML = '<span style="color:' + (isErr ? '#ef4444' : '#22c55e') + '">' + escapeHtml(msg) + '</span>';
}
// Git 测试连接
const btnGitTest = mask.querySelector('#btn-git-test');
if (btnGitTest) {
btnGitTest.addEventListener('click', async function () {
const aUrl = (gitUrlInp.value || '').trim();
const repo = (gitRepoInp.value || '').trim();
const branch = (gitBranchInp.value || '').trim() || 'main';
const token = (gitTokenInp.value || '').trim();
localStorage.setItem('javdb_git_api_url', aUrl);
localStorage.setItem('javdb_git_repo', repo);
localStorage.setItem('javdb_git_branch', branch);
localStorage.setItem('javdb_git_token', token);
if (!repo) { setGitStatus('❌ 请填写 Git 仓库(格式:owner/repo)', true); return; }
if (!token) { setGitStatus('❌ 请填写 Git 访问令牌 (Personal Access Token)', true); return; }
setGitStatus('正在连接 Git 仓库并验证权限...', false);
try {
const client = new GitBackupClient(aUrl, repo, branch, token);
const res = await client.testConnection();
const repoName = res && res.name ? res.name : repo;
setGitStatus('✅ Git 连接成功!已验证仓库 [' + repoName + '] 与 Token 权限有效。', false);
} catch (err) {
setGitStatus('❌ Git 连接失败: ' + (err.message || err), true);
}
});
}
// Git 立即备份
const btnGitBackup = mask.querySelector('#btn-git-backup');
if (btnGitBackup) {
btnGitBackup.addEventListener('click', async function () {
if (isCloudSyncBusy) return;
const aUrl = (gitUrlInp.value || '').trim();
const repo = (gitRepoInp.value || '').trim();
const branch = (gitBranchInp.value || '').trim() || 'main';
const token = (gitTokenInp.value || '').trim();
localStorage.setItem('javdb_git_api_url', aUrl);
localStorage.setItem('javdb_git_repo', repo);
localStorage.setItem('javdb_git_branch', branch);
localStorage.setItem('javdb_git_token', token);
if (!repo || !token) { setGitStatus('❌ 请先填写完整的 Git 仓库与 Token 凭据', true); return; }
isCloudSyncBusy = true;
btnGitBackup.disabled = true;
setGitStatus('正在导出本地数据库并上传到 Git 仓库...', false);
try {
if (!FAV || !FAV.exportJson) throw new Error('收藏夹数据模块未就绪');
const jsonStr = await FAV.exportJson();
const client = new GitBackupClient(aUrl, repo, branch, token);
const fileName = getBackupFileName();
await client.backup(fileName, jsonStr);
setGitStatus('🎉 备份成功!Git 提交已创建:' + fileName, false);
} catch (err) {
setGitStatus('❌ Git 备份失败: ' + (err.message || err), true);
} finally {
isCloudSyncBusy = false;
btnGitBackup.disabled = false;
}
});
}
// Git 查看备份列表
const btnGitList = mask.querySelector('#btn-git-list');
if (btnGitList) {
btnGitList.addEventListener('click', async function () {
if (isCloudSyncBusy) return;
const aUrl = (gitUrlInp.value || '').trim();
const repo = (gitRepoInp.value || '').trim();
const branch = (gitBranchInp.value || '').trim() || 'main';
const token = (gitTokenInp.value || '').trim();
if (!repo || !token) { setGitStatus('❌ 请先填写完整的 Git 仓库与 Token 凭据', true); return; }
isCloudSyncBusy = true;
btnGitList.disabled = true;
setGitStatus('正在获取 Git 仓库备份文件列表...', false);
try {
const client = new GitBackupClient(aUrl, repo, branch, token);
const list = await client.getBackupList();
setGitStatus('已获取到 ' + list.length + ' 个 Git 备份文件。', false);
openCloudBackupListModal(list, client, 'Git');
} catch (err) {
setGitStatus('❌ 获取 Git 列表失败: ' + (err.message || err), true);
} finally {
isCloudSyncBusy = false;
btnGitList.disabled = false;
}
});
}
// 诊断选项卡联动渲染
sideItems.forEach(function (item) {
item.addEventListener('click', function () {
if (item.getAttribute('data-tab') === 'diagnostics') {
const dContainer = mask.querySelector('#jhs-diagnostics-panel-container');
if (dContainer && typeof PluginDiagnostics !== 'undefined') {
PluginDiagnostics.renderPanel(dContainer);
}
}
});
});
// 数据管理:本地导出
const exportBtn = mask.querySelector('#btn-data-export');
if (exportBtn) {
exportBtn.addEventListener('click', function () {
try {
if (!FAV || typeof FAV.exportJSON !== 'function') throw new Error('收藏夹导出模块未初始化');
FAV.exportJSON();
} catch (e) { alert('导出失败: ' + e.message); }
});
}
// 数据管理:本地导入
const importBtn = mask.querySelector('#btn-data-import');
if (importBtn) {
importBtn.addEventListener('click', function () {
try {
if (!FAV || typeof FAV.doImport !== 'function') throw new Error('收藏夹导入模块未就绪');
FAV.doImport(function () {
if (typeof FAVUI !== 'undefined' && typeof FAVUI.reload === 'function') FAVUI.reload();
else location.reload();
});
} catch (err) { alert('导入启动失败: ' + err.message); }
});
}
// 数据管理:清理残留无归属作品
const purgeBtn = mask.querySelector('#btn-data-purge');
if (purgeBtn) {
purgeBtn.addEventListener('click', async function () {
try {
const loggedIn = typeof isJavdbUserLoggedIn === 'function' && isJavdbUserLoggedIn();
let cleanUnauth = false;
if (loggedIn) {
cleanUnauth = confirm('是否一并清理无清单归属的仅标记(想看/看過)作品?\n「确定」= 彻底清理(清除所有无清单归属的作品)\n「取消」= 仅清理无标记且无清单的孤儿作品');
} else {
cleanUnauth = true;
}
purgeBtn.disabled = true;
purgeBtn.innerHTML = '<span class="material-symbols-outlined spin" style="font-size:15px;">sync</span> 正在清理...';
const count = (typeof FAV !== 'undefined' && typeof FAV.dbPurgeOrphanMovies === 'function')
? await FAV.dbPurgeOrphanMovies(cleanUnauth)
: 0;
if (count > 0) {
alert('清理完成!成功清除 ' + count + ' 部未收藏的残留影片。');
if (typeof FAVUI !== 'undefined' && typeof FAVUI.reload === 'function') FAVUI.reload();
} else {
alert('检查完成:本地数据库非常干净,未发现任何无归属的残留作品!');
}
} catch (err) {
alert('清理异常: ' + (err && err.message ? err.message : err));
} finally {
purgeBtn.disabled = false;
purgeBtn.innerHTML = '<span class="material-symbols-outlined" style="font-size:15px;">cleaning_services</span> 清理残留';
}
});
}
// 数据管理:清空数据库
mask.querySelector('#btn-data-clear').addEventListener('click', async function () {
if (!confirm('⚠️ 警告:该操作将清空本地 IndexedDB 数据库内所有的影片、清单、画廊、实体备注!\n\n确定要彻底清空吗?')) return;
try {
if (typeof FAV !== 'undefined' && typeof FAV.clearAll === 'function') {
await FAV.clearAll();
} else if (typeof window.__efavResetDb === 'function') {
window.__efavResetDb();
return;
}
alert('本地数据库已全部清空!');
location.reload();
} catch (e) { alert('清空失败: ' + e.message); }
});
}
/* 渲染设置中的插件列表 */
function renderFullPluginList(mask) {
const box = mask.querySelector('#full-plugin-list');
if (!box) return;
let enabledCount = 0;
let html = '';
PLUGIN_REGISTRY.forEach(function (pl) {
const isEn = isPluginEnabled(pl.id);
if (isEn) enabledCount++;
html +=
'<div class="jhs-item-row">' +
'<div class="jhs-item-info">' +
'<div class="jhs-item-name">' + escapeHtml(pl.name) + '</div>' +
'<div class="jhs-item-desc">' + escapeHtml(pl.desc) + '</div>' +
'</div>' +
'<div><input type="checkbox" id="pl-toggle-' + pl.id + '" class="mini-switch"' + (isEn ? ' checked' : '') + '></div>' +
'</div>';
});
box.innerHTML = html;
const pmTot = mask.querySelector('#pm-total');
const pmEn = mask.querySelector('#pm-enabled');
const pmDis = mask.querySelector('#pm-disabled');
if (pmTot) pmTot.textContent = String(PLUGIN_REGISTRY.length);
if (pmEn) pmEn.textContent = String(enabledCount);
if (pmDis) pmDis.textContent = String(PLUGIN_REGISTRY.length - enabledCount);
PLUGIN_REGISTRY.forEach(function (pl) {
const cb = box.querySelector('#pl-toggle-' + pl.id);
if (cb) {
cb.addEventListener('change', function () {
setPluginEnabled(pl.id, cb.checked);
if (pl.id === 'dfPreview') {
localStorage.setItem(SET_KEYS.dfPreview, cb.checked ? '1' : '0');
if (!cb.checked) dfPopupHide();
}
if (pl.id === 'infiniteScroll' && cb.checked && typeof InfiniteScrollManager !== 'undefined') {
InfiniteScrollManager.init();
}
renderFullPluginList(mask);
syncSettingsControls();
});
}
});
}
/* 渲染外部播放站点列表 */
/* 渲染外部播放站点列表(支持拖拽排序与分组编辑) */
function renderFullSiteList(mask) {
const box = mask.querySelector('#full-site-list');
if (!box) return;
const sites = getPlaySiteConfigs();
let html = '';
sites.forEach(function (s, idx) {
html +=
'<div class="jhs-item-row site-sortable-row" draggable="true" data-idx="' + idx + '" style="align-items:flex-start;padding:10px 14px;cursor:default;">' +
'<div style="display:flex;align-items:center;margin-top:4px;margin-right:10px;">' +
'<span class="material-symbols-outlined site-drag-handle" title="按住上下拖拽调整站点顺序">drag_indicator</span>' +
'</div>' +
'<div class="jhs-item-info" style="margin-top:2px;flex:1;">' +
'<div class="jhs-item-name" style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">' +
renderSiteLogoHtml(s.icon, s.name, s.id) +
'<span style="font-weight:600;font-size:13.5px;">' + escapeHtml(s.name) + '</span>' +
'<span class="site-group-badge">' + escapeHtml(s.group || '其他') + '</span>' +
(s.id.startsWith('custom_') ? '<span style="font-size:10px;background:rgba(255,255,255,.1);padding:1px 5px;border-radius:3px;color:#aaa;">自定义</span>' : '') +
'</div>' +
'<div style="display:flex;flex-direction:column;gap:6px;">' +
'<div style="display:flex;align-items:center;gap:6px;">' +
'<span style="font-size:11px;color:#888;width:55px;flex-shrink:0;">网址模板:</span>' +
'<input type="text" class="jhs-input-text site-tpl-input" data-idx="' + idx + '" value="' + escapeAttr(s.template || '') + '" placeholder="网址模板,如 https://.../{code}">' +
'</div>' +
'<div style="display:flex;align-items:center;gap:6px;">' +
'<span style="font-size:11px;color:#888;width:55px;flex-shrink:0;">Logo链接:</span>' +
'<input type="text" class="jhs-input-text site-icon-input" data-idx="' + idx + '" value="' + escapeAttr(s.icon || '') + '" placeholder="图标URL(留空则显示缩写徽标)">' +
'</div>' +
'<div style="display:flex;align-items:center;gap:6px;">' +
'<span style="font-size:11px;color:#888;width:55px;flex-shrink:0;">所属分组:</span>' +
'<input type="text" class="jhs-input-text site-group-input" data-idx="' + idx + '" value="' + escapeAttr(s.group || '') + '" placeholder="分组名(如:主力 / 主流 / 综合 / 其他)" style="max-width:180px;">' +
'</div>' +
'</div>' +
'</div>' +
'<div style="display:flex;align-items:center;gap:10px;margin-top:6px;margin-left:12px;">' +
'<input type="checkbox" class="mini-switch site-en-toggle" data-idx="' + idx + '"' + (s.enabled !== false ? ' checked' : '') + '>' +
(s.id.startsWith('custom_') ? '<button type="button" class="jhs-btn jhs-btn-danger btn-del-site" data-idx="' + idx + '" style="padding:3px 8px;font-size:11px;">删除</button>' : '') +
'</div>' +
'</div>';
});
box.innerHTML = html;
box.querySelectorAll('.site-tpl-input').forEach(function (inp) {
inp.addEventListener('change', function () {
const idx = parseInt(inp.getAttribute('data-idx'), 10);
sites[idx].template = inp.value.trim();
savePlaySiteConfigs(sites);
if (enabled && isDetailPage()) restructureDetail(true);
});
});
box.querySelectorAll('.site-icon-input').forEach(function (inp) {
inp.addEventListener('change', function () {
const idx = parseInt(inp.getAttribute('data-idx'), 10);
sites[idx].icon = inp.value.trim();
savePlaySiteConfigs(sites);
renderFullSiteList(mask);
if (enabled && isDetailPage()) restructureDetail(true);
});
});
box.querySelectorAll('.site-group-input').forEach(function (inp) {
inp.addEventListener('change', function () {
const idx = parseInt(inp.getAttribute('data-idx'), 10);
sites[idx].group = inp.value.trim() || '其他';
savePlaySiteConfigs(sites);
renderFullSiteList(mask);
if (enabled && isDetailPage()) restructureDetail(true);
});
});
box.querySelectorAll('.site-en-toggle').forEach(function (cb) {
cb.addEventListener('change', function () {
const idx = parseInt(cb.getAttribute('data-idx'), 10);
sites[idx].enabled = cb.checked;
savePlaySiteConfigs(sites);
if (enabled && isDetailPage()) restructureDetail(true);
});
});
box.querySelectorAll('.btn-del-site').forEach(function (btn) {
btn.addEventListener('click', function () {
const idx = parseInt(btn.getAttribute('data-idx'), 10);
sites.splice(idx, 1);
savePlaySiteConfigs(sites);
renderFullSiteList(mask);
if (enabled && isDetailPage()) restructureDetail(true);
});
});
// 绑定 HTML5 拖拽重排事件
let dragSrcIdx = null;
box.querySelectorAll('.site-sortable-row').forEach(function (row) {
row.addEventListener('dragstart', function (e) {
dragSrcIdx = parseInt(row.getAttribute('data-idx'), 10);
row.classList.add('is-dragging');
e.dataTransfer.effectAllowed = 'move';
try { e.dataTransfer.setData('text/plain', String(dragSrcIdx)); } catch (err) {}
});
row.addEventListener('dragover', function (e) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
row.classList.add('drag-over');
});
row.addEventListener('dragleave', function () {
row.classList.remove('drag-over');
});
row.addEventListener('drop', function (e) {
e.preventDefault();
row.classList.remove('drag-over');
const targetIdx = parseInt(row.getAttribute('data-idx'), 10);
if (dragSrcIdx !== null && dragSrcIdx !== targetIdx && !isNaN(targetIdx)) {
const moved = sites.splice(dragSrcIdx, 1)[0];
sites.splice(targetIdx, 0, moved);
savePlaySiteConfigs(sites);
renderFullSiteList(mask);
if (enabled && isDetailPage()) restructureDetail(true);
}
});
row.addEventListener('dragend', function () {
row.classList.remove('is-dragging', 'drag-over');
box.querySelectorAll('.site-sortable-row').forEach(function (r) {
r.classList.remove('drag-over', 'is-dragging');
});
});
});
}
/* 渲染番号信息站点配置列表(支持拖拽排序与自定义添加/删除) */
function renderFullInfoSiteList(mask) {
const box = mask.querySelector('#full-info-site-list');
if (!box) return;
const isEn = isPluginEnabled('infoSites');
const masterToggle = mask.querySelector('#full-toggle-infosites');
if (masterToggle) masterToggle.checked = isEn;
const infoSites = getInfoSiteConfigs();
let html = '';
infoSites.forEach(function (s, idx) {
html +=
'<div class="jhs-item-row site-sortable-row info-site-sortable-row" draggable="' + (isEn ? 'true' : 'false') + '" data-idx="' + idx + '" style="align-items:flex-start;padding:10px 14px;cursor:default;opacity:' + (isEn ? '1' : '0.5') + ';">' +
'<div style="display:flex;align-items:center;margin-top:4px;margin-right:10px;">' +
'<span class="material-symbols-outlined site-drag-handle" title="' + (isEn ? '按住上下拖拽调整信息站点顺序' : '请先开启信息站点') + '">drag_indicator</span>' +
'</div>' +
'<div class="jhs-item-info" style="margin-top:2px;flex:1;">' +
'<div class="jhs-item-name" style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">' +
renderSiteLogoHtml(s.icon, s.name, s.id) +
'<span style="font-weight:600;font-size:13.5px;">' + escapeHtml(s.name) + '</span>' +
(s.id && s.id.startsWith('custom_') ? '<span style="font-size:10px;background:rgba(255,255,255,.1);padding:1px 5px;border-radius:3px;color:#aaa;">自定义</span>' : '') +
'</div>' +
'<div style="display:flex;flex-direction:column;gap:6px;">' +
'<div style="display:flex;align-items:center;gap:6px;">' +
'<span style="font-size:11px;color:#888;width:55px;flex-shrink:0;">网址模板:</span>' +
'<input type="text" class="jhs-input-text info-site-tpl-input" data-idx="' + idx + '" value="' + escapeAttr(s.template || '') + '" placeholder="网址模板,如 https://.../{code}"' + (isEn ? '' : ' disabled') + ' style="font-size:12px;">' +
'</div>' +
'<div style="display:flex;align-items:center;gap:6px;">' +
'<span style="font-size:11px;color:#888;width:55px;flex-shrink:0;">Logo链接:</span>' +
'<input type="text" class="jhs-input-text info-site-icon-input" data-idx="' + idx + '" value="' + escapeAttr(s.icon || '') + '" placeholder="图标URL(留空则显示缩写徽标)"' + (isEn ? '' : ' disabled') + ' style="font-size:12px;">' +
'</div>' +
'</div>' +
'</div>' +
'<div style="display:flex;align-items:center;gap:10px;margin-top:6px;margin-left:12px;">' +
'<input type="checkbox" class="mini-switch info-site-en-toggle" data-idx="' + idx + '"' + (s.enabled !== false ? ' checked' : '') + (isEn ? '' : ' disabled') + '>' +
(s.id && s.id.startsWith('custom_') ? '<button type="button" class="jhs-btn jhs-btn-danger btn-del-info-site" data-idx="' + idx + '" style="padding:3px 8px;font-size:11px;"' + (isEn ? '' : ' disabled') + '>删除</button>' : '') +
'</div>' +
'</div>';
});
box.innerHTML = html;
box.querySelectorAll('.info-site-tpl-input').forEach(function (inp) {
inp.addEventListener('change', function () {
const idx = parseInt(inp.getAttribute('data-idx'), 10);
infoSites[idx].template = inp.value.trim();
saveInfoSiteConfigs(infoSites);
if (enabled && isDetailPage()) restructureDetail(true);
});
});
box.querySelectorAll('.info-site-icon-input').forEach(function (inp) {
inp.addEventListener('change', function () {
const idx = parseInt(inp.getAttribute('data-idx'), 10);
infoSites[idx].icon = inp.value.trim();
saveInfoSiteConfigs(infoSites);
renderFullInfoSiteList(mask);
if (enabled && isDetailPage()) restructureDetail(true);
});
});
box.querySelectorAll('.info-site-en-toggle').forEach(function (cb) {
cb.addEventListener('change', function () {
const idx = parseInt(cb.getAttribute('data-idx'), 10);
infoSites[idx].enabled = cb.checked;
saveInfoSiteConfigs(infoSites);
if (enabled && isDetailPage()) restructureDetail(true);
});
});
box.querySelectorAll('.btn-del-info-site').forEach(function (btn) {
btn.addEventListener('click', function () {
const idx = parseInt(btn.getAttribute('data-idx'), 10);
infoSites.splice(idx, 1);
saveInfoSiteConfigs(infoSites);
renderFullInfoSiteList(mask);
if (enabled && isDetailPage()) restructureDetail(true);
});
});
// 绑定 HTML5 拖拽重排事件
let dragSrcIdx = null;
box.querySelectorAll('.info-site-sortable-row').forEach(function (row) {
row.addEventListener('dragstart', function (e) {
if (!isEn) { e.preventDefault(); return; }
dragSrcIdx = parseInt(row.getAttribute('data-idx'), 10);
row.classList.add('is-dragging');
e.dataTransfer.effectAllowed = 'move';
try { e.dataTransfer.setData('text/plain', String(dragSrcIdx)); } catch (err) {}
});
row.addEventListener('dragover', function (e) {
if (!isEn) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
row.classList.add('drag-over');
});
row.addEventListener('dragleave', function () {
row.classList.remove('drag-over');
});
row.addEventListener('drop', function (e) {
if (!isEn) return;
e.preventDefault();
row.classList.remove('drag-over');
const targetIdx = parseInt(row.getAttribute('data-idx'), 10);
if (dragSrcIdx !== null && dragSrcIdx !== targetIdx && !isNaN(targetIdx)) {
const moved = infoSites.splice(dragSrcIdx, 1)[0];
infoSites.splice(targetIdx, 0, moved);
saveInfoSiteConfigs(infoSites);
renderFullInfoSiteList(mask);
if (enabled && isDetailPage()) restructureDetail(true);
}
});
row.addEventListener('dragend', function () {
row.classList.remove('is-dragging', 'drag-over');
box.querySelectorAll('.info-site-sortable-row').forEach(function (r) {
r.classList.remove('drag-over', 'is-dragging');
});
});
});
}
function formatRelativeTime(ts) {
if (!ts) return '未知';
const diff = Date.now() - ts;
if (diff < 60000) return '刚刚';
if (diff < 3600000) return Math.floor(diff / 60000) + '分钟前';
if (diff < 86400000) return Math.floor(diff / 3600000) + '小时前';
const d = new Date(ts);
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
}
/* 渲染预览图抓取站点配置列表(支持拖拽排序与自定义添加/删除) */
function renderFullPreviewSiteList(mask) {
const box = mask.querySelector('#full-preview-site-list');
if (!box) return;
const isEn = isPluginEnabled('quickStatusPreview');
const masterToggle = mask.querySelector('#full-toggle-preview-sites');
if (masterToggle) masterToggle.checked = isEn;
const sites = getPreviewSiteConfigs();
let html = '';
sites.forEach(function (s, idx) {
html +=
'<div class="jhs-item-row site-sortable-row preview-site-sortable-row" draggable="' + (isEn ? 'true' : 'false') + '" data-idx="' + idx + '" style="align-items:flex-start;padding:10px 14px;cursor:default;opacity:' + (isEn ? '1' : '0.5') + ';">' +
'<div style="display:flex;align-items:center;margin-top:4px;margin-right:10px;">' +
'<span class="material-symbols-outlined site-drag-handle" title="' + (isEn ? '按住上下拖拽调整预览站点检索优先级' : '请先开启预览图功能') + '">drag_indicator</span>' +
'</div>' +
'<div class="jhs-item-info" style="margin-top:2px;flex:1;">' +
'<div class="jhs-item-name" style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">' +
renderSiteLogoHtml(s.icon, s.name, s.id) +
'<span style="font-weight:600;font-size:13.5px;">' + escapeHtml(s.name) + '</span>' +
(s.id && s.id.startsWith('custom_') ? '<span style="font-size:10px;background:rgba(255,255,255,.1);padding:1px 5px;border-radius:3px;color:#aaa;">自定义</span>' : '<span style="font-size:10px;background:rgba(56,189,248,.15);color:#38bdf8;padding:1px 5px;border-radius:3px;">内置引擎</span>') +
'</div>' +
'<div style="display:flex;flex-direction:column;gap:6px;">' +
'<div style="display:flex;align-items:center;gap:6px;">' +
'<span style="font-size:11px;color:#888;width:55px;flex-shrink:0;">网址模板:</span>' +
'<input type="text" class="jhs-input-text preview-site-tpl-input" data-idx="' + idx + '" value="' + escapeAttr(s.template || '') + '" placeholder="网址模板,如 https://.../{code}"' + (isEn ? '' : ' disabled') + ' style="font-size:12px;">' +
'</div>' +
'<div style="display:flex;align-items:center;gap:6px;">' +
'<span style="font-size:11px;color:#888;width:55px;flex-shrink:0;">Logo链接:</span>' +
'<input type="text" class="jhs-input-text preview-site-icon-input" data-idx="' + idx + '" value="' + escapeAttr(s.icon || '') + '" placeholder="图标URL(留空则显示缩写徽标)"' + (isEn ? '' : ' disabled') + ' style="font-size:12px;">' +
'</div>' +
'</div>' +
'</div>' +
'<div style="display:flex;align-items:center;gap:10px;margin-top:6px;margin-left:12px;">' +
'<input type="checkbox" class="mini-switch preview-site-en-toggle" data-idx="' + idx + '"' + (s.enabled !== false ? ' checked' : '') + (isEn ? '' : ' disabled') + '>' +
(s.id && s.id.startsWith('custom_') ? '<button type="button" class="jhs-btn jhs-btn-danger btn-del-preview-site" data-idx="' + idx + '" style="padding:3px 8px;font-size:11px;"' + (isEn ? '' : ' disabled') + '>删除</button>' : '') +
'</div>' +
'</div>';
});
box.innerHTML = html;
box.querySelectorAll('.preview-site-tpl-input').forEach(function (inp) {
inp.addEventListener('change', function () {
const idx = parseInt(inp.getAttribute('data-idx'), 10);
sites[idx].template = inp.value.trim();
savePreviewSiteConfigs(sites);
});
});
box.querySelectorAll('.preview-site-icon-input').forEach(function (inp) {
inp.addEventListener('change', function () {
const idx = parseInt(inp.getAttribute('data-idx'), 10);
sites[idx].icon = inp.value.trim();
savePreviewSiteConfigs(sites);
renderFullPreviewSiteList(mask);
});
});
box.querySelectorAll('.preview-site-en-toggle').forEach(function (cb) {
cb.addEventListener('change', function () {
const idx = parseInt(cb.getAttribute('data-idx'), 10);
sites[idx].enabled = cb.checked;
savePreviewSiteConfigs(sites);
});
});
box.querySelectorAll('.btn-del-preview-site').forEach(function (btn) {
btn.addEventListener('click', function () {
const idx = parseInt(btn.getAttribute('data-idx'), 10);
sites.splice(idx, 1);
savePreviewSiteConfigs(sites);
renderFullPreviewSiteList(mask);
});
});
// 绑定 HTML5 拖拽重排事件
let dragSrcIdx = null;
box.querySelectorAll('.preview-site-sortable-row').forEach(function (row) {
row.addEventListener('dragstart', function (e) {
if (!isEn) { e.preventDefault(); return; }
dragSrcIdx = parseInt(row.getAttribute('data-idx'), 10);
row.classList.add('is-dragging');
e.dataTransfer.effectAllowed = 'move';
try { e.dataTransfer.setData('text/plain', String(dragSrcIdx)); } catch (err) {}
});
row.addEventListener('dragover', function (e) {
if (!isEn) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
row.classList.add('drag-over');
});
row.addEventListener('dragleave', function () {
row.classList.remove('drag-over');
});
row.addEventListener('drop', function (e) {
if (!isEn) return;
e.preventDefault();
row.classList.remove('drag-over');
const targetIdx = parseInt(row.getAttribute('data-idx'), 10);
if (dragSrcIdx !== null && dragSrcIdx !== targetIdx && !isNaN(targetIdx)) {
const moved = sites.splice(dragSrcIdx, 1)[0];
sites.splice(targetIdx, 0, moved);
savePreviewSiteConfigs(sites);
renderFullPreviewSiteList(mask);
}
});
row.addEventListener('dragend', function () {
row.classList.remove('is-dragging', 'drag-over');
box.querySelectorAll('.preview-site-sortable-row').forEach(function (r) {
r.classList.remove('drag-over', 'is-dragging');
});
});
});
}
/* 渲染设置中心 · 元数据纠错管理表格 */
async function renderFullMetaCorrectList(mask, searchKw) {
const container = mask.querySelector('#full-meta-correct-table-container');
if (!container) return;
const countEl = mask.querySelector('#meta-correct-total-count');
let allMovies = [];
try {
if (typeof FAV !== 'undefined' && typeof FAV.dbAllMovies === 'function') {
allMovies = await FAV.dbAllMovies();
}
} catch (e) {}
// 筛选具有 customMeta 的作品
let records = allMovies.filter(function (m) {
if (!m || !m.customMeta) return false;
const cm = m.customMeta;
const hasActors = cm.actors && ((cm.actors.added && cm.actors.added.length > 0) || (cm.actors.deleted && cm.actors.deleted.length > 0));
const hasCategories = cm.categories && ((cm.categories.added && cm.categories.added.length > 0) || (cm.categories.deleted && cm.categories.deleted.length > 0));
return hasActors || hasCategories || cm.director || cm.maker || cm.series;
});
if (countEl) countEl.textContent = String(records.length);
if (searchKw) {
const q = searchKw.toLowerCase();
records = records.filter(function (m) {
const c = (m.code || '').toLowerCase();
const t = (m.title || '').toLowerCase();
const a = (m.actors || []).map(function (x) { return x.name || ''; }).join(' ').toLowerCase();
return c.indexOf(q) !== -1 || t.indexOf(q) !== -1 || a.indexOf(q) !== -1;
});
}
// 按最后更新时间降序排列
records.sort(function (a, b) {
const ta = (a.customMeta && a.customMeta.updatedAt) || 0;
const tb = (b.customMeta && b.customMeta.updatedAt) || 0;
return tb - ta;
});
if (!records.length) {
container.innerHTML = '<div style="padding:30px;text-align:center;color:#64748b;font-size:12px;">' +
(searchKw ? '未搜索到匹配的元数据更正记录' : '暂无任何手动自定义修正的番号元数据。在番号详情页鼠标划过元数据行即可点击「编辑」进行自定义修正。') +
'</div>';
return;
}
let tableHtml = '<table style="width:100%;min-width:780px;table-layout:fixed;border-collapse:collapse;font-size:12px;color:#cbd5e1;text-align:left;">' +
'<colgroup>' +
'<col style="width:115px;">' +
'<col style="width:200px;">' +
'<col style="width:auto;">' +
'<col style="width:105px;">' +
'<col style="width:90px;">' +
'<col style="width:75px;">' +
'</colgroup>' +
'<thead>' +
'<tr style="background:rgba(255,255,255,0.06);border-bottom:1px solid rgba(255,255,255,0.12);color:#e2e8f0;font-size:12px;font-weight:600;">' +
'<th style="padding:10px 12px;white-space:nowrap;word-break:keep-all;">番号</th>' +
'<th style="padding:10px 12px;white-space:nowrap;word-break:keep-all;">作品标题</th>' +
'<th style="padding:10px 12px;white-space:nowrap;word-break:keep-all;">自定义修正内容</th>' +
'<th style="padding:10px 12px;text-align:center;white-space:nowrap;word-break:keep-all;">官方状态比对</th>' +
'<th style="padding:10px 12px;text-align:center;white-space:nowrap;word-break:keep-all;">更正时间</th>' +
'<th style="padding:10px 12px;text-align:center;white-space:nowrap;word-break:keep-all;">操作</th>' +
'</tr>' +
'</thead>' +
'<tbody>';
records.forEach(function (m) {
const cm = m.customMeta || {};
const parts = [];
if (cm.actors) {
if (cm.actors.deleted && cm.actors.deleted.length) {
parts.push('<span style="color:#ef4444;">❌ 剔除演员: ' + escapeHtml(cm.actors.deleted.join(', ')) + '</span>');
}
if (cm.actors.added && cm.actors.added.length) {
parts.push('<span style="color:#4ade80;">🟢 补充女优: ' + escapeHtml(cm.actors.added.map(function (a) { return a.name; }).join(', ')) + '</span>');
}
}
if (cm.categories) {
if (cm.categories.deleted && cm.categories.deleted.length) {
parts.push('<span style="color:#f87171;">❌ 排除类别: ' + escapeHtml(cm.categories.deleted.join(', ')) + '</span>');
}
if (cm.categories.added && cm.categories.added.length) {
parts.push('<span style="color:#34d399;">🟢 新增类别: ' + escapeHtml(cm.categories.added.map(function (c) { return c.text; }).join(', ')) + '</span>');
}
}
if (cm.director) parts.push('<span style="color:#38bdf8;">✎ 导演: ' + escapeHtml(cm.director.text) + '</span>');
if (cm.maker) parts.push('<span style="color:#38bdf8;">✎ 片商: ' + escapeHtml(cm.maker.text) + '</span>');
if (cm.series) parts.push('<span style="color:#38bdf8;">✎ 系列: ' + escapeHtml(cm.series.text) + '</span>');
const summaryHtml = parts.length ? parts.join('<br>') : '<span style="color:#64748b;">(无详细项)</span>';
const detailUrl = m.detailHref || ('/v/' + m.code);
const timeStr = formatRelativeTime(cm.updatedAt);
tableHtml +=
'<tr style="border-bottom:1px solid rgba(255,255,255,0.06);transition:background 0.15s;" onmouseover="this.style.background=\'rgba(255,255,255,0.03)\'" onmouseout="this.style.background=\'transparent\'">' +
'<td style="padding:10px 12px;font-family:monospace;font-weight:700;white-space:nowrap;word-break:keep-all;">' +
'<a href="' + escapeAttr(detailUrl) + '" target="_blank" class="em-link" style="color:#38bdf8;text-decoration:none;" title="直达番号详情页">' + escapeHtml(m.code) + '</a>' +
'</td>' +
'<td style="padding:10px 12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="' + escapeAttr(m.title || '') + '">' +
escapeHtml(m.title || m.code) +
'</td>' +
'<td style="padding:10px 12px;font-size:11.5px;line-height:1.5;word-break:break-word;">' + summaryHtml + '</td>' +
'<td style="padding:10px 12px;text-align:center;white-space:nowrap;word-break:keep-all;">' +
'<span style="font-size:11px;padding:3px 8px;border-radius:4px;background:rgba(255,255,255,0.08);color:#94a3b8;display:inline-block;white-space:nowrap;">待检查</span>' +
'</td>' +
'<td style="padding:10px 12px;text-align:center;font-size:11px;color:#94a3b8;white-space:nowrap;word-break:keep-all;">' + escapeHtml(timeStr) + '</td>' +
'<td style="padding:10px 12px;text-align:center;white-space:nowrap;">' +
'<button type="button" class="jhs-btn jhs-btn-danger btn-clear-single-meta" data-code="' + escapeAttr(m.code) + '" style="padding:3px 8px;font-size:11px;border-radius:4px;white-space:nowrap;" title="清除此作品自定义更正,恢复官方元数据">清除</button>' +
'</td>' +
'</tr>';
});
tableHtml += '</tbody></table>';
container.innerHTML = tableHtml;
container.querySelectorAll('.btn-clear-single-meta').forEach(function (btn) {
btn.addEventListener('click', async function () {
const code = btn.getAttribute('data-code');
if (!confirm('确定清除 ' + code + ' 的所有自定义更正,恢复 JAVDB 官方原始元数据吗?')) return;
try {
const m = await FAV.dbGetMovie(code);
if (m) {
delete m.customMeta;
await FAV.dbPutMovie(m);
try {
document.dispatchEvent(new CustomEvent('emby-fav-changed', { bubbles: true, detail: { code: code } }));
} catch (e) {}
renderFullMetaCorrectList(mask, searchKw);
if (typeof showToast === 'function') showToast('已恢复 ' + code + ' 的官方元数据');
}
} catch (e) {
alert('清除失败: ' + e.message);
}
});
});
}
/* 批量比对官方云端更新并一键自愈 */
async function runBatchCloudCheck(mask) {
const banner = mask.querySelector('#meta-cloud-sync-banner');
const msgEl = mask.querySelector('#meta-cloud-sync-msg');
const progEl = mask.querySelector('#meta-cloud-sync-progress');
const syncBtn = mask.querySelector('#btn-meta-cloud-sync');
let allMovies = [];
let dbErr = null;
try {
allMovies = await FAV.dbAllMovies();
} catch (e) {
dbErr = e;
}
if (dbErr) {
alert('本地数据库读取失败:' + (dbErr.message || dbErr));
return;
}
const modifiedMovies = allMovies.filter(function (m) { return m && m.customMeta; });
if (!modifiedMovies.length) {
alert('当前没有需要自愈检查的自定义修正番号。');
return;
}
if (syncBtn) { syncBtn.disabled = true; syncBtn.style.opacity = '0.5'; }
if (banner) { banner.style.display = 'flex'; }
if (msgEl) msgEl.textContent = '正在连接 JAVDB 官方云端比对最新元数据...';
let healedCount = 0;
let checkedCount = 0;
let failedCount = 0;
for (let i = 0; i < modifiedMovies.length; i++) {
const m = modifiedMovies[i];
checkedCount++;
if (progEl) progEl.textContent = checkedCount + ' / ' + modifiedMovies.length;
if (msgEl) msgEl.textContent = '正在比对 [' + m.code + '] 官方云端数据...';
try {
const detailUrl = m.detailHref || ('/v/' + m.code);
const fullUrl = toAbsUrl(detailUrl);
const html = await gmHttp.request({ url: fullUrl, method: 'GET', timeout: 10000 });
if (html) {
const doc = new DOMParser().parseFromString(html, 'text/html');
const officialDet = (typeof FAV !== 'undefined' && typeof FAV.parseDetail === 'function') ? FAV.parseDetail(doc) : null;
if (officialDet && officialDet.code) {
const healed = (typeof tryAutoHealCustomMeta === 'function') ? tryAutoHealCustomMeta(m, officialDet) : false;
if (healed) {
healedCount++;
await FAV.dbPutMovie(m);
try {
document.dispatchEvent(new CustomEvent('emby-fav-changed', { bubbles: true, detail: { code: m.code } }));
} catch (e) {}
}
}
}
} catch (err) {
failedCount++;
log('比对番号 ' + m.code + ' 失败: ' + err.message);
}
// 适度节流避免高频请求触发防爬
await new Promise(function (res) { setTimeout(res, 400); });
}
if (banner) {
banner.style.background = 'rgba(34,197,94,0.15)';
if (msgEl) msgEl.textContent = '检查完毕!共比对 ' + checkedCount + ' 部作品,发现并自愈合流 ' + healedCount + ' 部已由官方修复的作品' + (failedCount > 0 ? '(' + failedCount + ' 部比对请求失败)' : '') + '。';
if (progEl) progEl.textContent = '完成';
}
if (syncBtn) { syncBtn.disabled = false; syncBtn.style.opacity = '1'; }
if (healedCount > 0) {
try {
document.dispatchEvent(new CustomEvent('emby-fav-changed', { bubbles: true }));
window.dispatchEvent(new CustomEvent('emby-fav-changed', { bubbles: true }));
if (typeof refreshFavStatus === 'function') refreshFavStatus();
} catch (e) {}
}
const activeContainer = document.getElementById('full-meta-correct-table-container');
const activeMask = activeContainer ? (activeContainer.closest('.efav-modal-mask') || mask) : mask;
const searchInp = (activeMask || mask).querySelector('input[type="text"], .jhs-input-text');
const searchKw = searchInp ? searchInp.value : '';
renderFullMetaCorrectList(activeMask, searchKw);
if (typeof showToast === 'function') {
showToast('⚡ 自愈检查完成:已自动合流 ' + healedCount + ' 部作品!');
}
}
/* WebDAV 备份文件查看弹窗 */
function resetAllSettings() {
localStorage.removeItem(SET_KEYS.blur);
localStorage.removeItem(SET_KEYS.coverSize);
localStorage.removeItem(SET_KEYS.coverMode);
localStorage.removeItem(SET_KEYS.trophyStyle);
localStorage.removeItem(SET_KEYS.layout);
localStorage.removeItem(SET_KEYS.style);
localStorage.removeItem(SET_KEYS.liquidAmp);
localStorage.removeItem(SET_KEYS.detailCoverW);
localStorage.removeItem(SET_KEYS.detailCoverWCrop);
localStorage.removeItem(SET_KEYS.detailCoverWFusion);
localStorage.removeItem(SET_KEYS.detailCoverWHybrid);
localStorage.removeItem(SET_KEYS.detailCoverWFull);
localStorage.removeItem(SET_KEYS.dfPreview);
localStorage.removeItem(SET_KEYS.dfPreviewDelay);
localStorage.removeItem(SET_KEYS.dfPreviewSize);
localStorage.removeItem(SET_KEYS.showNotes);
localStorage.removeItem(SET_KEYS.expandCoact);
PLUGIN_REGISTRY.forEach(function (pl) {
localStorage.removeItem('javdb_plugin_' + pl.id);
});
applySettings();
applyStyleTheme('emby');
try { syncDetailTranslation(); } catch (e) {}
try { rebuildDetailPlayButton(); } catch (e) {}
syncSettingsControls();
try { window.dispatchEvent(new CustomEvent('efav-notes-change')); } catch (e) {}
alert('已恢复为默认设置!');
}
/* 同步快捷面板与完整大弹窗中的所有控件显示 */
function syncSettingsControls() {
buildQuickSettingsPopover();
buildFullSettingsDialog();
const pop = document.getElementById('emby-quick-settings-popover');
const mask = document.getElementById('jhs-settings-mask');
// 快捷设置插件状态同步 (6 项)
if (pop) {
['translate', 'dfPreview', 'otherSite', 'infiniteScroll', 'reviewListInfinite', 'quickStatusPreview'].forEach(function (pid) {
const cb = pop.querySelector('#qs-plugin-' + pid);
if (cb) cb.checked = isPluginEnabled(pid);
});
}
// 常用布局与外观同步
const layout = localStorage.getItem(SET_KEYS.layout) || 'grid';
const coverMode = localStorage.getItem(SET_KEYS.coverMode) || 'fusion';
const trophyStyle = localStorage.getItem(SET_KEYS.trophyStyle) || 'card';
if (pop) {
const ql = pop.querySelector('#qs-layout');
if (ql) ql.value = layout;
const qcm = pop.querySelector('#qs-cover-mode');
if (qcm) qcm.value = coverMode;
const qts = pop.querySelector('#qs-trophy-style');
if (qts) qts.value = trophyStyle;
const qR = pop.querySelector('#qs-random-bg');
if (qR) qR.checked = localStorage.getItem(SET_KEYS.randomBg) !== '0';
const qN = pop.querySelector('#qs-show-notes');
if (qN) qN.checked = localStorage.getItem(SET_KEYS.showNotes) !== '0';
}
const blur = parseInt(localStorage.getItem(SET_KEYS.blur), 10);
const blurVal = !isNaN(blur) ? blur : 4;
if (pop) {
const qb = pop.querySelector('#qs-blur'), qbv = pop.querySelector('#qs-blur-val');
if (qb) { qb.value = blurVal; qbv.textContent = blurVal + 'px'; }
}
if (mask) {
const fb = mask.querySelector('#full-blur'), fbv = mask.querySelector('#full-blur-val');
if (fb) { fb.value = blurVal; fbv.textContent = blurVal + 'px'; }
}
const curCoverMode = localStorage.getItem(SET_KEYS.coverMode) || 'fusion';
const coverVal = getDetailCoverWForMode(curCoverMode);
if (pop) {
const qc = pop.querySelector('#qs-cover-size'), qcv = pop.querySelector('#qs-cover-size-val');
if (qc) { qc.value = coverVal; qcv.textContent = coverVal + 'px'; }
}
if (mask) {
const fc = mask.querySelector('#full-cover'), fcv = mask.querySelector('#full-cover-val');
if (fc) { fc.value = coverVal; fcv.textContent = coverVal + 'px'; }
}
if (mask) {
const fs = mask.querySelector('#full-style');
if (fs) fs.value = localStorage.getItem(SET_KEYS.style) || 'emby';
const fl = mask.querySelector('#full-layout');
if (fl) fl.value = layout;
const fcm = mask.querySelector('#full-cover-mode');
if (fcm) fcm.value = coverMode;
const fts = mask.querySelector('#full-trophy-style');
if (fts) fts.value = trophyStyle;
const rawLa = localStorage.getItem(SET_KEYS.liquidAmp);
let laPct = 50;
if (rawLa !== null && rawLa !== '') {
const sLa = String(rawLa).trim();
const parsed = parseFloat(sLa);
if (!isNaN(parsed)) {
if (sLa.includes('.') && parsed <= 0.6) {
laPct = Math.round((parsed / 0.44) * 100);
} else {
laPct = Math.round(parsed);
}
}
}
laPct = Math.max(0, Math.min(100, laPct));
const fli = mask.querySelector('#full-liquid'), fliv = mask.querySelector('#full-liquid-val');
if (fli) { fli.value = laPct; fliv.textContent = laPct + '%'; }
const dd = parseFloat(localStorage.getItem(SET_KEYS.dfPreviewDelay) || '0.8');
const ddVal = (!isNaN(dd) && dd >= 0.1 && dd <= 3) ? dd : 0.8;
const fdd = mask.querySelector('#full-dfdelay'), fddv = mask.querySelector('#full-dfdelay-val');
if (fdd) { fdd.value = ddVal; fddv.textContent = ddVal.toFixed(1) + 's'; }
const dsz = Math.round(dfPreviewSizeVw() * 100);
const fdsz = mask.querySelector('#full-dfsize'), fdszv = mask.querySelector('#full-dfsize-val');
if (fdsz) { fdsz.value = dsz; fdszv.textContent = dsz + '%'; }
const frb = mask.querySelector('#full-randombg');
if (frb) frb.checked = localStorage.getItem(SET_KEYS.randomBg) !== '0';
const fsn = mask.querySelector('#full-shownotes');
if (fsn) fsn.checked = localStorage.getItem(SET_KEYS.showNotes) !== '0';
const fco = mask.querySelector('#full-expandcoact');
if (fco) fco.checked = localStorage.getItem(SET_KEYS.expandCoact) === '1';
const wu = mask.querySelector('#wd-url');
if (wu) wu.value = localStorage.getItem('javdb_webdav_url') || '';
const wus = mask.querySelector('#wd-user');
if (wus) wus.value = localStorage.getItem('javdb_webdav_user') || '';
const wp = mask.querySelector('#wd-pass');
if (wp) wp.value = localStorage.getItem('javdb_webdav_pass') || '';
renderFullPluginList(mask);
renderFullSiteList(mask);
renderFullInfoSiteList(mask);
renderFullPreviewSiteList(mask);
renderFullMetaCorrectList(mask);
}
}
function openSettingsPanel(defaultTab) {
buildFullSettingsDialog();
syncSettingsControls();
const mask = document.getElementById('jhs-settings-mask');
if (!mask) return;
if (defaultTab) {
const tabBtn = mask.querySelector('.jhs-side-item[data-tab="' + defaultTab + '"]');
if (tabBtn) tabBtn.click();
}
mask.style.display = 'flex';
mask.classList.add('open');
if (typeof LiquidGlass !== 'undefined') {
requestAnimationFrame(function () {
const dialog = mask.querySelector('.jhs-settings-dialog');
if (dialog) LiquidGlass.ensureApplied(dialog);
});
}
}
function closeSettingsPanel() {
const mask = document.getElementById('jhs-settings-mask');
if (mask) {
mask.classList.remove('open');
mask.style.display = 'none';
}
}
/* =======================================================================
* 搜索弹窗:点击搜索框 → 屏幕正中,内容模糊为背景,仅突出搜索框
* 搜索框直接克隆原版(verbatim),不改动结构
* ===================================================================== */
function buildSearchModal() {
if (document.getElementById('emby-search-modal')) return;
const m = document.createElement('div');
m.id = 'emby-search-modal';
m.innerHTML =
'<button class="emby-search-close material-symbols-outlined" title="关闭 (Esc)">close</button>' +
'<div class="emby-search-inner"></div>';
document.body.appendChild(m);
const inner = m.querySelector('.emby-search-inner');
// 克隆原版搜索框(含 search-type 下拉、输入框、進階、以圖搜、檢索),verbatim
const src = document.querySelector('#search-bar-container .search-bar-wrap');
if (src) inner.appendChild(src.cloneNode(true));
wireSearchClone(inner);
const closeBtn = m.querySelector('.emby-search-close');
if (closeBtn) closeBtn.addEventListener('click', closeSearchModal);
m.addEventListener('click', function (e) { if (e.target === m) closeSearchModal(); });
// Esc 关闭(搜索 / 设置)
document.addEventListener('keydown', function (e) {
if (e.key !== 'Escape') return;
if (m.classList.contains('open')) closeSearchModal();
const sp = document.getElementById('jhs-settings-mask') || document.getElementById('emby-settings-panel');
if (sp && sp.classList.contains('open')) closeSettingsPanel();
});
}
// 给克隆的原版搜索框接线(用 class 选择器,避免与原版重复 id 冲突导致监听器挂错元素)
function wireSearchClone(inner) {
if (!inner) return;
const inp = inner.querySelector('.search-input input');
const sub = inner.querySelector('.search-submit button');
const typ = inner.querySelector('.search-type select');
if (!inp || !sub) return;
function doSearch() {
const q = (inp.value || '').trim();
if (!q) { inp.focus(); return; }
const t = typ ? typ.value : 'all';
location.href = '/search?q=' + encodeURIComponent(q) +
(t && t !== 'all' ? '&type=' + encodeURIComponent(t) : '');
}
sub.addEventListener('click', doSearch);
inp.addEventListener('keydown', function (e) {
if (e.key === 'Enter') { e.preventDefault(); doSearch(); }
});
}
function openSearchModal() {
const m = document.getElementById('emby-search-modal');
if (!m) return;
// 若原版搜索框尚未加载(SPA),重新克隆并接线
const inner = m.querySelector('.emby-search-inner');
if (!inner.firstChild) {
const src = document.querySelector('#search-bar-container .search-bar-wrap');
if (src) { inner.appendChild(src.cloneNode(true)); wireSearchClone(inner); }
}
m.classList.add('open');
const inp = inner.querySelector('.search-input input');
if (inp) setTimeout(function () { inp.focus(); }, 300);
}
function closeSearchModal() {
const m = document.getElementById('emby-search-modal');
if (m) m.classList.remove('open');
}
/* =======================================================================
* 主页公告栏(最新網址/最新网址)隐藏
* javdb 原版会在主页顶部插入一条「最新網址」公告,EMBY 模式下移除。
* 仅加 emby-hide-announce 类(CSS 仅在 emby-skin 下生效),关闭皮肤即恢复。
* ===================================================================== */
function hideJavdbAnnouncement() {
const RE = /最新網址|最新网址|最新域名|永久域名|javdb\d+\.com/;
let found = false;
// 1) javdb 主页公告栏:nav.sub-header(含官方App/最新域名/永久域名通知)
document.querySelectorAll('nav.sub-header').forEach(function (el) {
if (RE.test(el.textContent || '')) { el.classList.add('emby-hide-announce'); found = true; }
});
// 2) 常见 .notification 容器
document.querySelectorAll('.notification').forEach(function (el) {
if (RE.test(el.textContent || '')) { el.classList.add('emby-hide-announce'); found = true; }
});
if (found) return;
// 3) 兜底:含关键词且自身直接文本的块容器(全 DOM 扫描较重,每个路由只跑一次)
if (announceFallbackDone) return;
announceFallbackDone = true;
Array.from(document.querySelectorAll('body *')).forEach(function (el) {
if (el.children.length !== 0) return;
const t = (el.textContent || '').trim();
if (!t || !RE.test(t) || t.length > 160) return;
let n = el;
while (n && n !== document.body) {
const tag = n.tagName;
if (n.classList && (n.classList.contains('notification') || n.classList.contains('sub-header'))) {
n.classList.add('emby-hide-announce'); return;
}
if (/^(DIV|SECTION|ARTICLE|MAIN|HEADER|NAV)$/.test(tag)) {
if (n.querySelectorAll('img').length === 0 && n.querySelectorAll('a').length <= 3) {
n.classList.add('emby-hide-announce'); return;
}
}
n = n.parentNode;
}
});
}
/* =======================================================================
* 顶部及内容广告位(纯推广 / 磁链评论区广告)全面隐藏
* 针对 JavDB 顶部 .moj-content、磁链区 .sda-content、/ads/ 推广图及各页面广告容器
* ===================================================================== */
function hideJavdbAds() {
if (!document.documentElement.classList.contains('emby-skin')) return;
const adSelectors = [
'.moj-content', '.moj-container', '.moj-banner', '.moj-wrap', '[class*="moj-"]',
'.sda-content', '.sda-container', '.sda-banner', '.sda-wrap', '[class*="sda-"]', '[class*="-sda"]',
'#magnets-content .sda-content', '#magnets .sda-content', '.magnet-links .sda-content',
'.columns[data-controller*="movie"] .moj-content', '.columns[data-controller*="movie"] .sda-content',
'.columns .moj-content', '.columns .sda-content',
'.top-ads', '.ad-banner', '.ads-banner', '.ads-section', '.banner-ads', '[data-ad]',
'[class*="ad-banner"]', '[class*="ads-container"]'
];
try {
document.querySelectorAll(adSelectors.join(', ')).forEach(function (el) {
el.style.setProperty('display', 'none', 'important');
});
} catch (e) {}
// 隐藏包含 /ads/ 广告图片的容器与链接(彻底覆盖磁链、评论等所有内嵌推广位)
try {
document.querySelectorAll('img[src*="/ads/"], img[src*="jdbstatic.com/ads/"]').forEach(function (img) {
const p = img.closest('.sda-content, [class*="sda-"], .moj-content, [class*="moj-"], a') || img;
p.style.setProperty('display', 'none', 'important');
if (p.parentElement && (p.parentElement.classList.contains('sda-content') || p.parentElement.id === 'magnets-content')) {
const wrap = p.closest('.sda-content, [class*="sda-"]');
if (wrap) wrap.style.setProperty('display', 'none', 'important');
}
});
} catch (e) {}
// 根据「純推廣 / 纯推广 / 廣告 / 推广 / 赞助商」文本特征精准识别并隐藏广告容器
try {
const PROMO_RE = /^(?:純推廣|纯推广|廣告|推广|赞助商)$/;
document.querySelectorAll('.tag, .badge, span, div, a').forEach(function (el) {
const t = (el.textContent || '').trim();
if (PROMO_RE.test(t)) {
const card = el.closest('.moj-content, [class*="moj-"], .sda-content, [class*="sda-"], .item, .box, .notification, .message, .panel, .columns, div') || el;
if (card && !card.classList.contains('video-detail') && !card.classList.contains('emby-detail-root') && !card.id.startsWith('emby-')) {
card.style.setProperty('display', 'none', 'important');
}
}
});
} catch (e) {}
}
/* 首页/收藏夹视图切换键(模块级:buildChrome 顶栏 logo 与 buildHomeTabs 共用) */
const HOME_TAB_KEY = 'javdbEmbyHomeTab'; // 'home' | 'favorites'
/* =======================================================================
* STEAM 风格卡牌 3D 倾斜 + 高光
* 替代原 Emby 文字/播放浮层:鼠标在卡片上移动时实时计算 rotateX/rotateY,
* 并让高光层 (.emby-card-shine) 跟随光标位置。
* (封面右半裁切已移至瀑布流大视图 CSS,见 emby-masonry-large)
* ===================================================================== */
const TILT_MAX = 12; // 最大倾斜角度(deg)
function updateDynamicFocusGrid(grid, active) {
if (!grid) return;
if (active && !active.isConnected) active = null;
const cells = Array.prototype.slice.call(grid.querySelectorAll(':scope > .item'));
cells.forEach(function (cell) {
cell.classList.toggle('is-focus', cell === active);
cell.classList.toggle('is-near', !!active && cell !== active);
});
// 大图气泡不在缩放切换里触发,改由后续 hover 钩子带光标坐标调用 dfHoverStart
}
/* =======================================================================
* 未裁切大封面气泡预览(全局,7.114 起从动态聚焦解耦)
* 卡片缩略图多为「裁切」显示,但 src 本身是完整原图;预览层复用
* 同一张图按原比例整图展示,零额外请求。标准网格 / 瀑布流 / 动态聚焦三种布局通用。
* - 设置面板总开关 dfPreview(默认开)+ 大小滑块 dfPreviewSize(25%~85%vw, 默认53%)
* + 延迟滑块 dfPreviewDelay(0.1~3s, 默认0.8s) 控制
* - 动态聚焦布局额外保留宫格缩放聚焦(is-focus/is-near),其它布局仅弹气泡
* - 弹出时全屏暗色遮罩 #emby-df-scrim 压暗页面,移出卡片立即撤除
* - 位置实时跟随光标重新计算:气泡永远不盖住鼠标,上下随空间智能择向
* - 底部黑底信息条镜像标准网格卡片:番号强标题 + 标题文字
* ===================================================================== */
const dfPopup = { item: null, x: 0, y: 0, timer: 0, raf: 0, hidden: true, wh: null, mouseBound: 0, scrollBound: 0 };
function dfPreviewOn() {
return (localStorage.getItem(SET_KEYS.dfPreview) || '1') !== '0';
}
function dfPreviewDelayMs() {
const v = parseFloat(localStorage.getItem(SET_KEYS.dfPreviewDelay));
if (isNaN(v)) return 800;
return Math.max(0.1, Math.min(3, v)) * 1000;
}
// 大图宽度占屏幕宽度比例(设置滑块 25~85,默认 53 ≈ 解耦前的实际尺寸,无缝过渡)
function dfPreviewSizeVw() {
const v = parseFloat(localStorage.getItem(SET_KEYS.dfPreviewSize));
if (isNaN(v)) return 0.53;
return Math.max(0.25, Math.min(0.85, v / 100));
}
function ensureDfPreview() {
let p = document.getElementById('emby-df-preview');
if (p) return p;
const scrim = document.createElement('div');
scrim.id = 'emby-df-scrim';
document.body.appendChild(scrim);
p = document.createElement('div');
p.id = 'emby-df-preview';
p.innerHTML = '<div class="emby-df-preview-inner"><img alt="">' +
'<div class="emby-df-preview-info"><div class="dfp-title"></div><div class="dfp-meta"></div><div class="dfp-extra"></div></div>' +
'</div>';
document.body.appendChild(p);
// 滚动即收:fixed 定位在滚动后会错位,收起等下一次悬停重新弹出
// (removeEmbyDOM 会移除预览元素;重建时防重复绑定 scroll 监听)
if (!dfPopup.scrollBound) {
dfPopup.scrollBound = 1;
window.addEventListener('scroll', function () { dfPopupHide(); }, true);
}
dfBindMouse();
return p;
}
// 全局 mousemove:① 大图可见时实时跟随光标重定位(永远不盖住鼠标,rAF 节流);
// ② 兜底:光标一旦离开当前卡片就强制收回缩放与弹窗 —— 网格重建(收藏夹筛选/排序)后
// 即使 mouseleave 委托遗漏了,也绝不会出现「封面放大卡着不收回」的 BUG。
function dfBindMouse() {
if (dfPopup.mouseBound) return;
dfPopup.mouseBound = 1;
document.addEventListener('mousemove', function (e) {
if (!dfPopup.item) return;
dfPopup.x = e.clientX; dfPopup.y = e.clientY;
const it = dfPopup.item;
if (it && it.isConnected) {
const r = it.getBoundingClientRect();
const out = e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom;
if (out) {
// 收缩放只对动态聚焦网格有意义;网格/瀑布流只收气泡即可
const g = it.closest ? it.closest('.movie-list.emby-dynamic-focus') : null;
dfPopupHide(); // 清计时器 + 隐藏弹窗/遮罩 + 置空 item
try { if (g) updateDynamicFocusGrid(g, null); } catch (err) {} // 收回该网格所有缩放
return;
}
}
if (dfPopup.hidden) return; // 延迟等待期不重定位
if (dfPopup.raf) return;
dfPopup.raf = requestAnimationFrame(function () {
dfPopup.raf = 0;
dfPopupReposition();
});
});
}
function dfPopupSize(srcEl) {
const vw = window.innerWidth;
// 直接读网格里已加载封面图的真实比例,避免预览 img 未缓存时退回默认比例
const aspect = (srcEl.naturalWidth && srcEl.naturalHeight) ? srcEl.naturalWidth / srcEl.naturalHeight : 1.5;
// 大图宽度 = 屏宽 × 设置比例(设置滑块 25%~85%,默认 53% ≈ 7.113 固定尺寸,无缝过渡);
// 小屏下限 320px 防过窄,硬上限 85vw 防溢出
let w = Math.max(320, Math.round(vw * dfPreviewSizeVw()));
w = Math.min(w, Math.round(vw * 0.85));
// +88 信息条预留(标题/评分日期磁力/演员导演片商):窄尺寸下更易换行,多留一点防底部裁切
return { w: w, h: Math.round(w / aspect) + 88 };
}
function dfPopupReposition() {
const p = document.getElementById('emby-df-preview');
if (!p || !dfPopup.wh) return;
const vw = window.innerWidth, vh = window.innerHeight;
const w = dfPopup.wh.w, h = dfPopup.wh.h;
// 光标上方 vs 下方剩余空间:哪边大就把图弹到哪边,确保不盖住鼠标
const below = (vh - dfPopup.y) >= dfPopup.y;
let x = dfPopup.x - Math.round(w / 2) + 24; // 略向右偏,避开光标所在列
if (x < 12) x = 12;
if (x + w > vw - 12) x = vw - w - 12;
let top = below ? dfPopup.y + 18 : dfPopup.y - 18 - h;
top = Math.max(64, Math.min(top, vh - h - 12)); // 64 = 顶栏高度,避免遮挡
p.style.width = w + 'px';
p.style.left = x + 'px';
p.style.top = top + 'px';
p.style.transformOrigin = below ? 'top center' : 'bottom center';
}
// 评分(含星级图标)+ 日期 + 磁力种子状态标签:直接从卡片 DOM 继承网页给的数据
function dfPopupMetaRow(item) {
const parts = [];
const stars = item.querySelector('.score-stars');
if (stars) parts.push('<span class="dfp-stars">' + stars.innerHTML + '</span>');
const score = item.querySelector('.score');
if (score) {
const t = (score.textContent || '').replace(/\s+/g, ' ').trim();
if (t) parts.push('<span class="dfp-score">' + escapeHtml(t) + '</span>');
}
const meta = item.querySelector('.meta');
if (meta) {
// 无演员作品的行内备注附在 .meta(日期 · 时长)里;读取日期时须剥离备注节点,
// 否则备注会随「日期 · 时长」一起被带进信息条(无橙色),与行末的橙色备注重复出现两份
var mc = meta.cloneNode(true);
var mnote = mc.querySelector('.efav-card-note');
if (mnote) mnote.remove();
const d = (mc.textContent || '').replace(/\s+/g, ' ').trim();
if (d) parts.push('<span class="dfp-date">' + escapeHtml(d) + '</span>');
}
const tags = Array.prototype.map.call(item.querySelectorAll('.tags .tag'), function (t) {
const tx = (t.textContent || '').trim();
return tx ? '<span class="dfp-tag">' + escapeHtml(tx) + '</span>' : '';
}).join('');
if (tags) parts.push('<span class="dfp-tags">' + tags + '</span>');
return parts.length ? parts.join('<span class="dfp-dot">·</span>') : '';
}
// 演员 / 系列 / 导演 / 片商(收藏夹由 buildCard 写入 .box 的 data-* 属性)。
// 时长不再放这里:meta 行的日期旁已带时长(.meta = 「日期 · 时长」),重复展示已按需求去掉。
// 演员图标用女性符号(data-actors 在 buildCard 里已滤除识别过的男优,展示的都是女优/未知)。
function dfPopupExtraRow(item) {
if (!item) return '';
const box = item.querySelector('.box');
const actors = box ? (box.getAttribute('data-actors') || '') : '';
const series = box ? (box.getAttribute('data-series') || '') : '';
const director = box ? (box.getAttribute('data-director') || '') : '';
const maker = box ? (box.getAttribute('data-maker') || '') : '';
let html = '';
// 带备注的实体:名字标橙,不展示备注文本(详情页悬停气泡查看)。
// 演员按名逐个标色(data-actors-noted = 已备注演员名单)——共演未备注者不着色;
// 系列/片商为整名实体,直接整项标色
const notedArr = (box ? (box.getAttribute('data-actors-noted') || '') : '').split(', ').filter(Boolean);
const actorHtml = actors ? actors.split(', ').map(function (nm) {
return notedArr.indexOf(nm) !== -1 ? '<span class="noted">' + escapeHtml(nm) + '</span>' : escapeHtml(nm);
}).join(', ') : '';
const entNoted = function (attr) { return (box && box.hasAttribute(attr)) ? ' noted' : ''; };
if (actors) html += '<span class="dfp-actor"><span class="material-symbols-outlined">female</span>' + actorHtml + '</span>';
if (series) html += '<span class="dfp-ser' + entNoted('data-series-noted') + '"><span class="material-symbols-outlined">cards_star</span>' + escapeHtml(series) + '</span>';
if (director) html += '<span class="dfp-dir"><span class="material-symbols-outlined">patient_list</span>' + escapeHtml(director) + '</span>';
if (maker) html += '<span class="dfp-mak' + entNoted('data-maker-noted') + '"><span class="material-symbols-outlined">movie_edit</span>' + escapeHtml(maker) + '</span>';
// 作品级备注(仅番号本身的):斜体橙色附行末,与卡片同语言
const note = box ? (box.getAttribute('data-note') || '') : '';
if (note) html += '<span class="dfp-note"><span class="material-symbols-outlined">history_edu</span><i>' + escapeHtml(note) + '</i></span>';
return html;
}
function dfPopupShow(item) {
if (!item || !item.isConnected) return;
if (!dfPreviewOn()) return; // 全局开关关闭:任何布局都不弹大图
const p = ensureDfPreview();
const scrim = document.getElementById('emby-df-scrim');
const img = p.querySelector('img');
const srcEl = item.querySelector('.cover img');
if (!srcEl) return;
const src = srcEl.currentSrc || srcEl.src;
if (!src) return;
// 信息条:全盘镜像卡片数据 —— 番号强标题 + 标题 / 评分+日期+磁力标签 / 演员+时长
const info = p.querySelector('.emby-df-preview-info');
const titleEl = item.querySelector('.video-title');
let codeHtml = '', titleTxt = '';
if (titleEl) {
const strong = titleEl.querySelector('strong');
if (strong) {
codeHtml = '<strong>' + escapeHtml(strong.textContent) + '</strong>';
}
// 提取纯净作品标题:克隆节点并剔除番号、金标徽章与状态圆点,避免标题文本夹带徽章文字导致重复显示
const clone = titleEl.cloneNode(true);
clone.querySelectorAll('strong, .efav-award-badge, .efav-award, .efav-dot').forEach(function (el) { el.remove(); });
titleTxt = clone.textContent.trim();
}
const titleSlot = info.querySelector('.dfp-title');
if (titleSlot) {
// 金标奖章同步(明黄皇冠胶囊)
let medalHtml = '';
try {
const boxEl = item.querySelector('.box');
const aw = boxEl ? JSON.parse(boxEl.getAttribute('data-awards') || '[]') : null;
if (aw && aw.length) medalHtml = buildAwardBadgeHtml(aw);
} catch (e) {}
titleSlot.innerHTML = medalHtml + (codeHtml || '') + '<span>' + escapeHtml(titleTxt) + '</span>';
}
const metaSlot = info.querySelector('.dfp-meta');
if (metaSlot) metaSlot.innerHTML = dfPopupMetaRow(item);
const extraSlot = info.querySelector('.dfp-extra');
if (extraSlot) extraSlot.innerHTML = dfPopupExtraRow(item);
img.src = src;
dfPopup.wh = dfPopupSize(srcEl);
p.style.width = dfPopup.wh.w + 'px';
dfPopupReposition();
dfPopup.hidden = false;
void p.offsetHeight; // 先让 transform-origin 生效,再加 .show 触发弹出动画
p.classList.add('show');
if (scrim) scrim.classList.add('show');
}
function dfPopupHide() {
const p = document.getElementById('emby-df-preview');
const scrim = document.getElementById('emby-df-scrim');
if (dfPopup.timer) { clearTimeout(dfPopup.timer); dfPopup.timer = 0; }
if (dfPopup.raf) { cancelAnimationFrame(dfPopup.raf); dfPopup.raf = 0; }
dfPopup.hidden = true;
dfPopup.item = null;
if (p) p.classList.remove('show');
if (scrim) scrim.classList.remove('show');
}
// 悬停进入:先清旧弹窗记录坐标,若开启预览则等待延迟后、光标仍卡在卡片内才弹出
function dfHoverStart(item, mx, my) {
dfPopupHide();
dfPopup.item = item;
dfPopup.x = mx; dfPopup.y = my;
if (!dfPreviewOn()) return; // 全局开关关闭:不弹大图(动态聚焦的宫格缩放不受影响)
const delay = dfPreviewDelayMs();
dfPopup.timer = setTimeout(function () {
dfPopup.timer = 0;
if (!dfPreviewOn()) return;
if (!item || !item.isConnected) return;
// 光标若已移出该卡片范围则不开(防止从卡片滑出后仍弹出)
const r = item.getBoundingClientRect();
if (dfPopup.x < r.left || dfPopup.x > r.right || dfPopup.y < r.top || dfPopup.y > r.bottom) return;
dfPopupShow(item);
}, delay);
}
// 网格级 mouseover 委托(首页同款绑定方式):收藏夹网格由 FAVUI 每轮重建,
// 旧网格的委托会随 innerHTML 清空而丢失;这里在首次悬停时惰性安装,确保
// 收藏夹新网格与首页走完全相同的动态聚焦机制,无需额外手动重新绑定。
function ensureDynamicFocusGrid(grid) {
if (!grid || grid.dataset.dynamicFocusGrid) return;
grid.dataset.dynamicFocusGrid = '1';
grid.addEventListener('mouseover', function (e) {
const box = e.target.closest && e.target.closest('.box');
if (!box || !grid.contains(box) || !box.parentElement.classList.contains('item')) return;
const cell = box.parentElement;
if (e.relatedTarget && cell.contains(e.relatedTarget)) return;
updateDynamicFocusGrid(grid, cell);
dfHoverStart(cell, e.clientX, e.clientY);
});
grid.addEventListener('mouseleave', function () {
updateDynamicFocusGrid(grid, null);
dfPopupHide();
});
}
function attachDynamicFocus(box) {
if (!box || box.dataset.dynamicFocus) return;
box.dataset.dynamicFocus = '1';
// 收藏夹 buildCard 在卡片挂树前就调用本函数,此时 box 尚未连接 DOM,
// box.closest(...) 必然为 null,旧实现的 grid/cell 在绑定期捕获会静默失败
// (这正是收藏夹动态聚焦不同步首页的原因)。改为悬停时惰性解析 grid/cell:
// 卡片真正挂树后事件触发即可命中,并顺带给所在网格装上网格级委托。
box.addEventListener('mouseenter', function (ev) {
const grid = box.closest('.movie-list.emby-dynamic-focus');
const cell = box.closest('.item');
if (!grid || !cell) return;
// 7.107 修复严重 BUG:旧 handler 未接收事件对象却引用 e.clientX → ReferenceError,
// 且在 ensureDynamicFocusGrid 之前抛出 → 收藏夹筛选/排序重建网格后,网格级委托
// 永远装不上,表现为「缩放卡死不收回 + 大图不再弹出」,刷新也不恢复。
// 现改为:先装网格委托(try/catch 兜底)→ 再缩放 → 最后弹大图,
// 任一步异常都不再阻断后续逻辑,缩放收回由网格 mouseleave + 全局 mousemove 双重兜底。
try { ensureDynamicFocusGrid(grid); } catch (err) {}
try { updateDynamicFocusGrid(grid, cell); } catch (err) {}
try { dfHoverStart(cell, ev.clientX, ev.clientY); } catch (err) {}
});
}
function attachDynamicFocusToAll() {
if (!document.documentElement.classList.contains('emby-dynamic-focus')) return;
document.querySelectorAll('.movie-list.emby-dynamic-focus').forEach(ensureDynamicFocusGrid);
document.querySelectorAll('.movie-list.emby-dynamic-focus .item .box').forEach(function (b) {
attachCard3D(b); // STEAM 风格卡牌互动(7.115 起动态聚焦同样生效)
attachDynamicFocus(b); // 宫格缩放聚焦
});
}
// 全局 mousemove 兜底监听常驻:启动即挂载(详见 dfBindMouse),
// 保证任何网格重建/异常后,光标离开卡片都能强制收回缩放与大图。
dfBindMouse();
function attachCard3DToAll() {
if (document.documentElement.classList.contains('emby-dynamic-focus')) return;
document.querySelectorAll('.movie-list .item .box').forEach(attachCard3D);
if (isPluginEnabled('quickStatusPreview') && document.documentElement.classList.contains('emby-skin')) {
try { syncQuickStatusPreviewUI(); } catch (e) {}
}
}
function attachCard3D(box, opts) {
if (!box || box.dataset.tilt) return;
box.dataset.tilt = '1';
if (isPluginEnabled('quickStatusPreview') && document.documentElement.classList.contains('emby-skin')) {
try { attachCoverStatusAndPreview(box.closest('.item') || box.parentElement || box); } catch (e) {}
}
let raf = null;
// 悬停封面预览(全局):标准网格/瀑布流/动态聚焦卡片共用同一套气泡;
// noPreview 用于详情页推荐行等场景——保留倾斜/辉光,不弹封面预览
const noPreview = !!(opts && opts.noPreview);
box.addEventListener('mouseenter', function (ev) {
if (noPreview) return;
try { dfHoverStart(box.closest('.item') || box, ev.clientX, ev.clientY); } catch (err) {}
});
box.addEventListener('mousemove', function (e) {
const r = box.getBoundingClientRect();
const px = (e.clientX - r.left) / r.width; // 0~1
const py = (e.clientY - r.top) / r.height; // 0~1
const ry = (px - 0.5) * (TILT_MAX * 2);
const rx = (0.5 - py) * (TILT_MAX * 2);
if (raf) cancelAnimationFrame(raf);
raf = requestAnimationFrame(function () {
box.style.transform = 'perspective(700px) rotateX(' + rx.toFixed(2) + 'deg) rotateY(' + ry.toFixed(2) + 'deg) scale(1.05)';
box.style.setProperty('--mx', (px * 100).toFixed(1) + '%');
box.style.setProperty('--my', (py * 100).toFixed(1) + '%');
});
});
box.addEventListener('mouseleave', function () {
if (raf) cancelAnimationFrame(raf);
box.style.transform = '';
dfPopupHide(); // 移出卡片立即收气泡/遮罩(全局 mousemove 兜底之外再加一道即时回收)
});
}
/* =======================================================================
* FAV —— 收藏夹数据模块(v6.00)
* 原生 IndexedDB
* + 同源 fetch 抓取 + 筛选 + 导入导出
* ===================================================================== */
// 模块级并发配置(最外层 IIFE 作用域,FAV 与 FAVUI 两闭包均可访问):同步/补全/识别的并发 worker 数。
// 1=纯串行最稳,3=默认,6=最快。持久化在 localStorage,工具栏滑块实时调节。
let CONCURRENCY = Math.max(1, Math.min(6, parseInt(localStorage.getItem('javdbEmbyConcurrency') || '3', 10) || 3));
const FAV = (function () {
/* ---------- 存储层 ---------- */
const FAV_DB_NAME = 'javdb_emby_fav';
// 版本号取 > 老库(Dexie 曾升到 10):低于已存在版本会 open 失败,导致整个收藏夹读写出错
// v14: 新增 notes 实体备注表(演员/作品备注,随导出携带)
const FAV_DB_VERSION = 14;
let favDbPromise = null;
// ====== 图片图床域名抽象(防 JAVDB 换图床导致封面失效)======
// 入库时只存「路径」(剥掉协议+域名),渲染时拼当前探测到的图床域名 →
// 只要线上图床变了,脚本在访问页面时自动探测新域名并覆盖库内域名部分,无感且永远可用。
let IMG_HOST = 'https://c0.jdbstatic.com'; // 默认值,实际以页面探测为准
const IMG_HOST_RE = /^(https?:\/\/[a-z0-9.-]*jdbstatic\.com)/i;
let imgHostDetected = false; // 本次页面是否真实探测到 jdbstatic 图床(未探测到时不做域名迁移,避免用默认值误报)
function detectImgHost() {
// 从页面里任意 jdbstatic 图片提取图床域名(封面/头像均同域)
const imgs = document.images || [];
for (let i = 0; i < imgs.length; i++) {
const src = imgs[i].getAttribute('src') || imgs[i].getAttribute('data-src') || '';
const m = src.match(IMG_HOST_RE);
if (m) { IMG_HOST = m[1]; imgHostDetected = true; return; }
}
// 退路:页面里任何含 jdbstatic 的链接
const anchors = document.querySelectorAll('a[href*="jdbstatic.com"], link[href*="jdbstatic.com"]');
for (let i = 0; i < anchors.length; i++) {
const m = (anchors[i].getAttribute('href') || '').match(IMG_HOST_RE);
if (m) { IMG_HOST = m[1]; imgHostDetected = true; return; }
}
}
// 入库:完整 URL → 路径(剥掉协议+域名);已是路径则原样返回
function stripHost(url) {
if (!url) return url;
const m = url.match(/^https?:\/\/[^\/]+(\/.*)$/i);
return m ? m[1] : url;
}
// 渲染:路径 → 完整 URL(用当前探测域名);已是完整 URL 则原样返回(兼容未迁移旧数据)
function resolveImg(path) {
if (!path) return path;
let p = String(path).trim();
p = p.replace(/\/small_covers\//g, '/thumbs/');
p = p.replace(/https?:\/\/.*?\/rhe951l4q/g, 'https://c0.jdbstatic.com');
p = p.replace(/^https?:\/\/(www\.)?javdb\d*\.(com|me|life|party|today|vip)\/(covers|thumbs)/i, 'https://c0.jdbstatic.com/$3');
if (/^https?:\/\//i.test(p)) return p;
return IMG_HOST + (p.startsWith('/') ? '' : '/') + p;
}
// 域名变化时一次性迁移:覆盖库内所有封面/头像的域名部分,并通知用户
async function ensureImgHost() {
if (!imgHostDetected) return; // 页面没探测到 jdbstatic 图床时不动库(默认域名不作迁移依据)
const saved = await dbGetMeta('lastImgHost');
const cur = IMG_HOST;
if (saved && saved === cur) return; // 无变化
const all = await dbAllMovies();
if (!all.length) { await dbSetMeta('lastImgHost', cur); return; }
let changed = 0;
all.forEach(function (m) {
['coverThumb', 'coverLarge'].forEach(function (k) {
if (m[k] && /^https?:\/\//i.test(m[k])) { m[k] = stripHost(m[k]); changed++; }
});
if (m.actors && m.actors.length) {
m.actors.forEach(function (a) {
if (a.avatar && /^https?:\/\//i.test(a.avatar)) { a.avatar = stripHost(a.avatar); changed++; }
});
}
});
if (changed) {
await dbPutMovies(all);
try { showToast('封面图片域名已更新:' + cur); } catch (e) {}
}
await dbSetMeta('lastImgHost', cur);
}
// 清除数据库报错提示横幅
function clearFavDbError() {
try {
const el = document.getElementById('efav-db-error');
if (el) el.remove();
} catch (e) {}
}
// 数据库打开失败/警告时,在页面顶部显示友好提示(可手动关闭;数据库就绪后自动消失)
function showFavDbError(msg) {
try {
let el = document.getElementById('efav-db-error');
if (!el) {
el = document.createElement('div');
el.id = 'efav-db-error';
el.style.cssText = 'position:fixed;left:50%;top:calc(var(--e-header-h,64px) + 20px);transform:translateX(-50%);z-index:99999;max-width:90vw;' +
'background:#b00020;color:#fff;padding:10px 16px;border-radius:8px;font:13px/1.5 sans-serif;box-shadow:0 4px 20px rgba(0,0,0,.4);' +
'display:flex;align-items:center;gap:12px;transition:opacity 0.25s ease;';
el.innerHTML = '<span class="efav-err-text"></span><button type="button" style="background:none;border:none;color:#fff;font-size:18px;cursor:pointer;opacity:0.8;padding:0 4px;line-height:1;" onclick="this.parentElement.remove()" title="关闭提示">×</button>';
(document.body || document.documentElement).appendChild(el);
}
const txt = el.querySelector('.efav-err-text');
if (txt) txt.textContent = '⚠ 收藏夹数据库:' + msg;
else el.textContent = '⚠ 收藏夹数据库:' + msg;
} catch (e) {}
}
function dbOpen(isRetry) {
if (favDbPromise) return favDbPromise;
favDbPromise = new Promise(function (resolve, reject) {
let settled = false;
// 超时兜底:给足 10 秒等待。若超时且未重试过,则自动静默重试一次,避免首屏高并发/磁盘 I/O 争抢假死
const timeoutMs = isRetry ? 12000 : 10000;
const to = setTimeout(function () {
if (settled) return;
settled = true;
favDbPromise = null;
if (!isRetry) {
log('[DIAG] dbOpen 首次响应较慢,启动静默重试...');
dbOpen(true).then(resolve).catch(reject);
return;
}
const msg = '数据库打开超时(可能因多标签页锁死或磁盘占用)。若页面与收藏夹显示正常可直接关闭本提示;若数据空白,请硬刷新重试。';
console.warn('[JAVDB→Emby] ' + msg);
showFavDbError(msg);
reject(new Error(msg));
}, timeoutMs);
let req;
try {
req = indexedDB.open(FAV_DB_NAME, FAV_DB_VERSION);
} catch (openErr) {
clearTimeout(to);
settled = true;
favDbPromise = null;
showFavDbError('打开异常:' + openErr.message);
reject(openErr);
return;
}
req.onupgradeneeded = function (e) {
const idb = e.target.result;
if (!idb.objectStoreNames.contains('lists')) {
const listsStore = idb.createObjectStore('lists', { keyPath: 'id' });
listsStore.createIndex('type', 'type', { unique: false });
}
if (!idb.objectStoreNames.contains('movies')) {
const moviesStore = idb.createObjectStore('movies', { keyPath: 'code' });
moviesStore.createIndex('byListIds', 'listIds', { unique: false, multiEntry: true });
moviesStore.createIndex('byMaker', 'maker.text', { unique: false });
moviesStore.createIndex('bySeries', 'series.text', { unique: false });
moviesStore.createIndex('byDirector', 'director.text', { unique: false });
moviesStore.createIndex('byDate', 'date', { unique: false });
moviesStore.createIndex('byDetailComplete', 'detailComplete', { unique: false });
}
if (!idb.objectStoreNames.contains('meta')) {
idb.createObjectStore('meta', { keyPath: 'key' });
}
// 演员性别表:slug -> {slug, gender:'M'|'F'}
if (!idb.objectStoreNames.contains('actorGenders')) {
idb.createObjectStore('actorGenders', { keyPath: 'slug' });
}
// 预览画廊:每条 = {id, code, title, date, previewUrl, coverUrl, savedAt}
// id = code + '|' + previewUrl(复合 keyPath,天然去重 + 按 code 聚合)
if (!idb.objectStoreNames.contains('gallery')) {
idb.createObjectStore('gallery', { keyPath: 'id' });
}
// 实体备注(v14):key = 'actor:{slug}' | 'code:{PREFIX}',value = {key, name, content, updatedAt}
if (!idb.objectStoreNames.contains('notes')) {
idb.createObjectStore('notes', { keyPath: 'key' });
}
};
req.onsuccess = function (e) {
clearTimeout(to);
const idb = e.target.result;
// 多标签页协作:当其他标签页发起升级/重置时主动释放连接,彻底防止跨标签页锁死
idb.onversionchange = function () {
try { idb.close(); } catch (err) {}
favDbPromise = null;
};
idb.onclose = function () {
favDbPromise = null;
};
clearFavDbError(); // 成功建立连接即自动消除旧的超时警告!
if (settled) return;
settled = true;
log('[DIAG] dbOpen 成功' + (isRetry ? '(重试成功)' : ''));
setTimeout(runIntegrityCheck, 3500); // 延后 3.5s 执行,避开首屏高并发 I/O 争抢
resolve(idb);
};
req.onerror = function (e) {
clearTimeout(to);
if (settled) return;
settled = true;
// 关键:reject 时清空 favDbPromise 缓存,否则这个 rejected promise 会被永久复用
favDbPromise = null;
const err = e.target && e.target.error;
const msg = '打开失败(open v' + FAV_DB_VERSION + '):' + ((err && err.message) || e.message || e) +
'|若提示版本过低,可在控制台执行 window.__efavResetDb() 重建数据库';
console.error('[JAVDB→Emby] ' + msg);
showFavDbError(msg);
reject(err || new Error(msg));
};
req.onblocked = function () {
console.warn('[JAVDB→Emby] 数据库被其他标签页占用,等待连接释放...');
};
});
return favDbPromise;
}
// 轻量完整性校验:dbOpen 成功后 fire-and-forget 执行(不阻塞收藏夹加载)。
// 检查各 store 行数与 movies 抽样必填字段;发现异常仅警告提示,绝不自动清库(数据安全第一)。
function runIntegrityCheck() {
dbOpen().then(function (idb) {
try {
const counts = {};
const tx = idb.transaction(['lists', 'movies', 'gallery', 'actorGenders', 'meta'], 'readonly');
['lists', 'movies', 'gallery', 'actorGenders', 'meta'].forEach(function (name) {
const req = tx.objectStore(name).count();
req.onsuccess = function () { counts[name] = req.result; };
});
tx.oncomplete = function () {
const warn = [];
// 有清单有影片但影片数为 0:明显异常(正常同步后 movies 应 >0)
if ((counts.lists || 0) > 0 && (counts.movies || 0) === 0) warn.push('movies 库为空但有 ' + counts.lists + ' 个清单');
// 抽样:随机取 5 条影片检查必填字段
if ((counts.movies || 0) > 0) {
let checked = 0, bad = 0;
const cur = idb.transaction('movies', 'readonly').objectStore('movies').openCursor();
cur.onsuccess = function () {
const c = cur.result;
if (!c || checked >= 5) {
if (bad > 0) warn.push('movies 抽样 ' + checked + ' 条中发现 ' + bad + ' 条缺 code/缺标题 的畸形记录');
if (warn.length) {
console.warn('[JAVDB→Emby] 数据完整性提示: ' + warn.join(';') + '。如需重建可执行 window.__efavResetDb()');
showFavDbError('检测到数据异常(' + warn.join(';') + ')。建议执行 window.__efavResetDb() 重建后重新同步。');
}
return;
}
const v = c.value;
if (!v || !v.code || (!v.title && !v.coverThumb)) bad++;
checked++;
c.continue();
};
} else if (warn.length) {
console.warn('[JAVDB→Emby] 数据完整性提示: ' + warn.join(';'));
}
};
} catch (e) { log('完整性校验失败: ' + e.message); }
}).catch(function () {});
}
// 重建数据库:关闭连接后删除,下次脚本打开时由 onupgradeneeded 自动重建空库(store 全建好)。
// 数据源头在 JAVDB 服务器,重建后重新「同步清单」即可拉回,不会永久丢失。
window.__efavResetDb = function () {
try {
const del = indexedDB.deleteDatabase(FAV_DB_NAME);
del.onsuccess = function () { alert('收藏夹数据库已删除,请硬刷新页面,脚本将自动重建空库,再点「同步清单」拉回数据'); };
del.onerror = function () { alert('删除失败:' + (del.error && del.error.message)); };
del.onblocked = function () { alert('删除被阻塞:仍有其他标签页占用数据库,请关闭所有 javdb.com 标签页后再试'); };
} catch (e) { alert('异常:' + e.message); }
};
function dbReq(req) {
return new Promise(function (resolve, reject) {
req.onsuccess = function (e) { resolve(e.target.result); };
req.onerror = function (e) { reject(e.target.error); };
});
}
function dbStore(name, mode) {
return dbOpen().then(function (idb) { return idb.transaction(name, mode).objectStore(name); });
}
// ====== 批量写入(导入/恢复路径专用) ======
// 旧实现在导入循环里逐条 await(每条 2 次事务往返:先 get 再 put),
// 万条数据就是上万次事务 —— 慢到几乎不可用。改为「一次性读出建索引 → 内存合并
// → 批量写回」,事务数从 O(n) 降到 O(n/50)。
// 注意:store 名称与 keyPath 必须与 onupgradeneeded 中创建的完全一致,
// 否则 put 会抛 DataError 导致事务 abort。
function dbBulkPut(storeName, rows, chunk) {
if (!rows || !rows.length) return Promise.resolve(0);
const step = chunk || 50;
return dbOpen().then(function (idb) {
const batches = [];
for (let i = 0; i < rows.length; i += step) batches.push(rows.slice(i, i + step));
return batches.reduce(function (chain, batch) {
return chain.then(function () {
return new Promise(function (resolve, reject) {
const tx = idb.transaction(storeName, 'readwrite');
const st = tx.objectStore(storeName);
batch.forEach(function (r) { st.put(r); });
tx.oncomplete = function () { resolve(); };
tx.onerror = function (e) { reject(e.target.error); };
tx.onabort = function (e) { reject(e.target.error); };
});
});
}, Promise.resolve()).then(function () { return rows.length; });
});
}
function dbPutMovies(movies) {
if (Array.isArray(movies)) {
const now = Date.now();
for (let i = 0; i < movies.length; i++) {
const rec = movies[i];
if (rec && typeof rec === 'object' && !rec.updatedAt) {
rec.updatedAt = now;
}
}
}
return dbBulkPut('movies', movies, 50);
}
function dbPutLists(lists) { return dbBulkPut('lists', lists, 50); }
function dbPutGalleries(recs) { return dbBulkPut('gallery', recs, 100); }
function dbPutNotes(recs) { return dbBulkPut('notes', recs, 100); }
// 演员性别:入参为 [[slug, gender], ...],这里转成 store 行再批量写
function dbPutActorGenders(pairs) {
const rows = (pairs || []).filter(function (p) { return p && p[0] && p[1]; })
.map(function (p) { return { slug: p[0], gender: p[1] }; });
return dbBulkPut('actorGenders', rows, 200);
}
// meta 批量写:入参为普通对象 {key: value}
function dbSetMetaMany(obj) {
if (!obj) return Promise.resolve(0);
const rows = Object.keys(obj).filter(function (k) {
return obj[k] !== undefined && obj[k] !== null;
}).map(function (k) { return { key: k, value: obj[k] }; });
return dbBulkPut('meta', rows, 50);
}
function dbGetMovie(code) { return dbStore('movies', 'readonly').then(function (s) { return dbReq(s.get(code)); }); }
function dbPutMovie(m) {
if (m && typeof m === 'object' && !m.updatedAt) {
m.updatedAt = Date.now();
}
return dbStore('movies', 'readwrite').then(function (s) { return dbReq(s.put(m)); });
}
function dbDeleteMovie(code) { return dbStore('movies', 'readwrite').then(function (s) { return dbReq(s.delete(code)); }); }
// 自动清洗误入库的非收藏孤儿记录与未登录残留作品
async function dbPurgeOrphanMovies(cleanUnlinkedStatus) {
try {
const all = await dbAllMovies();
const loggedIn = isJavdbUserLoggedIn();
const orphans = (all || []).filter(function (m) {
if (!m || !m.code) return false;
if (m.customMeta || (typeof m.userScore === 'number' && m.userScore > 0) || (m.notes && typeof m.notes === 'string' && m.notes.trim())) return false; // 保护拥有自定义元数据更正记录、个人评分或备注的作品,不可作为孤儿被清除
const hasList = Array.isArray(m.listIds) && m.listIds.length > 0;
const hasStatus = !!m.reviewStatus;
// 1. 无清单且无标记的纯孤儿(如 TOP250 残留)
if (!hasList && !hasStatus) return true;
// 2. 未登录状态下产生的残留脏数据:
// - 若当前处于未登录状态,所有无清单归属作品均属无法在当前会话维护的孤儿,自动清理;
// - 若当前处于登录状态,当 cleanUnlinkedStatus 为 true 时,一并清除无清单归属的想看/看过标记残留作品
if (!hasList && (!loggedIn || cleanUnlinkedStatus)) return true;
return false;
});
if (!orphans.length) return 0;
for (let i = 0; i < orphans.length; i++) {
await dbDeleteMovie(orphans[i].code);
}
log('已从收藏夹数据库自动清理 ' + orphans.length + ' 部无归属/未登录残留作品');
return orphans.length;
} catch (err) {
log('清理无归属残留记录失败: ' + err.message);
return 0;
}
}
// 常浏览计数自增:读-改-写,确保即使旧记录无 views 字段也安全兜底为 0
function dbIncView(code) {
return dbGetMovie(code).then(function (m) {
if (!m) return null;
m.views = (typeof m.views === 'number' ? m.views : 0) + 1;
return dbPutMovie(m);
});
}
// 对外:点击封面进详情记为一次浏览。失败静默(不影响导航),仅记录日志。
function incView(code) {
if (!code) return;
dbIncView(code).catch(function (e) { log('浏览计数失败: ' + e.message); });
}
function dbAllMovies() {
return dbStore('movies', 'readonly').then(function (s) { return dbReq(s.getAll()); }).then(function (list) {
if (Array.isArray(list)) {
list.forEach(function (m) {
if (m && Array.isArray(m.awards)) {
m.awards.forEach(function (aw) {
if (aw && /^top\s*250$/i.test(aw.name)) aw.name = 'JavDB 影片TOP250';
});
}
});
}
return list || [];
});
}
function dbClearMovies() { return dbStore('movies', 'readwrite').then(function (s) { return dbReq(s.clear()); }); }
function dbReplaceLists(lists) {
return dbOpen().then(function (idb) {
return new Promise(function (resolve, reject) {
const tx = idb.transaction('lists', 'readwrite');
const st = tx.objectStore('lists');
st.clear();
lists.forEach(function (l) { st.put(l); });
tx.oncomplete = function () { resolve(); };
tx.onerror = function (e) { reject(e.target.error); };
});
});
}
function dbAllLists() { return dbStore('lists', 'readonly').then(function (s) { return dbReq(s.getAll()); }); }
function dbPutList(l) { return dbStore('lists', 'readwrite').then(function (s) { return dbReq(s.put(l)); }); }
function dbDeleteList(id) { return dbStore('lists', 'readwrite').then(function (s) { return dbReq(s.delete(id)); }); }
function dbClearLists() { return dbStore('lists', 'readwrite').then(function (s) { return dbReq(s.clear()); }); }
function dbClearGallery() { return dbStore('gallery', 'readwrite').then(function (s) { return dbReq(s.clear()); }); }
function dbClearMeta() { return dbStore('meta', 'readwrite').then(function (s) { return dbReq(s.clear()); }); }
function dbClearActorGenders() { return dbStore('actorGenders', 'readwrite').then(function (s) { return dbReq(s.clear()); }); }
async function clearAll() {
await dbClearLists();
await dbClearMovies();
await dbClearGallery();
await dbClearMeta();
await dbClearActorGenders();
if (typeof dbClearNotes === 'function') await dbClearNotes();
}
function dbGetMeta(key) {
return dbStore('meta', 'readonly').then(function (s) {
return dbReq(s.get(key)).then(function (r) { return r ? r.value : null; });
});
}
function dbAllMeta() {
return dbStore('meta', 'readonly').then(function (s) {
return dbReq(s.getAll()).then(function (rows) {
const m = {};
(rows || []).forEach(function (r) { m[r.key] = r.value; });
return m;
});
});
}
function dbSetMeta(key, value) {
return dbStore('meta', 'readwrite').then(function (s) { return dbReq(s.put({ key: key, value: value })); });
}
// 演员性别表:slug -> {slug, gender:'M'|'F'}
function dbGetActorGender(slug) {
if (!slug) return Promise.resolve(null);
return dbStore('actorGenders', 'readonly').then(function (s) { return dbReq(s.get(slug)); })
.then(function (r) { return r ? r.gender : null; });
}
function dbPutActorGender(slug, gender) {
if (!slug) return Promise.resolve();
return dbStore('actorGenders', 'readwrite').then(function (s) { return dbReq(s.put({ slug: slug, gender: gender })); });
}
function dbAllActorGenders() {
return dbStore('actorGenders', 'readonly').then(function (s) { return dbReq(s.getAll()); })
.then(function (rows) {
const m = {};
(rows || []).forEach(function (r) { if (r && r.slug) m[r.slug] = r.gender; });
return m;
});
}
// ---------- 预览画廊 store ----------
function dbPutGallery(rec) {
// rec: {id, code, title, date, previewUrl, coverUrl, savedAt}
// 注意:必须在 dbOpen().then 的同步回调内创建事务并发起请求,否则事务在跨 .then 后已 inactive(TransactionInactiveError)
return dbOpen().then(function (idb) {
return new Promise(function (resolve, reject) {
const tx = idb.transaction('gallery', 'readwrite');
tx.objectStore('gallery').put(rec);
tx.oncomplete = function () { resolve(); };
tx.onerror = function (e) { reject(e.target.error); };
tx.onabort = function (e) { reject(e.target.error); };
});
});
}
function dbGetGallery(id) {
return dbOpen().then(function (idb) {
return new Promise(function (resolve, reject) {
const tx = idb.transaction('gallery', 'readonly');
const req = tx.objectStore('gallery').get(id);
req.onsuccess = function () { resolve(req.result); };
req.onerror = function (e) { reject(e.target.error); };
});
});
}
function dbDeleteGallery(id) {
return dbOpen().then(function (idb) {
return new Promise(function (resolve, reject) {
const tx = idb.transaction('gallery', 'readwrite');
tx.objectStore('gallery').delete(id);
tx.oncomplete = function () { resolve(); };
tx.onerror = function (e) { reject(e.target.error); };
tx.onabort = function (e) { reject(e.target.error); };
});
});
}
function dbAllGallery() {
return dbOpen().then(function (idb) {
return new Promise(function (resolve, reject) {
const tx = idb.transaction('gallery', 'readonly');
const req = tx.objectStore('gallery').getAll();
req.onsuccess = function () { resolve(req.result || []); };
req.onerror = function (e) { reject(e.target.error); };
});
});
}
/* ---------- 实体备注 store(演员/作品,v14) ---------- */
function dbPutNote(rec) {
// rec: {key:'actor:{slug}'|'code:{PREFIX}', name, content, updatedAt}
return dbOpen().then(function (idb) {
return new Promise(function (resolve, reject) {
const tx = idb.transaction('notes', 'readwrite');
tx.objectStore('notes').put(rec);
tx.oncomplete = function () { resolve(); };
tx.onerror = function (e) { reject(e.target.error); };
tx.onabort = function (e) { reject(e.target.error); };
});
});
}
function dbGetNote(key) {
return dbOpen().then(function (idb) {
return new Promise(function (resolve, reject) {
const tx = idb.transaction('notes', 'readonly');
const req = tx.objectStore('notes').get(key);
req.onsuccess = function () { resolve(req.result || null); };
req.onerror = function (e) { reject(e.target.error); };
});
});
}
function dbDeleteNote(key) {
return dbOpen().then(function (idb) {
return new Promise(function (resolve, reject) {
const tx = idb.transaction('notes', 'readwrite');
tx.objectStore('notes').delete(key);
tx.oncomplete = function () { resolve(); };
tx.onerror = function (e) { reject(e.target.error); };
tx.onabort = function (e) { reject(e.target.error); };
});
});
}
function dbClearNotes() {
return dbOpen().then(function (idb) {
return new Promise(function (resolve, reject) {
const tx = idb.transaction('notes', 'readwrite');
tx.objectStore('notes').clear();
tx.oncomplete = function () { resolve(); };
tx.onerror = function (e) { reject(e.target.error); };
});
});
}
function dbAllNotes() {
return dbOpen().then(function (idb) {
return new Promise(function (resolve, reject) {
const tx = idb.transaction('notes', 'readonly');
const req = tx.objectStore('notes').getAll();
req.onsuccess = function () { resolve(req.result || []); };
req.onerror = function (e) { reject(e.target.error); };
});
});
}
/* ---------- 预览画廊:内存收藏真相源(避免异步查询竞态导致状态串台) ----------
核心原则:按钮「显示状态」由内存 Set 同步决定(collectedSet.has(当前图片id)),
永远是当前真实图片的瞬时正确值,异步 DB 结果无法串到别的图片上。
IndexedDB 只负责持久化:每次 put/delete 后同步维护 Set,启动时一次性载入。 */
const collectedSet = new Set();
let collectedSetReady = false;
function isCollected(id) { return collectedSetReady && id ? collectedSet.has(id) : false; }
function _gallerySyncFromRows(rows) { collectedSet.clear(); (rows || []).forEach(function (r) { if (r && r.id) collectedSet.add(r.id); }); collectedSetReady = true; }
// 启动时载入全量,建立内存真相源
dbAllGallery().then(function (rows) { _gallerySyncFromRows(rows); }).catch(function () {});
// 包装 DB 操作:持久化成功后同步维护 Set(详情页按钮与画廊 lightbox 共用同一真相源)
const _dbPutGallery = dbPutGallery;
dbPutGallery = function (rec) { return _dbPutGallery(rec).then(function (r) { if (rec && rec.id) collectedSet.add(rec.id); return r; }); };
const _dbDeleteGallery = dbDeleteGallery;
dbDeleteGallery = function (id) { return _dbDeleteGallery(id).then(function (r) { collectedSet.delete(id); return r; }); };
const _dbAllGallery = dbAllGallery;
dbAllGallery = function () { return _dbAllGallery().then(function (rows) { _gallerySyncFromRows(rows); return rows; }); };
/* ---------- 限速串行队列 ---------- */
const queue = {
running: false, paused: false, abort: false,
total: 0, done: 0, label: '',
// 单请求基础间隔:收紧到 0.6~1.2s(原 2~4s 过于保守,是 9 小时慢速主因)
delay: function () { return 600 + Math.random() * 600; }
};
function sleep(ms) { return new Promise(function (r) { setTimeout(r, ms); }); }
// 并发池:动态 worker 数(运行时随 CONCURRENCY 实时增减,不重启任务)。
// 每个 worker 内部串行(带 delay + 暂停检查)。共享游标 cursor 分配任务,避免重复/遗漏。
// 调高并发:spawn 新 worker 接新任务;调低:多余 worker 在取下一任务前 graceful 退出(不打断在跑请求)。
function runPool(items, workerFn) {
const cursor = { i: 0 };
const n = items.length;
let live = 0;
const workers = [];
let resolveFn, rejectFn;
// done 支持 reject:workerFn 异常(如取消 ABORT)汇聚为 done 拒绝,
// 调用方 catch 到 ABORT 显示「已取消」;避免 unhandled rejection 与控制流错乱
const done = new Promise(function (res, rej) { resolveFn = res; rejectFn = rej; });
function targetN() { return Math.max(1, Math.min(CONCURRENCY || 1, n)); }
function spawn() {
live++;
const self = {};
workers.push(self);
(async function () {
let firstErr = null;
try {
while (true) {
if (queue.abort) break;
// 超出目标并发且自身位置在目标之外 → 取完当前任务后 graceful 退出
if (live > targetN() && workers.indexOf(self) >= targetN()) break;
const idx = cursor.i++;
if (idx >= n) break;
try {
await workerFn(items[idx], idx);
} catch (e) {
if (e && e.message === 'ABORT') { if (!firstErr) firstErr = e; break; } // 取消:终止本 worker(其它 worker 由 queue.abort 在下一轮退出)
if (!firstErr) firstErr = e; // 其它错误:记录并继续处理剩余条目
}
}
} finally {
live--;
const wi = workers.indexOf(self); if (wi >= 0) workers.splice(wi, 1);
if (live === 0) {
if (firstErr) rejectFn(firstErr); else resolveFn();
}
}
})();
}
const initial = targetN();
for (let w = 0; w < initial; w++) spawn();
// 实时调节接口:滑块拖动时调用,按当前 CONCURRENCY 增减 worker(不打断在跑的请求)
runPool._adjust = function () {
while (live < targetN()) spawn();
// 超出目标的 worker 会在下次取任务前自动退出
};
return done;
}
// 当前正在执行的 fetch 的中断句柄(stop() 取消时主动 reject,让挂起的请求立即感知取消)
let pendingFetchAbort = null;
async function throttledFetch(url) {
// 超时保护:javdb/Cloudflare 对 fetch 偶发挂起(实测 30s+ 不返回),
// 取消/暂停若等它自然返回会「点了没反应」;12s 超时 + 用户取消时立即中断
let timeoutId = null;
let myReject = null;
let guard;
try {
guard = new Promise(function (_, reject) {
myReject = reject;
pendingFetchAbort = reject; // 注册当前请求的中断句柄(stop 时立即 reject)
timeoutId = setTimeout(function () {
if (queue.abort) reject(new Error('ABORT'));
else reject(new Error('FETCH_TIMEOUT'));
}, 12000);
});
const run = (async function () {
const resp = await fetch(url, { credentials: 'same-origin' });
if (resp.status === 403 || resp.status === 429) throw new Error('RATELIMIT:' + resp.status);
if (!resp.ok) throw new Error('HTTP ' + resp.status);
const html = await resp.text();
const head = html.slice(0, 3000);
// Cloudflare 挑战页特征检测
if (/cf-challenge|Checking your browser|Just a moment|__cf_chl/i.test(head)) {
throw new Error('RATELIMIT:cf');
}
// 登录页检测(会话过期/未登录时 javdb 跳转登录页)
if (/<title[^>]*>\s*(登入|Sign in)|\/login|\/sign_in/i.test(head) && /password|密碼/i.test(html)) {
throw new Error('LOGIN_REQUIRED');
}
return new DOMParser().parseFromString(html, 'text/html');
})();
return await Promise.race([run, guard]);
} finally {
if (timeoutId) clearTimeout(timeoutId);
// 仅清理自己的句柄(并发 worker 各注册自己的,防串台)
if (pendingFetchAbort === myReject) pendingFetchAbort = null;
}
}
// 统一错误转用户可读消息
function friendlyError(err) {
const m = String(err && err.message || err);
if (m === 'LOGIN_REQUIRED') return '登录已失效,请先登录 JAVDB 再操作';
if (m.indexOf('RATELIMIT:cf') === 0) return '触发 Cloudflare 验证,请手动访问 JAVDB 通过验证后重试';
if (m.indexOf('RATELIMIT') === 0) return '请求被限流,请稍后重试';
if (/QuotaExceededError|exceeded the quota/i.test(m)) return '浏览器存储空间不足,建议导出收藏夹后清理数据';
return m;
}
/* ---------- 解析器(选择器常量集中,方便日后维修) ---------- */
// 真实 URL 结构(据线上诊断确认):
// 我的清单索引:/users/lists → 链接 /users/list_detail?id={字母数字ID}(旁有 /users/remove_list?id=…)
// 收藏的清单索引:/users/favorite_lists(单独页面)
// 清单分页:/users/list_detail?id={ID}&page=N
// 1) 清单索引页解析:提取全部清单链接(兼容 /users/list_detail?id= 与公开清单 /lists/{id})
function parseListIndex(doc) {
const out = [];
const seen = {};
Array.from(doc.querySelectorAll('a[href*="list_detail?id="], a[href*="/lists/"]')).forEach(function (a) {
const item = parseListLink(a);
if (item && !seen[item.id]) { seen[item.id] = 1; out.push(item); }
});
// 索引页自身也分页
const next = doc.querySelector('a.pagination-next, .pagination-next');
const hasNext = !!(next && !next.classList.contains('is-disabled'));
return { lists: out, hasNext: hasNext };
}
// 抓完整个索引(逐页),返回清单数组。
// 与清单内页一致的容错:单页最多重试 3 次;风控自动暂停等待用户继续(不计入重试);
// 登录失效直接中止(重试无意义)。旧版裸调 throttledFetch,索引页一次抖动就中止整个同步。
async function fetchAllListIndex(basePath) {
const all = []; const seen = {};
let page = 1, hasNext = true;
while (hasNext && page <= 50) { // 50 页保险上限
if (page > 1) await sleep(queue.delay());
await waitIfPaused();
if (queue.abort) throw new Error('ABORT');
let doc = null, tries = 0;
while (tries < 3 && !doc) {
try {
doc = await throttledFetch(basePath + (basePath.indexOf('?') === -1 ? '?' : '&') + 'page=' + page);
} catch (err) {
if (String(err.message).indexOf('RATELIMIT') === 0) {
report('pause', '疑似触发风控,已自动暂停。稍后点击「继续」重试。');
queue.paused = true; await waitIfPaused();
if (queue.abort) throw new Error('ABORT');
continue; // 风控恢复后重试,不计入 tries
}
if (String(err.message) === 'LOGIN_REQUIRED') throw err;
tries++;
log('清单索引页抓取失败(p' + page + ' 第' + tries + '次): ' + err.message);
if (tries < 3) await sleep(3000 * tries);
}
}
if (!doc) throw new Error('清单索引第' + page + '页连续失败,同步已中止,可稍后重试');
const r = parseListIndex(doc);
r.lists.forEach(function (l) { if (!seen[l.id]) { seen[l.id] = 1; all.push(l); } });
hasNext = r.hasNext && r.lists.length > 0;
page++;
}
return all;
}
function parseListLink(a) {
const href = a.getAttribute('href') || '';
// 排除删除/编辑等动作链接
if (/remove_list|edit_list|favorite_lists\?|users\/lists\?/.test(href)) return null;
let m = href.match(/list_detail\?id=([A-Za-z0-9_-]+)/);
if (!m) m = href.match(/\/lists\/([A-Za-z0-9_-]+)/);
if (!m) return null;
const text = (a.textContent || '').trim().replace(/\s+/g, ' ');
if (!text) return null;
// 「名称 (数量)」或名称与数量分离
let name = text, count = 0;
const cm = text.match(/^(.*?)[\s·]*[\((](\d+)[\))]\s*$/);
if (cm) { name = cm[1].trim(); count = parseInt(cm[2], 10) || 0; }
// 链接文本不带数量时,javdb 常把数量放在链接所在卡片/行的 .count/.video-count/.badge 里
//(如 <div class="list"><a>名字</a><span class="count">5</span></div>),或直接写在列表项
// 的静态文本里(真实索引页:<a>名字</a> … "501 部影片, 被點擊了 1017 次")。逐层补抓:
if (!count) {
try {
const card = a.closest('.list, .box, .column, .panel-block, .list-item, li, tr, .video-list, .item');
// ① 结构化数量元素
const ce = card && card.querySelector('.count, .video-count, .badge, .item-count');
const cm2 = ce && (ce.textContent || '').match(/\d+/);
if (cm2) count = parseInt(cm2[0], 10) || 0;
// ② javdb 索引页「XXX 部影片」静态文本(精确匹配"部影片",避免误取点击次数等数字)
if (!count && card) {
const cm3 = (card.textContent || '').match(/(\d+)\s*部影片/);
if (cm3) count = parseInt(cm3[1], 10) || 0;
}
} catch (e) {}
}
// 保留链接真实地址(去掉已有 page 参数)作为翻页基址——
// 「我的清单」与「收藏的清单(他人分享)」的详情 URL 结构不同,绝不能自行拼接
const base = href.replace(/([?&])page=\d+&?/, '$1').replace(/[?&]$/, '');
return { id: m[1], name: name, count: count, base: base };
}
// 清单分页 URL:基于抓取时保存的真实 href 翻页
function listPageUrl(listOrId, page) {
const base = (listOrId && listOrId.base) ? listOrId.base
: '/users/list_detail?id=' + encodeURIComponent(listOrId.id || listOrId);
return base + (base.indexOf('?') === -1 ? '?' : '&') + 'page=' + page;
}
// 2) 清单分页:/lists/{id}?page=N → 16 个条目
// 产出键名对齐:coverThumb(列表缩略图)、detailHref(详情路径)
// 解析不依赖单一容器类名:JAVDB 不同页面(清单页/搜索页)卡片容器可能不同,
// 统一以「带 /v/ 链接的卡片」为锚点,覆盖更多页面结构,避免解析到 0 条导致库被清空。
function parseListPage(doc) {
const items = [];
const cards = doc.querySelectorAll('.item, .box.unit, .tile-item, a.box, .grid-item');
cards.forEach(function (it) {
const a = it.querySelector ? it.querySelector('a[href^="/v/"]') : null;
if (!a) return;
const img = it.querySelector ? it.querySelector('.cover img, img') : null;
const titleEl = it.querySelector ? it.querySelector('.video-title, .video-number, .uid, strong') : null;
let code = '', title = '';
if (titleEl) {
const strong = titleEl.querySelector ? titleEl.querySelector('strong') : null;
if (strong) {
code = strong.textContent.trim();
}
const clone = titleEl.cloneNode ? titleEl.cloneNode(true) : null;
if (clone && clone.querySelectorAll) {
clone.querySelectorAll('strong, .efav-award-badge, .efav-award, .efav-dot').forEach(function (e) { e.remove(); });
title = clone.textContent.trim();
} else {
title = (titleEl.textContent || '').replace(code, '').trim();
}
if (!code) {
const cm = (title || '').match(/^[A-Z0-9-]+/);
if (cm) code = cm[0];
}
}
const meta = it.querySelector ? it.querySelector('.meta') : null;
const scoreEl = it.querySelector ? it.querySelector('.score .value, .score') : null;
let score = null, scoreCount = null;
if (scoreEl) {
const sm = (scoreEl.textContent || '').match(/([\d.]+)\s*分/);
if (sm) score = parseFloat(sm[1]);
const cm2 = (scoreEl.textContent || '').match(/由\s*([\d,]+)\s*人/);
if (cm2) scoreCount = parseInt(cm2[1].replace(/,/g, ''), 10);
}
if (!code) {
// 退路:从详情链接反推番号。注意 javdb 详情 URL 是编码 ID(如 /v/r37kRv),
// 只有形如真实番号(全大写字母/数字 + 可选 -_ 分隔)才采用,避免编码 ID 污染数据库。
const vm = (a.getAttribute('href') || '').match(/\/v\/([^/?#]+)/);
if (vm && /^[A-Z0-9][A-Z0-9]*(?:[-_][A-Z0-9]+)*$/.test(vm[1])) code = vm[1];
}
if (!code) return;
items.push({
code: code, title: title,
detailHref: a.getAttribute('href') || '',
coverThumb: img ? stripHost(img.getAttribute('src') || img.getAttribute('data-src') || '') : '',
date: meta ? meta.textContent.trim() : '',
score: score, scoreCount: scoreCount
});
});
const next = doc.querySelector('a.pagination-next, .pagination-next');
const hasNext = !!(next && !next.classList.contains('is-disabled') && (next.getAttribute('href') || next.tagName === 'A'));
return { items: items, hasNext: hasNext };
}
// 3) 详情页解析
// director/maker/series 为 {text,href}|null,rating 为 {score,count,text}|null,
// categories 为 [{text,href}],actors 为 [{name,href,avatar}],封面存 coverLarge
function favBlockVal(root, labelRe) {
const blocks = root.querySelectorAll('.panel-block');
for (let i = 0; i < blocks.length; i++) {
const s = ((blocks[i].querySelector('strong') || {}).textContent || '').replace(/[::\s]/g, '');
if (labelRe.test(s)) return blocks[i];
}
return null;
}
function favLinkOf(block) {
if (!block) return null;
const a = block.querySelector('.value a[href]');
if (a) return { text: a.textContent.trim(), href: a.getAttribute('href') || '#' };
const v = ((block.querySelector('.value') || {}).textContent || '').trim();
return v ? { text: v, href: '#' } : null;
}
// 演员头像 URL:https://c0.jdbstatic.com/avatars/{id 前2位小写}/{id}.jpg
function favActorAvatar(href) {
const m = (href || '').match(/\/actors\/([^\/\?\#]+)/);
if (!m) return '';
const id = m[1];
return stripHost('https://c0.jdbstatic.com/avatars/' + id.slice(0, 2).toLowerCase() + '/' + id + '.jpg');
}
function parseDetail(root) {
const fb = root.querySelector('.panel-block.first-block');
const code = fb ? ((fb.querySelector('.value') || {}).textContent || '').trim().replace(/\s+/g, '') : '';
const dateB = favBlockVal(root, /^日期$/);
const date = dateB ? ((dateB.querySelector('.value') || {}).textContent || '').trim() : '';
const durB = favBlockVal(root, /^時長|时长$/);
const duration = durB ? ((durB.querySelector('.value') || {}).textContent || '').trim() : null;
const director = favLinkOf(favBlockVal(root, /^導演|导演$/));
const maker = favLinkOf(favBlockVal(root, /^片商|賣家|卖家$/));
const series = favLinkOf(favBlockVal(root, /^系列$/));
const ratingB = favBlockVal(root, /^評分|评分$/);
let rating = null;
if (ratingB) {
const v = ((ratingB.querySelector('.value') || {}).textContent || '').trim();
const sm = v.match(/([\d.]+)\s*分/);
const cm = v.match(/由(\d+)人/);
rating = { score: sm ? parseFloat(sm[1]) : 0, count: cm ? parseInt(cm[1], 10) : 0, text: v };
}
const catB = favBlockVal(root, /類別|类别|標籤|标签/);
const categories = catB ? Array.from((catB.querySelector('.value') || catB).querySelectorAll('a[href]'))
.map(function (a) { return { text: a.textContent.trim(), href: a.getAttribute('href') || '#' }; }) : [];
const actorEls = Array.from(root.querySelectorAll('.panel-block'))
.filter(function (b) { const s = (b.querySelector('strong') || {}).textContent || ''; return s.indexOf('演員') !== -1 || s.indexOf('演员') !== -1; })
.reduce(function (acc, b) { return acc.concat(Array.from(b.querySelectorAll('a[href^="/actors/"]'))); }, []);
const actors = actorEls.map(function (a) {
return { name: a.textContent.trim(), href: a.getAttribute('href') || '#', avatar: favActorAvatar(a.getAttribute('href') || '') };
});
// 标题:优先取原标题 .origin-title(屏蔽 JAVDB 翻译),否则取 strong.current-title,兜底取整个 h2 文本
let title = '';
const titleEl = root.querySelector('h2.title.is-4, h2.title');
if (titleEl) {
const isBlockTr = isPluginEnabled('blockJavdbTranslate');
const originT = titleEl.querySelector('.origin-title');
const strongT = titleEl.querySelector('strong.current-title');
if (isBlockTr && originT && originT.textContent.trim()) {
title = originT.textContent.trim();
} else if (strongT && strongT.textContent.trim()) {
title = strongT.textContent.trim();
} else {
title = titleEl.textContent.trim();
}
}
const coverEl = root.querySelector('img.video-cover');
const coverLarge = coverEl ? stripHost(coverEl.getAttribute('src') || '') : '';
// 金标(榜单徽章,实测结构):.ranking-tags a.tags 内 .tag.is-dark=排名(No.N)、.tag.is-warning=金标名
const awardEls = Array.from(root.querySelectorAll('.ranking-tags a.tags'));
const awards = awardEls.map(function (a) {
const rankTxt = (a.querySelector('.tag.is-dark') || {}).textContent || '';
const name = ((a.querySelector('.tag.is-warning') || {}).textContent || '').trim();
const rm = rankTxt.match(/No\.(\d+)/i);
return { name: name, rank: rm ? parseInt(rm[1], 10) : null };
}).filter(function (w) { return w.name; });
return { code: code, title: title, date: date, duration: duration,
director: director, maker: maker, series: series, rating: rating,
categories: categories, actors: actors, coverLarge: coverLarge, awards: awards };
}
/* ---------- 任务执行(拉取 / 同步 / 补全,带断点续跑) ---------- */
let progressCb = null; // UI 进度回调 {label, done, total, state:'run'|'pause'|'done'|'error', msg}
function report(state, msg) {
if (progressCb) progressCb({ label: queue.label, done: queue.done, total: queue.total, state: state, msg: msg || '' });
}
function pause() { queue.paused = true; report('pause'); }
function resume() { if (queue.resumeFn) { queue.paused = false; queue.resumeFn(); } }
function isBusy() { return queue.running; }
async function waitIfPaused() {
while (queue.paused) {
await new Promise(function (r) { queue.resumeFn = r; });
queue.resumeFn = null;
}
}
// 新 movie 记录骨架
function newMovieRecord(it) {
return {
code: it.code, title: it.title, date: it.date,
coverThumb: it.coverThumb, detailHref: it.detailHref,
listIds: [], detailComplete: false, detailError: null,
detailSyncedAt: null, listSyncedAt: Date.now(),
duration: null, director: null, maker: null, series: null,
rating: null, categories: [], actors: [],
reviewStatus: null, // JAVDB 标记:'wishlist' | 'watched' | null(想看/看過,随详情抓取/拦截器同步)
userScore: null, // 用户评分:1-5(用户个人评价星级,与 JAVDB 官方评分 m.rating 完全独立)
awards: [], // JAVDB 金标:[{name:'JavDB 影片TOP250', rank:2}, ...](无金标为空数组)
awardsChecked: false, // 详情页是否已抓取过金标(v7.128 起补全任务据此对存量作品一次性回填)
views: 0 // 常浏览计数:在收藏夹内点击封面进入详情计 1(真实浏览行为)
};
}
// 拉取/同步共用:抓全部我的清单 → diff 入库(movie.listIds 记归属,lists 全量替换)
async function runSync(selectedIds) {
if (queue.running) return;
queue.running = true; queue.paused = false; queue.abort = false;
queue.label = '同步清单'; queue.done = 0; queue.total = 1;
const syncStartAt = Date.now(); // 并发写入守卫基准:同步期间被改写的记录不做删除仲裁(见 diff 删除分支)
try {
report('run', '获取清单索引…');
const myLists = await fetchAllListIndex('/users/lists');
if (!myLists.length) { report('error', '未解析到任何清单,请确认已登录且有清单'); queue.running = false; return; }
// 选定要同步的清单:selectedIds 有值 → 只同步勾选的;否则(省略/空)→ 全量同步(向后兼容)
const selectedSet = {};
const selectedAll = !(selectedIds && selectedIds.length);
if (!selectedAll) selectedIds.forEach(function (id) { selectedSet[id] = 1; });
const syncLists = selectedAll ? myLists : myLists.filter(function (l) { return selectedSet[l.id]; });
if (!syncLists.length) { report('error', '未选择任何清单'); queue.running = false; return; }
// 清理库中已缓存但不再需要的 favorited 清单
try {
const allLists = await dbAllLists();
const old = allLists.filter(function (l) { return l.type === 'favorited'; });
for (let k = 0; k < old.length; k++) await dbDeleteList(old[k].id || old[k].name);
} catch (e) { log('清理旧收藏清单缓存失败: ' + e.message); }
// 估算总页数(仅按选中清单)
queue.total = syncLists.reduce(function (s, l) { return s + Math.max(1, Math.ceil((l.count || 16) / 16)); }, 0);
const freshMovies = {}; // code -> movie 记录(含 listIds)
const failedLists = [];
const failedListIds = [];
for (let li = 0; li < syncLists.length; li++) {
const list = syncLists[li];
let itemCount = 0;
let page = 1, hasNext = true, listFailed = false;
while (hasNext) {
await waitIfPaused();
if (queue.abort) throw new Error('ABORT');
await sleep(queue.delay());
let pg = null, tries = 0;
// 单页最多重试 3 次(网络抖动等瞬时错误),风控则暂停等待用户继续
while (tries < 3 && !pg) {
try {
pg = await throttledFetch(listPageUrl(list, page));
} catch (err) {
// 用户已取消:无论超时/普通错误都立即中止(取消必须"点了就有反应")
if (queue.abort || String(err.message) === 'ABORT') throw new Error('ABORT');
if (String(err.message).indexOf('RATELIMIT') === 0) {
report('pause', '疑似触发风控,已自动暂停。稍后点击「继续」重试。');
queue.paused = true; await waitIfPaused();
if (queue.abort) throw new Error('ABORT');
continue; // 风控恢复后重试,不计入 tries
}
tries++;
log('清单页抓取失败(' + list.name + ' p' + page + ' 第' + tries + '次): ' + err.message);
if (tries < 3) await sleep(3000 * tries);
}
}
if (!pg) {
// 三次仍失败:该清单标记失败,保留本地旧数据,继续下一个清单
listFailed = true;
failedLists.push(list.name);
failedListIds.push(list.id);
log('清单「' + list.name + '」第' + page + '页连续失败,跳过该清单(本地数据保留)');
break;
}
const parsed = parseListPage(pg);
parsed.items.forEach(function (it) {
if (!it.code) return;
if (!freshMovies[it.code]) freshMovies[it.code] = newMovieRecord(it);
if (freshMovies[it.code].listIds.indexOf(list.id) === -1) freshMovies[it.code].listIds.push(list.id);
itemCount++;
});
hasNext = parsed.hasNext && parsed.items.length > 0;
queue.done++;
// 动态修正总页数:索引页链接文本常不带数量(count=0)导致预估过低,
// 只要还有下一页就保证分母 ≥ 已完成+1,避免出现 122/10 这类倒挂。
if (hasNext && queue.total < queue.done + 1) queue.total = queue.done + 1;
if (!hasNext && li === syncLists.length - 1) queue.total = queue.done;
report('run', list.name + ' 第' + page + '页');
page++;
}
list.scrapedCount = listFailed ? 0 : itemCount;
list.lastSynced = Date.now();
}
// diff:仅对「选中清单」范围内的记录仲裁——
// 已存在 → 合并 listIds(保留未选清单的旧归属)+ 更新列表页字段,保留详情字段
// 本地有、本次未抓到 → 只在记录与选中清单有交集时处理:失败清单保留归属 / 仅标记保留 /
// 并发写入保留 / 其余从选中部分移除——若移出后仍有未选清单归属则保留更新,否则删除。
// 完全不属于选中清单的记录(未选清单的归属)→ 本次同步绝不触碰。
const localMovies = await dbAllMovies();
const localMap = {};
localMovies.forEach(function (m) { localMap[m.code] = m; });
const toPut = [], toDelete = [];
let keptMarked = 0, keptConcurrent = 0;
function inSyncScope(mIds) {
if (selectedAll) return true;
for (let i = 0; i < mIds.length; i++) { if (selectedSet[mIds[i]]) return true; }
return false;
}
localMovies.forEach(function (m) {
const mIds = m.listIds || [];
if (!freshMovies[m.code]) {
if (!inSyncScope(mIds)) return; // 不属于本次同步范围 → 保留不动
let belongsToFailed = false;
if (mIds.length && failedListIds.length) {
for (let i = 0; i < mIds.length; i++) {
if (failedListIds.indexOf(mIds[i]) !== -1) { belongsToFailed = true; break; }
}
}
if (belongsToFailed) {
// 保留失败清单归属 + 未选清单归属(只移除「已成功抓取却没出现的选中清单」归属)
m.listIds = mIds.filter(function (lid) { return failedListIds.indexOf(lid) !== -1 || (!selectedAll && !selectedSet[lid]); });
toPut.push(m);
} else if ((m.listSyncedAt || 0) > syncStartAt || (m.reviewSyncedAt || 0) > syncStartAt || (m.updatedAt || 0) > syncStartAt) {
// 同步抓取期间被并发写入(其他标签页/本页拦截器操作)的记录:快照可能滞后,跳过删除,下次同步再仲裁
keptConcurrent++;
} else {
// 从选中部分移除:计算剔除本次已成功同步清单后的剩余清单归属
const rest = selectedAll ? [] : mIds.filter(function (lid) { return !selectedSet[lid]; });
if (rest.length) {
// 仍属于其它未选清单:更新保留
m.listIds = rest;
toPut.push(m);
} else if (m.reviewStatus || m.customMeta || (m.notes && typeof m.notes === 'string' && m.notes.trim())) {
// 核心修复:线上已被用户从清单移除,但本地带有「想看/看過」标记、自定义纠错元数据(customMeta)或用户备注(notes):
// 清单归属需正确剔除(m.listIds 缩减/清空),但作品记录绝对不可物理删除;
// 写回数据库保持标记与用户数据完整,使得收藏夹面板在「全部」和「想看/看过」视图中继续正常显示。
m.listIds = [];
toPut.push(m);
keptMarked++;
} else {
// 既无剩余清单归属,亦无想看/看過标记、自定义纠错或备注 → 彻底从本地库移除
toDelete.push(m.code);
}
}
}
});
const freshCodes = Object.keys(freshMovies);
freshCodes.forEach(function (code) {
const fresh = freshMovies[code];
if (localMap[code]) {
const ex = localMap[code];
const exIds = ex.listIds || [];
// 合并 listIds:保留未选清单的旧归属 + 本次抓取的新归属(去重)——绝不能覆盖丢失未选归属
const merged = (selectedAll ? [] : exIds.filter(function (lid) { return !selectedSet[lid]; })).slice();
fresh.listIds.forEach(function (lid) { if (merged.indexOf(lid) === -1) merged.push(lid); });
// listSyncedAt 仅在清单归属实际变化(新加入清单)时刷新:
// 同步其余字段照常覆盖,但不把全部条目的入库时间冲成「现在」,
// 否则「入库 新→旧」排序与入库时间线会在每次同步后整体归零。
const idsChanged = exIds.join(',') !== merged.join(',');
ex.listIds = merged;
if (idsChanged) ex.listSyncedAt = fresh.listSyncedAt;
if (fresh.title) ex.title = fresh.title;
if (fresh.date) ex.date = fresh.date;
if (fresh.coverThumb) ex.coverThumb = fresh.coverThumb;
if (fresh.detailHref) ex.detailHref = fresh.detailHref;
toPut.push(ex);
} else {
toPut.push(fresh);
}
});
await dbPutMovies(toPut);
for (let i = 0; i < toDelete.length; i++) await dbDeleteMovie(toDelete[i]);
// 我的清单写库:选中的用本次抓取数;未选中的保留本地旧值(避免计数被清 0)
const localLists = await dbAllLists();
const localListMap = {};
localLists.forEach(function (l) { localListMap[l.id] = l; });
const allLists = myLists.map(function (l) {
const synced = selectedAll || !!selectedSet[l.id];
const ex = localListMap[l.id];
return {
id: l.id, name: l.name, type: 'mine',
scrapedCount: synced ? (l.scrapedCount || 0) : (ex ? (ex.scrapedCount || 0) : 0),
lastSynced: synced ? (l.lastSynced || Date.now()) : (ex ? (ex.lastSynced || 0) : 0),
base: l.base || (ex && ex.base) || ''
};
});
await dbReplaceLists(allLists);
await dbSetMeta('lastFullSync', Date.now());
let doneMsg = '同步完成:' + syncLists.length + ' 个清单,' + freshCodes.length + ' 部作品';
if (failedLists.length) doneMsg += '(' + failedLists.length + ' 个清单抓取失败已跳过:' + failedLists.join('、') + ',可稍后重新同步)';
if (keptMarked || keptConcurrent) doneMsg += '(保留 ' + keptMarked + ' 条仅标记记录、' + keptConcurrent + ' 条同步期间变更记录)';
report(failedLists.length ? 'error' : 'done', doneMsg);
} catch (err) {
if (String(err.message) === 'ABORT') report('done', '已取消');
else { log('同步失败: ' + err.message); report('error', '同步失败:' + friendlyError(err)); }
} finally {
queue.running = false;
}
}
/* ---------- 轻量清单元数据同步(访问 /users/lists 时触发) ----------
* 场景:用户只改了清单名/数量(如几十个清单中的一个加了一个字),
* 全量同步(runSync 逐清单抓作品)成本过高(大量作品时可达小时级)。
* 本函数三阶段(两两独立,任一失败不影响其余):
* ① 本地计数兜底:本地 scrapedCount=0 的「我的清单」用本地作品归属数回填
* (movies.listIds 统计,零请求)——不依赖网络,索引抓取失败也能恢复计数;
* ② 抓索引(通常 1~3 个请求)对比:名字/数量/链接变了 → 就地 dbPutList 更新;
* ③ 广播 emby-fav-changed → 收藏夹面板自动 reload(scope 下拉显示新名/新计数)。
* 触发:路由进入 /users/lists 页(顶层路由检测 + init 首载检查);
* 全量同步进行中自动跳过(避免与 dbReplaceLists 并发写库);失败一律静默(log)。
* 注意:javdb 索引页的 fetch 偶发瞬时失败(Cloudflare/网络抖动),
* 因此计数兜底必须与网络解耦——这是本版重构的核心。 */
let metaSyncRunning = false;
// 抓取超时保护:javdb 的 fetch 偶发挂起(Cloudflare/网络抖动,实测可达 30s+ 不返回),
// 自动后台同步绝不能因此卡死(否则 metaSyncRunning 永久为 true,后续触发全被阻塞)
function fetchWithTimeout(url, ms) {
return Promise.race([
throttledFetch(url),
new Promise(function (resolve, reject) {
setTimeout(function () { reject(new Error('FETCH_TIMEOUT')); }, ms || 12000);
})
]);
}
async function syncListMeta() {
if (metaSyncRunning || queue.running) return;
metaSyncRunning = true;
try {
const local = await dbAllLists();
const localMap = {};
local.forEach(function (l) { localMap[l.id] = l; });
const puts = [];
// —— 阶段①:本地计数兜底(零请求)——
// 先于抓索引执行:本地计数为 0 的「我的清单」直接用本地作品归属数回填。
// 即使 /users/lists 抓取失败(fetch 瞬时抖动),计数也能恢复,不再依赖网络。
const zeroIds = [];
local.forEach(function (l) {
if (l.type !== 'favorited' && !(l.scrapedCount || 0)) zeroIds.push(l.id);
});
if (zeroIds.length) {
try {
const allM = await dbAllMovies();
const counter = {};
zeroIds.forEach(function (id) { counter[id] = 0; });
allM.forEach(function (m) {
(m.listIds || []).forEach(function (id) { if (counter[id] !== undefined) counter[id]++; });
});
zeroIds.forEach(function (id) {
const ex = localMap[id];
if (!ex || ex.type === 'favorited') return;
if (counter[id] > 0) {
ex.scrapedCount = counter[id];
ex.lastSynced = Date.now();
puts.push(ex);
}
});
} catch (e) { log('轻量清单元数据同步:本地计数兜底失败(静默): ' + e.message); }
}
// —— 阶段②:抓索引 + 名字/链接/数量对比(网络,失败不阻塞)——
const lists = [];
const seen = {};
let fetchFailed = false;
try {
let page = 1, hasNext = true;
while (hasNext && page <= 50) { // 50 页保险上限但实际通常 1~3 页
if (page > 1) await sleep(300);
let doc = null, tries = 0;
while (tries < 3 && !doc) { // 单页最多重试 3 次(瞬时抖动通常一次即过)
try { doc = await fetchWithTimeout('/users/lists' + (page === 1 ? '' : '?page=' + page), 12000); }
catch (err) { tries++; if (tries < 3) await sleep(1200 * tries); }
}
if (!doc) { fetchFailed = true; break; }
const r = parseListIndex(doc);
r.lists.forEach(function (l) { if (!seen[l.id]) { seen[l.id] = 1; lists.push(l); } });
hasNext = r.hasNext && r.lists.length > 0;
page++;
}
} catch (e) {
fetchFailed = true;
log('轻量清单元数据同步:索引抓取失败(本地计数兜底已执行): ' + e.message);
}
if (lists.length) {
lists.forEach(function (nl) {
const ex = localMap[nl.id];
if (!ex || ex.type === 'favorited') return;
let diff = false;
if (ex.name !== nl.name) { ex.name = nl.name; diff = true; }
if (ex.base !== nl.base) { ex.base = nl.base; diff = true; }
// 计数保守策略:scrapedCount 是「上次同步实际抓到的作品数」,绝不覆盖已有计数;
// 本地仍为 0 且索引页明确解析出数量(>0)时回填(本地兜底没补上的情况)
if (!(ex.scrapedCount || 0) && (nl.count || 0) > 0) { ex.scrapedCount = nl.count; diff = true; }
if (diff) { ex.lastSynced = Date.now(); if (puts.indexOf(ex) === -1) puts.push(ex); }
});
} else if (fetchFailed) {
log('轻量清单元数据同步:索引抓取失败(' + lists.length + ' 条),仅本地计数兜底生效');
}
if (!puts.length) { log('轻量清单元数据同步:无变化'); return; }
// 批量写入(清单数通常在几十以内,一次事务搞定;原为逐条 await)
await dbPutLists(puts);
log('轻量清单元数据同步:更新 ' + puts.length + ' 个清单(' + puts.map(function (p) { return p.name; }).join('、') + ')');
try { document.dispatchEvent(new CustomEvent('emby-fav-changed', { bubbles: true, detail: { metaSync: true } })); } catch (e) {}
try { if (typeof showToast === 'function') showToast('清单名称/数量已同步:' + puts.length + ' 个'); } catch (e) {}
} catch (e) {
log('轻量清单元数据同步失败(静默): ' + e.message);
} finally {
metaSyncRunning = false;
}
}
// 补全:拉取 detailComplete=false 且非 404 的详情。
// refreshAll=true 时为「全量刷新」:重抓库内全部番号详情(元数据保鲜,演员改名/信息补录跟进线上)
async function runComplete(refreshAll) {
if (queue.running) return;
const all = await dbAllMovies();
const pending = refreshAll
? all.filter(function (m) { return m.detailError !== '404'; })
: all.filter(function (m) { return (!m.detailComplete || !m.awardsChecked) && m.detailError !== '404'; });
if (!pending.length) { report('done', '所有作品信息已完整,无需补全'); return; }
queue.running = true; queue.paused = false; queue.abort = false;
queue.label = refreshAll ? '刷新详情' : '补全信息'; queue.done = 0; queue.total = pending.length;
try {
// 并发池处理:每个 worker 串行 fetch+sleep,worker 数 = queue.concurrency。
// 风控时全局 pause(waitIfPaused 所有 worker 同阻塞),恢复后 worker 内部重试当前条。
await runPool(pending, async function (v) {
// 单条最多重试 3 次(含风控);abort 由 runPool 外层 while 检测
let tries = 0;
while (tries <= 3) {
await waitIfPaused();
if (queue.abort) throw new Error('ABORT');
await sleep(queue.delay());
let doc;
try {
doc = await throttledFetch(v.detailHref || ('/v/' + v.code));
} catch (err) {
if (String(err.message).indexOf('RATELIMIT') === 0) {
report('pause', '疑似触发风控,已自动暂停。稍后点击「继续」重试。');
queue.paused = true; await waitIfPaused();
tries = 0; continue; // 风控恢复后重试,不计入 tries
}
if (String(err.message) === 'LOGIN_REQUIRED') {
report('error', friendlyError(err));
queue.abort = true; throw new Error('ABORT'); // 登录失效:中止整批,已补全部分保留
}
if (String(err.message) === 'HTTP 404') {
v.detailComplete = true; v.detailError = '404';
await dbPutMovie(v);
queue.done++; report('run', v.code + ' (404)');
return; // 404 不再重试
}
tries++;
log('补全失败(' + v.code + ' 第' + tries + '次): ' + err.message);
if (tries < 3) { await sleep(3000 * tries); continue; }
queue.done++; return; // 单条失败跳过
}
const det = parseDetail(doc);
const pageSt = detectPageReviewStatus(doc);
const pageScore = detectPageUserScore(doc);
await applyDetail(v.code, det, pageSt, pageScore);
queue.done++; report('run', v.code);
return;
}
queue.done++; // 重试耗尽,跳过
});
report('done', '补全完成:' + queue.done + ' 部');
} catch (err) {
if (String(err.message) === 'ABORT') report('done', '已取消');
else report('error', '补全失败:' + friendlyError(err));
} finally {
queue.running = false;
}
}
function stop() {
queue.abort = true; queue.paused = false;
if (queue.resumeFn) queue.resumeFn();
// 立即中断当前挂起的 fetch(否则要等 12s 超时,用户感知为「取消没反应」)
if (pendingFetchAbort) {
const r = pendingFetchAbort;
pendingFetchAbort = null;
try { r(new Error('ABORT')); } catch (e) {}
}
}
// 识别演员性别:遍历所有作品里的演员 slug,逐个访问 /actors/{slug},
// 读 .section-meta 文本:含「男優」即男('M'),否则女('F')。结果写 actorGenders 表(slug 级共享)。
// 复用 queue/waitIfPaused/report,可暂停/继续/风控;已识别的跳过。
async function detectActorGenders() {
if (queue.running) return;
const all = await dbAllMovies();
const known = await dbAllActorGenders();
// 收集唯一 slug(来自 movie.actors[].href)
const slugSet = {};
all.forEach(function (m) {
(m.actors || []).forEach(function (a) {
const slug = (a.href || '').match(/\/actors\/([^\/\?\#]+)/);
if (slug && slug[1]) slugSet[slug[1]] = true;
});
});
const pending = Object.keys(slugSet).filter(function (s) { return !known[s]; });
if (!pending.length) { report('done', '演员性别已全部识别,无需补充'); return; }
queue.running = true; queue.paused = false; queue.abort = false;
queue.label = '识别性别'; queue.done = 0; queue.total = pending.length;
try {
// 并发池处理(与 runComplete 一致):worker 数 = CONCURRENCY,风控全局暂停
await runPool(pending, async function (slug) {
let tries = 0;
while (tries <= 3) {
await waitIfPaused();
if (queue.abort) throw new Error('ABORT');
await sleep(queue.delay());
let gender = 'F';
try {
const doc = await throttledFetch('/actors/' + slug);
// 注意:页面存在多个 .section-meta(含主名 + 副标题「男優, NNNN 部影片」)。
// 必须用 querySelectorAll 拼接所有 section-meta 文本再判「男優」。
const metas = doc.querySelectorAll('.section-meta');
let txt = '';
metas.forEach(function (m) { txt += (m.textContent || '') + ' '; });
gender = /男優/.test(txt) ? 'M' : 'F';
} catch (err) {
if (String(err.message).indexOf('RATELIMIT') === 0) {
report('pause', '疑似触发风控,已自动暂停。稍后点击「继续」重试。');
queue.paused = true; await waitIfPaused();
tries = 0; continue; // 风控恢复后重试,不计入 tries
}
if (String(err.message) === 'LOGIN_REQUIRED') {
report('error', friendlyError(err));
queue.abort = true; throw new Error('ABORT');
}
tries++;
log('识别性别失败(' + slug + ' 第' + tries + '次): ' + err.message);
if (tries < 3) { await sleep(3000 * tries); continue; }
queue.done++; return; // 单条失败跳过
}
await dbPutActorGender(slug, gender);
queue.done++; report('run', (gender === 'M' ? '男 ' : '女 ') + slug);
return;
}
queue.done++; // 重试耗尽,跳过
});
report('done', '性别识别完成:' + queue.done + ' 位');
} catch (err) {
if (String(err.message) === 'ABORT') report('done', '已取消');
else report('error', '性别识别失败:' + friendlyError(err));
} finally {
queue.running = false;
}
}
/* ---------- 同步想看/看過(专属页面快速通道,v7.129) ----------
* JAVDB 提供两个专属页面:/users/want_watch_videos 与 /users/watched_videos,
* 卡片结构与清单页同款(复用 parseListPage),分页参数 cp=N。
* 两页通常几秒抓完,代替“逐个访问详情页”获取标记状态。 */
function findReviewNextPage(doc, curUrl, base) {
// 优先常规 pagination-next;兜底解析同路径 cp=N 链接(单页时 nav 为空 → 无下一页)
const nx = doc.querySelector('a.pagination-next, .pagination-next');
if (nx && !nx.classList.contains('is-disabled')) {
const h = nx.getAttribute('href') || '';
if (h) return h.charAt(0) === '/' ? h : ('/' + h);
}
const curM = curUrl.match(/cp=(\d+)/);
const cur = curM ? parseInt(curM[1], 10) : 1;
let max = 0;
doc.querySelectorAll('a[href*="cp="]').forEach(function (a) {
const m = (a.getAttribute('href') || '').match(/cp=(\d+)/);
if (m) max = Math.max(max, parseInt(m[1], 10));
});
return max > cur ? (base + '?cp=' + (cur + 1)) : null;
}
async function fetchReviewVideos(kind) { // kind: 'want' | 'watched'
const base = kind === 'want' ? '/users/want_watch_videos' : '/users/watched_videos';
const all = []; const seen = {};
let url = base, guard = 0;
while (url && guard++ < 50) {
if (guard > 1) await sleep(queue.delay());
await waitIfPaused();
if (queue.abort) throw new Error('ABORT');
let doc = null, tries = 0;
while (tries < 3 && !doc) {
try { doc = await throttledFetch(url); }
catch (err) {
if (String(err.message).indexOf('RATELIMIT') === 0) {
report('pause', '疑似触发风控,已自动暂停。稍后点击「继续」重试。');
queue.paused = true; await waitIfPaused();
if (queue.abort) throw new Error('ABORT');
continue;
}
if (String(err.message) === 'LOGIN_REQUIRED') throw err;
tries++;
log('标记页抓取失败(' + url + ' 第' + tries + '次): ' + err.message);
if (tries < 3) await sleep(3000 * tries);
}
}
if (!doc) throw new Error('标记页连续失败:' + url);
const r = parseListPage(doc);
r.items.forEach(function (it) { if (it.code && !seen[it.code]) { seen[it.code] = 1; all.push(it); } });
url = findReviewNextPage(doc, url, base);
}
return all;
}
async function syncReviewStatus() {
if (queue.running) return;
queue.running = true; queue.paused = false; queue.abort = false;
queue.label = '同步标记'; queue.done = 0; queue.total = 3;
try {
report('run', '获取想看清单…');
const wants = await fetchReviewVideos('want');
queue.done = 1; report('run', '获取看過清单…');
const watcheds = await fetchReviewVideos('watched');
queue.done = 2; report('run', '写库…');
const localMovies = await dbAllMovies();
const localMap = {}; localMovies.forEach(function (m) { localMap[m.code] = m; });
const wantSet = {}, watchedSet = {};
wants.forEach(function (it) { wantSet[it.code] = it; });
watcheds.forEach(function (it) { watchedSet[it.code] = it; });
const toPut = [], toDelete = [];
function upsert(it, status) {
let v = localMap[it.code];
if (!v) {
v = newMovieRecord(it); // 仅标记作品也入库(与拦截器语义一致),可在收藏夹按想看/看過筛出
localMap[it.code] = v;
}
v.reviewStatus = status;
v.reviewSyncedAt = Date.now();
// 顺路刷新列表页可见字段(不覆盖详情字段,与同步清单的合并策略一致)
if (it.title) v.title = it.title;
if (it.date) v.date = it.date;
if (it.coverThumb) v.coverThumb = it.coverThumb;
if (it.detailHref) v.detailHref = it.detailHref;
// 若标记从看過变为想看,清除已有的个人评分;若保持看過,严格保留本地原有的 userScore
if (status !== 'watched') {
delete v.userScore;
}
toPut.push(v);
}
wants.forEach(function (it) { upsert(it, 'wishlist'); });
watcheds.forEach(function (it) { upsert(it, 'watched'); });
// 线上已取消标记:两份清单都不在 → 清除状态;若同时无清单归属 → 移除记录(与拦截器语义一致)
localMovies.forEach(function (m) {
if (m.reviewStatus && !wantSet[m.code] && !watchedSet[m.code]) {
if ((m.listIds || []).length) {
m.reviewStatus = null;
delete m.userScore;
m.reviewSyncedAt = Date.now();
toPut.push(m);
} else {
toDelete.push(m.code);
}
}
});
await dbPutMovies(toPut);
for (let i = 0; i < toDelete.length; i++) await dbDeleteMovie(toDelete[i]);
try {
document.dispatchEvent(new CustomEvent('emby-fav-changed', { bubbles: true }));
window.dispatchEvent(new CustomEvent('emby-fav-changed', { bubbles: true }));
if (typeof refreshFavStatus === 'function') refreshFavStatus();
} catch (_) {}
report('done', '标记同步完成:想看 ' + wants.length + ' 部、看過 ' + watcheds.length +
' 部(取消 ' + (toDelete.length) + ' 条仅标记记录)');
} catch (err) {
if (String(err.message) === 'ABORT') report('done', '已取消');
else { log('标记同步失败: ' + err.message); report('error', '标记同步失败:' + friendlyError(err) + '(本地数据未改动)'); }
} finally {
queue.running = false;
}
}
// 详情写库;
// pageStatus = 本次抓取页面的想看/看過标记(null=未标记,如实覆盖库内状态)
// pageUserScore = 本次抓取页面的用户个人评分(1-5,null=未评分)
async function applyDetail(code, det, pageStatus, pageUserScore) {
const v = await dbGetMovie(code);
if (!v) return;
if (det && det.code) {
v.duration = det.duration; v.director = det.director;
v.maker = det.maker; v.series = det.series;
v.rating = det.rating; v.categories = det.categories;
v.actors = det.actors;
v.awards = det.awards || [];
v.awardsChecked = true;
if (det.title) v.title = det.title;
if (det.date) v.date = det.date;
if (det.coverLarge) v.coverLarge = det.coverLarge;
v.reviewStatus = pageStatus || null;
if (pageStatus === 'watched') {
if (typeof pageUserScore === 'number' && pageUserScore > 0) {
v.userScore = pageUserScore;
}
} else {
delete v.userScore;
}
v.reviewSyncedAt = Date.now();
v.detailComplete = true; v.detailError = null; v.detailSyncedAt = Date.now();
} else {
v.detailError = 'parse_error';
}
await dbPutMovie(v);
}
// 从详情页 DOM 识别当前 JAVDB 标记状态(兼容 .review-title 与 原生操作按钮各种状态)
function detectPageReviewStatus(root) {
if (!root) return null;
// 1. 原生已标记标题栏文本识别(.review-title,最常见)
const t = root.querySelector('.review-title');
if (t) {
const s = (t.textContent || '').replace(/\s+/g, '');
if (/我想看這部影片/.test(s)) return 'wishlist';
if (/我看過這部影片/.test(s)) return 'watched';
}
// 2. 原生按钮激活态/取消标记态文本识别(兼容移动端/折叠版/不同版本的 JAVDB 按钮形态)
const btns = root.querySelectorAll('a.button, button, a[data-action], button[data-action]');
for (let i = 0; i < btns.length; i++) {
const btn = btns[i];
const text = (btn.textContent || '').replace(/\s+/g, '');
const cls = (btn.className || '') + ' ' + (btn.getAttribute('data-active') || '');
const isActive = /(^|\s)(is-active|active|on)(\s|$)/.test(cls);
if (/取消想看|已想看/.test(text) || (isActive && /想看|wish/i.test(text + cls))) return 'wishlist';
if (/取消看過|已看過/.test(text) || (isActive && /看過|watched/i.test(text + cls))) return 'watched';
}
return null;
}
// 从详情页 DOM 识别当前登录用户打出的星级评分(1-5 星)
function detectPageUserScore(root) {
if (!root || typeof root.querySelector !== 'function') return null;
// 1. 查找带 checked 属性/状态的评分 input
const checkedInput = root.querySelector('input[name*="[score]"][checked], input[name*="[score]"]:checked, input[name="video_review[score]"][checked]');
if (checkedInput && checkedInput.value) {
const sc = parseInt(checkedInput.value, 10);
if (sc >= 1 && sc <= 5) return sc;
}
// 2. 遍历所有评分单选框
const scoreRadios = root.querySelectorAll('input[type="radio"][name*="score"]');
for (let i = 0; i < scoreRadios.length; i++) {
if (scoreRadios[i].checked || scoreRadios[i].hasAttribute('checked')) {
const sc = parseInt(scoreRadios[i].value, 10);
if (sc >= 1 && sc <= 5) return sc;
}
}
// 3. 原生评分星级激活态容器
const activeStars = root.querySelectorAll('.review-score .is-active, .user-score .is-active, .my-rating .is-active, .score-stars .is-active');
if (activeStars.length >= 1 && activeStars.length <= 5) {
return activeStars.length;
}
return null;
}
// 即时抓取单个番号详情并存库(供快捷标记通道即时自愈入库,免去手动同步与进详情页)
async function fetchAndSaveDetail(code, videoId, status, userScore, initialData) {
if (!code) return null;
try {
let v = await dbGetMovie(code);
if (!v) {
v = newMovieRecord({
code: code,
title: (initialData && initialData.title) || code,
date: (initialData && initialData.date) || '',
coverThumb: (initialData && initialData.coverThumb) || '',
detailHref: videoId ? ('/v/' + videoId) : ('/v/' + code)
});
}
if (status) v.reviewStatus = status;
if (typeof userScore === 'number' && userScore > 0) v.userScore = userScore;
v.reviewSyncedAt = Date.now();
if (videoId && (!v.detailHref || v.detailHref.indexOf('/search') !== -1)) {
v.detailHref = '/v/' + videoId;
}
await dbPutMovie(v);
// 如果该作品尚未补全详情,或需要同步最新元数据,在后台即时拉取 /v/{videoId}
let targetVid = videoId || (v.detailHref || '').match(/\/v\/([^/?#]+)/)?.[1];
if (!targetVid && code) {
try {
const sDoc = await throttledFetch('/search?q=' + encodeURIComponent(code) + '&f=all');
if (sDoc) {
const firstA = sDoc.querySelector('.movie-list .item a.box, a[href^="/v/"]');
if (firstA) {
const m = (firstA.getAttribute('href') || '').match(/\/v\/([^/?#]+)/);
if (m) {
targetVid = m[1];
v.detailHref = '/v/' + targetVid;
}
}
}
} catch (se) {}
}
if (targetVid) {
const doc = await throttledFetch('/v/' + targetVid);
if (doc) {
const det = parseDetail(doc);
if (det && det.code) {
v.duration = det.duration || v.duration;
v.director = det.director || v.director;
v.maker = det.maker || v.maker;
v.series = det.series || v.series;
v.categories = det.categories || v.categories;
v.actors = det.actors || v.actors;
v.awards = det.awards || v.awards || [];
v.awardsChecked = true;
if (det.title) v.title = det.title;
if (det.date) v.date = det.date;
if (det.coverLarge) {
v.coverLarge = det.coverLarge;
if (!v.coverThumb) v.coverThumb = det.coverLarge;
}
if (det.rating) v.rating = det.rating;
v.detailComplete = true;
v.detailError = null;
v.detailSyncedAt = Date.now();
}
// 抓取页面可能存在的 reviewId
const delLink = doc.querySelector('a[data-method="delete"][href*="/reviews/"]');
if (delLink) {
const rMatch = delLink.href.match(/\/reviews\/(\d+)/);
if (rMatch && rMatch[1]) v.reviewId = rMatch[1];
}
// 如果页面上有官方标记状态且本地未定,自动校准
const pageSt = detectPageReviewStatus(doc.querySelector('.video-detail') || doc);
if (pageSt) v.reviewStatus = pageSt;
if (pageSt === 'watched' && !v.userScore) {
const pageScore = detectPageUserScore(doc.querySelector('.video-detail') || doc);
if (typeof pageScore === 'number' && pageScore > 0) v.userScore = pageScore;
}
await dbPutMovie(v);
log('即时补全番号详情成功: ' + code);
}
}
try {
document.dispatchEvent(new CustomEvent('emby-fav-changed', { bubbles: true, detail: { code: code, movie: v } }));
} catch (e) {}
return v;
} catch (err) {
log('即时抓取番号详情失败(' + code + '): ' + err.message);
return null;
}
}
/* =======================================================================
* 元数据自定义纠错与自愈引擎 (v7.308)
* ===================================================================== */
function tryAutoHealCustomMeta(v, det) {
if (!v || !v.customMeta || !det) return false;
let healed = false;
const cm = v.customMeta;
// 1. 演员自愈
if (cm.actors) {
const offActorNames = new Set((det.actors || []).map(function (a) { return a.name; }));
if (Array.isArray(cm.actors.added)) {
const beforeLen = cm.actors.added.length;
cm.actors.added = cm.actors.added.filter(function (a) { return !offActorNames.has(a.name); });
if (cm.actors.added.length !== beforeLen) healed = true;
}
if (Array.isArray(cm.actors.deleted)) {
const beforeLen = cm.actors.deleted.length;
cm.actors.deleted = cm.actors.deleted.filter(function (name) { return offActorNames.has(name); });
if (cm.actors.deleted.length !== beforeLen) healed = true;
}
}
// 2. 类别自愈
if (cm.categories) {
const offCatTexts = new Set((det.categories || []).map(function (c) { return c.text; }));
if (Array.isArray(cm.categories.added)) {
const beforeLen = cm.categories.added.length;
cm.categories.added = cm.categories.added.filter(function (c) { return !offCatTexts.has(c.text); });
if (cm.categories.added.length !== beforeLen) healed = true;
}
if (Array.isArray(cm.categories.deleted)) {
const beforeLen = cm.categories.deleted.length;
cm.categories.deleted = cm.categories.deleted.filter(function (txt) { return offCatTexts.has(txt); });
if (cm.categories.deleted.length !== beforeLen) healed = true;
}
}
// 3. 导演、片商、系列自愈
if (cm.director && det.director && cm.director.text === det.director.text) {
delete cm.director;
healed = true;
}
if (cm.maker && det.maker && cm.maker.text === det.maker.text) {
delete cm.maker;
healed = true;
}
if (cm.series && det.series && cm.series.text === det.series.text) {
delete cm.series;
healed = true;
}
// 若所有纠错项已被官方全部修正吸收,自动清除 customMeta
const hasActors = cm.actors && ((cm.actors.added && cm.actors.added.length > 0) || (cm.actors.deleted && cm.actors.deleted.length > 0));
const hasCategories = cm.categories && ((cm.categories.added && cm.categories.added.length > 0) || (cm.categories.deleted && cm.categories.deleted.length > 0));
if (!hasActors && !hasCategories && !cm.director && !cm.maker && !cm.series) {
delete v.customMeta;
healed = true;
}
return healed;
}
function getEffectiveMovie(m) {
if (!m) return m;
if (!m.customMeta) return m;
const cm = m.customMeta;
const clone = Object.assign({}, m);
if (cm.actors) {
let baseActors = (m.actors || []).map(function (a) { return Object.assign({}, a); });
const delSlugs = new Set(cm.actors.deleted || cm.actors.deletedSlugs || []);
baseActors = baseActors.filter(function (a) {
return !delSlugs.has(a.name) && !delSlugs.has(a.slug);
});
if (Array.isArray(cm.actors.added)) {
cm.actors.added.forEach(function (ca) {
if (!baseActors.some(function (x) { return x.name === ca.name; })) {
baseActors.push(Object.assign({}, ca, { isCustom: true }));
}
});
}
clone.actors = baseActors;
}
if (cm.categories) {
let baseCats = (m.categories || []).map(function (c) { return Object.assign({}, c); });
const delCats = new Set(cm.categories.deleted || []);
baseCats = baseCats.filter(function (c) { return !delCats.has(c.text); });
if (Array.isArray(cm.categories.added)) {
cm.categories.added.forEach(function (cc) {
if (!baseCats.some(function (x) { return x.text === cc.text; })) {
baseCats.push(Object.assign({}, cc, { isCustom: true }));
}
});
}
clone.categories = baseCats;
}
if (cm.series) clone.series = Object.assign({}, cm.series);
if (cm.director) clone.director = Object.assign({}, cm.director);
if (cm.maker) clone.maker = Object.assign({}, cm.maker);
return clone;
}
// 对比库记录与网页详情,返回有变化的人类可读字段(演员按 href 比名,支持改名)
function diffDetail(v, det) {
const changes = [];
function diff(name, oldV, newV) {
if (newV && oldV !== newV) changes.push(name);
}
diff('标题', v.title, det.title);
diff('日期', v.date, det.date);
diff('時長', v.duration, det.duration);
diff('導演', v.director && v.director.text, det.director && det.director.text);
diff('片商', v.maker && v.maker.text, det.maker && det.maker.text);
diff('系列', v.series && v.series.text, det.series && det.series.text);
if (det.rating && det.rating.score && (!v.rating || v.rating.score !== det.rating.score)) changes.push('評分');
if (det.awards && JSON.stringify(v.awards || []) !== JSON.stringify(det.awards)) changes.push('金标');
// 演员改名:同一 href(演员页)名字不同即修正
const oldActors = v.actors || [];
(det.actors || []).forEach(function (na) {
const oa = oldActors.filter(function (a) { return a.href === na.href; })[0];
if (oa && oa.name !== na.name) changes.push('演员「' + oa.name + ' → ' + na.name + '」');
});
if (det.coverLarge && v.coverLarge !== det.coverLarge) changes.push('封面');
return changes;
}
// 把网页详情字段写进记录(applyDetail 的公共部分)
function fillDetailFromPage(v, det) {
if (!det || !det.code) return false;
v.duration = det.duration; v.director = det.director;
v.maker = det.maker; v.series = det.series;
v.rating = det.rating; v.categories = det.categories;
v.actors = det.actors;
v.awards = det.awards || [];
v.awardsChecked = true;
if (det.title) v.title = det.title;
if (det.date) v.date = det.date;
if (det.coverLarge) v.coverLarge = det.coverLarge;
v.detailComplete = true; v.detailError = null; v.detailSyncedAt = Date.now();
return true;
}
// 收藏状态查询:详情页收藏图标用(在库 + 所属清单 + 标记状态)
async function getFavStatus(code) {
if (!code) return { inFav: false, listIds: [], lists: [], reviewStatus: null };
const v = await dbGetMovie(code);
if (!v) return { inFav: false, listIds: [], lists: [], reviewStatus: null };
const listIds = v.listIds || [];
let lists = [];
if (listIds.length) {
try {
const all = await dbAllLists();
lists = listIds.map(function (id) {
const l = all.filter(function (x) { return x.id === id; })[0];
return { id: id, name: l ? l.name : ('清单' + id) };
});
} catch (e) {}
}
return { inFav: listIds.length > 0, listIds: listIds, lists: lists, reviewStatus: v.reviewStatus || null };
}
// 被动采集:详情页 DOM 顺路解析(零请求)。
// 数据保鲜:只要该番号在库,就用页面上的最新数据覆盖详情字段——
// 演员改名/片商补录/评分变化等,都会在你日常浏览时自动跟上,无需重抓。
// 同时同步 JAVDB 标记状态(想看/看過)并实时提示差异修正。
async function passiveCollect() {
try {
const root = document.querySelector('.video-detail');
if (!root) return;
const det = parseDetail(document);
if (!det.code) return;
const pageStatus = detectPageReviewStatus(root);
const pathIdMatch = location.pathname.match(/\/v\/([^/?#]+)/);
const pageMovieId = pathIdMatch ? pathIdMatch[1] : '';
let v = await dbGetMovie(det.code);
// 若当前页面已存在存入清单弹窗且包含清单项,顺路执行一次真实云端状态实时校准
try {
const saveListModal = document.querySelector('#modal-save-list, .modal-save-list, [id*="save-list"], [id*="save_list"]');
if (saveListModal && saveListModal.querySelector('input[type="checkbox"]')) {
await syncWithSaveListModal(saveListModal);
v = await dbGetMovie(det.code);
}
} catch (e) {}
if (!v) {
// 不在库:绝不自动建记录入收藏夹(纯浏览会污染收藏夹)。
// 但存入 TOP250 独立缓存,供 TOP250 与榜单复用
try {
if (typeof Top250ViewManager !== 'undefined' && typeof Top250ViewManager.cacheMovieDetail === 'function') {
const coverUrl = det.coverLarge ? FAV.resolveImg(det.coverLarge) : '';
const pageRecord = {
code: det.code,
title: det.title,
date: det.date,
duration: det.duration,
coverLarge: coverUrl,
coverThumb: coverUrl,
detailHref: location.pathname,
rating: det.rating,
actors: det.actors,
maker: det.maker,
series: det.series,
director: det.director,
categories: det.categories,
awards: det.awards,
detailComplete: true,
detailSyncedAt: Date.now()
};
await Top250ViewManager.cacheMovieDetail(pageRecord, pageMovieId);
const toastKey = 't250_collected_' + det.code;
if (!sessionStorage.getItem(toastKey)) {
sessionStorage.setItem(toastKey, '1');
// 仅当作品确属 TOP250 榜单(含有 awards 奖项金标)时才展示此提示,避免普通未收藏作品浏览时弹窗误导
if (pageRecord.awards && pageRecord.awards.length > 0 && typeof showToast === 'function') {
showToast('🏆 已自动采集并补充 ' + det.code + ' 的作品详情!');
}
}
}
} catch (err) {
log('TOP250 独立缓存写入异常: ' + err.message);
}
return;
}
const changes = diffDetail(v, det);
if (v.customMeta) {
const healed = tryAutoHealCustomMeta(v, det);
if (healed) changes.push('官方更新自愈合流');
}
// 真实标记状态对比(包含 增加 / 变更 / 取消标记):
// 仅在登录且操作区正常渲染时,线上状态才具有权威性,杜绝未登录或 DOM 缺失误清标记
const loggedIn = isJavdbUserLoggedIn();
const hasActionArea = !!root.querySelector('.review-buttons, .panel-block .buttons, #save-list-button, .user-actions, .review-title, a[href*="/reviews"]');
const oldStatus = v.reviewStatus || null;
if (loggedIn && hasActionArea) {
if (pageStatus && oldStatus !== pageStatus) {
v.reviewStatus = pageStatus;
v.reviewSyncedAt = Date.now();
changes.push(pageStatus === 'watched' ? '标记状态→看過' : '标记状态→想看');
} else if (!pageStatus && oldStatus) {
// 防误清守卫:仅当明确检测到未激活的原生想看/看过按钮且无 review-title 时,才视为取消标记
const activeMarkBtn = root.querySelector('.review-buttons a.is-active, .review-buttons button.is-active, a[data-action*="unwatch"], a[data-action*="unwish"]');
const reviewTitle = root.querySelector('.review-title');
if (!activeMarkBtn && !reviewTitle) {
v.reviewStatus = null;
delete v.userScore;
v.reviewSyncedAt = Date.now();
changes.push(oldStatus === 'watched' ? '标记状态→取消看過' : '标记状态→取消想看');
}
}
// 用户评分实时同步与校准
if (v.reviewStatus === 'watched') {
const pageScore = detectPageUserScore(root);
if (typeof pageScore === 'number' && pageScore > 0 && v.userScore !== pageScore) {
v.userScore = pageScore;
changes.push('用户评分→' + pageScore + '星');
}
} else if (v.userScore != null) {
delete v.userScore;
changes.push('清除用户评分');
}
}
// 严格遵循收藏夹保留黄金准则:
// 既不在任何清单中,又没有任何想看/看過标记、自定义纠错(customMeta)或用户备注(notes) → 彻底从本地数据库删除!
const hasList = (v.listIds || []).length > 0;
const hasStatus = !!v.reviewStatus;
const hasCustomMeta = !!v.customMeta;
const hasNotes = !!(v.notes && typeof v.notes === 'string' && v.notes.trim());
if (!hasList && !hasStatus && !hasCustomMeta && !hasNotes) {
// 重新读取最新数据,防止并发操作导致快照滞后误删
const latest = await dbGetMovie(det.code);
if (latest && !(latest.listIds || []).length && !latest.reviewStatus && !latest.customMeta && !(latest.notes && typeof latest.notes === 'string' && latest.notes.trim())) {
await dbDeleteMovie(det.code);
log('被动采集:标记已取消且无清单归属与自定义数据,自动从本地数据库移除: ' + det.code);
try {
if (typeof showToast === 'function') {
showToast('已根据网页修正 ' + det.code + ':' + (changes.length ? changes.join('、') + ',已从收藏夹移除' : '已从收藏夹移除'));
}
} catch (e) {}
try { if (typeof refreshFavStatus === 'function') refreshFavStatus(); } catch (e) {}
try { document.dispatchEvent(new CustomEvent('emby-fav-changed', { bubbles: true, detail: { code: det.code } })); } catch (e) {}
return;
}
}
if (!changes.length) {
if (pageStatus && !v.reviewSyncedAt) { v.reviewSyncedAt = Date.now(); await dbPutMovie(v); }
try {
if (typeof Top250ViewManager !== 'undefined' && typeof Top250ViewManager.cacheMovieDetail === 'function') {
await Top250ViewManager.cacheMovieDetail(v, pageMovieId);
}
} catch (e) {}
return; // 无差异,无需写库/提示
}
fillDetailFromPage(v, det);
if (pageStatus) v.reviewSyncedAt = Date.now();
await dbPutMovie(v);
try {
if (typeof Top250ViewManager !== 'undefined' && typeof Top250ViewManager.cacheMovieDetail === 'function') {
await Top250ViewManager.cacheMovieDetail(v, pageMovieId);
}
} catch (e) {}
log('被动采集修正: ' + det.code + ' → ' + changes.join('、'));
try { if (typeof showToast === 'function') showToast('已根据网页修正 ' + det.code + ':' + changes.join('、')); } catch (e) {}
try { if (typeof refreshFavStatus === 'function') refreshFavStatus(); } catch (e) {}
try { document.dispatchEvent(new CustomEvent('emby-fav-changed', { bubbles: true, detail: { code: det.code } })); } catch (e) {}
} catch (e) { log('被动采集异常: ' + e.message); }
}
/* ---------- 存入清单即时入库(拦截网络请求,最准确) ----------
* 用户点「存入清单」并指定清单时,javdb 会发写入型请求(POST/PUT/PATCH,URL 含 list)。
* 脚本拦截该请求:番号从当前详情页 URL(/v/CODE)直接取,清单 id 从请求体解析;
* 命中即把该番号(含当前页完整详情)即时写库 → 收藏夹无需同步立即出现且信息完整。
* 支持反复取消/同时加多个清单:每次请求独立处理,add 写、remove 删对应 listId。 */
let interceptInstalled = false;
// 已知清单 id 命名(保守匹配):排除收藏他人清单与删除整个清单动作
const LIST_REMOVE_RX = /favorite_lists/i;
function currentDetailCode() {
// JAVDB 详情页 URL 是编码 ID(如 /v/r37kRv),不是番号;真实番号在 .panel-block.first-block .value 或 DOM 标题中。
try {
const root = document.getElementById('emby-detail-root');
if (root && root.dataset.code) return root.dataset.code;
const sticky = document.getElementById('emby-sticky-bar');
if (sticky && sticky.dataset.code) return sticky.dataset.code;
const fb = document.querySelector('.video-detail .panel-block.first-block .value, .panel-block.first-block .value');
if (fb) {
const c = (fb.textContent || '').trim().replace(/\s+/g, '');
if (c) return c;
}
// 从标题或 H1/H2 提取标准番号(例如 DLDSS-531、SSIS-500、FC2-PPV-123456)
const h = document.querySelector('.video-detail h2, h2.title, .emby-hero-title h1, h1');
if (h) {
const m = h.textContent.match(/([A-Za-z0-9]+(?:-[A-Za-z0-9]+)+)/);
if (m) return m[1];
}
const docTitle = (document.title || '').match(/([A-Za-z0-9]+(?:-[A-Za-z0-9]+)+)/);
if (docTitle) return docTitle[1];
} catch (e) {}
const m = (location.pathname || '').match(/^\/v\/([^\/\?\#]+)/);
return m ? m[1] : null;
}
function parseListIdsFromText(text) {
if (!text) return null;
const ids = [];
// 优先尝试标准 JSON 解析(批量加清单请求如 {"list_ids":["abc","def"]})
try {
const parsed = JSON.parse(text);
if (parsed && typeof parsed === 'object') {
if (parsed.list_id) ids.push(String(parsed.list_id));
if (Array.isArray(parsed.list_ids)) {
parsed.list_ids.forEach(function (id) { if (id) ids.push(String(id)); });
}
if (Array.isArray(parsed['list_ids[]'])) {
parsed['list_ids[]'].forEach(function (id) { if (id) ids.push(String(id)); });
}
if (ids.length) return Array.from(new Set(ids));
}
} catch (e) {}
// 兼容 form / url-encoded / rails 风格: list_id=.. / list_ids[]=.. / list_ids=..
const formRe = /(?:^|[&?,\s"'])list_ids?(?:\[\])?=([A-Za-z0-9_-]+)/gi;
let mm;
while ((mm = formRe.exec(text)) !== null) {
if (mm[1]) ids.push(mm[1]);
}
if (!ids.length) {
const re = /(?:^|[^a-z0-9_])list_ids?[_\[\]]*["']?\s*[:=]\s*["']?([A-Za-z0-9_-]+)/gi;
while ((mm = re.exec(text)) !== null) {
if (mm[1]) ids.push(mm[1]);
}
}
if (ids.length) return Array.from(new Set(ids));
// 退路:整段就是单个 id
const single = text.trim().match(/^[A-Za-z0-9_-]{3,}$/);
return single ? [single[0]] : null;
}
function isListWrite(url, method) {
if (!/list/i.test(url || '')) return false;
const m = (method || 'POST').toUpperCase();
if (m === 'GET') return false; // 排除 GET 浏览请求
if (LIST_REMOVE_RX.test(url)) return false; // 排除收藏他人清单
return /^(POST|PUT|PATCH|DELETE)$/.test(m);
}
async function handleListWrite(url, bodyText, method) {
if (!isJavdbUserLoggedIn()) return; // 严格守卫:未登录不将清单数据写入本地库
const code = currentDetailCode();
if (!code) return; // 不在详情页,跳过
const listIds = parseListIdsFromText(bodyText);
if (!listIds || !listIds.length) return;
// save_video_to_list 用 checked 区分加入/移出(body: {"video_id":..,"checked":false,"list_id":..}),
// checked=false 或 checked=0 必须视为移出,否则取消勾选会被误写成“加入该清单”。
const isRemove = /^(delete)$/i.test(method) || /remove/.test(url) || /remove/.test(bodyText || '') ||
/checked["']?\s*[:=]\s*(false|0|"false"|'false')/i.test(bodyText || '');
// 写库(读现存记录,没有则现场建一条含当前详情)
let v = await dbGetMovie(code);
const det = parseDetail(document); // 当前页 DOM 顺手解析,零额外请求
const hasDetail = det && det.code === code;
if (!v) {
if (isRemove) return; // 库里没有且是移除,没意义
v = newMovieRecord({
code: code,
title: hasDetail ? det.title : '',
date: hasDetail ? det.date : '',
coverThumb: hasDetail ? det.coverLarge : '',
detailHref: (/^\/v\//.test(location.pathname || '') ? location.pathname : ('/v/' + code))
});
}
const before = (v.listIds || []).slice();
if (isRemove) {
v.listIds = v.listIds.filter(function (id) { return listIds.indexOf(String(id)) === -1; });
} else {
const set = {};
(v.listIds || []).forEach(function (id) { set[id] = 1; });
listIds.forEach(function (id) { set[id] = 1; });
v.listIds = Object.keys(set);
}
if (v.listIds.join(',') === before.join(',')) return; // 无变化
// 页面(JAVDB 线上)标记状态:移出清单时若页面上仍有「想看/看過」,本地记录必须保留
const pageStatus = detectPageReviewStatus(document.querySelector('.video-detail'));
if (pageStatus) {
v.reviewStatus = pageStatus;
if (pageStatus === 'watched') {
const sc = detectPageUserScore(document.querySelector('.video-detail'));
if (typeof sc === 'number' && sc > 0) v.userScore = sc;
} else {
delete v.userScore;
}
v.reviewSyncedAt = Date.now();
}
// 移出所有清单且无想看/看過标记、自定义纠错与备注 → 自动删除本地记录(避免收藏夹残留无归属作品)
const hasCustomMeta = !!v.customMeta;
const hasNotes = !!(v.notes && typeof v.notes === 'string' && v.notes.trim());
if (isRemove && !v.listIds.length && !v.reviewStatus && !hasCustomMeta && !hasNotes) {
const latest = await dbGetMovie(code);
if (latest && !(latest.listIds || []).length && !latest.reviewStatus && !latest.customMeta && !(latest.notes && typeof latest.notes === 'string' && latest.notes.trim())) {
await dbDeleteMovie(code);
log('移出全部清单,自动删除记录: ' + code);
try { if (typeof showToast === 'function') showToast('已移出清单:' + code + '(已从收藏夹移除)'); } catch (e) {}
try { if (typeof refreshFavStatus === 'function') refreshFavStatus(); } catch (e) {}
try { document.dispatchEvent(new CustomEvent('emby-fav-changed', { bubbles: true, detail: { code: code } })); } catch (e) {}
return;
}
}
if (hasDetail) {
v.duration = det.duration; v.director = det.director; v.maker = det.maker; v.series = det.series;
v.rating = det.rating; v.categories = det.categories; v.actors = det.actors;
v.awards = det.awards || [];
v.awardsChecked = true;
if (det.title) v.title = det.title;
if (det.date) v.date = det.date;
if (det.coverLarge) v.coverLarge = det.coverLarge;
v.detailComplete = true; v.detailError = null; v.detailSyncedAt = Date.now();
} else {
v.detailComplete = v.detailComplete || false;
}
v.listSyncedAt = Date.now();
await dbPutMovie(v);
// 若清单 id 在库里还没有,补一条记录,使收藏夹「按清单筛选」可用
try {
const known = (await dbAllLists()).filter(function (l) { return l.type !== 'favorited'; })
.map(function (l) { return String(l.id); });
const missing = listIds.filter(function (id) { return known.indexOf(String(id)) === -1; });
for (let i = 0; i < missing.length; i++) {
await dbPutList({ id: String(missing[i]), name: '清单' + missing[i], type: 'mine', count: null, base: '/users/list_detail?id=' + missing[i] });
}
if (missing.length) log('即时入库:补建未知清单记录 ' + missing.join(','));
} catch (e) {}
log('即时入库[' + (isRemove ? '移出' : '存入') + '] ' + code + ' → 清单 ' + v.listIds.join(','));
try { if (typeof showToast === 'function') showToast((isRemove ? '已移出清单:' : '已加入清单:') + code); } catch (e) {}
try { document.dispatchEvent(new CustomEvent('emby-fav-changed', { bubbles: true, detail: { code: code } })); } catch (e) {}
}
/* ---------- DOM 弹窗复选框级即时入库(直接响应用户在存入清单弹窗中的点击) ---------- */
async function handleModalListCheck(code, listId, listName, isChecked) {
if (!isJavdbUserLoggedIn()) return; // 严格守卫:未登录不将清单数据写入本地库
if (!code || !listId) return;
const isRemove = !isChecked;
let v = await dbGetMovie(code);
const det = parseDetail(document);
const hasDetail = det && det.code === code;
if (!v) {
if (isRemove) return;
v = newMovieRecord({
code: code,
title: hasDetail ? det.title : '',
date: hasDetail ? det.date : '',
coverThumb: hasDetail ? det.coverLarge : '',
detailHref: (/^\/v\//.test(location.pathname || '') ? location.pathname : ('/v/' + code))
});
}
const before = (v.listIds || []).slice();
if (isRemove) {
v.listIds = v.listIds.filter(function (id) { return String(id) !== String(listId); });
} else {
const set = {};
(v.listIds || []).forEach(function (id) { set[id] = 1; });
set[listId] = 1;
v.listIds = Object.keys(set);
}
if (v.listIds.join(',') === before.join(',')) return; // 无变化
const pageStatus = detectPageReviewStatus(document.querySelector('.video-detail'));
if (pageStatus) {
v.reviewStatus = pageStatus;
if (pageStatus === 'watched') {
const sc = detectPageUserScore(document.querySelector('.video-detail'));
if (typeof sc === 'number' && sc > 0) v.userScore = sc;
} else {
delete v.userScore;
}
v.reviewSyncedAt = Date.now();
}
const hasCustomMeta = !!v.customMeta;
const hasNotes = !!(v.notes && typeof v.notes === 'string' && v.notes.trim());
if (isRemove && !v.listIds.length && !v.reviewStatus && !hasCustomMeta && !hasNotes) {
const latest = await dbGetMovie(code);
if (latest && !(latest.listIds || []).length && !latest.reviewStatus && !latest.customMeta && !(latest.notes && typeof latest.notes === 'string' && latest.notes.trim())) {
await dbDeleteMovie(code);
log('DOM移出全部清单,自动删除记录: ' + code);
try { if (typeof showToast === 'function') showToast('已移出清单:' + (listName ? (listName + ' · ') : '') + code); } catch (e) {}
try { if (typeof refreshFavStatus === 'function') refreshFavStatus(); } catch (e) {}
try { document.dispatchEvent(new CustomEvent('emby-fav-changed', { bubbles: true, detail: { code: code } })); } catch (e) {}
return;
}
}
if (hasDetail) {
v.duration = det.duration; v.director = det.director; v.maker = det.maker; v.series = det.series;
v.rating = det.rating; v.categories = det.categories; v.actors = det.actors;
v.awards = det.awards || [];
v.awardsChecked = true;
if (det.title) v.title = det.title;
if (det.date) v.date = det.date;
if (det.coverLarge) v.coverLarge = det.coverLarge;
v.detailComplete = true; v.detailError = null; v.detailSyncedAt = Date.now();
} else {
v.detailComplete = v.detailComplete || false;
}
v.listSyncedAt = Date.now();
await dbPutMovie(v);
// 补充清单基本信息(优先用界面上的真实清单名称,如「日本之外」)
try {
const known = (await dbAllLists()).filter(function (l) { return l.type !== 'favorited'; })
.map(function (l) { return String(l.id); });
if (known.indexOf(String(listId)) === -1) {
const finalName = (listName && listName.length > 0 && listName.length < 50) ? listName : ('清单' + listId);
await dbPutList({ id: String(listId), name: finalName, type: 'mine', count: null, base: '/users/list_detail?id=' + listId });
log('即时入库:补建未知清单记录 ' + finalName + '(' + listId + ')');
}
} catch (e) {}
log('DOM即时入库[' + (isRemove ? '移出' : '存入') + '] ' + code + ' → 清单 ' + v.listIds.join(',') + (listName ? (' (' + listName + ')') : ''));
try { if (typeof showToast === 'function') showToast((isRemove ? '已移出清单:' : '已加入清单:') + (listName ? (listName + ' · ') : '') + code); } catch (e) {}
}
/* ---------- 从复选框元素中多维度稳健提取真实清单 ID ---------- */
function extractListIdFromCheckbox(cb) {
if (!cb) return '';
if (cb.dataset && cb.dataset.listId) return String(cb.dataset.listId);
const dataAttr = cb.getAttribute('data-list-id') || (cb.dataset && cb.dataset.id);
if (dataAttr) return String(dataAttr);
if (cb.id) {
const im = cb.id.match(/list[_-]([A-Za-z0-9_-]+)/i);
if (im && im[1]) return String(im[1]);
}
if (cb.name) {
const nm = cb.name.match(/\[([A-Za-z0-9_-]+)\]/);
if (nm && nm[1] && nm[1] !== 'ids' && nm[1] !== 'list_ids') return String(nm[1]);
}
if (cb.value && cb.value !== 'on' && cb.value !== '1' && cb.value !== '0' && cb.value !== 'true' && cb.value !== 'false') {
return String(cb.value);
}
const container = cb.closest && cb.closest('li, .list-item, label, .field, .control, tr, div');
if (container) {
const cid = (container.dataset && container.dataset.listId) || container.getAttribute('data-list-id') || (container.dataset && container.dataset.id);
if (cid) return String(cid);
}
return '';
}
/* ---------- 存入清单弹窗真实云端状态实时校准(比对线上勾选并自愈/删除) ---------- */
async function syncWithSaveListModal(modal) {
if (!isJavdbUserLoggedIn()) return;
const code = currentDetailCode();
if (!code) return;
if (!modal) modal = document.querySelector('#modal-save-list, .modal-save-list, [id*="save-list"], [id*="save_list"]');
if (!modal) return;
const cbs = Array.from(modal.querySelectorAll('input[type="checkbox"]'));
if (!cbs.length) return; // 弹窗尚未渲染复选框
const checkedListIds = [];
const uncheckedListIds = [];
const listNameMap = {};
cbs.forEach(function (cb) {
const lid = extractListIdFromCheckbox(cb);
if (!lid) return;
const label = (cb.closest && cb.closest('label')) || cb.parentElement;
const name = label ? label.textContent.trim().replace(/^[\s\r\n\t]+/, '') : '';
if (name) listNameMap[lid] = name;
if (cb.checked) {
if (checkedListIds.indexOf(lid) === -1) checkedListIds.push(lid);
} else {
if (uncheckedListIds.indexOf(lid) === -1) uncheckedListIds.push(lid);
}
});
if (!checkedListIds.length && !uncheckedListIds.length) return;
let v = await dbGetMovie(code);
if (v) {
const oldListIds = (v.listIds || []).map(String);
// 1. 过滤:线上明确未打勾的清单,从本地彻底移出!
let updatedListIds = oldListIds.filter(function (id) {
return uncheckedListIds.indexOf(id) === -1;
});
// 2. 补录:线上已打勾但本地没有的清单,合并到本地!
checkedListIds.forEach(function (id) {
if (updatedListIds.indexOf(id) === -1) updatedListIds.push(id);
});
const pageStatus = detectPageReviewStatus(document.querySelector('.video-detail'));
if (pageStatus) {
v.reviewStatus = pageStatus;
if (pageStatus === 'watched') {
const sc = detectPageUserScore(document.querySelector('.video-detail'));
if (typeof sc === 'number' && sc > 0) v.userScore = sc;
} else {
delete v.userScore;
}
v.reviewSyncedAt = Date.now();
}
const hasList = updatedListIds.length > 0;
const hasStatus = !!(v.reviewStatus || pageStatus);
const hasCustomMeta = !!v.customMeta;
const hasNotes = !!(v.notes && typeof v.notes === 'string' && v.notes.trim());
// 3. 严格遵循黄金准则:既不在任何清单中,又没有任何想看/看过标记、自定义纠错与备注 → 彻底物理删除!
if (!hasList && !hasStatus && !hasCustomMeta && !hasNotes) {
const latest = await dbGetMovie(code);
if (latest && !(latest.listIds || []).length && !latest.reviewStatus && !latest.customMeta && !(latest.notes && typeof latest.notes === 'string' && latest.notes.trim())) {
await dbDeleteMovie(code);
log('[存入清单实时校准] 线上全部清单已取消且无标记与自定义数据,自动从本地数据库彻底清除: ' + code);
try { if (typeof showToast === 'function') showToast('已从收藏夹清除:' + code + '(线上已无任何清单归属)'); } catch (e) {}
try { if (typeof refreshFavStatus === 'function') refreshFavStatus(); } catch (e) {}
try { document.dispatchEvent(new CustomEvent('emby-fav-changed', { bubbles: true, detail: { code: code } })); } catch (e) {}
return;
}
}
// 4. 清单发生增减但有保留理由(有其他清单,或有想看/看过):写回更新
if (updatedListIds.sort().join(',') !== oldListIds.sort().join(',')) {
v.listIds = updatedListIds;
v.listSyncedAt = Date.now();
await dbPutMovie(v);
log('[存入清单实时校准] 清单归属已自动对齐: ' + code + ' → ' + updatedListIds.join(','));
try { if (typeof showToast === 'function') showToast('已根据线上清单状态实时同步:' + code); } catch (e) {}
try { if (typeof refreshFavStatus === 'function') refreshFavStatus(); } catch (e) {}
try { document.dispatchEvent(new CustomEvent('emby-fav-changed', { bubbles: true, detail: { code: code } })); } catch (e) {}
}
} else {
// 库里原本没有(未入库),但弹窗里发现线上存在勾选的清单(如跨设备在手机端加入了清单)
if (checkedListIds.length > 0) {
const det = parseDetail(document);
const newRec = newMovieRecord({
code: code,
title: det ? det.title : '',
date: det ? det.date : '',
coverThumb: det ? det.coverLarge : '',
detailHref: location.pathname
});
newRec.listIds = checkedListIds;
if (det) fillDetailFromPage(newRec, det);
await dbPutMovie(newRec);
log('[存入清单实时校准] 发现线上已有勾选清单,自动补录入库: ' + code + ' → ' + checkedListIds.join(','));
try { if (typeof showToast === 'function') showToast('已自动同步线上已加清单:' + code); } catch (e) {}
try { if (typeof refreshFavStatus === 'function') refreshFavStatus(); } catch (e) {}
try { document.dispatchEvent(new CustomEvent('emby-fav-changed', { bubbles: true, detail: { code: code } })); } catch (e) {}
}
}
}
/* ---------- 想看/看過 标记即时入库(拦截 review 写请求,与清单拦截同源) ---------- */
function isReviewWrite(url, method) {
if (!url || url.indexOf('/reviews') === -1) return false;
if (LIST_REMOVE_RX.test(url)) return false;
return /^(post|put|patch|delete)$/i.test(method || 'POST');
}
function parseReviewStatus(text) {
if (!text) return null;
const s = String(text).replace(/%5B/gi, '[').replace(/%5D/gi, ']');
let m = s.match(/video_review\[status\][^\w]*?([\w]+)/i);
if (!m) m = s.match(/["']status["']\s*:\s*["']([\w]+)["']/i);
if (!m) return null;
const v = m[1].toLowerCase();
if (v === 'watched') return 'watched';
if (v === 'want_watch') return 'wishlist';
return null;
}
function parseReviewScore(text) {
if (!text) return null;
const s = String(text).replace(/%5B/gi, '[').replace(/%5D/gi, ']');
let m = s.match(/video_review\[score\][^\d]*?(\d+)/i);
if (!m) m = s.match(/["']score["']\s*:\s*(\d+)/i);
if (m) {
const sc = parseInt(m[1], 10);
if (sc >= 1 && sc <= 5) return sc;
}
return null;
}
async function handleReviewWrite(url, bodyText, method) {
if (!isJavdbUserLoggedIn()) return; // 严格守卫:未登录不将标记数据写入本地库
const code = currentDetailCode();
if (!code) return; // 不在详情页,跳过
const isDelete = /^(delete)$/i.test(method) || /_method=delete/i.test(bodyText || '');
let status = null, isStatusChange = false;
if (isDelete && /\/reviews\/\d+/.test(url || '')) {
status = null; isStatusChange = true; // 删除 review = 取消标记
} else if (/want_to_watch/.test(url || '')) {
status = 'wishlist'; isStatusChange = true; // 想看直接提交
} else {
const st = parseReviewStatus(bodyText || '');
if (st) { status = st; isStatusChange = true; } // 標記/編輯表单
// 兜底:POST /v/{id}/reviews(无 review id)是「標記我看過」表单;
// 若表单没带 status 也按 watched 处理(该页面上唯一的裸 POST reviews 流程)
else if (/^(post)$/i.test(method) && /\/reviews$/.test(url || '')) { status = 'watched'; isStatusChange = true; }
}
if (!isStatusChange) return; // 普通评分/评论提交不改标记
let v = await dbGetMovie(code);
const det = parseDetail(document);
const hasDetail = det && det.code === code;
if (!v) {
v = newMovieRecord({
code: code,
title: hasDetail ? det.title : '',
date: hasDetail ? det.date : '',
coverThumb: hasDetail ? det.coverLarge : '',
detailHref: (/^\/v\//.test(location.pathname || '') ? location.pathname : ('/v/' + code))
});
}
v.reviewStatus = status;
if (status === 'watched') {
const submittedScore = parseReviewScore(bodyText || '');
if (submittedScore) {
v.userScore = submittedScore;
} else if (!v.userScore) {
const pageScore = detectPageUserScore(document.querySelector('.video-detail') || document);
if (typeof pageScore === 'number' && pageScore > 0) v.userScore = pageScore;
}
} else {
delete v.userScore;
}
v.reviewSyncedAt = Date.now();
if (hasDetail) {
v.duration = det.duration; v.director = det.director; v.maker = det.maker; v.series = det.series;
v.rating = det.rating; v.categories = det.categories; v.actors = det.actors;
v.awards = det.awards || [];
v.awardsChecked = true;
if (det.title) v.title = det.title;
if (det.date) v.date = det.date;
if (det.coverLarge) v.coverLarge = det.coverLarge;
v.detailComplete = true; v.detailError = null; v.detailSyncedAt = Date.now();
}
// 取消标记且不在任何清单 → 自动删除本地记录(不留无归属数据)
if (!status && !(v.listIds || []).length) {
await dbDeleteMovie(code);
} else {
await dbPutMovie(v);
}
log('标记即时入库[' + (status || '取消') + '] ' + code);
try {
if (typeof showToast === 'function') showToast(status
? ('已加入' + (status === 'watched' ? '看過' : '想看') + ':' + code)
: ('已取消标记:' + code));
} catch (e) {}
try { document.dispatchEvent(new CustomEvent('emby-fav-changed', { bubbles: true, detail: { code: code } })); } catch (e) {}
}
function installListIntercept() {
if (interceptInstalled) return; interceptInstalled = true;
// 提取请求体文本(兼容跨 Realm 的 FormData、URLSearchParams、字符串等)
function extractBodyText(body) {
if (!body) return '';
if (typeof body === 'string') return body;
try {
// 鸭子类型检测 FormData(跨 Realm 时 instanceof FormData 会失效)
if (body && typeof body.forEach === 'function' && typeof body.append === 'function') {
const parts = [];
body.forEach(function (v, k) {
parts.push(k + '=' + v);
});
return parts.join('&');
}
} catch (e) {}
try {
if (body && typeof body.toString === 'function') {
const s = body.toString();
if (s && s !== '[object Object]' && s !== '[object Blob]' && s !== '[object FormData]') return s;
}
} catch (e) {}
return '';
}
// 目标环境:同时覆盖油猴沙箱 window 与宿主页面 unsafeWindow
const winTargets = [];
if (typeof window !== 'undefined' && window) winTargets.push(window);
if (typeof unsafeWindow !== 'undefined' && unsafeWindow && unsafeWindow !== window) {
winTargets.push(unsafeWindow);
}
winTargets.forEach(function (targetWin) {
// 1. fetch 拦截
try {
const origFetch = targetWin.fetch ? targetWin.fetch.bind(targetWin) : null;
if (origFetch) {
targetWin.fetch = function (input, init) {
init = init || {};
const url = (typeof input === 'string') ? input : (input && input.url) || '';
const method = (init.method || (input && input.method) || 'GET').toUpperCase();
const req = origFetch(input, init);
try {
const body = init.body;
let txt = extractBodyText(body);
if (isListWrite(url, method) || isReviewWrite(url, method)) {
const doWrite = function (t) {
if (isListWrite(url, method)) handleListWrite(url, t, method);
if (isReviewWrite(url, method)) handleReviewWrite(url, t, method);
};
// 可靠性保障:待服务端响应成功后再写库
if (req && typeof req.then === 'function') {
req.then(function (res) {
if (res && (res.ok || (res.status >= 200 && res.status < 400))) {
if (txt) doWrite(txt);
else if (body && typeof body.text === 'function') {
body.text().then(doWrite).catch(function () {});
}
} else {
log('[存入清单拦截] 服务端响应异常: ' + (res ? res.status : 'unknown'));
}
}).catch(function () {});
} else {
if (txt) doWrite(txt);
}
}
} catch (e) {}
return req;
};
}
} catch (e) {}
// 2. XMLHttpRequest 拦截
try {
if (targetWin.XMLHttpRequest && targetWin.XMLHttpRequest.prototype) {
const origOpen = targetWin.XMLHttpRequest.prototype.open;
targetWin.XMLHttpRequest.prototype.open = function (method, url) {
this.__emby_method = method; this.__emby_url = url;
return origOpen.apply(this, arguments);
};
const origSend = targetWin.XMLHttpRequest.prototype.send;
targetWin.XMLHttpRequest.prototype.send = function (body) {
try {
if (isListWrite(this.__emby_url, this.__emby_method) || isReviewWrite(this.__emby_url, this.__emby_method)) {
const txt = extractBodyText(body);
const u = this.__emby_url, m = this.__emby_method;
this.addEventListener('load', function () {
try {
if (this.status >= 200 && this.status < 400) {
if (isListWrite(u, m)) { handleListWrite(u, txt, m); log('[存入清单拦截] URL=' + u + ' METHOD=' + m); }
if (isReviewWrite(u, m)) handleReviewWrite(u, txt, m);
} else {
log('[存入清单拦截] XHR 返回异常状态码: ' + this.status);
}
} catch (e) {}
}, { once: true });
}
} catch (e) {}
return origSend.apply(this, arguments);
};
}
} catch (e) {}
});
// 3. DOM 级事件双重保险:直接代理监听「存入清單」原生弹窗内的复选框变更事件
try {
document.addEventListener('change', function (e) {
try {
const target = e.target;
if (!target || target.type !== 'checkbox') return;
const modal = target.closest('#modal-save-list, .modal-save-list, [id*="save-list"], [id*="save_list"]');
if (!modal) return;
const isChecked = !!target.checked;
const listId = extractListIdFromCheckbox(target);
const label = target.closest('label') || target.parentElement;
const listName = label ? label.textContent.trim().replace(/^[\s\r\n\t]+/, '') : '';
const code = currentDetailCode();
if (code && listId) {
log('[存入清单DOM拦截] 番号=' + code + ' 清单=' + listName + '(' + listId + ') checked=' + isChecked);
Promise.resolve(handleModalListCheck(code, listId, listName, isChecked)).catch(function (e) {
log('handleModalListCheck 异常: ' + (e && e.message));
});
}
// 实时触发弹窗全量状态比对自愈与删除
Promise.resolve(syncWithSaveListModal(modal)).catch(function (e) {
log('syncWithSaveListModal 异常: ' + (e && e.message));
});
} catch (err) {}
}, true);
// 4. 点击打开「存入清單」按钮或 Emby list 按钮时,自动触发校准
document.addEventListener('click', function (e) {
try {
const trigger = e.target && e.target.closest && e.target.closest('#save-list-button, .save-list-button, .emby-circle-btn[data-type="list"]');
if (trigger) {
setTimeout(function () { Promise.resolve(syncWithSaveListModal()).catch(function () {}); }, 150);
setTimeout(function () { Promise.resolve(syncWithSaveListModal()).catch(function () {}); }, 500);
}
} catch (err) {}
}, true);
// 5. 监听弹窗展开(class 变为 is-active 或 DOM 节点追加)
try {
const modalObserver = new MutationObserver(function (mutations) {
mutations.forEach(function (m) {
if (m.type === 'attributes' && m.attributeName === 'class') {
const target = m.target;
if (target && target.classList && target.classList.contains('is-active')) {
if (target.matches && target.matches('#modal-save-list, .modal-save-list, [id*="save-list"], [id*="save_list"]')) {
Promise.resolve(syncWithSaveListModal(target)).catch(function () {});
setTimeout(function () { Promise.resolve(syncWithSaveListModal(target)).catch(function () {}); }, 300);
}
}
}
});
});
modalObserver.observe(document.body, { attributes: true, subtree: true, attributeFilter: ['class'] });
} catch (err) {}
} catch (e) {}
}
/* ---------- 导入导出 ---------- */
function collectAllConfig(includeCredentialsOpt) {
let includeCredentials = false;
if (typeof includeCredentialsOpt === 'boolean') {
includeCredentials = includeCredentialsOpt;
} else {
try {
includeCredentials = (localStorage.getItem('javdb_backup_include_auth') === 'true');
} catch (e) {}
}
const config = {
version: 1,
exportedAt: new Date().toISOString(),
plugins: {},
settings: {},
playSites: null,
infoSites: null,
auth: includeCredentials ? (localStorage.getItem('jhs_appAuthorization') || '') : ''
};
if (includeCredentials) {
try {
const sign = localStorage.getItem('jb_jdsignature');
if (sign) config.signature = sign;
const wdPass = localStorage.getItem('javdb_webdav_pass');
const wdUser = localStorage.getItem('javdb_webdav_user');
const wdUrl = localStorage.getItem('javdb_webdav_url');
if (wdPass || wdUser || wdUrl) {
config.webdav = { url: wdUrl || '', user: wdUser || '', pass: wdPass || '' };
}
const gitToken = localStorage.getItem('javdb_git_token');
const gitRepo = localStorage.getItem('javdb_git_repo');
const gitBranch = localStorage.getItem('javdb_git_branch');
const gitUrl = localStorage.getItem('javdb_git_api_url');
if (gitToken || gitRepo) {
config.git = { url: gitUrl || '', repo: gitRepo || '', branch: gitBranch || '', token: gitToken || '' };
}
} catch (e) {}
}
try {
PLUGIN_REGISTRY.forEach(function (pl) {
config.plugins[pl.id] = isPluginEnabled(pl.id);
});
} catch (e) {}
try {
Object.keys(SET_KEYS).forEach(function (k) {
const storageKey = SET_KEYS[k];
const val = localStorage.getItem(storageKey);
if (val !== null) config.settings[storageKey] = val;
});
const backupAuthVal = localStorage.getItem('javdb_backup_include_auth');
if (backupAuthVal !== null) config.settings['javdb_backup_include_auth'] = backupAuthVal;
} catch (e) {}
try {
const ps = localStorage.getItem('javdb_play_sites');
if (ps) config.playSites = JSON.parse(ps);
} catch (e) {}
try {
const is = localStorage.getItem('javdb_info_sites');
if (is) config.infoSites = JSON.parse(is);
} catch (e) {}
try {
const prs = localStorage.getItem('javdb_preview_sites');
if (prs) config.previewSites = JSON.parse(prs);
} catch (e) {}
return config;
}
function restoreAllConfig(config) {
if (!config || typeof config !== 'object') return;
try {
if (config.plugins && typeof config.plugins === 'object') {
Object.keys(config.plugins).forEach(function (pid) {
if (pid !== '__proto__' && pid !== 'constructor' && pid !== 'prototype') {
setPluginEnabled(pid, !!config.plugins[pid]);
}
});
}
if (config.settings && typeof config.settings === 'object') {
const allowedSettings = new Set(Object.values(SET_KEYS));
['javdbEmbyConcurrency', 'javdbEmbyGalleryView', 'javdbEmbyGallerySize', 'javdb_preferred_play_site', 'efavSyncSelList', 'javdb_backup_include_auth'].forEach(function (k) {
allowedSettings.add(k);
});
Object.keys(config.settings).forEach(function (sKey) {
if (sKey === '__proto__' || sKey === 'constructor' || sKey === 'prototype') return;
if ((allowedSettings.has(sKey) || (sKey.startsWith('javdbEmby') && /^[a-zA-Z0-9_-]+$/.test(sKey))) && config.settings[sKey] !== undefined) {
try { localStorage.setItem(sKey, String(config.settings[sKey])); } catch (_) {}
}
});
}
if (config.playSites && Array.isArray(config.playSites)) {
savePlaySiteConfigs(config.playSites);
}
if (config.infoSites && Array.isArray(config.infoSites)) {
saveInfoSiteConfigs(config.infoSites);
}
if (config.previewSites && Array.isArray(config.previewSites)) {
savePreviewSiteConfigs(config.previewSites);
}
if (typeof config.auth === 'string' && config.auth) {
localStorage.setItem('jhs_appAuthorization', config.auth);
}
if (typeof config.signature === 'string' && config.signature) {
localStorage.setItem('jb_jdsignature', config.signature);
}
if (config.webdav && typeof config.webdav === 'object') {
if (typeof config.webdav.url === 'string') localStorage.setItem('javdb_webdav_url', config.webdav.url);
if (typeof config.webdav.user === 'string') localStorage.setItem('javdb_webdav_user', config.webdav.user);
if (typeof config.webdav.pass === 'string') localStorage.setItem('javdb_webdav_pass', config.webdav.pass);
}
if (config.git && typeof config.git === 'object') {
if (typeof config.git.url === 'string') localStorage.setItem('javdb_git_api_url', config.git.url);
if (typeof config.git.repo === 'string') localStorage.setItem('javdb_git_repo', config.git.repo);
if (typeof config.git.branch === 'string') localStorage.setItem('javdb_git_branch', config.git.branch);
if (typeof config.git.token === 'string') localStorage.setItem('javdb_git_token', config.git.token);
}
try { applySettings(); } catch (e) {}
} catch (e) {
log('恢复配置异常: ' + e.message);
}
}
async function getBackupDataObject(includeCredentials) {
return {
app: 'javdb-emby-fav', version: 4, exportedAt: new Date().toISOString(),
lists: await dbAllLists(), movies: await dbAllMovies(),
gallery: await dbAllGallery(),
actorGenders: await dbAllActorGenders(),
notes: await dbAllNotes(),
meta: await dbAllMeta(),
__config: collectAllConfig(includeCredentials)
};
}
// 导出 = 完整备份:清单/影片/画廊/演员性别表/实体备注/全部 meta/用户个性化配置
async function exportJSON(includeCredentials) {
const data = await getBackupDataObject(includeCredentials);
const blob = new Blob([JSON.stringify(data)], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = getBackupFileName();
// 跨浏览器兼容:Firefox 等环境要求 <a> 必须在 DOM 树中才能触发 download click
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(function () { URL.revokeObjectURL(a.href); }, 5000);
}
async function exportJson(includeCredentials) {
const data = await getBackupDataObject(includeCredentials);
return JSON.stringify(data);
}
function fieldRichness(v) {
let n = 0;
['title', 'date', 'coverThumb', 'coverLarge', 'duration'].forEach(function (k) { if (v[k]) n++; });
['maker', 'series', 'director', 'rating'].forEach(function (k) { if (v[k]) n++; });
n += (v.actors || []).length ? 2 : 0;
n += (v.categories || []).length ? 2 : 0;
if (v.detailComplete) n += 5;
return n;
}
// 解析演员性别表:兼容对象({slug:'M'|'F'},导出/备份的实际序列化形态)与数组([{slug,gender}])两种形态。
// 只做解析、不写库 —— 写入统一交给 dbPutActorGenders 批量执行(原实现在循环里逐条 await)。
function collectActorGenderPairs(val) {
if (!val) return [];
const pairs = [];
if (Array.isArray(val)) {
val.forEach(function (g) { if (g && g.slug && g.gender) pairs.push([g.slug, g.gender]); });
} else if (typeof val === 'object') {
Object.keys(val).forEach(function (slug) {
const g = val[slug];
if (!slug || !g) return;
pairs.push([slug, (typeof g === 'object') ? g.gender : g]);
});
}
return pairs;
}
async function importJSON(obj, overwrite) {
if (typeof obj === 'string') {
try { obj = JSON.parse(obj); } catch (e) { throw new Error('无效的 JSON 格式: ' + e.message); }
}
const movies = obj && (obj.movies || obj.videos);
if (!obj || obj.app !== 'javdb-emby-fav' || !Array.isArray(obj.lists) || !Array.isArray(movies)) {
throw new Error('文件格式不正确');
}
// 随包恢复用户个性化配置(插件状态、自定义播放站点及排序、信息站点、外观布局、移动端Token)
if (obj.__config) {
try { restoreAllConfig(obj.__config); } catch (e) { log('恢复个性化配置失败: ' + e.message); }
}
// 覆盖 = 真·完整还原:清空全部数据表后写入文件内容(v7.111 起吸收原「恢复」按钮语义;
// 旧版覆盖导入不清画廊/元数据,与确认弹窗「清空全部收藏夹数据」的承诺不符)
if (overwrite) { await dbClearLists(); await dbClearMovies(); await dbClearGallery(); await dbClearMeta(); await dbClearActorGenders(); await dbClearNotes(); }
/* ====== 影片:一次性建索引 → 内存合并 → 批量写回 ======
* 旧实现逐条 `await dbGetMovie` + `await dbPutMovie`,每条 2 次事务往返 ——
* 万条导入要跑上万次事务,实测慢到几乎不可用。
* 现在:一次 getAll 建内存索引(1 次读事务),合并逻辑纯内存跑,
* 最后 dbPutMovies 按 50 条一批写(n/50 次写事务)。 */
const oldById = Object.create(null);
(await dbAllMovies()).forEach(function (m) { if (m && m.code) oldById[m.code] = m; });
const decided = Object.create(null); // code -> 1,同番号只定稿一次(防文件内重复行重复写)
const toPut = [];
function push(rec) {
if (!rec || !rec.code || decided[rec.code]) return;
decided[rec.code] = 1;
toPut.push(rec);
}
movies.forEach(function (nv) {
if (!nv || !nv.code) return;
const old = oldById[nv.code];
if (!old) { push(nv); return; }
if (!overwrite) {
// 属性级智能合并:永远优先保全用户的纠错修正、标记与备注数据
const merged = Object.create(null);
(old.listIds || []).concat(nv.listIds || []).forEach(function (c) { merged[c] = 1; });
const mergedListIds = Object.keys(merged);
// customMeta 自定义更正保全
let chosenCustomMeta = null;
if (old.customMeta && !nv.customMeta) chosenCustomMeta = old.customMeta;
else if (!old.customMeta && nv.customMeta) chosenCustomMeta = nv.customMeta;
else if (old.customMeta && nv.customMeta) {
chosenCustomMeta = ((old.customMeta.updatedAt || 0) >= (nv.customMeta.updatedAt || 0)) ? old.customMeta : nv.customMeta;
}
const finalReviewStatus = nv.reviewStatus || old.reviewStatus || null;
const finalReviewSyncedAt = (nv.reviewStatus ? nv.reviewSyncedAt : old.reviewSyncedAt) || null;
const finalUserScore = (typeof old.userScore === 'number' && old.userScore > 0) ? old.userScore : ((typeof nv.userScore === 'number' && nv.userScore > 0) ? nv.userScore : null);
const finalViews = Math.max(nv.views || 0, old.views || 0);
const finalNotes = nv.notes || old.notes || '';
const finalCustomCover = nv.customCover || old.customCover || '';
if (fieldRichness(nv) >= fieldRichness(old)) {
nv.listIds = mergedListIds;
nv.reviewStatus = finalReviewStatus;
nv.reviewSyncedAt = finalReviewSyncedAt;
if (finalUserScore) nv.userScore = finalUserScore;
else delete nv.userScore;
nv.views = finalViews;
if (chosenCustomMeta) nv.customMeta = chosenCustomMeta;
if (finalNotes) nv.notes = finalNotes;
if (finalCustomCover) nv.customCover = finalCustomCover;
push(nv);
} else {
old.listIds = mergedListIds;
old.reviewStatus = finalReviewStatus;
old.reviewSyncedAt = finalReviewSyncedAt;
if (finalUserScore) old.userScore = finalUserScore;
else delete old.userScore;
old.views = finalViews;
if (chosenCustomMeta) old.customMeta = chosenCustomMeta;
if (finalNotes) old.notes = finalNotes;
if (finalCustomCover) old.customCover = finalCustomCover;
push(old);
}
} else {
push(nv);
}
});
await dbPutMovies(toPut);
// 清单 / 画廊 / 备注 / 性别表 / meta:同样由逐条 await 改为批量写入
const lists = (obj.lists || []).filter(function (nl) { return nl && nl.id; });
lists.forEach(function (nl) { if (!nl.type) nl.type = 'mine'; });
await dbPutLists(lists);
const gallery = (obj.gallery || []).filter(function (ng) { return ng && ng.id; });
await dbPutGalleries(gallery);
// 画廊内存真相源(collectedSet)随批量写重建:防止导入后详情页按钮/画廊灯箱收藏态过期
try { await dbAllGallery(); } catch (e) { log('导入:画廊内存收藏集重建失败(进入画廊时会自动恢复): ' + e.message); }
// meta 逐键写回:兼容完整 meta(新导出/旧备份文件)与仅 {lastFullSync}(旧导出文件)两种形态
await dbSetMetaMany((obj.meta && typeof obj.meta === 'object') ? obj.meta : null);
// 演员性别表(v3 起随导出/备份文件携带;旧版 v2 导出文件无此字段则跳过);覆盖模式已在上方清空
const genderPairs = collectActorGenderPairs(obj.actorGenders);
await dbPutActorGenders(genderPairs);
// 实体备注:合并模式下按 updatedAt 智能差分,防止旧备份倒退覆盖新笔记
let notes = (Array.isArray(obj.notes) ? obj.notes : []).filter(function (nn) { return nn && nn.key; });
if (!overwrite && notes.length) {
try {
const oldNotes = await dbAllNotes();
const oldMap = {};
oldNotes.forEach(function (n) { if (n && n.key) oldMap[n.key] = n; });
notes = notes.map(function (nn) {
const ex = oldMap[nn.key];
if (!ex) return nn;
return ((ex.updatedAt || 0) > (nn.updatedAt || 0)) ? ex : nn;
});
} catch (e) {}
}
await dbPutNotes(notes);
return { lists: lists.length, videos: movies.length, gallery: gallery.length, genders: genderPairs.length, notes: notes.length };
}
/* ---------- 通用 JSON 本地备份导入(支持合并与覆盖双模式) ---------- */
function doImport(onComplete) {
const inp = document.createElement('input');
inp.type = 'file'; inp.accept = '.json,application/json';
inp.addEventListener('change', function () {
const f = inp.files && inp.files[0];
if (!f) return;
const overwrite = confirm('导入模式选择:\n「确定」= 合并导入(保留本地数据,字段更全者胜)\n「取消」= 下一步选择是否覆盖导入');
let ow = false;
if (!overwrite) {
ow = confirm('覆盖导入将【清空】本地全部数据(清单/影片/画廊/演员性别表/元数据)后完整还原文件内容。\n确定使用覆盖导入吗?(取消则中止导入)');
if (!ow) return;
}
const rd = new FileReader();
rd.onload = function () {
try {
const obj = JSON.parse(rd.result);
importJSON(obj, ow).then(function (r) {
alert('导入完成:' + r.lists + ' 个清单,' + r.videos + ' 部作品记录,' + (r.genders || 0) + ' 条演员性别,' + (r.notes || 0) + ' 条备注');
if (typeof onComplete === 'function') onComplete();
else if (typeof FAVUI !== 'undefined' && typeof FAVUI.reload === 'function') FAVUI.reload();
else location.reload();
}).catch(function (e) { alert('导入失败:' + e.message); });
} catch (e) { alert('文件解析失败:' + e.message); }
};
rd.readAsText(f);
});
inp.click();
}
/* ---------- 查询 ---------- */
async function getAll() {
try {
// 三张表互不依赖,并行读取(原为串行 await,库大时白等两轮 IO 往返)
const res = await Promise.all([dbAllLists(), dbAllMovies(), dbAllMeta()]);
const allMovies = res[1] || [];
// 关键防护:仅返回真正归属于清单或带有想看/看過标记、或具有用户自定义元数据更正的有效收藏,过滤掉无归属孤儿作品;并通过 getEffectiveMovie 施加自定义更正
const favMovies = allMovies.filter(function (m) {
return (Array.isArray(m.listIds) && m.listIds.length > 0) || !!m.reviewStatus || !!m.customMeta;
}).map(getEffectiveMovie);
return {
lists: (res[0] || []).filter(function (l) { return l.type !== 'favorited'; }),
videos: favMovies,
meta: res[2] || {}
};
} catch (e) { log('读取收藏夹数据库失败: ' + e.message); return { lists: [], videos: [], meta: {} }; }
}
async function getStarredLists() {
// 收藏的清单不再入库,改为即时访问 /users/favorite_lists(支持多页翻页抓取)
try {
const rows = [];
const seen = {};
let pageUrl = '/users/favorite_lists';
let guard = 0;
while (pageUrl && guard++ < 50) {
const doc = await throttledFetch(pageUrl);
if (!doc) break;
const links = doc.querySelectorAll('a[href*="list_detail?id="], a[href*="/lists/"]');
Array.from(links).forEach(function (a) {
const href = a.getAttribute('href') || '';
const id = href.replace(/^.*\//, '').replace(/^\?id=/, '') || a.textContent.trim();
const name = a.textContent.trim();
if (!name || !id || seen[id]) return;
seen[id] = 1;
const countEl = a.closest('.list, .box, .column') && a.closest('.list, .box, .column').querySelector('.count, .video-count');
const count = countEl ? (parseInt(countEl.textContent, 10) || 0) : 0;
rows.push({ id: id, name: name, count: count, base: href, type: 'favorited' });
});
// 找下一页:href 含 favorite_lists?page=N(N 大于当前),或 rel=next
const cur = (pageUrl.match(/page=(\d+)/) || [0, 1])[1];
const next = Array.from(doc.querySelectorAll('a[href*="favorite_lists"], a[rel="next"]'))
.map(function (a) { return a.getAttribute('href') || ''; })
.filter(function (h) {
const m = h.match(/page=(\d+)/);
return m && parseInt(m[1], 10) > parseInt(cur, 10);
})
.sort(function (x, y) { return (parseInt((x.match(/page=(\d+)/) || [0, 0])[1], 10)) - (parseInt((y.match(/page=(\d+)/) || [0, 0])[1], 10)); })[0];
pageUrl = next ? (next.charAt(0) === '/' ? next : '/' + next) : null;
}
return { lists: rows, error: null };
} catch (e) {
log('读取收藏的清单失败: ' + e.message);
return { lists: [], error: e.message };
}
}
return {
getEffectiveMovie: getEffectiveMovie, tryAutoHealCustomMeta: tryAutoHealCustomMeta,
runSync: runSync, runComplete: runComplete, detectActorGenders: detectActorGenders,
syncListMeta: syncListMeta, // 轻量清单元数据同步(访问 /users/lists 时触发,改名/数量就地更新)
syncReviewStatus: syncReviewStatus,
fetchAndSaveDetail: fetchAndSaveDetail, newMovieRecord: newMovieRecord,
pause: pause, resume: resume, stop: stop, isBusy: isBusy,
passiveCollect: passiveCollect, getFavStatus: getFavStatus,
exportJSON: exportJSON, exportJson: exportJson, importJSON: importJSON, importJson: importJSON, doImport: doImport,
getAll: getAll, getStarredLists: getStarredLists, dbPurgeOrphanMovies: dbPurgeOrphanMovies,
resolveImg: resolveImg, stripHost: stripHost,
throttledFetch: throttledFetch, parseListPage: parseListPage,
parseListIndex: parseListIndex, parseDetail: parseDetail, listPageUrl: listPageUrl,
fetchAllListIndex: fetchAllListIndex,
onProgress: function (cb) { progressCb = cb; },
incView: incView,
queue: queue,
detectImgHost: detectImgHost, ensureImgHost: ensureImgHost,
dbAllActorGenders: dbAllActorGenders, friendlyError: friendlyError,
dbPutGallery: dbPutGallery, dbGetGallery: dbGetGallery, dbAllGallery: dbAllGallery, dbDeleteGallery: dbDeleteGallery,
dbGetNote: dbGetNote, dbPutNote: dbPutNote, dbDeleteNote: dbDeleteNote, dbAllNotes: dbAllNotes,
dbGetMovie: dbGetMovie, dbPutMovie: dbPutMovie, dbAllMovies: dbAllMovies, dbPutMovies: dbPutMovies, dbDeleteMovie: dbDeleteMovie,
// 预览画廊内存收藏真相源(FAVUI 内按钮显示状态同步读取,避免异步竞态串台)
isCollected: isCollected, collectedSet: collectedSet,
runPool: runPool,
installListIntercept: installListIntercept,
syncWithSaveListModal: syncWithSaveListModal,
clearAll: clearAll,
dbClearLists: dbClearLists, dbClearMovies: dbClearMovies, dbClearGallery: dbClearGallery,
dbClearMeta: dbClearMeta, dbClearActorGenders: dbClearActorGenders, dbClearNotes: dbClearNotes,
dbGetMeta: dbGetMeta, dbSetMeta: dbSetMeta
};
})();
/* =======================================================================
* FAVUI —— 收藏夹界面模块(v5.00)
* 工具栏 + 条件胶囊 + 封面墙(分批渲染) + 筛选浮窗 + 收藏的清单浏览器
* ===================================================================== */
const FAVUI = (function () {
let panel = null; // #emby-favorites-panel
let favBgEl = null; // 随机背景层 div(fixed 钉视口)
let cache = null; // {lists, videos}
let favGenderMap = {}; // {slug:'M'|'F',...} 演员性别表(供 buildCard 过滤男演员)
let noteMap = {}; // {key:note,...} 实体备注索引(key = actor:{slug} / code:{PREFIX})
try {
if (typeof FAV !== 'undefined') {
if (typeof FAV.dbAllNotes === 'function') {
FAV.dbAllNotes().then(function (rows) {
(rows || []).forEach(function (n) { if (n && n.key) noteMap[n.key] = n; });
}).catch(function () {});
}
if (typeof FAV.dbAllActorGenders === 'function') {
FAV.dbAllActorGenders().then(function (m) {
if (m) favGenderMap = m;
}).catch(function () {});
}
}
} catch (e) {}
const state = {
scope: [], // 选中的清单 id(空=全部)
keyword: '',
sort: 'date_desc', // date_desc | score_desc | score_count_desc | code_asc | added_desc
coactMode: false, // 共演筛选(交集模式)
showActorAvatars: false, // 演员头像显示(仅筛选弹窗内)
actorAvatarSize: 'small', // 演员头像尺寸:small(默认,圆图+名字同行)| large(大图在上名字在下)
freqSort: false, // 常浏览排序:筛选浮窗内各维度按关联作品浏览总数降序(而非出现次数)
hideMaleActors: false, // 隐藏男优:筛选浮窗演员维度候选剔除已识别为男(M)的演员
onlyAward: false, // 只看金标:仅显示带 JAVDB 金标(awards)的作品
incompleteOnly: false, // 只看未补全:仅显示缺标题/封面/演员/评分任一关键字段的条目
notesOnly: false, // 只看有备注(筛选浮窗内):作品本身或其演员/片商/系列带备注
notesWorkOnly: false, // 工具条「仅显示备注作品」:只认作品级备注(详情页番号旁「備註」按钮写的)
reviewWanted: false, // 只看想看(筛选弹窗开关,与「看過」同开为并集)
reviewWatched: false, // 只看看過(筛选弹窗开关)
recentDays: 0, // 最近 N 天发行快捷视图(0=全部;7/30/90,按作品发行日期 date 判定)
filters: { actors: [], makers: [], series: [], directors: [], tags: [], years: [], awards: [], scoreMax: 5.0, codePrefix: '' }
};
// 画廊筛选(复用收藏夹筛选浮窗的画廊模式;元数据与收藏库 join)
// 开关字段(coactMode/showActorAvatars/actorAvatarSize/hideMaleActors/onlyAward)为画廊模式专属,
// 与收藏夹 state 完全隔离,互不污染
let galleryFilter = { actors: [], makers: [], series: [], directors: [], tags: [], years: [], awards: [], scoreMax: 5.0, codePrefix: '', coactMode: false, showActorAvatars: false, actorAvatarSize: 'small', hideMaleActors: false, onlyAward: false };
let galleryPanelEl = null; // renderGallery 记录当前画廊面板,供筛选确认后重渲染
let galleryRowsCache = null; // 最近一次画廊原始行(供筛选浮窗计算候选)
let renderList = []; // 当前筛选结果
let renderedCount = 0;
const BATCH = 60;
let sentinelObserver = null;
function h(html) { const t = document.createElement('div'); t.innerHTML = html; return t.firstElementChild; }
// v7.132 起全部图标迁移至 Material Symbols(icon 为兼容别名,内部同 sym)
function sym(n) { return '<span class="material-symbols-outlined" style="font-size:1.15em;">' + n + '</span>'; }
function icon(n) { return sym(n); }
function hasActiveFilters() {
const f = state.filters;
return f.actors.length || f.makers.length || f.series.length || f.directors.length ||
f.tags.length || f.years.length || f.awards.length || (f.scoreMax != null && f.scoreMax < 5.0) || f.codePrefix || state.keyword || state.scope.length;
}
// 演员名归一化:去掉尾部括号标记(如「(無碼)」「(有碼)」「(無修正)」等,全/半角括号都处理)。
// 用途:演员主页标题带标记、作品详情页不带标记 → 两侧归一后仍能匹配到同一个人。
// 仅作用于查询/匹配层,不改动采集与存储的数据。
function normActorName(s) {
if (!s) return '';
return String(s).replace(/\s*[((][^()()]*[))]\s*$/g, '').replace(/\s+/g, ' ').trim();
}
// 判断演员是否为男优(供 buildCard 过滤卡片信息行中的男演员名)
function isMaleByHref(actor) {
if (!actor) return false;
var m = (actor.href || '').match(/\/actors\/([^\/\?\#]+)/);
return !!(m && m[1] && favGenderMap[m[1]] === 'M');
}
// 未补全判定:缺标题/封面/演员/评分任一关键字段(主列表筛选与弹窗候选联动共用)
function isIncomplete(v) {
return !(v.title && (v.coverThumb || v.coverLarge) && (v.actors || []).length && v.rating);
}
// 作品是否「有备注」:作品本身(详情页按钮)、其片商、系列或任一演员带备注
function workHasNote(v) {
if (!noteMap) return false;
if (v.code && noteMap['code:' + v.code]) return true;
var mk = ((v.maker && v.maker.href) || '').match(/\/makers\/([^\/\?\#]+)/);
if (mk && noteMap['maker:' + mk[1]]) return true;
var se = ((v.series && v.series.href) || '').match(/\/series\/([^\/\?\#]+)/);
if (se && noteMap['series:' + se[1]]) return true;
return (v.actors || []).some(function (a) {
var m = (a.href || '').match(/\/actors\/([^\/\?\#]+)/);
return !!(m && noteMap['actor:' + m[1]]);
});
}
// 拼接作品关联的全部备注文本(供关键词搜索命中备注内容)
function workNoteText(v) {
if (!noteMap) return '';
var parts = [];
if (v.code) {
var n0 = noteMap['code:' + v.code]; // 作品级备注(详情页)
if (n0 && n0.content) parts.push(n0.content);
}
var mk = ((v.maker && v.maker.href) || '').match(/\/makers\/([^\/\?\#]+)/);
if (mk) { var n1 = noteMap['maker:' + mk[1]]; if (n1 && n1.content) parts.push(n1.content); }
var se = ((v.series && v.series.href) || '').match(/\/series\/([^\/\?\#]+)/);
if (se) { var n2 = noteMap['series:' + se[1]]; if (n2 && n2.content) parts.push(n2.content); }
(v.actors || []).forEach(function (a) {
var m = (a.href || '').match(/\/actors\/([^\/\?\#]+)/);
if (m) { var n3 = noteMap['actor:' + m[1]]; if (n3 && n3.content) parts.push(n3.content); }
});
return parts.join(' ');
}
// 备注是否显示(设置面板「收藏夹显示备注」全局开关,默认开)
function notesShown() {
try { return (localStorage.getItem(SET_KEYS.showNotes) || '1') !== '0'; } catch (e) { return true; }
}
// 卡片展示用备注:仅作品级(详情页番号旁按钮写的);
// 演员/片商/系列备注只在名字上橙色标记,不展示文本(内容在详情页悬停气泡查看)
function workNoteDisplay(v) {
if (!noteMap || !v.code) return '';
var n0 = noteMap['code:' + v.code];
return (n0 && n0.content) || '';
}
/* ---------- 挂载 ---------- */
async function mount(el) {
log('[DIAG] mount 进入');
// 图床域名迁移是非关键的后台任务,不要阻塞首屏渲染:fire-and-forget,失败仅 log
FAV.ensureImgHost().catch(function (e) { log('图床域名迁移失败: ' + e.message); });
if (panel) {
if (panel._efavBgChangeHandler) { window.removeEventListener('efav-randombg-change', panel._efavBgChangeHandler); }
if (panel._efavNotesChangeHandler) { window.removeEventListener('efav-notes-change', panel._efavNotesChangeHandler); }
if (panel._efavFavChangedHandler) {
window.removeEventListener('emby-fav-changed', panel._efavFavChangedHandler);
document.removeEventListener('emby-fav-changed', panel._efavFavChangedHandler);
}
}
panel = el;
panel.classList.add('has-ui');
// 销毁契约:mount 会整块重建面板(browseList 返回时再次进入),
// 清空前先注销内部已注册容器
try { WaterfallEngine.unregisterTree(panel); } catch (e) {}
panel.innerHTML = '';
// 随机背景层(真实 div,fixed 钉视口,独立于滚动)
const bgEl = document.createElement('div');
bgEl.className = 'efav-bg';
panel.appendChild(bgEl);
favBgEl = bgEl;
// 监听设置里「收藏夹随机背景」开关变化,实时重应用背景
const onBgChange = function () { try { applyFavRandomBg(); } catch (e) {} };
window.addEventListener('efav-randombg-change', onBgChange);
// 卸载时移除监听(避免泄漏/重复绑定)
panel._efavBgChangeHandler = onBgChange;
// 设置「收藏夹显示备注」开关变化 → 重渲染卡片备注行
const onNotesChange = function () { try { applyAndRender(); } catch (e) {} };
window.addEventListener('efav-notes-change', onNotesChange);
panel._efavNotesChangeHandler = onNotesChange;
// 还原上次离开时的筛选/排序/滚动状态(从详情页返回场景)
const saved = restoreState();
if (saved) {
state.scope = saved.scope || [];
state.keyword = saved.keyword || '';
state.sort = saved.sort || 'date_desc';
if (saved.filters) state.filters = saved.filters;
}
buildToolbar();
if (saved) {
if (searchInput) searchInput.value = state.keyword;
if (sortSel) sortSel.value = state.sort;
}
buildProgress();
buildChipsRow();
const gridWrap = document.createElement('div');
gridWrap.id = 'efav-grid-wrap';
panel.appendChild(gridWrap);
FAV.onProgress(onProgress);
// 详情页操作(存入清單/想看/看過)后实时刷新收藏夹数据(防抖,避免频繁全量重载)
const onFavChanged = function () {
if (!panel) return;
clearTimeout(panel._efavReloadTimer);
panel._efavReloadTimer = setTimeout(function () { try { reload(); } catch (e) { log('收藏夹实时刷新失败: ' + e.message); } }, 300);
};
window.addEventListener('emby-fav-changed', onFavChanged);
document.addEventListener('emby-fav-changed', onFavChanged);
panel._efavFavChangedHandler = onFavChanged;
// 滚动方向感知:下滑隐藏「首页/收藏夹」切换栏,上滑复现(对齐主页滚动收起体验)
// 防重复绑定:browseList 返回会再次进入 mount,同一面板只绑一次滚动监听
if (!panel.dataset.favScrollBound) {
panel.dataset.favScrollBound = '1';
let lastScrollTop = 0;
panel.addEventListener('scroll', function () {
const st = panel.scrollTop;
if (st > lastScrollTop && st > 60) document.documentElement.classList.add('efav-tabs-hide');
else if (st < lastScrollTop) document.documentElement.classList.remove('efav-tabs-hide');
lastScrollTop = st;
if (window.__embyUpdateBackTop) window.__embyUpdateBackTop(st);
}, { passive: true });
}
// 挂载数据:同一会话内再次进入收藏夹(详情页返回 / 浏览清单返回)时,直接用模块级
// cache 即时渲染首屏,再后台 reload 刷新数据(emby-fav-changed 驱动的 UI 重建保持不变)。
// 旧实现每次 mount 都 await 全量读库(dbAllMovies + 性别表 + 备注表),大库下切视图有明显等待。
if (cache && cache.videos && cache.videos.length) {
try { refreshScopeMenu(); } catch (e) {}
try { applyAndRender(); } catch (e) { log('缓存首屏渲染失败: ' + e.message); }
try { applyFavRandomBg(); } catch (e) {}
}
await reload();
log('[DIAG] mount: reload 完成');
if (saved && saved.scrollTop > 0) {
// 安全恢复上次滚动位置前的分批渲染:必须设上限 + 无进展即退出,避免 renderGrid 不推进时死循环卡死主线程
let guard = 0;
const target = Math.min(saved.rendered || 0, renderList.length);
while (renderedCount < target && guard++ < target + 2) {
const before = renderedCount;
renderGrid(false);
if (renderedCount === before) break; // 本次无进展(如 renderList 未就绪),停止以免死循环
}
requestAnimationFrame(function () { panel.scrollTop = saved.scrollTop; });
}
}
function unmount() {
if (panel) {
// 销毁契约:清空面板前注销其内部全部已注册容器
try { WaterfallEngine.unregisterTree(panel); } catch (e) {}
panel.classList.remove('has-ui'); panel.innerHTML = '';
if (panel._efavFavChangedHandler) {
window.removeEventListener('emby-fav-changed', panel._efavFavChangedHandler);
document.removeEventListener('emby-fav-changed', panel._efavFavChangedHandler);
}
if (panel._efavBgChangeHandler) { window.removeEventListener('efav-randombg-change', panel._efavBgChangeHandler); }
if (panel._efavNotesChangeHandler) { window.removeEventListener('efav-notes-change', panel._efavNotesChangeHandler); }
}
const m = document.getElementById('efav-filter-mask');
if (m) m.remove();
if (sentinelObserver) { sentinelObserver.disconnect(); sentinelObserver = null; }
panel = null;
// 缓存保留:session 内二次进入收藏夹直接复用内存数据渲染首屏,不再每次全量重读
// IndexedDB(大库下「收藏夹↔详情页」反复切换有明显等待);数据新鲜度由 mount 内的后台
// reload 兜底,任何 emby-fav-changed / 同步完成事件也会触发 reload 刷新。
// cache 仅随脚本重载自然重建(延长到 session 生命周期,旧实现每次卸载即清空)。
}
async function reload() {
log('[DIAG] reload: 开始 FAV.getAll');
// 三份数据互不依赖,并行加载(原为三段串行 await,库大时首屏白等)
const res = await Promise.all([
FAV.getAll(),
FAV.dbAllActorGenders().catch(function (err) { log('性别表读取失败: ' + err.message); return {}; }),
FAV.dbAllNotes().catch(function (err) { log('备注表读取失败: ' + err.message); return []; })
]);
cache = res[0];
favGenderMap = res[1] || {};
noteMap = {};
(res[2] || []).forEach(function (n) { if (n && n.key) noteMap[n.key] = n; });
log('[DIAG] reload: FAV.getAll 完成, videos=' + ((cache && cache.videos) ? cache.videos.length : 0));
refreshScopeMenu();
applyAndRender();
applyFavRandomBg();
// 后台静默安全清洗数据库中历史误入库的非收藏孤儿作品(如 TOP250 残留数据)
if (typeof FAV.dbPurgeOrphanMovies === 'function') {
FAV.dbPurgeOrphanMovies().then(function (purged) {
if (purged > 0) {
log('已自动清理 ' + purged + ' 部非收藏残留作品');
if (typeof showToast === 'function') {
showToast('🧹 已自动从收藏夹清理 ' + purged + ' 部非收藏的 TOP250 榜单残留作品!');
}
}
}).catch(function () {});
}
}
/* ---------- 工具栏 ---------- */
let scopeBtn, scopeMenu, searchInput, sortSel;
// 并发滑块元素与 document 级监听防重标记:
// buildToolbar 创建/绑定;openCollectModal(兄弟函数)跨函数调用 showConc 显示滑块
let concWrap = null;
let scopeDocClickBound = false;
function showConc() { if (concWrap) concWrap.style.display = ''; }
// 是否已同步过清单:meta.lastFullSync 在同步完成后写入,未同步则库为空,应先同步
function hasSyncedBefore() {
const ts = (cache && cache.meta && cache.meta.lastFullSync) || 0;
return !!ts && !isNaN(Number(ts));
}
function buildToolbar() {
const bar = h('<div class="efav-toolbar"></div>');
// 清单范围(多选下拉)
const scope = h('<div class="efav-scope"></div>');
scopeBtn = h('<button class="efav-btn">' + sym('all_inbox') + '<span class="scope-txt">全部清单</span>' + icon('arrow_drop_down') + '</button>');
scopeMenu = h('<div class="efav-scope-menu"></div>');
scope.appendChild(scopeBtn); scope.appendChild(scopeMenu);
scopeBtn.addEventListener('click', function (e) { e.stopPropagation(); scopeMenu.classList.toggle('open'); });
if (!scopeDocClickBound) {
scopeDocClickBound = true; // mount 可能因 browseList 返回被重复调用,document 级监听只绑一次
document.addEventListener('click', function () { if (scopeMenu) scopeMenu.classList.remove('open'); });
}
scopeMenu.addEventListener('click', function (e) { e.stopPropagation(); });
bar.appendChild(scope);
// 搜索框
searchInput = h('<input type="text" placeholder="搜索番号 / 标题…">');
let kwTimer = null;
searchInput.addEventListener('input', function () {
clearTimeout(kwTimer);
kwTimer = setTimeout(function () { state.keyword = searchInput.value.trim(); applyAndRender(); }, 250);
});
bar.appendChild(searchInput);
// 排序
sortSel = h('<select>' +
'<option value="date_desc">日期 新→旧</option>' +
'<option value="score_desc">评分 高→低</option>' +
'<option value="score_count_desc">评价人数 多→少</option>' +
'<option value="code_asc">番号 A→Z</option>' +
'<option value="added_desc">入库 新→旧</option>' +
'<option value="views_desc">常浏览 多→少</option>' +
'<option value="awards_asc">金标标识 正序</option>' +
'</select>');
sortSel.addEventListener('change', function () { state.sort = sortSel.value; applyAndRender(); });
bar.appendChild(sortSel);
// 按钮组
// 注意:openFilterModal(galleryMode) 有布尔参数,不能把函数引用直接交给 click
//(事件对象会被当成 truthy 的 galleryMode),必须包一层显式传 false
bar.appendChild(mkBtn('filter_alt', '筛选', function () { openFilterModal(false); }, true));
// 仅显示备注作品(工具条开关):只显示用户对番号本身做过备注的作品,再点恢复原显示
const nwBtn = h('<button class="efav-btn" id="efav-notes-work-btn">' + icon('history_edu') + '仅显示备注作品</button>');
nwBtn.title = '只显示你对番号进行过备注的作品(在作品详情页番号旁的「備註」按钮填写);再次点击恢复原显示。' +
'注意:演员/片商/系列页的备注不计入此开关(那些用筛选弹窗里的「只看有备注」)';
nwBtn.addEventListener('click', function () {
state.notesWorkOnly = !state.notesWorkOnly;
syncNotesWorkBtn();
saveState();
applyAndRender();
});
bar.appendChild(nwBtn);
bar.appendChild(mkBtn('auto_awesome', '获取和整理收藏', openCollectModal, false, '获取和整理收藏:同步清单 / 补全信息 / 刷新详情 / 识别性别 统一入口'));
const starredBtn = h('<button class="efav-btn">' + sym('cards_star') + '收藏的清单</button>');
starredBtn.title = '浏览你收藏的他人分享清单(实时抓取,纯预览)';
starredBtn.addEventListener('click', openBrowse);
bar.appendChild(starredBtn);
// 备份和恢复(合并入口):点击弹出选择窗
const bkBtn = h('<button class="efav-btn">' + sym('file_json') + '备份和恢复</button>');
bkBtn.title = '备份全部数据为 JSON 文件,或从文件恢复(合并/覆盖)';
bkBtn.addEventListener('click', openBackupModal);
bar.appendChild(bkBtn);
// 并发数滑块:控制同步/补全/识别的并发 worker 数(1=纯串行,3=默认,6=最快)
// 分步创建元素,确保 input/span 引用准确(避免 h() 解析 label 内嵌 input 的潜在引用问题)
const cc = document.createElement('label');
cc.className = 'efav-conc';
cc.title = '同步/补全/识别时的并发请求数:1=串行最稳,3=默认,6=最快(若触发风控请调低)';
const ccText = document.createTextNode('并发 ');
const ccInput = document.createElement('input');
ccInput.type = 'range'; ccInput.min = '1'; ccInput.max = '6'; ccInput.step = '1'; ccInput.value = String(CONCURRENCY);
const ccVal = document.createElement('span');
ccVal.className = 'efav-conc-val'; ccVal.textContent = String(CONCURRENCY);
cc.appendChild(ccText); cc.appendChild(ccInput); cc.appendChild(ccVal);
const onConc = function () {
const v = parseInt(ccInput.value, 10) || 1;
CONCURRENCY = v; ccVal.textContent = String(v);
try { localStorage.setItem('javdbEmbyConcurrency', String(v)); } catch (e) {}
// 若任务正在运行,实时增减 worker 数(不打断在跑的请求)
if (FAV.runPool && FAV.runPool._adjust) FAV.runPool._adjust();
log('[DIAG] 并发设为 ' + v);
};
ccInput.addEventListener('input', onConc);
ccInput.addEventListener('change', onConc);
bar.appendChild(cc);
// 显示大小滑块:实时控制收藏夹封面大小(工厂构造)
// - 网格模式吃 --e-card-w(.movie-list 的 grid minmax 基准)
// - 瀑布流模式吃 --mz-col-w(column 列宽);二者同步设置,滑块对两种布局都生效
const sizeWrap = rangeSlider({
cls: 'efav-gallery-size efav-cover-size',
title: '显示大小:滑动设置封面大小(网格 / 瀑布流 / 动态聚焦均生效)',
styleVar: ['--e-card-w', '--mz-col-w', '--df-col-w'],
storeKey: SET_KEYS.coverSize,
min: '180', max: '560', step: '10',
load: function () { return parseInt(localStorage.getItem(SET_KEYS.coverSize) || '300', 10) || 300; },
cssVal: function (v) { return parseFloat(v) + 'px'; },
fmt: function (v) { return Math.round(parseFloat(v)) + 'px'; },
onInput: function (v) {
try {
if (typeof WaterfallEngine !== 'undefined' && WaterfallEngine.refresh) {
WaterfallEngine.refresh();
}
} catch (e) {}
}
});
bar.appendChild(sizeWrap.wrap);
cc.style.display = 'none'; // 初始隐藏:仅当用户点击数据拉取类按钮后才显示并发滑块
concWrap = cc;
panel.appendChild(bar);
}
/* 共享滑块工厂:图标 + range + 实时数值 + 持久化(收藏夹显示大小 / 画廊显示大小复用,避免重复 DOM 构造) */
function rangeSlider(o) {
const wrap = document.createElement('div');
wrap.className = o.cls || 'efav-gallery-size';
wrap.title = o.title || '';
wrap.innerHTML = '<span class="material-symbols-outlined" style="font-size:1.1em;">' + (o.icon || 'photo_size_select_large') + '</span>';
const input = document.createElement('input');
input.type = 'range'; input.min = o.min; input.max = o.max; input.step = o.step;
const vars = Array.isArray(o.styleVar) ? o.styleVar : [o.styleVar];
const saved = o.load();
input.value = String(saved);
vars.forEach(function (v) {
if (!document.documentElement.style.getPropertyValue(v)) {
document.documentElement.style.setProperty(v, o.cssVal ? o.cssVal(saved) : saved);
}
});
const val = document.createElement('span'); val.className = 'efav-conc-val'; val.textContent = o.fmt(saved);
if (o.onInit) o.onInit(saved);
function update() {
const v = input.value;
vars.forEach(function (sv) { document.documentElement.style.setProperty(sv, o.cssVal ? o.cssVal(v) : v); });
val.textContent = o.fmt(v);
if (o.onInput) o.onInput(v);
try { localStorage.setItem(o.storeKey, String(v)); } catch (e) {}
}
input.addEventListener('input', update);
input.addEventListener('change', update);
wrap.appendChild(input); wrap.appendChild(val);
return { wrap: wrap, input: input, val: val };
}
function mkBtn(ic, txt, fn, accent, tip) {
const b = h('<button class="efav-btn' + (accent ? ' accent' : '') + '">' + icon(ic) + txt + '</button>');
if (tip) b.title = tip; // 鼠标悬停显示详细说明
b.addEventListener('click', fn);
return b;
}
// 工具条「仅显示备注作品」开关视觉同步(开 = 强调色)
function syncNotesWorkBtn() {
const b = document.getElementById('efav-notes-work-btn');
if (b) b.classList.toggle('accent', !!state.notesWorkOnly);
}
function refreshScopeMenu() {
if (!scopeMenu) return;
scopeMenu.innerHTML = '';
(cache.lists || []).forEach(function (l) {
const lb = h('<label><input type="checkbox" value="' + escapeAttr(l.id) + '"' +
(state.scope.indexOf(l.id) !== -1 ? ' checked' : '') + '> ' +
escapeHtml(l.name) + ' <span style="opacity:.5;">(' + (l.scrapedCount || 0) + ')</span></label>');
lb.querySelector('input').addEventListener('change', function (e) {
const id = e.target.value;
if (e.target.checked) { if (state.scope.indexOf(id) === -1) state.scope.push(id); }
else state.scope = state.scope.filter(function (x) { return x !== id; });
updateScopeTxt(); applyAndRender();
});
scopeMenu.appendChild(lb);
});
if (!(cache.lists || []).length) scopeMenu.appendChild(h('<label style="opacity:.5;">暂无清单,请先同步</label>'));
updateScopeTxt();
}
function updateScopeTxt() {
const t = scopeBtn.querySelector('.scope-txt');
if (!state.scope.length) t.textContent = '全部清单';
else {
const names = state.scope.map(function (id) {
const l = (cache.lists || []).find(function (x) { return x.id === id; });
return l ? l.name : id;
});
t.textContent = names.length > 2 ? names.slice(0, 2).join('、') + ' 等' + names.length + '个' : names.join('、');
}
}
/* ---------- 进度条 ---------- */
let progEl;
function buildProgress() {
progEl = h('<div class="efav-progress">' +
'<span class="txt lbl"></span><div class="bar"><i></i></div><span class="txt num"></span>' +
'<button class="efav-btn" data-act="pause">暂停</button>' +
'<button class="efav-btn" data-act="stop">取消</button></div>');
progEl.querySelector('[data-act=pause]').addEventListener('click', function () {
if (FAV.queue.paused) { FAV.resume(); this.textContent = '暂停'; }
else { FAV.pause(); this.textContent = '继续'; }
});
progEl.querySelector('[data-act=stop]').addEventListener('click', function () { FAV.stop(); });
panel.appendChild(progEl);
}
function onProgress(p) {
if (!progEl) return;
if (p.state === 'run' || p.state === 'pause') {
progEl.classList.add('show');
progEl.querySelector('.lbl').textContent = p.label + (p.msg ? ':' + p.msg : '');
progEl.querySelector('.lbl').classList.toggle('warn', p.state === 'pause');
progEl.querySelector('.num').textContent = p.done + ' / ' + p.total;
progEl.querySelector('.bar > i').style.width = (p.total ? (p.done / p.total * 100) : 0) + '%';
progEl.querySelector('[data-act=pause]').textContent = p.state === 'pause' ? '继续' : '暂停';
} else {
progEl.querySelector('.lbl').textContent = p.msg || '';
progEl.querySelector('.lbl').classList.toggle('warn', p.state === 'error');
progEl.querySelector('.bar > i').style.width = '100%';
setTimeout(function () { if (!FAV.isBusy()) progEl.classList.remove('show'); }, 4000);
reload();
}
}
/* ---------- 条件胶囊行 ---------- */
let chipsRow;
function buildChipsRow() {
chipsRow = h('<div class="efav-chips"></div>');
panel.appendChild(chipsRow);
}
function refreshChips() {
chipsRow.innerHTML = '';
const f = state.filters;
function chip(label, txt, rm) {
const c = h('<span class="efav-chip">' + escapeHtml(label + ':' + txt) + '<span class="x">✕</span></span>');
c.querySelector('.x').addEventListener('click', function () { rm(); applyAndRender(); });
chipsRow.appendChild(c);
}
state.scope.forEach(function (id) {
const l = (cache.lists || []).find(function (x) { return x.id === id; });
chip('清单', l ? l.name : id, function () {
state.scope = state.scope.filter(function (x) { return x !== id; });
refreshScopeMenu();
});
});
['actors', 'awards', 'tags', 'series', 'makers', 'directors', 'years'].forEach(function (dim) {
const lbl = { actors: '演员', awards: '金标', tags: '类别', series: '系列', makers: '片商', directors: '导演', years: '年份' }[dim];
f[dim].forEach(function (val) {
chip(lbl, val, function () { f[dim] = f[dim].filter(function (x) { return x !== val; }); });
});
});
if (f.scoreMax != null && f.scoreMax < 5.0) chip('评分', '≤ ' + f.scoreMax + '分', function () { f.scoreMax = 5.0; });
if (f.codePrefix) chip('番号前缀', f.codePrefix, function () { f.codePrefix = ''; });
if (state.keyword) chip('搜索', state.keyword, function () { state.keyword = ''; searchInput.value = ''; });
if (state.notesWorkOnly) chip('仅看', '备注作品', function () { state.notesWorkOnly = false; syncNotesWorkBtn(); });
const cnt = h('<span class="efav-count">共 ' + renderList.length + ' 部</span>');
chipsRow.appendChild(cnt);
syncNotesWorkBtn();
}
/* ---------- 筛选与排序 ---------- */
function applyAndRender() {
if (!cache) return;
const f = state.filters;
// 清单范围:movie.listIds 与选中清单求交集
const scopeSet = state.scope.length ? state.scope : null;
const kw = state.keyword.toLowerCase();
renderList = (cache.videos || []).filter(function (v) {
// 真实收藏守卫:必须属于某个清单或带有想看/看過标记
if (!((Array.isArray(v.listIds) && v.listIds.length > 0) || v.reviewStatus)) return false;
if (scopeSet && !(v.listIds || []).some(function (id) { return scopeSet.indexOf(id) !== -1; })) return false;
// 番号组前缀(详情页番号组「去收藏夹筛选」带入):前缀匹配、大小写不敏感
if (f.codePrefix) {
const p = f.codePrefix.toUpperCase();
if ((v.code || '').toUpperCase().substring(0, p.length) !== p) return false;
}
// 关键词命中番号/标题/关联备注文本(作品备注与演员备注)
if (kw && (v.code + ' ' + (v.title || '')).toLowerCase().indexOf(kw) === -1 &&
workNoteText(v).toLowerCase().indexOf(kw) === -1) return false;
if (f.actors.length) {
// 归一化匹配:演员名去掉尾部括号标记后比较(如「逢沢はるか(無碼)」可命中库里存的「逢沢はるか」)
var normActors = f.actors.map(normActorName);
var has = (v.actors || []).some(function (x) { return x && normActors.indexOf(normActorName(x.name)) !== -1; });
if (!has) return false;
if (state.coactMode) {
var all = f.actors.every(function (name) {
return (v.actors || []).some(function (x) { return x && normActorName(x.name) === normActorName(name); });
});
if (!all) return false;
}
}
if (f.tags.length && !f.tags.some(function (t) { return (v.categories || []).some(function (x) { return x && x.text === t; }); })) return false;
if (f.makers.length && f.makers.indexOf((v.maker && v.maker.text) || '') === -1) return false;
if (f.series.length && f.series.indexOf((v.series && v.series.text) || '') === -1) return false;
if (f.directors.length && f.directors.indexOf((v.director && v.director.text) || '') === -1) return false;
if (f.years.length && f.years.indexOf((v.date || '').slice(0, 4)) === -1) return false;
if (f.scoreMax != null && f.scoreMax < 5.0) {
const sc = getMovieScore(v);
if (sc <= 0 || sc > f.scoreMax) return false;
}
// 只看有备注(筛选浮窗):作品本身或其演员/片商/系列带备注
if (state.notesOnly && !workHasNote(v)) return false;
// 工具条「仅显示备注作品」:只认作品级备注(详情页番号旁「備註」按钮写的),其它层级不计入
if (state.notesWorkOnly) {
var wn = v.code ? noteMap['code:' + v.code] : null;
if (!(wn && wn.content)) return false;
}
// 想看/看過筛选(筛选弹窗开关):单开=精确状态;双开=并集(任一命中即可)
if (state.reviewWanted || state.reviewWatched) {
var rsOk = (state.reviewWanted && v.reviewStatus === 'wishlist') ||
(state.reviewWatched && v.reviewStatus === 'watched');
if (!rsOk) return false;
}
// 金标筛选:命中任一所选金标名即可(多选 OR)
if (f.awards.length && !f.awards.some(function (t) { return (v.awards || []).some(function (x) { return x && x.name === t; }); })) return false;
// 只看金标:仅显示带 JAVDB 金标(awards)的作品
if (state.onlyAward && !(v.awards || []).length) return false;
// 只看未补全:缺关键字段(标题/封面/演员/评分)任一即视为未补全
if (state.incompleteOnly && !isIncomplete(v)) return false;
// 最近 N 天发行:按作品发行日期(date)判定所在区间;缺失/无法解析日期的条目视为不在区间内
if (state.recentDays > 0) {
const rel = Date.parse(String(v.date || '').trim());
if (isNaN(rel) || (Date.now() - rel) > state.recentDays * 86400000) return false;
}
return true;
});
// 排序
const s = state.sort;
renderList.sort(function (a, b) {
if (s === 'score_desc') return ((b.rating && b.rating.score) || 0) - ((a.rating && a.rating.score) || 0);
if (s === 'score_count_desc') return ((b.rating && b.rating.count) || 0) - ((a.rating && a.rating.count) || 0);
if (s === 'code_asc') return (a.code || '').localeCompare(b.code || '');
if (s === 'added_desc') return (b.listSyncedAt || 0) - (a.listSyncedAt || 0);
if (s === 'views_desc') return ((b.views || 0) - (a.views || 0)) || (b.date || '').localeCompare(a.date || '');
if (s === 'awards_asc') {
// 金标标识 正序(新规则):
// 优先级:JAVDB影片TOP250 > JAVDB有码影片TOP250 > JAVDB年度影片TOP250(年份从新到旧排) > 无码TOP250 > FC2 TOP250 > 欧美TOP250
// 相同类别/年份内按名次升序(No.1 排在 No.2 前);同名次或无名次按日期降序;最后按番号升序
var wa = getVideoBestAwardWeight(a);
var wb = getVideoBestAwardWeight(b);
if (wa !== wb) return wa - wb;
return (b.date || '').localeCompare(a.date || '') || (a.code || '').localeCompare(b.code || '');
}
return (b.date || '').localeCompare(a.date || ''); // date_desc
});
refreshChips();
renderGrid(true);
applyFavRandomBg();
}
/* ---------- 收藏夹随机番号封面墙背景(复用现有 blur 设置) ---------- */
let favBgTimer = null;
function applyFavRandomBg() {
if (!panel) return;
// 随机背景开关:关闭时清掉背景图,面板回退 CSS 深灰兜底(不透明,盖住原生内容)
const on = localStorage.getItem(SET_KEYS.randomBg);
if (on === '0') {
if (favBgEl) { favBgEl.style.backgroundImage = 'none'; favBgEl.style.filter = 'none'; }
panel.style.removeProperty('--efav-bg');
panel.style.removeProperty('--efav-bg-blur');
return;
}
clearTimeout(favBgTimer);
favBgTimer = setTimeout(function () {
const covers = Array.from(panel.querySelectorAll('#efav-grid .cover img, #efav-grid .box .cover img'))
.map(function (img) { return img.getAttribute('src') || ''; })
.filter(Boolean);
if (!covers.length) return;
const src = covers[Math.floor(Math.random() * covers.length)];
if (favBgEl) {
// 仅设背景图;模糊强度由 CSS filter:blur(var(--e-blur)) 跟随「背景模糊强度」设置,不单独定义
favBgEl.style.backgroundImage = 'url(' + src + ')';
}
}, 120);
}
/* ---------- 封面墙(分批渲染 + 哨兵懒加载) ---------- */
function renderGrid(reset) {
const wrap = document.getElementById('efav-grid-wrap');
if (!wrap) return;
if (reset) {
// 重建网格前收掉可能残留的大图气泡/遮罩(防筛选时气泡悬空、遮罩卡住)
try { dfPopupHide(); } catch (err) {}
// 销毁契约:清空 innerHTML 前先注销旧 grid,否则每筛选/排序一次就漏一个
// 绑定在已脱离文档节点上的 MutationObserver(详见 WaterfallEngine.unregisterTree)
try { WaterfallEngine.unregisterTree(wrap); } catch (err) {}
wrap.innerHTML = '';
renderedCount = 0;
if (sentinelObserver) { sentinelObserver.disconnect(); sentinelObserver = null; }
if (!renderList.length) {
wrap.appendChild(h('<div class="efav-empty"><span class="material-symbols-outlined">' +
((cache.videos || []).length ? 'search_off' : 'favorite') + '</span><p>' +
((cache.videos || []).length ? '没有符合条件的作品' : '尚无数据,点击上方「同步清单」开始抓取你的 JAVDB 收藏数据。或直接在「备份和恢复中」导入数据') +
'</p></div>'));
return;
}
const grid = document.createElement('div');
// 复用首页容器类:movie-list(网格) / +emby-masonry(瀑布流),样式完全同源
const favLayout = localStorage.getItem('javdbEmbyLayout') || 'grid';
grid.className = 'movie-list' + (favLayout === 'masonry' ? ' emby-masonry' : '') + (favLayout === 'dynamic-focus' ? ' emby-dynamic-focus' : '');
grid.id = 'efav-grid';
wrap.appendChild(grid);
// 动态聚焦:新网格必须重装网格级委托(旧网格已随 innerHTML 销毁),
// 否则筛选/排序后会出现「缩放卡死不收回 + 大图不弹出」的 BUG。
if (favLayout === 'dynamic-focus') { try { ensureDynamicFocusGrid(grid); } catch (err) {} }
// 瀑布流:注册引擎(横向顺序、最短列分配);分批追加由引擎 MutationObserver 自动跟排
if (favLayout === 'masonry') { try { WaterfallEngine.register(grid); } catch (err) {} }
const sentinel = h('<div class="efav-sentinel"></div>');
wrap.appendChild(sentinel);
sentinelObserver = new IntersectionObserver(function (es) {
if (es[0].isIntersecting) renderGrid(false);
}, { root: panel, rootMargin: '600px' });
sentinelObserver.observe(sentinel);
}
const grid = document.getElementById('efav-grid');
if (!grid) return;
const end = Math.min(renderedCount + BATCH, renderList.length);
const frag = document.createDocumentFragment();
for (let i = renderedCount; i < end; i++) frag.appendChild(buildCard(renderList[i]));
grid.appendChild(frag);
renderedCount = end;
}
function favLayoutIsMasonry() {
try { return (localStorage.getItem('javdbEmbyLayout') || 'grid') === 'masonry'; } catch (e) { return false; }
}
function buildCard(v) {
// 与首页完全相同的 DOM 结构(.item > a.box > .cover img + .video-title + .score + .meta),
// 直接吃到皮肤里 .movie-list/.item/.box 的全套样式与 hover 行为,效果与首页零差异。
const href = safeHref(v.detailHref, '/search?q=' + encodeURIComponent(v.code || ''));
const vidMatch = (v.detailHref || '').match(/\/v\/([^/?#]+)/);
const vidVal = vidMatch ? vidMatch[1] : (v.id || '');
const vidAttr = vidVal ? (' data-video-id="' + escapeAttr(vidVal) + '"') : '';
const item = h('<div class="item"' + vidAttr + (v.id ? ' data-movie-id="' + escapeAttr(v.id) + '"' : '') + (v.code ? ' data-code="' + escapeAttr(v.code) + '"' : '') + '></div>');
const box = h('<a class="box"' + vidAttr + (v.code ? ' data-code="' + escapeAttr(v.code) + '"' : '') + ' href="' + escapeAttr(href) + '" title="' + escapeAttr(v.title || '') + '"></a>');
const coverThumbUrl = v.coverThumb ? FAV.resolveImg(v.coverThumb) : (v.thumb_url ? FAV.resolveImg(v.thumb_url) : '');
const coverLargeUrl = v.coverLarge ? FAV.resolveImg(v.coverLarge) : (v.cover_url ? FAV.resolveImg(v.cover_url) : '');
const coverRaw = coverThumbUrl || coverLargeUrl || '';
const cover = coverRaw ? FAV.resolveImg(coverRaw) : '';
const rt = v.rating || null;
let scoreHtml = ' ';
if (rt && (rt.score > 0 || (rt.text && rt.text.indexOf('分') !== -1))) {
const scoreNum = rt.score > 0 ? parseFloat(rt.score) : (parseFloat(rt.text) || 0);
const scoreVal = scoreNum > 0 ? scoreNum.toFixed(1) : '0.0';
const starsHtml = renderStars(scoreVal);
const countTxt = rt.count ? '<span class="score-count" style="color:#94a3b8;font-size:11px;margin-left:4px;">(' + rt.count + '人)</span>' : '';
scoreHtml = '<span class="value"><span class="score-stars">' + starsHtml + '</span> ' + scoreVal + '分' + countTxt + '</span>';
} else if (rt && rt.text) {
scoreHtml = escapeHtml(rt.text);
}
const offShelf = v.detailError === '404';
// 演员 + 时长:写入 .box 的 data-*(供大图气泡信息条读取),
// 并追加 .efav-card-meta 信息行(grid/masonry 直接显示,dynamic-focus 隐藏、由气泡展示)
// 演员过滤男优(isMaleByHref 依赖 favGenderMap,reload 时加载);时长合并到 .meta 行与日期同行同色
const actorsTxt = (v.actors || []).filter(function (a) { return a && !isMaleByHref(a); }).map(function (a) { return a && a.name ? a.name : ''; }).filter(Boolean).join(', ');
// 方案 D 微型头像圆徽 + 方案 4 头像层叠堆叠圈共演感知与折叠
const femaleActors = (v.actors || []).filter(function (a) { return a && !isMaleByHref(a) && a.name; });
const gradColors = [
'linear-gradient(135deg, #ec4899, #8b5cf6)',
'linear-gradient(135deg, #00a4dc, #6366f1)',
'linear-gradient(135deg, #f59e0b, #ef4444)',
'linear-gradient(135deg, #10b981, #06b6d4)',
'linear-gradient(135deg, #8b5cf6, #d946ef)'
];
function renderActorBadge(a, isFolded) {
var nm = (a.href || '').match(/\/actors\/([^\/\?\#]+)/);
var sid = nm ? nm[1] : '';
var noteObj = sid ? noteMap['actor:' + sid] : null;
var noted = !!noteObj;
var isFav = isPluginEnabled('actressInfo') && (typeof FavoriteActressManager !== 'undefined') &&
(FavoriteActressManager.isFavorite(a.name) || (sid && FavoriteActressManager.isFavorite(sid)));
// 解析头像 URL 与回退首字
var avaUrl = '';
if (a.avatar) {
avaUrl = (typeof FAV !== 'undefined' && FAV.resolveImg) ? FAV.resolveImg(a.avatar) : a.avatar;
} else if (sid) {
var rawAva = 'https://c0.jdbstatic.com/avatars/' + sid.slice(0, 2).toLowerCase() + '/' + sid + '.jpg';
avaUrl = (typeof FAV !== 'undefined' && FAV.resolveImg && FAV.stripHost) ? FAV.resolveImg(FAV.stripHost(rawAva)) : rawAva;
}
var officialAva = avaUrl;
if (typeof GfriendsAvatarService !== 'undefined') {
avaUrl = GfriendsAvatarService.getAvatarUrl(a.name, officialAva);
}
var initial = (a.name || '').trim().charAt(0) || '♀';
var hash = 0;
for (var c = 0; c < a.name.length; c++) hash = (hash * 31 + a.name.charCodeAt(c)) & 0xffff;
var bgGrad = gradColors[Math.abs(hash) % gradColors.length];
var avaHtml = '<span class="efav-actor-ava-wrap">' +
(avaUrl ? '<img class="efav-actor-avatar" loading="lazy" referrerpolicy="no-referrer" data-gfriends-actress="' + escapeAttr(a.name) + '" data-official-avatar="' + escapeAttr(officialAva) + '" src="' + escapeAttr(avaUrl) + '" alt="" onerror="if(this.dataset.officialAvatar && this.src !== this.dataset.officialAvatar){this.src=this.dataset.officialAvatar;}else{this.style.display=\'none\';if(this.nextElementSibling)this.nextElementSibling.style.display=\'inline-flex\';}" />' : '') +
'<span class="efav-actor-ava-fallback" style="background:' + bgGrad + ';' + (avaUrl ? 'display:none;' : '') + '">' + escapeHtml(initial) + '</span>' +
'</span>';
// 提取真实备注文字(不显示“已备注”字样,直接呈现备注内容本身)
var noteContent = '';
if (noteObj) {
if (typeof noteObj === 'object' && noteObj.content) noteContent = String(noteObj.content).trim();
else if (typeof noteObj === 'string') noteContent = noteObj.trim();
}
var badgeTitle = noteContent ? escapeAttr(noteContent) : escapeAttr(a.name);
const safeActorHref = safeHref(a.href, '');
var clickAttr = (safeActorHref && safeActorHref !== '#') ? (' data-href="' + escapeAttr(safeActorHref) + '" onclick="if(this.dataset.href){event.preventDefault();event.stopPropagation();window.open(this.dataset.href,\'_blank\');}"') : '';
return '<span class="efav-actor-badge' + (isFav ? ' is-favorite' : '') + (noted ? ' is-noted' : '') + (isFolded ? ' efav-actor-folded' : '') + '" title="' + badgeTitle + '"' + clickAttr + '>' +
avaHtml +
'<span class="efav-actor-name">' + escapeHtml(a.name) + '</span>' +
'</span>';
}
var actorSpans = [];
if (femaleActors.length <= 4) {
actorSpans = femaleActors.map(function (a) { return renderActorBadge(a, false); });
} else {
// 前 3 位正常展示
for (var ai = 0; ai < 3; ai++) {
actorSpans.push(renderActorBadge(femaleActors[ai], false));
}
// 方案 4:头像层叠堆叠圈徽标(展示接下来 2~3 位的重叠微头像 + 剩余人数)
var stackCirclesHtml = '';
var stackSlice = femaleActors.slice(3, 6);
for (var sIdx = 0; sIdx < stackSlice.length; sIdx++) {
var sa = stackSlice[sIdx];
var sNm = (sa.href || '').match(/\/actors\/([^\/\?\#]+)/);
var sSid = sNm ? sNm[1] : '';
var sAva = '';
if (sa.avatar) {
sAva = (typeof FAV !== 'undefined' && FAV.resolveImg) ? FAV.resolveImg(sa.avatar) : sa.avatar;
} else if (sSid) {
var sRawAva = 'https://c0.jdbstatic.com/avatars/' + sSid.slice(0, 2).toLowerCase() + '/' + sSid + '.jpg';
sAva = (typeof FAV !== 'undefined' && FAV.resolveImg && FAV.stripHost) ? FAV.resolveImg(FAV.stripHost(sRawAva)) : sRawAva;
}
var sOfficialAva = sAva;
if (typeof GfriendsAvatarService !== 'undefined') {
sAva = GfriendsAvatarService.getAvatarUrl(sa.name, sOfficialAva);
}
var sInitial = (sa.name || '').trim().charAt(0) || '♀';
var sHash = 0;
for (var sc = 0; sc < sa.name.length; sc++) sHash = (sHash * 31 + sa.name.charCodeAt(sc)) & 0xffff;
var sGrad = gradColors[Math.abs(sHash) % gradColors.length];
stackCirclesHtml += '<span class="efav-stack-circle">' +
(sAva ? '<img src="' + escapeAttr(sAva) + '" alt="" loading="lazy" referrerpolicy="no-referrer" data-gfriends-actress="' + escapeAttr(sa.name) + '" data-official-avatar="' + escapeAttr(sOfficialAva) + '" onerror="if(this.dataset.officialAvatar && this.src !== this.dataset.officialAvatar){this.src=this.dataset.officialAvatar;}else{this.style.display=\'none\';if(this.nextElementSibling)this.nextElementSibling.style.display=\'inline-flex\';}" />' : '') +
'<span class="efav-stack-circle-fallback" style="background:' + sGrad + ';' + (sAva ? 'display:none;' : '') + '">' + escapeHtml(sInitial) + '</span>' +
'</span>';
}
var remainCount = femaleActors.length - 3;
var remainNames = femaleActors.slice(3).map(function (a) { return a.name; }).join(', ');
var stackTitle = escapeAttr('共演女优 (还有' + remainCount + '位): ' + remainNames);
var stackBadgeHtml = '<span class="efav-actor-stack-badge" title="' + stackTitle + '" onclick="event.preventDefault();event.stopPropagation();var p=this.closest(\'.efav-card-actors\');if(p){p.classList.add(\'is-expanded\');try{if(typeof WaterfallEngine!==\'undefined\'&&typeof WaterfallEngine.refresh===\'function\')WaterfallEngine.refresh();}catch(e){}}">' +
'<span class="efav-stack-avatars">' + stackCirclesHtml + '</span>' +
'<span class="efav-stack-count">+' + remainCount + '</span>' +
'</span>';
actorSpans.push(stackBadgeHtml);
// 剩余演员(折叠时隐藏,展开时展示)
for (var aj = 3; aj < femaleActors.length; aj++) {
actorSpans.push(renderActorBadge(femaleActors[aj], true));
}
// 收起按钮(展开后在末尾显示)
var collapseBadgeHtml = '<span class="efav-actor-collapse-badge" title="收起共演演员" onclick="event.preventDefault();event.stopPropagation();var p=this.closest(\'.efav-card-actors\');if(p){p.classList.remove(\'is-expanded\');try{if(typeof WaterfallEngine!==\'undefined\'&&typeof WaterfallEngine.refresh===\'function\')WaterfallEngine.refresh();}catch(e){}}">' +
'<span>收起</span><span class="material-symbols-outlined">expand_less</span>' +
'</span>';
actorSpans.push(collapseBadgeHtml);
}
const durTxt = v.duration || '';
const seriesTxt = (v.series && v.series.text) ? v.series.text : '';
const directorTxt = (v.director && v.director.text) ? v.director.text : '';
const makerTxt = (v.maker && v.maker.text) ? v.maker.text : '';
if (actorsTxt) box.setAttribute('data-actors', actorsTxt);
if (seriesTxt) box.setAttribute('data-series', seriesTxt);
if (directorTxt) box.setAttribute('data-director', directorTxt);
if (makerTxt) box.setAttribute('data-maker', makerTxt);
// 备注(行内斜体橙色,附最后一行末尾):仅显示作品级备注;设置全局开关可关。
// 作品级备注写入 data-note 供悬停封面预览展示;演员/片商/系列只写 data-*-noted 标记(标橙不展示文本)
const noteTxt = notesShown() ? workNoteDisplay(v) : '';
if (noteTxt) box.setAttribute('data-note', noteTxt);
const noteHtml = noteTxt
? '<span class="efav-card-note" title="' + escapeAttr(noteTxt) + '"><span class="material-symbols-outlined">history_edu</span><i>' + escapeHtml(noteTxt) + '</i></span>'
: '';
try {
// 带备注的演员名单单独存:悬停预览里逐个名字标橙,共演未备注者不着色
var notedActors = [];
(v.actors || []).forEach(function (a) {
var nm = (a.href || '').match(/\/actors\/([^\/\?\#]+)/);
if (nm && noteMap['actor:' + nm[1]] && a.name) notedActors.push(a.name);
});
if (notedActors.length) box.setAttribute('data-actors-noted', notedActors.join(', '));
var mkN = ((v.maker && v.maker.href) || '').match(/\/makers\/([^\/\?\#]+)/);
if (mkN && noteMap['maker:' + mkN[1]]) box.setAttribute('data-maker-noted', '1');
var seN = ((v.series && v.series.href) || '').match(/\/series\/([^\/\?\#]+)/);
if (seN && noteMap['series:' + seN[1]]) box.setAttribute('data-series-noted', '1');
} catch (e) {}
let actorLineHtml = actorsTxt
? '<div class="efav-card-meta"><span class="efav-card-actors" title="' + escapeAttr(actorsTxt) + '">' + actorSpans.join('') + '</span>' + noteHtml + '</div>'
: '';
// 无封面:留空(.cover 有深色底),避免 src="" 让浏览器把当前页面 URL 当图片请求
const coverImgHtml = cover
? '<img loading="lazy" referrerpolicy="no-referrer" src="' + escapeAttr(cover) + '"' +
(coverLargeUrl ? ' data-large="' + escapeAttr(coverLargeUrl) + '"' : '') +
(coverThumbUrl ? ' data-thumb="' + escapeAttr(coverThumbUrl) + '"' : '') +
' alt="" onerror="this.onerror=null;if(this.dataset.large&&this.src!==this.dataset.large){this.src=this.dataset.large;}else if(this.src.indexOf(\'/small_covers/\')!==-1){this.src=this.src.replace(\'/small_covers/\',\'/thumbs/\');}else if(this.src.indexOf(\'c0.jdbstatic.com\')===-1){this.src=this.src.replace(/https:\\/\\/.*?\\/rhe951l4q/g,\'https://c0.jdbstatic.com\');}">'
: '';
// 金标奖章:明黄皇冠胶囊(方案 A:微型皇冠 + 粗体 TOP250 + 最佳名次 + 多标角标)
const awardIcon = buildAwardBadgeHtml(v.awards);
if ((v.awards || []).length) box.setAttribute('data-awards', JSON.stringify(v.awards));
const rank = v._topRank;
const rankCls = rank === 1 ? 'top1' : (rank === 2 ? 'top2' : (rank === 3 ? 'top3' : ''));
const topBadge = rank ? '<div class="top250-rank-badge ' + rankCls + '">' + (rank === 1 ? '👑 ' : '') + '#' + rank + '</div>' : '';
box.innerHTML =
topBadge +
'<div class="cover">' + coverImgHtml +
(offShelf ? '<span class="efav-offshelf" title="该作品已从 JAVDB 下架">已下架</span>' : '') + '</div>' +
'<div class="video-title">' + awardIcon + (v.detailComplete ? '' : '<span class="efav-dot" title="信息未补全"></span> ') +
'<strong>' + escapeHtml(v.code || '') + '</strong> ' + escapeHtml(v.title || '') + '</div>' +
'<div class="score">' + scoreHtml + '</div>' +
'<div class="meta">' + escapeHtml(v.date || '') + (durTxt ? ' · ' + escapeHtml(durTxt) : '') + (noteHtml && !actorsTxt ? ' ' + noteHtml : '') + '</div>' +
actorLineHtml +
'<span class="emby-card-shine"></span>';
// 首页同款:瀑布流错落比例挂在 box 上;3D 倾斜 + 高光绑定整个 box
box.style.setProperty('--mz-ar', String(masonryAspect(cover || v.code, v.code)));
item.appendChild(box);
try {
// STEAM 风格卡牌 3D 倾斜 + 辉光:在 item 装入 box 后绑定,确保 attachCard3D 能找到最近的 .item
attachCard3D(box);
if ((localStorage.getItem('javdbEmbyLayout') || 'grid') === 'dynamic-focus') attachDynamicFocus(box);
} catch (e) {}
// 离开前记录滚动位置(配合状态持久化,返回后可还原)
box.addEventListener('click', function () { if (FAV.incView) FAV.incView(v.code); saveState(); });
return item;
}
/* ---------- 状态持久化(从详情页返回不丢筛选/排序/滚动) ---------- */
const STATE_KEY = 'javdbEmbyFavState';
function saveState() {
try {
sessionStorage.setItem(STATE_KEY, JSON.stringify({
scope: state.scope, keyword: state.keyword, sort: state.sort,
coactMode: !!state.coactMode, showActorAvatars: !!state.showActorAvatars,
freqSort: !!state.freqSort, hideMaleActors: !!state.hideMaleActors,
onlyAward: !!state.onlyAward,
incompleteOnly: !!state.incompleteOnly, notesOnly: !!state.notesOnly,
notesWorkOnly: !!state.notesWorkOnly,
reviewWanted: !!state.reviewWanted, reviewWatched: !!state.reviewWatched,
recentDays: state.recentDays || 0,
filters: state.filters,
scrollTop: panel ? panel.scrollTop : 0,
rendered: renderedCount
}));
} catch (e) {}
}
function restoreState() {
try {
const raw = sessionStorage.getItem(STATE_KEY);
if (!raw) return null;
const s = JSON.parse(raw);
if (typeof s.coactMode === 'boolean') state.coactMode = s.coactMode;
if (typeof s.showActorAvatars === 'boolean') state.showActorAvatars = s.showActorAvatars;
if (s.actorAvatarSize === 'large' || s.actorAvatarSize === 'small') state.actorAvatarSize = s.actorAvatarSize;
if (typeof s.freqSort === 'boolean') state.freqSort = s.freqSort;
if (typeof s.hideMaleActors === 'boolean') state.hideMaleActors = s.hideMaleActors;
if (typeof s.onlyAward === 'boolean') state.onlyAward = s.onlyAward;
if (typeof s.incompleteOnly === 'boolean') state.incompleteOnly = s.incompleteOnly;
if (typeof s.notesOnly === 'boolean') state.notesOnly = s.notesOnly;
if (typeof s.notesWorkOnly === 'boolean') state.notesWorkOnly = s.notesWorkOnly;
if (typeof s.reviewWanted === 'boolean') state.reviewWanted = s.reviewWanted;
if (typeof s.reviewWatched === 'boolean') state.reviewWatched = s.reviewWatched;
if (s.recentDays === 7 || s.recentDays === 30 || s.recentDays === 90 || s.recentDays === 0) state.recentDays = s.recentDays;
return s;
} catch (e) { return null; }
}
/* ---------- 获取和整理收藏(合并:同步清单 / 补全信息 / 刷新详情) ---------- */
function collectBtn(ic, txt, sub, fn) {
const b = h('<button class="efav-collect-item">' +
'<span class="ci-ic material-symbols-outlined">' + ic + '</span>' +
'<span class="ci-txt"><b>' + txt + '</b><i>' + sub + '</i></span></button>');
b.addEventListener('click', function () {
// 自包含关闭弹窗:mask 是 openCollectModal 的局部变量,兄弟函数闭包取不到
//(旧代码引用未定义的 mask → ReferenceError,且排在 fn() 之前 → 四个功能按钮全部点了没反应)
const m = b.closest('.efav-modal-mask');
if (m) m.remove();
fn();
});
return b;
}
/* ---------- 同步清单:先选择清单,再只同步勾选的(避免每次全量重跑) ----------
* 流程:点击「同步清单」→ 抓取全部清单索引(仅索引,快)→ 弹窗勾选要同步的清单
* (新清单自动勾选并置顶,本地已有清单默认沿用上次选择)→ 确定 → runSync(选中id)。
* 只同步选中清单:未选清单的作品不清除、不抓取、计数保留,下次新增清单时增量同步即可。 */
function openSyncListSelect() {
if (FAV.isBusy()) return;
let mask = document.getElementById('efav-sync-select-mask');
if (mask) mask.remove();
mask = h('<div class="efav-modal-mask" id="efav-sync-select-mask"><div class="efav-modal"></div></div>');
const modal = mask.querySelector('.efav-modal');
modal.innerHTML = '<h3><span class="material-symbols-outlined">playlist_add_check</span>选择要同步的清单</h3>' +
'<p class="efav-dim" style="margin:.2em 0 .6em;color:var(--e-text2);font-size:.85em;">' +
'勾选需要拉取作品的清单后开始同步。新增清单已自动勾选;本地已有清单默认沿用上次选择,可手动调整。</p>' +
'<div class="efav-sync-select-body"><p class="efav-dim">正在获取清单列表…</p></div>' +
'<div class="foot efav-sync-select-foot">' +
'<button class="efav-btn" data-a="all">全选</button>' +
'<button class="efav-btn" data-a="none">全不选</button>' +
'<span class="sel-info"></span>' +
'<button class="efav-btn" data-a="cancel">取消</button>' +
'<button class="efav-btn accent" data-a="ok">开始同步</button></div>';
const body = modal.querySelector('.efav-sync-select-body');
document.body.appendChild(mask);
requestAnimationFrame(function () { mask.classList.add('open'); });
mask.addEventListener('click', function (e) { if (e.target === mask) mask.remove(); });
(async function () {
let lists = [];
try {
lists = await FAV.fetchAllListIndex('/users/lists');
} catch (e) {
body.innerHTML = '<p class="efav-dim">获取清单列表失败:' + escapeHtml(e.message) + '</p>';
return;
}
if (!lists.length) {
body.innerHTML = '<p class="efav-dim">未解析到任何清单,请确认已登录 JAVDB 且有「我的清单」。</p>';
return;
}
// 本地已有清单(判断哪些是新增);本地作品归属也计入「已有」
let localIds = {};
try {
const cache = await FAV.getAll();
(cache.lists || []).forEach(function (l) { localIds[l.id] = 1; });
const vids = cache.videos || [];
vids.forEach(function (v) {
(v.listIds || []).forEach(function (id) { localIds[id] = 1; });
});
} catch (e) {}
// 上次选择记忆(localStorage,json 数组)
let prevSel = [];
try { prevSel = JSON.parse(localStorage.getItem('efavSyncSelList') || '[]') || []; } catch (e) {}
const prevSet = {};
prevSel.forEach(function (id) { prevSet[id] = 1; });
// 排序:新清单(本地无记录)在前
lists.sort(function (a, b) {
const an = localIds[a.id] ? 1 : 0, bn = localIds[b.id] ? 1 : 0;
return an - bn;
});
body.innerHTML = '';
const checked = { n: 0 };
const cbxs = [];
const selInfo = modal.querySelector('.sel-info');
lists.forEach(function (l) {
const isNew = !localIds[l.id];
const defChecked = isNew ? true : !!prevSet[l.id];
if (defChecked) checked.n++;
const lb = h('<label class="efav-sync-item' + (isNew ? ' is-new' : '') + '">' +
'<input type="checkbox" value="' + escapeAttr(l.id) + '"' + (defChecked ? ' checked' : '') + '>' +
'<span class="nm">' + escapeHtml(l.name) + '</span>' +
'<span class="meta">' + (l.count || 0) + ' 部' + (isNew ? ' · 新增' : '') + '</span></label>');
const cb = lb.querySelector('input');
cb.addEventListener('change', function () {
checked.n += cb.checked ? 1 : -1;
if (selInfo) selInfo.textContent = '已选 ' + checked.n + ' 个清单';
});
cbxs.push(cb);
body.appendChild(lb);
});
if (selInfo) selInfo.textContent = '已选 ' + checked.n + ' 个清单';
const allBtn = modal.querySelector('[data-a=all]');
const noneBtn = modal.querySelector('[data-a=none]');
if (allBtn) allBtn.addEventListener('click', function () {
cbxs.forEach(function (cb) { if (!cb.checked) { cb.checked = true; checked.n++; } });
if (selInfo) selInfo.textContent = '已选 ' + checked.n + ' 个清单';
});
if (noneBtn) noneBtn.addEventListener('click', function () {
cbxs.forEach(function (cb) { if (cb.checked) { cb.checked = false; checked.n--; } });
if (selInfo) selInfo.textContent = '已选 ' + checked.n + ' 个清单';
});
modal.querySelector('[data-a=cancel]').addEventListener('click', function () { mask.remove(); });
modal.querySelector('[data-a=ok]').addEventListener('click', function () {
const sel = cbxs.filter(function (cb) { return cb.checked; }).map(function (cb) { return cb.value; });
if (!sel.length) { alert('请至少勾选一个清单'); return; }
try { localStorage.setItem('efavSyncSelList', JSON.stringify(sel)); } catch (e) {}
mask.remove();
showConc();
FAV.runSync(sel).then(reload);
});
})();
}
function openCollectModal() {
let mask = document.getElementById('efav-collect-mask');
if (mask) mask.remove();
mask = h('<div class="efav-modal-mask" id="efav-collect-mask"><div class="efav-modal"></div></div>');
const modal = mask.querySelector('.efav-modal');
modal.innerHTML = '<h3><span class="material-symbols-outlined">auto_awesome</span>获取和整理收藏</h3>' +
'<p class="efav-dim" style="margin:.2em 0 1em;color:var(--e-text2);font-size:.85em;">选择一项操作,整理你的 JAVDB 收藏数据:</p>' +
'<div class="efav-collect-list"></div>';
const list = modal.querySelector('.efav-collect-list');
list.appendChild(collectBtn('sync', '同步清单', '先选择要同步的清单,再拉取其作品(新增清单可只同步新增,无需全量重跑)', function () {
openSyncListSelect();
}));
list.appendChild(collectBtn('done_all', '同步想看/看過', '从 JAVDB 想看/看過专属页面快速同步标记状态(通常几分钟内完成)', function () {
if (FAV.isBusy()) return;
if (confirm('同步想看/看過标记状态:\n'
+ '· 抓取 JAVDB 的想看/看過两个专属页面(含分页)\n'
+ '· 线上有、本地无 → 补标记并入库(仅标记的作品也会入库可筛)\n'
+ '· 线上已取消 → 清除本地标记(仅标记不入清单的记录会移除)\n\n确定开始吗?')) { showConc(); FAV.syncReviewStatus().then(reload); }
}));
list.appendChild(collectBtn('downloading', '补全信息', '仅抓取库内尚未补全详情的作品(增量、不重复、耗时取决于未补全作品数量)', function () {
if (FAV.isBusy()) return;
if (!hasSyncedBefore()) { alert('⚠ 尚未同步清单\n\n请先点击「同步清单」拉取你的 JAVDB 清单,\n同步完成后再用「补全信息」抓取详情。'); return; }
if (confirm('⚠ 补全信息(仅抓未补全的作品)\n\n'
+ '· 只对库中尚未补全详情的作品逐个拉取详情页\n'
+ '· 已补全的作品不会重复抓取\n'
+ '· 量大时耗时较长,可随时暂停\n\n确定开始补全吗?')) { showConc(); FAV.runComplete().then(reload); }
}));
list.appendChild(collectBtn('refresh', '刷新详情', '强制全量重抓所有作品的详情(耗时较长、可暂停)', function () {
if (FAV.isBusy()) return;
if (!hasSyncedBefore()) { alert('⚠ 尚未同步清单\n\n请先点击「同步清单」拉取你的 JAVDB 清单,\n同步完成后再用「刷新详情」重抓元数据。'); return; }
if (confirm('⚠ 刷新详情(全量重抓,强制覆盖)\n\n'
+ '· 将对库内【所有】作品逐个重抓详情页,覆盖为线上最新\n'
+ '· 用于跟进演员改名、信息补录等线上变动\n'
+ '· 全部番号重抓,耗时非常长,可随时暂停\n\n确定要花较长时间全量刷新吗?')) { showConc(); FAV.runComplete(true).then(reload); }
}));
list.appendChild(collectBtn('wc', '识别性别', '逐个访问演员主页,标记男优(识别一次永久生效)', function () {
if (FAV.isBusy()) return;
if (!hasSyncedBefore()) { alert('⚠ 尚未同步清单\n\n请先点击「同步清单」拉取你的 JAVDB 清单,\n同步完成后再用「识别性别」判定男优。'); return; }
if (confirm('识别演员性别:逐个访问演员主页,副标题含「男優」即记为男优。识别一次永久生效,之后筛选弹窗可隐藏男优。演员较多时耗时较长,可随时暂停。\n\n确定开始识别吗?')) { showConc(); FAV.detectActorGenders().then(reload); }
}));
list.appendChild(collectBtn('favorite', '同步已收藏演员', '从 JAVDB 个人收藏演员页面快速同步已关注女优(点亮头像小红心徽章)', function () {
showToast('正在同步 JAVDB 收藏演员…');
FavoriteActressManager.sync(function (msg) { showToast(msg); }).then(function (total) {
const count = FavoriteActressManager.getCount();
showToast('✅ 演员同步完成!已收录 ' + count + ' 位女优');
alert('✅ JAVDB 已收藏演员同步完成!\n\n已成功收录 ' + count + ' 位演员。\n详情页演员头像将自动点亮专属小红心标记。');
}).catch(function (err) {
showToast('❌ 同步失败: ' + (err && err.message));
alert('❌ 同步演员失败:\n\n' + (err && err.message) + '\n\n建议排查:\n1. 请检查当前浏览器是否已在 JAVDB 登录;\n2. 若页面被 Cloudflare 验证拦截,请先新开标签页访问 javdb.com 完成人机验证;\n3. 也可直接在女优悬停卡片上点击右上角「♡ 收藏」一键点亮红心。');
});
}));
mask.addEventListener('click', function (e) { if (e.target === mask) mask.remove(); });
document.body.appendChild(mask);
requestAnimationFrame(function () { mask.classList.add('open'); });
}
/* ---------- 备份和恢复(合并入口弹窗) ---------- */
function openBackupModal() {
let mask = document.getElementById('efav-backup-mask');
if (mask) mask.remove();
mask = h('<div class="efav-modal-mask" id="efav-backup-mask"><div class="efav-modal"></div></div>');
const modal = mask.querySelector('.efav-modal');
modal.innerHTML = '<h3><span class="material-symbols-outlined">file_json</span>备份和恢复</h3>' +
'<p class="efav-dim" style="margin:.2em 0 1em;color:var(--e-text2);font-size:.85em;">选择一项操作:</p>' +
'<div class="efav-collect-list"></div>';
const list = modal.querySelector('.efav-collect-list');
function entry(icSym, txt, sub, fn) {
const b = h('<button class="efav-collect-item">' +
'<span class="ci-ic material-symbols-outlined">' + icSym + '</span>' +
'<span class="ci-txt"><b>' + txt + '</b><i>' + sub + '</i></span></button>');
b.addEventListener('click', function () { mask.remove(); fn(); });
return b;
}
list.appendChild(entry('backup_table', '备份', '导出全部数据(清单/影片/画廊/演员性别表/备注/元数据)为 JSON 文件', function () {
FAV.exportJSON();
}));
list.appendChild(entry('database_upload', '恢复', '从 JSON 文件恢复:合并(字段更全者胜)或覆盖(清空本地后完整还原);兼容旧备份文件', function () {
doImport();
}));
list.appendChild(entry('database', '前往数据管理设置', '打开主设置面板中的数据管理与云端备份配置,进行更高级的管理', function () {
openSettingsPanel('data');
}));
mask.addEventListener('click', function (e) { if (e.target === mask) mask.remove(); });
document.body.appendChild(mask);
requestAnimationFrame(function () { mask.classList.add('open'); });
}
/* ---------- 筛选浮窗(galleryMode=true 为画廊模式:候选来自画廊已有作品,元数据与收藏库 join) ---------- */
async function openFilterModal(galleryMode) {
const isTop250 = galleryMode === 'top250';
const isGallery = galleryMode === true;
const isFav = !galleryMode;
let mask = document.getElementById('efav-filter-mask');
if (mask) mask.remove();
mask = h('<div class="efav-modal-mask" id="efav-filter-mask"><div class="efav-modal"></div></div>');
const modal = mask.querySelector('.efav-modal');
modal.innerHTML = '<h3><span class="material-symbols-outlined">filter_alt</span>' +
(isTop250 ? 'TOP250 筛选条件' : (isGallery ? '画廊筛选条件' : '筛选条件')) +
'<button type="button" class="efav-modal-close material-symbols-outlined" title="关闭">close</button></h3>' +
'<div class="efav-modal-body"></div>';
const modalBody = modal.querySelector('.efav-modal-body');
const closeBtn = modal.querySelector('.efav-modal-close');
if (closeBtn) closeBtn.addEventListener('click', function () { mask.remove(); });
// 折叠状态记忆:读取用户偏好,未设置时默认全部展开 (false)
let collapsedMap = {};
try {
const savedCol = localStorage.getItem(SET_KEYS.filterCollapsed);
if (savedCol) collapsedMap = JSON.parse(savedCol) || {};
} catch (e) { collapsedMap = {}; }
function saveCollapsed(k, val) {
collapsedMap[k] = !!val;
try { localStorage.setItem(SET_KEYS.filterCollapsed, JSON.stringify(collapsedMap)); } catch (e) {}
}
// 顶部开关区(共演筛选 / 演员头像 / 头像尺寸滑动切换),方便用户优先点击
// 开关分三行:第一行 展示类开关;第二行 状态筛选开关;第三行 最近发行快捷
// 画廊模式:只展示与画廊预览筛选相关的开关(共演/头像/尺寸/隐藏男优/只看金标),
// 收藏夹专属开关(常浏览排序/只看未补全/只看有备注/想看/看過/最近发行)不展示
const toggleBar = h('<div class="efav-filter-rows"></div>');
toggleBar.appendChild(h('<div class="efav-filter-toggles">' +
'<label class="efav-toggle"><input type="checkbox" id="efav-coact-mode"> 共演筛选(交集)</label>' +
'<label class="efav-toggle"><input type="checkbox" id="efav-show-actor-avatars"> 演员头像</label>' +
'<label class="efav-toggle efav-size-toggle">头像尺寸' +
'<span class="efav-switch"><input type="checkbox" id="efav-avatar-size"><i></i></span>' +
'<span class="efav-size-label">小</span></label>' +
(isFav ? '<label class="efav-toggle"><input type="checkbox" id="efav-freq-sort"> 常浏览排序</label>' : '') +
'<label class="efav-toggle"><input type="checkbox" id="efav-hide-male"> 隐藏男优</label>' +
((isGallery || isFav) ? '<label class="efav-toggle" title="只显示带 JAVDB 金标(awards)的作品;勾选后下方候选随之联动"><input type="checkbox" id="efav-only-award"> 只看金标</label>' : '') +
'</div>'));
if (isFav) {
toggleBar.appendChild(h('<div class="efav-filter-toggles">' +
'<label class="efav-toggle" title="缺标题 / 封面 / 演员 / 评分任一关键字段即视为未补全;勾选后下方候选随之联动"><input type="checkbox" id="efav-incomplete-only"> 只看未补全</label>' +
'<label class="efav-toggle" title="仅显示带备注的作品——作品本身(详情页番号旁「備註」按钮)或其演员/片商/系列带备注即命中;勾选后下方候选随之联动"><input type="checkbox" id="efav-notes-only"> 只看有备注</label>' +
'<label class="efav-toggle" title="只显示想看的作品(JAVDB 标记状态;与「看過」同开为并集);勾选后下方候选随之联动"><input type="checkbox" id="efav-review-wanted"> 想看</label>' +
'<label class="efav-toggle" title="只显示看過的作品(与「想看」同开为并集);勾选后下方候选随之联动"><input type="checkbox" id="efav-review-watched"> 看過</label>' +
'</div>'));
toggleBar.appendChild(h('<div class="efav-filter-toggles">' +
'<span class="efav-toggle efav-recent">最近发行' +
'<button type="button" class="efav-op" data-days="7">7天</button>' +
'<button type="button" class="efav-op" data-days="30">30天</button>' +
'<button type="button" class="efav-op" data-days="90">90天</button>' +
'<button type="button" class="efav-op off" data-days="0">全部</button></span>' +
'</div>'));
}
modalBody.appendChild(toggleBar); // 放入内部滚动区域
const coactCb = toggleBar.querySelector('#efav-coact-mode');
const avatarCb = toggleBar.querySelector('#efav-show-actor-avatars');
const sizeCb = toggleBar.querySelector('#efav-avatar-size');
const freqCb = toggleBar.querySelector('#efav-freq-sort');
const hideMaleCb = toggleBar.querySelector('#efav-hide-male');
const onlyAwardCb = toggleBar.querySelector('#efav-only-award');
const incompleteCb = toggleBar.querySelector('#efav-incomplete-only');
const notesCb = toggleBar.querySelector('#efav-notes-only');
const wantedCb = toggleBar.querySelector('#efav-review-wanted');
const watchedCb = toggleBar.querySelector('#efav-review-watched');
const recentBtns = toggleBar.querySelectorAll('.efav-recent .efav-op');
const sizeLbl = toggleBar.querySelector('.efav-size-label');
// TOP250模式走 Top250ViewManager.getFilter(),画廊模式走 galleryFilter,收藏夹模式走 state,互不污染
const cur = isTop250 ? (typeof Top250ViewManager !== 'undefined' ? Top250ViewManager.getFilter() : {}) : (isGallery ? galleryFilter : state);
coactCb.checked = !!cur.coactMode;
avatarCb.checked = !!cur.showActorAvatars;
sizeCb.checked = cur.actorAvatarSize === 'large';
if (freqCb) freqCb.checked = !!cur.freqSort;
hideMaleCb.checked = !!cur.hideMaleActors;
if (onlyAwardCb) onlyAwardCb.checked = !!cur.onlyAward;
// 收藏夹专属开关(画廊模式 toggleBar 不构建 → 元素为 null,必须空保护,否则 openFilterModal 中断、弹窗不出现)
if (incompleteCb) incompleteCb.checked = !!cur.incompleteOnly;
if (notesCb) notesCb.checked = !!cur.notesOnly;
if (wantedCb) wantedCb.checked = !!cur.reviewWanted;
if (watchedCb) watchedCb.checked = !!cur.reviewWatched;
// 状态类开关即时联动:只看有备注/只看未补全/想看/看過 —— 勾选后候选与计数实时变化
if (notesCb) notesCb.addEventListener('change', function () { modalNotesOnly = notesCb.checked; refreshModalDims(); });
if (incompleteCb) incompleteCb.addEventListener('change', function () { modalIncomplete = incompleteCb.checked; refreshModalDims(); });
if (wantedCb) wantedCb.addEventListener('change', function () { modalWanted = wantedCb.checked; refreshModalDims(); });
if (watchedCb) watchedCb.addEventListener('change', function () { modalWatched = watchedCb.checked; refreshModalDims(); });
recentBtns.forEach(function (b) { b.classList.toggle('off', parseInt(b.dataset.days, 10) !== state.recentDays); });
sizeLbl.textContent = cur.actorAvatarSize === 'large' ? '大' : '小';
// 候选源(收藏夹模式按清单范围;画廊模式按画廊已有作品 join 收藏库元数据)。
// 「只看有备注」勾选后即时联动:候选/计数只统计有备注的作品
//(modalNotesOnly 是弹窗内即时态,确定时才随 notesCb 落到 state)
const scopeSet2 = state.scope.length ? state.scope : null;
// 弹窗内即时态(候选实时联动;确定时才落到 state 应用到主列表)
let modalNotesOnly = !!cur.notesOnly;
let modalIncomplete = !!cur.incompleteOnly;
let modalWanted = !!cur.reviewWanted;
let modalWatched = !!cur.reviewWatched;
let modalOnlyAward = !!cur.onlyAward;
function modalVids() {
let vs;
if (isTop250) {
vs = (typeof Top250ViewManager !== 'undefined' && Top250ViewManager.getCachedMovies) ? Top250ViewManager.getCachedMovies() : [];
} else if (isGallery) {
vs = gallerySourceVids();
} else {
vs = (cache.videos || []).filter(function (v) {
const isFav = (Array.isArray(v.listIds) && v.listIds.length > 0) || !!v.reviewStatus;
if (!isFav) return false;
return !scopeSet2 || (v.listIds || []).some(function (id) { return scopeSet2.indexOf(id) !== -1; });
});
}
if (modalNotesOnly) vs = vs.filter(workHasNote);
if (modalIncomplete) vs = vs.filter(function (v) { return isIncomplete(v); });
if (modalWanted || modalWatched) {
vs = vs.filter(function (v) {
return (modalWanted && v.reviewStatus === 'wishlist') || (modalWatched && v.reviewStatus === 'watched');
});
}
// 只看金标:仅保留带 JAVDB 金标(awards)的作品(画廊模式读 galleryFilter,弹窗内即时联动)
if ((isGallery ? !!galleryFilter.onlyAward : (isTop250 ? !!cur.onlyAward : modalOnlyAward))) {
vs = vs.filter(function (v) { return (v.awards || []).length > 0; });
}
return vs;
}
// 头像查找源 = 当前候选作品集(draw 中按演员名匹配第一条带 avatar 的记录)
// 注意:必须在此处初始化(modalVids 定义之后),且先于 refreshModalDims 使用
let vids = modalVids();
// 候选实时刷新:状态类开关勾选/取消时重算全部维度并重绘
function refreshModalDims() {
vids = modalVids(); // 候选集变化时同步头像查找源(修复:draw 曾引用未定义的 vids 导致启用头像后演员全部消失)
dims.forEach(function (d) { d.opts = computeDimOpts(d); });
allDraws.forEach(function (fn) { fn(''); });
}
// 演员性别表:slug -> 'M'|'F'。开启「隐藏男优」时,演员维度候选剔除已识别为男的。
// 非阻塞读取:失败仅降级「隐藏男优」功能,不影响弹窗显示(避免 await 异常导致筛选弹窗不出现)。
let genderMap = {};
try { genderMap = await FAV.dbAllActorGenders(); }
catch (e) { log('读取性别表失败(隐藏男优降级): ' + e.message); }
log('[DIAG] openFilterModal genderMap 载入, 已知性别数=' + Object.keys(genderMap).length);
function isMaleActor(actor) {
if (!actor) return false;
const slug = (actor.href || '').match(/\/actors\/([^\/\?\#]+)/);
return !!(slug && slug[1] && genderMap[slug[1]] === 'M');
}
// 候选源(可切换):默认全量候选(modalVids);共演筛选开启且已选演员时切换为「交集作品集」,
// 使弹窗内所有维度候选/计数实时反映与已选演员的共演交集(需求:点击共演筛选后实时更新)
let dimVidSource = modalVids;
// 共演交集作品集:仅保留包含全部已选演员(归一化匹配)的作品
function coactVids(selected) {
const sel = (selected || []).map(normActorName);
if (!sel.length) return modalVids();
return modalVids().filter(function (v) {
const names = (v.actors || []).map(function (a) { return a && a.name ? normActorName(a.name) : ''; });
return sel.every(function (s) { return names.indexOf(s) !== -1; });
});
}
// 依据当前开关+已选演员刷新候选源(供共演开关/演员点击时调用)
function refreshDimVidSource() {
if (coactCb && coactCb.checked && tmp && (tmp.actors || []).length) {
dimVidSource = function () { return coactVids(tmp.actors); };
} else {
dimVidSource = modalVids;
}
}
// 收藏夹专属开关读取(画廊模式无这些开关 → 恒 false,避免 undefined 参与排序/重绘)
function freqSortActive() { return !galleryMode && !!state.freqSort; }
function tally(getVals) {
const m = {};
dimVidSource().forEach(function (v) {
(getVals(v) || []).forEach(function (x) {
if (!x) return;
if (!m[x]) m[x] = { v: x, n: 0, views: 0 };
m[x].n += 1;
m[x].views += (typeof v.views === 'number' ? v.views : 0);
});
});
const arr = Object.keys(m).map(function (k) { return m[k]; });
// 常浏览排序开:按关联作品浏览总数降序;否则按出现次数降序
arr.sort(function (a, b) { return (freqSortActive() ? b.views - a.views : b.n - a.n); });
return arr;
}
// 「只看有备注」开启时:演员/系列/片商三维度候选 = 「已做备注的实体本身」,
// 不再把共演演员/其它系列/其它片商连带算进有备注范围——保留按备注筛选的视图意义。
// 计数 = 该实体在当前候选作品集内的作品数;候选名取作品库里的实际存名(保证点选后按名精确匹配能命中)。
function tallyNotedEntities(kind) { // kind: 'actor' | 'maker' | 'series'
var out = [];
var ns = kind + ':';
Object.keys(noteMap || {}).forEach(function (key) {
if (key.indexOf(ns) !== 0) return;
var n = noteMap[key];
if (!n || !n.content) return;
var slug = key.slice(ns.length);
var c = 0, views = 0, name = '';
dimVidSource().forEach(function (v) {
var hit = false, nm = '';
if (kind === 'actor') {
(v.actors || []).forEach(function (a) {
var m = (a.href || '').match(/\/actors\/([^\/\?\#]+)/);
if (m && m[1] === slug) { hit = true; nm = a.name || ''; }
});
} else if (kind === 'maker') {
var m1 = ((v.maker && v.maker.href) || '').match(/\/makers\/([^\/\?\#]+)/);
if (m1 && m1[1] === slug) { hit = true; nm = (v.maker && v.maker.text) || ''; }
} else {
var m2 = ((v.series && v.series.href) || '').match(/\/series\/([^\/\?\#]+)/);
if (m2 && m2[1] === slug) { hit = true; nm = (v.series && v.series.text) || ''; }
}
if (hit) { c++; views += (typeof v.views === 'number' ? v.views : 0); if (!name && nm) name = nm; }
});
if (c > 0) out.push({ v: name || n.name || slug, n: c, views: views });
});
out.sort(function (a, b) { return (freqSortActive() ? b.views - a.views : b.n - a.n) || (a.v < b.v ? -1 : a.v > b.v ? 1 : 0); });
return out;
}
// 维度候选统一入口:备注开 → 三实体维度走已备注实体;其余走常规 tally
function computeDimOpts(d) {
if (modalNotesOnly && (d.key === 'actors' || d.key === 'makers' || d.key === 'series')) {
return tallyNotedEntities(d.key === 'actors' ? 'actor' : (d.key === 'makers' ? 'maker' : 'series'));
}
return tally(d.getVals);
}
// 隐藏男优当前态(读当前生效的过滤器配置 cur)
function hideMaleActive() { return !!cur.hideMaleActors; }
const dims = [
{ key: 'actors', label: '演员', getVals: function (v) {
return (v.actors || []).filter(function (a) { return !(hideMaleActive() && isMaleActor(a)); }).map(function (x) { return x && x.name; });
}, search: true },
{ key: 'awards', label: '金标', getVals: function (v) { return (v.awards || []).map(function (x) { return x && x.name; }); }, search: true },
{ key: 'tags', label: '类别', getVals: function (v) { return (v.categories || []).map(function (x) { return x && x.text; }); }, search: true },
{ key: 'series', label: '系列', getVals: function (v) { return [v.series && v.series.text]; }, search: true },
{ key: 'makers', label: '片商', getVals: function (v) { return [v.maker && v.maker.text]; }, search: true },
{ key: 'directors', label: '导演', getVals: function (v) { return [v.director && v.director.text]; }, search: true },
{ key: 'years', label: '年份', getVals: function (v) { return [(v.date || '').slice(0, 4)]; } }
];
// 全部维度统一生成候选(勾选「只看有备注」时三实体维度切换为已备注实体;隐藏男优重算同入口)
dims.forEach(function (d) { d.opts = computeDimOpts(d); });
// 临时选择(点确定才生效):TOP250模式操作 cur,画廊模式操作 galleryFilter,收藏夹模式操作 state.filters
const tmp = JSON.parse(JSON.stringify(isTop250 ? cur : (galleryMode ? galleryFilter : state.filters)));
if (!tmp.awards) tmp.awards = [];
// 共演模式实时交集:已选演员变化 → 重算候选源(仅含与全部已选演员共演的作品)→ 重算全部维度候选并重绘。
// 开启共演筛选后,每次点击演员都会让「其它演员/片商/系列…」候选实时收敛到交集内,无需等确定。
function onActorToggle() {
if (coactCb && coactCb.checked) {
refreshDimVidSource();
dims.forEach(function (d) { d.opts = computeDimOpts(d); });
allDraws.forEach(function (fn) { fn(''); });
}
}
refreshDimVidSource();
const actorRedraw = []; // 演员维度 draw 钩子,供头像开关即时重绘
const allDraws = []; // 所有维度 draw 钩子,供「常浏览排序」开关即时重绘
let actorBox = null; // 演员维度容器,供尺寸切换加 class
dims.forEach(function (dim) {
if (!dim.opts.length) return;
const isCollapsed = !!collapsedMap[dim.key]; // 默认 false 即展开
const box = h('<div class="efav-dim' + (dim.key === 'actors' ? ' efav-dim-actors' : '') + (isCollapsed ? ' collapsed' : '') + '">' +
'<div class="dim-title">' +
'<span class="dim-toggle-btn" title="点击展开/收起">' +
'<span class="material-symbols-outlined dim-arrow">expand_more</span>' +
'<span class="dim-name">' + dim.label + '</span>' +
'<span class="dim-badge"></span>' +
'</span>' +
(dim.search ? ' <input type="text" placeholder="搜索' + dim.label + '…">' : '') +
'</div><div class="efav-opts"></div></div>');
if (dim.key === 'actors') actorBox = box;
const optsEl = box.querySelector('.efav-opts');
const badgeEl = box.querySelector('.dim-badge');
const toggleBtn = box.querySelector('.dim-toggle-btn');
const searchInput = box.querySelector('input[type=text]');
if (searchInput) {
searchInput.addEventListener('click', function (e) { e.stopPropagation(); });
}
function updateDimBadge() {
if (!badgeEl) return;
const count = (tmp[dim.key] || []).length;
if (count > 0) {
badgeEl.textContent = '已选 ' + count;
badgeEl.classList.add('on');
} else {
badgeEl.textContent = '';
badgeEl.classList.remove('on');
}
}
updateDimBadge();
toggleBtn.addEventListener('click', function () {
const nowCol = box.classList.toggle('collapsed');
saveCollapsed(dim.key, nowCol);
});
function draw(kw) {
optsEl.innerHTML = '';
const withAvatar = (dim.key === 'actors' && cur.showActorAvatars);
const large = withAvatar && cur.actorAvatarSize === 'large';
// 现场按当前开关重排:常浏览开→按关联作品浏览总数降序;否则按出现次数降序。
// 必须在 draw 内排序,否则勾选开关时只重绘标注、顺序仍是 tally 当初定死的数量序。
const list = dim.opts.slice().sort(function (a, b) {
return (freqSortActive() ? oViews(b) - oViews(a) : b.n - a.n) || (a.v < b.v ? -1 : a.v > b.v ? 1 : 0);
});
list.forEach(function (o) {
if (kw && o.v.toLowerCase().indexOf(kw) === -1 && tmp[dim.key].indexOf(o.v) === -1) return;
// 常浏览排序开:隐藏作品数量,仅显示浏览数,按浏览量排序
const inner = freqSortActive()
? escapeHtml(o.v) + ' (·' + oViews(o) + '阅)'
: escapeHtml(o.v) + ' (' + o.n + ')';
let av = '';
if (withAvatar) {
// 找该演员头像:vids(当前候选作品集)里匹配名字的第一条
for (let z = 0; z < vids.length; z++) {
const act = (vids[z].actors || []).find(function (x) { return x && x.name === o.v; });
if (act && act.avatar) { av = act.avatar; break; }
}
}
if (withAvatar) {
const officialAv = av ? FAV.resolveImg(av) : '';
let finalAv = officialAv;
if (typeof GfriendsAvatarService !== 'undefined') {
finalAv = GfriendsAvatarService.getAvatarUrl(o.v, officialAv);
}
const imgTag = finalAv ? '<img class="efav-opt-av" loading="lazy" referrerpolicy="no-referrer" data-gfriends-actress="' + escapeAttr(o.v) + '" data-official-avatar="' + escapeAttr(officialAv) + '" src="' + escapeAttr(finalAv) + '" alt="" onerror="if(this.dataset.officialAvatar && this.src !== this.dataset.officialAvatar){this.src=this.dataset.officialAvatar;}else{this.style.display=\'none\';}">' : '';
// large:上大图下名字;small:左圆图右名字
const innerHtml = large
? imgTag + '<span class="efav-opt-name">' + inner + '</span>'
: imgTag + inner;
const el = h('<span class="efav-opt' + (withAvatar ? ' efav-opt-avatar' : '') + (large ? ' efav-opt-avatar-large' : '') + (tmp[dim.key].indexOf(o.v) !== -1 ? ' on' : '') + '">' + innerHtml + '</span>');
el.addEventListener('click', function () {
const idx = tmp[dim.key].indexOf(o.v);
if (idx === -1) tmp[dim.key].push(o.v); else tmp[dim.key].splice(idx, 1);
el.classList.toggle('on');
updateDimBadge();
if (dim.key === 'actors') onActorToggle();
});
optsEl.appendChild(el);
return;
}
const el = h('<span class="efav-opt' + (tmp[dim.key].indexOf(o.v) !== -1 ? ' on' : '') + '">' + inner + '</span>');
el.addEventListener('click', function () {
const idx = tmp[dim.key].indexOf(o.v);
if (idx === -1) tmp[dim.key].push(o.v); else tmp[dim.key].splice(idx, 1);
el.classList.toggle('on');
updateDimBadge();
if (dim.key === 'actors') onActorToggle();
});
optsEl.appendChild(el);
});
}
function oViews(o) { return (typeof o.views === 'number' ? o.views : 0); }
if (dim.key === 'actors') actorRedraw.push(draw);
allDraws.push(draw);
draw('');
const si = box.querySelector('input[type=text]');
if (si) si.addEventListener('input', function () { draw(si.value.trim().toLowerCase()); });
modalBody.appendChild(box);
});
// 评分 ≤X (默认 5.0 满格不限,拖动向下过滤低分)
const isScoreCol = !!collapsedMap.score;
const curScoreMax = (tmp.scoreMax != null ? tmp.scoreMax : 5.0);
const scoreBox = h('<div class="efav-dim' + (isScoreCol ? ' collapsed' : '') + '">' +
'<div class="dim-title">' +
'<span class="dim-toggle-btn" title="点击展开/收起">' +
'<span class="material-symbols-outlined dim-arrow">expand_more</span>' +
'<span class="dim-name">评分 ≤ <span class="sv">' + (curScoreMax < 5.0 ? curScoreMax : 5.0) + '</span> 分</span>' +
'<span class="dim-badge' + (curScoreMax < 5.0 ? ' on' : '') + '">' + (curScoreMax < 5.0 ? ('≤ ' + curScoreMax + '分') : '') + '</span>' +
'</span>' +
'</div>' +
'<input type="range" min="0" max="5" step="0.5" value="' + curScoreMax + '" style="width:100%;accent-color:var(--e-accent);"></div>');
const scoreBadge = scoreBox.querySelector('.dim-badge');
const scoreToggleBtn = scoreBox.querySelector('.dim-toggle-btn');
scoreToggleBtn.addEventListener('click', function () {
const nowCol = scoreBox.classList.toggle('collapsed');
saveCollapsed('score', nowCol);
});
scoreBox.querySelector('input').addEventListener('input', function () {
tmp.scoreMax = parseFloat(this.value);
scoreBox.querySelector('.sv').textContent = this.value;
if (scoreBadge) {
if (tmp.scoreMax < 5.0) {
scoreBadge.textContent = '≤ ' + tmp.scoreMax + '分';
scoreBadge.classList.add('on');
} else {
scoreBadge.textContent = '';
scoreBadge.classList.remove('on');
}
}
});
modalBody.appendChild(scoreBox);
// 按名字判断演员性别(供隐藏男优切换时剔除已选男优;vids 中任一条同名记录命中即可)
function nameIsMale(name) {
for (let z = 0; z < vids.length; z++) {
const act = (vids[z].actors || []).find(function (x) { return x && x.name === name; });
if (act) return isMaleActor(act);
}
return false;
}
// 共演筛选开关:开启后按已选演员重算候选(实时交集);关闭恢复全量候选
coactCb.addEventListener('change', function () {
cur.coactMode = coactCb.checked;
refreshDimVidSource();
dims.forEach(function (d) { d.opts = computeDimOpts(d); });
allDraws.forEach(function (fn) { fn(''); });
});
// 只看金标开关:切换时即时过滤候选(作品不带 awards 即从候选集剔除,计数随之变化)
if (onlyAwardCb) onlyAwardCb.addEventListener('change', function () {
if (isGallery) galleryFilter.onlyAward = onlyAwardCb.checked;
else if (isTop250) cur.onlyAward = onlyAwardCb.checked;
else modalOnlyAward = onlyAwardCb.checked;
refreshModalDims();
});
// 演员头像开关:切换时即时重绘演员区(头像仅在筛选弹窗内显示)
avatarCb.addEventListener('change', function () {
cur.showActorAvatars = avatarCb.checked;
actorRedraw.forEach(function (fn) { fn(''); });
});
// 常浏览排序开关:切换时即时重绘所有维度(tally 已按 views 重排,draw 读 freqSortActive 标注;画廊模式无此开关)
if (freqCb) freqCb.addEventListener('change', function () {
cur.freqSort = freqCb.checked;
allDraws.forEach(function (fn) { fn(''); });
});
// 隐藏男优开关:开启即剔除演员维度里的男优候选,即时重绘(tally 已按 hideMaleActive 过滤)
hideMaleCb.addEventListener('change', function () {
cur.hideMaleActors = hideMaleCb.checked;
// 已选中的男优一并剔除(候选不再出现,保留会造成「选了却筛不出」的死角)
if (tmp.actors && tmp.actors.length) {
tmp.actors = tmp.actors.filter(function (n) { return !nameIsMale(n); });
}
// 重新计算演员维度的候选(tally 在 openFilterModal 启动时已缓存 opts,必须重算才能反映开关)
for (let di = 0; di < dims.length; di++) {
if (dims[di].key === 'actors' && dims[di].getVals) dims[di].opts = computeDimOpts(dims[di]);
}
let maleCount = 0;
for (const k in genderMap) if (genderMap[k] === 'M') maleCount++;
log('[DIAG] 隐藏男优=' + hideMaleActive() + ' | genderMap中男优数=' + maleCount + ' | 总已知=' + Object.keys(genderMap).length);
allDraws.forEach(function (fn) { fn(''); });
});
// 头像尺寸滑动切换:小↔大,即时重绘
sizeCb.addEventListener('change', function () {
const sz = sizeCb.checked ? 'large' : 'small';
cur.actorAvatarSize = sz;
sizeLbl.textContent = sz === 'large' ? '大' : '小';
if (actorBox) actorBox.classList.toggle('efav-av-large', sizeCb.checked);
actorRedraw.forEach(function (fn) { fn(''); });
});
// 底部按钮(共演/头像开关已移至顶部 toggleBar)
const foot = h('<div class="foot">' +
'<button class="efav-btn" data-a="clear">清空条件</button>' +
'<button class="efav-btn" data-a="cancel">取消</button>' +
'<button class="efav-btn accent" data-a="ok">确定</button></div>');
foot.querySelector('[data-a=clear]').addEventListener('click', function () {
if (modalBody) {
modalBody.querySelectorAll('.efav-opt.on').forEach(function (el) { el.classList.remove('on'); });
modalBody.querySelectorAll('.dim-badge').forEach(function (b) { b.textContent = ''; b.classList.remove('on'); });
}
if (scoreBox) {
const sb = scoreBox.querySelector('.dim-badge'); if (sb) { sb.textContent = ''; sb.classList.remove('on'); }
const sv = scoreBox.querySelector('.sv'); if (sv) sv.textContent = '5';
const si = scoreBox.querySelector('input'); if (si) si.value = '5';
}
if (isTop250) {
if (typeof Top250ViewManager !== 'undefined' && Top250ViewManager.resetFilter) {
Top250ViewManager.resetFilter();
}
mask.remove();
return;
}
if (isGallery) {
galleryFilter = { actors: [], makers: [], series: [], directors: [], tags: [], years: [], awards: [], scoreMax: 5.0, codePrefix: '', coactMode: false, showActorAvatars: false, actorAvatarSize: 'small', hideMaleActors: false, onlyAward: false };
mask.remove(); renderGallery(galleryPanelEl);
return;
}
state.filters = { actors: [], makers: [], series: [], directors: [], tags: [], years: [], awards: [], scoreMax: 5.0, codePrefix: '' };
coactCb.checked = false;
avatarCb.checked = false;
if (freqCb) freqCb.checked = false;
hideMaleCb.checked = false;
if (onlyAwardCb) onlyAwardCb.checked = false;
if (incompleteCb) incompleteCb.checked = false;
if (notesCb) notesCb.checked = false;
if (wantedCb) wantedCb.checked = false;
if (watchedCb) watchedCb.checked = false;
modalNotesOnly = false;
modalIncomplete = false;
modalWanted = false;
modalWatched = false;
modalOnlyAward = false;
state.recentDays = 0;
recentBtns.forEach(function (b) { b.classList.toggle('off', parseInt(b.dataset.days, 10) !== 0); });
if (sizeCb) { sizeCb.checked = false; sizeLbl.textContent = '小'; }
state.coactMode = false; state.showActorAvatars = false; state.actorAvatarSize = 'small'; state.freqSort = false; state.hideMaleActors = false; state.onlyAward = false; state.incompleteOnly = false; state.notesOnly = false; state.reviewWanted = false; state.reviewWatched = false;
mask.remove(); applyAndRender();
});
foot.querySelector('[data-a=cancel]').addEventListener('click', function () { mask.remove(); });
// 最近发行快捷:点选即改状态并即时重渲(无需等确定;按作品发行日期判定区间)
recentBtns.forEach(function (b) {
b.addEventListener('click', function () {
state.recentDays = parseInt(b.dataset.days, 10);
recentBtns.forEach(function (x) { x.classList.toggle('off', parseInt(x.dataset.days, 10) !== state.recentDays); });
applyAndRender();
});
});
foot.querySelector('[data-a=ok]').addEventListener('click', function () {
if (isTop250) {
tmp.coactMode = coactCb ? coactCb.checked : false;
tmp.showActorAvatars = avatarCb ? avatarCb.checked : false;
tmp.actorAvatarSize = (sizeCb && sizeCb.checked) ? 'large' : 'small';
tmp.hideMaleActors = hideMaleCb ? hideMaleCb.checked : false;
tmp.onlyAward = onlyAwardCb ? onlyAwardCb.checked : false;
if (typeof Top250ViewManager !== 'undefined' && Top250ViewManager.setFilter) {
Top250ViewManager.setFilter(tmp);
}
mask.remove();
return;
}
if (isGallery) {
galleryFilter = tmp;
galleryFilter.coactMode = coactCb ? coactCb.checked : false;
galleryFilter.showActorAvatars = avatarCb ? avatarCb.checked : false;
galleryFilter.actorAvatarSize = (sizeCb && sizeCb.checked) ? 'large' : 'small';
galleryFilter.hideMaleActors = hideMaleCb ? hideMaleCb.checked : false;
galleryFilter.onlyAward = onlyAwardCb ? onlyAwardCb.checked : false;
mask.remove(); renderGallery(galleryPanelEl); return;
}
state.filters = tmp;
state.coactMode = coactCb ? coactCb.checked : false;
state.showActorAvatars = avatarCb ? avatarCb.checked : false;
state.actorAvatarSize = (sizeCb && sizeCb.checked) ? 'large' : 'small';
state.freqSort = freqCb ? freqCb.checked : false;
state.hideMaleActors = hideMaleCb ? hideMaleCb.checked : false;
state.onlyAward = onlyAwardCb ? onlyAwardCb.checked : false;
state.incompleteOnly = incompleteCb ? incompleteCb.checked : false;
state.notesOnly = notesCb ? notesCb.checked : false;
state.reviewWanted = wantedCb ? wantedCb.checked : false;
state.reviewWatched = watchedCb ? watchedCb.checked : false;
mask.remove(); applyAndRender();
});
modal.appendChild(foot);
mask.addEventListener('click', function (e) { if (e.target === mask) mask.remove(); });
document.body.appendChild(mask);
requestAnimationFrame(function () { mask.classList.add('open'); });
}
/* ---------- 收藏的清单浏览器(仅内存,无限滚动) ---------- */
async function openBrowse() {
const res = await FAV.getStarredLists();
const starred = res.lists || [];
if (res.error) {
// 登录失效 / 抓取失败:透传真实原因,不误导为「未获取」
alert('读取「收藏的清单」失败:\n' + FAV.friendlyError(new Error(res.error)) +
'\n\n请确认:①已登录 JAVDB ②访问 /users/favorite_lists 能看到清单 ③未触发 Cloudflare 验证');
return;
}
if (!starred.length) {
alert('尚未获取到「收藏的清单」。\n请确认已登录 JAVDB,并且在「收藏的清单」页面有收藏内容。');
return;
}
let mask = document.getElementById('efav-filter-mask');
if (mask) mask.remove();
mask = h('<div class="efav-modal-mask" id="efav-filter-mask"><div class="efav-modal"></div></div>');
const modal = mask.querySelector('.efav-modal');
modal.innerHTML = '<h3><span class="material-symbols-outlined">collections_bookmark</span>收藏的清单</h3>';
const menu = h('<div class="efav-lists-menu"></div>');
starred.forEach(function (l) {
const itm = h('<div class="itm"><span>' + escapeHtml(l.name) + '</span><span style="opacity:.5;">' +
((l.scrapedCount || l.count) ? (l.scrapedCount || l.count) + ' 部' : '') + ' ›</span></div>');
itm.addEventListener('click', function () { mask.remove(); browseList(l); });
menu.appendChild(itm);
});
modal.appendChild(menu);
mask.addEventListener('click', function (e) { if (e.target === mask) mask.remove(); });
document.body.appendChild(mask);
requestAnimationFrame(function () { mask.classList.add('open'); });
}
function browseList(list) {
// 销毁契约同 renderGrid:清空面板前注销其内部全部已注册容器
try { WaterfallEngine.unregisterTree(panel); } catch (e) {}
panel.innerHTML = '';
const head = h('<div class="efav-browse-head">' +
'<button class="efav-btn" data-a="back">' + icon('arrow_back') + '返回收藏夹</button>' +
'<span class="ttl">' + escapeHtml(list.name) + '</span><span class="efav-count"></span></div>');
head.querySelector('[data-a=back]').addEventListener('click', function () { mount(panel); });
panel.appendChild(head);
const grid = document.createElement('div');
const browseLayout = localStorage.getItem('javdbEmbyLayout') || 'grid';
grid.className = 'movie-list' + (browseLayout === 'masonry' ? ' emby-masonry' : '') + (browseLayout === 'dynamic-focus' ? ' emby-dynamic-focus' : '');
panel.appendChild(grid);
if (browseLayout === 'masonry') { try { WaterfallEngine.register(grid); } catch (e) {} } // 清单浏览瀑布流横向顺序
const sentinel = h('<div class="efav-sentinel"></div>');
panel.appendChild(sentinel);
const cntEl = head.querySelector('.efav-count');
let page = 1, loading = false, ended = false, total = 0;
async function loadNext() {
if (loading || ended) return;
loading = true;
try {
const doc = await FAV.throttledFetch(FAV.listPageUrl(list, page));
const parsed = FAV.parseListPage(doc);
parsed.items.forEach(function (it) {
grid.appendChild(buildCard({
code: it.code, title: it.title, date: it.date, coverThumb: it.coverThumb,
detailHref: it.detailHref, detailComplete: true,
rating: it.score != null ? { score: it.score, count: it.scoreCount || 0, text: it.score + '分' + (it.scoreCount ? ',由' + it.scoreCount + '人評價' : '') } : null
}));
});
total += parsed.items.length;
cntEl.textContent = '已加载 ' + total + ' 部';
ended = !parsed.hasNext || !parsed.items.length;
page++;
} catch (e) {
cntEl.textContent = '加载失败:' + e.message;
ended = true;
}
loading = false;
}
const ob = new IntersectionObserver(function (es) {
if (es[0].isIntersecting) loadNext();
}, { root: panel, rootMargin: '600px' });
ob.observe(sentinel);
loadNext();
}
/* ---------- 导入 ---------- */
function doImport() {
if (typeof FAV !== 'undefined' && typeof FAV.doImport === 'function') {
FAV.doImport(reload);
}
}
// 从详情页(演员/导演/片商/系列/番号组)一键筛选收藏夹:设置对应维度筛选并跳收藏夹
function filterByDetail(type, name) {
if (!type || !name) return;
// 候选规整:
// - 演员维度:符号拆分多候选(中/日/别名同行);
// - 片商维度:注入端已给「整行名 + 拆分名」并集,这里原样采用(不再二次 join+split,
// 否则会把整名形态重新拆碎,"S1 NO.1 STYLE" 就永远配不上了);
// - 其它维度(导演/系列/番号组)一律整名单条件——名称含空格/顿号/逗号时拆分
// 会被误解析成多个条件,导致收藏夹筛不出对应作品。
var cands;
if (type === 'actors') {
if (Array.isArray(name)) name = name.join(' ');
cands = String(name).split(/[\s,,、・\n\r]+/).map(function (s) { return s.trim(); })
.filter(function (t) { return t && !/部影片|影片|作品|部作品|^\d+$/.test(t); });
if (!cands.length) cands = [String(name).trim()];
} else if (type === 'makers') {
cands = (Array.isArray(name) ? name : [String(name)]).map(function (s) { return String(s || '').trim(); }).filter(Boolean);
if (!cands.length) return;
} else {
// 非演员维度:候选 = 整行名(多行别名 → 多个 OR 条件),不做行内符号拆分
cands = (Array.isArray(name) ? name : [String(name)]).map(function (s) { return String(s || '').trim(); }).filter(Boolean);
if (!cands.length) return;
}
// 仅保留该维度,清空其他筛选/关键词/清单范围(codePrefix 为番号组前缀筛选)
state.filters = { actors: [], makers: [], series: [], directors: [], tags: [], years: [], awards: [], scoreMax: 5.0, codePrefix: '' };
if (type === 'codePrefix') {
state.filters.codePrefix = cands[0];
} else if (state.filters.hasOwnProperty(type)) {
// 多写法作 OR 条件(applyAndRender 用 .some 匹配任一即可),命中库内任一存名
cands.forEach(function (c) {
// 演员维度归一化后再存入筛选(去掉「(無碼)」等尾部标记),保证 chips 显示干净、匹配一致
var nc = (type === 'actors') ? normActorName(c) : c;
if (nc && state.filters[type].indexOf(nc) === -1) state.filters[type].push(nc);
});
}
state.keyword = '';
state.scope = [];
if (searchInput) searchInput.value = '';
saveState();
// 跳收藏夹:优先触发标签真实点击,兜底调用 switchTab
if (tabFav && typeof tabFav.click === 'function') tabFav.click();
else switchTab('favorites');
// 若收藏夹面板此前已完成挂载(在同一个会话中二次进入),直接单次应用新筛选条件重绘;
// 若尚未挂载,switchTab 内部会调用 mount(favPanel),mount 会自动从刚 saveState 的状态中加载,无需额外重绘。
if (panel && panel.classList.contains('has-ui')) {
try { refreshScopeMenu(); } catch (e) {}
try { applyAndRender(); } catch (e) {}
try { applyFavRandomBg(); } catch (e) {}
}
// 防冲守卫:演员/导演/片商等详情页 SPA 在导航后可能会发生微弱 DOM 突变冲掉 html 上的 class,
// 仅在 class 丢失时做纯 class 补齐,绝对严禁在此循环调用 applyAndRender(根治反复闪烁 5~6 次的重绘风暴)
let tries = 0;
function ensureViewClass() {
if (tries++ > 3) return;
if (!document.documentElement.classList.contains('emby-fav-view')) {
document.documentElement.classList.add('emby-fav-view');
if (favPanel) favPanel.classList.add('show');
}
setTimeout(ensureViewClass, 150);
}
setTimeout(ensureViewClass, 80);
}
/* ---------- 预览画廊渲染(平铺/收拢/全量 + 分批 + 灯箱 + 收藏切换 + 筛选浮窗画廊模式) ---------- */
function galleryImgUrl(rel) { return rel ? FAV.resolveImg(rel) : ''; }
// 画廊筛选候选:画廊已有作品(按 code 去重)与收藏库 join——库里有元数据用库的,没有则仅 code/title
function favVideoByCode(code) {
if (!cache || !cache.videos) return null;
for (let i = 0; i < cache.videos.length; i++) if (cache.videos[i].code === code) return cache.videos[i];
return null;
}
function gallerySourceVids() {
const rows = galleryRowsCache;
if (!rows || !rows.length) return [];
const seen = {}; const out = [];
rows.forEach(function (r) {
if (!r || !r.code || seen[r.code]) return;
seen[r.code] = 1;
out.push(favVideoByCode(r.code) || { code: r.code, title: r.title || '', actors: [], categories: [] });
});
return out;
}
function hasGalleryFilter() {
const f = galleryFilter;
return !!(f.actors.length || f.makers.length || f.series.length || f.directors.length ||
f.tags.length || f.years.length || f.awards.length || (f.scoreMax != null && f.scoreMax < 5.0) || f.onlyAward);
}
function matchGalleryFilter(rec) {
const f = galleryFilter;
const m = favVideoByCode(rec.code) || rec; // 元数据优先取收藏库
// 只看金标:仅保留带 JAVDB 金标(awards)的作品
if (f.onlyAward && !(m.awards || []).length) return false;
if (f.actors.length) {
const na = f.actors.map(normActorName);
// 隐藏男优:匹配时忽略已识别为男(M)的演员(与收藏夹弹窗候选行为一致)
const acts = (m.actors || []).filter(function (x) { return x && !(f.hideMaleActors && isMaleByHref(x)); });
if (!acts.some(function (x) { return x && na.indexOf(normActorName(x.name)) !== -1; })) return false;
// 共演筛选(交集):须同时命中全部已选演员
if (f.coactMode) {
const all = f.actors.every(function (name) {
return acts.some(function (x) { return x && normActorName(x.name) === normActorName(name); });
});
if (!all) return false;
}
}
if (f.tags.length && !f.tags.some(function (t) { return (m.categories || []).some(function (x) { return x && x.text === t; }); })) return false;
if (f.makers.length && f.makers.indexOf((m.maker && m.maker.text) || '') === -1) return false;
if (f.series.length && f.series.indexOf((m.series && m.series.text) || '') === -1) return false;
if (f.directors.length && f.directors.indexOf((m.director && m.director.text) || '') === -1) return false;
if (f.years.length && f.years.indexOf((m.date || '').slice(0, 4)) === -1) return false;
if (f.awards.length && !f.awards.some(function (t) { return (m.awards || []).some(function (x) { return x && x.name === t; }); })) return false;
if (f.scoreMax != null && f.scoreMax < 5.0) {
const sc = getMovieScore(m);
if (sc <= 0 || sc > f.scoreMax) return false;
}
return true;
}
// 筛选按钮角标:显示当前生效的条件数
function updateGalleryFilterBtn() {
const b = document.getElementById('efav-gallery-filter-btn');
if (!b) return;
const f = galleryFilter;
const n = f.actors.length + f.makers.length + f.series.length + f.directors.length +
f.tags.length + f.years.length + f.awards.length + ((f.scoreMax != null && f.scoreMax < 5.0) ? 1 : 0) + (f.onlyAward ? 1 : 0);
b.innerHTML = '<span class="material-icons" style="font-size:1.15em;">filter_alt</span>' + (n ? '筛选 · ' + n : '筛选');
}
// 画廊内灯箱:放大图片,支持上一张/下一张切换 + 键盘 ←/→ 翻页、Esc 关闭,右上角带「收藏/取消」按钮(与详情页灯箱一致)
let activeGalleryLbKeyHandler = null;
function galleryOpenLightbox(items, idx) {
// items: [{url, rec}];rec 可为 null(纯展示,无收藏按钮)
let ov = document.getElementById('efav-gallery-lb');
if (!ov) {
ov = document.createElement('div');
ov.id = 'efav-gallery-lb';
ov.className = 'efav-gallery-lb';
ov.innerHTML = '<img alt=""><span class="efav-gallery-lb-prev material-symbols-outlined">chevron_left</span><span class="efav-gallery-lb-next material-symbols-outlined">chevron_right</span><span class="efav-gallery-lb-fav material-symbols-outlined">bookmark_add</span><span class="efav-gallery-lb-close material-symbols-outlined">close</span>';
document.body.appendChild(ov);
ov.addEventListener('click', function (e) {
if (e.target === ov || e.target.classList.contains('efav-gallery-lb-close')) {
if (typeof ov._closeLb === 'function') ov._closeLb();
}
});
}
const img = ov.querySelector('img');
const favBtn = ov.querySelector('.efav-gallery-lb-fav');
const prevBtn = ov.querySelector('.efav-gallery-lb-prev');
const nextBtn = ov.querySelector('.efav-gallery-lb-next');
let cur = idx || 0;
// 缩放与拖拽状态 (0.5x ~ 5.0x,挂载在 ov 实例上避免二次打开闭包失效)
const drag = ov._drag || (ov._drag = {
scale: 1, tx: 0, ty: 0,
isDragging: false, startX: 0, startY: 0, lastTx: 0, lastTy: 0
});
function updateImgTransform(withTransition) {
if (!img) return;
img.style.transition = withTransition ? 'transform .18s ease-out' : 'none';
img.style.transform = 'translate(' + drag.tx + 'px, ' + drag.ty + 'px) scale(' + drag.scale + ')';
img.style.cursor = drag.scale > 1 ? (drag.isDragging ? 'grabbing' : 'grab') : 'zoom-in';
}
function resetZoom() {
drag.scale = 1;
drag.tx = 0;
drag.ty = 0;
drag.isDragging = false;
updateImgTransform(false);
}
ov._updateImgTransform = updateImgTransform;
ov._resetZoom = resetZoom;
function show(i) {
if (!items || !items.length) return;
cur = (i + items.length) % items.length;
const it = items[cur];
img.src = it.url;
resetZoom();
ov.classList.add('show');
// 收藏按钮状态(同步由内存真相源 isCollected 决定,异步结果不会串台)
const rec = it.rec;
if (!rec) { favBtn.style.display = 'none'; return; }
favBtn.style.display = 'inline-flex';
paintFav(favBtn, rec.id);
}
function paintFav(btn, id) {
if (FAV.isCollected(id)) { btn.classList.add('saved'); btn.title = '取消收藏'; }
else { btn.classList.remove('saved'); btn.title = '收藏此图'; }
}
function closeLb() {
ov.classList.remove('show');
resetZoom();
if (activeGalleryLbKeyHandler) {
document.removeEventListener('keydown', activeGalleryLbKeyHandler);
activeGalleryLbKeyHandler = null;
}
}
ov._closeLb = closeLb;
function onKey(e) {
// 关键守卫:仅当画廊灯箱自身处于可见显示状态时才拦截键盘,绝不干扰详情页 Fancybox!
if (!ov || !ov.classList.contains('show')) return;
if (e.key === 'ArrowLeft') { e.preventDefault(); show(cur - 1); }
else if (e.key === 'ArrowRight') { e.preventDefault(); show(cur + 1); }
else if (e.key === 'Escape') { e.preventDefault(); closeLb(); }
}
// 滚轮缩放支持 (0.5x ~ 5.0x)
ov.onwheel = function (e) {
if (!ov.classList.contains('show')) return;
e.preventDefault();
const factor = e.deltaY < 0 ? 1.15 : 0.87;
let newScale = drag.scale * factor;
if (newScale < 0.5) newScale = 0.5;
if (newScale > 5.0) newScale = 5.0;
if (Math.abs(newScale - 1) < 0.05 && factor < 1) {
newScale = 1;
drag.tx = 0;
drag.ty = 0;
}
drag.scale = newScale;
updateImgTransform(true);
};
// 拖拽平移
img.onmousedown = function (e) {
if (drag.scale <= 1) return;
e.preventDefault();
drag.isDragging = true;
drag.startX = e.clientX;
drag.startY = e.clientY;
drag.lastTx = drag.tx;
drag.lastTy = drag.ty;
updateImgTransform(false);
};
if (!ov._hasDragListeners) {
ov._hasDragListeners = true;
window.addEventListener('mousemove', function (e) {
const d = ov._drag;
if (!d || !d.isDragging || !ov.classList.contains('show')) return;
d.tx = d.lastTx + (e.clientX - d.startX);
d.ty = d.lastTy + (e.clientY - d.startY);
if (typeof ov._updateImgTransform === 'function') ov._updateImgTransform(false);
});
window.addEventListener('mouseup', function () {
const d = ov._drag;
if (d && d.isDragging) {
d.isDragging = false;
if (typeof ov._updateImgTransform === 'function') ov._updateImgTransform(false);
}
});
}
img.ondblclick = function (e) {
e.stopPropagation();
if (drag.scale > 1.05) {
resetZoom();
} else {
drag.scale = 2.0;
drag.tx = 0;
drag.ty = 0;
updateImgTransform(true);
}
};
prevBtn.onclick = function (e) { e.stopPropagation(); show(cur - 1); };
nextBtn.onclick = function (e) { e.stopPropagation(); show(cur + 1); };
favBtn.onclick = function (e) {
e.stopPropagation();
const rec = items[cur].rec;
if (!rec) return;
if (FAV.isCollected(rec.id)) {
FAV.dbDeleteGallery(rec.id).then(function () { paintFav(favBtn, rec.id); log('[DIAG] 画廊取消收藏: ' + rec.id); })
.catch(function (err) { log('画廊取消收藏失败: ' + (err && err.message)); });
} else {
FAV.dbPutGallery(rec).then(function () { paintFav(favBtn, rec.id); log('[DIAG] 画廊收藏: ' + rec.id); })
.catch(function (err) { log('画廊收藏失败: ' + (err && err.message)); });
}
};
if (activeGalleryLbKeyHandler) {
document.removeEventListener('keydown', activeGalleryLbKeyHandler);
}
activeGalleryLbKeyHandler = onKey;
document.addEventListener('keydown', onKey);
show(cur);
}
// 单卡渲染(按 vid 聚合)。recs[0].code = 真番号(strong 文本),recs[0].vid = 视频 ID(跳转用),recs[0].title = 剧名
function galleryCardByCode(vid, recs, panel) {
const rec0 = recs[0];
const code = rec0.code || '';
const jumpVid = vidOf(rec0) || vid || '';
const cover = (rec0.coverUrl || '');
const previews = recs.filter(function (r) { return r.previewUrl; });
const card = document.createElement('div');
card.className = 'efav-gc-card';
const titleBar = document.createElement('div');
titleBar.className = 'efav-gc-title';
const link = document.createElement('a');
link.href = '/v/' + jumpVid;
link.target = '_blank';
link.className = 'efav-gc-code';
link.textContent = code || jumpVid;
titleBar.appendChild(link);
const t = document.createElement('span');
t.className = 'efav-gc-name';
t.textContent = recs[0].title || '';
titleBar.appendChild(t);
card.appendChild(titleBar);
const body = document.createElement('div');
body.className = 'efav-gc-body';
// 构建 lightbox 可切换的图列表:封面 + 预览图
const items = [];
if (cover) items.push({ url: galleryImgUrl(cover), rec: recs[0] });
previews.forEach(function (r) { items.push({ url: galleryImgUrl(r.previewUrl), rec: r }); });
if (cover) {
const cw = document.createElement('div');
cw.className = 'efav-gc-cover';
const ci = document.createElement('img');
ci.src = galleryImgUrl(cover);
ci.addEventListener('click', function () { galleryOpenLightbox(items, 0); });
cw.appendChild(ci);
body.appendChild(cw);
}
const pw = document.createElement('div');
pw.className = 'efav-gc-previews';
previews.forEach(function (r) {
const pi = document.createElement('img');
pi.src = galleryImgUrl(r.previewUrl);
const idx = items.findIndex(function (it) { return it.rec === r; });
pi.addEventListener('click', function () { galleryOpenLightbox(items, idx); });
const wrap = document.createElement('div');
wrap.className = 'efav-gc-prev';
wrap.appendChild(pi);
pw.appendChild(wrap);
});
if (previews.length) body.appendChild(pw);
else {
const hint = document.createElement('div');
hint.className = 'efav-gc-coveronly';
hint.textContent = '(仅收藏了封面)';
body.appendChild(hint);
}
card.appendChild(body);
return card;
}
function vidOf(r) {
// 优先用 vid 字段;旧数据可能无 vid,从 id(格式 vid|rel)解析,保证跳转/聚合兼容
if (r.vid) return r.vid;
if (r.id) return r.id.split('|')[0] || 'unknown';
return 'unknown';
}
function galleryAggregate(rows) {
const byVid = {};
rows.forEach(function (r) { const k = vidOf(r); (byVid[k] = byVid[k] || []).push(r); });
const vids = Object.keys(byVid);
vids.sort(function (a, b) {
const ma = byVid[a][0].savedAt || '', mb = byVid[b][0].savedAt || '';
return mb < ma ? -1 : mb > ma ? 1 : 0;
});
return { byVid: byVid, vids: vids };
}
function renderGalleryBatch(container, cards, start, step) {
const end = Math.min(start + step, cards.length);
for (let i = start; i < end; i++) container.appendChild(cards[i]);
if (end < cards.length) setTimeout(function () { renderGalleryBatch(container, cards, end, step); }, 0);
}
function renderTile(panel, byVid, vids) {
const wrap = document.createElement('div');
wrap.className = 'efav-gc-list efav-gc-tile';
panel.appendChild(wrap);
const cards = vids.map(function (v) { return galleryCardByCode(v, byVid[v], panel); });
renderGalleryBatch(wrap, cards, 0, 24);
}
function renderAll(panel, byVid, vids) {
const wrap = document.createElement('div');
wrap.className = 'efav-gc-list efav-gc-all' + (favLayoutIsMasonry() ? ' emby-masonry' : '');
panel.appendChild(wrap);
// 瀑布流布局下注册引擎:预览图真实高度不定,图片 load 后引擎自动重排
if (favLayoutIsMasonry()) { try { WaterfallEngine.register(wrap); } catch (e) {} }
const cards = [];
vids.forEach(function (v) {
const recs = byVid[v];
const cover = recs[0].coverUrl;
if (cover) {
const ic = document.createElement('img');
ic.className = 'efav-gc-allimg';
ic.src = galleryImgUrl(cover);
ic.title = (recs[0].code || v) + ' 封面';
const items = [{ url: galleryImgUrl(cover), rec: recs[0] }];
recs.forEach(function (r) { if (r.previewUrl) items.push({ url: galleryImgUrl(r.previewUrl), rec: r }); });
ic.addEventListener('click', function () { galleryOpenLightbox(items, 0); });
cards.push(ic);
}
recs.forEach(function (r) {
if (!r.previewUrl) return;
const ip = document.createElement('img');
ip.className = 'efav-gc-allimg';
ip.src = galleryImgUrl(r.previewUrl);
ip.title = (recs[0].code || v);
const items = [{ url: galleryImgUrl(cover), rec: recs[0] }];
recs.forEach(function (rr) { if (rr.previewUrl) items.push({ url: galleryImgUrl(rr.previewUrl), rec: rr }); });
const idx = items.findIndex(function (it) { return it.rec === r; });
ip.addEventListener('click', function () { galleryOpenLightbox(items, idx); });
cards.push(ip);
});
});
renderGalleryBatch(wrap, cards, 0, 60);
}
function renderCollapse(panel, byVid, vids) {
const wrap = document.createElement('div');
wrap.className = 'efav-gc-list efav-gc-collapse';
panel.appendChild(wrap);
vids.forEach(function (v) {
const recs = byVid[v];
const cover = recs[0].coverUrl;
if (!cover) return;
const item = document.createElement('div');
item.className = 'efav-gc-citem';
item.setAttribute('data-vid', v);
// 封面 + 预览图堆叠层(封面在底,预览图从顶部错位探出小角 = 卡牌堆叠效果)
const box = document.createElement('a');
box.className = 'efav-gc-cbox';
box.href = 'javascript:;';
box.title = (recs[0].code || v) + ' · ' + (recs[0].title || '');
const ci = document.createElement('img');
ci.src = galleryImgUrl(cover);
ci.alt = recs[0].code || v;
box.appendChild(ci);
// 堆叠层:取前 3 张预览图,从封面右侧错位探出小角(卡牌堆叠)
const previews = recs.filter(function (r) { return r.previewUrl; }).slice(0, 3);
const expandItems = [{ url: galleryImgUrl(cover), rec: recs[0] }];
recs.forEach(function (r) { if (r.previewUrl) expandItems.push({ url: galleryImgUrl(r.previewUrl), rec: r }); });
const stack = document.createElement('div');
stack.className = 'efav-gc-stack';
previews.forEach(function (r, i) {
const sp = document.createElement('img');
sp.className = 'efav-gc-stackimg';
sp.src = galleryImgUrl(r.previewUrl);
sp.style.setProperty('--i', i);
sp.title = (recs[0].code || v) + ' 预览 ' + (i + 1);
const sidx = expandItems.findIndex(function (it) { return it.rec === r; });
sp.addEventListener('click', function (e) { e.stopPropagation(); galleryOpenLightbox(expandItems, sidx); });
stack.appendChild(sp);
});
box.appendChild(stack);
item.appendChild(box);
// 展开区(点击封面后该卡占满整行显示大预览;其它封面变暗)
const expand = document.createElement('div');
expand.className = 'efav-gc-expand';
// 关闭按钮:收起当前展开项(与点击空白处等价,给用户明确的关闭入口)
const closeBtn = document.createElement('span');
closeBtn.className = 'efav-gc-expand-close material-icons';
closeBtn.textContent = 'close';
closeBtn.title = '关闭';
closeBtn.addEventListener('click', function (e) {
e.stopPropagation();
item.classList.remove('open');
wrap.classList.remove('has-open');
});
expand.appendChild(closeBtn);
const bigCover = document.createElement('img');
bigCover.className = 'efav-gc-expand-cover';
bigCover.src = galleryImgUrl(cover);
bigCover.addEventListener('click', function () { galleryOpenLightbox(expandItems, 0); });
expand.appendChild(bigCover);
const brow = document.createElement('div');
brow.className = 'efav-gc-brow';
recs.forEach(function (r) {
if (!r.previewUrl) return;
const pi = document.createElement('img');
pi.className = 'efav-gc-expand-prev';
pi.src = galleryImgUrl(r.previewUrl);
const cidx = expandItems.findIndex(function (it) { return it.rec === r; });
pi.addEventListener('click', function () { galleryOpenLightbox(expandItems, cidx); });
brow.appendChild(pi);
});
if (!recs.some(function (r) { return r.previewUrl; })) {
const h = document.createElement('div'); h.className = 'efav-gc-coveronly'; h.textContent = '(仅封面)';
brow.appendChild(h);
}
expand.appendChild(brow);
item.appendChild(expand);
box.addEventListener('click', function (ev) {
ev.preventDefault();
const willOpen = !item.classList.contains('open');
// 同一时刻只展开一个:先收起其它已展开的
wrap.querySelectorAll('.efav-gc-citem.open').forEach(function (o) { if (o !== item) o.classList.remove('open'); });
item.classList.toggle('open', willOpen);
wrap.classList.toggle('has-open', willOpen);
});
wrap.appendChild(item);
});
// 点击空白处(面板内非卡片区域)收起所有展开;用单例避免重复绑定累积
if (window.__efavCollapseCloser) { try { panel.removeEventListener('click', window.__efavCollapseCloser); } catch (e) {} }
window.__efavCollapseCloser = function (ev) {
if (!ev.target.closest('.efav-gc-citem')) {
wrap.querySelectorAll('.efav-gc-citem.open').forEach(function (o) { o.classList.remove('open'); });
wrap.classList.remove('has-open');
}
};
panel.addEventListener('click', window.__efavCollapseCloser);
}
function applyGalleryRandomBg(panel) {
if (!panel) return;
const on = localStorage.getItem(SET_KEYS.randomBg);
let bgEl = panel.querySelector('.efav-bg');
if (!bgEl) {
bgEl = document.createElement('div');
bgEl.className = 'efav-bg';
panel.insertBefore(bgEl, panel.firstChild);
}
if (on === '0') {
bgEl.style.backgroundImage = 'none';
return;
}
if (typeof FAV !== 'undefined' && FAV.dbAllGallery) {
FAV.dbAllGallery().then(function (rows) {
if (!rows || !rows.length) return;
const covers = rows.map(function (r) { return r.coverUrl; }).filter(Boolean);
if (!covers.length) return;
const src = covers[Math.floor(Math.random() * covers.length)];
bgEl.style.backgroundImage = 'url(' + galleryImgUrl(src) + ')';
}).catch(function () {});
}
}
function renderGallery(panel) {
galleryPanelEl = panel;
applyGalleryRandomBg(panel);
// 工具条仅首次构建(不随列表重渲染销毁),放在面板顶部
let toolbar = panel.querySelector('.efav-gallery-toolbar');
if (!toolbar) toolbar = galleryBuildToolbar(panel);
// 列表容器:每次重渲染只清空它,工具条保留
let list = panel.querySelector('#emby-gallery-list');
if (!list) { list = document.createElement('div'); list.id = 'emby-gallery-list'; panel.appendChild(list); }
// 销毁契约:列表容器复用(只清 innerHTML),重建前注销其内部已注册容器
try { WaterfallEngine.unregisterTree(list); } catch (e) {}
list.innerHTML = '';
const view = (localStorage.getItem('javdbEmbyGalleryView') || 'tile');
syncToolbarView(toolbar, view);
FAV.dbAllGallery().then(function (rows) {
if (!rows || !rows.length) {
try { WaterfallEngine.unregisterTree(list); } catch (e) {}
list.innerHTML = '<div class="empty-state"><span class="material-symbols-outlined">collections</span>还没有收藏任何预览图。<br>在番号详情页点开预览大图,点右上角「收藏此图」即可加入画廊。</div>';
updateGalleryFilterBtn();
return;
}
galleryRowsCache = rows;
const agg0 = galleryAggregate(rows);
const agg = { byVid: agg0.byVid, vids: agg0.vids.slice() };
// 过滤框:按番号或标题过滤(不区分大小写)
const filterInput = panel.querySelector('.efav-gc-filter input');
const kw = (filterInput && filterInput.value || '').trim().toLowerCase();
if (kw) {
agg.vids = agg.vids.filter(function (v) {
const recs = agg.byVid[v] || [];
const re0 = recs[0] || {};
return ((re0.code || v) + ' ' + (re0.title || '')).toLowerCase().indexOf(kw) !== -1;
});
}
// 筛选浮窗(画廊模式):维度条件对画廊作品生效,元数据与收藏库 join
if (hasGalleryFilter()) {
agg.vids = agg.vids.filter(function (v) { return matchGalleryFilter(agg.byVid[v][0]); });
}
updateGalleryFilterBtn();
const agg2 = { byVid: agg.byVid, vids: agg.vids };
if (view === 'collapse') renderCollapse(list, agg2.byVid, agg2.vids);
else if (view === 'all') renderAll(list, agg2.byVid, agg2.vids);
else renderTile(list, agg2.byVid, agg2.vids);
}).catch(function (e) {
list.innerHTML = '<div class="empty-state">画廊读取失败: ' + ((e && e.message) || e) + '</div>';
});
}
function galleryBuildToolbar(panel) {
const bar = document.createElement('div');
bar.className = 'efav-gallery-toolbar efav-toolbar';
// 筛选:复用收藏夹筛选浮窗(画廊模式:候选来自画廊已有作品,元数据与收藏库 join)
const fbtn = document.createElement('button');
fbtn.type = 'button';
fbtn.id = 'efav-gallery-filter-btn';
fbtn.className = 'efav-btn accent';
fbtn.title = '按演员/片商/系列/导演/类别/年份/评分筛选画廊内作品';
fbtn.addEventListener('click', function () { openFilterModal(true); });
bar.appendChild(fbtn);
// 视图切换(segmented)
const seg = document.createElement('div');
seg.className = 'efav-seg';
[['tile', '平铺'], ['collapse', '收拢'], ['all', '全量']].forEach(function (p) {
const b = document.createElement('label');
b.className = 'efav-seg-item';
b.dataset.view = p[0];
b.textContent = p[1];
b.addEventListener('click', function () {
try { localStorage.setItem('javdbEmbyGalleryView', p[0]); } catch (e) {}
syncToolbarView(bar, p[0]);
// 仅重渲染列表区,工具条保留。
// 不再自己写 innerHTML —— renderGallery 内部会先 unregisterTree 再清空,
// 这里抢先清空会让注销找不到容器、反而漏掉 observer。
renderGallery(panel);
});
seg.appendChild(b);
});
bar.appendChild(seg);
// 番号/标题过滤框:仅过滤列表区,工具条保留(重渲染走 renderGallery 的保留逻辑)
const filterWrap = document.createElement('div');
filterWrap.className = 'efav-gc-filter';
filterWrap.title = '按番号或标题过滤';
const filterIc = document.createElement('span'); filterIc.className = 'material-symbols-outlined'; filterIc.style.cssText = 'font-size:1.1em;'; filterIc.textContent = 'search';
const filterInput = document.createElement('input');
filterInput.type = 'text'; filterInput.placeholder = '番号/标题过滤…'; filterInput.autocomplete = 'off';
let filterTimer = null;
filterInput.addEventListener('input', function () {
clearTimeout(filterTimer);
filterTimer = setTimeout(function () { renderGallery(panel); }, 200);
});
const filterClear = document.createElement('button');
filterClear.type = 'button'; filterClear.className = 'efav-gc-filter-clear'; filterClear.title = '清空过滤';
filterClear.textContent = '×';
filterClear.addEventListener('click', function () { filterInput.value = ''; renderGallery(panel); });
filterWrap.appendChild(filterIc); filterWrap.appendChild(filterInput); filterWrap.appendChild(filterClear);
bar.appendChild(filterWrap);
// 显示大小滑块(工厂构造):实时 CSS 变量 --gc-size,对三种模式都生效,无需重渲染
// 显示大小最小(60%)时:平铺模式改为双层(封面在上、预览在下竖向堆叠),避免小尺寸下横向并排浪费空间
function syncCompact(v) { panel.classList.toggle('gc-compact', parseFloat(v) <= 0.6); }
const sizeWrap = rangeSlider({
cls: 'efav-gallery-size',
title: '显示大小(对平铺/收拢/全量均生效)',
styleVar: '--gc-size',
storeKey: 'javdbEmbyGallerySize',
min: '0.6', max: '1.8', step: '0.05',
load: function () { return parseFloat(localStorage.getItem('javdbEmbyGallerySize') || '1') || 1; },
fmt: function (v) { return Math.round(parseFloat(v) * 100) + '%'; },
onInit: syncCompact,
onInput: function (v) {
syncCompact(v);
// 全量瀑布流列宽由引擎按 --gc-size 换算,滑动后必须重排(否则列宽不变、大小不生效)
try { WaterfallEngine.refresh(); } catch (e) {}
}
});
bar.appendChild(sizeWrap.wrap);
panel.appendChild(bar);
return bar;
}
function syncToolbarView(bar, view) {
bar.querySelectorAll('.efav-seg-item').forEach(function (b) { b.classList.toggle('active', b.dataset.view === view); });
}
/* ---------- 预览画廊:详情页灯箱收藏按钮(B 方案) ---------- */
function initGalleryCollect() {
if (document.getElementById('efav-gallery-collect')) return; // 幂等
const btn = document.createElement('button');
btn.id = 'efav-gallery-collect';
btn.className = 'efav-gallery-collect';
btn.type = 'button';
btn.innerHTML = '<span class="material-symbols-outlined">bookmark_add</span><span class="efav-gc-text">收藏此图</span>';
document.body.appendChild(btn);
function curVid() {
// javdb 视频 ID:详情页 URL /v/{vid} 的 pathname 段(如 qA7J4D)。注意这不是番号,仅用于跳转 /v/{vid} 与去重 key。
const m = (location.pathname || '').match(/\/v\/([^/?#]+)/);
return m ? m[1] : '';
}
function curIdCode() {
// 真番号:详情页 h2.title 里的 <strong> 文本(如 ABC-123)。javdb 网址不含番号,番号只能从这里取。
const t = document.querySelector('h2.title.is-4, .video-detail h2.title, h1.title');
if (!t) return '';
const st = t.querySelector('strong');
return st ? st.textContent.trim() : '';
}
function curTitle() {
// 剧名(去掉番号 strong 后的剩余文本,优先取日文原标题)
const t = document.querySelector('h2.title.is-4, .video-detail h2.title, h1.title');
if (!t) return '';
const ot = t.querySelector('.origin-title');
if (isPluginEnabled('blockJavdbTranslate') && ot && ot.textContent.trim()) {
return ot.textContent.trim();
}
const st = t.querySelector('strong');
return (st ? t.textContent.replace(st.textContent, '') : t.textContent).trim();
}
function curCoverRel() {
// 只取详情页真实封面 src(stripHost 成相对路径),绝不自己用 code 拼目录——JAVDB 图片目录是无意义的分片值,与番号无关
const img = document.querySelector('.video-cover img, #emby-detail-hero .poster-wrap img');
if (img && img.getAttribute('src')) return FAV.stripHost(img.getAttribute('src') || '');
return '';
}
function fancySrc() {
const fb = document.querySelector('.fancybox__container, .fancybox-container');
if (!fb) return '';
// 优先:通过 fancybox 实例 API 拿「当前 slide」的真实 src(最确定,不依赖多图共存时取哪张)
// 覆盖 fancyapps/ui v5(Fancybox.getActiveInstance / getInstance)、v4、以及 javdb 可能的挂载方式
try {
const F = window.Fancybox;
let inst = null;
if (F) {
if (F.getActiveInstance) inst = F.getActiveInstance();
else if (F.getInstance) inst = F.getInstance();
else if (F.instances && F.instances.length) inst = F.instances[F.instances.length - 1];
}
if (!inst && fb.__fancybox) inst = fb.__fancybox;
if (inst) {
let slide = null;
if (inst.getSlide) { try { slide = inst.getSlide(); } catch (e) {} }
if (!slide && inst.slides) slide = inst.slides[inst.page != null ? inst.page : (inst.index != null ? inst.index : 0)];
if (slide) {
const src = slide.src || (slide.$content && slide.$content.find && slide.$content.find('img').attr('src')) || (slide.content && slide.content.src) || '';
if (src) { window.__efavFbMethod = 'instance'; return src; }
}
}
} catch (e) {}
// 回退 A:当前激活 slide 选择器(覆盖各版本 class)
const sel = fb.querySelector('.fancybox__slide.is-selected .fancybox__content img, .fancybox__slide.is-current .fancybox__content img, .fancybox__slide[aria-hidden="false"] .fancybox__content img, .fancybox-slide--current img, .fancybox-slide.current img');
if (sel && sel.getAttribute('src')) { window.__efavFbMethod = 'slideClass'; return sel.getAttribute('src'); }
// 回退 B:多图共存时取「视口内、可见、面积最大」的那张(当前图铺满视口,相邻图被 transform 移出视口被排除)
const imgs = fb.querySelectorAll('.fancybox__content img, img.fancybox__image, .fancybox-image, img');
const vw = window.innerWidth || document.documentElement.clientWidth || 0;
const vh = window.innerHeight || document.documentElement.clientHeight || 0;
let best = null, bestArea = 0;
for (let i = 0; i < imgs.length; i++) {
const el = imgs[i];
const src = el.getAttribute('src');
if (!src) continue;
const cs = (window.getComputedStyle ? getComputedStyle(el) : el.style) || {};
if (cs.visibility === 'hidden' || cs.display === 'none') continue;
const op = parseFloat(cs.opacity || '1');
if (!(op > 0)) continue;
const r = el.getBoundingClientRect();
if (r.right <= 0 || r.bottom <= 0 || r.left >= vw || r.top >= vh) continue;
const area = r.width * r.height;
if (area > bestArea) { bestArea = area; best = el; }
}
if (best) { window.__efavFbMethod = 'area'; return best.getAttribute('src'); }
// 回退 C:任意第一张有 src 的灯箱 img
for (let i = 0; i < imgs.length; i++) { if (imgs[i].getAttribute('src')) { window.__efavFbMethod = 'first'; return imgs[i].getAttribute('src'); } }
return '';
}
// 计算当前灯箱图片的 gallery id(与 click 收藏逻辑一致):只取灯箱当前大图真实 src。
// 注意:JAVDB 图片目录是无意义分片值,绝不能用 #gallery-N + code 硬拼路径。
function currentGalleryId() {
const vid = curVid();
if (!vid) return '';
const src = fancySrc();
const rel = src ? FAV.stripHost(src) : '';
if (!rel) return '';
return vid + '|' + rel;
}
function updateBtn() {
const fb = document.querySelector('.fancybox__container, .fancybox-container');
// 轮询只管显示/隐藏,绝不重置按钮文字和 saved 状态(否则会冲掉「已收藏 ✓」)
btn.style.display = fb ? 'inline-flex' : 'none';
if (!fb && btn.classList.contains('saved')) resetBtn(); // 灯箱关闭时恢复初始态
}
function resetBtn() {
btn.classList.remove('saved');
const tx = btn.querySelector('.efav-gc-text'); if (tx) tx.textContent = '收藏此图';
}
btn.addEventListener('click', async function () {
const vid = curVid();
const idCode = curIdCode();
if (!vid) { alert('未能识别当前番号'); return; }
// 只取灯箱当前大图的真实 src(stripHost 成相对路径)。JAVDB 图片目录是无意义分片值,绝不能用 code 硬拼。
const src = fancySrc();
const rel = src ? FAV.stripHost(src) : '';
if (!rel) { alert('未能识别当前图片(灯箱可能尚未加载完成,请稍候重试)'); return; }
const isPreview = /_l_\d+\.jpg$/.test(rel);
const coverRel = curCoverRel();
const id = vid + '|' + rel;
const rec = {
id: id,
vid: vid, code: idCode, title: curTitle(), date: '',
previewUrl: isPreview ? rel : '',
coverUrl: coverRel,
savedAt: new Date().toISOString()
};
try {
// 收/取消切换:已收藏则删除(取消收藏),否则新增(收藏)
if (FAV.isCollected(id)) {
await FAV.dbDeleteGallery(id);
paintSaved(id); // 立即显示未收藏(Set 已删除),无需等下次 poll
log('[DIAG] 画廊取消收藏: ' + (isPreview ? '预览图 ' : '封面 ') + rel);
} else {
await FAV.dbPutGallery(rec);
paintSaved(id); // 收藏成功后立即按当前图同步显示(Set 已 add),无需等下次 poll
log('[DIAG] 画廊收藏: ' + (isPreview ? '预览图 ' : '封面 ') + rel);
}
} catch (e) { alert('操作失败: ' + e.message); }
});
// 用轻量轮询替代全文档 MutationObserver(避免 javdb 页面持续 DOM 变动触发回调风暴卡死主线程)。
// 仅在详情页(URL 含 /v/)才显示按钮并轮询 fancybox 开关,非详情页直接隐藏。
let pollTimer = null;
let movedToTop = false; // 灯箱出现时把按钮移到 body 末尾,确保同 z-index 时盖过灯箱
// 显示状态完全由内存真相源 isCollected(当前图片id) 同步决定,异步 DB 结果无法串台
function paintSaved(curId) {
if (!curId) return;
if (FAV.isCollected(curId)) {
btn.classList.add('saved');
const tx = btn.querySelector('.efav-gc-text'); if (tx) tx.textContent = '已收藏 ✓';
} else {
btn.classList.remove('saved');
const tx = btn.querySelector('.efav-gc-text'); if (tx) tx.textContent = '收藏此图';
}
}
function startPoll() {
if (pollTimer) return;
poll();
pollTimer = setInterval(poll, 200);
}
function stopPoll() {
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
if (btn) btn.style.display = 'none';
movedToTop = false;
}
const poll = function () {
if (!enabled) { stopPoll(); return; }
const onDetail = /\/v\/[^/?#]+/.test(location.pathname || '');
if (!onDetail) { stopPoll(); return; }
const fb = document.querySelector('.fancybox__container, .fancybox-container');
if (!fb) { stopPoll(); return; }
updateBtn();
if (!movedToTop) { document.body.appendChild(btn); movedToTop = true; }
const curId = currentGalleryId();
paintSaved(curId); // 同步:当前真实图的收藏状态,绝不会串到别的图
};
// 事件驱动监听灯箱开启:仅当用户打开灯箱时启动局部轮询,关闭即刻销毁定时器,根除全站常驻空转
const fbObserver = new MutationObserver(function () {
const fb = document.querySelector('.fancybox__container, .fancybox-container');
if (fb) startPoll();
else if (pollTimer) stopPoll();
});
fbObserver.observe(document.body, { childList: true });
window.addEventListener('beforeunload', function () { stopPoll(); fbObserver.disconnect(); });
}
initGalleryCollect();
return { mount: mount, unmount: unmount, filterByDetail: filterByDetail, renderGallery: renderGallery, buildCard: buildCard, applyGalleryRandomBg: applyGalleryRandomBg, openFilterModal: openFilterModal, doImport: doImport, reload: reload };
})();
// 暴露到 window,便于详情页注入的按钮点击时调用(同闭包内 FAVUI 也已可见,双保险)
window.FAVUI = FAVUI;
window.FAV = FAV;
try {
if (typeof unsafeWindow !== 'undefined' && unsafeWindow) {
unsafeWindow.FAVUI = FAVUI;
unsafeWindow.FAV = FAV;
}
} catch (e) {}
/* =======================================================================
* v7.308 状态快捷标记与预览图系统 (Quick Status & Preview Modal)
* 移植自 Javdb_Enhanced_Script,支持想看/已看评分快捷标记、预览嗅探与画廊浮层
* ===================================================================== */
const QuickIcons = {
plus: '<span class="material-symbols-outlined" style="font-size:1.35rem;line-height:1;pointer-events:none;">add</span>',
watched: '<span class="material-symbols-outlined" style="font-size:1.35rem;line-height:1;pointer-events:none;">order_approve</span>',
wanted: '<span class="material-symbols-outlined" style="font-size:1.35rem;line-height:1;pointer-events:none;">play_lesson</span>',
eye: '<span class="material-symbols-outlined" style="font-size:1.35rem;line-height:1;pointer-events:none;">order_approve</span>',
heart: '<span class="material-symbols-outlined" style="font-size:1.35rem;line-height:1;pointer-events:none;">play_lesson</span>',
preview: '<span class="material-symbols-outlined" style="font-size:1.35rem;line-height:1;pointer-events:none;">image_search</span>',
list: '<span class="material-symbols-outlined" style="font-size:1.35rem;line-height:1;pointer-events:none;">format_list_bulleted_add</span>'
};
const PreviewSys = {
cache: new Map(),
async fetch(code, force) {
if (!code) return { status: 'no-data' };
const normCode = String(code).trim().toUpperCase();
if (!force && this.cache.has(normCode)) {
const c = this.cache.get(normCode);
if (c && c.status === 'success') return c;
}
const sites = getPreviewSiteConfigs().filter(function (s) { return s.enabled !== false; });
for (let i = 0; i < sites.length; i++) {
const s = sites[i];
try {
const res = await this._fetchBySiteConfig(s, normCode);
if (res && res.img) {
const out = { status: 'success', img: res.img, gallery: res.gallery || [res.img], source: s.name, siteId: s.id };
this.cache.set(normCode, out);
this.cache.set(normCode + ':' + s.id, out);
return out;
}
} catch (e) {
log('预览图抓取 [' + s.name + '] 异常: ' + e.message);
}
}
const noData = { status: 'no-data' };
this.cache.set(normCode, noData);
return noData;
},
async fetchFromSite(siteId, code, force) {
if (!code || !siteId) return { status: 'no-data' };
const normCode = String(code).trim().toUpperCase();
const cacheKey = normCode + ':' + siteId;
if (!force && this.cache.has(cacheKey)) {
const c = this.cache.get(cacheKey);
if (c && c.status === 'success') return c;
}
const sites = getPreviewSiteConfigs();
const s = sites.find(function (item) { return item.id === siteId; });
if (!s) return { status: 'no-data' };
try {
const res = await this._fetchBySiteConfig(s, normCode);
if (res && res.img) {
const out = { status: 'success', img: res.img, gallery: res.gallery || [res.img], source: s.name, siteId: s.id };
this.cache.set(cacheKey, out);
return out;
}
} catch (e) {
log('指定站点 [' + s.name + '] 抓取异常: ' + e.message);
}
const noData = { status: 'no-data' };
this.cache.set(cacheKey, noData);
return noData;
},
async _fetchBySiteConfig(s, normCode) {
if (!s) return null;
if (s.id === 'javfree') {
return await this._fetchJavfree(normCode);
} else if (s.id === 'javstore') {
return await this._fetchJavstore(normCode);
} else if (s.id === 'blogjav') {
return await this._fetchBlogjav(normCode);
} else if (s.id === 'local') {
return await this._fetchLocal(normCode);
} else if (s.template) {
return await this._fetchCustom(s.template, normCode);
}
return null;
},
async _fetchJavfree(code) {
const searchUrl = 'https://javfree.me/search/' + encodeURIComponent(code);
const html = await gmHttp.request({ url: searchUrl, method: 'GET', timeout: 10000 });
if (!html) return null;
const doc = new DOMParser().parseFromString(html, 'text/html');
const a = doc.querySelector('.entry-title > a');
if (!a) return null;
const normInput = code.replace(/[\s\-_]/g, '').toUpperCase();
const normTitle = (a.textContent || '').replace(/[\s\-_]/g, '').toUpperCase();
if (normTitle.indexOf(normInput) === -1) return null; // 必须匹配番号,防止无关搜索条目
let articleUrl = a.getAttribute('href') || '';
if (articleUrl.startsWith('/')) articleUrl = 'https://javfree.me' + articleUrl;
const artHtml = await gmHttp.request({ url: articleUrl, method: 'GET', timeout: 10000 });
if (!artHtml) return null;
const artDoc = new DOMParser().parseFromString(artHtml, 'text/html');
const imgs = artDoc.querySelectorAll('p > img, .entry-content img');
const imgNode = imgs[1] || imgs[0];
const src = imgNode ? imgNode.getAttribute('src') : null;
if (!src) return null;
const cleanSrc = src.replace(/^http:/, 'https:');
const gallery = [];
imgs.forEach(function (im) {
const s = im.getAttribute('src');
if (s && gallery.indexOf(s) === -1) gallery.push(s.replace(/^http:/, 'https:'));
});
return { img: cleanSrc, gallery: gallery.length ? gallery : [cleanSrc] };
},
async _fetchJavstore(code) {
const searchUrl = 'https://javstore.net/search?q=' + encodeURIComponent(code);
const html = await gmHttp.request({ url: searchUrl, method: 'GET', timeout: 10000 });
if (!html) return null;
const doc = new DOMParser().parseFromString(html, 'text/html');
const normInput = code.replace(/[\s\-_]/g, '').toUpperCase();
const candidates = Array.from(doc.querySelectorAll('div.grid > a, a.group, article h2 a'));
let targetHref = null;
for (let i = 0; i < candidates.length; i++) {
const cand = candidates[i];
const h3Text = (cand.querySelector('h3') ? cand.querySelector('h3').textContent : '') || cand.textContent || '';
const normCand = h3Text.replace(/[\s\-_]/g, '').toUpperCase();
if (normCand.indexOf(normInput) !== -1) {
targetHref = cand.getAttribute('href');
break;
}
}
if (!targetHref) return null; // 未匹配到精确番号则放弃,绝不盲目回退到搜索结果第一条
if (targetHref.startsWith('/')) targetHref = 'https://javstore.net' + targetHref;
const artHtml = await gmHttp.request({ url: targetHref, method: 'GET', timeout: 10000 });
if (!artHtml) return null;
const artDoc = new DOMParser().parseFromString(artHtml, 'text/html');
const gallery = [];
artDoc.querySelectorAll('.prose a[href*=".jpg"], .prose a[href*=".png"], .prose a[href*="_s.jpg"], .entry-content a[href*=".jpg"]').forEach(function (node) {
const s = node.getAttribute('href');
if (s && !/avatar|logo|banner/i.test(s)) {
const u = s.replace(/^http:/, 'https:');
if (gallery.indexOf(u) === -1) gallery.push(u);
}
});
if (!gallery.length) {
artDoc.querySelectorAll('.prose img, .entry-content img').forEach(function (node) {
const s = node.getAttribute('src');
if (s && !/avatar|logo|banner/i.test(s)) {
const u = s.replace(/^http:/, 'https:');
if (gallery.indexOf(u) === -1) gallery.push(u);
}
});
}
if (!gallery.length) return null;
return { img: gallery[0], gallery: gallery };
},
async _fetchBlogjav(code) {
const searchUrl = 'https://blogjav.net/?s=' + encodeURIComponent(code);
const html = await gmHttp.request({ url: searchUrl, method: 'GET', timeout: 10000 });
if (!html) return null;
const doc = new DOMParser().parseFromString(html, 'text/html');
const normInput = code.replace(/[\s\-_]/g, '').toUpperCase();
const article = Array.from(doc.querySelectorAll('article')).find(function (a) {
const titleEl = a.querySelector('.entry-title a') || a.querySelector('h2 a');
if (!titleEl) return false;
const t = (titleEl.textContent || '').replace(/[\s\-_]/g, '').toUpperCase();
return t.indexOf(normInput) !== -1;
});
const link = article ? (article.querySelector('.entry-title a') || article.querySelector('h2 a')) : null;
if (!link) return null;
const href = link.getAttribute('href');
if (!href) return null;
const artHtml = await gmHttp.request({ url: href, method: 'GET', timeout: 10000 });
if (!artHtml) return null;
const artDoc = new DOMParser().parseFromString(artHtml, 'text/html');
const imgNodes = Array.from(artDoc.querySelectorAll('.entry-content img'));
if (!imgNodes.length) return null;
const gallery = [];
imgNodes.forEach(function (im) {
const s = im.getAttribute('src') || '';
if (s && !/avatar|logo|banner/i.test(s)) {
const clean = s.replace(/t(\d+)\.pixhost\.to\/thumbs/, 'img$1.pixhost.to/images').replace(/^http:/, 'https:');
if (gallery.indexOf(clean) === -1) gallery.push(clean);
}
});
return gallery.length ? { img: gallery[0], gallery: gallery } : null;
},
async _fetchLocal(code) {
// 优化:若当前正是详情页且存在预览图,直接从页面 DOM 秒提,无需重复发请求
if (typeof currentPageCode === 'function' && currentPageCode() === code) {
const localGallery = [];
document.querySelectorAll('.preview-images .tile-item, .tile-images.preview-images a').forEach(function (a) {
const href = a.getAttribute('href') || a.getAttribute('data-fancybox-href') || a.getAttribute('data-src');
if (href && !/avatar|logo|icon|banner|ads/i.test(href) && localGallery.indexOf(href) === -1) {
localGallery.push(toAbsUrl(href));
}
});
if (localGallery.length) {
return { img: localGallery[0], gallery: localGallery };
}
}
let detailUrl = '';
const sHtml = await gmHttp.request({ url: '/search?q=' + encodeURIComponent(code) + '&f=all', method: 'GET', timeout: 8000 });
if (!sHtml) return null;
const sDoc = new DOMParser().parseFromString(sHtml, 'text/html');
const firstItem = sDoc.querySelector('.movie-list .item a.box');
if (firstItem) detailUrl = firstItem.getAttribute('href') || '';
if (!detailUrl) return null;
const fullDetailUrl = toAbsUrl(detailUrl);
const dHtml = await gmHttp.request({ url: fullDetailUrl, method: 'GET', timeout: 8000 });
if (!dHtml) return null;
const dDoc = new DOMParser().parseFromString(dHtml, 'text/html');
const gallery = [];
dDoc.querySelectorAll('.preview-images .tile-item').forEach(function (a) {
const href = a.getAttribute('href');
if (href) gallery.push(toAbsUrl(href));
});
if (!gallery.length) {
const mainCover = dDoc.querySelector('.video-meta-panel .video-cover, .video-cover img');
if (mainCover) {
const src = mainCover.getAttribute('src');
if (src) gallery.push(toAbsUrl(src));
}
}
return gallery.length ? { img: gallery[0], gallery: gallery } : null;
},
async _fetchCustom(tpl, code) {
const url = tpl.replace(/\{code\}/g, encodeURIComponent(code));
const html = await gmHttp.request({ url: url, method: 'GET', timeout: 10000 });
if (!html) return null;
const doc = new DOMParser().parseFromString(html, 'text/html');
const imgs = Array.from(doc.querySelectorAll('.preview-images img, .entry-content img, article img, img'));
if (!imgs.length) return null;
const gallery = [];
imgs.forEach(function (im) {
const src = im.getAttribute('data-src') || im.getAttribute('src');
if (src && !/avatar|logo|icon|banner|ads/i.test(src) && gallery.indexOf(src) === -1) {
gallery.push(toAbsUrl(src));
}
});
return gallery.length ? { img: gallery[0], gallery: gallery } : null;
}
};
const ModalView = {
modalEl: null,
keyHandler: null,
open: function (code, initialImg, gallery, activeSiteId) {
if (document.getElementById('jdbe-preview-modal')) return;
const m = document.createElement('div');
m.id = 'jdbe-preview-modal';
m.className = 'jdbe-modal-v2';
m.setAttribute('aria-modal', 'true');
let images = Array.isArray(gallery) && gallery.length ? gallery : [initialImg];
let activeIdx = 0;
const allSites = getPreviewSiteConfigs();
const enabledSites = allSites.filter(function (s) { return s.enabled !== false; });
if (!activeSiteId && enabledSites.length > 0) {
activeSiteId = enabledSites[0].id;
}
// 缩放与平移状态
let scale = 1.0;
let panX = 0;
let panY = 0;
let isDragging = false;
let dragStartX = 0;
let dragStartY = 0;
m.innerHTML =
'<div class="pm-backdrop"></div>' +
'<div id="pm-main-view">' +
'<div class="pm-top-bar">' +
'<div class="pm-title">' +
'<span>' + escapeAttr(code || '预览图') + '</span>' +
'<span class="pm-badge pm-count-badge">1 / ' + images.length + '</span>' +
'</div>' +
'<div class="pm-zoom-controls">' +
'<button type="button" class="pm-tool-btn pm-btn-zoom-out" title="缩小 (-)">−</button>' +
'<span class="pm-zoom-level">100%</span>' +
'<button type="button" class="pm-tool-btn pm-btn-zoom-in" title="放大 (+)">+</button>' +
'<button type="button" class="pm-tool-btn pm-btn-zoom-reset" title="重置缩放 (0)">↺</button>' +
'<button type="button" class="pm-tool-btn pm-btn-original" title="100% 原始比例">1:1</button>' +
'</div>' +
'<button type="button" class="pm-close-btn" title="关闭 (Esc)">✕</button>' +
'</div>' +
(enabledSites.length > 0 ? (
'<div class="pm-sites-bar">' +
'<span class="pm-sites-label">预览图源:</span>' +
'<div class="pm-site-switchers">' +
enabledSites.map(function (s) {
const isActive = (s.id === activeSiteId);
return '<button type="button" class="pm-site-btn' + (isActive ? ' is-active' : '') + '" data-site-id="' + escapeAttr(s.id) + '" title="' + escapeAttr(s.name) + '">' +
renderSiteLogoHtml(s.icon, s.name, s.id) +
'<span class="pm-site-name">' + escapeHtml(s.name) + '</span>' +
'</button>';
}).join('') +
'</div>' +
'</div>'
) : '') +
'<div class="pm-viewport" id="pm-viewport">' +
'<img id="pm-main-image" src="' + escapeAttr(initialImg) + '" alt="' + escapeAttr(code) + '" draggable="false">' +
'</div>' +
'<div id="pm-gallery-strip">' +
images.map(function (src, i) {
return '<img class="pm-gallery-thumb' + (i === 0 ? ' is-active' : '') + '" src="' + escapeAttr(src) + '" data-idx="' + i + '" draggable="false">';
}).join('') +
'</div>' +
'</div>';
document.body.appendChild(m);
this.modalEl = m;
const viewport = m.querySelector('#pm-viewport');
const mainImg = m.querySelector('#pm-main-image');
const zoomLevelEl = m.querySelector('.pm-zoom-level');
const countBadge = m.querySelector('.pm-count-badge');
const btnZoomIn = m.querySelector('.pm-btn-zoom-in');
const btnZoomOut = m.querySelector('.pm-btn-zoom-out');
const btnZoomReset = m.querySelector('.pm-btn-zoom-reset');
const btnOriginal = m.querySelector('.pm-btn-original');
function updateTransform(animate) {
if (!mainImg) return;
mainImg.style.transition = animate ? 'transform 0.18s cubic-bezier(0.2, 0, 0.2, 1)' : 'none';
mainImg.style.transform = 'translate(' + panX + 'px, ' + panY + 'px) scale(' + scale + ')';
if (zoomLevelEl) zoomLevelEl.textContent = Math.round(scale * 100) + '%';
if (viewport) {
viewport.classList.toggle('is-zoomable', scale > 1.02);
viewport.classList.toggle('is-dragging', isDragging);
}
}
function zoom(delta, targetX, targetY) {
const oldScale = scale;
let newScale = Math.round((scale + delta) * 100) / 100;
newScale = Math.min(5.0, Math.max(0.5, newScale));
if (Math.abs(newScale - 1.0) < 0.04) newScale = 1.0;
if (newScale === 1.0) {
panX = 0;
panY = 0;
} else if (targetX != null && targetY != null) {
const ratio = newScale / oldScale;
panX = targetX - (targetX - panX) * ratio;
panY = targetY - (targetY - panY) * ratio;
}
scale = newScale;
updateTransform(true);
}
function resetZoom() {
scale = 1.0;
panX = 0;
panY = 0;
updateTransform(true);
}
function switchImage(idx) {
if (idx < 0 || idx >= images.length) return;
activeIdx = idx;
mainImg.src = images[idx];
const curThumbs = m.querySelectorAll('.pm-gallery-thumb');
curThumbs.forEach(function (t, i) { t.classList.toggle('is-active', i === idx); });
if (countBadge) countBadge.textContent = (idx + 1) + ' / ' + images.length;
resetZoom();
}
function bindThumbnailClicks() {
m.querySelectorAll('.pm-gallery-thumb').forEach(function (th) {
th.addEventListener('click', function (e) {
e.stopPropagation();
const idx = parseInt(th.getAttribute('data-idx'), 10);
switchImage(idx);
});
});
}
bindThumbnailClicks();
// 外部预览站点切换事件
const siteBtns = m.querySelectorAll('.pm-site-btn');
siteBtns.forEach(function (btn) {
btn.addEventListener('click', async function (e) {
e.stopPropagation();
const targetSiteId = btn.getAttribute('data-site-id');
if (!targetSiteId) return;
if (btn.classList.contains('is-loading')) return;
siteBtns.forEach(function (b) { b.classList.remove('is-loading'); });
btn.classList.add('is-loading');
try {
const res = await PreviewSys.fetchFromSite(targetSiteId, code);
btn.classList.remove('is-loading');
if (res && res.status === 'success' && res.img) {
siteBtns.forEach(function (b) { b.classList.remove('is-active'); });
btn.classList.add('is-active');
images = Array.isArray(res.gallery) && res.gallery.length ? res.gallery : [res.img];
activeIdx = 0;
const strip = m.querySelector('#pm-gallery-strip');
if (strip) {
strip.innerHTML = images.map(function (src, i) {
return '<img class="pm-gallery-thumb' + (i === 0 ? ' is-active' : '') + '" src="' + escapeAttr(src) + '" data-idx="' + i + '" draggable="false">';
}).join('');
bindThumbnailClicks();
}
mainImg.src = images[0];
if (countBadge) countBadge.textContent = '1 / ' + images.length;
resetZoom();
if (typeof showToast === 'function') {
showToast('已切换至 ' + (res.source || targetSiteId) + ' 预览图(共 ' + images.length + ' 张)');
}
} else {
if (typeof showToast === 'function') {
showToast('从 ' + (btn.getAttribute('title') || targetSiteId) + ' 未能获取到预览图');
} else {
alert('从该站点未能获取到预览图');
}
}
} catch (err) {
btn.classList.remove('is-loading');
log('切换预览站点异常: ' + err.message);
}
});
});
// 滚轮缩放:以鼠标位置为中心平滑缩放
viewport.addEventListener('wheel', function (e) {
e.preventDefault();
e.stopPropagation();
const rect = viewport.getBoundingClientRect();
const targetX = e.clientX - (rect.left + rect.width / 2);
const targetY = e.clientY - (rect.top + rect.height / 2);
const step = e.deltaY < 0 ? 0.25 : -0.25;
zoom(step, targetX, targetY);
}, { passive: false });
// 双击缩放切换 (1x <-> 2.2x)
viewport.addEventListener('dblclick', function (e) {
e.preventDefault();
e.stopPropagation();
if (scale > 1.2) {
resetZoom();
} else {
const rect = viewport.getBoundingClientRect();
const targetX = e.clientX - (rect.left + rect.width / 2);
const targetY = e.clientY - (rect.top + rect.height / 2);
scale = 2.2;
panX = -targetX * 0.7;
panY = -targetY * 0.7;
updateTransform(true);
}
});
// 拖拽平移
viewport.addEventListener('mousedown', function (e) {
if (e.button !== 0 || scale <= 1.02) return;
e.preventDefault();
isDragging = true;
dragStartX = e.clientX - panX;
dragStartY = e.clientY - panY;
updateTransform(false);
});
const onMouseMove = function (e) {
if (!isDragging) return;
e.preventDefault();
panX = e.clientX - dragStartX;
panY = e.clientY - dragStartY;
updateTransform(false);
};
const onMouseUp = function () {
if (isDragging) {
isDragging = false;
updateTransform(false);
}
};
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
// 工具栏按钮事件
btnZoomIn.addEventListener('click', function (e) { e.stopPropagation(); zoom(0.3); });
btnZoomOut.addEventListener('click', function (e) { e.stopPropagation(); zoom(-0.3); });
btnZoomReset.addEventListener('click', function (e) { e.stopPropagation(); resetZoom(); });
btnOriginal.addEventListener('click', function (e) { e.stopPropagation(); scale = 1.0; panX = 0; panY = 0; updateTransform(true); });
const closeHandler = function () {
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
ModalView.close();
};
m.querySelector('.pm-backdrop').addEventListener('click', closeHandler);
m.querySelector('.pm-close-btn').addEventListener('click', closeHandler);
const keyHandler = function (e) {
if (e.key === 'Escape') {
closeHandler();
} else if (e.key === 'ArrowRight') {
if (activeIdx < images.length - 1) switchImage(activeIdx + 1);
} else if (e.key === 'ArrowLeft') {
if (activeIdx > 0) switchImage(activeIdx - 1);
} else if (e.key === '+' || e.key === '=') {
zoom(0.25);
} else if (e.key === '-' || e.key === '_') {
zoom(-0.25);
} else if (e.key === '0') {
resetZoom();
}
};
this.keyHandler = keyHandler;
window.addEventListener('keydown', keyHandler);
},
close: function () {
if (this.keyHandler) {
window.removeEventListener('keydown', this.keyHandler);
this.keyHandler = null;
}
const el = document.getElementById('jdbe-preview-modal');
if (el) el.remove();
this.modalEl = null;
}
};
/* 快捷标记与预览大图挂载与事件 */
function extractVideoIdFromItem(item) {
if (!item) return '';
if (item.dataset && item.dataset.videoId) return item.dataset.videoId;
if (item.dataset && item.dataset.movieId) return item.dataset.movieId;
const box = item.querySelector('a.box, .box');
if (box) {
if (box.dataset && box.dataset.videoId) return box.dataset.videoId;
if (box.dataset && box.dataset.movieId) return box.dataset.movieId;
const bHref = box.getAttribute('href') || '';
const bm = bHref.match(/\/v\/([^/?#]+)/);
if (bm) return bm[1];
}
const anchor = item.querySelector('a[href*="/v/"]');
if (anchor) {
const href = anchor.getAttribute('href') || '';
const m = href.match(/\/v\/([^/?#]+)/);
if (m) return m[1];
}
return '';
}
function extractCodeFromItem(item) {
if (!item) return '';
if (item.dataset && item.dataset.code) return item.dataset.code;
const box = item.querySelector('a.box, .box');
if (box && box.dataset && box.dataset.code) return box.dataset.code;
const titleEl = item.querySelector('.video-title strong, strong.video-number, .video-number');
if (titleEl && titleEl.textContent.trim()) return titleEl.textContent.trim();
if (box && box.getAttribute('title')) {
const m = box.getAttribute('title').match(/^[A-Za-z0-9\-_]+/);
if (m) return m[0];
}
return '';
}
async function showCoverRatingModal(item, videoId, code) {
if (!item) return;
const box = item.querySelector('.box') || item;
box.style.position = 'relative';
const existing = box.querySelector('.cover-modal-base') || item.querySelector('.cover-modal-base');
if (existing) { existing.remove(); return; }
if (!videoId) {
videoId = extractVideoIdFromItem(item);
}
let defaultScore = 5;
if (code && typeof FAV !== 'undefined' && typeof FAV.dbGetMovie === 'function') {
try {
const m = await FAV.dbGetMovie(code);
if (m) {
if (!videoId && m.detailHref) {
const match = m.detailHref.match(/\/v\/([^/?#]+)/);
if (match) videoId = match[1];
}
if (typeof m.userScore === 'number' && m.userScore > 0) {
defaultScore = m.userScore;
}
}
} catch (e) {}
}
const overlay = document.createElement('div');
overlay.className = 'cover-modal-base';
overlay.innerHTML =
'<div style="font-size:12px;font-weight:700;color:#fff;margin-bottom:2px;text-shadow:0 1px 2px rgba(0,0,0,0.8);">评分并标记为看過</div>' +
'<div class="rating-stars">' +
'<span class="star-item" data-score="1" title="1星">★</span>' +
'<span class="star-item" data-score="2" title="2星">★</span>' +
'<span class="star-item" data-score="3" title="3星">★</span>' +
'<span class="star-item" data-score="4" title="4星">★</span>' +
'<span class="star-item" data-score="5" title="5星">★</span>' +
'</div>' +
'<button type="button" class="button">取消</button>';
overlay.addEventListener('click', function (e) {
e.stopPropagation();
e.preventDefault();
if (e.target === overlay) {
overlay.remove();
}
});
box.appendChild(overlay);
const cancelBtn = overlay.querySelector('.button');
if (cancelBtn) {
cancelBtn.addEventListener('click', function (e) {
e.stopPropagation();
e.preventDefault();
overlay.remove();
});
}
const starEls = overlay.querySelectorAll('.star-item');
function setStars(hoverScore) {
starEls.forEach(function (s) {
const sc = parseInt(s.dataset.score, 10);
s.style.color = (sc <= hoverScore) ? '#f59e0b' : 'rgba(255,255,255,0.3)';
s.style.transform = (sc === hoverScore) ? 'scale(1.25)' : 'scale(1)';
});
}
setStars(defaultScore);
starEls.forEach(function (star) {
star.addEventListener('mouseenter', function (e) {
e.stopPropagation();
setStars(parseInt(star.dataset.score, 10));
});
star.addEventListener('click', function (e) {
e.stopPropagation();
e.preventDefault();
const score = parseInt(star.dataset.score, 10) || 5;
overlay.remove();
updateCoverReviewStatus('watched', videoId, item, code, score);
});
});
const starContainer = overlay.querySelector('.rating-stars');
if (starContainer) {
starContainer.addEventListener('mouseleave', function () {
setStars(defaultScore);
});
}
}
async function updateCoverReviewStatus(action, videoId, item, code, rating) {
if (!videoId && item) videoId = extractVideoIdFromItem(item);
if (!code && item) code = extractCodeFromItem(item);
const csrfMeta = document.querySelector('meta[name="csrf-token"]');
const csrf = csrfMeta ? csrfMeta.content : '';
if (!csrf) {
if (typeof showToast === 'function') showToast('请先登录 JAVDB 账号以使用标记功能');
else alert('请先登录 JAVDB 账号以使用标记功能');
return;
}
const fullUrl = function (path) {
return toAbsUrl(path);
};
// 如果尚未获得 videoId,尝试通过数据库或搜索解析
if (!videoId && code && typeof FAV !== 'undefined' && typeof FAV.dbGetMovie === 'function') {
try {
const m = await FAV.dbGetMovie(code);
if (m && m.detailHref) {
const match = m.detailHref.match(/\/v\/([^/?#]+)/);
if (match) videoId = match[1];
}
} catch (e) {}
}
if (!videoId && code) {
try {
const sRes = await fetch(fullUrl('/search?q=' + encodeURIComponent(code) + '&f=all'), { credentials: 'same-origin' });
if (sRes.ok) {
const sText = await sRes.text();
const sMatch = sText.match(/\/v\/([^/?#"'\s]+)/);
if (sMatch) videoId = sMatch[1];
}
} catch (e) {}
}
let reviewId = (item && item.dataset) ? (item.dataset.reviewId || '') : '';
if (!reviewId && code && typeof FAV !== 'undefined' && typeof FAV.dbGetMovie === 'function') {
try {
const m = await FAV.dbGetMovie(code);
if (m && m.reviewId) reviewId = m.reviewId;
} catch (e) {}
}
// 当需要删除标记但没有 reviewId 时,尝试抓取详情页查找真实的 reviewId
if (action === 'delete' && videoId && !reviewId) {
try {
const dRes = await fetch(fullUrl('/v/' + videoId), { credentials: 'same-origin' });
if (dRes.ok) {
const dHtml = await dRes.text();
const delMatch = dHtml.match(/href=["']\/v\/[^"']+\/reviews\/(\d+)["'][^>]*data-method=["']delete["']/i) ||
dHtml.match(/href=["'](\/v\/[^"']+\/reviews\/(\d+))["'][^>]*data-method=["']delete["']/i) ||
dHtml.match(/\/reviews\/(\d+)/);
if (delMatch) {
reviewId = delMatch[delMatch.length - 1];
if (item) item.dataset.reviewId = reviewId;
}
}
} catch (e) {}
}
// 针对 action === 'delete':如果云端根本没有评价记录(reviewId 为空),直接做本地清理,避免 POST /v/:videoId/reviews 报 404
if (action === 'delete' && !reviewId) {
const c = item ? item.querySelector('.csb-status-container') : null;
if (c) {
c.className = 'csb-status-container show-unmarked';
c.querySelectorAll('.star-arc-container span').forEach(function (s) { s.classList.remove('is-filled'); });
}
if (code && typeof FAV !== 'undefined' && typeof FAV.dbGetMovie === 'function') {
try {
const m = await FAV.dbGetMovie(code);
if (m) {
m.reviewStatus = null;
delete m.userScore;
delete m.reviewId;
m.reviewSyncedAt = Date.now();
await FAV.dbPutMovie(m);
}
} catch (e) {}
}
if (typeof showToast === 'function') showToast('已取消作品标记');
try {
document.dispatchEvent(new CustomEvent('emby-fav-changed', { bubbles: true, detail: { code: code } }));
} catch (e) {}
return;
}
if (!videoId) {
if (typeof showToast === 'function') showToast('未能定位该作品的详情页 ID');
return;
}
let urlPath = '';
let body = 'authenticity_token=' + encodeURIComponent(csrf);
if (action === 'watched' && rating != null) {
urlPath = reviewId ? ('/v/' + videoId + '/reviews/' + reviewId) : ('/v/' + videoId + '/reviews');
body += '&video_review[status]=watched&video_review[score]=' + rating + '&video_review[content]=' + (reviewId ? '&_method=patch' : '');
} else if (action === 'wanted') {
urlPath = '/v/' + videoId + '/reviews/want_to_watch';
} else if (action === 'delete') {
urlPath = '/v/' + videoId + '/reviews/' + reviewId;
body += '&_method=delete';
} else {
return;
}
try {
if (typeof showToast === 'function') showToast('正在同步标记状态至 JAVDB 云端...');
let res = await fetch(fullUrl(urlPath), {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
'X-CSRF-Token': csrf,
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'text/javascript, application/javascript, application/ecmascript, application/x-ecmascript, */*; q=0.01'
},
body: body,
credentials: 'same-origin'
});
// 如果返回 422 且未指定 reviewId,说明 JAVDB 线上该作品已存在评价记录,必须使用 PATCH 更新
if (!res.ok && res.status === 422 && action === 'watched' && !reviewId) {
try {
const detailRes = await fetch(fullUrl('/v/' + videoId), { credentials: 'same-origin' });
if (detailRes.ok) {
const detailHtml = await detailRes.text();
const delMatch = detailHtml.match(/href=["']\/v\/[^"']+\/reviews\/(\d+)["'][^>]*data-method=["']delete["']/i) ||
detailHtml.match(/\/reviews\/(\d+)/);
if (delMatch && delMatch[1]) {
reviewId = delMatch[1];
if (item) item.dataset.reviewId = reviewId;
const patchUrl = '/v/' + videoId + '/reviews/' + reviewId;
const patchBody = 'authenticity_token=' + encodeURIComponent(csrf) +
'&_method=patch&video_review[status]=watched&video_review[score]=' + rating + '&video_review[content]=';
res = await fetch(fullUrl(patchUrl), {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
'X-CSRF-Token': csrf,
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'text/javascript, application/javascript, application/ecmascript, application/x-ecmascript, */*; q=0.01'
},
body: patchBody,
credentials: 'same-origin'
});
}
}
} catch (subErr) {
log('自动重试 PATCH 评价记录异常: ' + subErr.message);
}
}
if (!res.ok) {
throw new Error('HTTP ' + res.status + ': ' + (res.statusText || 'Request failed'));
}
// 更新卡片 UI(仅在 hover 时显现,不常驻卡片)
const c = item ? item.querySelector('.csb-status-container') : null;
if (c) {
c.className = 'csb-status-container show-' + (action === 'delete' ? 'unmarked' : action);
if (action === 'watched') {
const hasRating = (typeof rating === 'number' && rating > 0);
c.querySelectorAll('.star-arc-container span').forEach(function (s, i) {
s.classList.toggle('is-filled', hasRating && i < rating);
});
const iconEl = c.querySelector('.state-watched .status-main-icon');
if (iconEl) {
iconEl.title = hasRating ? ('已看 (我的评分: ' + rating + ' 星)') : '已看 (未评分)';
}
} else {
c.querySelectorAll('.star-arc-container span').forEach(function (s) {
s.classList.remove('is-filled');
});
const iconEl = c.querySelector('.state-watched .status-main-icon');
if (iconEl) {
iconEl.title = '已看';
}
}
}
// 同步本地数据库 & 触发全量详情抓取入库
if (code && typeof FAV !== 'undefined') {
const targetStatus = (action === 'delete' ? null : (action === 'wanted' ? 'wishlist' : 'watched'));
if (action === 'delete') {
try {
const m = await FAV.dbGetMovie(code);
if (m) {
m.reviewStatus = null;
delete m.userScore;
delete m.reviewId;
m.reviewSyncedAt = Date.now();
await FAV.dbPutMovie(m);
}
} catch (e) {}
} else {
// 立即落库核心字段(状态 + 个人打分),杜绝切页竞态
try {
let m = await FAV.dbGetMovie(code);
if (!m) {
const imgEl = item ? item.querySelector('img') : null;
m = newMovieRecord({
code: code,
title: (item ? (item.querySelector('.video-title')?.getAttribute('title') || item.querySelector('.video-title strong')?.textContent?.trim()) : '') || code,
date: item ? (item.querySelector('.meta')?.textContent?.trim() || '') : '',
coverThumb: imgEl ? (imgEl.getAttribute('data-src') || imgEl.getAttribute('src') || '') : '',
detailHref: videoId ? ('/v/' + videoId) : ('/v/' + code)
});
}
m.reviewStatus = targetStatus;
if (action === 'watched' && typeof rating === 'number' && rating > 0) {
m.userScore = rating;
} else if (action !== 'watched') {
delete m.userScore;
}
m.reviewSyncedAt = Date.now();
await FAV.dbPutMovie(m);
} catch (e) {}
if (typeof FAV.fetchAndSaveDetail === 'function') {
const imgEl = item ? item.querySelector('img') : null;
const initialData = {
title: (item ? (item.querySelector('.video-title')?.getAttribute('title') || item.querySelector('.video-title strong')?.textContent?.trim()) : '') || code,
date: item ? (item.querySelector('.meta')?.textContent?.trim() || '') : '',
coverThumb: imgEl ? (imgEl.getAttribute('data-src') || imgEl.getAttribute('src') || '') : ''
};
FAV.fetchAndSaveDetail(code, videoId, targetStatus, rating, initialData).catch(function (err) {
log('后台即时抓取番号详情失败: ' + err.message);
});
}
}
}
if (typeof showToast === 'function') {
showToast(action === 'delete' ? '已取消作品标记' : (action === 'wanted' ? '已加入「想看」' : ('已标记为「看過」(' + rating + '星)')));
}
try {
document.dispatchEvent(new CustomEvent('emby-fav-changed', { bubbles: true, detail: { code: code } }));
} catch (e) {}
} catch (err) {
log('更新标记状态失败: ' + err.message);
if (typeof showToast === 'function') showToast('标记失败: ' + err.message);
}
}
function attachCoverStatusAndPreview(item) {
if (!item || !isPluginEnabled('quickStatusPreview') || !document.documentElement.classList.contains('emby-skin')) return;
if (item.querySelector('.cover-status-buttons')) return;
const vid = extractVideoIdFromItem(item);
const code = extractCodeFromItem(item);
if (!vid && !code) return;
const wrapper = document.createElement('div');
wrapper.className = 'cover-status-buttons';
wrapper.addEventListener('click', function (e) {
e.stopPropagation();
});
wrapper.innerHTML =
'<div class="cover-ui-wrapper">' +
'<div class="csb-outer-container">' +
'<div class="csb-status-container show-unmarked">' +
'<div class="state state-unmarked">' +
'<div class="status-main-icon" title="未标记">' + QuickIcons.plus + '</div>' +
'<div class="action-buttons-wrapper">' +
'<div class="action-buttons">' +
'<button type="button" class="button btn-set-wanted">想看</button>' +
'<button type="button" class="button btn-set-watched js-set-watched">已看</button>' +
'</div>' +
'</div>' +
'</div>' +
'<div class="state state-watched">' +
'<div class="status-main-icon" title="已看">' + QuickIcons.watched +
'<div class="star-arc-container"><span>★</span><span>★</span><span>★</span><span>★</span><span>★</span></div>' +
'</div>' +
'<div class="action-buttons-wrapper">' +
'<div class="action-buttons">' +
'<button type="button" class="button btn-modify">评分</button>' +
'<button type="button" class="button btn-delete js-delete">删除</button>' +
'</div>' +
'</div>' +
'</div>' +
'<div class="state state-wanted">' +
'<div class="status-main-icon" title="想看">' + QuickIcons.wanted + '</div>' +
'<div class="action-buttons-wrapper">' +
'<div class="action-buttons">' +
'<button type="button" class="button btn-set-watched js-set-watched">已看</button>' +
'<button type="button" class="button btn-delete js-delete">删除</button>' +
'</div>' +
'</div>' +
'</div>' +
'</div>' +
'</div>' +
'<button type="button" class="csb-preview-icon-container" title="嗅探预览大图与剧照">' +
QuickIcons.preview +
'</button>' +
'</div>';
// 挂载到卡片内部(.box 或 .item)
const box = item.querySelector('.box') || item;
box.style.position = 'relative';
box.appendChild(wrapper);
// 检查本地库是否有该番号的现有状态(注意:绝不添加 status-ui-persist,卡片移出即彻底消失)
if (code && typeof FAV !== 'undefined' && typeof FAV.dbGetMovie === 'function') {
FAV.dbGetMovie(code).then(function (m) {
if (m && m.reviewStatus) {
const c = wrapper.querySelector('.csb-status-container');
if (c) {
const st = (m.reviewStatus === 'wishlist' ? 'wanted' : m.reviewStatus);
c.className = 'csb-status-container show-' + st;
if (st === 'watched') {
const hasUserScore = (typeof m.userScore === 'number' && m.userScore > 0);
const stars = hasUserScore ? Math.min(5, Math.max(1, Math.round(m.userScore))) : 0;
c.querySelectorAll('.star-arc-container span').forEach(function (s, i) {
s.classList.toggle('is-filled', i < stars);
});
const iconEl = c.querySelector('.state-watched .status-main-icon');
if (iconEl) {
iconEl.title = hasUserScore ? ('已看 (我的评分: ' + m.userScore + ' 星)') : '已看 (未评分)';
}
}
}
}
}).catch(function () {});
}
// 事件绑定
const btnWan = wrapper.querySelector('.btn-set-wanted');
const btnWat = wrapper.querySelectorAll('.btn-set-watched, .btn-modify');
const btnDel = wrapper.querySelectorAll('.btn-delete');
const prevBtn = wrapper.querySelector('.csb-preview-icon-container');
if (btnWan) {
btnWan.addEventListener('click', function (e) {
e.preventDefault(); e.stopPropagation();
updateCoverReviewStatus('wanted', vid, item, code);
});
}
btnWat.forEach(function (b) {
b.addEventListener('click', function (e) {
e.preventDefault(); e.stopPropagation();
showCoverRatingModal(item, vid, code);
});
});
btnDel.forEach(function (b) {
b.addEventListener('click', function (e) {
e.preventDefault(); e.stopPropagation();
if (confirm('确定删除该作品的标记状态吗?')) {
updateCoverReviewStatus('delete', vid, item, code);
}
});
});
if (prevBtn) {
prevBtn.addEventListener('click', async function (e) {
e.preventDefault(); e.stopPropagation();
if (prevBtn.classList.contains('is-loading')) return;
prevBtn.classList.add('is-loading');
try {
const res = await PreviewSys.fetch(code);
prevBtn.classList.remove('is-loading');
if (res && res.status === 'success' && res.img) {
prevBtn.classList.add('has-preview');
ModalView.open(code, res.img, res.gallery, res.siteId);
} else {
if (typeof showToast === 'function') showToast('未能嗅探到 ' + code + ' 的预览图');
else alert('未能嗅探到 ' + code + ' 的预览图');
}
} catch (err) {
prevBtn.classList.remove('is-loading');
log('预览图获取失败: ' + err.message);
}
});
}
}
function syncQuickStatusPreviewUI() {
const isEn = isPluginEnabled('quickStatusPreview') && document.documentElement.classList.contains('emby-skin');
if (!isEn) {
document.querySelectorAll('.cover-status-buttons, .cover-modal-base, .jdbe-modal-v2, #jdbe-preview-modal').forEach(function (el) { el.remove(); });
return;
}
document.querySelectorAll('.movie-list .item, #emby-favorites-panel .item, #emby-top250-panel .item').forEach(function (item) {
attachCoverStatusAndPreview(item);
});
}
/* =======================================================================
* 首页视图标签栏(首页 / 收藏夹)
* pill 样式切换按钮,置于 .movie-list 上方。
* 「首页」显示正常影片列表;「收藏夹」显示空白占位面板(后续增强)。
* 活跃标签持久化到 localStorage,刷新后保持选择。
* ===================================================================== */
let homeTabsBuilt = false; // 模块级防重:即使 SPA 重渲染替换了 .movie-list 节点也不重建第二条标签栏
let homeTabsBar = null;
// 画廊面板内部滚动:① 驱动回顶按钮;② 下滑隐藏「首页/收藏夹/预览画廊」切换栏,上滑复现(与收藏夹一致)。
// buildHomeTabs 与 ensureFavSurface 两处建面板入口共用,dataset 防重复绑定。
function bindGalleryPanelScroll(gp) {
if (!gp || gp.dataset.gScrollBound) return;
gp.dataset.gScrollBound = '1';
let lastGScrollTop = 0;
gp.addEventListener('scroll', function () {
const st = gp.scrollTop || 0;
if (st > lastGScrollTop && st > 60) document.documentElement.classList.add('efav-tabs-hide');
else if (st < lastGScrollTop) document.documentElement.classList.remove('efav-tabs-hide');
lastGScrollTop = st;
if (window.__embyUpdateBackTop) window.__embyUpdateBackTop(st);
}, { passive: true });
}
function bindTop250PanelScroll(tp) {
if (!tp || tp.dataset.topScrollBound) return;
tp.dataset.topScrollBound = '1';
let lastTopScrollTop = 0;
tp.addEventListener('scroll', function () {
const st = tp.scrollTop || 0;
if (st > lastTopScrollTop && st > 60) document.documentElement.classList.add('efav-tabs-hide');
else if (st < lastTopScrollTop) document.documentElement.classList.remove('efav-tabs-hide');
lastTopScrollTop = st;
if (window.__embyUpdateBackTop) window.__embyUpdateBackTop(st);
}, { passive: true });
}
function buildHomeTabs(movieListEl) {
if (!movieListEl) return;
const parent = movieListEl.parentNode;
// 找到容器最顶部锚点(原生分类栏/工具栏或容器首个元素),导航按钮必须置于其上方
const topAnchor = parent ? (parent.querySelector('.tabs, .main-tabs, .toolbar') || parent.firstElementChild || movieListEl) : movieListEl;
if (homeTabsBuilt && homeTabsBar && homeTabsBar.isConnected) {
// 已建过且在文档中:确保标签栏移到当前主页最顶部(置于原生分类条之上)
if (parent && homeTabsBar !== topAnchor && homeTabsBar.nextElementSibling !== topAnchor) {
parent.insertBefore(homeTabsBar, topAnchor);
}
return;
}
homeTabsBuilt = true;
// 标签栏容器
const tabsBar = document.createElement('div');
tabsBar.className = 'emby-home-tabs';
// 视图 Tab 动态生成(与 LOGO 悬停导航共用 HOME_TAB_DEFS,新增 tab 两处自动同步)
HOME_TAB_DEFS.forEach(function (def) {
const t = document.createElement('a');
t.className = 'emby-home-tab';
t.href = '#' + def.key;
t.dataset.tab = def.key;
t.innerHTML = '<span class="material-symbols-outlined" style="font-size:1em;margin-right:.3em;vertical-align:-2px;">' + def.icon + '</span>' + def.label;
t.addEventListener('click', function (e) { e.preventDefault(); switchTab(def.key); });
tabsBar.appendChild(t);
if (def.key === 'home') tabHome = t;
else if (def.key === 'favorites') tabFav = t;
else if (def.key === 'gallery') tabGallery = t;
else if (def.key === 'top250') tabTop250 = t;
});
homeTabsBar = tabsBar;
// 收藏夹面板(v5.00:由 FAVUI 挂载真实功能界面)
favPanel = document.createElement('div');
favPanel.id = 'emby-favorites-panel';
// 预览画廊面板
galleryPanel = document.createElement('div');
galleryPanel.id = 'emby-gallery-panel';
// 画廊面板内部滚动也驱动回顶按钮(画廊是面板内滚动,不触发 window scroll);监听绑定见 bindGalleryPanelScroll
bindGalleryPanelScroll(galleryPanel);
// TOP250 面板
top250Panel = document.createElement('div');
top250Panel.id = 'emby-top250-panel';
bindTop250PanelScroll(top250Panel);
// 四个导航按钮插入到页面最顶部(置于原生分类栏与工具条之上),子面板挂载在父容器中
if (parent) {
if (topAnchor && topAnchor !== tabsBar) {
parent.insertBefore(tabsBar, topAnchor);
} else {
parent.insertBefore(tabsBar, movieListEl);
}
parent.insertBefore(favPanel, movieListEl);
parent.insertBefore(galleryPanel, movieListEl);
parent.insertBefore(top250Panel, movieListEl);
}
// 仅当 URL hash 显式指定对应视图时才在根主页激活子面板,常规访问主页始终激活 home
const hash = (location.hash || '').replace(/^#/, '');
if (hash === 'top250') {
switchTab('top250');
} else if (hash === 'favorites') {
switchTab('favorites');
} else if (hash === 'gallery') {
switchTab('gallery');
} else {
switchTab('home');
}
}
/* =======================================================================
* 网格重构 —— 基于 .movie-list > .item > .box 真实结构
* 为每张卡片注入 STEAM 风格卡牌高光层 (.emby-card-shine) 并绑定 3D 倾斜,
* 不再注入 Emby 文字/播放浮层。
* ===================================================================== */
function restructureGrid() {
if (!enabled) return;
// 布局类自愈 + 引擎残留清理(必须先于 movieLists 过滤执行——
// 收藏夹面板内的懒加载 mutation 也会走到这里,若面板内是唯一 .movie-list,
// 过滤后 movieLists 为空会提前 return,残留就清不到了)
ensureLayoutClasses();
// 排除收藏夹与TOP250面板内部生成的 .movie-list(面板自管卡片)
const movieLists = Array.prototype.filter.call(
document.querySelectorAll('.movie-list'),
function (l) { return !l.closest('#emby-favorites-panel') && !l.closest('#emby-top250-panel'); }
);
if (!movieLists.length) return;
// 主页:隐藏「最新網址」公告栏(裁切仅发生在瀑布流大视图,见 emby-masonry-large)
hideJavdbAnnouncement();
try { hideJavdbAds(); } catch (e) {}
// ====== 首页视图标签栏(首页 / 收藏夹)—— 仅真正主页、仅首次 ======
// 演员页/系列页/搜索页等也有 .movie-list,绝不能在这些页面构建标签栏,
// 否则 localStorage 记住的「收藏夹」态会全屏覆盖这些页面。
if (location.pathname === '/' || /^\/(\?|$)/.test(location.pathname + location.search)) {
buildHomeTabs(movieLists[0]);
}
let count = 0;
// 只处理未标记过的卡片(:not([data-grid-done])):懒加载/增量渲染时跳过已加互动层的老卡,
// 避免每次 mutation 都对全量卡片重扫(SPA 懒加载下 restructureGrid 可被 500ms 防抖反复触发)
const items = movieLists[0].querySelectorAll('.item:not([data-grid-done])');
items.forEach(function (item) {
item.dataset.gridDone = '1';
const box = item.querySelector('.box');
if (!box) return;
// 注入 Steam 高光层
if (!box.querySelector('.emby-card-shine')) {
const shine = document.createElement('span');
shine.className = 'emby-card-shine';
box.appendChild(shine);
}
// 瀑布流:为每张卡赋予「错落比例」(--mz-ar),使高度参差(高低错落)。
// 用封面 URL 哈希做确定性取值 → 刷新/重排不抖动;范围约 0.6~1.12(竖图偏多)。
const cvImg = box.querySelector('.cover img');
if (cvImg) {
if (cvImg.getAttribute('referrerpolicy') !== 'no-referrer') {
cvImg.setAttribute('referrerpolicy', 'no-referrer');
cvImg.referrerPolicy = 'no-referrer';
}
}
const srcKey = cvImg ? (cvImg.getAttribute('src') || '') : '';
const ar = masonryAspect(srcKey, count);
const cvEl = box.querySelector('.cover');
if (cvEl) cvEl.style.setProperty('--mz-ar', ar.toFixed(3));
attachCard3D(box); // STEAM 风格卡牌互动:三布局统一
if (document.documentElement.classList.contains('emby-dynamic-focus')) attachDynamicFocus(box);
count++;
});
// 日志收敛:懒加载批量追加可能高频触发重构,只在新卡出现且 10s 内未打印过才记录
const nowTs = Date.now();
if (count > 0 && (!restructureGrid._lastLogTs || nowTs - restructureGrid._lastLogTs > 10000)) {
restructureGrid._lastLogTs = nowTs;
log('网格重构完成:' + count + ' 张卡片已加 STEAM 风格卡牌悬停');
}
// 瀑布流:--mz-ar 全部写好后重排(JS 引擎按最短列横向布局;懒加载新卡由引擎 MutationObserver 自动跟排)
if (document.documentElement.classList.contains('emby-masonry-large')) {
try { WaterfallEngine.refresh(); } catch (e) {}
}
}
/* =======================================================================
* 详情页重构 —— 基于 .video-detail 真实结构
*
* 真实 DOM 层级:
* div.video-detail[data-controller="movie-detail"]
* h2.title.is-4 > strong.current-title ← 标题
* div.video-meta-panel > div.columns
* div.column-video-cover ← 封面列
* a > img.video-cover ← 封面图 src
* div.column
* nav.panel.movie-panel-info ← 信息面板
* div.panel-block.first-block ← 番號行
* span.value ← "MDVR-428"
* div.panel-block ← 其他元数据行
* span.value ← 值
* div.panel-block > ...review-buttons ← 按钮
* a.button.is-info.is-outlined[href="#magnet-links"] ← 下载
* ===================================================================== */
// 原版预览节点:用「移动」方式保留其原生 Fancybox 控件(可看原站预告片/样图)。
// 重建时需在 removeDetailDom 中还原到原位,故记录父节点与参照兄弟。
let previewNode = null, previewParent = null, previewNext = null;
// 还原「原版预览节点」到原位并清空引用。
// 关键:Turbolinks 式换页(点相关影片等局部导航)后旧引用变成游离节点,若不清理,
// buildDetailRoot 的 !previewNode 判断会失败 → 新页面的预览图永远不会被搬进 Emby 布局(预览区丢失)。
function restorePreviewNode() {
if (previewNode && previewParent && previewParent.isConnected) {
try { previewParent.insertBefore(previewNode, previewNext); }
catch (e) { try { previewParent.appendChild(previewNode); } catch (e2) {} }
}
previewNode = null; previewParent = null; previewNext = null;
}
/* =======================================================================
* 详情页数据提取与构建(v4.30)
* 保留 v4.28「海报 + 信息」两栏 Hero 布局;按原版 JAVDB 结构重排字段:
* 番號(可复制) / 日期 / 時長 / 片商 / 系列 / 評分(IMDB风格) / 類別
* 评分与「想看 / 看過 / 存入清單」按钮统一放一行;演员列表保持现状。
* ===================================================================== */
function detailBlockVal(root, labelRe) {
const blocks = root.querySelectorAll('.panel-block');
for (let i = 0; i < blocks.length; i++) {
const s = ((blocks[i].querySelector('strong') || {}).textContent || '').replace(/[::\s]/g, '');
if (labelRe.test(s)) return blocks[i];
}
return null;
}
function linkOf(block) {
if (!block) return null;
const a = block.querySelector('.value a[href]');
if (a) return { text: a.textContent.trim(), href: a.getAttribute('href') || '#' };
const v = ((block.querySelector('.value') || {}).textContent || '').trim();
return v ? { text: v, href: '#' } : null;
}
// 严格只提取「想看 / 看過 / 存入清單」三个按钮(过滤掉下載/訂正/短評規則約定/查看賬戶狀態等)。
// 关键修复 v4.35:兼容「已标记 / 取消」态的变体文本(如 想看→取消想看、看過→已看過),
// 并增加 data-action/class 兜底——javdb 点击后常重渲染按钮容器,纯文本锚定会抽不到,
// 导致圆按钮消失、代理点击打到游离节点。
function extractReviewButtons(root) {
var found = { wishlist: null, watched: null, list: null };
// 清理按钮文本(去除前缀图标/emoji/多余空格)
function cleanText(s) { return s.replace(/^[^\u4e00-\u9fff\w]+/, '').replace(/[\s\n]+/g, ' ').trim(); }
// —— 已标记态:javdb 会把操作行换成 .review-title 标签 + 「修改/刪除」——
// 「我想看這部影片 / 我看過這部影片」不是按钮,纯按钮扫描抽不到,
// 若不识别会导致标记后圆按钮消失、无法再取消。
var titleTag = root.querySelector('.review-title a, .review-title span');
if (titleTag) {
var tt = (titleTag.textContent || '').replace(/\s+/g, '');
var activeType = null;
if (/我想看這部影片/.test(tt)) activeType = 'wishlist';
else if (/我看過這部影片/.test(tt)) activeType = 'watched';
if (activeType) {
found[activeType] = {
text: (titleTag.textContent || '').trim().replace(/\s+/g, ' '),
href: titleTag.getAttribute('href') || (titleTag.tagName === 'A' ? '#' : ''),
className: titleTag.className || '',
isActive: true, // 已标记:圆按钮显示实心图标
active: true,
type: activeType,
nativeEl: titleTag
};
}
}
// 遍历原版所有可点击元素(覆盖常见容器,必要时回退到全量 a.button/button)
var allClickables = root.querySelectorAll(
'.panel-block .buttons a.button, .review-buttons a.button, .panel-block .buttons button,' +
' a[data-action], button[data-action],' +
' a[class*="wishlist"], a[class*="watch"], a[class*="list"]'
);
if (!allClickables.length) allClickables = root.querySelectorAll('a.button, button');
allClickables.forEach(function (el) {
var text = cleanText(el.textContent || '');
if (!text) return;
var type = null;
// 优先按文本匹配(含已标记 / 取消态变体,避免标记后图标消失)
if (/^想\s*看$|^願望$|取消\s*想\s*看|已\s*想\s*看/.test(text)) type = 'wishlist';
else if (/^看\s*過$|^已\s*看\s*過$|取消\s*看\s*過/.test(text)) type = 'watched';
else if (/存入清單|加入清單|收藏|清單/.test(text)) type = 'list';
// 其次按 data-action / class 推断(javdb 重渲染后文本可能变化,属性更稳定)
if (!type) {
var da = (el.getAttribute('data-action') || '').toLowerCase();
var cls = (el.className || '').toLowerCase();
if (/wish|願望|想\s*看/.test(da + cls)) type = 'wishlist';
else if (/watch|看\s*過/.test(da + cls)) type = 'watched';
else if (/list|清單|收藏/.test(da + cls)) type = 'list';
}
if (!type) return; // 不是目标按钮,跳过
if (!found[type]) {
found[type] = {
text: text,
href: el.getAttribute('href') || (el.tagName === 'A' ? '#' : ''),
className: el.className || '',
// 注意:必须用词边界,否则 "button" 本身含 "on" 会被误判为已激活
isActive: /(^|\s)(is-active|active|on)(\s|$)/.test((el.className || '') + ' ' + (el.getAttribute('data-active') || '')),
type: type,
nativeEl: el
};
}
});
// 已标记类型补上原生「刪除」链接(取消标记的真实入口,带 data-confirm)。
// 仅供 handleCircleClick 在“已标记”时触发取消;未标记类型不需要。
var deleteLink = root.querySelector('.review-buttons a[data-method="delete"], a[data-confirm][data-method="delete"], .review-buttons a.is-danger');
if (deleteLink) {
if (found.wishlist && found.wishlist.active) found.wishlist.removeEl = deleteLink;
if (found.watched && found.watched.active) found.watched.removeEl = deleteLink;
}
// 按固定顺序返回(去重,每种最多一个)
var result = [];
if (found.wishlist) result.push(found.wishlist);
if (found.watched) result.push(found.watched);
if (found.list) result.push(found.list);
return result;
}
function buildStarHtml(score) {
const full = Math.floor(score);
const hasHalf = (score - full) >= 0.25 && (score - full) < 0.75;
let empty = 5 - full - (hasHalf ? 1 : 0);
let html = '';
for (let i = 0; i < full; i++) html += '<span class="star full">\u2605</span>';
if (hasHalf) html += '<span class="star half">\u2605</span>';
for (let i = 0; i < empty; i++) html += '<span class="star empty">\u2606</span>';
return html;
}
function extractDetailData(root) {
const fb = root.querySelector('.panel-block.first-block');
const code = fb ? ((fb.querySelector('.value') || {}).textContent || '').trim().replace(/\s+/g, '') : '';
const dateB = detailBlockVal(root, /^日期$/);
const date = dateB ? ((dateB.querySelector('.value') || {}).textContent || '').trim() : '';
const durB = detailBlockVal(root, /^時長$/);
const duration = durB ? ((durB.querySelector('.value') || {}).textContent || '').trim() : '';
const director = linkOf(detailBlockVal(root, /^導演$/));
const maker = linkOf(detailBlockVal(root, /^片商$/));
const series = linkOf(detailBlockVal(root, /^系列$/));
const ratingB = detailBlockVal(root, /^評分$/);
let rating = null;
if (ratingB) {
const v = ((ratingB.querySelector('.value') || {}).textContent || '').trim();
const sm = v.match(/([\d.]+)\s*分/);
const cm = v.match(/由(\d+)人/);
rating = { score: sm ? parseFloat(sm[1]) : 0, count: cm ? parseInt(cm[1], 10) : 0, text: v };
}
const catB = detailBlockVal(root, /類別|標籤/);
const categories = catB ? Array.from((catB.querySelector('.value') || catB).querySelectorAll('a[href]'))
.map(function (a) { return { text: a.textContent.trim(), href: a.getAttribute('href') || '#' }; }) : [];
const reviewBtns = extractReviewButtons(root);
const actorEls = Array.from(root.querySelectorAll('.panel-block'))
.filter(function (b) { const s = (b.querySelector('strong') || {}).textContent || ''; return s.indexOf('演員') !== -1; })
.reduce(function (acc, b) { return acc.concat(Array.from(b.querySelectorAll('a[href^="/actors/"]'))); }, []);
const actors = actorEls.map(function (a) {
return { name: a.textContent.trim(), href: a.getAttribute('href') || '#', avatar: actorAvatarUrl(a.getAttribute('href') || '') };
});
// 解析金标荣誉榜单数据(TOP250、年度榜等)
const awardNodes = Array.from((root || document).querySelectorAll(
'.ranking-tags a, a.tags.has-addons, .panel-block a[href*="/rankings/"]'
));
const awards = [];
const seenAwardKeys = new Set();
awardNodes.forEach(function (a) {
const href = a.getAttribute('href') || '';
const darkEl = a.querySelector('.tag.is-dark');
const warnEl = a.querySelector('.tag.is-warning');
let rankTxt = (darkEl ? darkEl.textContent : '').trim();
let name = (warnEl ? warnEl.textContent : a.textContent).trim();
if (!rankTxt) {
const m = name.match(/No\.?\s*(\d+)/i) || (a.textContent || '').match(/No\.?\s*(\d+)/i);
if (m) rankTxt = 'No.' + m[1];
}
if (rankTxt) {
name = name.replace(rankTxt, '').trim();
}
const rankNumMatch = rankTxt.match(/\d+/);
const rankNum = rankNumMatch ? parseInt(rankNumMatch[0], 10) : null;
const key = (href || name) + rankTxt;
if (key && !seenAwardKeys.has(key) && (name || rankTxt)) {
seenAwardKeys.add(key);
awards.push({
name: name || 'JAVDB 荣誉榜',
rank: rankNum,
rankTxt: rankTxt || (rankNum ? ('No.' + rankNum) : ''),
href: href
});
}
});
// 按综合权重排序(TOP250总榜优先)
if (typeof getAwardWeight === 'function') {
awards.sort(function (a, b) { return getAwardWeight(a) - getAwardWeight(b); });
}
return { code: code, date: date, duration: duration, director: director, maker: maker, series: series,
rating: rating, categories: categories, reviewBtns: reviewBtns, actors: actors, awards: awards };
}
// 圆形操作按钮的图标:off=未标记(描边),on=已标记(实心/不同样式)
// 看過的 off/on 必须是两种不同字形,否则点击后看不出变化
// 想看/看過 图标互换(想看=勾,看過=心),激活色同步互换(绿=想看,粉=看過)
// 图标映射(用户最终方案):
// 想看 = 书签(描边 → 实心,蓝色)
// 看過 = 打勾(描边勾 → 实心勾,绿色)
var CIRCLE_ICONS = {
// v7.311 Material Symbols 图标统一映射:
// 想看: play_lesson | 看过: order_approve | 存入清单: 未收藏 format_list_bulleted_add → 已收藏 红心 favorite
wishlist: { off: 'play_lesson', on: 'play_lesson' }, // 想看
watched: { off: 'order_approve', on: 'order_approve' }, // 看過
list: { off: 'format_list_bulleted_add', on: 'favorite' } // 存入清單/收藏:未收藏清单 → 已收藏红心
};
// list 圆按钮的本地收藏状态(由 refreshFavStatus 维护,供 syncCircleActiveByType 使用)
var favInDb = false;
// 圆形操作按钮(参考 Emby 截图:播放按钮右侧的圆形图标按钮)
function buildCircleBtnHtml(b) {
var ic = CIRCLE_ICONS[b.type] || { off: 'star', on: 'star' };
var icon = b.isActive ? ic.on : ic.off;
var activeCls = b.isActive ? ' active' : '';
return '<button class="emby-circle-btn' + activeCls + '" data-type="' + b.type + '"' +
' data-review-idx="' + b._idx + '"' +
' title="' + escapeAttr(b.text.replace(/\s+/g, '')) + '">' +
'<span class="material-symbols-outlined">' + icon + '</span>' +
'</button>';
}
// ====== 详情页交互层(v4.35 重写:健壮化)======
// 设计要点(修复「标记后图标消失 / 代理点击失效 / 开关被卡死」三类 BUG):
// 1) 点击圆按钮时【实时】重新查找当前原生按钮(按类型),不依赖构建时捕获的 nativeEl 引用
// —— javdb 点击后常重渲染按钮容器,旧引用会变游离节点,代理点击打空。
// 2) 标记 / 取消标记后,通过「轮询 + 对原生 .video-detail 的轻量观察器」自愈圆按钮图标。
// 3) 事件用 document 委托(一次绑定),圆按钮被重建也不丢失监听。
var detailDelegateBound = false;
var currentReviewBtns = [];
var reviewRefreshObserver = null;
// 按类型实时查找当前页面上的原生按钮(每次都重新抽取,抗重渲染)
function findLiveNativeReviewButton(type) {
var root = document.querySelector('.video-detail');
if (!root) return null;
var live = extractReviewButtons(root);
for (var i = 0; i < live.length; i++) if (live[i].type === type) return live[i];
return null;
}
// 依当前原生按钮的 is-active 同步单个圆按钮的图标字形 + active 类
function syncCircleActiveByType(type, btn) {
if (!btn) return;
var nowActive;
if (type === 'list') {
// list 圆按钮的激活态 = 本地收藏夹状态(favInDb 由 refreshFavStatus 维护)。
// 原生「存入清單」按钮永远没有 is-active,若按原生同步会把图标重置回描边。
nowActive = favInDb;
} else {
var info = findLiveNativeReviewButton(type);
nowActive = info ? (info.isActive === true || info.active === true ||
/(^|\s)(is-active|active|on)(\s|$)/.test((info.nativeEl.className || '') + ' ' + (info.nativeEl.getAttribute('data-active') || ''))) : false;
}
var ic = CIRCLE_ICONS[type];
if (ic) {
var span = btn.querySelector('.material-icons, .material-symbols-outlined');
var nextIcon = nowActive ? ic.on : ic.off;
if (span && span.textContent !== nextIcon) span.textContent = nextIcon;
}
btn.classList.toggle('active', nowActive);
}
// 同步全部圆按钮(原生重渲染后统一刷新)
function syncAllCircleActive() {
document.querySelectorAll('.emby-circle-btn').forEach(function (btn) {
syncCircleActiveByType(btn.getAttribute('data-type') || '', btn);
});
}
function handleCopyClick(btn) {
var text = btn.getAttribute('data-clipboard-text') || '';
if (!text) return;
var done = function () { btn.setAttribute('title', '已複製 ✓'); btn.classList.add('copied'); setTimeout(function () { btn.setAttribute('title', '複製番號'); btn.classList.remove('copied'); }, 2000); };
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(done, function () {});
else { var ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); try { document.execCommand('copy'); done(); } catch (e2) {} document.body.removeChild(ta); }
}
// 打开原生「存入清單」弹窗(模态由 javdb 的 list controller 管理,必须走按钮点击)
function openNativeSaveList() {
if (!isJavdbUserLoggedIn()) {
showToast('⚠️ 未登录 JAVDB 账号,无法存入清单,请先登录!');
return;
}
var btn = document.getElementById('save-list-button');
if (btn) { try { btn.click(); } catch (e) { log('打开存入清單失败: ' + e); } }
else {
var modal = document.getElementById('modal-save-list');
if (modal) { modal.classList.add('is-active'); try { syncNativeOverlayGuard(); } catch (e) {} }
}
// 弹窗打开后即时执行清单真实状态自愈与删除对齐
try {
if (typeof FAV !== 'undefined' && typeof FAV.syncWithSaveListModal === 'function') {
setTimeout(function () { FAV.syncWithSaveListModal(); }, 150);
setTimeout(function () { FAV.syncWithSaveListModal(); }, 500);
}
} catch (e) {}
}
// 详情页当前番号(真实番号,来自面板,非 URL 编码 ID)
function currentPageCode() {
try {
const fb = document.querySelector('.video-detail .panel-block.first-block .value');
if (fb) { const c = (fb.textContent || '').trim().replace(/\s+/g, ''); if (c) return c; }
} catch (e) {}
return null;
}
// 刷新 list 圆按钮的收藏状态(存入清單/已收藏 两态合一的按钮):
// 未收藏 → playlist_add;已收藏 → 红心 favorite + active(红)
function refreshFavStatus() {
var code = currentPageCode();
if (!code) return;
FAV.getFavStatus(code).then(function (st) {
if (currentPageCode() !== code) return; // 页面已切换,丢弃过期结果
favInDb = !!(st && st.inFav);
var listBtn = document.querySelector('.emby-circle-btn[data-type="list"]');
if (!listBtn) return;
var span = listBtn.querySelector('.material-symbols-outlined, .material-icons');
var nextIcon = favInDb ? (CIRCLE_ICONS.list.on || 'favorite') : (CIRCLE_ICONS.list.off || 'format_list_bulleted_add');
if (span && span.textContent !== nextIcon) span.textContent = nextIcon;
listBtn.classList.toggle('active', favInDb);
listBtn.title = favInDb
? ((st && st.lists && st.lists.length) ? ('已收藏:' + st.lists.map(function (l) { return l.name; }).join('、')) : '已收藏')
: '存入清單';
}).catch(function (e) { log('收藏状态读取失败: ' + e.message); });
}
function handleCircleClick(btn, e) {
e.preventDefault();
var type = btn.getAttribute('data-type') || '';
var labels = { wishlist: '想看', watched: '看過', list: '存入清單' };
var label = labels[type] || type;
// 涟漪动画
btn.classList.remove('rippling'); void btn.offsetWidth; btn.classList.add('rippling');
setTimeout(function () { btn.classList.remove('rippling'); }, 420);
// 实时查找原生按钮并触发其 click(原版 handler 在隐藏容器中仍可触发)
var info = findLiveNativeReviewButton(type);
var wasActive = !!(info && (info.isActive || info.active));
// 未登录账号拦截保护:严禁未登录将想看/看过/清单写入本地库或打开未登录原生弹窗
if (!isJavdbUserLoggedIn()) {
if (type === 'list') {
showToast('⚠️ 未登录 JAVDB 账号,无法存入清单,请先登录!');
return;
}
if (type === 'watched') {
showToast('⚠️ 未登录 JAVDB 账号,无法标记看過,请先登录!');
return;
}
if (type === 'wishlist') {
showToast('⚠️ 未登录 JAVDB 账号,正在前往登录页面…');
setTimeout(function () {
if (info && info.href && info.href !== '#') location.href = info.href;
else location.href = '/login';
}, 400);
return;
}
}
if (type === 'list') {
// 存入清單:打开原生弹窗;勾选/取消由网络拦截器实时入库并刷新收藏状态
openNativeSaveList();
showToast('已打开「存入清單」,勾选清单即自动同步收藏夹');
return;
}
if (type === 'watched' && !wasActive) {
// 看過(未标记):打开原生「標記我看過」弹窗;首次会先弹「短評規則約定」20秒倒计时
if (info && info.nativeEl) {
try { info.nativeEl.click(); log('看過弹窗代理触发'); }
catch (err) { log('看過弹窗触发失败: ' + err); }
setTimeout(function () {
var warn = document.getElementById('modal-comment-warning');
if (warn && warn.classList.contains('is-active')) {
showToast('首次使用看過:请先阅读并同意短評規則(约20秒)');
} else {
showToast('已打开「標記看過」弹窗,提交后自动同步收藏夹');
}
}, 350);
} else {
showToast('未找到「看過」按钮,请刷新重试');
}
return;
}
if (info && info.nativeEl) {
if (wasActive && info.removeEl) {
// 已标记:点击原生「刪除」取消标记(自动确认 data-confirm,重入安全防永久玷污)
if (!window.__origConfirm) window.__origConfirm = window.confirm;
try {
window.confirm = function () { return true; };
info.removeEl.click();
log('取消标记(删除)代理触发: ' + info.text);
} catch (err) {
log('取消标记触发失败: ' + err);
} finally {
window.confirm = window.__origConfirm;
}
showToast('已取消' + label);
} else {
try { info.nativeEl.click(); log('操作按钮代理触发: ' + info.text); }
catch (err) { log('操作按钮触发失败: ' + err); }
showToast('已加入' + label);
}
// 同步激活态:轮询多次,覆盖原生下拉 / 模态异步回写 is-active 的时机
syncCircleActiveByType(type, btn);
setTimeout(function () { syncCircleActiveByType(type, btn); }, 500);
setTimeout(function () { syncCircleActiveByType(type, btn); }, 1500);
}
}
// document 委托(一次绑定):圆按钮 / 复制按钮无论是否被重建都有效
function bindDetailInteractions() {
if (detailDelegateBound) return;
detailDelegateBound = true;
document.addEventListener('click', function (e) {
var t = e.target;
var cb = t && t.closest ? t.closest('.emby-circle-btn') : null;
if (cb) { handleCircleClick(cb, e); return; }
// 作品级备注按钮(详情页番号行):打开备注编辑小窗
var nb = t && t.closest ? t.closest('.em-note-btn') : null;
if (nb) {
e.preventDefault();
openNoteModal(nb.getAttribute('data-note-key') || '', nb.getAttribute('data-note-name') || '');
return;
}
var cp = t && t.closest ? t.closest('.em-copy') : null;
if (cp) { handleCopyClick(cp); }
// 磁链/短评/相关清单 Tab 切换时即时触发去广告
var tabA = t && t.closest ? t.closest('.columns[data-controller="movie-tab"] .tabs a') : null;
if (tabA) {
setTimeout(function () { try { hideJavdbAds(); } catch (e) {} }, 60);
setTimeout(function () { try { hideJavdbAds(); } catch (e) {} }, 300);
}
});
// 本地收藏夹数据变化(存入/移出清单、想看/看過标记)→ 实时刷新详情页收藏状态图标
document.addEventListener('emby-fav-changed', function () { refreshFavStatus(); });
}
// 监听原生详情区的变化:javdb 标记后重渲染按钮容器时,自愈圆按钮集合与状态
function setupReviewRefresh() {
if (reviewRefreshObserver) return;
var timer = null;
reviewRefreshObserver = new MutationObserver(function (mutations) {
// 守卫:忽略来自脚本自身 Emby 交互层或操作栏的 DOM 突变,杜绝自身引发自激循环
var hasExternalChange = false;
for (var mi = 0; mi < mutations.length; mi++) {
var mt = mutations[mi].target;
if (mt && mt.nodeType === 1 && !mt.closest('#emby-detail-root, .emby-action-row, .emby-circle-btn')) {
hasExternalChange = true;
break;
}
}
if (!hasExternalChange) return;
if (timer) clearTimeout(timer);
timer = setTimeout(function () {
if (document.getElementById('emby-detail-root') && isDetailPage()) {
refreshReviewButtons();
try { hideJavdbAds(); } catch (e) {}
}
}, 350);
});
var obsTarget = document.querySelector('.video-detail') || document.body;
reviewRefreshObserver.observe(obsTarget, { childList: true, subtree: true, attributes: true, attributeFilter: ['class'] });
}
function refreshReviewButtons() {
var root = document.querySelector('.video-detail');
if (!root) return;
var live = extractReviewButtons(root);
live.forEach(function (b, i) { b._idx = i; });
currentReviewBtns = live;
var row = document.querySelector('.emby-action-row');
if (!row) return;
var domTypes = Array.prototype.map.call(row.querySelectorAll('.emby-circle-btn'), function (b) { return b.getAttribute('data-type'); });
var liveTypes = live.map(function (b) { return b.type; });
// 按钮集合变化(出现 / 消失)才重建行;否则仅同步激活态,避免无谓重绘
if (JSON.stringify(domTypes) !== JSON.stringify(liveTypes)) {
row.innerHTML = live.map(buildCircleBtnHtml).join('');
refreshFavStatus(); // 重建后恢复收藏状态图标 / list 圆按钮态
} else {
syncAllCircleActive();
}
}
// 简易 toast 提示
function showToast(msg) {
var existing = document.getElementById('emby-toast');
if (existing) existing.remove();
var t = document.createElement('div');
t.id = 'emby-toast';
t.className = 'emby-toast';
t.textContent = msg;
document.body.appendChild(t);
// 触发淡入
requestAnimationFrame(function () { t.classList.add('show'); });
setTimeout(function () { t.classList.remove('show'); setTimeout(function () { t.remove(); }, 350); }, 1800);
}
/* 详情页原生官方播放信息提取
* JAVDB 官方播放机制解析:
* 1. 结构为 /v/{vid}/play?from_ranking=0&pst={pst_token}
* - {vid} 为 JAVDB 内部十六进制/Base62 视频 ID,而非车牌号;
* - {pst} 为服务端实时签发的防盗链与鉴权 Token(带时间戳和加密校验),无法前端静态生成;
* 2. JAVDB 原版页面在封面 DOM 中已包含带有效 pst 的完整播放节点:
* .column-video-cover a.cover-container[href*="/play"]
* 3. 会员用户访问直接加载播放器正片;非会员重定向至套餐购买页;
* 4. 无在线片源番号原页面无播放链接,此函数返回 null。
*/
function extractNativePlayInfo(root) {
try {
const doc = root || document.querySelector('.video-detail') || document;
const coverA = doc.querySelector(
'.column-video-cover a.cover-container[href*="/play"], ' +
'.video-detail a.cover-container[href*="/play"], ' +
'a.cover-container[href*="/play"]'
);
if (!coverA) return null;
const playBtn = coverA.querySelector('.play-button');
const textEl = playBtn ? (playBtn.querySelector('.text') || playBtn) : null;
const btnText = textEl ? textEl.textContent.trim() : '';
const href = coverA.getAttribute('href') || coverA.href || '';
if (!href) return null;
return {
href: href,
text: btnText || '播放正片',
label: btnText ? ('JAVDB ' + btnText) : 'JAVDB 官方播放'
};
} catch (e) {
return null;
}
}
/* =======================================================================
* 播放站点 LOGO 色彩实时提取与自适应渲染引擎 (动态 Canvas 采样 + GM 跨域穿透 + 本地持久化缓存)
* 逻辑:
* 1. 彻底移除静态颜色写死字典,由脚本全自动实时解析 LOGO 的视觉品牌核心色;
* 2. 利用 GM_xmlhttpRequest 获取二进制 Blob 对象,彻底绕过浏览器跨域 Canvas 污染 (Tainted Canvas);
* 3. 核心采样算法:过滤透明背景、近黑、近白底色与极低饱和度杂色,智能评分提取高饱和度代表色;
* 4. 提取后自动写入 localStorage (javdb_site_rgb_cache_v1),下次加载实现 0ms 闪电瞬发渲染,免去重复计算。
* ===================================================================== */
const SITE_RGB_STORAGE_KEY = 'javdb_site_rgb_cache_v1';
let siteRgbCache = {};
try {
const raw = localStorage.getItem(SITE_RGB_STORAGE_KEY);
if (raw) siteRgbCache = JSON.parse(raw);
} catch (e) {
siteRgbCache = {};
}
function saveSiteRgbCache() {
try {
localStorage.setItem(SITE_RGB_STORAGE_KEY, JSON.stringify(siteRgbCache));
} catch (e) {}
}
function sampleDominantRgbFromCanvas(img) {
try {
const canvas = document.createElement('canvas');
const size = 24;
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, size, size);
const imgData = ctx.getImageData(0, 0, size, size).data;
let bestRgb = null;
let bestScore = -1;
for (let i = 0; i < imgData.length; i += 4) {
const a = imgData[i + 3];
if (a < 70) continue; // 过滤透明或半透明像素
const r = imgData[i], g = imgData[i + 1], b = imgData[i + 2];
const max = Math.max(r, g, b), min = Math.min(r, g, b);
const delta = max - min;
const sat = max === 0 ? 0 : delta / max;
const lum = 0.299 * r + 0.587 * g + 0.114 * b;
// 过滤近黑、近白底色与极低饱和度杂色(如外围白底、灰边、黑边)
if (lum < 24 || lum > 238 || sat < 0.12) continue;
// 优先选取高饱和度且明度适中的核心品牌色
const lumFactor = 1 - Math.abs(lum - 128) / 128;
const score = sat * 2.8 + lumFactor;
if (score > bestScore) {
bestScore = score;
bestRgb = r + ', ' + g + ', ' + b;
}
}
return bestRgb;
} catch (e) {
return null;
}
}
function getSiteLogoRgb(siteId, iconUrl) {
if (siteId && siteRgbCache[siteId]) return siteRgbCache[siteId];
if (iconUrl && siteRgbCache[iconUrl]) return siteRgbCache[iconUrl];
return '0, 164, 220'; // 初始/未提取时的默认优雅底色
}
function applyPlayButtonLogoRgb(targetEl, siteId, iconUrl) {
if (!targetEl) return;
const img = targetEl.querySelector('.emby-site-logo');
if (!iconUrl && img) {
iconUrl = img.getAttribute('src') || '';
}
const cachedRgb = (siteId && siteRgbCache[siteId]) || (iconUrl && siteRgbCache[iconUrl]);
if (cachedRgb) {
targetEl.style.setProperty('--btn-logo-rgb', cachedRgb);
return;
}
// 设置初始过渡色
targetEl.style.setProperty('--btn-logo-rgb', '0, 164, 220');
if (!iconUrl && !img) return;
const commitRgb = function (rgb) {
if (!rgb) return;
if (siteId) siteRgbCache[siteId] = rgb;
if (iconUrl) siteRgbCache[iconUrl] = rgb;
saveSiteRgbCache();
targetEl.style.setProperty('--btn-logo-rgb', rgb);
};
// 1. 若 DOM 中的 img 已加载完毕,尝试直接绘制采样
if (img && img.complete && img.naturalWidth > 0) {
const sampled = sampleDominantRgbFromCanvas(img);
if (sampled) { commitRgb(sampled); return; }
}
// 2. 借助 GM_xmlhttpRequest 抓取 Blob 本地对象(彻底消除跨域限制),确保 100% 提取成功
if (iconUrl && typeof GM_xmlhttpRequest !== 'undefined') {
try {
GM_xmlhttpRequest({
method: 'GET',
url: iconUrl,
responseType: 'blob',
timeout: 6000,
onload: function (res) {
if (res.response && res.response instanceof Blob) {
const blobUrl = URL.createObjectURL(res.response);
const blobImg = new Image();
blobImg.onload = function () {
const sampled = sampleDominantRgbFromCanvas(blobImg);
URL.revokeObjectURL(blobUrl);
if (sampled) commitRgb(sampled);
};
blobImg.onerror = function () {
URL.revokeObjectURL(blobUrl);
};
blobImg.src = blobUrl;
}
},
onerror: function () {},
ontimeout: function () {}
});
return;
} catch (e) {}
}
// 3. 兜底监听 img onload
if (img && !img.complete) {
img.addEventListener('load', function () {
const sampled = sampleDominantRgbFromCanvas(img);
if (sampled) commitRgb(sampled);
}, { once: true });
}
}
/* 详情页多源播放下拉组件
* 重构逻辑:
* 1. 当【外部播放站点】插件关闭时:
* - 若当前番号有 JAVDB 官方片源,直接调用官方播放(带官方“播放正片/中字正片”文案);
* - 若无官方片源,自动退回外部播放,激活播放渠道选择菜单供用户自选。
* 2. 当【外部播放站点】插件开启时(或无官方播放退回外部播放时):
* - 主播放按钮显示当前选中的播放站点(持久化记忆偏好);
* - 点击下拉箭头朝右侧横向弹出站点选择菜单(若遇屏幕右边缘遮挡则自适应向下展开);
* - 菜单内每个站点展示网站 Logo(带容灾徽标)与选中勾选状态;
* - 点击菜单项切换默认播放源,点击主按钮才执行播放。
*/
function buildDetailPlayDropdownHtml(code, root) {
if (!code) return '';
const isOtherSiteEn = isPluginEnabled('otherSite');
const nativePlay = extractNativePlayInfo(root);
const sites = getPlaySiteConfigs().filter(function (s) { return s.enabled !== false; });
// 模式 A:未启用外部站点插件,且当前番号有 JAVDB 官方播放源时,直接展示官方播放按钮
if (!isOtherSiteEn && nativePlay) {
return (
'<a class="emby-play-btn emby-play-native-btn" id="emby-play-btn" style="--btn-logo-rgb:0, 164, 220;" href="' + escapeAttr(nativePlay.href) + '" target="_blank" rel="noopener" title="JAVDB 官方播放">' +
'<span class="emby-play-btn-logo" id="emby-play-btn-logo">' + renderSiteLogoHtml('https://javdb.com/favicon.ico', '官方播放', 'javdb_native') + '</span>' +
'<span class="emby-play-label">' + escapeHtml(nativePlay.text || '播放正片') + '</span></a>'
);
}
// 模式 B:启用外部站点插件,或者无官方播放源时自动退回外部播放(激活播放渠道选择供用户自选)
const preferredId = getPreferredPlaySiteId();
let activeId = '';
let activeName = '';
let activeIcon = '';
let activeUrl = '';
let activeTitle = '';
// 判断当前应高亮/主选的播放源
if (preferredId === 'javdb_native' && nativePlay) {
activeId = 'javdb_native';
activeName = nativePlay.text || '官方播放';
activeIcon = 'https://javdb.com/favicon.ico';
activeUrl = nativePlay.href;
activeTitle = 'JAVDB 官方播放 (' + activeName + ')';
} else {
const matchSite = sites.find(function (s) { return s.id === preferredId; });
if (matchSite) {
activeId = matchSite.id;
activeName = matchSite.name;
activeIcon = matchSite.icon || '';
activeUrl = buildSitePlayUrl(matchSite, code);
activeTitle = matchSite.name;
} else if (sites[0]) {
activeId = sites[0].id;
activeName = sites[0].name;
activeIcon = sites[0].icon || '';
activeUrl = buildSitePlayUrl(sites[0], code);
activeTitle = sites[0].name;
} else if (nativePlay) {
activeId = 'javdb_native';
activeName = nativePlay.text || '官方播放';
activeIcon = 'https://javdb.com/favicon.ico';
activeUrl = nativePlay.href;
activeTitle = 'JAVDB 官方播放';
} else {
activeId = '';
activeName = '选择播放源';
activeIcon = '';
activeUrl = '#';
activeTitle = '选择播放源';
}
}
// 提取所有不重复的分组标签(方案 A:顶部 Tabs 胶囊切换)
const groupSet = new Set();
if (nativePlay) groupSet.add('官方');
sites.forEach(function (s) {
if (s.group && s.group.trim()) groupSet.add(s.group.trim());
});
const groups = Array.from(groupSet);
let tabsHtml = '<div class="emby-play-group-tabs" id="emby-play-group-tabs">';
tabsHtml += '<button type="button" class="emby-group-tab active" data-group="all">全部</button>';
groups.forEach(function (g) {
tabsHtml += '<button type="button" class="emby-group-tab" data-group="' + escapeAttr(g) + '">' + escapeHtml(g) + '</button>';
});
tabsHtml += '</div>';
const menuHeaderHtml =
'<div class="emby-play-menu-header">' +
'<div class="emby-play-menu-title">选择默认播放源</div>' +
tabsHtml +
'</div>';
// 双列网格呈现所有可选站点
let gridItemsHtml = '<div class="emby-play-menu-grid" id="emby-play-menu-grid">';
// 若原版页面包含 JAVDB 官方播放链接,在下拉菜单首项呈现 JAVDB 官方播放
if (nativePlay) {
const isSel = activeId === 'javdb_native';
const nativeFullName = nativePlay.label || 'JAVDB 官方播放';
const nativeShortName = nativePlay.text || '官方播放';
gridItemsHtml +=
'<div class="emby-play-menu-item' + (isSel ? ' selected' : '') + '" data-site-id="javdb_native" data-site-name="' + escapeAttr(nativeShortName) + '" data-site-icon="https://javdb.com/favicon.ico" data-play-url="' + escapeAttr(nativePlay.href) + '" data-group="官方" title="' + escapeAttr(nativeFullName + ' (官方)') + '">' +
renderSiteLogoHtml('https://javdb.com/favicon.ico', 'JAVDB', 'javdb_native') +
'<span class="emby-site-name" title="' + escapeAttr(nativeFullName) + '">' + escapeHtml(nativeFullName) + '</span>' +
'<span class="emby-site-badge emby-site-badge-official">官方</span>' +
'<span class="emby-site-status status-ok" id="site-status-javdb_native" title="检测到在线片源">✓</span>' +
'<span class="material-symbols-outlined emby-site-check">check</span>' +
'</div>';
}
sites.forEach(function (s) {
const url = buildSitePlayUrl(s, code);
const isSel = activeId === s.id;
const sGroup = s.group || '其他';
const safeId = String(s.id).replace(/[^a-zA-Z0-9_-]/g, '_');
const badgeClass = getSiteBadgeClass(sGroup);
const fullItemTitle = s.name + (sGroup ? ' (' + sGroup + ')' : '');
gridItemsHtml +=
'<div class="emby-play-menu-item' + (isSel ? ' selected' : '') + '" data-site-id="' + escapeAttr(s.id) + '" data-site-name="' + escapeAttr(s.name) + '" data-site-icon="' + escapeAttr(s.icon || '') + '" data-site-template="' + escapeAttr(s.template || '') + '" data-play-url="' + escapeAttr(url) + '" data-group="' + escapeAttr(sGroup) + '" title="' + escapeAttr(fullItemTitle) + '">' +
renderSiteLogoHtml(s.icon, s.name, s.id) +
'<span class="emby-site-name" title="' + escapeAttr(s.name) + '">' + escapeHtml(s.name) + '</span>' +
'<span class="emby-site-badge ' + badgeClass + '">' + escapeHtml(sGroup) + '</span>' +
'<span class="emby-site-status status-loading" id="site-status-' + escapeAttr(safeId) + '" title="片源检测中..."></span>' +
'<span class="material-symbols-outlined emby-site-check">check</span>' +
'</div>';
});
gridItemsHtml += '</div>';
const menuFooterHtml =
'<div class="emby-play-menu-divider"></div>' +
'<div class="emby-play-menu-config" id="emby-play-config-trigger">' +
'<span class="material-symbols-outlined" style="font-size:14px;">settings</span> 外部网站与分组排序配置' +
'</div>';
const menuItemsHtml = menuHeaderHtml + gridItemsHtml + menuFooterHtml;
return (
'<div class="emby-play-group" id="emby-play-group">' +
'<div class="emby-play-split-btn" id="emby-play-split-btn" style="--btn-logo-rgb:' + escapeAttr(getSiteLogoRgb(activeId, activeIcon)) + ';">' +
'<a class="emby-play-main-action" id="emby-play-btn" href="' + escapeAttr(activeUrl) + '" target="_blank" rel="noopener" title="' + escapeAttr(activeTitle) + '">' +
'<span class="emby-play-btn-logo" id="emby-play-btn-logo">' + renderSiteLogoHtml(activeIcon, activeName, activeId) + '</span>' +
'<span class="emby-play-label">' + escapeHtml(activeName) + '</span>' +
'</a>' +
'<div class="emby-play-split-divider"></div>' +
'<button type="button" class="emby-play-arrow-action" id="emby-play-arrow-btn" title="选择播放源">' +
'<span class="material-symbols-outlined emby-play-arrow-icon">arrow_drop_down</span>' +
'</button>' +
'</div>' +
'<div class="emby-play-menu" id="emby-play-menu">' + menuItemsHtml + '</div>' +
'</div>'
);
}
// 详情页标题翻译实时同步(设置变动或构建时调用)
function syncDetailTranslation() {
const trBoxes = document.querySelectorAll('.emby-translated-title');
if (!isPluginEnabled('translate')) {
trBoxes.forEach(function (el) { el.remove(); });
return;
}
if (trBoxes.length > 0) return;
const dr = document.getElementById('emby-detail-root');
if (!dr) return;
const titleTarget = dr.querySelector('.emby-title') || dr.querySelector('.info > h1') || dr.querySelector('h1');
if (!titleTarget) return;
const titleText = (titleTarget.textContent || '').trim();
if (!titleText) return;
const reqCode = typeof currentPageCode === 'function' ? currentPageCode() : '';
(async function () {
try {
const trans = await translateText(titleText, 'ja', 'zh-CN');
if (trans && trans !== titleText && isPluginEnabled('translate')) {
if (typeof currentPageCode === 'function' && currentPageCode() !== reqCode) return;
if (document.querySelector('.emby-translated-title')) return;
const trBox = document.createElement('div');
trBox.className = 'emby-translated-title';
trBox.innerHTML =
'<span class="tr-label">译</span>' +
'<span class="tr-text">' + escapeHtml(trans) + '</span>';
if (titleTarget && titleTarget.parentNode && document.body.contains(titleTarget)) {
titleTarget.parentNode.insertBefore(trBox, titleTarget.nextSibling);
}
}
} catch (e) {}
})();
}
// 播放下拉菜单交互绑定(主按钮Logo联动、自适应弹出、分组筛选、片源检测)
function bindPlayDropdownEvents(dr, code, root) {
if (!dr) dr = document.getElementById('emby-detail-root');
if (!dr) return;
if (!root) root = document.querySelector('.video-detail');
if (!code) code = currentPageCode();
const playGroup = dr.querySelector('#emby-play-group');
const playMenu = dr.querySelector('#emby-play-menu');
const playArrow = dr.querySelector('#emby-play-arrow-btn');
if (!playGroup || !playMenu || !playArrow) return;
if (playArrow._playMenuBound) return;
playArrow._playMenuBound = true;
function closePlayMenu() {
playMenu.classList.remove('open', 'pos-right', 'pos-bottom', 'pos-align-right');
playGroup.classList.remove('menu-open');
const heroEl = document.getElementById('emby-detail-hero');
if (heroEl) heroEl.classList.remove('has-menu-open');
}
playArrow.addEventListener('click', function (e) {
e.preventDefault();
e.stopPropagation();
if (playMenu.classList.contains('open')) {
closePlayMenu();
} else {
// 智能计算展开方向(双列菜单宽约 380px,优先横向弹出到右侧;若遇屏幕右边缘遮挡或处于混合模式则自适应向下展开)
const btnRect = playGroup.getBoundingClientRect();
const menuWidth = 380;
const spaceRight = window.innerWidth - btnRect.right;
const isHybrid = document.documentElement.classList.contains('cover-hybrid') ||
!!document.querySelector('.cover-hybrid');
playMenu.classList.remove('pos-right', 'pos-bottom', 'pos-align-right');
if (spaceRight >= menuWidth + 24 && !isHybrid) {
playMenu.classList.add('pos-right');
} else {
playMenu.classList.add('pos-bottom');
// 若从左向右展开会超出视口,或处于混合模式右侧独立操作区,采用右对齐展开(右边缘对齐按钮右边缘)
if (isHybrid || (btnRect.left + menuWidth > window.innerWidth - 16)) {
playMenu.classList.add('pos-align-right');
}
}
playMenu.classList.add('open');
playGroup.classList.add('menu-open');
const heroEl = document.getElementById('emby-detail-hero');
if (heroEl) heroEl.classList.add('has-menu-open');
if (document.documentElement.classList.contains('emby-style-liquid') && typeof LiquidGlass !== 'undefined') {
LiquidGlass.ensureApplied(playMenu);
}
}
});
const docCloseHandler = function (e) {
if (!document.body.contains(playGroup)) {
document.removeEventListener('click', docCloseHandler);
return;
}
if (!playGroup.contains(e.target)) closePlayMenu();
};
document.addEventListener('click', docCloseHandler);
// 方案 A:顶部分组标签(Tabs 胶囊按钮)切换联动
const tabsContainer = playMenu.querySelector('#emby-play-group-tabs');
if (tabsContainer) {
tabsContainer.querySelectorAll('.emby-group-tab').forEach(function (tab) {
tab.addEventListener('click', function (e) {
e.preventDefault();
e.stopPropagation();
tabsContainer.querySelectorAll('.emby-group-tab').forEach(function (t) { t.classList.remove('active'); });
tab.classList.add('active');
const selGroup = tab.getAttribute('data-group');
playMenu.querySelectorAll('.emby-play-menu-item').forEach(function (item) {
const itemGroup = item.getAttribute('data-group');
if (selGroup === 'all' || itemGroup === selGroup) {
item.classList.remove('is-filtered-out');
} else {
item.classList.add('is-filtered-out');
}
});
});
});
}
// 站点项点击:切换默认播放源并同步主按钮 Logo 与文本
playMenu.querySelectorAll('.emby-play-menu-item[data-site-id]').forEach(function (item) {
item.addEventListener('click', function (e) {
e.preventDefault();
e.stopPropagation();
const siteId = item.getAttribute('data-site-id');
const siteName = item.getAttribute('data-site-name');
const siteIcon = item.getAttribute('data-site-icon') || '';
const playUrl = item.getAttribute('data-play-url');
if (!siteId || !playUrl) return;
// 1. 持久化存储用户选中的偏好站点
setPreferredPlaySiteId(siteId);
// 2. 同步更新主播放按钮的动作链接、文本与站标 Logo
const playBtn = dr.querySelector('#emby-play-btn');
if (playBtn) {
playBtn.setAttribute('href', playUrl);
playBtn.setAttribute('target', '_blank');
playBtn.setAttribute('title', siteName);
const labelEl = playBtn.querySelector('.emby-play-label');
if (labelEl) labelEl.textContent = siteName;
const logoEl = playBtn.querySelector('#emby-play-btn-logo');
if (logoEl) logoEl.innerHTML = renderSiteLogoHtml(siteIcon, siteName, siteId);
const splitBtn = dr.querySelector('.emby-play-split-btn, .emby-play-btn');
if (splitBtn) applyPlayButtonLogoRgb(splitBtn, siteId, siteIcon);
}
// 3. 更新菜单项的高亮勾选态
playMenu.querySelectorAll('.emby-play-menu-item').forEach(function (el) {
el.classList.toggle('selected', el === item);
});
// 4. 收起菜单并轻提示
closePlayMenu();
showToast('默认播放源已切换为:' + siteName);
});
});
// 异步后台并发调度在线片源状态检测
const checkItems = Array.from(playMenu.querySelectorAll('.emby-play-menu-item[data-site-id]'));
function updateSiteBadge(item, hasMovie) {
const badge = item.querySelector('.emby-site-status');
if (badge) {
badge.classList.remove('status-loading', 'status-ok', 'status-fail');
if (hasMovie) {
badge.classList.add('status-ok');
badge.textContent = '✓';
badge.setAttribute('title', '检测到在线片源');
} else {
badge.classList.add('status-fail');
badge.textContent = '✕';
badge.setAttribute('title', '未检测到在线片源');
}
}
}
async function runCheckQueue(items, maxConcurrent) {
let cursor = 0;
async function worker() {
while (cursor < items.length) {
const item = items[cursor++];
if (!item) break;
const sId = item.getAttribute('data-site-id');
const sName = item.getAttribute('data-site-name');
const sTpl = item.getAttribute('data-site-template');
const sIcon = item.getAttribute('data-site-icon');
const siteObj = {
id: sId,
name: sName,
template: sTpl,
icon: sIcon,
isNative: sId === 'javdb_native'
};
try {
const hasMovie = await checkSiteAvailability(siteObj, code, root);
updateSiteBadge(item, hasMovie);
} catch (err) {
updateSiteBadge(item, false);
}
}
}
const workers = [];
const count = Math.min(maxConcurrent || 3, items.length);
for (let i = 0; i < count; i++) {
workers.push(worker());
}
await Promise.all(workers);
}
// 启动 3 并发队列,平滑执行各站点状态探针并逐个更新徽标
runCheckQueue(checkItems, 3);
const cfgTrig = dr.querySelector('#emby-play-config-trigger');
if (cfgTrig) {
cfgTrig.addEventListener('click', function (e) {
e.preventDefault();
e.stopPropagation();
closePlayMenu();
openSettingsPanel('sites');
});
}
const initSplitBtn = dr.querySelector('.emby-play-split-btn, .emby-play-btn');
if (initSplitBtn) {
const preferredId = getPreferredPlaySiteId();
applyPlayButtonLogoRgb(initSplitBtn, preferredId, '');
}
}
// 详情页播放按钮实时重建(外部播放站点设置变动时即时生效)
function rebuildDetailPlayButton() {
const dr = document.getElementById('emby-detail-root');
if (!dr) return;
const btnGroup = dr.querySelector('.emby-btn-group');
if (!btnGroup) return;
const root = document.querySelector('.video-detail');
const code = currentPageCode();
if (!code) return;
const newPlayHtml = buildDetailPlayDropdownHtml(code, root);
const oldPlay = btnGroup.querySelector('#emby-play-group') || btnGroup.querySelector('#emby-play-btn');
if (oldPlay) {
const temp = document.createElement('div');
temp.innerHTML = newPlayHtml;
const newPlayEl = temp.firstElementChild;
if (newPlayEl) {
oldPlay.parentNode.replaceChild(newPlayEl, oldPlay);
}
}
if (document.documentElement.classList.contains('emby-style-liquid') && typeof LiquidGlass !== 'undefined') {
try {
const splitBtn = btnGroup.querySelector('.emby-play-split-btn');
const singleBtn = btnGroup.querySelector('.emby-play-btn');
if (splitBtn) LiquidGlass.ensureApplied(splitBtn);
if (singleBtn) LiquidGlass.ensureApplied(singleBtn);
} catch (e) {}
}
bindPlayDropdownEvents(dr, code, root);
}
/* ====== JAVDB 荣誉榜单 金标数据渲染组件 (v7.260 精细化重构) ====== */
// 方案 1: 金标荣誉卡(右侧独立卡片,Steam 3D 物理倾斜 + 掠影流光,上榜记录使用TOP250导航同款实心图标)
function buildTrophyCardHtml(awards, code) {
if (!awards || !awards.length) return '';
var primaryAward = awards.find(function (w) {
return /top\s*250/i.test(w.name) || /top_250/i.test(w.href);
}) || awards[0];
var itemsHtml = '';
awards.forEach(function (w) {
var linkStart = w.href ? '<a href="' + escapeAttr(w.href) + '" target="_blank" class="emby-trophy-item" rel="noopener">' : '<div class="emby-trophy-item">';
var linkEnd = w.href ? '</a>' : '</div>';
itemsHtml += linkStart +
'<div class="emby-trophy-item-left">' +
'<span class="material-symbols-outlined emby-trophy-item-icon">social_leaderboard</span>' +
'<span class="emby-trophy-item-name" title="' + escapeAttr(w.name) + '">' + escapeHtml(w.name) + '</span>' +
'</div>' +
(w.rankTxt ? '<span class="emby-trophy-tag">' + escapeHtml(w.rankTxt) + '</span>' : '') +
linkEnd;
});
var crownHtml = typeof crownSvgHtml === 'function' ? crownSvgHtml() : '👑';
return '<div class="emby-trophy-card shimmer-effect" id="emby-trophy-card">' +
'<div class="emby-trophy-header">' +
'<div class="emby-trophy-header-left">' +
'<span class="emby-trophy-crown">' + crownHtml + '</span>' +
'<div class="emby-trophy-titles">' +
'<div class="emby-trophy-main-title">JAVDB 荣誉榜单</div>' +
'<div class="emby-trophy-sub-title">TOP250 认证入榜作品</div>' +
'</div>' +
'</div>' +
'<span class="emby-trophy-count-badge">' + awards.length + ' 项荣誉</span>' +
'</div>' +
'<div class="emby-trophy-rank-box">' +
(code ? '<div class="emby-trophy-work-plaque"><div class="emby-trophy-work-line"></div><span class="emby-trophy-work-code">' + escapeHtml(code) + '</span><div class="emby-trophy-work-line"></div></div>' : '') +
'<div class="emby-trophy-rank-label">当前排名</div>' +
'<div class="emby-trophy-rank-val">' +
'<span class="emby-trophy-no">No.</span>' +
'<span class="emby-trophy-num">' + escapeHtml(String(primaryAward.rank || primaryAward.rankTxt.replace(/^No\.?/i, '') || '-')) + '</span>' +
'</div>' +
'<div class="emby-trophy-award-name" title="' + escapeAttr(primaryAward.name) + '">' + escapeHtml(primaryAward.name) + '</div>' +
'</div>' +
'<div class="emby-trophy-records">' +
'<div class="emby-trophy-records-title">' +
'<span>上榜认证记录</span>' +
'</div>' +
'<div class="emby-trophy-list">' + itemsHtml + '</div>' +
'</div>' +
'<div class="emby-trophy-footer">★ 点击榜单可直达对应排行榜</div>' +
'</div>';
}
// 方案 2: 简约金标条(精简尺寸,单行容纳3个金标,统一皇冠标识)
function buildTrophyBarHtml(awards) {
if (!awards || !awards.length) return '';
var badgesHtml = '';
var crownHtml = typeof crownSvgHtml === 'function' ? crownSvgHtml() : '👑';
awards.forEach(function (w) {
var linkStart = w.href ? '<a href="' + escapeAttr(w.href) + '" target="_blank" class="emby-trophy-bar-badge shimmer-effect" rel="noopener">' : '<div class="emby-trophy-bar-badge shimmer-effect">';
var linkEnd = w.href ? '</a>' : '</div>';
badgesHtml += linkStart +
'<span class="emby-trophy-bar-icon">' + crownHtml + '</span>' +
'<span class="emby-trophy-bar-name">' + escapeHtml(w.name) + '</span>' +
(w.rankTxt ? '<span class="emby-trophy-bar-rank">' + escapeHtml(w.rankTxt) + '</span>' : '') +
linkEnd;
});
return '<div class="emby-trophy-bar" id="emby-trophy-bar">' + badgesHtml + '</div>';
}
// 方案 4: HUD徽章(与收藏夹金标药丸完全同步:明黄黑字 + 实心皇冠 + TOP250 + 排名 + 多标+N角标)
function buildTrophyHudHtml(awards) {
if (!awards || !awards.length) return '';
var primaryAward = awards.find(function (w) {
return /top\s*250/i.test(w.name) || /top_250/i.test(w.href);
}) || awards[0];
var bestRank = primaryAward.rank || (primaryAward.rankTxt ? primaryAward.rankTxt.replace(/^No\.?/i, '') : null);
if (!bestRank) {
awards.forEach(function (w) {
if (typeof w.rank === 'number' && w.rank > 0) {
if (bestRank === null || w.rank < bestRank) bestRank = w.rank;
}
});
}
var rankHtml = bestRank !== null && bestRank !== '' ? ('<span class="aw-rank">#' + escapeHtml(String(bestRank)) + '</span>') : '';
var moreHtml = awards.length > 1 ? ('<span class="aw-more">+' + (awards.length - 1) + '</span>') : '';
var fullTitle = awards.map(function (w) { return (w.rankTxt ? w.rankTxt + ' ' : '') + w.name; }).join('\n');
var linkStart = primaryAward.href
? '<a href="' + escapeAttr(primaryAward.href) + '" target="_blank" class="emby-trophy-hud-badge shimmer-effect" title="' + escapeAttr(fullTitle) + '" rel="noopener">'
: '<span class="emby-trophy-hud-badge shimmer-effect" title="' + escapeAttr(fullTitle) + '">';
var linkEnd = primaryAward.href ? '</a>' : '</span>';
var crownHtml = typeof crownSvgHtml === 'function' ? crownSvgHtml() : '👑';
return linkStart +
'<span class="emby-trophy-hud-icon">' + crownHtml + '</span>' +
'<span class="emby-trophy-hud-name">TOP250</span>' +
rankHtml +
moreHtml +
linkEnd;
}
function buildDetailRoot(root) {
removeDetailDom();
const coverImg = root.querySelector('img.video-cover');
const coverUrl = coverImg ? (coverImg.getAttribute('src') || '') : '';
const originTitleEl = root.querySelector('h2.title.is-4 .origin-title');
const currentTitleEl = root.querySelector('h2.title.is-4 strong.current-title');
const isBlockTr = isPluginEnabled('blockJavdbTranslate');
const titleEl = (isBlockTr && originTitleEl && originTitleEl.textContent.trim())
? originTitleEl
: (currentTitleEl || root.querySelector('h2.title.is-4'));
const displayTitle = titleEl ? titleEl.textContent.trim() : '';
const d = extractDetailData(root);
const code = d.code;
const actors = d.actors;
const awards = d.awards || [];
// 金标荣誉组件 HTML 生成
const hudBadgeHtml = awards.length ? buildTrophyHudHtml(awards) : '';
const barHtml = awards.length ? buildTrophyBarHtml(awards) : '';
const trophyCardHtml = awards.length ? buildTrophyCardHtml(awards, code) : '';
const trophyColHtml = trophyCardHtml ? '<div class="emby-trophy-col">' + trophyCardHtml + '</div>' : '';
// 背景虚化(封面大图)
const bg = document.createElement('div'); bg.id = 'emby-detail-bg';
if (coverUrl) bg.style.backgroundImage = 'url("' + coverUrl + '")';
const scrim = document.createElement('div'); scrim.id = 'emby-detail-scrim';
document.body.appendChild(bg); document.body.appendChild(scrim);
const dr = document.createElement('div'); dr.id = 'emby-detail-root'; dr.className = 'emby-detail-root';
const body = document.createElement('div'); body.className = 'emby-detail-body';
// --- Hero(海报 + 信息 两栏,支持多源播放下拉与 JAVDB 官方播放联动)---
const playHtml = buildDetailPlayDropdownHtml(code, root);
// 圆形操作按钮(想看 / 看過 / 存入清單)— 紧跟播放按钮右侧
let circleBtnsHtml = '';
d.reviewBtns.forEach(function (b, i) { b._idx = i; circleBtnsHtml += buildCircleBtnHtml(b); });
const actionRowHtml = circleBtnsHtml
? '<div class="emby-action-row">' + circleBtnsHtml + '</div>' : '';
// 结构化信息字段(参考原版 JAVDB 顺序:番號/日期/時長/導演/片商/系列/評分/類別)
const codeLinkEl = root.querySelector('.panel-block.first-block .value a[href]');
const codeLink = codeLinkEl ? (codeLinkEl.getAttribute('href') || '#') : '#';
let fieldsHtml = '';
if (code) {
const infoSitesHtml = isPluginEnabled('infoSites') ? buildInfoSitesHtml(code) : '';
fieldsHtml += '<div class="em-row em-code"><span class="em-k">番號</span>' +
'<a class="em-v em-link em-meta-chip" href="' + escapeAttr(codeLink) + '" rel="noopener">' + escapeHtml(code) + '</a>' +
'<button class="em-copy" data-clipboard-text="' + escapeAttr(code) + '" title="複製番號"><span class="material-symbols-outlined">content_copy</span></button>' +
'<button class="em-note-btn" id="emby-work-note-btn" data-note-key="code:' + escapeAttr(code) + '" data-note-name="' + escapeAttr(code) + '" title="備註"><span class="material-symbols-outlined">history_edu</span></button>' +
hudBadgeHtml +
infoSitesHtml +
'</div>';
}
if (d.date) fieldsHtml += '<div class="em-row"><span class="em-k">日期</span><span class="em-v">' + escapeHtml(d.date) + '</span></div>';
if (d.duration) fieldsHtml += '<div class="em-row"><span class="em-k">時長</span><span class="em-v">' + escapeHtml(d.duration) + '</span></div>';
// 导演行
const dirTxt = (d.director && d.director.text) ? d.director.text : '';
const dirHref = (d.director && d.director.href) ? d.director.href : '#';
fieldsHtml += '<div class="em-row group/row" data-meta-row="director"><span class="em-k">導演</span>' +
'<span class="em-v-wrap" id="em-director-wrap">' +
(dirTxt ? '<a class="em-v em-link em-meta-chip" href="' + escapeAttr(dirHref) + '" rel="noopener">' + escapeHtml(dirTxt) + '</a>' : '') +
'</span>' +
'<button type="button" class="row-hover-edit-btn em-meta-edit-btn" data-edit-field="director" title="编辑导演">✎ 编辑</button></div>';
// 片商行
const makerTxt = (d.maker && d.maker.text) ? d.maker.text : '';
const makerHref = (d.maker && d.maker.href) ? d.maker.href : '#';
fieldsHtml += '<div class="em-row group/row" data-meta-row="maker"><span class="em-k">片商</span>' +
'<span class="em-v-wrap" id="em-maker-wrap">' +
(makerTxt ? '<a class="em-v em-link em-meta-chip" href="' + escapeAttr(makerHref) + '" rel="noopener">' + escapeHtml(makerTxt) + '</a>' : '') +
'</span>' +
'<button type="button" class="row-hover-edit-btn em-meta-edit-btn" data-edit-field="maker" title="编辑片商">✎ 编辑</button></div>';
// 系列行
const seriesTxt = (d.series && d.series.text) ? d.series.text : '';
const seriesHref = (d.series && d.series.href) ? d.series.href : '#';
fieldsHtml += '<div class="em-row group/row" data-meta-row="series"><span class="em-k">系列</span>' +
'<span class="em-v-wrap" id="em-series-wrap">' +
(seriesTxt ? '<a class="em-v em-link em-meta-chip" href="' + escapeAttr(seriesHref) + '" rel="noopener">' + escapeHtml(seriesTxt) + '</a>' : '') +
'</span>' +
'<button type="button" class="row-hover-edit-btn em-meta-edit-btn" data-edit-field="series" title="编辑系列">✎ 编辑</button></div>';
// 評分(星级+分数,操作按钮已移至播放按钮后的圆形图标)
if (d.rating) {
const stars = buildStarHtml(d.rating.score);
const scoreTxt = d.rating.score ? d.rating.score.toFixed(2) + '分' : d.rating.text;
const voteTxt = d.rating.count ? '由' + d.rating.count + '人評價' : '';
fieldsHtml += '<div class="em-row em-rating"><span class="em-k">評分</span>' +
'<span class="em-stars">' + stars + '</span>' +
(scoreTxt ? '<span class="em-score">' + escapeHtml(scoreTxt) + (voteTxt ? ', ' + escapeHtml(voteTxt) : '') + '</span>' : '') +
'</div>';
}
// 類別行
let cats = '';
(d.categories || []).forEach(function (c) {
cats += '<a class="em-cat" href="' + escapeAttr(c.href) + '" rel="noopener" data-cat="' + escapeAttr(c.text) + '">' + escapeHtml(c.text) + '</a>';
});
fieldsHtml += '<div class="em-row em-cat-row group/row" data-meta-row="categories"><span class="em-k">類別</span>' +
'<span class="em-v" id="em-categories-wrap">' + cats + '</span>' +
'<button type="button" class="row-hover-edit-btn em-meta-edit-btn" data-edit-field="categories" title="编辑类别">✎ 编辑</button></div>';
// 演员核心区(保持原生纯粹架构,无额外文字标签干扰,末尾编辑演员按钮悬停显现;若无演员则始终常驻占位)
let actorsHtml = '';
(actors || []).forEach(function (a) { actorsHtml += buildActorChipHtml(a); });
const hasInitialActors = !!(actors && actors.length > 0);
const actorsSectionHtml =
'<div class="emby-actors' + (hasInitialActors ? '' : ' has-no-actors') + '" id="emby-actors-list">' +
actorsHtml +
'<a class="emby-actor-chip emby-actor-add-chip" id="emby-actor-add-chip" href="javascript:void(0);" title="编辑演员">' +
'<span class="ava"><span class="material-symbols-outlined" style="font-size:32px;">add</span></span>' +
'<span class="nm">编辑演员</span>' +
'</a>' +
'</div>';
const popoverHtml = '<div id="actor-search-popover" class="hidden" style="display:none;"></div>';
// T形布局架构:头部横梁是标题,下方双列并排(左侧元数据+演员+操作,右侧金标荣誉卡)
const hero = document.createElement('div');
hero.className = 'emby-detail-hero'; hero.id = 'emby-detail-hero';
hero.innerHTML =
'<div class="poster-wrap">' + (coverUrl ? '<img src="' + escapeHtml(coverUrl) + '" alt="poster">' : '') + '</div>' +
'<div class="info">' +
'<h1 class="emby-title">' + escapeHtml(displayTitle) + '</h1>' +
barHtml +
'<div class="emby-content-split">' +
'<div class="emby-meta-col">' +
'<div class="emby-meta">' + fieldsHtml + '</div>' +
actorsSectionHtml +
popoverHtml +
'<div class="emby-btn-group">' + playHtml + actionRowHtml + '</div>' +
'</div>' +
trophyColHtml +
'</div>' +
'</div>';
body.appendChild(hero);
// 为金标卡片绑定 Steam 3D 物理交互与光标流光
const trophyCardEl = hero.querySelector('.emby-trophy-card');
if (trophyCardEl && typeof attachCard3D === 'function') {
attachCard3D(trophyCardEl, { noPreview: true });
}
// 混合模式:清晰大封面 hero(上半屏,独立于模糊背景层,参考 Letterboxd)
// 模糊背景 #emby-detail-bg 在所有模式下永远在(见通用规则),这里额外展示一张清晰的整封面
// 用 <img> 元素(非 background)确保 100% 展示原始比例、不裁切;羽化遮罩由 ::before/::after 叠加
const heroCover = document.createElement('div');
heroCover.className = 'emby-detail-herocover';
if (coverUrl) heroCover.innerHTML = '<img src="' + escapeHtml(coverUrl) + '" alt="cover">';
body.insertBefore(heroCover, hero);
// 预览图:移动原版 Fancybox 画廊(保留原生播放/看图控件),并挂载快捷嗅探大图按钮
const nativePrev = root.querySelector('.tile-images.preview-images');
if (nativePrev && previewNode && previewNode !== nativePrev) {
// 引用自愈:上一页未正常卸载或存在游离旧引用时立即重置,防止新页预览区丢失
try { restorePreviewNode(); } catch (e) {}
}
if ((nativePrev && nativePrev.parentNode && !previewNode) || (!nativePrev && !previewNode)) {
if (nativePrev && nativePrev.parentNode) {
previewParent = nativePrev.parentNode;
previewNext = nativePrev.nextSibling;
previewNode = nativePrev;
}
const sec = document.createElement('div'); sec.className = 'emby-sec emby-native-preview';
sec.innerHTML =
'<div class="emby-preview-title-row">' +
'<h2>預覽圖 / 預覽影片</h2>' +
'<button type="button" class="csb-preview-icon-container emby-preview-sniff-btn" title="嗅探外部高清预览大图与剧照">' +
QuickIcons.preview +
'</button>' +
'</div>';
if (nativePrev) {
sec.appendChild(nativePrev);
// 检测预告片项并注入播放按钮标记(含视频的 tile-item 加 .has-trailer)
enhancePreviewTrailerItems(nativePrev);
}
body.appendChild(sec);
const sniffBtn = sec.querySelector('.emby-preview-sniff-btn');
if (sniffBtn) {
sniffBtn.addEventListener('click', async function (e) {
e.preventDefault(); e.stopPropagation();
if (sniffBtn.classList.contains('is-loading')) return;
sniffBtn.classList.add('is-loading');
try {
const curCode = currentPageCode() || code;
const res = await PreviewSys.fetch(curCode);
sniffBtn.classList.remove('is-loading');
if (res && res.status === 'success' && res.img) {
ModalView.open(curCode, res.img, res.gallery, res.siteId);
} else {
if (typeof showToast === 'function') showToast('未能嗅探到 ' + curCode + ' 的外部预览大图');
else alert('未能嗅探到 ' + curCode + ' 的外部预览大图');
}
} catch (err) {
sniffBtn.classList.remove('is-loading');
log('详情页嗅探大图失败: ' + err.message);
}
});
}
}
dr.appendChild(body);
// 把原版操作按钮容器移到可访问的隐藏 holder(不能用 display:none,否则 javdb 的 handler 不触发)
// 隐藏 javdb 原生内容,保留原生 movie-tab(磁鏈/短評/相關清單)
// 对含操作按钮的容器用 soft hide(position:absolute 而非 display:none),保持 javdb handler 可触发
Array.from(root.children).forEach(function (ch) {
if (!ch.matches('.columns[data-controller="movie-tab"]')) {
// 原生弹窗(#modal-save-list / #modal-comment-warning 等)直接子节点绝不能隐藏,
// 否则「存入清單」「看過」弹窗 display:none 打不开 → 点击无反应。
if (ch.classList && ch.classList.contains('modal')) return;
if (ch.querySelector('.panel-block .buttons, .review-buttons')) {
ch.classList.add('emby-hide-soft');
} else {
ch.classList.add('emby-hide-original');
}
}
});
hideSection('還出演過');
hideSection('你可能也喜歡');
// 原生浮层守卫类在重建时清零:避免上一页残留的 modal/dropdown is-active
// 把 EMBY 固定层(含左下角切换开关)永久隐藏 —— 这是「开关消失」BUG 的根因之一
document.documentElement.classList.remove('emby-native-overlay-open');
if (root.parentNode) { root.parentNode.insertBefore(dr, root); }
else { document.body.appendChild(dr); }
// 事件用 document 委托(一次绑定);点击时实时查找当前原生按钮,抗 javdb 重渲染
currentReviewBtns = d.reviewBtns;
bindDetailInteractions();
setupReviewRefresh();
refreshFavStatus(); // 详情页收藏状态图标(本地收藏夹=JAVDB清单数据)
// 作品级备注按钮初始点亮态(有备注时橙色)
try {
FAV.dbGetNote('code:' + code).then(function (n) {
const wb = document.getElementById('emby-work-note-btn');
if (wb) wb.classList.toggle('has-note', !!(n && n.content));
}).catch(function () {});
} catch (e) {}
// 实体备注小气泡:预热备注缓存并安装悬停委托(演员头像/片商/系列)
try { primeDetailNotes(); bindNoteTips(); } catch (e) {}
// 播放下拉菜单交互(主按钮Logo联动、双列自适应弹出、分组筛选、片源状态检测)
bindPlayDropdownEvents(dr, code, root);
// 异步加载标题翻译
syncDetailTranslation();
// 异步预热女优数据并标注已收藏女优红心角标(平时保持纯净,悬停展示方案1一体化卡片)
if (actors && actors.length) {
FavoriteActressManager.markBadges(dr);
FavoriteActressManager.autoSync();
if (isPluginEnabled('actressInfo')) {
(async function () {
for (const act of actors) {
if (!act.name) continue;
try { await ActressService.getActressData(act.name); } catch (e) {}
}
})();
}
}
// 启动元数据纠错与自定义编辑引擎
try { setupDetailMetaEditor(dr, code, d); } catch (e) { log('元数据编辑器启动失败: ' + e.message); }
log('详情页构建完成 | 番号=' + code + ' | 演员=' + actors.length + ' | 操作按钮=' + d.reviewBtns.length);
// 详情页构建完成后,按当前封面模式重排 hero(混合模式把操作按钮移入独立右栏)。
// 注意:applySettings() 在 restructureCurrent() 之前调用,彼时 #emby-detail-hero 尚不存在,
// arrangeHero 会提前 return,故此处必须补调一次,确保首次进入混合模式即生效。
arrangeHero(localStorage.getItem(SET_KEYS.coverMode) || 'fusion');
// 融合模式:重置并更新度量缓存,补一次状态评估
if (document.documentElement.classList.contains('cover-fusion')) {
try {
fusionImgLoadAttached = false;
fusionOnScroll();
} catch (e) {}
}
// 全封面模式:构建紧凑条并补一次状态评估(避免滚动发生在 hero 构建前)
if (document.documentElement.classList.contains('cover-full')) {
try {
ensureCompactBar(coverUrl, displayTitle, code);
compactBarOnScroll();
} catch (e) {}
}
}
// 详情页:首次构建(force)或增量更新
function restructureDetail(force) {
const root = document.querySelector('.video-detail');
if (!root) { log('未找到 .video-detail 容器'); return; }
try { hideJavdbAds(); } catch (e) {}
try { autoBlockJavdbTranslate(root); } catch (e) {}
if (force || !detailBuilt) {
buildDetailRoot(root); detailBuilt = true;
try { hideJavdbAds(); } catch (e) {}
// 被动采集:顺路解析当前详情页字段,补全库中已收录且未完整的番号(零请求)
try { FAV.passiveCollect(); } catch (e) {}
}
updateRelatedSections(root);
}
// 增量追加 还出演过 / 你可能喜欢(懒加载,不重建 → 滚动不回弹)
function updateRelatedSections(root) {
const dr = document.getElementById('emby-detail-root');
if (!dr) return;
const alsoStarred = extractSection('還出演過');
const youMightLike = extractSection('你可能也喜歡');
if (alsoStarred.length && !dr.querySelector('[data-related="還出演過"]')) {
dr.appendChild(buildRow('TA(們)還出演過', alsoStarred, '還出演過'));
}
if (youMightLike.length && !dr.querySelector('[data-related="你可能也喜歡"]')) {
dr.appendChild(buildRow('你可能也喜歡', youMightLike, '你可能也喜歡'));
}
}
// 提取可点击的元数据标签:番號 / 導演 / 片商 / 系列 / 類別(=標籤)
// 真实 javdb DOM 标签对照:
// 導演 → /directors/{id} 片商 → /makers/{id} 系列 → /series/{id}
// 類別 → /tags?...(真实站点用「類別」,旧版用「標籤」,两者都兼容)
// 番號 → 在 .panel-block.first-block 内,可能含 /video_codes/{prefix} 链接
function extractMetaLinks(root) {
const CLICK = { '番號': 1, '導演': 1, '片商': 1, '系列': 1, '類別': 1, '標籤': 1 };
const out = [];
// 番號(first-block,优先取其内部链接,否则回退到番號搜索)
const fb = root.querySelector('.panel-block.first-block');
if (fb) {
const valEl = fb.querySelector('.value');
const codeText = (valEl ? valEl.textContent : '').trim().replace(/\s+/g, '');
if (codeText) {
const a = valEl ? valEl.querySelector('a[href]') : null;
const href = a ? (a.getAttribute('href') || '') : ('/search?q=' + encodeURIComponent(codeText));
out.push({ label: '番號', text: codeText, href: href, acc: true });
}
}
// 其余元数据行
Array.from(root.querySelectorAll('.panel-block')).forEach(function (b) {
const strong = b.querySelector('strong');
if (!strong) return;
const label = strong.textContent.trim().replace(/[::]/g, '');
if (!CLICK[label] || label === '番號') return;
const valEl = b.querySelector('.value');
if (!valEl) return;
if (label === '類別' || label === '標籤') {
// 類別含多个 <a> 标签 → 逐个生成 chip
Array.from(valEl.querySelectorAll('a[href]')).forEach(function (a) {
out.push({ label: label, text: a.textContent.trim(), href: a.getAttribute('href') || '#' });
});
return;
}
const a = valEl.querySelector('a[href]');
if (a) out.push({ label: label, text: a.textContent.trim(), href: a.getAttribute('href') || '#' });
});
return out;
}
// 预告片容器检测与美化
// javdb 原版结构:.preview-images > a.preview-video-container(与 .tile-item 兄弟关系)
// 内部:<span>預告片</span> + <img class="video-cover" style="width:150px">
// 本函数:①确认 .preview-video-container 存在 ②注入右下角「預告片」chip 标识
// CSS 已处理:统一尺寸(230×135)、隐藏原版黄色<span>标签、播放按钮覆盖层
function enhancePreviewTrailerItems(container) {
if (!container) return;
var trailer = container.querySelector('.preview-video-container');
if (!trailer) return; // 无预告片,不处理
// 注入右下角「預告片」chip 标识(避免重复注入)
if (!trailer.querySelector('.trailer-label')) {
var label = document.createElement('span');
label.className = 'trailer-label';
label.textContent = '預告片';
trailer.appendChild(label);
}
}
// 演员头像 URL:https://c0.jdbstatic.com/avatars/{id 前2位小写}/{id}.jpg(与封面/缩略图同规律)
function actorAvatarUrl(href) {
const m = (href || '').match(/\/actors\/([^\/\?\#]+)/);
if (!m) return '';
const id = m[1];
// 与收藏夹同一套图床抽象(stripHost/resolveImg):图床域名变化时详情页头像不失效
return FAV.resolveImg(FAV.stripHost('https://c0.jdbstatic.com/avatars/' + id.slice(0, 2).toLowerCase() + '/' + id + '.jpg'));
}
// 演员 chip(头像 + 名字,可点跳作品页)。已收藏女优在头像右上角显示小红心角标。
function buildActorChipHtml(a) {
const m = (a.href || '').match(/\/actors\/([^\/\?\#]+)/);
const sid = m ? m[1] : '';
const isFav = isPluginEnabled('actressInfo') && (typeof FavoriteActressManager !== 'undefined') &&
(FavoriteActressManager.isFavorite(a.name) || (sid && FavoriteActressManager.isFavorite(sid)));
const favCls = isFav && !a.isDeleted ? ' is-favorite' : '';
const safeAvatar = a.avatar ? safeHref(a.avatar, '') : '';
let finalAvatar = safeAvatar;
if (typeof GfriendsAvatarService !== 'undefined') {
finalAvatar = GfriendsAvatarService.getAvatarUrl(a.name, safeAvatar);
}
const cleanAvatar = finalAvatar ? encodeURI(finalAvatar).replace(/['"()]/g, '') : '';
const cleanOfficial = safeAvatar ? encodeURI(safeAvatar).replace(/['"()]/g, '') : '';
const avStyle = cleanAvatar ? ' style="background-image:url(\'' + cleanAvatar + '\')' + (a.isCustom ? ';border-color:rgba(34,197,94,0.6);' : '') + '"' : (a.isCustom ? ' style="border-color:rgba(34,197,94,0.6);"' : '');
const delCls = a.isDeleted ? ' is-deleted' : '';
const customCls = a.isCustom ? ' is-custom-actor' : '';
const nameCls = a.isCustom ? 'nm highlighter-green' : 'nm';
let customTooltip = '';
if (a.isCustom) {
customTooltip = '<div class="custom-tooltip-box">' +
'<div style="display:flex;align-items:center;gap:4px;color:#22c55e;font-weight:bold;font-size:11px;">' +
'<span style="width:6px;height:6px;border-radius:50%;background:#22c55e;display:inline-block;"></span>' +
'用户自定义更正演员' +
'</div>' +
'</div>';
}
const imgTag = finalAvatar ? '<img loading="lazy" referrerpolicy="no-referrer" data-gfriends-actress="' + escapeAttr(a.name) + '" data-official-avatar="' + escapeAttr(cleanOfficial) + '" src="' + escapeAttr(cleanAvatar) + '" alt="" onerror="if(this.dataset.officialAvatar && this.src !== this.dataset.officialAvatar){this.src=this.dataset.officialAvatar;}else{this.style.display=\'none\';}" />' : '';
return '<a class="emby-actor-chip' + favCls + delCls + customCls + (a.isCustom ? ' has-custom-tooltip' : '') + '" href="' + escapeAttr(safeHref(a.href, '#')) + '" rel="noopener" data-actor-name="' + escapeAttr(a.name) + '">' +
customTooltip +
'<span class="ava' + favCls + '"' + avStyle + '>' + imgTag + '</span>' +
'<span class="' + nameCls + '">' + escapeHtml(a.name) + '</span>' +
(a.isDeleted ? '<span class="del-badge" style="font-size:9px;background:rgba(239,68,68,0.2);color:#ef4444;border-radius:2px;padding:0 3px;margin-top:2px;">已排除</span>' : '') +
'</a>';
}
/* =======================================================================
* v7.308 元数据自定义与纠错自愈引擎 (Metadata Editor & Self-Healing UI)
* ===================================================================== */
async function searchJavdbActors(kw) {
if (!kw || !kw.trim()) return [];
try {
const url = '/search?q=' + encodeURIComponent(kw.trim()) + '&f=actor';
const resp = await fetch(url, { headers: { 'Accept': 'text/html' } });
if (!resp.ok) return [];
const html = await resp.text();
const doc = new DOMParser().parseFromString(html, 'text/html');
const results = [];
const seen = new Set();
const links = doc.querySelectorAll('a[href*="/actors/"]');
links.forEach(function (a) {
const href = a.getAttribute('href') || '';
const m = href.match(/\/actors\/([^\/\?\#]+)/);
if (!m) return;
const slug = m[1];
if (seen.has(slug)) return;
const img = a.querySelector('img');
const rawSrc = img ? (img.getAttribute('data-src') || img.getAttribute('src') || '') : '';
let name = (a.querySelector('strong, .name, span') || a).textContent.trim();
name = name.replace(/[\r\n\t]+/g, ' ').replace(/\s+/g, ' ').trim();
if (!name || name === '演員' || name === 'Actors' || name.length > 50) return;
seen.add(slug);
results.push({
name: name,
slug: slug,
href: '/actors/' + slug,
avatar: rawSrc ? (typeof FAV !== 'undefined' ? FAV.resolveImg(FAV.stripHost(rawSrc)) : rawSrc) : ''
});
});
return results;
} catch (err) {
log('搜索演员失败: ' + err.message);
return [];
}
}
async function setupDetailMetaEditor(dr, code, d) {
if (!dr || !code) return;
let m = null;
try {
if (typeof FAV !== 'undefined' && typeof FAV.dbGetMovie === 'function') {
m = await FAV.dbGetMovie(code);
}
} catch (e) {}
if (!m) {
m = {
code: code,
title: (d && d.title) || (document.querySelector('h2.title.is-4 strong.current-title') || document.querySelector('h2.title.is-4') || {}).textContent || code,
date: (d && d.date) || '',
duration: (d && d.duration) || '',
director: (d && d.director) || null,
maker: (d && d.maker) || null,
series: (d && d.series) || null,
rating: (d && d.rating) || null,
categories: (d && d.categories ? d.categories.slice() : []),
actors: (d && d.actors ? d.actors.slice() : []),
awards: (d && d.awards ? d.awards.slice() : [])
};
}
// 状态检测:判断作品是否已添加进「想看」、「看過」或「收藏清单」任意范围
async function checkIsCollected() {
try {
if (typeof FAV !== 'undefined' && typeof FAV.getFavStatus === 'function') {
const st = await FAV.getFavStatus(code);
if (st && (st.inFav || st.reviewStatus || (st.listIds && st.listIds.length > 0))) return true;
}
if (typeof FAV !== 'undefined' && typeof FAV.dbGetMovie === 'function') {
const dbM = await FAV.dbGetMovie(code);
if (dbM && ((dbM.listIds && dbM.listIds.length > 0) || dbM.reviewStatus || (dbM.customMeta && Object.keys(dbM.customMeta).length > 0))) return true;
}
} catch (e) {}
const nativeActive = !!document.querySelector('.video-detail .user-actions .is-active, .video-detail .review-actions .is-active, .emby-circle-btn.active');
return nativeActive;
}
// 核心拦截守卫:未入库作品点击编辑时友好提示用户
async function ensureCollectedGuard() {
const ok = await checkIsCollected();
if (!ok) {
if (typeof showToast === 'function') {
showToast('⚠️ 该作品尚未添加入库,请先添加进「想看」、「看過」或「收藏清单」任意范围后再开放元数据编辑!');
} else {
alert('该作品尚未添加入库,请先添加进「想看」、「看過」或「收藏清单」任意范围后再开放元数据编辑!');
}
return false;
}
return true;
}
function ensureCustomMeta() {
if (!m.customMeta) m.customMeta = {};
if (!m.customMeta.actors) m.customMeta.actors = { deleted: [], added: [] };
if (!Array.isArray(m.customMeta.actors.deleted)) m.customMeta.actors.deleted = [];
if (!Array.isArray(m.customMeta.actors.added)) m.customMeta.actors.added = [];
if (!m.customMeta.categories) m.customMeta.categories = { deleted: [], added: [] };
if (!Array.isArray(m.customMeta.categories.deleted)) m.customMeta.categories.deleted = [];
if (!Array.isArray(m.customMeta.categories.added)) m.customMeta.categories.added = [];
}
async function saveMovieRecord() {
if (m.customMeta) {
const cm = m.customMeta;
const hasActors = cm.actors && (cm.actors.deleted.length > 0 || cm.actors.added.length > 0);
const hasCats = cm.categories && (cm.categories.deleted.length > 0 || cm.categories.added.length > 0);
const hasOther = !!(cm.director || cm.maker || cm.series);
if (!hasActors && !hasCats && !hasOther) {
delete m.customMeta;
} else {
cm.updatedAt = Date.now();
}
}
try {
if (typeof FAV !== 'undefined' && typeof FAV.dbPutMovie === 'function') {
let latest = null;
try {
if (typeof FAV.dbGetMovie === 'function') latest = await FAV.dbGetMovie(code);
} catch (_) {}
const toSave = latest ? Object.assign({}, latest, { customMeta: m.customMeta }) : m;
if (!m.customMeta) delete toSave.customMeta;
await FAV.dbPutMovie(toSave);
}
} catch (e) {
log('保存自定义元数据失败: ' + e.message);
}
}
// 1. 渲染单值字段(导演、片商、系列)
function renderSingleField(field, label) {
const wrap = dr.querySelector('#em-' + field + '-wrap');
const row = dr.querySelector('[data-meta-row="' + field + '"]');
if (!wrap || !row) return;
const official = d && d[field];
const isOverridden = !!(m.customMeta && m.customMeta[field]);
const current = isOverridden ? m.customMeta[field] : official;
let html = '';
if (current && current.text) {
const href = current.href || ('/search?q=' + encodeURIComponent(current.text) + '&f=' + field);
html = '<a class="em-v em-link em-meta-chip' + (isOverridden ? ' is-custom-field' : '') + '" href="' + escapeAttr(href) + '" rel="noopener" style="' + (isOverridden ? 'background:rgba(34,197,94,0.15);border:1px solid rgba(34,197,94,0.4);color:#86efac;' : '') + '">' +
escapeHtml(current.text) +
(isOverridden ? ' <span style="font-size:10px;background:rgba(34,197,94,0.25);padding:1px 4px;border-radius:3px;color:#4ade80;margin-left:4px;">自定义</span>' : '') +
'</a>';
if (isOverridden) {
html += ' <button type="button" class="btn-reset-field" style="background:none;border:none;color:#f87171;font-size:12px;cursor:pointer;padding:0 4px;" title="恢复官方原始值">✕</button>';
}
} else {
html = '';
}
wrap.innerHTML = html;
row.style.display = '';
const resetBtn = wrap.querySelector('.btn-reset-field');
if (resetBtn) {
resetBtn.addEventListener('click', async function (e) {
e.preventDefault(); e.stopPropagation();
if (!(await ensureCollectedGuard())) return;
if (m.customMeta) {
delete m.customMeta[field];
await saveMovieRecord();
renderSingleField(field, label);
if (typeof showToast === 'function') showToast('已恢复' + label + '官方元数据');
}
});
}
}
['director', 'maker', 'series'].forEach(function (field) {
const label = (field === 'director' ? '導演' : (field === 'maker' ? '片商' : '系列'));
const row = dr.querySelector('[data-meta-row="' + field + '"]');
if (row && !row.querySelector('.row-hover-edit-btn')) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'row-hover-edit-btn em-meta-edit-btn';
btn.setAttribute('data-edit-field', field);
btn.title = '编辑' + label;
btn.textContent = '✎ 编辑';
row.appendChild(btn);
}
renderSingleField(field, label);
const btn = dr.querySelector('[data-edit-field="' + field + '"]');
if (btn) {
btn.addEventListener('click', async function (e) {
e.preventDefault(); e.stopPropagation();
if (!(await ensureCollectedGuard())) return;
const curVal = (m.customMeta && m.customMeta[field]) ? m.customMeta[field].text : ((d && d[field] && d[field].text) || '');
const inputVal = prompt('请输入修改后的' + label + '名称(留空恢复官方原值):', curVal);
if (inputVal === null) return;
ensureCustomMeta();
if (!inputVal.trim() || (d && d[field] && inputVal.trim() === d[field].text)) {
delete m.customMeta[field];
} else {
m.customMeta[field] = {
text: inputVal.trim(),
href: '/search?q=' + encodeURIComponent(inputVal.trim()) + '&f=' + field
};
}
await saveMovieRecord();
renderSingleField(field, label);
if (typeof showToast === 'function') showToast(label + '已更新');
});
}
});
// 2. 类别渲染与编辑
let isCatEditMode = false;
function renderCategories() {
const wrap = dr.querySelector('#em-categories-wrap');
if (!wrap) return;
const officialCats = (d && d.categories ? d.categories : []).slice();
const delCats = new Set((m.customMeta && m.customMeta.categories && m.customMeta.categories.deleted) || []);
const addCats = (m.customMeta && m.customMeta.categories && m.customMeta.categories.added) || [];
let html = '';
officialCats.forEach(function (c) {
const isDel = delCats.has(c.text);
html += '<span class="em-cat-wrap" style="display:inline-flex;align-items:center;margin-right:6px;margin-bottom:4px;' + (isDel ? 'opacity:0.5;' : '') + '">' +
'<a class="em-cat" href="' + escapeAttr(c.href) + '" rel="noopener" style="' + (isDel ? 'color:#ef4444!important;text-decoration:line-through!important;' : '') + '">' +
escapeHtml(c.text) +
'</a>';
if (isCatEditMode) {
html += '<button type="button" class="btn-toggle-del-cat" data-cat="' + escapeAttr(c.text) + '" style="background:none;border:none;cursor:pointer;font-size:11px;font-weight:bold;margin-left:2px;color:' + (isDel ? '#10b981' : '#ef4444') + ';" title="' + (isDel ? '恢复类别' : '排除此类别') + '">' +
(isDel ? '↺' : '✕') +
'</button>';
}
html += '</span>';
});
addCats.forEach(function (c) {
html += '<span class="em-cat-wrap is-custom-cat" style="display:inline-flex;align-items:center;margin-right:6px;margin-bottom:4px;background:rgba(34,197,94,0.15);border:1px solid rgba(34,197,94,0.4);border-radius:4px;padding:1px 5px;">' +
'<a class="em-cat" href="' + escapeAttr(c.href || '#') + '" rel="noopener" style="color:#86efac!important;">' + escapeHtml(c.text) + '</a>' +
'<span style="font-size:9px;background:rgba(34,197,94,0.3);color:#4ade80;border-radius:2px;padding:0 3px;margin-left:4px;">新增</span>';
if (isCatEditMode) {
html += '<button type="button" class="btn-remove-added-cat" data-cat="' + escapeAttr(c.text) + '" style="background:none;border:none;cursor:pointer;font-size:11px;font-weight:bold;margin-left:2px;color:#ef4444;" title="移除新增类别">✕</button>';
}
html += '</span>';
});
if (isCatEditMode) {
html += '<button type="button" class="btn-prompt-add-cat" style="display:inline-flex;align-items:center;padding:2px 8px;border-radius:4px;border:1px dashed rgba(34,197,94,0.6);background:none;color:#4ade80;font-size:11px;cursor:pointer;margin-left:4px;">+ 添加类别</button>';
}
wrap.innerHTML = html;
wrap.querySelectorAll('.btn-toggle-del-cat').forEach(function (btn) {
btn.addEventListener('click', async function (e) {
e.preventDefault(); e.stopPropagation();
if (!(await ensureCollectedGuard())) return;
const catName = btn.getAttribute('data-cat');
ensureCustomMeta();
const arr = m.customMeta.categories.deleted;
const idx = arr.indexOf(catName);
if (idx !== -1) arr.splice(idx, 1);
else arr.push(catName);
await saveMovieRecord();
renderCategories();
});
});
wrap.querySelectorAll('.btn-remove-added-cat').forEach(function (btn) {
btn.addEventListener('click', async function (e) {
e.preventDefault(); e.stopPropagation();
if (!(await ensureCollectedGuard())) return;
const catName = btn.getAttribute('data-cat');
ensureCustomMeta();
m.customMeta.categories.added = m.customMeta.categories.added.filter(function (c) { return c.text !== catName; });
await saveMovieRecord();
renderCategories();
});
});
const addBtn = wrap.querySelector('.btn-prompt-add-cat');
if (addBtn) {
addBtn.addEventListener('click', async function (e) {
e.preventDefault(); e.stopPropagation();
if (!(await ensureCollectedGuard())) return;
const val = prompt('请输入要添加的类别标签名称:');
if (!val || !val.trim()) return;
ensureCustomMeta();
const trimmed = val.trim();
if (!m.customMeta.categories.added.some(function (c) { return c.text === trimmed; })) {
m.customMeta.categories.added.push({
text: trimmed,
href: '/tags?c7=' + encodeURIComponent(trimmed)
});
await saveMovieRecord();
renderCategories();
}
});
}
}
const catRow = dr.querySelector('[data-meta-row="categories"]');
if (catRow && !catRow.querySelector('[data-edit-field="categories"]')) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'row-hover-edit-btn em-meta-edit-btn';
btn.setAttribute('data-edit-field', 'categories');
btn.title = '编辑类别';
btn.textContent = '✎ 编辑';
catRow.appendChild(btn);
}
renderCategories();
const catEditBtn = dr.querySelector('[data-edit-field="categories"]');
if (catEditBtn) {
catEditBtn.addEventListener('click', async function (e) {
e.preventDefault(); e.stopPropagation();
if (!isCatEditMode) {
if (!(await ensureCollectedGuard())) return;
}
isCatEditMode = !isCatEditMode;
catEditBtn.textContent = isCatEditMode ? '✓ 完成' : '✎ 编辑';
catEditBtn.classList.toggle('is-active', isCatEditMode);
renderCategories();
});
}
// 3. 核心演员名单渲染(用户点击「编辑演员」进入编辑状态,显示右上角排除按钮及搜索弹窗)
let isActorEditMode = false;
let actorsList = dr.querySelector('#emby-actors-list');
const popover = dr.querySelector('#actor-search-popover');
function openActorEditMode() {
isActorEditMode = true;
if (actorsList) actorsList.classList.add('is-editing');
renderActorsList();
openActorSearchPopover();
}
function closeActorEditMode() {
isActorEditMode = false;
if (actorsList) actorsList.classList.remove('is-editing');
if (popover) popover.style.display = 'none';
renderActorsList();
}
function renderActorsList() {
if (!actorsList) return;
const officialActors = (d && d.actors ? d.actors : []).slice();
const delActors = new Set((m.customMeta && m.customMeta.actors && m.customMeta.actors.deleted) || []);
const addActors = (m.customMeta && m.customMeta.actors && m.customMeta.actors.added) || [];
let html = '';
// A. 官方演员(调用无额外 wrapper 的 buildActorChipHtml,头像绝对不挤压)
officialActors.forEach(function (act) {
const isDel = delActors.has(act.name);
html += buildActorChipHtml({
name: act.name,
href: act.href,
avatar: act.avatar,
isDeleted: isDel,
isCustom: false
});
});
// B. 用户自定义新增演员
addActors.forEach(function (act) {
html += buildActorChipHtml({
name: act.name,
href: act.href || ('/search?q=' + encodeURIComponent(act.name) + '&f=actor'),
avatar: act.avatar,
isDeleted: false,
isCustom: true
});
});
actorsList.innerHTML = html;
actorsList.style.display = 'flex';
actorsList.classList.toggle('is-editing', isActorEditMode);
// C. 编辑按钮在演员的最后面(同款 chip 样式,绿色虚线圆圈 + 图标)
const addChip = document.createElement('a');
addChip.className = 'emby-actor-chip emby-actor-add-chip' + (isActorEditMode ? ' is-active' : '');
addChip.href = 'javascript:void(0);';
addChip.title = isActorEditMode ? '完成编辑' : '编辑演员';
addChip.innerHTML =
'<span class="ava"><span class="material-symbols-outlined" style="font-size:32px;">' + (isActorEditMode ? 'done' : 'add') + '</span></span>' +
'<span class="nm">' + (isActorEditMode ? '完成编辑' : '编辑演员') + '</span>';
actorsList.appendChild(addChip);
addChip.addEventListener('click', async function (e) {
e.preventDefault(); e.stopPropagation();
if (!isActorEditMode) {
if (!(await ensureCollectedGuard())) return;
openActorEditMode();
} else {
closeActorEditMode();
}
});
const totalVisibleActors = officialActors.filter(function (act) { return !delActors.has(act.name); }).length + addActors.length;
actorsList.classList.toggle('has-no-actors', totalVisibleActors === 0);
// 为每个演员挂载右上角排除/移除按钮(仅在进入演员编辑状态时显现)
actorsList.querySelectorAll('.emby-actor-chip:not(.emby-actor-add-chip)').forEach(function (chip) {
const actName = chip.getAttribute('data-actor-name');
if (!actName) return;
const isCustom = chip.classList.contains('is-custom-actor');
const isDel = chip.classList.contains('is-deleted');
const delBtn = document.createElement('button');
delBtn.type = 'button';
delBtn.className = 'actor-edit-del-btn ' + (isDel ? 'is-undo' : 'is-del');
delBtn.title = isCustom ? '移除此自定义演员' : (isDel ? '撤销排除,恢复该演员' : '从本片中排除此演员');
delBtn.textContent = isDel ? '↺' : '✕';
chip.appendChild(delBtn);
delBtn.addEventListener('click', async function (e) {
e.preventDefault(); e.stopPropagation();
if (!(await ensureCollectedGuard())) return;
ensureCustomMeta();
if (isCustom) {
m.customMeta.actors.added = m.customMeta.actors.added.filter(function (a) { return a.name !== actName; });
} else {
const delList = m.customMeta.actors.deleted;
const idx = delList.indexOf(actName);
if (idx !== -1) delList.splice(idx, 1);
else delList.push(actName);
}
await saveMovieRecord();
renderActorsList();
});
});
try {
if (typeof FavoriteActressManager !== 'undefined') FavoriteActressManager.markBadges(dr);
if (typeof primeDetailNotes === 'function') primeDetailNotes();
} catch (e) {}
}
renderActorsList();
// 4. 演员搜索气泡弹窗
function openActorSearchPopover() {
if (!popover) return;
popover.style.display = 'block';
popover.innerHTML =
'<div class="actor-search-header">' +
'<div class="actor-search-title">' +
'<span class="dot"></span>' +
'实时检索并录入 JAVDB 演员库' +
'</div>' +
'<button type="button" id="btn-close-popover">✕ 完成</button>' +
'</div>' +
'<div class="actor-search-input-wrap">' +
'<input type="text" id="actor-search-inp" placeholder="输入女优名字(例如:葵つかさ、三上悠亜、河北彩花)..." />' +
'</div>' +
'<div id="actor-search-res">' +
'<div class="actor-search-tip">请输入关键词开始检索...</div>' +
'</div>';
const closeBtn = popover.querySelector('#btn-close-popover');
if (closeBtn) closeBtn.addEventListener('click', function () { closeActorEditMode(); });
const inp = popover.querySelector('#actor-search-inp');
const resContainer = popover.querySelector('#actor-search-res');
if (inp) {
inp.focus();
let timer = null;
let searchSeq = 0;
inp.addEventListener('input', function () {
clearTimeout(timer);
const q = inp.value.trim();
const curSeq = ++searchSeq;
if (!q) {
resContainer.innerHTML = '<div class="actor-search-tip">请输入关键词开始检索...</div>';
return;
}
resContainer.innerHTML = '<div class="actor-search-tip" style="color:#94a3b8;">正在查询 JAVDB 官方演员库...</div>';
timer = setTimeout(async function () {
const list = await searchJavdbActors(q);
if (curSeq !== searchSeq) return;
renderSearchResults(list, q);
}, 350);
});
}
function renderSearchResults(list, kw) {
if (!resContainer) return;
let html = '';
if (list && list.length) {
list.forEach(function (act) {
const officialAva = act.avatar ? (typeof FAV !== 'undefined' && FAV.resolveImg ? FAV.resolveImg(act.avatar) : act.avatar) : '';
let finalAva = officialAva;
if (typeof GfriendsAvatarService !== 'undefined') {
finalAva = GfriendsAvatarService.getAvatarUrl(act.name, officialAva);
}
html += '<div class="actor-search-item" data-name="' + escapeAttr(act.name) + '" data-slug="' + escapeAttr(act.slug || '') + '" data-avatar="' + escapeAttr(act.avatar || '') + '" data-href="' + escapeAttr(act.href || '') + '">' +
(finalAva ? '<img src="' + escapeAttr(finalAva) + '" style="width:26px;height:26px;border-radius:50%;object-fit:cover;" onerror="this.style.display=\'none\';" />' : '<span style="width:26px;height:26px;border-radius:50%;background:#334155;display:inline-block;"></span>') +
'<span style="font-size:12px;color:#f1f5f9;font-weight:500;">' + escapeHtml(act.name) + '</span>' +
'</div>';
});
}
html += '<div class="actor-search-item is-direct-add" data-name="' + escapeAttr(kw) + '" data-slug="" data-avatar="" data-href="#">' +
'<span>➕ 直接添加为自定义演员: "' + escapeHtml(kw) + '"</span>' +
'</div>';
resContainer.innerHTML = html;
resContainer.querySelectorAll('.actor-search-item').forEach(function (item) {
item.addEventListener('click', async function () {
if (!(await ensureCollectedGuard())) return;
const actName = item.getAttribute('data-name');
const actSlug = item.getAttribute('data-slug');
const actAvatar = item.getAttribute('data-avatar');
const actHref = item.getAttribute('data-href');
if (!actName) return;
ensureCustomMeta();
const delIdx = m.customMeta.actors.deleted.indexOf(actName);
if (delIdx !== -1) {
m.customMeta.actors.deleted.splice(delIdx, 1);
} else {
if (!m.customMeta.actors.added.some(function (x) { return x.name === actName; })) {
m.customMeta.actors.added.push({
name: actName,
slug: actSlug,
avatar: actAvatar,
href: actHref || ('/search?q=' + encodeURIComponent(actName) + '&f=actor')
});
}
}
await saveMovieRecord();
closeActorEditMode();
if (typeof showToast === 'function') showToast('✅ 已成功添加演员: ' + actName);
});
});
}
}
}
// 提取区块(还出演过 / 你可能喜欢):message-header 含关键词的 .tile-images.tile-small
function extractSection(keyword) {
const headers = Array.from(document.querySelectorAll('.message-header p'));
const h = headers.find(function (p) { return (p.textContent || '').indexOf(keyword) !== -1; });
if (!h) return [];
const msg = h.closest('.message') || h.closest('.columns');
if (!msg) return [];
return Array.from(msg.querySelectorAll('.tile-images.tile-small .tile-item')).map(function (a) {
const img = a.querySelector('img');
const num = a.querySelector('.video-number');
const tit = a.querySelector('.video-title');
return {
href: a.getAttribute('href') || '#',
img: img ? (img.getAttribute('src') || '') : '',
number: num ? num.textContent.trim() : '',
title: tit ? tit.textContent.trim() : (a.getAttribute('title') || '')
};
});
}
function hideSection(keyword) {
const headers = Array.from(document.querySelectorAll('.message-header p'));
const h = headers.find(function (p) { return (p.textContent || '').indexOf(keyword) !== -1; });
if (!h) return;
const col = h.closest('.columns');
if (col) col.classList.add('emby-hide-original');
}
// 构建相关影片横向滚动行
function buildRow(title, items, marker) {
const sec = document.createElement('div');
sec.className = 'emby-sec';
if (marker) sec.setAttribute('data-related', marker);
let h = '<h2>' + escapeHtml(title) + '</h2><div class="emby-row">';
items.forEach(function (it) {
h += '<div class="item"><a class="box" href="' + escapeAttr(it.href) + '" rel="noopener">' +
'<div class="cover"><img src="' + escapeHtml(it.img || '') + '" loading="lazy" alt="">' +
'<span class="emby-card-shine"></span>' +
'</div>' +
(it.number ? '<div class="video-number" style="padding:.5em .6em .15em;font-size:.72em;color:var(--e-text3);">' + escapeHtml(it.number) + '</div>' : '') +
'<div class="video-title">' + escapeHtml(it.title) + '</div></a></div>';
});
h += '</div>';
sec.innerHTML = h;
// 绑定 STEAM 风格卡牌 3D 倾斜 + 高光(不弹悬停封面预览:详情页推荐行按需求排除)
sec.querySelectorAll('.item .box').forEach(function (b) { attachCard3D(b, { noPreview: true }); });
return sec;
}
function escapeAttr(s) { return escapeHtml(s); }
// 清理详情页注入结构(供路由切换重建时调用)
function removeDetailDom() {
try { stopDetailFilterBtnWatcher(); } catch (e) {}
// 先还原「原版预览节点」并清空引用:SPA 换页/强制重建后旧引用是游离节点,
// 不清理会导致新页面的预览图不被搬入 Emby 布局(预览区丢失)
if (typeof restorePreviewNode === 'function') {
try { restorePreviewNode(); } catch (e) {}
}
if (reviewRefreshObserver) {
try { reviewRefreshObserver.disconnect(); } catch (e) {}
reviewRefreshObserver = null;
}
hideNoteTip(); // 收起可能残留的实体备注气泡
['emby-detail-root', 'emby-detail-bg', 'emby-detail-scrim', 'emby-detail-hero', 'emby-compact-bar'].forEach(function (id) {
const el = document.getElementById(id);
if (el) el.remove();
});
document.querySelectorAll('.emby-card-play').forEach(function (el) { el.remove(); });
document.querySelectorAll('.emby-hide-original, .emby-hide-soft').forEach(function (el) {
el.classList.remove('emby-hide-original');
el.classList.remove('emby-hide-soft');
});
// 重建前的清理:同步清除原生浮层守卫类,避免上一页状态残留隐藏开关
document.documentElement.classList.remove('emby-native-overlay-open');
}
function escapeHtml(s) {
// 单引号一并转义:escapeAttr 是本函数的别名,而属性值也可能用单引号包裹;
// 少转义一个引号字符就等于留了一个属性注入口。
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')
.replace(/"/g,'"').replace(/'/g,''');
}
/* =======================================================================
* 页面路由分发
* ===================================================================== */
function restructureCurrent(force) {
if (isDetailPage()) {
restructureDetail(force);
try { ReviewListInfiniteManager.init(); } catch (e) {}
} else {
restructureGrid();
try { InfiniteScrollManager.init(); } catch (e) {}
}
}
/* =======================================================================
* 浮动开关按钮
* ===================================================================== */
const OFFICIAL_LOGO_BASE64 = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAIAAABMXPacAAAAAXNSR0IB2cksfwAAAAlwSFlzAAALEwAACxMBAJqcGAAAIV9JREFUeJzlnfuTVdWVx/tvmB8mmUolsaYyNVNl8kMmL80Yo3GcaDRGxUApKYxaIAMKigmRCKI8xICCwkQeFiCPkAQhaCtEMATRBDpq81BB6Ab7waO10ElpxVSlUpWaz7nf26v33Xuffc59AM5M1aqu09237+P7Weu71t7n3Ntt7548VjIGTvR9BOPt471nPU4c63FjoL8a7jFxsu8t/YQDBd+2ueK2UH09s/8D6ucJYjeQ0FEAXkhx9+uJvqNt/+tSPlS/JSTCl3+qv/fdfQeO7+w41b7z5JNb+37xjMWbz27jt2kAJvSQ4n1HFVX1K5EL4Kzr29rcz/sT/1V3d73TsU9yD6x+lgPU/+NzuxQcK/jtS7MW9h06EKovADrwkr36VZEGcNb1ba36ad3JZUR/e9tu4k8drxOZ1hu2oz4MBla2n1j+VHZQ4cHX40s3dk5b2DFvKdHTfchV0wNQDfcGtepHAJx1cc+o+hWHke4fvNBpCc6xSPx572FCvyUAA4DuR1ftuOkeKmDTmClv7HsVBsd6uk1W67F+ZVju10bb/yv17ZUq5aUssvL1L4d6/3byvb+dep/4a8+A6kD53v/TJwkOiN6FP3953Mz1l48CwJobJ+5+4TeHD+yHQd/RrioGS/DQcwYPhvC4AM66uKcv5WvU33fArIZA60z0Dz5EfZI9E31lu7TOnGdlO/HO6mcVHL8+ecG26ycBAP/52YjxL255hiLoPvS6MTjeG890j8FQBTSjy0dW66j02dyybbfMBOkt38l9dHdFR+tTazab7hbUQedts7cMnygAj19983Mbf7n35V1vvr73yKE33up+Ewb9byUZJACUqQP3VX2Uk90iGyUrr+uPew+ivgw9k56Ur0hPs5XueaKnATy9amXHi799bc8fMCIYqA5gcKLnCBiKSZQEYIq7Xz+Cutts7qlPpx0yHJO+fWdJ3S1oAB6ADUsf+/32rRTBwdf2dB187ejhgx6DFgBw5f6I5z6KGwNT3xy/6vWn3seCTHpTX8f61j12o2f+2j0T5xiARZddDwDawKu7X6ITHHpjH80AI+o9elj9oKAl1NUDTp/zNKO41pxh+rvqa5TMpP/gQyqAxMflTWIvXBhhBfTMW7X3jgcFgClIALY/s+mV372w79UOFYGMCAYUgQCkGTTehM+6+h6DaO676me2s2ZzqH6oeB6AIw+u8ACsnTsXAAyjuNBQEXRVi0AM0nVwpgGEhtaqCrCwSV8rKVNfc2Q69w1AtAccnrUsBMAgRBuQCzEOeZ2gkMFZAGBWVqxv8ja2yxiqz7DPZGm+j/o2XKYj3YTTAJiFrBVbJzAAeQzaBk7bbJM2nFZVgPutzCdrxe07yX3NPHwdqG2zedIXAnjzvsU04WwhdtVoA7B5/bqdz2+2NqB59GjXgbAIhpbKZ7gC6k3txvwnbv0n32PSTzhPydzXkvjgPYvSAKwNyIXKFMHZAdAqBmH6Yz5Kf61ycSHWWYXqpwFIfSbXPABMogCwPmxtIAQQMjjtAFqrctR/NP5X9xsq6mc7axXzQbUy6pcBAMgEANbDAqA+nO1MdL0pF0ozOC0ATp/oofo1ACrpn/XeyoJLO2sN6D4QS//+nz75+uQFaQDqw14bCDuBFgeK1gNoStbYPURnTX4SDv4nn9xqoydFUGg+5dXXRrQLYPuMBQLQvm71jl+3sxSwQagKwHGhsAhss6iVAKLO3nxBhOq7YemfHWzY/sELnXL/VqW/1Cd65q8FQGc5ANYG8ooAAGLQLIC3ixZWzdZEkP7h+C8AdoKlvPunAbjq4z8981blAdBiGACaRK0PsxowAF4RCADRAgBpS2lS+jwAnvrZNuLqZ6ubbh98WDj8JKb+gZj6+M+RB1dkAG6bXQeALh+AMUB6fW0EQFrchOgDpVfCeernAeArwmE7OsOVnv3TI39UffyHZXACgBbDthRwByFrA2EnIOoAYMIN5K9pW+L4dnY7zeZtpwG807EvO5++YXt2suXke4n0z8v6oYipj/+wDN5/57y6AGgQcvtwswCizdb7VTMh6ctYk31r8092WQMAKidb8gAUSm/q60S8qY//sAgwAJuGjQsB2FrMlgIegKYqoLHsLvSrhPO4KnvHIQBU03UlWv1GO3BB7jvqe7mP+vIfALySBKAKAEB0Em0QgJfdoZSFDbm4V/f3uM5j+lo1RC+1dAGgl65cw38SA2ik2QbOE+Y+6uM/UQBPzJr99KqVuQC6DmgQylsNpACcvjQP1Y9eW5m+0FV/ax0YyXRNVbb7VntaMZr+CedB+lB9+U/dAAYn0UQbKAaQ7sAt1D0aZQAMdHdlADZsVxMOG0Bhyy1UX+nvAXj0khEeAPUArcW0J+oNQrkAXCdJm0mrAKQupqwTwLv7DlQBrNnMGJodx040DuQkfnn1ywOwTenoJOoyaGtmrm9AdO+dC42lvzuG6pofAUBQRiABKKm+23IL1S8DQLsRjQAotPjGfObt2hZq+e66fF4PqBcAUv5572EPgC99re3kqS/pPfVdADunDQHY+vQGD0CeBRkAY9BWSse6enJM7kSOl0/5NACWAnxlBjULSsyaUfWPVNSPJn4ZAO6OtCoguhj2iqC4AhozmZI9tmH1owDQUQDKmH502E+rT7w8bmZ5AC2rgJLSvx28Xe30qe8BQE0BYDnGcXTQbMz0SwLY+fxmANiOdAKAXwGtUr/JaBKAKgA1WYVxXL7lHqlHfVnQ89/7YRkA7oaouydaB4DUPDroMC1RPw0g77dlAeTtMaB+PdK7ANpHTioJQNtBCReKACheDfT3eBjOcO7XAaBW/cZspxkAVgF5k2gjFlQ427Qw9xM3iAI41b6zCiBoueXV33vHgwodnyYAuRWQMqXW6V4m90/2vVVfBcxbVQWQv8o9UjTpm/ouBvtVFMBzG3/pAnAvEk0DIM4CgIY9p3AKygBs2C4AZdRPiJ4XCQB2VrLmlMDgqfkWAGjeeVolfQKAhtE89UPbMZ8pGYyhUQAvbnkmD4C3IVofgNYaTsggbTL2oRZ5NwstaKDyRqKI+vmmXxeDBgBYBURXAz4Ar/u1EMCJ2CZPeQDRW+qccAigzLjpttnTWgF5AMSgzdXdVf90RL1t1vuAi/DGUQAoburnrbPqEt1iz8Q5jVjQ4GI4DuD0yV1YAQn13cR3GRQCwHzQPVS/SekTAH614vGmKuBMAjhR+0kieQBMa+/NzeGfhAAwH3TPrqOKbW02I72iAQDWhEMAxJkGYJE2dy/3sz+JuVAeAPl+q9Q/fQDORAUcO+Z/mosBcDG4plQjfRKAGLgAOED6I5XrqBpTv7MidPMAwvPypwUA+irc40QkCqLmIPhcnbxW7AHQRZzeUquuTO+shH3rqm8Afn3zPQDQVRHFFRBbDDdoQeWFLkPCDuIfsOOyyZ+FogBa0nVd3Q1Mx9gZBqCUBXUdSABopAJqROxrDYkw02uOnQg7hwdAV9HaZTwlPccOdNxZK72pXxeAIQvKH0NzKyCyGuhrjdylMASf6eIx8BfDlctSAEDu22XM5XO/07EdHezJAbCnFkDagjwAxsDD0OaKHq6PzoD0EQbJT7fwXCgEoMuY61W/01E5L/07b5udB8DeJOMCcM9K5hVB2wlnfeR+oKi94DMgfQ2DhPq1rVgYPABHBq8jL9NmQ/VDBp35AFwLahyA2wBD9Q1Af19NNK913p2k0z9sAwag7xfPCAANIC19YeSpLwDbrp9kAJZPvz8KwC4NKlwMt7nSRwH0F4Una/N4GgDA8FMFULmQf48zR+4pyvdS5lMOgF2fGwUQHYTazHZrBkEn/fNE9wC0pCzqZZAA4EpfmPths91jotcCcNcBZStg8CL1fAA5r7Mw910YoYh9fd09/W/u7/vD1tc2bHh12Zrdjz7+u58QC397L8f88A/dv+UGCY8qwyAEoHfzlnSbuOlL90B9q4Btt94XAnCvUHcBWA+IulBb8+p7ACQ6iiM0geIcK9BdGPj5jE3jxm+8+pbV3+IAGIdPvpbHQA9UF4C61PeLYFBrV/cIgB89ZAA2r1/nAbAmHFpQMYAyupPdhKcX0iOldJfi0vonW34weeMo5L5h3QUozgHBT5BeN+bgttXXcTNqImTQn2QgAEyfzQMYUtw7rgWABdULIDEI1QCIDjxR6aW+MTDpTXeZDKVAaivkRfxKJK5d9QWCA74VCb6Ch4ODR1/tz2k/7sdctBKAJ3pO+pevAHu/fLgbYQB6e7p9ACUT32VAoKxsXeqTxf3/fZRAdI7lRYhLypPmBCpbAMBgcINqNawczl8B1Ut/ATAGHoDuFb+yd9PV2Heh9EnFQwDpCnDfJBPdj4tXQL3SW1jik9omPUjggaBS+T9XXCvpo8Fvuc33ln1DBcFf4UUwgMShw/t7e7urzSb4nBEPwJFl61C/CiBP6LqKIB/Ab25/IAHAvTYrAaBaAXnFXgiADJXufAWDSQ8P6Z7pu3K4F8itGPP4VYoMT+VXHAsDB6hP/HDFTbyY7En3RD7pgogCYBmcm+ZelND9ldqQBdUHIMeCqldFNJb7qK+RBvURXYbDt99f8s1M+kB3D4Ck58Yofv3irxN8q99ywLf8HACUwi2Lr3ypY2vuZ+D1HEkBKGEpiXgliFZVgOtCjQBwcx/dlfiIhWqWzuXVH7bw/O888kWCA2HgKxS5wf1LxhIwYIETMlARsBbLAMxb5QNotfrNAPB2I8oCQOsoAPk+AKQ+1o9xS7u09HnqX/nQ5y97+LOXzvsX4ob/uli35+vwBRdOmD8cI+Irryr8IDa50Dsd+2i/3Y+uagmAqPSv1E5BdQMYvEg9ZFBQAaH6yC311XL5qi6aUD+d+1KfuPjhz1w49xyCn2f3ufhKYtjcr1IEAOArr0qfEm8fzqwicAFUP1KjUQBp9aMVsGHpY2V6gLsbkaqAvGlHQcrbRoKpj6AlpUfQvNw39f9tzqcIfl4FsPD8UfP/A/WvnvrVVU/9VJ/Ubx9NO9DdJQBIb5cANQwgLX16Coq+UzXsAU0BUOPVfo58H+dJ+L6y2KYdpCfUcvPUNwDERXM+gwURHIydcbkY7N61o/rfEiovCQD6v0c+gNZ13c5yPSAE4J6Xd/fjPAZt5dOffKfTUgEyHw6QMk99rPzSH59LIB9Cw0nqI30Z9RVfm30Ot8x6w4/PxYVuuO+y+cvv5eWRXEcPvNZ36AAvAwDHd3aguxpACwHYDzuTAGRB6QpIFEFbXqcN01/7NgTqY0HKfYWnPr8ic9Fr0vybEY5jubwNPPo2dJ4w4MfNvjztEyoCSOx8frM+FEkMBODgPYuaBJDnPPr25XEzCa8JL7jg2kIAOimWAlBGfUt/27k084kG2Y36PMbfPvjw3ZPHNu94Egy0U9R0pU/kvlcH58/4FNLDABcCAy+ya1+nABAAYPXbDICo3YcAiF1jsvMBqN+yCvAAsPSPAtC+Jrprq5ks1iLWC3k9APAKfW68/ivHocP7+QlU+BUYiDLSW5x3/ye/ePc/AACQF938OV4qL9IYNA8gzUbS66A8APfCiCgAMWhzFefYwjUfUl4blhSBeq8WTaH6BO5PsjOxGAAx6O3pphQwJX5LBSBrSfWJ82d9EhdSEQBg49oV+rx+MQAAoqsNtBCAl/uKl26ZLgDbf/Bg2oJqmnACQFR9l4G2mhGdoA44Jv013ni7Okp/AGD6GYBT73v/Je7t472UAr9isuRO6gVw/h2fHjXxIrUW/dMKABBvPrutSQBK8FD6KIAdN2XnIwFQWAF1AMhT39ovuT9+49UaQ+U/XmjGJygOAXD/7euJylu69fZjFlAvdWzFytWcSzLAhT5/18dHjP5niuCKcefZf24RAAb/VlVANPF9AJUKmHXJNXkAvMtDPQAug1wAbgVop/6GdRegPsfaO5PoCklP7mf+w6BZsaBq4vf3HO8d2ky2N+AzF2/cuNqac8k2IACfHfkxpm8VAa8WAEhTFwBXU8/uE+oTqP/7ibMNwJKZ0xMA7NKgAgA1uvdUv1oFHDz66i2rv0UFAEA7bmht6rvSy3xosy4ARO9/q9s9BSEMWVn0HSWFrTlHnccDgAvBgDbw2LzZ9qn9AGA6NADZFSj1mE/50CLg5R89xBjaPn7aosuuLwPAFsPebsSQBQ3pHqsABlCdNbx21ReY8fGfbM85Jr3G/GzedwBIfXtIBT882feW3pvGE1Jz5h7UnEMAHBAugJl33aEPDOY1A4DhpCQAS3klezrfQwAsAl6buxz119w48bERow2AtxfkXh4a7ka4k2jbUOL3+Bhs71MdGACYD/rmSU8WE4joAuBh3Ae20I6mru2hJuhaas7qCpJeulsYAHrA6Ckj9N9zeM1vLF2PNZcBYFLWpbu7CADA/kd+tu3W+x6/+uYlYyaUB+BuiMYAxBjY1r+uaQCAEjyU3t3UxNANADkeVd99HvQGvQOAJ4odsdQKpQ8BMA49vWolLsTLLgmgAcW99NcMCgAsiBkUAE/Mmv3z5YsTAMyCvO0gYxAAqLUjF8A3lvwjIQCS3k18rW+1yCoPwJ4HFcqT5pUAgHvA8aMAkF4AvjvyX/Vf7LKPqpq3lN5oAPYEW6GynZYA4IEAQAe++yv/vmbcZACsfyJyPiBqQUUAessCiEpv6n9t9jkGgOkzIb09IZ4fz5inTjYhLnTzANCuAYD6xJpli3Rh/vYZC9wK0BuJ3AmneekVqL/7rgfhLQALJ965du5c78q4+F5Q7SmBmikoAqDXB3Db6usMAELnSS/1Uao8AJ4WacKTpp1ufXoDzo7E/DkAQgbajQDAxd/6p2u/+RX7/y3tU+e8ODYDoDcnNa+46e6qrwZAB95y54xNY6YwAj08ZbILwK4N9RZi6bVYm6d4GDRhXc4mABc/PLSh5klPyL6HAPQdHdI6CJ4Zz1XSr/yvBSQ16f/DFTdJ/SEAM7L71BjKsGQA7DM7kQPfJ/175q89eM+ilgAw3S0oMgDQb0j/bAAdM2H+9Kk87byLc2sAdB0o0QO8TiBEvdkYyuyPCxmAqPSmfgGASiWSDjw/ni75i4hTxo3+0tc/QfqzLkPuIQBIX4nqt3d8mhsAgBvf+O1LmUD04ttHTnp98gLSX/9pqrEJpwwANYCd0xY8cN4V+A9PAAAb164IP67G3Q1NnJOJWZDF4C1YiDF9sg4QgAvnnpOQ3gPAhDOkeyW4P3kOjkniL3tkLulM4pPal/74XHL8y9M+Yeq7DPg5/dkA3P7dYRoB29etXn/5KJ0P0P98b7n6mI+WYJ3TFqI+FkQDwH8EwPvELA/A0BSUsxQoBkAb0EqYMVQACJNepu9FDQBHevMcfIPhgZRHfaTH98luqe8CcCuA37JKUA8490t/N+l71VUosf6q0QA4UvmkDn2yZKGyZvF5ju+qT2gAxX8YQFkBsAQTAAYBMiAPgPsWgRIAemIAKn1Yp2IwIncHPzqquwAGTvSx1JL0BE+IIQGv5BnPmXo3WWyJ//m7Pm7qGwAvuI0agCrgnltuqlbA1Dmbho2j9+ojavZWPlGmUFbDkPAcU5/QFhAAqIAJn/sqDQAAj83LFgHt69dWp2HnI8v8ChgEELaBIgAVBpt3PKnrO3UOK62+B8D1HLySlKHZKvGxFHQvlJ7QXrQAoD5/ft/YWwVgzY0TtQj443O7cCG3A+eJnvg2qr7mH6nfPn4a/jNrwu10YAGIbgTZxbmeBUUAHOuJqR+0ARZfFAHDTyEA9BIA1rca8DXn0DBrmu0dny6T+AqtAPAf4OE/dGADgP9gO0j/p47XMSI38RPiFgJw1Qcw0b3iV/r3haT/nMl3AYAGhpG6bxDzTofVTEGpCoiq7wDgz3SpLN04YkEzfB4sEe5fMpanQlWSHcwJJAuD40U3f05Xl3jSp9UXAP5w1MSLyH0AjL3mOwLAGK4GgP9QAdaB65U7T3pLf8Z/0p/Q+ss6MC9NDSBcBJQBQCQBOBhwoVsWXwkDXKgGQKA+QWdG5fnL78VwSBNrtqQw0hMldTf/oaQ0/6A+d8UIJABrxk2mNyr96cB6J3sLpVf7Jf3Vfkl/tV/5T6ID5wLoCgGUUJ84dHg/6hPDFp5f2IShMnzBhTCgzZL1uIeuKHETv4z05v78OfCU/vgPIxAuDIDFF414+UcPdT+66q89A6zCbAUQdtqG1Qdwlv6VMzC0X6W//CdsAI0AiPeAXp8BtWNFwAog3YQJbsPUKGaQcN0mVN9bcHnDD9Jr+lT7Jf0ZgWbedQdaPHHZjfgP5vOXQ71qAN6UWah7Qn1dAPHi2Om2+p05chTuD4BHZ91vS7BwG85GoMgUFLhQW43o0TXBIAArApSNbpaF3VgpXOj1Uell/dQN3mXur/QHQKbCJSMwBxbAqA8DBtCScpdUn/TfMnyi1LfdNwHw/CevAUQrwAfApFgAoMKAPwPmSx1blde0WalZgCFH2aHdhZwb2AlIt/fSxmm/AKABoAX+o/Q3/2mV9NZ7Wfp2zFuK/zxw3hUafsx/vF3oaANQ+hcAOFaofm0R6LyVjKiAQeAq7uI2jFB95b5GT+3/4D8AwH9YjmIOrH5R/897DzP/tFZ9Mx+dfQQA6nv+Q/p7A2gaQLQNDPaA3pj7x4qAB8jeT7r4Ss3ytnsTGYeScpvoIQBTX4M/0st8SH81gIe+P2b95aNYfGX/vPbke8w/nfnzTwMAcDYAMPhvuXOGeq/SX+O/uwB25x+3AfgAKuq7RaBwLCgv92sBQJIigLbUcddThQleJv1hab7vqi/zEQD8h977+4mzTyx/Sv+6U+N/81nvWr92PVl5kfvwZuhKt1/Xf7wOXACgYAyNFQGPQTPQcAIDd76sOlJRskedh3vgfnT9oZzHtX4F/sMkvmnYOGZ/pFf6l3T/kurL+rXywuhmXXKN1Lf2q/R322/oPzUzaGUvugZAV70AemuKgHvnwXh4lNKFggyan731722RFXV894fugRKfP+QeWDTorK9yX75v6pP+JCPpj0D0XtK/Lvcvqf7uux7UWV9ZvyYf8x8v/evynyYA1LoQd83D8JDUgdS3LWVEFIkyCy4ZjqTPYuTHAKDEN/UR3QAwhqM+Ah1fuhH1ab804cL0125+SfW15cmid9OYKVg/vE39OVPv9tw/TP9SDaA26gMgBtwpD8CDicH85ffqOhHBMAxWE+LhxpDoFd1NejN9GEh9A0DvZSnEZKJ1L+Zz8smtZWb/ks5D1wUtjRf16b1S38yHwP01+yfS320AUt8dgaIMGgHAPXK/cOYhefjdu3Zs3Lh69JQRiMhXVQPHUrYaJrcjuie9HB8AMn1TX43X5k5sB/U/eKGzTO8tk/7qutmWxuDYw5rLU1/mo9k/mv55A2gKQH0W5FxYKAY8DLR5YDEgKUgQYdCVa7rEQdtBYUh013AkvWv66rpoIfXJfanPV3fjoZncZ+JEfUTH9xn5dbpR6rPaCM1Hs7+7+PLSvy4ARA6A2Dxql3haJ+CReFRjoNO8hgFlBQN3Eg8L052Ut6y3SV+B9FJfvu+qzwiUtv6SiY/toL72mTF9nWwx9RWu+Zj6Mp9o+kcaQDgCOZNoHRaUrRhqi4CH4fGMAc+JwuQp4kgULImD1qgvGG6ma1tNV5e4WW+64/jYDhPn+qtGYw5Hlq3Lhs7K2FNG/ULpSXxabvZel8pJLtRn5jHf99S3ySc0n+L0zwMQqYDw+rgAgFsE1o19Brt28CxJFjCQOBQvJKaMG42sSIzWbkR1z7aax0z42YjxCITVMOmr67LuZfX7cuUiWRO6XgCW+NtuvU+zJraDxaXVd63fVl6F6W9XBOWNofkAciogLIKQQXaZ264dKgVhwD2XPTKXFwOJ6bePB4Yb/ETBizfp20dOyhJ/3qps3j/5nrqu6/uupiUTny4ix2cdl13gP3ISD6T32uE8Am/qE27jLTSfVPqHALoTFpQE4BqRLrfSxSZiwFPhCfHMeH48S54rz9gw8Ep0goya8GLJzOnZdU5jJpCJqIPjsxDt+8UzWeJXNhtOLH/KJs7Q3wvVN+kxNOmO6NgOgfTTv31dQn238brqu+YT7j+H/hNN/wBA0elJe6+LWwTGgCxQHfDkzI4oBSpXGHgxkKCiCV7b2rlz26fOwQSU8mjUOXEOaY7cWb899b4S32wnqng6/WX3ynqCB1pwwbVgxvdhgO2wuFPxhc6Tl/vu5KOVl6V/RP38DqwMrmcdMOhCBqBaBBUj8rxIDFQKGpDAQOCkvCRi+4wFiI4ds/jsnLYw+7CHZetOte/UqEPuYz5MPrbN6amfznrpLq8n65mjFl80wqSX48+65BoSP6F+NPdD6zf1fQB57l9gQeUAiIFd9+nWgRh4diQMvAwKgtfz4pZnMF98hrUPotNd5TZ81X9kpg5KDvKuz0R1f+C8K/AZSa9RR4kv9V0AmvfxSXXd8uoXu78uTw8B1LUStv++57pQGQYqBSOha3KZPQCA3RP0W4qAr1SDRLTg23ToZtknGVb+0bWb70hPaM7hKxgISe+pr8TXeXZ35gnVD7d9TP286TO3B9S1FeH++8M4g8GrP42BWoKLwYJJTqP3S7MWMgtyjGpuoKNC3k1Gh6Ff6Wae6ARrCK1sF112vbJezdakJyS9zrFUbaey1ROqnzf1m/m46of+k7cTlwvAkzuU3sJtBgkGwiASgoEX0YFRh2YoEkSWquMmYxdI6QbihmG/leLcD6Lz59wt98OxdHez3k/8qXcr8T3bSavvNt54+kv95B6cXbDc5sqdkL7P+R+gIYBoHXgYQhK8PHKNWShbAV1yTXbN5SXXZJk7dU72NiC69NQ5mXGPmwwhvS3UQj/J5B4/jZvpxvyEezDdLeXzpJfju4kv09dqy9T3xp7Q+uPpn7cAdgF0HWhzsz6d+x6MKAOrA7cUohi0m82LJN14/aThw1MmZ0Pht6+TgiZi9nbcMRMyDIORvT90zAR+Lmze7bkTrai9tZ51WkmP55jje4lv6oe+X6y+23693huon1XA0PKqyHwSjhRhMPgkeE7atnMxmDWJAS+b7AODti4goVUxIgoJoRbqhn7ODaS4F6HueL0Mx5PeHJ/EZ1TzEj+qvvl+rvqDkec8kog/b3P9pyQD94NfPQBDDBwMbikIg5EwLyLvtIMEBtSxNTOqoR0KSk2ZibeZEe5q2FBvuivlzXBMevOcvMSPLriiAJRtxepXfm5bmW2uuZe3IGOQ2wy6awrNHMmqIdzFc7cuECh771Fl9wLhBAMdUVNIsnBOFlroBtxSoivfpTspz9oKrzfD0eaaK31Ufdd2cseewRdYqL4iO617ONu8aesd/BzUuqSPhlcHXktwm7ORcOvA3UGyNXNGYv1abV2oMhBUSKKh33IzblwVfd1q7sFNeTOcQundrR4v90P1C3PfGCj35cD/A6Mg83D/tOv5AAAAAElFTkSuQmCC';
const TOGGLE_CSS = `
#javdb-emby-toggle {
position:fixed; left:26px; bottom:26px; z-index:2147483647;
width:56px; height:56px; border-radius:50%; box-sizing:border-box;
background:#ece6e3; color:#fff; border:2px solid rgba(255,255,255,.35);
display:flex; align-items:center; justify-content:center;
font-family:"Segoe UI",Roboto,sans-serif; font-size:22px; font-weight:700;
cursor:pointer; box-shadow:0 4px 18px rgba(0,0,0,.45); user-select:none;
transition:transform .25s cubic-bezier(.22,1,.36,1), background .35s ease, box-shadow .25s ease,
border-color .3s ease, opacity .3s ease;
padding:0; overflow:visible;
}
#javdb-emby-toggle:hover { transform:scale(1.08); box-shadow:0 6px 24px rgba(0,0,0,.65); }
#javdb-emby-toggle .glyph {
width:100%; height:100%; display:flex; align-items:center; justify-content:center;
border-radius:50%; overflow:hidden; line-height:1;
}
#javdb-emby-toggle .javdb-toggle-logo {
width:100%; height:100%; max-width:100%; max-height:100%;
object-fit:cover; display:block; border-radius:50%; pointer-events:none;
}
/* 开启态:保持 EMBY 现有样式完全不变(J 字母按钮,Emby 强调色底 + 白字) */
#javdb-emby-toggle.on {
background:var(--e-accent,#00a4dc); border-color:transparent;
font-style:italic; letter-spacing:-1px;
}
#javdb-emby-toggle .tip {
position:absolute; left:64px; white-space:nowrap; background:rgba(0,0,0,.85);
color:#fff; padding:5px 10px; border-radius:6px; font-size:12px;
opacity:0; pointer-events:none; transition:opacity .25s;
box-shadow:0 2px 8px rgba(0,0,0,.3);
}
#javdb-emby-toggle:hover .tip { opacity:1; }
`;
// openToggleContextMenu 已按要求移除(保留左下角切换球与版本提示)
function injectToggle() {
if (document.getElementById('javdb-emby-toggle')) return;
const s = document.createElement('style');
s.id = 'javdb-emby-toggle-css';
s.textContent = TOGGLE_CSS;
document.head.appendChild(s);
const btn = document.createElement('div');
btn.id = 'javdb-emby-toggle';
btn.innerHTML = '<span class="glyph"></span><span class="tip"></span>';
btn.addEventListener('click', function () { setEnabled(!enabled); });
btn.addEventListener('contextmenu', function (e) {
e.preventDefault();
e.stopPropagation();
if (!enabled) {
setEnabled(true);
}
const styles = ['emby', 'glass', 'liquid'];
const curStyle = localStorage.getItem(SET_KEYS.style) || 'emby';
let idx = styles.indexOf(curStyle);
if (idx === -1) idx = 0;
const nextStyle = styles[(idx + 1) % styles.length];
localStorage.setItem(SET_KEYS.style, nextStyle);
applyStyleTheme(nextStyle);
syncSettingsControls();
updateToggle();
const styleNames = { emby: 'EMBY经典', glass: '毛玻璃', liquid: '液态玻璃' };
showToast('视觉风格已切换为:' + (styleNames[nextStyle] || nextStyle));
});
document.body.appendChild(btn);
updateToggle();
}
function updateToggle() {
const btn = document.getElementById('javdb-emby-toggle');
if (!btn) return;
btn.classList.toggle('on', enabled);
const glyph = btn.querySelector('.glyph');
const tip = btn.querySelector('.tip');
if (glyph) {
if (enabled) {
glyph.textContent = 'J';
} else {
glyph.innerHTML = '<img class="javdb-toggle-logo" src="' + OFFICIAL_LOGO_BASE64 + '" alt="JavdbEmbySkin" />';
}
}
const curStyle = localStorage.getItem(SET_KEYS.style) || 'emby';
const styleNames = { emby: 'EMBY经典', glass: '毛玻璃', liquid: '液态玻璃' };
const styleLabel = styleNames[curStyle] || curStyle;
if (tip) tip.textContent = enabled
? 'Emby 皮肤:开(左键开关/右键切风格:' + styleLabel + ')· v' + (typeof VERSION !== 'undefined' ? VERSION : '?')
: 'Emby 皮肤:关(左键开启)· v' + (typeof VERSION !== 'undefined' ? VERSION : '?');
}
/* =======================================================================
* 还原:移除所有注入的 Emby 结构,恢复 javdb 原样
* ===================================================================== */
function removeEmbyDOM() {
try { stopDetailFilterBtnWatcher(); } catch (e) {}
// 先把移动走的「原版预览节点」还原回原位(否则重建时 root.querySelector 找不到它)
if (typeof restorePreviewNode === 'function') {
try { restorePreviewNode(); } catch (e) {}
}
// 关闭皮肤:卸载各模式滚动监听与液态玻璃折射层(避免 window 级监听空转)
try { detachFusionScroll(); } catch (e) {}
try { detachFullCoverBar(); } catch (e) {}
try { dfPopupHide(); } catch (e) {}
try { if (typeof LiquidGlass !== 'undefined') LiquidGlass.clear(); } catch (e) {}
if (window.__embyScrollHandler) {
window.removeEventListener('scroll', window.__embyScrollHandler);
window.__embyScrollHandler = null;
}
if (reviewRefreshObserver) {
try { reviewRefreshObserver.disconnect(); } catch (e) {}
reviewRefreshObserver = null;
}
// 守卫释放:断开原生浮层与 Fancybox 守卫的轮询与 Observer,防止关闭皮肤后后台空转
try { teardownFancyboxGuard(); } catch (e) {}
try { teardownNativeOverlayGuard(); } catch (e) {}
// 卸载瀑布流引擎并还原 inline 定位(避免下次开启时残留 transform/height 影响 grid 布局)
try { WaterfallEngine.unregisterAll(); WaterfallEngine.clearInline(); } catch (e) {}
// 清理全部注入元素(旧版漏掉的 emby-backtop/gallery-panel/弹层等会在关闭皮肤后残留可见、
// 且再次开启时因重复 id 叠加出两个回顶按钮)
['emby-topbar','emby-drawer','emby-drawer-scrim',
'emby-detail-hero','emby-detail-bg','emby-detail-scrim',
'emby-detail-root','emby-play-btn','emby-compact-bar','emby-backtop',
'emby-search-modal','emby-settings-panel',
'emby-favorites-panel','emby-gallery-panel','emby-top250-panel',
'emby-df-preview','emby-df-scrim',
'efav-gallery-lb','efav-style-morph','efav-filter-mask','efav-collect-mask','efav-backup-mask','emby-note-tip',
'jhs-settings-mask','emby-quick-settings-popover','jhs-wd-list-modal','jhs-infinite-loader','plugin-diag-modal','top250-login-modal',
'jdbe-preview-modal'].forEach(function (id) {
const el = document.getElementById(id);
if (el) el.remove();
});
// 清理快捷标记与预览相关注入节点(确保关闭皮肤后绝无残留溢出影响原生页面)
document.querySelectorAll('.cover-status-buttons, .cover-modal-base, .jdbe-modal-v2, #jdbe-preview-modal').forEach(function (el) {
el.remove();
});
// 清理过渡遮罩
const overlay = document.querySelector('.emby-morph-overlay');
if (overlay) overlay.remove();
// 清理首页标签栏
const tabsBar = document.querySelector('.emby-home-tabs');
if (tabsBar) tabsBar.remove();
homeTabsBuilt = false;
homeTabsBar = null;
try { FAVUI.unmount(); } catch (e) {}
// 收藏夹/画廊/TOP250模块级引用清空(下次开启按需重建,避免悬挂游离节点)
favPanel = null; galleryPanel = null; top250Panel = null;
// 重置 movie-list 的 data 标记与布局类(恢复 JAVDB 原生排版)
document.querySelectorAll('.movie-list').forEach(function (ml) {
delete ml.dataset.homeTabsBuilt;
ml.style.display = '';
ml.classList.remove('emby-masonry', 'emby-dynamic-focus');
});
// 清理收藏夹全屏视图态与 <html> 上所有皮肤状态类(旧版遗留的类在下次开启时可能产生脏状态)
document.documentElement.classList.remove('emby-fav-view', 'efav-tabs-hide', 'emby-drawer-open',
'emby-native-overlay-open', 'emby-fancybox-open',
'emby-style-glass', 'emby-style-liquid',
'cover-full', 'cover-fusion', 'cover-fusion-collapsed', 'cover-hybrid',
'emby-masonry-large', 'emby-dynamic-focus');
document.querySelectorAll('.emby-card-play').forEach(function (el) { el.remove(); });
document.querySelectorAll('.emby-hide-original, .emby-hide-soft').forEach(function (el) {
el.classList.remove('emby-hide-original');
el.classList.remove('emby-hide-soft');
});
// 恢复被隐藏的 javdb 主页公告栏(关闭皮肤时)
document.querySelectorAll('.emby-hide-announce').forEach(function (el) {
el.classList.remove('emby-hide-announce');
});
// 恢复原生下载按钮
document.querySelectorAll('a.button.is-info.is-outlined[href="#magnet-links"], a.material-scroll-magnets[href="#magnet-links"]')
.forEach(function (el) { el.style.display = ''; });
detailBuilt = false;
}
/* =======================================================================
* 确保收藏夹面板 + 首页/收藏夹标签栏存在(解耦自 buildChrome 的 guard)
* javdb SPA 重入时 buildChrome 可能因 emby-topbar 已存在而 early return,
* 导致 favPanel/tabsBar 未创建;跳转收藏夹前调用本函数兜底补建。
* ===================================================================== */
function ensureFavSurface() {
// 标签栏
let tabsBar = document.querySelector('.emby-home-tabs');
if (!tabsBar) {
tabsBar = document.createElement('div');
tabsBar.className = 'emby-home-tabs';
// 视图 Tab 动态生成(与 buildHomeTabs / LOGO 悬停导航共用 HOME_TAB_DEFS)
HOME_TAB_DEFS.forEach(function (def) {
const t = document.createElement('a');
t.className = 'emby-home-tab';
t.href = '#' + def.key;
t.dataset.tab = def.key;
t.innerHTML = '<span class="material-symbols-outlined" style="font-size:1em;margin-right:.3em;vertical-align:-2px;">' + def.icon + '</span>' + def.label;
t.addEventListener('click', function (e) { e.preventDefault(); switchTab(def.key); });
tabsBar.appendChild(t);
if (def.key === 'home') tabHome = t;
else if (def.key === 'favorites') tabFav = t;
else if (def.key === 'gallery') tabGallery = t;
else if (def.key === 'top250') tabTop250 = t;
});
const ml = document.querySelector('.movie-list');
if (ml && ml.parentNode) {
const topAnchor = ml.parentNode.querySelector('.tabs, .main-tabs, .toolbar') || ml.parentNode.firstElementChild || ml;
if (topAnchor && topAnchor !== tabsBar) {
ml.parentNode.insertBefore(tabsBar, topAnchor);
} else {
ml.parentNode.insertBefore(tabsBar, ml);
}
} else {
(document.querySelector('main') || document.body).insertBefore(tabsBar, (document.querySelector('main') || document.body).firstChild);
}
} else {
tabHome = tabsBar.querySelector('[data-tab="home"]');
tabFav = tabsBar.querySelector('[data-tab="favorites"]');
tabGallery = tabsBar.querySelector('[data-tab="gallery"]');
tabTop250 = tabsBar.querySelector('[data-tab="top250"]');
}
// 面板
favPanel = document.getElementById('emby-favorites-panel');
if (!favPanel) {
favPanel = document.createElement('div');
favPanel.id = 'emby-favorites-panel';
const ml = document.querySelector('.movie-list');
if (ml && ml.parentNode) { ml.parentNode.insertBefore(favPanel, ml); }
else { (document.querySelector('main') || document.body).insertBefore(favPanel, (document.querySelector('main') || document.body).firstChild); }
}
galleryPanel = document.getElementById('emby-gallery-panel');
if (!galleryPanel) {
galleryPanel = document.createElement('div');
galleryPanel.id = 'emby-gallery-panel';
const ml = document.querySelector('.movie-list');
if (ml && ml.parentNode) { ml.parentNode.insertBefore(galleryPanel, ml); }
else { (document.querySelector('main') || document.body).insertBefore(galleryPanel, (document.querySelector('main') || document.body).firstChild); }
}
bindGalleryPanelScroll(galleryPanel);
top250Panel = document.getElementById('emby-top250-panel');
if (!top250Panel) {
top250Panel = document.createElement('div');
top250Panel.id = 'emby-top250-panel';
const ml = document.querySelector('.movie-list');
if (ml && ml.parentNode) { ml.parentNode.insertBefore(top250Panel, ml); }
else { (document.querySelector('main') || document.body).insertBefore(top250Panel, (document.querySelector('main') || document.body).firstChild); }
}
bindTop250PanelScroll(top250Panel);
}
/* =======================================================================
* 首页 / 收藏夹 切换(顶层函数,供 buildChrome 与 FAVUI.filterByDetail 跨闭包调用)
* 依赖顶层变量 tabHome / tabFav / favPanel(由 buildChrome 或 ensureFavSurface 赋值)。
* ===================================================================== */
function switchTab(tab) {
// 去收藏夹/画廊/TOP250前确保面板/标签栏存在(应对 buildChrome 因 SPA 重入 guard 未创建的情况)
if (tab === 'favorites') ensureFavSurface();
if (tab === 'gallery') ensureFavSurface();
if (tab === 'top250') ensureFavSurface();
// active 高亮统一处理:所有 .emby-home-tab[data-tab] 按钮(首页标签栏 + 顶栏 .emby-topnav 常驻按钮)
try {
document.querySelectorAll('.emby-home-tab[data-tab]').forEach(function (t) {
t.classList.toggle('active', t.dataset.tab === tab);
});
} catch (e) {}
// 兼容旧引用(部分代码直接读 tabHome/tabFav/tabGallery/tabTop250 的状态)
if (tabHome) tabHome.classList.toggle('active', tab === 'home');
if (tabFav) tabFav.classList.toggle('active', tab === 'favorites');
if (tabGallery) tabGallery.classList.toggle('active', tab === 'gallery');
if (tabTop250) tabTop250.classList.toggle('active', tab === 'top250');
// 防御:buildChrome 可能因 SPA 重入 guard 未给顶层面板赋值,这里兜底重新获取
if (!favPanel) favPanel = document.getElementById('emby-favorites-panel');
if (!galleryPanel) galleryPanel = document.getElementById('emby-gallery-panel');
if (!top250Panel) top250Panel = document.getElementById('emby-top250-panel');
if (tab !== 'top250') {
try { Top250ViewManager.abortSync(); } catch (e) {}
}
if (tab === 'favorites') {
// 全新干净全屏视图:隐藏 javdb 原生主页全部内容/按钮,仅留 emby 顶栏与抽屉
document.documentElement.classList.add('emby-fav-view');
if (favPanel) favPanel.classList.add('show');
if (galleryPanel) galleryPanel.classList.remove('show');
if (top250Panel) top250Panel.classList.remove('show');
// 挂载收藏夹功能 UI(仅首次或被清理后)
if (favPanel && !favPanel.dataset.favMounted) {
favPanel.dataset.favMounted = '1';
try { FAVUI.mount(favPanel); }
catch (err) { log('收藏夹挂载失败: ' + err.message); }
}
// 面板显示后容器宽度才可用:瀑布流引擎重排一次(含收藏夹/画廊,避免显示前宽度为 0 导致排布错误)
try { if (document.documentElement.classList.contains('emby-masonry-large')) WaterfallEngine.refresh(); } catch (e) {}
} else if (tab === 'gallery') {
document.documentElement.classList.add('emby-fav-view');
if (favPanel) favPanel.classList.remove('show');
if (galleryPanel) galleryPanel.classList.add('show');
if (top250Panel) top250Panel.classList.remove('show');
if (galleryPanel) {
if (!galleryPanel.dataset.galleryMounted) {
galleryPanel.dataset.galleryMounted = '1';
try { FAVUI.renderGallery(galleryPanel); }
catch (err) { log('画廊渲染失败: ' + err.message); }
} else {
try { FAVUI.applyGalleryRandomBg(galleryPanel); } catch (e) {}
}
}
try { if (document.documentElement.classList.contains('emby-masonry-large')) WaterfallEngine.refresh(); } catch (e) {}
// 画廊进入时立即按面板滚动位置更新回顶按钮可见性
if (window.__embyUpdateBackTop) window.__embyUpdateBackTop(galleryPanel ? (galleryPanel.scrollTop || 0) : 0);
} else if (tab === 'top250') {
document.documentElement.classList.add('emby-fav-view');
if (favPanel) favPanel.classList.remove('show');
if (galleryPanel) galleryPanel.classList.remove('show');
if (top250Panel) {
top250Panel.classList.add('show');
if (!top250Panel.dataset.top250Mounted) {
top250Panel.dataset.top250Mounted = '1';
try { Top250ViewManager.mount(top250Panel); }
catch (err) { log('TOP250挂载失败: ' + err.message); }
} else {
try { Top250ViewManager.applyTop250RandomBg(top250Panel); } catch (e) {}
}
}
try { if (document.documentElement.classList.contains('emby-masonry-large')) WaterfallEngine.refresh(); } catch (e) {}
if (window.__embyUpdateBackTop) window.__embyUpdateBackTop(top250Panel ? (top250Panel.scrollTop || 0) : 0);
} else {
document.documentElement.classList.remove('emby-fav-view');
document.documentElement.classList.remove('efav-tabs-hide');
if (favPanel) favPanel.classList.remove('show');
if (galleryPanel) galleryPanel.classList.remove('show');
if (top250Panel) top250Panel.classList.remove('show');
// 切回首页:原生 .movie-list 重新可见,瀑布流引擎按当前宽度重排
try { if (document.documentElement.classList.contains('emby-masonry-large')) WaterfallEngine.refresh(); } catch (e) {}
}
try {
if (tab === 'home') {
if (location.hash && ['#top250', '#favorites', '#gallery'].includes(location.hash)) {
history.replaceState(null, '', location.pathname + location.search);
}
} else {
history.replaceState(null, '', '#' + tab);
}
} catch (e) {}
try { localStorage.setItem(HOME_TAB_KEY, tab); } catch (e) {}
}
/* =======================================================================
* 皮肤启用 / 关闭
* ===================================================================== */
function applySkin(on) {
if (on) {
if (!skinEl) {
skinEl = document.createElement('style');
skinEl.id = 'javdb-emby-skin';
skinEl.textContent = buildEmbyCSS('#00a4dc');
document.head.appendChild(skinEl);
}
document.documentElement.classList.add('emby-skin');
try { buildChrome(); } catch (e) { log('buildChrome 异常: ' + e.message); }
try { applySettings(); } catch (e) { log('applySettings 异常: ' + e.message); }
try { setupFancyboxGuard(); } catch (e) {}
try { setupNativeOverlayGuard(); } catch (e) {}
try { FAV.detectImgHost(); } catch (e) {}
try { hideJavdbAds(); } catch (e) {}
try { restructureCurrent(); } catch (e) { log('restructureCurrent 异常: ' + e.message); }
try { syncQuickStatusPreviewUI(); } catch (e) {}
} else {
document.documentElement.classList.remove('emby-skin');
if (skinEl && skinEl.parentNode) { skinEl.parentNode.removeChild(skinEl); skinEl = null; }
removeEmbyDOM();
if (/^\/rankings\/(top|playback)(\/|\?|$)/.test(location.pathname) || location.pathname.startsWith('/plans') || (location.pathname.startsWith('/advanced_search') && (location.search.includes('handlePlayback') || location.search.includes('handleTop')))) {
try { Top250ViewManager.hookNativePage(); } catch (e) {}
}
}
}
function setEnabled(on, instant) {
enabled = on;
try { localStorage.setItem(STORAGE_KEY, on ? '1' : '0'); } catch (e) {}
// 创建/复用过渡遮罩(仅首次或被清理后重建)
let overlay = document.querySelector('.emby-morph-overlay');
if (!overlay) {
overlay = document.createElement('div');
overlay.className = 'emby-morph-overlay';
document.body.appendChild(overlay);
}
// 无论如何都要让遮罩淡出,避免「applySkin 抛异常 → setTimeout 未注册 →
// emby-morphing 类永久残留 → 全屏黑遮罩盖住一切(完全黑屏)」的死局。
const clearMorph = function () {
document.documentElement.classList.remove('emby-morphing');
};
if (instant) {
applySkin(on);
} else {
document.documentElement.classList.add('emby-morphing');
// 强制 reflow 让初始 opacity:0 生效,遮罩随后淡入
void overlay.offsetHeight;
try {
applySkin(on); // 开启/关闭皮肤;真实 javdb 复杂 DOM 下任何异常都不能卡死遮罩
} catch (err) {
try { console.error('[javdb-emby-skin] applySkin 异常:', err); } catch (e) {}
} finally {
// 主淡出:遮罩保留 480ms 覆盖 DOM 重排 + 渲染帧,随后 450ms 淡出
setTimeout(clearMorph, 480);
// 兜底:若主定时器因故未执行,1.2s 后强制清除,杜绝黑屏残留
setTimeout(clearMorph, 1200);
}
}
updateToggle();
}
/* =======================================================================
* 年龄弹窗自动点击
* ===================================================================== */
function tryAgeGate() {
const els = document.querySelectorAll('a, button');
for (let i = 0; i < els.length; i++) {
const t = els[i].textContent || '';
if (/滿18|over\s*18|18歲|18岁|18\+/i.test(t)) { els[i].click(); return true; }
}
return false;
}
function startAgeGate() {
let tries = 0;
(function tick() { if (tryAgeGate()) return; if (++tries < 24) setTimeout(tick, 400); })();
}
/* =======================================================================
* 路由变化监听(javdb 局部刷新)
* ===================================================================== */
// 皮肤自管理子树:这些容器内部的结构变化由各自模块自行处理(收藏夹/画廊分批渲染、
// 悬停预览气泡、弹窗、顶栏抽屉、卡片高光注入),无需触发 javdb 路由重构——
// 作为全局 MutationObserver 的增量过滤依据,砍掉 javdb 高频 DOM 变化中的最大噪声源。
const SKIN_CHROME_SEL = '#emby-topbar,#emby-drawer,#emby-drawer-scrim,#emby-favorites-panel,' +
'#emby-gallery-panel,#emby-top250-panel,#emby-df-preview,#emby-df-scrim,#emby-search-modal,#emby-settings-panel,#jhs-settings-mask,' +
'#emby-quick-settings-popover,#jhs-wd-list-modal,#emby-compact-bar,#emby-detail-root,#emby-detail-bg,' +
'#emby-detail-scrim,#emby-backtop,#emby-toast,#javdb-emby-toggle,.emby-home-tabs,' +
'.emby-morph-overlay,.efav-modal-mask,.emby-card-shine,.emby-note-tip,#efav-gallery-lb,#jhs-infinite-loader,.jhs-infinite-loader';
// 全部变化都落在皮肤子树内 → true(跳过调度)。任一记录的目标或新增节点在皮肤子树外 → false(相关)。
// 纯移除类记录(无 addedNodes)只看 target:javdb 原生区移除属于相关,面板内移除属于皮肤自管。
function allInsideSkinChrome(muts) {
for (let i = 0; i < muts.length; i++) {
const m = muts[i];
if (m.type === 'attributes' || m.type === 'characterData') continue; // 未观察这两类,防御性跳过
let t = m.target;
if (t && t.nodeType === 3) t = t.parentNode;
const tIn = !!(t && t.nodeType === 1 && t.closest && t.closest(SKIN_CHROME_SEL));
let addedIn = true;
if (m.addedNodes && m.addedNodes.length) {
for (let j = 0; j < m.addedNodes.length; j++) {
const n = m.addedNodes[j];
if (n && n.nodeType === 1 && !n.closest(SKIN_CHROME_SEL)) { addedIn = false; break; }
}
}
if (!tIn && !addedIn) return false;
}
return true;
}
function observeMutations() {
let timer = null;
const mo = new MutationObserver(function (muts) {
// 增量过滤:皮肤自身注入/面板渲染引起的突变直接跳过调度;
// 只在 javdb 原生区域发生结构变化时才进入 500ms 防抖重构。
if (!enabled) return;
ensureImageNoReferrer();
if (allInsideSkinChrome(muts)) return;
if (timer) clearTimeout(timer);
timer = setTimeout(function () {
try { if (isDetailPage()) autoBlockJavdbTranslate(); } catch (e) {}
if (enabled && !document.documentElement.classList.contains('emby-fancybox-open')) {
// 增量更新:详情页只追加懒加载区块(不重建 → 滚动不回弹),网格便宜可重建
// 灯箱打开时跳过:避免 Fancybox 改写 hash/挂载容器引发的突变误触发重建
//
// 整段包 try/catch:这是页面唯一的常驻自愈入口(布局类补回、瀑布流残留清理、
// 详情页增量重建都挂在里面)。任一步抛异常就会中断后续所有自愈,
// 表现为「页面越用越不对、刷新才好」——异常必须就地捕获并记日志。
try {
if (isDetailPage()) {
restructureDetail(false);
try { ReviewListInfiniteManager.init(); } catch (e) {}
}
else if (!document.documentElement.classList.contains('emby-fav-view')) restructureGrid();
// emby-fav-view(收藏夹/画廊全屏视图)下主页网格被隐藏,其懒加载由 FAVUI/画廊自管;
// 若仍执行 restructureGrid 会反复 ensureLayoutClasses/引擎清扫,白耗主线程。
injectDetailFilterBtn();
} catch (e) {
try { console.error('[javdb-emby-skin] 增量重建异常:', e); } catch (e2) {}
}
}
}, 500);
});
mo.observe(document.documentElement, { childList: true, subtree: true });
// 仅比较 pathname+search:Fancybox 打开预览会改写 location.hash(如 #gallery-1-0),
// 若比较完整 href 会把「点开灯箱」误判为「切换影片页」而触发整页重建、导致预览区丢失
function pageKey() { return location.pathname + location.search; }
let lastKey = pageKey();
function checkRouteChange() {
const cur = pageKey();
if (cur !== lastKey) {
lastKey = cur;
announceFallbackDone = false; // 新路由允许公告兜底扫描再跑一次
try { syncNativeOverlayGuard(); } catch (e) {}
try { stopDetailFilterBtnWatcher(); } catch (e) {}
try { if (isDetailPage()) autoBlockJavdbTranslate(); } catch (e) {}
if (enabled) {
detailBuilt = false;
restructureCurrent(true);
if (/^\/(actors|directors|makers|series|video_codes)\//.test(location.pathname)) startDetailFilterBtnWatcher();
// 排行榜全局拦截器(只绑一次):Emby 模式直接切入自洽的 TOP250 全屏面板
if (!window.__rankingClickBound) {
window.__rankingClickBound = true;
document.addEventListener('click', function (e) {
const a = e.target.closest('a[href*="/rankings/playback"], a[href*="/rankings/movies"], a[href*="/rankings/top"], a[href$="/rankings/playback"], a[href$="/rankings/movies"], a[href$="/rankings/top"]');
if (!a) return;
const href = a.getAttribute('href') || '';
const isPb = href.includes('playback');
if (enabled) {
e.preventDefault();
e.stopPropagation();
Top250ViewManager.switchMode(isPb ? 'playback' : 'top');
switchTab('top250');
} else {
// 原生模式下,仅拦截被官方收费限制的热播榜单,TOP250 官方榜单天然放行导航
if (isPb) {
e.preventDefault();
e.stopPropagation();
if (location.pathname.startsWith('/rankings/')) {
try { history.pushState({}, '', href); } catch (e) {}
const nb = document.getElementById('native-t250-box');
if (nb) nb.removeAttribute('data-mounted');
Top250ViewManager.hookNativePage();
} else {
location.href = href;
}
}
}
}, true);
}
// 若当前直接访问排行榜或被 302 到 /plans 或 /advanced_search
const isRankPage = location.pathname.startsWith('/plans') ||
/^\/rankings\/(movies|top|playback)(\/|\?|$)/.test(location.pathname) ||
(location.pathname.startsWith('/advanced_search') && (location.search.includes('handlePlayback') || location.search.includes('handleTop')));
if (isRankPage) {
try {
if (enabled) {
const isPb = location.pathname.includes('playback') || location.search.includes('handlePlayback') || (document.referrer && document.referrer.includes('playback'));
Top250ViewManager.switchMode(isPb ? 'playback' : 'top');
switchTab('top250');
} else {
Top250ViewManager.hookNativePage();
}
} catch (e) {}
}
} else {
if (isDetailPage()) {
try { ReviewListInfiniteManager.init(); } catch (e) {}
try { FAV.passiveCollect(); } catch (e) {}
}
const isRankPage = /^\/rankings\/playback(\/|\?|$)/.test(location.pathname) ||
(location.pathname.startsWith('/plans') && document.referrer && document.referrer.includes('playback')) ||
(location.pathname.startsWith('/advanced_search') && location.search.includes('handlePlayback'));
if (isRankPage) try { Top250ViewManager.hookNativePage(); } catch (e) {}
}
// 进入「我的清单」页(/users/lists):轻量同步清单元数据——清单改名/数量就地更新,
// 无需重跑全量同步(全量同步在作品多时可到小时级,改名只该花 1~3 个索引请求)
if (/^\/users\/lists(\/|\?|$)/.test(location.pathname + location.search)) {
try { FAV.syncListMeta(); } catch (e) { log('轻量清单元数据同步触发失败: ' + e.message); }
}
if (/^\/users\/collection_actors(\/|\?|$)/.test(location.pathname + location.search)) {
try { FavoriteActressManager.harvestFromDoc(); } catch (e) {}
}
if (/^\/actors\/[^\/\?\#]+/.test(location.pathname)) {
try { FavoriteActressManager.autoHarvestActorPage(); } catch (e) {}
}
}
}
// 事件化监听:popstate 与 pushState/replaceState 钩子让 SPA 导航 0ms 立即响应
window.addEventListener('popstate', checkRouteChange);
window.addEventListener('turbolinks:load', checkRouteChange);
window.addEventListener('turbo:load', checkRouteChange);
if (!window.__embyHistoryHooked) {
window.__embyHistoryHooked = true;
try {
const _origPush = history.pushState;
history.pushState = function () {
const ret = _origPush.apply(this, arguments);
setTimeout(checkRouteChange, 0);
return ret;
};
const _origReplace = history.replaceState;
history.replaceState = function () {
const ret = _origReplace.apply(this, arguments);
setTimeout(checkRouteChange, 0);
return ret;
};
} catch (e) {}
}
setInterval(checkRouteChange, 900);
}
/* =======================================================================
* 初始化入口
* ===================================================================== */
function init() {
log('初始化 | 皮肤=' + (enabled ? '开' : '关'));
try { if (isDetailPage()) autoBlockJavdbTranslate(); } catch (e) {}
try { if (isDetailPage()) ReviewListInfiniteManager.init(); } catch (e) {}
try { if (isDetailPage()) FAV.passiveCollect(); } catch (e) {}
try { Top250ViewManager.hookNativePage(); } catch (e) {}
// 设置键迁移:旧版 SET_KEYS 缺 style 键,主题持久化在字面键 "undefined"
//(读写一致故功能可用,但键名脆弱、易与其它误用冲突)。有新键则不动,仅旧键有值且新键未设时迁移。
try {
const legacyStyle = localStorage.getItem('undefined');
if (legacyStyle !== null && !localStorage.getItem(SET_KEYS.style)) {
localStorage.setItem(SET_KEYS.style, legacyStyle);
localStorage.removeItem('undefined');
}
} catch (e) {}
injectToggle();
startAgeGate();
FAV.installListIntercept(); // 安装「存入清单」网络请求拦截 → 即时入库,免同步
if (enabled) {
setEnabled(true, true);
} else {
// 首屏引导提示
setTimeout(function () {
const tip = document.querySelector('#javdb-emby-toggle .tip');
if (tip) { tip.style.opacity = '1'; setTimeout(function () { if (tip) tip.style.opacity = ''; }, 4500); }
}, 700);
}
observeMutations();
// 首载即「我的清单」页:轻量同步清单元数据(清单改名/数量就地更新,后台生效无需全量重跑)
try {
if (/^\/users\/lists(\/|\?|$)/.test(location.pathname + location.search)) FAV.syncListMeta();
if (/^\/users\/collection_actors(\/|\?|$)/.test(location.pathname + location.search)) FavoriteActressManager.harvestFromDoc();
if (/^\/actors\/[^\/\?\#]+/.test(location.pathname)) FavoriteActressManager.autoHarvestActorPage();
} catch (e) { log('首载数据同步失败: ' + e.message); }
try { FavoriteActressManager.autoSync(); } catch (e) {}
try { bindNoteTips(); } catch (e) {}
try { syncQuickStatusPreviewUI(); } catch (e) {}
// 初始页面(演员/导演/片商/系列详情页)用观察者轮询注入「去收藏夹筛选」按钮
// (javdb SPA 抖动时单次注入会被清,轮询确保稳定后最终注入)
if (enabled) startDetailFilterBtnWatcher();
}
// 立即尝试执行一次,避免首屏翻译闪烁
try { if (isDetailPage()) autoBlockJavdbTranslate(); } catch (e) {}
try { if (isDetailPage()) ReviewListInfiniteManager.init(); } catch (e) {}
try { if (isDetailPage()) FAV.passiveCollect(); } catch (e) {}
try { Top250ViewManager.hookNativePage(); } catch (e) {}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();