Sync all projects

This commit is contained in:
github-actions[bot]
2026-06-29 03:41:14 +00:00
parent 529cd7c625
commit 8b025e0766
11717 changed files with 6370622 additions and 0 deletions
+5637
View File
@@ -0,0 +1,5637 @@
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta content="width=device-width, initial-scale=1, viewport-fit=cover" name="viewport"><meta content="#080808" name="theme-color"><title>影视库</title><script>
// App 环境检测:在 CSS 应用前同步打标,避免背景闪烁(参考 nostr-合并.html
if (window.fongmiBridge || window.fongmiClient) {
document.documentElement.classList.add("fm-native");
}
// ── 老 WebView 兼容垫片(Android 9 自带 WebView ≈ Chrome 66-70)──
// Promise.allSettled: Chrome 76+ 才有,缺失会让 boot/详情加载 TypeError
if (typeof Promise !== 'undefined' && !Promise.allSettled) {
Promise.allSettled = function (promises) {
return Promise.all(
Array.prototype.map.call(promises, function (p) {
return Promise.resolve(p).then(
function (value) { return { status: 'fulfilled', value: value }; },
function (reason) { return { status: 'rejected', reason: reason }; }
);
})
);
};
}
// Element.replaceChildren: Chrome 86+(本页已避免无参调用,留作兜底)
if (typeof Element !== 'undefined' && !Element.prototype.replaceChildren) {
Element.prototype.replaceChildren = function () {
while (this.firstChild) this.removeChild(this.firstChild);
for (var i = 0; i < arguments.length; i++) {
this.appendChild(arguments[i] instanceof Node ? arguments[i] : document.createTextNode(String(arguments[i])));
}
};
}
</script><style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
html{-webkit-tap-highlight-color:transparent}
body{-webkit-user-select:none;user-select:none}
:root{
--bg:transparent;
--surface:#111111;
--surface2:#1a1a1a;
--text:#f0ede8;
--muted:#595959;
--accent:#7df4ff;
--accent2:#c45cff;
--green:#3ddc84;
--red:#ff453a;
--yellow:#ffd60a;
--blue:#6688aa;
--nav-h:52px;
--gut:12px; /* 栅格间距:屏幕侧边留白 = 封面之间间距,保持均匀 */
--safe-t:max(var(--fm-safe-top,0px),env(safe-area-inset-top,0px));
--safe-b:env(safe-area-inset-bottom,0px);
--r:3px;
--serif:-apple-system,BlinkMacSystemFont,'SF Pro Display','SF Pro Text','PingFang SC','Helvetica Neue',Arial,sans-serif;
--mono:-apple-system,BlinkMacSystemFont,'SF Pro Rounded','SF Mono','Helvetica Neue',Arial,sans-serif;
}
/* 页面级背景默认透明(让 App 原生壁纸透出) */
html,body{background:transparent;color:var(--text);font-family:var(--serif);-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}
body{min-height:100vh;min-height:100dvh;overflow-x:hidden}
/* 非 App(普通浏览器)兜底:深色背景,避免透明导致白底 */
html:not(.fm-native),
html:not(.fm-native) body{ background:#080808; }
/* App 环境:强制透明 */
html.fm-native,
html.fm-native body{ background:transparent; }
/* scanline(仅非 App 环境显示,App 下隐藏避免压在壁纸上) */
body::before{content:'';position:fixed;top:0;right:0;bottom:0;left:0;z-index:0;pointer-events:none;
background:repeating-linear-gradient(0deg,transparent,transparent 3px,rgba(0,0,0,.04) 3px,rgba(0,0,0,.04) 4px)}
html.fm-native body::before{ display:none; }
/* ── NAV ── */
#nav{
position:fixed;top:0;left:0;right:0;z-index:100;
height:calc(var(--nav-h) + var(--safe-t));
padding-top:var(--safe-t);
background:rgba(8,8,8,.96);
backdrop-filter:blur(24px) saturate(1.3);
border-bottom:1px solid rgba(255,255,255,.06);
display:flex;align-items:center;
}
/* App 环境:导航栏半透明毛玻璃,让壁纸透出 */
html.fm-native #nav{
background:rgba(10,10,12,.42);
backdrop-filter:blur(18px) saturate(1.1);
border-bottom:1px solid rgba(255,255,255,.1);
}
.ntabs{
display:flex;align-items:center;flex:1;
overflow-x:auto;overflow-y:hidden;
padding:0 4px 0 14px;gap:2px;scrollbar-width:none;
}
.ntabs::-webkit-scrollbar{display:none}
.ntab{
display:inline-flex;align-items:center;gap:7px;
font-family:var(--serif);font-size:14px;font-weight:500;letter-spacing:.01em;
color:var(--text);
padding:6px 14px 6px 10px;
border-radius:6px;cursor:pointer;
background:none;border:none;white-space:nowrap;position:relative;
transition:color .15s,background .15s,transform .12s;
flex-shrink:0;
}
.ntab-ico{
width:17px;height:17px;flex-shrink:0;opacity:.8;
transition:opacity .15s;
}
.ntab-lbl{line-height:1}
.ntab.on{
background:none;
box-shadow:none;
outline:none;
background-image:linear-gradient(90deg,#c45cff 0%,#7df4ff 100%);
-webkit-background-clip:text;
-webkit-text-fill-color:transparent;
background-clip:text;
}
.ntab.on .ntab-ico{opacity:1;color:#7df4ff;filter:drop-shadow(0 0 3px rgba(125,244,255,.5))}
.ntab.on::after{
content:'';
position:absolute;bottom:2px;left:10px;right:14px;height:2px;border-radius:1px;
background:linear-gradient(90deg,#b8fbff 0%,#7df4ff 50%,#4dcfe0 100%);
box-shadow:0 0 6px rgba(125,244,255,.5);
}
.ntab:hover{color:var(--text);background-color:rgba(255,255,255,.06);background-image:none;-webkit-text-fill-color:currentColor;transform:translateY(-1px)}
.ntab:hover .ntab-ico{opacity:.8}
.ntab:active{transform:scale(.94) translateY(0);background-color:rgba(125,244,255,.1);background-image:none;-webkit-text-fill-color:currentColor}
.ntab.on:hover{background-color:transparent;background-image:linear-gradient(90deg,#c45cff 0%,#7df4ff 100%);-webkit-text-fill-color:transparent;color:transparent}
.ntab.on:active{background-color:transparent;background-image:linear-gradient(90deg,#c45cff 0%,#7df4ff 100%);-webkit-text-fill-color:transparent;color:transparent}
.ntab:focus{outline:none;color:var(--accent)}
.nbtn{
width:var(--nav-h);height:var(--nav-h);
display:flex;align-items:center;justify-content:center;
background:none;border:none;cursor:pointer;color:var(--text);flex-shrink:0;
transition:color .12s,transform .1s;border-radius:3px;
}
.nbtn:hover{color:#7df4ff;filter:drop-shadow(0 0 6px rgba(125,244,255,.4));transform:scale(1.12)}
.nbtn:active{transform:scale(.9)}
.nbtn:focus{outline:none;color:var(--accent)}
.nbtn svg{width:23px;height:23px;stroke-width:2.4}
/* ── MAIN ── */
#main{
padding-top:calc(var(--nav-h) + var(--safe-t));
padding-bottom:calc(36px + var(--safe-b));
position:relative;z-index:1;
}
/* ── HERO CAROUSEL ── */
#heroWrap{
position:relative;overflow:hidden;
min-height:41vw;max-height:410px;
/* 去掉硬边框,底部用渐变融入页面背景 */
}
/* individual slides */
.hero-slide{
position:absolute;top:0;right:0;bottom:0;left:0;
opacity:0;transition:opacity .8s ease;pointer-events:none;
}
.hero-slide.active{opacity:1;pointer-events:auto;cursor:pointer;}
.hero-slide-bg{
position:absolute;top:0;right:0;bottom:0;left:0;
background-size:cover;background-position:center 20%;
transition:transform 8s ease;
}
.hero-slide.active .hero-slide-bg{transform:scale(1.04)}
.hero-slide-grad{
position:absolute;top:0;right:0;bottom:0;left:0;
background:
linear-gradient(90deg,rgba(8,8,8,.92) 0%,rgba(8,8,8,.6) 36%,rgba(8,8,8,.1) 64%,transparent 100%),
linear-gradient(0deg,#080808 0%,rgba(8,8,8,.85) 18%,rgba(8,8,8,.35) 42%,transparent 70%);
}
/* App 透明环境:底部渐隐到透明(融入壁纸/页面),而非纯黑 */
html.fm-native .hero-slide-grad{
background:
linear-gradient(90deg,rgba(8,8,8,.85) 0%,rgba(8,8,8,.5) 36%,rgba(8,8,8,.08) 64%,transparent 100%),
linear-gradient(0deg,rgba(8,8,8,.92) 0%,rgba(8,8,8,.55) 22%,rgba(8,8,8,.15) 48%,transparent 72%);
}
.hero-slide-content{
position:absolute;
bottom:40px;
bottom:clamp(30px,4vw,58px);
left:0;z-index:2;
padding:0 20px;
padding:0 clamp(20px,4vw,54px);
max-width:500px;
max-width:min(500px,54%);
display:flex;flex-direction:column;
transform:translateY(12px);opacity:0;
transition:transform .6s ease .3s, opacity .6s ease .3s;
}
.hero-slide.active .hero-slide-content{transform:translateY(0);opacity:1}
.hero-title{
font-size:26px;font-size:clamp(20px,2.4vw,36px);font-weight:700;line-height:1.1;margin-bottom:6px;
text-shadow:0 2px 24px rgba(0,0,0,.8);letter-spacing:-.02em;
display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;
}
/* 轮播徽标(TMDB logo 替换标题文字) */
.hero-logo-wrap{
margin-bottom:8px;min-height:42px;
display:flex;align-items:flex-end;
}
.hero-logo-img{
max-width:340px;
max-width:min(340px,70%);
max-height:96px;
max-height:clamp(56px,12vw,108px);
width:auto;height:auto;object-fit:contain;object-position:left bottom;
filter:drop-shadow(0 3px 16px rgba(0,0,0,.85)) drop-shadow(0 1px 3px rgba(0,0,0,.6));
display:block;
opacity:0;transition:opacity .4s ease;
}
.hero-logo-img.loaded{opacity:1}
/* 有徽标时隐藏文字标题 */
.hero-title.has-logo{display:none}
.hero-meta{
font-family:var(--mono);font-size:12px;color:rgba(240,237,232,.55);
margin-bottom:8px;display:flex;align-items:center;flex-wrap:nowrap;overflow:hidden;
}
.hero-meta .hm-item{display:flex;align-items:center;white-space:nowrap}
.hero-meta .hm-item + .hm-item::before{content:'·';margin:0 6px;opacity:.4}
.hero-meta .star{color:var(--accent);font-weight:700}
.hero-play-btn{
display:inline-flex;align-items:center;gap:6px;
height:32px;padding:0 16px;
color:#030d10;
font-family:var(--mono);font-size:13px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;
border:none;cursor:pointer;border-radius:999px;width:fit-content;
position:relative;isolation:isolate;overflow:hidden;
background:linear-gradient(135deg,#c45cff,#7df4ff,#c45cff);
background-size:200% 200%;
animation:btn-flow 3s ease infinite;
box-shadow:0 0 22px rgba(125,244,255,.35),0 0 8px rgba(196,92,255,.3);
transition:box-shadow .2s,transform .15s;
}
.hero-play-btn::before{
content:'';position:absolute;inset:-3px;border-radius:999px;z-index:-1;
background:conic-gradient(from var(--btn-ang,0deg),#c45cff,#7df4ff,#c45cff,#7df4ff,#c45cff);
animation:btn-spin 4s linear infinite;
filter:blur(5px);opacity:.65;
}
.hero-play-btn::after{
content:'';position:absolute;inset:1.5px;border-radius:999px;z-index:-1;
background:linear-gradient(135deg,#c45cff,#7df4ff,#c45cff);
background-size:200% 200%;
animation:btn-flow 3s ease infinite;
}
.hero-play-btn svg,.hero-play-btn span{position:relative;z-index:1}
.hero-play-btn:hover{transform:translateY(-2px) scale(1.03);box-shadow:0 0 34px rgba(125,244,255,.55),0 0 14px rgba(196,92,255,.45)}
.hero-play-btn:active{transform:scale(.95);box-shadow:0 0 12px rgba(125,244,255,.3)}
.hero-play-btn svg{width:10px;height:10px;flex-shrink:0}
/* dots */
#heroDots{
position:absolute;bottom:14px;right:20px;z-index:5;
display:flex;gap:6px;align-items:center;
}
.hero-dot{
width:6px;height:6px;border-radius:50%;
background:rgba(255,255,255,.3);border:none;cursor:pointer;padding:0;
transition:background .2s,transform .2s,width .25s;
}
.hero-dot.active{background:var(--accent);width:20px;border-radius:3px;transform:none}
@media(min-width:900px){.hero-slide-content{max-width:540px;max-width:min(540px,48%)}}
@media(min-width:1200px){#heroWrap{max-height:450px}.hero-title{font-size:clamp(22px,2.4vw,38px)}.ntab{font-size:16px;padding:6px 15px}}
/* ── SECTION ── */
.sec{padding:0 var(--gut) 6px;scroll-margin-top:calc(var(--nav-h) + var(--safe-t) + 10px)}
.sechd{
display:flex;align-items:center;gap:10px;
padding:8px 0 6px;
}
.sectitle{
font-family:var(--serif);font-size:19px;font-weight:600;letter-spacing:.04em;
text-transform:none;color:var(--text);white-space:nowrap;
}
.secline{flex:1;height:1px;background:rgba(255,255,255,.07)}
.seccnt{display:none}
/* ── GRID ── */
.grid{
display:grid;
grid-template-columns:repeat(3,1fr);
gap:var(--gut);
}
@media(min-width:480px){:root{--gut:12px} .grid{grid-template-columns:repeat(4,1fr)}}
@media(min-width:640px){:root{--gut:14px} .grid{grid-template-columns:repeat(5,1fr)}}
@media(min-width:900px){:root{--gut:16px} .grid{grid-template-columns:repeat(7,1fr)}}
@media(min-width:1200px){:root{--gut:18px} .grid{grid-template-columns:repeat(9,1fr)}}
@media(min-width:1600px){:root{--gut:20px} .grid{grid-template-columns:repeat(11,1fr)}}
/* landscape for TV shows */
.grid-land{grid-template-columns:repeat(2,1fr)}
@media(min-width:480px){.grid-land{grid-template-columns:repeat(3,1fr)}}
@media(min-width:640px){.grid-land{grid-template-columns:repeat(4,1fr)}}
@media(min-width:900px){.grid-land{grid-template-columns:repeat(5,1fr)}}
@media(min-width:1200px){.grid-land{grid-template-columns:repeat(6,1fr)}}
/* ── 首页分区:横向单行滑动(左右滑动查看,不换行;卡片宽度由 JS 写入 --card-w,与原网格列宽一致) ── */
.sec .grid{
display:flex;
flex-wrap:nowrap;
overflow-x:auto;
overflow-y:hidden;
-webkit-overflow-scrolling:touch;
scrollbar-width:none;
padding-bottom:2px;
gap:var(--gut);
overscroll-behavior-x:contain;
scroll-behavior:auto;
}
.sec .grid::-webkit-scrollbar{display:none}
.sec .grid>*{
flex:0 0 var(--card-w,100px);
width:var(--card-w,100px);
min-width:0;
}
/* ── CARD ── */
.card{
cursor:pointer;position:relative;
border-radius:12px;overflow:hidden;
background:transparent;outline:none;
-webkit-tap-highlight-color:transparent;
transition:transform .14s cubic-bezier(.2,.8,.4,1);
}
.card:focus-visible{transform:scale(1.06);z-index:3}
.card:active{transform:scale(.96)}
@media (hover:hover) and (pointer:fine){
.card:hover{transform:scale(1.06);z-index:3}
.card:hover .card-overlay{opacity:1}
}
.card-img{
position:relative;overflow:hidden;
background:var(--surface2);
/* 老 WebView 兼容:padding-top 撑高度(2:3 → 150%);新引擎用下方 aspect-ratio 覆盖 */
height:0;padding-top:150%;
}
.card-img.land{padding-top:56.25%} /* 16:9 */
/* 支持 aspect-ratio 的引擎:恢复用 aspect-ratio,去掉 padding hack */
@supports (aspect-ratio:1/1){
.card-img{height:auto;padding-top:0;aspect-ratio:2/3}
.card-img.land{padding-top:0;aspect-ratio:16/9}
}
.card-img img{
position:absolute;top:0;left:0;
width:100%;height:100%;object-fit:cover;display:block;
transition:opacity .3s;
}
.card-img img[data-loading]{opacity:0}
.card-img img.loaded{opacity:1}
.card-overlay{
position:absolute;top:0;right:0;bottom:0;left:0;
background:linear-gradient(180deg,transparent 55%,rgba(0,0,0,.8) 100%);
opacity:0;transition:opacity .14s;
}
.card:focus-visible .card-overlay{opacity:1}
/* rating badge */
.card-rating{
position:absolute;top:5px;left:5px;z-index:2;
font-family:var(--mono);font-size:10px;font-weight:700;
background:rgba(0,0,0,.75);color:var(--accent);
padding:2px 5px;border-radius:2px;
backdrop-filter:blur(4px);
}
/* pan status dot */
.card-dot{
position:absolute;top:5px;right:5px;z-index:2;
width:7px;height:7px;border-radius:50%;
border:1.5px solid rgba(0,0,0,.5);display:none;
animation:dot-breathe 2s ease-in-out infinite;
}
.card-dot::after{
content:'';position:absolute;left:50%;top:50%;
width:7px;height:7px;border-radius:50%;
background:inherit;transform:translate(-50%,-50%);
animation:dot-sonar 2s ease-out infinite;
pointer-events:none;
}
.card-dot.ok{background:var(--green);display:block}
.card-dot.bad{background:var(--red);display:block}
.card-dot.locked{background:var(--yellow);display:block}
.card-dot.uncertain{background:var(--blue);display:block}
@keyframes dot-breathe{0%,100%{opacity:1}50%{opacity:.45}}
@keyframes dot-sonar{
0%{transform:translate(-50%,-50%) scale(1);opacity:.5}
100%{transform:translate(-50%,-50%) scale(3.2);opacity:0}
}
.card-body{padding:6px 7px 8px;background:rgba(0,0,0,.35)}
.card-name{
font-size:13.5px;font-weight:600;
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
color:var(--text);line-height:1.35;letter-spacing:-.01em;
}
.card-sub{display:none}
.card-badge{
display:inline-block;font-family:var(--mono);font-size:10px;
color:var(--accent2);background:rgba(196,92,255,.12);
padding:1px 4px;border-radius:2px;margin-top:3px;
}
/* ── SKELETON ── */
.sk{
background:linear-gradient(90deg,var(--surface) 25%,var(--surface2) 50%,var(--surface) 75%);
background-size:200% 100%;
animation:sk 1.5s infinite linear;
border-radius:var(--r);
}
@keyframes sk{to{background-position:-200% 0}}
.sk-card{height:0;padding-top:150%}
.sk-card.land{padding-top:56.25%}
@supports (aspect-ratio:1/1){
.sk-card{height:auto;padding-top:0;aspect-ratio:2/3}
.sk-card.land{padding-top:0;aspect-ratio:16/9}
}
/* ── SEARCH OVERLAY ── */
#srch{
position:fixed;top:0;right:0;bottom:0;left:0;z-index:200;
background:rgba(8,8,8,.97);backdrop-filter:blur(28px);
display:flex;flex-direction:column;
transform:translateY(-100%);transition:transform .28s cubic-bezier(.4,0,.2,1);
}
#srch.on{transform:translateY(0)}
.srch-top{
padding:calc(var(--safe-t) + 14px) 18px 0;
display:flex;align-items:center;gap:10px;
}
.srch-wrap{flex:1;position:relative}
.srch-inp,.conn-field,input,textarea{-webkit-user-select:text;user-select:text}
@keyframes srch-breathe{
0%,100%{box-shadow:0 0 0 1px rgba(125,244,255,.25),0 0 8px rgba(125,244,255,.12),0 0 18px rgba(125,244,255,.06)}
50%{box-shadow:0 0 0 1px rgba(125,244,255,.7),0 0 16px rgba(125,244,255,.38),0 0 36px rgba(125,244,255,.18)}
}
@keyframes srch-breathe-border{
0%,100%{border-color:rgba(125,244,255,.3)}
50%{border-color:rgba(125,244,255,.85)}
}
.srch-inp{
width:100%;background:var(--surface);
border:1px solid rgba(125,244,255,.3);
border-radius:999px;color:var(--text);
font-family:var(--serif);font-size:19px;
padding:11px 52px 11px 42px;outline:none;
transition:border-color .12s,box-shadow .12s;
-webkit-appearance:none;appearance:none;
animation:srch-breathe 2.8s ease-in-out infinite, srch-breathe-border 2.8s ease-in-out infinite;
}
.srch-inp::-webkit-search-cancel-button{display:none}
.srch-inp:focus{
border-color:var(--accent);
box-shadow:0 0 0 1px rgba(125,244,255,.9),0 0 20px rgba(125,244,255,.45),0 0 48px rgba(125,244,255,.2);
animation:none;
}
.srch-inp::placeholder{color:var(--muted)}
.srch-ico{
position:absolute;left:13px;top:50%;transform:translateY(-50%);
color:var(--muted);width:17px;height:17px;pointer-events:none;
}
.srch-cancel{
font-family:var(--mono);font-size:13px;color:var(--muted);
background:none;border:none;cursor:pointer;padding:8px 0;
white-space:nowrap;transition:color .12s;flex-shrink:0;
}
.srch-cancel:hover{color:var(--text)}
.srch-submit-btn{
position:absolute;right:10px;top:50%;transform:translateY(-50%);
background:var(--accent);border:none;cursor:pointer;
width:32px;height:32px;border-radius:50%;
display:flex;align-items:center;justify-content:center;
color:#000;transition:background .12s,transform .1s;flex-shrink:0;
}
.srch-submit-btn:hover{background:#a0f7ff;transform:translateY(-50%) scale(1.06)}
.srch-submit-btn:active{transform:translateY(-50%) scale(.93)}
.srch-label{
font-family:var(--mono);font-size:11px;letter-spacing:.2em;
text-transform:uppercase;color:var(--muted);
padding:18px 18px 8px;
}
.srch-hot{list-style:none;padding:0 18px;overflow-y:auto}
.srch-hot li{
display:flex;align-items:center;gap:10px;
padding:11px 0;border-bottom:1px solid rgba(255,255,255,.04);
cursor:pointer;transition:color .12s;
}
.srch-hot li:hover,.srch-hot li:focus{color:var(--accent);outline:none}
.srch-hot li svg{width:15px;height:15px;opacity:.35;flex-shrink:0}
.srch-hot .st{font-size:17px;flex:1}
.srch-hot .sm{font-family:var(--mono);font-size:11px;color:var(--muted)}
/* ── SEARCH ACTIVE: 主页内容模糊淡出 ── */
html.search-active #main > *:not(#s-search){
max-height:0 !important;
min-height:0 !important;
height:0 !important;
overflow:hidden !important;
margin-top:0 !important;
margin-bottom:0 !important;
padding-top:0 !important;
padding-bottom:0 !important;
pointer-events:none;
user-select:none;
}
html.search-active #s-search{
position:relative;z-index:5;
padding-bottom:calc(24px + env(safe-area-inset-bottom, 0px));
}
/* ── 搜索结果列表 ── */
#srch-list{
display:flex;flex-direction:column;gap:8px;
padding:4px 0 32px;
}
.sli{
display:flex;align-items:flex-start;gap:12px;
width:100%;padding:12px 14px;border-radius:12px;
background:rgba(255,255,255,.06);
border:1px solid rgba(255,255,255,.09);
text-align:left;color:var(--text);cursor:pointer;
transition:background .14s;
box-sizing:border-box;
}
.sli:hover,.sli:focus{background:rgba(255,255,255,.11);outline:none}
.sli:active{background:rgba(255,255,255,.04)}
.sli-poster{
width:60px;min-width:60px;height:90px;
object-fit:cover;border-radius:6px;
background:var(--surface2);flex-shrink:0;
}
.sli-body{flex:1;min-width:0}
.sli-title{
display:block;font-size:17px;font-weight:600;
line-height:1.3;color:var(--text);
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
}
.sli-meta{
display:block;font-size:13px;color:var(--text);
margin-top:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
}
.sli-stars{display:flex;align-items:center;gap:5px;margin-top:6px}
.sli-star-full{color:#f5a623;font-size:15px}
.sli-star-half{color:#f5a623;font-size:13px}
.sli-star-empty{color:rgba(255,255,255,.2);font-size:15px}
.sli-rating{font-size:15px;font-weight:700;color:#f5a623}
.sli-overview{
display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;
font-size:13px;color:var(--text);line-height:1.55;margin-top:6px;
}
/* loading / empty */
.srch-searching{
display:flex;flex-direction:column;align-items:center;
padding-top:72px;gap:14px;
}
.srch-searching-spinner{
width:30px;height:30px;border-radius:50%;
border:3px solid rgba(125,244,255,.15);
border-top-color:var(--accent);
animation:srch-spin .7s linear infinite;
}
@keyframes srch-spin{to{transform:rotate(360deg)}}
.srch-searching-text{font-size:15px;color:var(--muted)}
.srch-empty{
display:flex;flex-direction:column;align-items:center;
padding-top:72px;gap:10px;color:var(--muted);font-size:16px;text-align:center;
}
.srch-empty-ico{font-size:38px;line-height:1;margin-bottom:4px}
/* ── 搜索历史 & 联想 ── */
.srch-section-hd{
display:flex;align-items:center;justify-content:space-between;
padding:18px 18px 8px;
}
.srch-section-hd .srch-label{padding:0;margin:0}
.srch-clear-btn{
font-family:var(--mono);font-size:11px;color:var(--muted);
background:none;border:none;cursor:pointer;letter-spacing:.06em;
padding:3px 6px;border-radius:3px;transition:color .12s,background .12s;
}
.srch-clear-btn:hover{color:var(--red);background:rgba(255,69,58,.08)}
/* 历史 tag 行 */
.srch-hist-tags{
display:flex;flex-wrap:wrap;gap:7px;
padding:0 18px 4px;
}
.srch-hist-tag{
display:inline-flex;align-items:center;gap:5px;
padding:5px 11px 5px 12px;
background:rgba(255,255,255,.06);
border:1px solid rgba(255,255,255,.1);
border-radius:99px;cursor:pointer;
font-size:14px;color:var(--text);
transition:background .12s,border-color .12s,transform .1s;
max-width:180px;
}
.srch-hist-tag:hover{background:rgba(255,255,255,.11);border-color:rgba(255,255,255,.22);transform:translateY(-1px)}
.srch-hist-tag:active{transform:scale(.95)}
.srch-hist-tag-x{
color:var(--muted);font-size:12px;line-height:1;flex-shrink:0;
padding:1px;border-radius:50%;transition:color .1s,background .1s;
}
.srch-hist-tag:hover .srch-hist-tag-x{color:var(--red)}
/* 联想下拉列表 */
.srch-suggest-list{
list-style:none;padding:6px 0;overflow-y:auto;
flex:1;
}
.srch-suggest-item{
display:flex;align-items:center;gap:10px;
padding:10px 18px;cursor:pointer;
transition:background .1s;
}
.srch-suggest-item:hover,.srch-suggest-item.active{
background:rgba(255,255,255,.06);
}
.srch-suggest-item svg{width:15px;height:15px;flex-shrink:0;opacity:.35}
.srch-suggest-item .sg-kw{font-size:16px;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.srch-suggest-item .sg-kw em{color:var(--accent);font-style:normal}
.srch-suggest-item .sg-fill{
font-size:12px;color:var(--muted);flex-shrink:0;
opacity:0;transition:opacity .12s;
font-family:var(--mono);
}
.srch-suggest-item:hover .sg-fill{opacity:.6}
/* ── DETAIL PANEL ── */
#det{
position:fixed;top:0;right:0;bottom:0;left:0;z-index:150;
background:#06111b;
overflow-y:scroll; /* 强制scroll而非auto,避免部分WebView失效 */
overflow-x:hidden;
-webkit-overflow-scrolling:touch; /* iOS惯性滚动 */
scrollbar-width:none;
transform:translateX(100%);
transition:transform .26s cubic-bezier(.4,0,.2,1);
}
#det::-webkit-scrollbar{display:none}
#det.on{transform:translateX(0)}
/* ── 分类页(筛选 + 看更多)── */
#cat{
position:fixed;top:0;right:0;bottom:0;left:0;z-index:140;
background:#06111b;
display:flex;flex-direction:column;
transform:translateX(100%);
transition:transform .26s cubic-bezier(.4,0,.2,1);
}
#cat.on{transform:translateX(0)}
#catTopbar{
flex:0 0 auto;display:flex;align-items:center;gap:10px;
padding:calc(var(--safe-t) + 10px) var(--gut) 10px var(--gut);
background:rgba(6,17,27,.96);
border-bottom:1px solid rgba(255,255,255,.06);
}
.cat-back{
flex:0 0 auto;width:34px;height:34px;border-radius:50%;
display:inline-flex;align-items:center;justify-content:center;
background:rgba(255,255,255,.08);border:none;color:var(--text);cursor:pointer;
}
.cat-back svg{width:20px;height:20px}
.cat-back:active{transform:scale(.92)}
.cat-title{font-family:var(--serif);font-size:18px;font-weight:600;color:var(--text);white-space:nowrap}
.cat-count{font-family:var(--mono);font-size:12px;color:var(--muted);margin-left:auto}
/* 筛选标签栏 */
#catFilters{
flex:0 0 auto;
padding:8px var(--gut) 4px var(--gut);
background:rgba(6,17,27,.9);
border-bottom:1px solid rgba(255,255,255,.05);
}
.fil-row{display:flex;align-items:center;margin-bottom:8px;overflow-x:auto;scrollbar-width:none}
.fil-row > *{margin-right:8px} /* 用 margin 而非 flex gap:兼容 Chrome<84 旧 WebView */
.fil-row::-webkit-scrollbar{display:none}
.fil-label{flex:0 0 auto;font-family:var(--mono);font-size:11px;color:var(--text);letter-spacing:.1em;width:30px}
.fil-tag{
flex:0 0 auto;
padding:5px 13px;border-radius:14px;cursor:pointer;
font-size:13px;line-height:1;white-space:nowrap;
background:rgba(255,255,255,.06);color:var(--text);
border:1px solid transparent;transition:all .15s;
}
.fil-tag:active{transform:scale(.95)}
.fil-tag.on{background:var(--accent);color:#000;font-weight:600}
/* 结果滚动区 */
#catScroll{
flex:1 1 auto;overflow-y:scroll;overflow-x:hidden;
-webkit-overflow-scrolling:touch;
padding:14px var(--gut) calc(var(--safe-b) + 40px) var(--gut);
}
#catSentinel{height:1px}
.cat-foot{
text-align:center;padding:18px 0 6px;
font-family:var(--mono);font-size:12px;color:var(--muted);
}
.cat-foot.spin::before{
content:'';display:inline-block;width:16px;height:16px;vertical-align:-3px;margin-right:8px;
border:2px solid rgba(255,255,255,.2);border-top-color:var(--accent);border-radius:50%;
animation:catspin .7s linear infinite;
}
@keyframes catspin{to{transform:rotate(360deg)}}
/* 版块标题“更多”入口 */
.sec-more{
flex:0 0 auto;display:inline-flex;align-items:center;gap:2px;
font-size:12px;color:var(--text);cursor:pointer;
font-family:var(--mono);letter-spacing:.05em;
padding:3px 4px 3px 8px;border-radius:10px;transition:color .15s,background .15s;
}
.sec-more:hover{color:var(--accent);background:rgba(125,244,255,.08)}
.sec-more:active{transform:scale(.95)}
.sec-more svg{width:13px;height:13px}
.sechd.clickable{cursor:pointer}
.sechd.clickable:active .sectitle{color:var(--accent)}
/* ── DETAIL INNER ── */
#detInner{
position:relative;
/* 不设min-height,让内容自然撑高以支持滚动 */
}
/* ── IMMERSIVE DETAIL HERO ── */
/* 背景图固定:相对 #det 容器(有 transform)定位,不随内容滚动 */
.det-hero{
position:fixed;top:0;left:0;right:0;
height:100vh;height:100dvh;
z-index:0;
pointer-events:none;overflow:hidden;
}
.det-hero img,
.det-hero-img{
position:absolute;top:0;left:0;
width:100%;height:100%;object-fit:cover;object-position:center top;
filter:saturate(1.05) brightness(1.32);
opacity:0;transition:opacity 1.2s ease;
}
.det-hero-img.active{opacity:1}
/* ── 吸顶导航:随滚动渐显的毛玻璃导航栏 + 补位标题 ── */
#detNav{
position:fixed;top:0;left:0;right:0;z-index:5;
height:calc(var(--nav-h) + var(--safe-t));
padding-top:var(--safe-t);
display:flex;align-items:center;gap:10px;
padding-left:14px;padding-right:14px;
background:rgba(6,17,27,0);
border-bottom:1px solid rgba(255,255,255,0);
-webkit-backdrop-filter:none;
backdrop-filter:none;
transition:background-color .15s linear,border-color .15s linear,backdrop-filter .15s linear,-webkit-backdrop-filter .15s linear;
pointer-events:none;
}
#detNav.scrolled{
background:rgba(6,17,27,.86);
border-bottom:1px solid rgba(255,255,255,.06);
-webkit-backdrop-filter:blur(20px) saturate(1.2);
backdrop-filter:blur(20px) saturate(1.2);
}
.det-nav-back{display:none}
#detNavTitle{
flex:1;min-width:0;padding-left:4px;
font-family:var(--serif);font-size:16px;font-weight:600;color:var(--text);
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
opacity:0;transform:translateY(6px);
transition:opacity .2s linear,transform .2s linear;
}
#detNav.scrolled #detNavTitle{opacity:1;transform:translateY(0)}
/* 手机竖屏:全屏渐变遮罩 — 上半部分保持原图亮度,下半部分起开始变暗 */
.det-hero::after{
content:'';position:absolute;top:0;right:0;bottom:0;left:0;
background:
linear-gradient(180deg,rgba(6,17,27,0) 0%,rgba(6,17,27,0) 50%,rgba(6,17,27,.45) 70%,rgba(6,17,27,.92) 100%),
linear-gradient(90deg,rgba(6,17,27,.6) 0%,rgba(6,17,27,.05) 100%);
}
/* 宽屏桌面(≥1100):左侧深色文字区,右侧图片大量透出 */
@media(min-width:1100px){
.det-hero::after{
background:
linear-gradient(90deg,rgba(6,17,27,.95) 0%,rgba(6,17,27,.88) 28%,rgba(6,17,27,.55) 48%,rgba(6,17,27,.08) 68%,transparent 100%),
linear-gradient(180deg,rgba(6,17,27,.0) 0%,rgba(6,17,27,.1) 60%,rgba(6,17,27,.5) 100%);
}
}
/* ── DETAIL INNER: 自适应布局 ── */
.det-inner{
position:relative;z-index:2;
display:flex;
flex-direction:column;
min-height:100vh;
min-height:100dvh;
width:100%;
padding:calc(var(--safe-t) + 20px) 18px 48px;
box-sizing:border-box;
}
/* 平板 / 中等宽度(700-1100):单列铺满全宽,信息不被截断 */
@media(min-width:700px){
.det-inner{
display:flex;
flex-direction:column;
width:100%;
max-width:100%;
padding:calc(var(--safe-t) + 36px) 40px 64px 40px;
padding:calc(var(--safe-t) + 36px) clamp(34px,5vw,74px) 64px clamp(34px,5vw,74px);
}
}
/* 宽屏桌面(≥1100):双列,左侧信息 + 右侧留给背景图 */
@media(min-width:1100px){
.det-inner{
display:grid;
grid-template-columns:minmax(0,640px) 1fr;
column-gap:0;
align-items:start;
padding-left:60px;
padding-left:clamp(50px,6vw,98px);
padding-right:0;
}
.det-inner > *{
grid-column:1;
min-width:0; /* 防止grid子项溢出,保证overflow-x:auto生效 */
}
}
@media(min-width:1500px){
.det-inner{
grid-template-columns:minmax(0,760px) 1fr;
padding-left:80px;
padding-left:clamp(66px,7vw,122px);
}
}
/* 横屏手机(短屏宽屏):内容铺满全宽单列,不做双列 */
@media(max-height:500px) and (min-width:600px){
.det-inner{
display:flex;
flex-direction:column;
max-width:100%;
padding:calc(var(--safe-t) + 12px) 20px 32px;
}
}
/* ── POSTER ROW ── */
.det-poster-row{
display:flex;gap:16px;align-items:flex-end;
margin-bottom:18px;
}
/* 手机竖屏:顶部留出背景图空间 + 返回按钮空间 */
@media(max-width:699px){
.det-poster-row{margin-top:180px;margin-top:clamp(142px,45vw,262px)}
}
/* 平板单列(700-1100):留中等顶部空间露出背景图 */
@media(min-width:700px) and (max-width:1099px){
.det-poster-row{margin-top:0;padding-top:240px;padding-top:clamp(180px,34vh,360px)}
}
/* 宽屏桌面双列:不需要顶部留白,从 padding 开始 */
@media(min-width:1100px){
.det-poster-row{margin-top:0;padding-top:40px;padding-top:max(20px, 8vh)}
}
/* ── 触屏平板横屏(≥1100 但是触摸设备):撤销桌面双列,回到全宽单列 ──
桌面双列把内容塞进左侧 640px、右半屏留给剧照,平板横屏会显得信息半截、
排版破碎。这里用 pointer:coarse 把触屏设备单独拎出来,强制铺满全宽,
剧照改到顶部露出(与 700-1100 平板布局一致)。桌面鼠标(pointer:fine)不受影响。 */
@media(min-width:1100px) and (pointer:coarse){
.det-inner{
display:flex;
flex-direction:column;
width:100%;
max-width:100%;
column-gap:0;
padding:calc(var(--safe-t) + 36px) 40px 64px 40px;
padding:calc(var(--safe-t) + 36px) clamp(34px,5vw,74px) 64px clamp(34px,5vw,74px);
}
.det-inner > *{ grid-column:auto; min-width:0; }
/* 顶部留白露出剧照,而不是把它挤到右半屏 */
.det-poster-row{ margin-top:0; padding-top:240px; padding-top:clamp(180px,34vh,360px); }
/* 渐变恢复成全屏纵向(顶部透出剧照、底部压暗文字),取消左重右透的横向渐变 */
.det-hero::after{
background:
linear-gradient(180deg,rgba(6,17,27,.1) 0%,rgba(6,17,27,.45) 40%,rgba(6,17,27,.92) 100%),
linear-gradient(90deg,rgba(6,17,27,.6) 0%,rgba(6,17,27,.05) 100%);
}
}
.det-title-block{flex:1;min-width:0;padding-bottom:4px}
.det-eyebrow{
font-family:var(--mono);font-size:11px;letter-spacing:.2em;
text-transform:uppercase;color:var(--accent2);margin-bottom:16px;
display:flex;align-items:center;gap:6px;
}
.det-eyebrow::before{content:'';display:inline-block;width:14px;height:1px;background:var(--accent2)}
.det-logo-wrap{margin-bottom:16px;min-height:48px;display:flex;align-items:flex-end}
.det-logo-img{
max-width:360px;max-height:130px;
max-width:min(380px,80vw);max-height:clamp(82px,16vw,162px);
width:auto;height:auto;object-fit:contain;object-position:left bottom;
filter:drop-shadow(0 4px 20px rgba(0,0,0,.8)) drop-shadow(0 1px 4px rgba(0,0,0,.6));
display:block;
}
.det-title-fallback{/* fallback text title, shown until logo loads */}
.det-title{
font-size:30px;font-size:clamp(24px,5vw,50px);font-weight:900;line-height:1.08;
margin-bottom:6px;letter-spacing:-.01em;
text-shadow:0 3px 16px rgba(0,0,0,.6);
}
.det-orig-title{
font-family:var(--mono);font-size:13px;color:rgba(240,237,232,.4);
margin-bottom:16px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
}
.det-score-row{
display:flex;align-items:baseline;gap:5px;margin-bottom:4px;
}
.det-score{
font-family:var(--mono);font-size:24px;font-weight:700;color:var(--accent);
text-shadow:0 2px 8px rgba(125,244,255,.3);
}
.det-score-max{
font-family:var(--mono);font-size:13px;color:rgba(240,237,232,.35);
}
.det-votes{font-family:var(--mono);font-size:11px;color:var(--muted);margin-left:4px}
/* meta pills */
.det-pills{display:flex;flex-wrap:wrap;gap:6px;margin:0 0 16px}
.det-pill{
padding:4px 10px;border-radius:999px;
color:var(--text);background:rgba(255,255,255,.1);
border:1px solid rgba(255,255,255,.22);
font-family:var(--mono);font-size:13px;line-height:1.3;
}
.det-pill.strong{
color:#fff;border-color:rgba(255,255,255,.4);
background:rgba(255,255,255,.18);font-weight:700;
}
.det-pill-rating{
color:var(--accent);border-color:rgba(125,244,255,.45);
background:rgba(125,244,255,.12);font-weight:700;
letter-spacing:.02em;
}
/* action buttons */
.det-actions{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:16px;align-items:center}
.btn-p{
display:inline-flex;align-items:center;gap:7px;
color:#051a1e;
font-family:var(--mono);font-size:13px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;
border:none;cursor:pointer;padding:11px 20px;border-radius:999px;
background:#7df4ff;
box-shadow:0 0 0 1px rgba(125,244,255,.4) inset,0 6px 22px rgba(125,244,255,.35);
transition:filter .12s,transform .1s,box-shadow .12s;
}
.btn-p:hover{filter:brightness(1.08);transform:translateY(-1px);box-shadow:0 0 0 1px rgba(125,244,255,.5) inset,0 8px 32px rgba(125,244,255,.5)}
.btn-p:active{transform:scale(.95)}
/* ── 搜索播放按钮:霓虹流光版 ── */
@property --btn-ang{syntax:'<angle>';initial-value:0deg;inherits:false}
@keyframes btn-spin{to{--btn-ang:360deg}}
@keyframes btn-flow{0%,100%{background-position:0% 50%}50%{background-position:100% 50%}}
#detPlayBtn{
position:relative;isolation:isolate;overflow:hidden;
color:#030d10;
background:linear-gradient(135deg,#c45cff,#7df4ff,#c45cff);
background-size:200% 200%;
animation:btn-flow 3s ease infinite;
box-shadow:0 0 22px rgba(125,244,255,.35),0 0 8px rgba(196,92,255,.3);
transition:box-shadow .2s,transform .15s;
touch-action:none;
outline:none;
}
#detPlayBtn::before{
content:'';position:absolute;inset:-3px;border-radius:999px;z-index:-1;
background:conic-gradient(from var(--btn-ang,0deg),#c45cff,#7df4ff,#c45cff,#7df4ff,#c45cff);
animation:btn-spin 4s linear infinite;
filter:blur(5px);opacity:.65;
}
#detPlayBtn::after{
content:'';position:absolute;inset:1.5px;border-radius:999px;z-index:-1;
background:linear-gradient(135deg,#c45cff,#7df4ff,#c45cff);
background-size:200% 200%;
animation:btn-flow 3s ease infinite;
}
#detPlayBtn svg,#detPlayBtn span{position:relative;z-index:1}
#detPlayBtn:hover{
transform:translateY(-2px) scale(1.03);
box-shadow:0 0 34px rgba(125,244,255,.55),0 0 14px rgba(196,92,255,.45);
filter:none;
}
#detPlayBtn:active{transform:scale(.95);box-shadow:0 0 12px rgba(125,244,255,.3)}
.btn-s{
display:inline-flex;align-items:center;gap:7px;
background:rgba(255,255,255,.07);color:rgba(240,246,252,.92);
font-family:var(--mono);font-size:13px;letter-spacing:.06em;text-transform:uppercase;
border:1px solid rgba(255,255,255,.16);cursor:pointer;padding:11px 18px;border-radius:999px;
backdrop-filter:blur(12px);
transition:background .12s,border-color .12s,transform .1s;
box-shadow:0 1px 0 rgba(255,255,255,.18) inset,0 4px 16px rgba(0,0,0,.28);
}
.btn-s:hover{background:rgba(255,255,255,.12);border-color:rgba(255,255,255,.28);transform:translateY(-1px)}
.btn-s:active{transform:scale(.95)}
/* overview */
.det-overview{
font-size:14px;line-height:1.75;color:rgba(240,237,232,.75);
margin-bottom:0;
}
.det-overview.clamped{
display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;
overflow:hidden;
}
.det-overview-toggle{
display:none;
background:none;border:none;cursor:pointer;padding:0;
font-family:var(--mono);font-size:12px;color:var(--accent);
letter-spacing:.05em;margin-top:6px;margin-bottom:16px;
}
.det-overview-toggle.show{display:inline-flex;align-items:center;gap:3px}
.det-overview-toggle svg{width:11px;height:11px;transition:transform .2s}
.det-overview-toggle.open svg{transform:rotate(180deg)}
/* section label */
.det-sec-label{
font-family:var(--mono);font-size:11px;letter-spacing:.22em;
text-transform:uppercase;color:#fff;
margin:22px 0 10px;
display:flex;align-items:center;gap:10px;
}
.det-sec-label::after{content:'';flex:1;height:1px;background:rgba(255,255,255,.07)}
/* cast — 始终单行横向滚动,不换行 */
#detCast{min-width:0;width:100%;}
@media(min-width:1100px) and (pointer:fine){
#detCast{
grid-column:1 / -1 !important;
padding-right:clamp(50px,6vw,98px);
box-sizing:border-box;
}
}
.det-cast-wrap{
overflow-x:auto;overflow-y:visible;
scrollbar-width:none;
-webkit-overflow-scrolling:touch;
width:100%;
padding:6px 4px 2px; /* 给 box-shadow 留出空间 */
box-sizing:border-box;
}
.det-cast-wrap::-webkit-scrollbar{display:none}
.det-cast-rail{
display:flex;gap:12px;
flex-wrap:nowrap;
width:max-content;
padding-bottom:10px;
user-select:none;-webkit-user-select:none;
-webkit-touch-callout:none;
}
.det-cast-card{
flex:0 0 72px;width:72px;
display:flex;flex-direction:column;align-items:center;
cursor:pointer;transition:transform .14s;
background:none;border:none;padding:0;outline:none;
}
.det-cast-card:active{transform:scale(.94)}
@media (hover:hover) and (pointer:fine){
.det-cast-card:hover{transform:scale(1.07)}
.det-cast-card:hover .det-cast-img{border-color:rgba(255,255,255,.4)}
}
.det-cast-card.active .det-cast-img{
border-color:var(--accent);
box-shadow:0 0 0 3px rgba(125,244,255,.5);
}
/* 圆形头像 */
.det-cast-img{
width:72px;height:72px;border-radius:50%;
object-fit:cover;object-position:center top;
background:var(--surface2);
border:2px solid rgba(255,255,255,.14);
display:block;flex-shrink:0;
pointer-events:none;
-webkit-touch-callout:none;
user-select:none;-webkit-user-select:none;
-webkit-user-drag:none;
}
.det-cast-name{
font-size:12px;font-weight:700;color:#fff;
padding:5px 2px 2px;
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
text-align:center;width:80px;
}
.det-cast-char{
font-family:var(--mono);font-size:11px;color:rgba(240,237,232,.5);
padding:0 2px 6px;
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
text-align:center;width:80px;
}
/* ── 演员作品区 ── */
#detPersonWorks{
min-width:0;width:100%;
margin-bottom:4px;
}
@media(min-width:1100px) and (pointer:fine){
#detPersonWorks{
grid-column:1 / -1 !important;
padding-right:clamp(50px,6vw,98px);
box-sizing:border-box;
}
}
.det-person-header{
display:flex;align-items:center;gap:14px;
margin:16px 0 10px;
padding:12px 14px;
background:rgba(255,255,255,.04);
border:1px solid rgba(255,255,255,.08);
border-radius:10px;
}
.det-person-avatar{
width:54px;height:54px;border-radius:50%;
object-fit:cover;object-position:center top;
background:var(--surface2);
border:2px solid rgba(255,255,255,.15);
flex-shrink:0;display:block;
}
.det-person-meta{flex:1;min-width:0}
.det-person-name{font-size:16px;font-weight:700;margin-bottom:2px}
.det-person-bio{
font-family:var(--mono);font-size:12px;color:rgba(240,237,232,.5);
display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;
line-height:1.55;
}
.det-person-close{
width:32px;height:32px;border-radius:50%;flex-shrink:0;
background:rgba(255,255,255,.08);border:none;cursor:pointer;
color:var(--muted);font-size:18px;line-height:1;
display:flex;align-items:center;justify-content:center;
transition:background .12s,color .12s;
}
.det-person-close:hover{background:rgba(255,255,255,.16);color:var(--text)}
.det-person-works-rail{
display:flex;flex-wrap:nowrap;overflow-x:auto;overflow-y:hidden;
padding-bottom:8px;scrollbar-width:none;
-webkit-overflow-scrolling:touch;
width:100%;box-sizing:border-box;
}
.det-person-works-rail::-webkit-scrollbar{display:none}
.det-person-works-rail .card{
flex:0 0 var(--card-w,100px);
width:var(--card-w,100px);
min-width:0;margin-right:var(--gut);
}
.det-person-loading{
font-family:var(--mono);font-size:13px;color:var(--muted);
padding:20px 0 8px;
}
.det-cast-card:focus-visible .det-cast-img{
border-color:var(--accent);
box-shadow:0 0 0 3px rgba(125,244,255,.4);
}
/* similar / recs */
.det-recs-label{
font-family:var(--mono);font-size:11px;letter-spacing:.22em;
text-transform:uppercase;color:#fff;
margin:20px 0 8px;
display:flex;align-items:center;gap:10px;
}
.det-recs-label::after{content:'';flex:1;height:1px;background:rgba(255,255,255,.07)}
/*
推荐区:单行横向滚动,卡片宽度由 JS 按首页卡片像素宽固定
*/
#detRecs{
min-width:0; /* grid子项防止溢出撑开 */
width:100%;
}
/* 宽屏双列:推荐区跨满全宽 */
@media(min-width:1100px) and (pointer:fine){
#detRecs{
grid-column:1 / -1 !important;
width:100%;
padding-right:clamp(50px,6vw,98px);
box-sizing:border-box;
}
}
#detRecs .det-recs-grid{
display:flex;
flex-wrap:nowrap;
overflow-x:auto;
overflow-y:hidden;
padding-bottom:8px;
scrollbar-width:none;
-webkit-overflow-scrolling:touch;
width:100%;
box-sizing:border-box;
}
#detRecs .det-recs-grid::-webkit-scrollbar{display:none}
/* 卡片宽度由 JS 写入 --card-w 变量 */
#detRecs .det-recs-grid .card{
flex:0 0 var(--card-w, 100px);
width:var(--card-w, 100px);
min-width:0;
margin-right:var(--gut); /* 用 margin 而非 flex gap:兼容旧 WebView */
}
/* ── PAN SEARCH ── */
.pan-block{display:none;margin-top:18px}
.pan-block.on{display:block}
.pan-head{
display:flex;align-items:center;gap:10px;margin-bottom:8px;
}
.pan-head-title{font-family:var(--mono);font-size:12px;letter-spacing:.15em;text-transform:uppercase;color:var(--muted)}
.pan-hint{font-family:var(--mono);font-size:12px;color:var(--muted);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.pan-tabs-row{display:flex;gap:6px;flex-wrap:wrap;padding:3px 0 8px;overflow-x:auto;scrollbar-width:none}
.pan-tabs-row::-webkit-scrollbar{display:none}
.pan-chip{
flex:0 0 auto;padding:4px 11px;border-radius:99px;
font-family:var(--mono);font-size:12px;font-weight:700;
background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.1);
color:#fff;cursor:pointer;transition:all .12s;
}
.pan-chip.on{background:rgba(125,244,255,.12);border-color:var(--accent);color:var(--accent)}
.pan-chip:hover{color:#fff;opacity:.8}
.pan-list{
display:grid;gap:8px;
max-height:50vh;min-height:200px;
overflow-y:auto;scrollbar-width:none;
padding-right:2px;
}
.pan-list::-webkit-scrollbar{display:none}
.pan-item{
width:100%;text-align:left;
display:flex;align-items:center;gap:12px;padding:10px 12px;
background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.07);
border-radius:8px;cursor:pointer;transition:background .12s,border-color .12s,transform .1s;
}
.pan-item:hover,.pan-item:focus{background:rgba(255,255,255,.08);border-color:rgba(255,255,255,.18);outline:none;transform:translateY(-1px)}
.pan-item.bad{opacity:.5}
/* 网盘类型徽标 */
.pan-item-icon{
flex:0 0 auto;width:36px;height:36px;border-radius:50%;
display:flex;align-items:center;justify-content:center;
font-family:var(--serif);font-size:13px;font-weight:700;
letter-spacing:0;line-height:1;
background:rgba(255,255,255,.05);
border:1.5px solid currentColor;
}
.pan-item-body{flex:1;min-width:0;display:grid;gap:5px}
.pan-item-title{
display:flex;align-items:baseline;gap:7px;
font-size:14px;font-weight:700;line-height:1.3;
overflow:hidden;color:#fff;
}
.pan-item-title span:first-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0}
.pan-item-meta{
display:flex;align-items:center;gap:6px;flex-wrap:wrap;
font-family:var(--mono);font-size:11px;color:var(--muted);
}
.pan-tag{
display:inline-flex;align-items:center;line-height:1;
padding:2px 6px;border-radius:4px;font-size:10px;font-weight:600;
background:rgba(255,255,255,.05);border:1px solid rgba(255,255,255,.08);
color:rgba(240,237,232,.55);white-space:nowrap;
}
.pan-tag.score{color:var(--accent);border-color:rgba(125,244,255,.3);background:rgba(125,244,255,.08)}
.pan-tag.quality{color:var(--accent);border-color:rgba(125,244,255,.3);background:rgba(125,244,255,.08);font-weight:700}
.pan-tag.dv{color:#b8a4f8;border-color:rgba(184,164,248,.35);background:rgba(184,164,248,.12);font-weight:700;letter-spacing:.02em}
.pan-tag.hdrp{color:#f4a8d0;border-color:rgba(244,168,208,.3);background:rgba(244,168,208,.1);font-weight:700}
.pan-tag.fhd{color:#7ec8e3;border-color:rgba(126,200,227,.3);background:rgba(126,200,227,.1);font-weight:700}
.pan-tag.hd{color:#82c98a;border-color:rgba(130,201,138,.28);background:rgba(130,201,138,.09);font-weight:700}
.pan-tag.sd{color:rgba(240,237,232,.4);border-color:rgba(255,255,255,.1);background:rgba(255,255,255,.04)}
.pan-tag.pwd{color:var(--yellow);border-color:rgba(255,214,10,.25);background:rgba(255,214,10,.07)}
.pan-tag.src{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:140px}
/* 健康状态徽章(替代原小圆点,信息更直观) */
.pan-health{
position:relative;
flex:0 0 auto;width:.5rem;height:.5rem;border-radius:50%;
background:#64748b;box-shadow:0 0 0 1px rgba(255,255,255,.15);
animation:dot-breathe 2s ease-in-out infinite;
}
.pan-health::after{
content:'';position:absolute;left:50%;top:50%;
width:.5rem;height:.5rem;border-radius:50%;
background:inherit;transform:translate(-50%,-50%);
animation:dot-sonar 2s ease-out infinite;
pointer-events:none;
}
.pan-health.ok{background:#22c55e}
.pan-health.bad{background:#ef4444}
.pan-health.locked{background:#f59e0b}
.pan-health.pending{background:#60a5fa}
.pan-empty{font-family:var(--mono);font-size:13px;color:var(--muted);padding:20px 0;text-align:center}
/* ── CONNECTION PANEL FIELDS ── */
.conn-field{
width:100%;background:rgba(255,255,255,.05);
border:1px solid rgba(255,255,255,.1);
border-radius:var(--r);color:var(--text);
font-family:var(--mono);font-size:13px;
padding:8px 10px;outline:none;
transition:border-color .12s;resize:none;
}
.conn-field:focus{border-color:var(--accent)}
.conn-field::placeholder{color:rgba(240,237,232,.3)}
.conn-label{
display:grid;gap:4px;font-family:var(--mono);font-size:11px;
letter-spacing:.12em;text-transform:uppercase;color:var(--muted);
}
.conn-disk-grid{
display:grid;grid-template-columns:repeat(auto-fill,minmax(56px,1fr));gap:6px;
margin:4px 0;
}
.conn-disk-label{
display:flex;align-items:center;gap:5px;
font-family:var(--mono);font-size:12px;color:var(--muted);cursor:pointer;
padding:5px 6px;border-radius:3px;border:1px solid rgba(255,255,255,.07);
transition:border-color .12s;
}
.conn-disk-label:hover{border-color:rgba(255,255,255,.2);color:var(--text)}
.conn-disk-label input{width:12px;height:12px;accent-color:var(--accent);cursor:pointer}
.conn-sep{height:1px;background:rgba(255,255,255,.07);margin:10px 0}
/* ── TOAST ── */
#toast{
position:fixed;bottom:calc(28px + var(--safe-b));left:50%;
transform:translateX(-50%) translateY(16px);
background:var(--surface2);border:1px solid rgba(255,255,255,.1);
color:var(--text);font-family:var(--mono);font-size:13px;
padding:7px 16px;border-radius:2px;z-index:999;
opacity:0;transition:opacity .2s,transform .2s;pointer-events:none;white-space:nowrap;
}
#toast.on{opacity:1;transform:translateX(-50%) translateY(0)}
/* ── LOADER ── */
#ldr{
position:fixed;top:0;right:0;bottom:0;left:0;z-index:500;background:var(--bg);
display:flex;align-items:center;justify-content:center;flex-direction:column;gap:14px;
transition:opacity .5s;
}
#ldr.done{opacity:0;pointer-events:none}
html.fm-native #ldr{ background:rgba(8,8,8,.4); }
.ldr-logo{font-family:var(--mono);font-size:20px;font-weight:700;letter-spacing:.3em;color:var(--accent)}
.ldr-bar{width:100px;height:2px;background:var(--surface2);border-radius:2px;overflow:hidden}
.ldr-fill{
height:100%;background:linear-gradient(90deg,var(--accent2),var(--accent));
width:0%;animation:lf 1.4s ease forwards;
}
@keyframes lf{to{width:100%}}
/* ── MISC ── */
.accent-line{
height:2px;width:100%;
background:linear-gradient(90deg,var(--accent2),var(--accent),transparent);
}
/* ── STATUS DOT ── */
.dot{flex-shrink:0;color:var(--muted)}
.dot.ok{color:#3ddc84}
.dot.warn{color:#ffcc4d}
.dot.bad{color:#ff453a}
.dot .dot-core{fill:currentColor;animation:dot-breathe 2s ease-in-out infinite}
.dot .dot-ring{
fill:none;stroke:currentColor;stroke-width:.75;
transform-box:fill-box;transform-origin:center;
animation:dot-sonar-hex 2s linear infinite;
}
@keyframes dot-sonar-hex{
0%{transform:scale(1);opacity:.9}
100%{transform:scale(4.5);opacity:0}
}
/* ── CONNECTION DOCK ── */
.conn-dock{position:relative;z-index:200;flex-shrink:0}
.conn-toggle{
display:flex;align-items:center;justify-content:center;
width:var(--nav-h);height:var(--nav-h);padding:0;
background:none;border:none;cursor:pointer;
color:var(--muted);overflow:visible;
transition:color .12s;
}
.conn-toggle:hover,.conn-toggle:focus{color:var(--text);outline:none}
.conn-arrow{font-size:15px;transition:transform .16s;opacity:.6}
.conn-dock.open .conn-arrow{transform:rotate(180deg)}
.conn-body{
display:none;position:absolute;right:0;top:calc(100% + 4px);
width:380px;
width:min(calc(100vw - 28px),380px);
background:rgba(17,17,17,.96);
border:1px solid rgba(255,255,255,.09);
border-radius:var(--r);
box-shadow:0 16px 48px rgba(0,0,0,.5);
backdrop-filter:blur(20px);
padding:14px;z-index:300;
transform:translateX(var(--conn-shift,0px));
max-height:80vh;overflow-y:auto;scrollbar-width:none;
}
.conn-body::-webkit-scrollbar{display:none}
.conn-body.open{display:block}
.conn-grid{display:grid;gap:8px;margin-bottom:12px}
.conn-item{
display:grid;grid-template-columns:minmax(52px,70px) 1fr;
gap:8px;align-items:baseline;font-size:13px;line-height:1.45;
}
.conn-item b{color:var(--muted);font-family:var(--mono);font-weight:700;font-size:12px}
.conn-item span{word-break:break-word;color:var(--text);font-family:var(--mono);font-size:12px}
.conn-actions{display:flex;gap:6px;flex-wrap:wrap;margin-top:10px;padding-top:10px;border-top:1px solid rgba(255,255,255,.06)}
.conn-btn{
font-family:var(--mono);font-size:13px;letter-spacing:.08em;
padding:6px 12px;background:var(--surface);color:var(--text);
border:1px solid rgba(255,255,255,.1);border-radius:var(--r);
cursor:pointer;transition:border-color .12s,background .12s;
}
.conn-btn:hover{border-color:rgba(255,255,255,.3);background:var(--surface2)}
.conn-section-title{
font-family:var(--mono);font-size:11px;letter-spacing:.2em;text-transform:uppercase;
color:var(--muted);margin-bottom:6px;margin-top:10px;
}
/* ── MIRROR CONFIG (官方 demo 移植) ── */
.conn-mirror-config {
display: grid;
gap: 8px;
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid rgba(255,255,255,.12);
}
.conn-mirror-config label {
display: grid;
gap: 5px;
color: var(--muted);
font-size: 11px;
font-weight: 700;
letter-spacing:.04em;
text-transform: uppercase;
}
.conn-mirror-config .conn-field {
width: 100%;
min-height: 36px;
padding: 8px 10px;
font-size: 11px;
font-family: var(--mono);
}
.conn-mirror-config textarea.conn-field {
min-height: 64px;
resize: vertical;
}
.mirror-progress {
min-height: 52px;
padding: 8px 10px;
border: 1px solid rgba(255,255,255,.12);
border-radius: var(--r);
color: var(--text);
background: rgba(0,0,0,.2);
font-family: var(--mono);
font-size: 11px;
line-height: 1.5;
white-space: pre-line;
word-break: break-word;
}
/* ══════════════════════════════════════════
TV / 遥控器 专属样式
仅在 body.tv-mode 下生效,不影响手机/桌面
焦点样式统一由下方「TV 性能优化」块定义
══════════════════════════════════════════ */
/* 聚焦时显示卡片浮层 */
body.tv-mode .card.tv-focus .card-overlay{ opacity:1 }
/* TV 游标指示器(屏幕右下角显示方向键提示,仅 TV 模式) */
#tv-hint{
display:none;
position:fixed;bottom:calc(14px + var(--safe-b));right:18px;z-index:9999;
font-family:var(--mono);font-size:11px;color:rgba(240,237,232,.35);
pointer-events:none;letter-spacing:.05em;
}
body.tv-mode #tv-hint{ display:block }
/* ══════════════════════════════════════════
TV 性能优化
- 关闭所有高成本视觉效果,优先稳定帧率
- 卡片自包含,缩小布局/样式影响范围
- 焦点反馈不改尺寸/边框,避免布局抖动
- 彻底消除浏览器默认白色 outline
══════════════════════════════════════════ */
/* 0. 彻底清除浏览器默认焦点白框:TV 模式下所有元素一律 outline:none
焦点样式由下方 .tv-focus 类统一接管,不依赖 :focus 伪类 */
body.tv-mode *:focus{
outline: none !important;
box-shadow: none !important;
}
/* 1a. 关闭大面积 backdrop-filter / blur / 图片 filter */
body.tv-mode #nav{ backdrop-filter:none; -webkit-backdrop-filter:none; background:rgba(8,8,8,.98); }
body.tv-mode #srch{ backdrop-filter:none; -webkit-backdrop-filter:none; background:rgba(8,8,8,.99); }
body.tv-mode .conn-body{ backdrop-filter:none; -webkit-backdrop-filter:none; background:rgba(17,17,17,.99); }
body.tv-mode #quickpan{ backdrop-filter:none; -webkit-backdrop-filter:none; background:rgba(6,17,27,.98); }
body.tv-mode #panBlock{ backdrop-filter:none; -webkit-backdrop-filter:none; }
body.tv-mode .card-rating{ backdrop-filter:none; -webkit-backdrop-filter:none; }
body.tv-mode .btn-s{ backdrop-filter:none !important; -webkit-backdrop-filter:none !important; }
body.tv-mode .card-tag,
body.tv-mode .card-badge{ backdrop-filter:none !important; -webkit-backdrop-filter:none !important; }
body.tv-mode .det-hero img{ filter:none; }
/* 详情吸顶栏:关掉动画毛玻璃过渡,滚动后纯色背景 */
body.tv-mode #detNav{
backdrop-filter:none !important; -webkit-backdrop-filter:none !important;
transition: background-color .15s linear, border-color .15s linear;
}
body.tv-mode #detNav.scrolled{ background:rgba(6,17,27,.97); }
/* 1b. 关闭持续重绘动画 */
/* 搜索框呼吸光效(box-shadow + border 双动画,每帧都 repaint */
body.tv-mode .srch-inp{ animation:none; box-shadow:none; border-color:rgba(125,244,255,.35); }
/* 播放按钮:流光 + 旋转霓虹环(conic+blur 持续合成层重绘) */
body.tv-mode #detPlayBtn{ animation:none; box-shadow:none; }
body.tv-mode #detPlayBtn::before{ display:none; }
body.tv-mode #detPlayBtn::after{ animation:none; }
body.tv-mode .hero-play-btn{ animation:none; box-shadow:none; }
body.tv-mode .hero-play-btn::before{ display:none; }
body.tv-mode .hero-play-btn::after{ animation:none; }
/* 卡片状态点呼吸 + 声纳扩散(每张卡片都在跑,积累重绘面积大) */
body.tv-mode .card-dot{ animation:none; }
body.tv-mode .card-dot::after{ animation:none; display:none; }
/* Hero 轮播背景 Ken Burns8s transform,触发 GPU 合成层抖动) */
body.tv-mode .hero-slide.active .hero-slide-bg{ transform:none !important; transition:none; }
/* 扫描线叠层 */
body.tv-mode body::before{ display:none; }
/* 2. 卡片 / 列表项自包含,缩小 layout/style 影响范围 */
body.tv-mode .card,
body.tv-mode .sli,
body.tv-mode .pan-item,
body.tv-mode .det-cast-card,
body.tv-mode .srch-suggest-item,
body.tv-mode .srch-hist-tag{
contain: layout style;
}
/* 2b. 网格/版块容器同样自包含:焦点切换时的局部重排不波及整页,
减少遥控器连续按键时的布局计算量 */
body.tv-mode .grid,
body.tv-mode .sec{
contain: layout style;
}
/* 3. 焦点反馈:统一走 .tv-focus 类,outline 描边 + 轻 transform,零 box-shadow */
body.tv-mode .card.tv-focus{
transform: scale(1.06);
outline: 3px solid var(--accent);
outline-offset: -1px;
box-shadow: none !important;
z-index: 20;
}
body.tv-mode .tv-focusable.tv-focus{
outline: 2.5px solid var(--accent) !important;
box-shadow: none !important;
}
body.tv-mode .ntab.tv-focus{
color: var(--accent) !important;
background: rgba(125,244,255,.15) !important;
outline: 2px solid var(--accent) !important;
outline-offset: 1px;
box-shadow: none !important;
}
body.tv-mode .sli.tv-focus{
background: rgba(255,255,255,.12) !important;
outline: 2px solid var(--accent) !important;
box-shadow: none !important;
}
body.tv-mode .srch-suggest-item.tv-focus,
body.tv-mode .srch-hot li.tv-focus{
background: rgba(255,255,255,.09) !important;
outline: 2px solid var(--accent) !important;
color: var(--accent);
box-shadow: none !important;
}
body.tv-mode .hero-play-btn.tv-focus{
outline: 2px solid var(--accent) !important;
transform: scale(1.04) !important;
box-shadow: none !important;
filter: brightness(1.12);
}
body.tv-mode .btn-p.tv-focus,
body.tv-mode .btn-s.tv-focus{
outline: 2.5px solid var(--accent) !important;
outline-offset: 2px;
box-shadow: none !important;
filter: brightness(1.12);
}
body.tv-mode .fil-tag.tv-focus,
body.tv-mode .cat-back.tv-focus,
body.tv-mode .sec-more.tv-focus,
body.tv-mode .sechd.tv-focus{
outline: 2.5px solid var(--accent) !important;
outline-offset: 2px;
border-radius: 14px;
box-shadow: none !important;
}
body.tv-mode .det-cast-card.tv-focus .det-cast-img{
border-color: var(--accent);
box-shadow: none !important;
}
body.tv-mode .pan-item.tv-focus,
body.tv-mode .pan-chip.tv-focus{
outline: 2px solid var(--accent) !important;
box-shadow: none !important;
}
/* ── TV 详情页专属微调 ──
1) 去掉遥控选中框(outline),只保留缩放/高亮,贴合按钮圆角和卡片本身造型
2) 整体内容往内收一些安全边距,避免被电视 overscan 裁切到边缘
3) 收紧顶部留白,让标题/简介/按钮组不至于堆在屏幕偏下位置 */
body.tv-mode #det .btn-p.tv-focus,
body.tv-mode #det .btn-s.tv-focus{
outline: none !important;
}
body.tv-mode #detRecsGrid .card.tv-focus{
outline: none !important;
}
body.tv-mode .det-inner{
padding-left: max(56px, 5vw) !important;
padding-right: max(56px, 5vw) !important;
/* 上下边距:原值继承自手机端窄边距(20px/48px),电视上贴边明显,
这里按电视安全区放大,避免被 overscan 裁切、避免内容贴边 */
padding-top: max(40px, 4vh) !important;
padding-bottom: max(56px, 6vh) !important;
}
body.tv-mode #detNav{
padding-left: max(28px, 3vw) !important;
padding-right: max(28px, 3vw) !important;
}
body.tv-mode .det-poster-row{
padding-top: max(8px, 3vh) !important;
margin-top: max(8px, 3vh) !important;
}
</style></head><body><!-- Loader --><div id="tv-hint">↑↓←→ 导航 OK 确认 ← 返回</div><!-- Nav --><nav id="nav"><div class="ntabs" id="ntabs"><button class="ntab on" data-sec="s-new" onclick="scrollSec('s-new')"><svg class="ntab-ico" viewbox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" /><polyline points="9 22 9 12 15 12 15 22" /></svg><span class="ntab-lbl">最新上线</span></button><button class="ntab" data-sec="s-movie" onclick="scrollSec('s-movie')"><svg class="ntab-ico" viewbox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="20" rx="2.18" ry="2.18" /><line x1="7" y1="2" x2="7" y2="22" /><line x1="17" y1="2" x2="17" y2="22" /><line x1="2" y1="12" x2="22" y2="12" /><line x1="2" y1="7" x2="7" y2="7" /><line x1="2" y1="17" x2="7" y2="17" /><line x1="17" y1="17" x2="22" y2="17" /><line x1="17" y1="7" x2="22" y2="7" /></svg><span class="ntab-lbl">电影</span></button><button class="ntab" data-sec="s-top" onclick="scrollSec('s-top')"><svg class="ntab-ico" viewbox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" /></svg><span class="ntab-lbl">高分精选</span></button><button class="ntab" data-sec="s-cn" onclick="scrollSec('s-cn')"><svg class="ntab-ico" viewbox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" /></svg><span class="ntab-lbl">国产剧</span></button><button class="ntab" data-sec="s-hk" onclick="scrollSec('s-hk')"><svg class="ntab-ico" viewbox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10" /><line x1="2" y1="12" x2="22" y2="12" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg><span class="ntab-lbl">港台剧</span></button><button class="ntab" data-sec="s-jpkr" onclick="scrollSec('s-jpkr')"><svg class="ntab-ico" viewbox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 18v-6a9 9 0 0 1 18 0v6" /><path d="M21 19a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3zM3 19a2 2 0 0 0 2 2h1a2 2 0 0 0 2-2v-3a2 2 0 0 0-2-2H3z" /></svg><span class="ntab-lbl">日韩剧</span></button><button class="ntab" data-sec="s-us" onclick="scrollSec('s-us')"><svg class="ntab-ico" viewbox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="7" width="20" height="15" rx="2" ry="2" /><polyline points="17 2 12 7 7 2" /></svg><span class="ntab-lbl">欧美剧</span></button><button class="ntab" data-sec="s-doc" onclick="scrollSec('s-doc')"><svg class="ntab-ico" viewbox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" 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><span class="ntab-lbl">纪录片</span></button><button class="ntab" data-sec="s-ani" onclick="scrollSec('s-ani')"><svg class="ntab-ico" viewbox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" /></svg><span class="ntab-lbl">动漫</span></button><button class="ntab" data-sec="s-var" onclick="scrollSec('s-var')"><svg class="ntab-ico" viewbox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" /><circle cx="12" cy="12" r="3" fill="currentColor" stroke="none" /></svg><span class="ntab-lbl">综艺</span></button></div><button aria-label="搜索" class="nbtn" onclick="openSrch()"><svg fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewbox="0 0 24 24"><circle cx="11" cy="11" r="8" /><line x1="21" x2="16.65" y1="21" y2="16.65" /></svg></button><!-- 状态按钮 --><div class="conn-dock" id="connDock"><button aria-expanded="false" class="conn-toggle" id="connToggle" onclick="toggleConn(event)"><svg class="dot warn" id="connDot" width="20" height="20" viewbox="0 0 20 20" style="overflow:visible" xmlns="http://www.w3.org/2000/svg"><g transform="translate(10,10)"><polygon class="dot-ring" points="0,-4 3.5,-2 3.5,2 0,4 -3.5,2 -3.5,-2" /><polygon class="dot-ring" points="0,-4 3.5,-2 3.5,2 0,4 -3.5,2 -3.5,-2" style="animation-delay:.66s" /><polygon class="dot-ring" points="0,-4 3.5,-2 3.5,2 0,4 -3.5,2 -3.5,-2" style="animation-delay:1.33s" /><polygon class="dot-core" points="0,-4 3.5,-2 3.5,2 0,4 -3.5,2 -3.5,-2" /></g></svg></button><div class="conn-body" id="connBody"><!-- 连接状态 --><div class="conn-section-title">连接状态</div><div class="conn-grid"><div class="conn-item"><b>App SDK</b><span id="stSdk">检测中</span></div><div class="conn-item"><b>TMDB</b><span id="stTmdb">等待请求</span></div><div class="conn-item"><b>站点</b><span id="stSite">未配置</span></div><div class="conn-item"><b>盘搜</b><span id="stPan">未搜索</span></div></div><div class="conn-sep"></div><!-- TMDB 配置 --><div class="conn-section-title">TMDB 配置</div><label class="conn-label" style="margin-top:6px">TMDB API Key<input autocomplete="off" class="conn-field" id="tmdbKeyInp" placeholder="留空使用内置 Key" type="password"></label><div class="conn-sep"></div><!-- 盘搜配置 --><div class="conn-section-title">盘搜配置</div><label class="conn-label" style="margin-top:6px">盘搜地址<input class="conn-field" id="panBaseInp" placeholder="https://so.252035.xyz" type="url"></label><label class="conn-label" style="margin-top:8px">账号(未启用认证可留空)<input autocomplete="username" class="conn-field" id="panUserInp" placeholder="用户名" type="text"></label><label class="conn-label" style="margin-top:8px">密码<input autocomplete="current-password" class="conn-field" id="panPassInp" placeholder="密码" type="password"></label><label class="conn-label" style="margin-top:8px">启用网盘类型<div class="conn-disk-grid" id="connDiskGrid"></div></label><div class="conn-sep"></div><!-- 镜像 Relay 配置 (官方 demo 移植) --><div class="conn-section-title">镜像 Relay</div><div class="conn-mirror-config"><label>镜像源 Relays<textarea class="conn-field" id="mirrorSourceInput" rows="3" placeholder="每行一个源 relay" readonly></textarea></label><label>镜像目标 Relays<textarea class="conn-field" id="mirrorTargetInput" rows="2" placeholder="每行一个目标 relay,例如 wss://your-relay.example" readonly></textarea></label><div class="mirror-progress" id="mirrorProgress">镜像未执行</div><button class="conn-btn" id="mirrorRelayBtn" onclick="mirrorHotEventsToRelays()">镜像榜单</button></div><div class="conn-sep"></div><div class="conn-actions"><button class="conn-btn" onclick="saveConnConfig()">保存配置</button><button class="conn-btn" onclick="setConnOpen(false)">关闭</button></div></div></div></nav><!-- Main --><main id="main"><div class="accent-line"></div><!-- Search Result Section (inline, hidden by default) --><section class="sec" id="s-search" style="display:none"><div class="sechd"><span class="sectitle" id="srchResTitle">搜索结果</span><div class="secline"></div><span class="seccnt" id="cnt-search"></span><button onclick="closeSrchResult()" style="background:none;border:none;cursor:pointer;color:var(--muted);font-size:13px;padding:4px 8px;border-radius:4px;white-space:nowrap;flex-shrink:0">✕ 清除</button></div><div id="srch-list"></div></section><!-- Hero Carousel --><div id="heroWrap"><div id="heroSlides"></div><div id="heroDots"></div></div><!-- Sections --><section class="sec" id="s-new"><div class="sechd clickable" onclick="openCat('new')"><span class="sectitle">最新上线</span><div class="secline"></div><span class="seccnt" id="cnt-new"></span><span class="sec-more">更多<svg viewbox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6" /></svg></span></div><div class="grid" id="g-new"></div></section><section class="sec" id="s-movie"><div class="sechd clickable" onclick="openCat('movie')"><span class="sectitle">电影</span><div class="secline"></div><span class="seccnt" id="cnt-movie"></span><span class="sec-more">更多<svg viewbox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6" /></svg></span></div><div class="grid" id="g-movie"></div></section><section class="sec" id="s-top"><div class="sechd clickable" onclick="openCat('top')"><span class="sectitle">高分精选</span><div class="secline"></div><span class="seccnt" id="cnt-top"></span><span class="sec-more">更多<svg viewbox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6" /></svg></span></div><div class="grid" id="g-top"></div></section><section class="sec" id="s-cn"><div class="sechd clickable" onclick="openCat('cn')"><span class="sectitle">国产剧</span><div class="secline"></div><span class="seccnt" id="cnt-cn"></span><span class="sec-more">更多<svg viewbox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6" /></svg></span></div><div class="grid grid-land" id="g-cn"></div></section><section class="sec" id="s-hk"><div class="sechd clickable" onclick="openCat('hk')"><span class="sectitle">港台剧</span><div class="secline"></div><span class="seccnt" id="cnt-hk"></span><span class="sec-more">更多<svg viewbox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6" /></svg></span></div><div class="grid grid-land" id="g-hk"></div></section><section class="sec" id="s-jpkr"><div class="sechd clickable" onclick="openCat('jpkr')"><span class="sectitle">日韩剧</span><div class="secline"></div><span class="seccnt" id="cnt-jpkr"></span><span class="sec-more">更多<svg viewbox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6" /></svg></span></div><div class="grid grid-land" id="g-jpkr"></div></section><section class="sec" id="s-us"><div class="sechd clickable" onclick="openCat('us')"><span class="sectitle">欧美剧</span><div class="secline"></div><span class="seccnt" id="cnt-us"></span><span class="sec-more">更多<svg viewbox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6" /></svg></span></div><div class="grid grid-land" id="g-us"></div></section><section class="sec" id="s-doc"><div class="sechd clickable" onclick="openCat('doc')"><span class="sectitle">纪录片</span><div class="secline"></div><span class="seccnt" id="cnt-doc"></span><span class="sec-more">更多<svg viewbox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6" /></svg></span></div><div class="grid" id="g-doc"></div></section><section class="sec" id="s-ani"><div class="sechd clickable" onclick="openCat('ani')"><span class="sectitle">动漫</span><div class="secline"></div><span class="seccnt" id="cnt-ani"></span><span class="sec-more">更多<svg viewbox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6" /></svg></span></div><div class="grid" id="g-ani"></div></section><section class="sec" id="s-var"><div class="sechd clickable" onclick="openCat('var')"><span class="sectitle">综艺</span><div class="secline"></div><span class="seccnt" id="cnt-var"></span><span class="sec-more">更多<svg viewbox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6" /></svg></span></div><div class="grid grid-land" id="g-var"></div></section></main><!-- Search --><div aria-label="搜索" aria-modal="true" id="srch" role="dialog"><div class="srch-top"><div class="srch-wrap"><svg class="srch-ico" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewbox="0 0 24 24"><circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" /></svg><input autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" class="srch-inp" id="srchInp" placeholder="搜索电影、剧集…" type="search" enterkeyhint="search" inputmode="search" oninput="onSrchInput(event)" onkeydown="onSrchKey(event)"><button class="srch-submit-btn" id="srchSubmit" onclick="submitSrch()" aria-label="搜索"><svg fill="none" width="15" height="15" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2.2" viewbox="0 0 24 24"><circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" /></svg></button></div><button class="srch-cancel" onclick="closeSrch()">取消</button></div><div id="srchBody"><div class="srch-label">热门搜索</div><ul class="srch-hot" id="srchHot"></ul></div></div><!-- Detail --><div id="det"><div id="detNav"><span id="detNavTitle"></span></div><div id="detInner"></div></div><!-- 分类页(筛选 + 看更多) --><div id="cat" role="dialog" aria-modal="true" aria-label="分类"><div id="catTopbar"><button id="catBack" class="cat-back" aria-label="返回" onclick="history.back()"><svg viewbox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6" /></svg></button><span id="catTitle" class="cat-title"></span><span id="catCount" class="cat-count"></span></div><div id="catFilters"></div><div id="catScroll"><div class="grid" id="catGrid"></div><div id="catSentinel"></div><div id="catFoot" class="cat-foot"></div></div></div><!-- Toast --><div id="toast"></div><script>
'use strict';
// ── CONFIG ──
let TMDB_KEY = 'd7040155454e7fdf547c4d889ebbcca7';
const TMDB = 'https://api.tmdb.org/3';
const IMG_W500 = 'https://image.tmdb.org/t/p/w780'; // 升级到 w780
const IMG_W780 = 'https://image.tmdb.org/t/p/w1280'; // 升级到 w1280
const IMG_ORIG = 'https://image.tmdb.org/t/p/original';
const LANG = 'zh-CN';
// TV 端用更小尺寸的图:盒子解码慢、显存小,原图/大图是遥控卡顿的主因。
// 注意 TV_MODE 在文件后段定义,这里在运行时(渲染发生在 boot 之后)读取,安全。
function posterSrc(path, land){
if(!path) return '';
if(typeof TV_MODE !== 'undefined' && TV_MODE)
return (land ? 'https://image.tmdb.org/t/p/w500' : 'https://image.tmdb.org/t/p/w342') + path;
return (land ? IMG_W780 : IMG_W500) + path;
}
function backdropSrc(path){
if(!path) return '';
return ((typeof TV_MODE !== 'undefined' && TV_MODE) ? 'https://image.tmdb.org/t/p/w1280' : IMG_ORIG) + path;
}
// ── SDK ──
const IS_APP = !!window.fongmiBridge;
const IS_TV = window.fongmiClient ? window.fongmiClient.isLeanback : false;
// fm-native 已在 <head> 顶部同步打标;此处兜底(SDK 延迟注入时再确认一次)
if((IS_APP || window.fongmiClient) && !document.documentElement.classList.contains('fm-native')){
document.documentElement.classList.add('fm-native');
}
let siteKey = '';
let heroData = null;
let srchTimer = null;
let detData = null;
let _nativeSdkWait = null;
function waitForNativeSdk(timeout){
if(window.fm || !window.fongmiBridge) return Promise.resolve(!!window.fm);
if(_nativeSdkWait) return _nativeSdkWait;
_nativeSdkWait = new Promise(resolve=>{
let done = false;
const finish = ()=>{
if(done) return; done = true;
window.removeEventListener('fmsdk', finish);
_nativeSdkWait = null;
resolve(!!window.fm);
};
window.addEventListener('fmsdk', finish);
setTimeout(finish, timeout == null ? 1500 : timeout);
});
return _nativeSdkWait;
}
function addPanPassword(url, password, opts){
// 简单拼接提取码(浏览器 fallback 用)
if(!url) return url;
if(password && !/[\?&]pwd=/.test(url)){
const sep = url.includes('?') ? '&' : '?';
return url + sep + 'pwd=' + encodeURIComponent(password);
}
return url;
}
function sdk(){
if(window.fm){
const pan = window.fm.pan || {};
return Object.assign({}, window.fm, { pan, check: pan.check || window.fm.check });
}
// 浏览器 / SDK 未注入时的完整 fallback —— 永远可用
const check = async (items)=>({ results: (items||[]).map(item=>({
type: item.type, url: item.url, normalized_url: item.url,
state:'idle', summary:'浏览器预览不检测', cache_hit:false,
checked_at: Date.now(), expires_at: Date.now()+300000
}))});
// fallback pan.playNative SDK 未注入时禁止播放,明确报错,不静默失败
const play = async (payload)=>{
console.error('[Eclipse] pan.play fallback triggered — Native SDK not injected', payload);
throw new Error('Native SDK 未注入,无法调起原生播放器。请确认在 FongMi App 内打开此页面。');
};
return {
req: async (url, opts)=>{ const r=await fetch(url); return {ok:r.ok, body:await r.json()}; },
res: (url)=>url,
play: async (url)=>{ throw new Error('Native SDK 未注入'); },
search: async (kw, opts)=>toast('原生搜索'+(opts&&opts.direct?'(直接播放)':'')+''+kw),
openLive: async ()=>toast('请在 App 中打开直播'),
openKeep: async ()=>toast('请在 App 中打开收藏'),
history: async ()=>[],
vod: async (siteKey, vodId, title)=>toast('原生播放:'+(title||vodId||siteKey||'')),
pan: { check, play },
check,
ui: {
setToolbar: async ()=>{},
setChrome: async ()=>{},
restoreChrome: async ()=>{},
getViewport: async ()=>({ width: innerWidth, height: innerHeight, safeTop:0, safeRight:0, safeBottom:0, safeLeft:0, chromeMode:'normal', systemBarsHidden:false })
},
cache: {
get: async (key)=>localStorage.getItem('fm_'+key)||'',
set: async (key,value)=>localStorage.setItem('fm_'+key,value),
del: async (key)=>localStorage.removeItem('fm_'+key)
},
site: async ()=>({key:'browser',name:'浏览器预览'}),
config: async ()=>({id:'browser',url:location.href,driveCheck:false}),
device: async ()=>({type:1})
};
}
function sdkReady(cb){
if(window.fm){ cb(); return; }
if(!window.fongmiBridge){ cb(); return; }
window.addEventListener('fmsdk', cb, {once:true});
}
// ── TMDB FETCH ──
async function tmdb(path, params={}){
const u = new URL(TMDB+path);
u.searchParams.set('api_key', TMDB_KEY);
u.searchParams.set('language', LANG);
for(const [k,v] of Object.entries(params)) u.searchParams.set(k,v);
// 优先走 sdk().reqNative OkHttp),绕开 WebView CORS 限制
// 浏览器模式降级:直接 fetch(TMDB 支持浏览器跨域,api_key 模式允许 CORS
try{
const r = await sdk().req(u.toString(), {responseType:'text', timeout:18});
if(r && r.error) throw new Error(r.error);
const body = r && r.body;
const data = typeof body === 'string' ? JSON.parse(body || '{}') : (body || {});
updateConnStatus('tmdb','成功');
return data;
}catch(e){
// sdk().req 失败(CORS / 未注入)时,尝试直接 fetch
try{
const resp = await fetch(u.toString(), {
headers:{ 'Accept':'application/json' }
});
if(!resp.ok) throw new Error('HTTP '+resp.status);
const data = await resp.json();
updateConnStatus('tmdb','成功');
return data;
}catch(e2){
updateConnStatus('tmdb','请求失败');
throw e2;
}
}
}
// ── IMG URL ──
function imgUrl(path, size=IMG_W500){
if(!path) return '';
return size + path;
}
function resUrl(path, size=IMG_W500){
if(!path) return '';
return size + path;
}
// ── ESCAPE ──
function esc(s){ return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;'); }
// ── SKELETON ──
function sks(n, land=false){
return Array.from({length:n},()=>`<div class="sk sk-card${land?' land':''}"></div>`).join('');
}
// ── COLOR FROM ID ──
function hue(id){ return (id * 137) % 360; }
// ── HERO CAROUSEL ──
let heroItems = [];
let heroCurrent = 0;
let heroTimer = null;
const HERO_INTERVAL = 5000;
function buildHeroSlide(item, idx){
const isTV = !!item.name;
const title = item.title || item.name || '';
const backdrop = item.backdrop_path;
const overview = item.overview || '';
const year = (item.release_date||item.first_air_date||'').slice(0,4);
const rating = item.vote_average ? item.vote_average.toFixed(1) : '';
const bgUrl = backdropSrc(backdrop);
return `<div class="hero-slide${idx===0?' active':''}" data-idx="${idx}" onclick="openDet(heroItems[${idx}])">
<div class="hero-slide-bg" style="${bgUrl?`background-image:url(${bgUrl})`:''}"></div>
<div class="hero-slide-grad"></div>
<div class="hero-slide-content">
<div class="hero-logo-wrap" id="heroLogoWrap${idx}">
<div class="hero-title" id="heroTitle${idx}">${esc(title)}</div>
</div>
<div class="hero-meta">${[
year ? `<span class="hm-item">${year}</span>` : '',
rating ? `<span class="hm-item star">★ ${rating}</span>` : '',
`<span class="hm-item">${isTV?'剧集':'电影'}</span>`
].filter(Boolean).join('')}</div>
<button class="hero-play-btn" onclick="event.stopPropagation();heroPlayItem(heroItems[${idx}])">
<svg fill="currentColor" viewBox="0 0 24 24"><path d="M5 3l14 9-14 9V3z"/></svg>
搜索播放
</button>
</div>
</div>`;
}
// 异步加载轮播徽标(TMDB logo),成功则替换文字标题
async function loadHeroLogos(items){
items.forEach(async (item, idx)=>{
try{
const isTV = !!item.name;
const endpoint = isTV ? `/tv/${item.id}/images` : `/movie/${item.id}/images`;
const imgs = await tmdb(endpoint, {include_image_language:'zh,en,null'});
const logos = (imgs.logos||[]).sort((a,b)=>(b.vote_average||0)-(a.vote_average||0));
const logo = logos.find(l=>l.file_path&&l.iso_639_1==='zh')
|| logos.find(l=>l.file_path&&l.iso_639_1==='en')
|| logos[0];
if(!logo || !logo.file_path) return;
const wrap = document.getElementById('heroLogoWrap'+idx);
const title = document.getElementById('heroTitle'+idx);
if(!wrap) return;
const img = document.createElement('img');
img.className = 'hero-logo-img';
img.alt = item.title || item.name || '';
img.decoding = 'async';
// 对齐官方 logo-pending:先隐藏文字标题,避免「文字闪一下再变徽标」的跳动
if(title) title.classList.add('has-logo');
img.onload = ()=>{ img.classList.add('loaded'); };
img.onerror = ()=>{ img.remove(); if(title) title.classList.remove('has-logo'); }; // 失败恢复文字标题
// 徽标显示高度 ≤108px,用 w500 足够清晰且比 original 小很多,TV 上加载更快、更稳定出图
img.src = 'https://image.tmdb.org/t/p/w500' + logo.file_path;
wrap.appendChild(img);
}catch(e){ /* 静默失败,保留文字标题 */ }
});
}
function buildHeroDots(){
const el = document.getElementById('heroDots');
if(!el) return;
el.innerHTML = heroItems.map((_,i)=>
`<button class="hero-dot${i===heroCurrent?' active':''}" onclick="event.stopPropagation();goHero(${i})"></button>`
).join('');
}
function goHero(idx){
const slides = document.querySelectorAll('.hero-slide');
const dots = document.querySelectorAll('.hero-dot');
if(!slides.length) return;
if(slides[heroCurrent])slides[heroCurrent].classList.remove('active');
if(dots[heroCurrent])dots[heroCurrent].classList.remove('active');
heroCurrent = (idx + heroItems.length) % heroItems.length;
if(slides[heroCurrent])slides[heroCurrent].classList.add('active');
if(dots[heroCurrent])dots[heroCurrent].classList.add('active');
heroData = heroItems[heroCurrent];
resetHeroProgress();
}
function resetHeroProgress(){
const bar = document.getElementById('heroProgress');
if(!bar) return;
if(typeof TV_MODE !== 'undefined' && TV_MODE) return; // TV 模式不跑进度条动画
bar.style.transition = 'none';
bar.style.width = '0%';
requestAnimationFrame(()=>{
requestAnimationFrame(()=>{
bar.style.transition = `width ${HERO_INTERVAL}ms linear`;
bar.style.width = '100%';
});
});
}
function startHeroTimer(){
clearInterval(heroTimer);
// TV 模式:关闭自动轮播定时器。低端盒子上每 5s 的 DOM 切换 + 进度条动画
// 会占用主线程,和遥控按键事件抢时间片,是按键延迟的常见来源。
// 手动切换(点击 dot / 滑动)仍然可用,只是不再无人值守自动播放。
if(typeof TV_MODE !== 'undefined' && TV_MODE) return;
heroTimer = setInterval(()=>goHero(heroCurrent+1), HERO_INTERVAL);
resetHeroProgress();
}
// 页面切到后台(切 App / 锁屏 / 切 tab)时暂停轮播计时器;回到前台再续上。
// 否则后台期间计时器仍在空跑,回来时可能一次性跳过好几张。
document.addEventListener('visibilitychange', ()=>{
if(!heroItems || !heroItems.length) return;
if(document.hidden) clearInterval(heroTimer);
else startHeroTimer();
});
function initHeroCarousel(items){
heroItems = items;
heroData = items[0];
heroCurrent = 0;
const slidesEl = document.getElementById('heroSlides');
if(slidesEl) slidesEl.innerHTML = items.map((item,i)=>buildHeroSlide(item,i)).join('');
buildHeroDots();
startHeroTimer();
loadHeroLogos(items);
// pause on hover —— 仅真鼠标设备启用;触屏点击会触发合成 mouseenter 却无 mouseleave
// 会把计时器永久清掉导致返回后不再轮播,故触屏不绑定悬停暂停。
const wrap = document.getElementById('heroWrap');
const _canHover = (window.matchMedia && window.matchMedia('(hover:hover) and (pointer:fine)').matches);
if(wrap && _canHover){
wrap.addEventListener('mouseenter',()=>clearInterval(heroTimer));
wrap.addEventListener('mouseleave',()=>startHeroTimer());
}
// ── 触屏滑动手势(移动端左右滑切换轮播)──
// 仅在非鼠标设备上绑定,避免与桌面端悬停暂停逻辑冲突
if(wrap && !_canHover){
let _hTx = 0, _hTy = 0, _hSwiping = false, _hMoved = false;
wrap.addEventListener('touchstart', e=>{
if(e.touches.length !== 1) return;
_hTx = e.touches[0].clientX;
_hTy = e.touches[0].clientY;
_hSwiping = false;
_hMoved = false;
clearInterval(heroTimer); // 手触期间暂停自动轮播
}, {passive:true});
wrap.addEventListener('touchmove', e=>{
if(e.touches.length !== 1) return;
const dx = e.touches[0].clientX - _hTx;
const dy = e.touches[0].clientY - _hTy;
// 第一次 move:判定方向;水平方向明显大于垂直才认定为滑动,否则让页面正常纵向滚动
if(!_hSwiping && !_hMoved){
_hMoved = true;
if(Math.abs(dx) > Math.abs(dy) + 6){
_hSwiping = true;
}
}
if(_hSwiping) e.preventDefault(); // 阻止页面纵向滚动
}, {passive:false});
wrap.addEventListener('touchend', e=>{
const dx = e.changedTouches[0].clientX - _hTx;
const dy = e.changedTouches[0].clientY - _hTy;
startHeroTimer(); // 无论如何都恢复自动轮播
// 水平位移足够(>50px)且水平方向比垂直方向更主导,才触发切换
if(_hSwiping && Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy) * 1.2){
goHero(dx < 0 ? heroCurrent + 1 : heroCurrent - 1);
}
_hSwiping = false;
_hMoved = false;
}, {passive:true});
}
}
function heroClick(){ if(heroData) openDet(heroData); }
function heroPlay(){ if(heroData) openDet(heroData); }
async function heroPlayItem(item){
if(!item) return;
await openDet(item);
// slight delay so detail panel is ready
setTimeout(()=>detPlay(), 300);
}
// ── CARD HTML ──
function cardHtml(item, land=false){
const isTV = !!item.name;
const title = item.title || item.name || '';
const year = (item.release_date||item.first_air_date||'').slice(0,4);
const rating = item.vote_average ? item.vote_average.toFixed(1) : '';
const poster = land ? item.backdrop_path : item.poster_path;
const imgSrc = posterSrc(poster, land);
const bg = `hsla(${hue(item.id)},18%,13%,0.5)`;
return `<div class="card" tabindex="0" role="button"
data-id="${item.id}" data-tv="${isTV?1:0}"
onclick="openDet(ITEMS[${item.id}])"
onkeydown="if(event.key==='Enter')openDet(ITEMS[${item.id}])"
oncontextmenu="event.preventDefault();lpLongPress(${item.id})">
<div class="card-img${land?' land':''}" style="background:${bg}">
${imgSrc?`<img src="${imgSrc}" alt="" loading="lazy" decoding="async" data-loading onload="this.classList.add('loaded');this.removeAttribute('data-loading')" onerror="this.remove()">`:``}
<div class="card-overlay"></div>
${rating?`<div class="card-rating">★${rating}</div>`:''}
<div class="card-dot" id="dot-${item.id}"></div>
</div>
<div class="card-body">
<div class="card-name">${esc(title)}</div>
<div class="card-sub">${year}${isTV?' · 剧集':' · 电影'}</div>
</div>
</div>`;
}
// Global item registry
const ITEMS = {};
// 记录各轨道是否已经被用户滑动过(首次渲染后置true,重新进页面时loadAll会重置)
const _gridScrolled = {};
function renderGrid(gridId, cntId, items, land=false){
items.forEach(i=>ITEMS[i.id]=i);
const g = document.getElementById(gridId);
if(g){
const sl = g.scrollLeft;
g.innerHTML = items.map(i=>cardHtml(i,land)).join('');
// 已被用户滑动过的轨道:恢复位置;首次加载:从0开始
if(_gridScrolled[gridId] && sl) g.scrollLeft = sl;
// 监听用户首次滑动
if(!_gridScrolled[gridId]){
g.addEventListener('scroll', ()=>{ _gridScrolled[gridId] = true; }, {once:true, passive:true});
}
}
const c = document.getElementById(cntId);
if(c) c.textContent = items.length + ' 部';
}
// ── LOAD ALL ──
// 动态日期工具(参考 nostr 版逻辑)
function relDate(offsetDays){
const d = new Date();
if(offsetDays) d.setDate(d.getDate() + offsetDays);
return d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0')+'-'+String(d.getDate()).padStart(2,'0');
}
async function loadAll(){
// 重新进页面时清除滚动记录,让所有轨道回到起点
Object.keys(_gridScrolled).forEach(k=>delete _gridScrolled[k]);
updateHomeRails();
// Skeletons
document.getElementById('g-new').innerHTML = sks(24);
document.getElementById('g-movie').innerHTML = sks(24);
document.getElementById('g-top').innerHTML = sks(24);
document.getElementById('g-cn').innerHTML = sks(20, true);
document.getElementById('g-hk').innerHTML = sks(20, true);
document.getElementById('g-jpkr').innerHTML = sks(20, true);
document.getElementById('g-us').innerHTML = sks(20, true);
document.getElementById('g-doc').innerHTML = sks(20);
document.getElementById('g-ani').innerHTML = sks(20);
document.getElementById('g-var').innerHTML = sks(20, true);
const get = r => (r.status==='fulfilled' && r.value && r.value.results) ? r.value.results : [];
function merge(...arrays){
const seen = new Set(); const out = [];
for(const arr of arrays)
for(const item of arr)
if(item && item.id && !seen.has(item.id)){ seen.add(item.id); out.push(item); }
return out;
}
// ── 并行所有请求 ──
const [
trendWeek,
trendDay,
nowPlay1, nowPlay2,
movPop1, movPop2, movUpcoming,
cnNew1, cnNew2,
hkPop, hkNew,
twPop, twNew,
krPop, krNew,
jpPop, jpNew,
usPop1, usPop2, usNew,
docPop, docTv,
aniJP, aniZH,
// 综艺
varCN1, varCN2,
varHK, varTW,
varKR, varJP,
topMov1, topMov2,
topTv1, topTv2,
] = await Promise.allSettled([
tmdb('/trending/all/week', {page:'1'}),
tmdb('/trending/movie/day', {page:'1'}),
// 电影:院线 + 热门
tmdb('/movie/now_playing', {page:'1'}),
tmdb('/movie/now_playing', {page:'2'}),
tmdb('/movie/popular', {page:'1'}),
tmdb('/movie/popular', {page:'2'}),
tmdb('/movie/upcoming', {page:'1'}),
// 国产剧:with_type=4连续剧, without_genres排除综艺10764/真人秀10766/脱口秀10767
tmdb('/discover/tv', {
with_original_language:'zh', with_origin_country:'CN',
without_genres:'10764,10766,10767',
with_type:'4',
sort_by:'popularity.desc',
include_null_first_air_dates:'false',
'first_air_date.gte': relDate(-180),
'first_air_date.lte': relDate(7),
}),
tmdb('/discover/tv', {
with_original_language:'zh', with_origin_country:'CN',
without_genres:'10764,10766,10767',
with_type:'4',
sort_by:'first_air_date.desc',
include_null_first_air_dates:'false',
'first_air_date.gte': relDate(-180),
'first_air_date.lte': relDate(7),
}),
// 港剧
tmdb('/discover/tv', {
with_original_language:'zh', with_origin_country:'HK',
without_genres:'16,99,10764,10766,10767',
sort_by:'popularity.desc',
include_null_first_air_dates:'false',
'first_air_date.gte': relDate(-730),
'first_air_date.lte': relDate(30),
}),
tmdb('/discover/tv', {
with_original_language:'zh', with_origin_country:'HK',
without_genres:'16,99,10764,10766,10767',
sort_by:'first_air_date.desc',
include_null_first_air_dates:'false',
'first_air_date.gte': relDate(-365),
'first_air_date.lte': relDate(30),
}),
// 台剧
tmdb('/discover/tv', {
with_original_language:'zh', with_origin_country:'TW',
without_genres:'16,99,10764,10766,10767',
sort_by:'popularity.desc',
include_null_first_air_dates:'false',
'first_air_date.gte': relDate(-730),
'first_air_date.lte': relDate(30),
}),
tmdb('/discover/tv', {
with_original_language:'zh', with_origin_country:'TW',
without_genres:'16,99,10764,10766,10767',
sort_by:'first_air_date.desc',
include_null_first_air_dates:'false',
'first_air_date.gte': relDate(-365),
'first_air_date.lte': relDate(30),
}),
// 韩剧(by language ko
tmdb('/discover/tv', {
with_original_language:'ko',
without_genres:'16,99,10764,10766,10767',
sort_by:'popularity.desc',
include_null_first_air_dates:'false',
'first_air_date.gte': relDate(-730),
'first_air_date.lte': relDate(60),
}),
tmdb('/discover/tv', {
with_original_language:'ko',
without_genres:'16,99,10764,10766,10767',
sort_by:'first_air_date.desc',
include_null_first_air_dates:'false',
'first_air_date.gte': relDate(-365),
'first_air_date.lte': relDate(60),
}),
// 日剧(by language ja
tmdb('/discover/tv', {
with_original_language:'ja',
without_genres:'16,99,10764,10766,10767',
sort_by:'popularity.desc',
include_null_first_air_dates:'false',
'first_air_date.gte': relDate(-730),
'first_air_date.lte': relDate(60),
}),
tmdb('/discover/tv', {
with_original_language:'ja',
without_genres:'16,99,10764,10766,10767',
sort_by:'first_air_date.desc',
include_null_first_air_dates:'false',
'first_air_date.gte': relDate(-365),
'first_air_date.lte': relDate(60),
}),
// 欧美剧:US主流流媒体近90天 + GB + 补充
tmdb('/discover/tv', {
with_original_language:'en', with_origin_country:'US',
without_genres:'16,10764,10766,10767',
with_watch_providers:'8|337|350|384|9|386',
watch_region:'US',
sort_by:'popularity.desc',
include_null_first_air_dates:'false',
'first_air_date.gte': relDate(-90),
'first_air_date.lte': relDate(14),
}),
tmdb('/discover/tv', {
with_original_language:'en', with_origin_country:'GB',
without_genres:'16,10764,10766,10767',
with_watch_providers:'8|337|350|384|9|386',
watch_region:'US',
sort_by:'popularity.desc',
include_null_first_air_dates:'false',
'first_air_date.gte': relDate(-90),
'first_air_date.lte': relDate(14),
}),
tmdb('/discover/tv', {
with_original_language:'en', with_origin_country:'US',
without_genres:'16,10764,10766,10767',
sort_by:'popularity.desc',
include_null_first_air_dates:'false',
'first_air_date.gte': relDate(-60),
'first_air_date.lte': relDate(14),
'vote_count.gte':'10',
}),
// 纪录片
tmdb('/discover/movie', {with_genres:'99', sort_by:'popularity.desc', 'vote_count.gte':'50', language:'zh-CN'}),
tmdb('/discover/tv', {with_genres:'99', sort_by:'popularity.desc', 'vote_count.gte':'20', language:'zh-CN'}),
// 动漫:日本动画 + 中文动画,近120天
tmdb('/discover/tv', {
with_genres:'16', with_original_language:'ja', with_origin_country:'JP',
sort_by:'first_air_date.desc',
include_null_first_air_dates:'false',
'first_air_date.gte': relDate(-120),
'first_air_date.lte': relDate(30),
}),
tmdb('/discover/tv', {
with_genres:'16', with_original_language:'zh',
sort_by:'first_air_date.desc',
include_null_first_air_dates:'false',
'first_air_date.gte': relDate(-120),
'first_air_date.lte': relDate(30),
}),
// 综艺:中国内地(genre 10764=真人秀 + 10767=脱口秀)热度 + 最新
tmdb('/discover/tv', {
with_genres:'10764', with_original_language:'zh', with_origin_country:'CN',
sort_by:'popularity.desc',
include_null_first_air_dates:'false',
'first_air_date.gte': relDate(-180),
'first_air_date.lte': relDate(14),
}),
tmdb('/discover/tv', {
with_genres:'10764', with_original_language:'zh', with_origin_country:'CN',
sort_by:'first_air_date.desc',
include_null_first_air_dates:'false',
'first_air_date.gte': relDate(-180),
'first_air_date.lte': relDate(14),
}),
// 综艺:香港
tmdb('/discover/tv', {
with_genres:'10764,10767', with_original_language:'zh', with_origin_country:'HK',
sort_by:'first_air_date.desc',
include_null_first_air_dates:'false',
'first_air_date.gte': relDate(-180),
'first_air_date.lte': relDate(30),
}),
// 综艺:台湾
tmdb('/discover/tv', {
with_genres:'10764,10767', with_original_language:'zh', with_origin_country:'TW',
sort_by:'first_air_date.desc',
include_null_first_air_dates:'false',
'first_air_date.gte': relDate(-180),
'first_air_date.lte': relDate(30),
}),
// 综艺:韩国(genre 10764
tmdb('/discover/tv', {
with_genres:'10764', with_original_language:'ko',
sort_by:'popularity.desc',
include_null_first_air_dates:'false',
'first_air_date.gte': relDate(-180),
'first_air_date.lte': relDate(14),
}),
// 综艺:日本
tmdb('/discover/tv', {
with_genres:'10764', with_original_language:'ja',
sort_by:'popularity.desc',
include_null_first_air_dates:'false',
'first_air_date.gte': relDate(-180),
'first_air_date.lte': relDate(14),
}),
// 高分精选:高分电影(vote_average>=7.5, vote_count>=200
tmdb('/discover/movie', {sort_by:'vote_average.desc','vote_count.gte':'300','vote_average.gte':'7.5',page:'1'}),
tmdb('/discover/movie', {sort_by:'vote_average.desc','vote_count.gte':'300','vote_average.gte':'7.5',page:'2'}),
// 高分电视剧
tmdb('/discover/tv', {sort_by:'vote_average.desc','vote_count.gte':'200','vote_average.gte':'7.5',page:'1'}),
tmdb('/discover/tv', {sort_by:'vote_average.desc','vote_count.gte':'200','vote_average.gte':'7.5',page:'2'}),
]);
// ── 合并各分类 ──
const newItems = merge(get(trendWeek), get(nowPlay1), get(nowPlay2)).slice(0,24);
const movieItems = merge(get(nowPlay1), get(nowPlay2), get(movPop1), get(movPop2), get(trendDay), get(movUpcoming))
.filter(i=>i.title||i.original_title).slice(0,24);
// 国产剧:popularity + first_air_date 双排序交叉
const cnItems = merge(get(cnNew1), get(cnNew2)).slice(0,24);
// 港台剧:港剧+台剧 四路合并
const hkItems = merge(get(hkPop), get(hkNew), get(twPop), get(twNew)).slice(0,24);
// 日韩剧:韩+日各双路
const jpkrItems = merge(get(krPop), get(krNew), get(jpPop), get(jpNew)).slice(0,24);
// 欧美剧:三路合并
const usItems = merge(get(usPop1), get(usPop2), get(usNew)).slice(0,24);
// 纪录片
const docItems = merge(get(docPop), get(docTv)).slice(0,24);
// 动漫
const aniItems = merge(get(aniJP), get(aniZH)).slice(0,24);
// ── Hero carousel ──
const heroPool = merge(get(trendWeek), get(trendDay), get(nowPlay1), get(movPop1))
.filter(i=>i.backdrop_path && i.poster_path && i.overview);
const today2 = new Date();
const dateSeed = today2.getFullYear()*10000 + (today2.getMonth()+1)*100 + today2.getDate();
function seededShuffle(arr, seed){
const a = arr.slice(); let s = seed >>> 0;
for(let i=a.length-1;i>0;i--){
s = (Math.imul(s,1664525)+1013904223)>>>0;
const j = s%(i+1); [a[i],a[j]]=[a[j],a[i]];
}
return a;
}
const heroSix = seededShuffle(heroPool, dateSeed).slice(0,6);
if(heroSix.length) initHeroCarousel(heroSix);
// 高分精选:电影+电视剧混合,按评分排序后交叉合并
const topItems = merge(get(topMov1), get(topTv1), get(topMov2), get(topTv2))
.filter(i=>i.poster_path).slice(0,24);
renderGrid('g-top', 'cnt-top', topItems);
renderGrid('g-new', 'cnt-new', newItems);
renderGrid('g-movie', 'cnt-movie', movieItems);
renderGrid('g-cn', 'cnt-cn', cnItems, true);
renderGrid('g-hk', 'cnt-hk', hkItems, true);
renderGrid('g-jpkr', 'cnt-jpkr', jpkrItems, true);
renderGrid('g-us', 'cnt-us', usItems, true);
renderGrid('g-doc', 'cnt-doc', docItems);
renderGrid('g-ani', 'cnt-ani', aniItems);
// 综艺:中国+港+台+韩+日 六路合并
const varItems = merge(get(varCN1), get(varCN2), get(varHK), get(varTW), get(varKR), get(varJP)).slice(0,24);
renderGrid('g-var', 'cnt-var', varItems, true);
}
// ── NAV SCROLL ACTIVE ──
// ══════════════════════════════════════════════════════════
// 分类页(筛选标签 + 看更多 / 无限分页)
// · 配置驱动:CAT_CONFIG 每个分类一份(endpoint + base 约束 + 筛选组)
// · 筛选合并:{...base, ...各组选中参数, sort_by, page} → TMDB discover
// · 作为与详情页同级的全屏模态层,接入既有 返回/全屏/历史 系统
// ══════════════════════════════════════════════════════════
const _CY = new Date().getFullYear();
const CAT_YEARS = [];
for(let y=_CY; y>=_CY-5; y--) CAT_YEARS.push(String(y));
const GENRES_MOVIE = [['喜剧','35'],['动作','28'],['爱情','10749'],['科幻','878'],['悬疑','9648'],['惊悚','53'],['恐怖','27'],['犯罪','80'],['动画','16'],['奇幻','14'],['冒险','12'],['剧情','18'],['家庭','10751'],['战争','10752'],['历史','36'],['音乐','10402']];
const GENRES_TV = [['喜剧','35'],['剧情','18'],['动作冒险','10759'],['悬疑','9648'],['犯罪','80'],['科幻奇幻','10765'],['动画','16'],['家庭','10751'],['真人秀','10764'],['战争政治','10768']];
// 筛选组生成器(kind 决定如何映射到 TMDB 参数)
function gSort(){ return {key:'sort', label:'排序', kind:'sort', opts:[['热门','popularity.desc'],['最新','__date__'],['高分','vote_average.desc']]}; }
function gGenre(type){ return {key:'genre', label:'类型', kind:'genre', opts:[['全部','']].concat(type==='movie'?GENRES_MOVIE:GENRES_TV)}; }
function gYear(){ return {key:'year', label:'年份', kind:'year', opts:[['全部','']].concat(CAT_YEARS.map(y=>[y,y]))}; }
const CAT_CONFIG = {
top: { title:'高分精选', type:'movie', land:false,
base:{'vote_count.gte':'200','vote_average.gte':'7.5', sort_by:'vote_average.desc'},
groups:[
{key:'mtype',label:'类型',kind:'mtype',opts:[['全部','both'],['电影','movie'],['剧集','tv']]},
gGenre('movie'),
{key:'region',label:'地区',kind:'param',opts:[['全部',{}],['华语',{with_original_language:'zh'}],['欧美',{with_original_language:'en'}],['韩国',{with_original_language:'ko'}],['日本',{with_original_language:'ja'}]]},
gYear(),
]},
new: { title:'最新上线', type:'movie', land:false,
base:{'vote_count.gte':'20'},
groups:[
{key:'mtype',label:'类型',kind:'mtype',opts:[['全部','both'],['电影','movie'],['剧集','tv']]},
gYear()
]},
movie: { title:'电影', type:'movie', land:false,
base:{},
groups:[
gGenre('movie'),
{key:'region',label:'地区',kind:'param',opts:[['全部',{}],['华语',{with_original_language:'zh'}],['欧美',{with_original_language:'en'}],['韩国',{with_original_language:'ko'}],['日本',{with_original_language:'ja'}]]},
gYear()
]},
cn: { title:'国产剧', type:'tv', land:false,
base:{with_original_language:'zh', with_origin_country:'CN', with_type:'4', without_genres:'10764,10766,10767'},
groups:[ gGenre('tv'), gYear() ]},
hk: { title:'港台剧', type:'tv', land:false,
base:{with_original_language:'zh', without_genres:'16,99,10764,10766,10767'},
groups:[ gGenre('tv'),
{key:'region',label:'地区',kind:'param',opts:[['全部',{with_origin_country:'HK|TW'}],['香港',{with_origin_country:'HK'}],['台湾',{with_origin_country:'TW'}]]},
gYear() ]},
jpkr: { title:'日韩剧', type:'tv', land:false,
base:{without_genres:'16,99,10764,10766,10767'},
groups:[ gGenre('tv'),
{key:'region',label:'地区',kind:'param',opts:[['韩国',{with_original_language:'ko'}],['日本',{with_original_language:'ja'}]]},
gYear() ]},
us: { title:'欧美剧', type:'tv', land:false,
base:{with_original_language:'en', without_genres:'16,10764,10766,10767'},
groups:[ gGenre('tv'),
{key:'region',label:'地区',kind:'param',opts:[['全部',{with_origin_country:'US|GB'}],['美国',{with_origin_country:'US'}],['英国',{with_origin_country:'GB'}]]},
gYear() ]},
doc: { title:'纪录片', type:'tv', land:false,
base:{with_genres:'99'},
groups:[
{key:'mtype',label:'类型',kind:'mtype',opts:[['全部','both'],['电影','movie'],['剧集','tv']]},
gYear() ]},
ani: { title:'动漫', type:'tv', land:false,
base:{with_genres:'16'},
groups:[
{key:'region',label:'地区',kind:'param',opts:[['日本',{with_original_language:'ja'}],['华语',{with_original_language:'zh'}],['全部',{}]]},
gYear() ]},
var: { title:'综艺', type:'tv', land:false,
base:{with_genres:'10764'},
groups:[
{key:'region',label:'地区',kind:'param',opts:[['内地',{with_original_language:'zh',with_origin_country:'CN'}],['港台',{with_origin_country:'HK|TW'}],['韩国',{with_original_language:'ko'}],['日本',{with_original_language:'ja'}],['全部',{}]]},
gYear() ]},
};
let _catState = null;
let _catObs = null;
function _catEnsureObs(){
if(_catObs) return;
const sc=document.getElementById('catScroll'), sentinel=document.getElementById('catSentinel');
if(!sc||!sentinel) return;
_catObs=new IntersectionObserver(es=>{ if(es.some(e=>e.isIntersecting)) _catLoadNext(); }, {root:sc, rootMargin:'700px'});
_catObs.observe(sentinel);
// 兜底:scroll 事件检测(修复横屏下 IntersectionObserver 失效问题)
sc.addEventListener('scroll', ()=>{
if(!_catState || _catState.done || _catState.loading) return;
if(sc.scrollTop + sc.clientHeight >= sc.scrollHeight - 300) _catLoadNext();
}, {passive:true});
}
function openCat(key){
const cfg = CAT_CONFIG[key]; if(!cfg) return;
_catState = { key, cfg, sel:{}, page:0, totalPages:1, loading:false, done:false, count:0, token:1, seen:new Set() };
cfg.groups.forEach(g=>{ _catState.sel[g.key]=0; });
document.getElementById('catTitle').textContent = cfg.title;
document.getElementById('catCount').textContent = '';
document.getElementById('catGrid').innerHTML = '';
document.getElementById('catFoot').textContent = '';
document.getElementById('catFoot').className = 'cat-foot';
// 重进页面:先清空筛选栏,让 renderCatFilters 不恢复旧滚动位置
const _cf = document.getElementById('catFilters');
if(_cf) _cf.innerHTML = '';
renderCatFilters();
const sc=document.getElementById('catScroll'); if(sc) sc.scrollTop=0;
document.getElementById('cat').classList.add('on');
try{ _enterFullscreen(); }catch(e){}
if(location.hash !== '#category') history.pushState({eclipse:'category'}, '', '#category');
_catEnsureObs();
_catLoadNext();
}
function renderCatFilters(){
if(!_catState) return;
// 保存各行当前滚动位置
const container = document.getElementById('catFilters');
const scrollMap = {};
if(container){
container.querySelectorAll('.fil-row').forEach((row, i) => { scrollMap[i] = row.scrollLeft; });
}
const html = _catState.cfg.groups.map(g=>{
const tags = g.opts.map((o,i)=>{
const on = _catState.sel[g.key]===i ? ' on' : '';
return `<span class="fil-tag${on}" onclick="setCatFilter('${g.key}',${i})">${esc(o[0])}</span>`;
}).join('');
return `<div class="fil-row"><span class="fil-label">${esc(g.label)}</span>${tags}</div>`;
}).join('');
if(container){
container.innerHTML = html;
// 恢复各行滚动位置
container.querySelectorAll('.fil-row').forEach((row, i) => { if(scrollMap[i]) row.scrollLeft = scrollMap[i]; });
}
}
function setCatFilter(gkey, idx){
if(!_catState) return;
if(_catState.sel[gkey]===idx) return;
_catState.sel[gkey]=idx;
renderCatFilters();
// 重置并重新加载第一页(释放旧的 in-flight 锁,旧请求会因 token 失配被丢弃)
_catState.page=0; _catState.totalPages=1; _catState.done=false; _catState.count=0;
_catState.token++; _catState.seen=new Set(); _catState.loading=false;
document.getElementById('catGrid').innerHTML='';
document.getElementById('catCount').textContent='';
_catLoadNext();
}
function _catSelectedTypes(){
const g=_catState.cfg.groups.find(x=>x.kind==='mtype');
if(!g) return [_catState.cfg.type];
const v=g.opts[_catState.sel[g.key]][1];
return v==='both' ? ['movie','tv'] : [v];
}
function _catBuildParams(type, page){
const cfg=_catState.cfg, sel=_catState.sel;
const p = Object.assign({}, cfg.base, {page:String(page), include_adult:'false'});
let sort='popularity.desc';
cfg.groups.forEach(g=>{
const o=g.opts[sel[g.key]]; if(!o) return;
if(g.kind==='sort') sort=o[1];
else if(g.kind==='genre'){ if(o[1]) p.with_genres = (p.with_genres? p.with_genres+',':'')+o[1]; }
else if(g.kind==='year'){ if(o[1]){ if(type==='tv') p.first_air_date_year=o[1]; else p.primary_release_year=o[1]; } }
else if(g.kind==='param') Object.assign(p, o[1]);
// mtype 不在此处理,决定 type/endpoint
});
if(sort==='__date__') sort = (type==='tv') ? 'first_air_date.desc' : 'primary_release_date.desc';
if(sort==='vote_average.desc') p['vote_count.gte'] = String(Math.max(parseInt(p['vote_count.gte']||'0',10), 80));
p.sort_by = sort;
// 按日期降序时排除未来 / 空日期,避免顶部全是未上映
if(sort.indexOf('date')>=0){
if(type==='tv'){ p['first_air_date.lte']=relDate(0); p['include_null_first_air_dates']='false'; }
else { p['primary_release_date.lte']=relDate(0); }
}
return p;
}
async function _catLoadNext(){
if(!_catState || _catState.loading || _catState.done) return;
if(!_isOn('cat')) return;
_catState.loading = true;
const myToken = _catState.token;
const page = _catState.page + 1;
const foot = document.getElementById('catFoot');
if(foot){ foot.className='cat-foot spin'; foot.textContent='加载中'; }
const types = _catSelectedTypes();
try{
const reqs = types.map(t=>{
const ep = (t==='movie') ? '/discover/movie' : '/discover/tv';
return tmdb(ep, _catBuildParams(t, page)).then(r=>({t,r})).catch(()=>({t,r:null}));
});
const res = await Promise.all(reqs);
if(!_catState || _catState.token!==myToken) return; // 已切换筛选,丢弃过期结果
let added=0, maxTotal=1;
const frag=[];
res.forEach(({t,r})=>{
if(!r || !r.results) return;
maxTotal = Math.max(maxTotal, Math.min(r.total_pages||1, 500));
r.results.forEach(it=>{
if(!it || !it.id || !it.poster_path) return;
if(_catState.seen.has(it.id)) return;
_catState.seen.add(it.id);
if(t==='tv' && !it.name) it.name = it.title || it.original_name || ''; // 确保识别为剧集
ITEMS[it.id]=it;
frag.push(cardHtml(it, _catState.cfg.land));
added++;
});
});
const grid=document.getElementById('catGrid');
if(grid) grid.insertAdjacentHTML('beforeend', frag.join(''));
_catState.page = page;
_catState.totalPages = maxTotal;
_catState.count += added;
document.getElementById('catCount').textContent = _catState.count ? (_catState.count+' 部') : '';
if(page>=maxTotal || (added===0 && page>1)) _catState.done=true;
if(foot){
foot.className='cat-foot';
foot.textContent = _catState.done ? (_catState.count? '· 没有更多了 ·' : '暂无结果') : '';
}
}catch(e){
if(foot){ foot.className='cat-foot'; foot.textContent='加载失败'; }
}finally{
if(_catState && _catState.token===myToken) _catState.loading=false;
_catMaybeFill();
}
}
// 内容不足一屏时自动续加载,保证 sentinel 能再次进入视口
// +400 余量修复横屏下 clientHeight 极小导致误判"已够一屏"的问题
function _catMaybeFill(){
if(!_catState || _catState.done || _catState.loading) return;
const sc=document.getElementById('catScroll');
if(sc && sc.scrollHeight <= sc.clientHeight + 400) _catLoadNext();
}
function _doCloseCat(){
const el=document.getElementById('cat');
if(el) el.classList.remove('on');
setTimeout(()=>{ if(!_isOn('cat')){ const g=document.getElementById('catGrid'); if(g) g.innerHTML=''; } }, 320);
_catState=null;
}
function scrollSec(id){
const tv = document.body.classList.contains('tv-mode');
var _secEl=document.getElementById(id); if(_secEl)_secEl.scrollIntoView({behavior: tv ? 'auto' : 'smooth'});
document.querySelectorAll('.ntab').forEach(t=>t.classList.toggle('on', t.dataset.sec===id));
// TV:切换分类后把焦点移到该 section 第一张卡片,方便继续下导航
if(tv){
requestAnimationFrame(()=>{
const sec = document.getElementById(id);
const first = sec && sec.querySelector('.card');
if(first && typeof _tvSetFocus === 'function') _tvSetFocus(first, false);
});
}
}
const secObs = new IntersectionObserver(entries=>{
entries.forEach(e=>{
if(e.isIntersecting){
const id = e.target.id;
document.querySelectorAll('.ntab').forEach(t=>t.classList.toggle('on', t.dataset.sec===id));
}
});
},{rootMargin:'-40% 0px -55% 0px'});
['s-new','s-movie','s-top','s-cn','s-hk','s-jpkr','s-us','s-doc','s-ani','s-var'].forEach(id=>{
const el = document.getElementById(id);
if(el) secObs.observe(el);
});
// ── SEARCH ──
const HOT_POOL = [
'星际穿越','流浪地球','肖申克的救赎','寄生虫','蜘蛛侠','复仇者联盟','黑暗骑士','千与千寻',
'泰坦尼克号','盗梦空间','楚门的世界','搏击俱乐部','阿甘正传','美丽人生','辛德勒的名单',
'指环王','哈利·波特','星球大战','变形金刚','速度与激情',
'天空之城','幽灵公主','龙猫','红猪','悬崖上的金鱼姬',
'釜山行','与神同行','寄生上流','82年生的金智英','经典之作',
'哪吒之魔童降世','姜子牙','长安三万里','大鱼海棠','西游记之大圣归来',
'唐探','误杀','消失的她','孤注一掷','年会不能停',
'繁花','狂飙','山海情','漫长的季节','白夜追凶',
'权力的游戏','绝命毒师','纸牌屋','西部世界','黑镜',
'鱿鱼游戏','顶楼','虽然是精神病但没关系','请回答1988','我的解放日志',
];
function _shuffleHot(){
const pool = HOT_POOL.slice();
for(let i=pool.length-1;i>0;i--){const j=Math.floor(Math.random()*(i+1));[pool[i],pool[j]]=[pool[j],pool[i]];}
return pool.slice(0,8);
}
let HOT = _shuffleHot();
function openSrch(){
const overlay = document.getElementById('srch');
const inp = document.getElementById('srchInp');
overlay.classList.add('on');
inp.value = '';
_lastSuggestKw = '';
clearTimeout(_suggestTimer);
HOT = _shuffleHot();
showHome();
try{ _enterFullscreen(); }catch(e){}
if(location.hash !== '#search') history.pushState({eclipse:'search'}, '', '#search');
setTimeout(()=>{ inp.focus(); inp.click(); }, 120);
}
function _doCloseSrch(){
document.getElementById('srch').classList.remove('on');
document.getElementById('srchInp').blur();
}
function _closeSrchAndHome(){
_doCloseSrch();
_ensureHome();
}
function closeSrch(){
_doCloseSrch();
}
// ── SEARCH HISTORY ──
const HIST_KEY = 'eclipse_srch_hist';
const HIST_MAX = 10;
function loadHist(){
try{ return JSON.parse(localStorage.getItem(HIST_KEY)||'[]'); }catch(e){ return []; }
}
function saveHist(list){
try{ localStorage.setItem(HIST_KEY, JSON.stringify(list.slice(0,HIST_MAX))); }catch(e){}
}
function addHist(kw){
kw = kw.trim();
if(!kw) return;
let h = loadHist().filter(s=>s!==kw);
h.unshift(kw);
saveHist(h);
}
function removeHist(kw){
saveHist(loadHist().filter(s=>s!==kw));
showHome();
}
function clearHist(){
try{ localStorage.removeItem(HIST_KEY); }catch(e){}
showHome();
}
// ── SEARCH SUGGEST (TMDB /search/multi debounced) ──
let _suggestTimer = null;
let _suggestAbort = null;
let _lastSuggestKw = '';
function onSrchInput(e){
const kw = e.target.value.trim();
if(!kw){ showHome(); return; }
// 立即显示历史过滤,同时发起联想
clearTimeout(_suggestTimer);
_suggestTimer = setTimeout(()=> fetchSuggest(kw), 220);
}
// ── 拼音首字母映射(基于 Unicode 码点区间,覆盖 GB2312 全部 6763 一级汉字)──
// 区间表:[起始码点, 声母],按码点升序排列,取最后一个 ≤ cp 的声母
const _PY_RANGES = (()=>{
// 每个元素 [unicodeCodePoint, initial],代表"从此码点起声母变为 initial"
// 数据来源:GB2312 一级汉字拼音分区(经过实测验证的主流版本)
const raw = [
[0x554A,'a'],[0x9F44,'a'],// 啊
[0x516B,'b'],[0x6253,'b'],// 八...把
[0x64E6,'c'],[0x6491,'c'],// 擦...插
[0x5927,'d'],[0x4ED8,'d'],// 大...打(注:d 区从 搭 开始)
// 使用更可靠的 Intl 方案替代硬编码区间
];
// 实际使用 Intl.Collator 参照锚字做声母判断(精确锚点来自 Unicode Han Database
// 声母锚字(每个声母拼音最小的一个常用汉字,按 Unicode 拼音排序)
const ANCHORS = [
[0x5E00,'z'],[0x4E2B,'y'],[0x5915,'x'],[0x5A03,'w'],
[0x5854,'t'],[0x4EE8,'s'],[0x7136,'r'],[0x671F,'q'],
[0x5478,'p'],[0x54E6,'o'],[0x62FF,'n'],[0x5988,'m'],
[0x5783,'l'],[0x54B3,'k'],[0x51FB,'j'],[0x54C8,'h'],
[0x5676,'g'],[0x53D1,'f'],[0x9E45,'e'],[0x5927,'d'],
[0x64E6,'c'],[0x516B,'b'],[0x554A,'a'],
];
const col = typeof Intl!=='undefined'
? new Intl.Collator('zh-Hans-CN',{sensitivity:'base'})
: null;
return function getInitial(ch){
if(!ch) return '';
const cp = ch.codePointAt(0);
// 非 CJK 统一汉字区:直接取字母
if(cp < 0x4E00 || cp > 0x9FFF){
return /[a-zA-Z]/.test(ch) ? ch.toLowerCase() : '';
}
if(!col) return ''; // 降级:无 Intl 环境
for(const [anchor, ini] of ANCHORS){
if(col.compare(ch, String.fromCodePoint(anchor)) >= 0) return ini;
}
return '';
};
})();
// 取一段文本的拼音首字母串,例如 "流浪地球" → "lldq"
function getPinyinInitials(text){
let s = '';
for(const ch of text){
const ini = _PY_RANGES(ch);
if(ini) s += ini;
else if(/[a-zA-Z0-9]/.test(ch)) s += ch.toLowerCase();
}
return s;
}
// 判断输入 kw 是否纯拼音首字母(全为 a-z 且长度 ≤ 8)
function isPinyinInitialQuery(kw){
return /^[a-z]{1,8}$/i.test(kw);
}
// 去除标题中的「第x季/Season x/SxAB季/特别篇」等季号后缀,得到可被 TMDB /search 接口匹配到的纯剧名
// 例:「怪奇物语 第四季」→「怪奇物语」;「Stranger Things Season 4」→「Stranger Things」
function stripSeasonSuffix(title){
let s = String(title || '').trim();
s = s.replace(/[\s·:\-—]*第\s*[0-9一二三四五六七八九十百]+\s*季\s*$/u, '');
s = s.replace(/[\s·:\-—]*Season\s*\d+\s*$/i, '');
s = s.replace(/[\s·:\-—]*S\d{1,2}\s*$/i, '');
s = s.replace(/[\s·:\-—]*(特别篇|总集篇|番外篇)\s*$/u, '');
s = s.trim();
return s || String(title || '').trim();
}
// 从候选标题列表中找出拼音首字母匹配的项
// 返回 [{label, initials, matchStart}]matchStart 是首字母串中首次匹配的位置(用于高亮整词)
function filterByInitials(titles, kw){
const q = kw.toLowerCase();
return titles.filter(t=>{
const ini = getPinyinInitials(t);
return ini.includes(q);
});
}
// 高亮:支持普通子串 + 拼音首字母两种模式
// 拼音首字母模式:把每个匹配的汉字首字包裹 <em>,其余正常
function highlightKw(text, kw){
if(!kw) return esc(text);
// 普通子串匹配
const idx = text.toLowerCase().indexOf(kw.toLowerCase());
if(idx >= 0){
return esc(text.slice(0,idx))
+ `<em>${esc(text.slice(idx,idx+kw.length))}</em>`
+ esc(text.slice(idx+kw.length));
}
// 拼音首字母匹配:逐字高亮
if(isPinyinInitialQuery(kw)){
const q = kw.toLowerCase();
const initials = getPinyinInitials(text);
const start = initials.indexOf(q);
if(start < 0) return esc(text);
// 把 initials[start..start+q.length] 对应回原字符串下标
// initials[i] 对应 text 中第 i 个"有首字母"的字符
let iPos = 0; // initials 计数
let matchStart = -1, matchEnd = -1;
for(let ci = 0; ci < text.length; ci++){
const ch = text[ci];
const ini = _PY_RANGES(ch) || (/[a-zA-Z0-9]/.test(ch) ? ch.toLowerCase() : '');
if(ini){
if(iPos === start) matchStart = ci;
if(iPos === start + q.length - 1) { matchEnd = ci + 1; break; }
iPos++;
}
}
if(matchStart < 0) return esc(text);
return esc(text.slice(0,matchStart))
+ `<em>${esc(text.slice(matchStart,matchEnd))}</em>`
+ esc(text.slice(matchEnd));
}
return esc(text);
}
async function fetchSuggest(kw){
if(kw === _lastSuggestKw) return;
_lastSuggestKw = kw;
// 纯拼音首字母:先用本地历史快速渲染,同时并行发 TMDB 请求
const isInitial = isPinyinInitialQuery(kw);
const hist = loadHist();
const localMatches = filterByInitials(hist, kw);
// 先渲染骨架(带本地历史命中)
renderSuggest(kw, null, localMatches);
try{
// 拼音首字母模式:同时搜索每个首字母可能对应的汉字变体,TMDB 用 kw 直接搜(会匹配英文/数字)
// 另外 TMDB 支持中文,所以对纯首字母也直接发请求,让 TMDB 自己处理
const data = await tmdb('/search/multi', {query:kw, page:'1'});
const currentKw = document.getElementById('srchInp').value.trim();
if(kw !== currentKw) return; // 竞态保护
renderSuggest(kw, data.results||[], localMatches);
}catch(e){
if(kw !== document.getElementById('srchInp').value.trim()) return;
renderSuggest(kw, [], localMatches);
}
}
function renderSuggest(kw, results, localMatches){
localMatches = localMatches || [];
const body = document.getElementById('srchBody');
const isInitial = isPinyinInitialQuery(kw);
if(!results){
// loading 骨架:若有本地历史命中,先展示它们
let html = '';
if(localMatches.length){
html += `<div class="srch-label" style="padding-top:14px;padding-bottom:6px">历史记录</div>
<ul class="srch-suggest-list">
${localMatches.slice(0,4).map(t=>`
<li class="srch-suggest-item" tabindex="0"
onclick="tapSuggest('${esc(t).replace(/'/g,'\\\'')}')"
onkeydown="if(event.key==='Enter')tapSuggest('${esc(t).replace(/'/g,'\\\'')}')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 8v4l3 3m6-3a9 9 0 1 1-18 0 9 9 0 0 1 18 0z"/></svg>
<span class="sg-kw">${highlightKw(t, kw)}</span>
<span class="sg-fill">搜索</span>
</li>`).join('')}
</ul>`;
}
html += `<ul class="srch-suggest-list">
${[0,1,2,3].map(i=>`
<li class="srch-suggest-item">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" 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>
<span class="sg-kw sk" style="width:${90+i*22}px;height:15px;border-radius:3px;display:inline-block"></span>
</li>`).join('')}
</ul>`;
body.innerHTML = html;
return;
}
// 构建候选列表:TMDB 结果 + 本地历史,去重
const titles = [];
const seen = new Set();
// 第一条:原始关键词(用于直接搜索)
titles.push({label:kw, exact:true, src:'query'});
seen.add(kw.toLowerCase());
// 本地历史命中(拼音首字母匹配)—— 排在联想前面
for(const t of localMatches){
if(seen.has(t.toLowerCase())) continue;
seen.add(t.toLowerCase());
titles.push({label:t, exact:false, src:'hist'});
if(titles.length >= 5) break;
}
// TMDB 联想结果
for(const r of (results||[])){
const t = r.title||r.name||'';
if(!t || seen.has(t.toLowerCase())) continue;
// 拼音首字母模式:只保留首字母也匹配的项(或中英文直接包含 kw 的)
if(isInitial){
const ini = getPinyinInitials(t);
const directMatch = t.toLowerCase().includes(kw.toLowerCase());
if(!ini.includes(kw.toLowerCase()) && !directMatch) continue;
}
seen.add(t.toLowerCase());
// 剧名可能带「第x季/Season x」后缀(TMDB 分季条目),搜索时按纯剧名查,否则 /search 接口匹配不到
const searchKw = stripSeasonSuffix(t);
titles.push({label:t, exact:false, src:'tmdb', searchKw});
if(titles.length >= 9) break;
}
if(titles.length <= 1 && !localMatches.length){
body.innerHTML = `
<ul class="srch-suggest-list">
<li class="srch-suggest-item" onclick="doSrch('${esc(kw).replace(/'/g,'\\\'')}')" tabindex="0">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" 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>
<span class="sg-kw"><em>${esc(kw)}</em></span>
<span class="sg-fill">搜索</span>
</li>
</ul>`;
return;
}
const srcIcon = {
query: `<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>`,
hist: `<path d="M12 8v4l3 3m6-3a9 9 0 1 1-18 0 9 9 0 0 1 18 0z"/>`,
tmdb: `<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>`,
};
body.innerHTML = `<ul class="srch-suggest-list" id="suggestList">
${titles.map((t,i)=>`
<li class="srch-suggest-item${t.exact?' active':''}" tabindex="0"
data-idx="${i}" data-kw="${esc(t.searchKw||t.label)}"
onclick="tapSuggest('${esc(t.searchKw||t.label).replace(/'/g,'\\\'')}')"
onkeydown="if(event.key==='Enter')tapSuggest('${esc(t.searchKw||t.label).replace(/'/g,'\\\'')}')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
${srcIcon[t.src]||srcIcon.query}
</svg>
<span class="sg-kw">${t.exact ? `<em>${esc(t.label)}</em>` : highlightKw(t.label, kw)}</span>
<span class="sg-fill">搜索</span>
</li>`).join('')}
</ul>`;
}
function tapSuggest(kw){
const inp = document.getElementById('srchInp');
inp.value = kw;
doSrch(kw);
}
// ── 首页(历史 + 热门)──
function showHome(){
_lastSuggestKw = '';
const hist = loadHist();
const body = document.getElementById('srchBody');
let html = '';
if(hist.length){
html += `
<div class="srch-section-hd">
<div class="srch-label" style="padding:0">最近搜索</div>
<button class="srch-clear-btn" onclick="clearHist()">全部清除</button>
</div>
<div class="srch-hist-tags">
${hist.map(s=>`
<span class="srch-hist-tag" tabindex="0"
onclick="tapHist('${esc(s).replace(/'/g,'\\\'')}')"
onkeydown="if(event.key==='Enter')tapHist('${esc(s).replace(/'/g,'\\\'')}')">
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(s)}</span>
<span class="srch-hist-tag-x" onclick="event.stopPropagation();removeHist('${esc(s).replace(/'/g,'\\\'')}')" title="删除">✕</span>
</span>`).join('')}
</div>`;
}
html += `
<div class="srch-label" style="padding-top:${hist.length?16:18}px">热门搜索</div>
<ul class="srch-hot">${HOT.map(s=>`
<li tabindex="0" onclick="tapHot(this,'${esc(s)}')" onkeydown="if(event.key==='Enter')tapHot(this,'${esc(s)}')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" 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>
<span class="st">${esc(s)}</span>
</li>`).join('')}</ul>`;
body.innerHTML = html;
}
function tapHist(kw){
const inp = document.getElementById('srchInp');
inp.value = kw;
doSrch(kw);
}
function showHot(){ showHome(); }
function tapHot(el, kw){
const inp = document.getElementById('srchInp');
inp.value = kw;
doSrch(kw);
}
function submitSrch(){
const v = document.getElementById('srchInp').value.trim();
if(v) doSrch(v);
}
function onSrchKey(e){
if(e.key === 'Escape'){ closeSrch(); return; }
if(e.key === 'Enter'){
const v = e.target.value.trim();
if(v){ e.preventDefault(); doSrch(v); }
}
// 上下键在联想列表中导航
if(e.key === 'ArrowDown' || e.key === 'ArrowUp'){
const list = document.getElementById('suggestList');
if(!list) return;
const items = list.querySelectorAll('.srch-suggest-item');
if(!items.length) return;
e.preventDefault();
let idx = [...items].findIndex(i=>i.classList.contains('active'));
if(items[idx])items[idx].classList.remove('active');
idx = e.key==='ArrowDown' ? (idx+1)%items.length : (idx-1+items.length)%items.length;
items[idx].classList.add('active');
// 填入输入框预览
const inp = document.getElementById('srchInp');
inp.value = items[idx].dataset.kw || inp.value;
}
}
async function doSrch(kw){
kw = kw.trim();
if(!kw) return;
_lastSuggestKw = '';
clearTimeout(_suggestTimer);
// 记录历史
addHist(kw);
// 搜索框内显示 loading
const body = document.getElementById('srchBody');
body.innerHTML = `
<div class="srch-searching">
<div class="srch-searching-spinner"></div>
<div class="srch-searching-text">搜索「${esc(kw)}」…</div>
</div>`;
try{
const [mv, tv] = await Promise.all([
tmdb('/search/movie', {query:kw}),
tmdb('/search/tv', {query:kw}),
]);
const results = [...(mv.results||[]), ...(tv.results||[])]
.filter(i => i.poster_path)
.slice(0, 24);
results.forEach(i => ITEMS[i.id] = i);
if(!results.length){
body.innerHTML = `<div class="srch-empty"><div class="srch-empty-ico">🔍</div><div>「${esc(kw)}」暂无结果</div></div>`;
return;
}
// 关掉搜索框,用 replaceState 复用「搜索」这一历史层级(结果页不再额外压一层),
// 这样从结果页手势返回只需一步即可直达主页。
_doCloseSrch();
history.replaceState({eclipse:'search-result'}, '', '#search-result');
showSrchResult(kw, results);
}catch(e){
body.innerHTML = `<div class="srch-empty"><div class="srch-empty-ico">⚠️</div><div>搜索失败,请检查网络</div></div>`;
}
}
function renderStars(score){
const n = Number(score) || 0;
const full = Math.min(5, Math.floor(n/2));
const half = n%2 >= 1 ? 1 : 0;
const empty = 5 - full - half;
const s = (n,cls,ch)=> n ? `<span class="${cls}">${ch.repeat(n)}</span>` : '';
return s(full,'sli-star-full','★') +
(half ? '<span class="sli-star-half">½</span>' : '') +
s(empty,'sli-star-empty','★');
}
function buildSliHtml(item){
const isTV = !!item.name;
const title = item.title || item.name || '';
const year = (item.release_date||item.first_air_date||'').slice(0,4);
const rating = item.vote_average ? Number(item.vote_average).toFixed(1) : '';
const genre = (item.genres&&item.genres[0]&&item.genres[0].name) || '';
const media = isTV ? '剧集' : '电影';
const meta = [media, year, genre].filter(Boolean).join(' · ');
const poster = item.poster_path ? (IMG_W500 + item.poster_path) : '';
const overview = (item.overview||'').slice(0, 90) + ((item.overview||'').length>90?'…':'');
const stars = rating ? renderStars(Number(item.vote_average)) : '';
return `<button class="sli" tabindex="0" onclick="openDet(ITEMS[${item.id}])">
${poster ? `<img class="sli-poster" src="${poster}" alt="" loading="lazy">` : `<div class="sli-poster"></div>`}
<div class="sli-body">
<span class="sli-title">${esc(title)}</span>
<span class="sli-meta">${esc(meta)}</span>
${stars ? `<div class="sli-stars">${stars}<span class="sli-rating">${esc(rating)}</span></div>` : ''}
${overview ? `<div class="sli-overview">${esc(overview)}</div>` : ''}
</div>
</button>`;
}
function showSrchResult(kw, results){
const sec = document.getElementById('s-search');
const list = document.getElementById('srch-list');
const title = document.getElementById('srchResTitle');
const cnt = document.getElementById('cnt-search');
title.textContent = `${kw}`;
cnt.textContent = results.length + ' 个结果';
list.innerHTML = results.map(i => buildSliHtml(i)).join('');
sec.style.display = '';
// 主页内容模糊
document.documentElement.classList.add('search-active');
// 滚到顶部显示结果
window.scrollTo({top: 0, behavior: 'smooth'});
}
function clearSrchResult(){
document.getElementById('s-search').style.display = 'none';
document.getElementById('srch-list').innerHTML = '';
document.documentElement.classList.remove('search-active');
}
function closeSrchResult(){
clearSrchResult();
}
// ── 详情页背景剧照轮播 ──
let _detBdTimer = null;
let _detBdToken = '';
function _clearDetBackdrop(){
if(_detBdTimer){ clearInterval(_detBdTimer); _detBdTimer = null; }
_detBdToken = '';
}
// 用多张剧照启动背景轮播(每 5 秒淡入淡出)
function _startDetBackdrop(backdrops, token){
_clearDetBackdrop();
if(!backdrops || backdrops.length < 2) return; // 少于2张不轮播
// TV 模式:只保留首张静态背景,不创建额外图层、不跑定时器。
// 低端盒子内存小,同时持有多张 w1280 大图 + 每5s切换重绘是常见卡顿来源。
if(typeof TV_MODE !== 'undefined' && TV_MODE) return;
_detBdToken = token;
const hero = document.getElementById('detHero');
if(!hero) return;
// 已有首张静态图(active),把剩余剧照追加为隐藏层
const urls = backdrops.map(b=>IMG_ORIG + b.file_path);
// 去重:避免和首张重复
const firstSrc = (hero.querySelector('.det-hero-img') || {}).src || '';
const extra = urls.filter(u=>u && firstSrc.indexOf(u.split('/').pop())<0);
// 预创建所有图层(最多 6 张,避免内存压力)
const all = [];
const firstImg = hero.querySelector('.det-hero-img');
if(firstImg) all.push(firstImg);
extra.slice(0,5).forEach(u=>{
const img = document.createElement('img');
img.className = 'det-hero-img';
img.loading = 'eager';
img.src = u;
hero.appendChild(img);
all.push(img);
});
if(all.length < 2) return;
let cur = 0;
_detBdTimer = setInterval(()=>{
if(_detBdToken !== token){ _clearDetBackdrop(); return; }
if(!document.getElementById('det').classList.contains('on')){ _clearDetBackdrop(); return; }
const prev = all[cur];
cur = (cur + 1) % all.length;
const next = all[cur];
if(prev) prev.classList.remove('active');
if(next) next.classList.add('active');
}, 5000);
}
// ── DETAIL ──
async function openDet(item){
if(!item) return;
detData = item;
// 推入一条历史记录,让 Android 返回手势/按键能被 popstate 拦截,而不是退出 App
if(location.hash !== '#detail') history.pushState({eclipse:'detail'}, '', '#detail');
const isTV = !!item.name;
const title = item.title||item.name||'';
const poster = item.poster_path;
const backdrop = item.backdrop_path;
const bg = `hsla(${hue(item.id)},18%,13%,0.5)`;
const backdropUrl = backdropSrc(backdrop);
const panel = document.getElementById('det');
const inner = document.getElementById('detInner');
panel.scrollTop = 0;
const detNav = document.getElementById('detNav');
document.getElementById('detNavTitle').textContent = title;
detNav.classList.remove('scrolled');
// Set immersive background — handled by det-hero img inside detInner
inner.innerHTML = `
<div class="det-hero" id="detHero">
${backdrop ? `<img class="det-hero-img active" src="${backdropSrc(backdrop)}" alt="" loading="eager">` : ``}
</div>
<div class="det-inner">
<div class="det-poster-row">
<div class="det-title-block" style="padding-bottom:4px;width:100%">
<div class="det-eyebrow">${isTV ? '剧集' : '电影'}</div>
<div class="det-orig-title" id="detOrigTitle"></div>
<div class="det-logo-wrap" id="detLogoWrap">
<div class="det-title det-title-fallback" id="detTitleFallback">${esc(title)}</div>
</div>
</div>
</div>
<div class="det-actions">
<button class="btn-p" id="detContinueBtn" onclick="detContinue()" style="display:none">
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M5 3l14 9-14 9V3z"/></svg>
<span id="detContinueText">继续观看</span>
</button>
<button class="btn-p" id="detPlayBtn" oncontextmenu="detPlayLongPress(event)" onpointerdown="detPlayPressStart(event)" onpointerup="detPlayPressEnd(event)" onpointercancel="detPlayPressEnd(event)" title="单击播放最优资源,长按聚合搜索播放">
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M5 3l14 9-14 9V3z"/></svg>
<span id="detPlayText">播放</span>
</button>
<button class="btn-s" id="panSearchBtn" onclick="openPanSearch()">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6.2 18.3h11.6a4.1 4.1 0 0 0 .42-8.18 6.2 6.2 0 0 0-11.9 1.68A3.28 3.28 0 0 0 6.2 18.3Z"/></svg>
盘搜
</button>
</div>
<div class="det-pills" id="detPills">
<span class="det-pill sk" style="width:50px;height:22px">&nbsp;</span>
<span class="det-pill sk" style="width:70px;height:22px">&nbsp;</span>
</div>
<div class="det-overview sk" id="detOverview" style="height:80px;border-radius:4px"></div>
<button class="det-overview-toggle" id="detOverviewToggle" onclick="toggleOverview()">
<span id="detOverviewToggleText">展开</span>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
</button>
<div id="panBlock" class="pan-block">
<div class="pan-head">
<span class="pan-head-title">盘搜资源</span>
<span class="pan-hint" id="panHint"></span>
<button class="btn-s" style="padding:5px 12px;font-size:12px" onclick="closePanSearch()">✕ 关闭</button>
</div>
<div class="pan-tabs-row" id="panTabs"></div>
<div class="pan-list" id="panList"></div>
</div>
<div id="detCast"></div>
<div id="detPersonWorks" style="display:none"></div>
<div id="detRecs"></div>
</div>`;
panel.classList.add('on');
try{ _enterFullscreen(); }catch(e){}
// 继续观看:拉历史匹配(并行,不阻塞详情加载)
_checkDetContinue(item);
// ── 后台静默盘搜:载入详情页自动开始,不展开面板 ──
_bgPanSearch(title);
// Load full details
try{
const endpoint = isTV ? `/tv/${item.id}` : `/movie/${item.id}`;
const [d, credits, recs] = await Promise.allSettled([
tmdb(endpoint, {append_to_response:'credits'}),
tmdb(endpoint+'/credits'),
tmdb(endpoint+'/recommendations'),
]);
const det = d.status==='fulfilled' ? d.value : {};
// TMDB Logo (replaces title text) + 背景剧照轮播
try{
const imagesEndpoint = isTV ? `/tv/${item.id}/images` : `/movie/${item.id}/images`;
const imgs = await tmdb(imagesEndpoint, {include_image_language:'zh,en,null'});
const logos = (imgs.logos||[]).sort((a,b)=>(b.vote_average||0)-(a.vote_average||0));
const logo = logos.find(l=>l.file_path&&l.iso_639_1==='zh') || logos.find(l=>l.file_path&&l.iso_639_1==='en') || logos[0];
const logoWrap = document.getElementById('detLogoWrap');
const titleFallback = document.getElementById('detTitleFallback');
if(logo && logo.file_path && logoWrap){
const logoImg = document.createElement('img');
logoImg.className = 'det-logo-img';
logoImg.src = IMG_ORIG + logo.file_path;
logoImg.alt = title;
logoImg.onload = ()=>{
if(titleFallback) titleFallback.style.display='none';
logoWrap.appendChild(logoImg);
};
logoImg.onerror = ()=>{}; // keep fallback title
}
// 背景剧照轮播:优先无文字剧照(iso_639_1 为 null),按评分排序
if(detData === item){ // 确认仍是当前影片
const backdrops = (imgs.backdrops||[])
.filter(b=>b.file_path)
.sort((a,b)=>{
// 无语言文字的剧照优先(更干净),再按评分
const an = a.iso_639_1 ? 1 : 0, bn = b.iso_639_1 ? 1 : 0;
if(an !== bn) return an - bn;
return (b.vote_average||0) - (a.vote_average||0);
})
.slice(0, 6);
_startDetBackdrop(backdrops, (isTV?'tv':'mv')+':'+item.id);
}
}catch(e){}
const year = (det.release_date||det.first_air_date||'').slice(0,4);
const rating = det.vote_average ? det.vote_average.toFixed(1) : '';
const voteCount = det.vote_count ? det.vote_count.toLocaleString() : '';
const genres = (det.genres||[]).map(g=>g.name).filter(n=>/[\u4e00-\u9fff]/.test(n)).slice(0,4);
const overview = det.overview || item.overview || '';
const runtime = det.runtime ? `${det.runtime} min` : '';
const eps = det.number_of_episodes || 0;
const seasons = det.number_of_seasons || 0;
const origTitle = det.original_title || det.original_name || '';
const lang = det.original_language ? det.original_language.toUpperCase() : '';
const status = det.status || '';
const _pc0 = (det.production_countries||[])[0]; const country = (det.origin_country||[])[0] || (_pc0 && _pc0.iso_3166_1) || '';
const budget = det.budget ? `$${(det.budget/1e6).toFixed(0)}M` : '';
// Orig title
const origEl = document.getElementById('detOrigTitle');
if(origEl && origTitle && origTitle !== title) origEl.textContent = origTitle;
// Pills (rating as first pill)
const pillsEl = document.getElementById('detPills');
if(pillsEl){
pillsEl.innerHTML = [
rating && `<span class="det-pill det-pill-rating">★ ${rating}</span>`,
year && `<span class="det-pill strong">${year}</span>`,
runtime && `<span class="det-pill">${runtime}</span>`,
seasons && `<span class="det-pill">${seasons}${eps}集</span>`,
country && `<span class="det-pill">${country}</span>`,
lang && `<span class="det-pill">${lang}</span>`,
...genres.map(g=>`<span class="det-pill">${g}</span>`),
].filter(Boolean).join('');
}
// Overview
const ovEl = document.getElementById('detOverview');
const ovToggle = document.getElementById('detOverviewToggle');
if(ovEl){
ovEl.className='det-overview clamped';
ovEl.style='';
ovEl.textContent=overview;
if(ovToggle){
ovToggle.classList.remove('show','open');
document.getElementById('detOverviewToggleText').textContent='展开';
requestAnimationFrame(()=>{
if(ovEl.scrollHeight - ovEl.clientHeight > 2){
ovToggle.classList.add('show');
}
});
}
}
// Cast
const castData = d.status==='fulfilled' && d.value.credits ? d.value.credits
: credits.status==='fulfilled' ? credits.value : null;
const castList = ((castData && castData.cast)||[]).filter(c=>c.profile_path).slice(0,16);
if(castList.length){
const castEl = document.getElementById('detCast');
if(castEl){
// 存演员数据
window._CAST_MAP = {};
castList.forEach(c=>{ window._CAST_MAP[String(c.id)] = c; });
castEl.innerHTML = `
<div class="det-sec-label">主演</div>
<div class="det-cast-wrap"><div class="det-cast-rail">${castList.map(c=>`
<button class="det-cast-card" type="button" data-person-id="${c.id}">
<img class="det-cast-img" src="${resUrl(c.profile_path, IMG_W500)}" alt="" loading="lazy">
<div class="det-cast-name">${esc(c.name)}</div>
<div class="det-cast-char">${esc(c.character||'')}</div>
</button>`).join('')}</div></div>`;
const rail = castEl.querySelector('.det-cast-wrap');
if(rail){
// 禁止长按弹出菜单 / 文字选中
rail.addEventListener('contextmenu', e=>e.preventDefault());
rail.addEventListener('selectstart', e=>e.preventDefault());
// 点击:单选,click 事件委托
rail.addEventListener('click', function(e){
const btn = e.target.closest('.det-cast-card');
if(!btn) return;
const pid = btn.dataset.personId;
const person = window._CAST_MAP[pid];
if(person) loadPersonWorks(person);
});
}
}
}
// Recommendations
const recItems = (recs.status==='fulfilled' ? ((recs.value && recs.value.results)||[]) : []).slice(0,12);
recItems.forEach(i=>ITEMS[i.id]=i);
const recsEl = document.getElementById('detRecs');
if(recItems.length && recsEl){
recsEl.innerHTML = `
<div class="det-recs-label">猜你喜欢</div>
<div class="det-recs-grid" id="detRecsGrid">${recItems.map(i=>cardHtml(i)).join('')}</div>`;
requestAnimationFrame(()=>requestAnimationFrame(updateRecsGrid));
}
}catch(e){
console.warn('detail load failed', e);
}
}
// ── 演员作品加载 ──
async function loadPersonWorks(person){
if(!person || !person.id) return;
const block = document.getElementById('detPersonWorks');
if(!block) return;
// 高亮当前选中的演员卡片
document.querySelectorAll('.det-cast-card').forEach(btn=>{
btn.classList.toggle('active', String(btn.dataset.personId) === String(person.id));
});
// 再次点击同一演员则收起
if(block._personId === String(person.id) && block.style.display !== 'none'){
block.style.display = 'none';
block._personId = '';
document.querySelectorAll('.det-cast-card').forEach(btn=>btn.classList.remove('active'));
return;
}
block._personId = String(person.id);
block.style.display = 'block';
block.innerHTML = `
<div class="det-recs-label" style="margin-top:16px">
${esc(person.name||'')} · 相关作品
<button onclick="closePersonWorks()" style="margin-left:auto;background:none;border:none;color:var(--muted);cursor:pointer;font-size:16px;padding:0 4px;line-height:1">✕</button>
</div>
<div class="det-person-works-rail" id="detPersonWorksRail">
<span class="det-person-loading">加载中…</span>
</div>`;
setTimeout(()=>{
const det = document.getElementById('det');
if(!det || !block) return;
const navEl = document.getElementById('detNav');
const navH = navEl ? navEl.getBoundingClientRect().height : 0;
// block 相对 det 容器的偏移,再避开吸顶导航栏遮挡
const offset = block.getBoundingClientRect().top - det.getBoundingClientRect().top + det.scrollTop - navH - 16;
det.scrollTo({ top: offset, behavior: 'smooth' });
}, 100);
try{
const url = `${TMDB}/person/${person.id}?api_key=${TMDB_KEY}&language=zh-CN&append_to_response=combined_credits`;
const res = await fetch(url);
const data = await res.json();
const credits = data.combined_credits || {};
// 去重 + 过滤 + 排序
const seen = new Set();
const works = (credits.cast||[])
.concat(credits.crew||[])
.filter(w=>{
if(!w.poster_path) return false;
if(w.media_type!=='movie' && w.media_type!=='tv') return false;
if(seen.has(w.id)) return false;
seen.add(w.id);
return true;
})
.sort((a,b)=>(b.vote_count||0)-(a.vote_count||0))
.slice(0,20);
const rail = document.getElementById('detPersonWorksRail');
if(!rail) return;
if(!works.length){
rail.innerHTML = '<span class="det-person-loading">暂无相关作品</span>';
return;
}
// 规范化成 cardHtml 所需结构并注册到 ITEMS
works.forEach(w=>{
const norm = {
id: w.id,
title: w.title || w.name || '',
name: w.name || '',
poster_path: w.poster_path,
backdrop_path: w.backdrop_path || '',
vote_average: w.vote_average || 0,
vote_count: w.vote_count || 0,
release_date: w.release_date || '',
first_air_date: w.first_air_date || '',
overview: w.overview || '',
media_type: w.media_type,
genre_ids: w.genre_ids || [],
};
ITEMS[w.id] = norm;
});
rail.innerHTML = works.map(w=>cardHtml(ITEMS[w.id])).join('');
updatePersonWorksGrid();
}catch(e){
console.warn('loadPersonWorks error', e);
const rail = document.getElementById('detPersonWorksRail');
if(rail) rail.innerHTML = '<span class="det-person-loading">作品加载失败</span>';
}
}
function closePersonWorks(){
const block = document.getElementById('detPersonWorks');
if(block){ block.style.display='none'; block._personId=''; }
document.querySelectorAll('.det-cast-card').forEach(btn=>btn.classList.remove('active'));
}
function updatePersonWorksGrid(){
const rail = document.getElementById('detPersonWorksRail');
if(!rail) return;
const vw = window.innerWidth;
let cols, gut;
if(vw >= 1600){ cols=11; gut=20; }
else if(vw >= 1200){ cols=9; gut=18; }
else if(vw >= 900) { cols=7; gut=16; }
else if(vw >= 640) { cols=5; gut=14; }
else if(vw >= 480) { cols=4; gut=12; }
else { cols=3; gut=12; }
const cardW = Math.floor((vw - gut*2 - gut*(cols-1)) / cols);
rail.style.setProperty('--card-w', cardW+'px');
}
window.addEventListener('resize', updatePersonWorksGrid);
// ── RECS GRID:卡片宽度 & 间距 = 首页同视口下的统一值(--gut 体系)──
function updateRecsGrid(){
const g = document.getElementById('detRecsGrid');
if(!g) return;
const vw = window.innerWidth;
// 首页断点:列数 + 统一间距 gut(gut 同时是屏边留白与卡片间距)
let cols, gut;
if(vw >= 1600){ cols=11; gut=20; }
else if(vw >= 1200){ cols=9; gut=18; }
else if(vw >= 900) { cols=7; gut=16; }
else if(vw >= 640) { cols=5; gut=14; }
else if(vw >= 480) { cols=4; gut=12; }
else { cols=3; gut=12; }
// 首页单张卡片像素宽(左右留白 = gut,卡片间距 = gut)
const cardW = Math.floor((vw - gut*2 - gut*(cols-1)) / cols);
g.style.setProperty('--card-w', cardW + 'px');
// 间距交给 CSS 的 var(--gut),与首页完全一致(不再内联覆盖)
g.style.removeProperty('gap');
}
window.addEventListener('resize', updateRecsGrid);
// ── 首页分区横向滑动:卡片宽度按视口断点与原网格列数保持一致 ──
// .sec .grid 现为 flex 单行布局,宽度不再由 grid-template-columns 决定,需 JS 计算后写入 --card-w
function updateHomeRails(){
const vw = window.innerWidth;
let cols, gut;
if(vw >= 1600){ cols=11; gut=20; }
else if(vw >= 1200){ cols=9; gut=18; }
else if(vw >= 900) { cols=7; gut=16; }
else if(vw >= 640) { cols=5; gut=14; }
else if(vw >= 480) { cols=4; gut=12; }
else { cols=3; gut=12; }
const cardW = Math.floor((vw - gut*2 - gut*(cols-1)) / cols);
let lcols;
if(vw >= 1200){ lcols=6; }
else if(vw >= 900) { lcols=5; }
else if(vw >= 640) { lcols=4; }
else if(vw >= 480) { lcols=3; }
else { lcols=2; }
const cardWLand = Math.floor((vw - gut*2 - gut*(lcols-1)) / lcols);
['g-new','g-movie','g-top','g-doc','g-ani'].forEach(id=>{
const g = document.getElementById(id);
if(g) g.style.setProperty('--card-w', cardW+'px');
});
['g-cn','g-hk','g-jpkr','g-us','g-var'].forEach(id=>{
const g = document.getElementById(id);
if(g) g.style.setProperty('--card-w', cardWLand+'px');
});
}
window.addEventListener('resize', updateHomeRails);
function _doCloseDet(){
const panel = document.getElementById('det');
panel.classList.remove('on');
panel.style.removeProperty('--det-hero-bg');
_clearDetBackdrop(); // 停止背景剧照轮播
_detContinueItem = null;
setTimeout(()=>{ panel.scrollTop=0; document.getElementById('detInner').innerHTML=''; }, 300);
// 不清 playbackReturn,保留给重新打开盘搜时复原用
resetPanState();
// 关闭详情页保持全屏(不恢复工具栏);退出全屏只在返回到主页之下时处理
}
function toggleOverview(){
const ovEl = document.getElementById('detOverview');
const toggle = document.getElementById('detOverviewToggle');
if(!ovEl || !toggle) return;
const open = ovEl.classList.toggle('clamped') === false;
toggle.classList.toggle('open', open);
document.getElementById('detOverviewToggleText').textContent = open ? '收起' : '展开';
}
// ── HISTORY 哨兵 ──
// 栈结构示例(从底到顶):[sentinel, detail, pan]
// 业务层各自 pushState 自己的条目;popstate 弹出后只关闭对应 UI
// 不再操作 history,让栈自然变浅。
// 唯一例外:弹到最底层哨兵时,再补一条哨兵,防止 back 穿透退出 App。
function _ensureHome(){
// 回到主页时补一条无 hash 的 home 记录,防止再 back 直接退出 App
if(!location.hash)
history.pushState({eclipse:'home'}, '', location.pathname + location.search);
}
// 页面初始化:底层哨兵
// hash 路由初始化
if(history.scrollRestoration) history.scrollRestoration = 'manual';
if(location.hash) history.replaceState({}, '', location.pathname + location.search);
// popstateAndroid 返回手势 / 浏览器回退 拦截
// 关键:popstate 给到的 location.hash 是「弹栈之后」剩下那层的 hash,
// 不能拿它当作「要关哪一层」。一律以实际可见的模态状态为准,从顶层往下逐层关闭。
// 注意:panBlock 只有打开过详情页才会注入 DOM,所有取元素都必须空值安全,否则未进过详情时返回会抛错。
function _isOn(id){ const el = document.getElementById(id); return !!el && el.classList.contains('on'); }
function _srchResultOpen(){ const el = document.getElementById('s-search'); return !!el && el.style.display !== 'none'; }
function _anyModalOpen(){ return _isOn('panBlock') || _isOn('det') || _isOn('cat') || _isOn('srch') || _srchResultOpen(); }
// ── 工具栏 / Chrome 模型(官方 setChrome API)──
function isHomeFullscreenEnabled(){
return !window._uiPrefs || window._uiPrefs.homeFullscreenEnabled !== false;
}
function _setNativeToolbarVisible(visible){
try{
const ui = sdk().ui || {};
if(TV_MODE && ui.setChrome){
ui.setChrome({ mode: visible ? (isHomeFullscreenEnabled() ? 'edge' : 'tv-normal') : 'tv-full' });
return;
}
if(!TV_MODE && ui.setChrome){
ui.setChrome({
mode: visible ? (isHomeFullscreenEnabled() ? 'edge' : 'normal') : 'immersive',
statusBarStyle: 'light',
navigationBarStyle: 'light',
restoreAffordance: visible ? 'auto' : 'none',
startup: visible
});
return;
}
if(ui.setToolbar) ui.setToolbar(visible);
}catch(e){}
}
function _syncChromeForRoute(){
_setNativeToolbarVisible(!_anyModalOpen());
}
function _resumeHomeHero(){
try{ if(typeof heroItems !== 'undefined' && heroItems && heroItems.length) startHeroTimer(); }catch(e){}
}
// 打开子页面时调用:隐藏 toolbar
function _enterFullscreen(){ _setNativeToolbarVisible(false); }
// 关闭子页面后,若回到主页则恢复 toolbar
function _afterCloseStay(){
if(_anyModalOpen()) return;
_resumeHomeHero();
_setNativeToolbarVisible(true);
_ensureHome();
}
window.addEventListener('popstate', (e)=>{
// 1) 有模态层 → 关最顶层,本次返回到此结束
if(_isOn('panBlock')){ _doClosePanSearch(); _afterCloseStay(); return; }
if(_isOn('det')){ _doCloseDet(); _afterCloseStay(); return; }
if(_isOn('cat')){ _doCloseCat(); _afterCloseStay(); return; }
if(_isOn('srch') || _srchResultOpen()){
_doCloseSrch();
clearSrchResult();
_afterCloseStay();
return;
}
// 2) 已在主页(无模态可关):放行让返回交由宿主(退出 App)
_resumeHomeHero();
});
// ── 后台静默盘搜 ──
// 只启动搜索、不展开 panBlock 面板;结果存入 panState 供 detPlay() 直接消费
function _bgPanSearch(kw){
if(!kw) return;
// 已有同片结果则不重复搜
if(panState.keyword === kw && panState.results.length > 0) return;
// 重置状态并静默搜索
resetPanState();
doSearchPan(kw);
}
// ── 长按计时器 ──
let _playLongTimer = null;
let _playLongFired = false;
let _playPressX = 0;
let _playPressY = 0;
// ── 单击播放的"重新选择"计时 ──
// 首次单击:固定播夸克/百度排名前5的第一个;
// 1 分钟内再次单击:视为"重新选择",从前5里随机挑一个播放。
let _detPlayLastClickAt = 0;
const DET_PLAY_RESELECT_WINDOW = 60000; // 1 分钟
function detPlayPressStart(e){
// 阻止触摸端 300ms 延迟 & 系统长按菜单
e.preventDefault();
if(e.button && e.button !== 0) return;
// 捕获指针:即使手指移出按钮再松开,pointerup/cancel 仍会回到本元素,
// 否则长按计时器不被清除会误触发聚合搜索
try{ e.currentTarget.setPointerCapture && e.currentTarget.setPointerCapture(e.pointerId); }catch(_){}
_playLongFired = false;
_playPressX = e.clientX;
_playPressY = e.clientY;
_playLongTimer = setTimeout(()=>{
_playLongFired = true;
// 触觉反馈(部分 Android WebView 支持)
if(navigator.vibrate) navigator.vibrate(30);
_fallbackSearch();
}, 500);
}
function detPlayPressEnd(e){
const btn = document.getElementById('detPlayBtn');
if(btn){ btn.blur(); }
if(_playLongTimer){ clearTimeout(_playLongTimer); _playLongTimer = null; }
if(_playLongFired){ _playLongFired = false; return; }
const dx = Math.abs((e.clientX||0) - _playPressX);
const dy = Math.abs((e.clientY||0) - _playPressY);
if(dx > 10 || dy > 10) return;
detPlay();
}
function detPlayLongPress(e){ e.preventDefault(); }
// ── 单击:播放盘搜最高画质资源 ──
function detPlay(){
if(!detData) return;
if(!window.fm){ toast('播放功能需在 App 内使用'); return; }
// 有盘搜结果 → 取排名第一的直接播放
if(panState.results && panState.results.length > 0){
_playTopPanResult();
return;
}
// 结果还在加载中 → 等待最多 6 秒
if(panState.loading){
const btn = document.getElementById('detPlayBtn');
const txt = document.getElementById('detPlayText');
if(txt) txt.textContent = '搜索中…';
if(btn) btn.disabled = true;
let waited = 0;
const poll = setInterval(()=>{
waited += 300;
if(panState.results && panState.results.length > 0){
clearInterval(poll);
if(btn) btn.disabled = false;
if(txt) txt.textContent = '播放';
_playTopPanResult();
} else if(!panState.loading || waited >= 6000){
clearInterval(poll);
if(btn) btn.disabled = false;
if(txt) txt.textContent = '播放';
if(waited >= 6000 || !panState.loading){
// 盘搜无结果,降级到原生搜索播放
_fallbackSearch();
}
}
}, 300);
return;
}
// 盘搜未启动或无结果,降级到原生搜索播放
_fallbackSearch();
}
// 取排序第一条(排序逻辑与 renderPanUI 一致)播放
// sortedCandidates 可选:传入已排好序的候选列表(用于自动换源)
function _playTopPanResult(sortedCandidates, _tryIndex){
const QUALITY_KEYWORDS = [
{re:/(?<![A-Za-z])DV(?!D)|dolby.?vision|杜比视界/i, score:10},
{re:/hdr10\+|hdr10plus/i, score:9},
{re:/hdr(?!10\+)|hlg|hdr10(?!\+)/i, score:8},
{re:/4k|2160p|uhd/i, score:7},
{re:/remux/i, score:6},
{re:/blu[\-.]?ray/i, score:5},
{re:/高码|高码率/i, score:4},
{re:/vivid/i, score:4},
{re:/杜比(?!视界)|atmos/i, score:3},
{re:/120fps|120p/i, score:2},
{re:/60fps|60p/i, score:1},
{re:/1080p|1080i|全高清|fhd/i, score:2},
{re:/720p/i, score:1},
{re:/480p|576p/i, score:0},
{re:/sdr/i, score:0},
];
const getQuality = r => {
const t = String(r.title||'');
let count=0, score=0;
for(const q of QUALITY_KEYWORDS){ if(q.re.test(t)){count++;score+=q.score;} }
return {count,score};
};
const PAN_HEALTH_PRI_LOC = {ok:0,uncertain:1,pending:2,idle:3,locked:4,unsupported:5,bad:99};
const getHPri = r => {
const h = panState.health[panHealthKey(r)];
const p = PAN_HEALTH_PRI_LOC[(h&&h.state)||'idle'];
return p===undefined ? 99 : p;
};
// 只在 夸克 / 百度 / 115 三个网盘里选,其余网盘排除。
const PAN_PLAY_SET = new Set(['quark','baidu','115']);
// 画质相同时的网盘次序:夸克/百度(0) > 115(1)
const PAN_PLAY_PRI = r => {
const t = panNormType(r.diskType);
if(t === 'quark' || t === 'baidu') return 0;
if(t === '115') return 1;
return 2;
};
// 构建/复用已排序候选列表
let isFreshPick = false;
if(!sortedCandidates){
isFreshPick = true;
const candidates = panState.results.filter(r => {
const h = panState.health[panHealthKey(r)];
if(h && h.state === 'bad') return false;
return PAN_PLAY_SET.has(panNormType(r.diskType)); // 仅保留三个网盘
});
candidates.sort((a,b)=>{
// 1) 画质关键字优先(命中组合数 → 权重总和),播最优画质
const qa=getQuality(a), qb=getQuality(b);
if(qb.count!==qa.count) return qb.count-qa.count;
if(qb.score!==qa.score) return qb.score-qa.score;
// 2) 同画质时按网盘次序(夸克/百度 → 115)
const pd = PAN_PLAY_PRI(a) - PAN_PLAY_PRI(b); if(pd) return pd;
const sd=(b._score||0)-(a._score||0); if(sd) return sd;
const hd=getHPri(a)-getHPri(b); if(hd) return hd;
return (b.updatedAt||0)-(a.updatedAt||0);
});
sortedCandidates = candidates;
}
let idx = _tryIndex || 0;
// 首次进入(非自动换源)时:只在夸克/百度排名前5里选——
// 1 分钟内未再点击 → 固定播第1个;1 分钟内重新点击 → 随机挑前5之一。
if(isFreshPick){
const top5 = sortedCandidates.filter(r => {
const t = panNormType(r.diskType);
return t === 'quark' || t === 'baidu';
}).slice(0, 5);
const now = Date.now();
const isReselect = _detPlayLastClickAt > 0 && (now - _detPlayLastClickAt) < DET_PLAY_RESELECT_WINDOW;
_detPlayLastClickAt = now;
if(top5.length){
const pick = isReselect ? top5[Math.floor(Math.random()*top5.length)] : top5[0];
const pickIdx = sortedCandidates.indexOf(pick);
idx = pickIdx >= 0 ? pickIdx : 0;
}
}
const best = sortedCandidates[idx];
if(!best){
// 首次就没有可播的三网盘资源 → 降级原生搜索播放;换源到底则提示
if(idx === 0) _fallbackSearch();
else toast('所有资源均无法播放');
return;
}
let qualLabel = '';
for(const q of QUALITY_KEYWORDS){ if(q.re.test(String(best.title||''))){ qualLabel=q.label||''; break; } }
const idxLabel = idx > 0 ? ` (第${idx+1}源)` : '';
toast(qualLabel ? `播放 ${qualLabel}${idxLabel}` : `播放最优资源${idxLabel}`);
// ── 4 秒超时自动换源 ──
// 判断依据:App 打开播放器时页面会被隐藏(document.hidden=true
// 若 4 秒内页面仍可见,说明播放未启动,自动切下一条
let _switchTimer = null;
let _visHandler = null;
const _cleanup = () => {
if(_switchTimer){ clearTimeout(_switchTimer); _switchTimer=null; }
if(_visHandler){ document.removeEventListener('visibilitychange', _visHandler); _visHandler=null; }
};
_visHandler = () => {
if(document.hidden) _cleanup(); // 页面隐藏 = 播放器已启动,成功
};
document.addEventListener('visibilitychange', _visHandler);
_switchTimer = setTimeout(()=>{
_cleanup();
if(document.hidden) return; // 双重保险:已在后台则不切
toast(`资源未响应,尝试第${idx+2}源…`);
_playTopPanResult(sortedCandidates, idx+1);
}, 4000);
playPanResult(best);
}
function _fallbackSearch(){
const title = detData ? (detData.title||detData.name||'') : '';
if(!title){ toast('没有影片标题'); return; }
try{
const r = sdk().search(title, { direct: true });
if(r && r.catch) r.catch(e=>toast('搜索播放失败:'+(e.message||'unknown')));
toast('已发起搜索播放');
}catch(e){ toast('搜索播放失败:'+(e.message||'unknown')); }
}
// ── 继续观看(移植自 demo,适配 TMDB detData)──
let _detContinueItem = null; // 当前详情匹配到的历史记录
// 标题归一化:去季/部、空格、标点,便于模糊匹配
function _normTitle(t){
return String(t||'')
.toLowerCase()
.replace(/[第][一二三四五六七八九十0-9]+[季部]?/g,'')
.replace(/\s+/g,'')
.replace(/[·:,.。!?'"“”‘’《》<>【】()\[\]{}_-]/g,'')
.trim();
}
// 历史项是否可续播(必须有 siteKey + vodId + 标题,排除推送/直链类型)
function _historyRestorable(h){
const key = String(h && h.key || '');
const parts = key.split('@@@');
const siteKey = (h && h.siteKey) || parts[0] || '';
const vodId = (h && h.vodId) || parts.slice(1).join('@@@') || '';
const title = String(h && (h.vodName||h.title||h.name) || '').trim();
if(!siteKey || !vodId || !title) return false;
if(siteKey === 'push_agent') return false;
if(/^(push|https?|file|magnet|ed2k|thunder|video):/i.test(siteKey)) return false;
return true;
}
// 从历史项提取标准字段
function _historyParts(h){
const key = String(h && h.key || '');
const parts = key.split('@@@');
return {
siteKey: (h && h.siteKey) || parts[0] || '',
vodId: (h && h.vodId) || parts.slice(1).join('@@@') || '',
title: String(h && (h.vodName||h.title||h.name) || '').trim(),
pic: (h && (h.vodPic||h.pic)) || '',
createTime: Number(h && h.createTime || 0)
};
}
// 判断历史记录是否匹配当前详情影片
function _historyMatches(item, h){
if(!item || !h || !_historyRestorable(h)) return false;
const p = _historyParts(h);
// TMDB id 直接匹配(历史 vodId 含 tmdb id 时)
const tmdbId = String(item.id||'').trim();
const vid = String(p.vodId||'').trim();
if(tmdbId && vid && (vid === tmdbId || (/tmdb/i.test(vid) && vid.split(/[^a-z0-9]+/i).includes(tmdbId)))) return true;
// 标题模糊匹配
const htitle = _normTitle(p.title);
if(!htitle) return false;
const cands = [item.title, item.name, item.original_title, item.original_name].map(_normTitle).filter(Boolean);
return cands.some(c=>{
if(c === htitle) return true;
return c.length>=4 && htitle.length>=4 && (c.includes(htitle) || htitle.includes(c));
});
}
// 详情页打开后:拉历史,匹配则显示「继续观看」
async function _checkDetContinue(item){
_detContinueItem = null;
const btn = document.getElementById('detContinueBtn');
const playText = document.getElementById('detPlayText');
if(btn) btn.style.display = 'none';
if(playText) playText.textContent = '搜索播放';
if(!window.fm) return; // 浏览器预览无历史
try{
const list = await sdk().history();
if(detData !== item) return; // 已切换影片
const arr = (Array.isArray(list) ? list : [])
.slice()
.sort((a,b)=>Number(b&&b.createTime||0)-Number(a&&a.createTime||0));
const match = arr.find(h=>_historyMatches(item, h));
if(!match) return;
_detContinueItem = _historyParts(match);
if(btn) btn.style.display = '';
// 命中续播时,搜索按钮降级为「搜索」+ 次要样式
if(playText) playText.textContent = '搜索';
const playBtn = document.getElementById('detPlayBtn');
if(playBtn){ playBtn.classList.remove('btn-p'); playBtn.classList.add('btn-s'); }
}catch(e){ /* 历史读取失败,保持搜索播放 */ }
}
function detContinue(){
const it = _detContinueItem;
if(!it || !it.siteKey || !it.vodId){ toast('没有可继续观看的记录'); return; }
if(!window.fm){ toast('播放功能需在 App 内使用'); return; }
try{
const r = sdk().vod(it.siteKey, it.vodId, it.title, it.pic);
if(r && r.catch) r.catch(e=>toast('继续观看失败:'+(e.message||'unknown')));
toast('继续观看');
}catch(e){
toast('继续观看失败:'+(e.message||'unknown'));
}
}
function playEp(n){ toast(`${n}`); }
// ── TOAST ──
let _tt;
function toast(msg){
const el = document.getElementById('toast');
el.textContent = msg; el.classList.add('on');
clearTimeout(_tt);
_tt = setTimeout(()=>el.classList.remove('on'), 2000);
}
// ── KEYBOARD ──
document.addEventListener('keydown', e=>{
const srchOpen = document.getElementById('srch').classList.contains('on');
const detOpen = document.getElementById('det').classList.contains('on');
const srchResultEl = document.getElementById('s-search');
const srchResultVisible = srchResultEl && srchResultEl.style.display !== 'none';
if(e.key==='Escape'||e.key==='Back'){
if(srchOpen){ closeSrch(); return; }
if(detOpen){ history.back(); return; } // 详情由 openDet 的 pushState 入栈,回退即触发 popstate→_doCloseDet
// 搜索结果页:返回键 = 清除按钮(沉浸式同款逻辑)
if(srchResultVisible){ e.preventDefault(); closeSrchResult(); return; }
}
if((e.key==='f'||e.key==='F') && !srchOpen && !detOpen){ openSrch(); }
});
// ── PAN CONFIG CONSTANTS ──
const PAN_API_BASE_DEFAULT = 'https://so.252035.xyz';
const PAN_DISK_TYPES = [
{id:'quark',name:'夸克'},{id:'aliyun',name:'阿里'},{id:'baidu',name:'百度'},
{id:'uc',name:'UC'},{id:'tianyi',name:'天翼'},{id:'xunlei',name:'迅雷'},
{id:'123',name:'123'},{id:'115',name:'115'},{id:'mobile',name:'移动'},
{id:'pikpak',name:'PikPak'},{id:'guangya',name:'光鸭'},{id:'magnet',name:'磁力'},{id:'ed2k',name:'电驴'}
];
const PAN_CHECKABLE = new Set(['quark','aliyun','baidu','uc','tianyi','xunlei','123','115','mobile']);
const PAN_DEFAULT_TYPES = ['quark','aliyun','baidu','uc','tianyi','xunlei','123','115','mobile','pikpak','magnet','ed2k'];
const PAN_HEALTH_PRI = {ok:0,locked:1,pending:2,idle:3,unsupported:4,uncertain:5,bad:6};
// ── CONNECTION / PAN CONFIG STATE ──
let panConfig = {
apiBase: PAN_API_BASE_DEFAULT,
diskTypes: PAN_DEFAULT_TYPES.slice(),
user: '', pass: '', token: '', tokenExpiresAt: 0
};
const connStatus = { sdk:'检测中', tmdb:'等待请求', site:'未配置', pan:'未搜索' };
function updateConnDot(){
const dot = document.getElementById('connDot');
if(!dot) return;
const ok = connStatus.tmdb.includes('成功')||connStatus.tmdb.includes('完成');
const bad = connStatus.tmdb.includes('失败');
dot.setAttribute('class', 'dot ' + (ok ? 'ok' : bad ? 'bad' : 'warn'));
}
function updateConnStatus(key, val){
connStatus[key] = val;
const el = document.getElementById('st'+key.charAt(0).toUpperCase()+key.slice(1));
if(el) el.textContent = val;
updateConnDot();
}
// Build disk type checkboxes
function buildDiskGrid(){
const grid = document.getElementById('connDiskGrid');
if(!grid) return;
grid.innerHTML = PAN_DISK_TYPES.map(d=>`
<label class="conn-disk-label">
<input type="checkbox" value="${d.id}" ${panConfig.diskTypes.includes(d.id)?'checked':''} onchange="markDiskDirty()">
${d.name}
</label>`).join('');
}
function markDiskDirty(){ /* just let save collect values */ }
function readConnFormConfig(){
const tmdbKey = (document.getElementById('tmdbKeyInp')||{}).value||'';
const apiBase = ((document.getElementById('panBaseInp')||{}).value||'').trim() || PAN_API_BASE_DEFAULT;
const user = ((document.getElementById('panUserInp')||{}).value||'').trim();
const pass = ((document.getElementById('panPassInp')||{}).value||'').trim();
const checked = Array.from(document.querySelectorAll('#connDiskGrid input[type=checkbox]:checked')).map(c=>c.value);
return { tmdbKey, apiBase, user, pass, diskTypes: checked.length ? checked : PAN_DEFAULT_TYPES.slice() };
}
function populateConnForm(){
const b = document.getElementById('panBaseInp'); if(b) b.value = panConfig.apiBase||'';
const u = document.getElementById('panUserInp'); if(u) u.value = panConfig.user||'';
const p = document.getElementById('panPassInp'); if(p) p.value = panConfig.pass||'';
buildDiskGrid();
}
async function loadConnConfig(){
try{
try{
const saved = await sdk().cache.get('film_lib_pan_config');
if(saved){ const p = JSON.parse(saved); if(p) Object.assign(panConfig, p); }
}catch(e){}
}catch(e){}
populateConnForm();
}
async function saveConnConfig(){
const cfg = readConnFormConfig();
if(cfg.tmdbKey){ TMDB_KEY = cfg.tmdbKey; }
panConfig = Object.assign(panConfig, {
apiBase: cfg.apiBase, user: cfg.user, pass: cfg.pass, diskTypes: cfg.diskTypes,
token:'', tokenExpiresAt:0
});
try{
const str = JSON.stringify(panConfig);
await sdk().cache.set('film_lib_pan_config', str);
toast('配置已保存');
updateConnStatus('pan','已配置');
}catch(e){ toast('保存失败: '+(e.message||'')); }
// 重新读取 driveCheck
try{
const cfg = await sdk().config();
panState.checkEnabled = !!(cfg && cfg.driveCheck);
}catch(e){}
}
function setConnOpen(open){
const dock = document.getElementById('connDock');
const body = document.getElementById('connBody');
const toggle = document.getElementById('connToggle');
if(!dock||!body) return;
dock.classList.toggle('open', open);
body.classList.toggle('open', open);
toggle.setAttribute('aria-expanded', open?'true':'false');
if(open){
populateConnForm();
requestAnimationFrame(()=>{
const rect = body.getBoundingClientRect();
const pad = 8;
let shift = 0;
if(rect.left < pad) shift = pad - rect.left;
if(rect.right + shift > window.innerWidth - pad) shift = window.innerWidth - pad - rect.right;
body.style.setProperty('--conn-shift', shift+'px');
});
}
}
function toggleConn(e){
if(e){ e.preventDefault(); e.stopPropagation(); }
setConnOpen(!document.getElementById('connDock').classList.contains('open'));
}
document.addEventListener('click', e=>{
const dock = document.getElementById('connDock');
if(dock && !dock.contains(e.target)) setConnOpen(false);
// 搜索结果页:点击结果区域外的空白 → 等同于点清除按钮
if(document.documentElement.classList.contains('search-active')){
const srchSection = document.getElementById('s-search');
if(srchSection && !srchSection.contains(e.target)){
closeSrchResult();
}
}
});
// ── PAN SEARCH STATE ──
const panState = {
loading:false, keyword:'', results:[], activeType:'', health:{}, pending:{},
queued:new Map(), inFlight:new Set(), flushTimer:0, pollTimers:[], viewToken:'',
checkEnabled:false, observer:null,
playbackReturn:null, focusKey:'', focusMode:''
};
const PAN_PLAYBACK_RETURN_TTL = 5 * 60 * 1000; // 5分钟
function panNormType(t){
const v = String(t||'').trim().toLowerCase();
if(v==='ali'||v==='alipan') return 'aliyun';
if(v==='123pan') return '123';
if(v==='139'||v==='caiyun') return 'mobile';
if(v==='pikpakdrive') return 'pikpak';
if(v==='guangyapan') return 'guangya';
if(v==='magnetlink'||v==='magnet:?'||v==='bt') return 'magnet';
if(v==='ed2k:') return 'ed2k';
if(v==='other') return 'others';
return v;
}
function panDiskName(id){ return (PAN_DISK_TYPES.find(d=>d.id===panNormType(id))||{name:id}).name||id; }
// 网盘类型 → 配色 / 徽标字符(让线路卡片一眼可辨)
const PAN_DISK_STYLE = {
quark: {bg:'#5b8def', label:'夸'},
aliyun: {bg:'#ff6a00', label:'阿'},
baidu: {bg:'#2932e1', label:'百'},
uc: {bg:'#f59e0b', label:'UC'},
tianyi: {bg:'#0a7bd4', label:'天'},
xunlei: {bg:'#2e9e6b', label:'迅'},
'123': {bg:'#00a96e', label:'123'},
'115': {bg:'#ff5000', label:'115'},
mobile: {bg:'#0099ff', label:'移'},
pikpak: {bg:'#7c3aed', label:'PK'},
guangya: {bg:'#06b6d4', label:'光'},
magnet: {bg:'#9333ea', label:'磁'},
ed2k: {bg:'#64748b', label:'电'},
};
function panDiskStyle(id){
return PAN_DISK_STYLE[panNormType(id)] || {bg:'#595959', label:(panDiskName(id)||'?').slice(0,2)};
}
// 格式化文件大小(字节数 → 易读单位)
function panFormatSize(bytes){
const n = Number(bytes);
if(!n || n <= 0) return '';
const units = ['B','KB','MB','GB','TB'];
let i = 0, v = n;
while(v >= 1024 && i < units.length-1){ v /= 1024; i++; }
return (v >= 100 || i === 0 ? Math.round(v) : v.toFixed(1)) + units[i];
}
function panApi(path){ return (panConfig.apiBase||PAN_API_BASE_DEFAULT).replace(/\/+$/,'')+path; }
async function ensurePanToken(){
const cfg = panConfig;
if(!cfg.user||!cfg.pass) return {};
if(cfg.token && cfg.tokenExpiresAt > Date.now()+60000) return {Authorization:'Bearer '+cfg.token};
try{
const url = panApi('/api/auth/token');
let d;
if(window.fm){
const res = await sdk().req(url, {
method:'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({username:cfg.user, password:cfg.pass}),
responseType:'json', timeout:15
});
d = res.body || {};
} else {
const r = await fetch(url, {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:cfg.user,password:cfg.pass})});
d = await r.json();
}
panConfig.token = d.token||d.access_token||'';
panConfig.tokenExpiresAt = d.expires_at||0;
return panConfig.token ? {Authorization:'Bearer '+panConfig.token} : {};
}catch(e){ return {}; }
}
function resetPanState(){
if(panState.observer){ panState.observer.disconnect(); panState.observer=null; }
panState.pollTimers.forEach(t=>clearTimeout(t));
if(panState.flushTimer){ clearTimeout(panState.flushTimer); panState.flushTimer=0; }
Object.assign(panState,{loading:false,keyword:'',results:[],activeType:'',health:{},pending:{},viewToken:'',pollTimers:[],focusKey:'',focusMode:''});
panState.queued.clear(); panState.inFlight.clear();
_detPlayLastClickAt = 0;
}
// ── 长按封面:直接调用播放器搜索播放 ──
function lpLongPress(itemId){
const item = ITEMS[itemId];
if(!item) return;
const title = item.title || item.name || '';
if(!title) return;
if(!window.fm){ toast('播放功能需在 App 内使用'); return; }
try{
const r = sdk().search(title, { direct: true });
if(r && r.catch) r.catch(e=>toast('搜索播放失败:'+(e.message||'unknown')));
toast('已发起搜索播放');
}catch(e){
toast('搜索播放失败:'+(e.message||'unknown'));
}
}
async function openPanSearch(){
const kw = detData ? (detData.title||detData.name||'') : '';
if(!kw){ toast('没有影片标题'); return; }
document.getElementById('panBlock').classList.add('on');
if(location.hash !== '#pan') history.pushState({eclipse:'pan'}, '', '#pan');
// 同一部影片已有结果 → 直接复原,不重复搜索(官方 demo 同款逻辑)
if(panState.keyword === kw && panState.results.length > 0){
document.getElementById('panHint').textContent = kw + ' · 共 ' + panState.results.length + ' 条';
renderPanUI();
// 尝试恢复上次的滚动位置和焦点
await restorePanPlaybackReturn();
return;
}
document.getElementById('panList').innerHTML = '<div class="pan-empty">搜索中…</div>';
document.getElementById('panTabs').innerHTML = '';
document.getElementById('panHint').textContent = kw + ' · 搜索中';
doSearchPan(kw);
}
function _doClosePanSearch(){
document.getElementById('panBlock').classList.remove('on');
// 只停止轮询/检测,保留 results + keyword 供下次打开复原
if(panState.observer){ panState.observer.disconnect(); panState.observer=null; }
panState.pollTimers.forEach(t=>clearTimeout(t));
if(panState.flushTimer){ clearTimeout(panState.flushTimer); panState.flushTimer=0; }
panState.loading = false;
panState.viewToken = '';
panState.queued.clear(); panState.inFlight.clear();
}
function closePanSearch(){
_doClosePanSearch();
}
// 统一 HTTP 请求:App 内优先用 fm.req(绕 CORS),浏览器预览降级 fetch
async function panFetch(url, payload, headers){
const body = JSON.stringify(payload);
const reqHeaders = Object.assign({'Content-Type':'application/json'}, headers);
// App 环境:用 fm.req 走 Native OkHttp
if(window.fm){
try{
const res = await sdk().req(url, {
method: 'POST',
headers: reqHeaders,
body,
responseType: 'json',
timeout: 30
});
if(!res.ok) throw new Error('HTTP ' + res.status);
return res.body;
}catch(e){ throw e; }
}
// 浏览器预览降级 fetch
const resp = await fetch(url, {method:'POST', headers:reqHeaders, body});
if(!resp.ok) throw new Error('HTTP ' + resp.status);
return resp.json();
}
async function doSearchPan(kw){
resetPanState();
panState.loading = true;
panState.keyword = kw;
const token = 'pan-'+Date.now();
panState.viewToken = token;
updateConnStatus('pan','搜索 '+kw);
try{
const authHeaders = await ensurePanToken();
const payload = {kw, res:'merge', src:'all', cloud_types: panConfig.diskTypes.map(panNormType).filter(Boolean)};
const body = await panFetch(panApi('/api/search'), payload, authHeaders);
if(panState.viewToken!==token) return;
const results = applyPanBody(body, kw);
updateConnStatus('pan', results.length ? '找到 '+results.length+' 条' : '无资源');
renderPanUI();
// 轮询两次补充结果
[4500, 9000].forEach((delay,i)=>{
const t = setTimeout(()=>pollPan(token, kw, i+1), delay);
panState.pollTimers.push(t);
});
}catch(e){
if(panState.viewToken!==token) return;
panState.results = [];
const list = document.getElementById('panList');
if(list) list.innerHTML = '<div class="pan-empty">盘搜失败:'+esc(e.message||'网络错误')+'</div>';
updateConnStatus('pan','搜索失败');
}finally{
if(panState.viewToken===token) panState.loading=false;
}
}
async function pollPan(token, kw, round){
if(panState.viewToken!==token||!panState.keyword) return;
try{
updateConnStatus('pan','更新中 '+round);
const authHeaders = await ensurePanToken();
const payload = {kw, res:'merge', src:'all', cloud_types: panConfig.diskTypes.map(panNormType).filter(Boolean)};
const body = await panFetch(panApi('/api/search'), payload, authHeaders);
if(panState.viewToken!==token) return;
const before = panState.results.length;
const results = applyPanBody(body, kw);
const added = results.length - before;
updateConnStatus('pan', added>0 ? `新增${added}条共${results.length}` : `已更新${results.length}`);
renderPanUI();
}catch(e){ if(panState.viewToken===token) updateConnStatus('pan','更新失败'); }
}
// ── PAN RELEVANCE SCORING ──
// 可信来源站点列表(部分已知盘搜源)
const PAN_TRUSTED_SOURCES = new Set([
'nostr','nostr推荐','so.252035.xyz','xiaobaipan','小白盘','阿虎盘搜',
'panso','盘搜搜','xiaoxiaopan','小小盘','pansearch','labi','猫盘'
]);
/**
* 计算单条盘搜结果的相关性得分
* @param {object} item - 盘搜结果条目
* @param {string} keyword - 当前搜索关键词
* @param {string} [year] - 影片年份(4位字符串,可选)
* @returns {number} score
*/
function relevanceScore(item, keyword, year){
const raw = String(item.title||'').trim();
const title = raw.toLowerCase();
const kw = String(keyword||'').trim().toLowerCase();
let score = 0;
if(!kw) return 0;
// ── 标题完全匹配 +100 ──
if(title === kw) score += 100;
// ── 标题包含关键词 +50 ──
else if(title.includes(kw)) score += 50;
// ── 标题以关键词开头 +30(在"包含"之外额外加分)──
if(title.startsWith(kw)) score += 30;
// ── 年份匹配 +20 ──
if(year && raw.includes(year)) score += 20;
// ── 来源可信 +10 ──
const src = String(item.source||'').trim().toLowerCase();
if(src && PAN_TRUSTED_SOURCES.has(src)) score += 10;
return score;
}
/**
* 垃圾结果过滤
* - 无标题资源
* - 标题长度 < 2
* - 标题仅数字
*/
function isPanJunk(item){
const t = String(item.title||'').trim();
if(!t || t.length < 2) return true;
if(/^\d+$/.test(t)) return true;
return false;
}
function applyPanBody(body, kw){
const data = body&&body.data ? body.data : body;
const merged = data&&data.merged_by_type || {};
const enabled = new Set(panConfig.diskTypes.map(panNormType));
// 从 detData 取年份用于评分(TMDB release_date 格式 "2023-07-15"
const year = detData && ((detData.release_date||detData.first_air_date||'').slice(0,4)) || '';
const seen = new Set();
const incoming = [];
Object.entries(merged).forEach(([type,links])=>{
const dt = panNormType(type);
if(!enabled.has(dt)||!Array.isArray(links)) return;
links.forEach((link,i)=>{
const url = String(link&&(link.url||link.link||link.href)||'').trim();
if(!url) return;
const key = dt+'|'+url;
if(seen.has(key)) return;
seen.add(key);
const title = String(link.note||link.name||link.title||link.work_title||kw||'盘搜资源').trim();
const entry = {
key,
diskType: dt,
url,
password: String(link.password||link.pwd||link.passcode||link.code||'').trim(),
title,
source: String(link.source||link.channel||'').trim(),
normalizedUrl: String(link.normalized_url||link.normalizedUrl||'').trim(),
updatedAt: Number(link.updated_at||link.updatedAt||link.created_at||0),
_rawIndex: incoming.length + i/1000,
};
// 挂载相关性得分(供排序和调试用)
entry._score = relevanceScore(entry, kw, year);
incoming.push(entry);
});
});
// ── 去重合并 ──
const map = new Map(panState.results.map(r=>[r.key,r]));
incoming.forEach(r=>{
const ex = map.get(r.key);
if(ex){
// 更新可变字段,但保留已有健康状态关联 key
Object.assign(ex, r);
} else {
map.set(r.key, r);
}
});
// ── 垃圾过滤 ──
let all = Array.from(map.values()).filter(r => !isPanJunk(r));
panState.results = all;
panState._scoreKeyword = kw;
ensurePanActiveType();
return panState.results;
}
function ensurePanActiveType(){
const types = getPanTypes();
if(!types.length){ panState.activeType=''; return ''; }
if(!panState.activeType||!types.some(t=>t.id===panState.activeType)) panState.activeType=types[0].id;
return panState.activeType;
}
function getPanTypes(){
const counts = new Map();
panState.results.forEach(r=>{ const t=panNormType(r.diskType); if(t) counts.set(t,(counts.get(t)||0)+1); });
return PAN_DISK_TYPES.filter(d=>counts.has(d.id)).map(d=>({id:d.id,name:d.name,count:counts.get(d.id)||0}));
}
function renderPanUI(){
const tabs = document.getElementById('panTabs');
const list = document.getElementById('panList');
const hint = document.getElementById('panHint');
if(!tabs||!list) return;
const types = getPanTypes();
const active = ensurePanActiveType();
// tabs
tabs.innerHTML = types.map(t=>`
<button class="pan-chip${t.id===active?' on':''}" onclick="selectPanType('${esc(t.id)}')">${esc(t.name)} ${t.count}</button>`).join('');
// hint
if(hint) hint.textContent = panState.keyword ? `${panState.keyword} · 共 ${panState.results.length}` : '';
// ── 多维排序 ──
// 1. _score DESC(相关性)
// 2. health state ASCok=0 最优)
// 3. updatedAt DESC(越新越靠前)
// 前20条只展示高相关(_score > 0),其余补充在后面
const filtered = panState.results.filter(r => {
if(active && panNormType(r.diskType) !== active) return false;
const h = panState.health[panHealthKey(r)];
if(h && h.state === 'bad') return false; // 失效资源直接不显示
return true;
});
const getHealthPri = r => {
const h = panState.health[panHealthKey(r)];
var _pri = PAN_HEALTH_PRI[(h && h.state) || 'idle']; return (_pri===undefined||_pri===null) ? 99 : _pri;
};
// 画质关键词优先级(score 越高排越前):
// DV/HDR10+ > HDR10/HLG > 4K/2160P > 高码率 > 杜比全景声 > 1080P > 720P > SDR
// score:0 的只显示标签不影响排序;同 score 下组合命中数多的排前面
const QUALITY_KEYWORDS = [
{re:/(?<![A-Za-z])DV(?!D)|dolby.?vision|杜比视界/i, score:10, label:'DolbyVision', cls:'dv'},
{re:/hdr10\+|hdr10plus/i, score:9, label:'HDR10+', cls:'hdrp'},
{re:/hdr(?!10\+)|hlg|hdr10(?!\+)/i, score:8, label:'HDR'},
{re:/4k|2160p|uhd/i, score:7, label:'4K'},
{re:/remux/i, score:6, label:'REMUX'},
{re:/blu[\-.]?ray/i, score:5, label:'BluRay'},
{re:/高码|高码率/i, score:4, label:'高码'},
{re:/vivid/i, score:4, label:'Vivid'},
{re:/杜比(?!视界)|atmos/i, score:3, label:'杜比全景声'},
{re:/120fps|120p/i, score:2, label:'120fps'},
{re:/60fps|60p/i, score:1, label:'60fps'},
{re:/1080p|1080i|全高清|fhd/i, score:2, label:'1080P', cls:'fhd'},
{re:/720p/i, score:1, label:'720P', cls:'hd'},
{re:/480p|576p/i, score:0, label:'SD', cls:'sd'},
{re:/sdr/i, score:0, label:'SDR'},
];
const getQualityInfo = r => {
const title = String(r.title || '');
let count = 0, score = 0;
for(const q of QUALITY_KEYWORDS){ if(q.re.test(title)){ count++; score += q.score; } }
return {count, score};
};
const sortFn = (a, b) => {
// 画质关键词命中数量 DESC(组合最多者优先,即"置顶")
const qa = getQualityInfo(a), qb = getQualityInfo(b);
const cd = qb.count - qa.count;
if(cd !== 0) return cd;
// 命中数量相同时,按关键词权重总和 DESC(HDR/DV/杜比/高码/4K/SDR 依次)
const qd = qb.score - qa.score;
if(qd !== 0) return qd;
// score DESC(相关性)
const sd = (b._score || 0) - (a._score || 0);
if(sd !== 0) return sd;
// health ASCok 优先)
const hd = getHealthPri(a) - getHealthPri(b);
if(hd !== 0) return hd;
// updatedAt DESC
return (b.updatedAt || 0) - (a.updatedAt || 0);
};
// 分高相关(score > 0)和低相关(score === 0)两组分别排序,然后合并
// 前20条取高相关,超出部分 + 零分全部追加到后面
const highRel = filtered.filter(r => (r._score || 0) > 0).sort(sortFn);
const lowRel = filtered.filter(r => !(r._score || 0)).sort(sortFn);
const top20 = highRel.slice(0, 20);
const rest = [...highRel.slice(20), ...lowRel];
const items = [...top20, ...rest];
if(!items.length){
list.innerHTML = '<div class="pan-empty">当前网盘暂无资源</div>';
return;
}
const renderKey = items.map(i=>panHealthKey(i)+'='+(panState.health[panHealthKey(i)]||{}).state+'='+i._score).join('|');
if(list.dataset.renderKey === renderKey) return;
list.dataset.renderKey = renderKey;
// 官方 demo 方式:用 addEventListener 传真实 item 对象,避免 innerHTML 内联 onclick 的双重转义问题
while(list.firstChild)list.removeChild(list.firstChild);
items.forEach(item=>{
const hk = panHealthKey(item);
const h = panState.health[hk] || {state:'idle'};
const canCheck = isPanCheckable(item);
const isPending = !!panState.pending[hk] || panState.inFlight.has(hk);
const dotState = isPending ? 'pending' : h.state;
const healthDot = canCheck
? `<span class="pan-health ${esc(dotState)}"></span>`
: '';
const isBad = h.state === 'bad';
const isFocus = panState.focusKey === hk;
// 检测画质标签;合并规则:
// 1. DolbyVision + 杜比全景声同时命中 → 合并为「双杜比」
// 2. 4K + 1080P 同时命中 → 只保留 4K,去掉 1080P
const _qtitle = String(item.title||'');
let _qhits = QUALITY_KEYWORDS.filter(q=>q.re.test(_qtitle));
const _hasDV = _qhits.some(q=>q.cls==='dv');
const _hasAtmos = _qhits.some(q=>q.label==='杜比');
const _has4K = _qhits.some(q=>q.label==='4K');
if(_hasDV && _hasAtmos)
_qhits = [{ label:'双杜比', cls:'dv' }, ..._qhits.filter(q=>q.cls!=='dv' && q.label!=='杜比')];
if(_has4K)
_qhits = _qhits.filter(q=>q.label!=='1080P');
const qualityTags = _qhits
.map(q=>`<span class="pan-tag quality${q.cls?' '+q.cls:''}">${esc(q.label)}</span>`).join('');
const btn = document.createElement('button');
btn.className = 'pan-item' + (isBad ? ' bad' : '');
btn.dataset.panKey = hk;
btn.tabIndex = 0;
if(isFocus) btn.autofocus = true;
btn.innerHTML = `
<div class="pan-item-body">
<div class="pan-item-title"><span>${esc(item.title||panState.keyword||'盘搜资源')}</span></div>
${qualityTags ? `<div class="pan-item-meta">${qualityTags}</div>` : ''}
</div>
${healthDot}`;
btn.addEventListener('focus', ()=>{ panState.focusKey = hk; panState.focusMode = 'results'; });
// 直接绑定 item 对象,与官方 demo 完全一致,绕开 key 序列化/反序列化问题
btn.addEventListener('click', ()=> playPanResult(item));
list.appendChild(btn);
});
// 启动可见性检测
observePanVisibleItems();
}
function panHealthLabel(s){
return ({ok:'链接有效',bad:'链接失效',locked:'需要提取码',unsupported:'暂不支持',uncertain:'结果不确定',pending:'检测中',idle:'未检测'})[s]||'未检测';
}
function selectPanType(typeId){
panState.activeType = typeId;
renderPanUI();
}
// ── PAN PLAYBACK RETURN ──
async function rememberPanPlaybackReturn(item){
const list = document.getElementById('panList');
const det = document.getElementById('det');
const hk = panHealthKey(item);
panState.playbackReturn = {
key: hk,
activeType: panState.activeType || '',
keyword: panState.keyword || '',
detScrollTop: det ? Math.round(det.scrollTop || 0) : 0,
listScrollTop: list ? Math.round(list.scrollTop || 0) : 0,
at: Date.now()
};
panState.focusKey = hk;
panState.focusMode = 'results';
try{
const str = JSON.stringify(panState.playbackReturn);
sdk().cache.set('film_lib_pan_return', str).catch(()=>{});
}catch(e){}
}
async function restorePanPlaybackReturn(){
// 尝试从缓存读取
let saved = panState.playbackReturn;
if(!saved){
try{
let str;
str = await sdk().cache.get('film_lib_pan_return').catch(()=>null);
if(str) saved = JSON.parse(str);
}catch(e){}
}
if(!saved) return false;
if(Date.now() - Number(saved.at||0) > PAN_PLAYBACK_RETURN_TTL){
panState.playbackReturn = null;
return false;
}
// 需要detail页和pan面板都在打开状态
const det = document.getElementById('det');
const block = document.getElementById('panBlock');
if(!det || !det.classList.contains('on') || !block || !block.classList.contains('on')) return false;
if(saved.activeType) panState.activeType = saved.activeType;
panState.focusKey = saved.key || panState.focusKey || '';
panState.focusMode = 'results';
renderPanUI();
const apply = ()=>{
const d = document.getElementById('det');
const l = document.getElementById('panList');
if(d) d.scrollTop = Number(saved.detScrollTop || 0);
if(l) l.scrollTop = Number(saved.listScrollTop || 0);
// 恢复焦点到上次点击的条目
if(saved.key && l){
const target = l.querySelector(`[data-pan-key="${CSS.escape(saved.key)}"]`);
if(target) target.focus();
}
};
requestAnimationFrame(apply);
setTimeout(apply, 80);
setTimeout(apply, 240);
panState.playbackReturn = null;
// 清理缓存
try{
sdk().cache.set('film_lib_pan_return','').catch(()=>{});
}catch(e){}
// 播放器返回时原生层可能消耗了一次 history.back
// 若栈顶已不是 pan 条目,补回一条,保证 UI(盘搜面板) 与栈一致,
// 这样后续返回手势能逐层关闭:pan → detail → 主页
if(!history.state || history.state.eclipse !== 'pan'){
if(location.hash !== '#pan') history.pushState({eclipse:'pan'}, '', '#pan');
}
return true;
}
// ── PAN PLAY → push_agent/pvideo 链路 ──
function buildPanPlayPayload(item){
let url = String(item && (item.normalizedUrl || item.normalized_url || item.url) || '').trim();
if(!url) return null;
// 去掉 push:// 前缀(App内部会处理,但统一清理)
if(/^push:\/\//i.test(url)) url = url.replace(/^push:\/\//i,'');
return {
type: panNormType(item.diskType),
url,
password: item.password || '',
title: item.title || panState.keyword || url
};
}
async function playPanItem(hkEsc){
// hkEsc 是 panHealthKey(item) 经 esc() 编码后的值
const hk = hkEsc.replace(/&amp;/g,'&').replace(/&lt;/g,'<').replace(/&gt;/g,'>').replace(/&quot;/g,'"').replace(/&#39;/g,"'");
const item = panState.results.find(r=>panHealthKey(r)===hk);
if(!item){ toast('资源不存在'); return; }
await playPanResult(item);
}
// 与官方 demo 完全一致的播放入口 —— 始终通过 sdk() 调用
async function playPanResult(item){
const payload = buildPanPlayPayload(item);
if(!payload){ toast('无效链接'); return; }
// ── 诊断日志(生产环境可按需移除)──
console.log('[Eclipse] window.fm =', window.fm);
console.log('[Eclipse] sdk() =', sdk());
console.log('[Eclipse] typeof sdk().pan.play =', typeof (sdk().pan && sdk().pan.play));
console.log('[Eclipse] playPanResult payload =', payload);
// ── Native SDK 检测:进入 fallback 时明确提示,禁止静默失败 ──
if(!window.fm){
const msg = 'Native SDK 未注入,请在 FongMi App 内使用盘搜播放功能。';
console.error('[Eclipse]', msg);
toast(msg);
return;
}
await rememberPanPlaybackReturn(item);
try{
const pan = sdk().pan || {};
console.log('[Eclipse] pan.play exists =', typeof pan.play);
if(typeof pan.play !== 'function') throw new Error('当前 App 不支持 pan.play' + typeof pan.play + '');
await pan.play(payload);
toast('已交给原生播放');
}catch(e){
console.error('[Eclipse] pan.play 调用失败:', e);
toast('播放失败:'+(e.message||'unknown'));
}
}
// ── PAN CHECK:完整队列式 IntersectionObserver 实现 ──
function panHealthKey(item){
return panNormType(item.diskType)+'|'+(item.normalizedUrl||item.url||'');
}
function isPanCheckable(item){
return PAN_CHECKABLE.has(panNormType(item.diskType));
}
function canCheckPanLinks(){
// checkEnabled는 init에서 세팅되지만, fm.pan.check가 있으면 최소한 시도 가능
if(!window.fm) return false;
try{
const pan = sdk().pan || {};
if(typeof pan.check !== 'function') return false;
if(panState.checkEnabled === false) return false;
return true;
}catch(e){ return false; }
}
function queuePanCheck(item){
if(!item || !canCheckPanLinks() || !isPanCheckable(item)) return;
const hk = panHealthKey(item);
if(!hk || panState.health[hk] || panState.pending[hk] || panState.queued.has(hk) || panState.inFlight.has(hk)) return;
panState.queued.set(hk, item);
schedulePanCheckFlush();
}
function schedulePanCheckFlush(){
if(panState.flushTimer) return;
panState.flushTimer = setTimeout(()=>{ panState.flushTimer=0; flushPanCheckQueue(); }, 180);
}
async function flushPanCheckQueue(){
if(!canCheckPanLinks()){ panState.queued.clear(); return; }
if(!panState.queued.size) return;
const entries = Array.from(panState.queued.entries()).slice(0,10);
entries.forEach(([hk])=>panState.queued.delete(hk));
entries.forEach(([hk])=>{ panState.pending[hk]=true; panState.inFlight.add(hk); });
renderPanUI();
try{
const pan = sdk().pan || {};
if(!pan.check) throw new Error('当前 App 不支持 pan.check');
const response = await pan.check(entries.map(([,item])=>({type:item.diskType,url:item.url,password:item.password})));
const results = response && Array.isArray(response.results) ? response.results : [];
entries.forEach(([origHk,fallback],i)=>{
const res = results[i] || {};
const normUrl = String(res.normalized_url||res.normalizedUrl||'').trim();
if(normUrl) fallback.normalizedUrl = normUrl;
const health = {
state: res.state || 'uncertain',
summary: res.summary || '',
checkedAt: res.checked_at || Date.now(),
expiresAt: res.expires_at || Date.now() + 300000
};
panState.health[origHk] = health;
// 也用新 normalizedUrl 的 key 存一份
const newHk = panHealthKey({diskType: panNormType(res.type||fallback.diskType), url: res.url||fallback.url});
if(newHk && newHk !== origHk) panState.health[newHk] = health;
});
const checked = panState.results.filter(r=>isPanCheckable(r)&&!!panState.health[panHealthKey(r)]).length;
const total = panState.results.filter(isPanCheckable).length;
updateConnStatus('pan', `已检测 ${checked}/${total}`);
}catch(e){
entries.forEach(([,item])=>{
panState.health[panHealthKey(item)] = {state:'uncertain',summary:e.message||'检测失败',checkedAt:Date.now(),expiresAt:Date.now()+300000};
});
}finally{
entries.forEach(([hk])=>{ delete panState.pending[hk]; panState.inFlight.delete(hk); });
renderPanUI();
scheduleVisiblePanCheck();
if(panState.queued.size) schedulePanCheckFlush();
}
}
function scheduleVisiblePanCheck(){
clearTimeout(scheduleVisiblePanCheck._t);
scheduleVisiblePanCheck._t = setTimeout(queueVisiblePanChecks, 90);
}
function queueVisiblePanChecks(){
if(!canCheckPanLinks()) return;
const list = document.getElementById('panList');
if(!list) return;
const listRect = list.getBoundingClientRect();
const bottomLimit = listRect.bottom + Math.max(80, listRect.height * .4);
Array.from(list.querySelectorAll('.pan-item')).some(node=>{
const rect = node.getBoundingClientRect();
if(rect.bottom < listRect.top || rect.top > bottomLimit) return false;
const hk = node.dataset.panKey;
const item = panState.results.find(r=>panHealthKey(r)===hk);
queuePanCheck(item);
return panState.queued.size + panState.inFlight.size >= 10;
});
}
function observePanVisibleItems(){
const list = document.getElementById('panList');
if(panState.observer) panState.observer.disconnect();
if(!canCheckPanLinks() || !list || !('IntersectionObserver' in window)){
queueVisiblePanChecks();
return;
}
panState.observer = new IntersectionObserver(entries=>{
entries.forEach(e=>{
if(!e.isIntersecting || e.intersectionRatio < 0.25) return;
const hk = e.target.dataset.panKey;
const item = panState.results.find(r=>panHealthKey(r)===hk);
queuePanCheck(item);
});
},{root:list, threshold:[0.25, 0.6]});
list.querySelectorAll('.pan-item').forEach(node=>panState.observer.observe(node));
scheduleVisiblePanCheck();
}
function checkVisiblePanItems(items){
// 兼容旧调用点,现在由 observePanVisibleItems 统一管理
observePanVisibleItems();
}
// ── RESUME (App background restore) ──
window.addEventListener('fmresume', async ()=>{
const restored = await restorePanPlaybackReturn();
if(!restored && window.fm){
sdk().history().then(hist=>{
if(!hist||!hist.length) return;
}).catch(()=>{});
}
});
// ── INIT ──
// ── RELAY MIRROR (官方 demo 完整移植) ──
const RELAY_MIRROR_PAGE_LIMIT = 1000;
const RELAY_MIRROR_MAX_PAGES_PER_SOURCE = 120;
// 默认源 relay 列表(与官方 demo 一致)
const DEFAULT_MIRROR_RELAYS = [
'wss://relay-sgp.signedbyme.com',
'wss://kotukonostr.onrender.com',
'wss://nostr.spicyz.io',
'wss://relay.wellorder.net'
];
function normalizeRelayUrl(value){
const text = String(value||'').trim();
if(!text) return '';
try{
const url = new URL(text);
if(url.protocol!=='wss:' && url.protocol!=='ws:') return '';
url.hash = '';
return url.toString().replace(/\/$/,'');
}catch(e){ return ''; }
}
function parseRelayList(value){
return Array.from(new Set(String(value||'')
.split(/[\s,]+/)
.map(normalizeRelayUrl)
.filter(Boolean)));
}
function shortRelay(url){
return String(url||'').replace(/^wss?:\/\//,'').replace(/\/$/, '');
}
function setupMirrorDefaults(){
const src = document.getElementById('mirrorSourceInput');
const tgt = document.getElementById('mirrorTargetInput');
if(src && !src.value.trim()) src.value = DEFAULT_MIRROR_RELAYS.join('\n');
if(tgt) tgt.value = tgt.value || '';
// enable editing on click (官方 disablePanelTextEditing 模式)
[src, tgt].forEach(el=>{
if(!el) return;
el.readOnly = true;
el.addEventListener('click', ()=>{ el.readOnly=false; el.focus(); });
el.addEventListener('blur', ()=>{ el.readOnly=true; });
});
}
function setMirrorProgress(lines){
const el = document.getElementById('mirrorProgress');
if(!el) return;
el.textContent = Array.isArray(lines)
? lines.filter(Boolean).join('\n')
: String(lines||'镜像未执行');
}
// 判断一个 Nostr 事件是否可镜像(保留接口,具体逻辑视 nostr 集成而定)
function isMirrorableEvent(event){
return !!(event && event.id && event.pubkey && event.sig);
}
function mirrorEventKey(event){
// 优先用 d-tag 地址作 key,没有则用 id
const d = event && event.tags && event.tags.find(t=>t[0]==='d');
if(d && d[1]) return (event.kind||'') + ':' + event.pubkey + ':' + d[1];
return String(event && event.id || '');
}
function mirrorEventIsNewer(next, old){
if(!old) return true;
const nc = Number(next && next.created_at || 0);
const oc = Number(old && old.created_at || 0);
if(nc !== oc) return nc > oc;
return String(next && next.id||'') > String(old && old.id||'');
}
// 从单个 relay 拉取事件(WebSocket REQ 协议)
async function queryMirrorEventsFromRelay(relay, since, progressNote){
const byId = new Map();
let until = Math.floor(Date.now()/1000);
let pages = 0;
const LIMIT = RELAY_MIRROR_PAGE_LIMIT;
while(until > since && pages < RELAY_MIRROR_MAX_PAGES_PER_SOURCE){
pages++;
setMirrorProgress([
`拉取中:${shortRelay(relay)}`,
`${pages} 页,已收到 ${byId.size}`,
progressNote || ''
]);
const events = await new Promise((resolve)=>{
let done = false;
const finish = (evts)=>{ if(done)return; done=true; resolve(evts); };
const collected = [];
try{
const ws = new WebSocket(relay);
const subId = 'mirror_' + Math.random().toString(36).slice(2,8);
const timer = setTimeout(()=>{ try{ws.close();}catch(e){} finish(collected); }, 12000);
ws.onopen = ()=>{
ws.send(JSON.stringify(['REQ', subId,
{ kinds:[1], since, until, limit:LIMIT }
]));
};
ws.onmessage = (msg)=>{
try{
const data = JSON.parse(msg.data);
if(data[0]==='EVENT' && data[1]===subId && data[2]) collected.push(data[2]);
if(data[0]==='EOSE' && data[1]===subId){ clearTimeout(timer); try{ws.close();}catch(e){} finish(collected); }
}catch(e){}
};
ws.onerror = ()=>{ clearTimeout(timer); finish(collected); };
ws.onclose = ()=>finish(collected);
}catch(e){ finish([]); }
});
events.filter(isMirrorableEvent).forEach(ev=>byId.set(ev.id, ev));
if(!events.length) break;
collected_complete = events.length >= LIMIT;
let oldest = until;
for(const ev of events) oldest = Math.min(oldest, Number(ev.created_at||oldest));
if(events.length < LIMIT || oldest >= until) break;
until = oldest - 1;
}
return Array.from(byId.values());
}
// 向单个 relay 发布一个事件
async function publishMirrorEventToRelay(relay, event, timeout){
return new Promise(resolve=>{
let settled = false;
const done = (ok, text)=>{ if(settled)return; settled=true; resolve({ok, text:text||''}); };
try{
const ws = new WebSocket(relay);
const timer = setTimeout(()=>{ try{ws.close();}catch(e){} done(false,'超时'); }, timeout||9000);
ws.onopen = ()=> ws.send(JSON.stringify(['EVENT', event]));
ws.onmessage = (msg)=>{
try{
const data = JSON.parse(msg.data);
if(data[0]!=='OK') return;
clearTimeout(timer); try{ws.close();}catch(e){}
done(!!data[2], data[2] ? 'OK' : data[3]||'拒绝');
}catch(e){}
};
ws.onerror = ()=>{ clearTimeout(timer); done(false,'失败'); };
ws.onclose = ()=> done(false,'断开');
}catch(e){ done(false, e.message||'失败'); }
});
}
async function mirrorHotEventsToRelays(){
if(mirrorHotEventsToRelays.busy) return;
setupMirrorDefaults();
const srcEl = document.getElementById('mirrorSourceInput');
const tgtEl = document.getElementById('mirrorTargetInput');
const sources = parseRelayList(srcEl && srcEl.value || DEFAULT_MIRROR_RELAYS.join('\n'));
const targets = parseRelayList(tgtEl && tgtEl.value || '');
if(!sources.length){ toast('请填写源 relay'); return; }
if(!targets.length){ toast('请填写目标 relay'); return; }
mirrorHotEventsToRelays.busy = true;
const btn = document.getElementById('mirrorRelayBtn');
if(btn) btn.textContent = '镜像中…';
try{
const since = Math.floor(Date.now()/1000) - 7*24*3600; // 近7天
const eventsByAddress = new Map();
for(const relay of sources){
const events = await queryMirrorEventsFromRelay(relay, since,
`${sources.indexOf(relay)+1}/${sources.length} · 用户 ${eventsByAddress.size}`);
events.forEach(ev=>{
const key = mirrorEventKey(ev);
if(key && mirrorEventIsNewer(ev, eventsByAddress.get(key))) eventsByAddress.set(key, ev);
});
setMirrorProgress([
`源完成:${shortRelay(relay)}`,
`本源有效 ${events.length}`,
`按用户去重后 ${eventsByAddress.size}`
]);
}
const events = Array.from(eventsByAddress.values())
.sort((a,b)=>Number(b.created_at||0)-Number(a.created_at||0));
if(!events.length){
setMirrorProgress(['镜像完成','没有从源 relay 拉到可镜像的事件']);
toast('没有可镜像事件'); return;
}
let ok=0, rejected=0, failed=0, done=0;
for(const target of targets){
let tOk=0, tRej=0, tFail=0;
for(const ev of events){
done++;
setMirrorProgress([
`发布中:${shortRelay(target)}`,
`事件 ${done}/${events.length*targets.length}`,
`总计 OK ${ok} · 拒绝 ${rejected} · 失败 ${failed}`
]);
const result = await publishMirrorEventToRelay(target, ev, 9000);
if(result.ok || /duplicate|exists|already|newer|older|replace/i.test(result.text||'')){
ok++; tOk++;
}else if((result.text||'').includes('拒绝')){
rejected++; tRej++;
}else{
failed++; tFail++;
}
}
setMirrorProgress([
`目标完成:${shortRelay(target)}`,
`OK ${tOk} · 拒绝 ${tRej} · 失败 ${tFail}`,
`总计 OK ${ok} · 拒绝 ${rejected} · 失败 ${failed}`
]);
}
setMirrorProgress([
'镜像完成',
`源 relay ${sources.length} 个,目标 relay ${targets.length}`,
`最新用户事件 ${events.length}`,
`发布 OK ${ok} · 拒绝 ${rejected} · 失败 ${failed}`
]);
toast(`镜像完成:${events.length} 条事件`);
}finally{
mirrorHotEventsToRelays.busy = false;
if(btn) btn.textContent = '镜像榜单';
}
}
// ══════════════════════════════════════════════════════════
// TV 遥控器导航模块
// 职责:
// 1. 检测 TV 模式(IS_TV 或 URL ?tv=1
// 2. 给所有可聚焦元素加 tv-focusable 类
// 3. 拦截方向键 / OK / 返回 做空间导航
// 4. 搜索框特殊处理(方向键不拦截,只拦截 OK/返回)
// 5. 完全不修改现有业务逻辑
// ══════════════════════════════════════════════════════════
let TV_MODE = IS_TV || /[?&]tv=1/.test(location.search);
if(TV_MODE) document.body.classList.add('tv-mode');
// 运行时自动进入 TV 模式:某些盒子不报 isLeanback,收到遥控键时再启用
function _tvEnable(){
if(TV_MODE) return;
TV_MODE = true;
document.body.classList.add('tv-mode');
try{ _tvWatchPanels(); }catch(_){}
const items = _tvGetItems();
if(items.length && !_tvFocus) _tvSetFocus(items[0], false);
}
// ── 焦点区域定义(按层级优先级) ──
// 每个"层"是一组可以被方向键导航的元素集合
// 当某层打开时,只在该层内导航
let _tvFocus = null; // 当前 tv-focus 元素
function _tvEl(el){
return el && el.offsetParent !== null && !el.disabled;
}
// ── 列数缓存(按视口宽度 + grid 类型)──
// 避免每次方向键都 getComputedStyle 读 grid-template-columns
let _tvColCache = { w:0, normal:3, land:2 };
function _tvCols(isLand){
const w = window.innerWidth;
if(_tvColCache.w !== w){
// 与 CSS 媒体查询断点保持一致
let normal, land;
if(w>=1600){ normal=11; land=6; }
else if(w>=1200){ normal=9; land=6; }
else if(w>=900){ normal=7; land=5; }
else if(w>=640){ normal=5; land=4; }
else if(w>=480){ normal=4; land=3; }
else { normal=3; land=2; }
_tvColCache = { w, normal, land };
}
return isLand ? _tvColCache.land : _tvColCache.normal;
}
// 获取当前活跃层的所有可导航元素(仅在需要时调用,结果可缓存)
function _tvGetItems(){
const _on = (id)=>{ const el = document.getElementById(id); return !!el && el.classList.contains('on'); };
const panOpen = _on('panBlock');
const qpOpen = _on('quickpan');
const detOpen = _on('det');
const srchOpen = _on('srch');
const srchResultVisible = (() => {
const el = document.getElementById('s-search');
return el && el.style.display !== 'none';
})();
if(srchOpen){
return [...document.querySelectorAll('#srchBody .srch-suggest-item, #srchBody .srch-hot li, #srchBody .srch-hist-tag')].filter(_tvEl);
}
if(qpOpen){
return [...document.querySelectorAll('#qpList .pan-item, #qpTabs .pan-chip, #quickpan .qp-back')].filter(_tvEl);
}
if(panOpen){
return [...document.querySelectorAll('#panList .pan-item, #panTabs .pan-chip, #panBlock .btn-s')].filter(_tvEl);
}
if(detOpen){
return [...document.querySelectorAll('#det .btn-p, #det .btn-s, #det .det-cast-card, #detRecsGrid .card')].filter(_tvEl);
}
if(_on('cat')){
return [...document.querySelectorAll('#cat .cat-back, #catFilters .fil-tag, #catGrid .card')].filter(_tvEl);
}
if(srchResultVisible){
return [...document.querySelectorAll('#srch-list .sli')].filter(_tvEl);
}
const tabs = [...document.querySelectorAll('.ntab')].filter(_tvEl);
const activeTab = document.querySelector('.ntab.on');
let more = [];
let cards = [];
if(activeTab){
const secId = activeTab.dataset.sec;
const sec = document.getElementById(secId);
if(sec){
const m = sec.querySelector('.sec-more');
if(m && _tvEl(m)) more = [m]; // 「更多」入口可被遥控聚焦 → 进分类页
cards = [...sec.querySelectorAll('.card')].filter(_tvEl);
}
}
return [...tabs, ...more, ...cards];
}
// ── 快速路径:主页 grid 卡片用索引导航(支持跨版块上下贯穿)──
// 返回 null 表示当前不在可快速导航的元素上,需走几何兜底
// 整个过程只查询「当前/相邻」一两个网格,不做全页几何搜索,避免卡顿
function _secGrid(sec){ return sec ? sec.querySelector('.grid') : null; }
function _secCards(sec){
const g = _secGrid(sec);
return g ? [...g.querySelectorAll('.card')].filter(_tvEl) : [];
}
// 相邻「有卡片的版块」:dir = 'next' | 'prev',跳过空版块与分类页
function _adjSec(sec, dir){
let s = sec;
while(s){
s = dir==='next' ? s.nextElementSibling : s.previousElementSibling;
if(!s) return null;
if(!s.classList || !s.classList.contains('sec')) continue;
if(s.closest('#cat')) continue;
if(_secCards(s).length) return s;
}
return null;
}
// 取版块中尽量保持列位置的卡片(跨版块时列数可能不同,做夹取)
function _secCardAtCol(sec, col){
const cards = _secCards(sec);
if(!cards.length) return null;
const isLand = _secGrid(sec).classList.contains('grid-land');
const cols = _tvCols(isLand);
return cards[Math.min(col, cols-1, cards.length-1)] || cards[0];
}
// 取版块最后一行、尽量保持列位置的卡片(用于向上跨入上一版块)
function _secLastRowAtCol(sec, col){
const cards = _secCards(sec);
if(!cards.length) return null;
const isLand = _secGrid(sec).classList.contains('grid-land');
const cols = _tvCols(isLand);
const lastRowStart = Math.floor((cards.length-1)/cols)*cols;
return cards[Math.min(lastRowStart+col, cards.length-1)] || cards[cards.length-1];
}
function _tvGridFastNav(cur, dir){
if(!cur || !cur.classList) return null;
if(cur.closest && cur.closest('#cat')) return null; // 分类页交给几何寻路
// —— 焦点在「更多」入口 ——
if(cur.classList.contains('sec-more')){
const sec = cur.closest('.sec');
if(dir==='down'){ return _secCards(sec)[0] || (_adjSec(sec,'next') && _secCards(_adjSec(sec,'next'))[0]) || cur; }
if(dir==='up'){
const prev = _adjSec(sec,'prev');
if(prev) return _secCards(prev)[0] || cur;
return document.querySelector('.ntab.on') || document.querySelector('.ntab') || cur;
}
return cur; // 左右不动
}
// —— 焦点在卡片 ——
if(!cur.classList.contains('card')) return null;
const grid = cur.closest('.grid');
if(!grid) return null;
const sec = grid.closest('.sec');
const cards = [...grid.querySelectorAll('.card')].filter(_tvEl);
const idx = cards.indexOf(cur);
if(idx < 0) return null;
// 首页分区现为单行横向滑动:每个版块只有一行,左右在本行内移动,
// 上下用于跨版块(不再有"同版块下一行"的概念)
if(sec){
if(dir==='left') return idx>0 ? cards[idx-1] : cur;
if(dir==='right') return idx<cards.length-1 ? cards[idx+1] : cur;
if(dir==='down'){
const ns = _adjSec(sec,'next');
return ns ? (_secCards(ns)[0] || cur) : cur;
}
if(dir==='up'){
const more = sec.querySelector('.sec-more');
if(more && _tvEl(more)) return more;
const ps = _adjSec(sec,'prev');
if(ps) return _secCards(ps)[0] || cur;
return document.querySelector('.ntab.on') || document.querySelector('.ntab') || cur;
}
return null;
}
// 兜底:其它仍为多行 grid 的区域(理论上已被前面 #cat 判断排除)
const isLand = grid.classList.contains('grid-land');
const cols = _tvCols(isLand);
const col = idx % cols;
if(dir==='left'){
if(col === 0) return cur; // 行首向左不动
return cards[idx-1] || cur;
}
if(dir==='right'){
if(col === cols-1) return cur; // 行尾向右不动
return cards[idx+1] || cur;
}
if(dir==='down'){
const nxt = cards[idx+cols];
if(nxt) return nxt; // 同版块下一行
const ns = _adjSec(sec,'next'); // 最后一行 → 跨到下一版块(保持列位置)
if(ns) return _secCardAtCol(ns, col);
return cur; // 已是末版块末行
}
if(dir==='up'){
if(idx >= cols) return cards[idx-cols] || cur; // 同版块上一行
// 首行向上 → 本版块「更多」入口;没有再跨到上一版块最后一行;再没有回 tab
const more = sec && sec.querySelector('.sec-more');
if(more && _tvEl(more)) return more;
const ps = _adjSec(sec,'prev');
if(ps) return _secLastRowAtCol(ps, col);
return document.querySelector('.ntab.on') || document.querySelector('.ntab') || cur;
}
return null;
}
// ── 几何最近邻(兜底,仅在非规则区域使用)──
function _tvCenter(el){
const r = el.getBoundingClientRect();
return { x: r.left + r.width/2, y: r.top + r.height/2 };
}
function _tvNearest(cur, items, dir){
if(!cur) return items[0] || null;
const cc = _tvCenter(cur);
let best = null, bestScore = Infinity;
for(const el of items){
if(el === cur) continue;
const ec = _tvCenter(el);
const dx = ec.x - cc.x, dy = ec.y - cc.y;
const inDir = (
(dir==='right' && dx > 2) ||
(dir==='left' && dx < -2) ||
(dir==='down' && dy > 2) ||
(dir==='up' && dy < -2)
);
if(!inDir) continue;
let primary, side;
if(dir==='right'||dir==='left'){ primary=Math.abs(dx); side=Math.abs(dy); }
else { primary=Math.abs(dy); side=Math.abs(dx); }
const score = primary + side * 2.5;
if(score < bestScore){ bestScore=score; best=el; }
}
return best;
}
// ── 焦点设置 + rAF 滚动合帧 ──
let _tvScrollTarget = null;
let _tvScrollRAF = 0;
function _tvScheduleScroll(el){
_tvScrollTarget = el;
if(_tvScrollRAF) return;
_tvScrollRAF = requestAnimationFrame(()=>{
_tvScrollRAF = 0;
const t = _tvScrollTarget;
_tvScrollTarget = null;
if(!t) return;
// 列表内部优先滚动容器,整页只在必要时滚
t.scrollIntoView({ block:'nearest', inline:'nearest' });
});
}
function _tvSetFocus(el, scroll=true){
if(_tvFocus && _tvFocus !== el){
_tvFocus.classList.remove('tv-focus');
}
_tvFocus = el;
if(!el) return;
el.classList.add('tv-focus');
// 原生 focus 不触发滚动,滚动统一走 rAF
if(el.focus){ try{ el.focus({ preventScroll:true }); }catch(_){ } }
if(scroll) _tvScheduleScroll(el);
}
// 统一激活(对齐官方 demo activateFocusedElement):
// 程序化 el.click() 只派发合成 click,不会触发 pointerdown/pointerup
// 因此对「只绑 pointer 事件、无 onclick」的控件需按其逻辑动作直接路由。
function _tvActivate(el){
if(!el) return false;
// 播放按钮只监听 pointerdown/up(长按=聚合搜索,单击=播放最优),click() 对它无效 → 直接走单击
if(el.id === 'detPlayBtn'){
if(typeof detPlay === 'function'){ detPlay(); return true; }
}
if(typeof el.click === 'function'){ el.click(); return true; }
return false;
}
// 初始化:页面加载完成后给首个元素聚焦(数据慢加载时重试,确保 OK 有落点)
function _tvInit(){
if(!TV_MODE) return;
let tries = 0;
const tryFocus = ()=>{
if(_tvFocus && _tvEl(_tvFocus)) return; // 已有有效焦点
const items = _tvGetItems();
if(items.length){ _tvSetFocus(items[0], false); return; }
if(tries++ < 12) setTimeout(tryFocus, 300); // 最多重试约 4 秒
};
setTimeout(tryFocus, 500);
}
// 搜索框是否处于文字输入状态
function _tvInSearchInput(){
const inp = document.getElementById('srchInp');
return document.activeElement === inp || (inp && inp === document.querySelector(':focus'));
}
// 主键盘拦截(TV 模式专用)
function _tvKeydown(e){
// 老 WebView / 部分 TV 固件只给 keyCode,不给标准 e.key,做归一化兜底
var key = e.key;
var kc = e.keyCode || e.which || 0;
if(!key || key === 'Unidentified'){
var KC = {37:'ArrowLeft',38:'ArrowUp',39:'ArrowRight',40:'ArrowDown',
13:'Enter',23:'Enter',66:'Enter',32:'Enter', // 23=DPAD_CENTER, 66=ENTER, 32=空格(部分盒子OK)
27:'Escape',8:'Escape',4:'Escape',461:'Escape'}; // 4=BACK, 461=LG返回
if(KC[kc]) key = KC[kc];
}
// OK/确认键的各种写法统一成 Enter:
// - e.key 别名(不同固件五花八门,且有时 e.key 非空但非标准,所以不能只靠上面的 keyCode 兜底)
// - keyCode 13(Enter)/23(DPAD_CENTER)/66(ENTER) 无条件兜底(这几个码就是 OK,覆盖 e.key 异常的盒子)
if(key === 'Select' || key === 'Accept' || key === 'Spacebar' || key === ' ' ||
key === 'Execute' || key === 'Go' || key === 'Center' || key === 'DpadCenter' ||
key === 'OK' || key === 'Confirm' || key === 'NavigationDefault' || key === 'GamepadA' ||
kc === 13 || kc === 23 || kc === 66){
key = 'Enter';
}
// 是否遥控键:收到即认定处于 TV 环境,自动启用(兼容不报 isLeanback 的盒子)
const _isRemote = /^Arrow/.test(key) || key === 'Enter' || key === 'Escape' || key === 'GoBack' || key === 'Back';
if(!TV_MODE){
// 方向键 / GoBack:明确的遥控导航 → 启用 TV 模式
if(/^Arrow/.test(key) || key === 'GoBack'){ _tvEnable(); }
// OK 键:仅当焦点已落在可导航元素上(排除文字输入框)才认定为遥控并启用,
// 这样不报 isLeanback 的盒子按 OK 也能用;手机软键盘回车(焦点在输入框)不会误切 TV。
else if(key === 'Enter'){
const ae = document.activeElement;
const inText = ae && (ae.tagName === 'INPUT' || ae.tagName === 'TEXTAREA' || ae.isContentEditable);
const target = (_tvFocus && _tvEl(_tvFocus)) ? _tvFocus
: (ae && ae !== document.body && _tvEl(ae)) ? ae : null;
if(!inText && target){ _tvEnable(); }
else { return; }
}
else { return; }
}
const srchOpen = document.getElementById('srch').classList.contains('on');
// 搜索框输入状态:只拦截 Enter(OK) 和 Escape/GoBack,方向键交给输入法
if(srchOpen && _tvInSearchInput()){
if(key === 'Enter'){ submitSrch(); e.preventDefault(); return; }
if(key === 'Escape' || key === 'GoBack' || key === 'Back'){
closeSrch(); e.preventDefault(); return;
}
// ArrowUp/Down 在联想列表中已由 onSrchKey 处理,此处不重复拦截
return;
}
// 方向键 / OK / 返回
const DIR_KEYS = { ArrowUp:'up', ArrowDown:'down', ArrowLeft:'left', ArrowRight:'right' };
if(DIR_KEYS[key]){
e.preventDefault();
const dir = DIR_KEYS[key];
const cur = _tvFocus && _tvEl(_tvFocus) ? _tvFocus : null;
// 快速路径:主页 grid 卡片用索引计算,避免全局几何搜索
if(cur){
const fast = _tvGridFastNav(cur, dir);
if(fast){ if(fast !== cur) _tvSetFocus(fast); return; }
}
// 兜底:几何最近邻(仅在非规则区域或跨区域时)
const items = _tvGetItems();
if(!items.length) return;
const c2 = cur && items.includes(cur) ? cur : null;
const next = _tvNearest(c2, items, dir);
if(next) _tvSetFocus(next);
return;
}
if(key === 'Enter'){
e.preventDefault();
e.stopPropagation(); // 阻止冒泡到元素自身的内联 onkeydown,避免 openDet 双触发
// 解析激活目标:优先 _tvFocus,其次原生 activeElement(对齐官方),再次当前层首项
let el = (_tvFocus && _tvEl(_tvFocus)) ? _tvFocus : null;
if(!el){
const ae = document.activeElement;
if(ae && ae !== document.body && _tvEl(ae)) el = ae;
}
if(!el){
const items = _tvGetItems();
if(items.length) el = items[0];
}
if(!el) return;
_tvSetFocus(el, false); // 焦点对齐到将要激活的元素
_tvActivate(el); // 单次 OK 即激活(播放按钮等 pointer 控件已路由,不再需要按两次)
return;
}
// 返回键(与手机端保持一致)
if(
key === 'Escape' ||
key === 'GoBack' ||
key === 'Back' ||
key === 'BrowserBack'
){
e.preventDefault();
// 走 history 栈,触发 popstate
history.back();
setTimeout(()=>{
const items = _tvGetItems();
if(items.length) _tvSetFocus(items[0], false);
}, 300);
return;
}
// 彩色键 / 快捷键
if(key === 'ColorF0Red' || key === 'F1'){ openSrch(); e.preventDefault(); return; }
}
// 监听层切换后自动重置焦点(用 MutationObserver 监听面板 class 变化)
function _tvWatchPanels(){
if(!TV_MODE) return;
const panels = [
document.getElementById('srch'),
document.getElementById('det'),
document.getElementById('cat'),
document.getElementById('panBlock'),
document.getElementById('quickpan'),
document.getElementById('s-search'),
];
const mo = new MutationObserver(()=>{
setTimeout(()=>{
const items = _tvGetItems();
if(_tvFocus && items.includes(_tvFocus)) return;
_tvSetFocus(items[0] || null, false);
}, 180);
});
panels.forEach(el=>{
if(el) mo.observe(el, { attributes:true, attributeFilter:['class'] });
});
}
// 窗口尺寸变化时让列数缓存失效(resize 在 TV 上极少发生,开销可忽略)
window.addEventListener('resize', ()=>{ _tvColCache.w = 0; }, {passive:true});
// 横竖屏切换后重新检测分类页内容是否足够,修复横屏时卡在 20 条不加载的问题
window.addEventListener('orientationchange', ()=>{
setTimeout(()=>{ _catMaybeFill(); }, 350);
}, {passive:true});
// 注册 TV 键盘拦截(在现有 keydown 之前处理)
document.addEventListener('keydown', _tvKeydown, true); // capture 阶段,优先于现有 handler
_tvWatchPanels();
// boot 完成后初始化焦点
// (放在 boot() 之后由 setTimeout 触发,见 _tvInit)
async function boot(){
updateConnStatus('sdk', window.fongmiBridge ? '连接中…' : '浏览器模式');
if(window.fongmiBridge) await waitForNativeSdk(1500);
updateConnStatus('sdk', window.fm ? 'App 已连接' : (window.fongmiBridge ? 'SDK 未就绪' : '浏览器模式'));
try{
const s = await sdk().site();
siteKey = s && s.key || '';
updateConnStatus('site', siteKey ? siteKey : '未配置');
}catch(e){ updateConnStatus('site', window.fm ? '获取失败' : '浏览器预览'); }
try{
const cfg = await sdk().config();
panState.checkEnabled = !!(cfg && cfg.driveCheck);
}catch(e){ panState.checkEnabled = false; }
if(panState.checkEnabled){
updateConnStatus('pan','检测已开启');
}
await loadConnConfig();
setupMirrorDefaults();
loadAll();
try{ _syncChromeForRoute(); }catch(e){} // 首页 chrome 初始化
_tvInit();
setTimeout(()=>restorePanPlaybackReturn().catch(()=>{}), 300);
}
// 根据 chromeMode 调整顶部安全区:有原生 Toolbar 时归零(Toolbar 已避让),
// 全屏时用 --fm-safe-top(状态栏高度)。
function _applySafeTop(mode){
const hasToolbar = mode === 'normal' || mode === 'tv-normal' || mode === 'tv-overlay';
document.documentElement.style.setProperty(
'--safe-t',
hasToolbar ? '0px' : 'max(var(--fm-safe-top,0px),env(safe-area-inset-top,0px))'
);
}
window.addEventListener('fmviewport', function(e){
_applySafeTop((e.detail || {}).chromeMode || '');
});
// 初始化时主动读一次,避免首帧 fmviewport 尚未触发时顶部间距偏大
(function(){
if(!window.fm) return;
try{
fm.ui.getViewport().then(function(vp){ _applySafeTop((vp || {}).chromeMode || ''); }).catch(function(){});
}catch(e){}
})();
boot();
</script></body></html>