4255 lines
161 KiB
HTML
4255 lines
161 KiB
HTML
|
||
<!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}
|
||
|
||
:root{
|
||
--bg:transparent;
|
||
--surface:#111111;
|
||
--surface2:#1a1a1a;
|
||
--text:#f0ede8;
|
||
--muted:#595959;
|
||
--accent:#e8c97a;
|
||
--accent2:#c85c3a;
|
||
--green:#3ddc84;
|
||
--red:#ff453a;
|
||
--yellow:#ffd60a;
|
||
--blue:#6688aa;
|
||
--nav-h:52px;
|
||
--safe-t: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;inset: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{
|
||
color:var(--accent);
|
||
background:linear-gradient(135deg,rgba(232,201,122,.18) 0%,rgba(232,201,122,.08) 100%);
|
||
box-shadow:inset 0 0 0 1px rgba(232,201,122,.2);
|
||
outline:none;
|
||
}
|
||
.ntab.on .ntab-ico{opacity:1;filter:drop-shadow(0 0 4px rgba(232,201,122,.5))}
|
||
.ntab.on::after{
|
||
content:'›';
|
||
position:absolute;right:5px;top:50%;transform:translateY(-50%);
|
||
font-size:15px;color:var(--accent);opacity:.65;line-height:1;
|
||
}
|
||
.ntab:hover{color:var(--text);background:rgba(255,255,255,.06);transform:translateY(-1px)}
|
||
.ntab:hover .ntab-ico{opacity:.8}
|
||
.ntab:active{transform:scale(.94) translateY(0);background:rgba(232,201,122,.1)}
|
||
.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:var(--accent);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;inset: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;inset: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;inset: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:clamp(30px,4vw,58px);
|
||
left:0;z-index:2;
|
||
padding:0 clamp(20px,4vw,54px);
|
||
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: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:min(340px,70%);
|
||
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;
|
||
background:var(--accent);color:#000;
|
||
font-family:var(--mono);font-size:13px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;
|
||
border:none;cursor:pointer;border-radius:var(--r);width:fit-content;
|
||
box-shadow:0 2px 10px rgba(232,201,122,.25);
|
||
transition:background .12s,transform .12s,box-shadow .12s;
|
||
}
|
||
.hero-play-btn:hover{background:#f5de8a;transform:translateY(-2px);box-shadow:0 6px 20px rgba(232,201,122,.35)}
|
||
.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}
|
||
/* progress bar */
|
||
#heroProgress{
|
||
position:absolute;bottom:0;left:0;z-index:5;
|
||
height:2px;background:var(--accent);opacity:.6;
|
||
width:0%;transition:width linear;
|
||
}
|
||
|
||
@media(min-width:900px){.hero-slide-content{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 20px 24px;border-bottom:1px solid rgba(255,255,255,.04)}
|
||
.sechd{
|
||
display:flex;align-items:center;gap:10px;
|
||
padding:20px 0 12px;
|
||
}
|
||
.sectitle{
|
||
font-family:var(--serif);font-size:15px;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{font-family:var(--mono);font-size:12px;color:var(--muted)}
|
||
|
||
/* ── GRID ── */
|
||
.grid{
|
||
display:grid;
|
||
grid-template-columns:repeat(3,1fr);
|
||
gap:6px;
|
||
}
|
||
@media(min-width:480px){.grid{grid-template-columns:repeat(4,1fr);gap:8px}}
|
||
@media(min-width:640px){.grid{grid-template-columns:repeat(5,1fr);gap:9px}}
|
||
@media(min-width:900px){.grid{grid-template-columns:repeat(7,1fr);gap:10px}}
|
||
@media(min-width:1200px){.grid{grid-template-columns:repeat(9,1fr);gap:12px}}
|
||
@media(min-width:1600px){.grid{grid-template-columns:repeat(11,1fr);gap:14px}}
|
||
|
||
/* 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)}}
|
||
|
||
/* ── CARD ── */
|
||
.card{
|
||
cursor:pointer;position:relative;
|
||
border-radius:var(--r);overflow:hidden;
|
||
background:var(--surface);outline:none;
|
||
transition:transform .14s cubic-bezier(.2,.8,.4,1);
|
||
}
|
||
.card:hover,.card:focus{transform:scale(1.06);z-index:3}
|
||
.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;inset:0;
|
||
background:linear-gradient(180deg,transparent 55%,rgba(0,0,0,.8) 100%);
|
||
opacity:0;transition:opacity .14s;
|
||
}
|
||
.card:hover .card-overlay,.card:focus .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;
|
||
}
|
||
.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}
|
||
|
||
.card-body{padding:6px 7px 8px}
|
||
.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{
|
||
font-family:var(--mono);font-size:11px;color:var(--muted);
|
||
margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
|
||
}
|
||
.card-badge{
|
||
display:inline-block;font-family:var(--mono);font-size:10px;
|
||
color:var(--accent2);background:rgba(200,92,58,.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;inset: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{
|
||
width:100%;background:var(--surface);
|
||
border:1px solid rgba(255,255,255,.07);
|
||
border-radius:var(--r);color:var(--text);
|
||
font-family:var(--serif);font-size:19px;
|
||
padding:11px 52px 11px 42px;outline:none;
|
||
transition:border-color .12s;
|
||
-webkit-appearance:none;appearance:none;
|
||
}
|
||
.srch-inp::-webkit-search-cancel-button{display:none}
|
||
.srch-inp:focus{border-color:var(--accent)}
|
||
.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:6px;
|
||
display:flex;align-items:center;justify-content:center;
|
||
color:#000;transition:background .12s,transform .1s;flex-shrink:0;
|
||
}
|
||
.srch-submit-btn:hover{background:#f5de8a;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){
|
||
filter:blur(5px) brightness(.45);
|
||
pointer-events:none;
|
||
user-select:none;
|
||
transition:filter .25s ease;
|
||
}
|
||
html.search-active #s-search{
|
||
position:relative;z-index:5;
|
||
}
|
||
/* ── 搜索结果列表 ── */
|
||
#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(--muted);
|
||
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(--muted);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(232,201,122,.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;inset:0;z-index:150;
|
||
background:#06111b;
|
||
overflow-y:scroll; /* 强制scroll而非auto,避免部分WebView失效 */
|
||
overflow-x:hidden;
|
||
-webkit-overflow-scrolling:touch; /* iOS惯性滚动 */
|
||
transform:translateX(100%);
|
||
transition:transform .26s cubic-bezier(.4,0,.2,1);
|
||
}
|
||
#det.on{transform:translateX(0)}
|
||
|
||
|
||
|
||
/* ── 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.1);
|
||
opacity:0;transition:opacity 1.2s ease;
|
||
}
|
||
.det-hero-img.active{opacity:1}
|
||
/* 手机竖屏:全屏渐变遮罩 — 顶部更透明,让图片透出 */
|
||
.det-hero::after{
|
||
content:'';position:absolute;inset:0;
|
||
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%);
|
||
}
|
||
/* 宽屏桌面(≥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) 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: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: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:clamp(142px,45vw,262px)}
|
||
}
|
||
/* 平板单列(700-1100):留中等顶部空间露出背景图 */
|
||
@media(min-width:700px) and (max-width:1099px){
|
||
.det-poster-row{margin-top:0;padding-top:clamp(180px,34vh,360px)}
|
||
}
|
||
/* 宽屏桌面双列:不需要顶部留白,从 padding 开始 */
|
||
@media(min-width:1100px){
|
||
.det-poster-row{margin-top:0;padding-top:max(20px, 8vh)}
|
||
}
|
||
|
||
|
||
.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:6px;
|
||
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:6px;min-height:48px;display:flex;align-items:flex-end}
|
||
.det-logo-img{
|
||
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: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:10px;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(232,201,122,.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:12px 0 18px}
|
||
.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;
|
||
}
|
||
|
||
/* action buttons */
|
||
.det-actions{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:20px;align-items:center}
|
||
.btn-p{
|
||
display:inline-flex;align-items:center;gap:7px;
|
||
background:var(--accent);color:#000;
|
||
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;
|
||
transition:background .12s,transform .1s,box-shadow .12s;
|
||
box-shadow:0 4px 20px rgba(232,201,122,.3);
|
||
}
|
||
.btn-p:hover{background:#f0d890;transform:translateY(-1px);box-shadow:0 8px 28px rgba(232,201,122,.4)}
|
||
.btn-p:active{transform:scale(.95)}
|
||
.btn-s{
|
||
display:inline-flex;align-items:center;gap:7px;
|
||
background:rgba(255,255,255,.1);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,.22);cursor:pointer;padding:11px 18px;border-radius:999px;
|
||
backdrop-filter:blur(16px);
|
||
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,.17);border-color:rgba(255,255,255,.35);transform:translateY(-1px)}
|
||
.btn-s:active{transform:scale(.95)}
|
||
|
||
/* overview */
|
||
.det-overview{
|
||
font-size:16px;line-height:1.75;color:rgba(240,237,232,.75);
|
||
margin-bottom:24px;
|
||
}
|
||
|
||
/* section label */
|
||
.det-sec-label{
|
||
font-family:var(--mono);font-size:11px;letter-spacing:.22em;
|
||
text-transform:uppercase;color:var(--muted);
|
||
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%;}
|
||
.det-cast-rail{
|
||
display:flex;gap:12px;overflow-x:auto;overflow-y:hidden;
|
||
padding:2px 0 10px;scrollbar-width:none;margin-bottom:8px;
|
||
flex-wrap:nowrap;
|
||
-webkit-overflow-scrolling:touch;
|
||
width:100%;
|
||
}
|
||
.det-cast-rail::-webkit-scrollbar{display:none}
|
||
|
||
.det-cast-card{
|
||
flex:0 0 72px;width:72px;
|
||
display:flex;flex-direction:column;align-items:center;
|
||
cursor:pointer;transition:transform .14s;
|
||
}
|
||
.det-cast-card:hover{transform:scale(1.07)}
|
||
|
||
/* 圆形头像 */
|
||
.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;
|
||
}
|
||
.det-cast-name{
|
||
font-size:12px;font-weight:700;
|
||
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:var(--muted);
|
||
padding:0 2px 6px;
|
||
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
|
||
text-align:center;width:80px;
|
||
}
|
||
|
||
|
||
|
||
/* similar / recs */
|
||
.det-recs-label{
|
||
font-family:var(--mono);font-size:11px;letter-spacing:.22em;
|
||
text-transform:uppercase;color:var(--muted);
|
||
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%;
|
||
}
|
||
#detRecs .det-recs-grid{
|
||
display:flex;
|
||
flex-wrap:nowrap;
|
||
overflow-x:auto;
|
||
overflow-y:hidden;
|
||
gap:6px;
|
||
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;
|
||
}
|
||
|
||
/* ── 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(232,201,122,.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:grid;gap:5px;padding:10px 12px;
|
||
background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.07);
|
||
border-radius:var(--r);cursor:pointer;transition:background .12s,border-color .12s;
|
||
}
|
||
.pan-item:hover,.pan-item:focus{background:rgba(255,255,255,.08);border-color:rgba(255,255,255,.18);outline:none}
|
||
.pan-item.bad{opacity:.5}
|
||
.pan-item-title{
|
||
display:flex;align-items:center;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}
|
||
.pan-health{
|
||
flex:0 0 auto;width:.5rem;height:.5rem;border-radius:50%;
|
||
background:#64748b;box-shadow:0 0 0 1px rgba(255,255,255,.15);
|
||
}
|
||
.pan-health.ok{background:#22c55e}
|
||
.pan-health.bad{background:#ef4444}
|
||
.pan-health.locked{background:#f59e0b}
|
||
.pan-health.pending{background:#60a5fa;animation:ph-pulse 1.1s ease-in-out infinite}
|
||
@keyframes ph-pulse{0%,100%{opacity:.45;transform:scale(.9)}50%{opacity:1;transform:scale(1.1)}}
|
||
.pan-item-meta{font-family:var(--mono);font-size:11px;color:var(--muted)}
|
||
.pan-item-url{font-family:var(--mono);font-size:11px;color:rgba(240,237,232,.35);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
.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;inset: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{width:8px;height:8px;border-radius:50%;background:var(--muted);flex-shrink:0}
|
||
.dot.ok{background:#3ddc84}
|
||
.dot.warn{background:#ffcc4d}
|
||
.dot.bad{background:#ff453a}
|
||
|
||
/* ── CONNECTION DOCK ── */
|
||
.conn-dock{position:relative;z-index:200;flex-shrink:0}
|
||
.conn-toggle{
|
||
display:flex;align-items:center;gap:6px;
|
||
height:var(--nav-h);padding:0 14px;
|
||
background:none;border:none;cursor:pointer;
|
||
color:var(--muted);font-family:var(--mono);font-size:13px;
|
||
letter-spacing:.1em;white-space:nowrap;
|
||
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: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 下生效,不影响手机/桌面
|
||
══════════════════════════════════════════ */
|
||
body.tv-mode *:focus{ outline:none }
|
||
|
||
/* 统一焦点环:金色描边 + 轻微放大 */
|
||
body.tv-mode .tv-focusable:focus,
|
||
body.tv-mode .tv-focusable.tv-focus{
|
||
outline: 2.5px solid var(--accent) !important;
|
||
outline-offset: 2px;
|
||
box-shadow: 0 0 0 4px rgba(232,201,122,.22) !important;
|
||
z-index: 10;
|
||
position: relative;
|
||
}
|
||
|
||
/* 卡片焦点:放大 + 金色边框 */
|
||
body.tv-mode .card.tv-focus{
|
||
transform: scale(1.10) !important;
|
||
outline: 2.5px solid var(--accent) !important;
|
||
box-shadow: 0 0 0 4px rgba(232,201,122,.25), 0 8px 32px rgba(0,0,0,.6) !important;
|
||
z-index: 20;
|
||
}
|
||
body.tv-mode .card.tv-focus .card-overlay{ opacity:1 }
|
||
|
||
/* 导航 tab 焦点 */
|
||
body.tv-mode .ntab.tv-focus{
|
||
color: var(--accent) !important;
|
||
background: rgba(232,201,122,.18) !important;
|
||
outline: 2px solid var(--accent) !important;
|
||
outline-offset: 1px;
|
||
}
|
||
|
||
/* 搜索结果条目焦点 */
|
||
body.tv-mode .sli.tv-focus{
|
||
background: rgba(255,255,255,.14) !important;
|
||
outline: 2px solid var(--accent) !important;
|
||
}
|
||
|
||
/* 联想列表焦点 */
|
||
body.tv-mode .srch-suggest-item.tv-focus,
|
||
body.tv-mode .srch-hot li.tv-focus{
|
||
background: rgba(255,255,255,.1) !important;
|
||
color: var(--accent);
|
||
}
|
||
|
||
/* Hero 焦点 */
|
||
body.tv-mode .hero-play-btn.tv-focus{
|
||
background: #f5de8a !important;
|
||
transform: translateY(-2px) scale(1.05) !important;
|
||
outline: 2px solid var(--accent) !important;
|
||
}
|
||
|
||
/* 详情页按钮焦点 */
|
||
body.tv-mode .btn-p.tv-focus,
|
||
body.tv-mode .btn-s.tv-focus{
|
||
outline: 2.5px solid var(--accent) !important;
|
||
outline-offset: 2px;
|
||
filter: brightness(1.2);
|
||
}
|
||
|
||
/* 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 性能优化(参考开发文档 22.6 / 22.7)
|
||
- 关闭高成本视觉效果,优先稳定帧率
|
||
- 卡片自包含,缩小布局/样式影响范围
|
||
- 焦点反馈不改尺寸/边框,避免布局抖动
|
||
══════════════════════════════════════════ */
|
||
|
||
/* 1. TV 模式关闭大面积 blur / backdrop-filter / 复杂阴影 / 图片 filter */
|
||
body.tv-mode #nav{ backdrop-filter:none; background:rgba(8,8,8,.98); }
|
||
body.tv-mode #srch{ backdrop-filter:none; background:rgba(8,8,8,.99); }
|
||
body.tv-mode .conn-body{ backdrop-filter:none; background:rgba(17,17,17,.99); }
|
||
body.tv-mode #quickpan{ backdrop-filter:none; }
|
||
body.tv-mode .card-rating{ backdrop-filter:none; }
|
||
body.tv-mode .det-hero img{ filter:none; }
|
||
body.tv-mode body::before{ display:none; } /* 关扫描线叠层 */
|
||
|
||
/* 2. 卡片 / 列表项自包含,减少重排范围 */
|
||
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{
|
||
contain: layout style;
|
||
}
|
||
|
||
/* 3. 焦点反馈:用 outline + 轻微 transform,不触发布局抖动
|
||
(覆盖上面用 box-shadow 的高成本写法,TV 下走更轻的样式)*/
|
||
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,
|
||
body.tv-mode .tv-focusable:focus{
|
||
box-shadow: none !important;
|
||
outline: 2.5px solid var(--accent) !important;
|
||
}
|
||
body.tv-mode .btn-p.tv-focus,
|
||
body.tv-mode .btn-s.tv-focus{
|
||
filter: none;
|
||
background: rgba(232,201,122,.2);
|
||
}
|
||
body.tv-mode .hero-play-btn.tv-focus{
|
||
box-shadow: none !important;
|
||
}
|
||
|
||
|
||
</style>
|
||
|
||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||
</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-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"></circle><line x1="21" x2="16.65" y1="21" y2="16.65"></line>
|
||
</svg>
|
||
</button>
|
||
<!-- 状态按钮 -->
|
||
<div class="conn-dock" id="connDock">
|
||
<button aria-expanded="false" class="conn-toggle" id="connToggle" onclick="toggleConn(event)">
|
||
<span class="dot warn" id="connDot"></span>
|
||
<span>状态</span>
|
||
<span class="conn-arrow">⌃</span>
|
||
</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 id="heroProgress"></div>
|
||
</div>
|
||
<!-- Sections -->
|
||
<section class="sec" id="s-new">
|
||
<div class="sechd"><span class="sectitle">最新上线</span><div class="secline"></div><span class="seccnt" id="cnt-new"></span></div>
|
||
<div class="grid" id="g-new"></div>
|
||
</section>
|
||
<section class="sec" id="s-movie">
|
||
<div class="sechd"><span class="sectitle">电影</span><div class="secline"></div><span class="seccnt" id="cnt-movie"></span></div>
|
||
<div class="grid" id="g-movie"></div>
|
||
</section>
|
||
<section class="sec" id="s-cn">
|
||
<div class="sechd"><span class="sectitle">国产剧</span><div class="secline"></div><span class="seccnt" id="cnt-cn"></span></div>
|
||
<div class="grid grid-land" id="g-cn"></div>
|
||
</section>
|
||
<section class="sec" id="s-hk">
|
||
<div class="sechd"><span class="sectitle">港台剧</span><div class="secline"></div><span class="seccnt" id="cnt-hk"></span></div>
|
||
<div class="grid grid-land" id="g-hk"></div>
|
||
</section>
|
||
<section class="sec" id="s-jpkr">
|
||
<div class="sechd"><span class="sectitle">日韩剧</span><div class="secline"></div><span class="seccnt" id="cnt-jpkr"></span></div>
|
||
<div class="grid grid-land" id="g-jpkr"></div>
|
||
</section>
|
||
<section class="sec" id="s-us">
|
||
<div class="sechd"><span class="sectitle">欧美剧</span><div class="secline"></div><span class="seccnt" id="cnt-us"></span></div>
|
||
<div class="grid grid-land" id="g-us"></div>
|
||
</section>
|
||
<section class="sec" id="s-doc">
|
||
<div class="sechd"><span class="sectitle">纪录片</span><div class="secline"></div><span class="seccnt" id="cnt-doc"></span></div>
|
||
<div class="grid" id="g-doc"></div>
|
||
</section>
|
||
<section class="sec" id="s-ani">
|
||
<div class="sechd"><span class="sectitle">动漫</span><div class="secline"></div><span class="seccnt" id="cnt-ani"></span></div>
|
||
<div class="grid" id="g-ani"></div>
|
||
</section>
|
||
<section class="sec" id="s-var">
|
||
<div class="sechd"><span class="sectitle">综艺</span><div class="secline"></div><span class="seccnt" id="cnt-var"></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="detInner"></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';
|
||
|
||
// ── 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 _homeToolbarRestored = false; // 主页是否已退出全屏(控制返回行为)
|
||
|
||
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.play:Native 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 ()=>{} },
|
||
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().req(Native 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,'''); }
|
||
|
||
// ── 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 = backdrop ? resUrl(backdrop, IMG_ORIG) : '';
|
||
|
||
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.onload = ()=>{
|
||
if(title) title.classList.add('has-logo');
|
||
img.classList.add('loaded');
|
||
};
|
||
img.onerror = ()=>{ img.remove(); }; // 加载失败保留文字标题
|
||
img.src = IMG_ORIG + 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;
|
||
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);
|
||
heroTimer = setInterval(()=>goHero(heroCurrent+1), HERO_INTERVAL);
|
||
resetHeroProgress();
|
||
}
|
||
|
||
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
|
||
const wrap = document.getElementById('heroWrap');
|
||
if(wrap){
|
||
wrap.addEventListener('mouseenter',()=>clearInterval(heroTimer));
|
||
wrap.addEventListener('mouseleave',()=>startHeroTimer());
|
||
}
|
||
}
|
||
|
||
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 = poster ? resUrl(poster, land ? IMG_W780 : IMG_W500) : '';
|
||
const bg = `hsl(${hue(item.id)},18%,13%)`;
|
||
|
||
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')">`:``}
|
||
<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 = {};
|
||
|
||
function renderGrid(gridId, cntId, items, land=false){
|
||
items.forEach(i=>ITEMS[i.id]=i);
|
||
const g = document.getElementById(gridId);
|
||
if(g) g.innerHTML = items.map(i=>cardHtml(i,land)).join('');
|
||
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(){
|
||
// Skeletons
|
||
document.getElementById('g-new').innerHTML = sks(24);
|
||
document.getElementById('g-movie').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,
|
||
] = 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),
|
||
}),
|
||
]);
|
||
|
||
// ── 合并各分类 ──
|
||
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);
|
||
|
||
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 ──
|
||
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-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 = ['星际穿越','流浪地球','肖申克的救赎','寄生虫','蜘蛛侠','复仇者联盟','黑暗骑士','千与千寻'];
|
||
|
||
function openSrch(){
|
||
const overlay = document.getElementById('srch');
|
||
const inp = document.getElementById('srchInp');
|
||
overlay.classList.add('on');
|
||
inp.value = '';
|
||
_lastSuggestKw = '';
|
||
clearTimeout(_suggestTimer);
|
||
showHome();
|
||
history.pushState({eclipse:'search'}, '');
|
||
setTimeout(()=>{ inp.focus(); inp.click(); }, 120);
|
||
}
|
||
function _doCloseSrch(){
|
||
document.getElementById('srch').classList.remove('on');
|
||
document.getElementById('srchInp').blur();
|
||
}
|
||
function closeSrch(){
|
||
_doCloseSrch();
|
||
}
|
||
|
||
// ── SEARCH HISTORY ──
|
||
const HIST_KEY = 'eclipse_srch_hist';
|
||
const HIST_MAX = 20;
|
||
|
||
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);
|
||
}
|
||
|
||
// 从候选标题列表中找出拼音首字母匹配的项
|
||
// 返回 [{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());
|
||
titles.push({label:t, exact:false, src:'tmdb'});
|
||
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.label)}"
|
||
onclick="tapSuggest('${esc(t.label).replace(/'/g,'\\\'')}')"
|
||
onkeydown="if(event.key==='Enter')tapSuggest('${esc(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;
|
||
}
|
||
|
||
// 关闭搜索覆盖层,push 一条 search-result 条目
|
||
// 栈:[sentinel, search, search-result]
|
||
// popstate 逐层弹出关闭,最终回主页
|
||
_doCloseSrch();
|
||
history.pushState({eclipse:'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张不轮播
|
||
_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
|
||
history.pushState({eclipse:'detail'}, '');
|
||
const isTV = !!item.name;
|
||
const title = item.title||item.name||'';
|
||
const poster = item.poster_path;
|
||
const backdrop = item.backdrop_path;
|
||
const bg = `hsl(${hue(item.id)},18%,13%)`;
|
||
const backdropUrl = backdrop ? resUrl(backdrop, IMG_ORIG) : '';
|
||
|
||
const panel = document.getElementById('det');
|
||
const inner = document.getElementById('detInner');
|
||
panel.scrollTop = 0;
|
||
|
||
// 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="${resUrl(backdrop, IMG_ORIG)}" 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-logo-wrap" id="detLogoWrap">
|
||
<div class="det-title det-title-fallback" id="detTitleFallback">${esc(title)}</div>
|
||
</div>
|
||
<div class="det-orig-title" id="detOrigTitle"></div>
|
||
<div class="det-score-row">
|
||
<span class="det-score sk" style="width:52px;height:22px;border-radius:3px"> </span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="det-pills" id="detPills">
|
||
<span class="det-pill sk" style="width:50px;height:22px"> </span>
|
||
<span class="det-pill sk" style="width:70px;height:22px"> </span>
|
||
</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" onclick="detPlay()">
|
||
<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-overview sk" id="detOverview" style="height:80px;border-radius:4px"></div>
|
||
<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="detRecs"></div>
|
||
</div>`;
|
||
|
||
panel.classList.add('on');
|
||
try{ sdk().ui.setToolbar(false); }catch(e){}
|
||
_homeToolbarRestored = false; // 重新进入全屏,复位标志
|
||
|
||
// 继续观看:拉历史匹配(并行,不阻塞详情加载)
|
||
_checkDetContinue(item);
|
||
|
||
// 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).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;
|
||
|
||
// Score
|
||
const scoreRow = inner.querySelector('.det-score-row');
|
||
if(scoreRow){
|
||
scoreRow.innerHTML = rating
|
||
? `<span class="det-score">★ ${rating}</span><span class="det-score-max">/ 10</span><span class="det-votes">${voteCount} votes</span>`
|
||
: '';
|
||
}
|
||
|
||
// Pills
|
||
const pillsEl = document.getElementById('detPills');
|
||
if(pillsEl){
|
||
pillsEl.innerHTML = [
|
||
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');
|
||
if(ovEl){ ovEl.className='det-overview'; ovEl.style=''; ovEl.textContent=overview; }
|
||
|
||
|
||
|
||
// 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) castEl.innerHTML = `
|
||
<div class="det-sec-label">主演</div>
|
||
<div class="det-cast-rail">${castList.map(c=>`
|
||
<div class="det-cast-card">
|
||
<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>
|
||
</div>`).join('')}</div>`;
|
||
}
|
||
|
||
// 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);
|
||
}
|
||
}
|
||
|
||
// ── RECS GRID:卡片宽度 = 首页同视口下的单卡像素宽 ──
|
||
function updateRecsGrid(){
|
||
const g = document.getElementById('detRecsGrid');
|
||
if(!g) return;
|
||
const vw = window.innerWidth;
|
||
// 首页断点列数和gap
|
||
let cols, gap;
|
||
if(vw >= 1600){ cols=11; gap=14; }
|
||
else if(vw >= 1200){ cols=9; gap=12; }
|
||
else if(vw >= 900) { cols=7; gap=10; }
|
||
else if(vw >= 640) { cols=5; gap=9; }
|
||
else if(vw >= 480) { cols=4; gap=8; }
|
||
else { cols=3; gap=6; }
|
||
// 首页单张卡片像素宽
|
||
const cardW = Math.floor((vw - 40 - gap*(cols-1)) / cols);
|
||
g.style.setProperty('--card-w', cardW + 'px');
|
||
g.style.gap = gap + 'px';
|
||
}
|
||
window.addEventListener('resize', updateRecsGrid);
|
||
|
||
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 closeDet(){
|
||
_doCloseDet();
|
||
}
|
||
|
||
// ── HISTORY 哨兵 ──
|
||
// 栈结构示例(从底到顶):[sentinel, detail, pan]
|
||
// 业务层各自 pushState 自己的条目;popstate 弹出后只关闭对应 UI,
|
||
// 不再操作 history,让栈自然变浅。
|
||
// 唯一例外:弹到最底层哨兵时,再补一条哨兵,防止 back 穿透退出 App。
|
||
function _ensureSentinel(){
|
||
// 仅当栈顶不是任何业务层(即已回到主页)时才补哨兵
|
||
history.pushState({eclipse:'sentinel'}, '');
|
||
}
|
||
|
||
// 页面初始化:底层哨兵
|
||
history.replaceState({eclipse:'sentinel'}, '');
|
||
|
||
// popstate:Android 返回手势 / 浏览器回退 拦截
|
||
window.addEventListener('popstate', (e)=>{
|
||
const st = (e.state && e.state.eclipse) || '';
|
||
const panOpen = document.getElementById('panBlock').classList.contains('on');
|
||
const detOpen = document.getElementById('det').classList.contains('on');
|
||
const srchOpen = document.getElementById('srch').classList.contains('on');
|
||
const srchResultEl = document.getElementById('s-search');
|
||
const srchResultVisible = srchResultEl && srchResultEl.style.display !== 'none';
|
||
|
||
// 逐层关闭:只关 UI,不操作 history(栈已被 popstate 自然弹出)
|
||
if(panOpen){ _doClosePanSearch(); return; }
|
||
if(detOpen){ _doCloseDet(); return; }
|
||
if(srchOpen){ _doCloseSrch(); return; }
|
||
if(srchResultVisible){ clearSrchResult(); return; }
|
||
|
||
// 没有任何层打开 = 回到了主页(弹出了底部哨兵)
|
||
// 第一次在主页按返回:退出全屏(恢复工具栏),并补哨兵继续拦截,不退出 App
|
||
// 工具栏已恢复后再按返回:放行给 App(退出)
|
||
if(!_homeToolbarRestored){
|
||
_homeToolbarRestored = true;
|
||
try{ sdk().ui.setToolbar(true); }catch(e){}
|
||
_ensureSentinel(); // 补哨兵,拦下这次返回
|
||
return;
|
||
}
|
||
// 工具栏已恢复,正常放行(不补哨兵 = 让 App 处理返回退出)
|
||
});
|
||
|
||
function detPlay(){
|
||
if(!detData) return;
|
||
const title = detData.title || detData.name || '';
|
||
if(!title){ toast('没有影片标题'); return; }
|
||
if(!window.fm){ toast('播放功能需在 App 内使用'); return; }
|
||
try{
|
||
// 与 demo 一致:发起原生聚合搜索并直接播放(direct:true)
|
||
// App 会在各站源搜索该片名并自动进入播放,而非用 TMDB id 点播某个固定站点
|
||
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');
|
||
if(e.key==='Escape'||e.key==='Back'){
|
||
if(srchOpen){ closeSrch(); return; }
|
||
if(detOpen){ closeDet(); 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.className = '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);
|
||
});
|
||
|
||
// ── 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; }
|
||
|
||
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();
|
||
}
|
||
|
||
|
||
|
||
|
||
// ── 长按封面:直接调用播放器搜索播放 ──
|
||
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');
|
||
history.pushState({eclipse:'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 ASC(ok=0 最优)
|
||
// 3. updatedAt DESC(越新越靠前)
|
||
// 前20条只展示高相关(_score > 0),其余补充在后面
|
||
const filtered = panState.results.filter(r => !active || panNormType(r.diskType) === active);
|
||
|
||
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;
|
||
};
|
||
|
||
const sortFn = (a, b) => {
|
||
// score DESC
|
||
const sd = (b._score || 0) - (a._score || 0);
|
||
if(sd !== 0) return sd;
|
||
// health ASC(ok 优先)
|
||
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)}" title="${esc(panHealthLabel(dotState))}"></span>`
|
||
: '';
|
||
const isBad = h.state === 'bad';
|
||
const isFocus = panState.focusKey === hk;
|
||
const scoreTag = (item._score > 0)
|
||
? `<span style="font-size:10px;color:var(--accent);opacity:.6;margin-left:4px">${item._score}分</span>`
|
||
: '';
|
||
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-title"><span>${esc(item.title||panState.keyword||'盘搜资源')}</span>${scoreTag}${healthDot}</div>
|
||
<div class="pan-item-meta">${esc(panDiskName(item.diskType))}${item.password?' · 提取码 '+esc(item.password):''}${item.source?' · '+esc(item.source):''}</div>
|
||
<div class="pan-item-url">${esc(item.normalizedUrl||item.url)}</div>`;
|
||
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'){
|
||
history.pushState({eclipse:'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(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/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. 完全不修改现有业务逻辑
|
||
// ══════════════════════════════════════════════════════════
|
||
const TV_MODE = IS_TV || /[?&]tv=1/.test(location.search);
|
||
if(TV_MODE) document.body.classList.add('tv-mode');
|
||
|
||
// ── 焦点区域定义(按层级优先级) ──
|
||
// 每个"层"是一组可以被方向键导航的元素集合
|
||
// 当某层打开时,只在该层内导航
|
||
|
||
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 panOpen = document.getElementById('panBlock').classList.contains('on');
|
||
const qpOpen = document.getElementById('quickpan') && document.getElementById('quickpan').classList.contains('on');
|
||
const detOpen = document.getElementById('det').classList.contains('on');
|
||
const srchOpen = document.getElementById('srch').classList.contains('on');
|
||
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(srchResultVisible){
|
||
return [...document.querySelectorAll('#srch-list .sli')].filter(_tvEl);
|
||
}
|
||
const tabs = [...document.querySelectorAll('.ntab')].filter(_tvEl);
|
||
const activeTab = document.querySelector('.ntab.on');
|
||
let cards = [];
|
||
if(activeTab){
|
||
const secId = activeTab.dataset.sec;
|
||
const sec = document.getElementById(secId);
|
||
if(sec) cards = [...sec.querySelectorAll('.card')].filter(_tvEl);
|
||
}
|
||
return [...tabs, ...cards];
|
||
}
|
||
|
||
// ── 快速路径:主页 grid 卡片用索引导航 ──
|
||
// 返回 null 表示当前不在 grid 内,需走几何兜底
|
||
function _tvGridFastNav(cur, dir){
|
||
if(!cur || !cur.classList.contains('card')) return null;
|
||
// 当前卡片所在的 grid(同一 section 的 .grid)
|
||
const grid = cur.closest('.grid');
|
||
if(!grid) return null;
|
||
const cards = [...grid.querySelectorAll('.card')].filter(_tvEl);
|
||
const idx = cards.indexOf(cur);
|
||
if(idx < 0) return null;
|
||
const isLand = grid.classList.contains('grid-land');
|
||
const cols = _tvCols(isLand);
|
||
|
||
if(dir==='left'){
|
||
// 行首向左 → 不动(保持,或交给几何兜底跳 tab,这里返回 self 阻止乱跳)
|
||
if(idx % cols === 0) return cur;
|
||
return cards[idx-1] || cur;
|
||
}
|
||
if(dir==='right'){
|
||
if(idx % cols === cols-1) return cur;
|
||
return cards[idx+1] || cur;
|
||
}
|
||
if(dir==='down'){
|
||
const nxt = cards[idx+cols];
|
||
if(nxt) return nxt;
|
||
return cur; // 已是最后一行
|
||
}
|
||
if(dir==='up'){
|
||
if(idx < cols){
|
||
// 首行向上 → 回到导航 tab(取当前激活的)
|
||
const tab = document.querySelector('.ntab.on') || document.querySelector('.ntab');
|
||
return tab || cur;
|
||
}
|
||
return cards[idx-cols] || 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);
|
||
}
|
||
|
||
// 初始化:页面加载完成后给首个元素聚焦
|
||
function _tvInit(){
|
||
if(!TV_MODE) return;
|
||
setTimeout(()=>{
|
||
const items = _tvGetItems();
|
||
if(items.length) _tvSetFocus(items[0], false);
|
||
}, 800);
|
||
}
|
||
|
||
// 搜索框是否处于文字输入状态
|
||
function _tvInSearchInput(){
|
||
const inp = document.getElementById('srchInp');
|
||
return document.activeElement === inp || (inp && inp === document.querySelector(':focus'));
|
||
}
|
||
|
||
// 主键盘拦截(TV 模式专用)
|
||
function _tvKeydown(e){
|
||
if(!TV_MODE) return;
|
||
|
||
// 老 WebView / 部分 TV 固件只给 keyCode,不给标准 e.key,做归一化兜底
|
||
var key = e.key;
|
||
if(!key || key === 'Unidentified'){
|
||
var kc = e.keyCode || e.which;
|
||
var KC = {37:'ArrowLeft',38:'ArrowUp',39:'ArrowRight',40:'ArrowDown',
|
||
13:'Enter',23:'Enter',66:'Enter', // 23=DPAD_CENTER, 66=ENTER
|
||
27:'Escape',8:'Escape',4:'Escape',461:'Escape'}; // 4=BACK, 461=LG返回
|
||
if(KC[kc]) key = KC[kc];
|
||
}
|
||
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' || key === 'Accept'){
|
||
e.preventDefault();
|
||
const el = _tvFocus;
|
||
if(!el) return;
|
||
// 搜索框 OK → 打开搜索
|
||
const srchResultVisible = document.getElementById('s-search').style.display !== 'none';
|
||
if(!srchOpen && !document.getElementById('det').classList.contains('on') && !srchResultVisible){
|
||
// 检测是否在导航 tab
|
||
if(el.classList.contains('ntab')){
|
||
el.click(); return;
|
||
}
|
||
}
|
||
el.click();
|
||
return;
|
||
}
|
||
|
||
// 返回键
|
||
if(key === 'Escape' || key === 'GoBack' || key === 'Back' || key === 'BrowserBack'){
|
||
// 由现有 keyboard handler 处理,此处只重置焦点
|
||
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('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});
|
||
|
||
// 注册 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{ sdk().ui.setToolbar(false); }catch(e){}
|
||
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();
|
||
_tvInit();
|
||
setTimeout(()=>restorePanPlaybackReturn().catch(()=>{}), 300);
|
||
}
|
||
boot();
|
||
</script>
|
||
</body>
|
||
</html> |