Sync all projects
This commit is contained in:
@@ -0,0 +1,3524 @@
|
||||
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no,viewport-fit=cover">
|
||||
<title>玩偶聚合 WebHome | Tab S9 功能增强完整版</title>
|
||||
<script>(function(){
|
||||
// ===== 兼容引导层:纯 ES5 书写(var+function),保证在任何旧内核上都能先执行 =====
|
||||
// 目的:旧 WebView(Chromium 50-70 常见于电视/盒子)不支持 flex gap / clamp() / aspect-ratio 等特性时,
|
||||
// 给 <html> 打降级类,配合下方 CSS 里的 html.no-* 规则做兜底,避免间距消失、字号异常、海报高度塌缩。
|
||||
var root = document.documentElement;
|
||||
try {
|
||||
if (window.fongmiBridge || window.fm) root.className += ' fm-native';
|
||||
} catch (e) {}
|
||||
|
||||
// 1) flex gap 检测:CSS.supports 在部分旧内核上会“撒谎”,用隐藏元素实测渲染高度更可靠
|
||||
try {
|
||||
if (!window.CSS || !CSS.supports || !CSS.supports('gap', '10px')) {
|
||||
root.className += ' no-layout-gap';
|
||||
} else {
|
||||
var flex = document.createElement('div');
|
||||
flex.style.cssText = 'position:absolute;visibility:hidden;display:flex;flex-direction:column;gap:4px;width:10px;';
|
||||
flex.appendChild(document.createElement('div'));
|
||||
flex.appendChild(document.createElement('div'));
|
||||
flex.children[0].style.height = '3px';
|
||||
flex.children[1].style.height = '3px';
|
||||
document.body ? document.body.appendChild(flex) : document.documentElement.appendChild(flex);
|
||||
if (flex.scrollHeight < 9) root.className += ' no-layout-gap';
|
||||
flex.parentNode.removeChild(flex);
|
||||
}
|
||||
} catch (e) { root.className += ' no-layout-gap'; }
|
||||
|
||||
// 2) min()/max()/clamp() 检测
|
||||
try {
|
||||
if (!window.CSS || !CSS.supports || !CSS.supports('width', 'clamp(10px, 2vw, 20px)')) {
|
||||
root.className += ' no-css-functions';
|
||||
}
|
||||
} catch (e) { root.className += ' no-css-functions'; }
|
||||
|
||||
// 3) aspect-ratio 检测:同样需要实测渲染结果,不能只信 CSS.supports
|
||||
try {
|
||||
if (!window.CSS || !CSS.supports || !CSS.supports('aspect-ratio', '2 / 3')) {
|
||||
root.className += ' no-aspect-ratio';
|
||||
} else {
|
||||
var ar = document.createElement('div');
|
||||
ar.style.cssText = 'position:absolute;visibility:hidden;width:100px;aspect-ratio:2/3;';
|
||||
document.body ? document.body.appendChild(ar) : document.documentElement.appendChild(ar);
|
||||
var h = ar.offsetHeight;
|
||||
if (h < 140 || h > 160) root.className += ' no-aspect-ratio';
|
||||
ar.parentNode.removeChild(ar);
|
||||
}
|
||||
} catch (e) { root.className += ' no-aspect-ratio'; }
|
||||
})();</script>
|
||||
<style>
|
||||
/* ========== One UI 主题变量 双主题切换 ========== */
|
||||
:root{
|
||||
--text:#ffffff;
|
||||
--muted:rgba(255,255,255,.65);
|
||||
--line:rgba(255,255,255,.12);
|
||||
--soft:rgba(30,38,48,.35);
|
||||
--soft-blur:rgba(22,28,36,.6);
|
||||
--accent:#ff6b35;
|
||||
--accent2:#ffb86b;
|
||||
--accent-grad:linear-gradient(135deg,#ff6b35,#ffb86b);
|
||||
--accent-rgb:255,107,53;
|
||||
--accent2-rgb:255,184,107;
|
||||
--accent-l1:#ff8050;
|
||||
--accent-d1:#e8501a;
|
||||
--accent-d2:#e85520;
|
||||
--green:#42e88a;
|
||||
--green-glow:0 0 14px rgba(66,232,138,.35);
|
||||
--green-rgb:66,232,138;
|
||||
--green-l1-rgb:120,255,170;
|
||||
--green-d1-rgb:30,180,90;
|
||||
--radius-xl:20px;
|
||||
--radius-lg:16px;
|
||||
--radius-md:12px;
|
||||
--radius-full:999px;
|
||||
--shadow-deep:0 10px 30px rgba(0,0,0,.45);
|
||||
--shadow-soft:0 4px 16px rgba(0,0,0,.25);
|
||||
--transition-fast:all 0.18s cubic-bezier(0.2,0,0.2,1);
|
||||
--transition-slow:all 0.3s cubic-bezier(0.2,0,0.2,1);
|
||||
--bg:#0d1117;
|
||||
--bar-h:56px;
|
||||
--gap:10px; /* 卡片间距 = 屏幕边距,统一留白 */
|
||||
|
||||
/* ===== 全屏安全区:优先读 Native 注入的 --fm-* 变量,兜底用 env(),两者取大不叠加 ===== */
|
||||
--safe-top: max(var(--fm-safe-top, 0px), env(safe-area-inset-top, 0px));
|
||||
--safe-bottom: max(var(--fm-safe-bottom, 0px), env(safe-area-inset-bottom, 0px));
|
||||
--safe-left: max(var(--fm-safe-left, 0px), env(safe-area-inset-left, 0px));
|
||||
--safe-right: max(var(--fm-safe-right, 0px), env(safe-area-inset-right, 0px));
|
||||
}
|
||||
/* 霓虹亮色主题 */
|
||||
body.neon{
|
||||
--bg:#080c1a;
|
||||
--soft-blur:rgba(10,16,40,.7);
|
||||
--accent:#35a8ff;
|
||||
--accent2:#6bd9ff;
|
||||
--accent-grad:linear-gradient(135deg,#35a8ff,#6bd9ff);
|
||||
--accent-rgb:53,168,255;
|
||||
--accent2-rgb:107,217,255;
|
||||
--accent-l1:#5cc0ff;
|
||||
--accent-d1:#1a78d8;
|
||||
--accent-d2:#1c82e0;
|
||||
--green:#8a42e8;
|
||||
--green-glow:0 0 14px rgba(138,66,232,.35);
|
||||
--green-rgb:138,66,232;
|
||||
--green-l1-rgb:180,120,255;
|
||||
--green-d1-rgb:90,30,180;
|
||||
}
|
||||
/* 琥珀金主题 */
|
||||
body.theme-amber{
|
||||
--bg:#0d0c0a;
|
||||
--soft-blur:rgba(20,17,12,.7);
|
||||
--accent:#d4a048;
|
||||
--accent2:#f0c878;
|
||||
--accent-grad:linear-gradient(135deg,#d4a048,#f0c878);
|
||||
--accent-rgb:212,160,72;
|
||||
--accent2-rgb:240,200,120;
|
||||
--accent-l1:#e0b060;
|
||||
--accent-d1:#b07e2e;
|
||||
--accent-d2:#b8862e;
|
||||
--green:#42e88a;
|
||||
--green-glow:0 0 14px rgba(66,232,138,.35);
|
||||
--green-rgb:66,232,138;
|
||||
--green-l1-rgb:120,255,170;
|
||||
--green-d1-rgb:30,180,90;
|
||||
}
|
||||
/* 玫瑰金主题 */
|
||||
body.theme-rose{
|
||||
--bg:#0d0a0b;
|
||||
--soft-blur:rgba(20,14,14,.7);
|
||||
--accent:#c16c63;
|
||||
--accent2:#e0a08f;
|
||||
--accent-grad:linear-gradient(135deg,#c16c63,#e0a08f);
|
||||
--accent-rgb:193,108,99;
|
||||
--accent2-rgb:224,160,143;
|
||||
--accent-l1:#cc8278;
|
||||
--accent-d1:#a3534a;
|
||||
--accent-d2:#a85a4e;
|
||||
--green:#42e88a;
|
||||
--green-glow:0 0 14px rgba(66,232,138,.35);
|
||||
--green-rgb:66,232,138;
|
||||
--green-l1-rgb:120,255,170;
|
||||
--green-d1-rgb:30,180,90;
|
||||
}
|
||||
/* 午夜蓝紫主题 */
|
||||
body.theme-indigo{
|
||||
--bg:#0a0a14;
|
||||
--soft-blur:rgba(14,14,28,.7);
|
||||
--accent:#4a5a96;
|
||||
--accent2:#8a6ad0;
|
||||
--accent-grad:linear-gradient(135deg,#4a5a96,#8a6ad0);
|
||||
--accent-rgb:74,90,150;
|
||||
--accent2-rgb:138,106,208;
|
||||
--accent-l1:#5f70ad;
|
||||
--accent-d1:#37457a;
|
||||
--accent-d2:#3b4a80;
|
||||
--green:#42e88a;
|
||||
--green-glow:0 0 14px rgba(66,232,138,.35);
|
||||
--green-rgb:66,232,138;
|
||||
--green-l1-rgb:120,255,170;
|
||||
--green-d1-rgb:30,180,90;
|
||||
}
|
||||
/* 雾蓝青主题 */
|
||||
body.theme-teal{
|
||||
--bg:#081012;
|
||||
--soft-blur:rgba(10,22,24,.7);
|
||||
--accent:#3c8c96;
|
||||
--accent2:#7cc4cc;
|
||||
--accent-grad:linear-gradient(135deg,#3c8c96,#7cc4cc);
|
||||
--accent-rgb:60,140,150;
|
||||
--accent2-rgb:124,196,204;
|
||||
--accent-l1:#4fa3ad;
|
||||
--accent-d1:#2c6e76;
|
||||
--accent-d2:#2f757e;
|
||||
--green:#d4a048;
|
||||
--green-glow:0 0 14px rgba(212,160,72,.3);
|
||||
--green-rgb:212,160,72;
|
||||
--green-l1-rgb:240,200,120;
|
||||
--green-d1-rgb:160,110,30;
|
||||
}
|
||||
/* 香槛灰金主题 */
|
||||
body.theme-sand{
|
||||
--bg:#0c0b09;
|
||||
--soft-blur:rgba(20,18,14,.7);
|
||||
--accent:#96856a;
|
||||
--accent2:#c2b294;
|
||||
--accent-grad:linear-gradient(135deg,#96856a,#c2b294);
|
||||
--accent-rgb:150,133,106;
|
||||
--accent2-rgb:194,178,148;
|
||||
--accent-l1:#a89878;
|
||||
--accent-d1:#7d6e56;
|
||||
--accent-d2:#82735a;
|
||||
--green:#42e88a;
|
||||
--green-glow:0 0 14px rgba(66,232,138,.35);
|
||||
--green-rgb:66,232,138;
|
||||
--green-l1-rgb:120,255,170;
|
||||
--green-d1-rgb:30,180,90;
|
||||
}
|
||||
/* 墨绿主题 */
|
||||
body.theme-jade{
|
||||
--bg:#080d0a;
|
||||
--soft-blur:rgba(10,20,16,.7);
|
||||
--accent:#3c6e5a;
|
||||
--accent2:#7cbca0;
|
||||
--accent-grad:linear-gradient(135deg,#3c6e5a,#7cbca0);
|
||||
--accent-rgb:60,110,90;
|
||||
--accent2-rgb:124,188,160;
|
||||
--accent-l1:#4f8a72;
|
||||
--accent-d1:#2c5444;
|
||||
--accent-d2:#2f5947;
|
||||
--green:#d4a048;
|
||||
--green-glow:0 0 14px rgba(212,160,72,.3);
|
||||
--green-rgb:212,160,72;
|
||||
--green-l1-rgb:240,200,120;
|
||||
--green-d1-rgb:160,110,30;
|
||||
}
|
||||
|
||||
*{box-sizing:border-box;-webkit-font-smoothing:antialiased;-webkit-tap-highlight-color:transparent}
|
||||
html,body{
|
||||
margin:0;min-height:100%;
|
||||
background:transparent; /* App 内透明,露出 App 背景 + 雾层 */
|
||||
color:var(--text);
|
||||
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Samsung Sans",Arial,sans-serif;
|
||||
scroll-behavior:smooth;
|
||||
/* 防止 iOS 双击缩放 */
|
||||
touch-action:manipulation;
|
||||
}
|
||||
/* 非 App(纯浏览器)用纯色背景兜底,避免透明到空白 */
|
||||
html:not(.fm-native),html:not(.fm-native) body{background:var(--bg)}
|
||||
/* 全局高斯模糊雾层:无论 App 背景是什么,都蒙一层磨砂雾 */
|
||||
#bgfog{
|
||||
position:fixed;top:0;right:0;bottom:0;left:0;inset:0;z-index:-1;pointer-events:none;
|
||||
background:rgba(13,17,23,.42);
|
||||
backdrop-filter:blur(46px) saturate(1.25);
|
||||
-webkit-backdrop-filter:blur(46px) saturate(1.25);
|
||||
}
|
||||
html:not(.fm-native) #bgfog{display:none}
|
||||
::-webkit-scrollbar{width:4px;height:4px}
|
||||
::-webkit-scrollbar-track{background:rgba(255,255,255,.05)}
|
||||
::-webkit-scrollbar-thumb{background:rgba(255,255,255,.18);border-radius:var(--radius-full)}
|
||||
|
||||
/* ========== 主容器:edge 全屏模式下用安全区变量避让 ========== */
|
||||
.app{
|
||||
min-height:100vh;
|
||||
min-height:var(--fm-web-height, 100vh); /* Native 注入真实高度时优先用 */
|
||||
/* edge:WebView 铺到状态栏后面,需要叠加安全区高度避让;
|
||||
normal:原生顶部工具栏已经把 WebView 顶下来,--safe-top 不再需要叠加,只留一个小间距 */
|
||||
padding-top: calc(var(--safe-top) + 12px);
|
||||
padding-bottom: calc(var(--safe-bottom) + 12px);
|
||||
padding-left: calc(var(--safe-left) + var(--gap));
|
||||
padding-right: calc(var(--safe-right) + var(--gap));
|
||||
}
|
||||
/* 非全屏(原生顶部工具栏可见):不再叠加 safe-top,避免和工具栏间距过宽 */
|
||||
html.chrome-normal .app{
|
||||
padding-top: var(--gap);
|
||||
}
|
||||
|
||||
/* ========== 顶部导航栏 — 单行 ========== */
|
||||
/* 搜索时整页模糊遮罩,bar 浮在上层 */
|
||||
#searchOverlay{
|
||||
position:fixed;top:0;right:0;bottom:0;left:0;inset:0;z-index:25;
|
||||
backdrop-filter:blur(10px) brightness(0.6);
|
||||
-webkit-backdrop-filter:blur(10px) brightness(0.6);
|
||||
opacity:0;pointer-events:none;
|
||||
transition:opacity .25s ease;
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
}
|
||||
#searchOverlay.show{opacity:1;pointer-events:none}
|
||||
.search-loading{
|
||||
display:flex;flex-direction:column;align-items:center;justify-content:center;
|
||||
gap:14px;color:#fff;font-size:14px;
|
||||
opacity:0;transform:translateY(6px);
|
||||
transition:opacity .3s ease .1s,transform .3s ease .1s;
|
||||
}
|
||||
#searchOverlay.show .search-loading{opacity:1;transform:translateY(0)}
|
||||
|
||||
/* 搜索框聚焦时:模糊 bar 以外的所有内容(精确列出兄弟元素,不碰 .bar) */
|
||||
html.search-focused .navrow,
|
||||
html.search-focused #status,
|
||||
html.search-focused #content {
|
||||
filter: blur(8px) brightness(0.45);
|
||||
pointer-events: none !important;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
transition: filter .25s ease;
|
||||
}
|
||||
/* 聚焦态:整个 .app 内除 .bar 外一律不可交互 */
|
||||
html.search-focused .app {
|
||||
pointer-events: none;
|
||||
}
|
||||
html.search-focused .app .bar {
|
||||
pointer-events: auto;
|
||||
}
|
||||
/* 搜索框聚焦时:全屏透明蒙层,拦截所有点击,点击蒙层退出聚焦 */
|
||||
#searchFocusMask {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top:0;right:0;bottom:0;left:0;inset:0;
|
||||
z-index: 19;
|
||||
background: transparent;
|
||||
touch-action: none;
|
||||
pointer-events: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
html.search-focused #searchFocusMask {
|
||||
display: block;
|
||||
pointer-events: auto;
|
||||
}
|
||||
/* 搜索栏保持在蒙层之上,isolation 创建独立层叠上下文 */
|
||||
html.search-focused .bar {
|
||||
position: relative;
|
||||
z-index: 50;
|
||||
isolation: isolate;
|
||||
}
|
||||
.bar{
|
||||
position:relative;z-index:30;
|
||||
display:flex;align-items:center;gap:8px;
|
||||
flex-wrap:nowrap;
|
||||
padding:8px 14px;
|
||||
background:none;
|
||||
backdrop-filter:none;
|
||||
-webkit-backdrop-filter:none;
|
||||
border:none;
|
||||
border-radius:0;
|
||||
margin-bottom:12px;
|
||||
box-shadow:none;
|
||||
}
|
||||
/* 两个子行合并为同级 flex 子项,隐藏原包装层 */
|
||||
.bar-top{display:contents}
|
||||
.bar-bottom{display:contents}
|
||||
.bar-right{display:contents}
|
||||
|
||||
.brand{font-size:20px;font-weight:900;white-space:nowrap;letter-spacing:0.5px;flex-shrink:0}
|
||||
.brand b{background:var(--accent-grad);-webkit-background-clip:text;-webkit-text-fill-color:transparent;color:transparent}
|
||||
|
||||
/* 搜索框:渐变光晕描边(外层渐变描边 + 内层纯色,聚焦时光晕增强) */
|
||||
.search-wrap{
|
||||
position:relative;flex:1;min-width:0;
|
||||
border-radius:calc(var(--radius-full) + 1px);
|
||||
padding:1px;
|
||||
background:linear-gradient(135deg,rgba(var(--accent-rgb),.55),rgba(255,255,255,.07) 45%,rgba(var(--accent2-rgb),.4));
|
||||
box-shadow:0 12px 28px -12px rgba(0,0,0,.55);
|
||||
transition:var(--transition-fast);
|
||||
}
|
||||
.search-wrap:focus-within{
|
||||
background:linear-gradient(135deg,rgba(var(--accent-rgb),.85),rgba(255,255,255,.1) 45%,rgba(var(--accent2-rgb),.65));
|
||||
box-shadow:0 0 0 3px rgba(var(--accent-rgb),.14),0 12px 28px -12px rgba(0,0,0,.55);
|
||||
}
|
||||
.search{
|
||||
position:relative;
|
||||
flex:1;min-width:0;display:flex;gap:8px;align-items:center;
|
||||
background:#11151f;
|
||||
border:0;
|
||||
border-radius:var(--radius-full);
|
||||
padding:0 6px 0 16px;
|
||||
height:40px;
|
||||
}
|
||||
.search input{flex:1;min-width:0;background:transparent;border:0;outline:0;color:var(--text);font-size:16px}
|
||||
.search input::placeholder{color:var(--muted)}
|
||||
|
||||
/* ===== 搜索联想下拉:小海报 + 年份 ===== */
|
||||
.search-sugg{
|
||||
position:absolute;left:0;right:0;top:calc(100% + 8px);z-index:60;
|
||||
background:linear-gradient(150deg,rgba(var(--accent-rgb),.22),rgba(var(--accent2-rgb),.10) 60%,rgba(13,17,23,.55));
|
||||
backdrop-filter:blur(22px) saturate(1.5);-webkit-backdrop-filter:blur(22px) saturate(1.5);
|
||||
border:1px solid rgba(var(--accent2-rgb),.25);
|
||||
border-radius:var(--radius-lg);
|
||||
box-shadow:var(--shadow-deep);
|
||||
max-height:62vh;overflow-y:auto;overflow-x:hidden;
|
||||
-webkit-overflow-scrolling:touch;
|
||||
display:none;
|
||||
}
|
||||
.search-sugg.show{display:block}
|
||||
.sugg-item{
|
||||
display:flex;align-items:center;gap:11px;
|
||||
padding:8px 12px;cursor:pointer;
|
||||
border-bottom:1px solid rgba(255,255,255,.05);
|
||||
transition:background .12s;
|
||||
}
|
||||
.sugg-item:last-child{border-bottom:0}
|
||||
.sugg-item:active{background:rgba(var(--accent-rgb),.18)}
|
||||
@media(hover:hover){.sugg-item:hover{background:rgba(255,255,255,.06)}}
|
||||
.sugg-poster{
|
||||
width:38px;height:54px;flex-shrink:0;
|
||||
border-radius:6px;overflow:hidden;
|
||||
background:rgba(255,255,255,.06);
|
||||
border:1px solid rgba(255,255,255,.08);
|
||||
display:grid;place-items:center;
|
||||
}
|
||||
.sugg-poster img{width:100%;height:100%;object-fit:cover;display:block}
|
||||
.sugg-poster .ph{font-size:16px;opacity:.5}
|
||||
.sugg-info{flex:1;min-width:0}
|
||||
.sugg-title{font-size:14px;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.sugg-meta{margin-top:4px;display:flex;align-items:center;gap:7px;font-size:11px;color:var(--muted)}
|
||||
.sugg-type{
|
||||
padding:1px 7px;border-radius:999px;
|
||||
background:rgba(var(--accent-rgb),.14);
|
||||
border:1px solid rgba(var(--accent-rgb),.32);
|
||||
color:var(--accent);font-weight:800;font-size:10px;
|
||||
}
|
||||
.sugg-year{font-weight:700}
|
||||
.sugg-loading{padding:14px;text-align:center;color:var(--muted);font-size:13px}
|
||||
|
||||
/* ========== 通用按钮 ========== */
|
||||
.btn{
|
||||
position:relative;overflow:hidden;
|
||||
border:1px solid rgba(255,255,255,.12);
|
||||
background:rgba(45,54,68,.9);
|
||||
color:var(--text);
|
||||
border-radius:var(--radius-full);
|
||||
padding:0 16px;
|
||||
height:40px;
|
||||
font-size:14px;font-weight:700;
|
||||
letter-spacing:.3px;
|
||||
transition:var(--transition-fast);
|
||||
white-space:nowrap;flex-shrink:0;
|
||||
display:inline-flex;align-items:center;justify-content:center;
|
||||
cursor:pointer;
|
||||
box-shadow:0 2px 8px rgba(0,0,0,.25);
|
||||
}
|
||||
.btn:active{
|
||||
transform:scale(0.96);
|
||||
box-shadow:0 1px 4px rgba(0,0,0,.2);
|
||||
}
|
||||
|
||||
/* Primary 搜索按钮 */
|
||||
.btn.primary{
|
||||
background:var(--accent);
|
||||
border:1px solid rgba(var(--accent-rgb),.5);
|
||||
color:#fff;font-weight:800;
|
||||
padding:0 18px;
|
||||
box-shadow:0 2px 10px rgba(var(--accent-rgb),.3);
|
||||
}
|
||||
.btn.primary:active{
|
||||
box-shadow:0 1px 5px rgba(var(--accent-rgb),.2);
|
||||
}
|
||||
|
||||
/* 图标按钮 */
|
||||
.btn-icon{width:40px;height:40px;padding:0;min-width:unset;font-size:17px}
|
||||
|
||||
/* ========== 站点 Tabs / 分类 Chips ========== */
|
||||
/* 导航行:安静文字源 + 实心胶囊分类(方案A) */
|
||||
.navrow{
|
||||
position:relative;z-index:20;
|
||||
display:block;
|
||||
padding:6px 0 14px;
|
||||
}
|
||||
/* 站源:安静文字 Tab 行,选中加下划线 */
|
||||
.src-row{
|
||||
display:flex;gap:18px;overflow-x:auto;scrollbar-width:none;
|
||||
-webkit-overflow-scrolling:touch;
|
||||
padding:0 var(--gap) 9px;
|
||||
}
|
||||
.src-row::-webkit-scrollbar{display:none}
|
||||
.src-tab{
|
||||
position:relative;white-space:nowrap;flex-shrink:0;cursor:pointer;
|
||||
font-size:15px;font-weight:700;color:var(--muted);
|
||||
padding:2px 0 8px;background:none;border:0;
|
||||
transition:color .15s;
|
||||
}
|
||||
.src-tab.on{color:#fff;font-weight:900}
|
||||
.src-tab.on::after{
|
||||
content:"";position:absolute;left:0;right:0;bottom:0;height:3px;border-radius:3px;
|
||||
background:linear-gradient(90deg,var(--accent2),var(--accent));
|
||||
}
|
||||
.src-tab:active{opacity:.7}
|
||||
.src-sep{height:1px;background:rgba(255,255,255,.12);margin:0 var(--gap) 10px}
|
||||
|
||||
/* 分类:实心胶囊,醒目层级 */
|
||||
.chips{
|
||||
display:flex;gap:8px;overflow-x:auto;scrollbar-width:none;
|
||||
-webkit-overflow-scrolling:touch;
|
||||
padding:0 var(--gap);
|
||||
}
|
||||
.chips::-webkit-scrollbar{display:none}
|
||||
.navrow{transition:filter .25s ease,opacity .25s ease}
|
||||
.navrow.blurred{filter:blur(8px);opacity:.4;pointer-events:none}
|
||||
/* 搜索中:页面内容区高斯模糊 */
|
||||
#content{transition:filter .25s ease,opacity .25s ease;min-height:calc(100vh - 160px)}
|
||||
#content.searching{filter:blur(6px);opacity:.5;pointer-events:none}
|
||||
/* 搜索时整个页面模糊遮罩(不模糊 .bar,保持搜索框可见且浮在遮罩之上) */
|
||||
.app{transition:filter .28s ease}
|
||||
.app.searching .navrow,
|
||||
.app.searching #status,
|
||||
.app.searching #content{filter:blur(8px)}
|
||||
.chip{
|
||||
white-space:nowrap;cursor:pointer;flex-shrink:0;
|
||||
font-size:12px;font-weight:700;color:var(--muted);
|
||||
background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.12);
|
||||
border-radius:var(--radius-full);
|
||||
padding:6px 12px;
|
||||
transition:var(--transition-fast);
|
||||
}
|
||||
.chip.active{
|
||||
color:#1a1206;font-weight:900;
|
||||
background:linear-gradient(135deg,var(--accent2),var(--accent));
|
||||
border-color:transparent;
|
||||
box-shadow:0 3px 12px rgba(var(--accent-rgb),.35);
|
||||
}
|
||||
.chip:active{opacity:.85;transform:scale(.96)}
|
||||
|
||||
.status{display:none}
|
||||
|
||||
/* ========== 卡片网格 ========== */
|
||||
.grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--gap)}
|
||||
/* 竖屏 3 列,横屏 7 列 */
|
||||
@media(orientation:portrait){.grid{grid-template-columns:repeat(3,minmax(0,1fr))}}
|
||||
@media(orientation:landscape){.grid{grid-template-columns:repeat(7,minmax(0,1fr))}}
|
||||
/* 左右滑动切换分类:新分类网格淡入位移 */
|
||||
@keyframes catSlideFromR{from{opacity:.25;transform:translateX(28px)}to{opacity:1;transform:none}}
|
||||
@keyframes catSlideFromL{from{opacity:.25;transform:translateX(-28px)}to{opacity:1;transform:none}}
|
||||
.grid.slide-l{animation:catSlideFromR .24s cubic-bezier(.2,0,.2,1)}
|
||||
.grid.slide-r{animation:catSlideFromL .24s cubic-bezier(.2,0,.2,1)}
|
||||
/* 滑到边界时的轻微回弹提示 */
|
||||
@keyframes catEdgeBounceL{0%{transform:none}40%{transform:translateX(-14px)}100%{transform:none}}
|
||||
@keyframes catEdgeBounceR{0%{transform:none}40%{transform:translateX(14px)}100%{transform:none}}
|
||||
#content.edge-l{animation:catEdgeBounceL .26s ease}
|
||||
#content.edge-r{animation:catEdgeBounceR .26s ease}
|
||||
|
||||
.card{
|
||||
border-radius:var(--radius-lg);overflow:hidden;
|
||||
background:linear-gradient(150deg,rgba(var(--accent-rgb),.22),rgba(var(--accent2-rgb),.10) 60%,rgba(13,17,23,.4));
|
||||
backdrop-filter:blur(20px) saturate(1.5);
|
||||
-webkit-backdrop-filter:blur(20px) saturate(1.5);
|
||||
border:1px solid rgba(var(--accent2-rgb),.25);
|
||||
transition:var(--transition-fast);
|
||||
cursor:pointer;position:relative;
|
||||
box-shadow:0 4px 16px rgba(0,0,0,.4),0 1px 0 rgba(255,255,255,.04) inset;
|
||||
}
|
||||
.card:active{transform:scale(0.97);opacity:.88}
|
||||
/* 关键修复:禁用系统长按选择/图片菜单,避免和"长按收藏"手势抢触摸导致卡死 */
|
||||
.card,.card *{
|
||||
-webkit-user-select:none;-moz-user-select:none;user-select:none;
|
||||
-webkit-touch-callout:none;
|
||||
}
|
||||
.card img{-webkit-user-drag:none;user-drag:none}
|
||||
|
||||
.poster{aspect-ratio:3/4;position:relative;overflow:hidden;background:#1a222c}
|
||||
.poster img{width:100%;height:100%;object-fit:cover;transition:var(--transition-slow);display:block;position:absolute;top:0;right:0;bottom:0;left:0;inset:0;z-index:0}
|
||||
.noimg{height:100%;display:grid;place-items:center;color:var(--muted);font-size:15px;font-weight:700;letter-spacing:.5px;text-align:center;line-height:1.5;padding:0 10px;background:linear-gradient(145deg,#202833,#11161d);position:absolute;top:0;right:0;bottom:0;left:0;inset:0;z-index:0}
|
||||
|
||||
/* 更新状态角标:右上角,抓取自站源(替换原收藏心形按钮的位置) */
|
||||
.status-badge{
|
||||
position:absolute;top:7px;right:7px;z-index:3;
|
||||
max-width:78%;
|
||||
padding:3px 9px;border-radius:999px;
|
||||
font-size:10px;font-weight:800;letter-spacing:.2px;line-height:1.3;
|
||||
color:#fff;
|
||||
background:linear-gradient(135deg,rgba(var(--accent-rgb),.95),rgba(var(--accent2-rgb),.9));
|
||||
border:1px solid rgba(255,255,255,.28);
|
||||
box-shadow:0 2px 8px rgba(0,0,0,.4);
|
||||
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
|
||||
-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px);
|
||||
}
|
||||
/* 收藏态不再做视觉高亮(收藏内容直接到“收藏”分类查看);功能保留,仅去掉描边/光晕 */
|
||||
|
||||
/* 角标 */
|
||||
.source-badge{
|
||||
position:absolute;bottom:4px;right:6px;
|
||||
padding:2px 7px;border-radius:5px;font-size:9px;font-weight:800;
|
||||
background:linear-gradient(135deg,var(--accent-l1),var(--accent));
|
||||
color:#fff;box-shadow:0 2px 6px rgba(var(--accent-rgb),.4);
|
||||
border:1px solid rgba(var(--accent2-rgb),.3);
|
||||
display:none;
|
||||
max-width:60%;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
|
||||
}
|
||||
.source-badge.show{display:block}
|
||||
.badge{
|
||||
position:absolute;left:6px;bottom:4px;
|
||||
padding:2px 7px;border-radius:var(--radius-full);
|
||||
background:rgba(0,0,0,.72);font-size:10px;font-weight:800;
|
||||
backdrop-filter:blur(6px);border:1px solid rgba(255,255,255,.1);
|
||||
}
|
||||
.meta{padding:8px 10px}
|
||||
.name{font-weight:800;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.srcline{margin-top:4px;color:var(--accent2);font-size:10px;line-height:1.4;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
|
||||
.remark{margin-top:4px;color:var(--muted);font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
|
||||
/* ========== 详情弹层:沉浸式全屏(参考 Eclipse)========== */
|
||||
.sheet{
|
||||
position:fixed;top:0;right:0;bottom:0;left:0;inset:0;z-index:40;
|
||||
display:none;
|
||||
background:#0d1117;
|
||||
overflow-y:scroll;
|
||||
overflow-x:hidden;
|
||||
-webkit-overflow-scrolling:touch;
|
||||
transform:translateX(100%);
|
||||
transition:transform .28s cubic-bezier(.4,0,.2,1);
|
||||
scrollbar-width:none; /* Firefox */
|
||||
-ms-overflow-style:none; /* 旧版 Edge/IE */
|
||||
}
|
||||
.sheet::-webkit-scrollbar{display:none} /* Chrome/Safari/WebView */
|
||||
.sheet.active{display:block;transform:translateX(0)}
|
||||
|
||||
/* 独立海报大图区:不再做沉浸式背景叠加,海报清晰完整展示 */
|
||||
/* 竖版海报:按 2:3 比例展示;横版海报由 JS 检测后切换为 .landscape,按 16:9 展示 */
|
||||
.det-hero{
|
||||
position:relative;
|
||||
width:100%;
|
||||
aspect-ratio:2/3;
|
||||
max-height:62vh;
|
||||
z-index:0;overflow:hidden;
|
||||
background:linear-gradient(160deg, #0f1923 0%, #1a2535 50%, #0d1117 100%);
|
||||
}
|
||||
.det-hero.landscape{aspect-ratio:auto}
|
||||
/* ===== Emby 风格全屏海报背景(独立于 sheet,fixed 不受 sheet transform 影响)===== */
|
||||
.det-hero-bg{
|
||||
position:fixed;top:0;right:0;bottom:0;left:0;inset:0;z-index:38; /* 低于 sheet(40),详情打开后透过透明的 sheet 透出 */
|
||||
background-size:cover;background-position:center 12%;
|
||||
background-color:#0d1117; /* 深色兜底:海报图加载前也不会透出主页 */
|
||||
opacity:0;pointer-events:none;
|
||||
transition:opacity .4s ease;
|
||||
will-change:opacity;
|
||||
}
|
||||
.det-hero-bg.show{opacity:1}
|
||||
/* 轮播图层:两层交替 crossfade;淡出时露出的是 detHeroBg 深色底而非主页 */
|
||||
.det-hero-bg-img{
|
||||
position:absolute;top:0;right:0;bottom:0;left:0;inset:0;
|
||||
background-size:cover;background-position:center 12%;
|
||||
opacity:0;transition:opacity .9s ease;
|
||||
}
|
||||
/* 渐变压暗层:大幅调淡——资源已收进带玻璃背景的折叠条,海报得以大部分清晰露出 */
|
||||
.det-hero-scrim{
|
||||
position:absolute;top:0;right:0;bottom:0;left:0;inset:0;
|
||||
background:linear-gradient(180deg,
|
||||
rgba(13,17,23,.12) 0%,
|
||||
rgba(13,17,23,0) 32%,
|
||||
rgba(13,17,23,0) 60%,
|
||||
rgba(13,17,23,.28) 82%,
|
||||
rgba(13,17,23,.5) 100%);
|
||||
}
|
||||
/* 横版沉浸式:sheet 背景透明(透出后面的全屏海报),用 flex 纵向排让内容沉底 */
|
||||
.sheet.landscape-mode{background:transparent!important;display:flex;flex-direction:column}
|
||||
/* 海报区撑开占据上方所有空间,把播放按钮挤到底沿,紧接下方标题与折叠条 */
|
||||
.sheet.landscape-mode .det-hero{
|
||||
flex:1 1 auto;height:auto;min-height:26vh;
|
||||
background:transparent;aspect-ratio:auto;max-height:none;
|
||||
overflow:visible; /* 让播放按钮的呼吸光晕不被裁切 */
|
||||
}
|
||||
.sheet.landscape-mode .det-hero-img{display:none} /* 用全屏 detHeroBg 背景代替 */
|
||||
.sheet.landscape-mode .det-hero::after{display:none}
|
||||
/* 播放按钮落在海报底沿正中,间距收紧让底部内容组更紧凑 */
|
||||
.sheet.landscape-mode .det-hero-cta{align-items:flex-end;justify-content:center;padding-bottom:12px}
|
||||
/* 沉浸式详情页隐藏吸顶标题栏,避免滑动时顶部标题栏闪现、破坏沉浸感 */
|
||||
.sheet.landscape-mode .det-topbar{display:none!important}
|
||||
/* 海报大图:完整清晰展示,不裁切关键内容,默认透明,加载成功后淡入 */
|
||||
.det-hero-img{
|
||||
position:absolute;top:0;right:0;bottom:0;left:0;inset:0;z-index:0;
|
||||
width:100%;height:100%;object-fit:cover;object-position:center center;
|
||||
opacity:0;transition:opacity .35s ease;
|
||||
}
|
||||
.det-hero-img.loaded{opacity:1}
|
||||
/* 底部轻渐变,仅用于与下方内容衔接,不遮挡海报画面主体 */
|
||||
.det-hero::after{
|
||||
content:'';position:absolute;left:0;right:0;bottom:0;height:64px;z-index:1;
|
||||
background:linear-gradient(180deg, rgba(13,17,23,0) 0%, rgba(13,17,23,.9) 100%);
|
||||
}
|
||||
/* 海报内部底部:播放按钮悬浮在海报下沿,居中 */
|
||||
.det-hero-cta{
|
||||
position:absolute;left:0;right:0;bottom:0;z-index:3;
|
||||
display:flex;justify-content:center;align-items:flex-end;
|
||||
padding:0 14px 14px;
|
||||
pointer-events:none;
|
||||
}
|
||||
.det-hero-cta .det-play-btn{pointer-events:auto}
|
||||
|
||||
/* 吸顶导航栏:已去除返回按钮,仅在滚动后吸顶显示标题(关闭详情页改为手势/系统返回) */
|
||||
.det-topbar{
|
||||
position:fixed;top:0;left:0;right:0;z-index:10;
|
||||
height:calc(52px + var(--safe-top));
|
||||
padding-top:var(--safe-top);
|
||||
display:flex;align-items:center;gap:10px;
|
||||
padding-left:18px;padding-right:18px;
|
||||
background:rgba(13,17,23,0);
|
||||
border-bottom:1px solid rgba(255,255,255,0);
|
||||
backdrop-filter:blur(0px);
|
||||
-webkit-backdrop-filter:blur(0px);
|
||||
transition:background .2s,border-color .2s,backdrop-filter .2s,-webkit-backdrop-filter .2s;
|
||||
pointer-events:none;
|
||||
}
|
||||
.det-topbar.scrolled{
|
||||
background:linear-gradient(150deg,rgba(var(--accent-rgb),.20),rgba(var(--accent2-rgb),.08) 60%,rgba(13,17,23,.7));
|
||||
border-bottom:1px solid rgba(var(--accent2-rgb),.2);
|
||||
backdrop-filter:blur(20px) saturate(1.5);
|
||||
-webkit-backdrop-filter:blur(20px) saturate(1.5);
|
||||
}
|
||||
.det-topbar-title{
|
||||
flex:1;min-width:0;
|
||||
font-size:16px;font-weight:700;color:#fff;
|
||||
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
|
||||
opacity:0;transform:translateY(5px);
|
||||
transition:opacity .18s,transform .18s;
|
||||
}
|
||||
.det-topbar.scrolled .det-topbar-title{opacity:1;transform:translateY(0)}
|
||||
|
||||
/* 主内容区:海报大图现为常规流内元素,内容区紧随其后,顶部只需让出吸顶栏高度 */
|
||||
.panel{
|
||||
position:relative;z-index:2;
|
||||
padding:6px 18px calc(var(--safe-bottom) + 24px);
|
||||
box-sizing:border-box;
|
||||
}
|
||||
/* 横版沉浸式:资源区自然高度,作为 flex 项紧贴撑开的海报下方,整组沉到屏幕底部 */
|
||||
.sheet.landscape-mode .panel{
|
||||
margin-top:0!important;
|
||||
background:transparent!important;
|
||||
position:relative;z-index:2;
|
||||
flex:0 0 auto;
|
||||
}
|
||||
/* ===== Apple TV 风格大标题:播放按钮下方,大号粗体 ===== */
|
||||
.det-bigtitle{
|
||||
font-size:34px;font-weight:850;color:#fff;line-height:1.1;
|
||||
letter-spacing:.01em;margin:2px 0 12px;text-align:center;
|
||||
text-shadow:0 2px 24px rgba(0,0,0,.6),0 1px 4px rgba(0,0,0,.5);
|
||||
}
|
||||
.sheet.landscape-mode .det-bigtitle{font-size:40px;margin:8px 0 6px;text-align:center}
|
||||
.sheet.landscape-mode .det-pills{justify-content:center}
|
||||
/* 影视类型标签:标题下方居中显示 */
|
||||
.det-genres{
|
||||
text-align:center;color:rgba(255,255,255,.72);
|
||||
font-size:13px;font-weight:600;letter-spacing:.04em;
|
||||
margin:0 0 10px;text-shadow:0 1px 8px rgba(0,0,0,.55);
|
||||
}
|
||||
.det-genres:empty{display:none}
|
||||
/* ===== 资源折叠条:默认收起、两条并排,露出大部分海报 ===== */
|
||||
.det-collapse-row{display:block}
|
||||
.det-collapse{
|
||||
background:linear-gradient(150deg,rgba(var(--accent-rgb),.18),rgba(var(--accent2-rgb),.08) 60%,rgba(13,17,23,.38)); /* 降低不透明度,海报透出更多 */
|
||||
backdrop-filter:blur(22px) saturate(1.5);
|
||||
-webkit-backdrop-filter:blur(22px) saturate(1.5);
|
||||
border:1px solid rgba(var(--accent2-rgb),.22);
|
||||
border-radius:var(--radius-full);overflow:hidden; /* 圆角同步顶部搜索框 */
|
||||
box-shadow:0 10px 30px rgba(0,0,0,.32);
|
||||
width:fit-content;max-width:92%;margin:0 auto; /* 收起时宽度自适应内容,横竖屏都刚好不挤不空 */
|
||||
transition:max-width .28s ease,border-radius .28s ease;
|
||||
}
|
||||
.det-collapse.open{width:100%;max-width:720px;border-radius:18px} /* 展开时铺开、收圆角为长方形,避免大弧形盖住内容 */
|
||||
.det-collapse-head{
|
||||
display:flex;align-items:center;gap:12px;justify-content:center;
|
||||
padding:10px 20px;cursor:pointer;user-select:none;
|
||||
font-size:15px;font-weight:800;color:#fff;
|
||||
}
|
||||
/* 资源 tab 切换:网盘资源 / 盘搜资源,居中显示 */
|
||||
.det-res-tabs{display:flex;gap:10px;flex:0 0 auto;justify-content:center}
|
||||
.det-res-tab{
|
||||
background:transparent;border:none;cursor:pointer;
|
||||
color:rgba(255,255,255,.5);font-size:15px;font-weight:800;
|
||||
padding:6px 14px;border-radius:var(--radius-full); /* 圆角同步资源框(胶囊)*/
|
||||
white-space:nowrap; /* 文字不换行 */
|
||||
display:inline-flex;align-items:center;transition:background .15s,color .15s;
|
||||
}
|
||||
.det-res-tab.active{color:#fff;background:rgba(255,255,255,.12)}
|
||||
.det-res-tab em{
|
||||
font-style:normal;margin-left:7px;padding:1px 8px;border-radius:999px;
|
||||
font-size:12px;font-weight:900;color:var(--accent2);
|
||||
background:rgba(var(--accent2-rgb),.15);border:1px solid rgba(var(--accent2-rgb),.3);
|
||||
}
|
||||
.det-res-pane[hidden]{display:none}
|
||||
.det-collapse-arrow{opacity:.65;font-size:12px;transition:transform .22s ease;flex-shrink:0}
|
||||
.det-collapse.open .det-collapse-arrow{transform:rotate(180deg)}
|
||||
.det-collapse-body{display:none;padding:0 14px 14px}
|
||||
.det-collapse.open .det-collapse-body{display:block}
|
||||
.det-collapse-body .pan-list{height:400px;max-height:48vh;overflow-y:auto} /* 固定约6条高度,数量不同不再跳动,不足则留白 */
|
||||
.det-collapse .pan-cfg-btn{flex-shrink:0}
|
||||
|
||||
/* poster row — 海报已独立展示为顶部大图,此处只保留文字标题区 */
|
||||
.det-poster-row{
|
||||
display:flex;gap:16px;align-items:flex-end;
|
||||
margin-top:18px;
|
||||
margin-bottom:18px;
|
||||
}
|
||||
.det-title-block{flex:1;min-width:0;padding-bottom:4px}
|
||||
|
||||
/* eyebrow tag */
|
||||
.det-eyebrow{
|
||||
font-size:11px;letter-spacing:.2em;text-transform:uppercase;
|
||||
color:var(--accent2);margin-bottom:10px;
|
||||
display:flex;align-items:center;gap:6px;font-weight:700;
|
||||
}
|
||||
.det-eyebrow::before{content:'';display:inline-block;width:12px;height:1px;background:var(--accent2)}
|
||||
|
||||
/* main title */
|
||||
.det-main-title{
|
||||
font-size:28px;font-size:clamp(22px,6vw,38px);font-weight:900;line-height:1.08;
|
||||
letter-spacing:-.01em;
|
||||
text-shadow:0 3px 16px rgba(0,0,0,.6);
|
||||
margin-bottom:10px;
|
||||
}
|
||||
|
||||
/* meta pills */
|
||||
.det-pills{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:14px}
|
||||
.det-pill{
|
||||
padding:4px 10px;border-radius:999px;
|
||||
color:rgba(255,255,255,.85);background:rgba(255,255,255,.1);
|
||||
border:1px solid rgba(255,255,255,.2);
|
||||
font-size:12px;line-height:1.4;
|
||||
}
|
||||
.det-pill.accent{
|
||||
color:var(--accent);border-color:rgba(var(--accent-rgb),.5);
|
||||
background:rgba(var(--accent-rgb),.12);font-weight:700;
|
||||
}
|
||||
|
||||
/* action buttons — 居中 + 与首页主按钮统一质感 */
|
||||
.det-actions{display:flex;gap:8px;flex-wrap:wrap;margin:4px 0 18px;align-items:center;justify-content:center}
|
||||
.det-play-btn{
|
||||
position:relative;overflow:visible;
|
||||
display:inline-flex;align-items:center;justify-content:center;
|
||||
color:#fff;
|
||||
cursor:pointer;
|
||||
width:54px;height:54px;padding:0;
|
||||
border-radius:50%;
|
||||
background:var(--accent);
|
||||
border:1px solid rgba(var(--accent-rgb),.5);
|
||||
border-top:1px solid rgba(255,255,255,.22);
|
||||
box-shadow:0 2px 10px rgba(var(--accent-rgb),.3),0 1px 0 rgba(255,255,255,.08) inset;
|
||||
transition:var(--transition-fast);
|
||||
}
|
||||
.det-play-btn svg{width:20px;height:20px;margin-left:2px}
|
||||
.det-play-btn::before{
|
||||
content:'';position:absolute;top:0;right:0;bottom:0;left:0;inset:0;border-radius:50%;pointer-events:none;z-index:-1;
|
||||
box-shadow:0 0 0 0 rgba(var(--accent-rgb),.5);
|
||||
animation:detPlayPulse 2.6s cubic-bezier(.2,.7,.3,1) infinite;
|
||||
}
|
||||
@keyframes detPlayPulse{
|
||||
0%{box-shadow:0 0 0 0 rgba(var(--accent-rgb),.45)}
|
||||
70%{box-shadow:0 0 0 16px rgba(var(--accent-rgb),0)}
|
||||
100%{box-shadow:0 0 0 16px rgba(var(--accent-rgb),0)}
|
||||
}
|
||||
.det-hero-cta .det-play-btn:active{transform:scale(.96);box-shadow:0 1px 5px rgba(var(--accent-rgb),.2)}
|
||||
|
||||
/* section label */
|
||||
.det-sec-label{
|
||||
font-size:11px;letter-spacing:.2em;text-transform:uppercase;color:#fff;
|
||||
margin:12px 0 8px;
|
||||
display:flex;align-items:center;gap:10px;font-weight:700;
|
||||
}
|
||||
.det-sec-label::after{content:'';flex:1;height:1px;background:rgba(255,255,255,.08)}
|
||||
|
||||
/* 网盘资源区 — 整齐单列 */
|
||||
.pan-list{display:flex;flex-direction:column;gap:8px;margin-top:4px}
|
||||
.pan-item{
|
||||
display:flex;
|
||||
flex-direction:row;
|
||||
align-items:center;
|
||||
gap:12px;
|
||||
padding:12px 14px;border-radius:14px;
|
||||
background:rgba(255,255,255,.04);
|
||||
border:1px solid rgba(255,255,255,.08);
|
||||
border-top:1px solid rgba(255,255,255,.13);
|
||||
transition:var(--transition-fast);
|
||||
box-shadow:0 2px 12px rgba(0,0,0,.3);
|
||||
min-width:0;
|
||||
}
|
||||
@media(hover:hover){.pan-item:hover{background:rgba(255,255,255,.07);border-color:rgba(255,255,255,.14)}}
|
||||
.pan-type{
|
||||
flex-shrink:0;
|
||||
padding:5px 11px;border-radius:999px;
|
||||
background:linear-gradient(135deg,rgba(var(--accent2-rgb),.18),rgba(var(--accent-rgb),.1));
|
||||
border:1px solid rgba(var(--accent2-rgb),.35);
|
||||
color:var(--accent2);font-size:11px;font-weight:900;
|
||||
min-width:46px;text-align:center;
|
||||
text-shadow:0 0 8px rgba(var(--accent2-rgb),.4);
|
||||
}
|
||||
.pan-info{flex:1;min-width:0;overflow:hidden}
|
||||
.pan-name{font-size:13px;font-weight:800;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.pan-url{color:var(--muted);font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-top:2px}
|
||||
.pan-actions{flex-shrink:0;display:flex;gap:6px;align-items:center}
|
||||
.copy-btn{
|
||||
height:30px;padding:0 12px;border-radius:8px;
|
||||
background:rgba(255,255,255,.06);
|
||||
border:1px solid rgba(255,255,255,.12);
|
||||
color:rgba(255,255,255,.8);font-size:12px;font-weight:700;cursor:pointer;
|
||||
display:inline-flex;align-items:center;transition:background .12s;
|
||||
}
|
||||
.copy-btn:active{transform:scale(.95);opacity:.8}
|
||||
@media(hover:hover){.copy-btn:hover{background:rgba(255,255,255,.12)}}
|
||||
.pan-open-btn{
|
||||
height:30px;padding:0 14px;border-radius:8px;
|
||||
background:var(--accent);
|
||||
color:#fff;font-size:12px;font-weight:800;cursor:pointer;
|
||||
display:inline-flex;align-items:center;
|
||||
border:none;
|
||||
transition:opacity .12s,transform .1s;
|
||||
}
|
||||
.pan-open-btn:active{transform:scale(.95);opacity:.85}
|
||||
@media(hover:hover){.pan-open-btn:hover{opacity:.88}}
|
||||
|
||||
/* ===== 盘搜(PanSou 聚合网盘搜索)===== */
|
||||
.pan-cfg-btn{
|
||||
background:transparent;border:none;color:var(--muted);
|
||||
font-size:13px;cursor:pointer;padding:2px 4px;flex-shrink:0;
|
||||
line-height:1;transition:opacity .12s;
|
||||
}
|
||||
.pan-cfg-btn:active{opacity:.5}
|
||||
|
||||
/* ===== 自绘弹窗:替代原生 prompt/confirm,避免浏览器“网址为 xxx 显示”样式 ===== */
|
||||
.custom-modal-mask{
|
||||
position:fixed;top:0;right:0;bottom:0;left:0;inset:0;z-index:80;
|
||||
background:rgba(0,0,0,.55);
|
||||
backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
padding:24px;
|
||||
opacity:0;pointer-events:none;
|
||||
transition:opacity .2s ease;
|
||||
}
|
||||
.custom-modal-mask.show{opacity:1;pointer-events:auto}
|
||||
.custom-modal{
|
||||
width:100%;max-width:340px;
|
||||
background:linear-gradient(160deg,rgba(30,38,50,.98),rgba(18,24,32,.98));
|
||||
border:1px solid rgba(255,255,255,.12);
|
||||
border-top:1px solid rgba(255,255,255,.18);
|
||||
border-radius:var(--radius-lg);
|
||||
box-shadow:var(--shadow-deep);
|
||||
padding:18px 18px 14px;
|
||||
transform:translateY(10px) scale(.97);
|
||||
transition:transform .2s ease;
|
||||
}
|
||||
.custom-modal-mask.show .custom-modal{transform:translateY(0) scale(1)}
|
||||
.custom-modal-title{font-size:15px;font-weight:700;color:#fff;margin-bottom:12px}
|
||||
.custom-modal-input{
|
||||
width:100%;height:42px;border-radius:var(--radius-md);
|
||||
background:rgba(255,255,255,.06);
|
||||
border:1px solid rgba(255,255,255,.12);
|
||||
color:var(--text);font-size:14px;padding:0 12px;outline:0;
|
||||
transition:var(--transition-fast);
|
||||
}
|
||||
.custom-modal-input:focus{
|
||||
border-color:rgba(var(--accent-rgb),.45);
|
||||
box-shadow:0 0 0 3px rgba(var(--accent-rgb),.12);
|
||||
}
|
||||
.custom-modal-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:16px}
|
||||
.custom-modal-btn{
|
||||
border:none;background:rgba(255,255,255,.08);color:var(--text);
|
||||
font-size:14px;font-weight:600;padding:8px 16px;border-radius:var(--radius-md);
|
||||
cursor:pointer;transition:opacity .12s;
|
||||
}
|
||||
.custom-modal-btn:active{opacity:.6}
|
||||
.custom-modal-btn.accent{background:var(--accent-grad);color:#fff}
|
||||
/* ===== 盘搜:网盘类型多选 ===== */
|
||||
.pan-type-cfg{margin-top:14px}
|
||||
.pan-type-cfg-label{
|
||||
display:flex;align-items:center;justify-content:space-between;
|
||||
font-size:12px;color:var(--muted);font-weight:700;margin-bottom:8px;
|
||||
}
|
||||
.pan-type-all{
|
||||
border:none;background:rgba(var(--accent-rgb),.16);color:var(--accent);
|
||||
font-size:12px;font-weight:700;padding:3px 10px;border-radius:var(--radius-full);
|
||||
cursor:pointer;transition:opacity .12s;
|
||||
}
|
||||
.pan-type-all:active{opacity:.55}
|
||||
.pan-type-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}
|
||||
.pan-type-chip{
|
||||
display:flex;align-items:center;justify-content:center;gap:4px;
|
||||
height:38px;border-radius:var(--radius-md);
|
||||
background:rgba(255,255,255,.05);
|
||||
border:1px solid rgba(255,255,255,.12);
|
||||
color:var(--muted);font-size:13px;font-weight:700;
|
||||
cursor:pointer;-webkit-user-select:none;user-select:none;
|
||||
transition:var(--transition-fast);
|
||||
}
|
||||
.pan-type-chip:active{transform:scale(.96)}
|
||||
.pan-type-chip.on{
|
||||
background:rgba(var(--accent-rgb),.18);
|
||||
border-color:rgba(var(--accent-rgb),.5);
|
||||
color:#fff;
|
||||
}
|
||||
.pan-type-chip .tick{width:14px;height:14px;flex-shrink:0;opacity:0;transition:opacity .12s}
|
||||
.pan-type-chip.on .tick{opacity:1}
|
||||
.pan-search-tabs{
|
||||
display:flex;gap:6px;overflow-x:auto;padding-bottom:2px;margin-bottom:10px;
|
||||
-webkit-overflow-scrolling:touch;scrollbar-width:none;
|
||||
}
|
||||
.pan-search-tabs::-webkit-scrollbar{display:none}
|
||||
.pan-search-tab{
|
||||
flex-shrink:0;padding:6px 13px;border-radius:999px;
|
||||
font-size:12px;font-weight:800;white-space:nowrap;cursor:pointer;
|
||||
background:rgba(255,255,255,.05);
|
||||
border:1px solid rgba(255,255,255,.1);
|
||||
color:var(--muted);transition:var(--transition-fast);
|
||||
}
|
||||
.pan-search-tab.active{
|
||||
background:var(--accent);color:#fff;
|
||||
border-color:rgba(var(--accent-rgb),.5);
|
||||
}
|
||||
.pan-search-status{
|
||||
padding:18px 14px;text-align:center;color:var(--muted);font-size:13px;
|
||||
}
|
||||
.pan-health-dot{
|
||||
width:8px;height:8px;border-radius:50%;flex-shrink:0;
|
||||
background:rgba(255,255,255,.22);transition:background .2s;
|
||||
}
|
||||
.pan-health-dot[data-state="ok"]{background:var(--green);box-shadow:var(--green-glow)}
|
||||
.pan-health-dot[data-state="bad"]{background:#ff4d4f}
|
||||
.pan-health-dot[data-state="locked"]{background:#ffb020}
|
||||
.pan-health-dot[data-state="uncertain"]{background:rgba(255,255,255,.35)}
|
||||
.pan-health-dot[data-state="unsupported"]{visibility:hidden}
|
||||
|
||||
/* 加载骨架 */
|
||||
.det-loading{
|
||||
display:flex;flex-direction:column;align-items:center;justify-content:center;
|
||||
min-height:60vh;gap:14px;color:var(--muted);font-size:14px;
|
||||
}
|
||||
.det-spinner{
|
||||
width:36px;height:36px;border-radius:50%;
|
||||
border:3px solid rgba(255,255,255,.08);
|
||||
border-top-color:var(--accent);
|
||||
animation:det-spin .7s linear infinite;
|
||||
}
|
||||
@keyframes det-spin{to{transform:rotate(360deg)}}
|
||||
|
||||
.empty{
|
||||
padding:32px 16px;text-align:center;color:var(--muted);
|
||||
background:rgba(255,255,255,.03);
|
||||
border:1px dashed rgba(255,255,255,.1);
|
||||
border-radius:var(--radius-xl);font-size:14px;
|
||||
}
|
||||
.close-bar{display:none}
|
||||
|
||||
/* ========== 平板(≥600px)优化 ========== */
|
||||
@media(min-width:600px){
|
||||
.brand{font-size:22px}
|
||||
.btn{font-size:15px;padding:0 16px}
|
||||
}
|
||||
|
||||
/* ========== Tab S9 / 大平板(≥720px)========== */
|
||||
@media(min-width:720px){
|
||||
.panel{
|
||||
max-width:820px;margin:32px auto;
|
||||
border-radius:var(--radius-xl);
|
||||
min-height:auto;
|
||||
padding:24px 22px 32px;
|
||||
}
|
||||
.head{flex-direction:row;align-items:flex-start}
|
||||
.pic{width:160px}
|
||||
.title{font-size:22px}
|
||||
.pan-item{
|
||||
gap:14px;padding:14px 18px;
|
||||
}
|
||||
.pan-name{font-size:14px}
|
||||
.pan-actions{flex-wrap:nowrap}
|
||||
}
|
||||
|
||||
/* ========== 大屏平板 / 桌面(≥960px)========== */
|
||||
@media(min-width:960px){
|
||||
.app{max-width:1400px;margin:0 auto}
|
||||
}
|
||||
@media(min-width:1200px){
|
||||
.app{max-width:1400px;margin:0 auto}
|
||||
}
|
||||
|
||||
/* 盘搜框:固定约 6 条高度,资源数量变化时不再高低跳动,不足 6 条留白 */
|
||||
.pan-search-box .pan-list{height:400px;overflow-y:auto}
|
||||
/* 详情页“网盘资源 / 盘搜资源”两块:默认竖排堆叠 */
|
||||
.det-pan-cols{display:block}
|
||||
/* 平板横屏:两块并排、各自内部滚动、固定等高(对齐不层次不齐)*/
|
||||
@media(min-width:900px) and (orientation:landscape){
|
||||
.panel{max-width:1120px}
|
||||
.det-pan-cols{display:grid;grid-template-columns:1fr 1fr;gap:20px;align-items:start}
|
||||
.det-pan-col{min-width:0;display:flex;flex-direction:column}
|
||||
.det-pan-col>.det-sec-label{margin-top:0}
|
||||
.det-pan-col>.pan-list{height:400px;overflow-y:auto} /* 网盘资源框:定高,与盘搜框统一 */
|
||||
.det-pan-col>.pan-search-box{height:400px;display:flex;flex-direction:column} /* 盘搜框:与左侧等高 */
|
||||
.det-pan-col>.pan-search-box>.pan-list{flex:1;min-height:0;max-height:none;overflow-y:auto}
|
||||
}
|
||||
|
||||
/* ========== 悬停效果仅限有鼠标的设备 ========== */
|
||||
@media(hover:hover){
|
||||
.card:hover{
|
||||
transform:translateY(-3px);
|
||||
border-color:rgba(var(--accent2-rgb),.3);
|
||||
}
|
||||
.card:hover .poster img{transform:scale(1.05)}
|
||||
.card:active{transform:none;opacity:1}
|
||||
.btn:hover{background:rgba(60,72,88,.95);transform:translateY(-1px)}
|
||||
.btn.primary:hover{opacity:.92}
|
||||
.det-play-btn:hover{opacity:.92}
|
||||
.tab:hover,.chip:hover{background:rgba(50,60,74,.9);color:rgba(255,255,255,.75)}
|
||||
}
|
||||
/* ========== 回顶按钮(独立悬浮 · 可拖动贴边)========== */
|
||||
#backTop{
|
||||
position:fixed;
|
||||
right:0;
|
||||
top:58%;
|
||||
z-index:60;
|
||||
width:50px;height:50px;
|
||||
border-radius:25px 0 0 25px;
|
||||
background:rgba(28,34,46,.82);
|
||||
backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);
|
||||
border:1px solid rgba(255,255,255,.18);
|
||||
border-right:none;
|
||||
box-shadow:-3px 0 18px rgba(0,0,0,.45);
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
cursor:pointer;
|
||||
opacity:0;pointer-events:none;
|
||||
touch-action:none; /* 允许拖动,避免与页面滚动冲突 */
|
||||
transition:opacity .24s ease, transform .14s ease;
|
||||
}
|
||||
#backTop.show{opacity:1;pointer-events:auto}
|
||||
/* 详情页(.sheet)打开期间,回到顶部按钮不应出现——它的 z-index 高于详情页会悬浮遮挡 */
|
||||
body.sheet-open #backTop{opacity:0!important;pointer-events:none!important}
|
||||
/* 贴靠左侧时镜像圆角与阴影 */
|
||||
#backTop.left{
|
||||
border-radius:0 25px 25px 0;
|
||||
border-right:1px solid rgba(255,255,255,.18);
|
||||
border-left:none;
|
||||
box-shadow:3px 0 18px rgba(0,0,0,.45);
|
||||
}
|
||||
#backTop.dragging{opacity:1;transition:none;box-shadow:0 8px 24px rgba(0,0,0,.5)}
|
||||
#backTop:active{background:rgba(45,54,72,.92);transform:scale(.92)}
|
||||
#backTop svg{display:block;flex-shrink:0}
|
||||
|
||||
/* ========== 轻提示 toast(收藏反馈等)========== */
|
||||
#toast{
|
||||
position:fixed;left:50%;bottom:calc(var(--safe-bottom) + 96px);
|
||||
transform:translateX(-50%) translateY(10px);
|
||||
z-index:70;
|
||||
background:rgba(20,26,34,.96);color:#fff;
|
||||
padding:9px 18px;border-radius:999px;
|
||||
font-size:13px;font-weight:700;white-space:nowrap;
|
||||
border:1px solid rgba(255,255,255,.14);
|
||||
box-shadow:0 6px 22px rgba(0,0,0,.5);
|
||||
-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);
|
||||
opacity:0;pointer-events:none;
|
||||
transition:opacity .2s ease, transform .2s ease;
|
||||
}
|
||||
#toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
|
||||
|
||||
/* ========== 搜索记录 ========== */
|
||||
#searchHistory{
|
||||
position:absolute;left:-14px;right:-14px;top:calc(100% + 10px);z-index:60;
|
||||
max-height:240px;overflow-y:auto;overflow-x:hidden;
|
||||
-webkit-overflow-scrolling:touch;
|
||||
border-radius:var(--radius-lg);
|
||||
background:linear-gradient(150deg,rgba(var(--accent-rgb),.22),rgba(var(--accent2-rgb),.10) 60%,rgba(13,17,23,.55));
|
||||
backdrop-filter:blur(22px) saturate(1.5);-webkit-backdrop-filter:blur(22px) saturate(1.5);
|
||||
border:1px solid rgba(var(--accent2-rgb),.25);
|
||||
box-shadow:var(--shadow-deep);
|
||||
display:none;
|
||||
padding:4px 0;
|
||||
}
|
||||
#searchHistory.show{display:block}
|
||||
.sh-head{
|
||||
display:flex;align-items:center;justify-content:space-between;
|
||||
padding:8px 14px 4px;
|
||||
font-size:10px;font-weight:800;letter-spacing:1px;
|
||||
color:rgba(255,255,255,.32);
|
||||
}
|
||||
.sh-clear{
|
||||
color:rgba(255,255,255,.4);font-size:12px;font-weight:600;
|
||||
cursor:pointer;padding:2px 6px;border-radius:6px;
|
||||
}
|
||||
.sh-clear:active{color:var(--accent);background:rgba(255,255,255,.06)}
|
||||
.sh-item{
|
||||
display:flex;align-items:center;justify-content:space-between;gap:8px;
|
||||
padding:9px 14px;cursor:pointer;
|
||||
border-bottom:1px solid rgba(255,255,255,.05);
|
||||
}
|
||||
.sh-item:last-child{border-bottom:0}
|
||||
.sh-item:active{background:rgba(var(--accent-rgb),.18)}
|
||||
@media(hover:hover){.sh-item:hover{background:rgba(255,255,255,.06)}}
|
||||
.sh-kw{
|
||||
display:flex;align-items:center;gap:9px;min-width:0;
|
||||
font-size:14px;color:rgba(255,255,255,.85);
|
||||
overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
|
||||
}
|
||||
.sh-kw svg{flex-shrink:0;color:rgba(255,255,255,.32)}
|
||||
.sh-del{
|
||||
flex-shrink:0;width:22px;height:22px;display:grid;place-items:center;
|
||||
color:rgba(255,255,255,.32);border-radius:50%;
|
||||
}
|
||||
.sh-del:active{color:var(--accent);background:rgba(255,255,255,.08)}
|
||||
.sh-empty{padding:18px 14px;text-align:center;font-size:12px;color:rgba(255,255,255,.35)}
|
||||
/* ===== 旧内核降级:flex gap 兼容层(Chromium <84 不支持 flex gap,间距会消失)
|
||||
由 JS 特征检测在不支持时给 <html> 加 no-layout-gap 类,这里用外边距兜底 ===== */
|
||||
html.no-layout-gap .search-loading{gap:0}
|
||||
html.no-layout-gap .search-loading > * + *{margin-top:14px}
|
||||
html.no-layout-gap .bar{gap:0}
|
||||
html.no-layout-gap .bar > * + *{margin-left:8px}
|
||||
html.no-layout-gap .search{gap:0}
|
||||
html.no-layout-gap .search > * + *{margin-left:8px}
|
||||
html.no-layout-gap .sugg-item{gap:0}
|
||||
html.no-layout-gap .sugg-item > * + *{margin-left:11px}
|
||||
html.no-layout-gap .sugg-meta{gap:0}
|
||||
html.no-layout-gap .sugg-meta > * + *{margin-left:7px}
|
||||
html.no-layout-gap .src-row{gap:0}
|
||||
html.no-layout-gap .src-row > * + *{margin-left:18px}
|
||||
html.no-layout-gap .chips{gap:0}
|
||||
html.no-layout-gap .chips > * + *{margin-left:8px}
|
||||
html.no-layout-gap .det-topbar{gap:0}
|
||||
html.no-layout-gap .det-topbar > * + *{margin-left:10px}
|
||||
html.no-layout-gap .det-collapse-head{gap:0}
|
||||
html.no-layout-gap .det-collapse-head > * + *{margin-left:12px}
|
||||
html.no-layout-gap .det-res-tabs{gap:0}
|
||||
html.no-layout-gap .det-res-tabs > * + *{margin-left:10px}
|
||||
html.no-layout-gap .det-poster-row{gap:0}
|
||||
html.no-layout-gap .det-poster-row > * + *{margin-left:16px}
|
||||
html.no-layout-gap .det-eyebrow{gap:0}
|
||||
html.no-layout-gap .det-eyebrow > * + *{margin-left:6px}
|
||||
html.no-layout-gap .det-pills{gap:0}
|
||||
html.no-layout-gap .det-pills > * + *{margin-left:6px}
|
||||
html.no-layout-gap .det-actions{gap:0}
|
||||
html.no-layout-gap .det-actions > * + *{margin-left:8px}
|
||||
html.no-layout-gap .det-sec-label{gap:0}
|
||||
html.no-layout-gap .det-sec-label > * + *{margin-left:10px}
|
||||
html.no-layout-gap .pan-list{gap:0}
|
||||
html.no-layout-gap .pan-list > * + *{margin-top:8px}
|
||||
html.no-layout-gap .pan-item{gap:0}
|
||||
html.no-layout-gap .pan-item > * + *{margin-left:12px}
|
||||
html.no-layout-gap .pan-actions{gap:0}
|
||||
html.no-layout-gap .pan-actions > * + *{margin-left:6px}
|
||||
html.no-layout-gap .custom-modal-actions{gap:0}
|
||||
html.no-layout-gap .custom-modal-actions > * + *{margin-left:8px}
|
||||
html.no-layout-gap .pan-type-chip{gap:0}
|
||||
html.no-layout-gap .pan-type-chip > * + *{margin-left:4px}
|
||||
html.no-layout-gap .pan-search-tabs{gap:0}
|
||||
html.no-layout-gap .pan-search-tabs > * + *{margin-left:6px}
|
||||
html.no-layout-gap .det-loading{gap:0}
|
||||
html.no-layout-gap .det-loading > * + *{margin-top:14px}
|
||||
html.no-layout-gap .sh-item{gap:0}
|
||||
html.no-layout-gap .sh-item > * + *{margin-left:8px}
|
||||
html.no-layout-gap .sh-kw{gap:0}
|
||||
html.no-layout-gap .sh-kw > * + *{margin-left:9px}
|
||||
|
||||
/* ===== 旧内核降级:aspect-ratio 兼容层(Chromium <88 不支持,高度会塌缩为 0)
|
||||
由 JS 特征检测(含渲染实测,CSS.supports 在部分内核上不可靠)在不支持时给 <html> 加 no-aspect-ratio 类 ===== */
|
||||
html.no-aspect-ratio .poster{aspect-ratio:none;height:0;padding-top:133.33%}
|
||||
html.no-aspect-ratio .det-hero{aspect-ratio:none;height:0;padding-top:150%}
|
||||
html.no-aspect-ratio .det-hero.landscape,
|
||||
html.no-aspect-ratio .sheet.landscape-mode .det-hero{padding-top:0;height:auto}
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="bgfog"></div>
|
||||
<div id="searchFocusMask"></div>
|
||||
<div id="searchOverlay"><div class="search-loading"><div class="det-spinner"></div><span>聚合搜索中…</span></div></div>
|
||||
<div class="app">
|
||||
<div class="bar">
|
||||
<div class="bar-bottom">
|
||||
<div class="search-wrap">
|
||||
<form class="search" id="searchForm" style="padding:0 6px 0 6px">
|
||||
<button class="btn btn-icon" id="aggBtn" title="切换聚合/单站" style="position:relative;z-index:5;overflow:visible;width:30px;height:30px;padding:0;flex-shrink:0;background:rgba(255,255,255,.08);border-color:rgba(255,255,255,.15);color:#fff">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M5 12h14"/><path d="M5 6l7 6-7 6"/><path d="M12 6l7 6-7 6"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div style="width:1px;height:18px;background:rgba(255,255,255,.15);flex-shrink:0"></div>
|
||||
<input id="kw" placeholder="玩偶聚合搜索…">
|
||||
<button class="btn primary" style="width:36px;height:32px;padding:0;min-width:unset;flex-shrink:0" title="搜索">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
</button>
|
||||
<div class="search-sugg" id="searchSugg"></div>
|
||||
<div id="searchHistory"></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navrow">
|
||||
<div class="src-row" id="srcRow"></div>
|
||||
<div class="src-sep"></div>
|
||||
<div class="chips" id="chips"></div>
|
||||
</div>
|
||||
<div class="status" id="status">准备加载…</div>
|
||||
<main id="content"></main>
|
||||
</div>
|
||||
<button id="backTop" aria-label="回到顶部">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,.9)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="18 15 12 9 6 15"/>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- 自绘弹窗:替代 prompt(),用于设置盘搜接口地址 -->
|
||||
<div class="custom-modal-mask" id="panCfgMask">
|
||||
<div class="custom-modal">
|
||||
<div class="custom-modal-title">盘搜设置</div>
|
||||
<input class="custom-modal-input" id="panCfgInput" type="text" inputmode="url" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||
<div class="pan-type-cfg" id="panTypeCfg" style="display:none">
|
||||
<div class="pan-type-cfg-label"><span>网盘类型(多选)</span><button type="button" class="pan-type-all" id="panTypeAll">全选</button></div>
|
||||
<div class="pan-type-grid" id="panTypeGrid"></div>
|
||||
</div>
|
||||
<div class="custom-modal-actions">
|
||||
<button class="custom-modal-btn" id="panCfgCancel">取消</button>
|
||||
<button class="custom-modal-btn accent" id="panCfgOk">确定</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Emby 风格全屏海报背景:独立于 sheet(放在 sheet 外),避免 sheet 的 transform 让 fixed 失效 -->
|
||||
<div class="det-hero-bg" id="detHeroBg"><div class="det-hero-bg-img" id="detHeroBgA"></div><div class="det-hero-bg-img" id="detHeroBgB"></div><div class="det-hero-scrim"></div></div>
|
||||
<div class="sheet" id="sheet">
|
||||
<!-- 独立海报大图:竖版按2:3比例展示,横版由JS检测后自动切换为16:9比例 -->
|
||||
<div class="det-hero" id="detHero">
|
||||
<img class="det-hero-img" id="detHeroImg" referrerpolicy="no-referrer" src="" alt="">
|
||||
<div class="det-hero-cta" id="detHeroCta"></div>
|
||||
</div>
|
||||
<!-- 吸顶导航:已去除返回按钮,仅显示标题 -->
|
||||
<div class="det-topbar" id="detTopbar">
|
||||
<div class="det-topbar-title" id="detTopbarTitle"></div>
|
||||
</div>
|
||||
<!-- 内容 -->
|
||||
<div class="panel" id="panel"></div>
|
||||
</div>
|
||||
<script>(function(){'use strict';
|
||||
|
||||
/* ========================================================
|
||||
全屏 / Chrome 模式管理
|
||||
文档:fm.ui.setChrome / fm.ui.restoreChrome / fmviewport
|
||||
======================================================== */
|
||||
var _chromeReady = false;
|
||||
|
||||
/* 初始化 edge 全屏融合模式(首页推荐模式:隐藏原生顶底栏,WebView 铺满窗口) */
|
||||
function initChrome() {
|
||||
if (_chromeReady || !window.fm || !fm.ui) return;
|
||||
_chromeReady = true;
|
||||
try {
|
||||
fm.ui.setChrome({
|
||||
mode: 'edge',
|
||||
statusBarStyle: 'light',
|
||||
navigationBarStyle: 'light',
|
||||
restoreAffordance: 'auto',
|
||||
scrim: { top: 'transparent', bottom: 'transparent' },
|
||||
startup: true
|
||||
});
|
||||
} catch(e) {}
|
||||
// 首帧主动读一次当前视口状态,fmviewport 事件只在后续变化时才触发
|
||||
try {
|
||||
var vp = fm.ui.getViewport && fm.ui.getViewport();
|
||||
if (vp && vp.chromeMode) _applyChromeModeClass(vp.chromeMode);
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
/* 监听 Native 安全区变化,同步更新 CSS 变量 */
|
||||
window.addEventListener('fmviewport', function(e) {
|
||||
var d = e.detail || {};
|
||||
var r = document.documentElement.style;
|
||||
if (d.safeTop !== undefined) r.setProperty('--safe-top', d.safeTop + 'px');
|
||||
if (d.safeBottom !== undefined) r.setProperty('--safe-bottom', d.safeBottom + 'px');
|
||||
if (d.safeLeft !== undefined) r.setProperty('--safe-left', d.safeLeft + 'px');
|
||||
if (d.safeRight !== undefined) r.setProperty('--safe-right', d.safeRight + 'px');
|
||||
if (d.chromeMode !== undefined) _applyChromeModeClass(d.chromeMode);
|
||||
});
|
||||
|
||||
/* 根据 chromeMode 给 html 打 class,非全屏(normal)时去掉顶部多余的安全区叠加 */
|
||||
function _applyChromeModeClass(mode) {
|
||||
var cl = document.documentElement.classList;
|
||||
cl.toggle('chrome-normal', mode === 'normal');
|
||||
cl.toggle('chrome-edge', mode !== 'normal');
|
||||
}
|
||||
|
||||
/* ── History 哨兵 + popstate(参考 Eclipse) ──
|
||||
栈结构(底→顶):[sentinel(无hash), detail(#detail)]
|
||||
popstate 只看实际 UI 状态,不依赖 event.state */
|
||||
|
||||
// 初始化:清除残留 hash,建立底层哨兵
|
||||
if (history.scrollRestoration) history.scrollRestoration = 'manual';
|
||||
if (location.hash) history.replaceState({}, '', location.pathname + location.search);
|
||||
|
||||
function _ensureHome() {
|
||||
// 回到主页时补一条无 hash 哨兵,防止再 back 退出 App
|
||||
if (!location.hash)
|
||||
history.pushState({ wo: 'home' }, '', location.pathname + location.search);
|
||||
}
|
||||
|
||||
// 是否处于搜索结果页:直接读内容区实际状态,不用 JS 标志位(更不容易和真实状态错位)
|
||||
function _inSearchMode(){
|
||||
return !!content && content.dataset.mode === 'search';
|
||||
}
|
||||
function _enterSearchHistory() {
|
||||
if (location.hash !== '#search')
|
||||
history.pushState({ wo: 'search' }, '', '#search');
|
||||
}
|
||||
function _exitSearchToHome() {
|
||||
_searchGen++; // 让仍在后台跑的旧搜索结果作废
|
||||
_catActive=true;
|
||||
if (kw) kw.value = '';
|
||||
hideSugg();
|
||||
_clearSearchUI();
|
||||
const snap = _catSnapshot;
|
||||
// 站源 + 分类一致且有缓存内容 → 直接还原,不再发网络请求
|
||||
if (snap && snap.site === activeSite && snap.cat === activeCat && snap.list && snap.list.length) {
|
||||
page = snap.page || 1;
|
||||
renderGrid(snap.list, true);
|
||||
if (snap.statusText) status.textContent = snap.statusText;
|
||||
requestAnimationFrame(() => window.scrollTo(0, snap.scrollY || 0));
|
||||
} else {
|
||||
loadCategory();
|
||||
}
|
||||
}
|
||||
|
||||
function _doCloseSheet() {
|
||||
const sheet = $('#sheet');
|
||||
if (!sheet.classList.contains('active')) return;
|
||||
sheet.classList.remove('landscape-mode'); // 先恢复 transform 能力,否则滑出动画会被 transform:none 卡住
|
||||
stopBgRotation();
|
||||
{const bg=document.getElementById('detHeroBg');if(bg)bg.classList.remove('show')} // 隐藏全屏海报背景
|
||||
sheet.classList.remove('active');
|
||||
document.body.classList.remove('sheet-open');
|
||||
const topbar = $('#detTopbar');
|
||||
if (topbar) topbar.classList.remove('scrolled');
|
||||
if (sheet._detScrollCb) {
|
||||
sheet.removeEventListener('scroll', sheet._detScrollCb);
|
||||
sheet._detScrollCb = null;
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('popstate', function() {
|
||||
if ($('#sheet').classList.contains('active')) {
|
||||
_doCloseSheet();
|
||||
_ensureHome();
|
||||
return;
|
||||
}
|
||||
if (_inSearchMode()) {
|
||||
_exitSearchToHome();
|
||||
_ensureHome();
|
||||
return;
|
||||
}
|
||||
if (document.documentElement.classList.contains('search-focused')) {
|
||||
document.documentElement.classList.remove('search-focused');
|
||||
hideSugg();
|
||||
kw.blur();
|
||||
_ensureHome();
|
||||
return;
|
||||
}
|
||||
// 已在主页,放行(宿主处理退出)
|
||||
});
|
||||
|
||||
|
||||
if (window.fm && window.fm.ui) {
|
||||
initChrome();
|
||||
} else {
|
||||
var _chromeTimer = 0, _chromeTries = 0;
|
||||
_chromeTimer = setInterval(function() {
|
||||
if (window.fm && window.fm.ui) { clearInterval(_chromeTimer); initChrome(); return; }
|
||||
if (++_chromeTries > 30) clearInterval(_chromeTimer);
|
||||
}, 100);
|
||||
}
|
||||
|
||||
/* ======================================================== */
|
||||
// 本地存储持久化:收藏 + 历史记录
|
||||
const STORAGE_KEY_FAV = 'wo_fav_list';
|
||||
const STORAGE_KEY_HIST = 'wo_history_list';
|
||||
const STORAGE_KEY_SEARCH_HIST = 'wo_search_kw_history';
|
||||
let favList = JSON.parse(localStorage.getItem(STORAGE_KEY_FAV) || '[]');
|
||||
let historyList = JSON.parse(localStorage.getItem(STORAGE_KEY_HIST) || '[]');
|
||||
let searchKwHistory = (()=>{try{return JSON.parse(localStorage.getItem(STORAGE_KEY_SEARCH_HIST)||'[]')}catch(e){return []}})();
|
||||
|
||||
if(window.fongmiBridge||window.fm)document.documentElement.classList.add('fm-native');
|
||||
const SITES=[
|
||||
{id:'wanou',name:'玩偶',domains:['https://wogg.xxooo.cf','https://www.wogg.net'],listSelector:'.module-item',searchListSelector:'.module-search-item',detailPanSelector:'.module-row-info p',categoryUrl:'/vodshow/{categoryId}--------{page}---.html',searchUrl:'/vodsearch/-------------.html?wd={keyword}&page={page}',cats:[['history','最近'],['fav','收藏'],['44','臻彩'],['1','电影'],['2','电视剧'],['3','动漫'],['4','综艺'],['5','音乐'],['6','短剧'],['46','纪录片']]},
|
||||
{id:'muou',name:'木偶',domains:['https://www.muou.site','https://www.muou.asia','https://666.666291.xyz'],listSelector:'#main .module-item',searchListSelector:'.module-search-item',detailPanSelector:'.module-row-info p',cats:[['25','臻选'],['1','电影'],['2','电视剧'],['3','动漫'],['4','纪录片'],['29','综艺'],['30','原盘']]},
|
||||
{id:'labi',name:'蜡笔',domains:['http://xiaocgege.shop','http://xiaocge.fun','http://feimo.fun'],listSelector:'#main .module-item',searchListSelector:'.module-search-item',detailPanSelector:'.module-row-info p',cats:[['29','臻彩'],['1','电影'],['2','电视剧'],['3','动漫'],['4','综艺'],['5','短剧'],['24','蜡笔4K']]},
|
||||
{id:'zhizhen',name:'至臻',domains:['https://zhizhen1.top','https://www.mihdr.top','https://mihdr.top'],listSelector:'#main .module-item',searchListSelector:'.module-search-item',detailPanSelector:'.module-row-info p',cats:[['26','臻彩'],['1','电影'],['2','电视剧'],['3','动漫'],['4','综艺'],['5','短剧'],['24','老剧']]},
|
||||
{id:'erxiao',name:'二小',domains:['https://www.2xiaopan.top'],listSelector:'#main .module-item',searchListSelector:'.module-search-item',detailPanSelector:'.module-row-info p',cats:[['4','臻彩'],['1','电影'],['2','电视剧'],['3','动漫'],['21','综艺']]},
|
||||
{id:'huban',name:'虎斑',domains:['http://38.76.197.172:16969','http://103.45.162.207:20720','http://xhban.xyz:20720'],listSelector:'#main .module-item',searchListSelector:'.module-search-item',detailPanSelector:'.module-row-info p',cats:[['6','臻彩'],['1','电影'],['2','电视剧'],['3','综艺'],['4','动漫'],['5','短剧'],['30','115网盘']]},
|
||||
{id:'kuaiying',name:'快映',domains:['http://103.45.162.207:12512','http://38.76.197.172:12521'],listSelector:'#main .module-item',searchListSelector:'.module-search-item',detailPanSelector:'.module-row-info p',cats:[['5','臻彩'],['1','电影'],['2','电视剧'],['3','综艺'],['4','动漫'],['6','短剧'],['30','115'],['35','123'],['36','天移迅']]},
|
||||
{id:'shandian',name:'闪电',domains:['http://shandian.blog','http://sd.sduc.site'],listSelector:'#main .module-item',searchListSelector:'.module-search-item',detailPanSelector:'.module-row-info p',cats:[['1','电影'],['2','电视剧'],['3','综艺'],['4','动漫'],['30','短剧']]},
|
||||
{id:'ouge',name:'欧哥',domains:['https://woog.nxog.eu.org'],listSelector:'#main .module-item',searchListSelector:'.module-search-item',detailPanSelector:'.module-row-info p',cats:[['1','电影'],['2','电视剧'],['3','动漫'],['4','综艺'],['5','短剧'],['21','综合']]}
|
||||
];
|
||||
let activeSite='wanou',activeCat='',page=1,aggregate=true,last=[];
|
||||
// 是否处于“分类浏览”态,专门给左右滑动手势用。
|
||||
// 之前滑动判定依赖 content.dataset.mode==='category',但这个值要等一次分类请求真正渲染完成才会被设置,
|
||||
// 导致“滑到下一类、还在加载”的这段时间里 dataset.mode 可能还是上一次的值(甚至是 search),滑动手势被锁住、必须等加载完。
|
||||
// 改成这个显式标志:一旦“决定要看分类”(滑动/点击分类、切站源、从搜索返回分类)就立刻置 true,不等网络请求结果,做到跟手;
|
||||
// 只有真正离开分类浏览(进入搜索)才置 false。
|
||||
let _catActive=true;
|
||||
const CATS_CACHE={}; // 各站自动抓取到的真实分类缓存(按 siteId)
|
||||
const $=s=>document.querySelector(s),chips=$('#chips'),content=$('#content'),status=$('#status');
|
||||
|
||||
// ===== 自绘输入弹窗:替代原生 prompt(),避免浏览器自带的“网址为 xxx 的网页显示”样式 =====
|
||||
function customPrompt(title,defaultValue){
|
||||
return new Promise(resolve=>{
|
||||
const mask=$('#panCfgMask'),input=$('#panCfgInput'),okBtn=$('#panCfgOk'),cancelBtn=$('#panCfgCancel'),titleEl=mask&&mask.querySelector('.custom-modal-title');
|
||||
if(!mask){resolve(null);return}
|
||||
const typeCfg=$('#panTypeCfg'); if(typeCfg) typeCfg.style.display='none';
|
||||
if(titleEl) titleEl.textContent=title||'';
|
||||
input.value=defaultValue||'';
|
||||
mask.classList.add('show');
|
||||
requestAnimationFrame(()=>{input.focus();input.select()});
|
||||
let done=false;
|
||||
const finish=(val)=>{
|
||||
if(done)return;done=true;
|
||||
mask.classList.remove('show');
|
||||
okBtn.onclick=null;cancelBtn.onclick=null;mask.onclick=null;input.onkeydown=null;
|
||||
resolve(val);
|
||||
};
|
||||
okBtn.onclick=()=>finish(input.value);
|
||||
cancelBtn.onclick=()=>finish(null);
|
||||
mask.onclick=(e)=>{ if(e.target===mask) finish(null); };
|
||||
input.onkeydown=(e)=>{
|
||||
if(e.key==='Enter'){e.preventDefault();finish(input.value)}
|
||||
else if(e.key==='Escape'){e.preventDefault();finish(null)}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// 常用 DOM 引用缓存,避免热路径里反复查询
|
||||
const appEl=$('.app'),navrowEl=$('.navrow'),searchOverlayEl=$('#searchOverlay');
|
||||
// 分类返回快照:进入搜索前记下分类列表/滚动位置/页码,返回时秒级还原(不再重新请求)
|
||||
let _catSnapshot=null;
|
||||
// 左右滑动切换分类的临时状态
|
||||
let _pendingSlide=null,_swiped=false;
|
||||
// 统一清理搜索态的视觉效果(去模糊、关遮罩、退出聚焦)
|
||||
function _clearSearchUI(){
|
||||
if(navrowEl)navrowEl.classList.remove('blurred');
|
||||
content.classList.remove('searching');
|
||||
if(appEl)appEl.classList.remove('searching');
|
||||
if(searchOverlayEl)searchOverlayEl.classList.remove('show');
|
||||
document.documentElement.classList.remove('search-focused');
|
||||
}
|
||||
|
||||
// 本地存储工具函数
|
||||
function saveFav(){localStorage.setItem(STORAGE_KEY_FAV,JSON.stringify(favList))}
|
||||
function saveHistory(){localStorage.setItem(STORAGE_KEY_HIST,JSON.stringify(historyList))}
|
||||
function saveSearchKwHistory(){try{localStorage.setItem(STORAGE_KEY_SEARCH_HIST,JSON.stringify(searchKwHistory))}catch(e){}}
|
||||
function addSearchKwHistory(q){
|
||||
q=String(q||'').trim();
|
||||
if(!q)return;
|
||||
const idx=searchKwHistory.indexOf(q);
|
||||
if(idx>-1)searchKwHistory.splice(idx,1);
|
||||
searchKwHistory.unshift(q);
|
||||
if(searchKwHistory.length>30)searchKwHistory.length=30;
|
||||
saveSearchKwHistory();
|
||||
}
|
||||
function isFav(item){return favList.some(x=>x.href===item.href)}
|
||||
// 轻提示
|
||||
let _toastTimer=null;
|
||||
function toast(msg){
|
||||
let el=document.getElementById('toast');
|
||||
if(!el){el=document.createElement('div');el.id='toast';document.body.appendChild(el)}
|
||||
el.textContent=msg;el.classList.add('show');
|
||||
clearTimeout(_toastTimer);_toastTimer=setTimeout(()=>el.classList.remove('show'),1500);
|
||||
}
|
||||
function toggleFav(item){
|
||||
const idx = favList.findIndex(x=>x.href===item.href);
|
||||
const willFav = idx<0;
|
||||
if(idx>-1) favList.splice(idx,1);
|
||||
else favList.unshift(item);
|
||||
saveFav();
|
||||
// 正在浏览“收藏”分类时,取消收藏需从列表移除并重渲染
|
||||
if(content.dataset.mode==='category' && activeCat==='fav'){
|
||||
// 延后一帧重建网格:避免在手指仍按住(长按触发)时同步拆除被触摸的卡片,
|
||||
// 否则会残留卡片的 :active 缩放态并导致后续点击无响应
|
||||
status.textContent='我的收藏 · '+favList.length+' 条';
|
||||
requestAnimationFrame(()=>{
|
||||
if(activeCat==='fav'){renderGrid(favList,false);content.dataset.mode='category'}
|
||||
});
|
||||
}else{
|
||||
// 其它情况仅就地切换对应卡片的描边态,避免整页重渲染
|
||||
[...content.querySelectorAll('.card')].forEach(c=>{
|
||||
if(c.dataset.href===item.href) c.classList.toggle('is-fav',willFav);
|
||||
});
|
||||
}
|
||||
toast(willFav?'已收藏':'已取消收藏');
|
||||
return willFav;
|
||||
}
|
||||
function addHistory(item){
|
||||
const idx = historyList.findIndex(x=>x.href===item.href);
|
||||
if(idx>-1) historyList.splice(idx,1);
|
||||
historyList.unshift(item);
|
||||
if(historyList.length>60) historyList.length=60;
|
||||
saveHistory();
|
||||
}
|
||||
|
||||
function esc(s){return String(s||'').replace(/[&<>'"]/g,c=>({'&':'&','<':'<','>':'>',"'":''','"':'"'}[c]))}
|
||||
function clean(s){return String(s||'').replace(/\s+/g,' ').trim()}
|
||||
function site(){return SITES.find(x=>x.id===activeSite)||SITES[0]}
|
||||
function abs(base,u){try{return new URL(u,base+'/').href}catch(e){return u||''}}
|
||||
// 等待原生桥接注入:App 可能在页面脚本之后才注入 window.fm,最多等 4 秒
|
||||
let _fmReadyP=null;
|
||||
function fmReady(){
|
||||
if(_fmReadyP)return _fmReadyP;
|
||||
_fmReadyP=new Promise(res=>{
|
||||
if(window.fm)return res(window.fm);
|
||||
let t=0;const iv=setInterval(()=>{t+=60;if(window.fm||t>4000){clearInterval(iv);res(window.fm||null)}},60);
|
||||
});
|
||||
return _fmReadyP;
|
||||
}
|
||||
// 仅在「非 App 的浏览器网页」且直连失败时才会用到的 CORS 代理兜底(App 内不会走到这里)
|
||||
const CORS_PROXIES=[
|
||||
u=>'https://api.allorigins.win/raw?url='+encodeURIComponent(u),
|
||||
u=>'https://corsproxy.io/?url='+encodeURIComponent(u)
|
||||
];
|
||||
async function req(url,timeoutSec=16,quiet=false){
|
||||
if(!quiet)status.textContent='请求:'+url;
|
||||
// ① 原生 App 桥接:直连(等桥接就绪后再判断,避免早于注入时机误走浏览器 fetch)
|
||||
const fm=await fmReady();
|
||||
if(fm&&fm.req){
|
||||
let r=await fm.req(url,{method:'GET',headers:{'User-Agent':'Mozilla/5.0','Referer':url},responseType:'text',timeout:timeoutSec});
|
||||
if(!r.ok)throw new Error(r.error||('HTTP '+r.status));
|
||||
return r.body||''
|
||||
}
|
||||
// ② 没有原生桥接(纯浏览器网页)才走这里:先直连
|
||||
try{
|
||||
const ac=new AbortController();const t=setTimeout(()=>ac.abort(),timeoutSec*1000);
|
||||
let r;
|
||||
try{r=await fetch(url,{headers:{'User-Agent':'Mozilla/5.0'},signal:ac.signal})}finally{clearTimeout(t)}
|
||||
return await r.text()
|
||||
}catch(e){
|
||||
// 直连被 CORS / 混合内容拦截时,再尝试 CORS 代理(仅浏览器网页场景)
|
||||
let lastErr=e.message;
|
||||
for(const mk of CORS_PROXIES){
|
||||
try{let r=await fetch(mk(url));if(r.ok){let t=await r.text();if(t)return t}lastErr='HTTP '+r.status}
|
||||
catch(err){lastErr=err.message}
|
||||
}
|
||||
throw new Error(lastErr||'请求失败');
|
||||
}
|
||||
}
|
||||
// ===== 可用域名缓存 =====
|
||||
// 过去多域名站每次请求都「同时」向所有备用域名发请求。聚合搜索时 9 个站一起跑,
|
||||
// 每站再放大成 2~3 个并发,瞬间二十多个请求打满原生网络桥接,排在后面的站全超时——
|
||||
// 这正是「聚合只出最前面玩偶/木偶、后面站搜不到」的根因(单站搜只发 1 个站故不受影响)。
|
||||
// 改为:记住每个站上次成功的那个域名,之后只打这一个(单连接),把聚合并发压到约 9 个;
|
||||
// 缓存域名失效时才回退到并发竞速重新发现,并更新缓存。持久化到 localStorage 跨会话复用。
|
||||
const DOMAIN_CACHE=(()=>{try{return JSON.parse(localStorage.getItem('wo_domain_cache')||'{}')}catch(e){return {}}})();
|
||||
function _saveDomainCache(){try{localStorage.setItem('wo_domain_cache',JSON.stringify(DOMAIN_CACHE))}catch(e){}}
|
||||
async function get(site,path,timeoutSec=16,quiet=false){
|
||||
// 绝对 URL(例如详情页 href,已经带着真实域名):不依赖站源域名列表,直接请求一次。
|
||||
if(path.startsWith('http')){
|
||||
let h=await req(path,timeoutSec,quiet);
|
||||
if(!h||/just a moment|cf-browser|access denied/i.test(h))throw new Error('响应被拦截或为空');
|
||||
let base=path;try{const u=new URL(path);base=u.protocol+'//'+u.host}catch(e){}
|
||||
return{html:h,base}
|
||||
}
|
||||
const domains=site.domains;
|
||||
// 单个域名探测:成功返回 {html,base},失败抛错
|
||||
const tryOne=async(d,to)=>{
|
||||
const base=d.replace(/\/+$/,''),u=base+path;
|
||||
let h=await req(u,to,quiet);
|
||||
if(!h||/just a moment|cf-browser|access denied/i.test(h))throw new Error('域名被拦截或无响应');
|
||||
return{html:h,base,_domain:d}
|
||||
};
|
||||
if(domains.length===1){
|
||||
return await tryOne(domains[0],timeoutSec);
|
||||
}
|
||||
// 多域名:① 优先只打「上次成功的域名」(单连接,大幅降低聚合并发压力)
|
||||
const cached=DOMAIN_CACHE[site.id];
|
||||
if(cached&&domains.includes(cached)){
|
||||
try{
|
||||
// 缓存域名给稍短超时快速验证;通过就直接用,不再惊动其他域名
|
||||
return await tryOne(cached,Math.min(timeoutSec,10));
|
||||
}catch(e){ /* 缓存域名失效,落到下面并发竞速重新发现 */ }
|
||||
}
|
||||
// ② 没有可用缓存:并发竞速所有域名,谁先成功用谁,并把它记进缓存供下次单连接复用
|
||||
return new Promise((resolve,reject)=>{
|
||||
let pending=domains.length,lastErr='',settled=false;
|
||||
domains.forEach(d=>{
|
||||
const base=d.replace(/\/+$/,''),u=base+path;
|
||||
req(u,timeoutSec,quiet).then(h=>{
|
||||
if(settled)return;
|
||||
if(h&&!/just a moment|cf-browser|access denied/i.test(h)){
|
||||
settled=true;
|
||||
if(DOMAIN_CACHE[site.id]!==d){DOMAIN_CACHE[site.id]=d;_saveDomainCache()}
|
||||
resolve({html:h,base});
|
||||
}
|
||||
else{lastErr='响应被拦截';if(--pending===0&&!settled)reject(new Error(lastErr||'全部域名失败'))}
|
||||
}).catch(e=>{
|
||||
if(settled)return;
|
||||
lastErr=e.message;
|
||||
if(--pending===0&&!settled)reject(new Error(lastErr||'全部域名失败'));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
function doc(h){return new DOMParser().parseFromString(h,'text/html')}
|
||||
function imgUrl(u){
|
||||
if(!u)return'';
|
||||
u=u.replace(/^\/\//,'https://');
|
||||
let m=u.match(/^https?:\/\/image\.baidu\.com\/search\/down\?url=([^&]+)/i);
|
||||
return m?decodeURIComponent(m[1]):u
|
||||
}
|
||||
// 自动识别清晰度
|
||||
function getQuality(text){
|
||||
if(/4K|2160/i.test(text))return'4K';
|
||||
if(/1080|1080P/i.test(text))return'1080P';
|
||||
if(/720|720P/i.test(text))return'720P';
|
||||
return'';
|
||||
}
|
||||
// 去除站源标题里常见的"立即播放/立刻播放/在线观看 XX"之类统一文案前缀,
|
||||
// 只保留真正的片名部分,避免污染显示标题和 TMDB 匹配
|
||||
function stripTitleNoise(t){
|
||||
const orig=String(t||'').trim();
|
||||
let r=orig
|
||||
.replace(/^[【\[]?(?:立即|立刻|马上|火速|超清|高清|蓝光)?(?:播放|观看|看片|点播|追剧)(?:[::、\s]+)?/,'')
|
||||
.replace(/^(?:在线观看|在线播放|免费观看|免费播放)(?:[::、\s]+)?/,'')
|
||||
.trim();
|
||||
// 保护:若清洗后为空,或剩余内容过短(≤1字,疑似把片名也吃掉了),则保留原标题
|
||||
if(!r||r.length<=1)return orig;
|
||||
return r;
|
||||
}
|
||||
function cardFrom(el,base,site){
|
||||
let a=el.querySelector('a[href]')||el.closest('a[href]')||el,href=a.getAttribute('href')||'';
|
||||
let img=el.querySelector('img'),title=a.getAttribute('title')||img&&img.getAttribute('alt')||clean((el.querySelector('.module-item-title,.module-search-item-title,.video-title,.title')||{}).textContent)||clean(a.textContent);
|
||||
let pic=imgUrl(img&&(img.getAttribute('data-src')||img.getAttribute('data-original')||img.getAttribute('src'))||'');
|
||||
let remark=clean((el.querySelector('.module-item-note,.module-search-item-note,.video-info,.note')||{}).textContent)||site.name;
|
||||
return{
|
||||
title:stripTitleNoise(title.replace(/\s+/g,' ')).slice(0,60),
|
||||
href:abs(base,href),
|
||||
pic:abs(base,pic),
|
||||
remark,
|
||||
siteId:site.id,
|
||||
siteName:site.name,
|
||||
quality:getQuality(title+' '+remark)
|
||||
}
|
||||
}
|
||||
// ====== 自动抓取站点真实分类 ======
|
||||
function firstRealCat(cats){const c=(cats||[]).find(x=>x[0]!=='history'&&x[0]!=='fav');return (c||cats[0]||['',''])[0]}
|
||||
function effCats(s){return CATS_CACHE[s.id]||s.cats}
|
||||
// 从页面导航里解析出分类(苹果CMS 常见的 vodshow/vodtype/type/id 等链接)
|
||||
function parseNavCats(d){
|
||||
const out=[],seen=new Set();
|
||||
const idFrom=(href)=>{
|
||||
let m;
|
||||
if(m=href.match(/\/vodshow\/(\d+)[-_\/.]/i))return m[1];
|
||||
if(m=href.match(/\/vodtype\/(\d+)/i))return m[1];
|
||||
if(m=href.match(/\/(?:type|show)\/id\/(\d+)/i))return m[1];
|
||||
if(m=href.match(/\/vod\/show\/id\/(\d+)/i))return m[1];
|
||||
if(m=href.match(/[?&](?:type|tid|id)=(\d+)/i))return m[1];
|
||||
if(m=href.match(/\/show\/(\d+)[-_\/.]/i))return m[1];
|
||||
return '';
|
||||
};
|
||||
let anchors=[];
|
||||
const navSel=['.module-nav a','.stui-header__menu a','.nav-menu a','.navbar-nav a','.head-nav a','header nav a','#nav a','.nav a'];
|
||||
for(const sel of navSel){const els=d.querySelectorAll(sel);if(els.length){anchors=[...els];break}}
|
||||
if(!anchors.length)anchors=[...d.querySelectorAll('a[href]')];
|
||||
anchors.forEach(a=>{
|
||||
const href=a.getAttribute('href')||'';
|
||||
const id=idFrom(href);
|
||||
if(!id||seen.has(id))return;
|
||||
let name=clean(a.textContent);
|
||||
if(!name||name.length>8)return;
|
||||
if(/首页|排行|榜单|热门|最新|专题|留言|演员|明星|求片|公告|APP|下载|登录|注册|会员|搜索|关于|友情|链接/.test(name))return;
|
||||
seen.add(id);out.push([id,name]);
|
||||
});
|
||||
return out.slice(0,24);
|
||||
}
|
||||
// 切到某站时确保拿到真实分类(抓首页导航,按 siteId 缓存;失败回退硬编码)
|
||||
async function ensureCats(s){
|
||||
if(CATS_CACHE[s.id])return CATS_CACHE[s.id];
|
||||
try{
|
||||
const r=await get(s,'/');
|
||||
const cats=parseNavCats(doc(r.html));
|
||||
if(cats.length>=2){
|
||||
const special=(s.cats||[]).filter(c=>c[0]==='history'||c[0]==='fav');
|
||||
const merged=special.concat(cats);
|
||||
CATS_CACHE[s.id]=merged;
|
||||
return merged;
|
||||
}
|
||||
}catch(e){}
|
||||
CATS_CACHE[s.id]=s.cats; // 兜底
|
||||
return s.cats;
|
||||
}
|
||||
function renderTabs(){
|
||||
const row=$('#srcRow');
|
||||
if(!row)return;
|
||||
row.innerHTML=SITES.map(s=>`<button class="src-tab ${activeSite===s.id?'on':''}" data-id="${s.id}">${s.name}</button>`).join('');
|
||||
row.querySelectorAll('.src-tab').forEach(b=>b.onclick=async()=>{
|
||||
if(b.dataset.id===activeSite)return;
|
||||
_catActive=true;
|
||||
activeSite=b.dataset.id;
|
||||
renderTabs();
|
||||
chips.innerHTML=''; // 先清空旧分类
|
||||
content.innerHTML='<div class="empty">加载中…</div>';
|
||||
const s=site();
|
||||
const cats=await ensureCats(s); // 自动抓取该站真实分类
|
||||
activeCat=firstRealCat(cats);
|
||||
page=1;renderChips();loadCategory()
|
||||
})
|
||||
}
|
||||
// 搜索代际计数器:每次「离开搜索结果页」就 +1,让仍在后台跑的旧搜索结果晚到也不会再去刷新当前页面
|
||||
let _searchGen=0,_catGen=0;
|
||||
function renderChips(){
|
||||
let s=site();
|
||||
const cats=effCats(s);
|
||||
if(!activeCat&&cats[0])activeCat=cats[0][0];
|
||||
chips.innerHTML=cats.map(c=>`<button class="chip ${activeCat===c[0]?'active':''}" data-id="${c[0]}">${c[1]}</button>`).join('');
|
||||
chips.querySelectorAll('.chip').forEach(b=>b.onclick=()=>activateCategory(b.dataset.id));
|
||||
// 选中的分类胶囊滚动到可见区域中央
|
||||
const act=chips.querySelector('.chip.active');
|
||||
if(act&&act.scrollIntoView)try{act.scrollIntoView({inline:'center',block:'nearest',behavior:'smooth'})}catch(e){}
|
||||
}
|
||||
// 切换分类(点击胶囊 / 左右滑动都走这里)。slideDir:'l' 右滑出现下一类,'r' 左滑出现上一类
|
||||
function activateCategory(id,slideDir){
|
||||
if(id==null)return;
|
||||
_catActive=true;
|
||||
activeCat=id;page=1;_pendingSlide=slideDir||null;renderChips();
|
||||
if(id==='history'){_searchGen++;_catGen++;_catSnapshot=null;const hList=historyList.filter(x=>x.siteId===activeSite);renderGrid(hList,false);content.dataset.mode='category';status.textContent='浏览历史 · '+hList.length+' 条'}
|
||||
else if(id==='fav'){_searchGen++;_catGen++;_catSnapshot=null;renderGrid(favList,false);content.dataset.mode='category';status.textContent='我的收藏 · '+favList.length+' 条'}
|
||||
else loadCategory()
|
||||
}
|
||||
// 渲染卡片:右上角显示站源抓取的更新状态(取代原收藏按钮),收藏态用整卡描边表示
|
||||
function card(v,i){
|
||||
const favOn = isFav(v);
|
||||
// 画质角标已去除:和橙色状态角标内容经常重复重叠(如"4K HDR"),不再单独展示
|
||||
// 更新状态:去掉"年份 分类"等无关词,保留 HD/4K/更新至/完结 等有意义的状态
|
||||
function stripRemarkPrefix(r,siteName){
|
||||
if(!r)return'';
|
||||
const SKIP=/^(\d{4}|电影|电视剧|剧集|动漫|综艺|短剧|纪录片|音乐|原盘)$/;
|
||||
const words=r.split(/\s+/);
|
||||
const kept=words.filter(w=>w&&!SKIP.test(w)); // 去掉分类/年份,但保留 siteName
|
||||
const result=kept.join(' ').trim();
|
||||
// 如果过滤后为空或只剩 siteName,不显示
|
||||
return (result&&result!==siteName) ? result : '';
|
||||
}
|
||||
// 在聚合模式下,只用第一个源的remark;单源时用v的remark
|
||||
const remarksToCheck = v.sources ? [v.sources[0].remark] : [v.remark];
|
||||
const st = remarksToCheck.map(r => stripRemarkPrefix(r, v.siteName)).filter(Boolean)[0] || '';
|
||||
const statusTag = st ? `<span class="status-badge" title="${esc(st)}">${esc(st)}</span>` : '';
|
||||
// 聚合时同一资源常被多个站收录(它们被合并进 v.sources)。
|
||||
// badge 显示「N 站」,标题下方再完整列出所有来源站名,让用户一眼看到资源有多全。
|
||||
const siteNames = v.sources ? [...new Set(v.sources.map(x=>x.siteName).filter(Boolean))] : [v.siteName].filter(Boolean);
|
||||
const multi = siteNames.length>1;
|
||||
const badgeText = multi ? (siteNames.length+'站') : (siteNames[0]||v.siteName||'');
|
||||
const srcLine = multi ? `<div class="srcline">${esc(siteNames.join(' · '))}</div>` : '';
|
||||
return`
|
||||
<article class="card${favOn?' is-fav':''}" data-i="${i}" data-href="${esc(v.href)}">
|
||||
<div class="poster">
|
||||
${v.pic?`<img loading="lazy" referrerpolicy="no-referrer" src="${esc(v.pic)}" onload="window._posterOk&&window._posterOk(this)" onerror="window._posterFail&&window._posterFail(this)">`:'<div class="noimg">宝宝巴士</div>'}
|
||||
${statusTag}
|
||||
<span class="badge">${esc(badgeText)}</span>
|
||||
<span class="source-badge"></span>
|
||||
</div>
|
||||
<div class="meta">
|
||||
<div class="name">${esc(v.title)}</div>
|
||||
${srcLine}
|
||||
</div>
|
||||
</article>`
|
||||
}
|
||||
function renderGrid(list,pager){
|
||||
last=list;
|
||||
if(window._backTopReset) _backTopReset();
|
||||
content.dataset.mode = pager ? 'category' : 'search';
|
||||
content.innerHTML=list.length
|
||||
?`<div class="grid">${list.map(card).join('')}</div>${pager?'<div id="infiniteSentinel" style="height:1px;margin:8px 0"></div>':''}`
|
||||
:'<div class="empty">暂无内容</div>';
|
||||
// 左右滑动切换分类时,给新网格加入场动画
|
||||
if(_pendingSlide){
|
||||
const g=content.querySelector('.grid');
|
||||
if(g){g.classList.add('slide-'+_pendingSlide);g.addEventListener('animationend',()=>g.classList.remove('slide-l','slide-r'),{once:true})}
|
||||
_pendingSlide=null;
|
||||
}
|
||||
// 收藏改为长按卡片切换(见底部滑动/长按手势模块)
|
||||
// 卡片打开详情
|
||||
content.querySelectorAll('.card').forEach(c=>c.onclick=()=>{
|
||||
const i=parseInt(c.dataset.i);openDetail(list[i])
|
||||
});
|
||||
// 无限加载:IntersectionObserver 监听底部哨兵
|
||||
if(pager){
|
||||
const sentinel=document.getElementById('infiniteSentinel');
|
||||
if(sentinel){
|
||||
if(window._infiniteObs)window._infiniteObs.disconnect();
|
||||
let _loading=false;
|
||||
window._infiniteObs=new IntersectionObserver(entries=>{
|
||||
if(!entries[0].isIntersecting||_loading)return;
|
||||
_loading=true;
|
||||
// 追加下一页到现有网格,不清空
|
||||
const curPage=page;
|
||||
page++;
|
||||
const s=site();
|
||||
get(s,catUrl(s,activeCat,page)).then(r=>{
|
||||
const d=doc(r.html);
|
||||
const els=[...d.querySelectorAll(s.listSelector||'.module-item')];
|
||||
const newList=els.map(e=>cardFrom(e,r.base,s)).filter(x=>x.href&&x.title).slice(0,60);
|
||||
if(!newList.length){
|
||||
// 没有更多了
|
||||
window._infiniteObs.disconnect();
|
||||
sentinel.innerHTML='<div style="text-align:center;padding:16px 0 8px;color:rgba(255,255,255,.28);font-size:12px">— 已经到底了 —</div>';
|
||||
return;
|
||||
}
|
||||
const startIdx=last.length;
|
||||
last=[...last,...newList];
|
||||
const grid=content.querySelector('.grid');
|
||||
if(grid) grid.insertAdjacentHTML('beforeend',newList.map((v,i)=>card(v,startIdx+i)).join(''));
|
||||
// 重新绑定新卡片事件
|
||||
const allCards=content.querySelectorAll('.card');
|
||||
allCards.forEach(c=>c.onclick=()=>{
|
||||
const i=parseInt(c.dataset.i);openDetail(last[i])
|
||||
});
|
||||
enhanceGridPosters(newList);
|
||||
status.textContent=`${s.name} · 第 ${page} 页 · ${last.length} 条`;
|
||||
// 同步快照,返回时保留已加载的多页内容与页码
|
||||
if(content.dataset.mode==='category')_catSnapshot={site:activeSite,cat:activeCat,list:last.slice(),page,scrollY:0,statusText:status.textContent};
|
||||
_loading=false;
|
||||
}).catch(()=>{page=curPage;_loading=false});
|
||||
},{rootMargin:'0px 0px 200px 0px'});
|
||||
window._infiniteObs.observe(sentinel);
|
||||
}
|
||||
}
|
||||
// 封面:先显示站源原图,再用 TMDB(api.tmdb.org 国内直连)匹配到的海报静默替换
|
||||
if(list.length)enhanceGridPosters(list);
|
||||
}
|
||||
function catUrl(s,c,p){
|
||||
if(s.categoryUrl)return s.categoryUrl.replace('{categoryId}',c).replace('{page}',p);
|
||||
return `/index.php/vod/show/id/${c}/page/${p}.html`
|
||||
}
|
||||
async function loadCategory(){
|
||||
_searchGen++; // 离开搜索结果页:旧 search() 哪怕还在后台跑,结果回来了也不再生效
|
||||
const gen=++_catGen; // 本次分类请求的代际号:之后任何「又切了别的分类」都会让它失效,慢请求晚到不再覆盖新内容
|
||||
if(location.hash==='#search') history.replaceState({wo:'home'},'',location.pathname+location.search);
|
||||
_clearSearchUI();
|
||||
content.innerHTML='<div class="empty">加载中…</div>';
|
||||
let s=site(),cat=activeCat,pg=page;
|
||||
try{
|
||||
let r=await get(s,catUrl(s,cat,pg)),d=doc(r.html);
|
||||
if(gen!==_catGen)return; // 等待期间又切到了别的分类,这份结果作废,避免覆盖已显示的新内容
|
||||
let els=[...d.querySelectorAll(s.listSelector||'.module-item')];
|
||||
let list=els.map(e=>cardFrom(e,r.base,s)).filter(x=>x.href&&x.title).slice(0,60);
|
||||
renderGrid(list,true);
|
||||
const st=`${s.name} · 第 ${pg} 页 · ${list.length} 条`;
|
||||
status.textContent=st;
|
||||
// 记录当前分类状态,作为之后“从搜索返回”的还原快照
|
||||
_catSnapshot={site:activeSite,cat:cat,list:last.slice(),page:pg,scrollY:0,statusText:st};
|
||||
}catch(e){if(gen===_catGen)content.innerHTML='<div class="empty">分类失败:'+esc(e.message)+'</div>'}
|
||||
}
|
||||
// ===== 搜索地址自适应 =====
|
||||
// 各站搜索 URL 格式各不相同(伪静态规则不同),过去只给“玩偶”配了 searchUrl、
|
||||
// 其余站统一套用一个默认路径,导致服务器不认这个路径的站搜不出结果。
|
||||
// 这里改为:未显式配置 searchUrl 的站,依次尝试多种常见 MacCMS 搜索格式,
|
||||
// 谁能返回结果就用谁,并把命中的格式持久化缓存,下次直接命中、不再逐个试。
|
||||
const SEARCH_URL_TEMPLATES=[
|
||||
'/index.php/vod/search/page/{p}/wd/{kw}.html', // MacCMS10 标准伪静态
|
||||
'/vodsearch/-------------.html?wd={kw}&page={p}', // 参数走 query(玩偶式)
|
||||
'/index.php/vod/search/wd/{kw}.html', // 不带 page
|
||||
'/vodsearch/{kw}----------{p}---.html', // 关键词嵌在路径里
|
||||
'/index.php/vod/search.html?wd={kw}', // 纯动态
|
||||
'/search.html?wd={kw}' // 简化动态
|
||||
];
|
||||
// 命中格式缓存(按 siteId),持久化到 localStorage,跨会话复用
|
||||
const SEARCH_URL_CACHE=(()=>{try{return JSON.parse(localStorage.getItem('wo_search_url_cache')||'{}')}catch(e){return {}}})();
|
||||
function _saveSearchUrlCache(){try{localStorage.setItem('wo_search_url_cache',JSON.stringify(SEARCH_URL_CACHE))}catch(e){}}
|
||||
// 搜索结果项多选择器兜底:不同模板的结果项 class 不一定是 .module-search-item
|
||||
function _pickSearchEls(d,s){
|
||||
const sels=[s.searchListSelector,'.module-search-item','.module-items .module-item',
|
||||
'.module-list .module-item','.stui-vodlist__media','ul.stui-vodlist > li',
|
||||
'.stui-vodlist li','.search-list li','.vodlist li','.module-item'].filter(Boolean);
|
||||
for(const sel of sels){const els=[...d.querySelectorAll(sel)];if(els.length)return els}
|
||||
return [];
|
||||
}
|
||||
async function searchSite(s,q,timeoutSec=16){
|
||||
const ekw=encodeURIComponent(q);
|
||||
// 候选格式:显式配置 > 该站已缓存命中 > 全套模板逐个试
|
||||
let templates;
|
||||
if(s.searchUrl){
|
||||
templates=[s.searchUrl.replace('{keyword}','{kw}').replace('{page}','{p}')];
|
||||
}else if(SEARCH_URL_CACHE[s.id]){
|
||||
// 缓存命中的放最前,但保留兜底(万一站点改了规则,仍能自动重新发现)
|
||||
templates=[SEARCH_URL_CACHE[s.id],...SEARCH_URL_TEMPLATES.filter(t=>t!==SEARCH_URL_CACHE[s.id])];
|
||||
}else{
|
||||
// 未知格式:聚合模式下并发压力大,只试最常见的前 2 种,避免单个站串行试一长串拖垮整体;
|
||||
// 单站模式没有并发竞争,可以把全套格式都试一遍,尽量搜全。
|
||||
templates=aggregate?SEARCH_URL_TEMPLATES.slice(0,2):SEARCH_URL_TEMPLATES;
|
||||
}
|
||||
let lastErr=null,anyOk=false;
|
||||
for(const tpl of templates){
|
||||
const path=tpl.replace(/\{kw\}/g,ekw).replace(/\{p\}/g,'1');
|
||||
let r;
|
||||
try{r=await get(s,path,timeoutSec,true)}
|
||||
catch(e){lastErr=e;continue} // 这个地址请求失败(404/超时/拦截),换下一种格式
|
||||
anyOk=true;
|
||||
const d=doc(r.html);
|
||||
const els=_pickSearchEls(d,s);
|
||||
const list=els.map(e=>cardFrom(e,r.base,s)).filter(x=>x.href&&x.title).slice(0,20);
|
||||
if(list.length){
|
||||
if(!s.searchUrl&&SEARCH_URL_CACHE[s.id]!==tpl){SEARCH_URL_CACHE[s.id]=tpl;_saveSearchUrlCache()}
|
||||
return list;
|
||||
}
|
||||
// 请求成功但没解析到结果:可能这词真没有,也可能格式不对,继续试其他格式
|
||||
}
|
||||
if(!anyOk&&lastErr)throw lastErr; // 所有格式都网络层失败 → 抛错,让聚合搜索的重试机制接手
|
||||
return []; // 请求通了但确实没搜到
|
||||
}
|
||||
function normName(t){
|
||||
let s=clean(t).replace(/[《》\[\]【】()()\s·.\-_::|\/]/g,'');
|
||||
s=s.replace(/第[一二三四五六七八九十\d]+季|Season\s*\d+/ig,'');
|
||||
// 忽略版本/画质/规格/语言等后缀,使同一部片的不同版本(蓝光/4K/臻彩/国语…)归一到同一个名字。
|
||||
// 复合词放前面,避免「臻彩视界」被「臻彩」先匹配后残留「视界」。
|
||||
s=s.replace(/臻彩视界|臻彩|杜比视界|杜比全景声|杜比|导演剪辑版|未删减版|加长版|完整版|收藏版|纪念版|国际版|蓝光原盘|蓝光|原盘|remastered|bluray|blu-ray|remux|2160p|1080p|720p|4k|2160|1080|hdr10\+?|hdr|sdr|dovi|dolby|hevc|h\.?265|h\.?264|x265|x264|国粤双语|国粤|国语|粤语|中英双语|中英|双语|中字|英语|高清|超清|高码/ig,'');
|
||||
return s.toLowerCase();
|
||||
}
|
||||
// 从标题/备注里提取年份(19xx/20xx),排除 1080/2160 等分辨率数字;用于按年份合并与过滤
|
||||
function extractYear(x){
|
||||
return (String((x&&((x.title||'')+' '+(x.remark||'')))||'').match(/(?:^|[^\d])((?:19|20)\d{2})(?:[^\d]|$)/)||[])[1]||'';
|
||||
}
|
||||
// ===== 简繁通搜:单字对照(原词始终参与搜索,转换仅用于扩大召回)=====
|
||||
const _ST_S='们这个来国时会说过现还后经发对开关门问间见觉学实样应该让认识话语读书笔记词议论试课讲谁请谢谈译证评诉询详误谣谓爱万与丑专业丛东丝丢两严丧临为丽举义乌乐乔习乡买乱争亏亚产亩亲亿仅从仑仓仪价众优伛伞伟传伤伥伦伧伪体余佣侠侣侥侦侧侨侩侪侬俣俦俨俩俪俭债倾偬偻偾偿傥傧储傩儿兑兖党兰兴兹养兽冁内冈册写军农冯冲决况冻净凄准凉减凑凛几凤凫凭凯击凼凿刍划刘则刚创删别刬刭刹刽剀剂剐剑剥剧劝办务劢动励劲劳势勋勚匀匦匮区医华协单卖卢卤卫却卷厂厅历厉压厌厍厐厕厢厣厦厨厩县叁参叆双变叙叠叶号叹叽吁吓吕吗吣吨听启吴呒呓呕呖呗员呙呛呜咏咙咛咝响哑哒哓哔哕哗哙哜哝哟唛唝唠唡唢唤啧啬啭啮啰啴啸喷喽喾嗫嗳嘘嘤嘱噜嚣团园围囵图圆圣场坂坏块坚坛坜坝坞坟坠垄垅垦垩垫垭垲垴埘埙埚堑堕墙壮声壳壶壸处备复够头夸夹夺奁奂奋奖奥妆妇妈妩妪妫姗姹娄娅娆娇娈娱娲娴婳婴婵婶媪嫒嫔嫱嬷孙孪宁宝宠审宪宫宽宾寝寻导寿将尔尘尝尧尴尸层屃屉届属屡屦屿岁岂岖岗岘岙岚岛岭岽岿峄峡峣峤峥峦崂崃崄崭嵘嵚嵝巅巩巯币帅师帏帐帘帜带帧帮帱帻帼幂并幺广庄庆庐庑库庙庞废廪异弃张弥弪弯弹强归当录彝彦彻径徕忆忏忧忾怀态怂怃怅怆怜总怼怿恋恒恳恶恸恹恺恻恼恽悦悫悬悭悯惊惧惨惩惫惬惭惮惯愠愤愦愿慑懑懒懔戆戋戏戗战戬户扑执扩扪扫扬扰抚抛抟抠抡抢护报担拟拢拣拥拦拧拨择挂挚挛挜挝挞挟挠挡挢挣挤挥挦损捡换捣据掳掴掷掸掺掼揽揿搀搁搂搅携摄摅摆摇摈摊撄撑撵撷撸撺擞攒敌敛数斋斓斗斩断无旧旷旸昙昼昽显晋晒晓晔晕晖暂暧术机杀杂权杆条杨杩杰极构枞枢枣枥枧枨枪枫枭柜柠柽栀栈栉栊栋栌栎栏树栖栾桠桡桢档桤桥桦桧桩梦梼梾检棁棂椁椟椠椤椭楼榄榇榈榉槚槛槟槠横樯樱橥橱橹橼檩欢欤欧歼殁殇残殒殓殚殡殴毁毂毕毙毡毵氇气氢氩氲汇汉污汤汹沟没沣沤沥沦沧沩沪泞注泪泶泷泸泺泻泼泽泾洁洒洼浃浅浆浇浈浊测浍济浏浑浒浓浔涛涝涞涟涠涡涢涣涤润涧涨涩淀渊渌渍渐渑渔渖渗温游湾湿溃溅溆滗滚滞滟滠满滢滤滥滦滨滩漓潆潇潋潍澜濑濒灏灭灯灵灾灿炀炉炖炜炝点炼炽烁烂烃烛烟烦烧烨烩烫烬热焕焖焘煴爷牍牦牵牺犊状犷犸犹狈狍狝狞独狭狮狯狰狱狲猃猎猕猡猪猫献獭玑玛玮环玱玺珐珑珰珲琏琐琼瑶瑷璎瓒瓮瓯电画畅畴疖疗疟疠疡疬疮疯疱疴痈痉痒痖痨痪痫瘅瘗瘘瘪瘫瘾瘿癞癣癫皑皱皲盏盐监盖盗盘眍眦眬着睁睐睑瞒瞩矫矶矾矿砀码砖砗砚砜砺砻砾础硁硕硖硗硙确碍碜碱礼祃祎祢祯祷祸禀禄禅离秃秆种积称秽税稆稣稳穑穷窃窍窎窜窝窥窦窭竖竞笃笋笕笺笼笾筑筚筛筜筝筹签简箓箦箧箨箩箪箫篑篓篮篱簖籁籴类籼粜粝粤粪粮糁糇紧絷纟纠纡红纣纤纥约级纨纩纪纫纬纭纯纰纱纲纳纵纶纷纸纹纺纻纼纽纾线绀绁绂练组绅细织终绉绊绋绌绍绎绑绒结绔绕绖绗绘给绚绛络绝绞统绠绡绢绣绤绥绦继绨绩绪绫续绮绯绰绱绲绳维绵绶绷绸绹绺绻综绽绾绿缀缁缂缃缄缅缆缇缈缉缊缋缌缍缎缏缐缑缒缓缔缕编缗缘缙缚缛缜缝缞缟缠缡缢缣缤缥缦缧缨缩缪缫缬缭缮缯缰缱缲缳缴缵罂网罗罚罢罴羁羟翘耢耧耸耻聂聋职聍联聩聪肃肠肤肮肴肾肿胀胁胆胜胧胨胪胫脉脍脏脐脑脓脔脚脱脶脸腊腌腭腻腼腽腾膑臜舆舍舣舰舱舻艰艳艺节芈芗芜芦苁苍苎苏苹范茎茏茑茔茕茧荆荐荙荚荛荜荝荞荟荠荡荣荤荥荦荧荨荩荪荫荬荭荮药莅莱莲莳莴莶获莸莹莺萝萤营萦萧萨葱蒇蒉蒋蒌蓝蓟蓠蓣蓥蓦蔂蔷蔹蔺蔼蕲蕴薮藓蘖虏虑虚虫虬虱虽虾虿蚀蚁蚂蚃蚕蚬蛊蛎蛏蛮蛰蛱蛲蛳蛴蜕蜗蜡蝇蝈蝉蝼蝾螀螨蟏衅衔补衬衮袄袅袆袜袯装裆裈裢裣裤裥褛褴观觃规觅视觇览觊觋觌觍觎觏觐觑觞觯誉誊讠计订讣讥讦讧讨讪讫训讯讱讳讴讵讶讷许讹讻讼讽设访诀诂诃诅诇诈诊诋诌诎诏诒诓诔诖诗诘诙诚诛诜诞诟诠诡诣诤诧诨诩诫诬诮诰诱诲诳诵诶诸诹诺诼诽诿谀谂调谄谅谆谇谊谋谌谍谎谏谐谑谒谔谕谖谗谘谙谚谛谜谝谞谟谠谡谤谥谦谧谨谩谪谫谬谭谮谯谰谱谲谳谴谵谶贝贞负贠贡财责贤败账货质贩贪贫贬购贮贯贰贱贲贳贴贵贶贷贸费贺贻贼贽贾贿赀赁赂赃资赅赆赇赈赉赊赋赌赍赎赏赐赑赒赓赔赕赖赗赘赙赚赛赜赝赞赟赠赡赢赣赪赵赶趋趱趸跃跄跖跞践跷跸跹跻踊踌踪踬踯蹑蹒蹰蹿躏躯车轧轨轩轪轫转轭轮软轰轱轲轳轴轵轶轷轸轹轺轻轼载轾轿辁辂较辄辅辆辇辈辉辊辋辌辍辎辏辐辑辒输辔辕辖辗辘辙辚辞辩辫边辽达迁迈运进远违连迟迩迳迹适选逊递逦逻遗遥邓邝邬邮邹邺邻郏郐郑郓郦郧郸酂酦酱酽酾酿释里鉴銮錾钅钆钇针钉钊钋钌钍钎钏钐钒钓钔钕钖钗钘钙钚钛钜钝钞钟钠钡钢钣钤钥钦钧钨钩钪钫钬钭钮钯钰钱钲钳钴钵钶钷钸钹钺钻钼钽钾钿铀铁铂铃铄铅铆铇铈铉铊铋铌铍铎铏铐铑铒铓铔铕铖铗铘铙铚铛铜铝铞铟铠铡铢铣铤铥铦铧铨铩铪铫铬铭铮铯铰铱铲铳铴铵银铷铸铹铺铻铼铽链铿销锁锂锃锄锅锆锇锈锉锊锋锌锍锎锏锐锑锒锓锔锕锖锗错锚锛锝锞锟锡锢锣锤锥锦锧锨锩锪锫锬锭键锯锰锱锲锳锴锵锶锷锸锹锺锻锼锽锾锿镀镁镂镃镄镅镆镇镈镉镊镋镌镍镎镏镐镑镒镓镔镖镗镘镙镚镛镜镝镞镟镠镡镢镣镤镥镦镧镨镩镪镫镬镭镮镯镰镱镲镳镴镵镶长闩闪闫闬闭闯闰闱闲闳闵闶闷闸闹闺闻闼闽闾闿阀阁阂阃阄阅阆阇阈阉阊阋阌阍阎阏阐阑阒阓阔阕阖阗阘阙阚阛队阳阴阵阶际陆陇陈陉陕陧陨险随隐隶隽难雏雠雳雾霁霉霭靓静面靥鞑鞒鞯韦韧韨韩韪韫韬韵页顶顷顸项顺须顼顽顾顿颀颁颂颃预颅领颇颈颉颊颋颌颍颎颏颐频颒颓颔颕颖颗题颙颚颛颜额颞颟颠颡颢颤颥颦颧风飏飐飑飒飓飔飕飖飗飘飙飚飞飨餍饣饤饥饦饧饨饩饪饫饬饭饮饯饰饱饲饳饴饵饶饷饸饹饺饻饼饽饾饿馀馁馂馃馄馅馆馇馈馉馊馋馌馍馎馏馐馑馒馓馔馕马驭驮驯驰驱驲驳驴驵驶驷驸驹驺驻驼驽驾驿骀骁骂骃骄骅骆骇骈骉骊骋验骍骎骏骐骑骒骓骔骕骖骗骘骙骚骛骜骝骞骟骠骡骢骣骤骥骦骧髅髋髌鬓魇魉鱼鱽鱾鱿鲀鲁鲂鲄鲅鲆鲇鲈鲉鲊鲋鲌鲍鲎鲏鲐鲑鲒鲓鲔鲕鲖鲗鲘鲙鲚鲛鲜鲝鲞鲟鲠鲡鲢鲣鲤鲥鲦鲧鲨鲩鲪鲫鲬鲭鲮鲯鲰鲱鲲鲳鲴鲵鲶鲷鲸鲹鲺鲻鲼鲽鲾鲿鳀鳁鳂鳃鳄鳅鳆鳇鳈鳉鳊鳋鳌鳍鳎鳏鳐鳑鳒鳓鳔鳕鳖鳗鳘鳙鳚鳛鳜鳝鳞鳟鳠鳡鳢鸟鸠鸡鸢鸣鸤鸥鸦鸧鸨鸩鸪鸫鸬鸭鸮鸯鸰鸱鸲鸳鸴鸵鸶鸷鸸鸹鸺鸻鸼鸽鸾鸿鹀鹁鹂鹃鹄鹅鹆鹇鹈鹉鹊鹋鹌鹍鹎鹏鹐鹑鹒鹔鹕鹖鹗鹘鹙鹚鹛鹜鹝鹞鹟鹠鹡鹢鹣鹤鹥鹦鹧鹨鹩鹪鹫鹬鹭鹮鹯鹰鹱鹲鹳鹴鹾麦麸黄黉黡黩黪黾鼋鼌鼍鼹齐齑齿龀龁龂龃龄龅龆龇龈龉龊龋龌龙龚龛龟';
|
||||
const _ST_T='們這個來國時會說過現還後經發對開關門問間見覺學實樣應該讓認識話語讀書筆記詞議論試課講誰請謝談譯證評訴詢詳誤謠謂愛萬與醜專業叢東絲丟兩嚴喪臨為麗舉義烏樂喬習鄉買亂爭虧亞產畝親億僅從侖倉儀價眾優傴傘偉傳傷倀倫傖偽體餘傭俠侶僥偵側僑儈儕儂俁儔儼倆儷儉債傾傯僂僨償儻儐儲儺兒兌兗黨蘭興茲養獸囅內岡冊寫軍農馮沖決況凍淨淒準涼減湊凜幾鳳鳧憑凱擊氹鑿芻劃劉則剛創刪別剗剄剎劊剴劑剮劍剝劇勸辦務勱動勵勁勞勢勛勩勻匭匱區醫華協單賣盧鹵衛卻捲廠廳歷厲壓厭厙龐廁廂厴廈廚廄縣叄參靉雙變敘疊葉號嘆嘰籲嚇呂嗎唚噸聽啟吳嘸囈嘔嚦唄員咼嗆嗚詠嚨嚀噝響啞噠嘵嗶噦嘩噲嚌噥喲嘜嗊嘮啢嗩喚嘖嗇囀嚙囉嘽嘯噴嘍嚳囁噯噓嚶囑嚕囂團園圍圇圖圓聖場阪壞塊堅壇壢壩塢墳墜壟壠墾堊墊埡塏堖塒塤堝塹墮牆壯聲殼壺壼處備復夠頭誇夾奪奩奐奮獎奧妝婦媽嫵嫗媯姍奼婁婭嬈嬌孌娛媧嫻嫿嬰嬋嬸媼嬡嬪嬙嬤孫孿寧寶寵審憲宮寬賓寢尋導壽將爾塵嘗堯尷屍層屭屜屆屬屢屨嶼歲豈嶇崗峴嶴嵐島嶺崬巋嶧峽嶢嶠崢巒嶗崍嶮嶄嶸嶔嶁巔鞏巰幣帥師幃帳簾幟帶幀幫幬幘幗冪並么廣莊慶廬廡庫廟龐廢廩異棄張彌弳彎彈強歸當錄彞彥徹徑徠憶懺憂愾懷態慫憮悵愴憐總懟懌戀恆懇惡慟懨愷惻惱惲悅愨懸慳憫驚懼慘懲憊愜慚憚慣慍憤憒願懾懣懶懍戇戔戲戧戰戩戶撲執擴捫掃揚擾撫拋摶摳掄搶護報擔擬攏揀擁攔擰撥擇掛摯攣掗撾撻挾撓擋撟掙擠揮撏損撿換搗據擄摑擲撣摻摜攬撳攙擱摟攪攜攝攄擺搖擯攤攖撐攆擷擼攛擻攢敵斂數齋斕鬥斬斷無舊曠暘曇晝曨顯晉曬曉曄暈暉暫曖術機殺雜權桿條楊榪傑極構樅樞棗櫪梘棖槍楓梟櫃檸檉梔棧櫛櫳棟櫨櫟欄樹棲欒椏橈楨檔榿橋樺檜樁夢檮棶檢梲欞槨櫝槧欏橢樓欖櫬櫚櫸檟檻檳櫧橫檣櫻櫫櫥櫓櫞檁歡歟歐殲歿殤殘殞殮殫殯毆毀轂畢斃氈毿氌氣氫氬氳匯漢汙湯洶溝沒灃漚瀝淪滄溈滬濘註淚澩瀧瀘濼瀉潑澤涇潔灑窪浹淺漿澆湞濁測澮濟瀏渾滸濃潯濤澇淶漣潿渦溳渙滌潤澗漲澀澱淵淥漬漸澠漁瀋滲溫遊灣濕潰濺漵潷滾滯灩灄滿瀅濾濫灤濱灘灕瀠瀟瀲濰瀾瀨瀕灝滅燈靈災燦煬爐燉煒熗點煉熾爍爛烴燭煙煩燒燁燴燙燼熱煥燜燾熅爺牘氂牽犧犢狀獷獁猶狽麅獮獰獨狹獅獪猙獄猻獫獵獼玀豬貓獻獺璣瑪瑋環瑲璽琺瓏璫琿璉瑣瓊瑤璦瓔瓚甕甌電畫暢疇癤療瘧癘瘍癧瘡瘋皰痾癰痙癢瘂癆瘓癇癉瘞瘺癟癱癮癭癩癬癲皚皺皸盞鹽監蓋盜盤瞘眥矓著睜睞瞼瞞矚矯磯礬礦碭碼磚硨硯碸礪礱礫礎硜碩硤磽磑確礙磣鹼禮禡禕禰禎禱禍稟祿禪離禿稈種積稱穢稅穭穌穩穡窮竊竅窵竄窩窺竇窶豎競篤筍筧箋籠籩築篳篩簹箏籌簽簡籙簀篋籜籮簞簫簣簍籃籬籪籟糴類秈糶糲粵糞糧糝餱緊縶糹糾紆紅紂纖紇約級紈纊紀紉緯紜純紕紗綱納縱綸紛紙紋紡紵紖紐紓線紺紲紱練組紳細織終縐絆紼絀紹繹綁絨結絝繞絰絎繪給絢絳絡絕絞統綆綃絹繡綌綏絛繼綈績緒綾續綺緋綽緔緄繩維綿綬繃綢綯綹綣綜綻綰綠綴緇緙緗緘緬纜緹緲緝縕繢緦綞緞緶線緱縋緩締縷編緡緣縉縛縟縝縫縗縞纏縭縊縑繽縹縵縲纓縮繆繅纈繚繕繒韁繾繰繯繳纘罌網羅罰罷羆羈羥翹耮耬聳恥聶聾職聹聯聵聰肅腸膚骯餚腎腫脹脅膽勝朧腖臚脛脈膾髒臍腦膿臠腳脫腡臉臘醃齶膩靦膃騰臏臢輿捨艤艦艙艫艱豔藝節羋薌蕪蘆蓯蒼苧蘇蘋範莖蘢蔦塋煢繭荊薦薘莢蕘蓽萴蕎薈薺蕩榮葷滎犖熒蕁藎蓀蔭蕒葒葤藥蒞萊蓮蒔萵薟獲蕕瑩鶯蘿螢營縈蕭薩蔥蕆蕢蔣蔞藍薊蘺蕷鎣驀虆薔蘞藺藹蘄蘊藪蘚櫱虜慮虛蟲虯蝨雖蝦蠆蝕蟻螞蠁蠶蜆蠱蠣蟶蠻蟄蛺蟯螄蠐蛻蝸蠟蠅蟈蟬螻蠑螿蟎蠨釁銜補襯袞襖裊褘襪襏裝襠褌褳襝褲襇褸襤觀覎規覓視覘覽覬覡覿覥覦覯覲覷觴觶譽謄訁計訂訃譏訐訌討訕訖訓訊訒諱謳詎訝訥許訛訩訟諷設訪訣詁訶詛詗詐診詆謅詘詔詒誆誄詿詩詰詼誠誅詵誕詬詮詭詣諍詫諢詡誡誣誚誥誘誨誑誦誒諸諏諾諑誹諉諛諗調諂諒諄誶誼謀諶諜謊諫諧謔謁諤諭諼讒諮諳諺諦謎諞諝謨讜謖謗謚謙謐謹謾謫譾謬譚譖譙讕譜譎讞譴譫讖貝貞負貟貢財責賢敗賬貨質販貪貧貶購貯貫貳賤賁貰貼貴貺貸貿費賀貽賊贄賈賄貲賃賂贓資賅贐賕賑賚賒賦賭齎贖賞賜贔賙賡賠賧賴賵贅賻賺賽賾贗贊贇贈贍贏贛赬趙趕趨趲躉躍蹌蹠躒踐蹺蹕躚躋踴躊蹤躓躑躡蹣躕躥躪軀車軋軌軒軑軔轉軛輪軟轟軲軻轤軸軹軼軤軫轢軺輕軾載輊轎輇輅較輒輔輛輦輩輝輥輞輬輟輜輳輻輯轀輸轡轅轄輾轆轍轔辭辯辮邊遼達遷邁運進遠違連遲邇逕跡適選遜遞邐邏遺遙鄧鄺鄔郵鄒鄴鄰郟鄶鄭鄆酈鄖鄲酇醱醬釅釃釀釋裡鑒鑾鏨釒釓釔針釘釗釙釕釷釺釧釤釩釣鍆釹鍚釵鈃鈣鈈鈦鉅鈍鈔鐘鈉鋇鋼鈑鈐鑰欽鈞鎢鉤鈧鈁鈥鈄鈕鈀鈺錢鉦鉗鈷缽鈳鉕鈽鈸鉞鑽鉬鉭鉀鈿鈾鐵鉑鈴鑠鉛鉚鉋鈰鉉鉈鉍鈮鈹鐸鉶銬銠鉺鋩錏銪鋮鋏鋣鐃銍鐺銅鋁銱銦鎧鍘銖銑鋌銩銛鏵銓鎩鉿銚鉻銘錚銫鉸銥鏟銃鐋銨銀銣鑄鐒鋪鋙錸鋱鏈鏗銷鎖鋰鋥鋤鍋鋯鋨鏽銼鋝鋒鋅鋶鐦鐧銳銻鋃鋟鋦錒錆鍺錯錨錛鍀錁錕錫錮鑼錘錐錦鑕鍁錈鍃錇錟錠鍵鋸錳錙鍥鍈鍇鏘鍶鍔鍤鍬鍾鍛鎪鍠鍰鎄鍍鎂鏤鎡鐨鎇鏌鎮鎛鎘鑷钂鐫鎳鎿鎦鎬鎊鎰鎵鑌鏢鏜鏝鏍鏰鏞鏡鏑鏃鏇鏐鐔钁鐐鏷鑥鐓鑭鐠鑹鏹鐙鑊鐳鐶鐲鐮鐿鑔鑣鑞鑱鑲長閂閃閆閈閉闖閏闈閑閎閔閌悶閘鬧閨聞闥閩閭闓閥閣閡閫鬮閱閬闍閾閹閶鬩閿閽閻閼闡闌闃闠闊闋闔闐闒闕闞闤隊陽陰陣階際陸隴陳陘陝隉隕險隨隱隸雋難雛讎靂霧霽黴靄靚靜麵靨韃鞽韉韋韌韍韓韙韞韜韻頁頂頃頇項順須頊頑顧頓頎頒頌頏預顱領頗頸頡頰頲頜潁熲頦頤頻頮頹頷頴穎顆題顒顎顓顏額顳顢顛顙顥顫顬顰顴風颺颭颮颯颶颸颼颻飀飄飆飈飛饗饜飠飣飢飥餳飩餼飪飫飭飯飲餞飾飽飼飿飴餌饒餉餄餎餃餏餅餑餖餓餘餒餕餜餛餡館餷饋餶餿饞饁饃餺餾饈饉饅饊饌饢馬馭馱馴馳驅馹駁驢駔駛駟駙駒騶駐駝駑駕驛駘驍罵駰驕驊駱駭駢驫驪騁驗騂駸駿騏騎騍騅騌驌驂騙騭騤騷騖驁騮騫騸驃騾驄驏驟驥驦驤髏髖髕鬢魘魎魚魛魢魷魨魯魴魺鮁鮃鯰鱸鮋鮓鮒鮊鮑鱟鮍鮐鮭鮚鮳鮪鮞鮦鯽鮜鱠鱭鮫鮮鮺鯗鱘鯁鱺鰱鰹鯉鰣鰷鯀鯊鯇鮶鯽鯒鯖鯪鯕鯫鯡鯤鯧鯝鯢鯰鯛鯨鰺蝨鯔鱝鰈鰏鱨鯷鰮鰃鰓鱷鰍鰒鰉鰁鱂鯿鰠鰲鰭鰨鰥鰩鰟鰜鰳鰾鱈鱉鰻鰵鱅䲁鰼鱖鱔鱗鱒鱯鱤鱧鳥鳩雞鳶鳴鳲鷗鴉鶬鴇鴆鴣鶇鸕鴨鴞鴦鴒鴟鴝鴛鷽鴕鷥鷙鴯鴰鵂鴴鵃鴿鸞鴻鵐鵓鸝鵑鵠鵝鵒鷳鵜鵡鵲鶓鵪鵾鵯鵬鵮鶉鶊鷫鶘鶡鶚鶻鶖鶿鶥鶩鷊鷂鶲鶹鶺鷁鶼鶴鷖鸚鷓鷚鷯鷦鷲鷸鷺䴉鸇鷹鸌鸏鸛鸘鹺麥麩黃黌黶黷黲黽黿鼂鼉鼴齊齏齒齔齕齗齟齡齙齠齜齦齬齪齲齷龍龔龕龜';
|
||||
const _s2t={},_t2s={};
|
||||
for(let i=0;i<_ST_S.length;i++){_s2t[_ST_S[i]]=_ST_T[i];if(!(_ST_T[i] in _t2s))_t2s[_ST_T[i]]=_ST_S[i];}
|
||||
function toSimp(s){let o='';for(const c of String(s||''))o+=(_t2s[c]||c);return o}
|
||||
async function search(q,opts){
|
||||
opts=opts||{};
|
||||
const targetYear=opts.year?String(opts.year):''; // 从联想点击带入的年份;用于过滤掉明确属于其它年份的结果
|
||||
addSearchKwHistory(q);
|
||||
_catActive=false;
|
||||
const gen=++_searchGen; // 本次搜索的代际号;之后任何「离开搜索页」的操作都会让它失效
|
||||
// 进入搜索前,若当前在分类浏览态,快照分类内容/滚动位置,供返回时秒级还原
|
||||
if(content.dataset.mode==='category'&&last.length){
|
||||
_catSnapshot={site:activeSite,cat:activeCat,list:last.slice(),page,scrollY:window.scrollY||0,statusText:status.textContent};
|
||||
}
|
||||
_enterSearchHistory();
|
||||
if(navrowEl) navrowEl.classList.add('blurred');
|
||||
if(searchOverlayEl) searchOverlayEl.classList.add('show');
|
||||
if(appEl) appEl.classList.add('searching');
|
||||
await new Promise(r=>setTimeout(r,0));
|
||||
content.innerHTML='<div class="empty">聚合搜索中…</div>';
|
||||
const map=new Map();
|
||||
let arr=[],done=0,shown=false,flushTimer=null;
|
||||
function mergeOne(list){
|
||||
// 若用户从联想点了带年份的条目(targetYear),过滤掉「明确标了其它年份」的结果;
|
||||
// 没标年份的一律保留,避免把该年份但站点没标年份的资源误杀。
|
||||
if(targetYear){
|
||||
list=list.filter(x=>{const yr=extractYear(x);return !yr||yr===targetYear});
|
||||
}
|
||||
if(aggregate){
|
||||
// 不同站各自成卡;同站 + 同片名(忽略版本后缀) + 同年份 才合并成一张卡,
|
||||
// 同站同片的多个版本收进同一张卡的 sources,点开后在详情页按网盘线路分开列。
|
||||
// 年份不同(如《迷墙》1982 与 2026)分成两张。
|
||||
list.forEach(x=>{
|
||||
let yr=extractYear(x);
|
||||
let k=x.siteId+'|'+normName(toSimp(x.title))+'|'+yr;
|
||||
if(!map.has(k))map.set(k,Object.assign({},x,{sources:[x]}));else map.get(k).sources.push(x)
|
||||
});
|
||||
}else{
|
||||
list.forEach(x=>{let k=x.href||normName(toSimp(x.title));if(!map.has(k))map.set(k,x)});
|
||||
}
|
||||
}
|
||||
function reveal(){
|
||||
if(shown)return;shown=true;
|
||||
_clearSearchUI();
|
||||
const hb=document.getElementById('searchHistory');
|
||||
if(hb) hb.classList.remove('show');
|
||||
kw.blur();
|
||||
}
|
||||
function flush(total,extra){
|
||||
if(gen!==_searchGen)return; // 已经离开搜索页(点返回/切分类等),旧搜索结果作废,不再刷新当前页面
|
||||
arr=[...map.values()];
|
||||
renderGrid(arr,false);
|
||||
reveal();
|
||||
let st = done<total
|
||||
? `搜索:${q} · ${arr.length} 条 · 加载中 ${done}/${total}…`
|
||||
: `搜索:${q} · ${arr.length} 条`;
|
||||
if(extra)st+=extra;
|
||||
status.textContent = st;
|
||||
}
|
||||
// 聚合模式下站源很多,不再一次性把全部请求炸出去(互相抢占原生桥接/带宽,
|
||||
// 慢站容易超时失败而被静默吞掉),改用并发池限流,失败的任务记下来,
|
||||
// 第一轮跑完后用更长超时单独重试一次。
|
||||
// 注意:每个站源只生成一个任务(不再叠加简繁变体),避免同一站源被并发重复打多次而触发限流。
|
||||
const jobs = aggregate
|
||||
? SITES.map(s=>({site:s,q}))
|
||||
: [{site:site(),q}];
|
||||
const total=jobs.length;
|
||||
const AGG_CONCURRENCY=5;
|
||||
const siteCounts={}; // 诊断:记录每个站各自搜到几条('✕'=请求失败/超时)
|
||||
async function runJob(job,timeoutSec,failBucket){
|
||||
try{
|
||||
const _list=await searchSite(job.site,job.q,timeoutSec);
|
||||
siteCounts[job.site.id]=_list.length; // 成功:记录条数(0 表示请求通了但没结果)
|
||||
mergeOne(_list);
|
||||
}catch(e){
|
||||
// 记录失败的具体原因,便于区分「被拦截(CF盾)/超时/连不上/地址错」
|
||||
const msg=String((e&&e.message)||'');
|
||||
let tag='✕';
|
||||
if(/拦截|just a moment|cf-browser|access denied|无响应|为空/i.test(msg))tag='✕盾';
|
||||
else if(/abort|timeout|超时/i.test(msg))tag='✕超时';
|
||||
else if(/HTTP\s*4|HTTP\s*5|not found|404|403|503/i.test(msg))tag='✕'+(msg.match(/\d{3}/)||['错'])[0];
|
||||
else if(/failed|network|fetch|connect|无法|失败/i.test(msg))tag='✕断';
|
||||
siteCounts[job.site.id]=tag;
|
||||
failBucket.push(job);
|
||||
}finally{
|
||||
done++;
|
||||
if(done===total){flush(total);return} // 最后一个站源:无论有没有结果都要收尾
|
||||
if(map.size===0)return; // 还没有任何结果,继续等,避免提前显示「暂无内容」
|
||||
if(!shown){flush(total)} // 第一批有效结果,立刻展示
|
||||
else if(!flushTimer)flushTimer=setTimeout(()=>{flushTimer=null;flush(total)},400);
|
||||
}
|
||||
}
|
||||
async function runPool(list,timeoutSec,failBucket,conc){
|
||||
let i=0;
|
||||
async function worker(){while(i<list.length){await runJob(list[i++],timeoutSec,failBucket)}}
|
||||
await Promise.all(Array.from({length:Math.min(conc||AGG_CONCURRENCY,list.length)||1},worker));
|
||||
}
|
||||
let failed=[];
|
||||
await runPool(jobs,20,failed);
|
||||
if(flushTimer){clearTimeout(flushTimer);flushTimer=null}
|
||||
if(gen!==_searchGen)return; // 搜索期间已经离开了搜索页:到这里直接收手,不再动 DOM、不再关加载动画
|
||||
if(failed.length){
|
||||
// 给超时/失败的站源第二次机会:单独重试,超时放宽、并发降到2,减少再次抢占
|
||||
const retryJobs=failed;failed=[];
|
||||
done=total-retryJobs.length;
|
||||
await runPool(retryJobs,30,failed,3);
|
||||
if(flushTimer){clearTimeout(flushTimer);flushTimer=null}
|
||||
if(gen!==_searchGen)return;
|
||||
}
|
||||
const failedNames=[...new Set(failed.map(j=>j.site.name))];
|
||||
flush(total, failedNames.length?` · ${failedNames.join('、')}超时未返回`:'');
|
||||
reveal(); // 兜底:万一全程零结果也要把加载动画关掉
|
||||
content.classList.remove('searching');
|
||||
// 后台逐张验证:抓详情判断有没有网盘,把确认没网盘的空卡淡出隐藏(聚合模式才做)。
|
||||
if(aggregate && gen===_searchGen && arr.length) verifyAndHideEmpty(arr.slice(), gen);
|
||||
}
|
||||
function panType(u){
|
||||
u=String(u||'').toLowerCase();
|
||||
if(u.includes('pan.baidu'))return'百度';
|
||||
if(u.includes('quark'))return'夸克';
|
||||
if(u.includes('aliyun')||u.includes('alipan'))return'阿里';
|
||||
if(u.includes('xunlei'))return'迅雷';
|
||||
if(u.includes('115.com')||u.includes('115cdn'))return'115';
|
||||
// 123网盘官方域名较多且常变化(123684/123865/123912/123pan等),
|
||||
// 用域名模式匹配而非裸数字"123",避免站源自家域名/广告链接被误判
|
||||
if(/123(?:\d{2,4})?\.(?:com|cn|net)|123pan\.(?:com|cn)/.test(u))return'123';
|
||||
if(u.includes('cloud.189'))return'天翼';
|
||||
if(u.includes('caiyun')||u.includes('139'))return'移动';
|
||||
if(u.includes('drive.uc.cn')||u.includes('uc123')||/(?:^|[.\/])uc\.cn/.test(u))return'UC';
|
||||
return'网盘'
|
||||
}
|
||||
// ========== TMDB 海报获取 ==========
|
||||
const TMDB_KEY='d7040155454e7fdf547c4d889ebbcca7';
|
||||
// 用 api.tmdb.org(非官方 api.themoviedb.org,后者在国内被 DNS 污染,需代理才通)
|
||||
// 大部分地区可直连,无需代理;image.tmdb.org 一般可直接访问
|
||||
const TMDB_API='https://api.tmdb.org/3';
|
||||
const TMDB_IMG_LIST='https://image.tmdb.org/t/p/w342'; // 首页卡片网格:小图,省流量
|
||||
const TMDB_IMG_FULL='https://image.tmdb.org/t/p/original'; // 详情页大图:最高原始画质
|
||||
function tmdbCleanTitle(t){
|
||||
return clean(t)
|
||||
// 去掉成对括号及其中内容(如 (臻彩)/(4K)/(国语) 等标签),即只用前面的正片名去匹配
|
||||
.replace(/[\((\[【][^\))\]】]*[\))\]】]/g,'')
|
||||
// 去掉残留的孤立括号符号
|
||||
.replace(/[\[\]【】()()]/g,'')
|
||||
.replace(/第[一二三四五六七八九十\d]+[季部]|Season\s*\d+/ig,'')
|
||||
.replace(/\s{2,}/g,' ')
|
||||
// 去掉结尾残留的分隔符/标点
|
||||
.replace(/[\s·::\-—_、,,。.]+$/,'')
|
||||
.trim();
|
||||
}
|
||||
// TMDB 请求:优先走原生桥接 fm.req(App 内可直连 api.tmdb.org,绕过 WebView fetch 限制),
|
||||
// 无桥接时用带超时的 fetch;连续失败熔断,避免拖慢界面
|
||||
let _tmdbDead=false,_tmdbFail=0;
|
||||
async function tmdbJSON(url,timeoutSec=8){
|
||||
if(_tmdbDead)throw new Error('tmdb-skip');
|
||||
const fm=await fmReady();
|
||||
try{
|
||||
let data;
|
||||
if(fm&&fm.req){
|
||||
const r=await fm.req(url,{method:'GET',headers:{},responseType:'text',timeout:timeoutSec});
|
||||
if(!r||!r.ok)throw new Error((r&&r.error)||('HTTP '+(r?r.status:'?')));
|
||||
data=JSON.parse(r.body);
|
||||
}else{
|
||||
const ac=new AbortController();const t=setTimeout(()=>ac.abort(),timeoutSec*1000);
|
||||
try{const r=await fetch(url,{signal:ac.signal});data=await r.json()}finally{clearTimeout(t)}
|
||||
}
|
||||
_tmdbFail=0;
|
||||
return data;
|
||||
}catch(e){
|
||||
if(++_tmdbFail>=4)_tmdbDead=true;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
// 标题归一化:去空格/标点,转小写,用于相似度比较
|
||||
function tmdbNormForMatch(s){
|
||||
return (s||'').toLowerCase().replace(/[\s·::\-—_、,,。.!!??'"“”‘’()()\[\]【】]/g,'');
|
||||
}
|
||||
// 判断 TMDB 候选结果的标题是否与查询标题足够匹配
|
||||
// 规则:归一化后完全相等,或互相包含(处理"XX 第二季"清洗后与"XX"匹配的情况),
|
||||
// 短标题(≤2 个有效字符)要求完全相等,避免"玩偶""孤儿"等泛标题误配到无关条目
|
||||
function tmdbTitleMatches(query,candidate){
|
||||
const q=tmdbNormForMatch(query),c=tmdbNormForMatch(candidate);
|
||||
if(!q||!c)return false;
|
||||
if(q===c)return true;
|
||||
if(q.length<=2||c.length<=2)return q===c;
|
||||
return q.includes(c)||c.includes(q);
|
||||
}
|
||||
// 返回 TMDB 匹配到的完整条目(id / media_type / poster_path 等),由调用方按需取用
|
||||
async function tmdbMatchItem(title,year){
|
||||
const q=tmdbCleanTitle(title);
|
||||
if(!q)return null;
|
||||
try{
|
||||
let url=`${TMDB_API}/search/multi?api_key=${TMDB_KEY}&language=zh-CN&query=${encodeURIComponent(q)}`;
|
||||
let d=await tmdbJSON(url);
|
||||
let results=d.results||[];
|
||||
const yearOf=(x)=>String(x.release_date||x.first_air_date||'').slice(0,4);
|
||||
// 标题匹配且有海报的候选
|
||||
let titleMatched=results.filter(x=>x.poster_path&&tmdbTitleMatches(q,x.title||x.name||x.original_title||x.original_name||''));
|
||||
// 有年份时:优先选「标题匹配 且 年份一致」的那一版,确保海报对应正确年份(如《鹿鼎记》1992 ≠ 2008)
|
||||
if(year){
|
||||
let exact=titleMatched.find(x=>yearOf(x)===year);
|
||||
if(exact)return exact;
|
||||
}
|
||||
if(titleMatched.length)return titleMatched[0];
|
||||
// 兜底:冷门/非英语片 TMDB 可能没有匹配的中文译名
|
||||
let withPoster=results.filter(x=>x.poster_path);
|
||||
if(year){ let ey=withPoster.find(x=>yearOf(x)===year); if(ey)return ey; }
|
||||
if(results.length<=3&&withPoster.length)return withPoster[0];
|
||||
}catch(e){}
|
||||
return null;
|
||||
}
|
||||
// 匹配缓存(按清洗后的标题做 key,缓存完整条目,避免重复请求;海报/更新状态共用同一份缓存)
|
||||
const tmdbMatchCache=new Map();
|
||||
function tmdbMatchCached(title,year){
|
||||
const ct=tmdbCleanTitle(title);
|
||||
if(!ct)return Promise.resolve(null);
|
||||
const key=ct+'|'+(year||'');
|
||||
if(tmdbMatchCache.has(key))return tmdbMatchCache.get(key);
|
||||
const p=tmdbMatchItem(title,year);
|
||||
tmdbMatchCache.set(key,p);
|
||||
return p;
|
||||
}
|
||||
async function tmdbPathCached(title,year){
|
||||
const item=await tmdbMatchCached(title,year);
|
||||
return item?item.poster_path:null;
|
||||
}
|
||||
// 首页卡片网格用:小图
|
||||
async function tmdbPosterCached(title,year){
|
||||
const path=await tmdbPathCached(title,year);
|
||||
return path?TMDB_IMG_LIST+path:null;
|
||||
}
|
||||
// 详情页大图用:最高原始画质
|
||||
async function tmdbPosterFullCached(title,year){
|
||||
const path=await tmdbPathCached(title,year);
|
||||
return path?TMDB_IMG_FULL+path:null;
|
||||
}
|
||||
// 全屏沉浸式背景用:TMDB 横版剧照(backdrop),用 original 最高画质(高分屏平板不糊)
|
||||
async function tmdbBackdropCached(title,year){
|
||||
const item=await tmdbMatchCached(title,year);
|
||||
return item&&item.backdrop_path?(TMDB_IMG_FULL+item.backdrop_path):null;
|
||||
}
|
||||
// 详情页海报轮播图源:横屏用横版剧照(backdrops)、竖屏用竖版海报(posters),各自不裁切
|
||||
const tmdbImagesCache=new Map();
|
||||
async function tmdbImagesCached(title,year){
|
||||
const item=await tmdbMatchCached(title,year);
|
||||
if(!item||!item.id)return [];
|
||||
const landscape=window.innerWidth>=window.innerHeight;
|
||||
const type=(item.media_type==='tv'||item.first_air_date)?'tv':'movie';
|
||||
const ck=type+item.id+(landscape?'L':'P');
|
||||
if(tmdbImagesCache.has(ck))return tmdbImagesCache.get(ck);
|
||||
const p=(async()=>{
|
||||
try{
|
||||
const d=await tmdbJSON(`${TMDB_API}/${type}/${item.id}/images?api_key=${TMDB_KEY}`);
|
||||
let arr=((landscape?d.backdrops:d.posters)||[]).filter(b=>b.file_path);
|
||||
if(landscape && !arr.length) arr=(d.posters||[]).filter(b=>b.file_path); // 横屏没有横版剧照时,退回竖版海报(至少清晰,不用站源模糊图)
|
||||
arr.sort((a,b)=>(b.vote_average||0)-(a.vote_average||0)); // 高分图排前
|
||||
return arr.slice(0,6).map(b=>TMDB_IMG_FULL+b.file_path);
|
||||
}catch(e){return []}
|
||||
})();
|
||||
tmdbImagesCache.set(ck,p);
|
||||
return p;
|
||||
}
|
||||
// 海报轮播:两图层 A/B 交替 crossfade;淡出时露出的是 detHeroBg 深色底,不会露主页
|
||||
let _bgRot={timer:null,active:'A'};
|
||||
function stopBgRotation(){ if(_bgRot.timer){clearInterval(_bgRot.timer);_bgRot.timer=null;} }
|
||||
function setBackdrops(urls){
|
||||
stopBgRotation();
|
||||
const A=document.getElementById('detHeroBgA'),B=document.getElementById('detHeroBgB');
|
||||
if(!A||!B||!urls||!urls.length)return;
|
||||
const pos=window.innerWidth>=window.innerHeight?'center 12%':'center 28%'; // 横版偏上、竖版海报居中偏上
|
||||
A.style.backgroundPosition=pos;B.style.backgroundPosition=pos;
|
||||
A.style.backgroundImage=`url("${urls[0]}")`;A.style.opacity='1';
|
||||
B.style.opacity='0';
|
||||
_bgRot.active='A';
|
||||
if(urls.length>1){
|
||||
let idx=0;
|
||||
_bgRot.timer=setInterval(()=>{
|
||||
idx=(idx+1)%urls.length;
|
||||
const cur=_bgRot.active==='A'?A:B, nxt=_bgRot.active==='A'?B:A;
|
||||
nxt.style.backgroundImage=`url("${urls[idx]}")`;
|
||||
nxt.style.opacity='1';cur.style.opacity='0';
|
||||
_bgRot.active=_bgRot.active==='A'?'B':'A';
|
||||
},6500);
|
||||
}
|
||||
}
|
||||
// TMDB 类型(genre) ID→中文名(合并 movie 与 tv 两套),用于标题下方的影视类型标签
|
||||
const TMDB_GENRES={28:'动作',12:'冒险',16:'动画',35:'喜剧',80:'犯罪',99:'纪录',18:'剧情',10751:'家庭',14:'奇幻',36:'历史',27:'恐怖',10402:'音乐',9648:'悬疑',10749:'爱情',878:'科幻',10770:'电视电影',53:'惊悚',10752:'战争',37:'西部',10759:'动作冒险',10762:'儿童',10763:'新闻',10764:'真人秀',10765:'科幻奇幻',10766:'肥皂剧',10767:'脱口秀',10768:'战争政治'};
|
||||
async function tmdbGenresText(title,year){
|
||||
const item=await tmdbMatchCached(title,year);
|
||||
if(!item||!Array.isArray(item.genre_ids))return '';
|
||||
return item.genre_ids.map(id=>TMDB_GENRES[id]).filter(Boolean).slice(0,3).join(' · ');
|
||||
}
|
||||
// ===== TMDB 官方更新状态(连载中/已完结/更新至第N集),取代原先的「N条资源」角标 =====
|
||||
const tmdbStatusCache=new Map();
|
||||
async function tmdbStatusText(title,year){
|
||||
const key=tmdbCleanTitle(title)+'|'+(year||'');
|
||||
if(!tmdbCleanTitle(title))return null;
|
||||
if(tmdbStatusCache.has(key))return tmdbStatusCache.get(key);
|
||||
const p=(async()=>{
|
||||
try{
|
||||
const item=await tmdbMatchCached(title,year);
|
||||
if(!item||!item.id)return null;
|
||||
const type=item.media_type==='movie'?'movie':'tv'; // multi 搜索的非 tv/movie 结果(如 person)已在匹配阶段被海报条件过滤掉
|
||||
const url=`${TMDB_API}/${type}/${item.id}?api_key=${TMDB_KEY}&language=zh-CN`;
|
||||
const d=await tmdbJSON(url);
|
||||
if(type==='movie'){
|
||||
// 电影:用上映日期年份即可,状态意义不大
|
||||
const y=(d.release_date||'').slice(0,4);
|
||||
return d.status==='Released'?(y?`${y} 已上映`:'已上映'):(d.status?tmdbStatusZh(d.status):null);
|
||||
}
|
||||
// 剧集:在播给「更新至第N集」,完结/已取消给「已完结」
|
||||
if(d.in_production && (d.status==='Returning Series'||d.status==='In Production')){
|
||||
const last=d.last_episode_to_air;
|
||||
return last&&last.episode_number?`更新至第${last.episode_number}集`:'连载中';
|
||||
}
|
||||
if(d.status==='Ended'||d.status==='Canceled')return '已完结';
|
||||
return d.status?tmdbStatusZh(d.status):null;
|
||||
}catch(e){return null}
|
||||
})();
|
||||
tmdbStatusCache.set(key,p);
|
||||
return p;
|
||||
}
|
||||
function tmdbStatusZh(s){
|
||||
return ({Released:'已上映','Post Production':'制作后期',Planned:'制作中',
|
||||
'In Production':'制作中',Rumored:'传闻阶段','Returning Series':'连载中',
|
||||
Ended:'已完结',Canceled:'已取消'})[s]||null;
|
||||
}
|
||||
// 站源内容(标题/链接/资源等)保持不变,只异步替换卡片封面图
|
||||
// ===== 封面兜底链:原图正常则保留;缺图/裂图才依次尝试 TMDB → 站源详情页大图 → 占位 =====
|
||||
const _posterAltHost=(u)=> u.indexOf('://image.tmdb.org')!==-1 ? u.replace('://image.tmdb.org','://images.tmdb.org')
|
||||
: u.indexOf('://images.tmdb.org')!==-1 ? u.replace('://images.tmdb.org','://image.tmdb.org') : u;
|
||||
// 预加载 url,成功才把第 i 张卡封面换成它;href 防止异步返回时卡片已被新列表替换
|
||||
function posterTrySet(i,url,href){
|
||||
return new Promise(resolve=>{
|
||||
if(!url){resolve(false);return}
|
||||
const valid=()=>{const c=content.querySelector(`.card[data-i="${i}"]`);return (c&&(!href||c.dataset.href===href))?c:null};
|
||||
if(!valid()){resolve(false);return}
|
||||
const probe=new Image();let triedAlt=false;
|
||||
probe.referrerPolicy='no-referrer';
|
||||
probe.onload=()=>{
|
||||
const card=valid(); if(!card){resolve(false);return}
|
||||
const poster=card.querySelector('.poster'); if(!poster){resolve(false);return}
|
||||
let img=poster.querySelector('img');
|
||||
if(img){img.src=probe.src;img.style.display=''}
|
||||
else{img=document.createElement('img');img.loading='lazy';img.referrerPolicy='no-referrer';img.src=probe.src;poster.insertBefore(img,poster.firstChild)}
|
||||
const noimg=poster.querySelector('.noimg'); if(noimg)noimg.remove();
|
||||
resolve(true);
|
||||
};
|
||||
probe.onerror=()=>{ if(!triedAlt){triedAlt=true;const a=_posterAltHost(url);if(a!==url){probe.src=a;return}} resolve(false) };
|
||||
probe.src=url;
|
||||
});
|
||||
}
|
||||
// 二级兜底:到该卡对应站源的详情页抓大图
|
||||
async function posterFromDetail(v){
|
||||
const src=(v&&v.sources&&v.sources[0])||v;
|
||||
if(!src||!src.href)return'';
|
||||
const s=SITES.find(x=>x.id===src.siteId)||site();
|
||||
const r=await get(s,src.href),d=doc(r.html);
|
||||
const el=d.querySelector('.module-info-poster img,.detail-pic img,.module-item-pic img,img');
|
||||
if(!el)return'';
|
||||
let pic=el.getAttribute('data-original')||el.getAttribute('data-src')||el.getAttribute('src')||'';
|
||||
pic=imgUrl(pic); if(!pic)return'';
|
||||
pic=abs(r.base,pic);
|
||||
return pic===v.pic ? '' : pic; // 与已失败的列表图相同就不必再试
|
||||
}
|
||||
// 对第 i 张卡执行兜底链(自带去重,避免重复触发)
|
||||
async function posterFallback(i){
|
||||
const poster=content.querySelector(`.card[data-i="${i}"] .poster`);
|
||||
if(!poster||poster.dataset.fb)return; poster.dataset.fb='1';
|
||||
const v=(last||[])[i]; if(!v||!v.title)return;
|
||||
const href=v.href;
|
||||
try{const u=await tmdbPosterCached(v.title,extractYear(v)); if(u&&await posterTrySet(i,u,href))return}catch(e){}
|
||||
try{const u=await posterFromDetail(v); if(u&&await posterTrySet(i,u,href))return}catch(e){}
|
||||
// 都失败:保留“宝宝巴士”占位
|
||||
}
|
||||
// 列表图“加载成功”时检查:尺寸过小(1×1/占位/空白图)视为无效,转入兜底链
|
||||
window._posterOk=function(img){
|
||||
if(!img||img.dataset.failed||img.dataset.ok)return;
|
||||
if(img.naturalWidth&&img.naturalHeight&&img.naturalWidth<50&&img.naturalHeight<50){window._posterFail(img);return}
|
||||
img.dataset.ok='1';
|
||||
};
|
||||
// 列表图加载失败(裂图)时由 <img onerror> 调用:隐藏裂图、显示占位、启动兜底链
|
||||
window._posterFail=function(img){
|
||||
if(!img||img.dataset.failed)return; img.dataset.failed='1';
|
||||
const card=img.closest('.card'); if(!card)return;
|
||||
const poster=card.querySelector('.poster'); if(!poster)return;
|
||||
img.style.display='none';
|
||||
if(!poster.querySelector('.noimg')){const ph=document.createElement('div');ph.className='noimg';ph.textContent='宝宝巴士';poster.insertBefore(ph,poster.firstChild)}
|
||||
const i=parseInt(card.dataset.i);
|
||||
if(!isNaN(i))posterFallback(i);
|
||||
};
|
||||
async function enhanceGridPosters(list){
|
||||
const CONCURRENCY=4;
|
||||
let idx=0;
|
||||
function applyStatus(i,text){
|
||||
const badge=content.querySelector(`.card[data-i="${i}"] .source-badge`);
|
||||
if(!badge||!text)return;
|
||||
badge.textContent=text;
|
||||
badge.classList.add('show');
|
||||
}
|
||||
async function worker(){
|
||||
while(idx<list.length){
|
||||
const i=idx++;const v=list[i];
|
||||
if(!v||!v.title)continue;
|
||||
if(!v.pic)posterFallback(i); // 缺图:直接走兜底链(裂图由 onerror 触发,正常封面不动)
|
||||
else{
|
||||
// 有图:超时兜底——9 秒后仍未出图(卡住/空白)则转入兜底链
|
||||
const href=v.href, ii=i;
|
||||
setTimeout(()=>{
|
||||
const card=content.querySelector(`.card[data-i="${ii}"]`);
|
||||
if(!card||card.dataset.href!==href)return;
|
||||
const img=card.querySelector('.poster img');
|
||||
if(!img||img.dataset.ok||img.dataset.failed)return;
|
||||
if(!img.complete||!img.naturalWidth||img.naturalWidth<50)window._posterFail(img);
|
||||
},9000);
|
||||
}
|
||||
try{const st=await tmdbStatusText(v.title,extractYear(v));if(st)applyStatus(i,st)}catch(e){}
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({length:Math.min(CONCURRENCY,list.length)},worker));
|
||||
}
|
||||
// 复制链接工具
|
||||
async function copyText(text){
|
||||
try{await navigator.clipboard.writeText(text);alert('链接已复制!')}catch(e){alert('复制失败,请手动复制:'+text)}
|
||||
}
|
||||
// 探测一张卡点进去到底有没有网盘:抓详情页 HTML,跑网盘域名正则。
|
||||
// 返回 {ok:是否成功抓到详情, has:是否含网盘链接}。最多查前 3 个源,命中即停。
|
||||
const _PAN_TEST=/https?:\/\/(?:[\w.-]*\.)?(?:pan\.baidu\.com|pan\.quark\.cn|drive\.uc\.cn|aliyundrive\.com|alipan\.com|aliyunpan\.com|cloud\.189\.cn|115\.com|115cdn\.[a-z0-9]+|123[a-z0-9]*\.(?:com|cn|net)|caiyun\.139\.com|pan\.xunlei\.com|mypikpak\.com|lanzou[a-z]*\.[a-z]+|feijipan\.com)\//i;
|
||||
const _panProbeCache=new Map(); // 按 href 缓存探测结果,避免和详情页打开重复抓
|
||||
async function probePan(v){
|
||||
const sources=(v.sources||[v]).slice(0,3);
|
||||
let anyOk=false;
|
||||
for(const src of sources){
|
||||
const ck=src.href;
|
||||
if(ck&&_panProbeCache.has(ck)){ if(_panProbeCache.get(ck)) return {ok:true,has:true}; anyOk=true; continue; }
|
||||
try{
|
||||
const s=SITES.find(x=>x.id===src.siteId)||site();
|
||||
const r=await get(s,src.href,8,true);
|
||||
anyOk=true;
|
||||
const RAW=(r.html||'').replace(/\\\//g,'/');
|
||||
const hit=_PAN_TEST.test(RAW);
|
||||
if(ck)_panProbeCache.set(ck,hit);
|
||||
if(hit)return {ok:true,has:true};
|
||||
}catch(e){ if(ck)_panProbeCache.set(ck,false); }
|
||||
}
|
||||
return {ok:anyOk,has:false};
|
||||
}
|
||||
// 搜索后台验证:把「确认抓到了详情、但里面没有任何网盘链接」的空卡淡出隐藏。
|
||||
// 抓取失败/超时的卡一律保留,不冤枉。gen 用于在用户离开搜索后及时停止。
|
||||
async function verifyAndHideEmpty(list,gen){
|
||||
const CONC=6; // 验证在搜索请求结束后才跑,此时桥接空闲,可放开并发加快隐藏
|
||||
let idx=0;
|
||||
async function worker(){
|
||||
while(idx<list.length){
|
||||
const i=idx++;
|
||||
if(gen!==_searchGen)return;
|
||||
const v=list[i];
|
||||
if(!v)continue;
|
||||
let res;
|
||||
try{res=await probePan(v)}catch(e){res={ok:false,has:false}}
|
||||
if(gen!==_searchGen)return;
|
||||
if(res.ok&&!res.has){
|
||||
const card=content.querySelector(`.card[data-i="${i}"]`);
|
||||
if(card){
|
||||
card.style.transition='opacity .3s';
|
||||
card.style.opacity='0';
|
||||
setTimeout(()=>{if(card&&card.parentNode)card.style.display='none'},300);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({length:CONC},worker));
|
||||
}
|
||||
async function parseDetail(v){
|
||||
let sources=v.sources||[v];
|
||||
// 并发请求所有站源(最多5个),不再一个个串行等,详情页打开速度≈最慢那一个源,而不是全部加起来
|
||||
let results=await Promise.all(sources.slice(0,10).map(async src=>{
|
||||
let s=SITES.find(x=>x.id===src.siteId)||site();
|
||||
try{
|
||||
let r=await get(s,src.href),d=doc(r.html);
|
||||
let picEl=d.querySelector('.module-info-poster img,.detail-pic img,img');
|
||||
let title=stripTitleNoise(clean((d.querySelector('h1,.title,.module-info-heading h1')||{}).textContent))||src.title;
|
||||
let pic=imgUrl(picEl&&picEl.getAttribute('src')||src.pic);
|
||||
let desc=clean((d.querySelector('.module-info-introduction,.desc,.module-info-content')||{}).textContent)||'';
|
||||
let pans=[];
|
||||
let txtEls=[...d.querySelectorAll(s.detailPanSelector||'.module-row-info p')];
|
||||
txtEls.forEach(p=>{
|
||||
let t=clean(p.textContent);
|
||||
if(/^https?:/.test(t))pans.push({name:src.siteName+' · '+panType(t),url:t,type:panType(t),siteName:src.siteName})
|
||||
});
|
||||
[...d.querySelectorAll('a[href]')].forEach(a=>{
|
||||
let u=a.getAttribute('href')||'';
|
||||
if(/pan\.baidu|quark|aliyun|alipan|xunlei|115\.com|115cdn|123(?:\d{2,4})?\.(?:com|cn|net)|123pan\.(?:com|cn)|cloud\.189|caiyun|drive\.uc\.cn|magnet:/i.test(u))pans.push({name:src.siteName+' · '+(clean(a.textContent)||panType(u)),url:abs(r.base,u),type:panType(u),siteName:src.siteName})
|
||||
});
|
||||
// 终极兜底:直接对整页 HTML 源码做正则扫描。这样无论网盘链接是放在普通文本、
|
||||
// a 标签、复制按钮的 data-clipboard-text、输入框 value、还是 JS 字符串里,都能捞出来。
|
||||
// 先还原 JSON 里被转义的斜杠(\/→/),再按网盘域名匹配。
|
||||
const RAW=(r.html||'').replace(/\\\//g,'/');
|
||||
const PAN_RE=/https?:\/\/(?:[\w.-]*\.)?(?:pan\.baidu\.com|pan\.quark\.cn|drive\.uc\.cn|aliyundrive\.com|alipan\.com|aliyunpan\.com|cloud\.189\.cn|115\.com|115cdn\.[a-z0-9]+|123[a-z0-9]*\.(?:com|cn|net)|caiyun\.139\.com|pan\.xunlei\.com|mypikpak\.com|lanzou[a-z]*\.[a-z]+|feijipan\.com|f\.ws[a-z0-9.]+)\/[A-Za-z0-9\-_/?=&%.#~+]*/gi;
|
||||
let mm;
|
||||
while((mm=PAN_RE.exec(RAW))){
|
||||
let u=mm[0].replace(/&/g,'&').replace(/[,。、))]+$/,'');
|
||||
pans.push({name:src.siteName+' · '+panType(u),url:u,type:panType(u),siteName:src.siteName});
|
||||
}
|
||||
return{info:{title,pic:abs(r.base,pic),desc,siteName:src.siteName},pans};
|
||||
}catch(e){return null}
|
||||
}));
|
||||
// 按数组原本顺序(非返回先后)取第一个成功的源作为标题/海报/简介来源,避免并发后信息来源不稳定地跳动
|
||||
let main=(results.find(x=>x)||{}).info||v;
|
||||
let all=results.filter(x=>x).flatMap(x=>x.pans);
|
||||
let seen=new Set();
|
||||
all=all.filter(x=>x.url&&!seen.has(x.url)&&(seen.add(x.url)||true));
|
||||
return{info:main,pans:all}
|
||||
}
|
||||
let _currentDetailItem=null; // 当前详情页对应的条目,供播放时记录历史用(只在真正点击播放才记录,浏览/搜索不记录)
|
||||
async function openDetail(v){
|
||||
_currentDetailItem=v;
|
||||
if (location.hash !== '#detail') history.pushState({ wo: 'detail' }, '', '#detail');
|
||||
|
||||
const sheet=$('#sheet'), panel=$('#panel');
|
||||
const heroImg=$('#detHeroImg'), hero=$('#detHero'), topbar=$('#detTopbar'), topbarTitle=$('#detTopbarTitle');
|
||||
const heroCta=$('#detHeroCta');
|
||||
|
||||
// 重置滚动 & 顶栏状态
|
||||
sheet.scrollTop=0;
|
||||
topbar.classList.remove('scrolled');
|
||||
heroImg.classList.remove('loaded');
|
||||
heroImg.removeAttribute('src');
|
||||
hero.classList.add('landscape');
|
||||
sheet.classList.add('landscape-mode'); // 所有详情统一全屏沉浸,避免退回半屏的文档流海报
|
||||
{stopBgRotation();const bg=document.getElementById('detHeroBg');if(bg){delete bg.dataset.bd;bg.classList.add('show');}const A=document.getElementById('detHeroBgA'),B=document.getElementById('detHeroBgB');if(A){A.style.backgroundImage='';A.style.opacity='0';}if(B){B.style.backgroundImage='';B.style.opacity='0';}} // 立即显示深色背景层、清空旧海报,海报图加载好后再覆盖,避免空窗期露出主页
|
||||
heroCta.innerHTML='';
|
||||
|
||||
sheet.classList.add('active');
|
||||
document.body.classList.add('sheet-open');
|
||||
// 详情页不需要"回到顶部"按钮(它的 z-index 高于详情页,会悬浮在上面遮挡内容)
|
||||
if(window._backTopReset) window._backTopReset();
|
||||
|
||||
// 显示加载状态
|
||||
panel.innerHTML='<div class="det-loading"><div class="det-spinner"></div><span>读取详情…</span></div>';
|
||||
|
||||
// 吸顶滚动监听
|
||||
const onScroll=()=>{
|
||||
topbar.classList.toggle('scrolled', sheet.scrollTop > 60);
|
||||
};
|
||||
sheet.addEventListener('scroll', onScroll, {passive:true});
|
||||
sheet._detScrollCb = onScroll;
|
||||
|
||||
try{
|
||||
// 只等站源详情(直连、快),不再 await TMDB,避免被 TMDB 拖慢或卡住打不开
|
||||
let d=await parseDetail(v);
|
||||
let info=d.info;
|
||||
const title=info.title||v.title||'';
|
||||
const sources=(v.sources||[v]).map(x=>x.siteName).join(' · ');
|
||||
const quality=v.quality||'';
|
||||
|
||||
topbarTitle.textContent=title;
|
||||
// 海报:先用站源图立即显示;TMDB 高清图在后台拿到后再静默替换(超时/失败则保持站源图)
|
||||
const altHost=(u)=> u.indexOf('://image.tmdb.org')!==-1 ? u.replace('://image.tmdb.org','://images.tmdb.org')
|
||||
: u.indexOf('://images.tmdb.org')!==-1 ? u.replace('://images.tmdb.org','://image.tmdb.org') : u;
|
||||
const setHero=(pic)=>{
|
||||
if(!pic)return;
|
||||
let triedAlt=false;
|
||||
heroImg.onload=()=>{
|
||||
const bg=document.getElementById('detHeroBg');
|
||||
// 站源图(无论横竖)都作为全屏背景兜底,TMDB 高清图就绪后会覆盖
|
||||
if(bg && !bg.dataset.bd){
|
||||
const A=document.getElementById('detHeroBgA');
|
||||
if(A){A.style.backgroundImage=`url("${heroImg.src}")`;A.style.opacity='1';}
|
||||
bg.classList.add('show');
|
||||
}
|
||||
heroImg.classList.add('loaded');
|
||||
};
|
||||
heroImg.onerror=()=>{
|
||||
if(!triedAlt){triedAlt=true;const a=altHost(heroImg.src||'');if(a&&a!==heroImg.src){heroImg.src=a;return}}
|
||||
heroImg.classList.remove('loaded');heroImg.removeAttribute('src');
|
||||
};
|
||||
heroImg.src=pic;
|
||||
};
|
||||
setHero(v.pic||info.pic||'');
|
||||
tmdbPosterFullCached(v.title||'',extractYear(v)).then(tp=>{if(tp)setHero(tp)}).catch(()=>{});
|
||||
// 影视类型标签(TMDB genres),标题下方居中
|
||||
tmdbGenresText(v.title||'',extractYear(v)).then(g=>{
|
||||
const el=document.getElementById('detGenres');
|
||||
if(el&&g)el.textContent=g;
|
||||
}).catch(()=>{});
|
||||
tmdbImagesCached(v.title||'',extractYear(v)).then(urls=>{
|
||||
const bg=document.getElementById('detHeroBg');
|
||||
if(bg&&urls&&urls.length){
|
||||
setBackdrops(urls); // 多张剧照轮播;只有一张时就静态显示
|
||||
bg.dataset.bd='1';
|
||||
bg.classList.add('show');
|
||||
sheet.classList.add('landscape-mode');
|
||||
}
|
||||
}).catch(()=>{});
|
||||
|
||||
// pills
|
||||
const pills=[
|
||||
sources && `<span class="det-pill">${esc(sources)}</span>`,
|
||||
quality && `<span class="det-pill accent">${esc(quality)}</span>`,
|
||||
d.pans.length && `<span class="det-pill">${d.pans.length} 条资源</span>`,
|
||||
].filter(Boolean).join('');
|
||||
|
||||
// 海报内部底部:播放按钮
|
||||
heroCta.innerHTML = d.pans.length?`<button class="det-play-btn" id="detPlayBtn" aria-label="立即播放">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||||
</button>`:'';
|
||||
|
||||
panel.innerHTML=`
|
||||
<div class="det-bigtitle">${esc(title)}</div>
|
||||
<div class="det-genres" id="detGenres"></div>
|
||||
<div class="det-collapse-row">
|
||||
<section class="det-collapse det-res">
|
||||
<div class="det-collapse-head">
|
||||
<div class="det-res-tabs">
|
||||
<button class="det-res-tab active" data-tab="netdisk" type="button">网盘资源<em>${d.pans.length||0}</em></button>
|
||||
<button class="det-res-tab" data-tab="pansou" type="button">盘搜资源</button>
|
||||
</div>
|
||||
<button id="panCfgBtn" class="pan-cfg-btn" title="盘搜设置(地址 + 网盘类型)">⚙</button>
|
||||
</div>
|
||||
<div class="det-collapse-body">
|
||||
<div class="det-res-pane" data-pane="netdisk">
|
||||
<div class="pan-list">
|
||||
${d.pans.map((p,i)=>`
|
||||
<div class="pan-item">
|
||||
<span class="pan-type">${esc(p.type)}</span>
|
||||
<div class="pan-info">
|
||||
<div class="pan-name">${esc(p.name)}</div>
|
||||
<div class="pan-url">${esc(p.url)}</div>
|
||||
</div>
|
||||
<div class="pan-actions">
|
||||
<button class="pan-open-btn" data-i="${i}">打开</button>
|
||||
</div>
|
||||
</div>`).join('')||'<div class="empty">未提取到网盘链接</div>'}
|
||||
</div>
|
||||
</div>
|
||||
<div class="det-res-pane" data-pane="pansou" hidden>
|
||||
<div class="pan-search-box" id="panSearchBox"><div class="pan-search-status">正在聚合搜索网盘…</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>`;
|
||||
|
||||
// 单条资源框:tab 切换「网盘资源 / 盘搜资源」,箭头/头部空白处展开收起(默认收起,露出海报)
|
||||
const resBox=panel.querySelector('.det-res');
|
||||
const resHead=resBox.querySelector('.det-collapse-head');
|
||||
resBox.querySelectorAll('.det-res-tab').forEach(tab=>{
|
||||
tab.addEventListener('click',(e)=>{
|
||||
e.stopPropagation();
|
||||
resBox.querySelectorAll('.det-res-tab').forEach(t=>t.classList.toggle('active',t===tab));
|
||||
resBox.querySelectorAll('.det-res-pane').forEach(p=>{p.hidden=(p.dataset.pane!==tab.dataset.tab)});
|
||||
resBox.classList.add('open'); // 点 tab 自动展开
|
||||
});
|
||||
});
|
||||
resHead.addEventListener('click',(e)=>{
|
||||
if(e.target.closest('.det-res-tab')||e.target.closest('.pan-cfg-btn'))return;
|
||||
resBox.classList.toggle('open');
|
||||
});
|
||||
// 点击海报任意位置(资源框、播放按钮以外)自动收起资源框
|
||||
sheet.onclick=(e)=>{
|
||||
if(resBox.classList.contains('open') && !e.target.closest('.det-res') && !e.target.closest('.det-hero-cta')){
|
||||
resBox.classList.remove('open');
|
||||
}
|
||||
};
|
||||
|
||||
// 快捷播放第一条
|
||||
const playBtn=$('#detPlayBtn');
|
||||
if(playBtn&&d.pans.length) playBtn.onclick=()=>playPan(d.pans[0]);
|
||||
|
||||
// 复制 & 打开按钮
|
||||
panel.querySelectorAll('.copy-btn').forEach(btn=>{
|
||||
btn.onclick=()=>copyText(btn.dataset.link);
|
||||
});
|
||||
panel.querySelectorAll('.pan-open-btn[data-i]').forEach(b=>{
|
||||
b.onclick=()=>playPan(d.pans[parseInt(b.dataset.i)]);
|
||||
});
|
||||
|
||||
// 盘搜:异步聚合搜索网盘资源,不阻塞详情首屏
|
||||
const panBox=$('#panSearchBox');
|
||||
const panKw=panKeyword(title);
|
||||
if(panBox&&panKw) startPanSearch(panBox,panKw);
|
||||
const panCfgBtn=$('#panCfgBtn');
|
||||
if(panCfgBtn) panCfgBtn.onclick=async(e)=>{
|
||||
e.stopPropagation();
|
||||
const ok=await openPanConfig();
|
||||
if(ok && panBox && panKw) startPanSearch(panBox,panKw); // 保存后用新地址/类型重搜
|
||||
};
|
||||
|
||||
}catch(e){
|
||||
panel.innerHTML=`<div class="det-loading"><div class="empty">详情失败:${esc(e.message)}</div></div>`;
|
||||
}
|
||||
}
|
||||
// ===== 盘搜(PanSou 聚合网盘搜索)=====
|
||||
const PAN_CONFIG={
|
||||
apiBase:'https://so.252035.xyz',
|
||||
diskTypes:['quark','aliyun','baidu','uc','tianyi','xunlei','123','115','mobile','guangya','magnet','ed2k']
|
||||
};
|
||||
try{ const saved=localStorage.getItem('wo_pan_api'); if(saved) PAN_CONFIG.apiBase=saved; }catch(e){}
|
||||
try{ const t=JSON.parse(localStorage.getItem('wo_pan_types')||'null'); if(Array.isArray(t)&&t.length) PAN_CONFIG.diskTypes=t; }catch(e){}
|
||||
// 一次性迁移:把新增类型补进老用户已保存的选择里(每个类型只补一次,之后尊重用户勾选)
|
||||
try{
|
||||
if(localStorage.getItem('wo_pan_types')){
|
||||
let migrated=[]; try{migrated=JSON.parse(localStorage.getItem('wo_pan_types_migrated')||'[]')}catch(_){}
|
||||
if(!Array.isArray(migrated))migrated=[];
|
||||
// 兼容旧的磁力迁移标记:视为 magnet/ed2k 已迁移
|
||||
if(localStorage.getItem('wo_pan_magnet_migrated')==='1'){['magnet','ed2k'].forEach(t=>{if(migrated.indexOf(t)===-1)migrated.push(t)})}
|
||||
let changed=false;
|
||||
['magnet','ed2k','guangya'].forEach(t=>{
|
||||
if(migrated.indexOf(t)===-1){
|
||||
if(PAN_CONFIG.diskTypes.indexOf(t)===-1){PAN_CONFIG.diskTypes.push(t);changed=true}
|
||||
migrated.push(t);
|
||||
}
|
||||
});
|
||||
if(changed) localStorage.setItem('wo_pan_types',JSON.stringify(PAN_CONFIG.diskTypes));
|
||||
localStorage.setItem('wo_pan_types_migrated',JSON.stringify(migrated));
|
||||
}
|
||||
}catch(e){}
|
||||
// 全部可选网盘类型(顺序即弹窗里的排列顺序)
|
||||
const PAN_ALL_TYPES=['quark','aliyun','baidu','uc','tianyi','xunlei','123','115','mobile','guangya','magnet','ed2k'];
|
||||
const PAN_TYPE_LABEL={quark:'夸克',aliyun:'阿里',baidu:'百度',uc:'UC',tianyi:'天翼',xunlei:'迅雷',123:'123',115:'115',mobile:'移动',guangya:'光鸭',magnet:'磁力',ed2k:'电驴'};
|
||||
const PAN_CHECK_TYPES=new Set(['aliyun','quark','uc','baidu','tianyi','123','xunlei','115','mobile']);
|
||||
function panTypeLabel(t){return PAN_TYPE_LABEL[t]||t||'网盘'}
|
||||
// 盘搜设置弹窗:接口地址 + 网盘类型多选;确定后写入本地存储,返回 true 表示已保存
|
||||
function openPanConfig(){
|
||||
return new Promise(resolve=>{
|
||||
const mask=$('#panCfgMask'),input=$('#panCfgInput'),okBtn=$('#panCfgOk'),cancelBtn=$('#panCfgCancel');
|
||||
const titleEl=mask&&mask.querySelector('.custom-modal-title');
|
||||
const typeCfg=$('#panTypeCfg'),grid=$('#panTypeGrid'),allBtn=$('#panTypeAll');
|
||||
if(!mask){resolve(false);return}
|
||||
if(titleEl) titleEl.textContent='盘搜设置';
|
||||
input.value=PAN_CONFIG.apiBase||'';
|
||||
const sel=new Set(PAN_CONFIG.diskTypes);
|
||||
function paintAll(){ allBtn.textContent = sel.size>=PAN_ALL_TYPES.length ? '全不选' : '全选'; }
|
||||
if(typeCfg) typeCfg.style.display='block';
|
||||
grid.innerHTML=PAN_ALL_TYPES.map(t=>
|
||||
`<button type="button" class="pan-type-chip${sel.has(t)?' on':''}" data-t="${esc(t)}">`
|
||||
+`<svg class="tick" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>`
|
||||
+`<span>${esc(panTypeLabel(t))}</span></button>`).join('');
|
||||
grid.querySelectorAll('.pan-type-chip').forEach(chip=>{
|
||||
chip.onclick=()=>{
|
||||
const t=chip.dataset.t;
|
||||
if(sel.has(t))sel.delete(t);else sel.add(t);
|
||||
chip.classList.toggle('on',sel.has(t));
|
||||
paintAll();
|
||||
};
|
||||
});
|
||||
allBtn.onclick=()=>{
|
||||
if(sel.size>=PAN_ALL_TYPES.length)sel.clear();
|
||||
else PAN_ALL_TYPES.forEach(t=>sel.add(t));
|
||||
grid.querySelectorAll('.pan-type-chip').forEach(c=>c.classList.toggle('on',sel.has(c.dataset.t)));
|
||||
paintAll();
|
||||
};
|
||||
paintAll();
|
||||
mask.classList.add('show');
|
||||
requestAnimationFrame(()=>{input.focus()});
|
||||
let done=false;
|
||||
const finish=(ok)=>{
|
||||
if(done)return;done=true;
|
||||
mask.classList.remove('show');
|
||||
okBtn.onclick=null;cancelBtn.onclick=null;mask.onclick=null;input.onkeydown=null;allBtn.onclick=null;
|
||||
if(ok){
|
||||
const url=input.value.trim();
|
||||
if(url) PAN_CONFIG.apiBase=url;
|
||||
let arr=PAN_ALL_TYPES.filter(t=>sel.has(t));
|
||||
if(!arr.length) arr=PAN_ALL_TYPES.slice(); // 至少保留全部,避免搜不到任何资源
|
||||
PAN_CONFIG.diskTypes=arr;
|
||||
try{localStorage.setItem('wo_pan_api',PAN_CONFIG.apiBase)}catch(e){}
|
||||
try{localStorage.setItem('wo_pan_types',JSON.stringify(arr))}catch(e){}
|
||||
}
|
||||
resolve(ok);
|
||||
};
|
||||
okBtn.onclick=()=>finish(true);
|
||||
cancelBtn.onclick=()=>finish(false);
|
||||
mask.onclick=(e)=>{ if(e.target===mask) finish(false); };
|
||||
input.onkeydown=(e)=>{ if(e.key==='Enter'){e.preventDefault();finish(true)} else if(e.key==='Escape'){e.preventDefault();finish(false)} };
|
||||
});
|
||||
}
|
||||
function panApi(path){return PAN_CONFIG.apiBase.replace(/\/+$/,'')+path}
|
||||
function panKeyword(t){return clean(String(t||'').replace(/[《》\[\]【】]/g,'').replace(/[((].*?[))]/g,''))}
|
||||
function panNormUrl(u){return String(u||'').trim().replace(/[?&]+$/,'')}
|
||||
|
||||
async function panReq(path,body){
|
||||
const url=panApi(path);
|
||||
const fm=await fmReady();
|
||||
if(fm&&fm.req){
|
||||
let r=await fm.req(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body),responseType:'json',timeout:12});
|
||||
if(!r.ok)throw new Error(r.error||('HTTP '+r.status));
|
||||
return typeof r.body==='string'?JSON.parse(r.body):r.body;
|
||||
}
|
||||
let resp=await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
|
||||
if(!resp.ok)throw new Error('HTTP '+resp.status);
|
||||
return await resp.json();
|
||||
}
|
||||
|
||||
async function panSearch(kw){
|
||||
const data=await panReq('/api/search',{kw,res:'merge',src:'all',cloud_types:PAN_CONFIG.diskTypes});
|
||||
const byType=(data&&data.data&&data.data.merged_by_type)||(data&&data.merged_by_type)||{};
|
||||
let out=[];
|
||||
Object.keys(byType).forEach(type=>{
|
||||
if(PAN_CONFIG.diskTypes.indexOf(type)===-1)return;
|
||||
(byType[type]||[]).forEach(item=>{
|
||||
const url=item.url||item.share_url||'';
|
||||
if(!url)return;
|
||||
out.push({
|
||||
type,
|
||||
title:clean(item.note||item.title||item.name||kw),
|
||||
url,
|
||||
password:item.password||item.pwd||'',
|
||||
datetime:item.datetime||item.time||''
|
||||
});
|
||||
});
|
||||
});
|
||||
let seen=new Set(),dedup=[];
|
||||
out.forEach(x=>{const k=x.type+'|'+panNormUrl(x.url);if(!seen.has(k)){seen.add(k);dedup.push(x)}});
|
||||
dedup.forEach((x,i)=>{x._q=panQualityScore(x.title);x._i=i});
|
||||
dedup.sort((a,b)=>b._q-a._q||a._i-b._i); // 画质关键词命中多的靠前;命中数相同保持原有相对顺序
|
||||
return dedup;
|
||||
}
|
||||
|
||||
// 画质/版本关键词打分:标题里命中的关键词越多,分数越高,排序越靠前
|
||||
const PAN_QUALITY_KEYWORDS=[
|
||||
'4k','8k','2160p','1080p',
|
||||
'hdr','hdr10','hdr10+','dolby vision','dovi',
|
||||
'杜比视界','杜比全景声','atmos','dolby atmos','杜比',
|
||||
'remux','bluray','blu-ray','blueray','蓝光原盘','原盘',
|
||||
'uhd','webdl','web-dl','bdrip','x265','h265','hevc','x264','h264',
|
||||
'60帧','60fps','高码率','无水印','国语','粤语','国粤','中字','双语'
|
||||
];
|
||||
function panQualityScore(title){
|
||||
const t=String(title||'').toLowerCase();
|
||||
let score=0;
|
||||
PAN_QUALITY_KEYWORDS.forEach(kw=>{ if(t.indexOf(kw.toLowerCase())!==-1) score++; });
|
||||
return score;
|
||||
}
|
||||
|
||||
// 盘搜类型标签排序优先级;未列出的按全量清单顺序排在后面
|
||||
const PAN_TYPE_ORDER=['quark','baidu','123','uc','115','aliyun','tianyi','xunlei'];
|
||||
function panTypeRank(t){
|
||||
const i=PAN_TYPE_ORDER.indexOf(t);
|
||||
if(i>=0)return i;
|
||||
const j=PAN_ALL_TYPES.indexOf(t);
|
||||
return 100+(j<0?99:j);
|
||||
}
|
||||
function panTypesPresent(list){
|
||||
let m=new Map();
|
||||
list.forEach(x=>m.set(x.type,(m.get(x.type)||0)+1));
|
||||
return[...m.entries()].sort((a,b)=>panTypeRank(a[0])-panTypeRank(b[0]));
|
||||
}
|
||||
|
||||
function renderPanSearchBox(box,allList,activeType){
|
||||
if(!box)return;
|
||||
if(!allList.length){
|
||||
box.innerHTML='<div class="pan-search-status">未搜索到网盘资源</div>';
|
||||
return;
|
||||
}
|
||||
const types=panTypesPresent(allList);
|
||||
if(!activeType||!types.some(t=>t[0]===activeType))activeType=types[0][0];
|
||||
const tabsHtml=types.map(([t,n])=>`<button class="pan-search-tab${t===activeType?' active':''}" data-type="${esc(t)}" data-label="${esc(panTypeLabel(t))}">${esc(panTypeLabel(t))} ${n}</button>`).join('');
|
||||
const list=allList.filter(x=>x.type===activeType);
|
||||
box.innerHTML=`
|
||||
<div class="pan-search-tabs">${tabsHtml}</div>
|
||||
<div class="pan-list">
|
||||
${list.map(p=>`
|
||||
<div class="pan-item">
|
||||
<span class="pan-health-dot" data-state="${PAN_CHECK_TYPES.has(p.type)?'idle':'unsupported'}"></span>
|
||||
<span class="pan-type">${esc(panTypeLabel(p.type))}</span>
|
||||
<div class="pan-info">
|
||||
<div class="pan-name">${esc(p.title)}</div>
|
||||
<div class="pan-url">${esc(p.url)}${p.password?' · 提取码 '+esc(p.password):''}</div>
|
||||
</div>
|
||||
<div class="pan-actions">
|
||||
<button class="pan-open-btn" data-type="${esc(p.type)}" data-url="${esc(p.url)}" data-pwd="${esc(p.password)}" data-title="${esc(p.title)}">打开</button>
|
||||
</div>
|
||||
</div>`).join('')}
|
||||
</div>`;
|
||||
box.querySelectorAll('.pan-search-tab').forEach(btn=>{
|
||||
btn.onclick=(e)=>{e.stopPropagation();renderPanSearchBox(box,allList,btn.dataset.type)};
|
||||
});
|
||||
box.querySelectorAll('.copy-btn').forEach(btn=>btn.onclick=(e)=>{e.stopPropagation();copyText(btn.dataset.link)});
|
||||
box.querySelectorAll('.pan-open-btn').forEach(btn=>{
|
||||
btn.onclick=(e)=>{e.stopPropagation();playPanSearch({type:btn.dataset.type,url:btn.dataset.url,password:btn.dataset.pwd,title:btn.dataset.title})};
|
||||
});
|
||||
panHealthCheck(box,list);
|
||||
}
|
||||
|
||||
// 按路灯(健康检测)结果过滤:仅隐藏“确认非有效”的(失效/需权限/测不准);
|
||||
// 绿灯有效、尚未检测(idle)、无法检测(unsupported,如磁力/电驴) 一律保留
|
||||
function applyPanHealthFilter(box){
|
||||
if(!box)return;
|
||||
let shown=0;
|
||||
box.querySelectorAll('.pan-item').forEach(it=>{
|
||||
const dot=it.querySelector('.pan-health-dot');
|
||||
const st=dot?dot.dataset.state:'';
|
||||
const hide=(st==='bad'||st==='locked'||st==='uncertain');
|
||||
it.style.display=hide?'none':'';
|
||||
if(!hide)shown++;
|
||||
});
|
||||
// 当前激活标签的数字改为“有效条数”(仅已检测的当前类型可知,其余标签仍显示总数)
|
||||
const at=box.querySelector('.pan-search-tab.active');
|
||||
if(at&&at.dataset.label)at.textContent=at.dataset.label+' '+shown;
|
||||
let note=box.querySelector('.pan-empty-note');
|
||||
if(shown===0){
|
||||
const listEl=box.querySelector('.pan-list');
|
||||
if(!note&&listEl){note=document.createElement('div');note.className='pan-search-status pan-empty-note';listEl.appendChild(note)}
|
||||
if(note)note.textContent='该类暂无有效资源';
|
||||
}else if(note){note.remove()}
|
||||
}
|
||||
// 检测支持类型的全部资源(不再限制每批最多30条),分批每批10条,且需先确认 App「网盘检测」开关开启
|
||||
async function panHealthCheck(box,list){
|
||||
const fm=await fmReady();
|
||||
if(!fm||!fm.pan||!fm.pan.check)return; // 无检测能力(如纯浏览器):不过滤,保持全显示
|
||||
let cfg;
|
||||
try{ cfg=await fm.config(); }catch(e){ return; }
|
||||
if(!cfg||!cfg.driveCheck)return; // 未开启「网盘检测」:不过滤,保持全显示
|
||||
const checkable=list.map((p,i)=>({p,i})).filter(o=>PAN_CHECK_TYPES.has(o.p.type));
|
||||
if(!checkable.length){applyPanHealthFilter(box);return}
|
||||
const dots=box.querySelectorAll('.pan-health-dot');
|
||||
for(let s=0;s<checkable.length;s+=10){
|
||||
const batch=checkable.slice(s,s+10);
|
||||
try{
|
||||
const res=await fm.pan.check(batch.map(o=>({type:o.p.type,url:o.p.url,password:o.p.password||''})));
|
||||
(res.results||[]).forEach((r,k)=>{ const dot=dots[batch[k].i]; if(dot)dot.dataset.state=r.state||'uncertain'; });
|
||||
}catch(e){}
|
||||
applyPanHealthFilter(box); // 每批检完即刷新过滤
|
||||
}
|
||||
}
|
||||
|
||||
async function playPanSearch(p){
|
||||
if(_currentDetailItem) addHistory(_currentDetailItem);
|
||||
const fm=await fmReady();
|
||||
if(fm&&fm.pan&&fm.pan.play){
|
||||
try{ await fm.pan.play({type:p.type,url:p.url,password:p.password||'',title:p.title||''}); return; }catch(e){}
|
||||
}
|
||||
if(fm&&fm.play){ await fm.play(p.url.startsWith('magnet:')?p.url:'push://'+p.url,p.title||''); return; }
|
||||
location.href=p.url;
|
||||
}
|
||||
|
||||
let _panToken=0;
|
||||
function startPanSearch(box,kw){
|
||||
const tk=++_panToken;
|
||||
box.innerHTML='<div class="pan-search-status">正在聚合搜索网盘…</div>';
|
||||
panSearch(kw).then(list=>{
|
||||
if(tk!==_panToken)return;
|
||||
renderPanSearchBox(box,list,'');
|
||||
}).catch(e=>{
|
||||
if(tk!==_panToken)return;
|
||||
box.innerHTML='<div class="pan-search-status">盘搜失败:'+esc(e.message)+'</div>';
|
||||
});
|
||||
}
|
||||
|
||||
async function playPan(p){
|
||||
if(_currentDetailItem) addHistory(_currentDetailItem);
|
||||
let u=p.url;
|
||||
if(window.fm&&fm.play){
|
||||
await fm.play(u.startsWith('magnet:')?u:'push://'+u,p.name);
|
||||
return
|
||||
}
|
||||
location.href=u
|
||||
}
|
||||
function closeDetail(){
|
||||
// 走 history 栈,popstate 监听器负责关闭 UI
|
||||
history.back();
|
||||
}
|
||||
// 主题切换
|
||||
|
||||
searchForm.onsubmit=e=>{e.preventDefault();hideSugg();let q=kw.value.trim();q?search(q):(_inSearchMode()?history.back():loadCategory())};
|
||||
|
||||
// ========== 搜索联想(TMDB:小海报 + 年份)==========
|
||||
const searchSugg=$('#searchSugg');
|
||||
const TMDB_IMG_SUGG='https://image.tmdb.org/t/p/w154';
|
||||
let _suggSeq=0,_suggTimer=null;
|
||||
const _suggCache=new Map();
|
||||
|
||||
function hideSugg(){searchSugg.classList.remove('show');searchSugg.innerHTML='';if(window._searchHistUpdate)_searchHistUpdate()}
|
||||
function mediaTypeLabel(t){return t==='tv'?'剧集':t==='movie'?'电影':''}
|
||||
|
||||
function tmdbSuggest(q){
|
||||
const key=q.toLowerCase();
|
||||
if(_suggCache.has(key))return _suggCache.get(key);
|
||||
const url=`${TMDB_API}/search/multi?api_key=${TMDB_KEY}&language=zh-CN&include_adult=false&page=1&query=${encodeURIComponent(q)}`;
|
||||
const p=tmdbJSON(url).then(d=>(d.results||[])
|
||||
.filter(x=>x.media_type==='movie'||x.media_type==='tv')
|
||||
.map(x=>({
|
||||
title:x.title||x.name||x.original_title||x.original_name||'',
|
||||
year:(x.release_date||x.first_air_date||'').slice(0,4),
|
||||
poster:x.poster_path?TMDB_IMG_SUGG+x.poster_path:'',
|
||||
type:x.media_type,
|
||||
pop:x.popularity||0
|
||||
}))
|
||||
.filter(x=>x.title)
|
||||
.sort((a,b)=>b.pop-a.pop)
|
||||
.slice(0,8)
|
||||
).catch(()=>[]);
|
||||
_suggCache.set(key,p);
|
||||
return p;
|
||||
}
|
||||
|
||||
function renderSugg(list){
|
||||
if(!list.length){hideSugg();return}
|
||||
searchSugg.innerHTML=list.map(s=>`
|
||||
<div class="sugg-item">
|
||||
<div class="sugg-poster">${s.poster?`<img loading="lazy" referrerpolicy="no-referrer" src="${esc(s.poster)}" alt="" onerror="this.onerror=null;this.outerHTML='<span class=\\'ph\\'>🎬</span>'">`:'<span class="ph">🎬</span>'}</div>
|
||||
<div class="sugg-info">
|
||||
<div class="sugg-title">${esc(s.title)}</div>
|
||||
<div class="sugg-meta">
|
||||
${s.type?`<span class="sugg-type">${mediaTypeLabel(s.type)}</span>`:''}
|
||||
${s.year?`<span class="sugg-year">${esc(s.year)}</span>`:''}
|
||||
</div>
|
||||
</div>
|
||||
</div>`).join('');
|
||||
[...searchSugg.children].forEach((el,i)=>{
|
||||
el.addEventListener('click',()=>{
|
||||
const s=list[i];
|
||||
kw.value=s.title;
|
||||
hideSugg();
|
||||
kw.blur();
|
||||
search(s.title, s.year?{year:s.year}:undefined); // 把联想条目的年份带进搜索,过滤掉别的年份
|
||||
});
|
||||
});
|
||||
searchSugg.classList.add('show');
|
||||
if(window._searchHistUpdate)document.getElementById('searchHistory').classList.remove('show');
|
||||
}
|
||||
|
||||
async function onSuggInput(){
|
||||
const q=kw.value.trim();
|
||||
const minLen=/[\u4e00-\u9fa5]/.test(q)?1:2;
|
||||
if(q.length<minLen){hideSugg();return}
|
||||
const seq=++_suggSeq;
|
||||
try{
|
||||
const list=await tmdbSuggest(q);
|
||||
if(seq!==_suggSeq)return; // 丢弃过期请求
|
||||
if(kw.value.trim()!==q)return; // 输入已变化
|
||||
const sorted=list.slice().sort((a,b)=>b.pop-a.pop);
|
||||
renderSugg(sorted.slice(0,8));
|
||||
}catch(e){hideSugg()}
|
||||
}
|
||||
|
||||
kw.addEventListener('input',()=>{clearTimeout(_suggTimer);_suggTimer=setTimeout(onSuggInput,260);_searchHistUpdate()});
|
||||
kw.addEventListener('focus',()=>{
|
||||
document.documentElement.classList.add('search-focused');
|
||||
if (location.hash !== '#search' && !_inSearchMode())
|
||||
history.pushState({ wo: 'searchFocus' }, '', '#search');
|
||||
if(kw.value.trim())onSuggInput();
|
||||
_searchHistUpdate();
|
||||
});
|
||||
|
||||
// ========== 搜索记录 ==========
|
||||
(function(){
|
||||
const shEl=document.getElementById('searchHistory');
|
||||
if(!shEl)return;
|
||||
const CLOSE_ICON=`<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><path d="M18 6 6 18"/><path d="M6 6l12 12"/></svg>`;
|
||||
const CLOCK_ICON=`<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/></svg>`;
|
||||
|
||||
function render(){
|
||||
if(!searchKwHistory.length){
|
||||
shEl.innerHTML='<div class="sh-empty">暂无搜索记录</div>';
|
||||
return;
|
||||
}
|
||||
shEl.innerHTML=`<div class="sh-head"><span>搜索记录</span><span class="sh-clear" id="shClearAll">清空</span></div>`+
|
||||
searchKwHistory.map((q,i)=>`
|
||||
<div class="sh-item" data-i="${i}">
|
||||
<span class="sh-kw">${CLOCK_ICON}${esc(q)}</span>
|
||||
<span class="sh-del" data-del="${i}">${CLOSE_ICON}</span>
|
||||
</div>`).join('');
|
||||
shEl.querySelectorAll('.sh-item').forEach(el=>{
|
||||
el.addEventListener('click',e=>{
|
||||
if(e.target.closest('.sh-del'))return;
|
||||
const q=searchKwHistory[+el.dataset.i];
|
||||
if(!q)return;
|
||||
kw.value=q;hideSugg();shEl.classList.remove('show');kw.blur();search(q);
|
||||
});
|
||||
});
|
||||
shEl.querySelectorAll('.sh-del').forEach(el=>{
|
||||
el.addEventListener('click',e=>{
|
||||
e.stopPropagation();
|
||||
const i=+el.dataset.del;
|
||||
searchKwHistory.splice(i,1);
|
||||
saveSearchKwHistory();
|
||||
render();
|
||||
});
|
||||
});
|
||||
const clearBtn=shEl.querySelector('#shClearAll');
|
||||
if(clearBtn)clearBtn.addEventListener('click',e=>{
|
||||
e.stopPropagation();
|
||||
searchKwHistory.length=0;
|
||||
saveSearchKwHistory();
|
||||
render();
|
||||
});
|
||||
}
|
||||
|
||||
function show(){ render(); shEl.classList.add('show'); }
|
||||
function hide(){ shEl.classList.remove('show'); }
|
||||
|
||||
window._searchHistUpdate=function(){
|
||||
const q=kw.value.trim();
|
||||
const focused=document.documentElement.classList.contains('search-focused');
|
||||
if(focused&&!q&&!searchSugg.classList.contains('show')){show()}
|
||||
else{hide()}
|
||||
};
|
||||
})();
|
||||
window._searchHistUpdate=window._searchHistUpdate||function(){};
|
||||
document.getElementById('searchFocusMask').addEventListener('pointerdown',()=>{
|
||||
hideSugg();
|
||||
kw.blur();
|
||||
if(window._searchHistUpdate)document.getElementById('searchHistory').classList.remove('show');
|
||||
if (document.documentElement.classList.contains('search-focused') && !_inSearchMode()) history.back();
|
||||
else document.documentElement.classList.remove('search-focused');
|
||||
});
|
||||
kw.addEventListener('keydown',e=>{if(e.key==='Escape'){
|
||||
hideSugg();kw.blur();
|
||||
if (document.documentElement.classList.contains('search-focused') && !_inSearchMode()) history.back();
|
||||
else document.documentElement.classList.remove('search-focused');
|
||||
}});
|
||||
// 只有点击搜索区域之外才收起联想(点击联想项由其自身的 click 处理)
|
||||
document.addEventListener('pointerdown',e=>{if(!searchForm.contains(e.target))hideSugg()},true);
|
||||
const AGG_ICON=`<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="M5 6l7 6-7 6"/><path d="M12 6l7 6-7 6"/></svg>`;
|
||||
const SINGLE_ICON=`<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="M12 6l7 6-7 6"/></svg>`;
|
||||
aggBtn.innerHTML=AGG_ICON;
|
||||
aggBtn.onclick=()=>{aggregate=!aggregate;aggBtn.innerHTML=aggregate?AGG_ICON:SINGLE_ICON};
|
||||
window.__woClose=closeDetail;
|
||||
|
||||
// ========== 回顶按钮(可拖动 · 贴边吸附 · 记忆位置)==========
|
||||
(function(){
|
||||
const btn=document.getElementById('backTop');
|
||||
if(!btn)return;
|
||||
const KEY='wo_backtop_pos';
|
||||
|
||||
// 还原上次拖动后的位置
|
||||
function applyPos(p){
|
||||
if(!p)return;
|
||||
btn.style.top=p.top+'px';btn.style.bottom='auto';
|
||||
if(p.edge==='left'){btn.style.left='0';btn.style.right='auto';btn.classList.add('left')}
|
||||
else{btn.style.right='0';btn.style.left='auto';btn.classList.remove('left')}
|
||||
}
|
||||
let _saved=null;try{_saved=JSON.parse(localStorage.getItem(KEY)||'null')}catch(e){}
|
||||
if(_saved)applyPos(_saved);
|
||||
|
||||
// 滚动约 4 排后出现(原 9 排过晚)
|
||||
function getThreshold(){const cardH=(window.innerWidth/3)*(4/3)+52;return cardH*4}
|
||||
let _ticking=false;
|
||||
function onScroll(){
|
||||
if(_ticking)return;_ticking=true;
|
||||
requestAnimationFrame(()=>{
|
||||
const y=window.scrollY||document.documentElement.scrollTop;
|
||||
btn.classList.toggle('show',y>getThreshold());
|
||||
_ticking=false;
|
||||
});
|
||||
}
|
||||
window.addEventListener('scroll',onScroll,{passive:true});
|
||||
|
||||
// 拖动逻辑:拖动结束按就近吸附到左/右边缘并记忆
|
||||
let dragging=false,moved=false,sx=0,sy=0,ox=0,oy=0,curX=0,curY=0,justDragged=false;
|
||||
const sz=()=>btn.offsetWidth||50;
|
||||
function down(e){
|
||||
const t=e.touches?e.touches[0]:e;
|
||||
const r=btn.getBoundingClientRect();
|
||||
dragging=true;moved=false;ox=r.left;oy=r.top;sx=t.clientX;sy=t.clientY;
|
||||
btn.classList.add('dragging');
|
||||
}
|
||||
function move(e){
|
||||
if(!dragging)return;
|
||||
const t=e.touches?e.touches[0]:e;
|
||||
const dx=t.clientX-sx,dy=t.clientY-sy;
|
||||
if(!moved&&(Math.abs(dx)>6||Math.abs(dy)>6))moved=true;
|
||||
if(!moved)return;
|
||||
if(e.cancelable)e.preventDefault();
|
||||
const S=sz();
|
||||
curX=Math.max(0,Math.min(window.innerWidth-S,ox+dx));
|
||||
curY=Math.max(60,Math.min(window.innerHeight-S-10,oy+dy));
|
||||
btn.style.left=curX+'px';btn.style.right='auto';
|
||||
btn.style.top=curY+'px';btn.style.bottom='auto';
|
||||
}
|
||||
function up(){
|
||||
if(!dragging)return;
|
||||
dragging=false;btn.classList.remove('dragging');
|
||||
if(!moved)return; // 视为点按,交给 click 处理(回顶)
|
||||
justDragged=true;setTimeout(()=>{justDragged=false},400);
|
||||
const S=sz();
|
||||
const left=(curX+S/2)<window.innerWidth/2;
|
||||
const top=Math.max(60,Math.min(window.innerHeight-S-10,curY));
|
||||
const p={edge:left?'left':'right',top};
|
||||
applyPos(p);
|
||||
try{localStorage.setItem(KEY,JSON.stringify(p))}catch(e){}
|
||||
}
|
||||
btn.addEventListener('touchstart',down,{passive:true});
|
||||
btn.addEventListener('touchmove',move,{passive:false});
|
||||
btn.addEventListener('touchend',up,{passive:true});
|
||||
btn.addEventListener('touchcancel',()=>{dragging=false;btn.classList.remove('dragging')},{passive:true});
|
||||
// 桌面端鼠标拖动
|
||||
btn.addEventListener('mousedown',e=>{
|
||||
down(e);
|
||||
const mm=ev=>move(ev),mu=ev=>{up(ev);document.removeEventListener('mousemove',mm);document.removeEventListener('mouseup',mu)};
|
||||
document.addEventListener('mousemove',mm);document.addEventListener('mouseup',mu);
|
||||
});
|
||||
|
||||
btn.addEventListener('click',e=>{
|
||||
if(justDragged){e.preventDefault();e.stopPropagation();justDragged=false;return}
|
||||
window.scrollTo({top:0,behavior:'smooth'});
|
||||
});
|
||||
// 切页/切分类时隐藏
|
||||
window._backTopReset=function(){ btn.classList.remove('show') };
|
||||
})();
|
||||
|
||||
// ========== 左右滑动切换分类(非站源)+ 长按卡片收藏 ==========
|
||||
(function(){
|
||||
if(!content)return;
|
||||
let x0=0,y0=0,t0=0,tracking=false,decided=false,horizontal=false;
|
||||
let lpTimer=null,lpCard=null;
|
||||
const TH=56; // 触发切换的最小水平位移
|
||||
const SLOP=12; // 判定方向前的容差
|
||||
const MAXT=600; // 超过此时长视为拖拽/犹豫,不触发
|
||||
const LP=600; // 长按触发收藏的时长(ms),调长以避免轻点被误判为收藏
|
||||
|
||||
// 仅在分类浏览态可滑:搜索结果页、详情页、搜索聚焦态一律不响应
|
||||
function swipable(){
|
||||
return _catActive
|
||||
&& !$('#sheet').classList.contains('active')
|
||||
&& !document.documentElement.classList.contains('search-focused');
|
||||
}
|
||||
// 长按收藏:分类页与搜索结果页都允许(详情页/搜索聚焦态除外)
|
||||
function cardActive(){
|
||||
return !$('#sheet').classList.contains('active')
|
||||
&& !document.documentElement.classList.contains('search-focused');
|
||||
}
|
||||
function clearLP(){ if(lpTimer){clearTimeout(lpTimer);lpTimer=null} lpCard=null; }
|
||||
function edgeBounce(dir){
|
||||
content.classList.remove('edge-l','edge-r');
|
||||
void content.offsetWidth; // 重启动画
|
||||
content.classList.add(dir>0?'edge-l':'edge-r');
|
||||
content.addEventListener('animationend',()=>content.classList.remove('edge-l','edge-r'),{once:true});
|
||||
}
|
||||
// dir = +1 下一类(手指向左滑),-1 上一类(手指向右滑)
|
||||
function go(dir){
|
||||
const cats=effCats(site());
|
||||
let i=cats.findIndex(c=>c[0]===activeCat);
|
||||
if(i<0)i=0;
|
||||
const n=i+dir;
|
||||
if(n<0||n>=cats.length){edgeBounce(dir);return}
|
||||
activateCategory(cats[n][0], dir>0?'l':'r');
|
||||
}
|
||||
|
||||
content.addEventListener('touchstart',e=>{
|
||||
clearLP();
|
||||
_swiped=false; // 新触摸开始:清掉上一次的点击拦截标记,保证轻点永不被吞
|
||||
if(e.touches.length!==1){tracking=false;return}
|
||||
const t=e.touches[0];
|
||||
x0=t.clientX;y0=t.clientY;t0=Date.now();
|
||||
tracking=swipable();decided=false;horizontal=false;
|
||||
// 长按收藏计时
|
||||
if(cardActive()&&e.target.closest){
|
||||
const cd=e.target.closest('.card');
|
||||
if(cd){
|
||||
lpCard=cd;
|
||||
lpTimer=setTimeout(()=>{
|
||||
lpTimer=null;
|
||||
const card=lpCard;lpCard=null;tracking=false; // 已转为长按,取消滑动判定
|
||||
if(!card)return; // 防御:已被清理则不处理
|
||||
const item=(last||[]).find(x=>x.href===card.dataset.href);
|
||||
if(item){
|
||||
_swiped=true; // 一次性:仅拦截这次长按抬手产生的 click
|
||||
if(navigator.vibrate){try{navigator.vibrate(15)}catch(_){}}
|
||||
toggleFav(item);
|
||||
}
|
||||
},LP);
|
||||
}
|
||||
}
|
||||
},{passive:true});
|
||||
|
||||
content.addEventListener('touchmove',e=>{
|
||||
const t=e.touches[0];
|
||||
const dx=t.clientX-x0,dy=t.clientY-y0;
|
||||
if(Math.abs(dx)>8||Math.abs(dy)>8)clearLP(); // 一旦移动即取消长按
|
||||
if(!tracking)return;
|
||||
if(!decided){
|
||||
if(Math.abs(dx)<SLOP&&Math.abs(dy)<SLOP)return;
|
||||
decided=true;
|
||||
horizontal=Math.abs(dx)>Math.abs(dy)*1.3; // 横向意图明显才接管
|
||||
}
|
||||
},{passive:true});
|
||||
|
||||
content.addEventListener('touchend',e=>{
|
||||
clearLP();
|
||||
if(!tracking)return;
|
||||
tracking=false;
|
||||
if(!horizontal)return;
|
||||
const t=e.changedTouches[0];
|
||||
const dx=t.clientX-x0,dy=t.clientY-y0,dt=Date.now()-t0;
|
||||
if(dt>MAXT)return;
|
||||
if(Math.abs(dx)<TH||Math.abs(dx)<Math.abs(dy)*1.3)return;
|
||||
_swiped=true; // 一次性:拦截这次滑动落点产生的 click
|
||||
go(dx<0?1:-1); // 左滑→下一类,右滑→上一类
|
||||
},{passive:true});
|
||||
|
||||
content.addEventListener('touchcancel',()=>{clearLP();tracking=false},{passive:true});
|
||||
|
||||
// 捕获阶段拦截:滑动/长按结束时落点恰在卡片上,阻止误触发详情(一次性,消费后立即复位)
|
||||
content.addEventListener('click',e=>{
|
||||
if(_swiped){_swiped=false;e.stopPropagation();e.preventDefault()}
|
||||
},true);
|
||||
})();
|
||||
|
||||
renderTabs();
|
||||
(async()=>{
|
||||
const s=site();
|
||||
const cats=await ensureCats(s);
|
||||
activeCat=firstRealCat(cats);
|
||||
renderChips();
|
||||
loadCategory();
|
||||
})();
|
||||
// 建立初始哨兵,防止第一次 back 穿透退出
|
||||
_ensureHome();
|
||||
})();
|
||||
|
||||
/* ===== 长按搜索图标:循环切换主题(默认橙 + 7 种配色)=====
|
||||
- 长按搜索按钮 600ms 触发,依次循环到下一个主题,震动反馈 + 顶部小提示
|
||||
- 主题持久化到 localStorage,跨会话保留 */
|
||||
(function(){
|
||||
'use strict';
|
||||
const THEME_KEY='wo_theme';
|
||||
const btn=document.querySelector('.search .btn.primary');
|
||||
if(!btn)return;
|
||||
|
||||
// 主题列表:cls 为空字符串代表默认橙色主题(不附加 class)
|
||||
const THEMES=[
|
||||
{cls:'', name:'默认橙色'},
|
||||
{cls:'neon', name:'霓虹蓝'},
|
||||
{cls:'theme-amber', name:'琥珀金'},
|
||||
{cls:'theme-rose', name:'玫瑰金'},
|
||||
{cls:'theme-indigo',name:'午夜蓝紫'},
|
||||
{cls:'theme-teal', name:'雾蓝青'},
|
||||
{cls:'theme-sand', name:'香槛灰金'},
|
||||
{cls:'theme-jade', name:'墨绿'}
|
||||
];
|
||||
const THEME_CLASSES=THEMES.map(t=>t.cls).filter(Boolean);
|
||||
|
||||
function applyTheme(idx){
|
||||
THEME_CLASSES.forEach(c=>document.body.classList.remove(c));
|
||||
const t=THEMES[idx];
|
||||
if(t.cls)document.body.classList.add(t.cls);
|
||||
}
|
||||
function currentIndex(){
|
||||
const i=THEMES.findIndex(t=>t.cls && document.body.classList.contains(t.cls));
|
||||
return i<0 ? 0 : i;
|
||||
}
|
||||
// 启动时读取已保存主题
|
||||
let themeIdx=0;
|
||||
try{
|
||||
const saved=parseInt(localStorage.getItem(THEME_KEY),10);
|
||||
if(!isNaN(saved)&&saved>=0&&saved<THEMES.length){
|
||||
themeIdx=saved;
|
||||
applyTheme(themeIdx);
|
||||
}
|
||||
}catch(e){}
|
||||
|
||||
let toastEl=null;
|
||||
function toast(text){
|
||||
if(!toastEl){
|
||||
toastEl=document.createElement('div');
|
||||
toastEl.style.cssText='position:fixed;top:calc(var(--safe-top) + 14px);left:50%;'
|
||||
+'transform:translateX(-50%) translateY(-8px);z-index:90;'
|
||||
+'background:rgba(20,24,32,.92);backdrop-filter:blur(14px);-webkit-backdrop-filter:blur(14px);'
|
||||
+'border:1px solid rgba(255,255,255,.12);border-radius:999px;'
|
||||
+'padding:7px 16px;font-size:13px;font-weight:700;color:#fff;'
|
||||
+'opacity:0;transition:opacity .22s ease,transform .22s ease;pointer-events:none;white-space:nowrap';
|
||||
document.body.appendChild(toastEl);
|
||||
}
|
||||
toastEl.textContent=text;
|
||||
toastEl.style.opacity='1';
|
||||
toastEl.style.transform='translateX(-50%) translateY(0)';
|
||||
clearTimeout(toastEl._t);
|
||||
toastEl._t=setTimeout(()=>{
|
||||
toastEl.style.opacity='0';
|
||||
toastEl.style.transform='translateX(-50%) translateY(-8px)';
|
||||
},1400);
|
||||
}
|
||||
|
||||
const LP=600; // 长按触发时长,与卡片收藏长按保持一致手感
|
||||
const MOVE_TOL=10; // 超出此移动距离视为滑动/取消,不触发
|
||||
let timer=null,sx=0,sy=0,fired=false;
|
||||
|
||||
function clear(){ if(timer){clearTimeout(timer);timer=null} }
|
||||
|
||||
function start(x,y){
|
||||
fired=false;
|
||||
sx=x;sy=y;
|
||||
clear();
|
||||
timer=setTimeout(()=>{
|
||||
timer=null;fired=true;
|
||||
themeIdx=(currentIndex()+1)%THEMES.length;
|
||||
applyTheme(themeIdx);
|
||||
try{localStorage.setItem(THEME_KEY,String(themeIdx))}catch(e){}
|
||||
if(navigator.vibrate){try{navigator.vibrate([12,40,12])}catch(_){}}
|
||||
toast('已切换:'+THEMES[themeIdx].name+'主题');
|
||||
},LP);
|
||||
}
|
||||
function move(x,y){
|
||||
if(!timer)return;
|
||||
if(Math.abs(x-sx)>MOVE_TOL||Math.abs(y-sy)>MOVE_TOL)clear();
|
||||
}
|
||||
function end(e){
|
||||
clear();
|
||||
if(fired){ e.preventDefault(); e.stopPropagation(); } // 已触发主题切换,拦截这次抬手的 click/submit
|
||||
}
|
||||
|
||||
btn.addEventListener('touchstart',e=>{
|
||||
const t=e.touches[0];start(t.clientX,t.clientY);
|
||||
},{passive:true});
|
||||
btn.addEventListener('touchmove',e=>{
|
||||
const t=e.touches[0];move(t.clientX,t.clientY);
|
||||
},{passive:true});
|
||||
btn.addEventListener('touchend',end,{passive:false});
|
||||
btn.addEventListener('touchcancel',clear,{passive:true});
|
||||
|
||||
// 桌面端鼠标长按(便于在电脑上测试)
|
||||
btn.addEventListener('mousedown',e=>{start(e.clientX,e.clientY)});
|
||||
btn.addEventListener('mousemove',e=>{move(e.clientX,e.clientY)});
|
||||
btn.addEventListener('mouseup',end);
|
||||
btn.addEventListener('mouseleave',clear);
|
||||
|
||||
|
||||
// 长按触发后,阻止这次抬手连带的表单提交(搜索按钮在 <form> 内)
|
||||
btn.addEventListener('click',e=>{
|
||||
if(fired){fired=false;e.preventDefault();e.stopPropagation()}
|
||||
},true);
|
||||
})();
|
||||
|
||||
</script>
|
||||
<script>
|
||||
/* ===== Android TV / 电视遥控器(D-pad)适配 =====
|
||||
- 方向键在卡片/按钮/输入框间移动焦点(空间导航,按屏幕位置就近选择)
|
||||
- OK/确定键触发当前焦点元素
|
||||
- 返回键交给 history(自动关弹窗/详情/退出搜索)
|
||||
- 焦点高亮放大,电视远看也清晰
|
||||
- 仅在电视环境启用(带触摸的平板/手机不受影响);可用 ?tv=1 强制、?tv=0 关闭 */
|
||||
(function(){
|
||||
'use strict';
|
||||
var ua=navigator.userAgent;
|
||||
var isTV=/\b(TV|BRAVIA|AFT[A-Z]|MiBOX|MiTV|SHIELD|GoogleTV|AndroidTV|SmartTV|NetCast|Web0S|webOS|Tizen|HbbTV|DTV)\b/i.test(ua)
|
||||
|| (/Android/i.test(ua) && !('ontouchstart' in window) && !/Mobile/i.test(ua));
|
||||
if(/[?&]tv=0/.test(location.search)) return;
|
||||
if(!isTV && !/[?&]tv=1/.test(location.search)) return;
|
||||
document.documentElement.classList.add('tv-mode');
|
||||
|
||||
// 焦点高亮样式
|
||||
var st=document.createElement('style');
|
||||
st.textContent=[
|
||||
'.tv-mode :focus{outline:none}',
|
||||
'.tv-mode .card,.tv-mode .src-tab,.tv-mode .chip,.tv-mode .det-res-tab,.tv-mode .pan-item,.tv-mode .pan-open-btn,.tv-mode .det-play-btn,.tv-mode #kw,.tv-mode .pan-cfg-btn,.tv-mode .pan-search-tab,.tv-mode .custom-modal-btn{transition:transform .12s ease,box-shadow .12s ease}',
|
||||
'.tv-mode .card:focus,.tv-mode .src-tab:focus,.tv-mode .chip:focus,.tv-mode .det-res-tab:focus,.tv-mode .pan-item:focus,.tv-mode .pan-open-btn:focus,.tv-mode .det-play-btn:focus,.tv-mode #kw:focus,.tv-mode .pan-cfg-btn:focus,.tv-mode .pan-search-tab:focus,.tv-mode .custom-modal-btn:focus{outline:3px solid var(--accent);outline-offset:3px;box-shadow:0 0 0 6px rgba(var(--accent-rgb),.35),0 10px 32px rgba(0,0,0,.55);transform:scale(1.05);z-index:6;position:relative;border-radius:14px}',
|
||||
'.tv-mode .det-play-btn:focus{transform:scale(1.12)}',
|
||||
'.tv-mode .card:focus{transform:scale(1.06)}'
|
||||
].join('\n');
|
||||
document.head.appendChild(st);
|
||||
|
||||
var SEL='#kw,.src-tab,.chip,.card,.det-play-btn,.det-res-tab,.pan-item,.pan-open-btn,.pan-cfg-btn,.pan-search-tab,.custom-modal-btn';
|
||||
function vis(el){
|
||||
var r=el.getBoundingClientRect();
|
||||
if(r.width<=0||r.height<=0) return false;
|
||||
if(r.bottom<-2||r.top>innerHeight+2||r.right<-2||r.left>innerWidth+2) return false;
|
||||
var s=getComputedStyle(el);
|
||||
if(s.visibility==='hidden'||s.display==='none'||parseFloat(s.opacity||'1')===0) return false;
|
||||
return true;
|
||||
}
|
||||
function scope(){
|
||||
var modal=document.querySelector('.custom-modal-mask');
|
||||
if(modal&&getComputedStyle(modal).display!=='none'&&vis(modal)) return modal;
|
||||
var sheet=document.getElementById('sheet');
|
||||
if(sheet&&sheet.classList.contains('active')) return sheet;
|
||||
return document;
|
||||
}
|
||||
function focusables(){
|
||||
var root=scope();
|
||||
var els=Array.prototype.slice.call(root.querySelectorAll(SEL)).filter(vis);
|
||||
if(root===document){
|
||||
var sheet=document.getElementById('sheet');
|
||||
if(sheet) els=els.filter(function(el){return !sheet.contains(el);});
|
||||
}
|
||||
els.forEach(function(el){if(!el.hasAttribute('tabindex'))el.setAttribute('tabindex','0');});
|
||||
return els;
|
||||
}
|
||||
function ctr(el){var r=el.getBoundingClientRect();return{x:r.left+r.width/2,y:r.top+r.height/2};}
|
||||
function moveFocus(dir){
|
||||
var els=focusables(); if(!els.length) return;
|
||||
var cur=document.activeElement;
|
||||
if(!cur||els.indexOf(cur)<0){els[0].focus();sf(els[0]);return;}
|
||||
var c=ctr(cur),best=null,bs=Infinity;
|
||||
for(var i=0;i<els.length;i++){
|
||||
var el=els[i]; if(el===cur) continue;
|
||||
var t=ctr(el),dx=t.x-c.x,dy=t.y-c.y,valid,along,ortho;
|
||||
if(dir==='ArrowLeft'){valid=dx<-2;along=-dx;ortho=Math.abs(dy);}
|
||||
else if(dir==='ArrowRight'){valid=dx>2;along=dx;ortho=Math.abs(dy);}
|
||||
else if(dir==='ArrowUp'){valid=dy<-2;along=-dy;ortho=Math.abs(dx);}
|
||||
else{valid=dy>2;along=dy;ortho=Math.abs(dx);}
|
||||
if(!valid) continue;
|
||||
var score=along+ortho*1.7;
|
||||
if(score<bs){bs=score;best=el;}
|
||||
}
|
||||
if(best){best.focus();sf(best);}
|
||||
}
|
||||
function sf(el){try{el.scrollIntoView({block:'nearest',inline:'nearest',behavior:'smooth'});}catch(e){try{el.scrollIntoView();}catch(_){}}}
|
||||
|
||||
document.addEventListener('keydown',function(e){
|
||||
var k=e.key,ae=document.activeElement;
|
||||
if(k==='ArrowLeft'||k==='ArrowRight'||k==='ArrowUp'||k==='ArrowDown'){
|
||||
if(ae&&ae.id==='kw'&&(k==='ArrowLeft'||k==='ArrowRight')) return;
|
||||
moveFocus(k); e.preventDefault();
|
||||
}else if(k==='Enter'){
|
||||
if(ae&&ae.id==='kw') return; // 搜索框回车走原提交逻辑
|
||||
if(ae&&ae!==document.body){ae.click();e.preventDefault();}
|
||||
}else if(k==='Backspace'||k==='Escape'||k==='BrowserBack'||k==='GoBack'||e.keyCode===4||e.keyCode===10009){
|
||||
if(ae&&ae.id==='kw'&&k==='Backspace') return; // 搜索框退格删字
|
||||
if(history.length>1){history.back();e.preventDefault();}
|
||||
}
|
||||
});
|
||||
|
||||
// 自动聚焦:详情打开→播放按钮;列表/弹窗刷新→第一个可聚焦项
|
||||
var t=null;
|
||||
var mo=new MutationObserver(function(){
|
||||
clearTimeout(t);
|
||||
t=setTimeout(function(){
|
||||
var sheet=document.getElementById('sheet');
|
||||
if(sheet&&sheet.classList.contains('active')){
|
||||
if(!sheet.contains(document.activeElement)){
|
||||
var pb=sheet.querySelector('.det-play-btn')||sheet.querySelector('.det-res-tab');
|
||||
if(pb)pb.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
// 主页:若焦点丢失,落到第一个卡片/控件
|
||||
if(!document.activeElement||document.activeElement===document.body){
|
||||
var els=focusables(); if(els.length) els[0].focus();
|
||||
}
|
||||
},140);
|
||||
});
|
||||
mo.observe(document.body,{attributes:true,subtree:true,attributeFilter:['class']});
|
||||
})();
|
||||
</script>
|
||||
<script type="module" src="https://static.cloudflareinsights.com/beacon.min.js/v4513226cdae34746b4dedf0b4dfa099e1781791509496" integrity="sha512-ZE9pZaUXND66v380QUtch/5sE9tPFh2zg45pR2PB0CVkCtOREv2AJKkSidISWkysEuQ0EH8faUU5du78bx87UQ==" data-cf-beacon='{"version":"2024.11.0","token":"34fed34fc7e4404e80e2cb1bca1b6856","r":1}' crossorigin="anonymous"></script>
|
||||
</body>
|
||||
</html>
|
||||
+7677
-498
@@ -6,6 +6,21 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
|
||||
<title>盘链</title>
|
||||
|
||||
<!-- 提前对默认几个数据源域名做 DNS/TLS 预连接,减少首批请求的握手延迟;
|
||||
若用户在设置里改了源,boot() 里还会针对实际配置的域名再补一次。 -->
|
||||
<link rel="preconnect" href="https://pinglian.lol" crossorigin>
|
||||
<link rel="dns-prefetch" href="https://pinglian.lol">
|
||||
<link rel="preconnect" href="https://api.tmdb.org" crossorigin>
|
||||
<link rel="dns-prefetch" href="https://api.tmdb.org">
|
||||
<link rel="preconnect" href="https://images.tmdb.org" crossorigin>
|
||||
<link rel="dns-prefetch" href="https://images.tmdb.org">
|
||||
<link rel="preconnect" href="https://so.252035.xyz" crossorigin>
|
||||
<link rel="dns-prefetch" href="https://so.252035.xyz">
|
||||
<link rel="preconnect" href="https://4kzn.cc" crossorigin>
|
||||
<link rel="dns-prefetch" href="https://4kzn.cc">
|
||||
<link rel="preconnect" href="https://www.qwmp4.com" crossorigin>
|
||||
<link rel="dns-prefetch" href="https://www.qwmp4.com">
|
||||
|
||||
<!-- ============================================================
|
||||
兼容引导层:文档第一个内联脚本,只用 ES5(var + function)。
|
||||
旧内核(Chromium 50-70)先跑它。见开发文档 15.5。
|
||||
@@ -38,13 +53,15 @@
|
||||
if (P.parentNode) P.parentNode.removeChild(P);
|
||||
if (!(ar > 70 && ar < 110)) root.className += " no-aspect-ratio";
|
||||
if (W.fongmiBridge || W.fm || W.fongmi) root.className += " fm-native";
|
||||
try { var _t0 = localStorage.getItem("pl_theme"); if (_t0 && _t0 !== "default") root.setAttribute("data-theme", _t0); } catch (e0) {}
|
||||
try { var _t0 = localStorage.getItem("pl_theme"); if (_t0 && _t0 !== "default") { root.setAttribute("data-theme", _t0); } } catch (e0) {}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* ===== 令牌 —— 默认「极夜蓝」主题 ===== */
|
||||
:root {
|
||||
--gut: 16px;
|
||||
--card-gut: 8px;
|
||||
--panel-rgb: 22, 32, 66;
|
||||
--panel: rgba(var(--panel-rgb), .62);
|
||||
--panel-soft: rgba(var(--panel-rgb), .46);
|
||||
@@ -60,7 +77,8 @@
|
||||
--grad: linear-gradient(120deg, #2fd0e8 0%, #39c0ff 28%, #2f7bff 52%, #5468ff 76%, #2fd0e8 100%);
|
||||
--accent-soft: rgba(46, 166, 255, .18);
|
||||
--accent-line: rgba(46, 166, 255, .5);
|
||||
--sheet-bg: #070b1a;
|
||||
--sheet-bg: #000000;
|
||||
--dyn-bg: var(--sheet-bg); --dyn-bg-rgb: 0, 0, 0;
|
||||
--bg1: #0c1533; --bg2: #0a1024; --bg3: #070b1a;
|
||||
--badge-bg: rgba(46, 166, 255, .92);
|
||||
--badge-border: rgba(160, 210, 255, .5);
|
||||
@@ -86,6 +104,7 @@
|
||||
--accent-soft: rgba(255, 122, 69, .18);
|
||||
--accent-line: rgba(255, 122, 69, .5);
|
||||
--sheet-bg: #0f0605;
|
||||
--dyn-bg: var(--sheet-bg); --dyn-bg-rgb: 15, 6, 5;
|
||||
--bg1: #241210; --bg2: #180b09; --bg3: #0f0605;
|
||||
--badge-bg: rgba(255, 122, 69, .92);
|
||||
--badge-border: rgba(255, 190, 150, .5);
|
||||
@@ -108,12 +127,133 @@
|
||||
--accent-soft: rgba(139, 92, 246, .18);
|
||||
--accent-line: rgba(139, 92, 246, .5);
|
||||
--sheet-bg: #05030f;
|
||||
--dyn-bg: var(--sheet-bg); --dyn-bg-rgb: 5, 3, 15;
|
||||
--bg1: #140a33; --bg2: #0d0724; --bg3: #05030f;
|
||||
--badge-bg: rgba(139, 92, 246, .92);
|
||||
--badge-border: rgba(190, 175, 255, .5);
|
||||
--badge-shadow: rgba(139, 92, 246, .35);
|
||||
--btn-shadow: rgba(139, 92, 246, .34);
|
||||
}
|
||||
/* ===== 内置主题:苹果白(Apple TV+ 风格,白按钮黑字,长按 Logo 切换)=====
|
||||
页面背景保持黑色:大部分文字(标题/简介/卡片名等)直接铺在黑背景或图片上,用浅色;
|
||||
只有自身就是白色磨砂面板的小组件(搜索框、图标按钮、通知条、按钮、网盘资源行、
|
||||
设置面板、搜索联想、底部导航栏、toast)单独把文字/边框改回深色,跟它们自己的白底匹配。 */
|
||||
:root[data-theme="appletv"] {
|
||||
--panel-rgb: 120, 120, 128;
|
||||
--panel: rgba(255, 255, 255, .74);
|
||||
--panel-soft: rgba(255, 255, 255, .55);
|
||||
--panel-strong: rgba(255, 255, 255, .96);
|
||||
--line: rgba(255, 255, 255, .16);
|
||||
--line-strong: rgba(255, 255, 255, .30);
|
||||
--ink: #f5f5f7;
|
||||
--ink-dim: rgba(245, 245, 247, .68);
|
||||
--ink-faint: rgba(245, 245, 247, .42);
|
||||
--accent: #0071e3;
|
||||
--accent-2: #0058b0;
|
||||
--accent-ink: #111114;
|
||||
--grad: linear-gradient(135deg, #ffffff 0%, #f0f0f2 100%);
|
||||
--accent-soft: rgba(0, 113, 227, .14);
|
||||
--accent-line: rgba(0, 113, 227, .4);
|
||||
--sheet-bg: #000000;
|
||||
--dyn-bg: var(--sheet-bg); --dyn-bg-rgb: 0, 0, 0;
|
||||
--bg1: #0c1533; --bg2: #0a1024; --bg3: #070b1a;
|
||||
--badge-bg: rgba(255, 255, 255, .92);
|
||||
--badge-border: rgba(0, 0, 0, .12);
|
||||
--badge-shadow: rgba(0, 0, 0, .22);
|
||||
--btn-shadow: rgba(0, 0, 0, .16);
|
||||
}
|
||||
:root[data-theme="appletv"] .search-box,
|
||||
:root[data-theme="appletv"] .cat-searchbar,
|
||||
:root[data-theme="appletv"] .hdr-ico,
|
||||
:root[data-theme="appletv"] .clear-search,
|
||||
:root[data-theme="appletv"] .notice,
|
||||
:root[data-theme="appletv"] .search-suggest,
|
||||
:root[data-theme="appletv"] .btn,
|
||||
:root[data-theme="appletv"] .panel-sheet,
|
||||
:root[data-theme="appletv"] .toast,
|
||||
:root[data-theme="appletv"] .chips {
|
||||
--ink: #1d1d1f;
|
||||
--ink-dim: rgba(29, 29, 31, .65);
|
||||
--ink-faint: rgba(29, 29, 31, .38);
|
||||
--line: rgba(0, 0, 0, .12);
|
||||
--line-strong: rgba(0, 0, 0, .22);
|
||||
}
|
||||
:root[data-theme="appletv"] .cat-back { color: #ffffff; }
|
||||
:root[data-theme="appletv"] .chips {
|
||||
background: rgba(255, 255, 255, .88); border-color: rgba(0, 0, 0, .08);
|
||||
-webkit-backdrop-filter: none; backdrop-filter: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
:root[data-theme="appletv"] .chip.active { background: rgba(0, 113, 227, .12); color: var(--accent); box-shadow: none; }
|
||||
/* 类型筛选标签(分类页顶部的"全部/剧情/喜剧..."那一排)选中态:苹果白主题整体走"白底黑字"的
|
||||
按钮质感(跟 .btn / .chips 一样),这里单独覆盖成纯白底 + 深色字,不再沿用蓝色半透明底,
|
||||
跟主题基调统一,也比蓝底在黑色页面背景上更醒目。 */
|
||||
:root[data-theme="appletv"] .genre-chip.active { background: #ffffff; border-color: #ffffff; color: #1d1d1f; font-weight: 700; }
|
||||
:root[data-theme="appletv"] .panel-close { background: rgba(0, 0, 0, .05); }
|
||||
:root[data-theme="appletv"] .set-group { background: rgba(0, 0, 0, .03); }
|
||||
:root[data-theme="appletv"] .field input { background: rgba(0, 0, 0, .045); }
|
||||
:root[data-theme="appletv"] .switch { background: rgba(0, 0, 0, .14); }
|
||||
:root[data-theme="appletv"] .pan-sub .q { color: #ffffff; font-weight: 700; }
|
||||
:root[data-theme="appletv"] .cast-cell.sel { border-color: rgba(255, 255, 255, .7); animation: castSelBreatheWhite 2.2s ease-in-out infinite; }
|
||||
@keyframes castSelBreatheWhite {
|
||||
0%, 100% { box-shadow: 0 0 0 1px rgba(255,255,255,.22), inset 0 0 10px 0 rgba(255,255,255,.16); }
|
||||
50% { box-shadow: 0 0 0 1px rgba(255,255,255,.4), inset 0 0 16px 2px rgba(255,255,255,.26); }
|
||||
}
|
||||
:root[data-theme="appletv"] .pan-item { background: transparent; border-color: rgba(255, 255, 255, .22); }
|
||||
:root[data-theme="appletv"] .pan-play { color: #ffffff; }
|
||||
:root[data-theme="appletv"] .pan-name { color: #ffffff; }
|
||||
:root[data-theme="appletv"] .pan-sub { color: rgba(255, 255, 255, .68); }
|
||||
:root[data-theme="appletv"] .ed-works-head { color: #f5f5f7; }
|
||||
:root[data-theme="appletv"] .mh-more { color: var(--ink-dim); }
|
||||
|
||||
/* ===== 苹果白主题专属:底部导航栏 + 播放按钮,内置七彩流光呼吸 =====
|
||||
只加效果,不改原有形状:.chips 还是胶囊长条,.ed-play 还是圆角长条。
|
||||
做法:用 ::before 放一层模糊的七彩渐变(在元素内部、被圆角裁掉多余部分),
|
||||
::after 盖一层半透明白让效果显得含蓄,真正的图标/文字再用 z-index 提到最上层。 */
|
||||
@keyframes appletvRainbowFlowX { 0%, 100% { background-position: 0% 50%; } 50% { background-position: 100% 50%; } }
|
||||
@keyframes appletvRainbowBreatheBar { 0%, 100% { opacity: .4; transform: scale(1); } 50% { opacity: .78; transform: scale(1.03); } }
|
||||
@keyframes appletvRainbowSpin { from { filter: blur(20px) hue-rotate(0deg); } to { filter: blur(20px) hue-rotate(360deg); } }
|
||||
@keyframes appletvRainbowBreatheSoft { 0%, 100% { opacity: .26; transform: scale(.9); } 50% { opacity: .52; transform: scale(1.04); } }
|
||||
|
||||
/* 导航栏:七彩左右均匀分布 + 缓慢流动 + 呼吸(对应预览里的 B-2 方案) */
|
||||
:root[data-theme="appletv"] .chips {
|
||||
position: fixed; overflow: hidden;
|
||||
background: rgba(255, 255, 255, .55);
|
||||
}
|
||||
:root[data-theme="appletv"] .chips::before {
|
||||
content: ""; position: absolute; inset: -60% 0; z-index: 0; pointer-events: none;
|
||||
background: linear-gradient(90deg, #ff3b3b, #ff9d3b, #ffe23b, #3bff6a, #3bd4ff, #7a3bff, #ff3bbf, #ff3b3b);
|
||||
background-size: 200% 100%; filter: blur(18px);
|
||||
animation: appletvRainbowFlowX 6s ease-in-out infinite, appletvRainbowBreatheBar 3s ease-in-out infinite;
|
||||
}
|
||||
:root[data-theme="appletv"] .chips::after {
|
||||
content: ""; position: absolute; inset: 0; z-index: 0; pointer-events: none;
|
||||
background: rgba(255, 255, 255, .7);
|
||||
}
|
||||
:root[data-theme="appletv"] .chip { position: relative; z-index: 1; }
|
||||
|
||||
/* 播放按钮:保持长条形状与原有布局,仅把内部换成七彩微呼吸(关掉原本的蓝色流光,避免叠加) */
|
||||
:root[data-theme="appletv"] .ed-play {
|
||||
position: relative; overflow: hidden; animation: none;
|
||||
background: rgba(255, 255, 255, .92);
|
||||
}
|
||||
:root[data-theme="appletv"] .ed-play::before {
|
||||
content: ""; position: absolute; inset: -40%; z-index: 0; pointer-events: none;
|
||||
background: conic-gradient(from 0deg, #ff3b3b, #ff9d3b, #ffe23b, #3bff6a, #3bd4ff, #7a3bff, #ff3bbf, #ff3b3b);
|
||||
animation: appletvRainbowSpin 6s linear infinite, appletvRainbowBreatheSoft 4s ease-in-out infinite;
|
||||
}
|
||||
:root[data-theme="appletv"] .ed-play::after {
|
||||
content: ""; position: absolute; inset: 0; z-index: 0; pointer-events: none;
|
||||
background: rgba(255, 255, 255, .8);
|
||||
}
|
||||
:root[data-theme="appletv"] .ed-play .tri,
|
||||
:root[data-theme="appletv"] .ed-play #edPlayLabel {
|
||||
position: relative; z-index: 1;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
:root[data-theme="appletv"] .chips::before,
|
||||
:root[data-theme="appletv"] .ed-play::before { animation: none; }
|
||||
}
|
||||
.no-css-functions:root { --safe-top: 0px; --safe-bottom: 0px; }
|
||||
:root {
|
||||
--safe-top: min(max(var(--fm-safe-top, 0px), env(safe-area-inset-top, 0px)), 64px);
|
||||
@@ -126,96 +266,354 @@ html.chrome-normal { --safe-top: 0px; --safe-bottom: 0px; }
|
||||
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
|
||||
html, body {
|
||||
margin: 0; min-height: 100%; color: var(--ink);
|
||||
background:
|
||||
radial-gradient(1px 1px at 18% 28%, rgba(255,255,255,.7), transparent),
|
||||
radial-gradient(1px 1px at 68% 18%, rgba(255,255,255,.5), transparent),
|
||||
radial-gradient(1.5px 1.5px at 44% 58%, rgba(255,255,255,.5), transparent),
|
||||
radial-gradient(1px 1px at 84% 66%, rgba(255,255,255,.6), transparent),
|
||||
radial-gradient(1px 1px at 28% 84%, rgba(255,255,255,.45), transparent),
|
||||
radial-gradient(1.5px 1.5px at 92% 40%, rgba(255,255,255,.4), transparent),
|
||||
linear-gradient(180deg, var(--bg1) 0%, var(--bg2) 55%, var(--bg3) 100%) fixed;
|
||||
background-attachment: fixed; transition: background-color .3s;
|
||||
background: var(--dyn-bg, #000000);
|
||||
/* 故意不加 transition:hero-shade / hero-blend 两层融合渐变本身就是瞬时跟着 --dyn-bg-rgb 变的
|
||||
(见下面 .hero-blend 的注释),如果这里的页面背景还带着延迟过渡,融合层已经瞬间到位、
|
||||
页面背景却还在慢慢追,两者交界处(融合层贴着页面背景那条边)就会露出一条"追赶中"的接缝——
|
||||
不管是自动轮播还是手动拖拽,只要颜色一变就会看见。两边都改成瞬时,才能真正无缝衔接。 */
|
||||
font-family: "PingFang SC", "HarmonyOS Sans SC", "Microsoft YaHei", "Noto Sans CJK SC", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
font-size: 15px; line-height: 1.5; -webkit-font-smoothing: antialiased;
|
||||
}
|
||||
html:not(.fm-native), html:not(.fm-native) body {
|
||||
background:
|
||||
radial-gradient(1px 1px at 18% 28%, rgba(255,255,255,.7), transparent),
|
||||
radial-gradient(1px 1px at 68% 18%, rgba(255,255,255,.5), transparent),
|
||||
radial-gradient(1.5px 1.5px at 44% 58%, rgba(255,255,255,.5), transparent),
|
||||
radial-gradient(1px 1px at 84% 66%, rgba(255,255,255,.6), transparent),
|
||||
radial-gradient(1px 1px at 28% 84%, rgba(255,255,255,.45), transparent),
|
||||
linear-gradient(180deg, var(--bg1) 0%, var(--bg2) 55%, var(--bg3) 100%) fixed;
|
||||
background: var(--dyn-bg, #000000);
|
||||
}
|
||||
.mono { font-family: ui-monospace, "SF Mono", "Roboto Mono", "DejaVu Sans Mono", Consolas, monospace; letter-spacing: .04em; }
|
||||
/* 首页 / 分类页 这种"整页切换"原来是纯粹的 style.display 硬切,一帧内直接消失/出现,
|
||||
没有任何过渡。这里给刚显示出来的那个视图补一个轻微的"弹入"动画(略微放大回弹 + 上浮淡入),
|
||||
由 JS 在切换时手动重新触发(先移除类名强制重排,再加回去),不影响原有 display 切换逻辑,
|
||||
也不需要两个视图同时占位,所以不会有布局跳动的风险。 */
|
||||
@keyframes viewPopIn { from { opacity: 0; transform: translateY(14px) scale(.965); } to { opacity: 1; transform: translateY(0) scale(1); } }
|
||||
.view-pop { animation: viewPopIn .46s cubic-bezier(.34, 1.56, .64, 1); }
|
||||
@media (prefers-reduced-motion: reduce) { .view-pop { animation: none; } }
|
||||
/* TV 端:遥控操作追求最简单直接的响应,所有装饰性 keyframe 动画(呼吸光、流光、shimmer 加载条、
|
||||
弹入弹出)统一关掉,跟 prefers-reduced-motion 用同一套思路,只是不看系统设置、只认 tv-mode。
|
||||
注意这里只关 animation,不动 transition——面板/详情页开合那种短过渡还留着,纯瞬切会显得很生硬。 */
|
||||
.tv-mode * { animation: none !important; }
|
||||
.app { min-height: 100vh; min-height: var(--fm-web-height, 100vh); background: transparent; padding-left: var(--safe-left); padding-right: var(--safe-right); }
|
||||
button { font: inherit; color: inherit; cursor: pointer; border: 0; background: none; }
|
||||
/* 苹果橡皮筋回弹:越界拉伸时 JS 给容器加 translateY,松手用这条弹性过渡弹回。
|
||||
.rubber-snap 只在"松手弹回"那一刻加上(回弹用带过冲的缓动),跟手拖拽时不加过渡(实时跟手)。 */
|
||||
.rubber-snap { transition: transform .42s cubic-bezier(.22, 1, .36, 1); }
|
||||
#detailSheet.rubber-snap { transition: transform .42s cubic-bezier(.22, 1, .36, 1); }
|
||||
button { font: inherit; color: inherit; cursor: pointer; border: 0; background: none; padding: 0; margin: 0; appearance: none; -webkit-appearance: none; text-align: inherit; }
|
||||
input { font: inherit; color: inherit; }
|
||||
img { display: block; border: 0; }
|
||||
|
||||
.focusable:focus, .focusable.focus { outline: none; }
|
||||
.tv-mode .focusable:focus, .tv-mode .focusable.focus { box-shadow: 0 0 0 2px var(--accent) inset; background-color: var(--accent-soft); }
|
||||
.tv-mode .focusable:focus, .tv-mode .focusable.focus { transform: scale(1.06); }
|
||||
|
||||
/* 头部 */
|
||||
.header { position: sticky; top: 0; z-index: 30; padding-top: calc(var(--safe-top) + 8px); padding-bottom: 8px;
|
||||
background: linear-gradient(180deg, rgba(9,14,32,.95) 0%, rgba(9,14,32,.86) 60%, rgba(9,14,32,.55) 100%);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(1.3); backdrop-filter: blur(16px) saturate(1.3); }
|
||||
.fm-native .header { background: linear-gradient(180deg, rgba(9,14,32,.9) 0%, rgba(9,14,32,.8) 60%, rgba(9,14,32,.48) 100%); }
|
||||
.header { position: sticky; top: 0; z-index: 30; padding-top: calc(var(--safe-top) + 8px); padding-bottom: 8px; background: transparent; }
|
||||
.fm-native .header { background: transparent; }
|
||||
.header-row { display: flex; align-items: center; padding: 0 16px; }
|
||||
.brand { flex: 0 0 auto; display: flex; align-items: center; background: none; border: 0; padding: 0; margin-right: 8px; cursor: pointer; position: relative; z-index: 6; }
|
||||
.brand:active { transform: scale(.92); }
|
||||
.tv-mode .brand.focus, .tv-mode .brand:focus { box-shadow: 0 0 0 2px var(--accent); border-radius: 999px; }
|
||||
.brand { flex: 0 0 auto; display: flex; align-items: center; background: none; border: 0; padding: 4px 2px; cursor: pointer; }
|
||||
.brand:active { transform: scale(.94); }
|
||||
.tv-mode .brand.focus, .tv-mode .brand:focus { transform: scale(1.08); }
|
||||
.brand-mark { flex: 0 0 auto; display: block; }
|
||||
.brand .name { font-size: 17px; font-weight: 800; margin-left: 9px; letter-spacing: 1px; }
|
||||
.brand .tag { font-size: 10px; margin-left: 8px; padding: 2px 6px; border: 1px solid var(--line); border-radius: 6px; color: var(--ink-dim); text-transform: uppercase; }
|
||||
.search-box { flex: 1 1 auto; margin: 0; display: flex; align-items: center; background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 0 5px 0 12px; min-width: 0; }
|
||||
.search-input { flex: 1 1 auto; min-width: 0; background: transparent; border: 0; outline: none; padding: 9px 0; color: var(--ink); font-size: 15px; }
|
||||
.brand .name { font-size: 18px; font-weight: 800; margin-left: 9px; letter-spacing: 1px; }
|
||||
.hdr-spacer { flex: 1 1 auto; }
|
||||
/* 搜索面板(顶部固定浮层,搜索时出现) */
|
||||
.search-panel { position: fixed; top: 0; left: 0; right: 0; z-index: 60; visibility: hidden; padding: calc(var(--safe-top) + 10px) 16px 12px;
|
||||
background: transparent;
|
||||
opacity: 0; -webkit-transform: translateY(-14px); transform: translateY(-14px); pointer-events: none;
|
||||
transition: opacity .22s ease, -webkit-transform .3s cubic-bezier(.34,1.56,.64,1), transform .3s cubic-bezier(.34,1.56,.64,1), visibility 0s linear .26s; }
|
||||
.search-panel.open { visibility: visible; opacity: 1; -webkit-transform: translateY(0); transform: translateY(0); pointer-events: auto;
|
||||
transition: opacity .2s ease, -webkit-transform .32s cubic-bezier(.34,1.56,.64,1), transform .32s cubic-bezier(.34,1.56,.64,1); }
|
||||
/* 搜索面板打开时的背景遮罩:模糊 + 半透明,禁止背景滑动,点击空白处回弹关闭 */
|
||||
.search-mask { position: fixed; top: 0; right: 0; bottom: 0; left: 0; inset: 0; z-index: 55; visibility: hidden; opacity: 0;
|
||||
background: rgba(6,10,22,.46); -webkit-backdrop-filter: blur(0px); backdrop-filter: blur(0px);
|
||||
-ms-touch-action: none; touch-action: none;
|
||||
transition: opacity .24s ease, visibility 0s linear .24s; }
|
||||
.search-mask.open { visibility: visible; opacity: 1; -webkit-backdrop-filter: blur(16px) saturate(1.3); backdrop-filter: blur(16px) saturate(1.3);
|
||||
transition: opacity .2s ease, -webkit-backdrop-filter .3s ease, backdrop-filter .3s ease; }
|
||||
body.search-lock { position: fixed; left: 0; right: 0; width: 100%; }
|
||||
.search-box { display: flex; align-items: center; background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 0 6px 0 12px; min-width: 0; position: relative; }
|
||||
.search-ico { flex: 0 0 auto; display: flex; color: var(--ink-faint); margin-right: 8px; }
|
||||
.search-input { flex: 1 1 auto; min-width: 0; background: transparent; border: 0; outline: none; padding: 10px 0; color: var(--ink); font-size: 15px; }
|
||||
.search-input::placeholder { color: var(--ink-faint); }
|
||||
.search-go { flex: 0 0 auto; width: 34px; height: 34px; display: flex; align-items: center; justify-content: center; background: none; border: 0; color: var(--ink-dim); }
|
||||
.search-go:active { transform: scale(.88); }
|
||||
.tv-mode .search-go.focus, .tv-mode .search-go:focus { color: var(--accent); }
|
||||
/* 头部搜索框外的图标按钮(历史等) */
|
||||
.hdr-ico { flex: 0 0 auto; width: 40px; height: 40px; margin-left: 8px; border-radius: 11px; display: flex; align-items: center; justify-content: center; background: var(--panel); border: 1px solid var(--line); color: var(--ink-dim); }
|
||||
.hdr-ico:active { transform: scale(.92); }
|
||||
.hdr-ico.active { background: var(--accent-soft); border-color: var(--accent-line); color: var(--accent); }
|
||||
.tv-mode .hdr-ico.focus, .tv-mode .hdr-ico:focus { box-shadow: 0 0 0 2px var(--accent); color: var(--ink); }
|
||||
.search-go { flex: 0 0 auto; padding: 7px 15px; border-radius: 9px; background: var(--grad); color: var(--accent-ink); font-weight: 800; font-size: 13px; background-size: 220% 220%; animation: gradFlow 8s ease-in-out infinite; }
|
||||
/* 统一的圆角图标按钮底座:背景/描边/按下反馈只在这里定义一次,
|
||||
hdr-ico / cat-back / recent-clear-btn 都基于它,只覆盖各自的尺寸和默认字色。 */
|
||||
.icon-btn { flex: 0 0 auto; display: flex; align-items: center; justify-content: center;
|
||||
background: var(--panel); border: 1px solid var(--line); color: var(--ink-dim);
|
||||
outline: none; -webkit-tap-highlight-color: transparent; }
|
||||
.icon-btn::-moz-focus-inner { border: 0; }
|
||||
.icon-btn:active { transform: scale(.92); background: var(--accent-soft); border-color: var(--accent-line); }
|
||||
.icon-btn.active { background: var(--accent-soft); border-color: var(--accent-line); color: var(--accent); }
|
||||
.tv-mode .icon-btn.focus, .tv-mode .icon-btn:focus { transform: scale(1.12); }
|
||||
.hdr-ico { width: 40px; height: 40px; border-radius: 11px; }
|
||||
.search-go svg { display: block; }
|
||||
.tv-mode .search-go.focus, .tv-mode .search-go:focus { box-shadow: 0 0 0 3px var(--accent); }
|
||||
.chips { display: flex; justify-content: safe center; overflow-x: auto; white-space: nowrap; padding: 10px 16px 4px; -webkit-overflow-scrolling: touch; scrollbar-width: none; }
|
||||
.tv-mode .search-go.focus, .tv-mode .search-go:focus { transform: scale(1.08); }
|
||||
/* emby 式首页多行 */
|
||||
#homeRows { padding-top: 6px; }
|
||||
.mh-section { margin-bottom: 22px; }
|
||||
.mh-section-head { display: flex; align-items: baseline; justify-content: space-between; padding: 0 16px; margin-bottom: 10px; }
|
||||
.mh-section-title { font-size: 17px; font-weight: 800; letter-spacing: -.02em; }
|
||||
.mh-more { font-size: 13px; font-weight: 700; color: var(--accent); background: none; border: 0; display: inline-flex; align-items: center; gap: 2px; padding: 2px 0; }
|
||||
.mh-more:active { opacity: .6; }
|
||||
.mh-row { display: flex; gap: var(--card-gut); overflow-x: auto; padding: 0 var(--card-gut) 4px; scrollbar-width: none; -webkit-overflow-scrolling: touch; }
|
||||
.mh-row::-webkit-scrollbar { display: none; }
|
||||
.mh-row .card { width: 128px; flex: 0 0 auto; margin: 0; }
|
||||
/* 继续观看:横版剧照比例(16:9),而不是竖版海报比例 */
|
||||
.mh-row-recent .card { width: 208px; }
|
||||
.mh-row-recent .poster { padding-top: 56.25%; }
|
||||
.mh-row-recent .poster img { object-position: center 25%; }
|
||||
.grid.grid-recent { grid-template-columns: repeat(2, 1fr); }
|
||||
.grid-recent .poster { padding-top: 56.25%; }
|
||||
.grid-recent .poster img { object-position: center 25%; }
|
||||
@media (min-width: 600px) { .grid.grid-recent { grid-template-columns: repeat(3, 1fr); } }
|
||||
.mh-row-empty { padding: 4px var(--gut); color: var(--ink-faint); font-size: 12.5px; }
|
||||
/* 分类页头 */
|
||||
/* 分类页头:原来写死纯黑(#000000),跟首页/详情页现在都跟着海报自动变色的暖色调背景放一起,
|
||||
顶上突兀地卡一条黑条,很显眼很尴尬。改成跟 --dyn-bg-rgb 同色系的半透明磨砂条——颜色跟着
|
||||
当前页面的主题色走,同时留一点模糊(backdrop-filter)方便下面滚动的内容衬着文字看得清,
|
||||
观感上就是"同一块背景延伸上去",而不是另外扣一块黑色盖子上去。 */
|
||||
.cat-head { display: flex; align-items: center; gap: 8px; padding: calc(var(--safe-top) + 8px) var(--gut) 8px;
|
||||
position: sticky; top: 0; z-index: 20;
|
||||
background: rgba(var(--dyn-bg-rgb, 0,0,0), .82);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(1.15);
|
||||
backdrop-filter: blur(20px) saturate(1.15); }
|
||||
/* 返回按钮:原来直接吃 .icon-btn 的通用方形底座(10px 圆角、纯色板 + 细描边),
|
||||
跟其它功能性图标按钮长一个样,放在页头左上角显得很"工具栏",不够精致。
|
||||
改成苹果系统里常见的圆形毛玻璃返回键:正圆形、更强的模糊透光、外投影给一点浮起的
|
||||
厚度感,再叠一条极淡的内上高光模拟玻璃球面反光。按压时用回弹曲线代替瞬间跳变的
|
||||
scale,松手时的手感更软。箭头本身在 24 视口里其实比几何中心略靠右一点点,
|
||||
这里用 svg 自身的 translateX 往左挪 1px,视觉上更居中。 */
|
||||
.cat-back {
|
||||
width: 36px; height: 36px; border-radius: 50%; color: var(--ink);
|
||||
background: var(--panel-soft);
|
||||
border: 1px solid var(--line);
|
||||
box-shadow: 0 3px 12px rgba(0, 0, 0, .22), 0 1px 2px rgba(0, 0, 0, .16), inset 0 1px 0 rgba(255, 255, 255, .1);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(1.4);
|
||||
backdrop-filter: blur(16px) saturate(1.4);
|
||||
transition: transform .38s cubic-bezier(.34, 1.56, .64, 1), background .2s ease, box-shadow .2s ease, border-color .2s ease;
|
||||
}
|
||||
.cat-back svg { transform: translateX(-1px); }
|
||||
.cat-back:active {
|
||||
transform: scale(.86);
|
||||
background: var(--accent-soft);
|
||||
border-color: var(--accent-line);
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, .18), inset 0 1px 0 rgba(255, 255, 255, .06);
|
||||
}
|
||||
.tv-mode .cat-back.focus, .tv-mode .cat-back:focus {
|
||||
transform: scale(1.14);
|
||||
box-shadow: 0 0 0 3px var(--accent-line), 0 3px 12px rgba(0, 0, 0, .22);
|
||||
}
|
||||
.cat-title { font-size: 18px; font-weight: 800; flex: 0 0 auto; max-width: 40%; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
/* 搜索结果页头:把"搜索:关键词"这行纯文字标题换成一个"搜索框"样式的按钮——
|
||||
里面放一个放大镜图标 + 当前关键词,点它重新打开搜索面板改词(参考设计图,搜完仍看得到搜索框本身,
|
||||
而不是只剩一行标题文字)。同时把站源筛选胶囊单独挪到下一行。默认不显示,只有 #catView.searching 时出现。 */
|
||||
.cat-searchbar { display: none; align-items: center; gap: 8px; flex: 1 1 auto; min-width: 0; cursor: pointer;
|
||||
background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 9px 10px 9px 14px; color: var(--ink);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(1.3); backdrop-filter: blur(16px) saturate(1.3);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
-webkit-user-select: none; user-select: none; transition: background .18s ease, border-color .18s ease; }
|
||||
.cat-searchbar .csb-ico { flex: 0 0 auto; display: flex; color: var(--ink-faint); }
|
||||
.cat-searchbar .csb-kw { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 15px; text-align: left; }
|
||||
.cat-searchbar:active { background: var(--accent-soft); border-color: var(--accent-line); }
|
||||
#catView.searching .cat-title { display: none; }
|
||||
#catView.searching .cat-searchbar { display: flex; }
|
||||
#catView.searching .cat-head { flex-wrap: wrap; row-gap: 10px; }
|
||||
/* 胶囊掉到第二行、整行左对齐;order 拉大保证它排在这一行所有元素最后(即换行到下一行) */
|
||||
#catView.searching #genreChips { order: 99; flex: 0 0 100%; max-width: 100%; width: 100%; margin: 0; justify-content: flex-start; }
|
||||
.hero-wrap { position: relative; }
|
||||
.hero-wrap[hidden] { display: none; }
|
||||
.hero { position: relative; width: 100%; height: 58vh; min-height: 340px; max-height: 560px; overflow: hidden; margin: 0 0 6px; background: var(--sheet-bg); }
|
||||
.hero:empty { display: none; }
|
||||
@media (orientation: landscape) { .hero { height: 85vh; min-height: 280px; } }
|
||||
/* 平板竖屏(宽度够大且竖屏):轮播海报加高 8vh,画面更饱满。max-height 也放宽以免被 560px 卡住 */
|
||||
@media (min-width: 600px) and (orientation: portrait) { .hero { height: 66vh; max-height: 720px; } }
|
||||
/* 图片层:不参与左右翻页位移,只在当前帧/目标帧之间做透明度交叉渐隐渐显,
|
||||
叠色(hero-shade/hero-blend)跟着 --dyn-bg-rgb 同步瞬时变化,观感上颜色融合和图片过渡完全同步。 */
|
||||
.hero-imgs { position: absolute; inset: 0; width: 100%; height: 100%; overflow: hidden; z-index: 0; }
|
||||
.hero-track { position: relative; z-index: 1; display: flex; height: 100%; transition: transform .5s cubic-bezier(.4, 0, .2, 1); will-change: transform; }
|
||||
.hero-slide { position: relative; flex: 0 0 100%; height: 100%; overflow: hidden; display: flex; flex-direction: column; justify-content: flex-end; }
|
||||
.hero-bg { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; object-position: center 20%; opacity: 0; transition: opacity .5s cubic-bezier(.4, 0, .2, 1); }
|
||||
.hero-bg.active { opacity: 1; }
|
||||
/* 顶部渐变原来用 --dyn-bg-rgb(全图众数主色)去染顶部/次顶部区域,
|
||||
问题:主色是"整张海报"的投票结果,跟顶部那一小条局部像素的真实颜色(比如深色头发/五官)
|
||||
完全没有对应关系,容易出现"局部本来是黑的,却被强行染成主色(红/蓝等)"的违和感。
|
||||
改成不跟随主色、只用于状态栏文字可读性的极淡中性色,不再随海报变色。 */
|
||||
.hero-shade { position: absolute; inset: 0; background:
|
||||
linear-gradient(to bottom, rgba(0,0,0,.28) 0%, transparent 22%); }
|
||||
/* 底部融合层:不是拿一块纯色硬盖上去(那样不管颜色多准,照片的清晰细节和纯色之间还是有一条能看出来的边),
|
||||
而是用 backdrop-filter 把这块区域"背后的图"本身模糊掉,再叠一层从透明到接近实色的动态色渐变——
|
||||
越往下越糊、越往下颜色越重,模糊+调色是同步渐进的,所以看不出"从这里开始模糊/从这里开始变色"的分界线。
|
||||
最底部渐变到 .94 的不透明度,正好接近下方页面 --dyn-bg 的实色,和页面背景连成一片。
|
||||
自身再用 mask-image 做一次由上到下的显现渐变,让这层"变糊变色"的过渡本身也是渐进出现的,不是一整块突然出现。 */
|
||||
/* 底部融合:主页轮播跟着视差滚动,用【单层】backdrop-filter(滚动流畅,不闪)。
|
||||
关键是 mask 的透明度要【全程平缓爬升】——之前 62% 就到全黑(#000),导致下半截是一整块满模糊、
|
||||
和上面渐变段之间有明显硬分界。这里改成从顶部一路多档缓升,直到贴底才接近全实,
|
||||
模糊强度全程渐进,看不出"分界带"。 */
|
||||
.hero-blend {
|
||||
position: absolute; left: 0; right: 0; bottom: -2px; height: calc(56% + 2px); pointer-events: none;
|
||||
-webkit-backdrop-filter: blur(22px) saturate(1.12); backdrop-filter: blur(22px) saturate(1.12);
|
||||
background: linear-gradient(to bottom, rgba(var(--dyn-bg-rgb), 0) 0%, rgba(var(--dyn-bg-rgb), .22) 34%, rgba(var(--dyn-bg-rgb), .6) 58%, rgba(var(--dyn-bg-rgb), .9) 80%, rgba(var(--dyn-bg-rgb), 1) 92%, rgba(var(--dyn-bg-rgb), 1) 100%);
|
||||
-webkit-mask-image: linear-gradient(to bottom, transparent 0%, rgba(0,0,0,.04) 12%, rgba(0,0,0,.12) 26%, rgba(0,0,0,.26) 40%, rgba(0,0,0,.45) 54%, rgba(0,0,0,.66) 68%, rgba(0,0,0,.85) 82%, #000 96%);
|
||||
mask-image: linear-gradient(to bottom, transparent 0%, rgba(0,0,0,.04) 12%, rgba(0,0,0,.12) 26%, rgba(0,0,0,.26) 40%, rgba(0,0,0,.45) 54%, rgba(0,0,0,.66) 68%, rgba(0,0,0,.85) 82%, #000 96%);
|
||||
}
|
||||
/* 横屏(平板/手机横放):屏幕更矮更宽,56% 高度占比过大会糊到海报主体。调矮到 44%,
|
||||
模糊区更靠下,不遮画面。渐变/mask 比例是跟着高度百分比自动收缩的,只是整体下移。 */
|
||||
@media (orientation: landscape) { .hero-blend { height: calc(44% + 2px); -webkit-backdrop-filter: blur(18px) saturate(1.12); backdrop-filter: blur(18px) saturate(1.12); } }
|
||||
.hero-info { position: relative; z-index: 1; padding: 0 20px 36px; text-align: center; }
|
||||
|
||||
/* ============================================================
|
||||
首页轮播「异步滑动」视差(自 emby 首页移植)
|
||||
目标:下滑时只有海报图(.hero-bg)慢半拍上移,.hero-shade / .hero-blend / .hero-info
|
||||
全部保持页面常速、和下面的内容行一起走 —— 融合渐变便会自下而上把慢下来的海报逐渐"淹没",
|
||||
出现景深分层感;而信息文字始终贴着内容,不会脱节割裂。
|
||||
|
||||
为什么用滚动驱动动画(animation-timeline)而不是 JS 监听 scroll 写 transform:
|
||||
滚动本身跑在合成器线程,JS 改 transform 跑在主线程,海报位置永远比内容慢一帧 → 肉眼可见抖动,
|
||||
这是原理性的,调参数消不掉。绑到滚动时间轴上则整条动画由浏览器在合成器线程驱动,与滚动同帧。
|
||||
|
||||
⚠ 两个坑(都已避开):
|
||||
1) 时间轴用 scroll(root block) —— 本页是「整个窗口」在滚(window.scrollTo/pageYOffset),
|
||||
根滚动器就是文档。这里绝不能用 scroll(nearest):.hero 和 .hero-slide 都带 overflow:hidden,
|
||||
它们本身就算"滚动容器",nearest 会命中它们,而它们根本不滚 → 进度恒为 0 → 完全没效果。
|
||||
2) 位移只加在 .hero-bg 上,绝不碰 .hero-track —— 那一层的 transform 是轮播左右翻页在用的,
|
||||
动它会把轮播搞坏。
|
||||
============================================================ */
|
||||
@supports (animation-timeline: scroll()) {
|
||||
/* 动画区间要知道 hero 实际高度:用与 .hero 完全相同的算式复刻(含 min/max-height → clamp) */
|
||||
.hero { --hero-h: clamp(340px, 58vh, 560px); }
|
||||
@media (orientation: landscape) { .hero { --hero-h: clamp(280px, 85vh, 560px); } }
|
||||
@media (min-width: 600px) and (orientation: portrait) { .hero { --hero-h: clamp(340px, 66vh, 720px); } }
|
||||
|
||||
/* 海报:慢速下压(视差) + 极轻微推近(景深)。整条动画在合成器上跑,带 scale 也依然丝滑 */
|
||||
@keyframes heroParallax {
|
||||
from { transform: translate3d(0, 0, 0) scale(1); }
|
||||
to { transform: translate3d(0, calc(var(--hero-h, 58vh) * .42), 0) scale(1.06); }
|
||||
}
|
||||
/* 信息层:前 30% 完全不动、保持清晰,之后随滚动优雅淡出(只改透明度、不改位移,
|
||||
所以依旧与内容同速,不会重现"脱节"的割裂感) */
|
||||
@keyframes heroInfoFade {
|
||||
0%, 30% { opacity: 1; }
|
||||
100% { opacity: 0; }
|
||||
}
|
||||
/* 注意声明顺序:animation 简写会把 animation-timeline 重置为 auto,
|
||||
所以 animation-timeline / animation-range 必须写在简写「之后」,否则又变成不动。 */
|
||||
.hero-bg {
|
||||
animation: heroParallax linear both;
|
||||
animation-timeline: scroll(root block);
|
||||
animation-range: 0px var(--hero-h, 58vh);
|
||||
will-change: transform; backface-visibility: hidden;
|
||||
}
|
||||
.hero-info {
|
||||
animation: heroInfoFade linear both;
|
||||
animation-timeline: scroll(root block);
|
||||
animation-range: 0px calc(var(--hero-h, 58vh) * .78);
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hero-bg { animation: none; }
|
||||
.hero-info { animation: none; opacity: 1; }
|
||||
}
|
||||
}
|
||||
.hero-title-wrap { display: flex; justify-content: center; }
|
||||
.hero-title { font-size: 30px; font-weight: 900; letter-spacing: -.02em; line-height: 1.08; text-shadow: 0 2px 18px rgba(0,0,0,.55); max-width: 90%; margin: 0 auto 10px; }
|
||||
.hero-logo { display: block; max-width: 74%; max-height: 84px; width: auto; height: auto; margin: 0 auto 12px; object-fit: contain; filter: drop-shadow(0 4px 16px rgba(0,0,0,.6)); }
|
||||
.hero-meta { display: flex; align-items: center; justify-content: center; gap: 8px; flex-wrap: wrap; font-size: 13px; color: var(--ink-dim); text-shadow: 0 1px 6px rgba(0,0,0,.6); }
|
||||
.hero-meta { color: #d8def0; }
|
||||
.hero-ov { margin: 9px auto 0; font-size: 13px; color: #c7cddf; line-height: 1.55; max-width: 94%; text-shadow: 0 1px 6px rgba(0,0,0,.6); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; text-overflow: ellipsis; }
|
||||
/* 横版(手机横屏)时轮播信息改左对齐:标题、徽标、元信息、简介都靠左,不再居中,
|
||||
跟竖版是两套独立的对齐方式,互不影响。 */
|
||||
@media (orientation: landscape) {
|
||||
.hero-info { text-align: left; }
|
||||
.hero-title-wrap { justify-content: flex-start; }
|
||||
.hero-title { margin: 0 0 10px; max-width: 60%; }
|
||||
.hero-logo { margin: 0 0 12px; }
|
||||
.hero-meta { justify-content: flex-start; }
|
||||
.hero-ov { margin: 9px 0 0; max-width: 60%; }
|
||||
}
|
||||
/* 轮播指示器(圆点) */
|
||||
.hero-dots { position: absolute; left: 0; right: 0; bottom: 13px; z-index: 4; display: flex; align-items: center; justify-content: center; gap: 6px; pointer-events: none; }
|
||||
.hero-dots:empty { display: none; }
|
||||
.hero-dot { width: 6px; height: 6px; border-radius: 50%; background: rgba(255,255,255,.4); box-shadow: 0 1px 4px rgba(0,0,0,.4); transition: width .25s ease, background-color .25s ease, opacity .25s ease; }
|
||||
.hero-dot.on { width: 16px; background: #fff; }
|
||||
.chips { position: fixed; left: 50%; bottom: calc(10px + var(--safe-bottom)); transform: translateX(-50%);
|
||||
width: auto; max-width: 94%; display: inline-flex; align-items: center; gap: 2px; justify-content: center; z-index: 45;
|
||||
background: rgba(18, 26, 54, 0.62); -webkit-backdrop-filter: blur(24px) saturate(1.7); backdrop-filter: blur(24px) saturate(1.7);
|
||||
border: 1px solid rgba(160, 190, 255, 0.16); border-radius: 999px; padding: 5px 7px; overflow: visible;
|
||||
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.42), inset 0 1px 0 rgba(255, 255, 255, 0.10);
|
||||
-webkit-user-select: none; user-select: none; -webkit-touch-callout: none; }
|
||||
.chip { flex: 0 0 auto; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px;
|
||||
padding: 7px 22px; border-radius: 999px; background: none; border: 0; color: var(--ink-dim); font-size: 10px; font-weight: 700; white-space: nowrap; transition: color .16s, background .16s;
|
||||
-webkit-user-select: none; user-select: none; -webkit-touch-callout: none; -webkit-tap-highlight-color: transparent; }
|
||||
.chip .chip-ico { width: 23px; height: 23px; display: block; }
|
||||
.chip[data-nav="settings"] .chip-ico { width: 22px; height: 22px; }
|
||||
.chip.active { background: var(--accent-soft); color: var(--accent); }
|
||||
.chip:active { transform: scale(.94); }
|
||||
.tv-mode .chip.focus, .tv-mode .chip:focus { transform: scale(1.08); }
|
||||
/* 给内容留出底栏空间;详情/设置打开时隐藏底栏 */
|
||||
main { padding-bottom: calc(78px + var(--safe-bottom)); }
|
||||
body.detail-active .chips { display: none; }
|
||||
.chips::-webkit-scrollbar { display: none; }
|
||||
.chip { flex: 0 0 auto; margin-right: 8px; padding: 7px 15px; border-radius: 10px; font-size: 13px; background: var(--panel-soft); border: 1px solid var(--line); color: var(--ink-dim); }
|
||||
.chip:last-child { margin-right: 0; }
|
||||
.chip.active { background: var(--grad); color: var(--accent-ink); border-color: transparent; font-weight: 700; }
|
||||
/* 二级类型筛选 chips */
|
||||
.genre-chips { display: flex; flex: 1 1 auto; min-width: 0; justify-content: safe center; overflow-x: auto; white-space: nowrap; margin-left: 0; padding: 2px 0; -webkit-overflow-scrolling: touch; scrollbar-width: none; }
|
||||
/* 二级类型筛选 chips:横向滑动这里不再自己用 JS 接管 touchmove 手搓惯性/橡皮筋——
|
||||
之前 touch-action: none 会把这块区域的滚动完全交给 JS 主线程处理,每一帧 touchmove
|
||||
都要走一遍 JS(还强制 preventDefault),走不了浏览器/系统那条硬件加速的合成线程滚动
|
||||
通道,参数调得再准也比不过原生滚动的跟手度,低端设备上还会明显掉帧、延迟,这才是
|
||||
"不灵敏"的根子,不是触摸区域大小的问题。
|
||||
改成 touch-action: pan-x:告诉浏览器"这个元素只接管横向平移手势",横向滑动直接走原生
|
||||
滚动(自带真实的惯性衰减和越界橡皮筋回弹,观感就是 iOS 原生的样子),竖直方向的手势
|
||||
不被这里拦截,会正常冒泡去滚整个页面,不会出现"划到这排标签就滑不动整页"的问题。 */
|
||||
.genre-chips { display: flex; flex: 1 1 auto; min-width: 0; justify-content: safe center; overflow-x: auto; white-space: nowrap; margin-left: 0; padding: 2px 0; -webkit-overflow-scrolling: touch; scrollbar-width: none; touch-action: pan-x; overscroll-behavior-x: contain; }
|
||||
.genre-chips::-webkit-scrollbar { display: none; }
|
||||
.genre-chips:empty { display: none; margin: 0; }
|
||||
.genre-chip { flex: 0 0 auto; margin-right: 6px; padding: 4px 11px; border-radius: 8px; font-size: 12px; background: rgba(255,255,255,.05); border: 1px solid var(--line); color: var(--ink-dim); }
|
||||
.genre-chip.active { background: var(--accent-soft); border-color: var(--accent-line); color: var(--ink); font-weight: 600; }
|
||||
/* 搜索结果站源筛选栏复用 .genre-chip,额外挂一个计数徽标(如截图里"全部 75"那种圆角数字)。
|
||||
分组标题 .search-group-title 直接放进 #grid 里,用 grid-column:1/-1 撑满整行,
|
||||
不需要另起一个网格容器——分组之间共用同一个网格的列宽/间距,视觉上更连贯。 */
|
||||
.genre-chip .chip-count { margin-left: 5px; font-size: 10.5px; font-weight: 700; line-height: 15px; min-width: 15px;
|
||||
display: inline-block; text-align: center; padding: 0 5px; border-radius: 999px; background: rgba(255,255,255,.12); color: inherit; }
|
||||
.genre-chip.active .chip-count { background: rgba(0,0,0,.18); }
|
||||
.search-group-title { grid-column: 1 / -1; display: flex; align-items: baseline; gap: 8px;
|
||||
font-size: 15px; font-weight: 700; color: var(--ink); margin: 18px 0 2px; }
|
||||
.search-group-title:first-child { margin-top: 2px; }
|
||||
.search-group-title .cnt { font-size: 12px; font-weight: 500; color: var(--ink-dim); }
|
||||
|
||||
/* 区块 */
|
||||
.section { padding: 12px 10px 0; }
|
||||
.section-head { display: flex; align-items: center; margin-bottom: 10px; }
|
||||
.section { padding: 12px 0 0; }
|
||||
.section-head { display: flex; align-items: center; margin-bottom: 10px; padding: 0 var(--gut); }
|
||||
.section-title { display: none; }
|
||||
.section-sub { display: none; }
|
||||
.clear-search { margin-left: 8px; flex: 0 0 auto; padding: 5px 12px; border-radius: 9px; background: var(--panel-soft); border: 1px solid var(--line); font-size: 12px; color: var(--ink-dim); }
|
||||
.clear-search { flex: 0 0 auto; width: 26px; height: 26px; margin-left: 2px; border-radius: 9px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: var(--panel); border: 1px solid var(--line); color: var(--ink-faint);
|
||||
-webkit-tap-highlight-color: transparent; transition: background .15s ease, border-color .15s ease, color .15s ease; }
|
||||
.clear-search:active { background: var(--accent-soft); border-color: var(--accent-line); color: var(--accent); }
|
||||
.clear-search svg { display: block; }
|
||||
|
||||
.rail { display: flex; overflow-x: auto; white-space: nowrap; padding-bottom: 4px; -webkit-overflow-scrolling: touch; scrollbar-width: none; }
|
||||
.rail { display: flex; gap: 6px; overflow-x: auto; white-space: nowrap; padding-bottom: 4px; -webkit-overflow-scrolling: touch; scrollbar-width: none; }
|
||||
.rail::-webkit-scrollbar { display: none; }
|
||||
.rail .card { flex: 0 0 auto; width: 116px; margin-right: 12px; white-space: normal; }
|
||||
.rail .card { flex: 0 0 auto; width: 132px; margin-right: 0; white-space: normal; }
|
||||
|
||||
.grid { display: flex; flex-wrap: wrap; margin: 0 -3px; }
|
||||
.grid .card { flex: 0 0 auto; width: calc(33.333% - 6px); max-width: calc(33.333% - 6px); min-width: 0; margin: 0 3px 10px; box-sizing: border-box; }
|
||||
.grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--card-gut); margin: 0; padding: 0 var(--card-gut); align-items: start; width: 100%; min-width: 0; overflow: hidden; }
|
||||
/* 左右滑动切换分类/筛选标签时,新内容的方向性动画(见 gridSwipeAnim)。
|
||||
之前用多阶段关键帧模拟"弹簧回弹",位移会先冲过头再往回缩一点,视觉上是内容在原地
|
||||
小幅度弹跳/抖动,不是真的"流动过来"的感觉,观感反而更别扭。
|
||||
现在改回最直接的思路:内容整体从滑动方向的那一侧,用单段纯位移 + 减速缓动
|
||||
(不过冲、不回弹)滑到位——手指往左划,新内容就从右边平滑流进来;往右划,
|
||||
就从左边流进来。没有缩放、没有透明度变化,只有一次干净利落的横向位移,
|
||||
最贴近"内容左右流动过来"这种直观的滑动手感。 */
|
||||
@keyframes gridSwipeInL { from { transform: translateX(64px); } to { transform: translateX(0); } }
|
||||
@keyframes gridSwipeInR { from { transform: translateX(-64px); } to { transform: translateX(0); } }
|
||||
.grid.grid-swipe-l { animation: gridSwipeInL .36s cubic-bezier(.22, 1, .36, 1); will-change: transform; }
|
||||
.grid.grid-swipe-r { animation: gridSwipeInR .36s cubic-bezier(.22, 1, .36, 1); will-change: transform; }
|
||||
@media (prefers-reduced-motion: reduce) { .grid.grid-swipe-l, .grid.grid-swipe-r { animation: none; } }
|
||||
.grid .card { width: auto; max-width: none; min-width: 0; margin: 0; box-sizing: border-box; }
|
||||
|
||||
.poster { position: relative; width: 100%; padding-top: 150%; border-radius: var(--radius); overflow: hidden; background: linear-gradient(135deg, rgba(255,255,255,.06), rgba(255,255,255,.02)); border: 1px solid var(--line); box-shadow: 0 8px 22px rgba(8,24,64,.5); }
|
||||
.poster { position: relative; width: 100%; padding-top: 150%; border-radius: 12px; overflow: hidden; background: linear-gradient(135deg, rgba(255,255,255,.06), rgba(255,255,255,.02)); border: 1px solid var(--line); box-shadow: 0 4px 12px rgba(8,24,64,.38); }
|
||||
.poster img { position: absolute; top: 0; left: 0; width: 100%; height: 100%; object-fit: cover; }
|
||||
.poster .ph { position: absolute; top: 0; left: 0; width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; color: var(--ink-faint); font-size: 26px; font-weight: 800; text-align: center; padding: 8px; }
|
||||
.card-del { position: absolute; top: 5px; right: 5px; width: 19px; height: 19px; border-radius: 50%; border: none; background: rgba(0,0,0,.55); color: #fff; font-size: 10px; line-height: 1; display: flex; align-items: center; justify-content: center; z-index: 3; -webkit-backdrop-filter: blur(2px); backdrop-filter: blur(2px); }
|
||||
.card-del:active { background: var(--bad, #ff5c66); }
|
||||
.recent-clear-btn { margin-left: auto; width: 34px; height: 34px; border-radius: 10px; }
|
||||
.recent-clear-btn:active { background: rgba(255,92,102,.14); border-color: rgba(255,92,102,.5); color: var(--bad, #ff5c66); }
|
||||
.badge { position: absolute; top: 7px; left: 7px; padding: 2px 7px; border-radius: 6px; background: var(--badge-bg); border: 1px solid var(--badge-border); color: var(--accent-ink); font-size: 9.5px; font-weight: 800; letter-spacing: .02em; box-shadow: 0 2px 8px var(--badge-shadow); }
|
||||
.card-title { margin-top: 5px; font-size: 12.5px; line-height: 1.3; height: 2.6em; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; }
|
||||
.card-title { margin-top: 8px; font-size: 14px; font-weight: 700; letter-spacing: -.2px; line-height: 1.3; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.card-meta { display: none; }
|
||||
.card { transition: transform .12s ease; }
|
||||
.tv-mode .card.focus .poster { box-shadow: 0 0 0 3px var(--accent); }
|
||||
.tv-mode .card.focus { transform: translateY(-2px) scale(1.02); }
|
||||
.tv-mode .card.focus { transform: scale(1.06); }
|
||||
|
||||
.notice { margin: 12px 16px; padding: 16px; background: var(--panel-soft); border: 1px solid var(--line); border-radius: var(--radius); }
|
||||
.notice h4 { margin: 0 0 6px; font-size: 14px; }
|
||||
@@ -223,24 +621,94 @@ img { display: block; border: 0; }
|
||||
.notice .link { color: var(--accent); font-weight: 700; }
|
||||
.loading-row { padding: 26px 16px; text-align: center; color: var(--ink-faint); font-size: 13px; }
|
||||
|
||||
/* 详情 / 资源浮层 —— 满幅海报英雄区(参考 HBO/Emby) */
|
||||
.sheet { position: fixed; top: 0; right: 0; bottom: 0; left: 0; inset: 0; z-index: 60; display: none; background: transparent; overflow-y: auto; -webkit-overflow-scrolling: touch; overscroll-behavior-y: contain; }
|
||||
.sheet.active { display: block; }
|
||||
/* 详情 / 资源浮层 —— 满幅海报英雄区(参考 HBO/Emby)
|
||||
打开/关闭走苹果风格的弹性过渡:sheet 本身只负责整体淡入淡出 + 可见性开关
|
||||
(不能给 .sheet 自己加 transform,它内部 .ed-hero 是 position:fixed 贴合视口的背景层,
|
||||
一旦祖先元素有了 transform 就会变成新的包含块,.ed-hero 会跟着祖先一起动而不是贴着视口,
|
||||
动画过程中背景会明显错位跳动);.ed-hero 自己也只淡入淡出(opacity),不能再给它加
|
||||
transform —— 它的子层 .ed-hero-blend 用了 backdrop-filter 模糊,只要“背景模糊层的祖先”
|
||||
在做 transform 动画,模糊层的合成边界每帧都要重新采样,很容易撑不住,表现为开合时闪几下
|
||||
白块、甚至整个过渡看着像被跳过、瞬间完成。真正需要放大回弹手感的,放到 .ed-hero-imgs
|
||||
(纯图片层,没有 backdrop-filter 后代)上就安全了;.ed-body 同理没有 fixed/模糊后代,
|
||||
可以放心加 transform。 */
|
||||
.sheet { position: fixed; top: 0; right: 0; bottom: 0; left: 0; inset: 0; z-index: 60; background: transparent; overflow-y: auto; -webkit-overflow-scrolling: touch; overscroll-behavior-y: contain;
|
||||
visibility: hidden; opacity: 0; pointer-events: none;
|
||||
transition: opacity .22s ease, visibility 0s linear .3s; }
|
||||
.sheet.active { visibility: visible; opacity: 1; pointer-events: auto; transition: opacity .26s ease; }
|
||||
.sheet .ed-hero { opacity: 0; transition: opacity .3s ease; }
|
||||
.sheet.active .ed-hero { opacity: 1; transition: opacity .34s ease; }
|
||||
.sheet .ed-hero-imgs { transform: scale(1.05); transition: transform .3s ease; }
|
||||
.sheet.active .ed-hero-imgs { transform: scale(1); transition: transform .5s cubic-bezier(.22, 1, .36, 1); }
|
||||
.sheet .ed-body { transform: translateY(26px); transition: transform .26s ease; }
|
||||
.sheet.active .ed-body { transform: translateY(0); transition: transform .48s cubic-bezier(.34, 1.56, .64, 1); }
|
||||
body.detail-active .app { visibility: hidden; pointer-events: none; }
|
||||
body.detail-active { background: var(--sheet-bg); }
|
||||
body.detail-active { background: var(--ed-bg, var(--sheet-bg)); transition: background-color 1.1s ease; }
|
||||
|
||||
.ed-hero { position: fixed; inset: 0; z-index: -2; overflow: hidden; background: #070b1a; }
|
||||
.ed-hero img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; object-position: center 18%;
|
||||
transform: scale(1.06); transition: filter .2s linear;
|
||||
animation: heroIn 1.1s cubic-bezier(.2,.7,.2,1) forwards, heroBreathe 11s ease-in-out 1.1s infinite; }
|
||||
.ed-hero { position: fixed; inset: 0; z-index: -2; overflow: hidden; background: var(--ed-bg, #070b1a); transform: translateZ(0); backface-visibility: hidden; }
|
||||
.ed-hero-imgs { position: absolute; inset: 0; }
|
||||
.ed-hero-imgs img { position: absolute; inset: -1px; width: calc(100% + 2px); height: calc(100% + 2px); object-fit: cover; object-position: center 18%;
|
||||
transform: scale(1.06) translateZ(0); backface-visibility: hidden; opacity: 0; transition: opacity 1.1s ease, filter .2s linear; }
|
||||
.ed-hero-imgs img.active { opacity: 1; }
|
||||
.ed-hero-imgs img.active.first-in { animation: heroIn 1.1s cubic-bezier(.2,.7,.2,1) forwards, heroBreathe 11s ease-in-out 1.1s infinite; }
|
||||
.ed-hero-imgs img.active:not(.first-in) { animation: heroBreathe 11s ease-in-out infinite; }
|
||||
@keyframes heroIn { from { transform: scale(1.14); opacity: 0; } to { transform: scale(1.06); opacity: 1; } }
|
||||
@keyframes heroBreathe { 0%, 100% { transform: scale(1.06); } 50% { transform: scale(1.12); } }
|
||||
@media (prefers-reduced-motion: reduce) { .ed-hero img { animation: heroIn 1.1s cubic-bezier(.2,.7,.2,1) forwards; } }
|
||||
.ed-hero.contain img { object-fit: contain; object-position: center top; }
|
||||
.ed-hero-fade { position: absolute; inset: 0; z-index: 1; pointer-events: none;
|
||||
background: radial-gradient(120% 85% at 50% 6%, rgba(0,0,0,0) 0%, rgba(0,0,0,.14) 30%, rgba(0,0,0,.45) 45%, rgba(6,5,10,.85) 58%, var(--sheet-bg) 68%); }
|
||||
@media (prefers-reduced-motion: reduce) { .ed-hero-imgs img.active, .ed-hero-imgs img.active.first-in { animation: none; } }
|
||||
.ed-hero.contain .ed-hero-imgs img { object-fit: contain; object-position: center top; }
|
||||
/* 贴底那截跟首页轮播同一套思路:颜色不再写死 rgba(6,5,10,.85),改用当前海报自动取色算出来的
|
||||
--ed-bg-rgb / --ed-bg,让详情页背景也跟着每张海报的主色走,不再是固定藏青色兜底。
|
||||
没算出颜色前用 6,5,10 / var(--sheet-bg) 兜底,跟改之前效果一致,不会有空档。
|
||||
之前这里用 radial-gradient(椭圆,中心在顶部)来同时兼顾顶部轻微压暗和底部融合,
|
||||
代价是椭圆形状离中心越远(也就是画面两侧靠下的角落)衰减越快,中间又相对"漏"得更久,
|
||||
肉眼看上去底部是一个"U 型"的糊/暗区,跟首页轮播那种不分左右、只看高度的"平行"糊
|
||||
完全是两套观感,而且这一层本身根本没用 backdrop-filter,只是纯色渐变压暗,压根没有模糊。
|
||||
这里拆成两层,跟首页 .hero-shade / .hero-blend 的结构一一对应:
|
||||
ed-hero-shade 只负责顶部一丝丝压暗(不模糊,纯黑到透明,跟首页 hero-shade 的顶部渐变同理),
|
||||
ed-hero-blend 负责底部——真正用 backdrop-filter 把背后的海报本身模糊掉,再叠一层从透明到
|
||||
实色的动态色渐变,且渐变方向是 to bottom(水平等高线,不分左右),这样详情页底部就和
|
||||
首页轮播的底部融合是同一种"平行模糊",不再是圆形衰减出来的 U 型效果。 */
|
||||
.ed-hero-shade { position: absolute; inset: 0; z-index: 1; pointer-events: none;
|
||||
background: linear-gradient(to bottom, rgba(0,0,0,.32) 0%, rgba(0,0,0,.08) 18%, transparent 30%); }
|
||||
/* 底部融合改成"渐变模糊":单层 backdrop-filter 的模糊强度是均匀的,看着就是一整块糊。
|
||||
这里叠 3 层,每层模糊强度不同、且用 mask 限制在不同高度带——顶部只有最轻的一层生效,
|
||||
越往下越重的层依次叠加,于是模糊从上到下由轻到重平滑过渡(顶部几乎不糊、贴底最糊)。
|
||||
色彩渐变(把底部染成背景色、和下方页面连成一片)单独放在 .ed-hero-blend 自己身上。 */
|
||||
.ed-hero-blend {
|
||||
position: absolute; left: -1px; right: -1px; bottom: -1px; height: 72%; z-index: 1; pointer-events: none;
|
||||
transform: translateZ(0); backface-visibility: hidden;
|
||||
background: linear-gradient(to bottom,
|
||||
rgba(var(--ed-bg-rgb, 6,5,10), 0) 0%,
|
||||
rgba(var(--ed-bg-rgb, 6,5,10), .18) 22%,
|
||||
rgba(var(--ed-bg-rgb, 6,5,10), .5) 40%,
|
||||
rgba(var(--ed-bg-rgb, 6,5,10), .84) 56%,
|
||||
rgba(var(--ed-bg-rgb, 6,5,10), 1) 70%,
|
||||
rgba(var(--ed-bg-rgb, 6,5,10), 1) 100%);
|
||||
}
|
||||
/* 三层模糊:轻(顶)、中、重(底),各自 mask 到不同区间 */
|
||||
.ed-hero-blend::before, .ed-hero-blend::after, .ed-blur-mid {
|
||||
content: ""; position: absolute; inset: 0; pointer-events: none; transform: translateZ(0); backface-visibility: hidden;
|
||||
}
|
||||
.ed-hero-blend::before { /* 轻模糊,覆盖上半段,从顶部淡入 */
|
||||
-webkit-backdrop-filter: blur(8px) saturate(1.08); backdrop-filter: blur(8px) saturate(1.08);
|
||||
-webkit-mask-image: linear-gradient(to bottom, transparent 0%, rgba(0,0,0,.5) 20%, #000 45%, #000 100%);
|
||||
mask-image: linear-gradient(to bottom, transparent 0%, rgba(0,0,0,.5) 20%, #000 45%, #000 100%);
|
||||
}
|
||||
.ed-blur-mid { /* 中模糊,覆盖中下段 */
|
||||
-webkit-backdrop-filter: blur(20px) saturate(1.1); backdrop-filter: blur(20px) saturate(1.1);
|
||||
-webkit-mask-image: linear-gradient(to bottom, transparent 0%, transparent 32%, rgba(0,0,0,.6) 50%, #000 66%, #000 100%);
|
||||
mask-image: linear-gradient(to bottom, transparent 0%, transparent 32%, rgba(0,0,0,.6) 50%, #000 66%, #000 100%);
|
||||
}
|
||||
.ed-hero-blend::after { /* 重模糊,只在贴底那段 */
|
||||
-webkit-backdrop-filter: blur(44px) saturate(1.14); backdrop-filter: blur(44px) saturate(1.14);
|
||||
-webkit-mask-image: linear-gradient(to bottom, transparent 0%, transparent 55%, rgba(0,0,0,.6) 72%, #000 85%);
|
||||
mask-image: linear-gradient(to bottom, transparent 0%, transparent 55%, rgba(0,0,0,.6) 72%, #000 85%);
|
||||
}
|
||||
.ed-hero-spacer { width: 100%; height: 62vh; min-height: 380px; }
|
||||
@media (orientation: landscape) { .ed-hero-spacer { height: 75vh; min-height: 320px; } }
|
||||
/* 横屏时屏幕更矮更宽,原来 72% 高度的模糊区域相对占比更大,容易糊到人脸。
|
||||
这里单独把横屏下的模糊层调矮一些、往下移,渐变/遮罩的比例是跟着这个高度百分比自动收缩的,
|
||||
所以只是模糊起始的位置下移了,不影响下面页面信息(spacer 高度没变,内容位置不动)。 */
|
||||
@media (orientation: landscape) { .ed-hero-blend { height: 55%; } }
|
||||
|
||||
.ed-back { position: fixed; top: calc(var(--safe-top) + 8px); left: 14px; z-index: 3;
|
||||
width: 40px; height: 40px; border-radius: 50%; font-size: 24px; line-height: 1;
|
||||
@@ -271,7 +739,7 @@ body.detail-active { background: var(--sheet-bg); }
|
||||
.ed-play .tri { font-size: 13px; margin-right: 10px; }
|
||||
.ed-play:active { transform: scale(.99) translateZ(0); }
|
||||
.ed-play[disabled] { opacity: .55; }
|
||||
.tv-mode .ed-play.focus, .tv-mode .ed-play:focus { box-shadow: 0 0 0 3px var(--accent), 0 0 0 6px var(--btn-shadow); }
|
||||
.tv-mode .ed-play.focus, .tv-mode .ed-play:focus { transform: scale(1.05); }
|
||||
|
||||
.ed-actions { display: flex; justify-content: center; align-items: center; margin: 8px 0 2px; }
|
||||
.ed-ico { display: flex; align-items: center; justify-content: center; width: 44px; height: 40px; margin: 0 21px; font-size: 20px; color: var(--ink-dim);
|
||||
@@ -279,37 +747,45 @@ body.detail-active { background: var(--sheet-bg); }
|
||||
.ed-ico svg { width: 20px; height: 20px; display: block; }
|
||||
.ed-ico.on { color: #ff5d8f; }
|
||||
.ed-ico:active { transform: scale(.88); }
|
||||
.tv-mode .ed-ico.focus, .tv-mode .ed-ico:focus { color: var(--accent); }
|
||||
.tv-mode .ed-ico.focus, .tv-mode .ed-ico:focus { transform: scale(1.2); }
|
||||
|
||||
.overview { margin: 8px 2px 2px; font-size: 14px; color: var(--ink-dim); line-height: 1.7; text-align: left; align-self: stretch; position: relative; }
|
||||
.overview.clamp { display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
/* 横版详情页简介也改居中,跟标题/元信息等其它横版居中的区块保持一致 */
|
||||
@media (orientation: landscape) { .overview { text-align: center; } }
|
||||
|
||||
/* 演职员(TMDB) */
|
||||
.ed-cast { align-self: stretch; margin: 10px 0 0; }
|
||||
.ed-cast:empty { display: none; }
|
||||
.ed-cast-head { font-size: 15px; font-weight: 700; margin: 0 0 9px; }
|
||||
.ed-cast-row { display: flex; align-items: flex-start; overflow-x: auto; white-space: nowrap; -webkit-overflow-scrolling: touch; scrollbar-width: none; padding: 4px 3px; margin: 0 -3px; }
|
||||
.ed-cast-row { display: flex; align-items: stretch; overflow-x: auto; white-space: nowrap; -webkit-overflow-scrolling: touch; scrollbar-width: none; padding: 4px 18px; margin: 0 -18px; }
|
||||
.ed-cast-row::-webkit-scrollbar { display: none; }
|
||||
.cast-cell { flex: 0 0 auto; width: 66px; margin-right: 12px; text-align: center; }
|
||||
.cast-av { width: 66px; height: 66px; border-radius: 50%; overflow: hidden; background: var(--panel-soft); border: 1px solid var(--line); position: relative; }
|
||||
.cast-av.tappable { cursor: pointer; }
|
||||
.cast-av.sel { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent); }
|
||||
.cast-cell { flex: 0 0 auto; max-width: 240px; margin-right: 12px; display: flex; align-items: center; gap: 11px; text-align: left; background: transparent; border: 1px solid var(--line); border-radius: 999px; padding: 2px 18px 2px 6px; }
|
||||
.cast-av { flex: 0 0 auto; width: 48px; height: 48px; border-radius: 50%; overflow: hidden; background: var(--panel-soft); border: none; position: relative; }
|
||||
.cast-cell.tappable { cursor: pointer; }
|
||||
.cast-cell.sel { border-color: var(--accent); animation: castSelBreathe 2.2s ease-in-out infinite; }
|
||||
@keyframes castSelBreathe {
|
||||
0%, 100% { box-shadow: 0 0 0 1px var(--accent-soft), inset 0 0 10px 0 var(--accent-soft); }
|
||||
50% { box-shadow: 0 0 0 1px var(--accent-line), inset 0 0 16px 2px var(--accent-soft); }
|
||||
}
|
||||
.cast-av img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.cast-av.ph { display: flex; align-items: center; justify-content: center; color: var(--ink-faint); font-size: 24px; font-weight: 800; }
|
||||
.cast-name { margin-top: 6px; font-size: 11.5px; line-height: 1.25; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.cast-role { margin-top: 1px; font-size: 10.5px; color: var(--ink-faint); white-space: normal; display: -webkit-box; -webkit-line-clamp: 1; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.cast-av.ph { display: flex; align-items: center; justify-content: center; color: var(--ink-faint); font-size: 20px; font-weight: 800; }
|
||||
.cast-info { min-width: 0; flex: 1 1 auto; }
|
||||
.cast-name { font-size: 13.5px; font-weight: 700; line-height: 1.3; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.cast-orig { margin-top: 2px; font-size: 11px; color: var(--ink-faint); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.cast-role { margin-top: 2px; font-size: 11px; color: var(--ink-faint); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
/* 演员相关作品(展开横排) */
|
||||
.ed-works { align-self: stretch; }
|
||||
.ed-works:empty { display: none; }
|
||||
.ed-works-head { font-size: 13px; font-weight: 700; color: var(--accent); margin: 14px 0 9px; }
|
||||
.ed-works-row { display: flex; align-items: flex-start; overflow-x: auto; white-space: nowrap; -webkit-overflow-scrolling: touch; scrollbar-width: none; }
|
||||
.ed-works-row { display: flex; align-items: flex-start; overflow-x: auto; white-space: nowrap; -webkit-overflow-scrolling: touch; scrollbar-width: none; padding: 0 18px; margin: 0 -18px; }
|
||||
.ed-works-row::-webkit-scrollbar { display: none; }
|
||||
.work-cell { flex: 0 0 auto; width: 104px; margin-right: 11px; text-align: left; }
|
||||
.work-poster { position: relative; width: 104px; height: 156px; border-radius: 10px; overflow: hidden; background: var(--panel-soft); border: 1px solid var(--line); box-shadow: 0 6px 18px rgba(8,24,64,.45); }
|
||||
.work-poster img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.work-title { margin-top: 7px; font-size: 12px; line-height: 1.3; color: var(--ink-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.tv-mode .work-cell.focus .work-poster { box-shadow: 0 0 0 2px var(--accent); }
|
||||
.tv-mode .work-cell.focus .work-poster { transform: scale(1.06); }
|
||||
|
||||
/* 搜索联想下拉 */
|
||||
.search-box { position: relative; }
|
||||
@@ -330,7 +806,7 @@ body.detail-active { background: var(--sheet-bg); }
|
||||
.ed-qtab { flex: 0 0 auto; white-space: nowrap; margin: 0 6px 0 0; padding: 4px 11px; border-radius: 8px; font-size: 12px; font-weight: 600;
|
||||
background: rgba(255,255,255,.06); border: 1px solid var(--line); color: var(--ink-dim); }
|
||||
.ed-qtab.active { background: var(--grad); color: var(--accent-ink); border-color: transparent; }
|
||||
.tv-mode .ed-qtab.focus, .tv-mode .ed-qtab:focus { box-shadow: 0 0 0 2px var(--accent) inset; }
|
||||
.tv-mode .ed-qtab.focus, .tv-mode .ed-qtab:focus { transform: scale(1.08); }
|
||||
|
||||
.pan-head { display: flex; align-items: center; align-self: stretch; margin: 14px 0 8px; }
|
||||
.pan-head .title { font-size: 15px; font-weight: 700; flex: 0 0 auto; }
|
||||
@@ -338,15 +814,25 @@ body.detail-active { background: var(--sheet-bg); }
|
||||
.pan-list { align-self: stretch; min-height: 40px; }
|
||||
.btn { padding: 11px 24px; border-radius: 11px; font-size: 14px; font-weight: 700; background: var(--panel-soft); border: 1px solid var(--line); color: var(--ink); text-align: center; }
|
||||
.btn.primary { background: var(--grad); border-color: transparent; color: var(--accent-ink); box-shadow: 0 4px 16px var(--btn-shadow); }
|
||||
.btn.danger { background: var(--bad, #ff5c66); border-color: transparent; color: #fff; }
|
||||
.confirm-sheet { max-width: 320px; text-align: center; }
|
||||
.confirm-title { margin: 0; font-size: 17px; color: var(--ink); }
|
||||
.confirm-text { color: var(--ink-dim); font-size: 14px; line-height: 1.6; margin: 8px 0 0; }
|
||||
.btn:active { transform: scale(.985); }
|
||||
.tv-mode .btn.focus, .tv-mode .btn:focus { box-shadow: 0 0 0 2px var(--accent); }
|
||||
.tv-mode .btn.focus, .tv-mode .btn:focus { transform: scale(1.05); }
|
||||
|
||||
.pan-head { display: flex; align-items: center; margin: 14px 0 8px; }
|
||||
.pan-head .title { font-size: 15px; font-weight: 700; flex: 0 0 auto; }
|
||||
.pan-head .sub { margin-left: 8px; font-size: 11px; color: var(--ink-faint); }
|
||||
.pan-list { min-height: 60px; }
|
||||
.pan-item { display: flex; align-items: center; padding: 11px 12px; margin-bottom: 8px; background: var(--panel-soft); border: 1px solid var(--line); border-radius: 10px; }
|
||||
.tv-mode .pan-item.focus, .tv-mode .pan-item:focus { background: var(--accent-soft); box-shadow: 0 0 0 1px var(--accent) inset; }
|
||||
.pan-list { min-height: 60px; max-height: 480px; overflow-y: auto; overscroll-behavior: contain; -webkit-overflow-scrolling: touch;
|
||||
border: 1px solid var(--line); border-radius: 12px; padding: 8px 6px 8px 8px; background: rgba(255,255,255,.02); }
|
||||
.pan-list::-webkit-scrollbar { width: 4px; }
|
||||
.pan-list::-webkit-scrollbar-track { background: transparent; }
|
||||
.pan-list::-webkit-scrollbar-thumb { background: var(--line-strong); border-radius: 4px; }
|
||||
.pan-item { display: flex; align-items: center; padding: 11px 12px; margin-bottom: 8px; background: rgba(var(--panel-rgb), .13); border: 1px solid var(--line); border-radius: 10px; cursor: pointer; -webkit-tap-highlight-color: transparent; }
|
||||
.pan-item:last-child { margin-bottom: 0; }
|
||||
.pan-item:active { background: rgba(var(--panel-rgb), .26); }
|
||||
.tv-mode .pan-item.focus, .tv-mode .pan-item:focus { transform: scale(1.03); }
|
||||
.pan-dot { flex: 0 0 auto; width: 9px; height: 9px; border-radius: 50%; margin-right: 10px; background: var(--gray); }
|
||||
.pan-dot.ok { background: var(--ok); } .pan-dot.bad { background: var(--bad); }
|
||||
.pan-dot.locked { background: var(--lock); } .pan-dot.uncertain, .pan-dot.unsupported { background: var(--gray); }
|
||||
@@ -356,21 +842,102 @@ body.detail-active { background: var(--sheet-bg); }
|
||||
.pan-name { font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.pan-sub { font-size: 11px; color: var(--ink-faint); margin-top: 2px; }
|
||||
.pan-sub .q { color: #9cc7ff; margin-right: 6px; }
|
||||
.pan-play { flex: 0 0 auto; margin-left: 10px; padding: 7px 14px; border-radius: 8px; background: var(--grad); color: var(--accent-ink); font-weight: 700; font-size: 12px; }
|
||||
.pan-sub .site-tag { display: inline-block; padding: 0 6px; margin-right: 6px; border-radius: 5px; border: 1px solid; font-size: 10px; font-weight: 800; line-height: 16px; vertical-align: 1px; }
|
||||
.pan-sub .q-tag { display: inline-block; padding: 0 5px; margin-right: 4px; border-radius: 5px; border: 1px solid; font-size: 10px; line-height: 15px; vertical-align: 1px; font-weight: 700; }
|
||||
.pan-sub .pwd { margin-right: 6px; }
|
||||
.pan-sub .st { color: var(--ink-faint); }
|
||||
.pan-play { flex: 0 0 auto; margin-left: 10px; width: 26px; height: 26px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 15px; color: var(--accent); }
|
||||
.pan-empty, .pan-loading { padding: 22px 14px; text-align: center; color: var(--ink-faint); font-size: 13px; }
|
||||
/* 详情页骨架屏:概览 / 演员 / 网盘资源在数据到手前先铺占位条 + 流光动效,避免长时间空白或干等文案。
|
||||
三处都直接把骨架节点塞进真实容器里(#detailOverview / #edCast / #panList),容器本身没有独立文本节点,
|
||||
等真实内容用 textContent= 或 replaceChildren(...) 覆盖时,骨架节点自然被替换掉,不用额外维护显隐状态。 */
|
||||
.detail-skel-bar { border-radius: 5px; background: rgba(255,255,255,.07);
|
||||
background-image: linear-gradient(100deg, rgba(255,255,255,.05) 30%, rgba(255,255,255,.15) 50%, rgba(255,255,255,.05) 70%);
|
||||
background-size: 420px 100%; animation: skelShimmer 1.3s linear infinite; }
|
||||
.ov-skel { display: flex; flex-direction: column; gap: 8px; padding: 2px 0 4px; }
|
||||
.ov-skel .detail-skel-bar { height: 13px; }
|
||||
.ov-skel .detail-skel-bar:nth-child(1) { width: 97%; }
|
||||
.ov-skel .detail-skel-bar:nth-child(2) { width: 89%; }
|
||||
.ov-skel .detail-skel-bar:nth-child(3) { width: 58%; }
|
||||
.cast-skel { display: flex; gap: 14px; overflow: hidden; padding: 4px 18px; margin: 0 -18px; }
|
||||
.cast-skel-item { flex: 0 0 auto; width: 54px; display: flex; flex-direction: column; align-items: center; gap: 7px; }
|
||||
.cast-skel-item .detail-skel-bar.av { width: 52px; height: 52px; border-radius: 50%; }
|
||||
.cast-skel-item .detail-skel-bar.nm { width: 40px; height: 9px; }
|
||||
.pan-skel { display: flex; flex-direction: column; gap: 12px; padding: 14px 2px; }
|
||||
.pan-skel-item { display: flex; align-items: center; gap: 11px; }
|
||||
.pan-skel-item .detail-skel-bar.ic { width: 34px; height: 34px; border-radius: 9px; flex: 0 0 auto; }
|
||||
.pan-skel-body { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 6px; }
|
||||
.pan-skel-body .detail-skel-bar.t1 { width: 76%; height: 12px; }
|
||||
.pan-skel-body .detail-skel-bar.t2 { width: 42%; height: 10px; }
|
||||
/* 详情页大海报:图还没到手时(站内无海报 / TMDB 图集没回来)铺一块流光占位,不留纯黑/纯色空屏 */
|
||||
.ed-hero-skel { position: absolute; inset: 0;
|
||||
background-image: linear-gradient(100deg, rgba(255,255,255,.03) 30%, rgba(255,255,255,.09) 50%, rgba(255,255,255,.03) 70%);
|
||||
background-size: 420px 100%; animation: skelShimmer 1.3s linear infinite; }
|
||||
/* 「剧集」单集卡骨架:跟 .ep-tcard 同款尺寸(148px 宽 + 16:9 缩略图 + 两行标题),分季/翻季时先垫上 */
|
||||
.ep-skel-rail { display: flex; gap: 12px; overflow: hidden; padding: 4px 18px 6px; margin: 0 -18px; }
|
||||
.ep-skel-card { flex: 0 0 auto; width: 176px; }
|
||||
.ep-skel-card .detail-skel-bar.thumb { width: 176px; aspect-ratio: 16/9; border-radius: 10px; }
|
||||
.ep-skel-card .detail-skel-bar.line1 { width: 92%; height: 11px; margin-top: 8px; }
|
||||
.ep-skel-card .detail-skel-bar.line2 { width: 58%; height: 11px; margin-top: 6px; }
|
||||
.pan-item.pan-hit { animation: panHitFlash 1.6s ease; }
|
||||
@keyframes panHitFlash {
|
||||
0% { background: var(--accent-soft); box-shadow: 0 0 0 2px var(--accent) inset; }
|
||||
70% { background: var(--accent-soft); box-shadow: 0 0 0 2px var(--accent) inset; }
|
||||
100% { background: rgba(var(--panel-rgb), .13); box-shadow: none; }
|
||||
}
|
||||
|
||||
/* 集数网格(日历式格子):枫叶这类"第N集"线路用它,方便快速点选 */
|
||||
.ep-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(72px, 1fr)); gap: 8px; padding: 2px; }
|
||||
.ep-cell { position: relative; display: flex; align-items: center; justify-content: center; min-height: 46px; padding: 8px 6px; border-radius: 10px; background: rgba(var(--panel-rgb), .16); border: 1px solid var(--line); color: var(--ink); font-size: 14px; font-weight: 600; text-align: center; line-height: 1.15; cursor: pointer; -webkit-tap-highlight-color: transparent; transition: transform .06s ease; }
|
||||
.ep-cell:active { transform: scale(.94); }
|
||||
.ep-cell.playing { background: var(--grad); color: var(--accent-ink); border-color: transparent; }
|
||||
.ep-cell.resolving { color: var(--accent); border-color: var(--accent); }
|
||||
.ep-cell.bad { opacity: .45; }
|
||||
.ep-cell .ep-dot { position: absolute; top: 5px; right: 6px; width: 6px; height: 6px; border-radius: 50%; background: transparent; }
|
||||
.ep-cell .ep-dot.ok { background: var(--ok); } .ep-cell .ep-dot.bad { background: var(--bad); } .ep-cell .ep-dot.checking { background: var(--accent); animation: pulse 1s infinite; }
|
||||
.tv-mode .ep-cell.focus, .tv-mode .ep-cell:focus { transform: scale(1.08); }
|
||||
|
||||
/* 详情页「剧集」区:TMDB 分季 + 单集卡(类似 Emby),放在简介和演职人员之间 */
|
||||
.ed-episodes { align-self: stretch; margin-top: 4px; }
|
||||
.ed-episodes:empty { display: none; }
|
||||
.ed-episodes-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin: 14px 0 9px; }
|
||||
.ed-episodes-head .ed-works-head { margin: 0; flex: 0 0 auto; font-size: 15px; color: var(--ink); }
|
||||
.ep-season-tabs { display: flex; flex: 1 1 auto; min-width: 0; gap: 6px; overflow-x: auto; -webkit-overflow-scrolling: touch; scrollbar-width: none; justify-content: flex-start; }
|
||||
.ep-season-tabs::-webkit-scrollbar { display: none; }
|
||||
.ep-season-chip { flex: 0 0 auto; white-space: nowrap; padding: 5px 13px; border-radius: 999px; font-size: 12px; font-weight: 700;
|
||||
background: rgba(255,255,255,.06); border: 1px solid var(--line); color: var(--ink-dim); }
|
||||
.ep-season-chip.active { background: var(--grad); color: var(--accent-ink); border-color: transparent; }
|
||||
.tv-mode .ep-season-chip.focus, .tv-mode .ep-season-chip:focus { transform: scale(1.08); }
|
||||
.ep-rail { display: flex; align-items: flex-start; gap: 12px; overflow-x: auto; white-space: nowrap; -webkit-overflow-scrolling: touch; scrollbar-width: none; padding: 4px 18px 6px; margin: 0 -18px; }
|
||||
.ep-rail::-webkit-scrollbar { display: none; }
|
||||
.ep-tcard { flex: 0 0 auto; width: 176px; white-space: normal; cursor: pointer; -webkit-tap-highlight-color: transparent; }
|
||||
.ep-tcard:active { transform: scale(.98); }
|
||||
.ep-tcard-img { position: relative; width: 176px; aspect-ratio: 16/9; border-radius: 10px; overflow: hidden; background: var(--panel-soft); border: 1px solid var(--line); }
|
||||
.no-aspect-ratio .ep-tcard-img { height: 83px; }
|
||||
.ep-tcard-img img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.ep-tcard-num { position: absolute; left: 6px; bottom: 6px; padding: 1px 7px; border-radius: 6px; background: rgba(0,0,0,.6); color: #fff; font-size: 10.5px; font-weight: 700; }
|
||||
.ep-tcard-title { margin-top: 6px; font-size: 12px; line-height: 1.35; color: var(--ink-dim); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.tv-mode .ep-tcard.focus .ep-tcard-img, .tv-mode .ep-tcard:focus .ep-tcard-img { transform: scale(1.06); }
|
||||
.ep-loading { padding: 14px 4px; color: var(--ink-faint); font-size: 13px; white-space: normal; }
|
||||
|
||||
/* 设置 —— 居中独立弹出卡片 */
|
||||
.panel-overlay { position: fixed; top: 0; right: 0; bottom: 0; left: 0; inset: 0; z-index: 80; display: none; align-items: center; justify-content: center; padding: 24px calc(16px + var(--safe-right)) calc(16px + var(--safe-bottom)) calc(16px + var(--safe-left)); background: rgba(8,11,15,.6); }
|
||||
.panel-overlay.active { display: flex; }
|
||||
.panel-sheet { position: relative; width: 100%; max-width: 460px; max-height: 100%; background: var(--panel-strong); border: 1px solid var(--line); border-radius: 18px; overflow-y: auto; padding: 18px 16px calc(var(--safe-bottom) + 14px); box-shadow: 0 24px 64px rgba(0,0,0,.5); }
|
||||
/* 弹窗打开/关闭改成苹果风格的弹性缩放:遮罩只做淡入淡出,.panel-sheet 本身带一点
|
||||
过冲的回弹(cubic-bezier 峰值超过 1 再回落),比原来的 display none/flex 硬切换更跟手。 */
|
||||
.panel-overlay { position: fixed; top: 0; right: 0; bottom: 0; left: 0; inset: 0; z-index: 80; display: flex; align-items: center; justify-content: center; padding: 24px calc(16px + var(--safe-right)) calc(16px + var(--safe-bottom)) calc(16px + var(--safe-left)); background: rgba(8,11,15,.6);
|
||||
visibility: hidden; opacity: 0; pointer-events: none;
|
||||
transition: opacity .2s ease, visibility 0s linear .26s; }
|
||||
.panel-overlay.active { visibility: visible; opacity: 1; pointer-events: auto; transition: opacity .22s ease; }
|
||||
.panel-sheet { position: relative; width: 100%; max-width: 460px; max-height: 100%; background: var(--panel-strong); border: 1px solid var(--line); border-radius: 18px; overflow-y: auto; padding: 18px 16px calc(var(--safe-bottom) + 14px); box-shadow: 0 24px 64px rgba(0,0,0,.5);
|
||||
transform: translateY(18px) scale(.94); transition: transform .24s ease; }
|
||||
.panel-overlay.active .panel-sheet { transform: translateY(0) scale(1); transition: transform .46s cubic-bezier(.34, 1.56, .64, 1); }
|
||||
.panel-close { position: absolute; top: 14px; right: 14px; width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; border-radius: 999px; color: var(--ink-dim); font-size: 16px; line-height: 1; background: rgba(255,255,255,.06); }
|
||||
.panel-close:active { transform: scale(.88); }
|
||||
.tv-mode .panel-close.focus, .tv-mode .panel-close:focus { box-shadow: 0 0 0 2px var(--accent) inset; background: var(--accent-soft); color: var(--ink); }
|
||||
.set-hd { display: flex; align-items: center; margin-bottom: 16px; padding-right: 30px; }
|
||||
.set-mark { flex: 0 0 auto; width: 44px; height: 44px; border-radius: 12px; display: flex; align-items: center; justify-content: center; background: var(--accent-soft); border: 1px solid var(--accent-line); margin-right: 12px; }
|
||||
.set-hd h3 { margin: 0; font-size: 18px; }
|
||||
.set-hd .desc { margin: 2px 0 0; font-size: 11.5px; color: var(--ink-faint); }
|
||||
.set-group { background: rgba(255,255,255,.03); border: 1px solid var(--line); border-radius: 13px; padding: 12px 12px 4px; margin-bottom: 11px; }
|
||||
.tv-mode .panel-close.focus, .tv-mode .panel-close:focus { transform: scale(1.15); }
|
||||
.set-hd { display: flex; align-items: center; margin-bottom: 12px; padding-right: 30px; }
|
||||
.set-hd h3 { margin: 0; font-size: 17px; color: var(--ink); }
|
||||
.set-group { background: rgba(255,255,255,.03); border: 1px solid var(--line); border-radius: 13px; padding: 12px 12px 4px; margin-bottom: 10px; }
|
||||
.set-group-title { font-size: 11px; font-weight: 800; letter-spacing: .1em; color: var(--accent); margin-bottom: 10px; text-transform: uppercase; }
|
||||
.set-2col { display: flex; margin: 0 -5px; }
|
||||
.set-2col .field { flex: 1 1 0; min-width: 0; margin: 0 5px 10px; }
|
||||
@@ -379,10 +946,13 @@ body.detail-active { background: var(--sheet-bg); }
|
||||
.field label .hint { color: var(--ink-faint); font-size: 10.5px; font-weight: 400; }
|
||||
.field input { width: 100%; box-sizing: border-box; padding: 10px 12px; border-radius: 9px; background: rgba(0,0,0,.28); border: 1px solid var(--line); outline: none; color: var(--ink); font-size: 13px; }
|
||||
.field input:focus, .field input.focus { border-color: var(--accent); }
|
||||
.pan-type-wrap { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.pan-type-chip { flex: 0 0 auto; padding: 6px 13px; border-radius: 999px; font-size: 12.5px; font-weight: 600; border: 1px solid var(--line); background: rgba(255,255,255,.04); color: var(--ink-faint); }
|
||||
.pan-type-chip.on { background: var(--accent-soft); border-color: var(--accent-line); color: var(--ink); }
|
||||
.tv-mode .pan-type-chip.focus, .tv-mode .pan-type-chip:focus { transform: scale(1.08); }
|
||||
.toggle-row { display: flex; align-items: center; justify-content: space-between; padding: 4px 2px 8px; }
|
||||
.toggle-row .lbl { font-size: 13.5px; }
|
||||
.toggle-row .lbl small { display: block; font-size: 11px; color: var(--ink-faint); margin-top: 2px; }
|
||||
.set-status { padding-bottom: 8px; }
|
||||
.login-row { display: flex; align-items: center; gap: 10px; margin: 2px 0 10px; }
|
||||
.btn.small { flex: 0 0 auto; padding: 8px 16px; font-size: 13px; border-radius: 9px; }
|
||||
.login-state { font-size: 11.5px; color: var(--ink-faint); min-width: 0; }
|
||||
@@ -393,24 +963,36 @@ body.detail-active { background: var(--sheet-bg); }
|
||||
.switch.on { background: var(--grad); }
|
||||
/* 动态流光:主色渐变缓慢流动(约 8s 一轮,蓝调) */
|
||||
@keyframes gradFlow { 0% { background-position: 0% 50%; } 50% { background-position: 100% 50%; } 100% { background-position: 0% 50%; } }
|
||||
.chip.active, .ed-play, .ed-qtab.active, .btn.primary, .pan-play, .switch.on {
|
||||
.ed-play, .ed-qtab.active, .btn.primary, .switch.on {
|
||||
background-size: 220% 220%;
|
||||
animation: gradFlow 8s ease-in-out infinite;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.chip.active, .ed-play, .ed-qtab.active, .btn.primary, .pan-play, .switch.on { animation: none; }
|
||||
.ed-play, .ed-qtab.active, .btn.primary, .switch.on { animation: none; }
|
||||
}
|
||||
.switch .knob { position: absolute; top: 3px; left: 3px; width: 20px; height: 20px; border-radius: 50%; background: #fff; transition: left .15s; }
|
||||
.switch.on .knob { left: 23px; }
|
||||
.panel-actions { display: flex; justify-content: center; margin-top: 16px; }
|
||||
.panel-actions .btn { min-width: 116px; }
|
||||
.panel-actions .btn + .btn { margin-left: 12px; }
|
||||
.status-line { font-size: 12px; color: var(--ink-faint); margin: 3px 0; }
|
||||
/* ===== 设置页改为「正常页面」而非弹窗:铺满整个视口、顶部对齐、不带半透明遮罩,
|
||||
跟 .sheet(详情页)观感一致,而不是浮在中间的对话框卡片。confirmOverlay 仍保留
|
||||
原来居中弹窗的样式,这里只单独覆盖 #settingsOverlay。 ===== */
|
||||
#settingsOverlay { padding: 0; align-items: stretch; justify-content: stretch; background: var(--sheet-bg); }
|
||||
/* 设置页是"整页"而不是居中卡片,弹入方式跟 .panel-sheet 默认的缩放不一样:
|
||||
改成从底部带过冲地滑上来,跟详情页 sheet 的滑入手感统一。 */
|
||||
#settingsOverlay .panel-sheet { width: 100%; max-width: none; height: 100%; max-height: none; border-radius: 0; border: none; box-shadow: none; background: var(--panel-strong); padding: calc(var(--safe-top) + 18px) calc(16px + var(--safe-right)) calc(var(--safe-bottom) + 18px) calc(16px + var(--safe-left));
|
||||
transform: translateY(5%); }
|
||||
#settingsOverlay.active .panel-sheet { transform: translateY(0); transition: transform .48s cubic-bezier(.34, 1.56, .64, 1); }
|
||||
#settingsOverlay .panel-close { top: calc(var(--safe-top) + 14px); right: calc(var(--safe-right) + 14px); }
|
||||
.set-status { display: grid; grid-template-columns: 1fr 1fr; gap: 4px 10px; padding: 10px 0; }
|
||||
.status-line { font-size: 11.5px; color: var(--ink-faint); margin: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.status-line b { color: var(--ink-dim); font-weight: 600; }
|
||||
.status-line .on { color: var(--ok); } .status-line .off { color: var(--bad); }
|
||||
.divider { height: 1px; background: var(--line); margin: 16px 0; }
|
||||
|
||||
.toast { position: fixed; left: 50%; bottom: calc(var(--safe-bottom) + 22px); transform: translateX(-50%); z-index: 90; max-width: 84%; padding: 10px 16px; border-radius: 999px; background: var(--panel-strong); border: 1px solid var(--line-strong); font-size: 13px; opacity: 0; pointer-events: none; transition: opacity .2s; }
|
||||
.toast { position: fixed; left: 50%; bottom: calc(var(--safe-bottom) + 22px); transform: translateX(-50%); z-index: 90; max-width: 84%; padding: 10px 16px; border-radius: 999px; background: var(--panel-strong); border: 1px solid var(--line-strong); color: var(--ink); font-size: 13px; opacity: 0; pointer-events: none; transition: opacity .2s; }
|
||||
.toast.top { top: calc(var(--safe-top) + 12px); bottom: auto; }
|
||||
.toast.show { opacity: 1; }
|
||||
|
||||
/* 下拉刷新 */
|
||||
@@ -423,69 +1005,117 @@ body.detail-active { background: var(--sheet-bg); }
|
||||
@keyframes ptrspin { from { transform: rotate(0); } to { transform: rotate(360deg); } }
|
||||
|
||||
/* 自适应 */
|
||||
@media (min-width: 560px) { .grid .card { width: calc(25% - 6px); max-width: calc(25% - 6px); } }
|
||||
@media (min-width: 700px) { .grid .card { width: calc(20% - 6px); max-width: calc(20% - 6px); } }
|
||||
@media (min-width: 820px) { .grid .card { width: calc(16.666% - 6px); max-width: calc(16.666% - 6px); } }
|
||||
@media (min-width: 1040px) { .grid .card { width: calc(14.285% - 6px); max-width: calc(14.285% - 6px); } }
|
||||
@media (min-width: 1280px) { .grid .card { width: calc(12.5% - 6px); max-width: calc(12.5% - 6px); } }
|
||||
.tv-mode .grid .card { width: calc(16.666% - 6px); max-width: calc(16.666% - 6px); }
|
||||
@media (min-width: 1600px) { .tv-mode .grid .card { width: calc(12.5% - 6px); max-width: calc(12.5% - 6px); } }
|
||||
@media (min-width: 600px) { .grid { grid-template-columns: repeat(auto-fill, minmax(132px, 1fr)); } }
|
||||
@media (min-width: 1180px) { .grid { grid-template-columns: repeat(auto-fill, minmax(156px, 1fr)); } }
|
||||
.tv-mode .grid .card { width: calc(16.666% - var(--card-gut)); max-width: calc(16.666% - var(--card-gut)); }
|
||||
@media (min-width: 1600px) { .tv-mode .grid .card { width: calc(12.5% - var(--card-gut)); max-width: calc(12.5% - var(--card-gut)); } }
|
||||
.tv-mode .rail .card { width: 150px; }
|
||||
.tv-mode body, .tv-mode .app { font-size: 17px; }
|
||||
.tv-mode .card-title { font-size: 15px; }
|
||||
.tv-mode .section { padding: 20px 40px 0; }
|
||||
.tv-mode .header-row, .tv-mode .chips { padding-left: 40px; padding-right: 40px; }
|
||||
@supports (height: 100dvh) { .app { min-height: 100dvh; min-height: var(--fm-web-height, 100dvh); } }
|
||||
|
||||
/* ===== 主页整页骨架屏(首次加载遮罩,中央显示"宝宝巴士")===== */
|
||||
@keyframes skelShimmer { 0% { background-position: -420px 0; } 100% { background-position: 420px 0; } }
|
||||
@keyframes skelFade { to { opacity: 0; visibility: hidden; } }
|
||||
.home-skeleton { position: absolute; inset: 0; z-index: 30; background: var(--dyn-bg, #0c0b12); padding: 0 var(--gut); overflow: hidden; }
|
||||
.home-skeleton.hiding { animation: skelFade .45s ease forwards; pointer-events: none; }
|
||||
.home-skeleton .sk-hero { width: 100%; height: 46vh; min-height: 260px; max-height: 460px; border-radius: 16px; margin: 8px 0 22px; }
|
||||
.home-skeleton .sk-rowtitle { width: 120px; height: 18px; border-radius: 6px; margin: 18px 0 12px; }
|
||||
.home-skeleton .sk-cards { display: flex; gap: var(--card-gut, 10px); }
|
||||
.home-skeleton .sk-card { flex: 0 0 auto; width: 30vw; max-width: 128px; }
|
||||
.home-skeleton .sk-poster { width: 100%; aspect-ratio: 2/3; border-radius: 12px; }
|
||||
.home-skeleton .sk-line { height: 12px; border-radius: 5px; margin-top: 8px; }
|
||||
.home-skeleton .sk-line.w70 { width: 70%; } .home-skeleton .sk-line.w45 { width: 45%; }
|
||||
.home-skeleton .sk-shimmer { background: linear-gradient(100deg, rgba(255,255,255,.05) 30%, rgba(255,255,255,.12) 50%, rgba(255,255,255,.05) 70%); background-size: 840px 100%; animation: skelShimmer 1.3s linear infinite; }
|
||||
.home-skeleton .sk-brand { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; pointer-events: none; }
|
||||
.home-skeleton .sk-brand-text { font-size: 30px; font-weight: 900; letter-spacing: 4px; background: var(--grad); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; color: transparent; background-size: 220% 220%; animation: gradFlow 5s ease-in-out infinite; text-shadow: 0 2px 20px rgba(0,0,0,.3); }
|
||||
.home-skeleton .sk-brand-sub { margin-top: 10px; font-size: 12px; color: var(--ink-faint); letter-spacing: 2px; }
|
||||
.home-skeleton .sk-brand-dots { margin-top: 14px; display: flex; gap: 6px; }
|
||||
.home-skeleton .sk-brand-dots span { width: 7px; height: 7px; border-radius: 50%; background: var(--accent, #6ea8fe); opacity: .35; animation: skelDotPulse 1.1s ease-in-out infinite; }
|
||||
.home-skeleton .sk-brand-dots span:nth-child(2) { animation-delay: .18s; } .home-skeleton .sk-brand-dots span:nth-child(3) { animation-delay: .36s; }
|
||||
@keyframes skelDotPulse { 0%,100% { opacity: .25; transform: translateY(0); } 50% { opacity: 1; transform: translateY(-4px); } }
|
||||
/* ===== 轮播图骨架屏 ===== */
|
||||
.hero-skeleton { position: relative; width: 100%; height: 58vh; min-height: 340px; max-height: 560px;
|
||||
background: linear-gradient(135deg, rgba(46,166,255,.04) 0%, rgba(22,32,66,.55) 60%, rgba(7,11,26,.7) 100%);
|
||||
overflow: hidden; margin: 0 0 6px; }
|
||||
@media (orientation: landscape) { .hero-skeleton { height: 85vh; min-height: 280px; } }
|
||||
.hero-skeleton::after { content: ""; position: absolute; inset: 0;
|
||||
background: linear-gradient(90deg, transparent 0%, rgba(255,255,255,.07) 50%, transparent 100%);
|
||||
-webkit-animation: heroSkelShimmer 1.8s ease-in-out infinite;
|
||||
animation: heroSkelShimmer 1.8s ease-in-out infinite; }
|
||||
@-webkit-keyframes heroSkelShimmer { 0%{-webkit-transform:translateX(-100%);transform:translateX(-100%)} 100%{-webkit-transform:translateX(100%);transform:translateX(100%)} }
|
||||
@keyframes heroSkelShimmer { 0%{transform:translateX(-100%)} 100%{transform:translateX(100%)} }
|
||||
.hero-skel-body { position: absolute; bottom: 40px; left: 0; right: 0;
|
||||
display: -webkit-flex; display: flex; -webkit-flex-direction: column; flex-direction: column;
|
||||
-webkit-align-items: center; align-items: center; gap: 10px; padding: 0 20px; z-index: 1; }
|
||||
.hero-skel-title { width: 52%; height: 26px; border-radius: 7px; background: rgba(255,255,255,.09); }
|
||||
.hero-skel-meta { width: 36%; height: 12px; border-radius: 5px; background: rgba(255,255,255,.06); }
|
||||
.hero-skel-dots { position: absolute; bottom: 13px; left: 0; right: 0; z-index: 1;
|
||||
display: -webkit-flex; display: flex; -webkit-align-items: center; align-items: center;
|
||||
-webkit-justify-content: center; justify-content: center; gap: 6px; }
|
||||
.hero-skel-d { width: 6px; height: 6px; border-radius: 50%; background: rgba(255,255,255,.18); }
|
||||
.hero-skel-d.a { width: 16px; background: rgba(255,255,255,.35); border-radius: 3px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="app" id="app">
|
||||
<div class="ptr" id="ptr"><span class="ptr-ico" id="ptrIco">↓</span><span id="ptrText">下拉刷新</span></div>
|
||||
<header class="header">
|
||||
<div class="header-row">
|
||||
<div class="search-box">
|
||||
<button id="brandBtn" class="brand focusable" type="button" aria-label="盘链 · 设置">
|
||||
<svg class="brand-mark" viewBox="0 0 48 48" width="26" height="26" aria-hidden="true">
|
||||
<defs><linearGradient id="embermark" x1="0" y1="0" x2="1" y2="1"><stop offset="0" style="stop-color:var(--accent)"/><stop offset="1" style="stop-color:var(--accent-2)"/></linearGradient></defs>
|
||||
<g fill="none" stroke="url(#embermark)" stroke-width="5.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="6.5" y="17" width="24" height="14" rx="7" transform="rotate(-32 18.5 24)"/>
|
||||
<rect x="17.5" y="17" width="24" height="14" rx="7" transform="rotate(-32 29.5 24)"/>
|
||||
</g>
|
||||
</svg>
|
||||
</button>
|
||||
<input id="search" class="search-input focusable" type="text" placeholder="搜片名,找网盘资源">
|
||||
<button id="searchGo" class="search-go focusable" type="button" aria-label="搜索">
|
||||
<svg viewBox="0 0 24 24" width="19" height="19" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7.5"/><path d="M21 21l-4.3-4.3"/></svg>
|
||||
</button>
|
||||
<div class="search-suggest" id="searchSuggest"></div>
|
||||
</div>
|
||||
<button id="historyBtn" class="hdr-ico focusable" type="button" aria-label="最近观看">
|
||||
<svg viewBox="0 0 24 24" width="21" height="21" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3v5h5"/><path d="M3.05 13A9 9 0 1 0 6 5.3L3 8"/><path d="M12 7v5l3 3"/></svg>
|
||||
</button>
|
||||
<div class="search-mask" id="searchMask"></div>
|
||||
<div class="search-panel" id="searchPanel">
|
||||
<div class="search-box">
|
||||
<span class="search-ico"><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7.5"/><path d="M21 21l-4.3-4.3"/></svg></span>
|
||||
<input id="search" class="search-input focusable" type="text" placeholder="搜片名,找网盘资源">
|
||||
<button id="searchGo" class="search-go focusable" type="button" aria-label="搜索">搜索</button>
|
||||
<div class="search-suggest" id="searchSuggest"></div>
|
||||
</div>
|
||||
<div class="chips" id="chips"></div>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<main>
|
||||
<section class="section">
|
||||
<div class="section-head">
|
||||
<span class="section-title" id="browseTitle">全部资源</span>
|
||||
<span class="section-sub" id="browseSub"></span>
|
||||
<div id="homeView">
|
||||
<div class="hero-wrap" id="heroWrap" hidden>
|
||||
<div class="hero" id="heroCarousel" aria-label="推荐"></div>
|
||||
<div class="hero-dots" id="heroDots"></div>
|
||||
</div>
|
||||
<div id="homeRows"></div>
|
||||
</div>
|
||||
<section class="section" id="catView" style="display:none">
|
||||
<div class="cat-head">
|
||||
<button id="catBack" class="icon-btn cat-back focusable" type="button" aria-label="返回">
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 18l-6-6 6-6"/></svg>
|
||||
</button>
|
||||
<span class="cat-title" id="catTitle">全部资源</span>
|
||||
<div id="searchBar" class="cat-searchbar focusable" role="button" tabindex="0" aria-label="修改搜索词">
|
||||
<span class="csb-ico"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7.5"/><path d="M21 21l-4.3-4.3"/></svg></span>
|
||||
<span class="csb-kw" id="searchBarKw"></span>
|
||||
<button id="clearSearch" class="clear-search focusable" type="button" aria-label="清空搜索词" style="display:none">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6L6 18"/><path d="M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="genre-chips" id="genreChips"></div>
|
||||
<button id="clearSearch" class="clear-search focusable" type="button" style="display:none">✕ 清空</button>
|
||||
<span class="section-sub" id="browseSub"></span>
|
||||
<button id="clearRecent" class="icon-btn recent-clear-btn focusable" type="button" aria-label="清空全部记录" style="display:none">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2"/><path d="M19 6l-1 14a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="section-head">
|
||||
<span class="section-title" id="browseTitle" style="display:none"></span>
|
||||
</div>
|
||||
<div class="grid" id="grid"></div>
|
||||
<div id="browseNotice"></div>
|
||||
<div id="browseLoading" class="loading-row" style="display:none">加载中…</div>
|
||||
</section>
|
||||
</main>
|
||||
<nav class="chips" id="chips" aria-label="导航"></nav>
|
||||
</div>
|
||||
|
||||
<div class="sheet" id="detailSheet" aria-hidden="true">
|
||||
<div class="ed-hero" id="edHero">
|
||||
<img id="edHeroImg" alt="">
|
||||
<div class="ed-hero-fade"></div>
|
||||
<div class="ed-hero-imgs" id="edHeroImgs"></div>
|
||||
<div class="ed-hero-shade"></div>
|
||||
<div class="ed-hero-blend"><div class="ed-blur-mid"></div></div>
|
||||
</div>
|
||||
<div class="ed-hero-spacer"></div>
|
||||
<div class="ed-body">
|
||||
@@ -507,6 +1137,7 @@ body.detail-active { background: var(--sheet-bg); }
|
||||
</button>
|
||||
</div>
|
||||
<div class="overview clamp" id="detailOverview"></div>
|
||||
<div class="ed-episodes" id="edEpisodes"></div>
|
||||
<div class="ed-cast" id="edCast"></div>
|
||||
<div class="ed-works" id="edActorWorks"></div>
|
||||
<div class="pan-head"><span class="title" id="panTitle">网盘资源</span><div class="ed-qtabs" id="qTabs"></div><span class="sub" id="panSub" style="display:none"></span></div>
|
||||
@@ -517,21 +1148,9 @@ body.detail-active { background: var(--sheet-bg); }
|
||||
<div class="panel-overlay" id="settingsOverlay" aria-hidden="true">
|
||||
<div class="panel-sheet">
|
||||
<button id="cfgCloseX" class="panel-close focusable" type="button" aria-label="关闭">✕</button>
|
||||
<div class="set-hd">
|
||||
<span class="set-mark">
|
||||
<svg viewBox="0 0 48 48" width="30" height="30" aria-hidden="true">
|
||||
<defs><linearGradient id="setmark" x1="0" y1="0" x2="1" y2="1"><stop offset="0" style="stop-color:var(--accent)"/><stop offset="1" style="stop-color:var(--accent-2)"/></linearGradient></defs>
|
||||
<g fill="none" stroke="url(#setmark)" stroke-width="5.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="6.5" y="17" width="24" height="14" rx="7" transform="rotate(-32 18.5 24)"/>
|
||||
<rect x="17.5" y="17" width="24" height="14" rx="7" transform="rotate(-32 29.5 24)"/>
|
||||
</g>
|
||||
</svg>
|
||||
</span>
|
||||
<div><h3>设置</h3><p class="desc">盘链 · pinglian.lol</p></div>
|
||||
</div>
|
||||
<div class="set-hd"><h3>设置</h3></div>
|
||||
|
||||
<div class="set-group">
|
||||
|
||||
<div class="set-2col">
|
||||
<div class="field">
|
||||
<label>账号 / 邮箱</label>
|
||||
@@ -554,32 +1173,71 @@ body.detail-active { background: var(--sheet-bg); }
|
||||
<label>站点地址</label>
|
||||
<input id="cfgBase" class="focusable" type="text" placeholder="https://pinglian.lol">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="set-group">
|
||||
<div class="set-group-title">海报 · TMDB</div>
|
||||
<div class="set-2col">
|
||||
<div class="field">
|
||||
<label>API Key</label>
|
||||
<label>TMDB Key</label>
|
||||
<input id="cfgTmdb" class="focusable" type="password" autocomplete="off" placeholder="留空用站点海报">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>图片域名</label>
|
||||
<label>TMDB 图片域名</label>
|
||||
<input id="cfgTmdbImg" class="focusable" type="text" placeholder="image.tmdb.org">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>TMDB 数据接口域名 <span class="hint">详情页简介/演职员这些数据走这个域名;默认 api.tmdb.org(国内免代理可用),官方主域 api.themoviedb.org 在国内被墙;留空用默认</span></label>
|
||||
<input id="cfgTmdbApi" class="focusable" type="text" placeholder="api.tmdb.org">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>盘搜地址 <span class="hint">详情页「网盘搜索」用</span></label>
|
||||
<input id="cfgPanApi" class="focusable" type="text" placeholder="https://so.252035.xyz">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>指南站点 <span class="hint">豆瓣高分 / 合集 / 详情页补充网盘资源</span></label>
|
||||
<input id="cfgZn4k" class="focusable" type="text" placeholder="https://4kzn.cc">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>七味站点 <span class="hint">最新片单 / 详情页补充网盘资源(磁力/夸克/迅雷/百度等)</span></label>
|
||||
<input id="cfgQw" class="focusable" type="text" placeholder="https://www.qwmp4.com">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>蜗牛站点 <span class="hint">网盘资源站(115/夸克等)</span></label>
|
||||
<input id="cfgWn" class="focusable" type="text" placeholder="https://zmi.kdns.fr">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>SeedHub 站点 <span class="hint">网盘资源站(夸克/百度/迅雷等)</span></label>
|
||||
<input id="cfgSh" class="focusable" type="text" placeholder="https://sidhub.cc">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>玩偶站点 <span class="hint">网盘资源站</span></label>
|
||||
<input id="cfgWo" class="focusable" type="text" placeholder="https://wogg.xxooo.cf">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>木偶站点 <span class="hint">网盘资源站</span></label>
|
||||
<input id="cfgMo" class="focusable" type="text" placeholder="https://666.666291.xyz">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>pomo 站点 <span class="hint">网盘资源站</span></label>
|
||||
<input id="cfgPm" class="focusable" type="text" placeholder="https://pomo.mom">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="set-group">
|
||||
<div class="set-group-title">盘搜 PanSou</div>
|
||||
<div class="set-group-title">网盘类别</div>
|
||||
<div class="field">
|
||||
<label>盘搜地址 <span class="hint">详情页「网盘搜索」按钮用;结果直链直接播放</span></label>
|
||||
<input id="cfgPanApi" class="focusable" type="text" placeholder="https://so.252035.xyz">
|
||||
<label>选择要显示的网盘类别 <span class="hint">默认全部显示;取消的类别不会出现在网盘资源里(某类别最终没有资源时也会自动隐藏)</span></label>
|
||||
<div class="pan-type-wrap" id="cfgPanTypes"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="set-group">
|
||||
<div class="set-group-title">详情页</div>
|
||||
<div class="toggle-row">
|
||||
<div class="lbl">剧集单集列表<small>剧/综艺/动漫详情页里的分季单集卡片;关闭后不显示该区块,只保留网盘资源</small></div>
|
||||
<button id="cfgShowEpisodes" type="button" class="switch focusable" role="switch" aria-checked="true"><span class="knob"></span></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="set-group set-status">
|
||||
<div class="set-group-title">状态</div>
|
||||
<div id="statusBlock"></div>
|
||||
</div>
|
||||
|
||||
@@ -590,6 +1248,17 @@ body.detail-active { background: var(--sheet-bg); }
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-overlay" id="confirmOverlay" aria-hidden="true">
|
||||
<div class="panel-sheet confirm-sheet">
|
||||
<h3 class="confirm-title" id="confirmTitle">确认</h3>
|
||||
<p class="confirm-text" id="confirmText"></p>
|
||||
<div class="panel-actions">
|
||||
<button id="confirmOk" class="btn danger focusable" type="button">确定</button>
|
||||
<button id="confirmCancel" class="btn focusable" type="button">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<script>
|
||||
@@ -605,15 +1274,22 @@ var root = document.documentElement;
|
||||
var DEFAULT_BASE = "https://pinglian.lol";
|
||||
var BROWSER_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36 Edg/149.0.0.0";
|
||||
/* 内置登录态(Cookie)。session_token 为长期令牌,PHPSESSID 可能轮换,服务器会通过 Set-Cookie 刷新 */
|
||||
var AUTH_COOKIE = "session_token=fd538e5c3a483eabead1cf8df4825c5d9ec992979046df92b6192acf0cb623b7; PHPSESSID=k0f6q8hn60anjen4bq63c84mo1; skip_go_warning=1; announcement_dismissed=true";
|
||||
var AUTH_COOKIE = ""; /* 不再内置真实登录态,请在“设置”页填入你自己的 Cookie(含 session_token/PHPSESSID) */
|
||||
|
||||
/* 盘链分类:t=1电影 2剧集 3综艺 4动漫;空=全部 */
|
||||
var CAT_ICONS = {
|
||||
"1": '<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M3 9h18M8 4v16M16 4v16"/>',
|
||||
"2": '<rect x="3" y="5" width="18" height="12" rx="2"/><path d="M8 21h8M12 17v4"/>',
|
||||
"3": '<path d="M12 3l2.3 5.3L20 9l-4 3.6L17 19l-5-3-5 3 1-6.4L4 9l5.7-.7z"/>',
|
||||
"4": '<circle cx="12" cy="12" r="9"/><path d="M9 10h.01M15 10h.01M8.5 15a4 4 0 007 0"/>',
|
||||
"__recent__": '<path d="M3 3v5h5"/><path d="M3.05 13A9 9 0 1 0 6 5.3L3 8"/><path d="M12 7v5l3 3"/>'
|
||||
};
|
||||
var CATEGORIES = [
|
||||
{ slug: "", name: "全部" },
|
||||
{ slug: "1", name: "电影" },
|
||||
{ slug: "2", name: "剧集" },
|
||||
{ slug: "3", name: "综艺" },
|
||||
{ slug: "4", name: "动漫" }
|
||||
{ slug: "4", name: "动漫" },
|
||||
{ slug: "__recent__", name: "历史" }
|
||||
];
|
||||
|
||||
/* 网盘 host -> type(用于 fm.pan.play / fm.pan.check) */
|
||||
@@ -640,29 +1316,142 @@ var PAN_META = {
|
||||
"115": { label: "115", play: "115" },
|
||||
tianyi: { label: "天翼", play: "tianyi" },
|
||||
mobile: { label: "移动云", play: "mobile" },
|
||||
guangya: { label: "光鸭", play: "" },
|
||||
weiyun: { label: "微云", play: "" },
|
||||
lanzou: { label: "蓝奏", play: "" },
|
||||
jianguo: { label: "坚果", play: "" },
|
||||
guangya: { label: "光鸭盘", play: "http" }, // 新出的小众网盘:不在 App 检测支持表内(不检测);可走 fm.pan.play(type=http) 尝试播放,盘搜也常返回它
|
||||
pikpak: { label: "PikPak", play: "pikpak" },
|
||||
magnet: { label: "磁力", play: "magnet" }
|
||||
magnet: { label: "磁力", play: "magnet" },
|
||||
fy: { label: "枫叶", play: "" }, // 在线点播(m3u8 直链),不走网盘解析,播放走 fyPlayDirect
|
||||
http: { label: "网盘", play: "" }
|
||||
};
|
||||
var TYPE_LABEL = { quark: "夸克", uc: "UC", aliyun: "阿里", baidu: "百度", tianyi: "天翼", xunlei: "迅雷", "115": "115", "123": "123", mobile: "移动云", magnet: "磁力", http: "网页" };
|
||||
/* 网盘 tab 固定顺序:显式写死,不依赖 PAN_META 对象的声明顺序。
|
||||
各站资源是异步陆续到达的,谁先回来谁的类型就会先出现在 state.panItems 里——如果 tab 顺序
|
||||
跟着"谁先到"走,同一部片子这次打开是"夸克/百度/UC",下次因为响应快慢不同变成"UC/夸克/百度",
|
||||
看着就像顺序自己在跳。这里固定按下面这个顺序显示,不认到达先后,后续资源只在各自的 tab
|
||||
里往里加,tab 本身的位置和前后顺序不会再变。 */
|
||||
var PAN_GROUP_ORDER = ["夸克", "百度", "123", "UC", "115", "迅雷", "阿里", "天翼", "移动云", "PikPak", "光鸭盘"];
|
||||
/* 设置页「网盘类别」可屏蔽的可选项:覆盖 PAN_META 里实际会出现的网盘类型标签。
|
||||
枫叶在线(线路名是站点动态给的,不是固定网盘类型)不放进来。 */
|
||||
var PAN_TYPE_OPTIONS = ["夸克", "百度", "123", "UC", "115", "迅雷", "阿里", "天翼", "移动云", "PikPak", "光鸭盘", "磁力", "网盘"];
|
||||
/* 全局不展示的网盘类型:微云/蓝奏/坚果这几个基本已经没人用、也没接检测/播放,从入口就滤掉,
|
||||
不管哪个站源返回这几个类型都不会出现在网盘资源里(不只是设置项里去掉) */
|
||||
var PAN_TYPE_GLOBAL_EXCLUDE = ["weiyun", "lanzou", "jianguo"];
|
||||
/* 某个网盘类别是否被用户在设置里屏蔽了;config.panBlockedTypes 为空数组(默认)时不屏蔽任何类别 */
|
||||
function isPanTypeBlocked(q) {
|
||||
return !!(config.panBlockedTypes && config.panBlockedTypes.length && config.panBlockedTypes.indexOf(q) >= 0);
|
||||
}
|
||||
/* 站源 -> 徽标色(用于详情页网盘条目上标注"这条资源来自哪个站",字体色跟着站源走,一眼区分)。
|
||||
分组仍然只按网盘类型(PAN_META.label)来,不再按站源拆分 tab —— 站源只在条目里当一个小标签展示。 */
|
||||
var SITE_META = {
|
||||
"本站": { c: "#2ea6ff" },
|
||||
"蜗牛": { c: "#3ddc97" },
|
||||
"SeedHub": { c: "#a78bfa" },
|
||||
"玩偶": { c: "#ff9f43" },
|
||||
"木偶": { c: "#ff6b6b" },
|
||||
"pomo": { c: "#ffd166" },
|
||||
"4K指南": { c: "#f5c451" },
|
||||
"盘搜": { c: "#6ba7ff" },
|
||||
"枫叶": { c: "#ff7a45" },
|
||||
"枫叶 · 在线": { c: "#ff7a45" },
|
||||
"七味": { c: "#4fd1c5" },
|
||||
"TMDB": { c: "#01b4e4" }
|
||||
};
|
||||
function siteMetaOf(name) { return SITE_META[name] || { c: "#8b96b5" }; }
|
||||
/* 从资源标题里识别画质关键字(分辨率 / HDR·杜比视界 / 片源 / 码率 / 字幕),派两个用途:
|
||||
①算一个画质分数,同一网盘类型 tab 内按分数从高到低排(4K/杜比视界/HDR/原盘/高码 靠前);
|
||||
②在条目上显示对应徽标,不用点进去、光看列表就知道这条资源的画质信息。
|
||||
同一条资源标题不会变,解析一次缓存在 item._qInfo 上,不用每次重算。 */
|
||||
var QTAG_COLOR = {
|
||||
"8K": "#ff5f6d", "4K": "#ff8a3d", "1080P": "#ffb23e", "720P": "#c9a06a", "SD": "#8b96b5",
|
||||
"REMUX": "#3ddc97", "原盘": "#3ddc97", "蓝光": "#5ec9a8", "WEB-DL": "#7a8bb5",
|
||||
"DV": "#a78bfa", "HDR10+": "#7c9bff", "HDR": "#6a8bff",
|
||||
"高码": "#ff5fa8", "中字": "#f5c451", "双语": "#f5c451", "国语": "#f5c451"
|
||||
};
|
||||
function parseQuality(title) {
|
||||
var t = String(title || "");
|
||||
var tags = [], score = 0;
|
||||
if (/8K|4320P/i.test(t)) { tags.push("8K"); score += 500; }
|
||||
else if (/4K|2160P/i.test(t)) { tags.push("4K"); score += 400; }
|
||||
else if (/1080P|FHD/i.test(t)) { tags.push("1080P"); score += 300; }
|
||||
else if (/720P/i.test(t)) { tags.push("720P"); score += 200; }
|
||||
else if (/\bSD\b/i.test(t)) { tags.push("SD"); score += 100; }
|
||||
if (/REMUX/i.test(t)) { tags.push("REMUX"); score += 90; }
|
||||
else if (/蓝光原盘|原盘/i.test(t)) { tags.push("原盘"); score += 90; }
|
||||
else if (/BluRay|蓝光/i.test(t)) { tags.push("蓝光"); score += 40; }
|
||||
else if (/WEB-?DL/i.test(t)) { tags.push("WEB-DL"); score += 15; }
|
||||
if (/杜比视界|dolby\s*vision|DOVI|\bDV\b/i.test(t)) { tags.push("DV"); score += 60; }
|
||||
if (/HDR10\+/i.test(t)) { tags.push("HDR10+"); score += 45; }
|
||||
else if (/HDR/i.test(t)) { tags.push("HDR"); score += 30; }
|
||||
if (/高码率?|HI-?BIT-?RATE/i.test(t)) { tags.push("高码"); score += 15; }
|
||||
if (/中字|中文字幕|内封字幕/i.test(t)) tags.push("中字");
|
||||
else if (/双语/i.test(t)) tags.push("双语");
|
||||
else if (/国语/i.test(t)) tags.push("国语");
|
||||
return { tags: tags, score: score };
|
||||
}
|
||||
function qualityInfo(it) {
|
||||
if (!it._qInfo) it._qInfo = parseQuality((it.title || "") + " " + (it.groupName || ""));
|
||||
return it._qInfo;
|
||||
}
|
||||
function qualityTagsHTML(it) {
|
||||
var html = "";
|
||||
qualityInfo(it).tags.forEach(function (tg) {
|
||||
var c = QTAG_COLOR[tg] || "#8b96b5";
|
||||
html += "<span class='q-tag' style='color:" + c + ";border-color:" + c + "55;background:" + c + "1f'>" + escapeHtml(tg) + "</span>";
|
||||
});
|
||||
return html;
|
||||
}
|
||||
/* 网盘条目「副标题」统一构造:网盘类型徽标 + 站源徽标(各站固定配色)+ 画质徽标 + 提取码 + 状态文案。
|
||||
paintResources(首次渲染)和 setItemState(检测状态更新)共用,避免两处样式跑偏。 */
|
||||
function buildPanSubHTML(item) {
|
||||
var lab = (PAN_META[item.type] ? PAN_META[item.type].label : item.type);
|
||||
var siteName = item.source || "本站";
|
||||
var sm = siteMetaOf(siteName);
|
||||
var html = "<span class='q'>" + escapeHtml(lab) + "</span>";
|
||||
html += "<span class='site-tag' style='background:" + sm.c + ";border-color:" + sm.c + ";color:#0b0e14'>" + escapeHtml(siteName) + "</span>";
|
||||
html += qualityTagsHTML(item);
|
||||
if (item.password) html += "<span class='pwd'>提取码 " + escapeHtml(item.password) + "</span>";
|
||||
if (item.state === "resolving" || item.state === "bad") html += "<span class='st'>" + escapeHtml(stateText(item.state)) + "</span>";
|
||||
return html;
|
||||
}
|
||||
/* 非盘搜(站点自身网盘结果)默认排序:夸克 > 百度 > 123 > 115,其余类型顺序不限 */
|
||||
var PAN_SORT_PRIORITY = { quark: 0, baidu: 1, "123": 2, "115": 3 };
|
||||
function sortPanDefault(list) {
|
||||
var withIndex = (list || []).map(function (it, i) { return { it: it, i: i }; });
|
||||
withIndex.sort(function (a, b) {
|
||||
var pa = PAN_SORT_PRIORITY.hasOwnProperty(a.it.type) ? PAN_SORT_PRIORITY[a.it.type] : 99;
|
||||
var pb = PAN_SORT_PRIORITY.hasOwnProperty(b.it.type) ? PAN_SORT_PRIORITY[b.it.type] : 99;
|
||||
if (pa !== pb) return pa - pb;
|
||||
return a.i - b.i; // 同优先级/其余类型保持原始相对顺序,顺序不做强制要求
|
||||
});
|
||||
return withIndex.map(function (p) { return p.it; });
|
||||
}
|
||||
|
||||
var DEFAULT_TMDB_KEY = "d7040155454e7fdf547c4d889ebbcca7";
|
||||
var config = { base: DEFAULT_BASE, homeFull: true, tmdbKey: DEFAULT_TMDB_KEY, tmdbImg: "api.tmdb.org", cookie: "", account: "", password: "", panApi: "https://so.252035.xyz" };
|
||||
var DEFAULT_TMDB_KEY = "d7040155454e7fdf547c4d889ebbcca7"; /* 内置公共 TMDB key(可在设置里换成你自己的)。这个 key + api.tmdb.org 域名在国内免代理可用 */
|
||||
var config = { base: DEFAULT_BASE, homeFull: true, tmdbKey: DEFAULT_TMDB_KEY, tmdbImg: "images.tmdb.org", tmdbApi: "", cookie: "", account: "", password: "", panApi: "https://so.252035.xyz", zn4kBase: "https://4kzn.cc", qwBase: "https://www.qwmp4.com", fyBase: "https://www.cd-zj.com", wnBase: "https://zmi.kdns.fr", shBase: "https://sidhub.cc", woBases: { wo: "", mo: "", pm: "" }, panBlockedTypes: [], showEpisodes: true };
|
||||
var PANSOU_TYPES = ["quark", "aliyun", "baidu", "uc", "tianyi", "xunlei", "123", "115", "mobile"];
|
||||
var PANSOU_KEEP = PANSOU_TYPES.concat(["guangya"]); // 客户端保留的类型:比请求多一个光鸭盘(盘搜会返回,但它不在标准 cloud_types 里)
|
||||
var state = {
|
||||
sdkReady: false, isTv: false, isMobile: false, driveCheck: true,
|
||||
activeCat: "", activeGenre: "", searchMode: false, searchKw: "",
|
||||
activeCat: "1", activeGenre: "", searchMode: false, searchKw: "", searchSourceFilter: "",
|
||||
nav: "home", view: "home",
|
||||
heroItems: [], heroIndex: 0, heroTimer: null, heroToken: 0, heroLoading: false, heroRawCache: null,
|
||||
dHeroImages: [], dHeroIdx: 0, dHeroTimer: null, dHeroMode: "",
|
||||
page: 1, source: [], rendered: 0, loading: false, noMore: false, autoPages: 0,
|
||||
recentItems: [],
|
||||
reqToken: 0,
|
||||
currentItem: null, currentDetail: null, panItems: [], panToken: 0, panObserver: null,
|
||||
expandedActorId: 0, suggestTimer: 0, suggestToken: 0,
|
||||
loginPromise: null, useCookieManager: false, loggedInOnce: false, reloginTried: false,
|
||||
bestResource: null, qFilter: null, qGroups: [], favs: {},
|
||||
focusScope: "home", homeReturn: null, theme: "default"
|
||||
bestResource: null, qFilter: null, qFilterManual: false, qGroups: [], favs: {},
|
||||
focusScope: "home", homeReturn: null, viewReturn: null, theme: "default",
|
||||
homeCatCache: {}, homeCatMore: {}, homeHeroLoaded: false,
|
||||
searchPanelOpen: false, scrollLockY: 0, stackDepth: 0, expectedDepth: null,
|
||||
zn4kColCache: {}, zn4kCol: null,
|
||||
qwCatCache: {}, qwCatSel: "1", qwPage: 1, qwGoodBase: "",
|
||||
fyCache: {}, fyCat: "1", fyPage: 1, fyLastUrl: "", fyLastPlayApi: "",
|
||||
wnCache: {}, wnCat: "1", wnPage: 1,
|
||||
shCache: {}, shCat: "1", shPage: 1,
|
||||
woCache: {}, woSite: "wo", woCat: "", woPage: 1, woGood: {}, woBad: {},
|
||||
cfgPanBlockedTmp: []
|
||||
};
|
||||
|
||||
/* ---------- 存储 ---------- */
|
||||
@@ -676,9 +1465,105 @@ function storeSet(key, val) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
/* ---------- 首页提速:磁盘缓存 + 后台预取队列 + 请求去重 ----------
|
||||
1) 磁盘缓存(stale-while-revalidate):首页每一行的第 1 页结果落盘,下次进 App 直接从盘里秒出,
|
||||
不用干等网络;落盘超过 HOME_DISK_STALE 就在后台悄悄重抓一遍,回来了再无感覆盖那一行。
|
||||
2) 预取队列:网盘站那几行(豆瓣高分/蜗牛/SeedHub/玩偶/木偶)原先是"滑到才请求",第一次滑下去必然干等。
|
||||
现在首屏一渲染完就用并发上限 2 的后台队列提前抓,滑到时基本已经就位;真滑到了还没轮到就立刻插队跑,
|
||||
不会比原来慢。并发上限保证它们不跟首屏的分类请求抢带宽。
|
||||
3) 请求去重 + 短期缓存:各站的 HTML 抓取(列表页/搜索页/详情页)按 URL 做在途去重 + 3 分钟缓存,
|
||||
首页行、搜索合并、详情页补资源经常撞同一个 URL,撞上就不再打网络。 */
|
||||
var HOME_DISK_KEY = "homecache_v1";
|
||||
var HOME_DISK_TTL = 24 * 60 * 60 * 1000; // 超过 1 天的旧缓存不再拿来占位
|
||||
var HOME_DISK_STALE = 10 * 60 * 1000; // 超过 10 分钟就在后台重新抓一遍
|
||||
var _homeDiskTs = 0, _homeDiskTimer = 0;
|
||||
function homeCacheStale() { return !_homeDiskTs || (Date.now() - _homeDiskTs) > HOME_DISK_STALE; }
|
||||
function loadHomeDisk() {
|
||||
return storeGet(HOME_DISK_KEY).then(function (raw) {
|
||||
if (!raw) return;
|
||||
var d = null; try { d = JSON.parse(raw); } catch (e) { return; }
|
||||
if (!d || !d.ts || (Date.now() - d.ts) > HOME_DISK_TTL) return;
|
||||
_homeDiskTs = d.ts;
|
||||
if (d.cat) state.homeCatCache = d.cat;
|
||||
if (d.wn) state.wnCache = d.wn;
|
||||
if (d.sh) state.shCache = d.sh;
|
||||
if (d.wo) state.woCache = d.wo;
|
||||
if (d.zn) state.zn4kColCache = d.zn;
|
||||
if (d.qw) state.qwCatCache = d.qw;
|
||||
}, function () {});
|
||||
}
|
||||
function saveHomeDisk() {
|
||||
if (_homeDiskTimer) return; // 同一批更新合并成一次写盘
|
||||
_homeDiskTimer = setTimeout(function () {
|
||||
_homeDiskTimer = 0;
|
||||
try {
|
||||
var pack = { ts: Date.now(), cat: {}, wn: {}, sh: {}, wo: {}, zn: {}, qw: {} };
|
||||
function put(dst, src, n) {
|
||||
Object.keys(src || {}).forEach(function (k) {
|
||||
var l = src[k];
|
||||
if (l && l.length) dst[k] = l.slice(0, n);
|
||||
});
|
||||
}
|
||||
put(pack.cat, state.homeCatCache, 24);
|
||||
put(pack.wn, state.wnCache, 12);
|
||||
put(pack.sh, state.shCache, 12);
|
||||
put(pack.wo, state.woCache, 12);
|
||||
put(pack.zn, state.zn4kColCache, 12);
|
||||
put(pack.qw, state.qwCatCache, 12);
|
||||
_homeDiskTs = pack.ts;
|
||||
storeSet(HOME_DISK_KEY, JSON.stringify(pack));
|
||||
} catch (e) {}
|
||||
}, 800);
|
||||
}
|
||||
function clearHomeDisk() { _homeDiskTs = 0; if (_homeDiskTimer) { clearTimeout(_homeDiskTimer); _homeDiskTimer = 0; } storeSet(HOME_DISK_KEY, ""); }
|
||||
/* 后台预取队列(并发上限 2) */
|
||||
var _pfQueue = [], _pfRunning = 0, PF_MAX = 2, _pfOpen = false;
|
||||
function homePrefetch(fn) { _pfQueue.push(fn); if (_pfOpen) pumpPrefetch(); }
|
||||
function resetPrefetch() { _pfQueue = []; _pfOpen = false; }
|
||||
function startHomePrefetch() { if (_pfOpen) return; _pfOpen = true; pumpPrefetch(); }
|
||||
function _pfDone() { _pfRunning--; setTimeout(pumpPrefetch, 0); }
|
||||
function pumpPrefetch() {
|
||||
if (!_pfOpen) return;
|
||||
while (_pfRunning < PF_MAX && _pfQueue.length) {
|
||||
var fn = _pfQueue.shift(), p = null;
|
||||
_pfRunning++;
|
||||
try { p = fn(); } catch (e) { p = null; }
|
||||
Promise.resolve(p).then(_pfDone, _pfDone);
|
||||
}
|
||||
}
|
||||
/* 首页某一行的加载:滑到就立刻加载,没滑到也会在后台队列里排队预取,两条路只会真正跑一次 */
|
||||
function rowLoad(section, fn) {
|
||||
var ran = false;
|
||||
function once() { if (ran) return null; ran = true; return fn(); }
|
||||
lazyWhenVisible(section, function () { once(); });
|
||||
homePrefetch(once);
|
||||
}
|
||||
/* 各站 HTML 抓取:在途去重 + 3 分钟短期缓存(同一 URL 不重复打网络) */
|
||||
var _txtMemo = {}, _txtInflight = {}, TXT_TTL = 3 * 60 * 1000, TXT_MAX = 40;
|
||||
function _txtGc() {
|
||||
var ks = Object.keys(_txtMemo);
|
||||
if (ks.length <= TXT_MAX) return;
|
||||
ks.sort(function (a, b) { return _txtMemo[a].ts - _txtMemo[b].ts; });
|
||||
for (var i = 0; i < ks.length - TXT_MAX; i++) delete _txtMemo[ks[i]];
|
||||
}
|
||||
function httpTextMemo(key, fn) {
|
||||
var hit = _txtMemo[key];
|
||||
if (hit && (Date.now() - hit.ts) < TXT_TTL) return Promise.resolve(hit.v);
|
||||
if (_txtInflight[key]) return _txtInflight[key];
|
||||
var p = fn().then(function (v) {
|
||||
delete _txtInflight[key];
|
||||
var body = (v && v.html) ? v.html : v;
|
||||
if (body && String(body).length < 600000) { _txtMemo[key] = { v: v, ts: Date.now() }; _txtGc(); }
|
||||
return v;
|
||||
}, function (e) { delete _txtInflight[key]; throw e; });
|
||||
_txtInflight[key] = p;
|
||||
return p;
|
||||
}
|
||||
function clearTextMemo() { _txtMemo = {}; }
|
||||
|
||||
/* ---------- 主题(内置:默认极夜蓝 / 日落珊瑚 / 极光靛蓝,长按 Logo 切换) ---------- */
|
||||
var THEME_LIST = ["default", "ember", "aurora"];
|
||||
var THEME_NAMES = { "default": "极夜蓝", "ember": "日落珊瑚", "aurora": "极光靛蓝" };
|
||||
var THEME_LIST = ["default", "ember", "aurora", "appletv"];
|
||||
var THEME_NAMES = { "default": "极夜蓝", "ember": "日落珊瑚", "aurora": "极光靛蓝", "appletv": "苹果白" };
|
||||
function applyTheme(t) {
|
||||
if (THEME_LIST.indexOf(t) === -1) t = "default";
|
||||
if (t === "default") root.removeAttribute("data-theme"); else root.setAttribute("data-theme", t);
|
||||
@@ -688,14 +1573,32 @@ function cycleTheme() {
|
||||
var i = THEME_LIST.indexOf(state.theme);
|
||||
var next = THEME_LIST[(i + 1) % THEME_LIST.length];
|
||||
applyTheme(next);
|
||||
try { localStorage.setItem("pl_theme", next); } catch (e) {}
|
||||
storeSet("theme", next);
|
||||
toast("已切换主题:" + THEME_NAMES[next]);
|
||||
toast("已切换主题:" + THEME_NAMES[next], "top");
|
||||
}
|
||||
function loadTheme() {
|
||||
return storeGet("theme").then(function (t) { applyTheme(t || "default"); }, function () { applyTheme("default"); });
|
||||
var local = "";
|
||||
try { local = localStorage.getItem("pl_theme") || ""; } catch (e) {}
|
||||
return storeGet("theme").then(function (t) { applyTheme(t || local || "default"); }, function () { applyTheme(local || "default"); });
|
||||
}
|
||||
|
||||
/* ---------- 网络:带内置 Cookie;API 需伪装成 AJAX(否则返回“非法请求”) ---------- */
|
||||
// 原生 fm.req 分支自带 timeout:20(秒),但浏览器/无原生桥的 fetch() 兜底分支此前完全没有超时——
|
||||
// 弱网或服务器无响应时 fetch 会一直挂着不 resolve/reject,页面上的“读取中…”会永远转下去。
|
||||
// 这里统一给 fetch 包一层超时,和原生分支的 20s 对齐,超时就 abort 并 reject,让上层的 catch 正常显示错误提示。
|
||||
var FETCH_TIMEOUT_MS = 20000;
|
||||
function fetchTimeout(url, opts) {
|
||||
opts = opts || {};
|
||||
var ac = (typeof AbortController !== "undefined") ? new AbortController() : null;
|
||||
if (ac) opts.signal = ac.signal;
|
||||
var timer = ac ? setTimeout(function () { try { ac.abort(); } catch (e) {} }, FETCH_TIMEOUT_MS) : null;
|
||||
return fetch(url, opts).then(function (r) { if (timer) clearTimeout(timer); return r; }, function (err) {
|
||||
if (timer) clearTimeout(timer);
|
||||
if (err && err.name === "AbortError") throw new Error("请求超时");
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
function authCookie() { if (state.useCookieManager) return ""; return config.cookie || AUTH_COOKIE; }
|
||||
function pageHeaders() { var h = { "User-Agent": BROWSER_UA }; var ck = authCookie(); if (ck) h["Cookie"] = ck; return h; }
|
||||
function apiHeaders(refId) {
|
||||
@@ -712,14 +1615,14 @@ function httpText(url) {
|
||||
return fm.req(url, { method: "GET", responseType: "text", timeout: 20, headers: pageHeaders(), credentials: "include" })
|
||||
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.body; });
|
||||
}
|
||||
return fetch(url, { credentials: "include" }).then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.text(); });
|
||||
return fetchTimeout(url, { credentials: "include" }).then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.text(); });
|
||||
}
|
||||
function apiGet(url, refId) {
|
||||
if (state.sdkReady && window.fm && fm.req) {
|
||||
return fm.req(url, { method: "GET", responseType: "json", timeout: 20, headers: apiHeaders(refId), credentials: "include" })
|
||||
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.body; });
|
||||
}
|
||||
return fetch(url, { credentials: "include", headers: { "X-Requested-With": "XMLHttpRequest" } }).then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); });
|
||||
return fetchTimeout(url, { credentials: "include", headers: { "X-Requested-With": "XMLHttpRequest" } }).then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); });
|
||||
}
|
||||
function apiPost(url, bodyObj, refId) {
|
||||
var body = JSON.stringify(bodyObj);
|
||||
@@ -728,7 +1631,7 @@ function apiPost(url, bodyObj, refId) {
|
||||
return fm.req(url, { method: "POST", responseType: "json", timeout: 20, headers: h, body: body, credentials: "include" })
|
||||
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.body; });
|
||||
}
|
||||
return fetch(url, { method: "POST", credentials: "include", headers: { "X-Requested-With": "XMLHttpRequest", "Content-Type": "application/json" }, body: body }).then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); });
|
||||
return fetchTimeout(url, { method: "POST", credentials: "include", headers: { "X-Requested-With": "XMLHttpRequest", "Content-Type": "application/json" }, body: body }).then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); });
|
||||
}
|
||||
/* 图片:盘链源站图片用 no-referrer 防盗链,绝不能带 Referer */
|
||||
function imgSrc(url) {
|
||||
@@ -751,7 +1654,7 @@ function postForm(url, body) {
|
||||
return fm.req(url, { method: "POST", responseType: "json", timeout: 20, headers: headers, body: body, credentials: "include" })
|
||||
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r; });
|
||||
}
|
||||
return fetch(url, { method: "POST", credentials: "include", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: body })
|
||||
return fetchTimeout(url, { method: "POST", credentials: "include", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: body })
|
||||
.then(function (r) { return r.json().then(function (b) { return { ok: r.ok, status: r.status, body: b, headers: {} }; }); });
|
||||
}
|
||||
/* 从 Set-Cookie 合并出新的 Cookie 串(保留 session_token/PHPSESSID 等) */
|
||||
@@ -790,7 +1693,7 @@ function ensureLogin() {
|
||||
return login().then(function () { return true; }, function () { return false; });
|
||||
}
|
||||
function persistConfig() {
|
||||
return storeSet("config", JSON.stringify({ base: config.base, homeFull: config.homeFull, tmdbKey: config.tmdbKey, tmdbImg: config.tmdbImg, cookie: config.cookie, account: config.account, password: config.password, panApi: config.panApi }));
|
||||
return storeSet("config", JSON.stringify({ base: config.base, homeFull: config.homeFull, tmdbKey: config.tmdbKey, tmdbImg: config.tmdbImg, tmdbApi: config.tmdbApi, cookie: config.cookie, account: config.account, password: config.password, panApi: config.panApi, zn4kBase: config.zn4kBase, qwBase: config.qwBase, fyBase: config.fyBase, wnBase: config.wnBase, shBase: config.shBase, woBases: config.woBases, panBlockedTypes: config.panBlockedTypes, showEpisodes: config.showEpisodes }));
|
||||
}
|
||||
/* TMDB 用(外站,不带盘链 Cookie) */
|
||||
function httpJson(url) {
|
||||
@@ -798,7 +1701,7 @@ function httpJson(url) {
|
||||
return fm.req(url, { method: "GET", responseType: "json", timeout: 20 })
|
||||
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.body; });
|
||||
}
|
||||
return fetch(url).then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); });
|
||||
return fetchTimeout(url).then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); });
|
||||
}
|
||||
|
||||
/* ---------- URL 构造 ---------- */
|
||||
@@ -856,6 +1759,2692 @@ function parseDetail(html) {
|
||||
return { title: title, poster: poster, overview: overview, resources: [] };
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
4K指南(4kzn.cc)集成:把该站的搜索接口 + 站内网盘资源接进来。
|
||||
- 详情页:按标题去 4K指南 搜同名,解析其站内网盘链接,合并进「网盘资源」,
|
||||
不在搜索结果里单独列一个站源("搜索结果不用分站源,在详情页体现")。
|
||||
- 首页:新增「豆瓣高分」榜单行 +「合集」行,数据来自指南站。
|
||||
============================================================ */
|
||||
var ZN4K_DEFAULT_BASE = "https://4kzn.cc"; // 默认域名;可在设置里改(config.zn4kBase),换域名不用改代码
|
||||
/* 当前生效的指南站地址(跟着设置走) */
|
||||
function zn4kBase() { return trimSlash(config.zn4kBase || ZN4K_DEFAULT_BASE); }
|
||||
/* 这张图是不是指南站的(图片要带指南 Referer 才不被防盗链拦):按当前配置的域名判断,换域名自动跟着变 */
|
||||
function zn4kHost() { return zn4kBase().replace(/^https?:\/\//, "").split("/")[0]; }
|
||||
function isZn4kUrl(u) { var h = zn4kHost(); return !!(u && h && String(u).indexOf(h) >= 0); }
|
||||
var ZN4K_CAT_SLUG = "__zn4kcol__"; // 合集/榜单在分类视图里的内部 slug
|
||||
var ZN4K_UA = "Mozilla/5.0 (Linux; Android 10) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Mobile Safari/537.36";
|
||||
/* 4K指南 的豆瓣高分:作为独立分类行(放在 TMDB 热播 下面),是常规分类页,可翻页 */
|
||||
var ZN4K_TOP250 = { slug: "top250", name: "豆瓣高分", paged: true };
|
||||
/* 首页「合集」固定入口(对应 4K指南 站点 /books/{slug} 列表页),一页到底的固定名单 */
|
||||
var ZN4K_COLLECTIONS = [
|
||||
{ slug: "manwei", name: "漫威合集" },
|
||||
{ slug: "hanniba", name: "汉尼拔合集" },
|
||||
{ slug: "mztkn", name: "柯南剧场版合集" },
|
||||
{ slug: "xszrhj", name: "行尸走肉合集" }
|
||||
];
|
||||
/* 指南列表页 URL:第 1 页 /books/{slug},后续 /books/{slug}/page/N/ */
|
||||
function zn4kListUrl(slug, page) {
|
||||
var base = zn4kBase();
|
||||
return (page > 1) ? (base + "/books/" + slug + "/page/" + page + "/") : (base + "/books/" + slug);
|
||||
}
|
||||
|
||||
/* 指南站请求:不带盘链 Cookie,只用浏览器 UA + 指南自身 Referer */
|
||||
function zn4kHttpText(url) { return httpTextMemo("zn|" + url, function () { return zn4kHttpTextRaw(url); }); }
|
||||
function zn4kHttpTextRaw(url) {
|
||||
if (state.sdkReady && window.fm && fm.req) {
|
||||
return fm.req(url, { method: "GET", responseType: "text", timeout: 20, headers: { "User-Agent": ZN4K_UA, "Referer": zn4kBase() + "/" } })
|
||||
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.body; });
|
||||
}
|
||||
return fetchTimeout(url, { headers: {} }).then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.text(); });
|
||||
}
|
||||
/* 指南站图片要带指南 Referer(防盗链) */
|
||||
function zn4kImgSrc(url) {
|
||||
if (!url) return "";
|
||||
if (state.sdkReady && window.fm && fm.res) return fm.res(url, { headers: { "Referer": zn4kBase() + "/" } });
|
||||
return url;
|
||||
}
|
||||
/* 解析前切掉大段内联 script/style/注释,减轻 DOMParser 负担 */
|
||||
function zn4kStripHeavy(html) {
|
||||
try { return String(html).replace(/<script[\s\S]*?<\/script>/gi, "").replace(/<style[\s\S]*?<\/style>/gi, "").replace(/<!--[\s\S]*?-->/g, ""); }
|
||||
catch (e) { return html; }
|
||||
}
|
||||
function zn4kParseDoc(html) {
|
||||
try { return new DOMParser().parseFromString(zn4kStripHeavy(html), "text/html"); } catch (e) { return null; }
|
||||
}
|
||||
function zn4kDiskType(u) {
|
||||
for (var i = 0; i < HOSTMAP.length; i++) if (HOSTMAP[i][0].test(u)) return HOSTMAP[i][1];
|
||||
if (u.indexOf("magnet:") === 0) return "magnet";
|
||||
if (u.indexOf("ed2k:") === 0) return "ed2k";
|
||||
if (u.indexOf("thunder:") === 0) return "thunder";
|
||||
return "http";
|
||||
}
|
||||
/* 指南列表页/合集页 -> [{id,title,poster,meta}] */
|
||||
function zn4kParseList(html) {
|
||||
var doc = zn4kParseDoc(html);
|
||||
if (!doc) return [];
|
||||
var out = [], seen = {};
|
||||
function add(id, title, poster, meta) { if (!id || seen[id] || !title) return; seen[id] = 1; out.push({ id: id, title: title, poster: poster, meta: meta || "" }); }
|
||||
Array.from(doc.querySelectorAll("article.book-item, article.posts-item")).forEach(function (a) {
|
||||
var link = a.querySelector("a.item-image, .item-title a, a[href*='/book/']");
|
||||
if (!link) return;
|
||||
var m = (link.getAttribute("href") || "").match(/\/book\/(\d+)\.html/);
|
||||
if (!m) return;
|
||||
var img = a.querySelector("img");
|
||||
var poster = img ? (img.getAttribute("data-src") || img.getAttribute("data-original") || img.getAttribute("src") || "") : "";
|
||||
if (poster && poster.indexOf(".svg") >= 0 && img.getAttribute("data-src")) poster = img.getAttribute("data-src");
|
||||
var tEl = a.querySelector(".item-title a, .item-title, h3 a");
|
||||
var title = ((tEl ? tEl.textContent : "") || (img ? img.getAttribute("alt") : "") || "").trim();
|
||||
var metaEl = a.querySelector(".text-muted");
|
||||
add(m[1], title, poster, metaEl ? metaEl.textContent.trim() : "");
|
||||
});
|
||||
if (!out.length) {
|
||||
Array.from(doc.querySelectorAll("a[href*='/book/']")).forEach(function (link) {
|
||||
var m = (link.getAttribute("href") || "").match(/\/book\/(\d+)\.html/);
|
||||
if (!m) return;
|
||||
var scope = link.closest("article, li, .module-item, .public-list-box, .item, .col") || link.parentElement || link;
|
||||
var img = link.querySelector("img") || (scope ? scope.querySelector("img") : null);
|
||||
if (!img) return;
|
||||
var poster = img.getAttribute("data-src") || img.getAttribute("data-original") || img.getAttribute("src") || "";
|
||||
if (poster && poster.indexOf(".svg") >= 0 && img.getAttribute("data-src")) poster = img.getAttribute("data-src");
|
||||
var tEl = scope ? scope.querySelector(".item-title, .title, h3, h2") : null;
|
||||
var title = ((tEl ? tEl.textContent : "") || img.getAttribute("alt") || link.getAttribute("title") || link.textContent || "").trim();
|
||||
add(m[1], title, poster, "");
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
/* 指南详情页 -> { title, poster, overview, resources:[{url,label,quality,type}] } */
|
||||
function zn4kParseDetail(html) {
|
||||
var doc = zn4kParseDoc(html);
|
||||
if (!doc) return { title: "", poster: "", overview: "", resources: [] };
|
||||
var h1 = doc.querySelector("h1.site-name, .book-info h1, h1");
|
||||
var title = h1 ? h1.textContent.trim() : "";
|
||||
var cover = doc.querySelector(".book-cover img, .item-media img");
|
||||
var poster = cover ? (cover.getAttribute("data-src") || cover.getAttribute("src") || "") : "";
|
||||
var descEl = doc.querySelector("meta[name='description']");
|
||||
var overview = descEl ? (descEl.getAttribute("content") || "") : "";
|
||||
var res = [], seen = {};
|
||||
function pushLink(a, quality) {
|
||||
var url = (a.getAttribute("href") || "").trim();
|
||||
if (!url || !/^https?:|^magnet:|^ed2k:|^thunder:/.test(url)) return;
|
||||
if (seen[url]) return; seen[url] = 1;
|
||||
var bn = a.querySelector(".b-name");
|
||||
var label = ((bn ? bn.textContent : "") || a.getAttribute("title") || "").trim();
|
||||
res.push({ url: url, label: label, quality: quality || "", type: zn4kDiskType(url) });
|
||||
}
|
||||
Array.from(doc.querySelectorAll(".mt-n2")).forEach(function (b) {
|
||||
var q = b.querySelector("p");
|
||||
var quality = q ? q.textContent.trim() : "";
|
||||
Array.from(b.querySelectorAll(".site-go a[href]")).forEach(function (a) { pushLink(a, quality); });
|
||||
});
|
||||
if (!res.length) {
|
||||
Array.from(doc.querySelectorAll(".site-go a[href], .book-down a[href], a.btn[href*='pan.'], a.btn[href*='drive.']")).forEach(function (a) { pushLink(a, ""); });
|
||||
}
|
||||
return { title: title, poster: poster, overview: overview, resources: res };
|
||||
}
|
||||
|
||||
/* 指南解析出的资源 -> 盘链网盘条目(只保留 App 能处理的网盘类型 + 磁力) */
|
||||
function zn4kResList(resources) {
|
||||
var out = [], seen = {};
|
||||
(resources || []).forEach(function (r) {
|
||||
var type = r.type;
|
||||
if (PANSOU_KEEP.indexOf(type) < 0 && type !== "magnet") return;
|
||||
var url = r.url || "";
|
||||
if (!url) return;
|
||||
var k = type + "|" + normPanUrl(url);
|
||||
if (seen[k]) return; seen[k] = 1;
|
||||
var lab = PAN_META[type] ? PAN_META[type].label : (TYPE_LABEL[type] || type);
|
||||
out.push({
|
||||
type: type, url: url, password: "",
|
||||
title: r.label || "",
|
||||
groupName: lab, quality: lab,
|
||||
source: "4K指南", state: "idle", _zn4k: true
|
||||
});
|
||||
});
|
||||
return out;
|
||||
}
|
||||
/* 详情页里已经并进来的指南资源(刷新/重载时保留,跟 panSouKeep 同理) */
|
||||
function zn4kKeep() { return (state.panItems || []).filter(function (it) { return it._zn4k; }); }
|
||||
/* 把一批指南资源合并进当前详情页网盘列表(去重 + 重建 tab) */
|
||||
function zn4kMerge(pans, token) {
|
||||
if (token !== state.panToken || !pans || !pans.length) return;
|
||||
var have = {};
|
||||
state.panItems.forEach(function (it) { have[it.type + "|" + normPanUrl(it.url || it.token)] = 1; });
|
||||
var add = [];
|
||||
pans.forEach(function (p) { var k = p.type + "|" + normPanUrl(p.url); if (!have[k]) { have[k] = 1; add.push(p); } });
|
||||
if (!add.length) return;
|
||||
renderResources(state.panItems.concat(add));
|
||||
}
|
||||
/* 指南站搜索:/?post_type=book&s=关键词 -> 列表 */
|
||||
function zn4kSearch(kw) {
|
||||
var u = zn4kBase() + "/?post_type=book&s=" + encodeURIComponent(kw);
|
||||
return zn4kHttpText(u).then(function (html) { return zn4kParseList(html); }, function () { return []; });
|
||||
}
|
||||
/* 从指南搜索结果里挑与本片最匹配的一条(标题相关度优先,年份加权) */
|
||||
function zn4kPickBest(list, rawTitle, year) {
|
||||
var key = cleanTitle(rawTitle);
|
||||
var best = null, bestScore = 99;
|
||||
(list || []).forEach(function (it) {
|
||||
var ts = titleScore(it.title, key);
|
||||
if (ts > 3) return; // 完全不含关键词的丢掉
|
||||
var s = ts;
|
||||
if (year && ((it.title || "").indexOf(year) >= 0 || (it.meta || "").indexOf(year) >= 0)) s -= 0.3;
|
||||
if (s < bestScore) { bestScore = s; best = it; }
|
||||
});
|
||||
return best;
|
||||
}
|
||||
/* 详情页(盘链原生条目):按标题去指南搜同名,把站内网盘资源并进来 */
|
||||
function zn4kEnrich(rawTitle, token) {
|
||||
var kw = cleanTitle(rawTitle);
|
||||
if (!kw) return;
|
||||
var year = yearOf(rawTitle);
|
||||
zn4kSearch(kw).then(function (list) {
|
||||
if (token !== state.panToken || !list.length) return null;
|
||||
var best = zn4kPickBest(list, rawTitle, year);
|
||||
if (!best) return null;
|
||||
return zn4kHttpText(zn4kBase() + "/book/" + best.id + ".html").then(function (html) {
|
||||
if (token !== state.panToken) return;
|
||||
zn4kMerge(zn4kResList(zn4kParseDetail(html).resources), token);
|
||||
});
|
||||
}).then(null, function () {});
|
||||
}
|
||||
/* 详情页(指南来源条目):已知指南书籍 id,直接取其站内网盘资源 */
|
||||
function zn4kLoadById(id, token) {
|
||||
if (!id) return;
|
||||
zn4kHttpText(zn4kBase() + "/book/" + id + ".html").then(function (html) {
|
||||
if (token !== state.panToken) return;
|
||||
var d = zn4kParseDetail(html);
|
||||
if (state.currentDetail && !state.currentDetail.tmdb) {
|
||||
if (d.title) { state.currentDetail.rawTitle = d.title; showTitle(d.title); }
|
||||
var ov = $("detailOverview"); if (ov && !ov.textContent && !(state.currentDetail && state.currentDetail.tmdb) && d.overview && !isJunkOverview(d.overview)) { ov.textContent = d.overview; state.currentDetail.overviewFromSite = true; }
|
||||
if (d.poster && !state.currentDetail.poster && !(state.currentDetail && state.currentDetail.tmdb)) { state.currentDetail.poster = d.poster; setDetailHeroImages([d.poster], false); }
|
||||
}
|
||||
var pans = zn4kResList(d.resources);
|
||||
if (pans.length) renderResources(sortPanDefault(pans).concat(panSouKeep()));
|
||||
}, function () {});
|
||||
}
|
||||
/* 指南来源条目:顺便按标题反查盘链本站,命中就升级成原生条目(走完整资源+反向补指南) */
|
||||
function zn4kReverseLookup(item, token) {
|
||||
var title = item.title, yr = item.year || yearOf(item.title);
|
||||
apiGet(apiUrl("get_videos.php") + "?wd=" + encodeURIComponent(cleanTitle(title)) + "&pg=1&_t=" + Date.now(), "").then(function (d) {
|
||||
if (token !== state.panToken || state.currentItem !== item) return;
|
||||
var list = ((d && (d.list || d.data)) || []).map(mapVod);
|
||||
function score(it) { var s = 0; if (it.title === title) s += 8; else if (titleCloseContain(it.title, title)) s += 4; if (yr && it.year === yr) s += 3; return s; }
|
||||
var good = list.filter(function (it) { return score(it) >= 4; });
|
||||
if (good.length) openDetail(good.slice().sort(function (a, b) { return score(b) - score(a); })[0]);
|
||||
}, function () {});
|
||||
}
|
||||
|
||||
/* ---------- 首页「合集」(放在首页最下面一行) ---------- */
|
||||
function renderZn4kCollections(box) {
|
||||
var shell = makeRowShell("合集", null);
|
||||
box.appendChild(shell.section);
|
||||
var row = shell.row, frag = document.createDocumentFragment();
|
||||
ZN4K_COLLECTIONS.forEach(function (c, i) {
|
||||
var card = document.createElement("button");
|
||||
card.type = "button"; card.className = "card focusable";
|
||||
card.setAttribute("data-card-index", String(i));
|
||||
card.setAttribute("data-focus-row", "row");
|
||||
var poster = document.createElement("div"); poster.className = "poster";
|
||||
var ph = document.createElement("div"); ph.className = "ph"; ph.textContent = c.name.replace("合集", ""); poster.appendChild(ph);
|
||||
var badge = document.createElement("span"); badge.className = "badge mono"; badge.textContent = c.paged ? "榜单" : "合集"; poster.appendChild(badge);
|
||||
card.appendChild(poster);
|
||||
var title = document.createElement("div"); title.className = "card-title"; title.textContent = c.name; card.appendChild(title);
|
||||
var meta = document.createElement("div"); meta.className = "card-meta"; meta.textContent = "指南"; card.appendChild(meta);
|
||||
card.onclick = (function (col) { return function () { openZn4kCollection(col.slug, col.name, col.paged); }; })(c);
|
||||
frag.appendChild(card);
|
||||
(function (slug, posterEl, phEl) { rowLoad(shell.section, function () { return zn4kCollectionCover(slug, posterEl, phEl); }); })(c.slug, poster, ph);
|
||||
});
|
||||
row.replaceChildren(frag);
|
||||
}
|
||||
/* 首页「豆瓣高分」一行(4K指南 的豆瓣高分 分类,紧跟在 TMDB 热播 下面) */
|
||||
function renderZn4kTop250(box) {
|
||||
var c = ZN4K_TOP250;
|
||||
var shell = makeRowShell(c.name, function () { openZn4kCollection(c.slug, c.name, c.paged); });
|
||||
box.appendChild(shell.section);
|
||||
var cached = state.zn4kColCache[c.slug];
|
||||
var hasCache = !!(cached && cached.length);
|
||||
if (hasCache) fillRow(shell.row, cached.slice(0, 12), function (it) { openDetail(it); });
|
||||
else shell.row.replaceChildren(el("div", "mh-row-empty", "加载中…"));
|
||||
var load = function () {
|
||||
if (!document.body.contains(shell.row)) return null;
|
||||
return zn4kFetchPage(c.slug, 1).then(function (members) {
|
||||
if (!document.body.contains(shell.row)) return;
|
||||
if (!members.length) { if (!hasCache) shell.row.replaceChildren(el("div", "mh-row-empty", "暂无数据")); return; }
|
||||
state.zn4kColCache[c.slug] = members; saveHomeDisk();
|
||||
fillRow(shell.row, members.slice(0, 12), function (it) { openDetail(it); });
|
||||
}, function () { if (!hasCache) shell.row.replaceChildren(el("div", "mh-row-empty", "加载失败")); });
|
||||
};
|
||||
if (hasCache) { if (homeCacheStale()) homePrefetch(load); return; } // 有缓存:先秒显,旧了才后台刷新
|
||||
rowLoad(shell.section, load);
|
||||
}
|
||||
/* 抓指南列表页某一页 -> 成员数组(带 _zn4k 标记,点开走指南详情逻辑) */
|
||||
function zn4kFetchPage(slug, page) {
|
||||
return zn4kHttpText(zn4kListUrl(slug, page)).then(function (html) {
|
||||
return zn4kParseList(html).map(function (m) { m._zn4k = true; m._zn4kId = m.id; return m; });
|
||||
});
|
||||
}
|
||||
/* 封面懒加载:用该合集/榜单第一页里首个有海报的成员当封面;第一页结果缓存,点进去时秒开 */
|
||||
function zn4kCollectionCover(slug, posterEl, phEl) {
|
||||
function setCover(members) {
|
||||
var first = (members || []).filter(function (m) { return m.poster; })[0];
|
||||
if (!first || !posterEl) return;
|
||||
var img = document.createElement("img");
|
||||
img.alt = ""; img.loading = "lazy"; img.setAttribute("decoding", "async");
|
||||
img.onerror = function () { img.style.display = "none"; };
|
||||
img.src = zn4kImgSrc(first.poster);
|
||||
if (phEl) phEl.style.display = "none";
|
||||
posterEl.insertBefore(img, posterEl.firstChild);
|
||||
}
|
||||
var cached = state.zn4kColCache[slug];
|
||||
if (cached && cached.length) { setCover(cached); return null; }
|
||||
return zn4kFetchPage(slug, 1).then(function (members) {
|
||||
state.zn4kColCache[slug] = members;
|
||||
saveHomeDisk();
|
||||
setCover(members);
|
||||
}, function () {});
|
||||
}
|
||||
/* 打开某个指南合集/榜单:复用「查看全部」分类视图铺影片。
|
||||
paged=true(豆瓣高分 这类常规分类页)滑到底会继续翻页;固定名单的合集一页到底。 */
|
||||
function openZn4kCollection(slug, name, paged) {
|
||||
saveHomeReturn();
|
||||
state.view = "category";
|
||||
$("homeView").style.display = "none"; $("catView").style.display = "";
|
||||
$("genreChips").style.display = "none"; $("genreChips").replaceChildren();
|
||||
$("catTitle").textContent = name || "合集";
|
||||
$("clearSearch").style.display = "none"; $("clearRecent").style.display = "none";
|
||||
setNav(null); updateHeroVisible(); window.scrollTo(0, 0);
|
||||
navigate("category");
|
||||
state.searchMode = false; state.searchKw = "";
|
||||
state.activeCat = ZN4K_CAT_SLUG; state.activeGenre = "";
|
||||
state.zn4kCol = { slug: slug, name: name, paged: !!paged, page: 1, fetching: false };
|
||||
state.reqToken++; var token = state.reqToken;
|
||||
state.source = []; state.rendered = 0; state.noMore = !paged; state.loading = false; state.autoPages = 0;
|
||||
var grid = $("grid"); grid.replaceChildren(); grid.classList.remove("grid-recent");
|
||||
$("browseNotice").replaceChildren();
|
||||
$("browseSub").textContent = "加载中";
|
||||
$("browseLoading").style.display = "block";
|
||||
var cached = state.zn4kColCache[slug];
|
||||
if (cached) { zn4kShowCollection(cached, token); return; }
|
||||
zn4kFetchPage(slug, 1).then(function (members) {
|
||||
if (token !== state.reqToken) return;
|
||||
state.zn4kColCache[slug] = members;
|
||||
zn4kShowCollection(members, token);
|
||||
}, function () {
|
||||
if (token !== state.reqToken) return;
|
||||
$("browseLoading").style.display = "none";
|
||||
$("browseSub").textContent = "";
|
||||
$("grid").replaceChildren(el("div", "mh-row-empty", "加载失败,返回重试"));
|
||||
});
|
||||
}
|
||||
function zn4kShowCollection(members, token) {
|
||||
if (token !== state.reqToken) return;
|
||||
$("browseLoading").style.display = "none";
|
||||
state.source = (members || []).slice(); state.rendered = 0;
|
||||
$("grid").replaceChildren();
|
||||
if (!state.source.length) { $("grid").replaceChildren(el("div", "mh-row-empty", "该分类暂无内容")); $("browseSub").textContent = ""; state.noMore = true; return; }
|
||||
appendGrid();
|
||||
$("browseSub").textContent = state.source.length + " 部";
|
||||
}
|
||||
/* 指南榜单(豆瓣高分)触底续页:由 loadList(false) 转发过来 */
|
||||
function zn4kLoadMore() {
|
||||
var c = state.zn4kCol;
|
||||
if (!c || !c.paged || c.fetching || state.noMore) return;
|
||||
c.fetching = true; state.loading = true;
|
||||
var token = state.reqToken, page = c.page + 1;
|
||||
$("browseLoading").style.display = "block";
|
||||
zn4kFetchPage(c.slug, page).then(function (list) {
|
||||
c.fetching = false; state.loading = false;
|
||||
if (token !== state.reqToken) return;
|
||||
$("browseLoading").style.display = "none";
|
||||
var have = {}; state.source.forEach(function (s) { have[s.id] = 1; });
|
||||
var fresh = (list || []).filter(function (it) { return !have[it.id]; });
|
||||
if (!fresh.length) { state.noMore = true; return; }
|
||||
c.page = page;
|
||||
state.source = state.source.concat(fresh);
|
||||
appendGrid();
|
||||
$("browseSub").textContent = state.source.length + " 部";
|
||||
}, function () {
|
||||
c.fetching = false; state.loading = false;
|
||||
if (token !== state.reqToken) return;
|
||||
$("browseLoading").style.display = "none";
|
||||
state.noMore = true;
|
||||
});
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
七味网(qmp4.com)集成 —— 完全仿照上面 4K指南 的接法:
|
||||
把该站的搜索接口 + 站内下载资源(磁力 + 夸克/迅雷/百度等网盘)接进来。
|
||||
- 详情页:按标题去七味搜同名,解析下载区,把网盘/磁力并进「网盘资源」,
|
||||
不在搜索结果里单独列站源(同 zn4kEnrich)。
|
||||
- 搜索:并发搜七味,只有盘链、指南都没有的片子才补一张七味来源卡片。
|
||||
- 首页:新增「七味」一行(数据来自七味电影分类第一页,可翻页)。
|
||||
============================================================ */
|
||||
var QW_DEFAULT_BASE = "https://www.qwmp4.com"; // 默认用可直连的亚太镜像(官方永久域名 qmp4.com 需科学上网,不适合做默认)
|
||||
/* 已知镜像域名(发布页公布的亚太/全球线路),主域连不上时按顺序自动切换,命中后记在 state.qwGoodBase。
|
||||
这类站换域名很勤,把镜像都列上,任一存活就能用;用户也可在设置里手动指定「七味站点」。 */
|
||||
var QW_MIRRORS = [
|
||||
"https://www.qwmp4.com", "https://www.qwmkv.com", "https://www.qwfilm.com",
|
||||
"https://www.qwavi.com", "https://www.qwfun.com",
|
||||
"https://www.qnmp4.com", "https://www.qnnull.com", "https://www.qnhot.com",
|
||||
"https://www.qncool.com", "https://www.qwfree.com", "https://www.qmp4.com"
|
||||
];
|
||||
/* 用户配置/默认的「首选」域名(设置里填了就用填的) */
|
||||
function qwPreferredBase() { return trimSlash(config.qwBase || QW_DEFAULT_BASE); }
|
||||
/* 当前生效域名:优先用已探测到能用的镜像(state.qwGoodBase),否则用首选。
|
||||
URL 构造和图片 Referer 都走它,保证请求域名和 Referer 一致。 */
|
||||
function qwBase() { return trimSlash(state.qwGoodBase || qwPreferredBase()); }
|
||||
function qwHost() { return qwBase().replace(/^https?:\/\//, "").split("/")[0]; }
|
||||
function isQwUrl(u) { var h = qwHost(); return !!(u && h && String(u).indexOf(h) >= 0); }
|
||||
/* 七味海报放在 vvmp4 图床上,带防盗链,判定时把图床也算作七味的图 */
|
||||
function isQwPoster(u) { return isQwUrl(u) || /vvmp4\.com/.test(String(u || "")); }
|
||||
var QW_UA = ZN4K_UA; // 跟指南同一套移动端 UA
|
||||
var QW_CAT_SLUG = "__qwcat__"; // 七味分类在分类视图里的内部 slug
|
||||
/* 七味顶级分类(对应 /vt/{id}.html):电影1 / 剧集2 / 综艺3 / 动漫4 / 短剧30 */
|
||||
var QW_CATS = [
|
||||
{ t: "1", name: "电影" }, { t: "2", name: "剧集" }, { t: "3", name: "综艺" },
|
||||
{ t: "4", name: "动漫" }, { t: "30", name: "短剧" }
|
||||
];
|
||||
/* 列表页 URL:第 1 页 /vt/{cat}.html,后续 /vt/{cat}-{page}.html */
|
||||
function qwListUrl(cat, page) {
|
||||
var base = qwBase();
|
||||
return (page > 1) ? (base + "/vt/" + cat + "-" + page + ".html") : (base + "/vt/" + cat + ".html");
|
||||
}
|
||||
/* 七味搜索:GET 表单 action=/vs/-------------.html,关键词走 ?wd= */
|
||||
function qwSearchUrl(kw) { return qwBase() + "/vs/-------------.html?wd=" + encodeURIComponent(kw); }
|
||||
function qwDetailUrl(id) { return qwBase() + "/mv/" + id + ".html"; }
|
||||
|
||||
/* 候选域名列表:上次成功的 -> 配置/默认首选 -> 其余镜像(去重) */
|
||||
function qwCandidateBases() {
|
||||
var out = [], seen = {};
|
||||
function add(b) { b = trimSlash(b || ""); if (b && !seen[b]) { seen[b] = 1; out.push(b); } }
|
||||
add(state.qwGoodBase); add(config.qwBase); add(QW_DEFAULT_BASE);
|
||||
QW_MIRRORS.forEach(add);
|
||||
return out;
|
||||
}
|
||||
function qwPath(url) { return String(url || "").replace(/^https?:\/\/[^/]+/, "") || "/"; }
|
||||
/* 抓一次七味页面:从候选域名里逐个试,返回第一个"看着像七味页面"的结果,
|
||||
并把命中的域名记进 state.qwGoodBase(之后 URL/Referer 都用它,不再逐个试)。 */
|
||||
function qwFetchOne(base, path) {
|
||||
var full = base + path;
|
||||
if (state.sdkReady && window.fm && fm.req) {
|
||||
return fm.req(full, { method: "GET", responseType: "text", timeout: 12, headers: { "User-Agent": QW_UA, "Referer": base + "/" } })
|
||||
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.body; });
|
||||
}
|
||||
return fetchTimeout(full, { headers: {} }).then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.text(); });
|
||||
}
|
||||
function qwLooksValid(html) {
|
||||
// 旧版要求页面里出现 '/mv/'、'pic-list'、'down-link' 或 '七味' 这几个正向标记才算"像七味页面"——
|
||||
// 但一次真正的搜索如果关键词在该站确实 0 条匹配,结果页往往只是个空的 <ul class="pic-list"></ul>
|
||||
// 或者干脆不渲染这几个 class/文案,导致这条检测把"域名没问题、只是没搜到"误判成"域名失效",
|
||||
// 从而白白把 11 个镜像域名挨个试一遍(每个最多等 12 秒),要么把这次搜索拖到没意义,
|
||||
// 要么真凑巧所有镜像的空结果页都通不过检测,最终整条搜索直接返回空。
|
||||
// 改成排除法:只要响应不是空/太短、也不是停放页/跳转提示/人机校验页,就认为域名可用。
|
||||
if (!html || html.length < 200) return false;
|
||||
if (/该域名(已|可)(出售|转让)|域名过期|停止解析|正在跳转|即将跳转|window\.location\s*=|_guard\/|jsl_clearance|检测中|请稍候|访问异常|异常流量|人机验证|安全验证|访问验证|验证码|captcha|access denied|forbidden|频繁访问|非法访问|请开启\s*(javascript|cookie)|enable\s*javascript|bot\s*detect|blocked|unusual traffic/i.test(html)) return false;
|
||||
return true;
|
||||
}
|
||||
/* 七味排障开关:打开后搜索完在屏幕底部弹一个可点关闭的浮层,列出每个候选域名的请求结果
|
||||
(字节数/判定结果),用来分辨到底是"域名连不上""域名连上了但判定成无效""还是解析出 0 条"。
|
||||
排查完记得关回 false。 */
|
||||
var QW_SEARCH_DIAG = false;
|
||||
function qwSDiag(line) {
|
||||
if (!QW_SEARCH_DIAG) return;
|
||||
if (!state.qwDiagList) state.qwDiagList = [];
|
||||
if (state.qwDiagList.length < 30) state.qwDiagList.push(line);
|
||||
}
|
||||
function qwSDiagShow() {
|
||||
if (!QW_SEARCH_DIAG) return;
|
||||
var box = document.getElementById("qwSDiagBox");
|
||||
if (!box) {
|
||||
box = document.createElement("div");
|
||||
box.id = "qwSDiagBox";
|
||||
box.style.cssText = "position:fixed;left:8px;right:8px;bottom:96px;z-index:70;max-height:44vh;overflow:auto;" +
|
||||
"padding:10px 12px;border-radius:12px;background:rgba(10,14,24,.94);color:#cfe0ff;font-size:11px;" +
|
||||
"line-height:1.5;white-space:pre-wrap;text-align:left;box-shadow:0 8px 30px rgba(0,0,0,.5);" +
|
||||
"border:1px solid rgba(160,190,255,.22);-webkit-user-select:text;user-select:text;";
|
||||
box.addEventListener("click", function () { box.style.display = "none"; });
|
||||
document.body.appendChild(box);
|
||||
}
|
||||
box.textContent = "【七味搜索诊断】(点一下关闭)\n" + (state.qwDiagList || []).join("\n");
|
||||
box.style.display = "block";
|
||||
}
|
||||
|
||||
/* 七味站请求:不带盘链 Cookie,只用浏览器 UA + 七味自身 Referer;带镜像自动切换。
|
||||
给整条镜像级联一个总预算,别让一串连不上的镜像把这次抓取拖到失去意义。 */
|
||||
function qwSnippet(t) { return String(t == null ? "" : t).replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim().slice(0, 200); }
|
||||
function qwHttpText(url) { return httpTextMemo("qw|" + url, function () { return qwHttpTextRaw(url); }); }
|
||||
function qwHttpTextRaw(url) {
|
||||
var cands = qwCandidateBases(), path = qwPath(url);
|
||||
function tryAt(i) {
|
||||
if (i >= cands.length) { qwSDiag("——— 全部 " + cands.length + " 个镜像都失败/判定无效"); return Promise.reject(new Error("qw: all mirrors failed")); }
|
||||
return qwFetchOne(cands[i], path).then(function (html) {
|
||||
var ok = qwLooksValid(html);
|
||||
// 字节数偏小(<4000)时多半是拦截页/验证页而不是真的搜索结果页,把去标签后的文字片段打出来,
|
||||
// 不管判定成有效还是无效都打,避免"判定有效"掩盖了它其实是页错误页这件事。
|
||||
qwSDiag(cands[i] + "\n → " + (html ? html.length + " 字节" : "空") + (ok ? " ✓ 判定有效" : " ✕ 判定无效,换下一个") +
|
||||
((html && html.length < 4000) ? ("\n 内容片段:" + qwSnippet(html)) : ""));
|
||||
if (ok) { state.qwGoodBase = cands[i]; return html; } // 记住可用镜像
|
||||
return tryAt(i + 1); // landing/空页:换下一个
|
||||
}, function (e) {
|
||||
qwSDiag(cands[i] + "\n → 请求失败:" + ((e && e.message) || "未知"));
|
||||
return tryAt(i + 1);
|
||||
});
|
||||
}
|
||||
if (path.indexOf("/vs/") === 0) state.qwDiagList = []; // 每次新搜索前清空诊断缓冲
|
||||
var p = tryAt(0);
|
||||
if (path.indexOf("/vs/") === 0) p = p.then(function (html) {
|
||||
var n = qwParseList(html).length;
|
||||
qwSDiag("———\n最终用的响应解析出 " + n + " 条");
|
||||
qwSDiagShow();
|
||||
return html;
|
||||
}, function (e) { qwSDiag("———\n最终失败:" + ((e && e.message) || "未知")); qwSDiagShow(); throw e; });
|
||||
return qwWithBudget(p, 20000);
|
||||
}
|
||||
/* 给镜像级联一个总时间预算:到点还没个结果,直接判失败,不管还剩几个镜像没试完
|
||||
(不像 woWithBudget 那样"超时就当空结果",这里要保留 reject,好让上层区分
|
||||
"真的搜不到"和"没来得及搜完",避免把后者误当成前者缓存下来)。 */
|
||||
function qwWithBudget(promise, ms) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
var done = false;
|
||||
var timer = setTimeout(function () { if (!done) { done = true; reject(new Error("qw: budget exceeded")); } }, ms);
|
||||
promise.then(
|
||||
function (v) { if (!done) { done = true; clearTimeout(timer); resolve(v); } },
|
||||
function (e) { if (!done) { done = true; clearTimeout(timer); reject(e); } }
|
||||
);
|
||||
});
|
||||
}
|
||||
/* 七味图片要带七味 Referer(防盗链) */
|
||||
|
||||
function qwImgSrc(url) {
|
||||
if (!url) return "";
|
||||
if (state.sdkReady && window.fm && fm.res) return fm.res(url, { headers: { "Referer": qwBase() + "/" } });
|
||||
return url;
|
||||
}
|
||||
function qwStripHeavy(html) {
|
||||
try { return String(html).replace(/<script[\s\S]*?<\/script>/gi, "").replace(/<style[\s\S]*?<\/style>/gi, "").replace(/<!--[\s\S]*?-->/g, ""); }
|
||||
catch (e) { return html; }
|
||||
}
|
||||
function qwParseDoc(html) {
|
||||
try { return new DOMParser().parseFromString(qwStripHeavy(html), "text/html"); } catch (e) { return null; }
|
||||
}
|
||||
function qwDiskType(u) {
|
||||
for (var i = 0; i < HOSTMAP.length; i++) if (HOSTMAP[i][0].test(u)) return HOSTMAP[i][1];
|
||||
if (u.indexOf("magnet:") === 0) return "magnet";
|
||||
if (u.indexOf("ed2k:") === 0) return "ed2k";
|
||||
if (u.indexOf("thunder:") === 0) return "thunder";
|
||||
return "http";
|
||||
}
|
||||
/* 列表/搜索结果页 -> [{id,title,poster,meta,year}]
|
||||
七味卡片结构:ul.pic-list > li > a[href*='/mv/'] > article>img[alt] + h3>b(标题) + p(年份/地区/类型) */
|
||||
function qwParseList(html) {
|
||||
var doc = qwParseDoc(html);
|
||||
if (!doc) return [];
|
||||
var out = [], seen = {};
|
||||
function add(id, title, poster, meta, year) { if (!id || seen[id] || !title) return; seen[id] = 1; out.push({ id: id, title: title, poster: poster, meta: meta || "", year: year || "" }); }
|
||||
Array.from(doc.querySelectorAll(".pic-list li a[href*='/mv/'], ul.pic-list li a[href*='/mv/']")).forEach(function (a) {
|
||||
var m = (a.getAttribute("href") || "").match(/\/mv\/(\d+)\.html/);
|
||||
if (!m) return;
|
||||
var img = a.querySelector("img");
|
||||
var poster = img ? (img.getAttribute("data-src") || img.getAttribute("data-original") || img.getAttribute("src") || "") : "";
|
||||
var tEl = a.querySelector("h3 b, h3");
|
||||
var title = ((tEl ? tEl.textContent : "") || a.getAttribute("title") || (img ? img.getAttribute("alt") : "") || "").trim();
|
||||
var pEl = a.querySelector("p");
|
||||
var meta = pEl ? pEl.textContent.trim().replace(/\s+/g, " ") : "";
|
||||
var ym = meta.match(/(19|20)\d{2}/);
|
||||
add(m[1], title, poster, meta, ym ? ym[0] : "");
|
||||
});
|
||||
if (!out.length) { // 兜底:模板变了也能抓到 /mv/ 链接
|
||||
Array.from(doc.querySelectorAll("a[href*='/mv/']")).forEach(function (a) {
|
||||
var m = (a.getAttribute("href") || "").match(/\/mv\/(\d+)\.html/);
|
||||
if (!m) return;
|
||||
var scope = a.closest("li, .item, article") || a;
|
||||
var img = a.querySelector("img") || (scope ? scope.querySelector("img") : null);
|
||||
var poster = img ? (img.getAttribute("data-src") || img.getAttribute("src") || "") : "";
|
||||
var title = (a.getAttribute("title") || (img ? img.getAttribute("alt") : "") || a.textContent || "").trim();
|
||||
add(m[1], title, poster, "", "");
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
/* 详情页 -> { title, poster, overview, resources:[{url,label,quality,type}] }
|
||||
下载区结构:#donLink 里 .nav-tabs .title 是画质分组名,.gdt.content 里每个 a[href] 是一条下载/网盘链接 */
|
||||
function qwParseDetail(html) {
|
||||
var doc = qwParseDoc(html);
|
||||
if (!doc) return { title: "", poster: "", overview: "", resources: [] };
|
||||
var h1 = doc.querySelector("h1");
|
||||
var title = h1 ? h1.textContent.trim() : "";
|
||||
var descEl = doc.querySelector("meta[name='description']");
|
||||
var overview = descEl ? (descEl.getAttribute("content") || "") : "";
|
||||
var poster = "";
|
||||
var imgs = Array.from(doc.querySelectorAll("img"));
|
||||
for (var i = 0; i < imgs.length; i++) {
|
||||
var s = imgs[i].getAttribute("data-src") || imgs[i].getAttribute("src") || "";
|
||||
if (/upload\/vod|vvmp4/.test(s)) { poster = s; break; }
|
||||
}
|
||||
var res = [], seen = {};
|
||||
function pushLink(url, label, quality) {
|
||||
url = (url || "").trim();
|
||||
if (!url || !/^https?:|^magnet:|^ed2k:|^thunder:/.test(url)) return;
|
||||
if (seen[url]) return; seen[url] = 1;
|
||||
res.push({ url: url, label: (label || "").trim(), quality: quality || "", type: qwDiskType(url) });
|
||||
}
|
||||
// 七味详情页有多个下载面板(磁力/BT 一个、网盘一个),且都复用 id="donLink"(重复 id),
|
||||
// 不能只取第一个,必须遍历所有 .down-link 面板,各自的 tab(画质/网盘名)配各自的资源列表。
|
||||
var panels = Array.from(doc.querySelectorAll(".down-link"));
|
||||
if (!panels.length) panels = [doc];
|
||||
panels.forEach(function (panel) {
|
||||
var tabs = Array.from(panel.querySelectorAll(".nav-tabs .title, .tab-title .title"));
|
||||
var lists = Array.from(panel.querySelectorAll(".gdt.content, ul.gdt"));
|
||||
function qualOf(idx) {
|
||||
var t = tabs[idx]; if (!t) return "";
|
||||
var c = t.cloneNode(true); var b = c.querySelector(".badge"); if (b && b.parentNode) b.parentNode.removeChild(b);
|
||||
return (c.textContent || "").trim();
|
||||
}
|
||||
lists.forEach(function (ul, idx) {
|
||||
var q = qualOf(idx);
|
||||
Array.from(ul.querySelectorAll("a[href]")).forEach(function (a) { pushLink(a.getAttribute("href"), a.textContent, q); });
|
||||
});
|
||||
});
|
||||
// 网盘链接全页兜底扫描:无条件执行(不再只在面板一无所获时才跑),
|
||||
// 这样即便磁力面板先命中、或某些镜像的网盘区用了不同的类名/结构,
|
||||
// 只要页面里出现真实网盘域名前缀的 <a>,都会被补进来(pushLink 已按 url 去重)。
|
||||
// 只匹配已知网盘域名 + 磁力,广告/诱导链接(非网盘域名)自然不会被收。
|
||||
var sweep = "a[href^='magnet:'], a[href*='pan.baidu.com'], a[href*='pan.quark.cn'], "
|
||||
+ "a[href*='pan.xunlei.com'], a[href*='drive.uc.cn'], a[href*='cloud.189.cn'], "
|
||||
+ "a[href*='123pan.com'], a[href*='123684.com'], a[href*='123865.com'], a[href*='123912.com'], "
|
||||
+ "a[href*='aliyundrive.com'], a[href*='alipan.com'], a[href*='115.com'], a[href*='115cdn.com'], "
|
||||
+ "a[href*='mypikpak.com'], a[href*='caiyun'], a[href*='139.com']";
|
||||
Array.from(doc.querySelectorAll(sweep)).forEach(function (a) {
|
||||
// 尽量给个画质/网盘名标签:优先取所在面板 tab,其次取链接自身文字
|
||||
var q = "";
|
||||
var host = a.closest ? a.closest(".gdt.content, ul.gdt, li, p") : null;
|
||||
pushLink(a.getAttribute("href"), a.textContent, q);
|
||||
});
|
||||
return { title: title, poster: poster, overview: overview, resources: res };
|
||||
}
|
||||
/* 七味解析出的资源 -> 盘链网盘条目(只保留 App 能处理的网盘类型 + 磁力) */
|
||||
function qwResList(resources) {
|
||||
var out = [], seen = {};
|
||||
(resources || []).forEach(function (r) {
|
||||
var type = r.type;
|
||||
if (PANSOU_KEEP.indexOf(type) < 0 && type !== "magnet") return;
|
||||
var url = r.url || ""; if (!url) return;
|
||||
var k = type + "|" + normPanUrl(url);
|
||||
if (seen[k]) return; seen[k] = 1;
|
||||
var lab = PAN_META[type] ? PAN_META[type].label : (TYPE_LABEL[type] || type);
|
||||
out.push({
|
||||
type: type, url: url, password: "",
|
||||
title: r.label || "",
|
||||
groupName: lab, quality: lab,
|
||||
source: "七味", state: "idle", _qw: true
|
||||
});
|
||||
});
|
||||
return out;
|
||||
}
|
||||
/* 详情页里已经并进来的七味资源(刷新/重载时保留,跟 zn4kKeep 同理) */
|
||||
function qwKeep() { return (state.panItems || []).filter(function (it) { return it._qw; }); }
|
||||
/* 把一批七味资源合并进当前详情页网盘列表(去重 + 重建 tab) */
|
||||
function qwMerge(pans, token) {
|
||||
if (token !== state.panToken || !pans || !pans.length) return;
|
||||
var have = {};
|
||||
state.panItems.forEach(function (it) { have[it.type + "|" + normPanUrl(it.url || it.token)] = 1; });
|
||||
var add = [];
|
||||
pans.forEach(function (p) { var k = p.type + "|" + normPanUrl(p.url); if (!have[k]) { have[k] = 1; add.push(p); } });
|
||||
if (!add.length) return;
|
||||
renderResources(state.panItems.concat(add));
|
||||
}
|
||||
/* 七味搜索:/vs/...html?wd=关键词 -> 列表 */
|
||||
function qwSearch(kw) {
|
||||
return qwHttpText(qwSearchUrl(kw)).then(function (html) { return qwParseList(html); }, function () { return []; });
|
||||
}
|
||||
/* 从七味搜索结果里挑与本片最匹配的一条(标题相关度优先,年份加权) */
|
||||
function qwPickBest(list, rawTitle, year) {
|
||||
var key = cleanTitle(rawTitle), best = null, bestScore = 99;
|
||||
(list || []).forEach(function (it) {
|
||||
var ts = titleScore(it.title, key);
|
||||
if (ts > 3) return;
|
||||
var s = ts;
|
||||
if (year && ((it.title || "").indexOf(year) >= 0 || (it.meta || "").indexOf(year) >= 0 || it.year === year)) s -= 0.3;
|
||||
if (s < bestScore) { bestScore = s; best = it; }
|
||||
});
|
||||
return best;
|
||||
}
|
||||
/* 详情页(盘链原生条目):按标题去七味搜同名,把站内网盘/磁力并进来 */
|
||||
function qwEnrich(rawTitle, token) {
|
||||
var kw = cleanTitle(rawTitle);
|
||||
if (!kw) return;
|
||||
var year = yearOf(rawTitle);
|
||||
qwSearch(kw).then(function (list) {
|
||||
if (token !== state.panToken || !list.length) return null;
|
||||
var best = qwPickBest(list, rawTitle, year);
|
||||
if (!best) return null;
|
||||
return qwHttpText(qwDetailUrl(best.id)).then(function (html) {
|
||||
if (token !== state.panToken) return;
|
||||
qwMerge(qwResList(qwParseDetail(html).resources), token);
|
||||
});
|
||||
}).then(null, function () {});
|
||||
}
|
||||
/* 详情页(七味来源条目):已知七味影片 id,直接取其站内下载资源 */
|
||||
function qwLoadById(id, token) {
|
||||
if (!id) return;
|
||||
qwHttpText(qwDetailUrl(id)).then(function (html) {
|
||||
if (token !== state.panToken) return;
|
||||
var d = qwParseDetail(html);
|
||||
if (state.currentDetail && !state.currentDetail.tmdb) {
|
||||
if (d.title) { state.currentDetail.rawTitle = d.title; showTitle(d.title); }
|
||||
var ov = $("detailOverview"); if (ov && !ov.textContent && d.overview && !isJunkOverview(d.overview)) { ov.textContent = d.overview; state.currentDetail.overviewFromSite = true; }
|
||||
if (d.poster && !state.currentDetail.poster) { state.currentDetail.poster = d.poster; setDetailHeroImages([d.poster], false); }
|
||||
}
|
||||
var pans = qwResList(d.resources);
|
||||
if (pans.length) renderResources(sortPanDefault(pans).concat(panSouKeep()));
|
||||
}, function () {});
|
||||
}
|
||||
/* 七味来源条目:顺便按标题反查盘链本站,命中就升级成原生条目 */
|
||||
function qwReverseLookup(item, token) {
|
||||
var title = item.title, yr = item.year || yearOf(item.title);
|
||||
apiGet(apiUrl("get_videos.php") + "?wd=" + encodeURIComponent(cleanTitle(title)) + "&pg=1&_t=" + Date.now(), "").then(function (d) {
|
||||
if (token !== state.panToken || state.currentItem !== item) return;
|
||||
var list = ((d && (d.list || d.data)) || []).map(mapVod);
|
||||
function score(it) { var s = 0; if (it.title === title) s += 8; else if (titleCloseContain(it.title, title)) s += 4; if (yr && it.year === yr) s += 3; return s; }
|
||||
var good = list.filter(function (it) { return score(it) >= 4; });
|
||||
if (good.length) openDetail(good.slice().sort(function (a, b) { return score(b) - score(a); })[0]);
|
||||
}, function () {});
|
||||
}
|
||||
/* 抓七味分类某一页 -> 成员数组(带 _qw 标记,点开走七味详情逻辑) */
|
||||
function qwFetchPage(cat, page) {
|
||||
return qwHttpText(qwListUrl(cat, page)).then(function (html) {
|
||||
return qwParseList(html).map(function (m) { m._qw = true; m._qwId = m.id; return m; });
|
||||
});
|
||||
}
|
||||
/* 首页「七味」一行:点标题/更多进「查看全部」(含电影/剧集/综艺/动漫/短剧分类切换) */
|
||||
function renderQwHomeRow(box) {
|
||||
var shell = makeRowShell("七味", function () { openQwCategory("1", "七味"); });
|
||||
box.appendChild(shell.section);
|
||||
var cached = state.qwCatCache["1"];
|
||||
var hasCache = !!(cached && cached.length);
|
||||
if (hasCache) fillRow(shell.row, cached.slice(0, 12), function (it) { openDetail(it); });
|
||||
else shell.row.replaceChildren(el("div", "mh-row-empty", "加载中…"));
|
||||
var load = function () {
|
||||
if (!document.body.contains(shell.row)) return null;
|
||||
return qwFetchPage("1", 1).then(function (members) {
|
||||
if (!document.body.contains(shell.row)) return;
|
||||
if (!members.length) { if (!hasCache) shell.row.replaceChildren(el("div", "mh-row-empty", "暂无数据")); return; }
|
||||
state.qwCatCache["1"] = members; saveHomeDisk();
|
||||
fillRow(shell.row, members.slice(0, 12), function (it) { openDetail(it); });
|
||||
}, function () { if (!hasCache) shell.row.replaceChildren(el("div", "mh-row-empty", "加载失败")); });
|
||||
};
|
||||
if (hasCache) { if (homeCacheStale()) homePrefetch(load); return; }
|
||||
rowLoad(shell.section, load);
|
||||
}
|
||||
/* 打开七味「查看全部」:顶部分类 chips(电影/剧集/综艺/动漫/短剧),滑到底续页(同蜗牛 wnLoadCat) */
|
||||
function openQwCategory(t, name) {
|
||||
saveHomeReturn();
|
||||
state.view = "category";
|
||||
$("homeView").style.display = "none"; $("catView").style.display = "";
|
||||
$("catTitle").textContent = name || "七味";
|
||||
$("clearSearch").style.display = "none"; $("clearRecent").style.display = "none";
|
||||
setNav(null); updateHeroVisible(); window.scrollTo(0, 0);
|
||||
navigate("category");
|
||||
state.searchMode = false; state.searchKw = "";
|
||||
state.activeCat = QW_CAT_SLUG; state.qwCatSel = t || "1"; state.activeGenre = "";
|
||||
$("genreChips").style.display = ""; renderQwGenreChips();
|
||||
qwLoadCat(true);
|
||||
}
|
||||
function renderQwGenreChips() {
|
||||
var box = $("genreChips"); if (!box) return;
|
||||
var frag = document.createDocumentFragment();
|
||||
QW_CATS.forEach(function (c, i) {
|
||||
var b = document.createElement("button");
|
||||
b.type = "button";
|
||||
b.className = "genre-chip focusable" + (state.qwCatSel === c.t ? " active" : "");
|
||||
b.textContent = c.name; b.setAttribute("data-genre", c.name);
|
||||
b.setAttribute("data-focus-row", "genreChips"); b.setAttribute("data-focus-index", String(i));
|
||||
b.onclick = (function (cc) { return function () { state.qwCatSel = cc.t; renderQwGenreChips(); window.scrollTo(0, 0); qwLoadCat(true); }; })(c);
|
||||
frag.appendChild(b);
|
||||
});
|
||||
box.replaceChildren(frag);
|
||||
}
|
||||
function qwLoadCat(reset) {
|
||||
var t = state.qwCatSel || "1";
|
||||
if (reset) {
|
||||
state.reqToken++;
|
||||
state.qwPage = 1; state.source = []; state.rendered = 0; state.noMore = false; state.loading = false; state.autoPages = 0;
|
||||
$("grid").replaceChildren(); $("grid").classList.remove("grid-recent"); $("browseNotice").replaceChildren();
|
||||
var c = state.qwCatCache[t];
|
||||
if (c && c.length) { qwPaintCat(c, state.reqToken, 1); return; }
|
||||
}
|
||||
if (state.loading || state.noMore) return;
|
||||
var token = state.reqToken, page = reset ? 1 : (state.qwPage + 1);
|
||||
state.loading = true; $("browseLoading").style.display = "block"; if (reset) $("browseSub").textContent = "加载中";
|
||||
qwFetchPage(t, page).then(function (list) {
|
||||
state.loading = false;
|
||||
if (token !== state.reqToken) return;
|
||||
$("browseLoading").style.display = "none";
|
||||
if (page === 1) state.qwCatCache[t] = list;
|
||||
qwPaintCat(list, token, page);
|
||||
}, function () {
|
||||
state.loading = false;
|
||||
if (token !== state.reqToken) return;
|
||||
$("browseLoading").style.display = "none"; state.noMore = true;
|
||||
if (!state.source.length) $("grid").replaceChildren(el("div", "mh-row-empty", "加载失败,返回重试"));
|
||||
});
|
||||
}
|
||||
function qwPaintCat(list, token, page) {
|
||||
if (token !== state.reqToken) return;
|
||||
$("browseLoading").style.display = "none";
|
||||
var have = {}; state.source.forEach(function (s) { have[s.id] = 1; });
|
||||
var fresh = (list || []).filter(function (it) { return !have[it.id]; });
|
||||
if (!fresh.length) { state.noMore = true; if (!state.source.length) { $("grid").replaceChildren(el("div", "mh-row-empty", "该分类暂无内容")); $("browseSub").textContent = ""; } return; }
|
||||
state.qwPage = page; state.source = state.source.concat(fresh); appendGrid();
|
||||
$("browseSub").textContent = state.source.length + " 部";
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
枫叶4K影院(cd-zj.com)集成:这是在线点播站(MacCMS / CupFox 主题),不走网盘,
|
||||
播放页里就是 m3u8 直链,属于"即点即播"。
|
||||
- 站点结构:列表卡片 .public-list-box;详情 /detail/{id}.html;
|
||||
播放页 /play/{id}-{线路号}-{集号}.html,页面里有标准的 player_aaaa(含 m3u8 直链);
|
||||
搜索 /cupfox-search/-------------.html?wd=关键词;分类 /type/{t}.html。
|
||||
- 首页:新增「枫叶」一行 + 完整分类页(电影/剧集/综艺/动漫/短剧,可翻页)。
|
||||
- 详情页:枫叶的线路/剧集直接并进资源列表(线路做 tab、每集一行,点播放即播),
|
||||
和网盘资源共存;搜索结果同样不分站源,同名的只在详情页体现。
|
||||
============================================================ */
|
||||
var FY_DEFAULT_BASE = "https://www.cd-zj.com";
|
||||
var FY_CAT_SLUG = "__fy__"; // 枫叶在分类视图里的内部 slug
|
||||
var FY_UA = "Mozilla/5.0 (Linux; Android 10) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Mobile Safari/537.36";
|
||||
/* 站点各线路(至臻4k / 蓝光-2k / 自营t·y·r,data-form 都是 yhm3u8)共用的外挂播放器。
|
||||
这类线路的 player_aaaa.url 只是个 token(形如 JD-xxxxx),要拼到这个播放器地址后面才能换到真视频地址。
|
||||
正常情况下地址会从站点的 MacPlayerConfig 里读出来,读不到时用这个兜底。 */
|
||||
var FY_PARSE_FALLBACK = "https://fgsrg.hzqingshan.com/player/?url=";
|
||||
/* 站点的播放器配置 JS(MacCMS 主题一般把 MacPlayerConfig 放在这类文件里,而不是内联在播放页) */
|
||||
var FY_CONFIG_JS = ["/static/js/playerconfig.js", "/template/e/js/playerconfig.js", "/static/js/player.js"];
|
||||
var _fyParseCache = null; // { from -> parse }
|
||||
function fyBase() { return trimSlash(config.fyBase || FY_DEFAULT_BASE); }
|
||||
function fyHost() { return fyBase().replace(/^https?:\/\//, "").split("/")[0]; }
|
||||
/* 枫叶站分类(对应 /type/{t}.html) */
|
||||
var FY_CATS = [
|
||||
{ t: "1", name: "电影" },
|
||||
{ t: "2", name: "剧集" },
|
||||
{ t: "3", name: "综艺" },
|
||||
{ t: "4", name: "动漫" },
|
||||
{ t: "5", name: "短剧" }
|
||||
];
|
||||
/* 枫叶站的「来源标签」页(/label/{tag}.html)——按视频来源平台分,正好对应腾讯/优酷/B站。
|
||||
注意:这是来源标签,不是内容分类,标签名跟里面是电影还是剧集无关,所以行标题直接用平台名。 */
|
||||
var FY_LABELS = [
|
||||
{ tag: "qq", name: "腾讯" },
|
||||
{ tag: "youku", name: "优酷" },
|
||||
{ tag: "bli", name: "哔哩" }
|
||||
];
|
||||
|
||||
function fyHttpText(url) {
|
||||
if (state.sdkReady && window.fm && fm.req) {
|
||||
return fm.req(url, { method: "GET", responseType: "text", timeout: 20, headers: { "User-Agent": FY_UA, "Referer": fyBase() + "/" } })
|
||||
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.body; });
|
||||
}
|
||||
return fetchTimeout(url, { headers: {} }).then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.text(); });
|
||||
}
|
||||
function fyParseDoc(html) {
|
||||
try {
|
||||
var s = String(html).replace(/<script[\s\S]*?<\/script>/gi, "").replace(/<style[\s\S]*?<\/style>/gi, "").replace(/<!--[\s\S]*?-->/g, "");
|
||||
return new DOMParser().parseFromString(s, "text/html");
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
/* 列表页 / 搜索页卡片 -> 影片对象。海报走百度图片代理,站点自己就是 no-referrer,用通用 imgSrc 即可 */
|
||||
function fyParseList(html) {
|
||||
var doc = fyParseDoc(html);
|
||||
if (!doc) return [];
|
||||
var out = [], seen = {};
|
||||
Array.from(doc.querySelectorAll(".public-list-box")).forEach(function (box) {
|
||||
var a = box.querySelector("a.public-list-exp, a[href*='/detail/']");
|
||||
if (!a) return;
|
||||
var m = (a.getAttribute("href") || "").match(/\/detail\/(\d+)\.html/);
|
||||
if (!m) return;
|
||||
var id = m[1]; if (seen[id]) return;
|
||||
var img = box.querySelector("img");
|
||||
var poster = img ? (img.getAttribute("data-src") || img.getAttribute("data-original") || img.getAttribute("src") || "") : "";
|
||||
var tEl = box.querySelector(".time-title, .public-list-button a");
|
||||
var title = ((a.getAttribute("title") || "") || (tEl ? tEl.textContent : "") || (img ? img.getAttribute("alt") : "") || "").trim();
|
||||
if (!title) return;
|
||||
var rk = box.querySelector(".public-list-prb i, .public-list-prb");
|
||||
var status = rk ? rk.textContent.trim() : "";
|
||||
var sub = box.querySelector(".public-list-subtitle");
|
||||
seen[id] = 1;
|
||||
out.push({
|
||||
id: "fy_" + id, title: title, poster: poster, meta: status, status: status,
|
||||
overview: sub ? sub.textContent.trim() : "",
|
||||
_fy: true, _fyId: id
|
||||
});
|
||||
});
|
||||
return out;
|
||||
}
|
||||
/* 详情页 -> { title, poster, overview, lines:[线路名], eps:[{sid,nid,name}] }
|
||||
剧集不依赖 DOM 里各线路区块的先后顺序(站点会按"排序"按钮改顺序),
|
||||
直接从 /play/{id}-{sid}-{nid}.html 链接里读 sid/nid,稳。 */
|
||||
function fyParseDetail(html, id) {
|
||||
var doc = fyParseDoc(html);
|
||||
if (!doc) return null;
|
||||
var h3 = doc.querySelector(".slide-info-title, .detail-info h3, h1");
|
||||
var title = h3 ? h3.textContent.trim() : "";
|
||||
var cover = doc.querySelector(".detail-pic img");
|
||||
var poster = cover ? (cover.getAttribute("data-src") || cover.getAttribute("src") || "") : "";
|
||||
var ovEl = doc.querySelector("#height_limit, .switch-box .text");
|
||||
var overview = ovEl ? ovEl.textContent.replace(/^简介\s*[::]\s*/, "").trim() : "";
|
||||
if (!overview) {
|
||||
var mt = doc.querySelector("meta[name='description']");
|
||||
overview = mt ? (mt.getAttribute("content") || "") : "";
|
||||
}
|
||||
var lines = [];
|
||||
Array.from(doc.querySelectorAll(".anthology-tab .swiper-slide")).forEach(function (s) {
|
||||
var c = s.cloneNode(true);
|
||||
var b = c.querySelector(".badge"); if (b && b.parentNode) b.parentNode.removeChild(b); // 去掉"(5)"这种集数角标
|
||||
lines.push((c.textContent || "").replace(/\s+/g, " ").trim());
|
||||
});
|
||||
var eps = [], eseen = {};
|
||||
Array.from(doc.querySelectorAll("a[href*='/play/']")).forEach(function (a) {
|
||||
var mm = (a.getAttribute("href") || "").match(/\/play\/(\d+)-(\d+)-(\d+)\.html/);
|
||||
if (!mm) return;
|
||||
if (String(mm[1]) !== String(id)) return; // 只要本片的,排除"猜你喜欢"里别的片子
|
||||
var k = mm[2] + "-" + mm[3]; if (eseen[k]) return; eseen[k] = 1;
|
||||
eps.push({ sid: parseInt(mm[2], 10), nid: parseInt(mm[3], 10), name: (a.textContent || "").trim() || ("第" + mm[3] + "集") });
|
||||
});
|
||||
eps.sort(function (a, b) { return (a.sid - b.sid) || (a.nid - b.nid); });
|
||||
return { title: title, poster: poster, overview: overview, lines: lines, eps: eps };
|
||||
}
|
||||
/* 详情 -> 资源条目。按需求:只保留「至臻4k」这条线路(它对应 player_aaaa.url 直接是 m3u8,
|
||||
解析快、能秒播;自营/蓝光那几条走外挂解析、慢且常失败,全部丢掉),其余交给盘搜兜底。 */
|
||||
function fyIsZhizhen(lineName) {
|
||||
var s = String(lineName || "").replace(/\s/g, "").toLowerCase();
|
||||
return s.indexOf("至臻") >= 0 || s.indexOf("4k") >= 0; // "至臻4k"
|
||||
}
|
||||
function fyResList(d, id) {
|
||||
if (!d || !d.eps || !d.eps.length) return [];
|
||||
var out = [];
|
||||
d.eps.forEach(function (e) {
|
||||
var line = d.lines[e.sid - 1] || ("线路" + e.sid);
|
||||
if (!fyIsZhizhen(line)) return; // 只留至臻4k
|
||||
out.push({
|
||||
type: "fy", url: "", password: "",
|
||||
title: e.name,
|
||||
groupName: line, quality: line,
|
||||
source: "枫叶 · 在线",
|
||||
state: "idle",
|
||||
_fy: true, _fyId: id, _fySid: e.sid, _fyNid: e.nid,
|
||||
_fyPlayPage: fyBase() + "/play/" + id + "-" + e.sid + "-" + e.nid + ".html"
|
||||
});
|
||||
});
|
||||
return out;
|
||||
}
|
||||
/* ============ 枫叶播放地址破解(移植自站点通用解法,覆盖至臻4k / 蓝光-2k / 自营 t·y·r) ============
|
||||
流程和一个能跑通的第三方实现完全对齐:
|
||||
1) 播放页里读 player_aaaa:url(可能 urlencode/base64)、encrypt、from
|
||||
2) url 本身就是 .m3u8/.mp4 直链 -> 直接用
|
||||
3) 否则读站点 /static/js/playerconfig.js,按 from 找到该线路的 parse(解析页)地址,找不到用兜底
|
||||
4) 请求 "parse + 待解析url" 得到解析页 iframe,从 #player-data 读 data-te(token) 和 data-bt
|
||||
5) POST 到 {解析页域名}{bt}mplayer.php,body=url=...&token=...,拿到 realUrl 和 urlmode
|
||||
6) 按 urlmode 做 1/2/3 三种解密(含 WyJ 开头的多轮 jsDecrypt3),得到真正的 m3u8/mp4
|
||||
7) 播放:fm.play(realUrl, title, { referer: 站点 }) —— 注意是位置参数,不是对象! */
|
||||
var FY_PARSE_FALLBACK = "https://fgsrg.hzqingshan.com/player/?url=";
|
||||
var FY_MD5_TEST = "098f6bcd4621d373cade4e832627b4f6"; // md5("test"),jsDecrypt1 用;避免引 CryptoJS 直接写死
|
||||
|
||||
function fyFixB64(s) { if (!s) return s; var miss = s.length % 4; if (miss) s += new Array(4 - miss + 1).join("="); return s; }
|
||||
function fyB64Decode(s) { try { return atob(fyFixB64(s)); } catch (e) { return ""; } }
|
||||
/* urlmode=1:base64 -> 与 md5("test") 异或 -> 再 base64 */
|
||||
function fyDecrypt1(data) {
|
||||
try {
|
||||
var decoded = fyB64Decode(data), key = FY_MD5_TEST, out = "";
|
||||
for (var i = 0; i < decoded.length; i++) out += String.fromCharCode(decoded.charCodeAt(i) ^ key.charCodeAt(i % key.length));
|
||||
return atob(out);
|
||||
} catch (e) { return data; }
|
||||
}
|
||||
/* urlmode=2:base64 -> 取每 3 个字符里的第 2 个,在固定表里 +59 位移 */
|
||||
function fyDecrypt2(data) {
|
||||
var chars = "PXhw7UT1B0a9kQDKZsjIASmOezxYG4CHo5Jyfg2b8FLpEvRr3WtVnlqMidu6cN";
|
||||
try {
|
||||
var dec = fyB64Decode(data), out = "";
|
||||
for (var i = 1; i < dec.length; i += 3) {
|
||||
var ch = dec[i], idx = chars.indexOf(ch);
|
||||
out += (idx !== -1) ? chars[(idx + 59) % 62] : ch;
|
||||
}
|
||||
return out;
|
||||
} catch (e) { return data; }
|
||||
}
|
||||
/* urlmode=3:a/b/cipher 三段,用字典表逐字符映射 */
|
||||
function fyDecrypt3(data) {
|
||||
try {
|
||||
var parts = String(data).split("/");
|
||||
if (parts.length >= 3) {
|
||||
var arr1 = JSON.parse(fyB64Decode(parts[0]));
|
||||
var arr2 = JSON.parse(fyB64Decode(parts[1]));
|
||||
var cipher = fyB64Decode(parts.slice(2).join("/")), result = "";
|
||||
for (var i = 0; i < cipher.length; i++) {
|
||||
var c = cipher[i], idx = arr2.indexOf(c);
|
||||
result += (idx !== -1) ? arr1[idx] : c;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
} catch (e) {}
|
||||
return data;
|
||||
}
|
||||
/* 通用文本请求(可带 method/headers/body),走 App 的 fm.req,退回 fetch */
|
||||
function fyReq(url, options) {
|
||||
options = options || {};
|
||||
if (state.sdkReady && window.fm && fm.req) {
|
||||
return fm.req(url, {
|
||||
method: options.method || "GET",
|
||||
responseType: "text",
|
||||
timeout: options.timeout || 25,
|
||||
headers: Object.assign({ "User-Agent": FY_UA, "Referer": fyBase() + "/" }, options.headers || {}),
|
||||
credentials: "include",
|
||||
body: options.body
|
||||
}).then(function (r) {
|
||||
var ok = (r.ok !== undefined) ? r.ok : (r.status >= 200 && r.status < 300);
|
||||
if (!ok && r.status !== 200) throw new Error(r.error || ("HTTP " + r.status));
|
||||
return r.body || r.content || r.data || r.text || "";
|
||||
});
|
||||
}
|
||||
var fo = { headers: Object.assign({ "Referer": fyBase() + "/" }, options.headers || {}) };
|
||||
if (options.method === "POST") { fo.method = "POST"; fo.body = options.body; }
|
||||
return fetchTimeout(url, fo).then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.text(); });
|
||||
}
|
||||
/* 播放页 -> player_aaaa 对象 */
|
||||
function fyParsePlayerInfo(html) {
|
||||
var h = String(html);
|
||||
var m = h.match(/var\s+player_aaaa\s*=\s*(\{[\s\S]*?\})\s*<\/script>/);
|
||||
if (!m) m = h.match(/player_aaaa\s*=\s*(\{[\s\S]*?\})\s*;/);
|
||||
if (!m) return null;
|
||||
var obj = null;
|
||||
try { obj = JSON.parse(m[1]); } catch (e) { return null; }
|
||||
return obj;
|
||||
}
|
||||
function fyIsDirect(u) { return /^https?:\/\//i.test(u) && /\.(m3u8|mp4|flv|ts)(\?|$|#)/i.test(u); }
|
||||
|
||||
/* 核心:破解一集的真实播放地址(对齐第三方可用实现) */
|
||||
function fyCrackPlayUrl(playPageUrl) {
|
||||
return fyReq(playPageUrl).then(function (html) {
|
||||
var pd = fyParsePlayerInfo(html);
|
||||
if (!pd) return null;
|
||||
var durl = pd.url || "";
|
||||
var encrypt = parseInt(pd.encrypt, 10) || 0;
|
||||
try {
|
||||
if (encrypt === 1) durl = decodeURIComponent(durl);
|
||||
else if (encrypt === 2) durl = decodeURIComponent(atob(durl));
|
||||
} catch (e) {}
|
||||
// 2) 本身就是直链
|
||||
if (/^http/i.test(durl) && (durl.indexOf(".m3u8") >= 0 || durl.indexOf(".mp4") >= 0)) return durl;
|
||||
// 3) 读 playerconfig.js,按 from 找 parse
|
||||
return fyReq(fyBase() + "/static/js/playerconfig.js").then(function (cfg) { return cfg; }, function () { return ""; }).then(function (configJs) {
|
||||
var parseApi = "", fromFlag = pd.from || "";
|
||||
if (fromFlag && configJs) {
|
||||
var re = new RegExp('"' + fromFlag + '":\\{[^}]*"parse":"([^"]+)"');
|
||||
var mm = configJs.match(re);
|
||||
if (mm) parseApi = mm[1].replace(/\\\//g, "/");
|
||||
}
|
||||
if (!parseApi && configJs) { var m2 = configJs.match(/"parse":"(http[^"]+)"/); if (m2) parseApi = m2[1].replace(/\\\//g, "/"); }
|
||||
if (!parseApi) parseApi = FY_PARSE_FALLBACK;
|
||||
// 4) 请求解析页 iframe
|
||||
var iframeUrl = parseApi + encodeURIComponent(durl);
|
||||
return fyReq(iframeUrl, { headers: { Referer: playPageUrl } }).then(function (iframeHtml) {
|
||||
var div = document.createElement("div");
|
||||
div.innerHTML = String(iframeHtml).replace(/<script[\s\S]*?<\/script>/gi, function (x) { return x; });
|
||||
var pde = div.querySelector("#player-data");
|
||||
if (!pde) {
|
||||
// 没有 player-data:解析页可能直接就把地址写在里面了
|
||||
var direct = fyDeepFindUrl(iframeHtml);
|
||||
return direct || iframeUrl;
|
||||
}
|
||||
var token = pde.getAttribute("data-te") || "";
|
||||
var bt = pde.getAttribute("data-bt") || "/player/";
|
||||
var apiHost;
|
||||
try { apiHost = new URL(parseApi).origin; } catch (e) { apiHost = parseApi.replace(/(https?:\/\/[^\/]+).*/, "$1"); }
|
||||
var apiUrl = apiHost + bt + "mplayer.php";
|
||||
var postData = "url=" + encodeURIComponent(durl) + "&token=" + encodeURIComponent(token);
|
||||
// 5) POST mplayer.php
|
||||
return fyReq(apiUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
"Referer": iframeUrl,
|
||||
"Origin": apiHost
|
||||
},
|
||||
body: postData
|
||||
}).then(function (apiResText) {
|
||||
var apiJson = null;
|
||||
try { apiJson = JSON.parse(apiResText); } catch (err) { return null; }
|
||||
var realUrl = apiJson.url || (apiJson.data && apiJson.data.url) || "";
|
||||
var urlmode = String(apiJson.urlmode || (apiJson.data && apiJson.data.urlmode) || "");
|
||||
// 6) 按 urlmode 解密
|
||||
if (urlmode === "1") realUrl = fyDecrypt1(realUrl);
|
||||
else if (urlmode === "2") realUrl = fyDecrypt2(realUrl);
|
||||
else if (urlmode === "3") realUrl = fyDecrypt3(realUrl);
|
||||
for (var i = 0; i < 3; i++) {
|
||||
if (typeof realUrl === "string" && realUrl.indexOf("WyJ") === 0 && realUrl.indexOf("/") >= 0) realUrl = fyDecrypt3(realUrl);
|
||||
else break;
|
||||
}
|
||||
if (realUrl && (realUrl.indexOf(".m3u8") >= 0 || realUrl.indexOf(".mp4") >= 0)) return realUrl;
|
||||
return null;
|
||||
}, function () { return null; });
|
||||
});
|
||||
});
|
||||
}).then(null, function () { return null; });
|
||||
}
|
||||
/* 破解失败时的普通嗅探:video/iframe/m3u8/mp4 兜底 */
|
||||
function fyRegexUrl(t) {
|
||||
var m = String(t).replace(/\\\//g, "/").match(/https?:\/\/[^\s"'\\<>]+\.(?:m3u8|mp4|flv)[^\s"'\\<>]*/i);
|
||||
return m ? m[0] : "";
|
||||
}
|
||||
function fyDeepFindUrl(text) {
|
||||
var t = String(text || ""); if (!t) return "";
|
||||
var plain = t.replace(/\\\//g, "/").replace(/\\u002[fF]/g, "/");
|
||||
var u = fyRegexUrl(plain); if (u) return u;
|
||||
try { u = fyRegexUrl(decodeURIComponent(plain)); if (u) return u; } catch (e) {}
|
||||
var cands = plain.match(/[A-Za-z0-9+/=]{24,}/g) || [];
|
||||
for (var i = 0; i < cands.length && i < 60; i++) {
|
||||
var raw = fyB64Decode(cands[i]); if (!raw) continue;
|
||||
var one = fyRegexUrl(raw); if (one) return one;
|
||||
try { one = fyRegexUrl(decodeURIComponent(raw)); if (one) return one; } catch (e2) {}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
function fySniff(playPageUrl) {
|
||||
return fyReq(playPageUrl).then(function (html) {
|
||||
var v = String(html).match(/<video[^>]+src="([^"]+)"/i); if (v) return v[1];
|
||||
var m3 = String(html).match(/https?:\/\/[^\s"'<>]+\.m3u8[^\s"'<>]*/i); if (m3) return m3[0];
|
||||
var mp = String(html).match(/https?:\/\/[^\s"'<>]+\.mp4[^\s"'<>]*/i); if (mp) return mp[0];
|
||||
var ifr = String(html).match(/<iframe[^>]+src="([^"]+)"/i); if (ifr) return ifr[1];
|
||||
return "";
|
||||
}, function () { return ""; });
|
||||
}
|
||||
/* 对外:解析一集 -> 真实地址(先破解,失败再嗅探) */
|
||||
function fyResolve(item) {
|
||||
return fyCrackPlayUrl(item._fyPlayPage).then(function (real) {
|
||||
if (real) { item.url = real; item._fyReferer = fyBase() + "/"; return real; }
|
||||
return fySniff(item._fyPlayPage).then(function (u) {
|
||||
if (u) { item.url = u; item._fyReferer = fyBase() + "/"; return u; }
|
||||
throw new Error("线路「" + (item.groupName || "") + "」没解析到播放地址");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* 即点即播:拿到 m3u8 直链后交给 App 播放器。
|
||||
不同 App 版本暴露的入口不一样,按可用性依次探测,最后退回 fm.pan.play(type=http,
|
||||
跟网盘直链走同一条播放通道)。都没有就当浏览器预览,只提示地址。 */
|
||||
/* 播放:这个 App 的 fm.play 是【位置参数】签名 —— fm.play(url, title, { referer }),
|
||||
不是传一个对象!之前"暂无播放数据"就是因为把对象当第一个参数传进去,url 变成了 undefined。
|
||||
这里以位置参数为主,另留几个常见变体做兜底(不同 App 版本命名不同)。 */
|
||||
function fyPlayEntries() {
|
||||
var out = [];
|
||||
if (!window.fm) return out;
|
||||
if (typeof fm.play === "function") out.push("fm.play");
|
||||
if (typeof fm.playUrl === "function") out.push("fm.playUrl");
|
||||
if (fm.player && typeof fm.player.play === "function") out.push("fm.player.play");
|
||||
if (fm.video && typeof fm.video.play === "function") out.push("fm.video.play");
|
||||
if (fm.pan && typeof fm.pan.play === "function") out.push("fm.pan.play");
|
||||
return out;
|
||||
}
|
||||
function fyPlayDirect(url, title, pic, referer, meta) {
|
||||
if (!window.fm) { toast("已解析:" + url); return Promise.resolve(); }
|
||||
var ref = referer || fyBase();
|
||||
var header = { "User-Agent": FY_UA, "Referer": fyBase() + "/" };
|
||||
// 对象式 payload:带剧名、海报、整季选集——支持的 App(如 FongMi 系)会用它渲染标题和"选集"
|
||||
var rich = Object.assign({
|
||||
url: url, title: title || "枫叶资源", name: (meta && meta.name) || title, pic: pic || "",
|
||||
type: "http", referer: ref, header: header
|
||||
}, meta || {});
|
||||
try {
|
||||
// 优先走能吃对象(含 episodes)的入口
|
||||
if (fm.player && typeof fm.player.play === "function") { state.fyLastPlayApi = "fm.player.play(obj)"; return Promise.resolve(fm.player.play(rich)); }
|
||||
if (fm.video && typeof fm.video.play === "function") { state.fyLastPlayApi = "fm.video.play(obj)"; return Promise.resolve(fm.video.play(rich)); }
|
||||
if (typeof fm.playVod === "function") { state.fyLastPlayApi = "fm.playVod(obj)"; return Promise.resolve(fm.playVod(rich)); }
|
||||
// 回退:位置参数单集播放(参考实现验证可用),标题仍是剧名+集名,不显示 url
|
||||
if (typeof fm.play === "function") { state.fyLastPlayApi = "fm.play"; return Promise.resolve(fm.play(url, title || "枫叶资源", { referer: ref, name: (meta && meta.name) || title, pic: pic || "", header: header })); }
|
||||
if (typeof fm.playUrl === "function") { state.fyLastPlayApi = "fm.playUrl"; return Promise.resolve(fm.playUrl(url, title || "枫叶资源", { referer: ref })); }
|
||||
if (fm.pan && typeof fm.pan.play === "function") { state.fyLastPlayApi = "fm.pan.play"; return Promise.resolve(fm.pan.play(rich)); }
|
||||
} catch (e) { return Promise.reject(e); }
|
||||
toast("App 没有可用的播放接口"); return Promise.reject(new Error("no play api"));
|
||||
}
|
||||
|
||||
/* ---------- 枫叶:列表 / 搜索 / 详情 ---------- */
|
||||
/* 分类页分页 URL:站点用的是 MacCMS 那套,不同主题路径不一样,
|
||||
这里按常见的两种依次试(第 1 页固定 /type/{t}.html) */
|
||||
function fyListUrls(t, page) {
|
||||
var b = fyBase();
|
||||
// 来源标签页:t 形如 "label:qq",第 1 页 /label/{tag}.html,后续 /label/{tag}/page/{N}
|
||||
if (String(t).indexOf("label:") === 0) {
|
||||
var tag = t.slice(6);
|
||||
if (!page || page <= 1) return [b + "/label/" + tag + ".html"];
|
||||
return [b + "/label/" + tag + "/page/" + page + ".html", b + "/label/" + tag + "/page/" + page];
|
||||
}
|
||||
if (!page || page <= 1) return [b + "/type/" + t + ".html"];
|
||||
return [b + "/type/" + t + "-" + page + ".html", b + "/show/" + t + "--------" + page + "---.html"];
|
||||
}
|
||||
function fyFetchList(t, page) {
|
||||
var urls = fyListUrls(t, page);
|
||||
function tryUrl(i) {
|
||||
if (i >= urls.length) return Promise.resolve([]);
|
||||
return fyHttpText(urls[i]).then(function (html) {
|
||||
var list = fyParseList(html);
|
||||
if (list.length) return list;
|
||||
return tryUrl(i + 1);
|
||||
}, function () { return tryUrl(i + 1); });
|
||||
}
|
||||
return tryUrl(0);
|
||||
}
|
||||
function fySearch(kw) {
|
||||
var u = fyBase() + "/cupfox-search/-------------.html?wd=" + encodeURIComponent(kw);
|
||||
return fyHttpText(u).then(function (html) { return fyParseList(html); }, function () { return []; });
|
||||
}
|
||||
/* 详情页:枫叶条目自己的播放列表 */
|
||||
function fyLoadById(id, token) {
|
||||
if (!id) return;
|
||||
fyHttpText(fyBase() + "/detail/" + id + ".html").then(function (html) {
|
||||
if (token !== state.panToken) return;
|
||||
var d = fyParseDetail(html, id);
|
||||
if (!d) { renderResources(panSouKeep().concat(zn4kKeep()).concat(qwKeep())); return; }
|
||||
if (state.currentDetail && !state.currentDetail.tmdb) {
|
||||
if (d.title) { state.currentDetail.rawTitle = d.title; showTitle(d.title); }
|
||||
var ov = $("detailOverview"); if (ov && !ov.textContent && !(state.currentDetail && state.currentDetail.tmdb) && d.overview && !isJunkOverview(d.overview)) { ov.textContent = d.overview; state.currentDetail.overviewFromSite = true; }
|
||||
if (d.poster && !state.currentDetail.poster && !(state.currentDetail && state.currentDetail.tmdb)) { state.currentDetail.poster = d.poster; setDetailHeroImages([d.poster], false); }
|
||||
}
|
||||
renderResources(fyResList(d, id).concat(panSouKeep()).concat(zn4kKeep()).concat(qwKeep()));
|
||||
}, function () {
|
||||
if (token !== state.panToken) return;
|
||||
renderResources(panSouKeep().concat(zn4kKeep()).concat(qwKeep()));
|
||||
});
|
||||
}
|
||||
/* 已并入详情页的枫叶播放条目(刷新时保留,同 panSouKeep / zn4kKeep) */
|
||||
function fyKeep() { return (state.panItems || []).filter(function (it) { return it._fy; }); }
|
||||
/* 盘链/指南来源的条目:按标题去枫叶搜同名,命中就把在线播放线路并进资源列表 */
|
||||
/* ============================================================
|
||||
蜗牛4K影院(zmi.kdns.fr)集成:MacCMS 站,/voddetail/{id}/、/vodsearch/、/vodshow/ 分页。
|
||||
⚠ 它不是在线点播站——详情页给的是 115/夸克 这类【网盘下载链接】(就在静态 HTML 里),
|
||||
所以解析出来并进「网盘资源」,走盘链同一套播放/检测/密码逻辑,不需要在线解析。
|
||||
同样"探测可达才显示"(该站可能需要代理):首页一行 + 搜索合并 + 详情网盘资源。
|
||||
============================================================ */
|
||||
var WN_DEFAULT_BASE = "https://zmi.kdns.fr";
|
||||
var WN_CAT_SLUG = "__wn__";
|
||||
var WN_UA = "Mozilla/5.0 (Linux; Android 10) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Mobile Safari/537.36";
|
||||
function wnBase() { return trimSlash(config.wnBase || WN_DEFAULT_BASE); }
|
||||
var WN_CATS = [
|
||||
{ t: "1", name: "电影" },
|
||||
{ t: "2", name: "剧集" },
|
||||
{ t: "3", name: "综艺" },
|
||||
{ t: "4", name: "动漫" }
|
||||
];
|
||||
function wnHttpText(url) { return httpTextMemo("wn|" + url, function () { return wnHttpTextRaw(url); }); }
|
||||
function wnHttpTextRaw(url) {
|
||||
if (state.sdkReady && window.fm && fm.req) {
|
||||
return fm.req(url, { method: "GET", responseType: "text", timeout: 20, headers: { "User-Agent": WN_UA, "Referer": wnBase() + "/" } })
|
||||
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.body; });
|
||||
}
|
||||
return fetchTimeout(url, { headers: {} }).then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.text(); });
|
||||
}
|
||||
function wnParseDoc(html) {
|
||||
try { return new DOMParser().parseFromString(String(html).replace(/<script[\s\S]*?<\/script>/gi, "").replace(/<style[\s\S]*?<\/style>/gi, ""), "text/html"); }
|
||||
catch (e) { return null; }
|
||||
}
|
||||
function wnAbs(u) { if (!u) return ""; if (/^https?:/i.test(u)) return u; if (u.indexOf("//") === 0) return "https:" + u; return wnBase() + (u.charAt(0) === "/" ? "" : "/") + u; }
|
||||
/* 列表/搜索卡片 -> 影片对象
|
||||
2026-07 站点改版为 panlian_dark v2.3 模板:卡片外层直接是 <a class="video-card" href="/voddetail/{id}/">,
|
||||
标题/分数/画质分别在 .video-title / .video-score / .video-episode / .video-meta 里;
|
||||
旧模板 .module-item 结构不再存在,这里同时兼容新旧两套选择器,任一模板都能解析出来。 */
|
||||
function wnParseList(html) {
|
||||
var doc = wnParseDoc(html);
|
||||
if (!doc) return [];
|
||||
var out = [], seen = {};
|
||||
Array.from(doc.querySelectorAll(".video-card, .module-item")).forEach(function (b) {
|
||||
var a = (b.tagName === "A") ? b : b.querySelector("a[href*='/voddetail/']");
|
||||
if (!a) return;
|
||||
var m = (a.getAttribute("href") || "").match(/\/voddetail\/(\d+)/);
|
||||
if (!m) return;
|
||||
var id = m[1]; if (seen[id]) return;
|
||||
var img = b.querySelector("img");
|
||||
var poster = img ? (img.getAttribute("data-original") || img.getAttribute("data-src") || img.getAttribute("src") || "") : "";
|
||||
var tEl = b.querySelector(".video-title, .module-item-title, .module-poster-item-title, .title");
|
||||
var title = sanitizeSiteTitle(((tEl ? tEl.textContent : "") || a.getAttribute("title") || (img ? img.getAttribute("alt") : "") || ""));
|
||||
if (!title) return;
|
||||
var note = b.querySelector(".video-episode, .video-meta, .module-item-note, .module-item-text");
|
||||
seen[id] = 1;
|
||||
out.push({
|
||||
id: "wn_" + id, title: title, poster: wnAbs(poster),
|
||||
meta: note ? note.textContent.trim() : "", status: note ? note.textContent.trim() : "",
|
||||
_wn: true, _wnId: id
|
||||
});
|
||||
});
|
||||
return out;
|
||||
}
|
||||
/* 详情页信息 + 网盘链接。蜗牛不是在线点播站——它详情页给的是 115/夸克 这类网盘下载链接,
|
||||
所以解析出来并进「网盘资源」,走盘链同一套播放/检测。
|
||||
2026-07 站点改版为 panlian_dark v2.3 模板:资源区块变成 .pan-group(组名 .pan-group-title,
|
||||
如"百度资源"/"115离线下载")+ .pan-link-item(标题 .pan-link-title,链接在 .pan-link-actions a[href]),
|
||||
旧的 .module-row-one 结构不再存在,这里按新结构精确解析,同时保留旧结构 / 通用兜底以防再次改版。 */
|
||||
function wnParseDetail(html, id) {
|
||||
var doc = wnParseDoc(html);
|
||||
if (!doc) return null;
|
||||
var h1 = doc.querySelector(".module-info-heading h1, .video-info-header h1, h1");
|
||||
var title = h1 ? h1.textContent.trim() : "";
|
||||
var pic = doc.querySelector(".premium-poster img, .module-item-pic img, .video-cover img, .module-info-poster img");
|
||||
var poster = pic ? (pic.getAttribute("data-original") || pic.getAttribute("data-src") || pic.getAttribute("src") || "") : "";
|
||||
var ov = doc.querySelector("#detailDescText, .detail-desc-text, .module-info-introduction-content, .video-info-content, .content");
|
||||
var overview = ov ? ov.textContent.replace(/\s+/g, " ").trim() : "";
|
||||
// 网盘链接:优先新模板 .pan-group,拿不到再退回旧 .module-row-one,最后是通用 href / data-clipboard-text 兜底
|
||||
var pans = [], seen = {};
|
||||
function pushPan(url, name) {
|
||||
url = String(url || "").trim();
|
||||
var mm = url.match(/https?:\/\/[^\s"'<>]+/);
|
||||
if (!mm) return; url = mm[0];
|
||||
var type = wnDiskType(url); if (!type) return;
|
||||
// 密码:?password= / &pwd= / 提取码
|
||||
var pwd = "";
|
||||
var pm = url.match(/[?&](?:password|pwd|pass|code)=([0-9a-zA-Z]+)/);
|
||||
if (pm) pwd = pm[1];
|
||||
var key = type + "|" + url.replace(/[?#].*$/, "");
|
||||
if (seen[key]) return; seen[key] = 1;
|
||||
pans.push({
|
||||
type: type, url: url, password: pwd,
|
||||
title: (name || "").trim(),
|
||||
groupName: (PAN_META[type] ? PAN_META[type].label : type),
|
||||
quality: (PAN_META[type] ? PAN_META[type].label : type),
|
||||
source: "蜗牛", state: "idle", _wn: true
|
||||
});
|
||||
}
|
||||
Array.from(doc.querySelectorAll(".pan-group")).forEach(function (grp) {
|
||||
Array.from(grp.querySelectorAll(".pan-link-item")).forEach(function (item) {
|
||||
var nameEl = item.querySelector(".pan-link-title");
|
||||
var linkEl = item.querySelector(".pan-link-actions a[href]");
|
||||
var url = linkEl ? linkEl.getAttribute("href") : "";
|
||||
if (!url) { var cp = item.querySelector("[data-copy]"); url = cp ? cp.getAttribute("data-copy") : ""; }
|
||||
if (!url) { var metaEl = item.querySelector(".pan-link-meta"); url = metaEl ? metaEl.textContent : ""; }
|
||||
pushPan(url, nameEl ? nameEl.textContent : "");
|
||||
});
|
||||
});
|
||||
if (!pans.length) Array.from(doc.querySelectorAll(".module-row-one, .module-row-info")).forEach(function (row) {
|
||||
var a = row.querySelector("[data-clipboard-text]");
|
||||
var url = a ? a.getAttribute("data-clipboard-text") : "";
|
||||
if (!url) { var ln = row.querySelector(".module-row-title i, a[href*='115.com'], a[href*='pan.']"); url = ln ? (ln.textContent || ln.getAttribute("href")) : ""; }
|
||||
var h4 = row.querySelector(".module-row-title h4, h4");
|
||||
pushPan(url, h4 ? h4.textContent : ""); // 下载区里的链接:通用类型也收(原盘站会用小众网盘)
|
||||
});
|
||||
if (!pans.length) {
|
||||
// 通用兜底一:直接扫全页 <a href>,按已知网盘域名收,避免站内链接误当资源
|
||||
Array.from(doc.querySelectorAll("a[href]")).forEach(function (a) {
|
||||
var u = a.getAttribute("href") || "";
|
||||
if (!/^https?:\/\//i.test(u)) return;
|
||||
if (wnDiskType(u) === "http" || !wnDiskType(u)) return; // 兜底阶段只收明确网盘域名
|
||||
var box = a.closest ? (a.closest("li") || a.closest("div")) : null;
|
||||
var nameEl = box ? box.querySelector("h3, h4, strong, .title, .name") : null;
|
||||
pushPan(u, (nameEl ? nameEl.textContent : "") || a.textContent || "");
|
||||
});
|
||||
}
|
||||
if (!pans.length) {
|
||||
// 通用兜底二:旧模板 data-clipboard-text,只认明确的网盘域名
|
||||
Array.from(doc.querySelectorAll("[data-clipboard-text]")).forEach(function (a) {
|
||||
var u = a.getAttribute("data-clipboard-text") || "";
|
||||
var mm = u.match(/https?:\/\/[^\s"'<>]+/); if (!mm) return;
|
||||
if (wnDiskType(mm[0]) === "http") return; // 兜底阶段不收通用链接
|
||||
pushPan(u, a.getAttribute("title") || "");
|
||||
});
|
||||
}
|
||||
return { title: title, poster: wnAbs(poster), overview: overview, pans: pans };
|
||||
}
|
||||
/* 网盘类型识别:已知网盘归类;原盘高清这类站还会用光雅盘等小众网盘,
|
||||
未知的也保留(归为通用 http),让用户至少能复制到链接,不要直接丢掉。 */
|
||||
function wnDiskType(u) {
|
||||
for (var i = 0; i < HOSTMAP.length; i++) if (HOSTMAP[i][0].test(u)) return HOSTMAP[i][1];
|
||||
if (/guangyapan\.com/i.test(u)) return "guangya";
|
||||
if (/magnet:/i.test(u)) return "magnet";
|
||||
if (/^https?:/i.test(u)) return "http"; // 其它网盘/直链:保留为通用
|
||||
return "";
|
||||
}
|
||||
function wnFetchList(t, page) {
|
||||
var b = wnBase();
|
||||
var urls;
|
||||
if (!page || page <= 1) {
|
||||
urls = [b + "/vodtype/" + t + "/", b + "/vodshow/" + t + "-----------/"];
|
||||
} else if (state._wnPageFn) {
|
||||
// 已经探到能用的分页写法,直接用它,不再逐个试
|
||||
urls = [state._wnPageFn(t, page)];
|
||||
} else {
|
||||
urls = [
|
||||
b + "/vodtype/" + t + "-" + page + "/",
|
||||
b + "/vodshow/" + t + "--------" + page + "---/",
|
||||
b + "/vodshow/" + t + "-----" + page + "------/",
|
||||
b + "/vodshow/" + t + "-" + page + "----------/",
|
||||
b + "/vodshow/" + t + "-------" + page + "----/",
|
||||
b + "/vodtype/" + t + "/page/" + page + "/",
|
||||
b + "/vodtype/" + t + ".html?page=" + page
|
||||
];
|
||||
}
|
||||
// 记住每个候选对应的"生成函数",命中后固化,后续页直接套用
|
||||
var gens = [
|
||||
function (tt, p) { return b + "/vodtype/" + tt + "-" + p + "/"; },
|
||||
function (tt, p) { return b + "/vodshow/" + tt + "--------" + p + "---/"; },
|
||||
function (tt, p) { return b + "/vodshow/" + tt + "-----" + p + "------/"; },
|
||||
function (tt, p) { return b + "/vodshow/" + tt + "-" + p + "----------/"; },
|
||||
function (tt, p) { return b + "/vodshow/" + tt + "-------" + p + "----/"; },
|
||||
function (tt, p) { return b + "/vodtype/" + tt + "/page/" + p + "/"; },
|
||||
function (tt, p) { return b + "/vodtype/" + tt + ".html?page=" + p; }
|
||||
];
|
||||
function tryUrl(i) {
|
||||
if (i >= urls.length) return Promise.resolve([]);
|
||||
return wnHttpText(urls[i]).then(function (html) {
|
||||
var list = wnParseList(html);
|
||||
if (list.length) {
|
||||
if (page > 1 && !state._wnPageFn && urls.length > 1) state._wnPageFn = gens[i];
|
||||
return list;
|
||||
}
|
||||
return tryUrl(i + 1);
|
||||
}, function () { return tryUrl(i + 1); });
|
||||
}
|
||||
return tryUrl(0);
|
||||
}
|
||||
function wnSearch(kw) {
|
||||
// MacCMS 搜索:/vodsearch/{wd}-------------/
|
||||
var u = wnBase() + "/vodsearch/" + encodeURIComponent(kw) + "-------------/";
|
||||
return wnHttpText(u).then(function (html) { return wnParseList(html); }, function () { return []; });
|
||||
}
|
||||
/* 已并入详情页的蜗牛网盘条目(刷新保留) */
|
||||
function wnKeep() { return (state.panItems || []).filter(function (it) { return it._wn; }); }
|
||||
/* 详情页:蜗牛条目——信息补全 + 把它的网盘链接并进「网盘资源」 */
|
||||
function wnLoadById(id, token) {
|
||||
wnHttpText(wnBase() + "/voddetail/" + id + "/").then(function (html) {
|
||||
if (token !== state.panToken) return;
|
||||
var d = wnParseDetail(html, id);
|
||||
if (d && state.currentDetail && !state.currentDetail.tmdb) {
|
||||
if (d.title) { state.currentDetail.rawTitle = d.title; showTitle(d.title); }
|
||||
var ovv = $("detailOverview"); if (ovv && !ovv.textContent && !(state.currentDetail && state.currentDetail.tmdb) && d.overview && !isJunkOverview(d.overview)) { ovv.textContent = d.overview; state.currentDetail.overviewFromSite = true; }
|
||||
if (d.poster && !state.currentDetail.poster && !(state.currentDetail && state.currentDetail.tmdb)) { state.currentDetail.poster = d.poster; setDetailHeroImages([d.poster], false); }
|
||||
}
|
||||
var pans = (d && d.pans) || [];
|
||||
renderResources(sortPanDefault(pans).concat(fyKeep()).concat(panSouKeep()).concat(zn4kKeep()).concat(qwKeep()));
|
||||
}, function () { if (token === state.panToken) renderResources(fyKeep().concat(panSouKeep()).concat(zn4kKeep()).concat(qwKeep())); });
|
||||
}
|
||||
/* 盘链/其它来源条目:按标题去蜗牛搜同名,把它的网盘链接并进「网盘资源」 */
|
||||
function wnEnrich(rawTitle, token) {
|
||||
wnEnrichDo(rawTitle, token);
|
||||
}
|
||||
function wnEnrichDo(rawTitle, token) {
|
||||
var kw = cleanTitle(rawTitle);
|
||||
if (!kw) return;
|
||||
var year = yearOf(rawTitle);
|
||||
wnSearch(kw).then(function (list) {
|
||||
if (token !== state.panToken || !list.length) return;
|
||||
var best = null, bestScore = 99;
|
||||
list.forEach(function (it) {
|
||||
var ts = titleScore(it.title, kw); if (ts > 3) return;
|
||||
var s = ts; if (year && (it.title || "").indexOf(year) >= 0) s -= 0.3;
|
||||
if (s < bestScore) { bestScore = s; best = it; }
|
||||
});
|
||||
if (!best) return;
|
||||
wnHttpText(wnBase() + "/voddetail/" + best._wnId + "/").then(function (html) {
|
||||
if (token !== state.panToken) return;
|
||||
var d = wnParseDetail(html, best._wnId);
|
||||
var pans = (d && d.pans) || [];
|
||||
if (!pans.length) return;
|
||||
var have = {};
|
||||
state.panItems.forEach(function (it) { have[it.type + "|" + normPanUrl(it.url || it.token)] = 1; });
|
||||
var add = pans.filter(function (p) { return !have[p.type + "|" + normPanUrl(p.url)]; });
|
||||
if (add.length) renderResources(state.panItems.concat(add));
|
||||
}, function () {}); // 蜗牛4K 不进反查诊断(诊断只显示 pomo)
|
||||
}, function () {});
|
||||
}
|
||||
/* 首页「蜗牛4K」一行:探测可达才显示 */
|
||||
/* 元素滚动到快可见时才执行 fn(懒加载)。用于首页靠下的网盘站行——
|
||||
一进主页就并发抓十几个站会挤爆带宽、拖慢首屏,这里改成滑到附近才请求。 */
|
||||
function lazyWhenVisible(el, fn) {
|
||||
if (!el || typeof IntersectionObserver === "undefined") { fn(); return; }
|
||||
var done = false;
|
||||
var io = new IntersectionObserver(function (entries) {
|
||||
for (var i = 0; i < entries.length; i++) {
|
||||
if (entries[i].isIntersecting && !done) { done = true; io.disconnect(); fn(); return; }
|
||||
}
|
||||
}, { rootMargin: "600px 0px" }); // 提前 600px 预加载,滑到时已就绪
|
||||
io.observe(el);
|
||||
}
|
||||
function renderWnRow(box) {
|
||||
var shell = makeRowShell("蜗牛", function () { openWnCategory("1", "蜗牛"); });
|
||||
box.appendChild(shell.section);
|
||||
var cached = state.wnCache["1"];
|
||||
var hasCache = !!(cached && cached.length);
|
||||
if (hasCache) fillRow(shell.row, cached.slice(0, 12), function (it) { openDetail(it); });
|
||||
else shell.row.replaceChildren(el("div", "mh-row-empty", "加载中…"));
|
||||
var load = function () {
|
||||
if (!document.body.contains(shell.row)) return null;
|
||||
return wnFetchList("1", 1).then(function (list) {
|
||||
if (!document.body.contains(shell.row)) return;
|
||||
if (!list.length) { if (!hasCache) shell.row.replaceChildren(el("div", "mh-row-empty", "暂无数据")); return; }
|
||||
state.wnCache["1"] = list; saveHomeDisk();
|
||||
fillRow(shell.row, list.slice(0, 12), function (it) { openDetail(it); });
|
||||
}, function () { if (!hasCache) shell.row.replaceChildren(el("div", "mh-row-empty", "加载失败")); });
|
||||
};
|
||||
if (hasCache) { if (homeCacheStale()) homePrefetch(load); return; }
|
||||
rowLoad(shell.section, load);
|
||||
}
|
||||
function openWnCategory(t, name) {
|
||||
saveHomeReturn();
|
||||
state.view = "category";
|
||||
$("homeView").style.display = "none"; $("catView").style.display = "";
|
||||
$("catTitle").textContent = name || "蜗牛";
|
||||
$("clearSearch").style.display = "none"; $("clearRecent").style.display = "none";
|
||||
setNav(null); updateHeroVisible(); window.scrollTo(0, 0);
|
||||
navigate("category");
|
||||
state.searchMode = false; state.searchKw = "";
|
||||
state.activeCat = WN_CAT_SLUG; state.wnCat = t || "1"; state.activeGenre = "";
|
||||
$("genreChips").style.display = ""; renderWnGenreChips();
|
||||
wnLoadCat(true);
|
||||
}
|
||||
function renderWnGenreChips() {
|
||||
var box = $("genreChips"); if (!box) return;
|
||||
var frag = document.createDocumentFragment();
|
||||
WN_CATS.forEach(function (c, i) {
|
||||
var b = document.createElement("button");
|
||||
b.type = "button";
|
||||
b.className = "genre-chip focusable" + (state.wnCat === c.t ? " active" : "");
|
||||
b.textContent = c.name; b.setAttribute("data-genre", c.name);
|
||||
b.setAttribute("data-focus-row", "genreChips"); b.setAttribute("data-focus-index", String(i));
|
||||
b.onclick = (function (cc) { return function () { state.wnCat = cc.t; renderWnGenreChips(); window.scrollTo(0, 0); wnLoadCat(true); }; })(c);
|
||||
frag.appendChild(b);
|
||||
});
|
||||
box.replaceChildren(frag);
|
||||
}
|
||||
function wnLoadCat(reset) {
|
||||
var t = state.wnCat || "1";
|
||||
if (reset) {
|
||||
state.reqToken++;
|
||||
state.wnPage = 1; state.source = []; state.rendered = 0; state.noMore = false; state.loading = false; state.autoPages = 0;
|
||||
$("grid").replaceChildren(); $("grid").classList.remove("grid-recent"); $("browseNotice").replaceChildren();
|
||||
var c = state.wnCache[t];
|
||||
if (c && c.length) { wnPaintCat(c, state.reqToken, 1); return; }
|
||||
}
|
||||
if (state.loading || state.noMore) return;
|
||||
var token = state.reqToken, page = reset ? 1 : (state.wnPage + 1);
|
||||
state.loading = true; $("browseLoading").style.display = "block"; if (reset) $("browseSub").textContent = "加载中";
|
||||
wnFetchList(t, page).then(function (list) {
|
||||
state.loading = false;
|
||||
if (token !== state.reqToken) return;
|
||||
$("browseLoading").style.display = "none";
|
||||
if (page === 1) state.wnCache[t] = list;
|
||||
wnPaintCat(list, token, page);
|
||||
}, function () {
|
||||
state.loading = false;
|
||||
if (token !== state.reqToken) return;
|
||||
$("browseLoading").style.display = "none"; state.noMore = true;
|
||||
if (!state.source.length) $("grid").replaceChildren(el("div", "mh-row-empty", "加载失败,返回重试"));
|
||||
});
|
||||
}
|
||||
function wnPaintCat(list, token, page) {
|
||||
if (token !== state.reqToken) return;
|
||||
$("browseLoading").style.display = "none";
|
||||
var have = {}; state.source.forEach(function (s) { have[s.id] = 1; });
|
||||
var fresh = (list || []).filter(function (it) { return !have[it.id]; });
|
||||
if (!fresh.length) { state.noMore = true; if (!state.source.length) { $("grid").replaceChildren(el("div", "mh-row-empty", "该分类暂无内容")); $("browseSub").textContent = ""; } return; }
|
||||
state.wnPage = page; state.source = state.source.concat(fresh); appendGrid();
|
||||
$("browseSub").textContent = state.source.length + " 部";
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
森中有林 / SeedHub(sidhub.cc)集成:影视&动漫网盘分享站,跟蜗牛一个路子(网盘资源站,
|
||||
不在线播放)。区别在网盘链接是两跳——详情页 .pan-links 里给的是中转页
|
||||
/link_start/?redirect_to=pan_id_xxx&movie_title=... ,要请求它才拿到真实网盘分享链接。
|
||||
详情 /movies/{id}/;卡片 ul.pictures>li>a[href*='/movies/']。首页一行 + 搜索合并 + 详情网盘资源。
|
||||
============================================================ */
|
||||
var SH_DEFAULT_BASE = "https://sidhub.cc";
|
||||
var SH_CAT_SLUG = "__sh__";
|
||||
var SH_UA = "Mozilla/5.0 (Linux; Android 10) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Mobile Safari/537.36";
|
||||
function shBase() { return trimSlash(config.shBase || SH_DEFAULT_BASE); }
|
||||
/* SeedHub 分类:/categories/{id}/movies/ */
|
||||
var SH_CATS = [
|
||||
{ id: "1", name: "电影" },
|
||||
{ id: "3", name: "剧集" },
|
||||
{ id: "2", name: "动漫" }
|
||||
];
|
||||
function shHttpText(url) { return httpTextMemo("sh|" + url, function () { return shHttpTextRaw(url); }); }
|
||||
function shHttpTextRaw(url) {
|
||||
if (state.sdkReady && window.fm && fm.req) {
|
||||
return fm.req(url, { method: "GET", responseType: "text", timeout: 20, headers: { "User-Agent": SH_UA, "Referer": shBase() + "/" } })
|
||||
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.body; });
|
||||
}
|
||||
return fetchTimeout(url, { headers: {} }).then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.text(); });
|
||||
}
|
||||
function shParseDoc(html) {
|
||||
try { return new DOMParser().parseFromString(String(html).replace(/<script[\s\S]*?<\/script>/gi, "").replace(/<style[\s\S]*?<\/style>/gi, ""), "text/html"); }
|
||||
catch (e) { return null; }
|
||||
}
|
||||
function shAbs(u) { if (!u) return ""; if (/^https?:/i.test(u)) return u; if (u.indexOf("//") === 0) return "https:" + u; return shBase() + (u.charAt(0) === "/" ? "" : "/") + u; }
|
||||
/* 列表/搜索/推荐卡片 -> 影片对象。卡片是 ul.pictures>li>a[href*='/movies/'] + img + 标题 */
|
||||
function shParseList(html) {
|
||||
var doc = shParseDoc(html);
|
||||
if (!doc) return [];
|
||||
var out = [], seen = {};
|
||||
Array.from(doc.querySelectorAll("a[href*='/movies/']")).forEach(function (a) {
|
||||
var m = (a.getAttribute("href") || "").match(/\/movies\/(\d+)\//);
|
||||
if (!m) return;
|
||||
var id = m[1]; if (seen[id]) return;
|
||||
// 卡片里要有图才算(排除面包屑/锚点)
|
||||
var scope = a.closest("li") || a.parentElement || a;
|
||||
var img = a.querySelector("img") || (scope ? scope.querySelector("img") : null);
|
||||
if (!img) return;
|
||||
var poster = img.getAttribute("data-original") || img.getAttribute("data-src") || img.getAttribute("src") || "";
|
||||
var title = sanitizeSiteTitle((a.getAttribute("title") || img.getAttribute("alt") || ""));
|
||||
if (!title) { var td = scope ? scope.querySelector("div a, .title, h2, h3") : null; title = td ? td.textContent.trim() : ""; }
|
||||
title = sanitizeSiteTitle(title.replace(/\s*-\s*SeedHub.*$/i, ""));
|
||||
if (!title) return;
|
||||
seen[id] = 1;
|
||||
out.push({ id: "sh_" + id, title: title, poster: shAbs(poster), meta: "", _sh: true, _shId: id });
|
||||
});
|
||||
return out;
|
||||
}
|
||||
/* 详情页 -> 信息 + 网盘中转链接(.pan-links)。真实链接需再请求 /link_start 解析。 */
|
||||
function shParseDetail(html, id) {
|
||||
var doc = shParseDoc(html);
|
||||
if (!doc) return null;
|
||||
var mt = doc.querySelector("meta[name='description']");
|
||||
var overview = mt ? (mt.getAttribute("content") || "") : "";
|
||||
var h1 = doc.querySelector("h1");
|
||||
var title = (h1 ? h1.textContent : "").replace(/\s*-\s*SeedHub.*$/i, "").trim();
|
||||
var cover = doc.querySelector("meta[property='og:image']");
|
||||
var poster = cover ? cover.getAttribute("content") : "";
|
||||
if (!poster) { var im = doc.querySelector(".cover img, #cover img, .pictures img"); poster = im ? (im.getAttribute("src") || "") : ""; }
|
||||
var links = [], perType = {}, seenHref = {};
|
||||
function addLink(href, type, label) {
|
||||
if (!href || seenHref[href]) return;
|
||||
perType[type] = (perType[type] || 0);
|
||||
if (perType[type] >= 20) return; // 每种网盘最多 20 条,够用又不至于几百条卡死
|
||||
perType[type]++; seenHref[href] = 1;
|
||||
links.push({ transit: shAbs(href), type: type, label: label });
|
||||
}
|
||||
// 网盘分享链接(夸克/百度/UC/迅雷等,走 /link_start?redirect_to=pan_id_xxx 中转)
|
||||
Array.from(doc.querySelectorAll("ul.pan-links li a[href*='link_start']")).forEach(function (a) {
|
||||
var href = a.getAttribute("href") || "";
|
||||
var dl = a.getAttribute("data-link") || "";
|
||||
var type = shDiskType(dl) || shDiskTypeFromText(a.getAttribute("title") || "") || "http";
|
||||
addLink(href, type, (a.getAttribute("title") || a.textContent || "").trim());
|
||||
});
|
||||
// 磁力/BT 种子(.seeds 区块,走 /link_start?seed_id=xxx 中转到 magnet)
|
||||
Array.from(doc.querySelectorAll("ul.seeds li a[href*='link_start'], ul.seeds li a[href^='magnet']")).forEach(function (a) {
|
||||
var href = a.getAttribute("href") || "";
|
||||
addLink(href, "magnet", (a.getAttribute("title") || a.textContent || "").trim());
|
||||
});
|
||||
return { title: title, poster: shAbs(poster), overview: overview, links: links };
|
||||
}
|
||||
function shDiskType(dl) {
|
||||
var s = String(dl || "");
|
||||
if (/quark/i.test(s)) return "quark";
|
||||
if (/baidu/i.test(s)) return "baidu";
|
||||
if (/uc\.cn/i.test(s)) return "uc";
|
||||
if (/xunlei/i.test(s)) return "xunlei";
|
||||
if (/aliyun|alipan/i.test(s)) return "aliyun";
|
||||
if (/189/i.test(s)) return "tianyi";
|
||||
if (/123/i.test(s)) return "123";
|
||||
if (/115/i.test(s)) return "115";
|
||||
if (/139|caiyun/i.test(s)) return "mobile";
|
||||
if (/guangya/i.test(s)) return "guangya";
|
||||
return "";
|
||||
}
|
||||
function shDiskTypeFromText(t) {
|
||||
t = String(t || "");
|
||||
if (/夸克/.test(t)) return "quark"; if (/百度/.test(t)) return "baidu"; if (/迅雷/.test(t)) return "xunlei";
|
||||
if (/UC/i.test(t)) return "uc"; if (/阿里/.test(t)) return "aliyun"; if (/天翼/.test(t)) return "tianyi";
|
||||
if (/夸克/.test(t)) return "quark"; return "";
|
||||
}
|
||||
/* 请求中转页拿真实网盘链接。SeedHub 中转页结构(已按真实页面对齐):
|
||||
真实链接在 <a class="direct-pan" href="...">;提取码在 .tqm-value(百度)/.tqmxl-value(迅雷),
|
||||
UC/夸克通常免提取码这些 span 为空。返回 { url, password }。 */
|
||||
function shResolveTransit(transitUrl) {
|
||||
return shHttpText(transitUrl).then(function (html) {
|
||||
var doc = shParseDoc(html);
|
||||
var url = "", pwd = "";
|
||||
if (doc) {
|
||||
var a = doc.querySelector("a.direct-pan[href], a.direct-link[href], a[href^='magnet:'], a[href*='uc.cn'], a[href*='quark'], a[href*='baidu']");
|
||||
if (a) url = a.getAttribute("href") || "";
|
||||
var pv = doc.querySelector(".tqm-value, .tqmxl-value, .tqm3-value");
|
||||
if (pv) { var t = (pv.textContent || "").trim(); if (/^[0-9a-zA-Z]{2,8}$/.test(t)) pwd = t; }
|
||||
}
|
||||
if (!url) { var mg = String(html).match(/magnet:\?xt=urn:[a-z0-9:]+[^\s"'<>]*/i); if (mg) url = mg[0]; }
|
||||
if (!url) url = shFindPanUrl(html); // 兜底:正则扫明链
|
||||
if (!url) {
|
||||
var mm = String(html).replace(/\\\//g, "/").match(/(?:location\.href\s*=\s*['"]|url=|href=['"])(https?:\/\/[^\s"'<>]+)/i);
|
||||
if (mm && shDiskTypeFromUrl(mm[1])) url = mm[1];
|
||||
}
|
||||
if (!pwd && url) { var pm = url.match(/[?&](?:pwd|password|code)=([0-9a-zA-Z]+)/); if (pm) pwd = pm[1]; }
|
||||
return url ? { url: url, password: pwd } : "";
|
||||
}, function () { return ""; });
|
||||
}
|
||||
function shDiskTypeFromUrl(u) {
|
||||
if (/^magnet:/i.test(u)) return "magnet";
|
||||
for (var i = 0; i < HOSTMAP.length; i++) if (HOSTMAP[i][0].test(u)) return HOSTMAP[i][1];
|
||||
if (/guangyapan\.com/i.test(u)) return "guangya";
|
||||
return "";
|
||||
}
|
||||
function shFindPanUrl(html) {
|
||||
var m = String(html).replace(/\\\//g, "/").match(/https?:\/\/(?:pan\.quark\.cn|pan\.baidu\.com|drive\.uc\.cn|fast\.uc\.cn|pan\.xunlei\.com|(?:www\.)?alipan\.com|aliyundrive\.com|cloud\.189\.cn|123[0-9a-z]*\.com|115\.com|caiyun\.139\.com|yun\.139\.com|guangyapan\.com)\/[^\s"'<>]+/i);
|
||||
return m ? m[0] : "";
|
||||
}
|
||||
function shListUrls(cat, page) {
|
||||
var b = shBase(), c = cat || "1";
|
||||
if (!page || page <= 1) return [b + "/categories/" + c + "/movies/"];
|
||||
return [b + "/categories/" + c + "/movies/?page=" + page];
|
||||
}
|
||||
function shFetchList(cat, page) {
|
||||
var urls = shListUrls(cat, page);
|
||||
function tryUrl(i) {
|
||||
if (i >= urls.length) return Promise.resolve([]);
|
||||
return shHttpText(urls[i]).then(function (html) { var list = shParseList(html); return list.length ? list : tryUrl(i + 1); }, function () { return tryUrl(i + 1); });
|
||||
}
|
||||
return tryUrl(0);
|
||||
}
|
||||
function shSearch(kw) {
|
||||
var b = shBase();
|
||||
var urls = [b + "/?s=" + encodeURIComponent(kw), b + "/search/" + encodeURIComponent(kw) + "/", b + "/search/?q=" + encodeURIComponent(kw)];
|
||||
function tryUrl(i) {
|
||||
if (i >= urls.length) return Promise.resolve([]);
|
||||
return shHttpText(urls[i]).then(function (html) { var list = shParseList(html); return list.length ? list : tryUrl(i + 1); }, function () { return tryUrl(i + 1); });
|
||||
}
|
||||
return tryUrl(0);
|
||||
}
|
||||
function shKeep() { return (state.panItems || []).filter(function (it) { return it._sh; }); }
|
||||
/* 把 SeedHub 详情的中转链接解析成真实网盘链接,并进「网盘资源」。
|
||||
并发解析(一次最多 6 个),每解出一条就渐进渲染进去,不必等全部解完——快且有反馈。 */
|
||||
function shResolveAndMerge(links, token, resetBase) {
|
||||
var pending = (links || []).slice();
|
||||
var CONC = 8, active = 0;
|
||||
function haveKey(p) { return p.type + "|" + normPanUrl(p.url); }
|
||||
function mergeOne(p) {
|
||||
if (token !== state.panToken) return;
|
||||
var have = {};
|
||||
state.panItems.forEach(function (it) { have[it.type + "|" + normPanUrl(it.url || it.token)] = 1; });
|
||||
if (have[haveKey(p)]) return;
|
||||
renderResources(state.panItems.concat([p])); // 渐进:解出一条就并进去
|
||||
}
|
||||
function pump() {
|
||||
if (token !== state.panToken) return;
|
||||
while (active < CONC && pending.length) {
|
||||
var lk = pending.shift(); active++;
|
||||
shResolveTransit(lk.transit).then(function (cap) {
|
||||
return function (real) {
|
||||
active--;
|
||||
if (token !== state.panToken) return;
|
||||
var realUrl = real && real.url ? real.url : "";
|
||||
if (realUrl) {
|
||||
var type = shDiskTypeFromUrl(realUrl) || cap.type || "http";
|
||||
var pwd = (real.password || "") || (realUrl.match(/[?&](?:pwd|password|code)=([0-9a-zA-Z]+)/) || [])[1] || "";
|
||||
mergeOne({
|
||||
type: type, url: realUrl, password: pwd, title: cap.label || "",
|
||||
groupName: (PAN_META[type] ? PAN_META[type].label : type), quality: (PAN_META[type] ? PAN_META[type].label : type),
|
||||
source: "SeedHub", state: "idle", _sh: true
|
||||
});
|
||||
}
|
||||
pump();
|
||||
};
|
||||
}(lk), function () { active--; pump(); });
|
||||
}
|
||||
}
|
||||
pump();
|
||||
}
|
||||
function shLoadById(id, token) {
|
||||
shHttpText(shBase() + "/movies/" + id + "/").then(function (html) {
|
||||
if (token !== state.panToken) return;
|
||||
var d = shParseDetail(html, id);
|
||||
if (d && state.currentDetail && !state.currentDetail.tmdb) {
|
||||
if (d.title) { state.currentDetail.rawTitle = d.title; showTitle(d.title); }
|
||||
var ovv = $("detailOverview"); if (ovv && !ovv.textContent && !(state.currentDetail && state.currentDetail.tmdb) && d.overview && !isJunkOverview(d.overview)) { ovv.textContent = d.overview; state.currentDetail.overviewFromSite = true; }
|
||||
if (d.poster && !state.currentDetail.poster && !(state.currentDetail && state.currentDetail.tmdb)) { state.currentDetail.poster = d.poster; setDetailHeroImages([d.poster], false); }
|
||||
}
|
||||
var links = (d && d.links) || [];
|
||||
if (!links.length) { renderResources(fyKeep().concat(panSouKeep()).concat(zn4kKeep()).concat(qwKeep())); return; }
|
||||
renderResources(fyKeep().concat(panSouKeep()).concat(zn4kKeep()).concat(qwKeep())); // 先出兜底,再逐条解析中转链并入
|
||||
shResolveAndMerge(links, token, true);
|
||||
}, function () { if (token === state.panToken) renderResources(fyKeep().concat(panSouKeep()).concat(zn4kKeep()).concat(qwKeep())); });
|
||||
}
|
||||
/* 其它来源条目:按标题去 SeedHub 搜同名,把它的网盘链接并进「网盘资源」 */
|
||||
function shEnrich(rawTitle, token) {
|
||||
var kw = cleanTitle(rawTitle); if (!kw) return;
|
||||
var year = yearOf(rawTitle);
|
||||
shSearch(kw).then(function (list) {
|
||||
if (token !== state.panToken || !list.length) return;
|
||||
var best = null, bestScore = 99;
|
||||
list.forEach(function (it) { var ts = titleScore(it.title, kw); if (ts > 3) return; var sc = ts; if (year && (it.title || "").indexOf(year) >= 0) sc -= 0.3; if (sc < bestScore) { bestScore = sc; best = it; } });
|
||||
if (!best) return;
|
||||
shHttpText(shBase() + "/movies/" + best._shId + "/").then(function (html) {
|
||||
if (token !== state.panToken) return;
|
||||
var d = shParseDetail(html, best._shId);
|
||||
if (d && d.links && d.links.length) shResolveAndMerge(d.links, token, false);
|
||||
}, function () {});
|
||||
}, function () {});
|
||||
}
|
||||
/* 首页「SeedHub」一行 */
|
||||
function renderShRow(box) {
|
||||
var shell = makeRowShell("SeedHub", function () { openShCategory("1", "SeedHub"); });
|
||||
box.appendChild(shell.section);
|
||||
var cached = state.shCache["1"];
|
||||
var hasCache = !!(cached && cached.length);
|
||||
if (hasCache) fillRow(shell.row, cached.slice(0, 12), function (it) { openDetail(it); });
|
||||
else shell.row.replaceChildren(el("div", "mh-row-empty", "加载中…"));
|
||||
var load = function () {
|
||||
if (!document.body.contains(shell.row)) return null;
|
||||
return shFetchList("1", 1).then(function (list) {
|
||||
if (!document.body.contains(shell.row)) return;
|
||||
if (!list.length) { if (!hasCache) shell.row.replaceChildren(el("div", "mh-row-empty", "暂无数据")); return; }
|
||||
state.shCache["1"] = list; saveHomeDisk();
|
||||
fillRow(shell.row, list.slice(0, 12), function (it) { openDetail(it); });
|
||||
}, function () { if (!hasCache) shell.row.replaceChildren(el("div", "mh-row-empty", "加载失败")); });
|
||||
};
|
||||
if (hasCache) { if (homeCacheStale()) homePrefetch(load); return; }
|
||||
rowLoad(shell.section, load);
|
||||
}
|
||||
function openShCategory(cat, name) {
|
||||
saveHomeReturn();
|
||||
state.view = "category";
|
||||
$("homeView").style.display = "none"; $("catView").style.display = "";
|
||||
$("catTitle").textContent = name || "SeedHub";
|
||||
$("clearSearch").style.display = "none"; $("clearRecent").style.display = "none";
|
||||
setNav(null); updateHeroVisible(); window.scrollTo(0, 0);
|
||||
navigate("category");
|
||||
state.searchMode = false; state.searchKw = "";
|
||||
state.activeCat = SH_CAT_SLUG; state.shCat = cat || "1"; state.activeGenre = "";
|
||||
$("genreChips").style.display = ""; renderShGenreChips();
|
||||
shLoadCat(true);
|
||||
}
|
||||
function renderShGenreChips() {
|
||||
var box = $("genreChips"); if (!box) return;
|
||||
var frag = document.createDocumentFragment();
|
||||
SH_CATS.forEach(function (c, i) {
|
||||
var b = document.createElement("button");
|
||||
b.type = "button";
|
||||
b.className = "genre-chip focusable" + (state.shCat === c.id ? " active" : "");
|
||||
b.textContent = c.name; b.setAttribute("data-genre", c.name);
|
||||
b.setAttribute("data-focus-row", "genreChips"); b.setAttribute("data-focus-index", String(i));
|
||||
b.onclick = (function (cc) { return function () { state.shCat = cc.id; renderShGenreChips(); window.scrollTo(0, 0); shLoadCat(true); }; })(c);
|
||||
frag.appendChild(b);
|
||||
});
|
||||
box.replaceChildren(frag);
|
||||
}
|
||||
function shLoadCat(reset) {
|
||||
var cat = state.shCat || "1";
|
||||
if (reset) {
|
||||
state.reqToken++;
|
||||
state.shPage = 1; state.source = []; state.rendered = 0; state.noMore = false; state.loading = false; state.autoPages = 0;
|
||||
$("grid").replaceChildren(); $("grid").classList.remove("grid-recent"); $("browseNotice").replaceChildren();
|
||||
var c = state.shCache[cat];
|
||||
if (c && c.length) { shPaintCat(c, state.reqToken, 1); return; }
|
||||
}
|
||||
if (state.loading || state.noMore) return;
|
||||
var token = state.reqToken, page = reset ? 1 : (state.shPage + 1);
|
||||
state.loading = true; $("browseLoading").style.display = "block"; if (reset) $("browseSub").textContent = "加载中";
|
||||
shFetchList(cat, page).then(function (list) {
|
||||
state.loading = false;
|
||||
if (token !== state.reqToken) return;
|
||||
$("browseLoading").style.display = "none";
|
||||
if (page === 1) state.shCache[cat] = list;
|
||||
shPaintCat(list, token, page);
|
||||
}, function () {
|
||||
state.loading = false;
|
||||
if (token !== state.reqToken) return;
|
||||
$("browseLoading").style.display = "none"; state.noMore = true;
|
||||
if (!state.source.length) $("grid").replaceChildren(el("div", "mh-row-empty", "加载失败,返回重试"));
|
||||
});
|
||||
}
|
||||
function shPaintCat(list, token, page) {
|
||||
if (token !== state.reqToken) return;
|
||||
$("browseLoading").style.display = "none";
|
||||
var have = {}; state.source.forEach(function (s) { have[s.id] = 1; });
|
||||
var fresh = (list || []).filter(function (it) { return !have[it.id]; });
|
||||
if (!fresh.length) { state.noMore = true; if (!state.source.length) { $("grid").replaceChildren(el("div", "mh-row-empty", "该分类暂无内容")); $("browseSub").textContent = ""; } return; }
|
||||
state.shPage = page; state.source = state.source.concat(fresh); appendGrid();
|
||||
$("browseSub").textContent = state.source.length + " 部";
|
||||
}
|
||||
|
||||
function fyEnrich(rawTitle, token) {
|
||||
fyEnrichDo(rawTitle, token);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
玩偶 / 木偶(MacCMS 网盘明链站,结构同原盘高清)。两站结构完全一致,
|
||||
只是域名/分类不同,用一个通用构造器生成,避免重复代码。
|
||||
详情页网盘链接是明链(整页正则扫出夸克/百度/UC/迅雷/阿里/115/123/天翼/移动/磁力)。
|
||||
============================================================ */
|
||||
var WO_SITES = {
|
||||
wo: {
|
||||
key: "wo", name: "玩偶", slug: "__wo__",
|
||||
domains: ["https://wogg.xxooo.cf", "https://www.wogg.net"],
|
||||
categoryUrl: "/vodshow/{cat}--------{page}---.html", // 玩偶专用伪静态
|
||||
// 玩偶站的搜索地址是已验证的固定格式(同 wogg 里的配置):直接给出,woSearch 就会走
|
||||
// "own" 分支只打这一个地址,不用先猜 ajax/suggest 接口在不在、也不用把 6 种通用格式
|
||||
// 挨个试一遍(那样跨 2 个域名 × 6 种格式,最坏情况会拖到超过 22 秒的搜索预算,
|
||||
// 表现就是"搜了但一直不出玩偶结果")。
|
||||
searchUrls: ["/vodsearch/-------------.html?wd={kw}&page=1"],
|
||||
cats: [["44", "臻彩"], ["1", "电影"], ["2", "电视剧"], ["3", "动漫"], ["4", "综艺"], ["6", "短剧"], ["46", "纪录片"]]
|
||||
},
|
||||
mo: {
|
||||
key: "mo", name: "木偶", slug: "__mo__",
|
||||
domains: ["https://666.666291.xyz"],
|
||||
// 木偶没有专用 categoryUrl,走下面的通用候选(MacCMS10 index.php 优先)
|
||||
cats: [["25", "臻选"], ["1", "电影"], ["2", "电视剧"], ["3", "动漫"], ["4", "纪录片"], ["29", "综艺"], ["30", "原盘"]]
|
||||
},
|
||||
pm: {
|
||||
key: "pm", name: "pomo", slug: "__pm__",
|
||||
domains: ["https://pomo.mom"],
|
||||
categoryUrl: function (cat, page) { // 首页无后缀,翻页加 /page/N
|
||||
var base = cat ? ("/" + cat) : "";
|
||||
return page > 1 ? [base + "/page/" + page] : [base + "/", base || "/"];
|
||||
},
|
||||
searchUrls: ["/?keyword={kw}"],
|
||||
cats: [["", "最新"], ["huayurm", "华语热门"], ["jiating", "家庭影院"],
|
||||
["donghuadadiany", "动画电影"], ["lengmenjiapian", "冷门佳片"],
|
||||
["paihangbang", "TOP250"], ["dianshiju", "电视剧"]]
|
||||
}
|
||||
};
|
||||
/* 所有网盘明链站,按定义顺序 */
|
||||
function woAll() { var a = [], k; for (k in WO_SITES) if (WO_SITES.hasOwnProperty(k)) a.push(WO_SITES[k]); return a; }
|
||||
var WO_UA = "Mozilla/5.0 (Linux; Android 10) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Mobile Safari/537.36";
|
||||
var WO_PAN_RE = /https?:\/\/(?:[\w.-]*\.)?(?:pan\.baidu\.com|pan\.quark\.cn|drive\.uc\.cn|fast\.uc\.cn|aliyundrive\.com|alipan\.com|aliyunpan\.com|cloud\.189\.cn|115\.com|115cdn\.[a-z0-9]+|123[a-z0-9]*\.(?:com|cn|net)|caiyun\.139\.com|yun\.139\.com|pan\.xunlei\.com|mypikpak\.com|guangyapan\.com)\/[A-Za-z0-9\-_/?=&%.#~+]*/gi;
|
||||
/* "别的片子"的区块名(整词匹配,见 inOtherBlock 的注释) */
|
||||
var WO_OTHER_BLOCK_RE = /(?:^|[-_])(?:related|relates|relate|recommend|recommends|recommended|guess|guesslike|tuijian|xgvod|sidebar|footer|comment|comments)(?:$|[-_])/i;
|
||||
/* 当前生效域名:优先用设置里手填的;否则用上次探测成功的;再否则第一个候选 */
|
||||
function woBase(site) {
|
||||
var custom = config.woBases && config.woBases[site.key];
|
||||
if (custom) return trimSlash(custom);
|
||||
if (state.woGood[site.key]) return trimSlash(state.woGood[site.key]);
|
||||
return trimSlash(site.domains[0]);
|
||||
}
|
||||
/* 该站所有候选域名(生效的排最前) */
|
||||
function woDomains(site) {
|
||||
var list = [];
|
||||
var cur = woBase(site);
|
||||
list.push(cur);
|
||||
site.domains.forEach(function (d) { if (trimSlash(d) !== cur) list.push(trimSlash(d)); });
|
||||
return list;
|
||||
}
|
||||
/* 连不上(超时/网络级失败)的域名短期黑名单。
|
||||
同一次搜索里,没有专属搜索地址的站(玩偶/木偶)会拿 6 种猜测格式反复打同几个域名;
|
||||
若某域名根本连不通,第一种格式打一次就记下来,后面 5 种格式直接跳过它,
|
||||
避免"6 格式 × 死域名 × 15 秒"层层叠加成几分钟。
|
||||
关键:只有连接级失败(超时/网络)才拉黑;HTTP 404 / 空页 / 人机校验挡板都说明
|
||||
域名是活的、只是这个路径/格式不对,绝不能因此拉黑域名。 */
|
||||
var WO_BAD_TTL = 90 * 1000;
|
||||
function woDomainBad(dom) { var t = state.woBad && state.woBad[dom]; return !!(t && (Date.now() - t) < WO_BAD_TTL); }
|
||||
function woMarkBad(dom) { if (!state.woBad) state.woBad = {}; state.woBad[dom] = Date.now(); }
|
||||
function woClearBad(dom) { if (state.woBad && state.woBad[dom]) delete state.woBad[dom]; }
|
||||
/* 给一条链一个总时间预算:不管里面几种格式、几个域名怎么试,到点就收工返回兜底值
|
||||
(搜索场景通常是空数组),个别死站最多拖到预算上限,不会把"读取中"拖成几分钟。 */
|
||||
function woWithBudget(promise, ms, fallback) {
|
||||
return new Promise(function (resolve) {
|
||||
var done = false;
|
||||
var timer = setTimeout(function () { if (!done) { done = true; resolve(fallback); } }, ms);
|
||||
promise.then(
|
||||
function (v) { if (!done) { done = true; clearTimeout(timer); resolve(v); } },
|
||||
function () { if (!done) { done = true; clearTimeout(timer); resolve(fallback); } }
|
||||
);
|
||||
});
|
||||
}
|
||||
/* 请求:给定"路径",自动在候选域名间切换,哪个通用哪个,并记住成功的域名 */
|
||||
function woHttpPath(site, path) { return httpTextMemo("wo|" + site.key + "|" + path, function () { return woHttpPathRaw(site, path); }); }
|
||||
function woDiag(line) {
|
||||
if (!state.woDiagList) state.woDiagList = [];
|
||||
if (state.woDiagList.length < 12) state.woDiagList.push(line);
|
||||
}
|
||||
function woHttpPathRaw(site, path) {
|
||||
var all = woDomains(site);
|
||||
// 跳过近期确认连不上的域名;但若全被拉黑了,仍整批试一遍,避免误杀(比如唯一域名、或黑名单过期边界)
|
||||
var live = all.filter(function (d) { return !woDomainBad(d); });
|
||||
var domains = live.length ? live : all;
|
||||
function tryOne(i) {
|
||||
if (i >= domains.length) return Promise.reject(new Error("all domains failed"));
|
||||
var dom = domains[i];
|
||||
var url = dom + path;
|
||||
var req;
|
||||
if (state.sdkReady && window.fm && fm.req) {
|
||||
req = fm.req(url, { method: "GET", responseType: "text", timeout: 15, credentials: "include",
|
||||
headers: { "User-Agent": WO_UA, "Referer": dom + "/" } })
|
||||
.then(function (r) { if (!r.ok) { var e = new Error("HTTP " + r.status); e.woAlive = true; throw e; } return r.body; });
|
||||
// r.ok=false 是服务器有应答(域名活着,只是这个路径不对)→ 标记 woAlive,不拉黑域名
|
||||
} else {
|
||||
req = fetchTimeout(url, { headers: {} }).then(function (r) { if (!r.ok) { var e = new Error("HTTP " + r.status); e.woAlive = true; throw e; } return r.text(); });
|
||||
}
|
||||
return req.then(function (html) {
|
||||
var guard = html && /_guard\/|easy_click|jsl_clearance|检测中|请稍候/i.test(html);
|
||||
woDiag(path + " " + (html ? html.length + "字节" : "空") + (guard ? " ← 人机校验挡板页" : "") +
|
||||
(html && html.length < 400 && !guard ? ("\n 内容:" + String(html).replace(/\s+/g, " ").slice(0, 200)) : ""));
|
||||
if (guard) woDiag(" 该站需先在 App 内置浏览器打开一次通过校验,之后本页会复用其 Cookie");
|
||||
if (!html || html.length < 300) { var e = new Error("empty"); e.woAlive = true; throw e; } // 有应答但内容空/太短:域名活着,路径不对
|
||||
state.woGood[site.key] = dom; // 记住能用的域名
|
||||
woClearBad(dom); // 之前若误判过,这次通了就洗白
|
||||
return { html: html, base: dom };
|
||||
}, function (e) {
|
||||
woDiag(path + " 失败 " + ((e && e.message) || "未知"));
|
||||
// 只有连接级失败(超时 / 网络 / 无应答)才拉黑域名;有应答的 HTTP/空页错误(woAlive)不拉黑。
|
||||
if (!(e && e.woAlive)) woMarkBad(dom);
|
||||
return tryOne(i + 1);
|
||||
});
|
||||
}
|
||||
return tryOne(0);
|
||||
}
|
||||
/* 兼容旧调用:woHttp(site, 完整url) -> 只取 path 再走多域名 */
|
||||
function woHttp(site, url) {
|
||||
var path = url;
|
||||
woDomains(site).forEach(function (d) { if (url.indexOf(d) === 0) path = url.slice(d.length); });
|
||||
if (/^https?:/i.test(path)) { var m = path.match(/^https?:\/\/[^/]+(\/.*)$/); path = m ? m[1] : "/"; }
|
||||
return woHttpPath(site, path).then(function (r) { return r.html; });
|
||||
}
|
||||
function woDoc(html) {
|
||||
try { return new DOMParser().parseFromString(String(html).replace(/<script[\s\S]*?<\/script>/gi, "").replace(/<style[\s\S]*?<\/style>/gi, ""), "text/html"); }
|
||||
catch (e) { return null; }
|
||||
}
|
||||
function woAbs(site, u) { if (!u) return ""; if (/^https?:/i.test(u)) return u; if (u.indexOf("//") === 0) return "https:" + u; return woBase(site) + (u.charAt(0) === "/" ? "" : "/") + u; }
|
||||
function woDiskType(u) {
|
||||
if (/^magnet:/i.test(u)) return "magnet";
|
||||
for (var i = 0; i < HOSTMAP.length; i++) if (HOSTMAP[i][0].test(u)) return HOSTMAP[i][1];
|
||||
if (/guangyapan\.com/i.test(u)) return "guangya";
|
||||
if (/^https?:/i.test(u)) return "http";
|
||||
return "";
|
||||
}
|
||||
function woParseList(site, html, base) {
|
||||
var d = woDoc(html); if (!d) return [];
|
||||
var out = [], seen = {};
|
||||
var els = d.querySelectorAll(".module-item, .module-search-item, #main .module-item, .module-items .module-item, .stui-vodlist__box, ul.stui-vodlist > li, .public-list-box, .video-info, ul.pic-list > li, .sr_lists > ul > li, .bg-cardbg");
|
||||
Array.from(els).forEach(function (el) {
|
||||
// 照 wogg 的 cardFrom:取任意第一个 <a href>,不假设 /voddetail/ 或 index.php 格式
|
||||
var a = el.querySelector("a[href]") || (el.closest ? el.closest("a[href]") : null) || el;
|
||||
var href = a && a.getAttribute ? (a.getAttribute("href") || "") : "";
|
||||
if (!href || href.charAt(0) === "#") return;
|
||||
var abs = woAbs2(base || woBase(site), href);
|
||||
if (seen[abs]) return;
|
||||
var img = el.querySelector("img");
|
||||
var poster = img ? (img.getAttribute("data-src") || img.getAttribute("data-original") || img.getAttribute("src") || "") : "";
|
||||
var tEl = el.querySelector(".module-item-title, .module-poster-item-title, .module-search-item-title, .video-title, .title, .video-name, h3 b, h3");
|
||||
var title = sanitizeSiteTitle((a && a.getAttribute && a.getAttribute("title")) || (img && img.getAttribute("alt")) || (tEl ? tEl.textContent : "") || (a ? a.textContent : "") || "");
|
||||
if (!title) return;
|
||||
seen[abs] = 1;
|
||||
out.push({ id: site.key + "_" + (out.length + 1) + "_" + Date.now(), title: title.slice(0, 60), poster: woAbs2(base || woBase(site), poster), meta: "", _wo: true, _woSite: site.key, _woHref: abs });
|
||||
});
|
||||
// 兜底:pomo 这类自定义主题(纯 Tailwind 工具类,没有 module-item / stui-vodlist 这些
|
||||
// MacCMS 约定类名)上面一条都选不中。改按结构认卡片:带海报图的站内链接就是一张卡。
|
||||
if (!out.length) out = woParseListLoose(site, d, base);
|
||||
return out;
|
||||
}
|
||||
/* 结构化兜底解析:<a href="站内详情页"> 里包一张 <img> 就当作一张影片卡。
|
||||
标题优先 a[title] -> img[alt] -> 卡片内的 h1~h4/.title。 */
|
||||
function woParseListLoose(site, d, base) {
|
||||
var out = [], seen = {};
|
||||
var rootBase = trimSlash(base || woBase(site));
|
||||
var imgs = d.querySelectorAll("a[href] img, a[href] > img");
|
||||
Array.from(imgs).forEach(function (img) {
|
||||
var a = img.closest ? img.closest("a[href]") : null;
|
||||
if (!a) return;
|
||||
var href = a.getAttribute("href") || "";
|
||||
if (!href || href.charAt(0) === "#" || /^(javascript|mailto|tel):/i.test(href)) return;
|
||||
var abs = woAbs2(rootBase, href);
|
||||
var path = "";
|
||||
try { path = new URL(abs).pathname; } catch (e) { path = abs.replace(/^https?:\/\/[^/]+/, ""); }
|
||||
if (!path || path === "/") return; // 站标 / 回首页的链接
|
||||
if (/\.(jpg|jpeg|png|webp|gif|css|js)$/i.test(path)) return;
|
||||
if (/^\/(?:page|tag|search|login|register|user|about)\b/i.test(path)) return;
|
||||
if (seen[abs]) return;
|
||||
var poster = img.getAttribute("data-src") || img.getAttribute("data-original") || img.getAttribute("src") || "";
|
||||
if (/logo|avatar|icon|placeholder/i.test(poster)) return; // 站标/头像不是海报
|
||||
var card = a.parentElement || a;
|
||||
var tEl = a.querySelector("h1,h2,h3,h4,.title,.name") || card.querySelector("h1,h2,h3,h4,.title,.name");
|
||||
var title = sanitizeSiteTitle(a.getAttribute("title") || img.getAttribute("alt") ||
|
||||
(tEl ? tEl.textContent : "") || a.textContent || "");
|
||||
if (!title) return;
|
||||
seen[abs] = 1;
|
||||
out.push({ id: site.key + "_L" + (out.length + 1) + "_" + Date.now(), title: title.slice(0, 60),
|
||||
poster: woAbs2(rootBase, poster), meta: "", _wo: true, _woSite: site.key, _woHref: abs });
|
||||
});
|
||||
return out;
|
||||
}
|
||||
function woAbs2(base, u) { if (!u) return ""; if (/^https?:/i.test(u)) return u; if (u.indexOf("//") === 0) return "https:" + u; try { return new URL(u, trimSlash(base) + "/").href; } catch (e) { return trimSlash(base) + (u.charAt(0) === "/" ? "" : "/") + u; } }
|
||||
function woParseDetail(site, html) {
|
||||
var d = woDoc(html);
|
||||
var title = "", poster = "", overview = "";
|
||||
if (d) {
|
||||
var h1 = d.querySelector(".module-info-heading h1, .video-info-header h1, h1, .movie-title, h2");
|
||||
title = h1 ? h1.textContent.trim() : "";
|
||||
var pic = d.querySelector(".module-info-poster img, .module-item-pic img, .detail-pic img");
|
||||
poster = pic ? (pic.getAttribute("data-original") || pic.getAttribute("data-src") || pic.getAttribute("src") || "") : "";
|
||||
var ov = d.querySelector(".module-info-introduction-content, .module-info-content, .video-info-content, .content");
|
||||
overview = ov ? ov.textContent.replace(/\s+/g, " ").trim() : "";
|
||||
}
|
||||
// 网盘链接:只在"下载/资源区块"里扫,不整页扫——整页扫会把详情页底部"相关推荐/其他影片"
|
||||
// 里别的片子的网盘链接也抓进来(就是"药神详情页里混进犬夜叉全集"的原因)。
|
||||
var pans = [], seen = {};
|
||||
function pushOne(u, rowTitle, primary) {
|
||||
u = String(u || "").replace(/&/g, "&").replace(/[,。、))"'<]+$/, "");
|
||||
if (!u) return;
|
||||
var type = woDiskType(u);
|
||||
var k = type + "|" + normPanUrl(u);
|
||||
if (seen[k]) return; seen[k] = 1;
|
||||
var pwd = (u.match(/[?&](?:pwd|password|code)=([0-9a-zA-Z]+)/) || [])[1] || "";
|
||||
pans.push({
|
||||
type: type, url: u, password: pwd, title: (rowTitle || "").replace(/\s+/g, " ").trim(),
|
||||
groupName: (PAN_META[type] ? PAN_META[type].label : type), quality: (PAN_META[type] ? PAN_META[type].label : type),
|
||||
source: site.name, state: "idle", _wo: true, _woPrimary: !!primary
|
||||
});
|
||||
}
|
||||
function pushFrom(scopeEl, rowTitle) {
|
||||
var raw = (scopeEl ? scopeEl.innerHTML : "");
|
||||
raw = String(raw).replace(/\\\//g, "/");
|
||||
var hits = [], mm;
|
||||
WO_PAN_RE.lastIndex = 0;
|
||||
while ((mm = WO_PAN_RE.exec(raw))) hits.push(mm[0]);
|
||||
// 磁力:不少站写在 data-url / 文本里而不是 href,所以按文本扫
|
||||
var MAG_RE = /magnet:\?xt=urn:btih:[A-Za-z0-9]{32,40}[^"'\s<>]*/gi, mg;
|
||||
while ((mg = MAG_RE.exec(raw))) hits.push(mg[0]);
|
||||
for (var hi = 0; hi < hits.length; hi++) pushOne(hits[hi], rowTitle, false);
|
||||
}
|
||||
/* 节点是否落在"相关推荐/猜你喜欢/侧栏/页脚"这类别的片子的区块里。
|
||||
注意必须按 class 的"整个词"判断:早先用子串匹配,Tailwind 的 relative
|
||||
会被 relat 命中,导致整页都算推荐区,头部的网盘按钮就被误杀了。 */
|
||||
function inOtherBlock(n) {
|
||||
for (var p = n; p && p.tagName; p = p.parentElement) {
|
||||
var tag = String(p.tagName).toLowerCase();
|
||||
if (tag === "footer" || tag === "aside") return true;
|
||||
var cn = p.className;
|
||||
cn = (cn && cn.baseVal !== undefined) ? cn.baseVal : cn; // SVG 元素的 className 是对象
|
||||
var toks = String(cn || "").split(/\s+/).concat(String(p.id || "").split(/\s+/));
|
||||
for (var ti = 0; ti < toks.length; ti++) {
|
||||
if (toks[ti] && WO_OTHER_BLOCK_RE.test(toks[ti])) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (d) {
|
||||
// ① 显式声明的网盘链接(href / data-url 等属性里直接写着的),无论它在页面哪儿都收。
|
||||
// pomo 的"一键转存"图标按钮就挂在标题区,不在任何资源行里 —— 只扫资源行会漏掉它。
|
||||
// 这些是页面自己声明的链接,不是正文里蒙出来的,所以可信,标记 primary 不参与片名过滤。
|
||||
var ATTRS = ["href", "data-url", "data-link", "data-href", "data-clipboard-text"];
|
||||
var linkNodes = d.querySelectorAll("a[href], [data-url], [data-link], [data-href], [data-clipboard-text]");
|
||||
Array.from(linkNodes).forEach(function (n) {
|
||||
if (inOtherBlock(n)) return;
|
||||
for (var ai = 0; ai < ATTRS.length; ai++) {
|
||||
var v = n.getAttribute(ATTRS[ai]);
|
||||
if (!v) continue;
|
||||
v = String(v).replace(/\\\//g, "/").trim();
|
||||
var isMag = /^magnet:\?xt=urn:btih:[A-Za-z0-9]{32,40}/i.test(v);
|
||||
WO_PAN_RE.lastIndex = 0;
|
||||
var isPan = WO_PAN_RE.test(v);
|
||||
if (!isMag && !isPan) continue;
|
||||
var t = n.getAttribute("title") || n.textContent || "";
|
||||
if (!t.trim()) { // 图标按钮没文字,往上找一层标题
|
||||
var host = n.closest ? (n.closest(".download-item") || n.closest("li") || n.parentElement) : n.parentElement;
|
||||
var th = host && host.querySelector ? host.querySelector(".x-dbjs-download-link, h4, .title, strong") : null;
|
||||
t = th ? th.textContent : "";
|
||||
}
|
||||
pushOne(v, t, true);
|
||||
}
|
||||
});
|
||||
// ② 每条资源行(含自己的标题),逐行扫——顺带拿到"这条资源叫什么",好过滤不相关的
|
||||
var rows = d.querySelectorAll(".module-row-one, .module-row-info, .module-row-text, .downlist li, .down-list li, .module-blocklist .module-row-one, .download-item, .x-dbjs-accordion-inner .download-links");
|
||||
if (rows.length) {
|
||||
Array.from(rows).forEach(function (row) {
|
||||
var t = row.querySelector(".module-row-title, .module-row-text h4, h4, .title, strong, .x-dbjs-download-link, a[data-url]");
|
||||
pushFrom(row, t ? t.textContent : "");
|
||||
});
|
||||
}
|
||||
// ③ 资源区整块(有的模板资源不在 module-row 里,而在一个下载 tab 容器里)
|
||||
if (!pans.length) {
|
||||
var box = d.querySelector(".module-tab-content, .module-downlist, .module-play-list, #download, .downurl, .pan-box");
|
||||
if (box) pushFrom(box, "");
|
||||
}
|
||||
}
|
||||
// 实在没有资源区块结构,才退回整页扫(老模板兜底)
|
||||
if (!pans.length) pushFrom(d && d.body ? d.body : { innerHTML: html }, "");
|
||||
// ④ 源码兜底:有的站(pomo 就是)把网盘链接放在 <script> 里的 JSON,或由 JS 运行时才插进
|
||||
// DOM —— woDoc 解析前会剥掉 <script>,这类链接在解析出的 DOM 里压根不存在,
|
||||
// 上面几轮怎么扫都扫不到(现象就是:磁力一堆、网盘零条)。
|
||||
// 所以只要 DOM 里一条"网盘"都没有(磁力不算),就直接对原始 HTML 文本再扫一遍。
|
||||
var hasDisk = false;
|
||||
for (var pi = 0; pi < pans.length; pi++) if (pans[pi].type !== "magnet") { hasDisk = true; break; }
|
||||
if (!hasDisk) {
|
||||
var rawAll = String(html || "")
|
||||
.replace(/\\\//g, "/") // JSON 里的 \/
|
||||
.replace(/\\u002[fF]/g, "/") // JSON 里的 \u002F
|
||||
.replace(/�*47;/g, "/").replace(/�*2[fF];/g, "/");
|
||||
var hits2 = [], m2;
|
||||
WO_PAN_RE.lastIndex = 0;
|
||||
while ((m2 = WO_PAN_RE.exec(rawAll))) hits2.push(m2[0]);
|
||||
for (var qi = 0; qi < hits2.length; qi++) pushOne(hits2[qi], "", true);
|
||||
}
|
||||
// 过滤:资源标题里若明显是"别的片子"(含另一个书名号片名、且跟当前片名完全不沾边),丢掉
|
||||
var selfKey = normTitleKey(title);
|
||||
if (selfKey) {
|
||||
pans = pans.filter(function (p) {
|
||||
var rt = p.title || ""; if (!rt) return true; // 没标题的保留(无法判断)
|
||||
var mk = rt.match(/[《【]([^》】]{2,30})[》】]/); // 资源标题里被书名号/方括号包起来的片名
|
||||
if (!mk) return true;
|
||||
var other = normTitleKey(mk[1]);
|
||||
if (!other) return true;
|
||||
// 资源里的片名 与 当前片名 互不包含 => 是别的片,丢弃
|
||||
return other.indexOf(selfKey) >= 0 || selfKey.indexOf(other) >= 0;
|
||||
});
|
||||
}
|
||||
return { title: title, poster: woAbs(site, poster), overview: overview, pans: pans };
|
||||
}
|
||||
function woFetchList(site, cat, page) {
|
||||
var urls;
|
||||
if (typeof site.categoryUrl === "function") { // 站点自己按页码拼(首页和翻页格式常常不同)
|
||||
urls = [].concat(site.categoryUrl(cat, page));
|
||||
} else if (site.categoryUrl) {
|
||||
urls = [site.categoryUrl.replace("{cat}", cat).replace("{page}", page)];
|
||||
} else {
|
||||
urls = [
|
||||
"/index.php/vod/show/id/" + cat + "/page/" + page + ".html",
|
||||
"/vodshow/" + cat + "--------" + page + "---.html",
|
||||
"/index.php/vod/show/id/" + cat + ".html",
|
||||
"/vodtype/" + cat + (page > 1 ? "-" + page : "") + ".html"
|
||||
];
|
||||
}
|
||||
state.woDiagList = [];
|
||||
function tryUrl(i) {
|
||||
if (i >= urls.length) return Promise.resolve([]);
|
||||
return woHttpPath(site, urls[i]).then(function (r) {
|
||||
var list = woParseList(site, r.html, r.base);
|
||||
woDiag(" ↑ 解析出 " + list.length + " 条");
|
||||
return list.length ? list : tryUrl(i + 1);
|
||||
}, function () { return tryUrl(i + 1); });
|
||||
}
|
||||
return tryUrl(0);
|
||||
}
|
||||
/* MacCMS 通用联想接口 /index.php/ajax/suggest?mid=1&wd=... —— 返回 JSON({list:[{id,name,pic}]})。
|
||||
很多轻量 App 搜这类站就是走它:不吃 HTML 搜索页那套伪静态/WAF 挡板,命中率通常最高。
|
||||
木偶的分类就是走 /index.php/vod/show/...(index.php 路由是开着的),所以它的 suggest 和
|
||||
详情页 /index.php/vod/detail/id/{id}.html 也走得通。玩偶等站若没有这接口,解析不出就返回空、
|
||||
自动退回下面的 6 种 HTML 搜索页格式,不会有副作用。 */
|
||||
/* —— 木偶搜索排障 ——(定位完把 WO_SEARCH_DIAG 关回 false 即可)
|
||||
只针对木偶(mo):把每次尝试(联想接口 + 各 HTML 搜索页)的域名/地址/字节/返回片段/解析条数
|
||||
收集起来,搜完在屏幕底部弹一个可点关闭的浮层。玩偶用的是同一套 woSearch 却能出,说明代码没问题,
|
||||
差异只可能在木偶服务器本身(域名死了、或搜索接口跟玩偶不一样),得看它实际返回什么才能配死。 */
|
||||
var WO_SEARCH_DIAG = false;
|
||||
function woSDiag(site, line) {
|
||||
if (!WO_SEARCH_DIAG || !site || site.key !== "mo") return;
|
||||
if (!state.woSDiag) state.woSDiag = [];
|
||||
if (state.woSDiag.length < 60) state.woSDiag.push(line);
|
||||
}
|
||||
function woSDiagShow(site) {
|
||||
if (!WO_SEARCH_DIAG || !site || site.key !== "mo") return;
|
||||
var box = document.getElementById("woSDiagBox");
|
||||
if (!box) {
|
||||
box = document.createElement("div");
|
||||
box.id = "woSDiagBox";
|
||||
box.style.cssText = "position:fixed;left:8px;right:8px;bottom:96px;z-index:70;max-height:44vh;overflow:auto;" +
|
||||
"padding:10px 12px;border-radius:12px;background:rgba(10,14,24,.94);color:#cfe0ff;font-size:11px;" +
|
||||
"line-height:1.5;white-space:pre-wrap;text-align:left;box-shadow:0 8px 30px rgba(0,0,0,.5);" +
|
||||
"border:1px solid rgba(160,190,255,.22);-webkit-user-select:text;user-select:text;";
|
||||
box.addEventListener("click", function () { box.style.display = "none"; });
|
||||
document.body.appendChild(box);
|
||||
}
|
||||
box.textContent = "【木偶搜索诊断】(点一下关闭)\n" + (state.woSDiag || []).join("\n");
|
||||
box.style.display = "block";
|
||||
}
|
||||
function woSnippet(t) { return String(t == null ? "" : t).replace(/\s+/g, " ").slice(0, 160); }
|
||||
function woSuggestParse(site, txt, dom) {
|
||||
var j; try { j = JSON.parse(txt); } catch (e) { return []; }
|
||||
var arr = j && (j.list || j.data || (j.info && j.info.list));
|
||||
if (!arr || !arr.length) return [];
|
||||
var base = trimSlash(dom || woBase(site)), out = [];
|
||||
arr.forEach(function (v) {
|
||||
var id = v.id || v.vod_id || v.ID; if (!id) return;
|
||||
var name = v.name || v.vod_name || v.title || ""; if (!name) return;
|
||||
var pic = v.pic || v.vod_pic || "";
|
||||
out.push({ id: site.key + "_s" + id, title: sanitizeSiteTitle(String(name)).slice(0, 60),
|
||||
poster: woAbs2(base, pic), meta: "", _wo: true, _woSite: site.key,
|
||||
_woHref: base + "/index.php/vod/detail/id/" + id + ".html" });
|
||||
});
|
||||
return out;
|
||||
}
|
||||
function woSuggest(site, ek) {
|
||||
var path = "/index.php/ajax/suggest?mid=1&limit=30&wd=" + ek;
|
||||
var all = woDomains(site);
|
||||
var live = all.filter(function (d) { return !woDomainBad(d); });
|
||||
var domains = live.length ? live : all;
|
||||
function tryOne(i) {
|
||||
if (i >= domains.length) return Promise.resolve([]);
|
||||
var dom = domains[i], url = dom + path, req;
|
||||
if (state.sdkReady && window.fm && fm.req) {
|
||||
req = fm.req(url, { method: "GET", responseType: "text", timeout: 12, credentials: "include",
|
||||
headers: { "User-Agent": WO_UA, "Referer": dom + "/", "X-Requested-With": "XMLHttpRequest" } })
|
||||
.then(function (r) { if (!r.ok) { var e = new Error("HTTP " + r.status); e.woAlive = true; throw e; } return r.body; });
|
||||
} else {
|
||||
req = fetchTimeout(url, { headers: { "X-Requested-With": "XMLHttpRequest" } })
|
||||
.then(function (r) { if (!r.ok) { var e = new Error("HTTP " + r.status); e.woAlive = true; throw e; } return r.text(); });
|
||||
}
|
||||
return req.then(function (txt) {
|
||||
state.woGood[site.key] = dom; woClearBad(dom);
|
||||
var list = woSuggestParse(site, txt, dom);
|
||||
if (!list.length) {
|
||||
// 有应答但没解析出:可能这个域名的接口挂了/返回的不是预期 JSON,继续换下一个域名再试
|
||||
woSDiag(site, "联想接口 " + dom + "\n → " + (txt ? txt.length + "字节" : "空") + ",解析出 0 条\n 返回片段:" + woSnippet(txt));
|
||||
return tryOne(i + 1);
|
||||
}
|
||||
woSDiag(site, "联想接口 " + dom + "\n → " + txt.length + "字节,解析出 " + list.length + " 条\n 片名:" +
|
||||
list.map(function (x) { return x.title; }).join(" / "));
|
||||
return list;
|
||||
}, function (e) {
|
||||
woSDiag(site, "联想接口 " + dom + " 失败:" + ((e && e.message) || "未知"));
|
||||
// 只有连接级失败(超时/网络/无应答)才拉黑域名;HTTP 500/404 说明域名活着、只是这个接口报错,
|
||||
// 不能拉黑(否则后面的 HTML 搜索页也会跳过它)。两种情况都继续换下一个域名。
|
||||
if (!(e && e.woAlive)) woMarkBad(dom);
|
||||
return tryOne(i + 1);
|
||||
});
|
||||
}
|
||||
return tryOne(0);
|
||||
}
|
||||
/* 搜索总预算:6 种格式 × 多域名再怎么试,最多拖到这个上限就返回已有结果(通常为空),
|
||||
其余数据源各自并发、各自到各自渲染,不会被某个死站拖着一起转圈。 */
|
||||
var WO_SEARCH_BUDGET_MS = 22000;
|
||||
function woSearch(site, kw) {
|
||||
var ek = encodeURIComponent(kw);
|
||||
var inner;
|
||||
if (site.searchUrls && site.searchUrls.length) { // 站点自带搜索地址优先
|
||||
var own = site.searchUrls.map(function (u) { return u.replace("{kw}", ek); });
|
||||
inner = (function tryOwn(i) {
|
||||
if (i >= own.length) return Promise.resolve([]);
|
||||
return woHttpPath(site, own[i]).then(function (r) {
|
||||
var list = woParseList(site, r.html, r.base);
|
||||
return list.length ? list : tryOwn(i + 1);
|
||||
}, function () { return tryOwn(i + 1); });
|
||||
})(0);
|
||||
} else {
|
||||
var urls = [
|
||||
"/index.php/vod/search/page/1/wd/" + ek + ".html",
|
||||
"/vodsearch/-------------.html?wd=" + ek + "&page=1",
|
||||
"/index.php/vod/search/wd/" + ek + ".html",
|
||||
"/vodsearch/" + ek + "----------1---.html",
|
||||
"/index.php/vod/search.html?wd=" + ek,
|
||||
"/search.html?wd=" + ek
|
||||
];
|
||||
var tryHtml = function tryUrl(i) {
|
||||
if (i >= urls.length) return Promise.resolve([]);
|
||||
return woHttpPath(site, urls[i]).then(function (r) {
|
||||
var list = woParseList(site, r.html, r.base);
|
||||
woSDiag(site, "搜索页 " + urls[i] + "\n → " + (r.html ? r.html.length + "字节" : "空") + ",解析出 " + list.length + " 条");
|
||||
return list.length ? list : tryUrl(i + 1);
|
||||
}, function () { woSDiag(site, "搜索页 " + urls[i] + " 全部域名失败/被挡"); return tryUrl(i + 1); });
|
||||
};
|
||||
if (site.key === "mo") state.woSDiag = []; // 每次木偶搜索前清空诊断缓冲
|
||||
// 先走 MacCMS 联想接口(最稳),没命中再退回 6 种 HTML 搜索页格式。
|
||||
inner = woSuggest(site, ek).then(function (list) {
|
||||
return (list && list.length) ? list : tryHtml(0);
|
||||
}, function () { return tryHtml(0); }).then(function (list) {
|
||||
if (site.key === "mo") {
|
||||
woSDiag(site, "———\n最终:" + ((list && list.length) || 0) + " 条");
|
||||
woSDiagShow(site);
|
||||
}
|
||||
return list;
|
||||
});
|
||||
}
|
||||
return woWithBudget(inner, WO_SEARCH_BUDGET_MS, []);
|
||||
}
|
||||
function woKeep() { return (state.panItems || []).filter(function (it) { return it._wo; }); }
|
||||
/* 详情:直接请求列表卡片里存的完整 href(不假设 URL 格式,照 wogg 做法) */
|
||||
function woHttpAbs(site, absUrl) {
|
||||
if (state.sdkReady && window.fm && fm.req) {
|
||||
return fm.req(absUrl, { method: "GET", responseType: "text", timeout: 15, headers: { "User-Agent": WO_UA, "Referer": absUrl } })
|
||||
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.body; });
|
||||
}
|
||||
return fetchTimeout(absUrl, { headers: {} }).then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.text(); });
|
||||
}
|
||||
function woLoadByHref(site, href, token) {
|
||||
woDiagPan("本站详情页 " + href, site);
|
||||
woHttpAbs(site, href).then(function (html) {
|
||||
if (token !== state.panToken) return;
|
||||
var guard = html && /_guard\/|easy_click|jsl_clearance|检测中|请稍候/i.test(html);
|
||||
woDiagPan(" 返回 " + (html ? html.length : 0) + " 字节" + (guard ? " ← 人机校验挡板页" : ""), site);
|
||||
var d = woParseDetail(site, html);
|
||||
var hist = {}, hk = [];
|
||||
d.pans.forEach(function (p) { if (!hist[p.type]) { hist[p.type] = 0; hk.push(p.type); } hist[p.type]++; });
|
||||
woDiagPan(" 解析出盘链 " + d.pans.length + " 条" +
|
||||
(hk.length ? ":" + hk.map(function (t) { return t + "×" + hist[t]; }).join(" ") : ""), site);
|
||||
if (state.currentDetail && !state.currentDetail.tmdb) {
|
||||
if (d.title) { state.currentDetail.rawTitle = d.title; showTitle(d.title); }
|
||||
var ovv = $("detailOverview"); if (ovv && !ovv.textContent && !(state.currentDetail && state.currentDetail.tmdb) && d.overview && !isJunkOverview(d.overview)) { ovv.textContent = d.overview; state.currentDetail.overviewFromSite = true; }
|
||||
if (d.poster && !state.currentDetail.poster && !(state.currentDetail && state.currentDetail.tmdb)) { state.currentDetail.poster = d.poster; setDetailHeroImages([d.poster], false); }
|
||||
}
|
||||
renderResources(sortPanDefault(d.pans).concat(fyKeep()).concat(panSouKeep()).concat(zn4kKeep()).concat(qwKeep()));
|
||||
}, function (e) {
|
||||
if (token !== state.panToken) return;
|
||||
woDiagPan(" 详情页请求失败:" + ((e && e.message) || "未知"), site);
|
||||
renderResources(fyKeep().concat(panSouKeep()).concat(zn4kKeep()).concat(qwKeep()));
|
||||
});
|
||||
}
|
||||
function panDiagBox() {
|
||||
var box = document.getElementById("panDiagBox");
|
||||
if (!box) {
|
||||
var host = document.getElementById("panList");
|
||||
if (!host) return null;
|
||||
box = el("div", "pan-empty", "");
|
||||
box.id = "panDiagBox";
|
||||
box.style.whiteSpace = "pre-wrap"; box.style.fontSize = "11px";
|
||||
box.style.textAlign = "left"; box.style.opacity = ".75"; box.style.marginTop = "10px";
|
||||
host.appendChild(box);
|
||||
}
|
||||
return box;
|
||||
}
|
||||
/* 反查诊断只保留 pomo 一站,避免多站刷屏把它挤掉。
|
||||
平时关掉不显示;要再排查时把 PAN_DIAG_ON 改成 true 即可。 */
|
||||
var PAN_DIAG_ON = false;
|
||||
var PAN_DIAG_KEY = "pm";
|
||||
function woDiagPan(line, site) {
|
||||
if (!PAN_DIAG_ON) return;
|
||||
if (!site || site.key !== PAN_DIAG_KEY) return;
|
||||
if (!state.panDiag) state.panDiag = [];
|
||||
if (state.panDiag.length < 40) state.panDiag.push(line);
|
||||
var box = panDiagBox();
|
||||
if (box) { box.textContent = "【反查诊断 · pomo】\n" + state.panDiag.join("\n"); box.style.display = "block"; }
|
||||
}
|
||||
function woEnrich(site, rawTitle, token) {
|
||||
var kw = cleanTitle(rawTitle); if (!kw) return;
|
||||
var year = yearOf(rawTitle);
|
||||
woDiagPan("搜「" + kw + "」…", site);
|
||||
woSearch(site, kw).then(function (list) {
|
||||
if (token !== state.panToken) return;
|
||||
woDiagPan(" 命中 " + ((list && list.length) || 0) + " 条" +
|
||||
(list && list.length ? ":" + list.slice(0, 4).map(function (x) { return (x.title || "").slice(0, 18); }).join(" / ") : ""), site);
|
||||
if (!list.length) return;
|
||||
var best = null, bestScore = 99;
|
||||
list.forEach(function (it) { var ts = titleScore(it.title, kw); if (ts > 3) return; var sc = ts; if (year && (it.title || "").indexOf(year) >= 0) sc -= 0.3; if (sc < bestScore) { bestScore = sc; best = it; } });
|
||||
if (!best || !best._woHref) { woDiagPan(" 没有匹配上(标题差异过大)", site); return; }
|
||||
woDiagPan(" 选中:" + (best.title || "").slice(0, 22), site);
|
||||
woHttpAbs(site, best._woHref).then(function (html) {
|
||||
if (token !== state.panToken) return;
|
||||
var d = woParseDetail(site, html);
|
||||
woDiagPan(" 详情页 " + (html ? html.length : 0) + "字节,盘链 " + d.pans.length + " 条", site);
|
||||
if (!d.pans.length) return;
|
||||
var have = {};
|
||||
state.panItems.forEach(function (it) { have[it.type + "|" + normPanUrl(it.url || it.token)] = 1; });
|
||||
var add = d.pans.filter(function (p) { return !have[p.type + "|" + normPanUrl(p.url)]; });
|
||||
if (add.length) renderResources(state.panItems.concat(add));
|
||||
}, function (e) { woDiagPan(" 详情页取不到:" + ((e && e.message) || "未知"), site); });
|
||||
}, function (e) { woDiagPan(" 搜索失败:" + ((e && e.message) || "未知"), site); });
|
||||
}
|
||||
/* 首页行 */
|
||||
function renderWoRow(box, site) {
|
||||
var shell = makeRowShell(site.name, function () { openWoCategory(site, site.cats[0][0], site.name); });
|
||||
box.appendChild(shell.section);
|
||||
var ck = site.key + ":" + site.cats[0][0];
|
||||
var cached = state.woCache[ck];
|
||||
var hasCache = !!(cached && cached.length);
|
||||
if (hasCache) fillRow(shell.row, cached.slice(0, 12), function (it) { openDetail(it); });
|
||||
else shell.row.replaceChildren(el("div", "mh-row-empty", "加载中…"));
|
||||
var load = function () {
|
||||
if (!document.body.contains(shell.row)) return null;
|
||||
return woFetchList(site, site.cats[0][0], 1).then(function (list) {
|
||||
if (!document.body.contains(shell.row)) return;
|
||||
if (!list.length) { if (!hasCache) shell.row.replaceChildren(el("div", "mh-row-empty", "暂无数据")); return; }
|
||||
state.woCache[ck] = list; saveHomeDisk();
|
||||
fillRow(shell.row, list.slice(0, 12), function (it) { openDetail(it); });
|
||||
}, function () { if (!hasCache) shell.row.replaceChildren(el("div", "mh-row-empty", "加载失败")); });
|
||||
};
|
||||
if (hasCache) { if (homeCacheStale()) homePrefetch(load); return; }
|
||||
rowLoad(shell.section, load);
|
||||
}
|
||||
function openWoCategory(site, cat, name) {
|
||||
saveHomeReturn();
|
||||
state.view = "category";
|
||||
$("homeView").style.display = "none"; $("catView").style.display = "";
|
||||
$("catTitle").textContent = name || site.name;
|
||||
$("clearSearch").style.display = "none"; $("clearRecent").style.display = "none";
|
||||
setNav(null); updateHeroVisible(); window.scrollTo(0, 0);
|
||||
navigate("category");
|
||||
state.searchMode = false; state.searchKw = "";
|
||||
state.activeCat = site.slug; state.woSite = site.key; state.woCat = cat || site.cats[0][0]; state.activeGenre = "";
|
||||
$("genreChips").style.display = ""; renderWoGenreChips(site);
|
||||
woLoadCat(true);
|
||||
}
|
||||
function renderWoGenreChips(site) {
|
||||
var box = $("genreChips"); if (!box) return;
|
||||
var frag = document.createDocumentFragment();
|
||||
site.cats.forEach(function (c, i) {
|
||||
var b = document.createElement("button");
|
||||
b.type = "button";
|
||||
b.className = "genre-chip focusable" + (state.woCat === c[0] ? " active" : "");
|
||||
b.textContent = c[1]; b.setAttribute("data-genre", c[1]);
|
||||
b.setAttribute("data-focus-row", "genreChips"); b.setAttribute("data-focus-index", String(i));
|
||||
b.onclick = (function (cc) { return function () { state.woCat = cc[0]; renderWoGenreChips(site); window.scrollTo(0, 0); woLoadCat(true); }; })(c);
|
||||
frag.appendChild(b);
|
||||
});
|
||||
box.replaceChildren(frag);
|
||||
}
|
||||
function woLoadCat(reset) {
|
||||
var site = WO_SITES[state.woSite]; if (!site) return;
|
||||
var cat = state.woCat || site.cats[0][0];
|
||||
var ck = site.key + ":" + cat;
|
||||
if (reset) {
|
||||
state.reqToken++;
|
||||
state.woPage = 1; state.source = []; state.rendered = 0; state.noMore = false; state.loading = false; state.autoPages = 0;
|
||||
$("grid").replaceChildren(); $("grid").classList.remove("grid-recent"); $("browseNotice").replaceChildren();
|
||||
var c = state.woCache[ck];
|
||||
if (c && c.length) { woPaintCat(c, state.reqToken, 1); return; }
|
||||
}
|
||||
if (state.loading || state.noMore) return;
|
||||
var token = state.reqToken, page = reset ? 1 : (state.woPage + 1);
|
||||
state.loading = true; $("browseLoading").style.display = "block"; if (reset) $("browseSub").textContent = "加载中";
|
||||
woFetchList(site, cat, page).then(function (list) {
|
||||
state.loading = false;
|
||||
if (token !== state.reqToken) return;
|
||||
$("browseLoading").style.display = "none";
|
||||
if (page === 1) state.woCache[ck] = list;
|
||||
woPaintCat(list, token, page);
|
||||
}, function () {
|
||||
state.loading = false;
|
||||
if (token !== state.reqToken) return;
|
||||
$("browseLoading").style.display = "none"; state.noMore = true;
|
||||
if (!state.source.length) $("grid").replaceChildren(el("div", "mh-row-empty", "加载失败,返回重试"));
|
||||
});
|
||||
}
|
||||
function woPaintCat(list, token, page) {
|
||||
if (token !== state.reqToken) return;
|
||||
$("browseLoading").style.display = "none";
|
||||
var have = {}; state.source.forEach(function (s) { have[s.id] = 1; });
|
||||
var fresh = (list || []).filter(function (it) { return !have[it.id]; });
|
||||
if (!fresh.length) {
|
||||
state.noMore = true;
|
||||
if (!state.source.length) {
|
||||
var box = el("div", "mh-row-empty", "该分类暂无内容");
|
||||
var lines = PAN_DIAG_ON ? (state.woDiagList || []) : [];
|
||||
if (lines.length) {
|
||||
var d = el("div", "mh-row-empty", "");
|
||||
d.style.whiteSpace = "pre-wrap"; d.style.fontSize = "11px"; d.style.opacity = ".8"; d.style.textAlign = "left";
|
||||
d.textContent = "【请求诊断】\n" + lines.join("\n");
|
||||
$("grid").replaceChildren(box, d);
|
||||
} else $("grid").replaceChildren(box);
|
||||
$("browseSub").textContent = "";
|
||||
}
|
||||
return;
|
||||
}
|
||||
state.woPage = page; state.source = state.source.concat(fresh); appendGrid();
|
||||
$("browseSub").textContent = state.source.length + " 部";
|
||||
}
|
||||
function fyEnrichDo(rawTitle, token) {
|
||||
var kw = cleanTitle(rawTitle);
|
||||
if (!kw) return;
|
||||
var year = yearOf(rawTitle);
|
||||
fySearch(kw).then(function (list) {
|
||||
if (token !== state.panToken || !list.length) return null;
|
||||
var best = null, bestScore = 99;
|
||||
list.forEach(function (it) {
|
||||
var ts = titleScore(it.title, kw);
|
||||
if (ts > 3) return;
|
||||
var s = ts;
|
||||
if (year && (it.title || "").indexOf(year) >= 0) s -= 0.3;
|
||||
if (s < bestScore) { bestScore = s; best = it; }
|
||||
});
|
||||
if (!best) return null;
|
||||
return fyHttpText(fyBase() + "/detail/" + best._fyId + ".html").then(function (html) {
|
||||
if (token !== state.panToken) return;
|
||||
var d = fyParseDetail(html, best._fyId);
|
||||
var res = fyResList(d, best._fyId);
|
||||
if (!res.length) return;
|
||||
var have = {};
|
||||
state.panItems.forEach(function (it) { if (it._fy) have[it._fyId + "-" + it._fySid + "-" + it._fyNid] = 1; });
|
||||
var add = res.filter(function (r) { return !have[r._fyId + "-" + r._fySid + "-" + r._fyNid]; });
|
||||
if (add.length) renderResources(state.panItems.concat(add));
|
||||
});
|
||||
}).then(null, function () {});
|
||||
}
|
||||
/* 枫叶来源条目:顺便反查盘链本站,命中就升级成原生条目(网盘资源 + 枫叶在线并存) */
|
||||
function fyReverseLookup(item, token) {
|
||||
var title = item.title, yr = item.year || yearOf(item.title);
|
||||
apiGet(apiUrl("get_videos.php") + "?wd=" + encodeURIComponent(cleanTitle(title)) + "&pg=1&_t=" + Date.now(), "").then(function (d) {
|
||||
if (token !== state.panToken || state.currentItem !== item) return;
|
||||
var list = ((d && (d.list || d.data)) || []).map(mapVod);
|
||||
function score(it) { var s = 0; if (it.title === title) s += 8; else if (titleCloseContain(it.title, title)) s += 4; if (yr && it.year === yr) s += 3; return s; }
|
||||
var good = list.filter(function (it) { return score(it) >= 4; });
|
||||
if (!good.length) return;
|
||||
// 本站有同名片:把网盘资源并进来,但保留已经在列表里的枫叶在线线路,两边共存
|
||||
var keepFy = fyKeep();
|
||||
var tok = state.panToken;
|
||||
var kw = cleanTitle(title);
|
||||
var best = good.slice().sort(function (a, b) { return score(b) - score(a); })[0];
|
||||
apiGet(apiUrl("search_pan_links.php") + "?keyword=" + encodeURIComponent(kw) + "&vod_id=" + encodeURIComponent(best.id) + "&_t=" + Date.now(), best.id).then(function (data) {
|
||||
if (tok !== state.panToken || !data || !data.success) return;
|
||||
var groups = data.data || {}, pans = [];
|
||||
Object.keys(groups).forEach(function (type) {
|
||||
var grp = groups[type], links = (grp && grp.links) || [];
|
||||
links.forEach(function (lk) {
|
||||
pans.push({
|
||||
type: type, groupName: (grp && grp.name) || (PAN_META[type] ? PAN_META[type].label : type),
|
||||
title: lk.title || "", password: lk.password || "", token: lk.token || "", source: lk.source || "",
|
||||
tier: lk.user_tier || 0, quality: (PAN_META[type] ? PAN_META[type].label : type), state: "idle"
|
||||
});
|
||||
});
|
||||
});
|
||||
if (pans.length) renderResources(keepFy.concat(sortPanDefault(pans)));
|
||||
}, function () {});
|
||||
}, function () {});
|
||||
}
|
||||
|
||||
/* ---------- 首页「枫叶」一行 + 分类页 ---------- */
|
||||
function renderFyRow(box) {
|
||||
var shell = makeRowShell("枫叶4K影院", function () { openFyCategory("1", "枫叶4K影院"); });
|
||||
box.appendChild(shell.section);
|
||||
var cached = state.fyCache["1"];
|
||||
if (cached) { fillRow(shell.row, cached.slice(0, 12), function (it) { openDetail(it); }); return; }
|
||||
fyFetchList("1", 1).then(function (list) {
|
||||
state.fyCache["1"] = list;
|
||||
if (!list.length) { shell.row.replaceChildren(el("div", "mh-row-empty", "暂无数据")); return; }
|
||||
fillRow(shell.row, list.slice(0, 12), function (it) { openDetail(it); });
|
||||
}, function () { shell.row.replaceChildren(el("div", "mh-row-empty", "加载失败")); });
|
||||
}
|
||||
/* 首页:腾讯视频 / 优酷 / 哔哩哔哩 三行(枫叶站按来源平台分的标签页) */
|
||||
/* 首页「枫叶」一行(腾讯/优酷/哔哩三个来源合成一个入口,默认铺腾讯,查看全部里用标签切换) */
|
||||
function renderFyLabelRows(box) {
|
||||
var first = FY_LABELS[0], t = "label:" + first.tag;
|
||||
var shell = makeRowShell("枫叶", function () { openFyCategory("label:" + first.tag, "枫叶"); });
|
||||
box.appendChild(shell.section);
|
||||
var cached = state.fyCache[t];
|
||||
if (cached && cached.length) { fillRow(shell.row, cached.slice(0, 12), function (it) { openDetail(it); }); return; }
|
||||
shell.row.replaceChildren(el("div", "mh-row-empty", "加载中…"));
|
||||
fyFetchList(t, 1).then(function (list) {
|
||||
state.fyCache[t] = list;
|
||||
if (!list.length) { shell.row.replaceChildren(el("div", "mh-row-empty", "暂无数据")); return; }
|
||||
fillRow(shell.row, list.slice(0, 12), function (it) { openDetail(it); });
|
||||
}, function () { shell.row.replaceChildren(el("div", "mh-row-empty", "加载失败")); });
|
||||
}
|
||||
/* 枫叶分类页:顶部筛选标签就是站点的 电影/剧集/综艺/动漫/短剧,滑到底自动翻页 */
|
||||
function openFyCategory(t, name) {
|
||||
saveHomeReturn();
|
||||
state.view = "category";
|
||||
$("homeView").style.display = "none"; $("catView").style.display = "";
|
||||
$("catTitle").textContent = name || "枫叶4K影院";
|
||||
$("clearSearch").style.display = "none"; $("clearRecent").style.display = "none";
|
||||
setNav(null); updateHeroVisible(); window.scrollTo(0, 0);
|
||||
navigate("category");
|
||||
state.searchMode = false; state.searchKw = "";
|
||||
state.activeCat = FY_CAT_SLUG;
|
||||
state.fyCat = t || "1";
|
||||
state.activeGenre = "";
|
||||
if (String(t).indexOf("label:") === 0) {
|
||||
// 来源标签页(枫叶:腾讯/优酷/哔哩)——用标签切换来源
|
||||
$("genreChips").style.display = "";
|
||||
renderFyLabelChips();
|
||||
} else {
|
||||
$("genreChips").style.display = "";
|
||||
renderFyGenreChips();
|
||||
}
|
||||
fyLoadCat(true);
|
||||
}
|
||||
/* 枫叶查看全部页的来源标签:腾讯 / 优酷 / 哔哩,点了切换来源重新加载 */
|
||||
function renderFyLabelChips() {
|
||||
var box = $("genreChips"); if (!box) return;
|
||||
var frag = document.createDocumentFragment();
|
||||
FY_LABELS.forEach(function (lb, i) {
|
||||
var t = "label:" + lb.tag;
|
||||
var b = document.createElement("button");
|
||||
b.type = "button";
|
||||
b.className = "genre-chip focusable" + (state.fyCat === t ? " active" : "");
|
||||
b.textContent = lb.name;
|
||||
b.setAttribute("data-genre", lb.name);
|
||||
b.setAttribute("data-focus-row", "genreChips");
|
||||
b.setAttribute("data-focus-index", String(i));
|
||||
b.onclick = (function (tt) { return function () { state.fyCat = tt; renderFyLabelChips(); window.scrollTo(0, 0); fyLoadCat(true); }; })(t);
|
||||
frag.appendChild(b);
|
||||
});
|
||||
box.replaceChildren(frag);
|
||||
}
|
||||
function renderFyGenreChips() {
|
||||
var box = $("genreChips"); if (!box) return;
|
||||
var frag = document.createDocumentFragment();
|
||||
FY_CATS.forEach(function (c, i) {
|
||||
var b = document.createElement("button");
|
||||
b.type = "button";
|
||||
b.className = "genre-chip focusable" + (state.fyCat === c.t ? " active" : "");
|
||||
b.textContent = c.name;
|
||||
b.setAttribute("data-genre", c.name);
|
||||
b.setAttribute("data-focus-row", "genreChips");
|
||||
b.setAttribute("data-focus-index", String(i));
|
||||
b.onclick = (function (cc) { return function () { state.fyCat = cc.t; renderFyGenreChips(); window.scrollTo(0, 0); fyLoadCat(true); }; })(c);
|
||||
frag.appendChild(b);
|
||||
});
|
||||
box.replaceChildren(frag);
|
||||
}
|
||||
function fyLoadCat(reset) {
|
||||
var t = state.fyCat || "1";
|
||||
if (reset) {
|
||||
state.reqToken++;
|
||||
state.fyPage = 1; state.source = []; state.rendered = 0; state.noMore = false; state.loading = false; state.autoPages = 0;
|
||||
$("grid").replaceChildren(); $("grid").classList.remove("grid-recent");
|
||||
$("browseNotice").replaceChildren();
|
||||
var cachedR = state.fyCache[t];
|
||||
if (cachedR && cachedR.length) { fyPaintCat(cachedR, state.reqToken, 1); return; }
|
||||
}
|
||||
if (state.loading || state.noMore) return;
|
||||
var token = state.reqToken, page = reset ? 1 : (state.fyPage + 1);
|
||||
state.loading = true;
|
||||
$("browseLoading").style.display = "block";
|
||||
if (reset) $("browseSub").textContent = "加载中";
|
||||
fyFetchList(t, page).then(function (list) {
|
||||
state.loading = false;
|
||||
if (token !== state.reqToken) return;
|
||||
$("browseLoading").style.display = "none";
|
||||
if (page === 1) state.fyCache[t] = list;
|
||||
fyPaintCat(list, token, page);
|
||||
}, function () {
|
||||
state.loading = false;
|
||||
if (token !== state.reqToken) return;
|
||||
$("browseLoading").style.display = "none";
|
||||
state.noMore = true;
|
||||
if (!state.source.length) $("grid").replaceChildren(el("div", "mh-row-empty", "加载失败,返回重试"));
|
||||
});
|
||||
}
|
||||
function fyPaintCat(list, token, page) {
|
||||
if (token !== state.reqToken) return;
|
||||
$("browseLoading").style.display = "none";
|
||||
var have = {}; state.source.forEach(function (s) { have[s.id] = 1; });
|
||||
var fresh = (list || []).filter(function (it) { return !have[it.id]; });
|
||||
if (!fresh.length) {
|
||||
state.noMore = true;
|
||||
if (!state.source.length) { $("grid").replaceChildren(el("div", "mh-row-empty", "该分类暂无内容")); $("browseSub").textContent = ""; }
|
||||
return;
|
||||
}
|
||||
state.fyPage = page;
|
||||
state.source = state.source.concat(fresh);
|
||||
appendGrid();
|
||||
$("browseSub").textContent = state.source.length + " 部";
|
||||
}
|
||||
|
||||
/* ---------- 设备形态 ---------- */
|
||||
function detectDevice() {
|
||||
var client = window.fongmiClient;
|
||||
@@ -879,95 +4468,990 @@ function whenSdk() {
|
||||
});
|
||||
}
|
||||
var toastTimer = null;
|
||||
function toast(msg) { var t = $("toast"); t.textContent = msg; t.classList.add("show"); if (toastTimer) clearTimeout(toastTimer); toastTimer = setTimeout(function () { t.classList.remove("show"); }, 2200); }
|
||||
function toast(msg, pos) { var t = $("toast"); t.textContent = msg; t.classList.toggle("top", pos === "top"); t.classList.add("show"); if (toastTimer) clearTimeout(toastTimer); toastTimer = setTimeout(function () { t.classList.remove("show"); }, 2200); }
|
||||
|
||||
/* ---------- chips ---------- */
|
||||
/* ---------- 底部导航(首页/搜索/历史)+ 视图切换 ---------- */
|
||||
var NAV_ITEMS = [
|
||||
{ key: "home", name: "首页", icon: '<path d="M3 11l9-8 9 8"/><path d="M5 10v10h5v-6h4v6h5V10"/>' },
|
||||
{ key: "settings", name: "设置", icon: '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06a1.65 1.65 0 00.33-1.82 1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06a1.65 1.65 0 001.82.33H9a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06a1.65 1.65 0 00-.33 1.82V9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/>' },
|
||||
{ key: "search", name: "搜索", icon: '<circle cx="11" cy="11" r="7.5"/><path d="M21 21l-4.3-4.3"/>' }
|
||||
];
|
||||
var CAT_LIST = [
|
||||
{ slug: "1", name: "电影" }, { slug: "2", name: "剧集" }, { slug: "3", name: "综艺" }, { slug: "4", name: "动漫" }
|
||||
];
|
||||
var TMDB_CAT_SLUG = "__tmdbtrend__";
|
||||
/* 平台标签对应 TMDB watch provider id(watch_region 用 US,海外平台绝大多数以美区数据最全)。
|
||||
provider 字段存在即视为"流媒体平台"标签,选中后 fetchTmdbProviderPage 会改走 discover 接口,
|
||||
而不是本地对 trending 结果做语言粗筛。 */
|
||||
var TMDB_TREND_GENRES = [
|
||||
{ name: "全部", value: "" },
|
||||
{ name: "电影", value: "电影" },
|
||||
{ name: "剧集", value: "剧集" },
|
||||
{ name: "Netflix", value: "netflix", provider: 8 },
|
||||
{ name: "Disney+", value: "disney", provider: 337 },
|
||||
{ name: "HBO", value: "hbo", provider: 1899 },
|
||||
{ name: "Hulu", value: "hulu", provider: 15 },
|
||||
{ name: "Prime Video", value: "prime", provider: 9 },
|
||||
{ name: "Apple TV+", value: "appletv", provider: 350 }
|
||||
];
|
||||
function tmdbProviderConf(g) {
|
||||
for (var i = 0; i < TMDB_TREND_GENRES.length; i++) if (TMDB_TREND_GENRES[i].value === g && TMDB_TREND_GENRES[i].provider) return TMDB_TREND_GENRES[i];
|
||||
return null;
|
||||
}
|
||||
/* TMDB 热播缓存 */
|
||||
var _trendingCache = null;
|
||||
var _trendingCacheTs = 0;
|
||||
var TRENDING_TTL = 10 * 60 * 1000; // 10 分钟
|
||||
function catName(slug) { var c = CAT_LIST.filter(function (x) { return x.slug === slug; })[0]; return c ? c.name : "分类"; }
|
||||
function renderChips() {
|
||||
var box = $("chips");
|
||||
var frag = document.createDocumentFragment();
|
||||
CATEGORIES.forEach(function (c, i) {
|
||||
var b = document.createElement("button");
|
||||
b.type = "button";
|
||||
b.className = "chip focusable" + (!state.searchMode && c.slug === state.activeCat ? " active" : "");
|
||||
b.textContent = c.name;
|
||||
b.setAttribute("data-cat", c.slug);
|
||||
b.setAttribute("data-focus-row", "chips");
|
||||
b.setAttribute("data-focus-index", String(i));
|
||||
b.onclick = function () { selectCat(c.slug); };
|
||||
var box = $("chips"); var frag = document.createDocumentFragment();
|
||||
NAV_ITEMS.forEach(function (n, i) {
|
||||
var b = document.createElement("button"); b.type = "button";
|
||||
b.className = "chip focusable" + (state.nav === n.key ? " active" : "");
|
||||
b.innerHTML = '<svg class="chip-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' + n.icon + '</svg><span>' + n.name + '</span>';
|
||||
b.setAttribute("data-nav", n.key); b.setAttribute("data-focus-row", "chips"); b.setAttribute("data-focus-index", String(i));
|
||||
b.onclick = function () { onNav(n.key); };
|
||||
frag.appendChild(b);
|
||||
});
|
||||
box.replaceChildren(frag);
|
||||
}
|
||||
function markActiveChip() {
|
||||
var chips = $("chips").children;
|
||||
for (var i = 0; i < chips.length; i++) {
|
||||
var el = chips[i];
|
||||
if (!state.searchMode && el.getAttribute("data-cat") === state.activeCat) el.classList.add("active");
|
||||
else el.classList.remove("active");
|
||||
function setNav(key) { state.nav = key; var c = $("chips").children; for (var i = 0; i < c.length; i++) c[i].classList.toggle("active", c[i].getAttribute("data-nav") === key); }
|
||||
function markActiveChip() { setNav(state.nav); }
|
||||
function onNav(key) {
|
||||
if (key === "home") showHome();
|
||||
else if (key === "search") openSearchPanel();
|
||||
else if (key === "settings") { if (state.chipLpFired) { state.chipLpFired = false; return; } openSettings(); }
|
||||
}
|
||||
|
||||
/* ---------- TMDB 热播(trending/all/week) ---------- */
|
||||
/* TMDB 单条结果 -> 内部影片对象。_popularity 供平台标签(Netflix/HBO…)合并 电影+剧集 两路
|
||||
discover 结果后按热度重新排序用 */
|
||||
function mapTmdbTrendItem(r) {
|
||||
var title = r.title || r.name || "";
|
||||
var yr = (r.release_date || r.first_air_date || "").slice(0, 4);
|
||||
var isMovie = r.media_type === "movie";
|
||||
var poster = r.poster_path ? ("https://image.tmdb.org/t/p/w1280" + r.poster_path) : "";
|
||||
return {
|
||||
id: "__tmdb__" + r.id,
|
||||
title: title,
|
||||
poster: poster,
|
||||
meta: [yr, isMovie ? "电影" : "剧集"].filter(function (x) { return x; }).join(" / "),
|
||||
status: "",
|
||||
year: yr,
|
||||
typeName: isMovie ? "电影" : "剧集",
|
||||
area: "",
|
||||
_tmdbId: r.id,
|
||||
_mediaType: r.media_type,
|
||||
_tmdbItem: r,
|
||||
_popularity: r.popularity || 0,
|
||||
overview: r.overview || ""
|
||||
};
|
||||
}
|
||||
/* discover 结果没有 media_type 字段(movie/tv 是分开查的),这里补上再复用 mapTmdbTrendItem */
|
||||
function mapTmdbDiscoverItem(r, mediaType) {
|
||||
r.media_type = mediaType;
|
||||
return mapTmdbTrendItem(r);
|
||||
}
|
||||
function tmdbTrending() {
|
||||
var now = Date.now();
|
||||
if (_trendingCache && (now - _trendingCacheTs < TRENDING_TTL)) return Promise.resolve(_trendingCache);
|
||||
if (!config.tmdbKey) return Promise.resolve([]);
|
||||
return httpJson(tmdbApi("/trending/all/week", {})).then(function (d) {
|
||||
var results = (d && d.results) || [];
|
||||
var items = results.map(mapTmdbTrendItem).filter(function (x) { return x.title && x.poster; });
|
||||
_trendingCache = items;
|
||||
_trendingCacheTs = now;
|
||||
return items;
|
||||
}, function () { return []; });
|
||||
}
|
||||
/* "TMDB 热播"查看全部:走跟下面分类一样的分页/无限滚动逻辑,按 page 拉 trending/all/week。
|
||||
同一页 3 分钟内重复进入直接吃缓存,翻到 TMDB 报告的最后一页就置 state.noMore */
|
||||
var _tmdbCatCache = {};
|
||||
function fetchTmdbTrendPage(page) {
|
||||
var p = page || 1;
|
||||
if (!config.tmdbKey) { state.noMore = true; return Promise.resolve([]); }
|
||||
var conf = tmdbProviderConf(state.activeGenre);
|
||||
if (conf) return fetchTmdbProviderPage(conf, p);
|
||||
var key = "p" + p;
|
||||
var hit = _tmdbCatCache[key];
|
||||
if (hit && (Date.now() - hit.ts) < TRENDING_TTL) {
|
||||
if (hit.noMore) state.noMore = true;
|
||||
state.lastDiag = "调试:TMDB trending 第" + p + "页 · " + hit.list.length + " 条 · 缓存";
|
||||
return Promise.resolve(hit.list.slice());
|
||||
}
|
||||
return httpJson(tmdbApi("/trending/all/week", { page: p })).then(function (d) {
|
||||
var results = (d && d.results) || [];
|
||||
var totalPages = (d && d.total_pages) || p;
|
||||
var items = results.map(mapTmdbTrendItem).filter(function (x) { return x.title && x.poster; });
|
||||
var noMoreNow = p >= totalPages;
|
||||
_tmdbCatCache[key] = { list: items, ts: Date.now(), noMore: noMoreNow };
|
||||
if (noMoreNow) state.noMore = true;
|
||||
state.lastDiag = "调试:TMDB trending 第" + p + "/" + totalPages + "页 · " + items.length + " 条";
|
||||
return items;
|
||||
}, function () { state.noMore = true; return []; });
|
||||
}
|
||||
/* 平台标签(Netflix/HBO/Hulu…):trending 接口本身不支持按平台筛,必须换成 discover 接口,
|
||||
同时分别查 电影/剧集 两路(discover 没有 all 合并入口),拉回来后按热度合并排序。
|
||||
总页数取两路较大的一个,避免其中一路先到底就提前把另一路截断。 */
|
||||
function fetchTmdbProviderPage(conf, p) {
|
||||
var key = "prov" + conf.provider + "_p" + p;
|
||||
var hit = _tmdbCatCache[key];
|
||||
if (hit && (Date.now() - hit.ts) < TRENDING_TTL) {
|
||||
if (hit.noMore) state.noMore = true;
|
||||
state.lastDiag = "调试:TMDB " + conf.name + " 第" + p + "页 · " + hit.list.length + " 条 · 缓存";
|
||||
return Promise.resolve(hit.list.slice());
|
||||
}
|
||||
var params = { page: p, with_watch_providers: conf.provider, watch_region: "US", sort_by: "popularity.desc" };
|
||||
return Promise.all([
|
||||
httpJson(tmdbApi("/discover/movie", params)),
|
||||
httpJson(tmdbApi("/discover/tv", params))
|
||||
]).then(function (res) {
|
||||
var dMovie = res[0], dTv = res[1];
|
||||
var movies = ((dMovie && dMovie.results) || []).map(function (r) { return mapTmdbDiscoverItem(r, "movie"); });
|
||||
var tvs = ((dTv && dTv.results) || []).map(function (r) { return mapTmdbDiscoverItem(r, "tv"); });
|
||||
var items = movies.concat(tvs).filter(function (x) { return x.title && x.poster; });
|
||||
items.sort(function (a, b) { return (b._popularity || 0) - (a._popularity || 0); });
|
||||
var totalPages = Math.max((dMovie && dMovie.total_pages) || p, (dTv && dTv.total_pages) || p);
|
||||
var noMoreNow = p >= totalPages;
|
||||
_tmdbCatCache[key] = { list: items, ts: Date.now(), noMore: noMoreNow };
|
||||
if (noMoreNow) state.noMore = true;
|
||||
state.lastDiag = "调试:TMDB " + conf.name + " 第" + p + "/" + totalPages + "页 · " + items.length + " 条";
|
||||
return items;
|
||||
}, function () { state.noMore = true; return []; });
|
||||
}
|
||||
/* 供轮播候选池用:跟 fetchTmdbTrendPage 吃同一份 _tmdbCatCache(key 都是 "p"+页码),
|
||||
缓存过的页直接秒开、不重复占带宽;但不碰 state.noMore / state.lastDiag 这些分类页翻页专用的全局状态,
|
||||
避免轮播预抓多页时把分类页的"到底了"提前标记,道理跟 fetchListOnceSilent 对站内素材的处理一样。 */
|
||||
function fetchTmdbTrendPageSilent(page) {
|
||||
var p = page || 1;
|
||||
if (!config.tmdbKey) return Promise.resolve([]);
|
||||
var key = "p" + p;
|
||||
var hit = _tmdbCatCache[key];
|
||||
if (hit && (Date.now() - hit.ts) < TRENDING_TTL) return Promise.resolve(hit.list.slice());
|
||||
return httpJson(tmdbApi("/trending/all/week", { page: p })).then(function (d) {
|
||||
var results = (d && d.results) || [];
|
||||
var totalPages = (d && d.total_pages) || p;
|
||||
var items = results.map(mapTmdbTrendItem).filter(function (x) { return x.title && x.poster; });
|
||||
_tmdbCatCache[key] = { list: items, ts: Date.now(), noMore: p >= totalPages };
|
||||
return items;
|
||||
}, function () { return []; });
|
||||
}
|
||||
function fetchTmdbTrendPagesSilent(pages) {
|
||||
if (!config.tmdbKey) return Promise.resolve([]);
|
||||
return Promise.all(pages.map(function (p) { return fetchTmdbTrendPageSilent(p); })).then(function (arr) {
|
||||
return arr.reduce(function (acc, a) { return acc.concat(a); }, []);
|
||||
});
|
||||
}
|
||||
/* 供轮播候选池用:跟 fetchTmdbProviderPage 吃同一份 _tmdbCatCache(key 都是 "prov"+id+"_p"+页码),
|
||||
但不碰 state.noMore / state.lastDiag —— 分类页翻页专用的全局状态,轮播这边不该动。
|
||||
轮播原来只吃笼统的 trending/all/week,压根没碰过 Netflix/HBO/Disney+ 这些平台标签自己的 discover 结果,
|
||||
池子小、来来回回就那几部;这个函数把某个平台的某一页拉回来,供下面混进轮播候选池。 */
|
||||
function fetchTmdbProviderPageSilent(conf, page) {
|
||||
var p = page || 1;
|
||||
if (!config.tmdbKey) return Promise.resolve([]);
|
||||
var key = "prov" + conf.provider + "_p" + p;
|
||||
var hit = _tmdbCatCache[key];
|
||||
if (hit && (Date.now() - hit.ts) < TRENDING_TTL) return Promise.resolve(hit.list.slice());
|
||||
var params = { page: p, with_watch_providers: conf.provider, watch_region: "US", sort_by: "popularity.desc" };
|
||||
return Promise.all([
|
||||
httpJson(tmdbApi("/discover/movie", params)),
|
||||
httpJson(tmdbApi("/discover/tv", params))
|
||||
]).then(function (res) {
|
||||
var dMovie = res[0], dTv = res[1];
|
||||
var movies = ((dMovie && dMovie.results) || []).map(function (r) { return mapTmdbDiscoverItem(r, "movie"); });
|
||||
var tvs = ((dTv && dTv.results) || []).map(function (r) { return mapTmdbDiscoverItem(r, "tv"); });
|
||||
var items = movies.concat(tvs).filter(function (x) { return x.title && x.poster; });
|
||||
var totalPages = Math.max((dMovie && dMovie.total_pages) || p, (dTv && dTv.total_pages) || p);
|
||||
_tmdbCatCache[key] = { list: items, ts: Date.now(), noMore: p >= totalPages };
|
||||
return items;
|
||||
}, function () { return []; });
|
||||
}
|
||||
/* 把 Netflix / Disney+ / HBO / Hulu / Prime Video / Apple TV+ 这些平台标签的某一页各拉一份、合并去重,
|
||||
专供轮播候选池混入——这样轮播才是真的从"全部加进去的平台"里抽,而不是只看 trending 那一小池。 */
|
||||
function fetchTmdbPlatformsPoolSilent(page) {
|
||||
if (!config.tmdbKey) return Promise.resolve([]);
|
||||
var provs = TMDB_TREND_GENRES.filter(function (g) { return g.provider; });
|
||||
return Promise.all(provs.map(function (g) { return fetchTmdbProviderPageSilent(g, page); })).then(function (arr) {
|
||||
return dedupById(arr.reduce(function (acc, a) { return acc.concat(a); }, []));
|
||||
});
|
||||
}
|
||||
/* 只读一眼 _tmdbCatCache 里"TMDB 热播"某一页还在不在有效期内,纯内存查表、不发任何请求。
|
||||
给轮播用:如果用户之前逛过"TMDB 热播"分类页,这份缓存大概率还热乎着,轮播可以不等站内
|
||||
电影/剧集这两路网络请求,直接先拿这批现成的顶上去,比每次都从零开始拼快得多。 */
|
||||
function peekTmdbTrendCache(page) {
|
||||
if (!config.tmdbKey) return null;
|
||||
var hit = _tmdbCatCache["p" + (page || 1)];
|
||||
if (hit && (Date.now() - hit.ts) < TRENDING_TTL) return hit.list.slice();
|
||||
return null;
|
||||
}
|
||||
/* 点热播条目:跟点普通条目一样,先立刻用TMDB数据把详情页开起来(不等任何网络请求);
|
||||
盘链搜片名挪到后台去做——搜到更准的真实资源版就原地升级(openDetail 已经会用 replace,
|
||||
不会多开一层历史记录),搜不到/搜索失败就留在 TMDB 兜底版。
|
||||
之前的写法是先 apiGet 搜盘链、等结果回来才 openDetail,点击那一刻界面毫无反馈,
|
||||
首次加载后跟盘链 API 还没热连接(DNS/TLS 握手都要重来),这一两秒的“没反应”就是这么来的。 */
|
||||
function openTrendingDetail(tmdbItem) {
|
||||
var title = tmdbItem.title;
|
||||
var yr = tmdbItem.year;
|
||||
var fallbackItem = { id: String(tmdbItem._tmdbId) + "__tmdb", title: title, poster: tmdbItem.poster, meta: tmdbItem.meta, status: "", year: yr, typeName: tmdbItem.typeName, area: "" };
|
||||
openDetail(fallbackItem);
|
||||
var openToken = state.panToken; // openDetail 内部已经 panToken++,记下这一次打开的代次
|
||||
apiGet(apiUrl("get_videos.php") + "?wd=" + encodeURIComponent(cleanTitle(title)) + "&pg=1&_t=" + Date.now(), "").then(function (d) {
|
||||
if (openToken !== state.panToken || state.currentItem !== fallbackItem) return; // 用户已经手动切走/关闭详情页,这次搜索结果作废
|
||||
var arr = (d && (d.list || d.data)) || [];
|
||||
var list = arr.map(mapVod);
|
||||
function score(it) {
|
||||
var s = 0;
|
||||
if (it.title === title) s += 8; else if (titleCloseContain(it.title, title)) s += 4;
|
||||
if (yr && it.year === yr) s += 3; else if (yr && it.year && Math.abs((parseInt(it.year, 10) || 0) - (parseInt(yr, 10) || 0)) <= 1) s += 1;
|
||||
return s;
|
||||
}
|
||||
var good = list.filter(function (it) { return score(it) >= 4; }); // 必须标题也搭上边,光年份对上不算
|
||||
if (good.length) openDetail(good.slice().sort(function (a, b) { return score(b) - score(a); })[0]);
|
||||
}, function () {});
|
||||
}
|
||||
function lockBodyScroll() {
|
||||
state.scrollLockY = window.pageYOffset || document.documentElement.scrollTop || 0;
|
||||
document.body.classList.add("search-lock");
|
||||
document.body.style.top = (-state.scrollLockY) + "px";
|
||||
}
|
||||
function unlockBodyScroll() {
|
||||
document.body.classList.remove("search-lock");
|
||||
document.body.style.top = "";
|
||||
window.scrollTo(0, state.scrollLockY || 0);
|
||||
}
|
||||
function openSearchPanel() {
|
||||
var p = $("searchPanel"); if (!p) return;
|
||||
if (!state.searchPanelOpen) {
|
||||
state.searchPanelOpen = true;
|
||||
lockBodyScroll();
|
||||
var m = $("searchMask"); if (m) m.classList.add("open");
|
||||
navigate("searchpanel");
|
||||
}
|
||||
p.classList.add("open");
|
||||
var t = $("searchToggle"); if (t) t.classList.add("active");
|
||||
setNav("search");
|
||||
var s = $("search"); if (s) { s.removeAttribute("readonly"); setTimeout(function () { try { s.focus(); } catch (e) {} }, 20); }
|
||||
}
|
||||
function popInView(el) {
|
||||
if (!el) return;
|
||||
el.classList.remove("view-pop");
|
||||
void el.offsetHeight; // 强制重排,让下面重新加的类名能重新触发一次 animation
|
||||
el.classList.add("view-pop");
|
||||
}
|
||||
function showHome(fromPopstate) {
|
||||
var wasPushedView = (state.view === "category" || state.view === "recent");
|
||||
state.searchMode = false; state.searchKw = ""; state.view = "home";
|
||||
$("homeView").style.display = ""; $("catView").style.display = "none";
|
||||
$("clearSearch").style.display = "none";
|
||||
$("clearRecent").style.display = "none";
|
||||
// 注意:这里只对 #homeRows 做弹入动画,不能碰 #homeView 或 #heroWrap 本身——
|
||||
// heroWrap 里的 .hero-blend 用了 backdrop-filter 模糊,一旦祖先元素带 transform 做动画,
|
||||
// 模糊层的合成边界跟不上缩放,滚动条/轮播交界处会闪一条白线(这就是之前反馈的那个问题)。
|
||||
popInView($("homeRows"));
|
||||
setNav("home"); updateHeroVisible(); renderHomeRows();
|
||||
// 从分类/最近观看回来时把滚动位置还原。
|
||||
// 首页的行是异步填内容的,刚 render 完页面高度还不够,scrollTo 会被夹到底部,
|
||||
// 所以要在后续几帧里重试,直到目标位置真的够得着。
|
||||
if (wasPushedView) restoreHomeReturnSoon();
|
||||
if (wasPushedView && !fromPopstate) closeRoute();
|
||||
}
|
||||
function restoreHomeReturnSoon() {
|
||||
// 双 rAF:不在 showHome() 同一个 tick 里同步恢复,而是等浏览器/WebView 这一帧的布局、
|
||||
// 绘制都做完了再恢复。如果宿主 WebView 不认 history.scrollRestoration='manual'(部分老内核
|
||||
// 确实不支持这个 API),它自己那次异步的滚动恢复通常发生在本帧或下一帧,这里故意排在后面,
|
||||
// 才能保证我们恢复的位置是最后生效、不被它盖掉的那一次。
|
||||
requestAnimationFrame(function () { requestAnimationFrame(restoreHomeReturn); });
|
||||
}
|
||||
function openCategory(slug, name) {
|
||||
saveHomeReturn(); // 「查看全部」原来漏了这一句,返回首页时无处可恢复
|
||||
state.view = "category"; $("homeView").style.display = "none"; $("catView").style.display = "";
|
||||
$("genreChips").style.display = ""; $("catTitle").textContent = name || catName(slug);
|
||||
popInView($("catView"));
|
||||
setNav(null); updateHeroVisible(); window.scrollTo(0, 0);
|
||||
navigate("category");
|
||||
selectCat(slug);
|
||||
}
|
||||
function showRecentView() {
|
||||
saveHomeReturn();
|
||||
state.view = "recent"; $("homeView").style.display = "none"; $("catView").style.display = "";
|
||||
$("genreChips").style.display = "none"; $("genreChips").replaceChildren();
|
||||
$("catTitle").textContent = "最近观看"; $("clearSearch").style.display = "none";
|
||||
popInView($("catView"));
|
||||
setNav("recent"); updateHeroVisible(); window.scrollTo(0, 0);
|
||||
navigate("category");
|
||||
state.activeCat = "__recent__"; state.searchMode = false;
|
||||
state.reqToken++; state.loading = false; renderRecentGrid();
|
||||
}
|
||||
function reloadCurrent() {
|
||||
if (state.view === "home") { renderHomeRows(true); loadHero(); }
|
||||
else if (state.view === "recent") renderRecentGrid();
|
||||
else loadList(true);
|
||||
}
|
||||
/* 首页多行(继续观看 + 各分类横滑) */
|
||||
function fillMissingRecentPosters(row, items) {
|
||||
items.forEach(function (it, idx) {
|
||||
if (it.poster) return;
|
||||
var t = (it.title || "").trim(); if (!t) return;
|
||||
apiGet(apiUrl("get_videos.php") + "?wd=" + encodeURIComponent(t) + "&pg=1&_t=" + Date.now(), "").then(function (d) {
|
||||
var arr = (d && (d.data || d.list)) || []; if (!arr.length) return;
|
||||
var mapped = arr.map(mapVod);
|
||||
var m = mapped.filter(function (x) { return x.title === t; })[0] || mapped[0];
|
||||
if (!m || !m.poster) return;
|
||||
var card = row.children[idx]; if (!card) return;
|
||||
var pd = card.querySelector(".poster"); if (!pd) return;
|
||||
var ph = pd.querySelector(".ph"); if (ph) ph.parentNode.removeChild(ph);
|
||||
var im = pd.querySelector("img");
|
||||
if (!im) { im = document.createElement("img"); im.loading = "lazy"; im.setAttribute("decoding", "async"); im.style.position = "absolute"; im.style.inset = "0"; im.style.width = "100%"; im.style.height = "100%"; im.style.objectFit = "cover"; pd.insertBefore(im, pd.firstChild); }
|
||||
im.style.display = ""; im.src = imgSrc(m.poster);
|
||||
persistRecentPoster(it.id, m.poster);
|
||||
}, function () {});
|
||||
});
|
||||
}
|
||||
function persistRecentPoster(id, poster) {
|
||||
var changed = false;
|
||||
(state.recentItems || []).forEach(function (h) { if (String(h.id) === String(id) && !h.poster) { h.poster = poster; changed = true; } });
|
||||
if (changed) storeSet("recent", JSON.stringify(state.recentItems));
|
||||
}
|
||||
function persistRecentBackdrop(id, backdrop) {
|
||||
var changed = false;
|
||||
(state.recentItems || []).forEach(function (h) { if (String(h.id) === String(id) && !h.backdrop) { h.backdrop = backdrop; changed = true; } });
|
||||
if (changed) storeSet("recent", JSON.stringify(state.recentItems));
|
||||
}
|
||||
/* 兜底:老记录没有横版剧照时,配了 TMDB Key 就后台静默补全 */
|
||||
function fillMissingRecentBackdrops(row, origItems) {
|
||||
if (!config.tmdbKey) return;
|
||||
origItems.forEach(function (h, idx) {
|
||||
if (h.backdrop) return;
|
||||
var t = (h.title || "").trim(); if (!t) return;
|
||||
var wantTv = /剧|集|综艺|动漫|动画/.test(h.note || "");
|
||||
tmdbSearchBest(titleCandidates(t), "", wantTv).then(function (pick) {
|
||||
if (!pick) return;
|
||||
httpJson(tmdbApi("/" + pick.media_type + "/" + pick.id, {
|
||||
append_to_response: "images", include_image_language: "zh,en,null"
|
||||
})).then(function (det) {
|
||||
if (!det) return;
|
||||
var imgs = det.images || {};
|
||||
var bdPath = tmdbBestImg(imgs.backdrops, null) || det.backdrop_path || "";
|
||||
if (!bdPath) return;
|
||||
var url = tmdbImgUrl("w780", bdPath);
|
||||
persistRecentBackdrop(h.id, url);
|
||||
// 更新首页横滑行对应卡片
|
||||
var card = row.children[idx]; if (!card) return;
|
||||
var pd = card.querySelector(".poster"); if (!pd) return;
|
||||
var im = pd.querySelector("img");
|
||||
if (!im) {
|
||||
im = document.createElement("img");
|
||||
im.loading = "lazy"; im.setAttribute("decoding", "async");
|
||||
im.style.cssText = "position:absolute;inset:0;width:100%;height:100%;object-fit:cover;";
|
||||
im.onerror = function () { im.style.display = "none"; };
|
||||
var ph2 = pd.querySelector(".ph"); if (ph2) ph2.style.display = "none";
|
||||
pd.insertBefore(im, pd.firstChild);
|
||||
}
|
||||
setTmdbImg(im, url);
|
||||
}, function () {});
|
||||
}, function () {});
|
||||
});
|
||||
}
|
||||
/* 分类排序:用最近观看记录里的类型关键词(note,如"电影/剧集/动漫")做简单加权,
|
||||
算出用户偏好的分类排序。收藏(state.favs)未记录类型信息,暂不参与加权。 */
|
||||
function computePreferredCats() {
|
||||
var freq = {};
|
||||
(state.recentItems || []).forEach(function (h) {
|
||||
var note = h.note || "";
|
||||
CAT_LIST.forEach(function (c) { if (note.indexOf(c.name) >= 0) freq[c.slug] = (freq[c.slug] || 0) + 1; });
|
||||
});
|
||||
return CAT_LIST.map(function (c) { return { slug: c.slug, name: c.name, score: freq[c.slug] || 0 }; })
|
||||
.sort(function (a, b) { return b.score - a.score; });
|
||||
}
|
||||
function showHomeSkeleton() {
|
||||
var host = $("homeView"); if (!host) return;
|
||||
if ($("homeSkeleton")) return; // 已经在显示
|
||||
// 首页已经有「真卡片」才别盖:磁盘缓存命中时内容会先渲染出来,
|
||||
// 这时候再压一层骨架屏,观感就是"加载好了→黑屏→又加载一次"。
|
||||
// 注意判据必须是卡片,不能是 children.length —— 那数的是行外壳,
|
||||
// 外壳里可能全是「加载中…」占位,这时候不盖反而露出一片空页。
|
||||
var rows = $("homeRows");
|
||||
if (rows && rows.querySelector(".card")) return;
|
||||
var sk = document.createElement("div");
|
||||
sk.className = "home-skeleton"; sk.id = "homeSkeleton";
|
||||
var html = '<div class="sk-hero sk-shimmer"></div>';
|
||||
for (var r = 0; r < 2; r++) {
|
||||
html += '<div class="sk-rowtitle sk-shimmer"></div><div class="sk-cards">';
|
||||
for (var c = 0; c < 5; c++) html += '<div class="sk-card"><div class="sk-poster sk-shimmer"></div><div class="sk-line w70 sk-shimmer"></div><div class="sk-line w45 sk-shimmer"></div></div>';
|
||||
html += '</div>';
|
||||
}
|
||||
sk.innerHTML = html;
|
||||
host.style.position = host.style.position || "relative";
|
||||
host.appendChild(sk);
|
||||
}
|
||||
function hideHomeSkeleton() {
|
||||
var sk = $("homeSkeleton"); if (!sk) return;
|
||||
if (state.isTv) { sk.parentNode.removeChild(sk); return; } // 没有淡出动画,直接摘掉,不用等
|
||||
sk.classList.add("hiding");
|
||||
setTimeout(function () { if (sk && sk.parentNode) sk.parentNode.removeChild(sk); }, 500);
|
||||
}
|
||||
function renderHomeRows(force) {
|
||||
var box = $("homeRows"); if (!box) return;
|
||||
// 从分类/详情返回首页时,如果内容没变化,直接复用现有 DOM,不重建、不重发请求(返回即秒回)
|
||||
var sig = (state.recentItems || []).map(function (h) { return h.id; }).join(",") + "|" + (config.tmdbKey ? 1 : 0);
|
||||
if (!force && box.children.length && state._homeSig === sig) { startHomePrefetch(); hideHomeSkeleton(); return; }
|
||||
state._homeSig = sig;
|
||||
if (force) { // 下拉刷新 / 换源:所有缓存作废,这次全部重抓
|
||||
state.homeCatCache = {}; state.zn4kColCache = {}; state.qwCatCache = {}; state.wnCache = {}; state.shCache = {}; state.woCache = {};
|
||||
_homeDiskTs = 0; clearTextMemo();
|
||||
}
|
||||
resetPrefetch(); // 旧的一批预取任务指向已被移除的行,作废
|
||||
box.replaceChildren();
|
||||
if (state.recentItems && state.recentItems.length) {
|
||||
var rec = state.recentItems.map(function (h) { return { id: String(h.id), title: h.title || "", poster: h.backdrop || h.poster || "", status: "", year: "", typeName: "", meta: "", payload: h.payload }; });
|
||||
var recShell = makeRowShell("继续观看", showRecentView);
|
||||
recShell.row.classList.add("mh-row-recent");
|
||||
box.appendChild(recShell.section);
|
||||
fillRow(recShell.row, rec, function (it) { openDetail(it); });
|
||||
fillMissingRecentPosters(recShell.row, rec);
|
||||
fillMissingRecentBackdrops(recShell.row, state.recentItems);
|
||||
}
|
||||
/* TMDB 热播行(仅配了 TMDB Key 时才显示) */
|
||||
if (config.tmdbKey) {
|
||||
var trendShell = makeRowShell("TMDB 热播", function () { openCategory(TMDB_CAT_SLUG, "TMDB 热播"); });
|
||||
box.appendChild(trendShell.section);
|
||||
tmdbTrending().then(function (items) {
|
||||
if (!items.length) { trendShell.row.replaceChildren(el("div", "mh-row-empty", "暂无数据")); return; }
|
||||
var frag = document.createDocumentFragment();
|
||||
items.slice(0, 12).forEach(function (it, i) {
|
||||
var card = document.createElement("button");
|
||||
card.type = "button"; card.className = "card focusable";
|
||||
card.setAttribute("data-card-index", String(i));
|
||||
card.setAttribute("data-focus-row", "row");
|
||||
var poster = document.createElement("div"); poster.className = "poster";
|
||||
var img = document.createElement("img");
|
||||
img.alt = it.title; img.loading = i < 4 ? "eager" : "lazy"; img.setAttribute("decoding", "async");
|
||||
setTmdbImg(img, it.poster);
|
||||
poster.appendChild(img);
|
||||
var badge = document.createElement("span"); badge.className = "badge mono"; badge.textContent = it.typeName || "HD";
|
||||
poster.appendChild(badge);
|
||||
card.appendChild(poster);
|
||||
var titleEl = document.createElement("div"); titleEl.className = "card-title"; titleEl.textContent = it.title; card.appendChild(titleEl);
|
||||
var metaEl = document.createElement("div"); metaEl.className = "card-meta"; metaEl.textContent = it.meta || ""; card.appendChild(metaEl);
|
||||
card.onclick = (function (x) { return function () { openTrendingDetail(x); }; })(it);
|
||||
frag.appendChild(card);
|
||||
});
|
||||
trendShell.row.replaceChildren(frag);
|
||||
}, function () { trendShell.row.replaceChildren(el("div", "mh-row-empty", "加载失败")); });
|
||||
}
|
||||
/* 豆瓣高分(4K指南):独立一行,位置在 TMDB 热播 和 电影 之间 */
|
||||
renderZn4kTop250(box);
|
||||
renderQwHomeRow(box);
|
||||
var preferredCats = computePreferredCats();
|
||||
var HOME_FIRST = 8; // 首屏每行先展示这么多,图片少、上屏快
|
||||
preferredCats.forEach(function (c, ci) {
|
||||
var shell = makeRowShell(c.name, function () { openCategory(c.slug, c.name); });
|
||||
box.appendChild(shell.section);
|
||||
var cached = state.homeCatCache[c.slug];
|
||||
var hasCache = !!(cached && cached.length);
|
||||
var load = function () {
|
||||
if (state.view !== "home" || !document.body.contains(shell.row)) return null; // 已经离开首页就不用再抓了
|
||||
return fetchListOnce(c.slug, 1).then(function (list) {
|
||||
if (!document.body.contains(shell.row)) return;
|
||||
state.homeCatCache[c.slug] = list || [];
|
||||
saveHomeDisk();
|
||||
fillRow(shell.row, (list || []).slice(0, HOME_FIRST), function (it) { openDetail(it); });
|
||||
warmupCategoryHome(c.slug, list || [], shell.row, HOME_FIRST);
|
||||
}, function () { if (!hasCache) shell.row.replaceChildren(el("div", "mh-row-empty", "加载失败")); });
|
||||
};
|
||||
if (hasCache) {
|
||||
// 磁盘/内存里已有上次的结果:先秒显,缓存旧了才在后台悄悄重抓一遍覆盖
|
||||
fillRow(shell.row, cached.slice(0, HOME_FIRST), function (it) { openDetail(it); });
|
||||
if (homeCacheStale()) homePrefetch(load);
|
||||
return;
|
||||
}
|
||||
setTimeout(load, ci === 0 ? 0 : ci * 60); // 第一行立刻发请求;其余错开一点,不与首行抢带宽
|
||||
});
|
||||
/* 蜗牛(zmi.kdns.fr):网盘资源站 */
|
||||
renderWnRow(box);
|
||||
/* 森中有林 / SeedHub(sidhub.cc):网盘资源站 */
|
||||
renderShRow(box);
|
||||
/* 玩偶 / 木偶(MacCMS 网盘明链站) */
|
||||
woAll().forEach(function (st) { renderWoRow(box, st); });
|
||||
/* 合集(指南站各系列合集)——放在首页最底部 */
|
||||
renderZn4kCollections(box);
|
||||
// 首屏的分类请求已经发出去了,稍等一下再开后台预取队列(抓网盘站那几行),不跟首屏抢带宽。
|
||||
// 有缓存时首屏本来就不打网络,可以更早开始。
|
||||
setTimeout(startHomePrefetch, homeCacheStale() ? 700 : 150);
|
||||
// 收骨架屏的条件必须是「真的有卡片」,不能只看行外壳。
|
||||
// 首页 12 行外壳是先建好、里面填「加载中…」占位的,卡片才是异步填进去的;
|
||||
// 早先按 box.children.length 判断,磁盘缓存为空时外壳一建好就收屏,
|
||||
// 露出的是满屏「加载中…」的深色空页——看着就是黑屏,卡片到位后又像重加载了一次。
|
||||
hideSkeletonWhenContent();
|
||||
}
|
||||
/* 等到首页真的出现卡片再收骨架屏;一直没有内容就交给 boot 里的 6 秒兜底 */
|
||||
function hideSkeletonWhenContent() {
|
||||
if (!$("homeSkeleton")) return;
|
||||
var box = $("homeRows"); if (!box) return;
|
||||
if (box.querySelector(".card")) { hideHomeSkeleton(); return; }
|
||||
if (state._skWatch) return; // 已经有一个在等了,别开第二个
|
||||
state._skWatch = setInterval(function () {
|
||||
var b = $("homeRows");
|
||||
if (!$("homeSkeleton") || !b || b.querySelector(".card")) {
|
||||
clearInterval(state._skWatch); state._skWatch = 0;
|
||||
hideHomeSkeleton();
|
||||
}
|
||||
}, 120);
|
||||
}
|
||||
/* 首页某一行先展示前 HOME_FIRST 个之后:
|
||||
1) 该分类第 1 页里剩下的(已经抓到手、只是还没上屏的)稍等一下自动补进这一行;
|
||||
2) 紧接着悄悄再多抓 1 页放进缓存,供用户点“查看全部”时秒开用,不占首屏时间 */
|
||||
function warmupCategoryHome(slug, page1List, row, firstCount) {
|
||||
var more = state.homeCatMore[slug] || (state.homeCatMore[slug] = { items: [], nextPage: 1, noMore: false, fetching: false });
|
||||
more.items = (page1List || []).slice(); more.nextPage = 2; more.noMore = (page1List || []).length < firstCount;
|
||||
if (page1List && page1List.length > firstCount) {
|
||||
setTimeout(function () {
|
||||
if (state.view !== "home" || !row || !document.body.contains(row)) return;
|
||||
var frag = document.createDocumentFragment();
|
||||
page1List.slice(firstCount).forEach(function (it, i) { frag.appendChild(mediaCard(it, firstCount + i, "row", function () { openDetail(it); })); });
|
||||
row.appendChild(frag);
|
||||
}, 260);
|
||||
}
|
||||
if (!more.noMore) homePrefetch(function () { fetchMoreHomePages(slug, 2); return null; }); // 续页预抓排进后台队列,不跟首屏抢带宽
|
||||
}
|
||||
/* 悄悄给某分类续抓 pagesToFetch 页,塞进 state.homeCatMore 缓存,不改动首页已经展示的内容 */
|
||||
function fetchMoreHomePages(slug, pagesToFetch) {
|
||||
var more = state.homeCatMore[slug]; if (!more || more.noMore || more.fetching) return;
|
||||
more.fetching = true;
|
||||
var page = more.nextPage;
|
||||
fetchListOnceSilent(slug, page).then(function (list) {
|
||||
more.fetching = false;
|
||||
if (!list || !list.length) { more.noMore = true; return; }
|
||||
more.items = more.items.concat(list);
|
||||
more.nextPage = page + 1;
|
||||
if (pagesToFetch > 1) fetchMoreHomePages(slug, pagesToFetch - 1);
|
||||
}, function () { more.fetching = false; });
|
||||
}
|
||||
function makeRowShell(title, onMore) {
|
||||
var section = el("div", "mh-section");
|
||||
var head = el("div", "mh-section-head");
|
||||
head.appendChild(el("div", "mh-section-title", title));
|
||||
if (onMore) {
|
||||
var more = document.createElement("button"); more.type = "button"; more.className = "mh-more focusable";
|
||||
more.innerHTML = '查看全部<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M9 6l6 6-6 6"/></svg>';
|
||||
more.onclick = onMore; head.appendChild(more);
|
||||
}
|
||||
section.appendChild(head);
|
||||
var row = el("div", "mh-row"); row.appendChild(el("div", "mh-row-empty", "加载中…")); section.appendChild(row);
|
||||
return { section: section, row: row };
|
||||
}
|
||||
function fillRow(row, items, onClick, onDelete) {
|
||||
row.replaceChildren();
|
||||
if (!items.length) { row.appendChild(el("div", "mh-row-empty", "暂无内容")); return; }
|
||||
items.forEach(function (it, i) { row.appendChild(mediaCard(it, i, "row", (function (x) { return function () { onClick(x); }; })(it), onDelete)); });
|
||||
}
|
||||
function makeRow(title, items, onMore, onClick) {
|
||||
var shell = makeRowShell(title, onMore);
|
||||
fillRow(shell.row, items, onClick);
|
||||
return shell.section;
|
||||
}
|
||||
function selectCat(slug) {
|
||||
state.searchMode = false; state.searchKw = "";
|
||||
state.activeCat = slug;
|
||||
state.activeGenre = "";
|
||||
state.activeCat = slug; state.activeGenre = "";
|
||||
$("genreChips").replaceChildren();
|
||||
markActiveChip();
|
||||
var hb = $("historyBtn"); if (hb) { var on = slug === "__recent__"; hb.classList.toggle("active", on); hb.setAttribute("aria-pressed", on ? "true" : "false"); }
|
||||
window.scrollTo(0, 0);
|
||||
if (slug === "__recent__") { state.reqToken++; state.loading = false; renderRecentGrid(); return; }
|
||||
var cat = CATEGORIES.filter(function (c) { return c.slug === slug; })[0];
|
||||
$("browseTitle").textContent = cat ? (slug ? cat.name : "全部资源") : "浏览";
|
||||
$("clearSearch").style.display = "none";
|
||||
$("clearRecent").style.display = "none";
|
||||
var warm = state.homeCatMore[slug];
|
||||
if (warm && warm.items && warm.items.length) {
|
||||
// 首页已经在后台悄悄抓过这个分类了,直接拿缓存秒开,不用再等一次网络请求
|
||||
state.reqToken++; state.page = warm.nextPage;
|
||||
state.source = warm.items.slice(); state.rendered = 0; state.noMore = warm.noMore;
|
||||
state.autoPages = 0; state.loading = false;
|
||||
$("grid").replaceChildren(); $("grid").classList.remove("grid-recent"); $("browseNotice").replaceChildren();
|
||||
appendGrid();
|
||||
renderGenreChips();
|
||||
$("browseSub").textContent = state.source.length + " 部";
|
||||
// 展示完缓存内容后,紧接着再往后多接一页,让用户往下滑时基本不用等
|
||||
if (!state.noMore) loadList(false);
|
||||
return;
|
||||
}
|
||||
loadList(true);
|
||||
}
|
||||
|
||||
/* ---------- 列表 / 搜索 加载 ---------- */
|
||||
/* 搜索兜底:盘链无有效搜索接口,逐页扫 get_videos.php + 本地按片名过滤,边扫边出结果 */
|
||||
function runSearchScan(kw) {
|
||||
/* 搜索:优先直接复用联想接口的完整结果(联想能出的说明这个接口是准的,直接铺满结果列表,不用一页页慢慢扫) */
|
||||
function searchViaSuggestAPI(kw) {
|
||||
var k = (kw || "").trim(); if (!k) return;
|
||||
state.page = 1; state.source = []; state.rendered = 0; state.noMore = false; state.autoPages = 0; state.loading = false;
|
||||
state.reqToken++; var token = state.reqToken;
|
||||
var grid = $("grid"); grid.replaceChildren(); $("browseNotice").replaceChildren();
|
||||
$("grid").replaceChildren(); $("grid").classList.remove("grid-recent"); $("browseNotice").replaceChildren();
|
||||
$("browseLoading").style.display = "block";
|
||||
$("browseSub").textContent = "加载中";
|
||||
// 一进来就并发去 TMDB 搜,不等站内搜完;等站内那条路走到底再拿这份结果做合并(同名的丢掉,只补站内没有的)。
|
||||
var tmdbP = tmdbSearchList(k);
|
||||
// 同时并发搜 4K指南。结果不单独分站源展示:同名的只保留盘链自己的卡片(指南的网盘资源会在
|
||||
// 详情页由 zn4kEnrich 合并进「网盘资源」),只有盘链完全没有的片子才补一张指南来源的卡片。
|
||||
var zn4kP = zn4kSearch(k).then(function (list) {
|
||||
return (list || []).map(function (m) { m._zn4k = true; m._zn4kId = m.id; return m; });
|
||||
}, function () { return []; });
|
||||
// 七味同理:并发搜,同名的不另开卡片(它的网盘/磁力会在详情页由 qwEnrich 并进资源),
|
||||
// 只有盘链、指南都没有的片子才补一张七味来源卡片。
|
||||
var qwP = qwSearch(k).then(function (list) {
|
||||
return (list || []).map(function (m) { m._qw = true; m._qwId = m.id; return m; });
|
||||
}, function () { return []; });
|
||||
// 枫叶(在线点播站)同理:同名的不另开卡片(它的播放线路会在详情页并进资源列表),
|
||||
// 只有盘链、指南都没有的片子才补一张枫叶卡片——它即点即播,比纯 TMDB 信息卡有用。
|
||||
var wnP = wnSearch(k).then(function (list) { return list || []; }, function () { return []; });
|
||||
var shP = shSearch(k).then(function (list) { return list || []; }, function () { return []; });
|
||||
var woPs = woAll().map(function (st) {
|
||||
return woSearch(st, k).then(function (list) { return list || []; }, function () { return []; });
|
||||
});
|
||||
// 站内那条路(联想 / wd / 慢扫)走到终点时统一调它:把 TMDB 里站内没有的补在下面。
|
||||
// 之前这里是 Promise.all(全部7路) 等全部结束才一次性 filter+渲染——网络上虽然是并发发出去的,
|
||||
// 但显示是"卡"死在最慢那一路上:随便一路慢/卡住,其余6路哪怕早就回来了也一起被拖着不显示,
|
||||
// 看着就像"1不出,2345都不出"。改成每一路各自 .then 各自处理、各自到就各自渲染一次,
|
||||
// 谁先回来谁先补上屏幕,不用互相等。
|
||||
// 顺带修了个之前漏掉的问题:woAll() 实际有三个站(玩偶/木偶/pomo),但原来只手写读了
|
||||
// arr[4]/arr[5] 两个位置(玩偶、木偶),第三个 pomo(arr[6])从来没被读取过——等于 pomo
|
||||
// 请求白发了,搜到东西也从来没显示过。这里改成跟着 woAll() 的实际站点数动态处理,不再写死几路。
|
||||
function mergeTmdb() {
|
||||
state.noMore = true; // 合并结果不分页,提前定住,避免触底逻辑误触发
|
||||
var hadLocal = state.source.length;
|
||||
// 不再跨来源去重:每个站的结果都独立成组显示,哪怕跟盘链/玩偶重名也各自保留(去重只在每个站内部做)。
|
||||
var jobs = [
|
||||
{ name: "4K指南", p: zn4kP },
|
||||
{ name: "七味", p: qwP },
|
||||
{ name: "蜗牛", p: wnP },
|
||||
{ name: "SeedHub", p: shP }
|
||||
];
|
||||
woAll().forEach(function (st, i) { jobs.push({ name: st.name, p: woPs[i] }); });
|
||||
jobs.push({ name: "TMDB", p: tmdbP, isTmdb: true }); // TMDB 不做 titleScore 过滤,保留原逻辑
|
||||
var settled = 0, extraCounts = {};
|
||||
function updateStatus() {
|
||||
var extra = jobs.filter(function (j) { return extraCounts[j.name]; })
|
||||
.map(function (j) { return "+" + extraCounts[j.name] + " " + j.name; });
|
||||
var total = state.source.length;
|
||||
$("browseSub").textContent = hadLocal
|
||||
? (hadLocal + " 部" + (extra.length ? " · " + extra.join(" ") : ""))
|
||||
: (total ? (total + " 部" + (extra.length ? " · " + extra.join(" ") : "")) : "");
|
||||
}
|
||||
// TMDB 只是信息卡兜底(没有可播资源):让资源站先各自渲染,TMDB 这一路排在最后再显示,
|
||||
// 保证它出现在结果最下面(顺序上排最后),不抢在资源站前面。
|
||||
var otherPs = jobs.filter(function (j) { return !j.isTmdb; }).map(function (j) { return j.p; });
|
||||
jobs.forEach(function (job) {
|
||||
var readyP = job.isTmdb ? Promise.all(otherPs).then(function () { return job.p; }) : job.p;
|
||||
readyP.then(function (list) {
|
||||
if (token !== state.reqToken) return; // 搜索词已经变了,这份结果作废
|
||||
var localSeen = {};
|
||||
var add = (list || []).filter(function (it) {
|
||||
var key = normTitleKey(it.title);
|
||||
if (!key || localSeen[key]) return false; // 只在本站结果内部去重,避免同一个站重复同名
|
||||
if (!job.isTmdb && titleScore(it.title, k) > 3) return false; // 相关性过滤保留:跟搜索词太不相关的才丢
|
||||
localSeen[key] = 1; return true;
|
||||
});
|
||||
settled++;
|
||||
if (add.length) {
|
||||
state.source = state.source.concat(sortByRelevance(add, k));
|
||||
extraCounts[job.name] = (extraCounts[job.name] || 0) + add.length;
|
||||
renderSearchGrid(); renderSearchSourceChips();
|
||||
}
|
||||
if (settled === jobs.length && !state.source.length) { renderEmpty(""); return; }
|
||||
updateStatus();
|
||||
});
|
||||
});
|
||||
}
|
||||
function finish(items) {
|
||||
if (token !== state.reqToken) return;
|
||||
state.loading = false; $("browseLoading").style.display = "none";
|
||||
state.noMore = true;
|
||||
state.source = (items && items.length) ? sortByRelevance(items, k) : []; // 完全同名的排最前,别被 xx僵尸 淹没
|
||||
if (state.source.length) { renderSearchGrid(); renderSearchSourceChips(); $("browseSub").textContent = state.source.length + " 部"; }
|
||||
mergeTmdb(); // 无论站内有没有,都拿并发那份 TMDB 补一遍
|
||||
}
|
||||
apiGet(apiUrl("search_suggestions.php") + "?q=" + encodeURIComponent(k) + "&_t=" + Date.now(), "").then(function (d) {
|
||||
if (token !== state.reqToken) return;
|
||||
var raw = (d && (d.data || d.suggestions || d.list)) || [];
|
||||
var items = normSuggest(raw, k).filter(function (it) { return it.id && !it.keyword; });
|
||||
if (items.length) { finish(items); return; }
|
||||
fallbackWd();
|
||||
}, function () { fallbackWd(); });
|
||||
function fallbackWd() {
|
||||
if (token !== state.reqToken) return;
|
||||
apiGet(apiUrl("get_videos.php") + "?wd=" + encodeURIComponent(k) + "&pg=1&_t=" + Date.now(), "").then(function (d) {
|
||||
if (token !== state.reqToken) return;
|
||||
var list = (d && (d.data || d.list)) || [];
|
||||
var seen = {}, uniq = [];
|
||||
list.map(mapVod).forEach(function (it) { if (it.title.indexOf(k) >= 0 && !seen[it.id]) { seen[it.id] = 1; uniq.push(it); } });
|
||||
if (uniq.length) { finish(uniq); return; }
|
||||
// 两个快接口都没找到,最后才用慢扫兜底;扫完由 mergeTmdb 把 TMDB 补上
|
||||
state.loading = false; $("browseLoading").style.display = "none";
|
||||
runSearchScan(k, token, mergeTmdb);
|
||||
}, function () {
|
||||
state.loading = false; $("browseLoading").style.display = "none";
|
||||
runSearchScan(k, token, mergeTmdb);
|
||||
});
|
||||
}
|
||||
}
|
||||
function runSearchScan(kw, extToken, onDone) {
|
||||
var k = (kw || "").trim(); if (!k) return;
|
||||
state.page = 1; state.source = []; state.rendered = 0; state.noMore = false; state.autoPages = 0; state.loading = false;
|
||||
// 有外层 token 就复用(并发合并要求前后用同一个 token),没有才自增
|
||||
var token = extToken || (++state.reqToken);
|
||||
var grid = $("grid"); grid.replaceChildren(); grid.classList.remove("grid-recent"); $("browseNotice").replaceChildren();
|
||||
$("browseLoading").style.display = "block";
|
||||
$("browseSub").textContent = "扫描中…";
|
||||
var CAP = 40;
|
||||
var CAP = 40; // 最多扫多少页
|
||||
var CONC = 6; // 除首页外,其余页用这个并发度同时拉,别一次把几十个请求全炸出去
|
||||
var finished = false;
|
||||
function done() {
|
||||
if (finished) return; finished = true;
|
||||
if (token !== state.reqToken) return;
|
||||
state.noMore = true; state.loading = false; $("browseLoading").style.display = "none";
|
||||
if (state.source.length) {
|
||||
// 并发扫回来的顺序不等于页码顺序,统一重排一次,让完全同名的(那部《僵尸》)浮到最前
|
||||
state.source = sortByRelevance(state.source, k);
|
||||
grid.replaceChildren(); state.rendered = 0; renderSearchGrid(); renderSearchSourceChips();
|
||||
}
|
||||
$("browseSub").textContent = state.source.length ? (state.source.length + " 部") : "";
|
||||
if (!state.source.length) renderEmpty("");
|
||||
if (onDone) onDone(); // 站内扫完,交给外层用并发那份 TMDB 补站内没有的
|
||||
}
|
||||
function scan(pg) {
|
||||
function fetchPage(pg) {
|
||||
return apiGet(apiUrl("get_videos.php") + "?pg=" + pg + "&q=" + encodeURIComponent(k) + "&_t=" + Date.now(), "");
|
||||
}
|
||||
// 把一页的结果并进 state.source + 渲染;返回该页汇报的总页数 & 服务器是否已按 q 过滤
|
||||
function ingest(d, pg) {
|
||||
if (token !== state.reqToken) return { pc: pg, serverFiltered: false };
|
||||
var raw = ((d && (d.list || d.data)) || []).map(mapVod);
|
||||
var pc = (d && d.pagecount) || pg;
|
||||
var serverFiltered = raw.length > 0 && raw.every(function (it) { return it.title.indexOf(k) >= 0; });
|
||||
var have = {}; state.source.forEach(function (s) { have[s.id] = 1; });
|
||||
var hits = raw.filter(function (it) { return it.title.indexOf(k) >= 0 && !have[it.id]; });
|
||||
if (hits.length) {
|
||||
state.source = state.source.concat(hits);
|
||||
var frag = document.createDocumentFragment();
|
||||
hits.forEach(function (it) { frag.appendChild(mediaCard(it, state.rendered++)); });
|
||||
grid.appendChild(frag);
|
||||
$("browseNotice").replaceChildren();
|
||||
}
|
||||
return { pc: pc, serverFiltered: serverFiltered };
|
||||
}
|
||||
// 第一页单独打:既要看结果,也要靠它判断服务器是不是已经按 q 过滤了(判定了就不用再扫别的页)、
|
||||
// 以及总页数是多少(决定后面并发池要跑几页)。
|
||||
fetchPage(1).then(function (d) {
|
||||
if (token !== state.reqToken) return;
|
||||
if (pg > CAP) { done(); return; }
|
||||
apiGet(apiUrl("get_videos.php") + "?pg=" + pg + "&q=" + encodeURIComponent(k) + "&_t=" + Date.now(), "").then(function (d) {
|
||||
if (token !== state.reqToken) return;
|
||||
var raw = ((d && (d.list || d.data)) || []).map(mapVod);
|
||||
var pc = (d && d.pagecount) || pg;
|
||||
var serverFiltered = raw.length > 0 && raw.every(function (it) { return it.title.indexOf(k) >= 0; });
|
||||
var have = {}; state.source.forEach(function (s) { have[s.id] = 1; });
|
||||
var hits = raw.filter(function (it) { return it.title.indexOf(k) >= 0 && !have[it.id]; });
|
||||
if (hits.length) {
|
||||
state.source = state.source.concat(hits);
|
||||
var frag = document.createDocumentFragment();
|
||||
hits.forEach(function (it) { frag.appendChild(mediaCard(it, state.rendered++)); });
|
||||
grid.appendChild(frag);
|
||||
$("browseNotice").replaceChildren();
|
||||
}
|
||||
state.lastDiag = "调试:搜“" + k + "” 扫 " + pg + "/" + Math.min(pc, CAP) + "页 · 找到 " + state.source.length + " 部";
|
||||
$("browseSub").textContent = state.source.length ? (state.source.length + " 部 · 扫描中") : ("扫描中 " + pg + "/" + Math.min(pc, CAP));
|
||||
if (serverFiltered) { done(); return; } // 服务器其实按 q 过滤了 -> 一页即可
|
||||
if (pg >= pc) { done(); return; }
|
||||
scan(pg + 1);
|
||||
}, function () { if (token === state.reqToken) done(); });
|
||||
var meta = ingest(d, 1);
|
||||
state.lastDiag = "调试:搜“" + k + "” 扫 1/" + Math.min(meta.pc, CAP) + "页 · 找到 " + state.source.length + " 部";
|
||||
$("browseSub").textContent = state.source.length ? (state.source.length + " 部 · 扫描中") : ("扫描中 1/" + Math.min(meta.pc, CAP));
|
||||
if (meta.serverFiltered) { done(); return; } // 服务器其实按 q 过滤了 -> 一页即可,不用再扫
|
||||
var lastPage = Math.min(meta.pc, CAP);
|
||||
if (lastPage <= 1) { done(); return; }
|
||||
// 剩下第 2..lastPage 页,起一个小并发池同时拉,取代原来"等上一页回来才发下一页"的串行扫描
|
||||
var pages = []; var p2; for (p2 = 2; p2 <= lastPage; p2++) pages.push(p2);
|
||||
var idx = 0, finishedCount = 0, totalJobs = pages.length;
|
||||
function worker() {
|
||||
if (idx >= pages.length || token !== state.reqToken) return Promise.resolve();
|
||||
var pg = pages[idx++];
|
||||
return fetchPage(pg).then(function (d2) {
|
||||
if (token === state.reqToken) ingest(d2, pg);
|
||||
}, function () { /* 单页失败不影响其它页,跳过即可 */ }).then(function () {
|
||||
finishedCount++;
|
||||
if (token === state.reqToken) {
|
||||
$("browseSub").textContent = state.source.length
|
||||
? (state.source.length + " 部 · 扫描中 " + finishedCount + "/" + totalJobs)
|
||||
: ("扫描中 " + finishedCount + "/" + totalJobs);
|
||||
}
|
||||
return worker();
|
||||
});
|
||||
}
|
||||
var workers = [], w; for (w = 0; w < Math.min(CONC, pages.length); w++) workers.push(worker());
|
||||
Promise.all(workers).then(done);
|
||||
}, function () { if (token === state.reqToken) done(); });
|
||||
}
|
||||
/* TMDB 搜索补充:每次搜索都并发调 TMDB /search/multi(不等站内、不看站内命中数),
|
||||
站内那条路走完后再把 TMDB 里站内没有的补在结果下面。
|
||||
不要求配 TMDB Key(没配就静默跳过,直接空结果)。结果标题带年份,供用户自己甄别是不是想要的那部;
|
||||
点击后走跟"TMDB 热播"一样的套路——openTrendingDetail 会先用 TMDB 数据把详情页开起来,
|
||||
后台再拿片名去盘链反查,搜到就原地升级成真实可播资源,搜不到就留在 TMDB 信息页(无法播放)。*/
|
||||
function tmdbSearchList(kw) {
|
||||
if (!config.tmdbKey) return Promise.resolve([]);
|
||||
var k = (kw || "").trim(); if (!k) return Promise.resolve([]);
|
||||
// 跟 Eclipse 一样分开查 movie + tv(各 20 条名额),比 /search/multi 更容易把冷门的完全同名片捞回来
|
||||
var mvP = httpJson(tmdbApi("/search/movie", { query: k, include_adult: "false" })).then(function (d) { return (d && d.results) || []; }, function () { return []; });
|
||||
var tvP = httpJson(tmdbApi("/search/tv", { query: k, include_adult: "false" })).then(function (d) { return (d && d.results) || []; }, function () { return []; });
|
||||
return Promise.all([mvP, tvP]).then(function (arr) {
|
||||
var all = arr[0].map(function (r) { return mapTmdbDiscoverItem(r, "movie"); })
|
||||
.concat(arr[1].map(function (r) { return mapTmdbDiscoverItem(r, "tv"); }));
|
||||
all.sort(function (a, b) { return (b._popularity || 0) - (a._popularity || 0); });
|
||||
var seen = {}, out = [];
|
||||
all.forEach(function (x) {
|
||||
if (!x.title || !x.poster) return;
|
||||
var key = normTitleKey(x.title) + "|" + (x.year || "");
|
||||
if (seen[key]) return; seen[key] = 1; out.push(x);
|
||||
});
|
||||
return out;
|
||||
});
|
||||
}
|
||||
/* 标题归一化:去空白/常见括号符号 + 小写,用来判断 TMDB 结果和站内结果是不是同一部(去重用)。 */
|
||||
/* 归一化标题做去重 key:先净化站点 SEO 后缀,再去掉年份/季号/画质/空格标点,
|
||||
这样"爱情公寓2(2011)"和"立刻播放爱情公寓2"会归一成同一个 key,自动合并成一部。 */
|
||||
function normTitleKey(t) {
|
||||
var s = sanitizeSiteTitle(String(t || ""));
|
||||
s = s.replace(/[((]\s*(?:19|20)\d{2}\s*[))]/g, "").replace(/(?:19|20)\d{2}/g, ""); // 去年份
|
||||
s = s.replace(/第\s*[0-9〇一二两三四五六七八九十百]+\s*[季部]/g, ""); // 去"第X季"
|
||||
return s.replace(/[\s《》「」【】\[\]()()·::\-—_.]/g, "").toLowerCase();
|
||||
}
|
||||
/* 判断一段简介是不是站点的 SEO 营销话术(不是真剧情),是的话就别往详情页塞——
|
||||
典型特征:含"点击获取/点击播放/磁力链接/提供修复/资源+/扫码/关注"等推广词,或整体太短。 */
|
||||
function isJunkOverview(t) {
|
||||
t = String(t || "").trim();
|
||||
if (!t) return true;
|
||||
if (/点击(获取|播放|观看|下载)|扫码|关注|公众号|提供修复|资源\+|磁力链接|networkdisk|获取\d?K?资源|群|加入我们|更多资源|在线播放|立即观看/i.test(t)) return true;
|
||||
return false;
|
||||
}
|
||||
/* 站点卡片标题净化:去掉各站为 SEO 塞在片名后的营销词/站名后缀,
|
||||
比如"XXX立即播放"、"XXX在线观看"、"XXX迅雷下载"、"XXX - SeedHub"、"XXX详情介绍"等,
|
||||
只保留真正的片名。搜索结果、卡片、详情标题都用它,避免脏字进片名、也帮 TMDB 匹配。 */
|
||||
function sanitizeSiteTitle(t) {
|
||||
t = String(t || "");
|
||||
// 去开头的营销前缀:"立刻播放/立即播放/在线观看XXX" 里的动词前缀
|
||||
t = t.replace(/^\s*(?:立刻播放|立即播放|立刻观看|立即观看|在线播放|在线观看|免费观看|免费播放|高清观看|点击播放|极速播放|手机观看)\s*/g, "");
|
||||
// 去站名/分隔后缀: - SeedHub | 蜗牛 | 影视&动漫分享 之类
|
||||
t = t.replace(/\s*[-_|·—]\s*(?:SeedHub|蜗牛|木偶|玩偶|原盘高清|影视.*$|.*分享.*$).*$/i, "");
|
||||
// 去常见 SEO 尾巴(从这些词开始,后面整段砍掉)
|
||||
t = t.replace(/(立即播放|立刻播放|立即观看|立刻观看|在线播放|在线观看|高清观看|免费观看|免费播放|极速播放|手机观看|点击播放|迅雷下载|BT下载|磁力下载|全集观看|全集播放|详情介绍|剧情介绍|完整版在线|高清在线|_高清|—高清).*$/g, "");
|
||||
// 去残留的书名号包裹(有的站把片名包在《》里再接后缀)
|
||||
var mm = t.match(/《([^》]{1,40})》/); if (mm) t = mm[1];
|
||||
return t.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
/* 网盘条目行标题净化:玩偶/木偶这类站,条目标题原样是页面上"复制链接"按钮的文案,
|
||||
形如"复制《金谍行动》第1集下载地址"、"复制《金谍行动》玩偶哥哥地址"——带着"复制/地址"这些操作性
|
||||
文字,观感很怪。直接取《》里包着的片名展示("金谍行动"),比集数/分组名更直观。只用于展示,
|
||||
不改 item.title 本身(原始文本还要拿来跟片名比对、判断是不是别的片子)。 */
|
||||
function cleanPanRowTitle(t) {
|
||||
var orig = String(t || "").trim();
|
||||
if (!orig) return orig;
|
||||
var mk = orig.match(/[《【]([^》】]{1,40})[》】]/);
|
||||
if (mk && mk[1].trim()) return mk[1].trim();
|
||||
var s = orig;
|
||||
s = s.replace(/^\s*(?:复制|点击复制|一键复制|获取)\s*/, ""); // 去掉开头的操作动词
|
||||
s = s.replace(/\s*(?:下载地址|地址|下载链接|链接|下载)\s*$/, ""); // 去掉结尾的"地址/链接/下载"
|
||||
s = s.replace(/\s+/g, " ").trim();
|
||||
return s || orig; // 清没了就回退原文,别显示空白
|
||||
}
|
||||
/* 相关度打分:完全同名 < 以关键词开头 < 以关键词结尾 < 中间包含 < 不含。数字越小越靠前。
|
||||
比较用归一化后的标题,避免《僵尸》和 僵尸 因书名号被判成不一致。 */
|
||||
function titleScore(t, k) {
|
||||
var nt = normTitleKey(t), nk = normTitleKey(k);
|
||||
if (!nk) return 4;
|
||||
if (nt === nk) return 0;
|
||||
if (nt.indexOf(nk) === 0) return 1;
|
||||
if (nt.length >= nk.length && nt.slice(nt.length - nk.length) === nk) return 2;
|
||||
// “中间包含”只有在多出的字数很少(≤3,容个别集数/清晰度尾巴)时才算同一部;
|
||||
// 差太多说明是完全不同的作品,标题只是凑巧含有这几个字(比如搜“幸运女神”
|
||||
// 命中《刚下山被幸运女神拉去领证》),不能当匹配处理,否则会把风马牛不相及的
|
||||
// 网盘资源并进详情页。
|
||||
if (nt.indexOf(nk) >= 0 && (nt.length - nk.length) <= 3) return 3;
|
||||
return 4;
|
||||
}
|
||||
/* 网盘资源标题校验:关键词必须出现在资源标题靠前的位置(允许最前面有《「"『【[ 等一两个
|
||||
包裹符号),才认为这条资源确实是这部片子的。像"刚下山被幸运女神拉去领证"这种关键词被埋在
|
||||
一大段无关内容后面的,直接判定不是同一部——真正对应的资源标题结构基本都是"片名 + 集数/
|
||||
清晰度/发布人尾巴",片名不会被塞在别的完整句子中间。 */
|
||||
/* 跟在片名后面的"发行版本/配音/修复"类后缀词:这些是同一部片的不同发行版本,
|
||||
不是新片名的一部分。用来把"功夫修复版/功夫数码修复版/功夫粤语版"里的版本尾巴剥掉,
|
||||
避免它们被下面的"同头不同片"判定误伤。长词在前,保证 $ 锚定时优先吃掉最长的一段。 */
|
||||
var EDITION_TAIL_RE = /(数码修复版?|数字修复版?|数码修复|数字修复|修复版?|重制版?|重置版?|国配版?|中配版?|港版|台版|美版|日版|内地版?|大陆版?|完全版?|完整版?|特别版?|纪念版?|典藏版?|珍藏版?|收藏版?|导演剪辑版?|导演版?|剧场版?|无删减版?|未删减版?|加长版?|双语版?|国语版?|粤语版?|中字版?|高清版?|超清版?|清晰版?|无水印版?|重映版?|重映|洗版|版)$/;
|
||||
function resourceTitleLikelyMatches(resTitle, kw, year) {
|
||||
var raw = String(resTitle || "");
|
||||
// 这是个视频网盘 App,不收漫画/小说/绘本这类印刷品——即使标题字面完全对上(比如同名老漫画
|
||||
// 《幸运女神》),也不是这部片子要的东西,而且这种"同名但不同媒介"的撞车,光靠标题位置判断
|
||||
// 根本分不出来,只能按内容类型直接排除。
|
||||
if (/漫画|小说|绘本|连环画|轻小说|作者[::]|\btxt\b/i.test(raw)) return false;
|
||||
var nk = normTitleKey(kw);
|
||||
if (!nk) return true; // 没有关键词可比对,不过滤
|
||||
var nt = normTitleKey(raw); // normTitleKey 本身就会把书名号/括号/空格等结构符号全部清掉
|
||||
var idx = nt.indexOf(nk);
|
||||
if (idx < 0) return false; // 压根不含关键词,肯定不是
|
||||
var hasYear = !!(year && raw.indexOf(String(year)) >= 0);
|
||||
|
||||
// 「同头不同片」防撞:搜「功夫」却召回《功夫熊猫》《功夫瑜伽》《功夫足球》——关键词只是人家
|
||||
// 更长片名的开头,而"功夫"两个字同样贴在标题最前面(idx<=3),光看位置根本分不出来。
|
||||
// 办法:先用 cleanTitle 把画质/版本/语言/年份/集数这些发行标签统统剥掉,得到真正的「核心片名」,
|
||||
// 再拿核心片名跟关键词比:
|
||||
// ① 核心片名 == 关键词 → 就是这部;
|
||||
// ② 关键词是核心片名前缀,剥掉版本尾巴后还黏着 ≥2 个成词汉字(熊猫/瑜伽/足球)→ 另一部同头的片,排除。
|
||||
// cleanTitle 剥得掉「4K/蓝光/粤语/修复版」这类标签,却剥不掉「熊猫」这种真片名的一部分,
|
||||
// 正好用它来区分「功夫粤语版4K」(是这部)和「功夫熊猫」(不是这部)。带本片年份的破例保留。
|
||||
var core = normTitleKey(cleanTitle(raw)) || nt;
|
||||
if (core !== nk && core.indexOf(nk) === 0) {
|
||||
var tail = core.slice(nk.length);
|
||||
while (EDITION_TAIL_RE.test(tail)) { var nx = tail.replace(EDITION_TAIL_RE, ""); if (nx === tail) break; tail = nx; }
|
||||
var tailCjk = tail.replace(/[^\u3400-\u9fff\uf900-\ufaff]/g, ""); // 尾巴里还剩几个汉字
|
||||
if (tailCjk.length >= 2 && !hasYear) return false; // 后面还跟着成词的汉字 → 是同头的另一部片
|
||||
}
|
||||
scan(1);
|
||||
|
||||
if (idx <= 3) return true; // 关键词紧贴标题开头(容3个字残留前缀),直接认
|
||||
// 前缀比较长时,先别急着当"蹭关键词"处理——很多真实资源发布者习惯在片名前加频道/分类前缀,
|
||||
// 比如"美剧速递:幸运女神(2026)",只要标题里明确带着这部片子的年份,年份+关键词同时命中,
|
||||
// 基本能排除是《刚下山被幸运女神拉去领证》这种完全不相关、也不会带这个年份的内容。
|
||||
if (hasYear) return true;
|
||||
return false;
|
||||
}
|
||||
/* 宽松包含判断:谁包含谁都行,但多出的字数要≤3,跟 titleScore 里“中间包含”那档用同一把尺,
|
||||
给几个各自维护自己打分逻辑的反查函数(zn4kReverseLookup/fyReverseLookup/openTrendingDetail/
|
||||
panReverseLookup)复用,避免它们各自的 indexOf 判断比 titleScore 还松,绕开了上面的收紧。 */
|
||||
function titleCloseContain(a, b) {
|
||||
a = String(a || ""); b = String(b || "");
|
||||
if (!a || !b) return false;
|
||||
if (a.indexOf(b) >= 0) return (a.length - b.length) <= 3;
|
||||
if (b.indexOf(a) >= 0) return (b.length - a.length) <= 3;
|
||||
return false;
|
||||
}
|
||||
/* 按相关度稳定排序:只把更贴近纯关键词的提到前面,同一档内保持原顺序(不打乱服务器给的排序)。
|
||||
解决「搜 僵尸 时,那部就叫《僵尸》的两字片被一堆 xx僵尸/僵尸xx 淹没」的问题。 */
|
||||
function sortByRelevance(list, k) {
|
||||
return list
|
||||
.map(function (x, i) { return { x: x, i: i, s: titleScore(x.title, k) }; })
|
||||
.sort(function (a, b) { return (a.s - b.s) || (a.i - b.i); })
|
||||
.map(function (o) { return o.x; });
|
||||
}
|
||||
function loadList(reset) {
|
||||
if (state.searchMode) {
|
||||
if (reset) searchViaSuggestAPI(state.searchKw);
|
||||
return;
|
||||
}
|
||||
// 指南合集/榜单(豆瓣高分):数据来自 4K指南,不走盘链的分类接口,续页交给 zn4kLoadMore
|
||||
if (state.activeCat === ZN4K_CAT_SLUG) {
|
||||
if (!reset) zn4kLoadMore();
|
||||
return;
|
||||
}
|
||||
// 七味分类(电影/剧集/综艺/动漫/短剧):数据来自七味镜像,续页交给 qwLoadCat
|
||||
if (state.activeCat === QW_CAT_SLUG) {
|
||||
qwLoadCat(!!reset);
|
||||
return;
|
||||
}
|
||||
// 枫叶:数据来自 cd-zj(在线点播站),同样不走盘链接口,续页交给 fyLoadCat
|
||||
if (state.activeCat === FY_CAT_SLUG) {
|
||||
fyLoadCat(!!reset);
|
||||
return;
|
||||
}
|
||||
// 蜗牛:数据来自 zmi.kdns.fr,续页交给 wnLoadCat
|
||||
if (state.activeCat === WN_CAT_SLUG) {
|
||||
wnLoadCat(!!reset);
|
||||
return;
|
||||
}
|
||||
// 森中有林:数据来自 sidhub.cc,续页交给 shLoadCat
|
||||
if (state.activeCat === SH_CAT_SLUG) {
|
||||
shLoadCat(!!reset);
|
||||
return;
|
||||
}
|
||||
// 玩偶/木偶:数据来自各自站,续页交给 woLoadCat
|
||||
if (woAll().some(function (st) { return state.activeCat === st.slug; })) {
|
||||
woLoadCat(!!reset);
|
||||
return;
|
||||
}
|
||||
if (reset) {
|
||||
state.page = 1; state.source = []; state.rendered = 0; state.noMore = false; state.autoPages = 0;
|
||||
state.loading = false; // 关键:切换/刷新时复位,旧的在途请求会因 token 失配被忽略
|
||||
$("grid").replaceChildren();
|
||||
$("grid").classList.remove("grid-recent");
|
||||
$("browseNotice").replaceChildren();
|
||||
state.reqToken++;
|
||||
}
|
||||
@@ -1006,31 +5490,67 @@ function loadList(reset) {
|
||||
});
|
||||
}
|
||||
/* 影片对象映射 */
|
||||
function stripHtml(s) { return String(s || "").replace(/<[^>]*>/g, "").replace(/ /g, " ").replace(/\u3000/g, " ").replace(/\s+/g, " ").trim(); }
|
||||
function mapVod(v) {
|
||||
return {
|
||||
id: String(v.vod_id),
|
||||
title: v.vod_name || "",
|
||||
poster: v.vod_pic || "",
|
||||
typeName: v.type_name || "",
|
||||
area: v.vod_area || "",
|
||||
year: (v.vod_year != null ? String(v.vod_year) : "").replace(/[^0-9].*$/, ""),
|
||||
meta: [v.vod_year, v.vod_area, v.type_name].filter(function (x) { return x; }).join(" / "),
|
||||
status: v.vod_remarks || ""
|
||||
status: v.vod_remarks || "",
|
||||
overview: stripHtml(v.vod_blurb || v.vod_content || "")
|
||||
};
|
||||
}
|
||||
/* 取列表:get_videos.php?pg=&t=(真分类 + 翻页)。全部=不带 t */
|
||||
/* 取列表:get_videos.php?pg=&t=(真分类 + 翻页)。全部=不带 t
|
||||
同一 分类+页码 短期内(3 分钟)重复请求,直接用内存缓存,不再打网络:
|
||||
首页各分类行、轮播候选池、分类页翻页,很多时候会撞上同一页,缓存后来回切换/重新打开都秒开。
|
||||
手动下拉刷新(refreshCurrent)会清空这份缓存,保证那次是真刷新。 */
|
||||
var _listCache = {};
|
||||
var LIST_CACHE_TTL = 3 * 60 * 1000;
|
||||
function _listCacheKey(cat, page) { return (cat || "") + "|" + (page || 1); }
|
||||
function fetchListOnce(cat, page) {
|
||||
var url = apiUrl("get_videos.php") + "?pg=" + (page || 1) + (cat ? "&t=" + encodeURIComponent(cat) : "") + "&_t=" + Date.now();
|
||||
var key = _listCacheKey(cat, page);
|
||||
var hit = _listCache[key];
|
||||
if (hit && (Date.now() - hit.ts) < LIST_CACHE_TTL) {
|
||||
state.lastDiag = "调试:get_videos t=" + (cat || "全部") + " 第" + (page || 1) + "/" + hit.pagecount + "页 · " + hit.list.length + " 条 · 缓存";
|
||||
if ((page || 1) >= hit.pagecount) state.noMore = true;
|
||||
return Promise.resolve(hit.list.slice());
|
||||
}
|
||||
var url = apiUrl("get_videos.php") + "?pg=" + (page || 1) + (cat ? "&t=" + encodeURIComponent(cat) : "");
|
||||
return apiGet(url, "").then(function (d) {
|
||||
if (!d) throw new Error("空响应");
|
||||
if (d.success === false || (d.code != null && d.code !== 1 && !(d.list && d.list.length))) throw new Error(d.msg || d.message || "接口失败");
|
||||
var list = d.list || d.data || [];
|
||||
var pagecount = d.pagecount || (list.length ? (page || 1) + 1 : (page || 1));
|
||||
state.lastDiag = "调试:get_videos t=" + (cat || "全部") + " 第" + (page || 1) + "/" + pagecount + "页 · " + list.length + " 条" + (state.sdkReady ? " · App" : " · 预览");
|
||||
var mapped = list.map(mapVod);
|
||||
_listCache[key] = { list: mapped, pagecount: pagecount, ts: Date.now() };
|
||||
state.lastDiag = "调试:get_videos t=" + (cat || "全部") + " 第" + (page || 1) + "/" + pagecount + "页 · " + mapped.length + " 条" + (state.sdkReady ? " · App" : " · 预览");
|
||||
if ((page || 1) >= pagecount) state.noMore = true;
|
||||
return list.map(mapVod);
|
||||
return mapped;
|
||||
});
|
||||
}
|
||||
/* 供轮播候选池用:只取数据,不动 state.noMore / state.lastDiag 这些分类列表翻页共用的全局状态,
|
||||
避免轮播预抓多页时把“到底了”提前标记,影响分类页正常的下拉加载更多。同样吃上面那份缓存。 */
|
||||
function fetchListOnceSilent(cat, page) {
|
||||
var key = _listCacheKey(cat, page);
|
||||
var hit = _listCache[key];
|
||||
if (hit && (Date.now() - hit.ts) < LIST_CACHE_TTL) return Promise.resolve(hit.list.slice());
|
||||
var url = apiUrl("get_videos.php") + "?pg=" + (page || 1) + (cat ? "&t=" + encodeURIComponent(cat) : "");
|
||||
return apiGet(url, "").then(function (d) {
|
||||
if (!d) throw new Error("空响应");
|
||||
if (d.success === false || (d.code != null && d.code !== 1 && !(d.list && d.list.length))) throw new Error(d.msg || d.message || "接口失败");
|
||||
var list = d.list || d.data || [];
|
||||
var pagecount = d.pagecount || (list.length ? (page || 1) + 1 : (page || 1));
|
||||
var mapped = list.map(mapVod);
|
||||
_listCache[key] = { list: mapped, pagecount: pagecount, ts: Date.now() };
|
||||
return mapped;
|
||||
});
|
||||
}
|
||||
function fetchList(cat, page) {
|
||||
if (cat === TMDB_CAT_SLUG) return fetchTmdbTrendPage(page);
|
||||
return fetchListOnce(cat, page).then(function (list) {
|
||||
// 首页取回 0 条且配了账号密码 -> 疑似登录失效,自动续登重试一次
|
||||
if (!list.length && (page || 1) === 1 && config.account && config.password && !state.reloginTried) {
|
||||
@@ -1094,8 +5614,8 @@ function renderEmpty(errMsg) {
|
||||
} else if (errMsg === "__nocards__") {
|
||||
div.innerHTML = "<h4>没解析到影片</h4><p>这个列表可能由脚本动态加载,或链接结构不同。已切到「全部」可正常浏览;此分类需要它的接口。</p>";
|
||||
var goAll = document.createElement("button");
|
||||
goAll.type = "button"; goAll.className = "btn focusable"; goAll.textContent = "回到全部"; goAll.style.maxWidth = "140px";
|
||||
goAll.onclick = function () { selectCat(""); };
|
||||
goAll.type = "button"; goAll.className = "btn focusable"; goAll.textContent = "回到首页"; goAll.style.maxWidth = "140px";
|
||||
goAll.onclick = function () { showHome(); };
|
||||
div.appendChild(goAll);
|
||||
} else if (errMsg) {
|
||||
div.innerHTML = "<h4>暂时没有内容</h4><p>" + escapeHtml(errMsg) + "。检查网络,或在设置里更新登录 Cookie。</p>";
|
||||
@@ -1112,9 +5632,105 @@ function renderEmpty(errMsg) {
|
||||
if (state.lastDiag) { var dbg = document.createElement("p"); dbg.style.cssText = "margin-top:10px;font-size:11px;color:var(--ink-faint)"; dbg.textContent = state.lastDiag; div.appendChild(dbg); }
|
||||
}
|
||||
|
||||
/* 动漫分类固定二级标签:不再按站点抓到的原始类型词随机罗列,只保留这三档,
|
||||
同一档内用「二级分类名 type_name」优先、「地区 vod_area」兜底两路匹配,兼容不同站点命名习惯 */
|
||||
var ANIME_GENRES = [
|
||||
{ name: "日漫", typeRe: /日漫|日本动画|日本动漫/, areaRe: /日本/ },
|
||||
{ name: "国漫", typeRe: /国漫|国产动画|国产动漫/, areaRe: /大陆|中国/ },
|
||||
{ name: "美漫", typeRe: /美漫|欧美动画|欧美动漫/, areaRe: /美国|欧美/ }
|
||||
];
|
||||
/* ---------- 搜索结果按站源分组 ----------
|
||||
之前搜索结果是一条 state.source 拼出来的平铺网格,各站的卡片混在一起,
|
||||
哪条来自哪站只能靠详情页里的 site-tag 才看得出来。改成跟"筛选类型"chip 同一套交互:
|
||||
#genreChips 复用出一排"全部 N / 站源 N"的胶囊,点哪个筛哪个;"全部"态下网格按站源分组,
|
||||
每组前插一条跨列的分组标题(.search-group-title 用 grid-column:1/-1 撑满,不用另起一个网格容器)。
|
||||
分组顺序固定为 mergeTmdb 里补数据的顺序(本站 -> 4K指南 -> 七味 -> 蜗牛 -> SeedHub -> 玩偶/木偶/pomo -> TMDB),
|
||||
跟"这条结果是怎么补进来的"保持一致,不随到达顺序抖动。 */
|
||||
var SEARCH_SOURCE_ORDER = ["本站", "4K指南", "七味", "蜗牛", "SeedHub", "玩偶", "木偶", "pomo", "TMDB"];
|
||||
function itemSourceName(it) {
|
||||
if (it._zn4k) return "4K指南";
|
||||
if (it._qw) return "七味";
|
||||
if (it._wn) return "蜗牛";
|
||||
if (it._sh) return "SeedHub";
|
||||
if (it._wo) return (WO_SITES[it._woSite] && WO_SITES[it._woSite].name) || "玩偶";
|
||||
if (it._tmdbId) return "TMDB";
|
||||
return "本站";
|
||||
}
|
||||
/* 展示用的来源名:内部分组键仍叫"本站"(itemSourceName / SEARCH_SOURCE_ORDER / 筛选 key 都不动),
|
||||
只在给用户看的胶囊和分组标题上把它显示成"盘链"。 */
|
||||
function searchSourceLabel(s) { return s === "本站" ? "盘链" : s; }
|
||||
function searchGroups() {
|
||||
var groups = {}, counts = {};
|
||||
state.source.forEach(function (it) {
|
||||
var s = itemSourceName(it);
|
||||
if (!groups[s]) { groups[s] = []; counts[s] = 0; }
|
||||
groups[s].push(it); counts[s]++;
|
||||
});
|
||||
var order = SEARCH_SOURCE_ORDER.filter(function (s) { return groups[s]; });
|
||||
Object.keys(groups).forEach(function (s) { if (order.indexOf(s) < 0) order.push(s); }); // 兜底:以后加了没登记的来源也不漏
|
||||
return { groups: groups, counts: counts, order: order };
|
||||
}
|
||||
/* 搜索模式下 #genreChips 的筛选栏:只有一个来源时(比如只有"本站")没有分组意义,直接隐藏 */
|
||||
function renderSearchSourceChips() {
|
||||
var box = $("genreChips"); if (!box) return;
|
||||
var g = searchGroups();
|
||||
if (g.order.length < 2) { box.style.display = "none"; box.replaceChildren(); return; }
|
||||
box.style.display = ""; // startSearch 进入搜索时把它设成了 none,这里真正有胶囊要显示时得改回来,否则胶囊塞进去了容器还是隐藏的
|
||||
var frag = document.createDocumentFragment();
|
||||
function makeChip(label, count, key, i) {
|
||||
var b = document.createElement("button");
|
||||
b.type = "button";
|
||||
b.className = "genre-chip focusable" + (state.searchSourceFilter === key ? " active" : "");
|
||||
b.innerHTML = escapeHtml(label) + "<span class='chip-count'>" + count + "</span>";
|
||||
b.setAttribute("data-focus-row", "genreChips");
|
||||
b.setAttribute("data-focus-index", String(i));
|
||||
b.onclick = function () {
|
||||
state.searchSourceFilter = (state.searchSourceFilter === key ? "" : key);
|
||||
renderSearchSourceChips(); renderSearchGrid();
|
||||
};
|
||||
return b;
|
||||
}
|
||||
frag.appendChild(makeChip("全部", state.source.length, "", 0));
|
||||
g.order.forEach(function (s, i) { frag.appendChild(makeChip(searchSourceLabel(s), g.counts[s], s, i + 1)); });
|
||||
box.replaceChildren(frag);
|
||||
}
|
||||
/* 搜索结果网格:数据量小、一次性到齐(不像分类页要分页),每次新数据到位就整体重画一次,
|
||||
不需要 appendGrid 那套"批量渲染+触底翻页"的逻辑。 */
|
||||
function renderSearchGrid() {
|
||||
var grid = $("grid"); if (!grid) return;
|
||||
var frag = document.createDocumentFragment();
|
||||
var idx = 0;
|
||||
function addItem(it) {
|
||||
var onClick = it._tmdbId ? (function (x) { return function () { openTrendingDetail(x); }; })(it) : undefined;
|
||||
frag.appendChild(mediaCard(it, idx++, undefined, onClick));
|
||||
}
|
||||
var g = searchGroups();
|
||||
if (state.searchSourceFilter) {
|
||||
(g.groups[state.searchSourceFilter] || []).forEach(addItem);
|
||||
} else {
|
||||
g.order.forEach(function (s) {
|
||||
var head = el("div", "search-group-title");
|
||||
head.appendChild(document.createTextNode(searchSourceLabel(s)));
|
||||
head.appendChild(el("span", "cnt", g.counts[s] + " 部"));
|
||||
frag.appendChild(head);
|
||||
g.groups[s].forEach(addItem);
|
||||
});
|
||||
}
|
||||
grid.replaceChildren(frag);
|
||||
state.rendered = state.source.length;
|
||||
}
|
||||
function visSource() {
|
||||
if (!state.activeGenre) return state.source;
|
||||
var g = state.activeGenre;
|
||||
if (state.activeCat === "4") {
|
||||
var conf = null;
|
||||
for (var i = 0; i < ANIME_GENRES.length; i++) if (ANIME_GENRES[i].name === g) { conf = ANIME_GENRES[i]; break; }
|
||||
if (conf) return pickKind(state.source, conf.typeRe, conf.areaRe);
|
||||
}
|
||||
if (state.activeCat === TMDB_CAT_SLUG && tmdbProviderConf(g)) {
|
||||
// 平台标签:state.source 已经是 fetchTmdbProviderPage 按该平台单独拉回的数据,无需再本地筛一遍
|
||||
return state.source;
|
||||
}
|
||||
return state.source.filter(function (it) { return it.typeName === g; });
|
||||
}
|
||||
function appendGrid() {
|
||||
@@ -1124,7 +5740,11 @@ function appendGrid() {
|
||||
var batch = state.rendered === 0 ? (state.isTv ? 18 : 12) : (state.isTv ? 12 : 9);
|
||||
var end = Math.min(state.rendered + batch, vis.length);
|
||||
var frag = document.createDocumentFragment();
|
||||
for (var i = state.rendered; i < end; i++) frag.appendChild(mediaCard(vis[i], i));
|
||||
for (var i = state.rendered; i < end; i++) {
|
||||
var _it = vis[i];
|
||||
var _onClick = _it._tmdbId ? (function (x) { return function () { openTrendingDetail(x); }; })(_it) : undefined;
|
||||
frag.appendChild(mediaCard(_it, i, undefined, _onClick));
|
||||
}
|
||||
grid.appendChild(frag);
|
||||
state.rendered = end;
|
||||
// 需要更多:已渲染到已加载末尾且还有下一页 -> 取下一页
|
||||
@@ -1133,12 +5753,29 @@ function appendGrid() {
|
||||
state.autoPages++;
|
||||
state.page++;
|
||||
loadList(false);
|
||||
} else {
|
||||
// 内容装不满一屏时页面根本没有滚动条,"触底"滚动事件永远不会触发,
|
||||
// 之前只能靠手动下拉刷新(复位 autoPages)才能继续加载;这里渲染完主动补一次检查,
|
||||
// 装不满就自动接着翻页,不用等用户刷新页面
|
||||
setTimeout(ensureFilled, 30);
|
||||
}
|
||||
}
|
||||
/* 装不满一屏(没有可滚动的空间)时自动继续翻页,直到装满屏或确实没有更多数据 */
|
||||
function ensureFilled() {
|
||||
if (state.focusScope !== "home" || state.searchMode || state.activeCat === "__recent__") return;
|
||||
if (state.loading || state.noMore) return;
|
||||
var vis = visSource();
|
||||
if (state.rendered < vis.length) { appendGrid(); return; } // 本地已有数据但还没渲染完,先渲染
|
||||
var doc = document.documentElement;
|
||||
var filled = doc.scrollHeight > window.innerHeight + 80;
|
||||
if (!filled) { state.autoPages = 0; appendGrid(); }
|
||||
}
|
||||
/* 二级类型筛选 */
|
||||
function renderGenreChips() {
|
||||
var box = $("genreChips"); if (!box) return;
|
||||
if (state.searchMode || state.activeCat === "__recent__") { box.replaceChildren(); return; }
|
||||
if (state.activeCat === "4") { renderAnimeGenreChips(box); return; }
|
||||
if (state.activeCat === TMDB_CAT_SLUG) { renderTmdbGenreChips(box); return; }
|
||||
var seen = {}, list = [];
|
||||
state.source.forEach(function (it) { var t = it.typeName; if (t && !seen[t]) { seen[t] = 1; list.push(t); } });
|
||||
if (list.length < 2) { box.replaceChildren(); return; }
|
||||
@@ -1156,10 +5793,52 @@ function renderGenreChips() {
|
||||
});
|
||||
box.replaceChildren(frag);
|
||||
}
|
||||
/* 动漫分类:固定渲染「日漫 / 国漫 / 美漫」三个标签,不随抓到的原始类型词变化 */
|
||||
function renderAnimeGenreChips(box) {
|
||||
var frag = document.createDocumentFragment();
|
||||
ANIME_GENRES.forEach(function (g, i) {
|
||||
var b = document.createElement("button");
|
||||
b.type = "button";
|
||||
b.className = "genre-chip focusable" + (state.activeGenre === g.name ? " active" : "");
|
||||
b.textContent = g.name;
|
||||
b.setAttribute("data-genre", g.name);
|
||||
b.setAttribute("data-focus-row", "genreChips");
|
||||
b.setAttribute("data-focus-index", String(i));
|
||||
b.onclick = function () { selectGenre(state.activeGenre === g.name ? "" : g.name); };
|
||||
frag.appendChild(b);
|
||||
});
|
||||
box.replaceChildren(frag);
|
||||
}
|
||||
/* TMDB 热播分类:固定「全部 / 电影 / 剧集 / Netflix / Disney+ / HBO / Hulu / Prime Video / Apple TV+」标签。
|
||||
“全部”对应空筛选值,点击即回到未筛选列表;电影、剧集复用通用 typeName 精确匹配(下面 visSource 的兜底分支);
|
||||
各平台标签走 tmdbProviderConf 命中的专门分支,切换时改拉 discover 接口(见 selectGenre 里的重置逻辑)。
|
||||
这几个标签点击直接切到对应值,不做“再点一次取消”那套 toggle,因为“全部”本身就承担了取消筛选的角色 */
|
||||
function renderTmdbGenreChips(box) {
|
||||
var frag = document.createDocumentFragment();
|
||||
TMDB_TREND_GENRES.forEach(function (g, i) {
|
||||
var b = document.createElement("button");
|
||||
b.type = "button";
|
||||
b.className = "genre-chip focusable" + (state.activeGenre === g.value ? " active" : "");
|
||||
b.textContent = g.name;
|
||||
b.setAttribute("data-genre", g.name);
|
||||
b.setAttribute("data-focus-row", "genreChips");
|
||||
b.setAttribute("data-focus-index", String(i));
|
||||
b.onclick = function () { selectGenre(g.value); };
|
||||
frag.appendChild(b);
|
||||
});
|
||||
box.replaceChildren(frag);
|
||||
}
|
||||
function selectGenre(t) {
|
||||
state.activeGenre = t;
|
||||
state.autoPages = 0;
|
||||
window.scrollTo(0, 0);
|
||||
if (state.activeCat === TMDB_CAT_SLUG) {
|
||||
// TMDB 这边「电影/剧集」是本地筛已加载数据,但平台标签(Netflix/HBO/葫芦…)背后是完全不同的
|
||||
// discover 接口,必须重新拉取,所以这里统一走 loadList(true) 重置分页
|
||||
renderGenreChips();
|
||||
loadList(true);
|
||||
return;
|
||||
}
|
||||
state.autoPages = 0;
|
||||
state.rendered = 0;
|
||||
$("grid").replaceChildren();
|
||||
$("browseNotice").replaceChildren();
|
||||
@@ -1169,7 +5848,7 @@ function selectGenre(t) {
|
||||
$("browseSub").textContent = (t ? vis.length + " 部(" + t + ")" : state.source.length + " 部");
|
||||
if (t && vis.length === 0 && state.noMore) renderEmpty("__nogenre__");
|
||||
}
|
||||
function mediaCard(item, index, row, onClick) {
|
||||
function mediaCard(item, index, row, onClick, onDelete) {
|
||||
var card = document.createElement("button");
|
||||
card.type = "button";
|
||||
card.className = "card focusable";
|
||||
@@ -1180,8 +5859,13 @@ function mediaCard(item, index, row, onClick) {
|
||||
if (item.poster) {
|
||||
var img = document.createElement("img");
|
||||
img.alt = item.title; img.loading = index < 6 ? "eager" : "lazy"; img.setAttribute("decoding", "async");
|
||||
img.src = imgSrc(item.poster);
|
||||
img.onerror = function () { img.style.display = "none"; };
|
||||
if (/tmdb\.org|themoviedb\.org/.test(item.poster)) { setTmdbImg(img, item.poster); }
|
||||
else if (item._zn4k) { img.src = zn4kImgSrc(item.poster); img.onerror = function () { img.style.display = "none"; }; }
|
||||
else if (item._qw) { img.src = qwImgSrc(item.poster); img.onerror = function () { img.style.display = "none"; }; }
|
||||
else if (item._wn && state.sdkReady && window.fm && fm.res) { img.src = fm.res(item.poster, { headers: { "Referer": wnBase() + "/" } }); img.onerror = function () { img.style.display = "none"; }; }
|
||||
else if (item._sh && state.sdkReady && window.fm && fm.res) { img.src = fm.res(item.poster, { headers: { "Referer": shBase() + "/" } }); img.onerror = function () { img.style.display = "none"; }; }
|
||||
else if (item._wo && state.sdkReady && window.fm && fm.res) { img.src = fm.res(item.poster, { headers: { "Referer": woBase(WO_SITES[item._woSite] || WO_SITES.wo) + "/" } }); img.onerror = function () { img.style.display = "none"; }; }
|
||||
else { img.src = imgSrc(item.poster); img.onerror = function () { img.style.display = "none"; }; }
|
||||
poster.appendChild(img);
|
||||
} else {
|
||||
var ph = document.createElement("div"); ph.className = "ph"; ph.textContent = (item.title || "?").slice(0, 6); poster.appendChild(ph);
|
||||
@@ -1190,10 +5874,18 @@ function mediaCard(item, index, row, onClick) {
|
||||
badge.className = "badge mono";
|
||||
badge.textContent = item.status || ((item.meta && item.meta.indexOf("4K") >= 0) ? "4K" : "HD");
|
||||
poster.appendChild(badge);
|
||||
if (onDelete) {
|
||||
var del = document.createElement("button");
|
||||
del.type = "button"; del.className = "card-del"; del.setAttribute("aria-label", "删除这条记录");
|
||||
del.innerHTML = "✕";
|
||||
del.onclick = function (ev) { ev.preventDefault(); ev.stopPropagation(); onDelete(item); };
|
||||
poster.appendChild(del);
|
||||
}
|
||||
card.appendChild(poster);
|
||||
var title = document.createElement("div"); title.className = "card-title"; title.textContent = item.title; card.appendChild(title);
|
||||
var meta = document.createElement("div"); meta.className = "card-meta"; meta.textContent = item.meta || ""; card.appendChild(meta);
|
||||
card.onclick = onClick || function () { openDetail(item); };
|
||||
bindPrewarmTrigger(card, function () { prewarmTmdb(item); });
|
||||
return card;
|
||||
}
|
||||
|
||||
@@ -1209,10 +5901,10 @@ function loadRecent() {
|
||||
ensureRecentChip(false);
|
||||
});
|
||||
}
|
||||
function recordRecent(id, title, poster, payload, note) {
|
||||
function recordRecent(id, title, poster, backdrop, payload, note) {
|
||||
if (!id || !payload || !payload.url) return;
|
||||
var list = (state.recentItems || []).filter(function (h) { return String(h.id) !== String(id); });
|
||||
list.unshift({ id: id, title: title || "", poster: poster || "", payload: payload, note: note || "", ts: Date.now() });
|
||||
list.unshift({ id: id, title: title || "", poster: poster || "", backdrop: backdrop || "", payload: payload, note: note || "", ts: Date.now() });
|
||||
if (list.length > 30) list = list.slice(0, 30);
|
||||
state.recentItems = list;
|
||||
storeSet("recent", JSON.stringify(list));
|
||||
@@ -1220,12 +5912,7 @@ function recordRecent(id, title, poster, payload, note) {
|
||||
if (state.activeCat === "__recent__") renderRecentGrid();
|
||||
}
|
||||
function ensureRecentChip(show) {
|
||||
// 最近观看入口已改为头部历史图标;清掉分类栏里可能残留的旧 chip
|
||||
var box = $("chips"); if (!box) return;
|
||||
var existing = box.querySelector("[data-cat='__recent__']");
|
||||
if (existing) { existing.parentNode.removeChild(existing); if (typeof reindexChips === "function") reindexChips(); }
|
||||
// 头部历史按钮:有记录才可用(无记录时点它也会提示暂无)
|
||||
var hb = $("historyBtn"); if (hb) hb.style.opacity = (state.recentItems && state.recentItems.length) ? "1" : ".5";
|
||||
if (state.view === "home" && typeof renderHomeRows === "function") renderHomeRows();
|
||||
}
|
||||
function ensureRecentChipOld(show) {
|
||||
var box = $("chips");
|
||||
@@ -1249,13 +5936,43 @@ function reindexChips() {
|
||||
var chips = $("chips").children;
|
||||
for (var i = 0; i < chips.length; i++) chips[i].setAttribute("data-focus-index", String(i));
|
||||
}
|
||||
function removeRecent(id) {
|
||||
var list = (state.recentItems || []).filter(function (h) { return String(h.id) !== String(id); });
|
||||
state.recentItems = list;
|
||||
storeSet("recent", JSON.stringify(list));
|
||||
ensureRecentChip(list.length > 0);
|
||||
if (state.activeCat === "__recent__") renderRecentGrid();
|
||||
}
|
||||
function openConfirm(title, text, onOk) {
|
||||
$("confirmTitle").textContent = title || "确认";
|
||||
$("confirmText").textContent = text || "";
|
||||
var ov = $("confirmOverlay");
|
||||
ov.classList.add("active"); ov.setAttribute("aria-hidden", "false");
|
||||
$("confirmOk").onclick = function () { closeConfirm(); if (onOk) onOk(); };
|
||||
}
|
||||
function closeConfirm() {
|
||||
var ov = $("confirmOverlay");
|
||||
ov.classList.remove("active"); ov.setAttribute("aria-hidden", "true");
|
||||
}
|
||||
function clearAllRecent() {
|
||||
if (!(state.recentItems || []).length) return;
|
||||
openConfirm("清空观看记录", "确定清空全部观看记录?此操作不可撤销。", function () {
|
||||
state.recentItems = [];
|
||||
storeSet("recent", JSON.stringify([]));
|
||||
ensureRecentChip(false);
|
||||
if (state.activeCat === "__recent__") renderRecentGrid();
|
||||
});
|
||||
}
|
||||
function renderRecentGrid() {
|
||||
$("browseTitle").textContent = "最近观看";
|
||||
$("browseSub").textContent = state.recentItems.length ? (state.recentItems.length + " 部") : "";
|
||||
$("clearSearch").style.display = "none";
|
||||
$("clearRecent").style.display = state.recentItems.length ? "" : "none";
|
||||
$("clearRecent").onclick = clearAllRecent;
|
||||
$("browseLoading").style.display = "none";
|
||||
$("browseNotice").replaceChildren();
|
||||
var grid = $("grid");
|
||||
grid.classList.add("grid-recent");
|
||||
if (!state.recentItems.length) {
|
||||
grid.replaceChildren();
|
||||
var div = el("div", "notice"); div.innerHTML = "<h4>暂无观看记录</h4><p>播放过的片子会出现在这里,点开直接进播放器。</p>";
|
||||
@@ -1268,8 +5985,14 @@ function renderRecentGrid() {
|
||||
card.type = "button"; card.className = "card focusable";
|
||||
card.setAttribute("data-card-index", String(i)); card.setAttribute("data-focus-row", "grid");
|
||||
var poster = document.createElement("div"); poster.className = "poster";
|
||||
if (h.poster) { var img = document.createElement("img"); img.alt = h.title; img.loading = i < 6 ? "eager" : "lazy"; img.setAttribute("decoding", "async"); setTmdbImg(img, h.poster); poster.appendChild(img); }
|
||||
var recentImgUrl = h.backdrop || h.poster || "";
|
||||
if (recentImgUrl) { var img = document.createElement("img"); img.alt = h.title; img.loading = i < 6 ? "eager" : "lazy"; img.setAttribute("decoding", "async"); setTmdbImg(img, recentImgUrl); poster.appendChild(img); }
|
||||
else { var ph = document.createElement("div"); ph.className = "ph"; ph.textContent = (h.title || "").slice(0, 6); poster.appendChild(ph); }
|
||||
var del = document.createElement("button");
|
||||
del.type = "button"; del.className = "card-del"; del.setAttribute("aria-label", "删除这条记录");
|
||||
del.innerHTML = "✕";
|
||||
del.onclick = function (ev) { ev.preventDefault(); ev.stopPropagation(); removeRecent(h.id); };
|
||||
poster.appendChild(del);
|
||||
card.appendChild(poster);
|
||||
var t = document.createElement("div"); t.className = "card-title"; t.textContent = h.title || ""; card.appendChild(t);
|
||||
var m = document.createElement("div"); m.className = "card-meta"; m.textContent = h.note || "继续观看"; card.appendChild(m);
|
||||
@@ -1280,6 +6003,8 @@ function renderRecentGrid() {
|
||||
}
|
||||
function playRecent(h) {
|
||||
if (!h || !h.payload || !h.payload.url) { toast("该记录已失效"); return; }
|
||||
if (h.payload.wallPic) delete h.payload.wallPic;
|
||||
state.pendingPlayWatch = { ts: Date.now(), kind: "recent", item: h };
|
||||
if (window.fm && fm.pan && fm.pan.play) {
|
||||
toast("正在打开播放器…");
|
||||
fm.pan.play(h.payload).then(function () {}, function () { toast("播放失败,链接可能已失效"); });
|
||||
@@ -1293,34 +6018,80 @@ function startSearch(kw) {
|
||||
kw = (kw || "").trim();
|
||||
if (!kw) return;
|
||||
var wasSearch = state.searchMode;
|
||||
state.searchMode = true; state.searchKw = kw;
|
||||
var panelWasOpen = state.searchPanelOpen;
|
||||
state.searchMode = true; state.searchKw = kw; state.searchSourceFilter = "";
|
||||
state.activeGenre = ""; $("genreChips").replaceChildren();
|
||||
$("search").value = kw;
|
||||
$("browseTitle").textContent = "搜索:" + kw;
|
||||
$("clearSearch").style.display = "";
|
||||
markActiveChip();
|
||||
$("clearRecent").style.display = "none";
|
||||
state.view = "search";
|
||||
$("homeView").style.display = "none"; $("catView").style.display = "";
|
||||
$("genreChips").style.display = "none"; $("genreChips").replaceChildren();
|
||||
$("catTitle").textContent = "搜索:" + kw;
|
||||
setNav("search");
|
||||
if (typeof updateHeroVisible === "function") updateHeroVisible();
|
||||
window.scrollTo(0, 0);
|
||||
if (!wasSearch) navigate("search"); // 压历史,返回手势可清空搜索回主页
|
||||
// 若是从搜索面板发起的搜索,用 replace 把“面板”这一层直接顶替成“搜索结果”,
|
||||
// 不额外新增历史深度;closeSearchPanel 这里跳过它自己的归位,避免重复处理。
|
||||
if (!wasSearch) navigate("search", panelWasOpen);
|
||||
closeSearchPanel(true);
|
||||
loadList(true);
|
||||
}
|
||||
function clearSearch() {
|
||||
function closeSearchPanel(skipRoute) {
|
||||
var p = $("searchPanel"); if (p) p.classList.remove("open");
|
||||
var m = $("searchMask"); if (m) m.classList.remove("open");
|
||||
var wasOpen = state.searchPanelOpen;
|
||||
if (state.searchPanelOpen) { state.searchPanelOpen = false; unlockBodyScroll(); }
|
||||
var t = $("searchToggle"); if (t) t.classList.remove("active");
|
||||
hideSuggest();
|
||||
var s = $("search"); if (s) { try { s.blur(); } catch (e) {} }
|
||||
if (!state.searchMode) setNav(state.view === "recent" ? "recent" : "home");
|
||||
if (wasOpen && !skipRoute) closeRoute();
|
||||
}
|
||||
function clearSearch(fromPopstate) {
|
||||
var wasSearchMode = state.searchMode;
|
||||
$("search").value = "";
|
||||
hideSuggest();
|
||||
selectCat(state.activeCat);
|
||||
showHome(true); // 这里的历史归位由 clearSearch 自己负责,避免 showHome 重复处理
|
||||
if (wasSearchMode && !fromPopstate) closeRoute();
|
||||
}
|
||||
/* 搜索联想 */
|
||||
function runSuggest() {
|
||||
var kw = ($("search").value || "").trim();
|
||||
if (kw.length < 1) { hideSuggest(); return; }
|
||||
var token = ++state.suggestToken;
|
||||
// 优先专用联想接口 search_suggestions.php?q=;失败/空则退回 get_videos.php?wd=
|
||||
// 站内联想 + TMDB 联想并发跑,谁先回来先画,两边都回来再合并去重、按相关度排序(完全同名置顶)。
|
||||
// 这样像 Eclipse 一样,输入「僵尸」时那部两字《僵尸》能被 TMDB 干净命中并顶到最前。
|
||||
var siteItems = null, tmdbItems = null;
|
||||
function paint() {
|
||||
if (token !== state.suggestToken) return;
|
||||
if (siteItems === null && tmdbItems === null) return;
|
||||
var merged = mergeSuggest(siteItems || [], tmdbItems || [], kw);
|
||||
if (merged.length) renderSuggest(merged.slice(0, 8));
|
||||
else if (siteItems !== null && tmdbItems !== null) hideSuggest(); // 两边都回来了且都空 -> 收起
|
||||
}
|
||||
// 站内:专用联想接口 search_suggestions.php?q=,空/失败退回 get_videos.php?wd=
|
||||
apiGet(apiUrl("search_suggestions.php") + "?q=" + encodeURIComponent(kw) + "&_t=" + Date.now(), "").then(function (d) {
|
||||
if (token !== state.suggestToken) return;
|
||||
var raw = (d && (d.data || d.suggestions || d.list)) || [];
|
||||
var items = normSuggest(raw, kw);
|
||||
if (items.length) { renderSuggest(items.slice(0, 8)); return; }
|
||||
suggestViaSearch(kw, token);
|
||||
}, function () { suggestViaSearch(kw, token); });
|
||||
var items = normSuggest((d && (d.data || d.suggestions || d.list)) || [], kw);
|
||||
if (items.length) { siteItems = items; paint(); }
|
||||
else suggestViaSearch(kw, token, function (u) { siteItems = u; paint(); });
|
||||
}, function () { suggestViaSearch(kw, token, function (u) { siteItems = u; paint(); }); });
|
||||
// TMDB:直接问 TMDB(没配 Key 就是空,静默)
|
||||
tmdbSearchList(kw).then(function (items) {
|
||||
if (token !== state.suggestToken) return;
|
||||
tmdbItems = items || []; paint();
|
||||
}, function () { if (token === state.suggestToken) { tmdbItems = []; paint(); } });
|
||||
}
|
||||
/* 合并站内 + TMDB 联想:先放能点开详情的真实条目(站内优先、按标题去重),
|
||||
再补 TMDB 里站内没有的,最后才是站内的纯关键词建议;整体按相关度排(完全同名最前)。 */
|
||||
function mergeSuggest(siteItems, tmdbItems, kw) {
|
||||
var out = [], seen = {};
|
||||
siteItems.forEach(function (it) { if (it.keyword || !it.id) return; var key = normTitleKey(it.title); if (key && !seen[key]) { seen[key] = 1; out.push(it); } });
|
||||
tmdbItems.forEach(function (it) { var key = normTitleKey(it.title); if (key && !seen[key]) { seen[key] = 1; out.push(it); } });
|
||||
siteItems.forEach(function (it) { if (!(it.keyword || !it.id)) return; var key = "kw:" + normTitleKey(it.title); if (normTitleKey(it.title) && !seen[key] && !seen[normTitleKey(it.title)]) { seen[key] = 1; out.push(it); } });
|
||||
return sortByRelevance(out, kw);
|
||||
}
|
||||
/* 联想结果归一化:既支持字符串关键词,也支持影片对象 */
|
||||
function normSuggest(raw, kw) {
|
||||
@@ -1332,14 +6103,14 @@ function normSuggest(raw, kw) {
|
||||
});
|
||||
return out;
|
||||
}
|
||||
function suggestViaSearch(kw, token) {
|
||||
function suggestViaSearch(kw, token, cb) {
|
||||
apiGet(apiUrl("get_videos.php") + "?wd=" + encodeURIComponent(kw) + "&pg=1&_t=" + Date.now(), "").then(function (d) {
|
||||
if (token !== state.suggestToken) return;
|
||||
var list = (d && (d.data || d.list)) || [];
|
||||
var seen = {}, uniq = [];
|
||||
list.map(mapVod).forEach(function (it) { if (it.title.indexOf(kw) >= 0 && !seen[it.id]) { seen[it.id] = 1; uniq.push(it); } });
|
||||
renderSuggest(uniq.slice(0, 8));
|
||||
}, function () { if (token === state.suggestToken) hideSuggest(); });
|
||||
cb(uniq);
|
||||
}, function () { if (token === state.suggestToken) cb([]); });
|
||||
}
|
||||
function renderSuggest(items) {
|
||||
var box = $("searchSuggest"); if (!box) return;
|
||||
@@ -1348,61 +6119,191 @@ function renderSuggest(items) {
|
||||
items.forEach(function (it) {
|
||||
var row = document.createElement("div"); row.className = "sug-item";
|
||||
var po = document.createElement("div"); po.className = "sug-pos";
|
||||
if (it.poster) { var im = document.createElement("img"); im.alt = ""; im.src = imgSrc(it.poster); im.onerror = function () { im.style.display = "none"; }; po.appendChild(im); }
|
||||
if (it.poster) { var im = document.createElement("img"); im.alt = ""; im.loading = "lazy"; im.setAttribute("decoding", "async"); if (/tmdb\.org|themoviedb\.org/.test(it.poster)) { setTmdbImg(im, it.poster); } else { im.src = imgSrc(it.poster); im.onerror = function () { im.style.display = "none"; }; } po.appendChild(im); }
|
||||
row.appendChild(po);
|
||||
var body = document.createElement("div"); body.className = "sug-body";
|
||||
var t = document.createElement("div"); t.className = "sug-title"; t.textContent = it.title; body.appendChild(t);
|
||||
var m = document.createElement("div"); m.className = "sug-meta"; m.textContent = [it.meta, it.status].filter(function (x) { return x; }).join(" · "); body.appendChild(m);
|
||||
row.appendChild(body);
|
||||
row.onclick = function () { hideSuggest(); if (it.keyword || !it.id) { $("search").value = it.title; startSearch(it.title); } else { openDetail(it); } };
|
||||
row.onclick = function () { hideSuggest(); if (it._tmdbId) { openTrendingDetail(it); } else if (it.keyword || !it.id) { $("search").value = it.title; startSearch(it.title); } else { openDetail(it); } };
|
||||
frag.appendChild(row);
|
||||
});
|
||||
box.replaceChildren(frag);
|
||||
}
|
||||
function hideSuggest() { state.suggestToken++; var b = $("searchSuggest"); if (b) b.replaceChildren(); }
|
||||
function refreshCurrent() {
|
||||
_listCache = {}; clearTextMemo(); clearHomeDisk(); // 手动下拉刷新:清掉短期列表缓存 + 各站 HTML 缓存 + 磁盘缓存,这次必须是最新数据
|
||||
if (state.view === "home") { state.heroRawCache = null; renderHomeRows(true); loadHero(); return; }
|
||||
if (state.activeCat === "__recent__") { loadRecent().then(renderRecentGrid); return; }
|
||||
loadList(true);
|
||||
}
|
||||
/* 左右滑动切换分类/筛选标签时给 #grid 加一次方向性的滑入渐显动画,纯视觉、不影响任何加载逻辑。
|
||||
delta>0(左滑,进下一个)新内容从右侧滑入;delta<0(右滑,进上一个)从左侧滑入。
|
||||
grid 的内容随后会被 selectCat/swipeGenre 触发的 replaceChildren+appendGrid 正常替换,
|
||||
动画只作用在容器本身的 transform/opacity 上,不关心内容何时真正换好,异步加载也不会打断视觉效果。 */
|
||||
function gridSwipeAnim(delta) {
|
||||
var grid = $("grid"); if (!grid) return;
|
||||
grid.classList.remove("grid-swipe-l", "grid-swipe-r");
|
||||
void grid.offsetWidth; // 强制重排,保证连续同方向滑动时动画能重新触发
|
||||
grid.classList.add(delta > 0 ? "grid-swipe-l" : "grid-swipe-r");
|
||||
}
|
||||
function swipeCategory(delta) {
|
||||
var chips = Array.from($("chips").children);
|
||||
if (!chips.length) return;
|
||||
var cats = chips.map(function (c) { return c.getAttribute("data-cat"); });
|
||||
if (cats.length && cats[0] == null) return; // 底栏已是导航(首页/搜索/历史),不做分类切换
|
||||
var cur = state.searchMode ? -1 : cats.indexOf(state.activeCat);
|
||||
if (cur < 0) cur = delta > 0 ? -1 : chips.length; // 搜索态:右滑进第一个 / 左滑进最后一个
|
||||
var ni = cur + delta;
|
||||
if (ni < 0 || ni >= chips.length) return;
|
||||
gridSwipeAnim(delta);
|
||||
selectCat(cats[ni]);
|
||||
var el = chips[ni];
|
||||
if (el && el.scrollIntoView) { try { el.scrollIntoView({ inline: "center", block: "nearest" }); } catch (e) {} }
|
||||
}
|
||||
/* 筛选标签栏(#genreChips)的横向滑动现在完全交给浏览器原生滚动处理(见上面 .genre-chips
|
||||
的 touch-action: pan-x 注释),不再需要这里手搓 touchmove 接管 + JS 惯性/橡皮筋模拟——
|
||||
原生滚动自带的跟手度和物理效果本来就比手搓的更准更顺,这个函数留空/移除,调用点也一并去掉。 */
|
||||
|
||||
/* “查看全部”页左右滑动切换筛选标签(剧情/喜剧/动作…)。三套 genreChips 渲染分支
|
||||
(renderGenreChips / renderAnimeGenreChips / renderTmdbGenreChips) 各自的 onclick 语义不完全一样
|
||||
(有的是“再点一次取消”的 toggle,有的是直接赋值),这里不重新猜测语义,直接找到目标 chip
|
||||
调用它自己的 click(),跟手指真的点了那个标签效果完全一致;没有筛选标签(如只有一种类型)时
|
||||
#genreChips 是空的,函数直接返回,什么都不做。 */
|
||||
function swipeGenre(delta) {
|
||||
var box = $("genreChips"); if (!box) return;
|
||||
var chips = box.children; if (!chips.length) return;
|
||||
var ci = -1;
|
||||
for (var i = 0; i < chips.length; i++) { if (chips[i].classList.contains("active")) { ci = i; break; } }
|
||||
if (ci < 0) ci = delta > 0 ? -1 : chips.length; // 当前未筛选:右滑进第一个 / 左滑进最后一个
|
||||
var ni = ci + delta;
|
||||
if (ni < 0 || ni >= chips.length) return;
|
||||
var target = chips[ni];
|
||||
gridSwipeAnim(delta);
|
||||
if (target.click) target.click();
|
||||
// 上面 target.click() 触发的 selectGenre() 会调用 renderGenreChips(),把 #genreChips
|
||||
// 的子节点整体 replaceChildren 重建一遍——这时候 target 已经变成不在文档里的游离节点,
|
||||
// 对它调用 scrollIntoView 不会有任何效果(浏览器找不到它现在该在哪)。这里重新从容器里
|
||||
// 按同样的位置索引取一次“活”的节点(顺序、数量都没变,索引仍然对得上),保险起见找不到
|
||||
// 就退回去直接找当前 .active 的那个,再让它滚到可视区域,标签栏就能跟手势联动滚动了。
|
||||
var live = box.children[ni];
|
||||
if (!live || !live.classList || !live.classList.contains("genre-chip")) live = box.querySelector(".genre-chip.active");
|
||||
// 这里改成手动算 scrollLeft 直接赋值,而不是 scrollIntoView:scrollIntoView 在部分
|
||||
// WebView 里对这种横向滚动容器会触发一次自带动画的滚动,滚到头/尾时会有越界回弹,
|
||||
// 手势连续左右滑页面时这排(返回按钮+筛选标签)看起来像弹簧一样跳一下,不好看。
|
||||
// 直接算好目标位置一次性赋值 scrollLeft 是瞬间跳转,没有任何动画/回弹,观感干净。
|
||||
if (live) {
|
||||
try {
|
||||
var tgt = live.offsetLeft + live.offsetWidth / 2 - box.clientWidth / 2;
|
||||
tgt = Math.max(0, Math.min(tgt, box.scrollWidth - box.clientWidth));
|
||||
box.scrollLeft = tgt;
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 详情 ---------- */
|
||||
/* 「网盘资源」记忆:一个条目的资源要跨 5~6 个站聚合,全部回来要几秒。这里把上次聚合到的结果按条目存住,
|
||||
再次打开同一条目时先把上次的秒显出来(播放按钮立刻可用),后台照常重新抓一遍,回来了再覆盖。 */
|
||||
var _panMemo = {}, PAN_MEMO_TTL = 10 * 60 * 1000, PAN_MEMO_MAX = 30;
|
||||
function panMemoKey(item) {
|
||||
if (!item) return "";
|
||||
var pre = item._wo ? ("wo:" + item._woSite) : item._sh ? "sh" : item._wn ? "wn" : item._zn4k ? "zn" : item._qw ? "qw" : "lo";
|
||||
return pre + "|" + String(item.id || item._woHref || item._wnId || item._shId || item._zn4kId || item._qwId || item.title || "");
|
||||
}
|
||||
function panMemoSave(items) {
|
||||
var k = panMemoKey(state.currentItem);
|
||||
if (!k || !items || !items.length) return;
|
||||
_panMemo[k] = { ts: Date.now(), items: items.slice() };
|
||||
var ks = Object.keys(_panMemo);
|
||||
if (ks.length > PAN_MEMO_MAX) { ks.sort(function (a, b) { return _panMemo[a].ts - _panMemo[b].ts; }); delete _panMemo[ks[0]]; }
|
||||
}
|
||||
function panMemoGet(item) {
|
||||
var m = _panMemo[panMemoKey(item)];
|
||||
if (!m || (Date.now() - m.ts) > PAN_MEMO_TTL) return null;
|
||||
// 复制一份,并把检测状态复位(上次的"检测中"不该带到这次)
|
||||
return m.items.map(function (p) { var q = {}; for (var kk in p) q[kk] = p[kk]; q._checking = false; q._checked = false; q.state = "idle"; return q; });
|
||||
}
|
||||
/* 网盘资源跨站聚合:按标题去除"本站"外的所有站源搜同名,把各自的网盘/在线资源都并进「网盘资源」。
|
||||
本站资源已在 openDetail 里先加载(排最前),这里补齐其余站,做到"共用所有站源的资源匹配、本站优先"。
|
||||
except 标记哪个是本站,避免对本站重复搜一遍。 */
|
||||
function enrichAllSites(title, token, except) {
|
||||
except = except || {};
|
||||
if (!except.zn4k) zn4kEnrich(title, token); // 4K指南
|
||||
if (!except.qw) qwEnrich(title, token); // 七味
|
||||
if (!except.wn) wnEnrich(title, token); // 原盘高清
|
||||
if (!except.sh) shEnrich(title, token); // SeedHub
|
||||
woAll().forEach(function (st) { // 各网盘明链站
|
||||
if (except.wo !== st.key) woEnrich(st, title, token);
|
||||
});
|
||||
// 盘链本站:非本站来源时也反查一遍,把本站网盘资源补进来
|
||||
if (!except.local) panReverseLookup(title, token);
|
||||
}
|
||||
/* 盘链本站按标题反查网盘资源(供其它站来源的详情页补本站资源用) */
|
||||
function panReverseLookup(title, token) {
|
||||
var kw = cleanTitle(title); if (!kw) return;
|
||||
apiGet(apiUrl("get_videos.php") + "?wd=" + encodeURIComponent(kw) + "&pg=1&_t=" + Date.now(), "").then(function (dd) {
|
||||
if (token !== state.panToken) return;
|
||||
var list = ((dd && (dd.list || dd.data)) || []).map(mapVod);
|
||||
var yr = yearOf(title);
|
||||
function score(it) { var s = 0; if (it.title === title) s += 8; else if (titleCloseContain(it.title, kw)) s += 4; if (yr && it.year === yr) s += 3; return s; }
|
||||
var good = list.filter(function (it) { return score(it) >= 4; }).sort(function (a, b) { return score(b) - score(a); });
|
||||
if (!good.length) return;
|
||||
var best = good[0], tok = state.panToken;
|
||||
apiGet(apiUrl("search_pan_links.php") + "?keyword=" + encodeURIComponent(kw) + "&vod_id=" + encodeURIComponent(best.id) + "&_t=" + Date.now(), best.id).then(function (data) {
|
||||
if (tok !== state.panToken || !data || !data.success) return;
|
||||
var groups = data.data || {}, pans = [];
|
||||
Object.keys(groups).forEach(function (type) {
|
||||
var grp = groups[type], links = (grp && grp.links) || [];
|
||||
links.forEach(function (lk) {
|
||||
pans.push({ type: type, groupName: (grp && grp.name) || (PAN_META[type] ? PAN_META[type].label : type), title: lk.title || "", password: lk.password || "", token: lk.token || "", source: lk.source || "本站", tier: lk.user_tier || 0, quality: (PAN_META[type] ? PAN_META[type].label : type), state: "idle" });
|
||||
});
|
||||
});
|
||||
if (!pans.length) return;
|
||||
var have = {};
|
||||
state.panItems.forEach(function (it) { have[it.type + "|" + normPanUrl(it.url || it.token)] = 1; });
|
||||
var add = pans.filter(function (p) { return !have[p.type + "|" + normPanUrl(p.token || p.url)]; });
|
||||
if (add.length) renderResources(state.panItems.concat(add));
|
||||
}, function () {});
|
||||
}, function () {});
|
||||
}
|
||||
function openDetail(item) {
|
||||
saveHomeReturn();
|
||||
saveViewReturn();
|
||||
state.currentItem = item;
|
||||
state.currentDetail = { id: item.id, title: cleanTitle(item.title), rawTitle: item.title, poster: item.poster || "", art: null, tmdb: null };
|
||||
state.bestResource = null;
|
||||
state.qFilter = null; state.qGroups = [];
|
||||
state.resumeRecord = null;
|
||||
state.panItems = []; // 先清空:下面 updatePlayButton() 要按 id 找"上次播的那条",
|
||||
// 不清的话会拿上一个条目残留的资源列表去比对,可能配错
|
||||
state.qFilter = null; state.qGroups = []; state.qFilterManual = false;
|
||||
|
||||
// 先用站点海报占位,秒开
|
||||
setHeroImage(item.poster, false);
|
||||
// 先用站点海报占位,秒开;这里是切换到"另一个条目",必须立刻显示,不能柔和过渡(否则会看见上一个条目的海报残留一瞬)
|
||||
setDetailHeroImages(item.poster ? [item.poster] : [], false);
|
||||
state.dHeroMode = ""; // 新条目:等 TMDB 图集到手后按当前方向重新定模式
|
||||
$("edLogo").replaceChildren();
|
||||
showTitle(item.title);
|
||||
renderChipsMeta(null, item);
|
||||
$("detailOverview").textContent = "";
|
||||
$("detailOverview").replaceChildren(overviewSkeleton());
|
||||
updatePlayButton();
|
||||
|
||||
$("panSub").textContent = ""; $("panTitle").textContent = "网盘资源";
|
||||
$("qTabs").replaceChildren();
|
||||
if ($("edCast")) $("edCast").replaceChildren();
|
||||
if ($("edCast")) $("edCast").replaceChildren(castSkeleton());
|
||||
if ($("edEpisodes")) $("edEpisodes").replaceChildren();
|
||||
state.epToken = (state.epToken || 0) + 1; // 让上一个条目还没返回的分季请求失效,避免串到新条目头上
|
||||
if (window.resetActorWorks) window.resetActorWorks();
|
||||
hideSuggest();
|
||||
$("panList").replaceChildren(el("div", "pan-loading", "读取网盘资源…"));
|
||||
state.panItems = [];
|
||||
state.panToken++;
|
||||
state.panToken++; state.panDiag = [];
|
||||
var memoPans = panMemoGet(item); // 上次看过这条:先把上次聚合到的资源秒显出来,后台照常重抓覆盖
|
||||
if (memoPans && memoPans.length) renderResources(memoPans);
|
||||
else $("panList").replaceChildren(panListSkeleton());
|
||||
|
||||
navigate("detail");
|
||||
// 详情页内部再跳到另一个条目(演员相关作品、合集/系列成员等)时,面板本身并没有关闭过,
|
||||
// 不能再往历史栈里 push 新记录——否则历史栈会不断堆积“幽灵”记录,导致返回键要按好几次
|
||||
// 才能把这些空记录耗尽,才能真正退出(这正是“主页按返回好几次退不出全屏”的根源)。
|
||||
// 只有详情页从关闭状态被打开时才 push,已经打开着就原地 replace,历史栈深度始终等于实际打开的层数。
|
||||
var detailAlreadyOpen = $("detailSheet").classList.contains("active");
|
||||
navigate("detail", detailAlreadyOpen);
|
||||
document.body.classList.add("detail-active");
|
||||
var sheet = $("detailSheet"); sheet.classList.add("active"); sheet.setAttribute("aria-hidden", "false");
|
||||
sheet.scrollTop = 0;
|
||||
@@ -1410,24 +6311,54 @@ function openDetail(item) {
|
||||
state.focusScope = "detail"; setAppFocusable(false); focusFirst(sheet);
|
||||
if (window.fm && fm.ui && fm.ui.setChrome) fm.ui.setChrome({ mode: "immersive", restoreAffordance: "none" }).then(function () {}, function () {});
|
||||
|
||||
loadResources(item);
|
||||
// 网盘资源跨站聚合:先加载"本站"资源(排第一),再统一按标题补齐其余所有站源。
|
||||
var t = state.panToken;
|
||||
if (item._wo) {
|
||||
woLoadByHref(WO_SITES[item._woSite] || WO_SITES.wo, item._woHref, t);
|
||||
enrichAllSites(item.title, t, { wo: item._woSite }); // 本站是玩偶或木偶,其余全补
|
||||
} else if (item._sh) {
|
||||
shLoadById(item._shId, t);
|
||||
enrichAllSites(item.title, t, { sh: 1 });
|
||||
} else if (item._wn) {
|
||||
wnLoadById(item._wnId, t);
|
||||
enrichAllSites(item.title, t, { wn: 1 });
|
||||
} else if (item._zn4k) {
|
||||
zn4kLoadById(item._zn4kId, t);
|
||||
zn4kReverseLookup(item, t);
|
||||
enrichAllSites(item.title, t, { zn4k: 1 });
|
||||
} else if (item._qw) {
|
||||
qwLoadById(item._qwId, t);
|
||||
qwReverseLookup(item, t);
|
||||
enrichAllSites(item.title, t, { qw: 1 });
|
||||
} else {
|
||||
// 本站(盘链)来源:本站资源排第一,其余站全补
|
||||
loadResources(item);
|
||||
enrichAllSites(item.title, t, { local: 1 });
|
||||
}
|
||||
loadTmdb(item);
|
||||
if (config.panApi) { var pt = state.panToken; setTimeout(function () { if (pt === state.panToken) runPanSou(true); }, 350); } // 进详情自动盘搜(静默)
|
||||
}
|
||||
function loadResources(item) {
|
||||
var token = state.panToken;
|
||||
var kw = cleanTitle(item.title);
|
||||
var ryear = item.year || yearOf(item.title);
|
||||
// 详情页是前端渲染的空壳,直接调 search_pan_links 取真链
|
||||
var api = apiUrl("search_pan_links.php") + "?keyword=" + encodeURIComponent(kw) + "&vod_id=" + encodeURIComponent(item.id) + "&_t=" + Date.now();
|
||||
apiGet(api, item.id).then(function (data) {
|
||||
if (token !== state.panToken) return;
|
||||
if (!data || !data.success) { renderResources(panSouKeep()); return; }
|
||||
if (!data || !data.success) { renderResources(woKeep().concat(shKeep()).concat(wnKeep()).concat(fyKeep()).concat(panSouKeep()).concat(zn4kKeep()).concat(qwKeep())); return; }
|
||||
var groups = data.data || {};
|
||||
var list = [];
|
||||
Object.keys(groups).forEach(function (type) {
|
||||
var grp = groups[type];
|
||||
var links = (grp && grp.links) || [];
|
||||
links.forEach(function (lk) {
|
||||
// search_pan_links 是服务端按 keyword 做模糊检索的,关键词只要出现在标题任意位置就会被
|
||||
// 服务端召回——这会混进《刚下山被幸运女神拉去领证》这种标题里凑巧含有关键词、但其实是
|
||||
// 完全不同一部作品的资源。这里只保留关键词出现在标题靠前位置(容书名号/引号等一两个
|
||||
// 前缀字符)的条目:真正同名资源的片名一般就在标题最前面,后面跟集数/清晰度/发布人这类
|
||||
// 附加信息;关键词前面顶着一大段别的内容,基本可以判定是别的作品蹭了关键词。
|
||||
if (lk.title && !resourceTitleLikelyMatches(lk.title, kw, ryear)) return;
|
||||
list.push({
|
||||
type: type,
|
||||
groupName: (grp && grp.name) || (PAN_META[type] ? PAN_META[type].label : type),
|
||||
@@ -1436,12 +6367,12 @@ function loadResources(item) {
|
||||
token: lk.token || "",
|
||||
source: lk.source || "",
|
||||
tier: lk.user_tier || 0,
|
||||
quality: (grp && grp.name) || type, // 用网盘名作为分组(tab)
|
||||
quality: (PAN_META[type] ? PAN_META[type].label : type), // 用固定网盘名作为分组(tab),不用服务端各异的grp.name,避免"迅雷"/"迅雷云盘"拆成两个tab
|
||||
state: "idle"
|
||||
});
|
||||
});
|
||||
});
|
||||
renderResources(list.concat(panSouKeep()));
|
||||
renderResources(sortPanDefault(list).concat(woKeep()).concat(shKeep()).concat(wnKeep()).concat(fyKeep()).concat(panSouKeep()).concat(zn4kKeep()).concat(qwKeep()));
|
||||
}, function (err) {
|
||||
if (token !== state.panToken) return;
|
||||
$("panList").replaceChildren(el("div", "pan-empty", "取资源失败:" + (err && err.message ? err.message : "网络异常") + "。可能登录态过期,去设置更新 Cookie。"));
|
||||
@@ -1450,29 +6381,428 @@ function loadResources(item) {
|
||||
}
|
||||
|
||||
/* ---------- TMDB:高清海报 + 元数据 ---------- */
|
||||
function cleanTitleLight(t) {
|
||||
// 跟 cleanTitle 类似,但保留年份和"第X季"——很多资源标题本身就带年份/季号,
|
||||
// 完全清洗掉反而搜不到"xxx2026"、"xxx 第2季"这类资源,所以单独留一份轻量清洗版做候选词。
|
||||
t = String(t || "");
|
||||
t = t.replace(/4K|2160P|1080P|蓝光原盘|蓝光|原盘|REMUX|HDR10?\+?|杜比视界|DV|WEB-?DL|WEB|内封|国粤双语|中英双语|国语|粤语|双语|中字|高清|超清|合集/gi, " ");
|
||||
return t.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
/* 盘搜关键词候选:优先保留年份/季号的"精确版",再退到完全清洗的"通用版",两版都搜、结果合并,
|
||||
这样带年份(xxx2026)、带季号(xxx 第2季)的资源和普通资源都尽量覆盖到 */
|
||||
function panSouKeywords(rawTitle) {
|
||||
var out = [];
|
||||
var light = cleanTitleLight(rawTitle);
|
||||
var full = cleanTitle(rawTitle);
|
||||
if (light) out.push(light);
|
||||
if (full && out.indexOf(full) < 0) out.push(full);
|
||||
return out;
|
||||
}
|
||||
function cleanTitle(t) {
|
||||
t = String(t || "");
|
||||
// 去掉各种括号里的修饰内容:(臻彩)(真彩)【4K】[HDR][2024] 等——这些是站点给资源名加的标签,
|
||||
// 不是片名的一部分,带着去 TMDB 搜必然匹配不到。中英文括号、方括号、书名号内注释统一清掉。
|
||||
t = t.replace(/[((]\s*(?:臻彩|真彩|甄彩|臻藏|收藏|珍藏|典藏|完整|全集|完结|会员|VIP|抢先|independent|[0-9]{1,3}集?)\s*[))]/gi, " ");
|
||||
t = t.replace(/【[^】]*】/g, " "); // 【...】整块去掉(画质/语言/字幕标签)
|
||||
t = t.replace(/\[[^\]]*\]/g, " "); // [...]整块去掉
|
||||
t = t.replace(/[((][^()()]{0,20}(?:版|4K|2160P|1080P|蓝光|原盘|HDR|DV|双语|中字|国语|粤语|高码|臻彩|真彩)[^()()]{0,20}[))]/gi, " "); // 含画质/版本关键词的括号
|
||||
t = t.replace(/[((]\s*\d{4}\s*[))]/g, " "); // 去年份括号
|
||||
t = t.replace(/\b(19|20)\d{2}\b/g, " ");
|
||||
t = t.replace(/4K|2160P|1080P|蓝光原盘|蓝光|原盘|REMUX|HDR10?\+?|杜比视界|DV|WEB-?DL|WEB|内封|国语|中字|高清|超清|合集/gi, " ");
|
||||
t = t.replace(/[·\-—_|].*$/, ""); // 去副标题分隔后缀
|
||||
return t.replace(/\s+/g, " ").trim();
|
||||
t = t.replace(/(^|[^0-9])(19|20)\d{2}(?!\d)/g, "$1 "); // 去裸年份(负向前瞻,避免老内核 lookbehind 报错)
|
||||
t = t.replace(/第\s*[0-9〇一二两三四五六七八九十百]+\s*[季部]/g, " ");
|
||||
t = t.replace(/\bseason\s*\d+\b/gi, " ");
|
||||
t = t.replace(/\bS\d{1,2}(?:E\d{1,3})?\b/gi, " ");
|
||||
// 画质/来源/语言/字幕/编码等一大票发布组杂词
|
||||
t = t.replace(/4K|8K|2160P|1440P|1080P|720P|UHD|蓝光原盘|蓝光|原盘|REMUX|BDRip|BluRay|HDR10?\+?|杜比视界|杜比|DoVi|DV|HQ|高码率?|高码|H\.?26[45]|x26[45]|AAC|DTS|Atmos|WEB-?DL|WEBRip|WEB|HDTV|内封|内嵌|外挂|国粤双语|中英双语|中英|国英|国语|粤语|英语|日语|韩语|双语|中字|简中|繁中|简繁|字幕|无水印|去水印|纯净版?|收藏版?|珍藏版?|完整版?|未删减|加长版?|导演剪辑版?|剧场版|高清|超清|流畅|臻彩|真彩|甄彩|合集|全集|完结|更新至?|第?\d{1,4}集/gi, " ");
|
||||
t = t.replace(/\d+(?:\.\d+)?\s*[GMT]B?\b/gi, " "); // 文件大小 77.74G / 12GB
|
||||
t = t.replace(/\bE\d{1,3}\b/gi, " "); // 单集号 E08
|
||||
t = t.replace(/\bEP\d{1,3}\b/gi, " "); // EP01
|
||||
t = t.replace(/\b(?:CHS|CHT|ENG|JPN|KOR|GB|BIG5|简|繁)\b/gi, " "); // 语言/字幕码
|
||||
t = t.replace(/60FPS|30FPS|MAX/gi, " ");
|
||||
t = t.replace(/(^|[^0-9])(19|20)\d{2}(?!\d)/g, "$1 "); // 再去一次裸年份(前面清标签后可能暴露出来)
|
||||
t = t.replace(/[..·。、,,&]+/g, " "); // 残留标点转空格
|
||||
t = t.replace(/\s版\s|\s版$/g, " "); // 孤立的"版"字
|
||||
t = t.replace(/\s+/g, " ").trim();
|
||||
return t;
|
||||
}
|
||||
function yearOf(t) { var m = String(t || "").match(/(19|20)\d{2}/); return m ? m[0] : ""; }
|
||||
function tmdbApi(path, params) {
|
||||
var q = "api_key=" + encodeURIComponent(config.tmdbKey) + "&language=zh-CN";
|
||||
if (params) for (var k in params) if (Object.prototype.hasOwnProperty.call(params, k)) q += "&" + k + "=" + encodeURIComponent(params[k]);
|
||||
return "https://api.tmdb.org/3" + path + "?" + q;
|
||||
var host = (config.tmdbApi || "").trim() || "api.tmdb.org"; // 默认走 api.tmdb.org:这个别名域名国内免代理可用;官方主域 api.themoviedb.org 在国内被墙。可在设置里改成自己的反代/镜像
|
||||
if (host === "api.themoviedb.org") host = "api.tmdb.org"; // 有人手填了被墙的官方主域,自动纠正到国内可用的别名域名
|
||||
return "https://" + host + "/3" + path + "?" + q;
|
||||
}
|
||||
function loadTmdb(item) {
|
||||
/* ---------- 顶部推荐轮播:站内随机 电影 + 美剧 + 国产剧 ---------- */
|
||||
function loadHero() {
|
||||
if (state.isTv) { updateHeroVisible(); return; } // TV 端首页不展示轮播,不必抓数据
|
||||
var box = $("heroCarousel"); if (!box) return;
|
||||
loadHeroLocal();
|
||||
}
|
||||
function shuffleArr(a) {
|
||||
for (var i = a.length - 1; i > 0; i--) { var j = Math.floor(Math.random() * (i + 1)); var t = a[i]; a[i] = a[j]; a[j] = t; }
|
||||
return a;
|
||||
}
|
||||
/* TMDB 候选去重(按 id,没 id 退回标题)——多页合并时避免同一部片重复占位 */
|
||||
function dedupById(list) {
|
||||
var seen = {}, out = [];
|
||||
(list || []).forEach(function (it) {
|
||||
var k = String((it && (it.tmdbId || it.id)) || (it && it.title) || "");
|
||||
if (!k || seen[k]) return; seen[k] = 1; out.push(it);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
/* 判断一条剧集属于哪个地区:优先看站点自己的二级分类名 type_name(比如“美剧/国产剧”),
|
||||
没命中再退回 vod_area(比如“美国/大陆”);两边都做容错,兼容不同站点的命名习惯 */
|
||||
function pickKind(list, typeRe, areaRe) {
|
||||
return list.filter(function (it) { return typeRe.test(it.typeName || "") || areaRe.test(it.area || ""); });
|
||||
}
|
||||
/* 并发抓取多页并合并,单页失败不影响其它页(fetchListOnce 已自带 catch) */
|
||||
function fetchPagesConcat(cat, pages) {
|
||||
return Promise.all(pages.map(function (p) {
|
||||
return fetchListOnceSilent(cat, p).then(function (a) { return a || []; }, function () { return []; });
|
||||
})).then(function (arr) { return arr.reduce(function (acc, a) { return acc.concat(a); }, []); });
|
||||
}
|
||||
/* 轮播加载中立刻渲染骨架屏,防止空白闪烁 */
|
||||
function showHeroSkeleton() {
|
||||
var box = $("heroCarousel"); if (!box) return;
|
||||
state.heroLoading = true;
|
||||
var skel = document.createElement("div"); skel.className = "hero-skeleton";
|
||||
var body = document.createElement("div"); body.className = "hero-skel-body";
|
||||
body.appendChild(el("div", "hero-skel-title"));
|
||||
body.appendChild(el("div", "hero-skel-meta"));
|
||||
skel.appendChild(body);
|
||||
var dots = document.createElement("div"); dots.className = "hero-skel-dots";
|
||||
for (var i = 0; i < 4; i++) { var d = document.createElement("span"); d.className = "hero-skel-d" + (i === 0 ? " a" : ""); dots.appendChild(d); }
|
||||
skel.appendChild(dots);
|
||||
box.replaceChildren(skel);
|
||||
$("heroDots").replaceChildren();
|
||||
var wrap = $("heroWrap"); if (wrap) wrap.hidden = false;
|
||||
}
|
||||
/* 站内随机:电影(t=1)+ 剧集(t=2,按类型/地区拆成美剧、国产剧、韩剧)+ TMDB 热门(trending/all/week)五路混合轮播,
|
||||
TMDB 热门这一路是主力、其余四路是点缀:每一路各取几部不是写死的固定数字,而是每次刷新在一个区间里
|
||||
随机抽(见 randHeroTake),TMDB 权重明显更高,其余四路时多时少、有时甚至抽到 0 个,这样每次进首页
|
||||
看到的轮播构成都不一样,不会总是那几个固定位置。
|
||||
TMDB 热门这一路直接点开走 openTrendingDetail(先站内搜片名,搜不到再退回 TMDB 详情),跟"TMDB 热播"分类页逻辑一致。
|
||||
每一路各多抓一批候选(不止要用的那几个),交给 fillHeroSlots 逐个试配高清图,配不到就换下一个顶上。
|
||||
候选池特意多抓几页(电影 3 页 / 剧集 4 页 / TMDB 3 页),避免池子太小、每次刷新都撞见同一批;
|
||||
TMDB 这一路吃的是 _tmdbCatCache(跟"TMDB 热播"分类页翻页共用),缓存过的页不会重复请求。 */
|
||||
function loadHeroLocal() {
|
||||
var box = $("heroCarousel"); if (!box) return;
|
||||
showHeroSkeleton();
|
||||
var cache = state.heroRawCache;
|
||||
if (cache && cache.movies.length && cache.drama.length) {
|
||||
buildHeroFromRaw(cache.movies, cache.drama, cache.tmdb || []); // 已经抓过原始素材,直接用缓存秒开,不用再等网络
|
||||
return;
|
||||
}
|
||||
// 优先用"TMDB 热播"分类页留下的缓存顶一版:如果这份缓存还热乎着(比如刚从那个分类页返回),
|
||||
// 不用等站内电影/剧集这两路网络请求,先拿这批现成的把轮播占上;等下面正常的五路请求回来后
|
||||
// 会用完整的混合结果整体覆盖一次,这里只是让首屏更快出图,不影响最终效果。
|
||||
var warmTmdb = peekTmdbTrendCache(1);
|
||||
if (warmTmdb && warmTmdb.length) buildHeroFromRaw([], [], warmTmdb);
|
||||
// 秒开优先:先只抓电影 + 剧集各 1 页 + TMDB 第 1 页,有什么就先拼出来展示,不等后面几页
|
||||
var moviesP = fetchPagesConcat("1", [1]);
|
||||
var dramaP = fetchPagesConcat("2", [1]);
|
||||
// TMDB 这一路首屏就把"笼统热播"和"Netflix/HBO/Disney+ 等平台热门"一起拉回来揉在一个池子里,
|
||||
// 不再只看 trending/all/week 那一小池——这样一开始展示的就不止那几部老面孔。
|
||||
var tmdbP = Promise.all([fetchTmdbTrendPagesSilent([1, 2]), fetchTmdbPlatformsPoolSilent(1)]).then(function (arr) {
|
||||
return dedupById(arr[0].concat(arr[1]));
|
||||
});
|
||||
Promise.all([moviesP, dramaP, tmdbP]).then(function (arr) {
|
||||
state.heroRawCache = { movies: arr[0].slice(), drama: arr[1].slice(), tmdb: arr[2].slice() };
|
||||
buildHeroFromRaw(arr[0], arr[1], arr[2]);
|
||||
// 后台再悄悄多抓几页扩充候选池,供下次刷新轮播时用更大的池子挑。
|
||||
// 延迟 2.5 秒再发——此时首屏内容已加载完,不跟首屏请求抢带宽,避免拖慢主页首次加载。
|
||||
setTimeout(function () {
|
||||
var tmdbMoreP = Promise.all([fetchTmdbTrendPagesSilent([3, 4, 5]), fetchTmdbPlatformsPoolSilent(2)]).then(function (a2) {
|
||||
return dedupById(a2[0].concat(a2[1]));
|
||||
});
|
||||
Promise.all([fetchPagesConcat("1", [2, 3]), fetchPagesConcat("2", [2, 3]), tmdbMoreP]).then(function (more) {
|
||||
if (!state.heroRawCache) return;
|
||||
state.heroRawCache.movies = state.heroRawCache.movies.concat(more[0]);
|
||||
state.heroRawCache.drama = state.heroRawCache.drama.concat(more[1]);
|
||||
state.heroRawCache.tmdb = dedupById(state.heroRawCache.tmdb.concat(more[2]));
|
||||
}, function () {});
|
||||
}, 2500);
|
||||
}, function () { if (!warmTmdb || !warmTmdb.length) { box.replaceChildren(); state.heroItems = []; state.heroLoading = false; buildHeroDots(0); updateHeroVisible(); } });
|
||||
}
|
||||
/* 返回 [min, max] 区间内的随机整数(含两端) */
|
||||
function randInt(min, max) { return min + Math.floor(Math.random() * (max - min + 1)); }
|
||||
/* 拿到手的电影/剧集/TMDB 原始素材(不管是刚抓的第 1 页,还是缓存里更大的池子)统一在这里分拣成五路候选并渲染。
|
||||
每一路要取几个在这里现抽:TMDB 热门是主力,区间明显更高;其余四路是辅助点缀,区间低、还带 0,
|
||||
池子本身不够也不会报错(take 会自动被 slice 截到池子实际长度)。 */
|
||||
function buildHeroFromRaw(moviesRaw, dramaRaw, tmdbRaw) {
|
||||
var box = $("heroCarousel"); if (!box) return;
|
||||
var movies = shuffleArr((moviesRaw || []).filter(function (it) { return it.poster; }));
|
||||
var dramas = (dramaRaw || []).filter(function (it) { return it.poster; });
|
||||
var us = shuffleArr(pickKind(dramas, /美剧|欧美剧/, /美国/));
|
||||
var cn = shuffleArr(pickKind(dramas, /国产剧|大陆剧/, /大陆/));
|
||||
var kr = shuffleArr(pickKind(dramas, /韩剧|韩国剧/, /韩国/));
|
||||
var tmdbPool = shuffleArr((tmdbRaw || []).filter(function (it) { return it.poster; }));
|
||||
state.lastDiag = "轮播:站内候选 电影" + movies.length + " · 美剧" + us.length + " · 国产剧" + cn.length + " · 韩剧" + kr.length + " · TMDB热门" + tmdbPool.length;
|
||||
var pools = [
|
||||
{ list: movies, label: "电影", take: randInt(0, 1) },
|
||||
{ list: us, label: "美剧", take: randInt(0, 1) },
|
||||
{ list: cn, label: "国产剧", take: randInt(0, 1) },
|
||||
{ list: kr, label: "韩剧", take: randInt(0, 1) },
|
||||
{ list: tmdbPool, label: "TMDB热门", tmdb: true, take: randInt(6, 8) } // 主力:每次从一大池 TMDB 热播里随机抽 6~8 个(池子已抓多页,所以每次抽到的都不同)
|
||||
];
|
||||
if (!movies.length && !us.length && !cn.length && !kr.length && !tmdbPool.length) { box.replaceChildren(); state.heroItems = []; buildHeroDots(0); updateHeroVisible(); return; }
|
||||
fillHeroSlots(pools);
|
||||
}
|
||||
/* 先用站内素材(各路取各自随机抽到的 take 个)立刻渲染轮播,完全不等待 TMDB;
|
||||
TMDB 高清图/铭牌随后在后台并行请求,谁先回来就先静默替换谁,互不阻塞、也不阻塞首屏。 */
|
||||
function fillHeroSlots(pools) {
|
||||
var box = $("heroCarousel"); if (!box) return;
|
||||
var token = ++state.heroToken;
|
||||
var picked = [];
|
||||
pools.forEach(function (p) {
|
||||
p.list.slice(0, p.take).forEach(function (raw) {
|
||||
picked.push({
|
||||
title: raw.title, year: raw.year, typeName: p.tmdb ? (raw.typeName || p.label) : p.label, status: raw.status,
|
||||
overview: raw.overview, poster: raw.poster, backdrop: "", logo: "",
|
||||
opener: (function (x, isTmdb) { return function () { if (isTmdb) openTrendingDetail(x); else openDetail(x); }; })(raw, !!p.tmdb),
|
||||
_raw: raw, _label: p.label, _isTmdb: !!p.tmdb
|
||||
});
|
||||
});
|
||||
});
|
||||
if (!picked.length) { box.replaceChildren(); state.heroItems = []; state.heroLoading = false; buildHeroDots(0); updateHeroVisible(); return; }
|
||||
shuffleArr(picked);
|
||||
state.lastDiag = "轮播:站内候选 " + pools.map(function (p) { return p.list.length + "(" + p.label + ")"; }).join(" · ");
|
||||
renderHeroSlides(picked); // 立即展示,用的是站源自带封面图(TMDB 这一路本身自带 TMDB 海报,不用等)
|
||||
if (!config.tmdbKey) return;
|
||||
var token = state.panToken;
|
||||
var name = cleanTitle(item.title);
|
||||
var yr = String(item.year || yearOf(item.title) || "").slice(0, 4);
|
||||
var wantTv = state.activeCat === "2" || state.activeCat === "4" || /第.?季|剧集|全集|季/.test(item.title || "") || /剧|集|动漫/.test(item.meta || "") || (item.typeName && /剧|综艺|动漫|动画/.test(item.typeName));
|
||||
var kind = wantTv ? "tv" : "movie";
|
||||
// 后台并行给每一帧去配 TMDB 高清图,互不等待、随到随换;TMDB 池本身已有 id,直接按 id 拉图,不用再搜标题
|
||||
picked.forEach(function (it, idx) {
|
||||
var resolver = it._isTmdb ? resolveHeroItemTmdb(it._raw) : resolveHeroItem(it._raw, it._label);
|
||||
resolver.then(function (res) {
|
||||
if (token !== state.heroToken) return; // 期间又刷新过一次,这批结果作废
|
||||
if (res.hd) upgradeHeroSlide(idx, res.item);
|
||||
}, function () {});
|
||||
});
|
||||
}
|
||||
/* 把某一帧的背景图/铭牌从站内海报静默换成 TMDB 高清图:
|
||||
先在内存里把新图完整预加载好,再做一次淡出淡入,避免用户看到加载过程中的闪烁或空白。
|
||||
这里的预加载探测图跟页面里真正显示的 <img>(setTmdbImg 那套)不是一回事——之前只写了
|
||||
pre.onload,没写 pre.onerror:TMDB 图片域名(api.tmdb.org / image.tmdb.org)有一个在
|
||||
当前网络下打不通,这张探测图就直接挂在“加载中”,onload 永远不触发,轮播也就永远停在
|
||||
站内低清封面上,界面上完全看不出任何报错。这里补上跟 setTmdbImg 一样的“一个域名不通就
|
||||
自动换另一个域名再试一次”的兜底,两个域名都失败才死心,保留原图。 */
|
||||
function upgradeHeroSlide(idx, item) {
|
||||
var box = $("heroCarousel"); if (!box || !box._imgMap) return;
|
||||
var rec = box._imgMap[idx]; if (!rec || !rec.img) return;
|
||||
if (state.heroItems && state.heroItems[idx]) {
|
||||
state.heroItems[idx].backdrop = item.backdrop; state.heroItems[idx].logo = item.logo;
|
||||
}
|
||||
if (item.backdrop) {
|
||||
var host = config.tmdbImg || "images.tmdb.org";
|
||||
var altHost = host.indexOf("images.tmdb.org") >= 0 ? "image.tmdb.org" : "images.tmdb.org";
|
||||
var url = "https://" + host + "/t/p/original" + item.backdrop;
|
||||
var altUrl = "https://" + altHost + "/t/p/original" + item.backdrop;
|
||||
(function () {
|
||||
var tried = false;
|
||||
var wasActive = rec.img.classList.contains("active"); // 换图期间用内联样式接管淡入淡出,换完再交还给 active 类控制
|
||||
var pre = new Image();
|
||||
pre.onload = function () {
|
||||
rec.img.style.transition = "opacity .35s";
|
||||
rec.img.style.opacity = "0";
|
||||
var finalUrl = tried ? altUrl : url;
|
||||
setTimeout(function () {
|
||||
setTmdbImg(rec.img, finalUrl);
|
||||
rec.img.style.opacity = wasActive ? "1" : "0";
|
||||
setTimeout(function () { rec.img.style.transition = ""; rec.img.style.opacity = ""; }, 400);
|
||||
}, 180);
|
||||
};
|
||||
pre.onerror = function () {
|
||||
if (tried) return; // 两个域名都失败,放弃,保留原来的站内封面
|
||||
tried = true;
|
||||
pre.src = imgSrc(altUrl);
|
||||
};
|
||||
pre.src = imgSrc(url);
|
||||
})();
|
||||
}
|
||||
if (item.logo) setHeroLogo(idx, item.logo);
|
||||
}
|
||||
/* 单条候选是否"配得上高清图":没配 TMDB Key 时不做这层过滤,直接沿用老行为(站内海报,永远算成功);
|
||||
配了 Key 则必须搜到条目且拿到 backdrop 大图才算成功,否则返回 hd:false 交给上层换下一个候选 */
|
||||
function resolveHeroItem(it, label) {
|
||||
var base = { title: it.title, year: it.year, typeName: label, status: it.status, overview: it.overview, poster: it.poster, backdrop: "", logo: "", opener: function () { openDetail(it); } };
|
||||
if (!config.tmdbKey) return Promise.resolve({ item: base, hd: true });
|
||||
var yr = String(it.year || yearOf(it.title) || "").slice(0, 4);
|
||||
var wantTv = label !== "电影";
|
||||
return tmdbSearchBest(titleCandidates(it.title), yr, wantTv).then(function (pick) {
|
||||
if (!pick) return { item: base, hd: false };
|
||||
return tmdbHeroImages(pick, base);
|
||||
}, function () { return { item: base, hd: false }; });
|
||||
}
|
||||
/* TMDB 热门候选池自带 _tmdbId/_mediaType,不用再像上面那样按标题模糊搜索去猜是哪部——
|
||||
直接拿 id 查详情+images,命中率更高、也更快,出来的结构跟 resolveHeroItem 一致,供 fillHeroSlots 统一调用 */
|
||||
function resolveHeroItemTmdb(raw) {
|
||||
var base = { title: raw.title, year: raw.year, typeName: raw.typeName, status: raw.status, overview: raw.overview, poster: raw.poster, backdrop: "", logo: "", opener: function () { openTrendingDetail(raw); } };
|
||||
if (!config.tmdbKey || !raw._tmdbId || !raw._mediaType) return Promise.resolve({ item: base, hd: !!config.tmdbKey });
|
||||
return tmdbHeroImages({ media_type: raw._mediaType, id: raw._tmdbId }, base);
|
||||
}
|
||||
/* 给定 {media_type, id} 拉详情图,取最佳横版大图 + 铭牌,供 resolveHeroItem / resolveHeroItemTmdb 共用 */
|
||||
function tmdbHeroImages(pick, base) {
|
||||
return httpJson(tmdbApi("/" + pick.media_type + "/" + pick.id, {
|
||||
append_to_response: "images",
|
||||
include_image_language: "zh,en,null"
|
||||
})).then(function (det) {
|
||||
if (!det) return { item: base, hd: false };
|
||||
var imgs = det.images || {};
|
||||
var backdropPath = tmdbBestImg(imgs.backdrops, null) || det.backdrop_path || "";
|
||||
if (!backdropPath) return { item: base, hd: false }; // 拿不到高清横版大图,判失败,换下一部
|
||||
var logoPath = tmdbBestImg(imgs.logos, "zh") || tmdbBestImg(imgs.logos, "en") || tmdbBestImg(imgs.logos, null) || ((imgs.logos && imgs.logos[0]) ? imgs.logos[0].file_path : "");
|
||||
return {
|
||||
hd: true,
|
||||
item: { title: base.title, year: base.year, typeName: base.typeName, status: base.status, overview: base.overview, poster: base.poster,
|
||||
backdrop: backdropPath, logo: logoPath ? tmdbImgUrl("original", logoPath) : "", opener: base.opener }
|
||||
};
|
||||
}, function () { return { item: base, hd: false }; });
|
||||
}
|
||||
function tmdbBestImg(arr, wantLang) {
|
||||
if (!arr || !arr.length) return "";
|
||||
var byLang = arr.filter(function (x) { return x.iso_639_1 === wantLang; });
|
||||
var pool = byLang.length ? byLang : arr;
|
||||
pool.sort(function (a, b) { return (b.vote_average || 0) - (a.vote_average || 0); });
|
||||
return pool[0].file_path;
|
||||
}
|
||||
function tmdbImgUrl(size, path) { return path ? ("https://" + (config.tmdbImg || "image.tmdb.org") + "/t/p/" + size + path) : ""; }
|
||||
/* 构建轮播 DOM:首尾各补一张克隆帧,实现「一直往同一方向转」的无缝循环,不会跳回开头。
|
||||
之前这里是一次性把所有帧(含首尾克隆,通常十几张)的 <img> 全部同时 src=…,弱网下
|
||||
浏览器会把这十几个图片请求全塞到一起抢带宽(同域名并发数还有限),结果连用户第一眼
|
||||
就看到的那一帧也跟着被拖慢,表现就是刚进主页轮播一片黑、迟迟不出图。
|
||||
这里按"离当前可见帧的远近"分三档错峰加载:第 0 帧(一进来就看到的那帧)立刻加载,
|
||||
不等任何东西;它两侧用来做无缝循环的克隆帧(可能手指一滑就看到)延迟一小会;
|
||||
其余离得远、要好几屏之后才可能划到的帧最后再补,用 requestIdleCallback(没有就退化成
|
||||
setTimeout)见缝插针地加载,不跟第 0 帧抢带宽。这样"秒出现"的只需要保证一张图,
|
||||
其余的在后台慢慢补齐,用户几乎感觉不到。 */
|
||||
function renderHeroSlides(items) {
|
||||
var box = $("heroCarousel"); if (!box) return;
|
||||
hideHomeSkeleton(); // 轮播出图了,收起首屏骨架屏
|
||||
state.heroItems = items; state.heroIndex = 0; state.heroLoading = false;
|
||||
var n = items.length;
|
||||
function scheduleLoad(fn, tier) {
|
||||
if (tier === 0) { fn(); return; }
|
||||
if (tier === 1) { setTimeout(fn, 260); return; }
|
||||
if (window.requestIdleCallback) requestIdleCallback(fn, { timeout: 2500 });
|
||||
else setTimeout(fn, 900);
|
||||
}
|
||||
// 图片层:图片本身不参与左右位移,每张图直接按逻辑序号建一层,靠 opacity 交叉淡入淡出切换,
|
||||
// 不需要像信息层那样首尾克隆帧(交叉渐隐渐显没有"循环边界"的问题,直接淡到目标帧即可)。
|
||||
var imgsBox = document.createElement("div"); imgsBox.className = "hero-imgs";
|
||||
var imgMap = {};
|
||||
function makeImgLayer(it, logicalIdx, tier) {
|
||||
var img = document.createElement("img"); img.className = "hero-bg"; img.alt = "";
|
||||
img.crossOrigin = "anonymous"; // 取色要读像素,不加这个非同源图片会让 canvas 变"脏",读不出颜色(读不出就静默放弃,不影响原本的显示)
|
||||
img.loading = tier === 0 ? "eager" : "lazy";
|
||||
img.decoding = "async";
|
||||
try { img.setAttribute("fetchpriority", tier === 0 ? "high" : "low"); } catch (e) {}
|
||||
img.onerror = function () { img.style.display = "none"; };
|
||||
var loaded = false;
|
||||
function doLoad() {
|
||||
if (loaded) return; loaded = true;
|
||||
if (it.backdrop) setTmdbImg(img, "https://" + (config.tmdbImg || "image.tmdb.org") + "/t/p/original" + it.backdrop);
|
||||
else img.src = imgSrc(it.poster || "");
|
||||
}
|
||||
img._forceLoad = doLoad; // 翻页/手滑到这一帧时可以跳过下面的排队延迟,立刻补加载
|
||||
scheduleLoad(doLoad, tier);
|
||||
imgMap[logicalIdx] = { img: img };
|
||||
return img;
|
||||
}
|
||||
items.forEach(function (it, idx) { imgsBox.appendChild(makeImgLayer(it, idx, idx === 0 ? 0 : 2)); });
|
||||
imgsBox.appendChild(el("div", "hero-shade"));
|
||||
imgsBox.appendChild(el("div", "hero-blend"));
|
||||
// 信息层:徽标/标题/元信息/简介继续走原来的左右滑动轨道,首尾各补一张克隆帧维持无缝循环手感
|
||||
var track = document.createElement("div"); track.className = "hero-track";
|
||||
var slideMap = {};
|
||||
function makeSlide(it, logicalIdx) {
|
||||
var slide = document.createElement("div"); slide.className = "hero-slide";
|
||||
var info = document.createElement("div"); info.className = "hero-info";
|
||||
var titleWrap = document.createElement("div"); titleWrap.className = "hero-title-wrap";
|
||||
var titleText = el("div", "hero-title", it.title);
|
||||
titleWrap.appendChild(titleText);
|
||||
info.appendChild(titleWrap);
|
||||
info.appendChild(el("div", "hero-meta", [it.year, it.typeName, it.status].filter(function (x) { return x; }).join(" · ")));
|
||||
if (it.overview) info.appendChild(el("div", "hero-ov", it.overview));
|
||||
slide.appendChild(info);
|
||||
slide.onclick = function () { if (it.opener) it.opener(); };
|
||||
slideMap[logicalIdx] = (slideMap[logicalIdx] || []).concat([{ titleWrap: titleWrap, titleText: titleText }]);
|
||||
return slide;
|
||||
}
|
||||
if (n > 1) track.appendChild(makeSlide(items[n - 1], n - 1)); // 首部克隆最后一张(仅信息层需要)
|
||||
items.forEach(function (it, idx) { track.appendChild(makeSlide(it, idx)); });
|
||||
if (n > 1) track.appendChild(makeSlide(items[0], 0)); // 尾部克隆第一张(仅信息层需要)
|
||||
box.replaceChildren(imgsBox, track);
|
||||
box._track = track; box._n = n; box._slideMap = slideMap; box._imgMap = imgMap;
|
||||
buildHeroDots(n);
|
||||
bindHeroSwipe(box);
|
||||
goHeroInstant(0);
|
||||
startHeroAuto();
|
||||
updateHeroVisible();
|
||||
// .hero-bg 到这里才真正存在,此时才能实测 CSS 滚动驱动视差有没有跑起来
|
||||
requestAnimationFrame(syncHeroParallaxMode);
|
||||
}
|
||||
|
||||
function score(r) {
|
||||
/* ── Hero 视差:JS 兜底路径 ───────────────────────────────────────────
|
||||
首选实现是上面样式表里的滚动驱动动画(@supports (animation-timeline: scroll())),
|
||||
它跑在合成器线程、与滚动同帧,完全不抖。这里这套 JS 只给不支持该特性的老内核兜底:
|
||||
rAF 写 transform 跑在主线程,与合成器滚动之间存在一帧差,会有轻微抖动(原理性的)。
|
||||
所以只要 CSS 那套真的生效,下面整段就不启用,避免两套机制争同一个 transform。
|
||||
|
||||
判定不能只看 CSS.supports:内核"认识"这个属性但时间轴没绑成功时它也返回 true,
|
||||
结果 CSS 不动、JS 又被关掉 → 两边都不干活,视差彻底消失。这里改成「实测」:
|
||||
真的去问元素身上有没有一个挂着 timeline 的动画在跑,跑得起来才把活交给 CSS。 */
|
||||
var HERO_PLX = 0.42; // 海报滞后系数:净上移 ≈ (1-0.42)=0.58×(与 CSS 关键帧保持一致)
|
||||
var _heroPlxCss = false, _heroPlxRaf = 0, _heroPlxBound = false;
|
||||
function heroCssParallaxWorks() {
|
||||
try {
|
||||
if (!window.CSS || !CSS.supports || !CSS.supports("animation-timeline", "scroll()")) return false;
|
||||
var el = document.querySelector(".hero-bg");
|
||||
if (!el || !el.getAnimations) return false;
|
||||
return el.getAnimations().some(function (a) {
|
||||
return a.animationName === "heroParallax" && a.timeline && a.timeline !== document.timeline;
|
||||
});
|
||||
} catch (e) { return false; }
|
||||
}
|
||||
function heroParallaxApply() {
|
||||
if (_heroPlxCss) return; // CSS 已接管,JS 不插手
|
||||
var box = $("heroCarousel"); if (!box) return;
|
||||
var hero = box.getBoundingClientRect();
|
||||
var imgs = box.getElementsByClassName("hero-bg");
|
||||
var reduce = false;
|
||||
try { reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches; } catch (e) {}
|
||||
var st = Math.max(0, window.pageYOffset || document.documentElement.scrollTop || 0);
|
||||
// 海报被 .hero/.hero-slide 的 overflow:hidden 上下裁切,下压不会露边,无需额外放大兜底
|
||||
var tf = (reduce || hero.height <= 0) ? "" : "translate3d(0," + (st * HERO_PLX).toFixed(2) + "px,0)";
|
||||
for (var i = 0; i < imgs.length; i++) imgs[i].style.transform = tf;
|
||||
}
|
||||
function syncHeroParallaxMode() {
|
||||
_heroPlxCss = heroCssParallaxWorks();
|
||||
if (_heroPlxCss) return; // 合成器接管:连滚动监听都不用绑
|
||||
if (!_heroPlxBound) {
|
||||
_heroPlxBound = true;
|
||||
window.addEventListener("scroll", function () {
|
||||
if (_heroPlxRaf) return;
|
||||
_heroPlxRaf = requestAnimationFrame(function () { _heroPlxRaf = 0; heroParallaxApply(); });
|
||||
}, { passive: true });
|
||||
}
|
||||
heroParallaxApply();
|
||||
}
|
||||
/* 徽标图加载成功后替换掉文字标题;失败则保留文字 */
|
||||
function setHeroLogo(logicalIdx, url) {
|
||||
var box = $("heroCarousel"); if (!box || !box._slideMap) return;
|
||||
var recs = box._slideMap[logicalIdx]; if (!recs) return;
|
||||
recs.forEach(function (rec) {
|
||||
var img = new Image();
|
||||
img.className = "hero-logo"; img.alt = "";
|
||||
img.onload = function () { rec.titleText.style.display = "none"; rec.titleWrap.appendChild(img); };
|
||||
setTmdbImg(img, url);
|
||||
});
|
||||
}
|
||||
/* 复用给"轮播补图"和"详情页拉资料"共用的检索逻辑:nameList 是一组候选搜索词(见 titleCandidates),
|
||||
按顺序逐个去 TMDB 搜,命中一个有效结果就停,不会像之前那样一开始就武断砍标题导致搜不到 */
|
||||
function tmdbSearchBest(nameList, yr, wantTv) {
|
||||
var kind = wantTv ? "tv" : "movie";
|
||||
function score(r, name) {
|
||||
var s = 0;
|
||||
var t = r.title || r.name || "";
|
||||
if (t === name) s += 6; else if (t.indexOf(name) >= 0 || name.indexOf(t) >= 0) s += 3;
|
||||
@@ -1480,47 +6810,649 @@ function loadTmdb(item) {
|
||||
if (!wantTv && r.media_type === "movie") s += 2;
|
||||
var ry = (r.release_date || r.first_air_date || "").slice(0, 4);
|
||||
if (yr && ry) {
|
||||
if (ry === yr) s += 9; // 年份精确匹配,强权重
|
||||
else { var diff = Math.abs((parseInt(ry, 10) || 0) - (parseInt(yr, 10) || 0)); s -= (diff <= 1 ? 1.5 : 7); } // 错年重罚
|
||||
if (ry === yr) s += 9;
|
||||
else { var diff = Math.abs((parseInt(ry, 10) || 0) - (parseInt(yr, 10) || 0)); s -= (diff <= 1 ? 1.5 : 7); }
|
||||
}
|
||||
s += Math.min(3, (r.popularity || 0) / 40);
|
||||
return s;
|
||||
}
|
||||
function proceed(list) {
|
||||
if (token !== state.panToken) return;
|
||||
list = (list || []).filter(function (r) { return r && (r.media_type === "movie" || r.media_type === "tv"); });
|
||||
if (!list.length) return;
|
||||
list.sort(function (a, b) { return score(b) - score(a); });
|
||||
var pick = list[0];
|
||||
return httpJson(tmdbApi("/" + pick.media_type + "/" + pick.id, {
|
||||
append_to_response: "images,content_ratings,release_dates,credits",
|
||||
include_image_language: "zh,en,null"
|
||||
})).then(function (det) {
|
||||
// 从"逐个候选词排队试、前一个失败才发下一个"改成"所有候选词并行发起",
|
||||
// 把原本最多 N 轮串行往返压到最多 2 轮并行往返(精确搜索一轮 + 兜底 multi 一轮)
|
||||
function pickBestAcrossEntries(entries) {
|
||||
var best = null, bestScore = -Infinity;
|
||||
entries.forEach(function (e) {
|
||||
(e.results || []).filter(function (r) { return r && (r.media_type === "movie" || r.media_type === "tv"); })
|
||||
.forEach(function (r) {
|
||||
var s = score(r, e.name);
|
||||
if (s > bestScore) { bestScore = s; best = r; }
|
||||
});
|
||||
});
|
||||
return best;
|
||||
}
|
||||
var names = (Array.isArray(nameList) ? nameList : [nameList]).filter(Boolean);
|
||||
if (!names.length) return Promise.resolve(null);
|
||||
|
||||
var wave1 = names.map(function (name) {
|
||||
if (!yr) return Promise.resolve({ name: name, results: [] });
|
||||
var p = { query: name, include_adult: "false" };
|
||||
p[wantTv ? "first_air_date_year" : "primary_release_year"] = yr;
|
||||
return httpJson(tmdbApi("/search/" + kind, p)).then(function (data) {
|
||||
return { name: name, results: (data && data.results ? data.results : []).map(function (r) { r.media_type = kind; return r; }) };
|
||||
}, function () { return { name: name, results: [] }; });
|
||||
});
|
||||
return Promise.all(wave1).then(function (entries) {
|
||||
var best = pickBestAcrossEntries(entries);
|
||||
if (best) return best;
|
||||
var wave2 = names.map(function (name) {
|
||||
return httpJson(tmdbApi("/search/multi", { query: name, include_adult: "false" })).then(function (d2) {
|
||||
return { name: name, results: d2 && d2.results ? d2.results : [] };
|
||||
}, function () { return { name: name, results: [] }; });
|
||||
});
|
||||
return Promise.all(wave2).then(pickBestAcrossEntries);
|
||||
});
|
||||
}
|
||||
/* 从原始标题派生一组搜索候选词:完整清洗后标题优先(保留『·』『:』等分隔符——很多片名本身就带,
|
||||
比如人名"哈利·波特"、副标题"碟中谍6:全面瓦解",缺了这部分反而搜不到);
|
||||
完整标题搜不到时,再退回分隔符前面的主标题、以及分隔符后面的部分依次兜底 */
|
||||
function titleCandidates(rawTitle) {
|
||||
var full = cleanTitle(rawTitle);
|
||||
var out = [];
|
||||
function add(x) { x = (x || "").replace(/\s+/g, " ").trim(); if (x && x.length >= 1 && out.indexOf(x) < 0) out.push(x); }
|
||||
add(full);
|
||||
// 冒号/破折号分隔的主副标题,两段都试
|
||||
var m = full.match(/^(.*?)[::·\-—_|](.+)$/);
|
||||
if (m) { add(m[1]); add(m[2]); }
|
||||
// 中英混排(如"误杀 Sheep Without a Shepherd"):分别取纯中文段、纯英文段
|
||||
var cjk = full.match(/[\u4e00-\u9fa5][\u4e00-\u9fa50-9]*/g);
|
||||
if (cjk && cjk.length) { var longest = cjk.slice().sort(function (a, b) { return b.length - a.length; })[0]; add(longest); }
|
||||
var eng = full.match(/[A-Za-z][A-Za-z0-9'&:.\s]{1,}/);
|
||||
if (eng) add(eng[0]);
|
||||
// 去掉末尾的纯数字续集号再试一次("误杀3" -> "误杀"),有时 TMDB 只收录系列主条目
|
||||
add(full.replace(/\s*\d+\s*$/, ""));
|
||||
return out.slice(0, 6);
|
||||
}
|
||||
function buildHeroDots(n) {
|
||||
var box = $("heroCarousel");
|
||||
var dotsBox = $("heroDots"); if (!dotsBox) return;
|
||||
var frag = document.createDocumentFragment();
|
||||
for (var i = 0; i < n; i++) { var d = el("span", "hero-dot" + (i === 0 ? " on" : "")); frag.appendChild(d); }
|
||||
dotsBox.replaceChildren(frag);
|
||||
if (box) box._dots = dotsBox;
|
||||
}
|
||||
function setDots(i) { var box = $("heroCarousel"); if (!box || !box._dots) return; var ds = box._dots.children; for (var k = 0; k < ds.length; k++) ds[k].classList.toggle("on", k === i); }
|
||||
/* 立即(无动画)定位到某一逻辑帧,用于初始化和无缝循环的瞬间归位 */
|
||||
function forceLoadHeroFrame(box, logicalIdx) {
|
||||
var rec = box._imgMap && box._imgMap[logicalIdx]; if (!rec) return;
|
||||
if (rec.img && rec.img._forceLoad) rec.img._forceLoad();
|
||||
}
|
||||
/* 首页背景跟随轮播海报变色:canvas 缩到很小的尺寸采样,取图中出现次数最多的颜色(众数),
|
||||
而不是所有像素的平均色——平均色容易被大片高光/阴影"拉"成一坨灰,众数能更准地反映
|
||||
海报里那块观众一眼认定的"主色"(比如大片天空蓝、大片西装黑)。
|
||||
取色需要读像素,图片必须"同源"或对方开了 CORS(前面 makeSlide 里已给 hero-bg 加了
|
||||
crossOrigin=anonymous);盘链自家图片站/未开 CORS 的图会让 canvas 变"脏",读取时会抛
|
||||
SecurityError —— 这里 try/catch 直接吞掉,取色失败就维持当前背景色不动,不会报错也不会
|
||||
影响轮播本身。同一张图算过一次就缓存,翻页来回滑不会重复计算。 */
|
||||
var heroBgCache = {};
|
||||
/* 之前踩过两个坑:
|
||||
1) 整图等权平均——取到的是海报"整体"基调,但真正要衔接背景的只是最下面那一条,
|
||||
如果海报本身底部渐隐成浅色,整图平均会被中上部"拉"深,背景比实际底边暗,衔接处露馅。
|
||||
而且"平均"本身就容易被画面里大片天空/大片阴影这类极端色"拖"成一坨不上不下的灰,
|
||||
跟画面里真正视觉主导的颜色对不上。
|
||||
2) 改成只采样底部一截之后又矫枉过正——很多海报底部本来就压了一层深色渐变蒙版方便盖标题字
|
||||
(这是海报设计自带的,不是页面加的),采样底部结果拿到的几乎是纯黑灰,色相被蒙版"洗掉"了,
|
||||
算出来的背景看起来就是灰扑扑一坨,看不出海报本该有的蓝/青/暖色调。
|
||||
现在把"色相该是什么颜色"和"亮度该有多暗"拆开来分别采样:色相/饱和度取自海报中上部更大范围
|
||||
(不受底部蒙版影响,能反映真实基调,比如路口戏该有的青蓝街景),亮度单独取贴底的那一条边缘
|
||||
(因为亮度才是决定衔接是否有台阶的关键)。两个区域各自都不再用"平均",而是把像素按颜色
|
||||
量化分桶,找出出现次数最多的那个桶(众数),再用桶内像素的均值代表这个众数颜色,
|
||||
既避免了量化造成的色块生硬,又不会被少数极端色像素带偏。两块众数色再用 HSL 重新组合:
|
||||
色相饱和度来自"氛围",亮度来自"边缘",既保留了海报本身最主导的调子,又保证亮度和
|
||||
背景衔接处精确匹配。 */
|
||||
function rgbToHsl(r, g, b) {
|
||||
r /= 255; g /= 255; b /= 255;
|
||||
var max = Math.max(r, g, b), min = Math.min(r, g, b);
|
||||
var h = 0, s = 0, l = (max + min) / 2;
|
||||
if (max !== min) {
|
||||
var d = max - min;
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
if (max === r) h = (g - b) / d + (g < b ? 6 : 0);
|
||||
else if (max === g) h = (b - r) / d + 2;
|
||||
else h = (r - g) / d + 4;
|
||||
h /= 6;
|
||||
}
|
||||
return [h, s, l];
|
||||
}
|
||||
function hslToRgb(h, s, l) {
|
||||
if (s === 0) { var v = Math.round(l * 255); return [v, v, v]; }
|
||||
function hue2rgb(p, q, t) {
|
||||
if (t < 0) t += 1; if (t > 1) t -= 1;
|
||||
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
||||
if (t < 1 / 2) return q;
|
||||
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
||||
return p;
|
||||
}
|
||||
var q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||
var p = 2 * l - q;
|
||||
return [Math.round(hue2rgb(p, q, h + 1 / 3) * 255), Math.round(hue2rgb(p, q, h) * 255), Math.round(hue2rgb(p, q, h - 1 / 3) * 255)];
|
||||
}
|
||||
/* 按"色相"给像素分桶,而不是按 RGB 立方体分桶——同一片渐变天空/背景,明暗从亮到暗一路变化,
|
||||
但色相基本不变;如果按 RGB 分桶,明暗渐变会把这片蓝硬拆成好几个桶,每个桶票数都不多,
|
||||
反而让画面里一小块颜色很"平"(比如衣服、肤色)的区域在众数投票里意外胜出——这正是之前
|
||||
"明明整张图很蓝,背景却取不到蓝"的根因。改成只按色相(+ 是否低饱和度的"中性/灰黑白"类别)
|
||||
分桶后,蓝天不管明暗怎么渐变都会被计入同一个桶,票数才能如实反映"画面里蓝色占比最大"。
|
||||
饱和度很低的像素(灰、黑、白)色相本身没有意义,单独按亮度分桶,避免被强行拉进某个色相桶里。 */
|
||||
var HERO_HUE_BINS = 20; // 色相环分 20 桶,每桶 18°
|
||||
var HERO_NEUTRAL_SAT = 0.12; // 饱和度低于这个值算"中性色",不参与色相投票
|
||||
var HERO_NEUTRAL_LUM_BINS = 6; // 中性色再按亮度分 6 桶
|
||||
function bumpHueBucket(buckets, r, g, b) {
|
||||
var hsl = rgbToHsl(r, g, b);
|
||||
var key = hsl[1] < HERO_NEUTRAL_SAT
|
||||
? "n" + Math.min(HERO_NEUTRAL_LUM_BINS - 1, (hsl[2] * HERO_NEUTRAL_LUM_BINS) | 0)
|
||||
: "h" + Math.min(HERO_HUE_BINS - 1, (hsl[0] * HERO_HUE_BINS) | 0);
|
||||
var bk = buckets[key];
|
||||
if (bk) { bk.n++; bk.r += r; bk.g += g; bk.b += b; }
|
||||
else buckets[key] = { n: 1, r: r, g: g, b: b };
|
||||
}
|
||||
/* 选出票数最多的桶,用桶内像素的真实均值作为代表色——分桶只用来"数票",
|
||||
不用桶的色相中心值直接当颜色,避免看起来一块块的量化色差。
|
||||
补丁:色相桶是按 18° 一刀切死的,同一片肉眼看着连续的颜色(比如整片深红墙面)
|
||||
如果色相刚好落在两个桶的分界线附近,会被硬拆成相邻两个桶,各自票数被"腰斩",
|
||||
于是明明是画面里占比最大的颜色,排名时却输给了某个没有被拆分、票数完整的次要颜色——
|
||||
这是"看着很红,取色却不红"的另一个根因(跟顶部遮罩是两回事)。
|
||||
修复:排名时把每个色相桶和它左右相邻的桶合并计票(色相环首尾相接),
|
||||
相邻本就是相近色相,合并后既纠正了边界切分造成的票数损失,颜色均值也更能代表
|
||||
"这一片色系",而不是某一刀切下来的窄条量化色。中性色(灰黑白)桶不参与这个合并,
|
||||
因为相邻的是亮度桶,本身没有色相连续性可言。 */
|
||||
function topColorBucket(buckets, hueBins) {
|
||||
hueBins = hueBins || HERO_HUE_BINS;
|
||||
var merged = {};
|
||||
for (var k in buckets) {
|
||||
if (!Object.prototype.hasOwnProperty.call(buckets, k)) continue;
|
||||
if (k.charAt(0) !== "h") { merged[k] = buckets[k]; continue; }
|
||||
var idx = parseInt(k.slice(1), 10);
|
||||
var n = 0, r = 0, g = 0, b = 0;
|
||||
for (var d = -1; d <= 1; d++) {
|
||||
var nb = buckets["h" + ((idx + d + hueBins) % hueBins)];
|
||||
if (nb) { n += nb.n; r += nb.r; g += nb.g; b += nb.b; }
|
||||
}
|
||||
merged[k] = { n: n, r: r, g: g, b: b };
|
||||
}
|
||||
var best = null;
|
||||
for (var k2 in merged) { if (Object.prototype.hasOwnProperty.call(merged, k2) && (!best || merged[k2].n > best.n)) best = merged[k2]; }
|
||||
if (!best) return null;
|
||||
return [best.r / best.n, best.g / best.n, best.b / best.n];
|
||||
}
|
||||
/* 还原 .hero-bg 实际用的 object-fit:cover + object-position:center 20% 会显示出来的那一块矩形,
|
||||
取色只在这块矩形里采样——原来是把整张原图不分青红皂白地压扁进 56×56 画布,海报如果
|
||||
本身长宽比例跟 .hero 容器(58vh,横屏/平板还各有一套比例)差得远,会把屏幕上其实被裁掉、
|
||||
根本没显示出来的那部分(比如很宽的横版剧照两侧留白)也一起计入色相投票,
|
||||
采出来的颜色容易跟屏幕上实际看到的画面对不上。 */
|
||||
function coverSampleRect(imgW, imgH, boxW, boxH, posYPercent) {
|
||||
if (!imgW || !imgH || !boxW || !boxH) return { sx: 0, sy: 0, sw: imgW, sh: imgH };
|
||||
var scale = Math.max(boxW / imgW, boxH / imgH);
|
||||
var overflowX = Math.max(0, imgW * scale - boxW);
|
||||
var overflowY = Math.max(0, imgH * scale - boxH);
|
||||
var sx = (overflowX * 0.5) / scale; // 水平始终居中,跟 CSS 的 center 一致
|
||||
var sy = (overflowY * (posYPercent / 100)) / scale;
|
||||
var sw = Math.min(imgW, boxW / scale), sh = Math.min(imgH, boxH / scale);
|
||||
sx = Math.min(Math.max(0, sx), imgW - sw);
|
||||
sy = Math.min(Math.max(0, sy), imgH - sh);
|
||||
return { sx: sx, sy: sy, sw: sw, sh: sh };
|
||||
}
|
||||
function sampleImageDominant(img, cb) {
|
||||
try {
|
||||
var w = 56, h = 56; // 从 40 提到 56:格数越多,色相分桶时同一片颜色的统计样本越充分,量化噪声占比越小
|
||||
var box = $("heroCarousel");
|
||||
var boxW = (box && box.clientWidth) || img.naturalWidth;
|
||||
var boxH = (box && box.clientHeight) || img.naturalHeight;
|
||||
// 20 对应 CSS 里 .hero-bg { object-position: center 20% },两边必须保持一致
|
||||
var rect = coverSampleRect(img.naturalWidth, img.naturalHeight, boxW, boxH, 20);
|
||||
var cv = document.createElement("canvas"); cv.width = w; cv.height = h;
|
||||
var ctx = cv.getContext("2d", { willReadFrequently: true });
|
||||
ctx.imageSmoothingEnabled = false; // 关掉缩放平滑:平滑插值会把蓝天和人像边缘"糊"出过渡杂色,稀释真实主色的票数
|
||||
ctx.drawImage(img, rect.sx, rect.sy, rect.sw, rect.sh, 0, 0, w, h);
|
||||
var data = ctx.getImageData(0, 0, w, h).data;
|
||||
var moodBuckets = {}, edgeBuckets = {}; // 氛围:0~85% 高度,不含贴底那截蒙版区;边缘:最底部 15%,只用来定亮度
|
||||
var moodN = 0, edgeN = 0;
|
||||
var edgeStartRow = Math.round(h * 0.85);
|
||||
for (var y = 0; y < h; y++) {
|
||||
for (var x = 0; x < w; x++) {
|
||||
var i = (y * w + x) * 4;
|
||||
if (data[i + 3] < 16) continue; // 忽略透明像素
|
||||
if (y < edgeStartRow) { bumpHueBucket(moodBuckets, data[i], data[i + 1], data[i + 2]); moodN++; }
|
||||
else { bumpHueBucket(edgeBuckets, data[i], data[i + 1], data[i + 2]); edgeN++; }
|
||||
}
|
||||
}
|
||||
if (!moodN && !edgeN) { cb(null); return; }
|
||||
var moodRgb = topColorBucket(moodN ? moodBuckets : edgeBuckets);
|
||||
var edgeRgb = topColorBucket(edgeN ? edgeBuckets : moodBuckets) || moodRgb;
|
||||
if (!moodRgb) { cb(null); return; }
|
||||
var moodHsl = rgbToHsl(moodRgb[0], moodRgb[1], moodRgb[2]);
|
||||
var edgeHsl = rgbToHsl(edgeRgb[0], edgeRgb[1], edgeRgb[2]);
|
||||
// 色相/饱和度用氛围区的众数色;亮度不能直接照抄边缘区——如果边缘本身压得很暗(比如底部蒙版接近黑),
|
||||
// 亮度一低,饱和度再高乘出来也看不出色相(暗到发黑,肉眼分不出是青是灰)。所以亮度用边缘为主、
|
||||
// 氛围为辅做加权(7:3),既大体贴着边缘真实亮度(保衔接),又留一点提升空间让色相能被看出来。
|
||||
var L = edgeHsl[2] * 0.7 + moodHsl[2] * 0.3;
|
||||
cb(hslToRgb(moodHsl[0], moodHsl[1], L));
|
||||
} catch (e) { cb(null); } // 跨域取色失败:静默放弃,背景保持原样
|
||||
}
|
||||
function heroBgFromRgb(rgb) {
|
||||
// 上面取色时亮度已经直接取自贴底边缘的真实值,这里不用再猜一个压暗比例去接近它——
|
||||
// 只留一道安全阀:万一边缘本身就很亮(比如海报底部是白色地板/白墙),也不能让它把整页
|
||||
// 背景拖得太亮,否则页面里大量浅色文字/图标会失去对比度。压暗时保持色相/色调不变。
|
||||
var r = rgb[0], g = rgb[1], b = rgb[2];
|
||||
var finalLum = 0.299 * r + 0.587 * g + 0.114 * b;
|
||||
var maxLum = 104;
|
||||
if (finalLum > maxLum) {
|
||||
var k = maxLum / finalLum;
|
||||
r = Math.round(r * k); g = Math.round(g * k); b = Math.round(b * k);
|
||||
} else {
|
||||
r = Math.round(r); g = Math.round(g); b = Math.round(b);
|
||||
}
|
||||
return { css: "rgb(" + r + "," + g + "," + b + ")", rgbList: r + "," + g + "," + b };
|
||||
}
|
||||
function setDynBg(bg) {
|
||||
try {
|
||||
document.documentElement.style.setProperty("--dyn-bg", bg.css);
|
||||
document.documentElement.style.setProperty("--dyn-bg-rgb", bg.rgbList);
|
||||
} catch (e) {}
|
||||
}
|
||||
/* 自动播放/点点/翻页这几种"非拖拽"切换,颜色原来是瞬间跳变的,但 .hero-bg 的淡入淡出和
|
||||
.hero-track 的位移都是 .5s cubic-bezier——图还在半透明交叉的时候,hero-blend/hero-shade
|
||||
已经先一步跳成了下一张的颜色,色彩和画面对不上,像是"颜色抢跑"。这里用 rAF 把 rgb
|
||||
从旧值线性过渡到新值,时长对齐同一个 .5s,让"海报换完"和"颜色换完"落在同一时刻。
|
||||
拖拽跟手那条路径(bindHeroSwipe 里)已经是逐帧按手指进度手动插值,不需要也不会走这里。 */
|
||||
var __dynBgAnim = { raf: 0, token: 0 };
|
||||
function setDynBgAnimated(bg, duration) {
|
||||
if (!bg) return;
|
||||
var fromRgb = parseRgbList(getComputedStyle(document.documentElement).getPropertyValue("--dyn-bg-rgb"));
|
||||
var toRgb = parseRgbList(bg.rgbList);
|
||||
if (!fromRgb || !toRgb) { setDynBg(bg); return; } // 没有旧值可插(比如刚打开首页),直接应用
|
||||
if (__dynBgAnim.raf) cancelAnimationFrame(__dynBgAnim.raf);
|
||||
var myToken = ++__dynBgAnim.token;
|
||||
var start = performance.now();
|
||||
duration = duration || 500;
|
||||
function step(now) {
|
||||
if (myToken !== __dynBgAnim.token) return; // 过渡中途又切到别的帧了,这轮插值作废
|
||||
var t = Math.min(1, (now - start) / duration);
|
||||
var mixed = lerpRgb(fromRgb, toRgb, t);
|
||||
setDynBg({ css: "rgb(" + mixed.join(",") + ")", rgbList: mixed.join(",") });
|
||||
if (t < 1) __dynBgAnim.raf = requestAnimationFrame(step);
|
||||
else __dynBgAnim.raf = 0;
|
||||
}
|
||||
__dynBgAnim.raf = requestAnimationFrame(step);
|
||||
}
|
||||
function applyHeroBgColor(logical, animate) {
|
||||
var it = state.heroItems && state.heroItems[logical]; if (!it) return;
|
||||
var key = (it.id != null ? it.id : it.title) + "";
|
||||
var apply = animate === false ? setDynBg : setDynBgAnimated;
|
||||
if (heroBgCache[key]) { apply(heroBgCache[key]); return; }
|
||||
var box = $("heroCarousel"); if (!box || !box._imgMap) return;
|
||||
var rec = box._imgMap[logical]; var img = rec && rec.img; if (!img) return;
|
||||
function go() {
|
||||
sampleImageDominant(img, function (rgb) {
|
||||
if (!rgb) return;
|
||||
var css = heroBgFromRgb(rgb);
|
||||
heroBgCache[key] = css;
|
||||
if (state.heroIndex === logical) apply(css); // 采样是异步的,回来时可能已经翻到别的页了,得核对一下
|
||||
});
|
||||
}
|
||||
if (img.complete && img.naturalWidth) go(); else img.addEventListener("load", go, { once: true });
|
||||
}
|
||||
/* 预热:只算色、存进 heroBgCache,不调 setDynBg —— 提前给"还没翻到"的相邻帧把颜色算好,
|
||||
等真的翻过去时 applyHeroBgColor 直接命中缓存同步生效,不用现场等图片解码+采样。
|
||||
配合 forceLoadHeroFrame 一起在"刚翻到当前帧"那一刻就对左右邻居触发,趁着自动播放的 5.2s
|
||||
间隔(或用户划走前的停留时间)把网络请求和采样都提前做完,真正翻页时观感才是"同步融合"。
|
||||
已经缓存过的帧直接跳过,不重复采样。 */
|
||||
function precomputeHeroBgColor(logical) {
|
||||
var it = state.heroItems && state.heroItems[logical]; if (!it) return;
|
||||
var key = (it.id != null ? it.id : it.title) + "";
|
||||
if (heroBgCache[key]) return;
|
||||
var box = $("heroCarousel"); if (!box || !box._imgMap) return;
|
||||
var rec = box._imgMap[logical]; var img = rec && rec.img; if (!img) return;
|
||||
function go() {
|
||||
sampleImageDominant(img, function (rgb) {
|
||||
if (!rgb) return;
|
||||
var css = heroBgFromRgb(rgb);
|
||||
heroBgCache[key] = css;
|
||||
// 极端时序下采样回来时刚好已经翻到了这一帧,能追上就立刻追上,不用等下一轮翻页
|
||||
if (state.heroIndex === logical) setDynBg(css);
|
||||
});
|
||||
}
|
||||
if (img.complete && img.naturalWidth) go(); else img.addEventListener("load", go, { once: true });
|
||||
}
|
||||
/* 图片层交叉淡入淡出:animate=false 时瞬时切换(初始化/无缝循环归位用),
|
||||
animate=true 时走 .hero-bg 自带的 opacity transition,与信息层的左右滑动同时长、同步完成。
|
||||
先清掉拖拽过程中可能残留的内联 opacity,把控制权交还给 .active 类,避免两边打架。 */
|
||||
function setHeroImageActive(logical, animate) {
|
||||
var box = $("heroCarousel"); if (!box || !box._imgMap) return;
|
||||
Object.keys(box._imgMap).forEach(function (k) {
|
||||
var rec = box._imgMap[k]; if (!rec || !rec.img) return;
|
||||
if (!animate) rec.img.style.transition = "none";
|
||||
rec.img.style.opacity = "";
|
||||
rec.img.classList.toggle("active", Number(k) === logical);
|
||||
if (!animate) { void rec.img.offsetHeight; rec.img.style.transition = ""; }
|
||||
});
|
||||
}
|
||||
function goHeroInstant(logical) {
|
||||
var box = $("heroCarousel"); if (!box || !box._track) return;
|
||||
var n = box._n || 0; var domIdx = n > 1 ? logical + 1 : 0;
|
||||
var track = box._track;
|
||||
track.style.transition = "none"; void track.offsetHeight;
|
||||
track.style.transform = "translateX(-" + (domIdx * 100) + "%)";
|
||||
void track.offsetHeight; track.style.transition = "";
|
||||
setHeroImageActive(logical, false);
|
||||
state.heroIndex = logical; setDots(logical);
|
||||
applyHeroBgColor(logical, false); // 无痕跳回真实帧,颜色也要瞬时对齐,不能跟着渐变一下
|
||||
if (n > 1) { precomputeHeroBgColor((logical + 1) % n); precomputeHeroBgColor((logical - 1 + n) % n); }
|
||||
}
|
||||
/* 翻页:i 是未取模的目标序号(当前 index ± 1)。越过首尾时先动画滑到克隆帧,
|
||||
动画结束后瞬间无痕跳回真实帧,实现「一直往前/往后」的连续循环,不会倒退回开头 */
|
||||
function goHero(i) {
|
||||
var box = $("heroCarousel"); var n = state.heroItems.length; if (!box || !n || !box._track) return;
|
||||
var track = box._track;
|
||||
var newLogical = ((i % n) + n) % n;
|
||||
forceLoadHeroFrame(box, newLogical);
|
||||
forceLoadHeroFrame(box, ((newLogical + 1) % n));
|
||||
forceLoadHeroFrame(box, ((newLogical - 1 + n) % n));
|
||||
var forwardWrap = n > 1 && i === n;
|
||||
var backwardWrap = n > 1 && i === -1;
|
||||
if (forwardWrap) {
|
||||
track.style.transform = "translateX(-" + ((n + 1) * 100) + "%)";
|
||||
onceTransitionEnd(track, function () { goHeroInstant(0); });
|
||||
} else if (backwardWrap) {
|
||||
track.style.transform = "translateX(0%)";
|
||||
onceTransitionEnd(track, function () { goHeroInstant(n - 1); });
|
||||
} else {
|
||||
var domIdx = n > 1 ? newLogical + 1 : 0;
|
||||
track.style.transform = "translateX(-" + (domIdx * 100) + "%)";
|
||||
}
|
||||
setHeroImageActive(newLogical, true);
|
||||
state.heroIndex = newLogical; setDots(newLogical);
|
||||
applyHeroBgColor(newLogical);
|
||||
if (n > 1) { precomputeHeroBgColor((newLogical + 1) % n); precomputeHeroBgColor((newLogical - 1 + n) % n); }
|
||||
startHeroAuto();
|
||||
}
|
||||
function onceTransitionEnd(track, cb) {
|
||||
function handler(e) { if (e.target !== track || (e.propertyName && e.propertyName !== "transform")) return; track.removeEventListener("transitionend", handler); cb(); }
|
||||
track.addEventListener("transitionend", handler);
|
||||
}
|
||||
function startHeroAuto() {
|
||||
if (state.heroTimer) { clearTimeout(state.heroTimer); state.heroTimer = null; }
|
||||
if (!state.heroItems || state.heroItems.length < 2) return;
|
||||
state.heroTimer = setTimeout(function () { goHero(state.heroIndex + 1); }, 5200);
|
||||
}
|
||||
/* 颜色插值用:把 "r,g,b" 字符串解析成数组;两个 rgb 数组按比例 t(0~1) 混合。
|
||||
拖拽跟手变色靠这两个小工具从"起始色"渐变到"目标邻居的色"。 */
|
||||
function parseRgbList(s) {
|
||||
var m = (s || "").split(",").map(function (x) { return parseInt(x, 10); });
|
||||
if (m.length !== 3 || m.some(function (x) { return isNaN(x); })) return null;
|
||||
return m;
|
||||
}
|
||||
function lerpRgb(a, b, t) {
|
||||
return [Math.round(a[0] + (b[0] - a[0]) * t), Math.round(a[1] + (b[1] - a[1]) * t), Math.round(a[2] + (b[2] - a[2]) * t)];
|
||||
}
|
||||
/* 跟手拖拽轮播:手指按住实时跟随移动,松手按位移/速度判定翻页或回弹(含首尾无缝衔接)
|
||||
注意:box(heroCarousel) 是持久节点,每次轮播刷新只会替换内部的 track,不会重建 box 本身;
|
||||
只在首次绑定一次事件,避免每次 loadHero() 都叠加一套监听器(叠加后一次滑动会被触发多次,
|
||||
表现为手动滑动“跳过一两张”,而自动轮播只用一个 timer 所以看不出问题)。 */
|
||||
function bindHeroSwipe(box) {
|
||||
if (box._swipeBound) return;
|
||||
box._swipeBound = true;
|
||||
var sx = 0, sy = 0, curX = 0, w = 1, dragging = false, moved = false, lockedOut = false, lastT = 0, lastX = 0, vx = 0;
|
||||
var dragStartRgb = null; // 按下瞬间的背景色,作为插值起点
|
||||
function widthPx() { return box.getBoundingClientRect().width || 1; }
|
||||
function baseDomPct() { var n = box._n || 0; return (n > 1 ? state.heroIndex + 1 : 0) * 100; }
|
||||
// 拖拽期间图片层交叉渐隐渐显跟手指走:关掉 CSS transition 直接改 opacity,避免和手指位移有延迟感;
|
||||
// 松手后再把 transition 还回去,交给 goHero/goHeroInstant 里的 class 切换来完成"归位"或"继续过渡到目标帧"。
|
||||
function setHeroImgTransition(t) {
|
||||
if (!box._imgMap) return;
|
||||
Object.keys(box._imgMap).forEach(function (k) { var rec = box._imgMap[k]; if (rec && rec.img) rec.img.style.transition = t; });
|
||||
}
|
||||
function dragImageProgress(fromLogical, toLogical, progress) {
|
||||
if (!box._imgMap) return;
|
||||
Object.keys(box._imgMap).forEach(function (k) {
|
||||
var idx = Number(k); var rec = box._imgMap[k]; if (!rec || !rec.img) return;
|
||||
var op = idx === fromLogical ? (1 - progress) : (idx === toLogical ? progress : 0);
|
||||
rec.img.style.opacity = op;
|
||||
});
|
||||
}
|
||||
box.addEventListener("touchstart", function (e) {
|
||||
var t = e.touches[0]; if (!t) return;
|
||||
sx = curX = lastX = t.clientX; sy = t.clientY; w = widthPx();
|
||||
dragging = true; moved = false; lockedOut = false; vx = 0; lastT = Date.now();
|
||||
if (state.heroTimer) { clearTimeout(state.heroTimer); state.heroTimer = null; }
|
||||
dragStartRgb = parseRgbList(getComputedStyle(document.documentElement).getPropertyValue("--dyn-bg-rgb"));
|
||||
// 手指一按下就把左右邻居的颜色提前备好,防止真的划出去时缓存还没算完、插值没有目标色可用
|
||||
var n0 = state.heroItems ? state.heroItems.length : 0;
|
||||
if (n0 > 1) { precomputeHeroBgColor((state.heroIndex + 1) % n0); precomputeHeroBgColor((state.heroIndex - 1 + n0) % n0); }
|
||||
}, { passive: true });
|
||||
box.addEventListener("touchmove", function (e) {
|
||||
if (!dragging || lockedOut) return;
|
||||
var t = e.touches[0]; if (!t) return;
|
||||
var dx = t.clientX - sx, dy = t.clientY - sy;
|
||||
if (!moved) {
|
||||
if (Math.abs(dy) > Math.abs(dx) && Math.abs(dy) > 8) { lockedOut = true; return; }
|
||||
if (Math.abs(dx) > 6) { moved = true; if (box._track) box._track.style.transition = "none"; setHeroImgTransition("none"); }
|
||||
}
|
||||
if (!moved) return;
|
||||
curX = t.clientX;
|
||||
var now = Date.now(); if (now > lastT) { vx = (curX - lastX) / (now - lastT); lastT = now; lastX = curX; }
|
||||
// 只有信息层(徽标/标题/简介)继续按位移左右滑动;图片本身不跟着位移,只用同一个 progress 做交叉渐隐渐显
|
||||
if (box._track) box._track.style.transform = "translateX(calc(-" + baseDomPct() + "% + " + dx + "px))";
|
||||
var n = state.heroItems ? state.heroItems.length : 0;
|
||||
var progress = Math.min(1, Math.abs(dx) / w);
|
||||
var targetLogical = n > 1 ? ((dx < 0 ? state.heroIndex + 1 : state.heroIndex - 1) % n + n) % n : state.heroIndex;
|
||||
if (n > 1) dragImageProgress(state.heroIndex, targetLogical, progress);
|
||||
// 背景色按同一个 progress,从起始色实时插值到"正在滑向的那个邻居"的色,图片、色彩、信息层三者同步走,
|
||||
// 而不是等松手那一刻才跳变到最终颜色
|
||||
if (n > 1 && dragStartRgb) {
|
||||
var targetItem = state.heroItems[targetLogical];
|
||||
var key = targetItem ? (targetItem.id != null ? targetItem.id : targetItem.title) + "" : null;
|
||||
var targetBg = key ? heroBgCache[key] : null;
|
||||
if (targetBg) {
|
||||
var targetRgb = parseRgbList(targetBg.rgbList);
|
||||
if (targetRgb) {
|
||||
var mixed = lerpRgb(dragStartRgb, targetRgb, progress);
|
||||
setDynBg({ css: "rgb(" + mixed.join(",") + ")", rgbList: mixed.join(",") });
|
||||
}
|
||||
}
|
||||
}
|
||||
}, { passive: true });
|
||||
function finish() {
|
||||
if (!dragging) return; dragging = false;
|
||||
if (box._track) box._track.style.transition = "";
|
||||
setHeroImgTransition("");
|
||||
if (!moved) { startHeroAuto(); return; }
|
||||
var dx = curX - sx;
|
||||
var far = Math.abs(dx) > w * 0.16, fast = Math.abs(vx) > 0.45 && Math.abs(dx) > 12;
|
||||
if (far || fast) goHero(state.heroIndex + (dx < 0 ? 1 : -1));
|
||||
else goHero(state.heroIndex);
|
||||
}
|
||||
box.addEventListener("touchend", finish, { passive: true });
|
||||
box.addEventListener("touchcancel", finish, { passive: true });
|
||||
}
|
||||
/* 搜索时隐藏轮播,浏览/历史时显示;TV 端首页固定隐藏轮播(遥控器场景更适合直接看行列表) */
|
||||
function updateHeroVisible() {
|
||||
// 搜索结果页的头部改造:进入搜索结果时给 #catView 挂 .searching(CSS 据此把标题换成搜索框、
|
||||
// 把胶囊移到下一行),并把当前关键词填进搜索框按钮里。离开搜索则摘掉。放在这里是因为本函数
|
||||
// 在每次视图切换(首页/分类/搜索/最近)时都会被调用,能可靠地跟着 state.view 同步。
|
||||
var cv = $("catView");
|
||||
if (cv) {
|
||||
if (state.view === "search") {
|
||||
cv.classList.add("searching");
|
||||
var kwEl = $("searchBarKw"); if (kwEl) kwEl.textContent = state.searchKw || "";
|
||||
} else {
|
||||
cv.classList.remove("searching");
|
||||
}
|
||||
}
|
||||
var wrap = $("heroWrap"); if (!wrap) return;
|
||||
var hide = state.isTv || state.searchMode || state.view !== "home" || (!state.heroLoading && (!state.heroItems || !state.heroItems.length));
|
||||
wrap.hidden = hide;
|
||||
if (hide) { if (state.heroTimer) { clearTimeout(state.heroTimer); state.heroTimer = null; } }
|
||||
else startHeroAuto();
|
||||
}
|
||||
/* 标题->TMDB(media_type,id) 命中缓存:session 内存 + 落盘(fm.cache),
|
||||
同一部片第二次进详情页直接跳过"搜索定位"那一步,只发一次详情请求 */
|
||||
var _tmdbPickCache = {};
|
||||
var _tmdbPickCacheLoaded = false;
|
||||
var _tmdbPickInflight = {}; // cacheKey -> Promise<pick|null>:预热和正式打开详情页如果撞在一起,只发一轮搜索,谁先发谁替后面的人把活干了
|
||||
var _tmdbDetailCache = {}; // "mediaType:id" -> 完整详情 JSON,只留内存(数据量大、命中率主要在"刚预热完就点进去"这种场景,不必落盘)
|
||||
var _tmdbDetailInflight = {}; // "mediaType:id" -> Promise<detail|null>
|
||||
function _tmdbPickKey(name, yr, wantTv) { return (name || "").toLowerCase() + "|" + (yr || "") + "|" + (wantTv ? 1 : 0); }
|
||||
function loadTmdbPickCacheOnce() {
|
||||
if (_tmdbPickCacheLoaded) return Promise.resolve();
|
||||
_tmdbPickCacheLoaded = true;
|
||||
return storeGet("tmdbPickCache").then(function (v) {
|
||||
if (v) { try { _tmdbPickCache = JSON.parse(v) || {}; } catch (e) {} }
|
||||
}, function () {});
|
||||
}
|
||||
function saveTmdbPickCacheDebounced() {
|
||||
clearTimeout(saveTmdbPickCacheDebounced._t);
|
||||
saveTmdbPickCacheDebounced._t = setTimeout(function () {
|
||||
try { storeSet("tmdbPickCache", JSON.stringify(_tmdbPickCache)); } catch (e) {}
|
||||
}, 800);
|
||||
}
|
||||
/* 算出某个条目在 TMDB 侧要用的候选词/年份/影或剧——预热和正式打开详情页必须用同一套推断逻辑,
|
||||
两边算出来的 cacheKey 对不上的话,预热等于白热。 */
|
||||
function tmdbLookupArgs(item) {
|
||||
var yr = String(item.year || yearOf(item.title) || "").slice(0, 4);
|
||||
var wantTv = state.activeCat === "2" || state.activeCat === "4" || /第.?季|剧集|全集|季/.test(item.title || "") || /剧|集|动漫/.test(item.meta || "") || (item.typeName && /剧|综艺|动漫|动画/.test(item.typeName));
|
||||
var names = titleCandidates(item.title);
|
||||
return { names: names, yr: yr, wantTv: wantTv, cacheKey: _tmdbPickKey(names[0] || item.title, yr, wantTv) };
|
||||
}
|
||||
/* 拿 {media_type,id}:落盘缓存 -> 在飞请求 -> 真发搜索,三级短路。
|
||||
预热(按下卡片)和正式打开详情页(loadTmdb)共用这一个函数,谁先触发谁把网络请求发出去,
|
||||
后来的人(不管是预热撞见已打开的详情页,还是详情页撞见还没跑完的预热)都是白捡。 */
|
||||
function tmdbResolvePick(args) {
|
||||
return loadTmdbPickCacheOnce().then(function () {
|
||||
var cached = _tmdbPickCache[args.cacheKey];
|
||||
if (cached) return cached;
|
||||
if (_tmdbPickInflight[args.cacheKey]) return _tmdbPickInflight[args.cacheKey];
|
||||
var p = tmdbSearchBest(args.names, args.yr, args.wantTv).then(function (pick) {
|
||||
delete _tmdbPickInflight[args.cacheKey];
|
||||
if (pick) { _tmdbPickCache[args.cacheKey] = { media_type: pick.media_type, id: pick.id }; saveTmdbPickCacheDebounced(); }
|
||||
return pick || null;
|
||||
}, function () { delete _tmdbPickInflight[args.cacheKey]; return null; });
|
||||
_tmdbPickInflight[args.cacheKey] = p;
|
||||
return p;
|
||||
}, function () { return null; });
|
||||
}
|
||||
/* 拿完整详情 JSON:同样内存缓存 -> 在飞请求 -> 真发请求三级短路 */
|
||||
function tmdbResolveDetail(pick) {
|
||||
if (!pick) return Promise.resolve(null);
|
||||
var key = pick.media_type + ":" + pick.id;
|
||||
if (_tmdbDetailCache[key]) return Promise.resolve(_tmdbDetailCache[key]);
|
||||
if (_tmdbDetailInflight[key]) return _tmdbDetailInflight[key];
|
||||
var p = httpJson(tmdbApi("/" + pick.media_type + "/" + pick.id, {
|
||||
append_to_response: "images,content_ratings,release_dates,credits",
|
||||
include_image_language: "zh,en,null"
|
||||
})).then(function (det) {
|
||||
delete _tmdbDetailInflight[key];
|
||||
_tmdbDetailCache[key] = det;
|
||||
return det;
|
||||
}, function (e) { delete _tmdbDetailInflight[key]; throw e; });
|
||||
_tmdbDetailInflight[key] = p;
|
||||
return p;
|
||||
}
|
||||
function loadTmdb(item) {
|
||||
if (!config.tmdbKey) return;
|
||||
var token = state.panToken;
|
||||
var args = tmdbLookupArgs(item);
|
||||
tmdbResolvePick(args).then(function (pick) {
|
||||
if (!pick || token !== state.panToken) return null;
|
||||
return tmdbResolveDetail(pick).then(function (det) {
|
||||
if (token !== state.panToken) return;
|
||||
applyTmdb(det, pick.media_type);
|
||||
});
|
||||
}
|
||||
|
||||
// ① 有年份:先按「类型+年份」精确搜(能把冷门的当年那部也捞出来)
|
||||
var firstStep;
|
||||
if (yr) {
|
||||
var p = { query: name, include_adult: "false" };
|
||||
p[wantTv ? "first_air_date_year" : "primary_release_year"] = yr;
|
||||
firstStep = httpJson(tmdbApi("/search/" + kind, p)).then(function (data) {
|
||||
var arr = (data && data.results ? data.results : []).map(function (r) { r.media_type = kind; return r; });
|
||||
return arr;
|
||||
}, function () { return []; });
|
||||
} else {
|
||||
firstStep = Promise.resolve([]);
|
||||
}
|
||||
firstStep.then(function (typed) {
|
||||
if (token !== state.panToken) return;
|
||||
if (typed && typed.length) { return proceed(typed); }
|
||||
// ② 退回宽搜 multi(年份仅参与打分,避免因首播/上映年差异而漏)
|
||||
return httpJson(tmdbApi("/search/multi", { query: name, include_adult: "false" })).then(function (d2) {
|
||||
return proceed(d2 && d2.results ? d2.results : []);
|
||||
}).then(function () {}, function () {});
|
||||
}
|
||||
/* 卡片按下就预热:手指刚触到卡片(比 click 早那么几十上百毫秒,且这时候用户点开的意图已经很明确了)
|
||||
就提前把"这是哪部片"+完整详情摸回来,等真正点进详情页时,loadTmdb() 大概率直接读缓存,
|
||||
一次网络往返都不用等——跟 loadResources()/enrichAllSites() 这些本来就没有身份确认这一步的国内源
|
||||
打平了。纯后台预热,不碰任何 UI、不占 state.panToken,就算用户按下后没点进去也没关系,
|
||||
缓存留着,下次刷到同一部片(哪怕换了个位置/换了个源)一样能命中。 */
|
||||
function prewarmTmdb(item) {
|
||||
if (!config.tmdbKey || !item || !item.title) return;
|
||||
var args = tmdbLookupArgs(item);
|
||||
tmdbResolvePick(args).then(function (pick) { return pick ? tmdbResolveDetail(pick) : null; }).then(function () {}, function () {});
|
||||
}
|
||||
/* 绑定"按下"触发:触屏用 touchstart,鼠标用 mousedown,两个都绑是因为老内核/部分设备
|
||||
触屏事件不一定完整触发到 mousedown,二选一漏了就退化成没预热;fired 防一次物理按下触发两次。 */
|
||||
function bindPrewarmTrigger(elm, fn) {
|
||||
var fired = false;
|
||||
function trigger() { if (fired) return; fired = true; fn(); }
|
||||
elm.addEventListener("touchstart", trigger, { passive: true });
|
||||
elm.addEventListener("mousedown", trigger);
|
||||
}
|
||||
/* 是不是"摸屏幕"的设备(手机、平板都算,靠指针精度判断,不用 state.isMobile 那个按宽度分的手机专用标记——
|
||||
平板横屏宽度往往超过 720,会被 state.isMobile 判成"不是手机",但平板一样存在"竖版海报被横屏裁掉一大截"的问题)。
|
||||
鼠标操作的桌面浏览器 pointer 是 fine,不会命中,维持原来"浏览器恒用竖版海报"的设计不受影响。 */
|
||||
function isTouchDevice() {
|
||||
if (state.isMobile) return true;
|
||||
try { return !!(window.matchMedia && matchMedia("(pointer: coarse)").matches); } catch (e) { return false; }
|
||||
}
|
||||
/* 当前视口宽高:优先 visualViewport(WebView 里旋转后它比 window.innerWidth/Height 更早刷新),
|
||||
再退到 window.inner*,最后退到文档元素的 client*。三者在不同壳子里各有失灵的时候,取先拿到的那个。 */
|
||||
function viewportWH() {
|
||||
var vv = window.visualViewport;
|
||||
var w = (vv && vv.width) || window.innerWidth || (document.documentElement && document.documentElement.clientWidth) || 0;
|
||||
var h = (vv && vv.height) || window.innerHeight || (document.documentElement && document.documentElement.clientHeight) || 0;
|
||||
return { w: w, h: h };
|
||||
}
|
||||
/* 大图轮播是否该用横版剧照:横屏用横版剧照,竖屏用竖版海报。
|
||||
之前这里优先信 CSS 媒体查询 (orientation: landscape),量尺寸只在拿不到 mq 结果时才用——
|
||||
但部分设备(尤其是平板)的 WebView 里这条媒体查询本身就不准/不跟手,会一直报"竖屏",
|
||||
结果哪怕实际画面已经是横屏、宽>高,也照样判成竖屏、继续用竖版海报——这才是
|
||||
"平板横屏还加载竖版海报"的根源:判断依据从一开始就没信真正的像素宽高。
|
||||
现在反过来:真实宽高(w>h)才是用户眼睛看到的那个方向,优先信它;
|
||||
只有宽高都拿不到(=0,极少见)时才退回媒体查询兜底。
|
||||
旋转瞬间宽高可能还没刷新的问题,交给下面 scheduleHeroRebuild 的多次重试(0/120/350/800ms)去兜底,
|
||||
而不是靠一个本身可能不准的 mq 硬顶上去。 */
|
||||
function wantBackdropHero() {
|
||||
var v = viewportWH();
|
||||
if (v.w && v.h) return v.w > v.h;
|
||||
try { if (window.matchMedia) return matchMedia("(orientation: landscape)").matches; } catch (e) {}
|
||||
return false;
|
||||
}
|
||||
/* 根据当前 wantBackdropHero() 从已经存好的图集里重新挑一批图,不需要重新请求 TMDB。
|
||||
手机旋转屏幕时调用这个来"热切换"轮播池。 */
|
||||
function buildDetailHeroList() {
|
||||
var pools = state.currentDetail && state.currentDetail.heroPools;
|
||||
if (!pools) return null;
|
||||
var useBackdrop = wantBackdropHero();
|
||||
var primary = useBackdrop ? (pools.backdropPrimary || pools.posterPrimary) : (pools.posterPrimary || pools.backdropPrimary);
|
||||
var poolRaw = useBackdrop ? (pools.backdrops || []) : ((pools.posters && pools.posters.length) ? pools.posters : (pools.backdrops || []));
|
||||
var heroList = [];
|
||||
if (primary) heroList.push(primary);
|
||||
poolRaw.slice().sort(function (a, b) { return (b.vote_average || 0) - (a.vote_average || 0); })
|
||||
.slice(0, 6).forEach(function (p) {
|
||||
var u = pools.tImg(p.file_path);
|
||||
if (u && heroList.indexOf(u) < 0) heroList.push(u);
|
||||
});
|
||||
}, function () {}).then(function () {}, function () {});
|
||||
return heroList.slice(0, 6);
|
||||
}
|
||||
/* 手机/平板横竖屏切换时,如果详情页正开着且已经有 TMDB 图集,就按新的方向重新挑一批图热切换。
|
||||
force=true 时无视"模式没变"的短路(旋转过渡中尺寸/媒体查询可能先后脚到,重试要能真正跑一次)。 */
|
||||
function rebuildDetailHeroForOrientation(force) {
|
||||
if (!state.currentDetail || !state.currentDetail.heroPools) return;
|
||||
var mode = wantBackdropHero() ? "backdrop" : "poster";
|
||||
if (!force && state.dHeroMode === mode) return; // 方向没变(比如只是软键盘弹出导致的 resize),不折腾
|
||||
var list = buildDetailHeroList();
|
||||
if (!list || !list.length) return;
|
||||
var cur = state.dHeroImages || [];
|
||||
if (cur.length === list.length && cur[0] === list[0]) { state.dHeroMode = mode; return; } // 图集实际没变(比如该片只有横版图),不重复淡入
|
||||
state.dHeroMode = mode;
|
||||
softUpdateDetailHeroImages(list, false);
|
||||
}
|
||||
/* 旋转防抖 + 多次重试:WebView 旋转后视口尺寸是分几帧陆续刷新的,只测一次很容易读到旧值。
|
||||
这里在 0 / 120 / 350 / 800ms 各校验一次,方向已经切过来了就重建,已经是目标模式的自然跳过(幂等)。 */
|
||||
var _heroRotTimers = [];
|
||||
function scheduleHeroRebuild() {
|
||||
_heroRotTimers.forEach(function (t) { clearTimeout(t); });
|
||||
_heroRotTimers = [0, 120, 350, 800].map(function (ms) {
|
||||
return setTimeout(function () { rebuildDetailHeroForOrientation(false); }, ms);
|
||||
});
|
||||
}
|
||||
function applyTmdb(det, mtype) {
|
||||
if (!det) return;
|
||||
@@ -1537,9 +7469,9 @@ function applyTmdb(det, mtype) {
|
||||
var logoPath = bestImg(imgs.logos, "zh") || bestImg(imgs.logos, "en") || bestImg(imgs.logos, null) || ((imgs.logos && imgs.logos[0]) ? imgs.logos[0].file_path : "");
|
||||
function tImg(size, path) { return path ? ("https://" + (config.tmdbImg || "image.tmdb.org") + "/t/p/" + size + path) : ""; }
|
||||
var tmdb = {
|
||||
poster: tImg("w780", posterPath),
|
||||
backdrop: tImg("w1280", backdropPath),
|
||||
logo: tImg("w500", logoPath),
|
||||
poster: tImg("original", posterPath),
|
||||
backdrop: tImg("original", backdropPath),
|
||||
logo: tImg("original", logoPath),
|
||||
genres: (det.genres || []).map(function (g) { return g.name; }),
|
||||
year: (det.release_date || det.first_air_date || "").slice(0, 4),
|
||||
seasons: det.number_of_seasons || 0,
|
||||
@@ -1548,24 +7480,166 @@ function applyTmdb(det, mtype) {
|
||||
overview: det.overview || "",
|
||||
cert: pickCert(det, mtype),
|
||||
mtype: mtype,
|
||||
cast: (det.credits && det.credits.cast ? det.credits.cast : []).slice(0, 15).map(function (c) {
|
||||
return { id: c.id, name: c.name || "", role: c.character || "", pic: c.profile_path ? tImg("w185", c.profile_path) : "" };
|
||||
cast: (det.credits && det.credits.cast ? det.credits.cast : []).slice(0, 6).map(function (c) {
|
||||
return { id: c.id, name: c.name || "", origName: c.original_name || "", role: c.character || "", pic: c.profile_path ? tImg("w185", c.profile_path) : "" };
|
||||
})
|
||||
};
|
||||
state.currentDetail.tmdb = tmdb;
|
||||
state.currentDetail.tmdbRawDet = det;
|
||||
state.currentDetail.tmdbRawMtype = mtype;
|
||||
|
||||
// 英雄图:TV 优先横版剧照,电影用竖版海报
|
||||
var useBackdrop = state.isTv && tmdb.backdrop;
|
||||
var heroUrl = useBackdrop ? tmdb.backdrop : (tmdb.poster || tmdb.backdrop);
|
||||
if (heroUrl) setHeroImage(heroUrl, false);
|
||||
// 大海报轮播:TV 恒用横版剧照池;手机/浏览器平时用竖版海报池(跟原来"选一张最佳图"的取向一致),
|
||||
// 但手机横屏时改用横版剧照池,避免竖版海报被裁掉一大截、显示不全(见 wantBackdropHero)。
|
||||
// 主图(原来单图逻辑选出的最佳那张)排轮播第一位,再从对应图集里按评分挑几张凑成候选池,自动去重。
|
||||
// 这里把原始图集(posters/backdrops)存到 currentDetail.heroPools 里,手机旋转屏幕时不用重新请求
|
||||
// TMDB,直接用 buildDetailHeroList() 就能按新方向重新挑图、热切换。
|
||||
state.currentDetail.heroPools = {
|
||||
posters: imgs.posters || [], backdrops: imgs.backdrops || [],
|
||||
posterPrimary: tmdb.poster, backdropPrimary: tmdb.backdrop,
|
||||
tImg: function (path) { return tImg("original", path); } // 横版剧照池 / 竖版海报池统一用 original,选最高清的图
|
||||
};
|
||||
var heroList = buildDetailHeroList();
|
||||
state.dHeroMode = wantBackdropHero() ? "backdrop" : "poster"; // 记下这批图是按哪个方向挑的,供旋转时判断要不要换池
|
||||
if (heroList && heroList.length) softUpdateDetailHeroImages(heroList, false);
|
||||
// 名称:有徽标用徽标图,否则用文字标题(都在播放键上方的正文区,始终完整)
|
||||
if (tmdb.logo) { var lg = document.createElement("img"); lg.alt = ""; setTmdbImg(lg, tmdb.logo); $("edLogo").replaceChildren(lg); showTitle(null); }
|
||||
else { $("edLogo").replaceChildren(); showTitle(state.currentDetail.title || state.currentDetail.rawTitle); }
|
||||
|
||||
renderChipsMeta(tmdb, state.currentItem);
|
||||
if (tmdb.overview) { $("detailOverview").textContent = tmdb.overview; state.currentDetail.overviewSet = true; }
|
||||
if (tmdb.overview && !state.currentDetail.overviewFromSite) { $("detailOverview").textContent = tmdb.overview; state.currentDetail.overviewSet = true; }
|
||||
if (state.currentDetail.poster === "" && tmdb.poster) state.currentDetail.poster = tmdb.poster;
|
||||
renderCast(tmdb.cast);
|
||||
// 「剧集」单集卡:只有剧/综艺/动漫(TMDB mtype === "tv")才有分季,电影没有,直接清空该区块;
|
||||
// 设置里关掉「剧集单集列表」的话同样直接清空,不构建
|
||||
if (config.showEpisodes !== false && mtype === "tv" && det.id && det.seasons && det.seasons.length) initEpisodesSection(det.id, det.seasons);
|
||||
else if ($("edEpisodes")) $("edEpisodes").replaceChildren();
|
||||
}
|
||||
/* 初始化「剧集」区:过滤掉没有集数的季(比如空的特别篇),默认选中第 1 季(没有就选列表第一个),
|
||||
拉这一季的分集列表。季选择器只在有 2 季及以上时才显示,跟网盘资源的画质 tab 一个逻辑。 */
|
||||
function initEpisodesSection(tvId, seasonsRaw) {
|
||||
var box = $("edEpisodes"); if (!box) return;
|
||||
var seasons = (seasonsRaw || []).filter(function (s) { return (s.episode_count || 0) > 0; });
|
||||
if (!seasons.length) { box.replaceChildren(); return; }
|
||||
state.currentDetail.tvId = tvId;
|
||||
state.currentDetail.tvSeasons = seasons;
|
||||
var def = seasons.filter(function (s) { return s.season_number === 1; })[0] || seasons[0];
|
||||
renderEpisodesShell(seasons, def.season_number);
|
||||
loadSeasonEpisodes(tvId, def.season_number);
|
||||
}
|
||||
/* 「剧集」区外壳:标题 + 分季 tab(复用 ed-qtab 同款交互),下面接一个占位的 ep-rail 等分集数据回来 */
|
||||
function renderEpisodesShell(seasons, activeNum) {
|
||||
var box = $("edEpisodes"); if (!box) return;
|
||||
box.replaceChildren();
|
||||
var head = document.createElement("div"); head.className = "ed-episodes-head";
|
||||
head.appendChild(el("span", "ed-works-head", "剧集"));
|
||||
if (seasons.length > 1) {
|
||||
var tabs = document.createElement("div"); tabs.className = "ep-season-tabs";
|
||||
seasons.forEach(function (s) {
|
||||
var b = document.createElement("button");
|
||||
b.type = "button";
|
||||
b.className = "ep-season-chip focusable" + (s.season_number === activeNum ? " active" : "");
|
||||
b.textContent = s.season_number === 0 ? "特别篇" : ("第 " + s.season_number + " 季");
|
||||
b.onclick = function () {
|
||||
if (state.currentDetail.epSeason === s.season_number) return;
|
||||
var kids = tabs.children; for (var i = 0; i < kids.length; i++) kids[i].classList.remove("active");
|
||||
b.classList.add("active");
|
||||
loadSeasonEpisodes(state.currentDetail.tvId, s.season_number);
|
||||
};
|
||||
tabs.appendChild(b);
|
||||
});
|
||||
head.appendChild(tabs);
|
||||
// 去掉 flex-end 右对齐后默认停在最左边,这里手动把当前季滚到可见范围内(不用整个居中,
|
||||
// 露出来就行,紧挨着的前后一两个也顺带露出来,让人知道还能往哪边滑)
|
||||
var activeChip = tabs.querySelector(".ep-season-chip.active");
|
||||
if (activeChip) {
|
||||
requestAnimationFrame(function () {
|
||||
var left = activeChip.offsetLeft - (tabs.clientWidth - activeChip.offsetWidth) / 2;
|
||||
tabs.scrollLeft = Math.max(0, left);
|
||||
});
|
||||
}
|
||||
}
|
||||
box.appendChild(head);
|
||||
var rail = document.createElement("div"); rail.className = "ep-rail"; rail.id = "epRail";
|
||||
rail.appendChild(epRailSkeleton());
|
||||
box.appendChild(rail);
|
||||
}
|
||||
/* 拉某一季的分集(TMDB /tv/{id}/season/{n}),带 token 防止切季快、旧请求后到把新一季的结果覆盖掉 */
|
||||
function loadSeasonEpisodes(tvId, seasonNum) {
|
||||
state.currentDetail.epSeason = seasonNum;
|
||||
state.epToken = (state.epToken || 0) + 1;
|
||||
var myToken = state.epToken;
|
||||
var rail = $("epRail"); if (rail) rail.replaceChildren(epRailSkeleton());
|
||||
httpJson(tmdbApi("/tv/" + tvId + "/season/" + seasonNum, {})).then(function (d) {
|
||||
if (myToken !== state.epToken || !state.currentDetail) return;
|
||||
paintEpisodeRail((d && d.episodes) || []);
|
||||
}, function () {
|
||||
if (myToken !== state.epToken) return;
|
||||
var r = $("epRail"); if (r) r.replaceChildren(el("div", "ep-loading", "剧集加载失败,可稍后重试"));
|
||||
});
|
||||
}
|
||||
/* 画单集卡:剧照(stills) + 集号 + 标题。TMDB 没图时给个占位。点一下滚到下面「网盘资源」,
|
||||
提示按第几集去找对应资源——单集卡本身只是 TMDB 的剧集信息,跟哪条网盘资源对应不了结构化关系。 */
|
||||
function paintEpisodeRail(episodes) {
|
||||
var rail = $("epRail"); if (!rail) return;
|
||||
if (!episodes || !episodes.length) { rail.replaceChildren(el("div", "ep-loading", "暂无剧集信息")); return; }
|
||||
var frag = document.createDocumentFragment();
|
||||
episodes.forEach(function (e) {
|
||||
var card = document.createElement("div"); card.className = "ep-tcard";
|
||||
var thumb = document.createElement("div"); thumb.className = "ep-tcard-img";
|
||||
if (e.still_path) {
|
||||
var img = document.createElement("img"); img.loading = "lazy"; img.alt = e.name || "";
|
||||
setTmdbImg(img, "https://" + (config.tmdbImg || "image.tmdb.org") + "/t/p/w300" + e.still_path);
|
||||
thumb.appendChild(img);
|
||||
} else {
|
||||
thumb.classList.add("ph");
|
||||
}
|
||||
var num = document.createElement("span"); num.className = "ep-tcard-num"; num.textContent = "第 " + e.episode_number + " 集";
|
||||
thumb.appendChild(num);
|
||||
card.appendChild(thumb);
|
||||
card.appendChild(el("div", "ep-tcard-title", e.name || ("第" + e.episode_number + "集")));
|
||||
card.onclick = function () { jumpToEpisodeResource(e.episode_number); };
|
||||
frag.appendChild(card);
|
||||
});
|
||||
rail.replaceChildren(frag);
|
||||
}
|
||||
/* 在「115」类别的网盘资源里,按标题里的 SxxExx(比如"雀骨 (2026) S01E09 ...")匹配当前季 + 单集卡点的这一集。
|
||||
只在 115 里找,是因为这种一集一条、标题里带 SxxExx 的命名规律目前只有 115 这类资源稳定带;
|
||||
数字前面允许有 0(S1/S01 都认,E9/E09 都认),后面用负向前瞻卡住,避免把第 9 集错认成第 91 集这种。 */
|
||||
function findEpisodeResource(seasonNum, epNum) {
|
||||
if (!state.panItems || !state.panItems.length) return null;
|
||||
var re = new RegExp("s0*" + seasonNum + "e0*" + epNum + "(?!\\d)", "i");
|
||||
for (var i = 0; i < state.panItems.length; i++) {
|
||||
var it = state.panItems[i];
|
||||
if ((it.quality || "") !== "115") continue;
|
||||
if (it.state === "bad") continue;
|
||||
if (isPanTypeBlocked(it.quality || "其他")) continue;
|
||||
var hay = (it.title || "") + " " + (it.groupName || "");
|
||||
if (re.test(hay)) return { item: it, index: i };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
/* 单集卡点击:优先在 115 网盘资源里按 SxxExx 精确匹配到这一集,匹配到就切到 115 这个 tab、
|
||||
滚过去高亮一下、直接播放(跟下面「枫叶」在线集数网格点一下就播是同一个交互逻辑)。
|
||||
115 里没有对应这集的资源时退回旧行为:滚到网盘资源区提示一下,不瞎猜。 */
|
||||
function jumpToEpisodeResource(num) {
|
||||
var seasonNum = (state.currentDetail && state.currentDetail.epSeason) || 1;
|
||||
var hit = findEpisodeResource(seasonNum, num);
|
||||
var panHead = document.querySelector("#detailSheet .pan-head");
|
||||
if (!hit) {
|
||||
if (panHead && panHead.scrollIntoView) panHead.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
toast("115 网盘里没找到第 " + num + " 集,可换个网盘类别看看");
|
||||
return;
|
||||
}
|
||||
if (state.qFilter !== "115" && state.qGroups && state.qGroups.indexOf("115") >= 0) {
|
||||
state.qFilter = "115";
|
||||
state.qFilterManual = true; // 点单集卡也是用户主动选择,同样不许被之后的异步同步冲掉
|
||||
renderQTabs();
|
||||
paintResources();
|
||||
}
|
||||
if (panHead && panHead.scrollIntoView) panHead.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
var row = document.querySelector('#panList .pan-item[data-pan-index="' + hit.index + '"]');
|
||||
if (row) { row.classList.add("pan-hit"); setTimeout(function () { row.classList.remove("pan-hit"); }, 1600); }
|
||||
playResource(hit.item);
|
||||
}
|
||||
function renderCast(cast) {
|
||||
var box = $("edCast"); if (!box) return;
|
||||
@@ -1574,14 +7648,18 @@ function renderCast(cast) {
|
||||
var frag = document.createDocumentFragment();
|
||||
var row = document.createElement("div"); row.className = "ed-cast-row";
|
||||
cast.forEach(function (c) {
|
||||
var cell = document.createElement("div"); cell.className = "cast-cell";
|
||||
var av = document.createElement("div"); av.className = "cast-av" + (c.id ? " tappable" : "");
|
||||
var cell = document.createElement("div"); cell.className = "cast-cell" + (c.id ? " tappable" : "");
|
||||
var av = document.createElement("div"); av.className = "cast-av";
|
||||
if (c.pic) { var im = document.createElement("img"); im.alt = c.name; im.loading = "lazy"; setTmdbImg(im, c.pic); av.appendChild(im); }
|
||||
else { av.className += " ph"; av.textContent = (c.name || "?").slice(0, 1); }
|
||||
if (c.id) { av.setAttribute("data-actor-id", String(c.id)); av.onclick = function () { toggleActorWorks(c, cell); }; }
|
||||
if (c.id) { cell.setAttribute("data-actor-id", String(c.id)); cell.onclick = function () { toggleActorWorks(c, cell); }; }
|
||||
cell.appendChild(av);
|
||||
var nm = document.createElement("div"); nm.className = "cast-name"; nm.textContent = c.name; cell.appendChild(nm);
|
||||
if (c.role) { var rl = document.createElement("div"); rl.className = "cast-role"; rl.textContent = c.role; cell.appendChild(rl); }
|
||||
var info = document.createElement("div"); info.className = "cast-info";
|
||||
var nm = document.createElement("div"); nm.className = "cast-name"; nm.textContent = c.name; info.appendChild(nm);
|
||||
// 原名只在跟中文名不一样时才加一行,避免西方演员两行重复显示同一个名字
|
||||
if (c.origName && c.origName !== c.name) { var on = document.createElement("div"); on.className = "cast-orig"; on.textContent = c.origName; info.appendChild(on); }
|
||||
if (c.role) { var rl = document.createElement("div"); rl.className = "cast-role"; rl.textContent = c.role; info.appendChild(rl); }
|
||||
cell.appendChild(info);
|
||||
row.appendChild(cell);
|
||||
});
|
||||
frag.appendChild(row);
|
||||
@@ -1592,8 +7670,8 @@ function toggleActorWorks(actor, cell) {
|
||||
var box = $("edActorWorks"); if (!box) return;
|
||||
var row = $("edCast");
|
||||
function markSel(on) {
|
||||
var avs = row.querySelectorAll(".cast-av"); for (var i = 0; i < avs.length; i++) avs[i].classList.remove("sel");
|
||||
if (on && cell) { var a = cell.querySelector(".cast-av"); if (a) a.classList.add("sel"); }
|
||||
var cells = row.querySelectorAll(".cast-cell"); for (var i = 0; i < cells.length; i++) cells[i].classList.remove("sel");
|
||||
if (on && cell) cell.classList.add("sel");
|
||||
}
|
||||
if (state.expandedActorId === actor.id) { state.expandedActorId = 0; box.replaceChildren(); markSel(false); return; }
|
||||
state.expandedActorId = actor.id;
|
||||
@@ -1603,7 +7681,7 @@ function toggleActorWorks(actor, cell) {
|
||||
var reqId = actor.id;
|
||||
httpJson(tmdbApi("/person/" + actor.id + "/combined_credits", {})).then(function (d) {
|
||||
if (state.expandedActorId !== reqId) return;
|
||||
var arr = (d && d.cast ? d.cast : []).filter(function (x) { return x.poster_path && (x.title || x.name); });
|
||||
var arr = (d && d.cast ? d.cast : []).filter(function (x) { return (x.media_type === "movie" || x.media_type === "tv") && x.poster_path && (x.title || x.name); });
|
||||
var seen = {}, works = [];
|
||||
arr.forEach(function (x) { if (seen[x.id]) return; seen[x.id] = 1; works.push(x); });
|
||||
works.sort(function (a, b) { return (b.popularity || 0) - (a.popularity || 0); });
|
||||
@@ -1620,7 +7698,7 @@ function toggleActorWorks(actor, cell) {
|
||||
var im = document.createElement("img"); im.alt = title; im.loading = "lazy"; setTmdbImg(im, "https://" + (config.tmdbImg || "image.tmdb.org") + "/t/p/w185" + x.poster_path); po.appendChild(im);
|
||||
cardEl.appendChild(po);
|
||||
var t = document.createElement("div"); t.className = "work-title"; t.textContent = title + (yr ? " (" + yr + ")" : ""); cardEl.appendChild(t);
|
||||
cardEl.onclick = function () { openWorkDetail(title, yr); };
|
||||
cardEl.onclick = function () { openWorkDetail(x, title, yr); };
|
||||
wr.appendChild(cardEl);
|
||||
});
|
||||
frag.appendChild(wr);
|
||||
@@ -1630,14 +7708,35 @@ function toggleActorWorks(actor, cell) {
|
||||
box.replaceChildren(el("div", "ed-works-head", "相关作品加载失败"));
|
||||
});
|
||||
}
|
||||
/* 点相关作品 -> 在盘链查该片名,选最佳匹配直接进详情 */
|
||||
function openWorkDetail(title, year) {
|
||||
/* 点相关作品 -> 先在盘链查该片名,命中则直接打开真实资源详情;
|
||||
盘链未收录时不再只是toast提示,而是拿 combined_credits 里自带的 TMDB id/media_type
|
||||
直接兜底进详情页——详情页里 loadTmdb 走 TMDB 元数据、loadResources+runPanSou 走盘搜兜底,
|
||||
即使最终没有网盘资源也统一能看到 TMDB 简介/海报/演员表等信息,不至于点了没反应。 */
|
||||
function openWorkDetail(work, title, year) {
|
||||
var t = (title || "").trim(); if (!t) return;
|
||||
var mediaType = (work && work.media_type === "movie") ? "movie" : "tv";
|
||||
var typeName = mediaType === "movie" ? "电影" : "剧集";
|
||||
var fallbackPoster = (work && work.poster_path)
|
||||
? ("https://" + (config.tmdbImg || "image.tmdb.org") + "/t/p/w1280" + work.poster_path)
|
||||
: "";
|
||||
function openTmdbFallback() {
|
||||
if (!work || !work.id) { toast("盘链未收录《" + t + "》"); return; }
|
||||
openDetail({
|
||||
id: String(work.id) + "__tmdb",
|
||||
title: t,
|
||||
poster: fallbackPoster,
|
||||
meta: [year, typeName].filter(function (x) { return x; }).join(" / "),
|
||||
status: "",
|
||||
year: year,
|
||||
typeName: typeName,
|
||||
area: ""
|
||||
});
|
||||
}
|
||||
toast("查找《" + t + "》…");
|
||||
apiGet(apiUrl("get_videos.php") + "?wd=" + encodeURIComponent(t) + "&pg=1&_t=" + Date.now(), "").then(function (d) {
|
||||
var arr = (d && (d.list || d.data)) || [];
|
||||
var list = arr.map(mapVod);
|
||||
if (!list.length) { toast("盘链未收录《" + t + "》"); return; }
|
||||
if (!list.length) { openTmdbFallback(); return; }
|
||||
var y = String(year || "");
|
||||
function score(it) {
|
||||
var s = 0;
|
||||
@@ -1647,7 +7746,7 @@ function openWorkDetail(title, year) {
|
||||
}
|
||||
var best = list.slice().sort(function (a, b) { return score(b) - score(a); })[0];
|
||||
openDetail(best);
|
||||
}, function () { toast("查找失败,请重试"); });
|
||||
}, function () { openTmdbFallback(); });
|
||||
}
|
||||
/* 兼容旧调用:回首页搜索 */
|
||||
function openWorkSearch(title) {
|
||||
@@ -1709,30 +7808,176 @@ function showTitle(text) {
|
||||
if (text == null || text === "") { el.classList.add("hidden"); el.textContent = ""; }
|
||||
else { el.classList.remove("hidden"); el.textContent = text; }
|
||||
}
|
||||
/* TMDB 图片有多个可能域名(images.tmdb.org / image.tmdb.org / api.tmdb.org),网络环境不同哪个通不一定;
|
||||
这里统一给出"另一个域名",供 setTmdbImg 的 onerror 兜底、以及详情页海报升级前的预加载探测共用。
|
||||
images.tmdb.org 与 image.tmdb.org 在国内免代理可用(参考站实测),优先互切这两个。 */
|
||||
function tmdbAltUrl(u) {
|
||||
if (!u || u.indexOf("tmdb.org") < 0) return "";
|
||||
if (u.indexOf("images.tmdb.org") >= 0) return u.replace("images.tmdb.org", "image.tmdb.org");
|
||||
if (u.indexOf("image.tmdb.org") >= 0) return u.replace("image.tmdb.org", "images.tmdb.org");
|
||||
if (u.indexOf("api.tmdb.org") >= 0) return u.replace("api.tmdb.org", "images.tmdb.org");
|
||||
return "";
|
||||
}
|
||||
function setTmdbImg(img, url) {
|
||||
img._orig = url; img._swapped = false;
|
||||
img.onerror = function () {
|
||||
var u = img._orig || "";
|
||||
if (img._swapped || u.indexOf("tmdb.org") < 0) { img.style.display = "none"; return; }
|
||||
var alt = img._swapped ? "" : tmdbAltUrl(img._orig || "");
|
||||
if (!alt) { img.style.display = "none"; return; }
|
||||
img._swapped = true;
|
||||
var nu = u.indexOf("api.tmdb.org") >= 0 ? u.replace("api.tmdb.org", "image.tmdb.org") : u.replace("image.tmdb.org", "api.tmdb.org");
|
||||
img._orig = nu; img.style.display = ""; img.src = imgSrc(nu);
|
||||
img._orig = alt; img.style.display = ""; img.src = imgSrc(alt);
|
||||
};
|
||||
img.style.display = ""; img.src = imgSrc(url);
|
||||
img.style.display = "";
|
||||
// 4K指南 / 七味 站的图有防盗链,必须带各自的 Referer,不能用盘链那套 no-referrer
|
||||
img.src = isZn4kUrl(url) ? zn4kImgSrc(url) : (isQwPoster(url) ? qwImgSrc(url) : imgSrc(url));
|
||||
}
|
||||
function setHeroImage(url, contain) {
|
||||
var hero = $("edHero"), img = $("edHeroImg");
|
||||
if (url) setTmdbImg(img, url);
|
||||
else { img.removeAttribute("src"); img.style.display = "none"; }
|
||||
/* 详情页海报 → 背景 自动取色:跟首页轮播用的是同一套采样算法(sampleImageDominant /
|
||||
heroBgFromRgb),但用独立的一套变量(--ed-bg / --ed-bg-rgb)和独立缓存(edBgCache),
|
||||
不跟首页轮播的 --dyn-bg 混在一起 —— 两边是各自独立的轮播/状态,混用一个变量的话,
|
||||
首页轮播还在后台跑定时器时会互相覆盖对方算出来的颜色。缓存 key 直接用图片 url(详情页
|
||||
这里手头只有 url 列表,没有条目 id),同一张图翻回来不用重复采样。 */
|
||||
var edBgCache = {};
|
||||
function setDetailDynBg(bg) {
|
||||
try {
|
||||
document.documentElement.style.setProperty("--ed-bg", bg.css);
|
||||
document.documentElement.style.setProperty("--ed-bg-rgb", bg.rgbList);
|
||||
} catch (e) {}
|
||||
}
|
||||
/* 立刻应用:img 已经在 DOM 里(可能还没加载完,会自动等 load)。cacheKey 命中缓存就同步生效,
|
||||
没命中就现场采样,采样是异步的,回来后不再校验"是不是还是这张"——详情页海报翻页间隔够长
|
||||
(5.2s 或用户停留时长),实际不会出现算完发现已经翻走好几张的情况;真出现了也只是这一次
|
||||
背景没跟手,下一次自动切换会自然纠正,不会一直错下去。 */
|
||||
function applyDetailBgColor(img, cacheKey) {
|
||||
if (!img) return;
|
||||
if (cacheKey && edBgCache[cacheKey]) { setDetailDynBg(edBgCache[cacheKey]); return; }
|
||||
function go() {
|
||||
sampleImageDominant(img, function (rgb) {
|
||||
if (!rgb) return;
|
||||
var css = heroBgFromRgb(rgb);
|
||||
if (cacheKey) edBgCache[cacheKey] = css;
|
||||
setDetailDynBg(css);
|
||||
});
|
||||
}
|
||||
if (img.complete && img.naturalWidth) go(); else img.addEventListener("load", go, { once: true });
|
||||
}
|
||||
/* 预热:只算色存缓存,不应用——用来提前给"轮播池里还没轮到"的其余几张图把颜色算好,
|
||||
等 startDetailHeroRotate 真的切过去时直接命中缓存。 */
|
||||
function precomputeDetailBgColor(img, cacheKey) {
|
||||
if (!img || !cacheKey || edBgCache[cacheKey]) return;
|
||||
function go() {
|
||||
sampleImageDominant(img, function (rgb) {
|
||||
if (!rgb) return;
|
||||
edBgCache[cacheKey] = heroBgFromRgb(rgb);
|
||||
});
|
||||
}
|
||||
if (img.complete && img.naturalWidth) go(); else img.addEventListener("load", go, { once: true });
|
||||
}
|
||||
/* 详情页“大海报”自动轮播:dHero* 系列 state 字段专属这个轮播,跟首页大轮播(heroXxx)是两套互不影响的东西。
|
||||
多张图叠放在 #edHeroImgs 里,同一时刻只有 .active 那张 opacity:1,定时切换 active 类实现自动淡入淡出。 */
|
||||
function stopDetailHeroRotate() {
|
||||
if (state.dHeroTimer) { clearInterval(state.dHeroTimer); state.dHeroTimer = null; }
|
||||
}
|
||||
function startDetailHeroRotate() {
|
||||
stopDetailHeroRotate();
|
||||
if ((state.dHeroImages || []).length < 2) return;
|
||||
state.dHeroTimer = setInterval(function () {
|
||||
var box = $("edHeroImgs"); if (!box) return;
|
||||
var imgs = box.children; if (imgs.length < 2) return;
|
||||
var cur = state.dHeroIdx || 0;
|
||||
var next = (cur + 1) % imgs.length;
|
||||
imgs[cur].classList.remove("active", "first-in");
|
||||
imgs[next].classList.add("active");
|
||||
state.dHeroIdx = next;
|
||||
applyDetailBgColor(imgs[next], state.dHeroImages && state.dHeroImages[next]);
|
||||
// 顺手预热再下一张,跟首页轮播预热左右邻居是同一个道理
|
||||
var after = (next + 1) % imgs.length;
|
||||
if (imgs[after]) precomputeDetailBgColor(imgs[after], state.dHeroImages && state.dHeroImages[after]);
|
||||
}, 5200);
|
||||
}
|
||||
/* 硬切换:打开"另一个条目"详情页时用,立刻显示新条目第一张图(入场缩放动画),
|
||||
不能让上一个条目的图残留哪怕一瞬间,所以直接清空重建整套图层 */
|
||||
function setDetailHeroImages(urls, contain) {
|
||||
stopDetailHeroRotate();
|
||||
var hero = $("edHero"), box = $("edHeroImgs");
|
||||
if (!box) return;
|
||||
if (contain) hero.classList.add("contain"); else hero.classList.remove("contain");
|
||||
var list = (urls || []).filter(function (u) { return u; });
|
||||
state.dHeroImages = list; state.dHeroIdx = 0;
|
||||
box.replaceChildren();
|
||||
if (!list.length) { box.appendChild(el("div", "ed-hero-skel")); return; }
|
||||
list.forEach(function (u, i) {
|
||||
var img = document.createElement("img");
|
||||
img.alt = ""; img.className = "ed-hero-img" + (i === 0 ? " active first-in" : "");
|
||||
img.loading = i === 0 ? "eager" : "lazy"; img.setAttribute("decoding", "async");
|
||||
img.crossOrigin = "anonymous"; // 取色要读像素,跟首页轮播海报一个道理,不加这个跨域图会让 canvas 变"脏"
|
||||
setTmdbImg(img, u);
|
||||
box.appendChild(img);
|
||||
if (i === 0) applyDetailBgColor(img, u); else precomputeDetailBgColor(img, u);
|
||||
});
|
||||
startDetailHeroRotate();
|
||||
}
|
||||
/* 详情页滚动 → 固定海报渐暗/轻微模糊(几何不变,避免播放键白线闪烁) */
|
||||
/* 软升级:同一条目内"站内海报 → TMDB 多图轮播池"到手后调用。
|
||||
先把新的第一张图预加载完整,再和旧图同时叠一帧做一次 opacity 交叉淡出淡入,
|
||||
过渡完再把旧图(占位用的站内海报)移除,只留下完整的轮播池继续自动播放。
|
||||
注意:pre.onload 是异步的,图片下载这段时间里用户完全可能已经手动切到下一个条目——
|
||||
#edHeroImgs 是所有详情页共用的同一个容器,调用方(loadTmdb/applyTmdb)只在“发起请求那一刻”
|
||||
校验过 panToken,并不能保证图片下载完成时条目没变。这里把发起时的 panToken 记下来,
|
||||
真正要写 DOM 的这一刻(apply)必须重新校验一次,代次不对就直接丢弃,绝不能把上一个条目
|
||||
的图插进已经属于新条目的容器里——这才是“切下一部残留上一部海报”的根源。 */
|
||||
function softUpdateDetailHeroImages(urls, contain) {
|
||||
var hero = $("edHero"), box = $("edHeroImgs");
|
||||
if (!box) return;
|
||||
var list = (urls || []).filter(function (u) { return u; });
|
||||
if (!list.length) return;
|
||||
var myToken = state.panToken;
|
||||
if (contain) hero.classList.add("contain"); else hero.classList.remove("contain");
|
||||
function apply(firstUrl) {
|
||||
if (myToken !== state.panToken) return; // 详情页已切换到别的条目,这批图作废
|
||||
var oldImgs = Array.prototype.slice.call(box.children);
|
||||
var newImgs = list.map(function (u, i) {
|
||||
var img = document.createElement("img");
|
||||
img.alt = ""; img.className = "ed-hero-img";
|
||||
img.crossOrigin = "anonymous"; // 取色要读像素,跟首页轮播海报一个道理,不加这个跨域图会让 canvas 变"脏"
|
||||
setTmdbImg(img, i === 0 ? firstUrl : u); // 第一张已经在下面验证过真的能加载出来了,直接用验证通过的那个域名;其余几张各自走 setTmdbImg 自带的域名兜底
|
||||
box.appendChild(img);
|
||||
return img;
|
||||
});
|
||||
void box.offsetHeight; // 强制回流:让新插入的 img 先以 opacity:0 被浏览器"看见"一次,紧接着切 active 类才能真正触发过渡
|
||||
oldImgs.forEach(function (im) { im.classList.remove("active", "first-in"); });
|
||||
newImgs[0].classList.add("active");
|
||||
stopDetailHeroRotate();
|
||||
state.dHeroImages = list; state.dHeroIdx = 0;
|
||||
applyDetailBgColor(newImgs[0], list[0]);
|
||||
newImgs.slice(1).forEach(function (im, idx) { precomputeDetailBgColor(im, list[idx + 1]); });
|
||||
setTimeout(function () {
|
||||
oldImgs.forEach(function (im) { if (im.parentNode) im.parentNode.removeChild(im); });
|
||||
startDetailHeroRotate();
|
||||
}, 1150);
|
||||
}
|
||||
// 先把新的第一张图完整预加载好、真正确认能显示出来了,再做交叉淡入淡出;
|
||||
// 加载失败先按域名兜底重试一次,两个域名都失败就放弃这次升级、继续显示原来那张(哪怕模糊
|
||||
// 但至少能看清)的旧图——不能像直接 apply() 那样,图还没影子就把旧图撤了,露出黑屏。
|
||||
var applied = false;
|
||||
var pre = new Image();
|
||||
var triedAlt = false;
|
||||
pre.onload = function () { if (applied) return; applied = true; apply(pre.src); };
|
||||
pre.onerror = function () {
|
||||
if (triedAlt) return; // 两个域名都失败:放弃这次升级,旧图原样保留
|
||||
var alt = tmdbAltUrl(list[0]);
|
||||
if (!alt) return;
|
||||
triedAlt = true;
|
||||
pre.src = imgSrc(alt);
|
||||
};
|
||||
pre.src = imgSrc(list[0]);
|
||||
if (pre.complete && pre.naturalWidth) { applied = true; apply(pre.src); }
|
||||
}
|
||||
/* 详情页滚动 → 固定海报渐暗/轻微模糊(几何不变,避免播放键白线闪烁);
|
||||
filter 加在整个图层容器上,同一时刻只有 .active 那张可见,效果跟只加在单张图上一致 */
|
||||
(function bindHeroScrollDim() {
|
||||
var sheet = $("detailSheet"), heroImg = $("edHeroImg"), dimEnd = 0, raf = 0;
|
||||
var sheet = $("detailSheet"), heroImgs = $("edHeroImgs"), dimEnd = 0, raf = 0;
|
||||
function measure() { var sp = sheet ? sheet.querySelector(".ed-hero-spacer") : null; var h = (sp && sp.offsetHeight) || (window.innerHeight * 0.68); dimEnd = Math.max(1, h * 0.7); }
|
||||
function apply() { raf = 0; if (!dimEnd) measure(); var t = Math.min(1, Math.max(0, sheet.scrollTop / dimEnd)); heroImg.style.filter = "brightness(" + (1 - t * 0.55).toFixed(3) + ") blur(" + (t * 5).toFixed(1) + "px)"; }
|
||||
if (sheet && heroImg) sheet.addEventListener("scroll", function () { if (raf) return; raf = requestAnimationFrame(apply); }, { passive: true });
|
||||
window.resetHeroScrollDim = function () { dimEnd = 0; if (heroImg) heroImg.style.filter = ""; };
|
||||
function apply() { raf = 0; if (!dimEnd) measure(); var t = Math.min(1, Math.max(0, sheet.scrollTop / dimEnd)); heroImgs.style.filter = "brightness(" + (1 - t * 0.55).toFixed(3) + ") blur(" + (t * 5).toFixed(1) + "px)"; }
|
||||
if (sheet && heroImgs) sheet.addEventListener("scroll", function () { if (raf) return; raf = requestAnimationFrame(apply); }, { passive: true });
|
||||
window.resetHeroScrollDim = function () { dimEnd = 0; if (heroImgs) heroImgs.style.filter = ""; };
|
||||
})();
|
||||
function nativeChromeVisible() {
|
||||
var m = "";
|
||||
@@ -1747,20 +7992,28 @@ function applyChromeMode() {
|
||||
syncSafeAreaClass();
|
||||
if (window.fm && fm.ui && fm.ui.setChrome) fm.ui.setChrome({ mode: config.homeFull ? "edge" : "normal", statusBarStyle: "light", navigationBarStyle: "light", startup: true }).then(function () { setTimeout(syncSafeAreaClass, 60); }, function () {});
|
||||
}
|
||||
function closeDetail() {
|
||||
function closeDetail(fromPopstate) {
|
||||
var sheet = $("detailSheet");
|
||||
if (!fromPopstate && !sheet.classList.contains("active")) return; // 已经关过了,避免重复消耗历史深度
|
||||
document.body.classList.remove("detail-active");
|
||||
var sheet = $("detailSheet"); sheet.classList.remove("active"); sheet.setAttribute("aria-hidden", "true");
|
||||
sheet.classList.remove("active"); sheet.setAttribute("aria-hidden", "true");
|
||||
state.currentDetail = null; state.focusScope = "home"; setAppFocusable(true);
|
||||
// 关闭时让还在飞的异步请求(比如"查看全部→TMDB 热播"进详情时的兜底搜索)自动作废:
|
||||
// 否则慢请求晚到时,openTrendingDetail 里的"用户是否已关闭"判断会误判成"还在看",
|
||||
// 悄悄把详情页重新弹出来,还会多 push 一条历史记录,导致返回键和真实历史深度错位失灵。
|
||||
state.currentItem = null; state.panToken++;
|
||||
stopDetailHeroRotate();
|
||||
if (state.panObserver) { try { state.panObserver.disconnect(); } catch (e) {} state.panObserver = null; }
|
||||
applyChromeMode();
|
||||
restoreHomeReturn();
|
||||
restoreViewReturn();
|
||||
if (!fromPopstate) closeRoute();
|
||||
}
|
||||
|
||||
/* ---------- 资源渲染 + 检测 + 播放 ---------- */
|
||||
function renderCollection(members) {
|
||||
state.isCollection = true;
|
||||
state.panItems = []; state.bestResource = null;
|
||||
state.qGroups = []; state.qFilter = null;
|
||||
state.panItems = []; state.bestResource = null; state.resumeRecord = null;
|
||||
state.qGroups = []; state.qFilter = null; state.qFilterManual = false;
|
||||
$("qTabs").replaceChildren();
|
||||
$("panTitle").textContent = "合集内容";
|
||||
$("panSub").textContent = members.length + " 部";
|
||||
@@ -1773,22 +8026,56 @@ function renderCollection(members) {
|
||||
}
|
||||
function renderResources(resources) {
|
||||
state.isCollection = false;
|
||||
state.panItems = resources || [];
|
||||
state.panItems = (resources || []).filter(function (r) { return PAN_TYPE_GLOBAL_EXCLUDE.indexOf(r.type) < 0; });
|
||||
panMemoSave(state.panItems);
|
||||
if (!state.panItems.length) {
|
||||
$("qTabs").replaceChildren();
|
||||
$("panList").replaceChildren(el("div", "pan-empty", "没有找到网盘资源。可点刷新重试。"));
|
||||
$("panSub").textContent = "";
|
||||
if (state.panDiag && state.panDiag.length) {
|
||||
var b0 = panDiagBox();
|
||||
if (b0) b0.textContent = "【反查诊断 · pomo】\n" + state.panDiag.join("\n");
|
||||
}
|
||||
updatePlayButton();
|
||||
return;
|
||||
}
|
||||
// 画质分组(对应参考里的分季)
|
||||
// 画质分组(对应参考里的分季):先收集出现过的网盘类型,再按固定顺序排列——
|
||||
// 不按"谁先到达"排,避免同一部片子每次打开 tab 顺序不一样、看着像自己在跳。
|
||||
var groups = [];
|
||||
state.panItems.forEach(function (r) { var q = r.quality || "其他"; if (groups.indexOf(q) < 0) groups.push(q); });
|
||||
state.panItems.forEach(function (r) { var q = r.quality || "其他"; if (isPanTypeBlocked(q)) return; if (groups.indexOf(q) < 0) groups.push(q); });
|
||||
// 用户在设置里屏蔽的类别都没有剩余资源(或者全部资源刚好都是被屏蔽的类别):
|
||||
// 提示一下,而不是显示一个空的资源列表让人以为是没搜到。
|
||||
if (!groups.length) {
|
||||
$("qTabs").replaceChildren();
|
||||
$("panList").replaceChildren(el("div", "pan-empty", "根据你在设置中选择的网盘类别,没有可显示的资源。可在设置 -「网盘类别」里调整。"));
|
||||
$("panSub").textContent = "";
|
||||
updatePlayButton();
|
||||
return;
|
||||
}
|
||||
var groupSeenOrder = groups.slice();
|
||||
groups.sort(function (a, b) {
|
||||
var ia = PAN_GROUP_ORDER.indexOf(a); if (ia < 0) ia = PAN_GROUP_ORDER.length;
|
||||
var ib = PAN_GROUP_ORDER.indexOf(b); if (ib < 0) ib = PAN_GROUP_ORDER.length;
|
||||
if (ia !== ib) return ia - ib;
|
||||
return groupSeenOrder.indexOf(a) - groupSeenOrder.indexOf(b); // 老内核 sort 非稳定排序,同优先级手动按首次出现顺序兜底
|
||||
});
|
||||
state.qGroups = groups;
|
||||
state.qFilter = groups.length > 1 ? groups[0] : null;
|
||||
/* 网盘资源是分批异步聚合的(蜗牛/SeedHub/玩偶/木偶/4K指南/盘搜等站点结果陆续到达),
|
||||
每来一批新结果都会重新调用一次本函数。之前这里无条件把 qFilter 重置成 groups[0],
|
||||
导致用户刚点到后面某个画质 tab,下一批异步结果一到就把选择冲掉、跳回第一个——
|
||||
打断了用户正在进行的操作。现在只在两种情况下才重置:①还没选过(初次渲染),
|
||||
②当前选中的画质在新分组里已经不存在了(比如那个画质的资源全部被过滤掉);
|
||||
其余情况下保留用户已经选中的 tab,不去动它。 */
|
||||
if (state.qFilter == null || groups.indexOf(state.qFilter) < 0) {
|
||||
state.qFilter = groups.length > 1 ? groups[0] : null;
|
||||
}
|
||||
$("panSub").textContent = state.panItems.length + " 条";
|
||||
renderQTabs();
|
||||
paintResources();
|
||||
if (state.panDiag && state.panDiag.length) {
|
||||
var b1 = panDiagBox();
|
||||
if (b1) b1.textContent = "【反查诊断 · pomo】\n" + state.panDiag.join("\n");
|
||||
}
|
||||
updatePlayButton();
|
||||
}
|
||||
function renderQTabs() {
|
||||
@@ -1804,6 +8091,7 @@ function renderQTabs() {
|
||||
b.setAttribute("data-focus-index", String(i));
|
||||
b.onclick = function () {
|
||||
state.qFilter = q;
|
||||
state.qFilterManual = true; // 用户手动点过 tab:后面异步到达的批次/继续播放同步都不许再冲掉这个选择
|
||||
var tabs = box.children; for (var j = 0; j < tabs.length; j++) tabs[j].classList.remove("active");
|
||||
b.classList.add("active");
|
||||
paintResources();
|
||||
@@ -1813,62 +8101,162 @@ function renderQTabs() {
|
||||
box.replaceChildren(frag);
|
||||
}
|
||||
function visibleItems() {
|
||||
if (!state.qFilter) return state.panItems.map(function (it, i) { return { it: it, i: i }; });
|
||||
var out = [];
|
||||
state.panItems.forEach(function (it, i) { if ((it.quality || "其他") === state.qFilter) out.push({ it: it, i: i }); });
|
||||
state.panItems.forEach(function (it, i) {
|
||||
if (it.state === "bad") return; // 网盘检测失效:不显示
|
||||
if (isPanTypeBlocked(it.quality || "其他")) return; // 用户在设置里屏蔽的网盘类别:不显示
|
||||
if (state.qFilter && (it.quality || "其他") !== state.qFilter) return;
|
||||
out.push({ it: it, i: i });
|
||||
});
|
||||
// 同一 tab 内按画质从高到低排(4K/杜比视界/HDR/原盘/高码 靠前),画质分相同的保持原有先后顺序
|
||||
out.sort(function (a, b) {
|
||||
var sa = qualityInfo(a.it).score, sb = qualityInfo(b.it).score;
|
||||
return sa !== sb ? (sb - sa) : (a.i - b.i);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
function paintResources() {
|
||||
var list = $("panList");
|
||||
var vis = visibleItems();
|
||||
// 当前分组若全是枫叶在线剧集("第N集"这类),铺成日历式格子,方便点选;
|
||||
// 网盘/盘搜那种带标题+提取码的,仍走下面的行列表。
|
||||
var allFyEp = vis.length > 0 && vis.every(function (p) { return p.it._fy; });
|
||||
if (allFyEp) { paintEpisodeGrid(list, vis); scheduleCheck(); return; }
|
||||
var frag = document.createDocumentFragment();
|
||||
visibleItems().forEach(function (pair) {
|
||||
vis.forEach(function (pair) {
|
||||
var item = pair.it, i = pair.i;
|
||||
var row = document.createElement("div");
|
||||
row.className = "pan-item";
|
||||
row.className = "pan-item focusable";
|
||||
row.setAttribute("data-pan-index", String(i));
|
||||
row.setAttribute("data-focus-row", "panList"); row.setAttribute("data-focus-index", String(i));
|
||||
row.setAttribute("role", "button"); row.setAttribute("tabindex", "0");
|
||||
var dot = document.createElement("span"); dot.className = "pan-dot " + (item.state || "idle"); row.appendChild(dot);
|
||||
var body = document.createElement("div"); body.className = "pan-body";
|
||||
var nm = document.createElement("div"); nm.className = "pan-name";
|
||||
nm.textContent = item.title || ((PAN_META[item.type] ? PAN_META[item.type].label : item.type) + "网盘");
|
||||
nm.textContent = (item.title ? cleanPanRowTitle(item.title) : "") || ((PAN_META[item.type] ? PAN_META[item.type].label : item.type) + "网盘");
|
||||
var sub = document.createElement("div"); sub.className = "pan-sub";
|
||||
var lab = (PAN_META[item.type] ? PAN_META[item.type].label : item.type);
|
||||
sub.innerHTML = "<span class='q'>" + escapeHtml(lab) + "</span>" +
|
||||
(item.password ? "提取码 " + escapeHtml(item.password) + " · " : "") +
|
||||
escapeHtml(item.state === "resolving" ? "解析中…" : (item.state === "bad" ? "解析失败" : (item.source || "点播放打开")));
|
||||
sub.innerHTML = buildPanSubHTML(item);
|
||||
body.appendChild(nm); body.appendChild(sub); row.appendChild(body);
|
||||
var play = document.createElement("button");
|
||||
play.type = "button"; play.className = "pan-play focusable";
|
||||
play.setAttribute("data-focus-row", "panList"); play.setAttribute("data-focus-index", String(i));
|
||||
play.textContent = "播放";
|
||||
play.onclick = function () { playResource(item); };
|
||||
// 播放动作绑在整行上,右侧只留一个不可单独点击的提示图标(见 .pan-play 样式),
|
||||
// 不再单独放一个播放按钮,减少一行两个可点目标的重复
|
||||
var play = document.createElement("span");
|
||||
play.className = "pan-play"; play.setAttribute("aria-hidden", "true"); play.textContent = "\u25B6";
|
||||
row.appendChild(play);
|
||||
row.onclick = function () { playResource(item); };
|
||||
frag.appendChild(row);
|
||||
});
|
||||
list.replaceChildren(frag);
|
||||
scheduleCheck();
|
||||
}
|
||||
/* 集数网格:每集一个格子,格里显示集号(能提取到数字就显示"01",否则显示原集名),点即播 */
|
||||
function paintEpisodeGrid(list, vis) {
|
||||
var grid = document.createElement("div");
|
||||
grid.className = "ep-grid";
|
||||
var frag = document.createDocumentFragment();
|
||||
vis.forEach(function (pair) {
|
||||
var item = pair.it, i = pair.i;
|
||||
var cell = document.createElement("button");
|
||||
cell.type = "button";
|
||||
cell.className = "ep-cell focusable" + (item.state === "resolving" ? " resolving" : "") + (item.state === "bad" ? " bad" : "");
|
||||
cell.setAttribute("data-pan-index", String(i));
|
||||
cell.setAttribute("data-focus-row", "panList");
|
||||
cell.setAttribute("data-focus-index", String(i));
|
||||
var m = String(item.title || "").match(/\d+/);
|
||||
cell.textContent = m ? (m[0].length < 2 ? "0" + m[0] : m[0]) : (cleanPanRowTitle(item.title) || ("第" + (item._fyNid || (i + 1)) + "集"));
|
||||
if (item.state && item.state !== "idle") { var dot = document.createElement("span"); dot.className = "ep-dot " + item.state; cell.appendChild(dot); }
|
||||
cell.onclick = function () { playResource(item); };
|
||||
frag.appendChild(cell);
|
||||
});
|
||||
grid.appendChild(frag);
|
||||
list.replaceChildren(grid);
|
||||
}
|
||||
/* 网盘资源候选顺序:按分组(跟「网盘资源」里 tab 的先后一致)排列,组内按画质从高到低排——
|
||||
这个顺序跟每个 tab 下面实际显示给用户看的顺序完全一致;画质相同的保留原始到达顺序。
|
||||
主播放按钮默认播这个顺序里的第一条;点开又很快返回(没看够 30 秒)就依次换下一条,
|
||||
一直换到最后一条后再从头循环,见 findNextResource。 */
|
||||
function sortedPanCandidates() {
|
||||
var groups = state.qGroups || [];
|
||||
var list = (state.panItems || []).filter(function (it) {
|
||||
if (it.state === "bad") return false; // 已确认失效的不参与候选
|
||||
if (isPanTypeBlocked(it.quality || "其他")) return false; // 用户在设置里屏蔽的类别不参与
|
||||
return true;
|
||||
});
|
||||
list.sort(function (a, b) {
|
||||
var ga = groups.indexOf(a.quality || "其他"); if (ga < 0) ga = groups.length;
|
||||
var gb = groups.indexOf(b.quality || "其他"); if (gb < 0) gb = groups.length;
|
||||
if (ga !== gb) return ga - gb;
|
||||
var sa = qualityInfo(a).score, sb = qualityInfo(b).score;
|
||||
if (sa !== sb) return sb - sa;
|
||||
return state.panItems.indexOf(a) - state.panItems.indexOf(b);
|
||||
});
|
||||
return list;
|
||||
}
|
||||
function pickBest() {
|
||||
var order = { quark: 0, uc: 1, baidu: 2, aliyun: 2, "123": 3, xunlei: 3, "115": 4, tianyi: 4, mobile: 5 };
|
||||
var best = null, bestScore = 999;
|
||||
state.panItems.forEach(function (it) {
|
||||
if (it.state === "bad") return;
|
||||
var playable = PAN_META[it.type] && PAN_META[it.type].play;
|
||||
var s = (order[it.type] == null ? 6 : order[it.type]) - (it.tier || 0) * 0.1;
|
||||
if (!playable) s += 10; // 不支持直接播放的网盘排后
|
||||
if (s < bestScore) { bestScore = s; best = it; }
|
||||
});
|
||||
return best;
|
||||
var list = sortedPanCandidates();
|
||||
return list.length ? list[0] : null;
|
||||
}
|
||||
/* 上次播过的那条记录(按当前详情页的 id 找) */
|
||||
function recentFor(id) {
|
||||
var list = state.recentItems || [];
|
||||
for (var i = 0; i < list.length; i++) {
|
||||
if (String(list[i].id) === String(id) && list[i].payload && list[i].payload.url) return list[i];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
/* 把记录里的播放地址映射回当前资源列表里的那一条。
|
||||
记录存的是播放时用的地址(网盘分享链接或解析后的直链),列表里是原始链接,
|
||||
字面不一定完全相同,所以先按 normPanUrl 比对,再退一步按当时的资源名比对。 */
|
||||
function matchResumeItem(rec) {
|
||||
var items = state.panItems || [], i, iu;
|
||||
var key = normPanUrl((rec.payload && rec.payload.url) || "");
|
||||
if (key) {
|
||||
for (i = 0; i < items.length; i++) {
|
||||
iu = items[i].url || items[i].token || "";
|
||||
if (iu && normPanUrl(iu) === key) return items[i];
|
||||
}
|
||||
}
|
||||
if (rec.note) {
|
||||
for (i = 0; i < items.length; i++) {
|
||||
if ((items[i].title || items[i].groupName || "") === rec.note) return items[i];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
/* 接着播的那条如果在别的网盘类型 tab 里,把 tab 切过去,让用户看得见会播哪条 */
|
||||
function syncTabTo(item) {
|
||||
if (!item) return;
|
||||
if (state.qFilterManual) return; // 用户已经手动选过 tab:这是"继续播放"每次重渲染都会走到的自动同步,
|
||||
// 不能因为它把用户刚点的 115/123 又拽回资源本来所在的 tab(通常是夸克),
|
||||
// 之前就是这里在每批异步资源到达后一次次把选择冲掉,感觉像连跳好几次
|
||||
var q = item.quality || "其他";
|
||||
if (!state.qFilter || state.qFilter === q) return;
|
||||
if ((state.qGroups || []).indexOf(q) < 0) return;
|
||||
state.qFilter = q;
|
||||
if (typeof renderQTabs === "function") renderQTabs();
|
||||
if (typeof paintResources === "function") paintResources();
|
||||
}
|
||||
function updatePlayButton() {
|
||||
var btn = $("edPlay"), label = $("edPlayLabel");
|
||||
var best = pickBest();
|
||||
state.bestResource = best;
|
||||
if (!best) { btn.setAttribute("disabled", "disabled"); label.textContent = "暂无可播放资源"; return; }
|
||||
btn.removeAttribute("disabled");
|
||||
var curId = state.currentDetail ? state.currentDetail.id : "";
|
||||
var seen = curId ? (state.recentItems || []).some(function (h) { return String(h.id) === String(curId); }) : false;
|
||||
label.textContent = seen ? "继续播放" : "播放";
|
||||
// 播过的片子:主按钮直接接着上次那条资源,而不是重新按排序挑"最佳"——
|
||||
// 否则点"继续播放"很可能换成了另一条链接,等于从头再来。
|
||||
var rec = curId ? recentFor(curId) : null;
|
||||
state.resumeRecord = rec;
|
||||
var resumed = rec ? matchResumeItem(rec) : null;
|
||||
var best = resumed || pickBest();
|
||||
state.bestResource = best;
|
||||
state.bestIsResume = !!resumed; // 记住这次是不是“接着上次那条播”,quick-return 顺移逻辑要避开它
|
||||
if (!best && !rec) { btn.setAttribute("disabled", "disabled"); label.textContent = "暂无可播放资源"; return; }
|
||||
btn.removeAttribute("disabled");
|
||||
label.textContent = rec ? "继续播放" : "播放";
|
||||
if (resumed) syncTabTo(resumed);
|
||||
}
|
||||
function playBest() {
|
||||
if (state.bestResource) { playResource(state.bestResource, state.bestIsResume); return; }
|
||||
// 列表里已经没有这条了(站点换链接/资源下架),就直接用记录里存的地址播
|
||||
if (state.resumeRecord) { playRecent(state.resumeRecord); return; }
|
||||
toast("资源还在加载…");
|
||||
}
|
||||
function playBest() { if (state.bestResource) playResource(state.bestResource); else toast("资源还在加载…"); }
|
||||
|
||||
/* 收藏(本地) */
|
||||
function favKey() { return state.currentDetail ? ("fav_" + state.currentDetail.id) : ""; }
|
||||
@@ -1888,7 +8276,79 @@ function toggleFav() {
|
||||
function stateText(s) {
|
||||
if (s === "resolving") return "解析中…"; if (s === "bad") return "解析失败"; return "点播放打开";
|
||||
}
|
||||
function scheduleCheck() { /* 盘链真链需解析后才有,不做上游检测 */ }
|
||||
/* ---------- 网盘检测(自动,受“增强功能 -> 网盘检测”开关控制) ---------- */
|
||||
function scheduleCheck() {
|
||||
if (!state.driveCheck || !state.sdkReady || !window.fm || !fm.pan || !fm.pan.check) return;
|
||||
var list = $("panList"); if (!list) return;
|
||||
if ("IntersectionObserver" in window) {
|
||||
if (!state.panObserver) {
|
||||
state.panObserver = new IntersectionObserver(function (entries) {
|
||||
var due = [];
|
||||
entries.forEach(function (en) {
|
||||
var idx = parseInt(en.target.getAttribute("data-pan-index"), 10);
|
||||
var it = state.panItems[idx];
|
||||
if (!en.isIntersecting) return;
|
||||
state.panObserver.unobserve(en.target);
|
||||
if (!it || it._checked || it._checking) return;
|
||||
if (CHECKABLE.indexOf(it.type) < 0) { it._checked = true; return; } // 仅检测 App 支持的网盘类型
|
||||
due.push(it);
|
||||
});
|
||||
if (due.length) queuePanCheck(due);
|
||||
}, { root: null, rootMargin: "200px 0px", threshold: 0.01 });
|
||||
}
|
||||
var rows = list.querySelectorAll(".pan-item[data-pan-index]");
|
||||
for (var i = 0; i < rows.length; i++) {
|
||||
var idx = parseInt(rows[i].getAttribute("data-pan-index"), 10);
|
||||
var it = state.panItems[idx];
|
||||
if (it && !it._checked && !it._checking) state.panObserver.observe(rows[i]);
|
||||
}
|
||||
} else {
|
||||
// 不支持 IntersectionObserver 的旧内核兜底:直接检测当前可见列表(数量本身有限)
|
||||
var fallback = [];
|
||||
state.panItems.forEach(function (it) { if (!it._checked && !it._checking && CHECKABLE.indexOf(it.type) >= 0) fallback.push(it); });
|
||||
if (fallback.length) queuePanCheck(fallback);
|
||||
}
|
||||
}
|
||||
function queuePanCheck(items) {
|
||||
var token = state.panToken;
|
||||
items.forEach(function (it) { it._checking = true; it.state = "checking"; });
|
||||
updateDots();
|
||||
function resolveOne(it) {
|
||||
if (it.url) return Promise.resolve(it);
|
||||
if (!it.token) { it._resolveFailed = true; return Promise.resolve(it); }
|
||||
return resolveToken(it.token).then(function (url) { it.url = url; return it; }, function () { it._resolveFailed = true; return it; });
|
||||
}
|
||||
Promise.all(items.map(resolveOne)).then(function (resolved) {
|
||||
if (token !== state.panToken) return;
|
||||
var checkable = [], skipped = [];
|
||||
resolved.forEach(function (it) { if (it.url && !it._resolveFailed) checkable.push(it); else skipped.push(it); });
|
||||
skipped.forEach(function (it) { it._checking = false; it._checked = true; it.state = "bad"; });
|
||||
if (skipped.length) refreshPanRows(skipped);
|
||||
if (!checkable.length) { updatePlayButton(); return; }
|
||||
function runBatch(start) {
|
||||
var batch = checkable.slice(start, start + 10); // 单批最多约 10 条
|
||||
if (!batch.length) { updatePlayButton(); return; }
|
||||
var payload = batch.map(function (it) { return { type: it.type, url: it.url, password: it.password || "" }; });
|
||||
fm.pan.check(payload).then(function (res) {
|
||||
if (token !== state.panToken) return;
|
||||
var results = (res && res.results) || [];
|
||||
batch.forEach(function (it, i) {
|
||||
var r = results[i];
|
||||
it._checking = false; it._checked = true;
|
||||
it.state = (r && r.state) || "uncertain";
|
||||
});
|
||||
refreshPanRows(batch);
|
||||
runBatch(start + 10);
|
||||
}, function () {
|
||||
if (token !== state.panToken) return;
|
||||
batch.forEach(function (it) { it._checking = false; it._checked = true; it.state = "uncertain"; });
|
||||
refreshPanRows(batch);
|
||||
runBatch(start + 10);
|
||||
});
|
||||
}
|
||||
runBatch(0);
|
||||
});
|
||||
}
|
||||
function updateDots() {
|
||||
var rows = $("panList").querySelectorAll(".pan-item");
|
||||
for (var i = 0; i < rows.length; i++) {
|
||||
@@ -1897,7 +8357,28 @@ function updateDots() {
|
||||
var dot = rows[i].querySelector(".pan-dot"); if (dot) dot.className = "pan-dot " + (it.state || "idle");
|
||||
}
|
||||
}
|
||||
function reorderByHealth() {}
|
||||
/* 检测结果原地刷新:不重排、不整表重绘,只更新这批条目自己的那一行——
|
||||
失效(bad)的直接从 DOM 里摘掉那一行,其余条目的位置和内容原封不动,
|
||||
用户正在看/正准备点的资源不会因为别的条目检测完而被冲到别的位置。 */
|
||||
function refreshPanRows(items) {
|
||||
var list = $("panList");
|
||||
var rows = list.querySelectorAll(".pan-item");
|
||||
var byIdx = {};
|
||||
items.forEach(function (it) {
|
||||
var idx = state.panItems.indexOf(it);
|
||||
if (idx >= 0) byIdx[idx] = it;
|
||||
});
|
||||
for (var i = 0; i < rows.length; i++) {
|
||||
var idx = parseInt(rows[i].getAttribute("data-pan-index"), 10);
|
||||
var it = byIdx[idx]; if (!it) continue;
|
||||
if (it.state === "bad") { if (rows[i].parentNode) rows[i].parentNode.removeChild(rows[i]); continue; }
|
||||
var dot = rows[i].querySelector(".pan-dot"); if (dot) dot.className = "pan-dot " + (it.state || "idle");
|
||||
var sub = rows[i].querySelector(".pan-sub"); if (sub) sub.innerHTML = buildPanSubHTML(it);
|
||||
}
|
||||
if (!list.querySelector(".pan-item") && !list.querySelector(".ep-cell") && visibleItems().length === 0 && state.panItems.length) {
|
||||
list.replaceChildren(el("div", "pan-empty", "没有找到网盘资源。可点刷新重试。"));
|
||||
}
|
||||
}
|
||||
|
||||
/* token -> 真实网盘地址 */
|
||||
function resolveToken(token) {
|
||||
@@ -1913,11 +8394,17 @@ function setItemState(item, st) {
|
||||
var rows = $("panList").querySelectorAll(".pan-item");
|
||||
for (var i = 0; i < rows.length; i++) {
|
||||
var idx = parseInt(rows[i].getAttribute("data-pan-index"), 10);
|
||||
if (state.panItems[idx] === item) { var sub = rows[i].querySelector(".pan-sub"); if (sub) { var lab = (PAN_META[item.type] ? PAN_META[item.type].label : item.type); sub.innerHTML = "<span class='q'>" + escapeHtml(lab) + "</span>" + (item.password ? "提取码 " + escapeHtml(item.password) + " · " : "") + escapeHtml(stateText(st)); } }
|
||||
if (state.panItems[idx] === item) { var sub = rows[i].querySelector(".pan-sub"); if (sub) sub.innerHTML = buildPanSubHTML(item); }
|
||||
}
|
||||
}
|
||||
/* ---------- 盘搜 PanSou(详情页「网盘搜索」按钮) ---------- */
|
||||
function normPanUrl(u) { return (u || "").split("?")[0].replace(/\/+$/, ""); }
|
||||
function normPanUrl(u) {
|
||||
u = u || "";
|
||||
// 磁力全都是 magnet:?xt=... ,按 ? 截断会把所有磁力算成同一条,这里改用 btih 哈希做键
|
||||
var mg = u.match(/btih:([A-Za-z0-9]+)/i);
|
||||
if (mg) return "magnet:" + mg[1].toLowerCase();
|
||||
return u.split("?")[0].replace(/\/+$/, "");
|
||||
}
|
||||
function extPost(url, bodyObj) {
|
||||
var body = JSON.stringify(bodyObj);
|
||||
var headers = { "Content-Type": "application/json", "Accept": "application/json" };
|
||||
@@ -1925,25 +8412,30 @@ function extPost(url, bodyObj) {
|
||||
if (state.sdkReady && window.fm && fm.req) {
|
||||
return fm.req(url, { method: "POST", responseType: "json", timeout: 20, headers: headers, body: body }).then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.body; });
|
||||
}
|
||||
return fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: body }).then(function (r) { return r.json(); });
|
||||
return fetchTimeout(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: body }).then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); });
|
||||
}
|
||||
function panSouSearch(kw) {
|
||||
function panSouSearch(kw, year) {
|
||||
var api = (config.panApi || "https://so.252035.xyz").replace(/\/+$/, "");
|
||||
var body = { kw: kw, res: "merge", src: "all", cloud_types: PANSOU_TYPES, channels: [] };
|
||||
return extPost(api + "/api/search", body).then(function (d) {
|
||||
var merged = (d && d.data && d.data.merged_by_type) || (d && d.merged_by_type) || {};
|
||||
var out = [];
|
||||
Object.keys(merged).forEach(function (t) {
|
||||
if (PANSOU_TYPES.indexOf(t) < 0) return; // 只保留 App 支持的类型
|
||||
if (PANSOU_KEEP.indexOf(t) < 0) return; // 保留 App 支持的类型 + 光鸭盘
|
||||
var lab = PAN_META[t] ? PAN_META[t].label : t;
|
||||
(merged[t] || []).forEach(function (it) {
|
||||
var url = it.url || it.link || "";
|
||||
if (!url) return;
|
||||
var rtitle = it.note || it.title || it.name || "";
|
||||
// 盘搜是聚合了一大堆第三方资源站的关键词全文检索,同样会把关键词只是凑巧出现在标题
|
||||
// 中间的不相关资源也搜出来(比如混进《刚下山被幸运女神拉去领证》),这里用跟本站
|
||||
// 资源同一把尺子过滤一遍,只保留关键词出现在标题靠前位置、或者标题里带着年份的。
|
||||
if (rtitle && !resourceTitleLikelyMatches(rtitle, kw, year)) return;
|
||||
out.push({
|
||||
type: t, url: url,
|
||||
password: it.password || it.pwd || it.code || "",
|
||||
title: it.note || it.title || it.name || "",
|
||||
groupName: lab + " · 盘搜", quality: lab + "·盘搜",
|
||||
title: rtitle,
|
||||
groupName: lab, quality: lab,
|
||||
source: "盘搜", state: "idle"
|
||||
});
|
||||
});
|
||||
@@ -1954,20 +8446,34 @@ function panSouSearch(kw) {
|
||||
function panSouKeep() { return (state.panItems || []).filter(function (it) { return it.source === "盘搜"; }); }
|
||||
function runPanSou(silent) {
|
||||
var d = state.currentDetail; if (!d) return;
|
||||
var kw = d.title || d.rawTitle || (state.currentItem && state.currentItem.title) || "";
|
||||
if (!kw) { if (!silent) toast("无标题,无法盘搜"); return; }
|
||||
var rawKw = d.rawTitle || d.title || (state.currentItem && state.currentItem.title) || "";
|
||||
if (!rawKw) { if (!silent) toast("无标题,无法盘搜"); return; }
|
||||
var rawYear = (state.currentItem && state.currentItem.year) || yearOf(rawKw) || (d.tmdb && d.tmdb.year) || "";
|
||||
var kwList = panSouKeywords(rawKw);
|
||||
var btn = $("nativeSearch"); if (btn && btn.getAttribute("data-busy") === "1") return;
|
||||
if (btn) btn.setAttribute("data-busy", "1");
|
||||
var token = state.panToken;
|
||||
if (!silent) toast("盘搜中…");
|
||||
panSouSearch(cleanTitle(kw)).then(function (list) {
|
||||
Promise.all(kwList.map(function (kw) { return panSouSearch(kw, rawYear).then(function (r) { return r; }, function () { return []; }); })).then(function (results) {
|
||||
if (btn) btn.removeAttribute("data-busy");
|
||||
if (token !== state.panToken) return; // 详情已切换,丢弃
|
||||
var have = {};
|
||||
state.panItems.forEach(function (it) { have[it.type + "|" + normPanUrl(it.url || it.token)] = 1; });
|
||||
var add = [];
|
||||
list.forEach(function (it) { var k = it.type + "|" + normPanUrl(it.url); if (!have[k]) { have[k] = 1; add.push(it); } });
|
||||
if (!add.length) { if (!silent) toast("盘搜没有新增结果"); return; }
|
||||
results.forEach(function (list) {
|
||||
(list || []).forEach(function (it) { var k = it.type + "|" + normPanUrl(it.url); if (!have[k]) { have[k] = 1; add.push(it); } });
|
||||
});
|
||||
if (!add.length) {
|
||||
if (!silent) { toast("盘搜没有新增结果"); return; }
|
||||
// 静默模式(自动触发)下,如果官方资源本来就是空的,盘搜也确实一无所获,
|
||||
// 原来这里直接 return,界面停留在 loadResources 留下的"没有找到网盘资源"占位文案,
|
||||
// 跟"盘搜压根没跑过"长得一模一样,没法区分,容易被误以为自动搜索失效了。
|
||||
// 这里补一句更明确的提示,证明盘搜确实跑过、只是没搜到。
|
||||
if (!state.panItems.length) {
|
||||
$("panList").replaceChildren(el("div", "pan-empty", "官方资源和盘搜都没有找到,可点刷新重试。"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
renderResources(state.panItems.concat(add)); // 合并盘搜结果,重建 tab
|
||||
if (!silent) toast("盘搜新增 " + add.length + " 条");
|
||||
}, function (e) {
|
||||
@@ -1975,8 +8481,9 @@ function runPanSou(silent) {
|
||||
if (!silent && token === state.panToken) toast("盘搜失败:" + (e && e.message ? e.message : "检查盘搜地址"));
|
||||
});
|
||||
}
|
||||
function playResource(item) {
|
||||
if (item.url) { doPlay(item, item.url); return; } // 盘搜等直链,直接播放
|
||||
function playResource(item, isResume) {
|
||||
if (item._fy) { playFyResource(item, isResume); return; } // 枫叶:在线点播,解析播放页拿 m3u8 直链后直接播
|
||||
if (item.url) { doPlay(item, item.url, isResume); return; } // 盘搜等直链,直接播放
|
||||
if (!item.token) { toast("该链接无地址,无法播放"); return; }
|
||||
if (item._resolving) return;
|
||||
item._resolving = true;
|
||||
@@ -1985,29 +8492,179 @@ function playResource(item) {
|
||||
resolveToken(item.token).then(function (url) {
|
||||
item._resolving = false;
|
||||
setItemState(item, "idle");
|
||||
doPlay(item, url);
|
||||
doPlay(item, url, isResume);
|
||||
}, function (err) {
|
||||
item._resolving = false;
|
||||
setItemState(item, "bad");
|
||||
toast("解析失败:" + (err && err.message ? err.message : "换一个链接试试"));
|
||||
});
|
||||
}
|
||||
function doPlay(item, url) {
|
||||
function doPlay(item, url, isResume) {
|
||||
var d = state.currentDetail || {};
|
||||
var tmdb = d.tmdb || {};
|
||||
var pic = (tmdb.poster || d.poster) || "";
|
||||
var wall = tmdb.backdrop || "";
|
||||
var wall = (tmdb.backdrop || "");
|
||||
var ptype = (PAN_META[item.type] && PAN_META[item.type].play) ? PAN_META[item.type].play : diskTypeFromUrl(url);
|
||||
var payload = { type: ptype, url: url, password: item.password || "", title: d.title || item.title || "盘链资源", pic: pic, wallPic: wall };
|
||||
storeSet("wall_" + hashKey(url), JSON.stringify({ title: payload.title, pic: pic, wallPic: wall }));
|
||||
var payload = { type: ptype, url: url, password: item.password || "", title: d.title || item.title || "盘链资源", pic: pic };
|
||||
var note = item.title || item.groupName || (PAN_META[item.type] ? PAN_META[item.type].label : "");
|
||||
if (d.id) recordRecent(d.id, payload.title, pic, payload, note);
|
||||
// 不在这里立即写入播放记录:如果 30 秒内又返回,说明大概率没看,等确认看够时长再记录。
|
||||
state.pendingPlayWatch = {
|
||||
ts: Date.now(), kind: "resource", item: item, isResume: !!isResume,
|
||||
recordInfo: d.id ? { id: d.id, title: payload.title, pic: pic, wall: wall, payload: payload, note: note } : null
|
||||
};
|
||||
if (window.fm && fm.pan && fm.pan.play) { toast("正在打开播放器…"); fm.pan.play(payload).then(function () {}, function () { toast("播放失败,试试下一个链接"); }); }
|
||||
else toast("已解析:" + url);
|
||||
}
|
||||
/* 枫叶(在线点播):按开发文档用 fm.vodInline 推送——
|
||||
① vod_name 用剧名(播放页标题就是剧名,不再是那串 url);
|
||||
② episodes 是整条至臻4k线路的全集,原生播放页会显示集数/上一集/下一集;
|
||||
③ 每集 resolve:true + pageUrl,进播放页不批量解析,点哪集才回调 __fmWebHomeInlineResolver
|
||||
去破解那一集的真实 m3u8 —— 这才是"点一集播一集、像在网站上直接点播"的正确做法,
|
||||
也避免了之前"进详情就想把所有集都解析、慢且常失败"的问题。 */
|
||||
function playFyResource(item, isResume) {
|
||||
var d = state.currentDetail || {};
|
||||
var tmdb = d.tmdb || {};
|
||||
var pic = (tmdb.poster || d.poster || item.poster || "");
|
||||
var wall = (tmdb.backdrop || "");
|
||||
var showName = (d.title || d.rawTitle || "").trim() || (item.title || "枫叶资源");
|
||||
var ref = fyBase() + "/";
|
||||
|
||||
if (!window.fm || typeof fm.vodInline !== "function") {
|
||||
// 没有 vodInline(旧版/浏览器):退回单集解析后直链播放
|
||||
playFyResourceFallback(item, showName, pic);
|
||||
return;
|
||||
}
|
||||
// 整条至臻4k线路的全集(同 quality),按集号排序
|
||||
var sameLine = (state.panItems || []).filter(function (it) {
|
||||
return it._fy && it._fyId === item._fyId && (it.quality || "") === (item.quality || "");
|
||||
}).sort(function (a, b) { return (a._fySid - b._fySid) || (a._fyNid - b._fyNid); });
|
||||
if (!sameLine.length) sameLine = [item];
|
||||
|
||||
var episodes = sameLine.map(function (it) {
|
||||
var nm = it.title || ("第" + it._fyNid + "集");
|
||||
return {
|
||||
name: nm,
|
||||
url: it._fyPlayPage, // 集数页面地址(不是最终媒体)
|
||||
pageUrl: it._fyPlayPage, // 按集解析时建议填
|
||||
resolve: true, // 点该集才回调解析真实地址
|
||||
ext: { sid: it._fySid, nid: it._fyNid, id: it._fyId }
|
||||
};
|
||||
});
|
||||
var mark = item.title || ("第" + item._fyNid + "集");
|
||||
|
||||
// 注册页面级 resolver:播放页点某一集时,Native 调它,返回 { url, format, headers }
|
||||
window.__fmWebHomeInlineResolver = function (episode) {
|
||||
var page = (episode && (episode.pageUrl || episode.url)) || "";
|
||||
if (!page) return Promise.reject(new Error("缺少播放页地址"));
|
||||
// 复用现成的破解逻辑(player_aaaa -> parse -> mplayer.php -> 解密),拿到真实 m3u8
|
||||
return fyCrackPlayUrl(page).then(function (real) {
|
||||
if (real) return real;
|
||||
return fySniff(page); // 破解失败退回普通嗅探
|
||||
}).then(function (real) {
|
||||
if (!real) throw new Error("没解析到播放地址");
|
||||
state.fyLastUrl = real;
|
||||
return {
|
||||
url: real,
|
||||
format: /\.m3u8/i.test(real) ? "hls" : "",
|
||||
headers: { "User-Agent": FY_UA, "Referer": ref }
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
if (d.id) {
|
||||
// 记录最近观看用当前集信息(剧名,不是 url)
|
||||
state.pendingPlayWatch = {
|
||||
ts: Date.now(), kind: "resource", item: item, isResume: !!isResume,
|
||||
recordInfo: { id: d.id, title: showName, pic: pic, wall: wall, note: "枫叶 · " + (item.groupName || "至臻4k") }
|
||||
};
|
||||
}
|
||||
|
||||
toast("正在打开播放器…");
|
||||
try {
|
||||
fm.vodInline({
|
||||
vod_id: "fy_" + item._fyId,
|
||||
vod_name: showName, // ← 剧名推到播放页
|
||||
vod_pic: pic,
|
||||
wallPic: wall,
|
||||
vod_play_from: "枫叶·至臻4k",
|
||||
mark: mark, // 默认选中你点的这一集
|
||||
headers: { "User-Agent": FY_UA, "Referer": ref },
|
||||
episodes: episodes // ← 整季选集
|
||||
});
|
||||
} catch (e) {
|
||||
playFyResourceFallback(item, showName, pic);
|
||||
}
|
||||
}
|
||||
/* 兜底:无 vodInline 时,解析当前单集后用 fm.play 直链播放(标题仍用剧名+集名) */
|
||||
function playFyResourceFallback(item, showName, pic) {
|
||||
if (item._resolving) return;
|
||||
var epName = (item.title || "").trim();
|
||||
var title = showName + (epName ? " " + epName : "");
|
||||
var ref = fyBase() + "/";
|
||||
item._resolving = true;
|
||||
setItemState(item, "resolving");
|
||||
toast("解析 " + (epName || "播放地址") + "…");
|
||||
fyResolve(item).then(function (url) {
|
||||
item._resolving = false; setItemState(item, "idle"); state.fyLastUrl = url;
|
||||
if (window.fm && typeof fm.play === "function") {
|
||||
state.fyLastPlayApi = "fm.play";
|
||||
fm.play(url, title, { pic: pic, headers: { "User-Agent": FY_UA, "Referer": ref } });
|
||||
} else toast("已解析:" + url);
|
||||
}, function (err) {
|
||||
item._resolving = false; setItemState(item, "bad");
|
||||
toast("解析失败:" + (err && err.message ? err.message : "换一集试试"));
|
||||
});
|
||||
}
|
||||
/* ---------- 播放返回过快:准备好下一条资源,但交给用户自己点播放 ---------- */
|
||||
function findNextResource(afterItem) {
|
||||
var list = sortedPanCandidates();
|
||||
var pos = list.indexOf(afterItem);
|
||||
if (pos < 0) return null;
|
||||
if (pos + 1 < list.length) return list[pos + 1]; // 顺移到下一条
|
||||
return list.length > 1 ? list[0] : null; // 已经是最后一条:从头循环
|
||||
}
|
||||
function prepareNextResource(afterItem) {
|
||||
var next = findNextResource(afterItem);
|
||||
if (!next) return;
|
||||
var nq = next.quality || "其他";
|
||||
if (state.qFilter && nq !== state.qFilter) { // 下一条在别的网盘类型tab里,同步切过去
|
||||
state.qFilter = nq;
|
||||
if (typeof renderQTabs === "function") renderQTabs();
|
||||
if (typeof paintResources === "function") paintResources();
|
||||
}
|
||||
state.bestResource = next; // 主播放按钮改成播放这条
|
||||
state.resumeRecord = null; // 已经换目标了,别再回落到上次那条
|
||||
state.bestIsResume = false; // 这是新挑的资源,不是接着上次那条播
|
||||
var btn = $("edPlay"), label = $("edPlayLabel");
|
||||
if (btn && label) {
|
||||
btn.removeAttribute("disabled");
|
||||
var curId = state.currentDetail ? state.currentDetail.id : "";
|
||||
var seen = curId ? (state.recentItems || []).some(function (h) { return String(h.id) === String(curId); }) : false;
|
||||
label.textContent = seen ? "继续播放" : "播放"; // 只显示普通文案,不带别的
|
||||
}
|
||||
}
|
||||
function handleAutoNextResume(pausedMs) {
|
||||
var w = state.pendingPlayWatch;
|
||||
if (!w) return;
|
||||
state.pendingPlayWatch = null;
|
||||
if (typeof pausedMs !== "number") pausedMs = Date.now() - w.ts;
|
||||
if (pausedMs >= 30000) { // 观看时间够长,正常返回,这时才真正写入播放记录
|
||||
if (w.kind === "resource" && w.recordInfo) {
|
||||
var r = w.recordInfo;
|
||||
recordRecent(r.id, r.title, r.pic, r.wall || "", r.payload, r.note);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// 不足 30 秒:不写入播放记录。但如果这次是“继续播放”(接着上次记住的那条),
|
||||
// 不去顺移换下一条——继续播放必须始终重试同一条链接,换源的逻辑只在“从头挑新资源”时生效。
|
||||
// 不自动跳转播放页,等用户自己点“播放”。
|
||||
if (w.kind === "resource" && !w.isResume) prepareNextResource(w.item);
|
||||
}
|
||||
function diskTypeFromUrl(u) {
|
||||
for (var i = 0; i < HOSTMAP.length; i++) if (HOSTMAP[i][0].test(u)) return HOSTMAP[i][1];
|
||||
if (/guangyapan\.com/i.test(u)) return "http"; // 光鸭盘:无法归类,按开发文档用 http 走 push 链路
|
||||
if (u.indexOf("magnet:") === 0) return "magnet";
|
||||
if (/^https?:/i.test(u)) return "http"; // 其它未知网盘也用 http,别再默认当夸克
|
||||
return "quark";
|
||||
}
|
||||
|
||||
@@ -2016,27 +8673,88 @@ function renderStatus() {
|
||||
var box = $("statusBlock"); box.replaceChildren();
|
||||
function line(label, val, cls) { var d = document.createElement("div"); d.className = "status-line"; d.innerHTML = "<b>" + escapeHtml(label) + ":</b><span class='" + (cls || "") + "'>" + escapeHtml(val) + "</span>"; box.appendChild(d); }
|
||||
line("运行环境", state.sdkReady ? (state.isTv ? "App · 电视端" : (state.isMobile ? "App · 手机端" : "App")) : "浏览器预览", state.sdkReady ? "on" : "off");
|
||||
line("网盘检测", state.driveCheck ? "已开启" : "已关闭(App 增强功能)", state.driveCheck ? "on" : "off");
|
||||
line("站点地址", config.base);
|
||||
line("TMDB Key", config.tmdbKey ? "已配置" : "未配置(用站点海报)", config.tmdbKey ? "on" : "off");
|
||||
line("网盘检测", state.driveCheck ? "已开启" : "已关闭", state.driveCheck ? "on" : "off");
|
||||
line("指南站点", zn4kBase().replace(/^https?:\/\//, ""), "on");
|
||||
line("七味", qwBase().replace(/^https?:\/\//, ""), "on");
|
||||
line("蜗牛", wnBase().replace(/^https?:\/\//, ""), "on");
|
||||
line("SeedHub", shBase().replace(/^https?:\/\//, ""), "on");
|
||||
woAll().forEach(function (st) { line(st.name, woBase(st).replace(/^https?:\/\//, ""), "on"); });
|
||||
// 枫叶是在线直链播放,跟网盘走的不是同一条播放通道。App 到底提供了哪些播放接口、
|
||||
// 上一次实际用的是哪个、解析出来的地址长什么样,都摆出来,播不出来时一眼能定位。
|
||||
var apis = (typeof fyPlayEntries === "function") ? fyPlayEntries() : [];
|
||||
var apiLine = document.createElement("div");
|
||||
apiLine.className = "status-line"; apiLine.style.gridColumn = "1 / -1";
|
||||
apiLine.innerHTML = "<b>播放接口:</b><span class='" + (apis.length ? "on" : "off") + "'>" + escapeHtml(apis.length ? apis.join("、") : "未检测到(浏览器预览?)") + "</span>";
|
||||
box.appendChild(apiLine);
|
||||
if (state.fyLastUrl) {
|
||||
var urlLine = document.createElement("div");
|
||||
urlLine.className = "status-line"; urlLine.style.gridColumn = "1 / -1";
|
||||
urlLine.innerHTML = "<b>枫叶最近解析:</b><span class='" + (state.fyLastPlayApi ? "on" : "") + "'>" +
|
||||
escapeHtml((state.fyLastPlayApi ? "[" + state.fyLastPlayApi + "] " : "") + state.fyLastUrl) + "</span>";
|
||||
box.appendChild(urlLine);
|
||||
}
|
||||
var loginWay = (config.account && config.password) ? (state.loggedInOnce ? "账号密码(已登录)" : "账号密码(待登录)") : (config.cookie ? "自定义 Cookie" : "内置 Cookie");
|
||||
line("登录方式", loginWay, (config.account && config.password && state.loggedInOnce) ? "on" : "");
|
||||
var loginLine = document.createElement("div");
|
||||
loginLine.className = "status-line"; loginLine.style.gridColumn = "1 / -1";
|
||||
loginLine.innerHTML = "<b>登录方式:</b><span class='" + ((config.account && config.password && state.loggedInOnce) ? "on" : "") + "'>" + escapeHtml(loginWay) + "</span>";
|
||||
box.appendChild(loginLine);
|
||||
}
|
||||
function openSettings() {
|
||||
saveHomeReturn();
|
||||
// 和 openDetail() 同理:如果设置页已经打开(比如遥控器按键连发、点击和长按松开的
|
||||
// “settings”分支几乎同时触发),只是重新刷新一次面板内容,并没有新增一层界面,
|
||||
// 这时绝不能再 push 一条历史记录——否则会在历史栈里留下一条没有对应界面的幽灵记录,
|
||||
// 之后关闭这一层时只会 pop 一次,深度对不上,需要多按一次返回才能耗尽。
|
||||
var alreadyOpen = $("settingsOverlay").classList.contains("active");
|
||||
if (!alreadyOpen) saveViewReturn();
|
||||
$("cfgBase").value = config.base;
|
||||
$("cfgCookie").value = config.cookie || "";
|
||||
$("cfgAccount").value = config.account || "";
|
||||
$("cfgPassword").value = config.password || "";
|
||||
$("cfgTmdb").value = config.tmdbKey || "";
|
||||
$("cfgTmdbImg").value = config.tmdbImg || "image.tmdb.org";
|
||||
$("cfgTmdbImg").value = config.tmdbImg || "images.tmdb.org";
|
||||
if ($("cfgTmdbApi")) $("cfgTmdbApi").value = config.tmdbApi || "";
|
||||
if ($("cfgPanApi")) $("cfgPanApi").value = config.panApi || "";
|
||||
if ($("cfgZn4k")) $("cfgZn4k").value = config.zn4kBase || "";
|
||||
if ($("cfgQw")) $("cfgQw").value = config.qwBase || "";
|
||||
if ($("cfgFy")) $("cfgFy").value = config.fyBase || "";
|
||||
if ($("cfgWn")) $("cfgWn").value = config.wnBase || "";
|
||||
if ($("cfgSh")) $("cfgSh").value = config.shBase || "";
|
||||
if ($("cfgWo")) $("cfgWo").value = (config.woBases && config.woBases.wo) || "";
|
||||
if ($("cfgMo")) $("cfgMo").value = (config.woBases && config.woBases.mo) || "";
|
||||
if ($("cfgPm")) $("cfgPm").value = (config.woBases && config.woBases.pm) || "";
|
||||
state.cfgPanBlockedTmp = (config.panBlockedTypes || []).slice();
|
||||
renderPanTypeChips();
|
||||
if ($("cfgShowEpisodes")) setSwitch($("cfgShowEpisodes"), config.showEpisodes !== false);
|
||||
setLoginState((config.account && config.password) ? (state.loggedInOnce ? "ok" : "") : "", (config.account && config.password) ? (state.loggedInOnce ? "已登录,过期会自动重登" : "已配账号,保存或点登录即生效") : "填账号密码后可自动续登");
|
||||
renderStatus();
|
||||
navigate("settings");
|
||||
navigate("settings", alreadyOpen);
|
||||
var ov = $("settingsOverlay"); ov.classList.add("active"); ov.setAttribute("aria-hidden", "false");
|
||||
state.focusScope = "settings"; setAppFocusable(false); focusFirst(ov);
|
||||
}
|
||||
/* 渲染「网盘类别」开关芯片:点一下切换该类别在 state.cfgPanBlockedTmp(编辑态,未保存前不生效)
|
||||
里的屏蔽状态;亮色 = 显示,暗色 = 屏蔽。真正落地要等点「保存」。 */
|
||||
function renderPanTypeChips() {
|
||||
var box = $("cfgPanTypes");
|
||||
if (!box) return;
|
||||
var frag = document.createDocumentFragment();
|
||||
PAN_TYPE_OPTIONS.forEach(function (name) {
|
||||
var b = document.createElement("button");
|
||||
b.type = "button";
|
||||
var on = state.cfgPanBlockedTmp.indexOf(name) < 0;
|
||||
b.className = "pan-type-chip focusable" + (on ? " on" : "");
|
||||
b.textContent = name;
|
||||
b.setAttribute("aria-pressed", on ? "true" : "false");
|
||||
b.onclick = function () {
|
||||
var idx = state.cfgPanBlockedTmp.indexOf(name);
|
||||
if (idx < 0) state.cfgPanBlockedTmp.push(name); else state.cfgPanBlockedTmp.splice(idx, 1);
|
||||
var nowOn = state.cfgPanBlockedTmp.indexOf(name) < 0;
|
||||
b.classList.toggle("on", nowOn);
|
||||
b.setAttribute("aria-pressed", nowOn ? "true" : "false");
|
||||
};
|
||||
frag.appendChild(b);
|
||||
});
|
||||
box.replaceChildren(frag);
|
||||
}
|
||||
function setLoginState(cls, msg) { var el = $("loginState"); if (!el) return; el.className = "login-state " + (cls || ""); el.textContent = msg; }
|
||||
function doLoginTest() {
|
||||
var acc = $("cfgAccount").value.trim(), pw = $("cfgPassword").value;
|
||||
@@ -2052,13 +8770,16 @@ function doLoginTest() {
|
||||
setLoginState("bad", "登录失败:" + (e && e.message ? e.message : "请检查账号密码"));
|
||||
});
|
||||
}
|
||||
function closeSettings() {
|
||||
var ov = $("settingsOverlay"); ov.classList.remove("active"); ov.setAttribute("aria-hidden", "true");
|
||||
function closeSettings(fromPopstate) {
|
||||
var ov = $("settingsOverlay");
|
||||
if (!fromPopstate && !ov.classList.contains("active")) return; // 已经关过了,避免重复消耗历史深度
|
||||
ov.classList.remove("active"); ov.setAttribute("aria-hidden", "true");
|
||||
state.focusScope = state.currentDetail ? "detail" : "home";
|
||||
setAppFocusable(state.focusScope === "home");
|
||||
restoreHomeReturn();
|
||||
restoreViewReturn();
|
||||
if (!fromPopstate) closeRoute();
|
||||
}
|
||||
function setSwitch(btn, on) { if (on) btn.classList.add("on"); else btn.classList.remove("on"); btn.setAttribute("data-on", on ? "1" : "0"); }
|
||||
function setSwitch(btn, on) { if (on) btn.classList.add("on"); else btn.classList.remove("on"); btn.setAttribute("data-on", on ? "1" : "0"); if (btn.hasAttribute("aria-checked")) btn.setAttribute("aria-checked", on ? "true" : "false"); }
|
||||
function saveConfig() {
|
||||
var b = trimSlash($("cfgBase").value.trim()) || DEFAULT_BASE;
|
||||
if (!/^https?:\/\//.test(b)) b = "https://" + b.replace(/^\/+/, "");
|
||||
@@ -2069,23 +8790,69 @@ function saveConfig() {
|
||||
var credChanged = (newAcc !== config.account) || (newPw !== config.password);
|
||||
config.account = newAcc; config.password = newPw;
|
||||
config.tmdbImg = ($("cfgTmdbImg").value.trim() || "image.tmdb.org").replace(/^https?:\/\//, "").replace(/\/.*$/, "");
|
||||
if ($("cfgTmdbApi")) config.tmdbApi = $("cfgTmdbApi").value.trim().replace(/^https?:\/\//, "").replace(/\/.*$/, "");
|
||||
if ($("cfgPanApi")) config.panApi = $("cfgPanApi").value.trim() || "https://so.252035.xyz";
|
||||
if ($("cfgZn4k")) {
|
||||
var z = trimSlash($("cfgZn4k").value.trim()) || ZN4K_DEFAULT_BASE;
|
||||
if (!/^https?:\/\//.test(z)) z = "https://" + z.replace(/^\/+/, "");
|
||||
config.zn4kBase = z;
|
||||
}
|
||||
if ($("cfgQw")) {
|
||||
var qw = trimSlash($("cfgQw").value.trim()) || QW_DEFAULT_BASE;
|
||||
if (!/^https?:\/\//.test(qw)) qw = "https://" + qw.replace(/^\/+/, "");
|
||||
config.qwBase = qw;
|
||||
}
|
||||
if ($("cfgFy")) {
|
||||
var f = trimSlash($("cfgFy").value.trim()) || FY_DEFAULT_BASE;
|
||||
if (!/^https?:\/\//.test(f)) f = "https://" + f.replace(/^\/+/, "");
|
||||
config.fyBase = f;
|
||||
}
|
||||
if ($("cfgWn")) {
|
||||
var w = trimSlash($("cfgWn").value.trim()) || WN_DEFAULT_BASE;
|
||||
if (!/^https?:\/\//.test(w)) w = "https://" + w.replace(/^\/+/, "");
|
||||
config.wnBase = w;
|
||||
}
|
||||
if ($("cfgSh")) {
|
||||
var sh = trimSlash($("cfgSh").value.trim()) || SH_DEFAULT_BASE;
|
||||
if (!/^https?:\/\//.test(sh)) sh = "https://" + sh.replace(/^\/+/, "");
|
||||
config.shBase = sh;
|
||||
}
|
||||
if (!config.woBases) config.woBases = {};
|
||||
if ($("cfgWo")) { var wo = trimSlash($("cfgWo").value.trim()) || WO_SITES.wo.domains[0]; if (!/^https?:\/\//.test(wo)) wo = "https://" + wo.replace(/^\/+/, ""); config.woBases.wo = wo; }
|
||||
if ($("cfgMo")) { var mo = trimSlash($("cfgMo").value.trim()) || WO_SITES.mo.domains[0]; if (!/^https?:\/\//.test(mo)) mo = "https://" + mo.replace(/^\/+/, ""); config.woBases.mo = mo; }
|
||||
if ($("cfgPm")) { var pm = trimSlash($("cfgPm").value.trim()) || WO_SITES.pm.domains[0]; if (!/^https?:\/\//.test(pm)) pm = "https://" + pm.replace(/^\/+/, ""); config.woBases.pm = pm; }
|
||||
config.panBlockedTypes = (state.cfgPanBlockedTmp || []).slice();
|
||||
if ($("cfgShowEpisodes")) {
|
||||
config.showEpisodes = $("cfgShowEpisodes").classList.contains("on");
|
||||
// 立即生效:用打开详情页时缓存下来的 TMDB 原始数据重绘/清空,不用重新拉网络
|
||||
var cd = state.currentDetail;
|
||||
if (cd && $("edEpisodes")) {
|
||||
if (!config.showEpisodes) $("edEpisodes").replaceChildren();
|
||||
else if (cd.tmdbRawMtype === "tv" && cd.tmdbRawDet && cd.tmdbRawDet.id && cd.tmdbRawDet.seasons && cd.tmdbRawDet.seasons.length) {
|
||||
initEpisodesSection(cd.tmdbRawDet.id, cd.tmdbRawDet.seasons);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 网盘类别屏蔽是纯前端展示过滤,当前详情页已经聚合好的资源不用重新请求网络,
|
||||
// 直接用已有的 state.panItems 按新设置重画一遍网盘资源区即可立即生效。
|
||||
if (state.currentDetail && !state.isCollection && state.panItems && state.panItems.length) renderResources(state.panItems);
|
||||
state.heroRawCache = null; state.homeCatCache = {}; state.homeCatMore = {}; state.zn4kColCache = {}; state.qwCatCache = {}; state.qwGoodBase = ""; state.fyCache = {}; state.wnCache = {}; state.shCache = {}; state.woCache = {}; _listCache = {}; clearTextMemo(); clearHomeDisk(); // 换源/改配置后旧缓存作废(含磁盘缓存),下次刷新重新抓
|
||||
persistConfig().then(function () {
|
||||
toast("已保存");
|
||||
applyChromeMode();
|
||||
if (config.account && config.password && (credChanged || !state.loggedInOnce)) {
|
||||
state.useCookieManager = false;
|
||||
login().then(function () { state.loggedInOnce = true; selectCat(state.activeCat); },
|
||||
function (e) { toast("登录失败:" + (e && e.message ? e.message : "检查账号密码")); selectCat(state.activeCat); });
|
||||
login().then(function () { state.loggedInOnce = true; reloadCurrent(); },
|
||||
function (e) { toast("登录失败:" + (e && e.message ? e.message : "检查账号密码")); reloadCurrent(); });
|
||||
} else {
|
||||
selectCat(state.activeCat);
|
||||
reloadCurrent();
|
||||
}
|
||||
closeSettings(); // 点保存即自动关闭
|
||||
});
|
||||
}
|
||||
function loadConfig() {
|
||||
return storeGet("config").then(function (raw) {
|
||||
if (raw) { try { var o = JSON.parse(raw); if (o.base) config.base = o.base; if (o.tmdbKey) config.tmdbKey = o.tmdbKey; if (o.tmdbImg) config.tmdbImg = o.tmdbImg; if (typeof o.cookie === "string") config.cookie = o.cookie; if (typeof o.account === "string") config.account = o.account; if (typeof o.password === "string") config.password = o.password; if (typeof o.panApi === "string" && o.panApi) config.panApi = o.panApi; } catch (e) {} }
|
||||
if (raw) { try { var o = JSON.parse(raw); if (o.base) config.base = o.base; if (o.tmdbKey) config.tmdbKey = o.tmdbKey; if (o.tmdbImg) config.tmdbImg = o.tmdbImg; if (typeof o.tmdbApi === "string") config.tmdbApi = o.tmdbApi; if (typeof o.cookie === "string") config.cookie = o.cookie; if (typeof o.account === "string") config.account = o.account; if (typeof o.password === "string") config.password = o.password; if (typeof o.panApi === "string" && o.panApi) config.panApi = o.panApi; if (typeof o.zn4kBase === "string" && o.zn4kBase) config.zn4kBase = o.zn4kBase; if (typeof o.qwBase === "string" && o.qwBase) config.qwBase = o.qwBase; if (typeof o.fyBase === "string" && o.fyBase) config.fyBase = o.fyBase; if (typeof o.wnBase === "string" && o.wnBase) config.wnBase = o.wnBase; if (typeof o.shBase === "string" && o.shBase) config.shBase = o.shBase; if (o.woBases && typeof o.woBases === "object") { if (o.woBases.wo) config.woBases.wo = o.woBases.wo; if (o.woBases.mo) config.woBases.mo = o.woBases.mo; } if (Object.prototype.toString.call(o.panBlockedTypes) === "[object Array]") { config.panBlockedTypes = o.panBlockedTypes.filter(function (v) { return typeof v === "string"; }); } if (typeof o.showEpisodes === "boolean") config.showEpisodes = o.showEpisodes; } catch (e) {} }
|
||||
}).then(function () {
|
||||
return storeGet("favs");
|
||||
}).then(function (raw2) {
|
||||
@@ -2094,16 +8861,61 @@ function loadConfig() {
|
||||
}
|
||||
|
||||
/* ---------- 路由 / 返回 ---------- */
|
||||
function navigate(route) { try { history.pushState({ route: route }, "", "#" + route); } catch (e) {} }
|
||||
function navigate(route, replace) {
|
||||
if (!replace) state.stackDepth = (state.stackDepth || 0) + 1;
|
||||
try {
|
||||
var st = { route: route, depth: state.stackDepth };
|
||||
if (replace) history.replaceState(st, "", "#" + route);
|
||||
else history.pushState(st, "", "#" + route);
|
||||
} catch (e) {}
|
||||
}
|
||||
// 关闭某一层时调用:之前用 replaceState 把记录“抹平”,看似不新增记录,
|
||||
// 但 replaceState 根本不会让浏览器/WebView 的真实历史深度减 1——它只是把当前这条
|
||||
// 记录的内容换成了空白,深度其实还留在原地没有被弹出。这样每次“非手势关闭”(点按钮、
|
||||
// 跳到另一个条目等)都会在原生历史栈里悄悄多出一条不对应任何界面的“幽灵记录”。
|
||||
// 手势返回完全由系统/WebView 按自己的历史深度(canGoBack)判断,不看这里的 state,
|
||||
// 一旦深度因为这些幽灵记录而错位,某次手势返回就可能判断“没有可返回的页面”,
|
||||
// 于是走了原生兜底逻辑——直接退出沉浸式全屏,而不是把事件交回页面触发 popstate。
|
||||
// 修正:真正调用 history.back() 把这条记录弹出去,让深度和实际打开的层数严格一致;
|
||||
// 用 suppressNextPop 标记这是“我方主动触发”的一次回退,避免这次 popstate 再重复关闭一遍
|
||||
// (界面在调用 closeRoute 之前已经被调用方自己关掉了)。
|
||||
// 之前用 suppressNextPop 这个单次布尔标志“猜测”下一次 popstate 是否是 closeRoute()
|
||||
// 自己触发的,靠 400ms 超时兜底复位。问题是:popstate 的到达时机并不可靠——
|
||||
// 部分 WebView/系统在没有更多历史记录时会直接走原生兜底(比如退出沉浸式全屏)而不派发
|
||||
// popstate,或者 popstate 因主线程繁忙而延迟到超时之后才到——这些情况下超时会把标志
|
||||
// 提前复位为 false,但真实的历史记录并没有被确认弹出;如果这个窗口期内又发生一次关闭,
|
||||
// closeRoute() 会因为标志还是 true 而直接 return、不调用 history.back(),可 UI 已经关了,
|
||||
// 于是产生“幽灵记录”,需要连按多次返回才能耗尽,表现为“点好几次返回都退不出全屏”。
|
||||
//
|
||||
// 改为深度比对:navigate() 时把逻辑深度写进 history.state.depth,closeRoute() 时记录
|
||||
// “关掉这一层后期望达到的深度”,popstate 到来时直接用 event.state.depth 和期望值核对,
|
||||
// 不再依赖“这次 popstate 是不是我触发的”这种时序猜测,无论 popstate 延迟、丢失还是被
|
||||
// 原生兜底吞掉,下一次任何来源的 popstate 都能用真实深度自我纠正,不会累积幽灵记录。
|
||||
function closeRoute() {
|
||||
state.expectedDepth = (state.stackDepth || 0) - 1;
|
||||
try { history.back(); } catch (e) { state.expectedDepth = null; }
|
||||
}
|
||||
window.addEventListener("popstate", function (event) {
|
||||
// 返回手势:按当前打开的层从上到下关闭一层
|
||||
if ($("settingsOverlay").classList.contains("active")) { closeSettings(); return; }
|
||||
if ($("detailSheet").classList.contains("active")) { closeDetail(); return; }
|
||||
if (state.searchMode) { clearSearch(); return; }
|
||||
var incomingDepth = (event.state && typeof event.state.depth === "number") ? event.state.depth : 0;
|
||||
state.stackDepth = incomingDepth;
|
||||
// 该关的这一层已经关过了(不管这次 popstate 来自我们自己的 back(),还是姗姗来迟、
|
||||
// 或是用户手势追加触发的),深度已经达到或低于期望值,直接消费掉,不再重复关闭下一层。
|
||||
if (state.expectedDepth != null && incomingDepth <= state.expectedDepth) {
|
||||
state.expectedDepth = null;
|
||||
return;
|
||||
}
|
||||
state.expectedDepth = null;
|
||||
// 返回手势:按当前打开的层从上到下关闭一层(浏览器已经弹出了历史记录,这里只需同步关闭对应界面)
|
||||
if ($("settingsOverlay").classList.contains("active")) { closeSettings(true); return; }
|
||||
if ($("detailSheet").classList.contains("active")) { closeDetail(true); return; }
|
||||
if (state.searchPanelOpen) { closeSearchPanel(true); return; } // 搜索面板打开中:回弹关闭,恢复主页
|
||||
if (state.searchMode) { clearSearch(true); return; }
|
||||
if (state.view === "category" || state.view === "recent") { showHome(true); return; }
|
||||
});
|
||||
function handleBack() {
|
||||
if (state.focusScope === "settings") { closeSettings(); return true; }
|
||||
if (state.focusScope === "detail") { closeDetail(); return true; }
|
||||
if (state.searchPanelOpen) { closeSearchPanel(); return true; } // 搜索面板打开中:回弹关闭,恢复主页
|
||||
if (state.searchMode) { clearSearch(); return true; }
|
||||
return false;
|
||||
}
|
||||
@@ -2125,6 +8937,22 @@ function scrollIntoViewSafe(el) {
|
||||
var r = el.getBoundingClientRect(), vh = window.innerHeight || 800;
|
||||
if (r.top < 70) window.scrollBy(0, r.top - 80);
|
||||
else if (r.bottom > vh - 20) window.scrollBy(0, r.bottom - vh + 40);
|
||||
scrollHorizIntoView(el);
|
||||
}
|
||||
/* 横向滚动容器(mh-row / chips / genreChips / cast-row / works-row / qTabs / ep-rail 等)
|
||||
遥控左右移动焦点时不会像鼠标滚轮那样自动带着容器滚,这里手动找最近的可横滚祖先,
|
||||
把焦点卡片顶到可视区域内,不够的话左右各留 8px 缓冲 */
|
||||
function scrollHorizIntoView(el) {
|
||||
var host = el.parentElement;
|
||||
while (host && host !== document.body) {
|
||||
if (host.scrollWidth > host.clientWidth + 2) {
|
||||
var hr = host.getBoundingClientRect(), er = el.getBoundingClientRect();
|
||||
if (er.left < hr.left + 8) host.scrollLeft -= (hr.left + 8 - er.left);
|
||||
else if (er.right > hr.right - 8) host.scrollLeft += (er.right - (hr.right - 8));
|
||||
return;
|
||||
}
|
||||
host = host.parentElement;
|
||||
}
|
||||
}
|
||||
function focusFirst(scopeEl) {
|
||||
var el = scopeEl.querySelector(".focusable");
|
||||
@@ -2201,13 +9029,13 @@ function handleArrow(dir, active) {
|
||||
if (dir === "left" && ai > 0) target = acts[ai - 1];
|
||||
else if (dir === "right" && ai < acts.length - 1) target = acts[ai + 1];
|
||||
else if (dir === "up") target = $("edPlay");
|
||||
else if (dir === "down") target = $("qTabs").querySelector(".focusable") || $("panList").querySelector(".pan-play");
|
||||
else if (dir === "down") target = $("qTabs").querySelector(".focusable") || $("panList").querySelector(".focusable");
|
||||
} else if (row === "qtabs") {
|
||||
var qs = Array.from($("qTabs").children), qi = qs.indexOf(active);
|
||||
if (dir === "left" && qi > 0) target = qs[qi - 1];
|
||||
else if (dir === "right" && qi < qs.length - 1) target = qs[qi + 1];
|
||||
else if (dir === "up") target = $("nativeSearch");
|
||||
else if (dir === "down") target = $("panList").querySelector(".pan-play");
|
||||
else if (dir === "down") target = $("panList").querySelector(".focusable");
|
||||
} else if (row === "coll") {
|
||||
var cc = Array.from($("panList").querySelectorAll(".card")), ci2 = cc.indexOf(active);
|
||||
var cols2 = state.isTv ? 6 : (function(){var w=window.innerWidth||360;return w>=1280?8:w>=1040?7:w>=820?6:w>=700?5:w>=560?4:3;})();
|
||||
@@ -2216,8 +9044,14 @@ function handleArrow(dir, active) {
|
||||
else if (dir === "down") ni2 = ci2 + cols2; else if (dir === "up") ni2 = ci2 - cols2;
|
||||
if (dir === "up" && ci2 < cols2) target = $("nativeSearch");
|
||||
else if (ni2 >= 0 && ni2 < cc.length) target = cc[ni2];
|
||||
} else if (row === "row") {
|
||||
var rc = active.parentElement ? Array.from(active.parentElement.children).filter(function (c) { return c.classList && c.classList.contains("focusable"); }) : [];
|
||||
var ri = rc.indexOf(active);
|
||||
if (dir === "left" && ri > 0) target = rc[ri - 1];
|
||||
else if (dir === "right" && ri < rc.length - 1) target = rc[ri + 1];
|
||||
// 上/下(换到别的行)交给下面的 nearestFocusable 几何兜底,同一行内左右已经精确处理了
|
||||
} else if (row === "panList") {
|
||||
var rows = Array.from($("panList").querySelectorAll(".pan-play")), pi = rows.indexOf(active);
|
||||
var rows = Array.from($("panList").querySelectorAll(".focusable")), pi = rows.indexOf(active);
|
||||
if (dir === "down" && pi < rows.length - 1) target = rows[pi + 1];
|
||||
else if (dir === "up") target = pi > 0 ? rows[pi - 1] : ($("qTabs").querySelector(".focusable") || $("nativeSearch"));
|
||||
}
|
||||
@@ -2270,81 +9104,398 @@ function saveHomeReturn() {
|
||||
function restoreHomeReturn() {
|
||||
if (state.focusScope !== "home") return;
|
||||
var hr = state.homeReturn; if (!hr) return;
|
||||
window.scrollTo(0, hr.scrollY);
|
||||
if (state.isTv && hr.key != null) { var card = $("grid").querySelector("[data-card-index='" + hr.key + "']"); if (card) focusRemoteTarget(card); }
|
||||
scrollHomeTo(hr.scrollY || 0, hr.key);
|
||||
}
|
||||
/* 详情页/设置页关闭后"原地恢复"用的单独槽位——跟 homeReturn 是两回事,不能共用:
|
||||
homeReturn 专门记"从分类/最近观看页离开时,主页当时滚到哪了",只在真正回到主页时使用,
|
||||
且主页内容是异步渲染的,需要 scrollHomeTo 的重试逻辑才够得着目标位置。
|
||||
而这里的 viewReturn 记的是"打开详情/设置这一刻,当前页面(不管是主页还是分类页)滚到哪了",
|
||||
关闭后原地恢复即可,当前页面早就渲染好了,不需要重试。
|
||||
以前两者共用 homeReturn 一个变量,一旦在分类页里点开详情,openDetail() 就会把分类页的
|
||||
滚动位置覆盖写进 homeReturn,等真正退出分类页回主页时用到的就是这个被污染的错误值,
|
||||
表现为"查看全部页返回主页后莫名回到顶部"。拆开后各管各的,互不覆盖。 */
|
||||
function saveViewReturn() {
|
||||
if (state.focusScope !== "home") return;
|
||||
var a = document.activeElement;
|
||||
state.viewReturn = { key: a && a.getAttribute ? a.getAttribute("data-card-index") : null, scrollY: window.pageYOffset || 0 };
|
||||
}
|
||||
function restoreViewReturn() {
|
||||
if (state.focusScope !== "home") return;
|
||||
var vr = state.viewReturn; if (!vr) return;
|
||||
window.scrollTo(0, vr.scrollY || 0);
|
||||
if (state.isTv && vr.key != null) {
|
||||
var scopeEl = (state.view === "category" || state.view === "recent") ? $("grid") : null;
|
||||
if (scopeEl) { var card = scopeEl.querySelector("[data-card-index='" + vr.key + "']"); if (card) focusRemoteTarget(card); }
|
||||
}
|
||||
}
|
||||
/* 滚回指定位置,直到页面真的够高为止。
|
||||
为什么不能只 scrollTo 一次:首页各行的卡片是异步填进去的,文档高度是一点点长起来的,
|
||||
刚返回那一刻页面可能只有几百像素高,scrollTo(0, 1450) 会被浏览器夹到当时的底部,
|
||||
看起来就是"又回到顶部了"。所以按高度重试,够得着了才算数。
|
||||
用户中途自己滑动/触摸就立刻放手,绝不跟人抢滚动。 */
|
||||
function scrollHomeTo(targetY, key) {
|
||||
if (state._scrollTask) { clearInterval(state._scrollTask); state._scrollTask = 0; }
|
||||
function focusCard() {
|
||||
if (state.isTv && key != null) { var c = $("grid") && $("grid").querySelector("[data-card-index='" + key + "']"); if (c) focusRemoteTarget(c); }
|
||||
}
|
||||
if (!targetY || targetY <= 0) { window.scrollTo(0, 0); focusCard(); return; }
|
||||
var tries = 0, abort = false, settledTicks = 0;
|
||||
var SETTLE_NEEDED = 6; // 到位后再稳稳保持这么多个 tick(约 600ms)才算数
|
||||
function bail() { abort = true; }
|
||||
window.addEventListener("touchstart", bail, { passive: true });
|
||||
window.addEventListener("wheel", bail, { passive: true });
|
||||
function stop() {
|
||||
clearInterval(state._scrollTask); state._scrollTask = 0;
|
||||
window.removeEventListener("touchstart", bail);
|
||||
window.removeEventListener("wheel", bail);
|
||||
}
|
||||
function tick() {
|
||||
if (abort || state.view !== "home") { stop(); return; }
|
||||
var doc = Math.max(document.documentElement.scrollHeight || 0, document.body ? document.body.scrollHeight : 0);
|
||||
var max = Math.max(0, doc - (window.innerHeight || 0));
|
||||
// 到位了也不立刻收手、更不能停止赋值:宿主 WebView 有时会在这之后再自己触发一次异步的
|
||||
// 滚动恢复,把我们刚设好的位置又盖回顶部;这里持续按目标值纠偏一小段时间,每 tick 都重新
|
||||
// 赋值一次,谁盖谁没用,我们这边最后一次赋值才是最终生效的那个。
|
||||
window.scrollTo(0, Math.min(targetY, max));
|
||||
var now = window.pageYOffset || document.documentElement.scrollTop || 0;
|
||||
if (Math.abs(now - targetY) < 4) {
|
||||
if (++settledTicks >= SETTLE_NEEDED) { stop(); focusCard(); return; }
|
||||
} else {
|
||||
settledTicks = 0;
|
||||
}
|
||||
if (++tries > 100) { stop(); return; } // 约 10 秒后认了:内容确实没那么长,或者有别的东西一直在抢
|
||||
}
|
||||
tick();
|
||||
state._scrollTask = setInterval(tick, 100);
|
||||
}
|
||||
|
||||
/* ---------- 工具 ---------- */
|
||||
function el(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }
|
||||
/* 详情页骨架屏:概览 3 行 / 演员 6 个头像 / 网盘资源 4 条,纯占位 DOM,不含文本节点
|
||||
(所以不会影响调用方 `!ov.textContent` 这类"是否已有内容"的判断),数据到手后被真实内容覆盖即可。 */
|
||||
function overviewSkeleton() {
|
||||
var wrap = el("div", "ov-skel");
|
||||
for (var i = 0; i < 3; i++) wrap.appendChild(el("div", "detail-skel-bar"));
|
||||
return wrap;
|
||||
}
|
||||
function castSkeleton() {
|
||||
var wrap = el("div", "cast-skel");
|
||||
for (var i = 0; i < 6; i++) {
|
||||
var item = el("div", "cast-skel-item");
|
||||
item.appendChild(el("div", "detail-skel-bar av"));
|
||||
item.appendChild(el("div", "detail-skel-bar nm"));
|
||||
wrap.appendChild(item);
|
||||
}
|
||||
return wrap;
|
||||
}
|
||||
function panListSkeleton() {
|
||||
var wrap = el("div", "pan-skel");
|
||||
for (var i = 0; i < 4; i++) {
|
||||
var item = el("div", "pan-skel-item");
|
||||
item.appendChild(el("div", "detail-skel-bar ic"));
|
||||
var body = el("div", "pan-skel-body");
|
||||
body.appendChild(el("div", "detail-skel-bar t1"));
|
||||
body.appendChild(el("div", "detail-skel-bar t2"));
|
||||
item.appendChild(body);
|
||||
wrap.appendChild(item);
|
||||
}
|
||||
return wrap;
|
||||
}
|
||||
function epRailSkeleton() {
|
||||
var rail = el("div", "ep-skel-rail");
|
||||
for (var i = 0; i < 4; i++) {
|
||||
var card = el("div", "ep-skel-card");
|
||||
card.appendChild(el("div", "detail-skel-bar thumb"));
|
||||
card.appendChild(el("div", "detail-skel-bar line1"));
|
||||
card.appendChild(el("div", "detail-skel-bar line2"));
|
||||
rail.appendChild(card);
|
||||
}
|
||||
return rail;
|
||||
}
|
||||
function trimSlash(s) { return (s || "").replace(/\/+$/, ""); }
|
||||
/* 给某个域名动态插入 preconnect 提示(用户在设置里改了源/图床/网盘接口时用得上),
|
||||
同一个域名只插一次,避免重复。静默失败:老内核不认 rel=preconnect 也没关系,不影响功能 */
|
||||
var _preconnected = {};
|
||||
function addPreconnect(url) {
|
||||
try {
|
||||
var m = /^https?:\/\/[^\/]+/.exec(url || ""); if (!m) return;
|
||||
var origin = m[0]; if (_preconnected[origin]) return;
|
||||
_preconnected[origin] = true;
|
||||
var l1 = document.createElement("link"); l1.rel = "preconnect"; l1.href = origin; l1.crossOrigin = "anonymous"; document.head.appendChild(l1);
|
||||
var l2 = document.createElement("link"); l2.rel = "dns-prefetch"; l2.href = origin; document.head.appendChild(l2);
|
||||
} catch (e) {}
|
||||
}
|
||||
function escapeHtml(s) { return String(s == null ? "" : s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'"); }
|
||||
function hashKey(s) { s = String(s || ""); var h = 5381, i = s.length; while (i) { h = (h * 33) ^ s.charCodeAt(--i); } return (h >>> 0).toString(16); }
|
||||
|
||||
/* ---------- 事件 ---------- */
|
||||
/* ============ 苹果橡皮筋回弹 ============
|
||||
Android WebView 默认没有 iOS 那种越界回弹。这里用 JS 在滚动"到边了还继续拉"时,
|
||||
给滚动容器加一个带阻尼的 translateY(越拉越难,橡皮筋手感),松手加 .rubber-snap
|
||||
过渡弹回原位。只在越界那一刻介入,正常滚动完全不接管,不影响性能和惯性。
|
||||
作用对象:主页/分类页(window 滚动的 .app)的底部越界;详情页 sheet 的顶/底越界。
|
||||
主页顶部已有"下拉刷新",那块不重复做,交给下拉刷新处理。 */
|
||||
function initRubberBand() {
|
||||
var MAX = 120; // 最大拉伸位移
|
||||
function damp(d) { return MAX * (1 - Math.exp(-Math.abs(d) / MAX)) * (d < 0 ? -1 : 1); } // 指数阻尼:越拉越慢
|
||||
|
||||
// —— 详情页 sheet:按需求不做橡皮筋 ——
|
||||
// —— 主页/分类:window 滚动,套在 .app 上,只做底部(顶部让给下拉刷新)——
|
||||
bindWindow($("app"));
|
||||
|
||||
function bindOne(scroller, doTop, doBottom) {
|
||||
if (!scroller) return;
|
||||
var startY = 0, startX = 0, pulling = 0, active = false, dir = 0, axis = 0;
|
||||
scroller.addEventListener("touchstart", function (e) {
|
||||
if (e.touches.length !== 1) { active = false; return; }
|
||||
startY = e.touches[0].clientY; startX = e.touches[0].clientX; pulling = 0; active = true; dir = 0; axis = 0;
|
||||
scroller.classList.remove("rubber-snap");
|
||||
}, { passive: true });
|
||||
scroller.addEventListener("touchmove", function (e) {
|
||||
if (!active) return;
|
||||
var dy = e.touches[0].clientY - startY;
|
||||
var dx = e.touches[0].clientX - startX;
|
||||
if (axis === 0) {
|
||||
if (Math.abs(dx) > 8 || Math.abs(dy) > 8) axis = Math.abs(dx) > Math.abs(dy) ? 1 : 2;
|
||||
}
|
||||
if (axis === 1) { active = false; return; } // 横向手势:放手给横排滚动
|
||||
if (axis !== 2 && pulling === 0) return;
|
||||
var top = scroller.scrollTop;
|
||||
var atTop = top <= 0;
|
||||
var atBottom = top + scroller.clientHeight >= scroller.scrollHeight - 1;
|
||||
if (dy > 0 && atTop && doTop) dir = 1;
|
||||
else if (dy < 0 && atBottom && doBottom) dir = -1;
|
||||
else if (pulling === 0) { dir = 0; return; } // 不在越界方向,交给正常滚动
|
||||
if (dir === 0) return;
|
||||
// 越界拉伸:阻止默认滚动,改用 transform
|
||||
if (e.cancelable) e.preventDefault();
|
||||
pulling = damp(dy);
|
||||
scroller.style.transform = "translateY(" + pulling + "px)";
|
||||
}, { passive: false });
|
||||
function end() {
|
||||
if (!active) { axis = 0; return; } active = false;
|
||||
if (pulling !== 0) {
|
||||
scroller.classList.add("rubber-snap");
|
||||
scroller.style.transform = "translateY(0)";
|
||||
pulling = 0;
|
||||
var el = scroller;
|
||||
setTimeout(function () { el.classList.remove("rubber-snap"); el.style.transform = ""; }, 460);
|
||||
}
|
||||
axis = 0;
|
||||
}
|
||||
scroller.addEventListener("touchend", end, { passive: true });
|
||||
scroller.addEventListener("touchcancel", end, { passive: true });
|
||||
}
|
||||
|
||||
// window 滚动版:位移作用在 .app 上,底部越界回弹
|
||||
function bindWindow(appEl) {
|
||||
if (!appEl) return;
|
||||
// .chips 底部导航栏是 position:fixed,但它是 #app 的子元素——CSS 规定祖先一旦带 transform,
|
||||
// 内部 fixed 元素就会变成相对该祖先定位,不再"焊死"在视口上。所以 #app 橡皮筋位移的时候,
|
||||
// 导航栏会跟着一起被拖动、松手时再跟着弹一下,看着像"跳动"。这里给它叠加一个反向的
|
||||
// translateY,实时抵消掉 #app 的位移,视觉上导航栏全程纹丝不动。
|
||||
var chipsEl = $("chips");
|
||||
var startY = 0, startX = 0, pulling = 0, active = false, armed = false, axis = 0;
|
||||
// 回弹结束后延时460ms才彻底清transform;如果这460ms内用户又快速滑了一次,
|
||||
// 旧的这个setTimeout不会自动取消,到时间照样会触发,把新手势/新回弹中途的transform强行清空——
|
||||
// 这就是"连续快速上下滑动导航栏还会跳一下"的根子。用snapTimer记着,每次开始新手势或
|
||||
// 触发新一轮回弹前,先取消上一个还没触发的清理定时器,保证同一时刻只有最新这一个在生效。
|
||||
var snapTimer = null;
|
||||
function clearSnapTimer() { if (snapTimer) { clearTimeout(snapTimer); snapTimer = null; } }
|
||||
function scrollY() { return window.pageYOffset || document.documentElement.scrollTop || 0; }
|
||||
function maxScroll() { return Math.max(0, (document.documentElement.scrollHeight || 0) - (window.innerHeight || 0)); }
|
||||
document.addEventListener("touchstart", function (e) {
|
||||
if (e.touches.length !== 1) { active = false; return; }
|
||||
// 详情页开着、搜索面板、或手指在横滚区/弹窗里,都不接管
|
||||
if (state.focusScope === "detail" || state.searchPanelOpen) { active = false; return; }
|
||||
if (e.target && e.target.closest && e.target.closest(".chips, .genre-chips, .rail, .sheet, .panel-overlay, .search-mask, .search-panel, .ed-cast-row, .ed-qtabs, .pan-list")) { active = false; return; }
|
||||
startY = e.touches[0].clientY; startX = e.touches[0].clientX; pulling = 0; active = true; armed = false; axis = 0;
|
||||
clearSnapTimer();
|
||||
appEl.classList.remove("rubber-snap");
|
||||
if (chipsEl) chipsEl.classList.remove("rubber-snap");
|
||||
// 只在"确有残留 transform"时才清空。普通滑动本就没有残留,无谓地写 .chips 的 transform
|
||||
// 会让它的 backdrop-filter 合成层被拆掉重建,视觉上闪一下(消失一帧再出现)。
|
||||
// 读 .style.transform 只反映行内值:没有行内 transform 时它是空串,直接跳过、绝不去碰。
|
||||
if (appEl.style.transform) appEl.style.transform = "";
|
||||
if (chipsEl && chipsEl.style.transform) chipsEl.style.transform = "";
|
||||
}, { passive: true });
|
||||
document.addEventListener("touchmove", function (e) {
|
||||
if (!active) return;
|
||||
var dy = e.touches[0].clientY - startY;
|
||||
var dx = e.touches[0].clientX - startX;
|
||||
// 先锁定主方向:横向手势明显时直接放弃竖向橡皮筋,交给左右滑/横排滚动
|
||||
if (axis === 0) {
|
||||
if (Math.abs(dx) > 8 || Math.abs(dy) > 8) axis = Math.abs(dx) > Math.abs(dy) ? 1 : 2;
|
||||
}
|
||||
if (axis === 1) { active = false; return; } // 横向:不接管
|
||||
if (axis !== 2) return; // 方向还没定,先不动
|
||||
// 只处理"到底了还往上拉"(dy<0 且已在最底部)
|
||||
if (dy < 0 && scrollY() >= maxScroll() - 1) armed = true;
|
||||
if (!armed) return;
|
||||
if (e.cancelable) e.preventDefault();
|
||||
pulling = damp(dy);
|
||||
appEl.style.transform = "translateY(" + pulling + "px)";
|
||||
if (chipsEl) chipsEl.style.transform = "translateX(-50%) translateY(" + (-pulling) + "px)";
|
||||
}, { passive: false });
|
||||
function end() {
|
||||
if (!active) { armed = false; axis = 0; return; }
|
||||
active = false;
|
||||
clearSnapTimer();
|
||||
if (pulling !== 0) {
|
||||
// 这一次确实拉动过:带过冲缓动弹回 0,460ms 后彻底清掉 transform。
|
||||
appEl.classList.add("rubber-snap");
|
||||
appEl.style.transform = "translateY(0)";
|
||||
if (chipsEl) { chipsEl.classList.add("rubber-snap"); chipsEl.style.transform = "translateX(-50%) translateY(0)"; }
|
||||
var el = appEl;
|
||||
// 回弹动画结束后彻底清掉 transform —— 否则 #app 长期带着 transform,
|
||||
// 会让内部 position:fixed 的底部导航栏(.chips)失去"相对视口固定",变成跟着页面滚动。
|
||||
snapTimer = setTimeout(function () {
|
||||
snapTimer = null;
|
||||
el.classList.remove("rubber-snap"); el.style.transform = "";
|
||||
if (chipsEl) { chipsEl.classList.remove("rubber-snap"); chipsEl.style.transform = ""; }
|
||||
}, 460);
|
||||
} else {
|
||||
// 这一次没拉动(典型:到底猛拉→松手→没等回弹结束就反手上滑,把上一轮清理定时器取消掉)。
|
||||
// 若上一轮在 #app 上残留了 transform(哪怕是 translateY(0)),会一直破坏 .chips 的 fixed,
|
||||
// 表现为"导航栏掉下去跟着页面滚",所以要清。但同样只在确有残留时才写,避免无谓触碰
|
||||
// .chips 的 transform 引起 backdrop-filter 闪烁。
|
||||
appEl.classList.remove("rubber-snap");
|
||||
if (chipsEl) chipsEl.classList.remove("rubber-snap");
|
||||
if (appEl.style.transform) appEl.style.transform = "";
|
||||
if (chipsEl && chipsEl.style.transform) chipsEl.style.transform = "";
|
||||
}
|
||||
pulling = 0; armed = false; axis = 0;
|
||||
}
|
||||
document.addEventListener("touchend", end, { passive: true });
|
||||
document.addEventListener("touchcancel", end, { passive: true });
|
||||
}
|
||||
}
|
||||
function bindEvents() {
|
||||
// 筛选标签栏横向滑动已改为原生滚动(见 .genre-chips 的 touch-action: pan-x),不再需要手动绑定拖动
|
||||
var sm = $("searchMask"); if (sm) sm.addEventListener("click", function () { if (state.searchPanelOpen) closeSearchPanel(); });
|
||||
$("searchGo").onclick = function () { hideSuggest(); startSearch($("search").value); };
|
||||
$("search").addEventListener("keydown", function (e) { if ((e.key === "Enter" || e.keyCode === 13) && !this.hasAttribute("readonly")) { hideSuggest(); startSearch(this.value); } });
|
||||
$("search").addEventListener("input", function () { if (state.suggestTimer) clearTimeout(state.suggestTimer); state.suggestTimer = setTimeout(runSuggest, 260); });
|
||||
$("search").addEventListener("blur", function () { setTimeout(function () { hideSuggest(); if (!state.searchMode) $("search").value = ""; }, 250); });
|
||||
$("clearSearch").onclick = clearSearch;
|
||||
$("historyBtn").onclick = function () {
|
||||
if (state.activeCat === "__recent__") {
|
||||
selectCat(state.prevCat != null ? state.prevCat : ""); // 再点 -> 回弹到之前的分类
|
||||
} else {
|
||||
state.prevCat = state.activeCat; // 记住当前分类
|
||||
selectCat("__recent__");
|
||||
}
|
||||
var on = state.activeCat === "__recent__";
|
||||
this.classList.toggle("active", on);
|
||||
this.setAttribute("aria-pressed", on ? "true" : "false");
|
||||
};
|
||||
$("search").addEventListener("blur", function () { setTimeout(function () { hideSuggest(); if (!state.searchMode) { $("search").value = ""; closeSearchPanel(); } }, 220); });
|
||||
$("clearSearch").onclick = function (e) { if (e && e.stopPropagation) e.stopPropagation(); clearSearch(); }; // 同上:不能直接引用,否则 click 事件会被当 fromPopstate 传入,closeRoute() 被跳过;stopPropagation 防止冒泡到外层搜索框触发 openSearchPanel
|
||||
// 查看全部/分类页 左上角返回:同样不能直接写 onclick = showHome,click 事件对象会被当成 fromPopstate 参数传进去,
|
||||
// 导致 showHome() 里 wasPushedView && !fromPopstate 判断为假,closeRoute() 被跳过、历史栈和 UI 深度错位。
|
||||
var catBackBtn = $("catBack");
|
||||
if (catBackBtn) catBackBtn.addEventListener("click", function () { showHome(); });
|
||||
// 搜索结果页头部的"搜索框"按钮:点它重新打开搜索面板(面板里的输入框仍保留着上次的关键词,可直接改)。
|
||||
var searchBarBtn = $("searchBar");
|
||||
if (searchBarBtn) searchBarBtn.addEventListener("click", function () { openSearchPanel(); });
|
||||
(function () {
|
||||
var brand = $("brandBtn");
|
||||
var LONG_MS = 550, lpTimer = null, lpFired = false, lpTouchHandled = false, lpResetTimer = null;
|
||||
var box = $("chips");
|
||||
if (!box) return;
|
||||
var LONG_MS = 550, lpTimer = null, lpTouchHandled = false, lpResetTimer = null;
|
||||
function settingsTarget(t) { return t && t.closest ? t.closest("[data-nav='settings']") : null; }
|
||||
function lpStart() {
|
||||
lpFired = false; clearTimeout(lpTimer);
|
||||
clearTimeout(lpTimer);
|
||||
lpTimer = setTimeout(function () {
|
||||
lpFired = true;
|
||||
state.chipLpFired = true;
|
||||
cycleTheme();
|
||||
if (navigator.vibrate) { try { navigator.vibrate(12); } catch (e) {} }
|
||||
}, LONG_MS);
|
||||
}
|
||||
function lpCancel() { clearTimeout(lpTimer); }
|
||||
function lpEnd(e) { clearTimeout(lpTimer); if (lpFired && e && e.cancelable) e.preventDefault(); }
|
||||
brand.addEventListener("touchstart", function () { lpTouchHandled = true; clearTimeout(lpResetTimer); lpStart(); }, { passive: true });
|
||||
brand.addEventListener("touchmove", lpCancel, { passive: true });
|
||||
brand.addEventListener("touchcancel", lpCancel, { passive: true });
|
||||
brand.addEventListener("touchend", function (e) { lpEnd(e); lpResetTimer = setTimeout(function () { lpTouchHandled = false; }, 500); }, { passive: false });
|
||||
brand.addEventListener("mousedown", function () { if (lpTouchHandled) return; lpStart(); });
|
||||
brand.addEventListener("mouseup", lpEnd);
|
||||
brand.addEventListener("mouseleave", lpCancel);
|
||||
brand.addEventListener("contextmenu", function (e) { e.preventDefault(); });
|
||||
brand.onclick = function () { if (lpFired) { lpFired = false; return; } openSettings(); };
|
||||
function lpEnd(e) { clearTimeout(lpTimer); if (state.chipLpFired && e && e.cancelable) e.preventDefault(); }
|
||||
box.addEventListener("touchstart", function (e) {
|
||||
if (!settingsTarget(e.target)) return;
|
||||
lpTouchHandled = true; clearTimeout(lpResetTimer); lpStart();
|
||||
}, { passive: true });
|
||||
box.addEventListener("touchmove", lpCancel, { passive: true });
|
||||
box.addEventListener("touchcancel", lpCancel, { passive: true });
|
||||
box.addEventListener("touchend", function (e) {
|
||||
if (!settingsTarget(e.target)) { lpCancel(); return; }
|
||||
lpEnd(e);
|
||||
lpResetTimer = setTimeout(function () { lpTouchHandled = false; state.chipLpFired = false; }, 500);
|
||||
}, { passive: false });
|
||||
box.addEventListener("mousedown", function (e) {
|
||||
if (lpTouchHandled || !settingsTarget(e.target)) return;
|
||||
lpStart();
|
||||
});
|
||||
box.addEventListener("mouseup", function (e) {
|
||||
if (!settingsTarget(e.target)) return;
|
||||
lpEnd(e);
|
||||
});
|
||||
box.addEventListener("mouseleave", lpCancel);
|
||||
box.addEventListener("contextmenu", function (e) { if (settingsTarget(e.target)) e.preventDefault(); });
|
||||
})();
|
||||
$("edPlay").onclick = playBest;
|
||||
$("detailOverview").onclick = function () { this.classList.toggle("clamp"); };
|
||||
$("edRefresh").onclick = function () { if (state.currentItem) { $("panList").replaceChildren(el("div", "pan-loading", "重新读取…")); state.panToken++; loadResources(state.currentItem); loadTmdb(state.currentItem); } };
|
||||
$("nativeSearch").onclick = runPanSou; /* 盘搜 PanSou */
|
||||
$("appSearchBtn").onclick = function () { var d = state.currentDetail; if (d && window.fm && fm.search) fm.search(d.rawTitle || d.title, { direct: true, pic: d.poster, wallPic: (d.tmdb && d.tmdb.backdrop) || "" }); else if (d) toast("请在 App 内使用原生搜索"); };
|
||||
$("edRefresh").onclick = function () {
|
||||
var it = state.currentItem; if (!it) return;
|
||||
$("panList").replaceChildren(panListSkeleton());
|
||||
state.panItems = [];
|
||||
state.panToken++; state.panDiag = [];
|
||||
if (it._zn4k) { zn4kLoadById(it._zn4kId, state.panToken); }
|
||||
else if (it._qw) { qwLoadById(it._qwId, state.panToken); }
|
||||
else { loadResources(it); zn4kEnrich(it.title, state.panToken); qwEnrich(it.title, state.panToken); }
|
||||
loadTmdb(it);
|
||||
if (config.panApi) { var pt = state.panToken; setTimeout(function () { if (pt === state.panToken) runPanSou(true); }, 350); }
|
||||
};
|
||||
$("nativeSearch").onclick = function () { runPanSou(); }; /* 盘搜 PanSou;同上不能直接引用,否则 click 事件会被当 silent=true 传入,正常点击时的提示会被吞掉 */
|
||||
$("appSearchBtn").onclick = function () {
|
||||
var d = state.currentDetail;
|
||||
if (d && window.fm && fm.search) {
|
||||
Promise.resolve(fm.search(d.rawTitle || d.title, { direct: true, pic: d.poster }))
|
||||
.then(function () {}, function () { toast("搜索失败,请稍后重试"); });
|
||||
} else if (d) {
|
||||
toast("请在 App 内使用原生搜索");
|
||||
}
|
||||
};
|
||||
$("cfgSave").onclick = saveConfig;
|
||||
if ($("cfgShowEpisodes")) $("cfgShowEpisodes").onclick = function () { setSwitch(this, !this.classList.contains("on")); };
|
||||
$("cfgLogin").onclick = doLoginTest;
|
||||
$("cfgClose").onclick = closeSettings;
|
||||
$("cfgCloseX").onclick = closeSettings;
|
||||
// 注意:不能直接 `$("cfgClose").onclick = closeSettings`——浏览器触发 click 时会把
|
||||
// 事件对象当第一个参数传进去,等于变成 closeSettings(clickEvent),而 clickEvent 恒为
|
||||
// truthy,会被当成 fromPopstate=true,导致 `if (!fromPopstate) closeRoute()` 永远不执行,
|
||||
// 点“关闭”/“✕”关掉设置页时历史记录永远不会被弹出,每开关一次就净增一条幽灵记录。
|
||||
// 必须用无参调用包一层。
|
||||
$("cfgClose").onclick = function () { closeSettings(); };
|
||||
$("cfgCloseX").onclick = function () { closeSettings(); };
|
||||
$("settingsOverlay").addEventListener("click", function (e) { if (e.target === this) closeSettings(); });
|
||||
$("confirmCancel").onclick = function () { closeConfirm(); };
|
||||
$("confirmOverlay").addEventListener("click", function (e) { if (e.target === this) closeConfirm(); });
|
||||
document.addEventListener("keydown", onKeydown, true);
|
||||
window.addEventListener("fmviewport", function () { gridColsCache.key = ""; if (typeof syncSafeAreaClass === "function") syncSafeAreaClass(); });
|
||||
window.addEventListener("resize", function () { gridColsCache.key = ""; });
|
||||
window.addEventListener("fmresume", function () { loadRecent(); });
|
||||
// 手机横竖屏切换:详情页大图轮播要跟着换池(竖版海报 <-> 横版剧照),见 wantBackdropHero /
|
||||
// rebuildDetailHeroForOrientation。用 matchMedia 监听比 resize/orientationchange 更准,
|
||||
// 键盘弹出之类的高度变化不会误触发;resize 兜底一下不支持 matchMedia 变更监听的旧浏览器。
|
||||
(function () {
|
||||
try {
|
||||
var mq = window.matchMedia && matchMedia("(orientation: landscape)");
|
||||
if (mq && mq.addEventListener) mq.addEventListener("change", scheduleHeroRebuild);
|
||||
else if (mq && mq.addListener) mq.addListener(scheduleHeroRebuild); // 老 Safari / 老内核兜底
|
||||
} catch (e) {}
|
||||
window.addEventListener("orientationchange", scheduleHeroRebuild);
|
||||
window.addEventListener("resize", scheduleHeroRebuild);
|
||||
// App 内嵌 WebView 旋转时,方向变化是通过 fmviewport 事件下发的(原生重新测量后才通知页面)。
|
||||
// 之前这个事件只用来清网格列数缓存,没接详情页大图,所以 App 里手机/平板转横屏时轮播池根本不会换 ——
|
||||
// 横版剧照不生效的主因。这里补上。
|
||||
window.addEventListener("fmviewport", scheduleHeroRebuild);
|
||||
// visualViewport 在 WebView 里比 window.resize 更早、更准地反映旋转后的真实视口
|
||||
try { if (window.visualViewport && window.visualViewport.addEventListener) window.visualViewport.addEventListener("resize", scheduleHeroRebuild); } catch (e) {}
|
||||
})();
|
||||
window.addEventListener("fmresume", function (event) {
|
||||
loadRecent();
|
||||
if (window.fm && fm.ctrl) { try { fm.ctrl("stop"); } catch (e) {} }
|
||||
var pausedMs = event && event.detail ? event.detail.pausedMs : undefined;
|
||||
handleAutoNextResume(pausedMs);
|
||||
});
|
||||
|
||||
// 下拉刷新(仅在页面顶部、主页/搜索/继续观看时)
|
||||
// 下拉刷新:严格只在首页(state.view === "home")生效,"查看全部"分类页/最近观看/
|
||||
// 搜索结果页一律不响应——这些页面要么有横向筛选手势会跟下拉打架,要么下拉刷新语义上就不对。
|
||||
var ptr = $("ptr"), pStartY = 0, pDist = 0, pActive = false, pRefreshing = false;
|
||||
var PT = 72; // 触发阈值
|
||||
function atTop() { return (window.pageYOffset || document.documentElement.scrollTop || 0) <= 2; }
|
||||
function ptrStart(e) {
|
||||
if (pRefreshing || state.focusScope !== "home") return;
|
||||
if (pRefreshing || state.focusScope !== "home" || state.searchPanelOpen || state.view !== "home") return;
|
||||
var t = e.touches && e.touches[0]; if (!t) return;
|
||||
if (!atTop()) { pActive = false; return; }
|
||||
if (e.target && e.target.closest && e.target.closest(".chips, .genre-chips, .rail, .sheet, .panel-overlay")) { pActive = false; return; }
|
||||
if (e.target && e.target.closest && e.target.closest(".chips, .genre-chips, .rail, .sheet, .panel-overlay, .search-mask, .search-panel")) { pActive = false; return; }
|
||||
pStartY = t.clientY; pDist = 0; pActive = true;
|
||||
}
|
||||
function ptrMove(e) {
|
||||
@@ -2379,12 +9530,15 @@ function bindEvents() {
|
||||
document.addEventListener("touchmove", ptrMove, { passive: true });
|
||||
document.addEventListener("touchend", ptrEnd, { passive: true });
|
||||
|
||||
// 左右滑动切换分类(主页;阈值较高,避免误触;忽略横滚/输入/详情)
|
||||
initRubberBand();
|
||||
|
||||
// 左右滑动切换:主页切换分类;“查看全部”页(state.view==="category")切换筛选标签;
|
||||
// “最近观看”页没有筛选标签可切,不响应。阈值较高,避免误触;忽略横滚/输入/详情
|
||||
var sx = 0, sy = 0, sTrack = false, sMoved = false;
|
||||
function swStart(e) {
|
||||
if (state.focusScope !== "home") { sTrack = false; return; }
|
||||
if (state.focusScope !== "home" || state.searchPanelOpen || state.view === "recent") { sTrack = false; return; }
|
||||
var t = e.touches && e.touches[0]; if (!t) { sTrack = false; return; }
|
||||
if (e.target && e.target.closest && e.target.closest(".chips, .genre-chips, .rail, input, .sheet, .panel-overlay, .ed-cast-row, .ed-qtabs")) { sTrack = false; return; }
|
||||
if (e.target && e.target.closest && e.target.closest(".chips, .genre-chips, .rail, input, .sheet, .panel-overlay, .ed-cast-row, .ed-qtabs, .hero-wrap, .search-mask, .search-panel")) { sTrack = false; return; }
|
||||
sx = t.clientX; sy = t.clientY; sMoved = false; sTrack = true;
|
||||
}
|
||||
function swMove(e) {
|
||||
@@ -2396,7 +9550,10 @@ function bindEvents() {
|
||||
if (!sTrack || !sMoved) { sTrack = false; return; }
|
||||
var t = e.changedTouches && e.changedTouches[0]; sTrack = false; if (!t) return;
|
||||
var dx = t.clientX - sx, dy = t.clientY - sy;
|
||||
if (Math.abs(dx) > 70 && Math.abs(dx) > Math.abs(dy) * 2) swipeCategory(dx < 0 ? 1 : -1);
|
||||
if (Math.abs(dx) > 70 && Math.abs(dx) > Math.abs(dy) * 2) {
|
||||
var delta = dx < 0 ? 1 : -1;
|
||||
if (state.view === "category") swipeGenre(delta); else swipeCategory(delta);
|
||||
}
|
||||
}
|
||||
document.addEventListener("touchstart", swStart, { passive: true });
|
||||
document.addEventListener("touchmove", swMove, { passive: true });
|
||||
@@ -2411,10 +9568,18 @@ function bindEvents() {
|
||||
|
||||
/* ---------- 启动 ---------- */
|
||||
function boot() {
|
||||
// 关掉浏览器自带的滚动恢复。默认 'auto' 时,popstate 之后浏览器会自己滚一次;
|
||||
// 而进分类页时首页被 display:none、文档高度塌掉,它记下的位置早被夹成 0,
|
||||
// 那一次异步的自动恢复会把我们刚恢复好的位置盖回顶部。
|
||||
try { if (window.history && "scrollRestoration" in history) history.scrollRestoration = "manual"; } catch (e) {}
|
||||
detectDevice();
|
||||
loadTheme();
|
||||
renderChips();
|
||||
bindEvents();
|
||||
// 骨架屏必须在这里就位:往下要等 SDK 就绪、拉配置、登录,实测 1.7 秒才轮到渲染内容。
|
||||
// 原来骨架屏是等这些都跑完、内容已经画出来之后才盖上去的,
|
||||
// 观感就成了"加载好了 → 黑屏 → 又加载一次"。
|
||||
showHomeSkeleton();
|
||||
whenSdk().then(function (ready) {
|
||||
state.sdkReady = ready;
|
||||
if (ready) {
|
||||
@@ -2427,10 +9592,24 @@ function boot() {
|
||||
return loadConfig();
|
||||
}).then(function () {
|
||||
applyChromeMode(); // 用加载后的真实 homeFull 校正 chrome + 安全区 class
|
||||
addPreconnect(config.base);
|
||||
addPreconnect("https://" + (config.tmdbImg || "image.tmdb.org"));
|
||||
addPreconnect("https://" + (config.tmdbApi || "api.tmdb.org"));
|
||||
addPreconnect(config.panApi);
|
||||
addPreconnect(zn4kBase());
|
||||
addPreconnect(qwBase());
|
||||
addPreconnect(wnBase());
|
||||
addPreconnect(shBase());
|
||||
woAll().forEach(function (st) { addPreconnect(woBase(st)); });
|
||||
return ensureLogin(); // 配了账号密码则先登录拿新 Cookie(失败不阻塞)
|
||||
}).then(function () {
|
||||
loadRecent();
|
||||
loadList(true);
|
||||
// 先把上次的首页内容 + 观看记录读回来(并行),首屏直接有内容,不用干等网络
|
||||
return Promise.all([loadHomeDisk(), loadRecent()]);
|
||||
}).then(function () {
|
||||
loadHero();
|
||||
showHome();
|
||||
// 兜底:无论如何 6 秒后收起骨架屏,避免网络异常时一直盖着
|
||||
setTimeout(hideHomeSkeleton, 6000);
|
||||
if (state.isTv) { var chip = $("chips").querySelector(".focusable"); if (chip) focusRemoteTarget(chip); }
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user