Files
tvbox_collect/tvbox/lib/eclipse.html
T
2026-06-29 03:41:14 +00:00

5637 lines
233 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>