Sync all projects
This commit is contained in:
@@ -0,0 +1,4038 @@
|
||||
<!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>
|
||||
<style>
|
||||
|
||||
|
||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||
|
||||
:root{
|
||||
--bg:#080808;
|
||||
--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;
|
||||
}
|
||||
|
||||
html{background:var(--bg);color:var(--text);font-family:var(--serif);-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}
|
||||
body{min-height:100dvh;overflow-x:hidden}
|
||||
|
||||
/* scanline */
|
||||
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)}
|
||||
|
||||
/* ── 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;
|
||||
}
|
||||
.logo{
|
||||
padding:0 16px 0 18px;white-space:nowrap;flex-shrink:0;
|
||||
display:flex;align-items:center;cursor:default;
|
||||
position:relative;
|
||||
}
|
||||
.logo-text{
|
||||
font-family:-apple-system,BlinkMacSystemFont,'SF Pro Display','Helvetica Neue',Arial,sans-serif;
|
||||
font-size:23px;font-weight:700;letter-spacing:.18em;text-transform:uppercase;
|
||||
background:linear-gradient(135deg,#c8a45a 0%,#f0de8a 35%,#fff8e1 50%,#e8c97a 65%,#b8882a 100%);
|
||||
-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;
|
||||
filter:drop-shadow(0 0 8px rgba(232,201,122,.4));
|
||||
transition:filter .25s,letter-spacing .25s;
|
||||
position:relative;
|
||||
}
|
||||
.logo:hover .logo-text{
|
||||
filter:drop-shadow(0 0 14px rgba(232,201,122,.75)) drop-shadow(0 0 28px rgba(232,201,122,.3));
|
||||
letter-spacing:.22em;
|
||||
}
|
||||
.logo-sub{
|
||||
font-family:-apple-system,BlinkMacSystemFont,'SF Pro Text','Helvetica Neue',Arial,sans-serif;
|
||||
font-size:10px;font-weight:500;letter-spacing:.35em;text-transform:uppercase;
|
||||
color:rgba(232,201,122,.5);position:absolute;bottom:-2px;left:18px;right:0;
|
||||
text-align:left;white-space:nowrap;
|
||||
}
|
||||
.logo em{color:var(--accent2);font-style:normal}
|
||||
.ndiv{width:1px;height:18px;background:rgba(255,255,255,.08);flex-shrink:0}
|
||||
.ntabs{
|
||||
display:flex;align-items:center;flex:1;
|
||||
overflow-x:auto;overflow-y:hidden;
|
||||
padding:0 4px;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(--muted);
|
||||
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:.5;
|
||||
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(--muted);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:20px;height:20px}
|
||||
|
||||
/* ── 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:56vw;max-height:580px;
|
||||
border-bottom:1px solid rgba(255,255,255,.06);
|
||||
}
|
||||
/* 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,.95) 0%,rgba(8,8,8,.65) 38%,rgba(8,8,8,.12) 66%,transparent 100%),
|
||||
linear-gradient(0deg,rgba(8,8,8,.98) 0%,rgba(8,8,8,.38) 34%,transparent 62%);
|
||||
}
|
||||
.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:660px}.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;
|
||||
aspect-ratio:2/3;background:var(--surface2);
|
||||
}
|
||||
.card-img.land{aspect-ratio:16/9}
|
||||
.card-img img{
|
||||
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{aspect-ratio:2/3}
|
||||
.sk-card.land{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 ── */
|
||||
/* 背景图容器:绝对定位在detInner顶部,高度固定为视口高,内容滚过时自然消失 */
|
||||
.det-hero{
|
||||
position:absolute;top:0;left:0;right:0;
|
||||
height:100vh;height:100dvh;
|
||||
z-index:0;
|
||||
pointer-events:none;overflow:hidden;
|
||||
}
|
||||
.det-hero img{
|
||||
width:100%;height:100%;object-fit:cover;object-position:center top;
|
||||
filter:saturate(1.05) brightness(1.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%);
|
||||
}
|
||||
/* 宽屏:左侧深色文字区,右侧图片大量透出 */
|
||||
@media(min-width:700px){
|
||||
.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:100dvh;
|
||||
width:100%;
|
||||
padding:calc(var(--safe-t) + 20px) 18px 48px;
|
||||
box-sizing:border-box;
|
||||
}
|
||||
|
||||
/* 宽屏横屏:左右留边,内容居左但利用足够宽度 */
|
||||
@media(min-width:700px){
|
||||
.det-inner{
|
||||
padding:calc(var(--safe-t) + 36px) clamp(34px,5vw,74px) 64px clamp(34px,5vw,74px);
|
||||
max-width:100%;
|
||||
}
|
||||
/* 横屏双列:左侧信息 + 右侧留给背景图 */
|
||||
.det-inner{
|
||||
display:grid;
|
||||
grid-template-columns:minmax(0,560px) 1fr;
|
||||
column-gap:0;
|
||||
align-items:start;
|
||||
padding-left:clamp(34px,5vw,74px);
|
||||
padding-right:0;
|
||||
}
|
||||
.det-inner > *{
|
||||
grid-column:1;
|
||||
min-width:0; /* 防止grid子项溢出,保证overflow-x:auto生效 */
|
||||
}
|
||||
}
|
||||
@media(min-width:1100px){
|
||||
.det-inner{
|
||||
grid-template-columns:minmax(0,640px) 1fr;
|
||||
padding-left:clamp(50px,6vw,98px);
|
||||
}
|
||||
}
|
||||
@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)}
|
||||
}
|
||||
/* 宽屏:不需要顶部 margin,从顶部 padding 直接开始 */
|
||||
@media(min-width:700px){
|
||||
.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}
|
||||
.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>
|
||||
</head>
|
||||
<body> <!-- Loader -->
|
||||
<div id="tv-hint">
|
||||
↑↓←→ 导航 OK 确认 ← 返回
|
||||
</div> <!-- Nav -->
|
||||
<nav id="nav">
|
||||
<div class="logo">
|
||||
<span class="logo-text">Eclipse</span><span class="logo-sub">Movies & TV</span>
|
||||
</div>
|
||||
<div class="ndiv"></div>
|
||||
<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" /><line x1="21" x2="16.65" y1="21" y2="16.65" />
|
||||
</svg> </button> <!-- 状态按钮 -->
|
||||
<div class="conn-dock" id="connDock"> <button aria-expanded="false" class="conn-toggle" id="connToggle" onclick="toggleConn(event)"> <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;
|
||||
let siteKey = '';
|
||||
let heroData = null;
|
||||
let srchTimer = null;
|
||||
let detData = null;
|
||||
|
||||
let _nativeSdkWait = null;
|
||||
function waitForNativeSdk(timeout){
|
||||
if(window.fm || !window.fongmiBridge) return Promise.resolve(!!window.fm);
|
||||
if(_nativeSdkWait) return _nativeSdkWait;
|
||||
_nativeSdkWait = new Promise(resolve=>{
|
||||
let done = false;
|
||||
const finish = ()=>{
|
||||
if(done) return; done = true;
|
||||
window.removeEventListener('fmsdk', finish);
|
||||
_nativeSdkWait = null;
|
||||
resolve(!!window.fm);
|
||||
};
|
||||
window.addEventListener('fmsdk', finish);
|
||||
setTimeout(finish, timeout == null ? 1500 : timeout);
|
||||
});
|
||||
return _nativeSdkWait;
|
||||
}
|
||||
|
||||
function addPanPassword(url, password, opts){
|
||||
// 简单拼接提取码(浏览器 fallback 用)
|
||||
if(!url) return url;
|
||||
if(password && !/[\?&]pwd=/.test(url)){
|
||||
const sep = url.includes('?') ? '&' : '?';
|
||||
return url + sep + 'pwd=' + encodeURIComponent(password);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
function sdk(){
|
||||
if(window.fm){
|
||||
const pan = window.fm.pan || {};
|
||||
return Object.assign({}, window.fm, { pan, check: pan.check || window.fm.check });
|
||||
}
|
||||
// 浏览器 / SDK 未注入时的完整 fallback —— 永远可用
|
||||
const check = async (items)=>({ results: (items||[]).map(item=>({
|
||||
type: item.type, url: item.url, normalized_url: item.url,
|
||||
state:'idle', summary:'浏览器预览不检测', cache_hit:false,
|
||||
checked_at: Date.now(), expires_at: Date.now()+300000
|
||||
}))});
|
||||
// fallback pan.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)=>toast('原生搜索:'+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;
|
||||
slides[heroCurrent]?.classList.remove('active');
|
||||
dots[heroCurrent]?.classList.remove('active');
|
||||
heroCurrent = (idx + heroItems.length) % heroItems.length;
|
||||
slides[heroCurrent]?.classList.add('active');
|
||||
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');
|
||||
document.getElementById(id)?.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'));
|
||||
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();
|
||||
}
|
||||
|
||||
// ── 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 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" onclick="detPlay()">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M5 3l14 9-14 9V3z"/></svg>
|
||||
搜索播放
|
||||
</button>
|
||||
<button class="btn-s" onclick="detKeep()">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>
|
||||
收藏
|
||||
</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){}
|
||||
|
||||
// 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
|
||||
}
|
||||
}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 country = (det.origin_country||[])[0] || (det.production_countries||[])[0]?.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?.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?.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');
|
||||
setTimeout(()=>{ panel.scrollTop=0; document.getElementById('detInner').innerHTML=''; }, 300);
|
||||
// 不清 playbackReturn,保留给重新打开盘搜时复原用
|
||||
resetPanState();
|
||||
try{ sdk().ui.setToolbar(true); }catch(e){}
|
||||
}
|
||||
|
||||
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
|
||||
_ensureSentinel();
|
||||
});
|
||||
|
||||
function detPlay(){
|
||||
if(!detData) return;
|
||||
const isTV = !!detData.name;
|
||||
const title = detData.title||detData.name||'';
|
||||
const pic = detData.poster_path ? IMG_W500+detData.poster_path : '';
|
||||
if(siteKey){
|
||||
sdk().vod(siteKey, String(detData.id), title, pic).catch(()=>{});
|
||||
} else if(!window.fm) {
|
||||
toast('播放功能需在 App 内使用');
|
||||
} else {
|
||||
toast('请先配置站点资源');
|
||||
}
|
||||
}
|
||||
|
||||
function detKeep(){
|
||||
try{ sdk().openKeep(); }catch(e){}
|
||||
toast('已加入收藏');
|
||||
}
|
||||
|
||||
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 isTV = !!item.name;
|
||||
const title = item.title || item.name || '';
|
||||
const pic = item.poster_path ? IMG_W500 + item.poster_path : '';
|
||||
if(siteKey){
|
||||
sdk().vod(siteKey, String(item.id), title, pic).catch(()=>{});
|
||||
} else if(!window.fm){
|
||||
toast('播放功能需在 App 内使用');
|
||||
} else {
|
||||
toast('请先配置站点资源');
|
||||
}
|
||||
}
|
||||
|
||||
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)];
|
||||
return PAN_HEALTH_PRI[(h && h.state) || 'idle'] ?? 99;
|
||||
};
|
||||
|
||||
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 的双重转义问题
|
||||
list.replaceChildren();
|
||||
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;
|
||||
|
||||
const key = e.key;
|
||||
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>
|
||||
@@ -0,0 +1,3876 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no,viewport-fit=cover">
|
||||
<title>玩偶聚合 WebHome | Tab S9 功能增强完整版</title>
|
||||
<style>
|
||||
/* ========== One UI 主题变量 双主题切换 ========== */
|
||||
:root{
|
||||
--text:#ffffff;
|
||||
--muted:rgba(255,255,255,.65);
|
||||
--line:rgba(255,255,255,.12);
|
||||
--soft:rgba(30,38,48,.35);
|
||||
--soft-blur:rgba(22,28,36,.6);
|
||||
--accent:#ff6b35;
|
||||
--accent2:#ffb86b;
|
||||
--accent-grad:linear-gradient(135deg,#ff6b35,#ffb86b);
|
||||
--accent-rgb:255,107,53;
|
||||
--accent2-rgb:255,184,107;
|
||||
--accent-l1:#ff8050;
|
||||
--accent-d1:#e8501a;
|
||||
--accent-d2:#e85520;
|
||||
--green:#42e88a;
|
||||
--green-glow:0 0 14px rgba(66,232,138,.35);
|
||||
--green-rgb:66,232,138;
|
||||
--green-l1-rgb:120,255,170;
|
||||
--green-d1-rgb:30,180,90;
|
||||
--radius-xl:20px;
|
||||
--radius-lg:16px;
|
||||
--radius-md:12px;
|
||||
--radius-full:999px;
|
||||
--shadow-deep:0 10px 30px rgba(0,0,0,.45);
|
||||
--shadow-soft:0 4px 16px rgba(0,0,0,.25);
|
||||
--transition-fast:all 0.18s cubic-bezier(0.2,0,0.2,1);
|
||||
--transition-slow:all 0.3s cubic-bezier(0.2,0,0.2,1);
|
||||
--bg:#0d1117;
|
||||
--bar-h:56px;
|
||||
--gap:10px; /* 卡片间距 = 屏幕边距,统一留白 */
|
||||
|
||||
/* ===== 全屏安全区:优先读 Native 注入的 --fm-* 变量,兜底用 env(),两者取大不叠加 ===== */
|
||||
--safe-top: max(var(--fm-safe-top, 0px), env(safe-area-inset-top, 0px));
|
||||
--safe-bottom: max(var(--fm-safe-bottom, 0px), env(safe-area-inset-bottom, 0px));
|
||||
--safe-left: max(var(--fm-safe-left, 0px), env(safe-area-inset-left, 0px));
|
||||
--safe-right: max(var(--fm-safe-right, 0px), env(safe-area-inset-right, 0px));
|
||||
}
|
||||
/* 霓虹亮色主题 */
|
||||
body.neon{
|
||||
--bg:#080c1a;
|
||||
--soft-blur:rgba(10,16,40,.7);
|
||||
--accent:#35a8ff;
|
||||
--accent2:#6bd9ff;
|
||||
--accent-grad:linear-gradient(135deg,#35a8ff,#6bd9ff);
|
||||
--accent-rgb:53,168,255;
|
||||
--accent2-rgb:107,217,255;
|
||||
--accent-l1:#5cc0ff;
|
||||
--accent-d1:#1a78d8;
|
||||
--accent-d2:#1c82e0;
|
||||
--green:#8a42e8;
|
||||
--green-glow:0 0 14px rgba(138,66,232,.35);
|
||||
--green-rgb:138,66,232;
|
||||
--green-l1-rgb:180,120,255;
|
||||
--green-d1-rgb:90,30,180;
|
||||
}
|
||||
/* 琥珀金主题 */
|
||||
body.theme-amber{
|
||||
--bg:#0d0c0a;
|
||||
--soft-blur:rgba(20,17,12,.7);
|
||||
--accent:#d4a048;
|
||||
--accent2:#f0c878;
|
||||
--accent-grad:linear-gradient(135deg,#d4a048,#f0c878);
|
||||
--accent-rgb:212,160,72;
|
||||
--accent2-rgb:240,200,120;
|
||||
--accent-l1:#e0b060;
|
||||
--accent-d1:#b07e2e;
|
||||
--accent-d2:#b8862e;
|
||||
--green:#42e88a;
|
||||
--green-glow:0 0 14px rgba(66,232,138,.35);
|
||||
--green-rgb:66,232,138;
|
||||
--green-l1-rgb:120,255,170;
|
||||
--green-d1-rgb:30,180,90;
|
||||
}
|
||||
/* 玫瑰金主题 */
|
||||
body.theme-rose{
|
||||
--bg:#0d0a0b;
|
||||
--soft-blur:rgba(20,14,14,.7);
|
||||
--accent:#c16c63;
|
||||
--accent2:#e0a08f;
|
||||
--accent-grad:linear-gradient(135deg,#c16c63,#e0a08f);
|
||||
--accent-rgb:193,108,99;
|
||||
--accent2-rgb:224,160,143;
|
||||
--accent-l1:#cc8278;
|
||||
--accent-d1:#a3534a;
|
||||
--accent-d2:#a85a4e;
|
||||
--green:#42e88a;
|
||||
--green-glow:0 0 14px rgba(66,232,138,.35);
|
||||
--green-rgb:66,232,138;
|
||||
--green-l1-rgb:120,255,170;
|
||||
--green-d1-rgb:30,180,90;
|
||||
}
|
||||
/* 午夜蓝紫主题 */
|
||||
body.theme-indigo{
|
||||
--bg:#0a0a14;
|
||||
--soft-blur:rgba(14,14,28,.7);
|
||||
--accent:#4a5a96;
|
||||
--accent2:#8a6ad0;
|
||||
--accent-grad:linear-gradient(135deg,#4a5a96,#8a6ad0);
|
||||
--accent-rgb:74,90,150;
|
||||
--accent2-rgb:138,106,208;
|
||||
--accent-l1:#5f70ad;
|
||||
--accent-d1:#37457a;
|
||||
--accent-d2:#3b4a80;
|
||||
--green:#42e88a;
|
||||
--green-glow:0 0 14px rgba(66,232,138,.35);
|
||||
--green-rgb:66,232,138;
|
||||
--green-l1-rgb:120,255,170;
|
||||
--green-d1-rgb:30,180,90;
|
||||
}
|
||||
/* 雾蓝青主题 */
|
||||
body.theme-teal{
|
||||
--bg:#081012;
|
||||
--soft-blur:rgba(10,22,24,.7);
|
||||
--accent:#3c8c96;
|
||||
--accent2:#7cc4cc;
|
||||
--accent-grad:linear-gradient(135deg,#3c8c96,#7cc4cc);
|
||||
--accent-rgb:60,140,150;
|
||||
--accent2-rgb:124,196,204;
|
||||
--accent-l1:#4fa3ad;
|
||||
--accent-d1:#2c6e76;
|
||||
--accent-d2:#2f757e;
|
||||
--green:#d4a048;
|
||||
--green-glow:0 0 14px rgba(212,160,72,.3);
|
||||
--green-rgb:212,160,72;
|
||||
--green-l1-rgb:240,200,120;
|
||||
--green-d1-rgb:160,110,30;
|
||||
}
|
||||
/* 香槛灰金主题 */
|
||||
body.theme-sand{
|
||||
--bg:#0c0b09;
|
||||
--soft-blur:rgba(20,18,14,.7);
|
||||
--accent:#96856a;
|
||||
--accent2:#c2b294;
|
||||
--accent-grad:linear-gradient(135deg,#96856a,#c2b294);
|
||||
--accent-rgb:150,133,106;
|
||||
--accent2-rgb:194,178,148;
|
||||
--accent-l1:#a89878;
|
||||
--accent-d1:#7d6e56;
|
||||
--accent-d2:#82735a;
|
||||
--green:#42e88a;
|
||||
--green-glow:0 0 14px rgba(66,232,138,.35);
|
||||
--green-rgb:66,232,138;
|
||||
--green-l1-rgb:120,255,170;
|
||||
--green-d1-rgb:30,180,90;
|
||||
}
|
||||
/* 墨绿主题 */
|
||||
body.theme-jade{
|
||||
--bg:#080d0a;
|
||||
--soft-blur:rgba(10,20,16,.7);
|
||||
--accent:#3c6e5a;
|
||||
--accent2:#7cbca0;
|
||||
--accent-grad:linear-gradient(135deg,#3c6e5a,#7cbca0);
|
||||
--accent-rgb:60,110,90;
|
||||
--accent2-rgb:124,188,160;
|
||||
--accent-l1:#4f8a72;
|
||||
--accent-d1:#2c5444;
|
||||
--accent-d2:#2f5947;
|
||||
--green:#d4a048;
|
||||
--green-glow:0 0 14px rgba(212,160,72,.3);
|
||||
--green-rgb:212,160,72;
|
||||
--green-l1-rgb:240,200,120;
|
||||
--green-d1-rgb:160,110,30;
|
||||
}
|
||||
|
||||
*{box-sizing:border-box;-webkit-font-smoothing:antialiased;-webkit-tap-highlight-color:transparent}
|
||||
html,body{
|
||||
margin:0;min-height:100%;
|
||||
background:transparent; /* App 内透明,露出 App 背景 + 雾层 */
|
||||
color:var(--text);
|
||||
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Samsung Sans",Arial,sans-serif;
|
||||
scroll-behavior:smooth;
|
||||
/* 防止 iOS 双击缩放 */
|
||||
touch-action:manipulation;
|
||||
}
|
||||
/* 非 App(纯浏览器)用纯色背景兜底,避免透明到空白 */
|
||||
html:not(.fm-native),html:not(.fm-native) body{background:var(--bg)}
|
||||
/* 全局高斯模糊雾层:无论 App 背景是什么,都蒙一层磨砂雾 */
|
||||
#bgfog{
|
||||
position:fixed;inset:0;z-index:-1;pointer-events:none;
|
||||
background:rgba(13,17,23,.48);
|
||||
backdrop-filter:blur(46px) saturate(1.35);
|
||||
-webkit-backdrop-filter:blur(46px) saturate(1.35);
|
||||
}
|
||||
html:not(.fm-native) #bgfog{display:none}
|
||||
/* iOS 26 玻璃态:全局环境光渲染层 */
|
||||
html:not(.fm-native) body::before{
|
||||
content:'';
|
||||
position:fixed;inset:0;z-index:-2;pointer-events:none;
|
||||
background:
|
||||
radial-gradient(ellipse 80% 50% at 15% 10%, rgba(var(--accent-rgb),0.12) 0%, transparent 65%),
|
||||
radial-gradient(ellipse 60% 40% at 85% 80%, rgba(var(--accent2-rgb),0.08) 0%, transparent 60%);
|
||||
}
|
||||
::-webkit-scrollbar{width:4px;height:4px}
|
||||
::-webkit-scrollbar-track{background:rgba(255,255,255,.05)}
|
||||
::-webkit-scrollbar-thumb{background:rgba(255,255,255,.18);border-radius:var(--radius-full)}
|
||||
|
||||
/* ========== 主容器 ========== */
|
||||
.app{
|
||||
min-height:100vh;
|
||||
min-height:var(--fm-web-height, 100vh);
|
||||
padding-top: calc(var(--safe-top) + 12px);
|
||||
padding-bottom: calc(var(--safe-bottom) + 80px);
|
||||
padding-left: calc(var(--safe-left) + var(--gap));
|
||||
padding-right: calc(var(--safe-right) + var(--gap));
|
||||
}
|
||||
html.chrome-normal .app{
|
||||
padding-top: var(--gap);
|
||||
}
|
||||
|
||||
/* ========== iOS 26 悬浮玻璃导航栏 ========== */
|
||||
#searchOverlay{
|
||||
position:fixed;inset:0;z-index:25;
|
||||
backdrop-filter:blur(10px) brightness(0.6);
|
||||
-webkit-backdrop-filter:blur(10px) brightness(0.6);
|
||||
opacity:0;pointer-events:none;
|
||||
transition:opacity .25s ease;
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
}
|
||||
#searchOverlay.show{opacity:1;pointer-events:none}
|
||||
.search-loading{
|
||||
display:flex;flex-direction:column;align-items:center;justify-content:center;
|
||||
gap:14px;color:#fff;font-size:14px;
|
||||
opacity:0;transform:translateY(6px);
|
||||
transition:opacity .3s ease .1s,transform .3s ease .1s;
|
||||
}
|
||||
#searchOverlay.show .search-loading{opacity:1;transform:translateY(0)}
|
||||
|
||||
/* 聚焦态模糊:搜索时站源坞下滑隐藏(已在 .src-row 规则中定义)*/
|
||||
html.search-focused #status,
|
||||
html.search-focused #content {
|
||||
filter: blur(8px) brightness(0.45);
|
||||
pointer-events: none !important;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
transition: filter .25s ease;
|
||||
}
|
||||
html.search-focused .navrow {
|
||||
filter: blur(8px) brightness(0.45);
|
||||
pointer-events: none;
|
||||
transition: filter .25s ease;
|
||||
}
|
||||
html.search-focused .app {
|
||||
pointer-events: none;
|
||||
}
|
||||
html.search-focused .app .bar {
|
||||
pointer-events: auto;
|
||||
}
|
||||
#searchFocusMask {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 19;
|
||||
background: transparent;
|
||||
touch-action: none;
|
||||
pointer-events: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
html.search-focused #searchFocusMask {
|
||||
display: block;
|
||||
pointer-events: auto;
|
||||
}
|
||||
html.search-focused .bar {
|
||||
position: relative;
|
||||
z-index: 50;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
/* ===== 顶部搜索栏:默认隐藏,search-focused 时滑入 ===== */
|
||||
.bar{
|
||||
position:fixed;
|
||||
top:0; left:0; right:0;
|
||||
z-index:50;
|
||||
display:flex;align-items:center;gap:8px;
|
||||
flex-wrap:nowrap;
|
||||
padding:calc(var(--safe-top) + 10px) 14px 10px;
|
||||
background:none;
|
||||
backdrop-filter:none;
|
||||
-webkit-backdrop-filter:none;
|
||||
border:none;
|
||||
border-radius:0;
|
||||
box-shadow:none;
|
||||
/* 默认:平移到屏幕上方隐藏 */
|
||||
transform:translateY(-110%);
|
||||
opacity:0;
|
||||
pointer-events:none;
|
||||
transition:transform .3s cubic-bezier(0.34,1.1,0.64,1), opacity .25s ease;
|
||||
}
|
||||
html.search-focused .bar{
|
||||
transform:translateY(0);
|
||||
opacity:1;
|
||||
pointer-events:auto;
|
||||
}
|
||||
.bar-hidden-placeholder-unused{
|
||||
position:fixed;
|
||||
top:calc(var(--safe-top) + 12px);
|
||||
left:50%;
|
||||
transform:translateX(-50%);
|
||||
z-index:30;
|
||||
display:flex;align-items:center;gap:8px;
|
||||
flex-wrap:nowrap;
|
||||
padding:6px 10px;
|
||||
width:calc(100% - 24px);
|
||||
max-width:700px;
|
||||
/* 液态玻璃背景:多层叠加模拟 iOS 26 效果 */
|
||||
background:
|
||||
linear-gradient(135deg,
|
||||
rgba(255,255,255,0.13) 0%,
|
||||
rgba(255,255,255,0.06) 40%,
|
||||
rgba(var(--accent-rgb),0.10) 70%,
|
||||
rgba(var(--accent2-rgb),0.07) 100%
|
||||
);
|
||||
backdrop-filter:blur(40px) saturate(1.8) brightness(1.05);
|
||||
-webkit-backdrop-filter:blur(40px) saturate(1.8) brightness(1.05);
|
||||
border:1px solid rgba(255,255,255,0.22);
|
||||
border-top:1px solid rgba(255,255,255,0.32);
|
||||
border-radius:999px;
|
||||
box-shadow:
|
||||
0 8px 32px rgba(0,0,0,0.45),
|
||||
0 2px 8px rgba(0,0,0,0.25),
|
||||
0 0 0 0.5px rgba(255,255,255,0.08) inset,
|
||||
0 1px 0 rgba(255,255,255,0.18) inset;
|
||||
transition:all 0.28s cubic-bezier(0.2,0,0.2,1);
|
||||
}
|
||||
/* search-focused bar border: handled by bar slide-in */
|
||||
|
||||
/* 两个子行合并为同级 flex 子项 */
|
||||
.bar-top{display:contents}
|
||||
.bar-bottom{display:contents}
|
||||
.bar-right{display:contents}
|
||||
|
||||
.brand{font-size:18px;font-weight:900;white-space:nowrap;letter-spacing:0.5px;flex-shrink:0}
|
||||
.brand b{background:var(--accent-grad);-webkit-background-clip:text;-webkit-text-fill-color:transparent;color:transparent}
|
||||
|
||||
/* 搜索框:无边框风格,融入玻璃底 */
|
||||
.search-wrap{
|
||||
position:relative;flex:1;min-width:0;
|
||||
border-radius:calc(var(--radius-full) + 1px);
|
||||
padding:1px;
|
||||
background:linear-gradient(135deg,rgba(var(--accent-rgb),.55),rgba(255,255,255,.07) 45%,rgba(var(--accent2-rgb),.4));
|
||||
box-shadow:0 12px 28px -12px rgba(0,0,0,.55);
|
||||
transition:var(--transition-fast);
|
||||
}
|
||||
.search-wrap:focus-within{
|
||||
background:linear-gradient(135deg,rgba(var(--accent-rgb),.85),rgba(255,255,255,.1) 45%,rgba(var(--accent2-rgb),.65));
|
||||
box-shadow:0 0 0 3px rgba(var(--accent-rgb),.14),0 12px 28px -12px rgba(0,0,0,.55);
|
||||
}
|
||||
.search{
|
||||
position:relative;
|
||||
flex:1;min-width:0;display:flex;gap:8px;align-items:center;
|
||||
background:rgba(255,255,255,0.06);
|
||||
border:0;
|
||||
border-radius:var(--radius-full);
|
||||
padding:0 6px 0 16px;
|
||||
height:40px;
|
||||
}
|
||||
.search input{flex:1;min-width:0;background:transparent;border:0;outline:0;color:var(--text);font-size:16px}
|
||||
.search input::placeholder{color:var(--muted)}
|
||||
|
||||
/* ===== 搜索联想下拉:小海报 + 年份 ===== */
|
||||
.search-sugg{
|
||||
position:absolute;left:0;right:0;top:calc(100% + 8px);z-index:60;
|
||||
background:linear-gradient(150deg,rgba(var(--accent-rgb),.22),rgba(var(--accent2-rgb),.10) 60%,rgba(13,17,23,.55));
|
||||
backdrop-filter:blur(22px) saturate(1.5);-webkit-backdrop-filter:blur(22px) saturate(1.5);
|
||||
border:1px solid rgba(var(--accent2-rgb),.25);
|
||||
border-radius:var(--radius-lg);
|
||||
box-shadow:var(--shadow-deep);
|
||||
max-height:62vh;overflow-y:auto;overflow-x:hidden;
|
||||
-webkit-overflow-scrolling:touch;
|
||||
display:none;
|
||||
}
|
||||
.search-sugg.show{display:block}
|
||||
.sugg-item{
|
||||
display:flex;align-items:center;gap:11px;
|
||||
padding:8px 12px;cursor:pointer;
|
||||
border-bottom:1px solid rgba(255,255,255,.05);
|
||||
transition:background .12s;
|
||||
}
|
||||
.sugg-item:last-child{border-bottom:0}
|
||||
.sugg-item:active{background:rgba(var(--accent-rgb),.18)}
|
||||
@media(hover:hover){.sugg-item:hover{background:rgba(255,255,255,.06)}}
|
||||
.sugg-poster{
|
||||
width:38px;height:54px;flex-shrink:0;
|
||||
border-radius:6px;overflow:hidden;
|
||||
background:rgba(255,255,255,.06);
|
||||
border:1px solid rgba(255,255,255,.08);
|
||||
display:grid;place-items:center;
|
||||
}
|
||||
.sugg-poster img{width:100%;height:100%;object-fit:cover;display:block}
|
||||
.sugg-poster .ph{font-size:16px;opacity:.5}
|
||||
.sugg-info{flex:1;min-width:0}
|
||||
.sugg-title{font-size:14px;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.sugg-meta{margin-top:4px;display:flex;align-items:center;gap:7px;font-size:11px;color:var(--muted)}
|
||||
.sugg-type{
|
||||
padding:1px 7px;border-radius:999px;
|
||||
background:rgba(var(--accent-rgb),.14);
|
||||
border:1px solid rgba(var(--accent-rgb),.32);
|
||||
color:var(--accent);font-weight:800;font-size:10px;
|
||||
}
|
||||
.sugg-year{font-weight:700}
|
||||
.sugg-loading{padding:14px;text-align:center;color:var(--muted);font-size:13px}
|
||||
|
||||
/* ========== 通用按钮 ========== */
|
||||
.btn{
|
||||
position:relative;overflow:hidden;
|
||||
border:1px solid rgba(255,255,255,.12);
|
||||
background:rgba(45,54,68,.9);
|
||||
color:var(--text);
|
||||
border-radius:var(--radius-full);
|
||||
padding:0 16px;
|
||||
height:40px;
|
||||
font-size:14px;font-weight:700;
|
||||
letter-spacing:.3px;
|
||||
transition:var(--transition-fast);
|
||||
white-space:nowrap;flex-shrink:0;
|
||||
display:inline-flex;align-items:center;justify-content:center;
|
||||
cursor:pointer;
|
||||
box-shadow:0 2px 8px rgba(0,0,0,.25);
|
||||
}
|
||||
.btn:active{
|
||||
transform:scale(0.96);
|
||||
box-shadow:0 1px 4px rgba(0,0,0,.2);
|
||||
}
|
||||
|
||||
/* Primary 搜索按钮 — 玻璃态高亮 */
|
||||
.btn.primary{
|
||||
background:linear-gradient(135deg,
|
||||
rgba(var(--accent-rgb),0.85) 0%,
|
||||
rgba(var(--accent2-rgb),0.7) 100%);
|
||||
border:1px solid rgba(var(--accent-rgb),.5);
|
||||
border-top:1px solid rgba(255,255,255,0.22);
|
||||
color:#fff;font-weight:800;
|
||||
padding:0 18px;
|
||||
box-shadow:
|
||||
0 2px 10px rgba(var(--accent-rgb),.35),
|
||||
0 1px 0 rgba(255,255,255,0.14) inset;
|
||||
backdrop-filter:blur(8px);
|
||||
-webkit-backdrop-filter:blur(8px);
|
||||
}
|
||||
.btn.primary:active{
|
||||
box-shadow:0 1px 5px rgba(var(--accent-rgb),.2);
|
||||
}
|
||||
|
||||
/* 图标按钮 */
|
||||
.btn-icon{width:40px;height:40px;padding:0;min-width:unset;font-size:17px}
|
||||
|
||||
/* ========== 站源 / 分类导航区 ========== */
|
||||
|
||||
/* navrow:回归普通流,只包裹 chips 分类行(留在页面顶部区域) */
|
||||
.navrow{
|
||||
position:relative;
|
||||
z-index:20;
|
||||
display:block;
|
||||
padding:4px 0 10px;
|
||||
transition:filter .25s ease,opacity .25s ease;
|
||||
}
|
||||
.navrow.blurred .src-row{
|
||||
filter:blur(8px);
|
||||
opacity:.4;
|
||||
pointer-events:none;
|
||||
}
|
||||
.navrow.blurred .chips{
|
||||
filter:blur(8px);
|
||||
opacity:.4;
|
||||
pointer-events:none;
|
||||
}
|
||||
|
||||
/* ===== 底部联体左半:站源选择器(收起只显示当前站,点击展开)===== */
|
||||
.src-row{
|
||||
position:relative;
|
||||
display:flex;
|
||||
gap:0;
|
||||
overflow:hidden;
|
||||
scrollbar-width:none;
|
||||
-webkit-overflow-scrolling:touch;
|
||||
pointer-events:auto;
|
||||
/* 联体左半:无独立外壳,背景/边框/圆角全由 .bottom-dock 承担 */
|
||||
background:transparent;
|
||||
border:0;
|
||||
border-radius:0;
|
||||
box-shadow:none;
|
||||
padding:0;
|
||||
justify-content:center;
|
||||
align-items:center;
|
||||
/* 宽度动画:收起→展开 */
|
||||
max-width:150px;
|
||||
height:50px;
|
||||
transition:
|
||||
max-width .38s cubic-bezier(0.34,1.2,0.64,1),
|
||||
opacity .2s ease;
|
||||
cursor:pointer;
|
||||
}
|
||||
/* 展开态 */
|
||||
.src-row.expanded{
|
||||
max-width:calc(100vw - 80px);
|
||||
overflow-x:auto;
|
||||
cursor:default;
|
||||
padding:0 10px;
|
||||
gap:6px;
|
||||
justify-content:flex-start;
|
||||
touch-action:pan-x;
|
||||
}
|
||||
.src-row::-webkit-scrollbar{display:none}
|
||||
|
||||
/* search-focused src-row: handled by bottom-dock opacity */
|
||||
|
||||
/* 站源 tab 胶囊 — 所有项默认无轮廓,融入外壳 */
|
||||
.src-tab{
|
||||
position:relative;
|
||||
white-space:nowrap;
|
||||
flex-shrink:0;
|
||||
cursor:pointer;
|
||||
font-size:13px;
|
||||
font-weight:600;
|
||||
color:rgba(255,255,255,0.50);
|
||||
padding:7px 18px;
|
||||
background:transparent;
|
||||
border:1px solid transparent;
|
||||
border-radius:var(--radius-full);
|
||||
transition:all .2s cubic-bezier(0.2,0,0.2,1);
|
||||
/* 收起时:隐藏非选中项,彻底脱离 flex 空间 */
|
||||
opacity:0;
|
||||
pointer-events:none;
|
||||
max-width:0;
|
||||
width:0;
|
||||
padding-left:0;
|
||||
padding-right:0;
|
||||
margin:0;
|
||||
border-width:0;
|
||||
overflow:hidden;
|
||||
}
|
||||
/* 收起时只显示选中项 */
|
||||
.src-tab.on{
|
||||
color:#fff;
|
||||
font-weight:800;
|
||||
opacity:1;
|
||||
pointer-events:auto;
|
||||
max-width:100px;
|
||||
width:auto;
|
||||
min-width:0;
|
||||
height:50px;
|
||||
padding:0 22px;
|
||||
margin:0;
|
||||
text-align:center;
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
gap:6px;
|
||||
/* 收起时:融入外壳 */
|
||||
background:transparent;
|
||||
border-color:transparent;
|
||||
box-shadow:none;
|
||||
border-radius:999px;
|
||||
line-height:1;
|
||||
overflow:hidden;
|
||||
text-overflow:ellipsis;
|
||||
white-space:nowrap;
|
||||
/* 收起态隐藏站名文字,只显示图标 */
|
||||
font-size:0;
|
||||
}
|
||||
/* 收起态图标:信号塔/多源 */
|
||||
.src-tab.on::before{
|
||||
content:'';
|
||||
display:inline-block;
|
||||
flex-shrink:0;
|
||||
width:20px;
|
||||
height:20px;
|
||||
background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20' viewBox='0 0 24 24' fill='none' stroke='rgba(255,255,255,0.9)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='3' width='7' height='7' rx='1.5'/%3E%3Crect x='14' y='3' width='7' height='7' rx='1.5'/%3E%3Crect x='3' y='14' width='7' height='7' rx='1.5'/%3E%3Crect x='14' y='14' width='7' height='7' rx='1.5'/%3E%3C/svg%3E");
|
||||
background-repeat:no-repeat;
|
||||
background-position:center;
|
||||
background-size:contain;
|
||||
}
|
||||
/* 展开态才给内层胶囊自己的样式 */
|
||||
.src-row.expanded .src-tab.on{
|
||||
width:auto;
|
||||
padding:14px 22px;
|
||||
border-radius:var(--radius-full);
|
||||
background:linear-gradient(135deg,
|
||||
rgba(var(--accent-rgb),0.32) 0%,
|
||||
rgba(var(--accent2-rgb),0.18) 100%);
|
||||
border-color:rgba(var(--accent-rgb),0.50);
|
||||
box-shadow:
|
||||
0 4px 18px rgba(var(--accent-rgb),0.30),
|
||||
0 1px 0 rgba(255,255,255,0.16) inset;
|
||||
/* 展开态恢复文字 */
|
||||
font-size:16px;
|
||||
gap:0;
|
||||
}
|
||||
/* 展开态隐藏图标 */
|
||||
.src-row.expanded .src-tab.on::before{
|
||||
display:none;
|
||||
}
|
||||
.src-row.expanded .src-tab{
|
||||
border-radius:var(--radius-full);
|
||||
width:auto;
|
||||
padding:14px 22px;
|
||||
background:transparent;
|
||||
border-color:transparent;
|
||||
}
|
||||
/* 展开态:所有 tab 可见 */
|
||||
.src-row.expanded .src-tab{
|
||||
opacity:1;
|
||||
pointer-events:auto;
|
||||
max-width:160px;
|
||||
padding:14px 22px;
|
||||
border-radius:var(--radius-full);
|
||||
width:auto;
|
||||
background:transparent;
|
||||
border-color:transparent;
|
||||
font-size:16px;
|
||||
}
|
||||
/* 展开态非选中项:纯文字,无轮廓 */
|
||||
.src-row.expanded .src-tab:not(.on){
|
||||
color:rgba(255,255,255,0.50);
|
||||
background:transparent;
|
||||
border-color:transparent;
|
||||
box-shadow:none;
|
||||
}
|
||||
.src-tab:active{opacity:.70 !important;transform:scale(.94)}
|
||||
|
||||
/* 收起时的点击提示:轻微脉冲 */
|
||||
@keyframes srcPulse{
|
||||
0%{box-shadow:0 1px 0 rgba(255,255,255,0.18) inset,0 -1px 0 rgba(255,255,255,0.06) inset,0 20px 56px rgba(0,0,0,0.52),0 6px 20px rgba(0,0,0,0.32)}
|
||||
50%{box-shadow:0 1px 0 rgba(255,255,255,0.18) inset,0 -1px 0 rgba(255,255,255,0.06) inset,0 20px 56px rgba(0,0,0,0.52),0 6px 20px rgba(0,0,0,0.32),0 0 0 4px rgba(var(--accent-rgb),0.18)}
|
||||
100%{box-shadow:0 1px 0 rgba(255,255,255,0.18) inset,0 -1px 0 rgba(255,255,255,0.06) inset,0 20px 56px rgba(0,0,0,0.52),0 6px 20px rgba(0,0,0,0.32)}
|
||||
}
|
||||
|
||||
/* 分隔线隐藏 */
|
||||
.src-sep{display:none}
|
||||
|
||||
/* ===== 底部联体胶囊容器:站源(左)+ 搜索(右)合并为一个胶囊 ===== */
|
||||
.bottom-dock{
|
||||
position:fixed;
|
||||
bottom:calc(var(--safe-bottom) + 14px);
|
||||
left:50%;
|
||||
transform:translateX(-50%);
|
||||
z-index:35;
|
||||
display:flex;
|
||||
align-items:center;
|
||||
gap:0;
|
||||
pointer-events:none;
|
||||
/* 联体外壳:液态玻璃底坞 */
|
||||
background:
|
||||
linear-gradient(160deg,
|
||||
rgba(255,255,255,0.15) 0%,
|
||||
rgba(255,255,255,0.07) 45%,
|
||||
rgba(var(--accent-rgb),0.10) 80%,
|
||||
rgba(var(--accent2-rgb),0.07) 100%
|
||||
);
|
||||
backdrop-filter:blur(44px) saturate(2.0) brightness(1.08);
|
||||
-webkit-backdrop-filter:blur(44px) saturate(2.0) brightness(1.08);
|
||||
border:1px solid rgba(var(--accent-rgb),0.55);
|
||||
border-top:1px solid rgba(var(--accent-rgb),0.70);
|
||||
border-radius:999px;
|
||||
box-shadow:
|
||||
0 1px 0 rgba(255,255,255,0.18) inset,
|
||||
0 -1px 0 rgba(255,255,255,0.06) inset,
|
||||
0 20px 56px rgba(0,0,0,0.52),
|
||||
0 6px 20px rgba(0,0,0,0.32);
|
||||
height:50px;
|
||||
overflow:hidden;
|
||||
transition:max-width .38s cubic-bezier(0.34,1.2,0.64,1), border-color .2s ease;
|
||||
}
|
||||
/* 展开态:去掉 accent 描边,换回通用玻璃描边 */
|
||||
.bottom-dock.expanded{
|
||||
border-color:rgba(255,255,255,0.20);
|
||||
border-top-color:rgba(255,255,255,0.32);
|
||||
}
|
||||
|
||||
/* 联体中间分隔线 */
|
||||
.dock-sep{
|
||||
width:1px;
|
||||
height:28px;
|
||||
background:rgba(255,255,255,0.18);
|
||||
flex-shrink:0;
|
||||
pointer-events:none;
|
||||
}
|
||||
|
||||
/* ===== 搜索胶囊(右侧,联体右半) ===== */
|
||||
.search-pill{
|
||||
pointer-events:auto;
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
width:76px;height:50px;
|
||||
border-radius:0;
|
||||
background:transparent;
|
||||
border:0;
|
||||
cursor:pointer;
|
||||
transition:background .18s cubic-bezier(0.2,0,0.2,1);
|
||||
flex-shrink:0;
|
||||
}
|
||||
.search-pill:active{background:rgba(255,255,255,0.10)}
|
||||
.search-pill-icon{
|
||||
background:transparent;border:0;padding:0;
|
||||
width:100%;height:100%;
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
color:rgba(255,255,255,0.80);
|
||||
cursor:pointer;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* 底栏搜索框 */
|
||||
.src-search-inner{
|
||||
flex:1;min-width:0;
|
||||
display:flex;align-items:center;gap:8px;
|
||||
background:rgba(255,255,255,0.07);
|
||||
border:1px solid rgba(255,255,255,0.15);
|
||||
border-radius:999px;
|
||||
padding:0 8px 0 14px;
|
||||
height:36px;
|
||||
transition:border-color .18s;
|
||||
}
|
||||
.src-search-inner:focus-within{
|
||||
border-color:rgba(var(--accent-rgb),0.55);
|
||||
background:rgba(255,255,255,0.10);
|
||||
}
|
||||
.src-search-inner input{
|
||||
flex:1;min-width:0;background:transparent;border:0;outline:0;
|
||||
color:#fff;font-size:15px;
|
||||
}
|
||||
.src-search-inner input::placeholder{color:rgba(255,255,255,0.38)}
|
||||
.src-cancel-btn{
|
||||
flex-shrink:0;
|
||||
background:transparent;border:0;
|
||||
color:rgba(255,255,255,0.65);
|
||||
font-size:13px;font-weight:600;
|
||||
padding:6px 4px 6px 8px;
|
||||
cursor:pointer;white-space:nowrap;
|
||||
}
|
||||
|
||||
/* 搜索提交按钮 */
|
||||
.src-search-submit{
|
||||
flex-shrink:0;
|
||||
width:32px;height:32px;padding:0;
|
||||
display:inline-flex;align-items:center;justify-content:center;
|
||||
background:linear-gradient(135deg,rgba(var(--accent-rgb),0.85),rgba(var(--accent2-rgb),0.7));
|
||||
border:1px solid rgba(var(--accent-rgb),.5);
|
||||
border-radius:999px;
|
||||
color:#fff;cursor:pointer;
|
||||
}
|
||||
.src-search-submit:active{opacity:.75}
|
||||
|
||||
/* 分类 chips:正常流,在 navrow 内 */
|
||||
.chips{
|
||||
display:flex;
|
||||
gap:8px;
|
||||
overflow-x:auto;
|
||||
scrollbar-width:none;
|
||||
-webkit-overflow-scrolling:touch;
|
||||
padding:0 var(--gap);
|
||||
}
|
||||
.chips::-webkit-scrollbar{display:none}
|
||||
|
||||
/* 搜索中:内容模糊 */
|
||||
#content{transition:filter .25s ease,opacity .25s ease;min-height:calc(100vh - 160px)}
|
||||
#content.searching{filter:blur(6px);opacity:.5;pointer-events:none}
|
||||
.app{transition:filter .28s ease}
|
||||
.app.searching .src-row,
|
||||
.app.searching #status,
|
||||
.app.searching #content{filter:blur(8px)}
|
||||
|
||||
.chip{
|
||||
white-space:nowrap;
|
||||
cursor:pointer;
|
||||
flex-shrink:0;
|
||||
font-size:14px;
|
||||
font-weight:600;
|
||||
color:#fff;
|
||||
background:rgba(255,255,255,0.06);
|
||||
border:1px solid rgba(255,255,255,0.11);
|
||||
border-radius:var(--radius-full);
|
||||
padding:6px 15px;
|
||||
transition:var(--transition-fast);
|
||||
}
|
||||
.chip.active{
|
||||
color:#fff;
|
||||
font-weight:800;
|
||||
background:linear-gradient(135deg,var(--accent2),var(--accent));
|
||||
border-color:transparent;
|
||||
box-shadow:0 3px 12px rgba(var(--accent-rgb),.38);
|
||||
}
|
||||
.chip:active{opacity:.82;transform:scale(.95)}
|
||||
|
||||
.status{display:none}
|
||||
|
||||
/* ========== 卡片网格 ========== */
|
||||
.grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--gap)}
|
||||
/* 竖屏 3 列,横屏 7 列 */
|
||||
@media(orientation:portrait){.grid{grid-template-columns:repeat(3,minmax(0,1fr))}}
|
||||
@media(orientation:landscape){.grid{grid-template-columns:repeat(7,minmax(0,1fr))}}
|
||||
/* 左右滑动切换分类:新分类网格淡入位移 */
|
||||
@keyframes catSlideFromR{from{opacity:.25;transform:translateX(28px)}to{opacity:1;transform:none}}
|
||||
@keyframes catSlideFromL{from{opacity:.25;transform:translateX(-28px)}to{opacity:1;transform:none}}
|
||||
.grid.slide-l{animation:catSlideFromR .24s cubic-bezier(.2,0,.2,1)}
|
||||
.grid.slide-r{animation:catSlideFromL .24s cubic-bezier(.2,0,.2,1)}
|
||||
/* 滑到边界时的轻微回弹提示 */
|
||||
@keyframes catEdgeBounceL{0%{transform:none}40%{transform:translateX(-14px)}100%{transform:none}}
|
||||
@keyframes catEdgeBounceR{0%{transform:none}40%{transform:translateX(14px)}100%{transform:none}}
|
||||
#content.edge-l{animation:catEdgeBounceL .26s ease}
|
||||
#content.edge-r{animation:catEdgeBounceR .26s ease}
|
||||
|
||||
.card{
|
||||
border-radius:var(--radius-lg);overflow:hidden;
|
||||
background:linear-gradient(150deg,rgba(var(--accent-rgb),.22),rgba(var(--accent2-rgb),.10) 60%,rgba(13,17,23,.4));
|
||||
backdrop-filter:blur(20px) saturate(1.5);
|
||||
-webkit-backdrop-filter:blur(20px) saturate(1.5);
|
||||
border:1px solid rgba(var(--accent2-rgb),.25);
|
||||
transition:var(--transition-fast);
|
||||
cursor:pointer;position:relative;
|
||||
box-shadow:0 4px 16px rgba(0,0,0,.4),0 1px 0 rgba(255,255,255,.04) inset;
|
||||
}
|
||||
.card:active{transform:scale(0.97);opacity:.88}
|
||||
/* 关键修复:禁用系统长按选择/图片菜单,避免和"长按收藏"手势抢触摸导致卡死 */
|
||||
.card,.card *{
|
||||
-webkit-user-select:none;-moz-user-select:none;user-select:none;
|
||||
-webkit-touch-callout:none;
|
||||
}
|
||||
.card img{-webkit-user-drag:none;user-drag:none}
|
||||
|
||||
.poster{aspect-ratio:3/4;position:relative;overflow:hidden;background:#1a222c}
|
||||
.poster img{width:100%;height:100%;object-fit:cover;transition:var(--transition-slow);display:block;position:absolute;inset:0;z-index:0}
|
||||
.noimg{height:100%;display:grid;place-items:center;color:var(--muted);font-size:15px;font-weight:700;letter-spacing:.5px;text-align:center;line-height:1.5;padding:0 10px;background:linear-gradient(145deg,#202833,#11161d);position:absolute;inset:0;z-index:0}
|
||||
|
||||
/* 更新状态角标:右上角,抓取自站源(替换原收藏心形按钮的位置) */
|
||||
.status-badge{
|
||||
position:absolute;top:7px;right:7px;z-index:3;
|
||||
max-width:78%;
|
||||
padding:3px 9px;border-radius:999px;
|
||||
font-size:10px;font-weight:800;letter-spacing:.2px;line-height:1.3;
|
||||
color:#fff;
|
||||
background:linear-gradient(135deg,rgba(var(--accent-rgb),.95),rgba(var(--accent2-rgb),.9));
|
||||
border:1px solid rgba(255,255,255,.28);
|
||||
box-shadow:0 2px 8px rgba(0,0,0,.4);
|
||||
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
|
||||
-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px);
|
||||
}
|
||||
/* 收藏态不再做视觉高亮(收藏内容直接到“收藏”分类查看);功能保留,仅去掉描边/光晕 */
|
||||
|
||||
/* 角标 */
|
||||
.source-badge{
|
||||
position:absolute;bottom:4px;right:6px;
|
||||
padding:2px 7px;border-radius:5px;font-size:9px;font-weight:800;
|
||||
background:linear-gradient(135deg,var(--accent-l1),var(--accent));
|
||||
color:#fff;box-shadow:0 2px 6px rgba(var(--accent-rgb),.4);
|
||||
border:1px solid rgba(var(--accent2-rgb),.3);
|
||||
display:none;
|
||||
max-width:60%;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
|
||||
}
|
||||
.source-badge.show{display:block}
|
||||
.badge{
|
||||
position:absolute;left:6px;bottom:4px;
|
||||
padding:2px 7px;border-radius:var(--radius-full);
|
||||
background:rgba(0,0,0,.72);font-size:10px;font-weight:800;
|
||||
backdrop-filter:blur(6px);border:1px solid rgba(255,255,255,.1);
|
||||
}
|
||||
.meta{padding:8px 10px}
|
||||
.name{font-weight:800;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.srcline{margin-top:4px;color:var(--accent2);font-size:10px;line-height:1.4;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
|
||||
.remark{margin-top:4px;color:var(--muted);font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
|
||||
/* ========== 详情弹层:沉浸式全屏(参考 Eclipse)========== */
|
||||
.sheet{
|
||||
position:fixed;inset:0;z-index:40;
|
||||
display:none;
|
||||
background:#0d1117;
|
||||
overflow-y:scroll;
|
||||
overflow-x:hidden;
|
||||
-webkit-overflow-scrolling:touch;
|
||||
transform:translateX(100%);
|
||||
transition:transform .28s cubic-bezier(.4,0,.2,1);
|
||||
scrollbar-width:none; /* Firefox */
|
||||
-ms-overflow-style:none; /* 旧版 Edge/IE */
|
||||
}
|
||||
.sheet::-webkit-scrollbar{display:none} /* Chrome/Safari/WebView */
|
||||
.sheet.active{display:block;transform:translateX(0)}
|
||||
|
||||
/* 独立海报大图区:不再做沉浸式背景叠加,海报清晰完整展示 */
|
||||
/* 竖版海报:按 2:3 比例展示;横版海报由 JS 检测后切换为 .landscape,按 16:9 展示 */
|
||||
.det-hero{
|
||||
position:relative;
|
||||
width:100%;
|
||||
aspect-ratio:2/3;
|
||||
max-height:62vh;
|
||||
z-index:0;overflow:hidden;
|
||||
background:linear-gradient(160deg, #0f1923 0%, #1a2535 50%, #0d1117 100%);
|
||||
}
|
||||
.det-hero.landscape{aspect-ratio:auto}
|
||||
/* ===== Emby 风格全屏海报背景(独立于 sheet,fixed 不受 sheet transform 影响)===== */
|
||||
.det-hero-bg{
|
||||
position:fixed;inset:0;z-index:38; /* 低于 sheet(40),详情打开后透过透明的 sheet 透出 */
|
||||
background-size:cover;background-position:center 12%;
|
||||
background-color:#0d1117; /* 深色兜底:海报图加载前也不会透出主页 */
|
||||
opacity:0;pointer-events:none;
|
||||
transition:opacity .4s ease;
|
||||
will-change:opacity;
|
||||
}
|
||||
.det-hero-bg.show{opacity:1}
|
||||
/* 轮播图层:两层交替 crossfade;淡出时露出的是 detHeroBg 深色底而非主页 */
|
||||
.det-hero-bg-img{
|
||||
position:absolute;inset:0;
|
||||
background-size:cover;background-position:center 12%;
|
||||
opacity:0;transition:opacity .9s ease;
|
||||
}
|
||||
/* 渐变压暗层:大幅调淡——资源已收进带玻璃背景的折叠条,海报得以大部分清晰露出 */
|
||||
.det-hero-scrim{
|
||||
position:absolute;inset:0;
|
||||
background:linear-gradient(180deg,
|
||||
rgba(13,17,23,.12) 0%,
|
||||
rgba(13,17,23,0) 32%,
|
||||
rgba(13,17,23,0) 60%,
|
||||
rgba(13,17,23,.28) 82%,
|
||||
rgba(13,17,23,.5) 100%);
|
||||
}
|
||||
/* 横版沉浸式:sheet 背景透明(透出后面的全屏海报),用 flex 纵向排让内容沉底 */
|
||||
.sheet.landscape-mode{background:transparent!important;display:flex;flex-direction:column}
|
||||
/* 海报区撑开占据上方所有空间,把播放按钮挤到底沿,紧接下方标题与折叠条 */
|
||||
.sheet.landscape-mode .det-hero{
|
||||
flex:1 1 auto;height:auto;min-height:26vh;
|
||||
background:transparent;aspect-ratio:auto;max-height:none;
|
||||
overflow:visible; /* 让播放按钮的呼吸光晕不被裁切 */
|
||||
}
|
||||
.sheet.landscape-mode .det-hero-img{display:none} /* 用全屏 detHeroBg 背景代替 */
|
||||
.sheet.landscape-mode .det-hero::after{display:none}
|
||||
/* 播放按钮落在海报底沿正中,间距收紧让底部内容组更紧凑 */
|
||||
.sheet.landscape-mode .det-hero-cta{align-items:flex-end;justify-content:center;padding-bottom:12px}
|
||||
/* 沉浸式详情页隐藏吸顶标题栏,避免滑动时顶部标题栏闪现、破坏沉浸感 */
|
||||
.sheet.landscape-mode .det-topbar{display:none!important}
|
||||
/* 海报大图:完整清晰展示,不裁切关键内容,默认透明,加载成功后淡入 */
|
||||
.det-hero-img{
|
||||
position:absolute;inset:0;z-index:0;
|
||||
width:100%;height:100%;object-fit:cover;object-position:center center;
|
||||
opacity:0;transition:opacity .35s ease;
|
||||
}
|
||||
.det-hero-img.loaded{opacity:1}
|
||||
/* 底部轻渐变,仅用于与下方内容衔接,不遮挡海报画面主体 */
|
||||
.det-hero::after{
|
||||
content:'';position:absolute;left:0;right:0;bottom:0;height:64px;z-index:1;
|
||||
background:linear-gradient(180deg, rgba(13,17,23,0) 0%, rgba(13,17,23,.9) 100%);
|
||||
}
|
||||
/* 海报内部底部:播放按钮悬浮在海报下沿,居中 */
|
||||
.det-hero-cta{
|
||||
position:absolute;left:0;right:0;bottom:0;z-index:3;
|
||||
display:flex;justify-content:center;align-items:flex-end;
|
||||
padding:0 14px 14px;
|
||||
pointer-events:none;
|
||||
}
|
||||
.det-hero-cta .det-play-btn{pointer-events:auto}
|
||||
|
||||
/* 吸顶导航栏:已去除返回按钮,仅在滚动后吸顶显示标题(关闭详情页改为手势/系统返回) */
|
||||
.det-topbar{
|
||||
position:fixed;top:0;left:0;right:0;z-index:10;
|
||||
height:calc(52px + var(--safe-top));
|
||||
padding-top:var(--safe-top);
|
||||
display:flex;align-items:center;gap:10px;
|
||||
padding-left:18px;padding-right:18px;
|
||||
background:rgba(13,17,23,0);
|
||||
border-bottom:1px solid rgba(255,255,255,0);
|
||||
backdrop-filter:blur(0px);
|
||||
-webkit-backdrop-filter:blur(0px);
|
||||
transition:background .2s,border-color .2s,backdrop-filter .2s,-webkit-backdrop-filter .2s;
|
||||
pointer-events:none;
|
||||
}
|
||||
.det-topbar.scrolled{
|
||||
background:linear-gradient(150deg,rgba(var(--accent-rgb),.20),rgba(var(--accent2-rgb),.08) 60%,rgba(13,17,23,.7));
|
||||
border-bottom:1px solid rgba(var(--accent2-rgb),.2);
|
||||
backdrop-filter:blur(20px) saturate(1.5);
|
||||
-webkit-backdrop-filter:blur(20px) saturate(1.5);
|
||||
}
|
||||
.det-topbar-title{
|
||||
flex:1;min-width:0;
|
||||
font-size:16px;font-weight:700;color:#fff;
|
||||
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
|
||||
opacity:0;transform:translateY(5px);
|
||||
transition:opacity .18s,transform .18s;
|
||||
}
|
||||
.det-topbar.scrolled .det-topbar-title{opacity:1;transform:translateY(0)}
|
||||
|
||||
/* 主内容区:海报大图现为常规流内元素,内容区紧随其后,顶部只需让出吸顶栏高度 */
|
||||
.panel{
|
||||
position:relative;z-index:2;
|
||||
padding:6px 18px calc(var(--safe-bottom) + 24px);
|
||||
box-sizing:border-box;
|
||||
}
|
||||
/* 横版沉浸式:资源区自然高度,作为 flex 项紧贴撑开的海报下方,整组沉到屏幕底部 */
|
||||
.sheet.landscape-mode .panel{
|
||||
margin-top:0!important;
|
||||
background:transparent!important;
|
||||
position:relative;z-index:2;
|
||||
flex:0 0 auto;
|
||||
}
|
||||
/* ===== Apple TV 风格大标题:播放按钮下方,大号粗体 ===== */
|
||||
.det-bigtitle{
|
||||
font-size:34px;font-weight:850;color:#fff;line-height:1.1;
|
||||
letter-spacing:.01em;margin:2px 0 12px;text-align:center;
|
||||
text-shadow:0 2px 24px rgba(0,0,0,.6),0 1px 4px rgba(0,0,0,.5);
|
||||
}
|
||||
.sheet.landscape-mode .det-bigtitle{font-size:40px;margin:8px 0 6px;text-align:center}
|
||||
.sheet.landscape-mode .det-pills{justify-content:center}
|
||||
/* 影视类型标签:标题下方居中显示 */
|
||||
.det-genres{
|
||||
text-align:center;color:rgba(255,255,255,.72);
|
||||
font-size:13px;font-weight:600;letter-spacing:.04em;
|
||||
margin:0 0 10px;text-shadow:0 1px 8px rgba(0,0,0,.55);
|
||||
}
|
||||
.det-genres:empty{display:none}
|
||||
/* ===== 资源折叠条:默认收起、两条并排,露出大部分海报 ===== */
|
||||
.det-collapse-row{display:block}
|
||||
.det-collapse{
|
||||
background:linear-gradient(150deg,rgba(var(--accent-rgb),.18),rgba(var(--accent2-rgb),.08) 60%,rgba(13,17,23,.38)); /* 降低不透明度,海报透出更多 */
|
||||
backdrop-filter:blur(22px) saturate(1.5);
|
||||
-webkit-backdrop-filter:blur(22px) saturate(1.5);
|
||||
border:1px solid rgba(var(--accent2-rgb),.22);
|
||||
border-radius:var(--radius-full);overflow:hidden; /* 圆角同步顶部搜索框 */
|
||||
box-shadow:0 10px 30px rgba(0,0,0,.32);
|
||||
width:fit-content;max-width:92%;margin:0 auto; /* 收起时宽度自适应内容,横竖屏都刚好不挤不空 */
|
||||
transition:max-width .28s ease,border-radius .28s ease;
|
||||
}
|
||||
.det-collapse.open{width:100%;max-width:720px;border-radius:18px} /* 展开时铺开、收圆角为长方形,避免大弧形盖住内容 */
|
||||
.det-collapse-head{
|
||||
display:flex;align-items:center;gap:12px;justify-content:center;
|
||||
padding:10px 20px;cursor:pointer;user-select:none;
|
||||
font-size:15px;font-weight:800;color:#fff;
|
||||
}
|
||||
/* 资源 tab 切换:网盘资源 / 盘搜资源,居中显示 */
|
||||
.det-res-tabs{display:flex;gap:10px;flex:0 0 auto;justify-content:center}
|
||||
.det-res-tab{
|
||||
background:transparent;border:none;cursor:pointer;
|
||||
color:rgba(255,255,255,.5);font-size:15px;font-weight:800;
|
||||
padding:6px 14px;border-radius:var(--radius-full); /* 圆角同步资源框(胶囊)*/
|
||||
white-space:nowrap; /* 文字不换行 */
|
||||
display:inline-flex;align-items:center;transition:background .15s,color .15s;
|
||||
}
|
||||
.det-res-tab.active{color:#fff;background:rgba(255,255,255,.12)}
|
||||
.det-res-tab em{
|
||||
font-style:normal;margin-left:7px;padding:1px 8px;border-radius:999px;
|
||||
font-size:12px;font-weight:900;color:var(--accent2);
|
||||
background:rgba(var(--accent2-rgb),.15);border:1px solid rgba(var(--accent2-rgb),.3);
|
||||
}
|
||||
.det-res-pane[hidden]{display:none}
|
||||
.det-collapse-arrow{opacity:.65;font-size:12px;transition:transform .22s ease;flex-shrink:0}
|
||||
.det-collapse.open .det-collapse-arrow{transform:rotate(180deg)}
|
||||
.det-collapse-body{display:none;padding:0 14px 14px}
|
||||
.det-collapse.open .det-collapse-body{display:block}
|
||||
.det-collapse-body .pan-list{height:400px;max-height:48vh;overflow-y:auto} /* 固定约6条高度,数量不同不再跳动,不足则留白 */
|
||||
.det-collapse .pan-cfg-btn{flex-shrink:0}
|
||||
|
||||
/* poster row — 海报已独立展示为顶部大图,此处只保留文字标题区 */
|
||||
.det-poster-row{
|
||||
display:flex;gap:16px;align-items:flex-end;
|
||||
margin-top:18px;
|
||||
margin-bottom:18px;
|
||||
}
|
||||
.det-title-block{flex:1;min-width:0;padding-bottom:4px}
|
||||
|
||||
/* eyebrow tag */
|
||||
.det-eyebrow{
|
||||
font-size:11px;letter-spacing:.2em;text-transform:uppercase;
|
||||
color:var(--accent2);margin-bottom:10px;
|
||||
display:flex;align-items:center;gap:6px;font-weight:700;
|
||||
}
|
||||
.det-eyebrow::before{content:'';display:inline-block;width:12px;height:1px;background:var(--accent2)}
|
||||
|
||||
/* main title */
|
||||
.det-main-title{
|
||||
font-size:clamp(22px,6vw,38px);font-weight:900;line-height:1.08;
|
||||
letter-spacing:-.01em;
|
||||
text-shadow:0 3px 16px rgba(0,0,0,.6);
|
||||
margin-bottom:10px;
|
||||
}
|
||||
|
||||
/* meta pills */
|
||||
.det-pills{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:14px}
|
||||
.det-pill{
|
||||
padding:4px 10px;border-radius:999px;
|
||||
color:rgba(255,255,255,.85);background:rgba(255,255,255,.1);
|
||||
border:1px solid rgba(255,255,255,.2);
|
||||
font-size:12px;line-height:1.4;
|
||||
}
|
||||
.det-pill.accent{
|
||||
color:var(--accent);border-color:rgba(var(--accent-rgb),.5);
|
||||
background:rgba(var(--accent-rgb),.12);font-weight:700;
|
||||
}
|
||||
|
||||
/* action buttons — 居中 + 与首页主按钮统一质感 */
|
||||
.det-actions{display:flex;gap:8px;flex-wrap:wrap;margin:4px 0 18px;align-items:center;justify-content:center}
|
||||
.det-play-btn{
|
||||
position:relative;overflow:visible;
|
||||
display:inline-flex;align-items:center;justify-content:center;
|
||||
color:#fff;
|
||||
cursor:pointer;
|
||||
width:54px;height:54px;padding:0;
|
||||
border-radius:50%;
|
||||
background:var(--accent);
|
||||
border:1px solid rgba(var(--accent-rgb),.5);
|
||||
border-top:1px solid rgba(255,255,255,.22);
|
||||
box-shadow:0 2px 10px rgba(var(--accent-rgb),.3),0 1px 0 rgba(255,255,255,.08) inset;
|
||||
transition:var(--transition-fast);
|
||||
}
|
||||
.det-play-btn svg{width:20px;height:20px;margin-left:2px}
|
||||
.det-play-btn::before{
|
||||
content:'';position:absolute;inset:0;border-radius:50%;pointer-events:none;z-index:-1;
|
||||
box-shadow:0 0 0 0 rgba(var(--accent-rgb),.5);
|
||||
animation:detPlayPulse 2.6s cubic-bezier(.2,.7,.3,1) infinite;
|
||||
}
|
||||
@keyframes detPlayPulse{
|
||||
0%{box-shadow:0 0 0 0 rgba(var(--accent-rgb),.45)}
|
||||
70%{box-shadow:0 0 0 16px rgba(var(--accent-rgb),0)}
|
||||
100%{box-shadow:0 0 0 16px rgba(var(--accent-rgb),0)}
|
||||
}
|
||||
.det-hero-cta .det-play-btn:active{transform:scale(.96);box-shadow:0 1px 5px rgba(var(--accent-rgb),.2)}
|
||||
|
||||
/* section label */
|
||||
.det-sec-label{
|
||||
font-size:11px;letter-spacing:.2em;text-transform:uppercase;color:#fff;
|
||||
margin:12px 0 8px;
|
||||
display:flex;align-items:center;gap:10px;font-weight:700;
|
||||
}
|
||||
.det-sec-label::after{content:'';flex:1;height:1px;background:rgba(255,255,255,.08)}
|
||||
|
||||
/* 网盘资源区 — 整齐单列 */
|
||||
.pan-list{display:flex;flex-direction:column;gap:8px;margin-top:4px}
|
||||
.pan-item{
|
||||
display:flex;
|
||||
flex-direction:row;
|
||||
align-items:center;
|
||||
gap:12px;
|
||||
padding:12px 14px;border-radius:14px;
|
||||
background:rgba(255,255,255,.04);
|
||||
border:1px solid rgba(255,255,255,.08);
|
||||
border-top:1px solid rgba(255,255,255,.13);
|
||||
transition:var(--transition-fast);
|
||||
box-shadow:0 2px 12px rgba(0,0,0,.3);
|
||||
min-width:0;
|
||||
}
|
||||
@media(hover:hover){.pan-item:hover{background:rgba(255,255,255,.07);border-color:rgba(255,255,255,.14)}}
|
||||
.pan-type{
|
||||
flex-shrink:0;
|
||||
padding:5px 11px;border-radius:999px;
|
||||
background:linear-gradient(135deg,rgba(var(--accent2-rgb),.18),rgba(var(--accent-rgb),.1));
|
||||
border:1px solid rgba(var(--accent2-rgb),.35);
|
||||
color:var(--accent2);font-size:11px;font-weight:900;
|
||||
min-width:46px;text-align:center;
|
||||
text-shadow:0 0 8px rgba(var(--accent2-rgb),.4);
|
||||
}
|
||||
.pan-info{flex:1;min-width:0;overflow:hidden}
|
||||
.pan-name{font-size:13px;font-weight:800;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.pan-url{color:var(--muted);font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-top:2px}
|
||||
.pan-actions{flex-shrink:0;display:flex;gap:6px;align-items:center}
|
||||
.copy-btn{
|
||||
height:30px;padding:0 12px;border-radius:8px;
|
||||
background:rgba(255,255,255,.06);
|
||||
border:1px solid rgba(255,255,255,.12);
|
||||
color:rgba(255,255,255,.8);font-size:12px;font-weight:700;cursor:pointer;
|
||||
display:inline-flex;align-items:center;transition:background .12s;
|
||||
}
|
||||
.copy-btn:active{transform:scale(.95);opacity:.8}
|
||||
@media(hover:hover){.copy-btn:hover{background:rgba(255,255,255,.12)}}
|
||||
.pan-open-btn{
|
||||
height:30px;padding:0 14px;border-radius:8px;
|
||||
background:var(--accent);
|
||||
color:#fff;font-size:12px;font-weight:800;cursor:pointer;
|
||||
display:inline-flex;align-items:center;
|
||||
border:none;
|
||||
transition:opacity .12s,transform .1s;
|
||||
}
|
||||
.pan-open-btn:active{transform:scale(.95);opacity:.85}
|
||||
@media(hover:hover){.pan-open-btn:hover{opacity:.88}}
|
||||
|
||||
/* ===== 盘搜(PanSou 聚合网盘搜索)===== */
|
||||
.pan-cfg-btn{
|
||||
background:transparent;border:none;color:var(--muted);
|
||||
font-size:13px;cursor:pointer;padding:2px 4px;flex-shrink:0;
|
||||
line-height:1;transition:opacity .12s;
|
||||
}
|
||||
.pan-cfg-btn:active{opacity:.5}
|
||||
|
||||
/* ===== 自绘弹窗:替代原生 prompt/confirm,避免浏览器“网址为 xxx 显示”样式 ===== */
|
||||
.custom-modal-mask{
|
||||
position:fixed;inset:0;z-index:80;
|
||||
background:rgba(0,0,0,.55);
|
||||
backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
padding:24px;
|
||||
opacity:0;pointer-events:none;
|
||||
transition:opacity .2s ease;
|
||||
}
|
||||
.custom-modal-mask.show{opacity:1;pointer-events:auto}
|
||||
.custom-modal{
|
||||
width:100%;max-width:340px;
|
||||
background:linear-gradient(160deg,rgba(30,38,50,.98),rgba(18,24,32,.98));
|
||||
border:1px solid rgba(255,255,255,.12);
|
||||
border-top:1px solid rgba(255,255,255,.18);
|
||||
border-radius:var(--radius-lg);
|
||||
box-shadow:var(--shadow-deep);
|
||||
padding:18px 18px 14px;
|
||||
transform:translateY(10px) scale(.97);
|
||||
transition:transform .2s ease;
|
||||
}
|
||||
.custom-modal-mask.show .custom-modal{transform:translateY(0) scale(1)}
|
||||
.custom-modal-title{font-size:15px;font-weight:700;color:#fff;margin-bottom:12px}
|
||||
.custom-modal-input{
|
||||
width:100%;height:42px;border-radius:var(--radius-md);
|
||||
background:rgba(255,255,255,.06);
|
||||
border:1px solid rgba(255,255,255,.12);
|
||||
color:var(--text);font-size:14px;padding:0 12px;outline:0;
|
||||
transition:var(--transition-fast);
|
||||
}
|
||||
.custom-modal-input:focus{
|
||||
border-color:rgba(var(--accent-rgb),.45);
|
||||
box-shadow:0 0 0 3px rgba(var(--accent-rgb),.12);
|
||||
}
|
||||
.custom-modal-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:16px}
|
||||
.custom-modal-btn{
|
||||
border:none;background:rgba(255,255,255,.08);color:var(--text);
|
||||
font-size:14px;font-weight:600;padding:8px 16px;border-radius:var(--radius-md);
|
||||
cursor:pointer;transition:opacity .12s;
|
||||
}
|
||||
.custom-modal-btn:active{opacity:.6}
|
||||
.custom-modal-btn.accent{background:var(--accent-grad);color:#fff}
|
||||
/* ===== 盘搜:网盘类型多选 ===== */
|
||||
.pan-type-cfg{margin-top:14px}
|
||||
.pan-type-cfg-label{
|
||||
display:flex;align-items:center;justify-content:space-between;
|
||||
font-size:12px;color:var(--muted);font-weight:700;margin-bottom:8px;
|
||||
}
|
||||
.pan-type-all{
|
||||
border:none;background:rgba(var(--accent-rgb),.16);color:var(--accent);
|
||||
font-size:12px;font-weight:700;padding:3px 10px;border-radius:var(--radius-full);
|
||||
cursor:pointer;transition:opacity .12s;
|
||||
}
|
||||
.pan-type-all:active{opacity:.55}
|
||||
.pan-type-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}
|
||||
.pan-type-chip{
|
||||
display:flex;align-items:center;justify-content:center;gap:4px;
|
||||
height:38px;border-radius:var(--radius-md);
|
||||
background:rgba(255,255,255,.05);
|
||||
border:1px solid rgba(255,255,255,.12);
|
||||
color:var(--muted);font-size:13px;font-weight:700;
|
||||
cursor:pointer;-webkit-user-select:none;user-select:none;
|
||||
transition:var(--transition-fast);
|
||||
}
|
||||
.pan-type-chip:active{transform:scale(.96)}
|
||||
.pan-type-chip.on{
|
||||
background:rgba(var(--accent-rgb),.18);
|
||||
border-color:rgba(var(--accent-rgb),.5);
|
||||
color:#fff;
|
||||
}
|
||||
.pan-type-chip .tick{width:14px;height:14px;flex-shrink:0;opacity:0;transition:opacity .12s}
|
||||
.pan-type-chip.on .tick{opacity:1}
|
||||
.pan-search-tabs{
|
||||
display:flex;gap:6px;overflow-x:auto;padding-bottom:2px;margin-bottom:10px;
|
||||
-webkit-overflow-scrolling:touch;scrollbar-width:none;
|
||||
}
|
||||
.pan-search-tabs::-webkit-scrollbar{display:none}
|
||||
.pan-search-tab{
|
||||
flex-shrink:0;padding:6px 13px;border-radius:999px;
|
||||
font-size:12px;font-weight:800;white-space:nowrap;cursor:pointer;
|
||||
background:rgba(255,255,255,.05);
|
||||
border:1px solid rgba(255,255,255,.1);
|
||||
color:var(--muted);transition:var(--transition-fast);
|
||||
}
|
||||
.pan-search-tab.active{
|
||||
background:var(--accent);color:#fff;
|
||||
border-color:rgba(var(--accent-rgb),.5);
|
||||
}
|
||||
.pan-search-status{
|
||||
padding:18px 14px;text-align:center;color:var(--muted);font-size:13px;
|
||||
}
|
||||
.pan-health-dot{
|
||||
width:8px;height:8px;border-radius:50%;flex-shrink:0;
|
||||
background:rgba(255,255,255,.22);transition:background .2s;
|
||||
}
|
||||
.pan-health-dot[data-state="ok"]{background:var(--green);box-shadow:var(--green-glow)}
|
||||
.pan-health-dot[data-state="bad"]{background:#ff4d4f}
|
||||
.pan-health-dot[data-state="locked"]{background:#ffb020}
|
||||
.pan-health-dot[data-state="uncertain"]{background:rgba(255,255,255,.35)}
|
||||
.pan-health-dot[data-state="unsupported"]{visibility:hidden}
|
||||
|
||||
/* 加载骨架 */
|
||||
.det-loading{
|
||||
display:flex;flex-direction:column;align-items:center;justify-content:center;
|
||||
min-height:60vh;gap:14px;color:var(--muted);font-size:14px;
|
||||
}
|
||||
.det-spinner{
|
||||
width:36px;height:36px;border-radius:50%;
|
||||
border:3px solid rgba(255,255,255,.08);
|
||||
border-top-color:var(--accent);
|
||||
animation:det-spin .7s linear infinite;
|
||||
}
|
||||
@keyframes det-spin{to{transform:rotate(360deg)}}
|
||||
|
||||
.empty{
|
||||
padding:32px 16px;text-align:center;color:var(--muted);
|
||||
background:rgba(255,255,255,.03);
|
||||
border:1px dashed rgba(255,255,255,.1);
|
||||
border-radius:var(--radius-xl);font-size:14px;
|
||||
}
|
||||
.close-bar{display:none}
|
||||
|
||||
/* ========== 平板(≥600px)优化 ========== */
|
||||
@media(min-width:600px){
|
||||
.brand{font-size:22px}
|
||||
.btn{font-size:15px;padding:0 16px}
|
||||
}
|
||||
|
||||
/* ========== Tab S9 / 大平板(≥720px)========== */
|
||||
@media(min-width:720px){
|
||||
.panel{
|
||||
max-width:820px;margin:32px auto;
|
||||
border-radius:var(--radius-xl);
|
||||
min-height:auto;
|
||||
padding:24px 22px 32px;
|
||||
}
|
||||
.head{flex-direction:row;align-items:flex-start}
|
||||
.pic{width:160px}
|
||||
.title{font-size:22px}
|
||||
.pan-item{
|
||||
gap:14px;padding:14px 18px;
|
||||
}
|
||||
.pan-name{font-size:14px}
|
||||
.pan-actions{flex-wrap:nowrap}
|
||||
}
|
||||
|
||||
/* ========== 大屏平板 / 桌面(≥960px)========== */
|
||||
@media(min-width:960px){
|
||||
.app{max-width:1400px;margin:0 auto}
|
||||
}
|
||||
@media(min-width:1200px){
|
||||
.app{max-width:1400px;margin:0 auto}
|
||||
}
|
||||
|
||||
/* 盘搜框:固定约 6 条高度,资源数量变化时不再高低跳动,不足 6 条留白 */
|
||||
.pan-search-box .pan-list{height:400px;overflow-y:auto}
|
||||
/* 详情页“网盘资源 / 盘搜资源”两块:默认竖排堆叠 */
|
||||
.det-pan-cols{display:block}
|
||||
/* 平板横屏:两块并排、各自内部滚动、固定等高(对齐不层次不齐)*/
|
||||
@media(min-width:900px) and (orientation:landscape){
|
||||
.panel{max-width:1120px}
|
||||
.det-pan-cols{display:grid;grid-template-columns:1fr 1fr;gap:20px;align-items:start}
|
||||
.det-pan-col{min-width:0;display:flex;flex-direction:column}
|
||||
.det-pan-col>.det-sec-label{margin-top:0}
|
||||
.det-pan-col>.pan-list{height:400px;overflow-y:auto} /* 网盘资源框:定高,与盘搜框统一 */
|
||||
.det-pan-col>.pan-search-box{height:400px;display:flex;flex-direction:column} /* 盘搜框:与左侧等高 */
|
||||
.det-pan-col>.pan-search-box>.pan-list{flex:1;min-height:0;max-height:none;overflow-y:auto}
|
||||
}
|
||||
|
||||
/* ========== 悬停效果仅限有鼠标的设备 ========== */
|
||||
@media(hover:hover){
|
||||
.card:hover{
|
||||
transform:translateY(-3px);
|
||||
border-color:rgba(var(--accent2-rgb),.3);
|
||||
}
|
||||
.card:hover .poster img{transform:scale(1.05)}
|
||||
.card:active{transform:none;opacity:1}
|
||||
.btn:hover{background:rgba(60,72,88,.95);transform:translateY(-1px)}
|
||||
.btn.primary:hover{opacity:.92}
|
||||
.det-play-btn:hover{opacity:.92}
|
||||
.tab:hover,.chip:hover{background:rgba(50,60,74,.9);color:rgba(255,255,255,.75)}
|
||||
}
|
||||
/* ========== 回顶按钮(独立悬浮 · 可拖动贴边)========== */
|
||||
#backTop{
|
||||
position:fixed;
|
||||
right:0;
|
||||
top:58%;
|
||||
z-index:60;
|
||||
width:50px;height:50px;
|
||||
border-radius:25px 0 0 25px;
|
||||
background:rgba(28,34,46,.82);
|
||||
backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);
|
||||
border:1px solid rgba(255,255,255,.18);
|
||||
border-right:none;
|
||||
box-shadow:-3px 0 18px rgba(0,0,0,.45);
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
cursor:pointer;
|
||||
opacity:0;pointer-events:none;
|
||||
touch-action:none; /* 允许拖动,避免与页面滚动冲突 */
|
||||
transition:opacity .24s ease, transform .14s ease;
|
||||
}
|
||||
#backTop.show{opacity:1;pointer-events:auto}
|
||||
/* 详情页(.sheet)打开期间,回到顶部按钮不应出现——它的 z-index 高于详情页会悬浮遮挡 */
|
||||
body:has(.sheet.active) #backTop{opacity:0!important;pointer-events:none!important}
|
||||
/* 贴靠左侧时镜像圆角与阴影 */
|
||||
#backTop.left{
|
||||
border-radius:0 25px 25px 0;
|
||||
border-right:1px solid rgba(255,255,255,.18);
|
||||
border-left:none;
|
||||
box-shadow:3px 0 18px rgba(0,0,0,.45);
|
||||
}
|
||||
#backTop.dragging{opacity:1;transition:none;box-shadow:0 8px 24px rgba(0,0,0,.5)}
|
||||
#backTop:active{background:rgba(45,54,72,.92);transform:scale(.92)}
|
||||
#backTop svg{display:block;flex-shrink:0}
|
||||
|
||||
/* ========== 轻提示 toast(收藏反馈等)========== */
|
||||
#toast{
|
||||
position:fixed;left:50%;bottom:calc(var(--safe-bottom) + 96px);
|
||||
transform:translateX(-50%) translateY(10px);
|
||||
z-index:70;
|
||||
background:rgba(20,26,34,.96);color:#fff;
|
||||
padding:9px 18px;border-radius:999px;
|
||||
font-size:13px;font-weight:700;white-space:nowrap;
|
||||
border:1px solid rgba(255,255,255,.14);
|
||||
box-shadow:0 6px 22px rgba(0,0,0,.5);
|
||||
-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);
|
||||
opacity:0;pointer-events:none;
|
||||
transition:opacity .2s ease, transform .2s ease;
|
||||
}
|
||||
#toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
|
||||
|
||||
/* ========== 搜索记录 ========== */
|
||||
#searchHistory{
|
||||
position:absolute;left:-14px;right:-14px;top:calc(100% + 10px);z-index:60;
|
||||
max-height:240px;overflow-y:auto;overflow-x:hidden;
|
||||
-webkit-overflow-scrolling:touch;
|
||||
border-radius:var(--radius-lg);
|
||||
background:linear-gradient(150deg,rgba(var(--accent-rgb),.22),rgba(var(--accent2-rgb),.10) 60%,rgba(13,17,23,.55));
|
||||
backdrop-filter:blur(22px) saturate(1.5);-webkit-backdrop-filter:blur(22px) saturate(1.5);
|
||||
border:1px solid rgba(var(--accent2-rgb),.25);
|
||||
box-shadow:var(--shadow-deep);
|
||||
display:none;
|
||||
padding:4px 0;
|
||||
}
|
||||
#searchHistory.show{display:block}
|
||||
.sh-head{
|
||||
display:flex;align-items:center;justify-content:space-between;
|
||||
padding:8px 14px 4px;
|
||||
font-size:10px;font-weight:800;letter-spacing:1px;
|
||||
color:rgba(255,255,255,.32);
|
||||
}
|
||||
.sh-clear{
|
||||
color:rgba(255,255,255,.4);font-size:12px;font-weight:600;
|
||||
cursor:pointer;padding:2px 6px;border-radius:6px;
|
||||
}
|
||||
.sh-clear:active{color:var(--accent);background:rgba(255,255,255,.06)}
|
||||
.sh-item{
|
||||
display:flex;align-items:center;justify-content:space-between;gap:8px;
|
||||
padding:9px 14px;cursor:pointer;
|
||||
border-bottom:1px solid rgba(255,255,255,.05);
|
||||
}
|
||||
.sh-item:last-child{border-bottom:0}
|
||||
.sh-item:active{background:rgba(var(--accent-rgb),.18)}
|
||||
@media(hover:hover){.sh-item:hover{background:rgba(255,255,255,.06)}}
|
||||
.sh-kw{
|
||||
display:flex;align-items:center;gap:9px;min-width:0;
|
||||
font-size:14px;color:rgba(255,255,255,.85);
|
||||
overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
|
||||
}
|
||||
.sh-kw svg{flex-shrink:0;color:rgba(255,255,255,.32)}
|
||||
.sh-del{
|
||||
flex-shrink:0;width:22px;height:22px;display:grid;place-items:center;
|
||||
color:rgba(255,255,255,.32);border-radius:50%;
|
||||
}
|
||||
.sh-del:active{color:var(--accent);background:rgba(255,255,255,.08)}
|
||||
.sh-empty{padding:18px 14px;text-align:center;font-size:12px;color:rgba(255,255,255,.35)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="bgfog"></div>
|
||||
<div id="searchFocusMask"></div>
|
||||
<div id="searchOverlay"><div class="search-loading"><div class="det-spinner"></div><span>聚合搜索中…</span></div></div>
|
||||
<div class="app">
|
||||
<div class="bar">
|
||||
<div class="bar-bottom">
|
||||
<div class="search-wrap">
|
||||
<form class="search" id="searchForm" style="padding:0 6px 0 6px">
|
||||
<button class="btn btn-icon" id="aggBtn" title="切换聚合/单站" style="position:relative;z-index:5;overflow:visible;width:30px;height:30px;padding:0;flex-shrink:0;background:rgba(255,255,255,.08);border-color:rgba(255,255,255,.15);color:#fff">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M5 12h14"/><path d="M5 6l7 6-7 6"/><path d="M12 6l7 6-7 6"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div style="width:1px;height:18px;background:rgba(255,255,255,.15);flex-shrink:0"></div>
|
||||
<input id="kw" placeholder="玩偶聚合搜索…">
|
||||
<button class="btn primary" style="width:36px;height:32px;padding:0;min-width:unset;flex-shrink:0" title="搜索">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
</button>
|
||||
<div class="search-sugg" id="searchSugg"></div>
|
||||
<div id="searchHistory"></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navrow">
|
||||
<!-- 底部联体胶囊:站源(左)+ 搜索(右)-->
|
||||
<div class="bottom-dock" id="bottomDock">
|
||||
<!-- 左:站源切换 -->
|
||||
<div class="src-row" id="srcRow"></div>
|
||||
<!-- 中:分隔线 -->
|
||||
<div class="dock-sep" id="dockSep"></div>
|
||||
<!-- 右:搜索按钮 -->
|
||||
<div class="search-pill" id="searchPill">
|
||||
<button class="search-pill-icon" id="srcSearchBtn" title="搜索">
|
||||
<svg width="22" height="22" 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>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="src-sep"></div>
|
||||
<div class="chips" id="chips"></div>
|
||||
</div>
|
||||
<div class="status" id="status">准备加载…</div>
|
||||
<main id="content"></main>
|
||||
</div>
|
||||
<button id="backTop" aria-label="回到顶部">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,.9)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="18 15 12 9 6 15"/>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- 自绘弹窗:替代 prompt(),用于设置盘搜接口地址 -->
|
||||
<div class="custom-modal-mask" id="panCfgMask">
|
||||
<div class="custom-modal">
|
||||
<div class="custom-modal-title">盘搜设置</div>
|
||||
<input class="custom-modal-input" id="panCfgInput" type="text" inputmode="url" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||
<div class="pan-type-cfg" id="panTypeCfg" style="display:none">
|
||||
<div class="pan-type-cfg-label"><span>网盘类型(多选)</span><button type="button" class="pan-type-all" id="panTypeAll">全选</button></div>
|
||||
<div class="pan-type-grid" id="panTypeGrid"></div>
|
||||
</div>
|
||||
<div class="custom-modal-actions">
|
||||
<button class="custom-modal-btn" id="panCfgCancel">取消</button>
|
||||
<button class="custom-modal-btn accent" id="panCfgOk">确定</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Emby 风格全屏海报背景:独立于 sheet(放在 sheet 外),避免 sheet 的 transform 让 fixed 失效 -->
|
||||
<div class="det-hero-bg" id="detHeroBg"><div class="det-hero-bg-img" id="detHeroBgA"></div><div class="det-hero-bg-img" id="detHeroBgB"></div><div class="det-hero-scrim"></div></div>
|
||||
<div class="sheet" id="sheet">
|
||||
<!-- 独立海报大图:竖版按2:3比例展示,横版由JS检测后自动切换为16:9比例 -->
|
||||
<div class="det-hero" id="detHero">
|
||||
<img class="det-hero-img" id="detHeroImg" referrerpolicy="no-referrer" src="" alt="">
|
||||
<div class="det-hero-cta" id="detHeroCta"></div>
|
||||
</div>
|
||||
<!-- 吸顶导航:已去除返回按钮,仅显示标题 -->
|
||||
<div class="det-topbar" id="detTopbar">
|
||||
<div class="det-topbar-title" id="detTopbarTitle"></div>
|
||||
</div>
|
||||
<!-- 内容 -->
|
||||
<div class="panel" id="panel"></div>
|
||||
</div>
|
||||
<script>(function(){'use strict';
|
||||
|
||||
/* ========================================================
|
||||
全屏 / Chrome 模式管理
|
||||
文档:fm.ui.setChrome / fm.ui.restoreChrome / fmviewport
|
||||
======================================================== */
|
||||
var _chromeReady = false;
|
||||
|
||||
/* 初始化 edge 全屏融合模式(首页推荐模式:隐藏原生顶底栏,WebView 铺满窗口) */
|
||||
function initChrome() {
|
||||
if (_chromeReady || !window.fm || !fm.ui) return;
|
||||
_chromeReady = true;
|
||||
try {
|
||||
fm.ui.setChrome({
|
||||
mode: 'edge',
|
||||
statusBarStyle: 'light',
|
||||
navigationBarStyle: 'light',
|
||||
restoreAffordance: 'auto',
|
||||
scrim: { top: 'transparent', bottom: 'transparent' },
|
||||
startup: true
|
||||
});
|
||||
} catch(e) {}
|
||||
// 首帧主动读一次当前视口状态,fmviewport 事件只在后续变化时才触发
|
||||
try {
|
||||
var vp = fm.ui.getViewport && fm.ui.getViewport();
|
||||
if (vp && vp.chromeMode) _applyChromeModeClass(vp.chromeMode);
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
/* 监听 Native 安全区变化,同步更新 CSS 变量 */
|
||||
window.addEventListener('fmviewport', function(e) {
|
||||
var d = e.detail || {};
|
||||
var r = document.documentElement.style;
|
||||
if (d.safeTop !== undefined) r.setProperty('--safe-top', d.safeTop + 'px');
|
||||
if (d.safeBottom !== undefined) r.setProperty('--safe-bottom', d.safeBottom + 'px');
|
||||
if (d.safeLeft !== undefined) r.setProperty('--safe-left', d.safeLeft + 'px');
|
||||
if (d.safeRight !== undefined) r.setProperty('--safe-right', d.safeRight + 'px');
|
||||
if (d.chromeMode !== undefined) _applyChromeModeClass(d.chromeMode);
|
||||
});
|
||||
|
||||
/* 根据 chromeMode 给 html 打 class,非全屏(normal)时去掉顶部多余的安全区叠加 */
|
||||
function _applyChromeModeClass(mode) {
|
||||
var cl = document.documentElement.classList;
|
||||
cl.toggle('chrome-normal', mode === 'normal');
|
||||
cl.toggle('chrome-edge', mode !== 'normal');
|
||||
}
|
||||
|
||||
/* ── History 哨兵 + popstate(参考 Eclipse) ──
|
||||
栈结构(底→顶):[sentinel(无hash), detail(#detail)]
|
||||
popstate 只看实际 UI 状态,不依赖 event.state */
|
||||
|
||||
// 初始化:清除残留 hash,建立底层哨兵
|
||||
if (history.scrollRestoration) history.scrollRestoration = 'manual';
|
||||
if (location.hash) history.replaceState({}, '', location.pathname + location.search);
|
||||
|
||||
function _ensureHome() {
|
||||
// 回到主页时补一条无 hash 哨兵,防止再 back 退出 App
|
||||
if (!location.hash)
|
||||
history.pushState({ wo: 'home' }, '', location.pathname + location.search);
|
||||
}
|
||||
|
||||
// 是否处于搜索结果页:直接读内容区实际状态,不用 JS 标志位(更不容易和真实状态错位)
|
||||
function _inSearchMode(){
|
||||
return !!content && content.dataset.mode === 'search';
|
||||
}
|
||||
function _enterSearchHistory() {
|
||||
if (location.hash !== '#search')
|
||||
history.pushState({ wo: 'search' }, '', '#search');
|
||||
}
|
||||
function _exitSearchToHome() {
|
||||
_searchGen++; // 让仍在后台跑的旧搜索结果作废
|
||||
_catActive=true;
|
||||
if (kw) kw.value = '';
|
||||
hideSugg();
|
||||
_clearSearchUI();
|
||||
const snap = _catSnapshot;
|
||||
// 站源 + 分类一致且有缓存内容 → 直接还原,不再发网络请求
|
||||
if (snap && snap.site === activeSite && snap.cat === activeCat && snap.list && snap.list.length) {
|
||||
page = snap.page || 1;
|
||||
renderGrid(snap.list, true);
|
||||
if (snap.statusText) status.textContent = snap.statusText;
|
||||
requestAnimationFrame(() => window.scrollTo(0, snap.scrollY || 0));
|
||||
} else {
|
||||
loadCategory();
|
||||
}
|
||||
}
|
||||
|
||||
function _doCloseSheet() {
|
||||
const sheet = $('#sheet');
|
||||
_currentDetailItem=null; // 作废任何仍在 await 的 _loadDetailContent
|
||||
if (!sheet.classList.contains('active')) return;
|
||||
sheet.classList.remove('landscape-mode'); // 先恢复 transform 能力,否则滑出动画会被 transform:none 卡住
|
||||
stopBgRotation();
|
||||
{const bg=document.getElementById('detHeroBg');if(bg){bg.style.transition='none';bg.classList.remove('show');const A=document.getElementById('detHeroBgA'),B=document.getElementById('detHeroBgB');if(A){A.style.transition='none';A.style.opacity='0';}if(B){B.style.transition='none';B.style.opacity='0';}requestAnimationFrame(()=>requestAnimationFrame(()=>{bg.style.transition='';if(A)A.style.transition='';if(B)B.style.transition='';}));}} // 隐藏全屏海报背景(瞬间,双 rAF 确保 opacity:0 已提交再恢复 transition)
|
||||
sheet.classList.remove('active');
|
||||
const topbar = $('#detTopbar');
|
||||
if (topbar) topbar.classList.remove('scrolled');
|
||||
if (sheet._detScrollCb) {
|
||||
sheet.removeEventListener('scroll', sheet._detScrollCb);
|
||||
sheet._detScrollCb = null;
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('popstate', function() {
|
||||
if ($('#sheet').classList.contains('active')) {
|
||||
_doCloseSheet();
|
||||
_ensureHome();
|
||||
return;
|
||||
}
|
||||
// sheet 还未 active(openDetail 极速返回),但 detHeroBg 可能已 show,需清理
|
||||
_currentDetailItem=null; // 作废仍在 await 的 _loadDetailContent
|
||||
{const bg=document.getElementById('detHeroBg');if(bg&&bg.classList.contains('show')){bg.style.transition='none';bg.classList.remove('show');const A=document.getElementById('detHeroBgA'),B=document.getElementById('detHeroBgB');if(A){A.style.transition='none';A.style.opacity='0';}if(B){B.style.transition='none';B.style.opacity='0';}requestAnimationFrame(()=>requestAnimationFrame(()=>{bg.style.transition='';if(A)A.style.transition='';if(B)B.style.transition='';}));}}
|
||||
if (_inSearchMode()) {
|
||||
_exitSearchToHome();
|
||||
_ensureHome();
|
||||
return;
|
||||
}
|
||||
if (document.documentElement.classList.contains('search-focused')) {
|
||||
document.documentElement.classList.remove('search-focused');
|
||||
hideSugg();
|
||||
kw.blur();
|
||||
_ensureHome();
|
||||
return;
|
||||
}
|
||||
// 已在主页,放行(宿主处理退出)
|
||||
});
|
||||
|
||||
|
||||
if (window.fm && window.fm.ui) {
|
||||
initChrome();
|
||||
} else {
|
||||
var _chromeTimer = 0, _chromeTries = 0;
|
||||
_chromeTimer = setInterval(function() {
|
||||
if (window.fm && window.fm.ui) { clearInterval(_chromeTimer); initChrome(); return; }
|
||||
if (++_chromeTries > 30) clearInterval(_chromeTimer);
|
||||
}, 100);
|
||||
}
|
||||
|
||||
/* ======================================================== */
|
||||
// 本地存储持久化:收藏 + 历史记录
|
||||
const STORAGE_KEY_FAV = 'wo_fav_list';
|
||||
const STORAGE_KEY_HIST = 'wo_history_list';
|
||||
const STORAGE_KEY_SEARCH_HIST = 'wo_search_kw_history';
|
||||
let favList = JSON.parse(localStorage.getItem(STORAGE_KEY_FAV) || '[]');
|
||||
let historyList = JSON.parse(localStorage.getItem(STORAGE_KEY_HIST) || '[]');
|
||||
let searchKwHistory = (()=>{try{return JSON.parse(localStorage.getItem(STORAGE_KEY_SEARCH_HIST)||'[]')}catch(e){return []}})();
|
||||
|
||||
if(window.fongmiBridge||window.fm)document.documentElement.classList.add('fm-native');
|
||||
const SITES=[
|
||||
{id:'wanou',name:'玩偶',domains:['https://wogg.xxooo.cf','https://www.wogg.net'],listSelector:'.module-item',searchListSelector:'.module-search-item',detailPanSelector:'.module-row-info p',categoryUrl:'/vodshow/{categoryId}--------{page}---.html',searchUrl:'/vodsearch/-------------.html?wd={keyword}&page={page}',cats:[['history','最近'],['fav','收藏'],['44','臻彩'],['1','电影'],['2','电视剧'],['3','动漫'],['4','综艺'],['5','音乐'],['6','短剧'],['46','纪录片']]},
|
||||
{id:'muou',name:'木偶',domains:['https://www.muou.site','https://www.muou.asia','https://666.666291.xyz'],listSelector:'#main .module-item',searchListSelector:'.module-search-item',detailPanSelector:'.module-row-info p',cats:[['25','臻选'],['1','电影'],['2','电视剧'],['3','动漫'],['4','纪录片'],['29','综艺'],['30','原盘']]},
|
||||
{id:'labi',name:'蜡笔',domains:['http://xiaocgege.shop','http://xiaocge.fun','http://feimo.fun'],listSelector:'#main .module-item',searchListSelector:'.module-search-item',detailPanSelector:'.module-row-info p',cats:[['29','臻彩'],['1','电影'],['2','电视剧'],['3','动漫'],['4','综艺'],['5','短剧'],['24','蜡笔4K']]},
|
||||
{id:'zhizhen',name:'至臻',domains:['https://zhizhen1.top','https://www.mihdr.top','https://mihdr.top'],listSelector:'#main .module-item',searchListSelector:'.module-search-item',detailPanSelector:'.module-row-info p',cats:[['26','臻彩'],['1','电影'],['2','电视剧'],['3','动漫'],['4','综艺'],['5','短剧'],['24','老剧']]},
|
||||
{id:'erxiao',name:'二小',domains:['https://www.2xiaopan.top'],listSelector:'#main .module-item',searchListSelector:'.module-search-item',detailPanSelector:'.module-row-info p',cats:[['4','臻彩'],['1','电影'],['2','电视剧'],['3','动漫'],['21','综艺']]},
|
||||
{id:'huban',name:'虎斑',domains:['http://38.76.197.172:16969','http://103.45.162.207:20720','http://xhban.xyz:20720'],listSelector:'#main .module-item',searchListSelector:'.module-search-item',detailPanSelector:'.module-row-info p',cats:[['6','臻彩'],['1','电影'],['2','电视剧'],['3','综艺'],['4','动漫'],['5','短剧'],['30','115网盘']]},
|
||||
{id:'kuaiying',name:'快映',domains:['http://103.45.162.207:12512','http://38.76.197.172:12521'],listSelector:'#main .module-item',searchListSelector:'.module-search-item',detailPanSelector:'.module-row-info p',cats:[['5','臻彩'],['1','电影'],['2','电视剧'],['3','综艺'],['4','动漫'],['6','短剧'],['30','115'],['35','123'],['36','天移迅']]},
|
||||
{id:'shandian',name:'闪电',domains:['http://shandian.blog','http://sd.sduc.site'],listSelector:'#main .module-item',searchListSelector:'.module-search-item',detailPanSelector:'.module-row-info p',cats:[['1','电影'],['2','电视剧'],['3','综艺'],['4','动漫'],['30','短剧']]},
|
||||
{id:'ouge',name:'欧哥',domains:['https://woog.nxog.eu.org'],listSelector:'#main .module-item',searchListSelector:'.module-search-item',detailPanSelector:'.module-row-info p',cats:[['1','电影'],['2','电视剧'],['3','动漫'],['4','综艺'],['5','短剧'],['21','综合']]}
|
||||
];
|
||||
let activeSite='wanou',activeCat='',page=1,aggregate=true,last=[];
|
||||
// 是否处于“分类浏览”态,专门给左右滑动手势用。
|
||||
// 之前滑动判定依赖 content.dataset.mode==='category',但这个值要等一次分类请求真正渲染完成才会被设置,
|
||||
// 导致“滑到下一类、还在加载”的这段时间里 dataset.mode 可能还是上一次的值(甚至是 search),滑动手势被锁住、必须等加载完。
|
||||
// 改成这个显式标志:一旦“决定要看分类”(滑动/点击分类、切站源、从搜索返回分类)就立刻置 true,不等网络请求结果,做到跟手;
|
||||
// 只有真正离开分类浏览(进入搜索)才置 false。
|
||||
let _catActive=true;
|
||||
const CATS_CACHE={}; // 各站自动抓取到的真实分类缓存(按 siteId)
|
||||
const $=s=>document.querySelector(s),chips=$('#chips'),content=$('#content'),status=$('#status');
|
||||
|
||||
// ===== 自绘输入弹窗:替代原生 prompt(),避免浏览器自带的“网址为 xxx 的网页显示”样式 =====
|
||||
function customPrompt(title,defaultValue){
|
||||
return new Promise(resolve=>{
|
||||
const mask=$('#panCfgMask'),input=$('#panCfgInput'),okBtn=$('#panCfgOk'),cancelBtn=$('#panCfgCancel'),titleEl=mask&&mask.querySelector('.custom-modal-title');
|
||||
if(!mask){resolve(null);return}
|
||||
const typeCfg=$('#panTypeCfg'); if(typeCfg) typeCfg.style.display='none';
|
||||
if(titleEl) titleEl.textContent=title||'';
|
||||
input.value=defaultValue||'';
|
||||
mask.classList.add('show');
|
||||
requestAnimationFrame(()=>{input.focus();input.select()});
|
||||
let done=false;
|
||||
const finish=(val)=>{
|
||||
if(done)return;done=true;
|
||||
mask.classList.remove('show');
|
||||
okBtn.onclick=null;cancelBtn.onclick=null;mask.onclick=null;input.onkeydown=null;
|
||||
resolve(val);
|
||||
};
|
||||
okBtn.onclick=()=>finish(input.value);
|
||||
cancelBtn.onclick=()=>finish(null);
|
||||
mask.onclick=(e)=>{ if(e.target===mask) finish(null); };
|
||||
input.onkeydown=(e)=>{
|
||||
if(e.key==='Enter'){e.preventDefault();finish(input.value)}
|
||||
else if(e.key==='Escape'){e.preventDefault();finish(null)}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// 常用 DOM 引用缓存,避免热路径里反复查询
|
||||
const appEl=$('.app'),navrowEl=$('.navrow'),searchOverlayEl=$('#searchOverlay');
|
||||
// 分类返回快照:进入搜索前记下分类列表/滚动位置/页码,返回时秒级还原(不再重新请求)
|
||||
let _catSnapshot=null;
|
||||
// 左右滑动切换分类的临时状态
|
||||
let _pendingSlide=null,_swiped=false;
|
||||
// 统一清理搜索态的视觉效果(去模糊、关遮罩、退出聚焦)
|
||||
function _clearSearchUI(){
|
||||
if(navrowEl)navrowEl.classList.remove('blurred');
|
||||
content.classList.remove('searching');
|
||||
if(appEl)appEl.classList.remove('searching');
|
||||
if(searchOverlayEl)searchOverlayEl.classList.remove('show');
|
||||
document.documentElement.classList.remove('search-focused');
|
||||
}
|
||||
|
||||
// 本地存储工具函数
|
||||
function saveFav(){localStorage.setItem(STORAGE_KEY_FAV,JSON.stringify(favList))}
|
||||
function saveHistory(){localStorage.setItem(STORAGE_KEY_HIST,JSON.stringify(historyList))}
|
||||
function saveSearchKwHistory(){try{localStorage.setItem(STORAGE_KEY_SEARCH_HIST,JSON.stringify(searchKwHistory))}catch(e){}}
|
||||
function addSearchKwHistory(q){
|
||||
q=String(q||'').trim();
|
||||
if(!q)return;
|
||||
const idx=searchKwHistory.indexOf(q);
|
||||
if(idx>-1)searchKwHistory.splice(idx,1);
|
||||
searchKwHistory.unshift(q);
|
||||
if(searchKwHistory.length>30)searchKwHistory.length=30;
|
||||
saveSearchKwHistory();
|
||||
}
|
||||
function isFav(item){return favList.some(x=>x.href===item.href)}
|
||||
// 轻提示
|
||||
let _toastTimer=null;
|
||||
function toast(msg){
|
||||
let el=document.getElementById('toast');
|
||||
if(!el){el=document.createElement('div');el.id='toast';document.body.appendChild(el)}
|
||||
el.textContent=msg;el.classList.add('show');
|
||||
clearTimeout(_toastTimer);_toastTimer=setTimeout(()=>el.classList.remove('show'),1500);
|
||||
}
|
||||
function toggleFav(item){
|
||||
const idx = favList.findIndex(x=>x.href===item.href);
|
||||
const willFav = idx<0;
|
||||
if(idx>-1) favList.splice(idx,1);
|
||||
else favList.unshift(item);
|
||||
saveFav();
|
||||
// 正在浏览“收藏”分类时,取消收藏需从列表移除并重渲染
|
||||
if(content.dataset.mode==='category' && activeCat==='fav'){
|
||||
// 延后一帧重建网格:避免在手指仍按住(长按触发)时同步拆除被触摸的卡片,
|
||||
// 否则会残留卡片的 :active 缩放态并导致后续点击无响应
|
||||
status.textContent='我的收藏 · '+favList.length+' 条';
|
||||
requestAnimationFrame(()=>{
|
||||
if(activeCat==='fav'){renderGrid(favList,false);content.dataset.mode='category'}
|
||||
});
|
||||
}else{
|
||||
// 其它情况仅就地切换对应卡片的描边态,避免整页重渲染
|
||||
[...content.querySelectorAll('.card')].forEach(c=>{
|
||||
if(c.dataset.href===item.href) c.classList.toggle('is-fav',willFav);
|
||||
});
|
||||
}
|
||||
toast(willFav?'已收藏':'已取消收藏');
|
||||
return willFav;
|
||||
}
|
||||
function addHistory(item){
|
||||
const idx = historyList.findIndex(x=>x.href===item.href);
|
||||
if(idx>-1) historyList.splice(idx,1);
|
||||
historyList.unshift(item);
|
||||
if(historyList.length>60) historyList.length=60;
|
||||
saveHistory();
|
||||
}
|
||||
|
||||
function esc(s){return String(s||'').replace(/[&<>'"]/g,c=>({'&':'&','<':'<','>':'>',"'":''','"':'"'}[c]))}
|
||||
function clean(s){return String(s||'').replace(/\s+/g,' ').trim()}
|
||||
function site(){return SITES.find(x=>x.id===activeSite)||SITES[0]}
|
||||
function abs(base,u){try{return new URL(u,base+'/').href}catch(e){return u||''}}
|
||||
// 等待原生桥接注入:App 可能在页面脚本之后才注入 window.fm,最多等 4 秒
|
||||
let _fmReadyP=null;
|
||||
function fmReady(){
|
||||
if(_fmReadyP)return _fmReadyP;
|
||||
_fmReadyP=new Promise(res=>{
|
||||
if(window.fm)return res(window.fm);
|
||||
let t=0;const iv=setInterval(()=>{t+=60;if(window.fm||t>4000){clearInterval(iv);res(window.fm||null)}},60);
|
||||
});
|
||||
return _fmReadyP;
|
||||
}
|
||||
// 仅在「非 App 的浏览器网页」且直连失败时才会用到的 CORS 代理兜底(App 内不会走到这里)
|
||||
const CORS_PROXIES=[
|
||||
u=>'https://api.allorigins.win/raw?url='+encodeURIComponent(u),
|
||||
u=>'https://corsproxy.io/?url='+encodeURIComponent(u)
|
||||
];
|
||||
async function req(url,timeoutSec=16,quiet=false){
|
||||
if(!quiet)status.textContent='请求:'+url;
|
||||
// ① 原生 App 桥接:直连(等桥接就绪后再判断,避免早于注入时机误走浏览器 fetch)
|
||||
const fm=await fmReady();
|
||||
if(fm&&fm.req){
|
||||
let r=await fm.req(url,{method:'GET',headers:{'User-Agent':'Mozilla/5.0','Referer':url},responseType:'text',timeout:timeoutSec});
|
||||
if(!r.ok)throw new Error(r.error||('HTTP '+r.status));
|
||||
return r.body||''
|
||||
}
|
||||
// ② 没有原生桥接(纯浏览器网页)才走这里:先直连
|
||||
try{
|
||||
const ac=new AbortController();const t=setTimeout(()=>ac.abort(),timeoutSec*1000);
|
||||
let r;
|
||||
try{r=await fetch(url,{headers:{'User-Agent':'Mozilla/5.0'},signal:ac.signal})}finally{clearTimeout(t)}
|
||||
return await r.text()
|
||||
}catch(e){
|
||||
// 直连被 CORS / 混合内容拦截时,再尝试 CORS 代理(仅浏览器网页场景)
|
||||
let lastErr=e.message;
|
||||
for(const mk of CORS_PROXIES){
|
||||
try{let r=await fetch(mk(url));if(r.ok){let t=await r.text();if(t)return t}lastErr='HTTP '+r.status}
|
||||
catch(err){lastErr=err.message}
|
||||
}
|
||||
throw new Error(lastErr||'请求失败');
|
||||
}
|
||||
}
|
||||
// ===== 可用域名缓存 =====
|
||||
// 过去多域名站每次请求都「同时」向所有备用域名发请求。聚合搜索时 9 个站一起跑,
|
||||
// 每站再放大成 2~3 个并发,瞬间二十多个请求打满原生网络桥接,排在后面的站全超时——
|
||||
// 这正是「聚合只出最前面玩偶/木偶、后面站搜不到」的根因(单站搜只发 1 个站故不受影响)。
|
||||
// 改为:记住每个站上次成功的那个域名,之后只打这一个(单连接),把聚合并发压到约 9 个;
|
||||
// 缓存域名失效时才回退到并发竞速重新发现,并更新缓存。持久化到 localStorage 跨会话复用。
|
||||
const DOMAIN_CACHE=(()=>{try{return JSON.parse(localStorage.getItem('wo_domain_cache')||'{}')}catch(e){return {}}})();
|
||||
function _saveDomainCache(){try{localStorage.setItem('wo_domain_cache',JSON.stringify(DOMAIN_CACHE))}catch(e){}}
|
||||
async function get(site,path,timeoutSec=16,quiet=false){
|
||||
// 绝对 URL(例如详情页 href,已经带着真实域名):不依赖站源域名列表,直接请求一次。
|
||||
if(path.startsWith('http')){
|
||||
let h=await req(path,timeoutSec,quiet);
|
||||
if(!h||/just a moment|cf-browser|access denied/i.test(h))throw new Error('响应被拦截或为空');
|
||||
let base=path;try{const u=new URL(path);base=u.protocol+'//'+u.host}catch(e){}
|
||||
return{html:h,base}
|
||||
}
|
||||
const domains=site.domains;
|
||||
// 单个域名探测:成功返回 {html,base},失败抛错
|
||||
const tryOne=async(d,to)=>{
|
||||
const base=d.replace(/\/+$/,''),u=base+path;
|
||||
let h=await req(u,to,quiet);
|
||||
if(!h||/just a moment|cf-browser|access denied/i.test(h))throw new Error('域名被拦截或无响应');
|
||||
return{html:h,base,_domain:d}
|
||||
};
|
||||
if(domains.length===1){
|
||||
return await tryOne(domains[0],timeoutSec);
|
||||
}
|
||||
// 多域名:① 优先只打「上次成功的域名」(单连接,大幅降低聚合并发压力)
|
||||
const cached=DOMAIN_CACHE[site.id];
|
||||
if(cached&&domains.includes(cached)){
|
||||
try{
|
||||
// 缓存域名给稍短超时快速验证;通过就直接用,不再惊动其他域名
|
||||
return await tryOne(cached,Math.min(timeoutSec,10));
|
||||
}catch(e){ /* 缓存域名失效,落到下面并发竞速重新发现 */ }
|
||||
}
|
||||
// ② 没有可用缓存:并发竞速所有域名,谁先成功用谁,并把它记进缓存供下次单连接复用
|
||||
return new Promise((resolve,reject)=>{
|
||||
let pending=domains.length,lastErr='',settled=false;
|
||||
domains.forEach(d=>{
|
||||
const base=d.replace(/\/+$/,''),u=base+path;
|
||||
req(u,timeoutSec,quiet).then(h=>{
|
||||
if(settled)return;
|
||||
if(h&&!/just a moment|cf-browser|access denied/i.test(h)){
|
||||
settled=true;
|
||||
if(DOMAIN_CACHE[site.id]!==d){DOMAIN_CACHE[site.id]=d;_saveDomainCache()}
|
||||
resolve({html:h,base});
|
||||
}
|
||||
else{lastErr='响应被拦截';if(--pending===0&&!settled)reject(new Error(lastErr||'全部域名失败'))}
|
||||
}).catch(e=>{
|
||||
if(settled)return;
|
||||
lastErr=e.message;
|
||||
if(--pending===0&&!settled)reject(new Error(lastErr||'全部域名失败'));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
function doc(h){return new DOMParser().parseFromString(h,'text/html')}
|
||||
function imgUrl(u){
|
||||
if(!u)return'';
|
||||
u=u.replace(/^\/\//,'https://');
|
||||
let m=u.match(/^https?:\/\/image\.baidu\.com\/search\/down\?url=([^&]+)/i);
|
||||
return m?decodeURIComponent(m[1]):u
|
||||
}
|
||||
// 自动识别清晰度
|
||||
function getQuality(text){
|
||||
if(/4K|2160/i.test(text))return'4K';
|
||||
if(/1080|1080P/i.test(text))return'1080P';
|
||||
if(/720|720P/i.test(text))return'720P';
|
||||
return'';
|
||||
}
|
||||
// 去除站源标题里常见的"立即播放/立刻播放/在线观看 XX"之类统一文案前缀,
|
||||
// 只保留真正的片名部分,避免污染显示标题和 TMDB 匹配
|
||||
function stripTitleNoise(t){
|
||||
const orig=String(t||'').trim();
|
||||
let r=orig
|
||||
.replace(/^[【\[]?(?:立即|立刻|马上|火速|超清|高清|蓝光)?(?:播放|观看|看片|点播|追剧)(?:[::、\s]+)?/,'')
|
||||
.replace(/^(?:在线观看|在线播放|免费观看|免费播放)(?:[::、\s]+)?/,'')
|
||||
.trim();
|
||||
// 保护:若清洗后为空,或剩余内容过短(≤1字,疑似把片名也吃掉了),则保留原标题
|
||||
if(!r||r.length<=1)return orig;
|
||||
return r;
|
||||
}
|
||||
function cardFrom(el,base,site){
|
||||
let a=el.querySelector('a[href]')||el.closest('a[href]')||el,href=a.getAttribute('href')||'';
|
||||
let img=el.querySelector('img'),title=a.getAttribute('title')||img&&img.getAttribute('alt')||clean(el.querySelector('.module-item-title,.module-search-item-title,.video-title,.title')?.textContent)||clean(a.textContent);
|
||||
let pic=imgUrl(img&&(img.getAttribute('data-src')||img.getAttribute('data-original')||img.getAttribute('src'))||'');
|
||||
let remark=clean(el.querySelector('.module-item-note,.module-search-item-note,.video-info,.note')?.textContent)||site.name;
|
||||
return{
|
||||
title:stripTitleNoise(title.replace(/\s+/g,' ')).slice(0,60),
|
||||
href:abs(base,href),
|
||||
pic:abs(base,pic),
|
||||
remark,
|
||||
siteId:site.id,
|
||||
siteName:site.name,
|
||||
quality:getQuality(title+' '+remark)
|
||||
}
|
||||
}
|
||||
// ====== 自动抓取站点真实分类 ======
|
||||
function firstRealCat(cats){const c=(cats||[]).find(x=>x[0]!=='history'&&x[0]!=='fav');return (c||cats[0]||['',''])[0]}
|
||||
function effCats(s){return CATS_CACHE[s.id]||s.cats}
|
||||
// 从页面导航里解析出分类(苹果CMS 常见的 vodshow/vodtype/type/id 等链接)
|
||||
function parseNavCats(d){
|
||||
const out=[],seen=new Set();
|
||||
const idFrom=(href)=>{
|
||||
let m;
|
||||
if(m=href.match(/\/vodshow\/(\d+)[-_\/.]/i))return m[1];
|
||||
if(m=href.match(/\/vodtype\/(\d+)/i))return m[1];
|
||||
if(m=href.match(/\/(?:type|show)\/id\/(\d+)/i))return m[1];
|
||||
if(m=href.match(/\/vod\/show\/id\/(\d+)/i))return m[1];
|
||||
if(m=href.match(/[?&](?:type|tid|id)=(\d+)/i))return m[1];
|
||||
if(m=href.match(/\/show\/(\d+)[-_\/.]/i))return m[1];
|
||||
return '';
|
||||
};
|
||||
let anchors=[];
|
||||
const navSel=['.module-nav a','.stui-header__menu a','.nav-menu a','.navbar-nav a','.head-nav a','header nav a','#nav a','.nav a'];
|
||||
for(const sel of navSel){const els=d.querySelectorAll(sel);if(els.length){anchors=[...els];break}}
|
||||
if(!anchors.length)anchors=[...d.querySelectorAll('a[href]')];
|
||||
anchors.forEach(a=>{
|
||||
const href=a.getAttribute('href')||'';
|
||||
const id=idFrom(href);
|
||||
if(!id||seen.has(id))return;
|
||||
let name=clean(a.textContent);
|
||||
if(!name||name.length>8)return;
|
||||
if(/首页|排行|榜单|热门|最新|专题|留言|演员|明星|求片|公告|APP|下载|登录|注册|会员|搜索|关于|友情|链接/.test(name))return;
|
||||
seen.add(id);out.push([id,name]);
|
||||
});
|
||||
return out.slice(0,24);
|
||||
}
|
||||
// 切到某站时确保拿到真实分类(抓首页导航,按 siteId 缓存;失败回退硬编码)
|
||||
async function ensureCats(s){
|
||||
if(CATS_CACHE[s.id])return CATS_CACHE[s.id];
|
||||
try{
|
||||
const r=await get(s,'/');
|
||||
const cats=parseNavCats(doc(r.html));
|
||||
if(cats.length>=2){
|
||||
const special=(s.cats||[]).filter(c=>c[0]==='history'||c[0]==='fav');
|
||||
const merged=special.concat(cats);
|
||||
CATS_CACHE[s.id]=merged;
|
||||
return merged;
|
||||
}
|
||||
}catch(e){}
|
||||
CATS_CACHE[s.id]=s.cats; // 兜底
|
||||
return s.cats;
|
||||
}
|
||||
function renderTabs(){
|
||||
const row=$('#srcRow');
|
||||
if(!row)return;
|
||||
row.innerHTML=SITES.map(s=>`<button class="src-tab ${activeSite===s.id?'on':''}" data-id="${s.id}">${s.name}</button>`).join('');
|
||||
|
||||
/* ── 收起/展开逻辑 ──
|
||||
收起态:点击整个坞 → 展开;展开态:点击某个 tab 切换站源 / 点击坞外 → 收起 */
|
||||
const _dock=document.getElementById('bottomDock');
|
||||
function collapse(){
|
||||
row.classList.remove('expanded');
|
||||
if(_dock)_dock.classList.remove('expanded');
|
||||
row.style.cursor='pointer';
|
||||
}
|
||||
function expand(){
|
||||
row.classList.add('expanded');
|
||||
if(_dock)_dock.classList.add('expanded');
|
||||
row.style.cursor='default';
|
||||
// 让选中项滚入可视区
|
||||
const on=row.querySelector('.src-tab.on');
|
||||
if(on)try{on.scrollIntoView({inline:'center',block:'nearest',behavior:'smooth'})}catch(e){}
|
||||
}
|
||||
|
||||
// 点击坞本体(收起态)→ 展开
|
||||
row.onclick=function(e){
|
||||
if(!row.classList.contains('expanded')){
|
||||
expand();
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// 点击具体 tab(展开态)→ 切换站源 + 收起
|
||||
row.querySelectorAll('.src-tab').forEach(b=>b.onclick=async(e)=>{
|
||||
if(!row.classList.contains('expanded')){
|
||||
// 未展开:先展开,不切换
|
||||
expand();
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
e.stopPropagation();
|
||||
if(b.dataset.id===activeSite){ collapse(); return; }
|
||||
_catActive=true;
|
||||
activeSite=b.dataset.id;
|
||||
collapse(); // 选完立刻收起
|
||||
renderTabs(); // 会重建 DOM,同时 collapse(默认不带 expanded)
|
||||
chips.innerHTML='';
|
||||
content.innerHTML='<div class="empty">加载中…</div>';
|
||||
const s=site();
|
||||
const cats=await ensureCats(s);
|
||||
activeCat=firstRealCat(cats);
|
||||
page=1;renderChips();loadCategory();
|
||||
});
|
||||
|
||||
// 点击坞外 → 收起(联体胶囊内的分隔线/搜索按钮不算坞外)
|
||||
const dock=document.getElementById('bottomDock');
|
||||
function outsideClick(e){
|
||||
if(!row.classList.contains('expanded'))return;
|
||||
if(!(dock||row).contains(e.target)){ collapse(); }
|
||||
}
|
||||
// 移除旧监听,防止叠加
|
||||
if(row._outsideClick) document.removeEventListener('click',row._outsideClick,true);
|
||||
row._outsideClick=outsideClick;
|
||||
document.addEventListener('click',outsideClick,true);
|
||||
}
|
||||
// 搜索代际计数器:每次「离开搜索结果页」就 +1,让仍在后台跑的旧搜索结果晚到也不会再去刷新当前页面
|
||||
let _searchGen=0,_catGen=0;
|
||||
function renderChips(){
|
||||
let s=site();
|
||||
const cats=effCats(s);
|
||||
if(!activeCat&&cats[0])activeCat=cats[0][0];
|
||||
chips.innerHTML=cats.map(c=>`<button class="chip ${activeCat===c[0]?'active':''}" data-id="${c[0]}">${c[1]}</button>`).join('');
|
||||
chips.querySelectorAll('.chip').forEach(b=>b.onclick=()=>activateCategory(b.dataset.id));
|
||||
// 选中的分类胶囊滚动到可见区域中央
|
||||
const act=chips.querySelector('.chip.active');
|
||||
if(act&&act.scrollIntoView)try{act.scrollIntoView({inline:'center',block:'nearest',behavior:'smooth'})}catch(e){}
|
||||
}
|
||||
// 切换分类(点击胶囊 / 左右滑动都走这里)。slideDir:'l' 右滑出现下一类,'r' 左滑出现上一类
|
||||
function activateCategory(id,slideDir){
|
||||
if(id==null)return;
|
||||
_catActive=true;
|
||||
activeCat=id;page=1;_pendingSlide=slideDir||null;renderChips();
|
||||
if(id==='history'){_searchGen++;_catGen++;_catSnapshot=null;renderGrid(historyList,false);content.dataset.mode='category';status.textContent='浏览历史 · '+historyList.length+' 条'}
|
||||
else if(id==='fav'){_searchGen++;_catGen++;_catSnapshot=null;renderGrid(favList,false);content.dataset.mode='category';status.textContent='我的收藏 · '+favList.length+' 条'}
|
||||
else loadCategory()
|
||||
}
|
||||
// 渲染卡片:右上角显示站源抓取的更新状态(取代原收藏按钮),收藏态用整卡描边表示
|
||||
function card(v,i){
|
||||
const favOn = isFav(v);
|
||||
// 画质角标已去除:和橙色状态角标内容经常重复重叠(如"4K HDR"),不再单独展示
|
||||
// 更新状态:去掉"年份 分类"等无关词,保留 HD/4K/更新至/完结 等有意义的状态
|
||||
function stripRemarkPrefix(r,siteName){
|
||||
if(!r)return'';
|
||||
const SKIP=/^(\d{4}|电影|电视剧|剧集|动漫|综艺|短剧|纪录片|音乐|原盘)$/;
|
||||
const words=r.split(/\s+/);
|
||||
const kept=words.filter(w=>w&&!SKIP.test(w)); // 去掉分类/年份,但保留 siteName
|
||||
const result=kept.join(' ').trim();
|
||||
// 如果过滤后为空或只剩 siteName,不显示
|
||||
return (result&&result!==siteName) ? result : '';
|
||||
}
|
||||
// 在聚合模式下,只用第一个源的remark;单源时用v的remark
|
||||
const remarksToCheck = v.sources ? [v.sources[0].remark] : [v.remark];
|
||||
const st = remarksToCheck.map(r => stripRemarkPrefix(r, v.siteName)).filter(Boolean)[0] || '';
|
||||
const statusTag = st ? `<span class="status-badge" title="${esc(st)}">${esc(st)}</span>` : '';
|
||||
// 聚合时同一资源常被多个站收录(它们被合并进 v.sources)。
|
||||
// badge 显示「N 站」,标题下方再完整列出所有来源站名,让用户一眼看到资源有多全。
|
||||
const siteNames = v.sources ? [...new Set(v.sources.map(x=>x.siteName).filter(Boolean))] : [v.siteName].filter(Boolean);
|
||||
const multi = siteNames.length>1;
|
||||
const badgeText = multi ? (siteNames.length+'站') : (siteNames[0]||v.siteName||'');
|
||||
const srcLine = multi ? `<div class="srcline">${esc(siteNames.join(' · '))}</div>` : '';
|
||||
return`
|
||||
<article class="card${favOn?' is-fav':''}" data-i="${i}" data-href="${esc(v.href)}">
|
||||
<div class="poster">
|
||||
${v.pic?`<img loading="lazy" referrerpolicy="no-referrer" src="${esc(v.pic)}" onload="window._posterOk&&window._posterOk(this)" onerror="window._posterFail&&window._posterFail(this)">`:'<div class="noimg">宝宝巴士</div>'}
|
||||
${statusTag}
|
||||
<span class="badge">${esc(badgeText)}</span>
|
||||
<span class="source-badge"></span>
|
||||
</div>
|
||||
<div class="meta">
|
||||
<div class="name">${esc(v.title)}</div>
|
||||
${srcLine}
|
||||
</div>
|
||||
</article>`
|
||||
}
|
||||
function renderGrid(list,pager){
|
||||
last=list;
|
||||
if(window._backTopReset) _backTopReset();
|
||||
content.dataset.mode = pager ? 'category' : 'search';
|
||||
content.innerHTML=list.length
|
||||
?`<div class="grid">${list.map(card).join('')}</div>${pager?'<div id="infiniteSentinel" style="height:1px;margin:8px 0"></div>':''}`
|
||||
:'<div class="empty">暂无内容</div>';
|
||||
// 左右滑动切换分类时,给新网格加入场动画
|
||||
if(_pendingSlide){
|
||||
const g=content.querySelector('.grid');
|
||||
if(g){g.classList.add('slide-'+_pendingSlide);g.addEventListener('animationend',()=>g.classList.remove('slide-l','slide-r'),{once:true})}
|
||||
_pendingSlide=null;
|
||||
}
|
||||
// 收藏改为长按卡片切换(见底部滑动/长按手势模块)
|
||||
// 卡片打开详情
|
||||
content.querySelectorAll('.card').forEach(c=>c.onclick=()=>{
|
||||
const i=parseInt(c.dataset.i);openDetail(list[i])
|
||||
});
|
||||
// 无限加载:IntersectionObserver 监听底部哨兵
|
||||
if(pager){
|
||||
const sentinel=document.getElementById('infiniteSentinel');
|
||||
if(sentinel){
|
||||
if(window._infiniteObs)window._infiniteObs.disconnect();
|
||||
let _loading=false;
|
||||
window._infiniteObs=new IntersectionObserver(entries=>{
|
||||
if(!entries[0].isIntersecting||_loading)return;
|
||||
_loading=true;
|
||||
// 追加下一页到现有网格,不清空
|
||||
const curPage=page;
|
||||
page++;
|
||||
const s=site();
|
||||
get(s,catUrl(s,activeCat,page)).then(r=>{
|
||||
const d=doc(r.html);
|
||||
const els=[...d.querySelectorAll(s.listSelector||'.module-item')];
|
||||
const newList=els.map(e=>cardFrom(e,r.base,s)).filter(x=>x.href&&x.title).slice(0,60);
|
||||
if(!newList.length){
|
||||
// 没有更多了
|
||||
window._infiniteObs.disconnect();
|
||||
sentinel.innerHTML='<div style="text-align:center;padding:16px 0 8px;color:rgba(255,255,255,.28);font-size:12px">— 已经到底了 —</div>';
|
||||
return;
|
||||
}
|
||||
const startIdx=last.length;
|
||||
last=[...last,...newList];
|
||||
const grid=content.querySelector('.grid');
|
||||
if(grid) grid.insertAdjacentHTML('beforeend',newList.map((v,i)=>card(v,startIdx+i)).join(''));
|
||||
// 重新绑定新卡片事件
|
||||
const allCards=content.querySelectorAll('.card');
|
||||
allCards.forEach(c=>c.onclick=()=>{
|
||||
const i=parseInt(c.dataset.i);openDetail(last[i])
|
||||
});
|
||||
enhanceGridPosters(newList);
|
||||
status.textContent=`${s.name} · 第 ${page} 页 · ${last.length} 条`;
|
||||
// 同步快照,返回时保留已加载的多页内容与页码
|
||||
if(content.dataset.mode==='category')_catSnapshot={site:activeSite,cat:activeCat,list:last.slice(),page,scrollY:0,statusText:status.textContent};
|
||||
_loading=false;
|
||||
}).catch(()=>{page=curPage;_loading=false});
|
||||
},{rootMargin:'0px 0px 200px 0px'});
|
||||
window._infiniteObs.observe(sentinel);
|
||||
}
|
||||
}
|
||||
// 封面:先显示站源原图,再用 TMDB(api.tmdb.org 国内直连)匹配到的海报静默替换
|
||||
if(list.length)enhanceGridPosters(list);
|
||||
}
|
||||
function catUrl(s,c,p){
|
||||
if(s.categoryUrl)return s.categoryUrl.replace('{categoryId}',c).replace('{page}',p);
|
||||
return `/index.php/vod/show/id/${c}/page/${p}.html`
|
||||
}
|
||||
async function loadCategory(){
|
||||
_searchGen++; // 离开搜索结果页:旧 search() 哪怕还在后台跑,结果回来了也不再生效
|
||||
const gen=++_catGen; // 本次分类请求的代际号:之后任何「又切了别的分类」都会让它失效,慢请求晚到不再覆盖新内容
|
||||
if(location.hash==='#search') history.replaceState({wo:'home'},'',location.pathname+location.search);
|
||||
_clearSearchUI();
|
||||
content.innerHTML='<div class="empty">加载中…</div>';
|
||||
let s=site(),cat=activeCat,pg=page;
|
||||
try{
|
||||
let r=await get(s,catUrl(s,cat,pg)),d=doc(r.html);
|
||||
if(gen!==_catGen)return; // 等待期间又切到了别的分类,这份结果作废,避免覆盖已显示的新内容
|
||||
let els=[...d.querySelectorAll(s.listSelector||'.module-item')];
|
||||
let list=els.map(e=>cardFrom(e,r.base,s)).filter(x=>x.href&&x.title).slice(0,60);
|
||||
renderGrid(list,true);
|
||||
const st=`${s.name} · 第 ${pg} 页 · ${list.length} 条`;
|
||||
status.textContent=st;
|
||||
// 记录当前分类状态,作为之后“从搜索返回”的还原快照
|
||||
_catSnapshot={site:activeSite,cat:cat,list:last.slice(),page:pg,scrollY:0,statusText:st};
|
||||
}catch(e){if(gen===_catGen)content.innerHTML='<div class="empty">分类失败:'+esc(e.message)+'</div>'}
|
||||
}
|
||||
// ===== 搜索地址自适应 =====
|
||||
// 各站搜索 URL 格式各不相同(伪静态规则不同),过去只给“玩偶”配了 searchUrl、
|
||||
// 其余站统一套用一个默认路径,导致服务器不认这个路径的站搜不出结果。
|
||||
// 这里改为:未显式配置 searchUrl 的站,依次尝试多种常见 MacCMS 搜索格式,
|
||||
// 谁能返回结果就用谁,并把命中的格式持久化缓存,下次直接命中、不再逐个试。
|
||||
const SEARCH_URL_TEMPLATES=[
|
||||
'/index.php/vod/search/page/{p}/wd/{kw}.html', // MacCMS10 标准伪静态
|
||||
'/vodsearch/-------------.html?wd={kw}&page={p}', // 参数走 query(玩偶式)
|
||||
'/index.php/vod/search/wd/{kw}.html', // 不带 page
|
||||
'/vodsearch/{kw}----------{p}---.html', // 关键词嵌在路径里
|
||||
'/index.php/vod/search.html?wd={kw}', // 纯动态
|
||||
'/search.html?wd={kw}' // 简化动态
|
||||
];
|
||||
// 命中格式缓存(按 siteId),持久化到 localStorage,跨会话复用
|
||||
const SEARCH_URL_CACHE=(()=>{try{return JSON.parse(localStorage.getItem('wo_search_url_cache')||'{}')}catch(e){return {}}})();
|
||||
function _saveSearchUrlCache(){try{localStorage.setItem('wo_search_url_cache',JSON.stringify(SEARCH_URL_CACHE))}catch(e){}}
|
||||
// 搜索结果项多选择器兜底:不同模板的结果项 class 不一定是 .module-search-item
|
||||
function _pickSearchEls(d,s){
|
||||
const sels=[s.searchListSelector,'.module-search-item','.module-items .module-item',
|
||||
'.module-list .module-item','.stui-vodlist__media','ul.stui-vodlist > li',
|
||||
'.stui-vodlist li','.search-list li','.vodlist li','.module-item'].filter(Boolean);
|
||||
for(const sel of sels){const els=[...d.querySelectorAll(sel)];if(els.length)return els}
|
||||
return [];
|
||||
}
|
||||
async function searchSite(s,q,timeoutSec=16){
|
||||
const ekw=encodeURIComponent(q);
|
||||
// 候选格式:显式配置 > 该站已缓存命中 > 全套模板逐个试
|
||||
let templates;
|
||||
if(s.searchUrl){
|
||||
templates=[s.searchUrl.replace('{keyword}','{kw}').replace('{page}','{p}')];
|
||||
}else if(SEARCH_URL_CACHE[s.id]){
|
||||
// 缓存命中的放最前,但保留兜底(万一站点改了规则,仍能自动重新发现)
|
||||
templates=[SEARCH_URL_CACHE[s.id],...SEARCH_URL_TEMPLATES.filter(t=>t!==SEARCH_URL_CACHE[s.id])];
|
||||
}else{
|
||||
// 未知格式:聚合模式下并发压力大,只试最常见的前 2 种,避免单个站串行试一长串拖垮整体;
|
||||
// 单站模式没有并发竞争,可以把全套格式都试一遍,尽量搜全。
|
||||
templates=aggregate?SEARCH_URL_TEMPLATES.slice(0,2):SEARCH_URL_TEMPLATES;
|
||||
}
|
||||
let lastErr=null,anyOk=false;
|
||||
for(const tpl of templates){
|
||||
const path=tpl.replace(/\{kw\}/g,ekw).replace(/\{p\}/g,'1');
|
||||
let r;
|
||||
try{r=await get(s,path,timeoutSec,true)}
|
||||
catch(e){lastErr=e;continue} // 这个地址请求失败(404/超时/拦截),换下一种格式
|
||||
anyOk=true;
|
||||
const d=doc(r.html);
|
||||
const els=_pickSearchEls(d,s);
|
||||
const list=els.map(e=>cardFrom(e,r.base,s)).filter(x=>x.href&&x.title).slice(0,20);
|
||||
if(list.length){
|
||||
if(!s.searchUrl&&SEARCH_URL_CACHE[s.id]!==tpl){SEARCH_URL_CACHE[s.id]=tpl;_saveSearchUrlCache()}
|
||||
return list;
|
||||
}
|
||||
// 请求成功但没解析到结果:可能这词真没有,也可能格式不对,继续试其他格式
|
||||
}
|
||||
if(!anyOk&&lastErr)throw lastErr; // 所有格式都网络层失败 → 抛错,让聚合搜索的重试机制接手
|
||||
return []; // 请求通了但确实没搜到
|
||||
}
|
||||
function normName(t){
|
||||
let s=clean(t).replace(/[《》\[\]【】()()\s·.\-_::|\/]/g,'');
|
||||
s=s.replace(/第[一二三四五六七八九十\d]+季|Season\s*\d+/ig,'');
|
||||
// 忽略版本/画质/规格/语言等后缀,使同一部片的不同版本(蓝光/4K/臻彩/国语…)归一到同一个名字。
|
||||
// 复合词放前面,避免「臻彩视界」被「臻彩」先匹配后残留「视界」。
|
||||
s=s.replace(/臻彩视界|臻彩|杜比视界|杜比全景声|杜比|导演剪辑版|未删减版|加长版|完整版|收藏版|纪念版|国际版|蓝光原盘|蓝光|原盘|remastered|bluray|blu-ray|remux|2160p|1080p|720p|4k|2160|1080|hdr10\+?|hdr|sdr|dovi|dolby|hevc|h\.?265|h\.?264|x265|x264|国粤双语|国粤|国语|粤语|中英双语|中英|双语|中字|英语|高清|超清|高码/ig,'');
|
||||
return s.toLowerCase();
|
||||
}
|
||||
// 从标题/备注里提取年份(19xx/20xx),排除 1080/2160 等分辨率数字;用于按年份合并与过滤
|
||||
function extractYear(x){
|
||||
return (String((x&&((x.title||'')+' '+(x.remark||'')))||'').match(/(?:^|[^\d])((?:19|20)\d{2})(?:[^\d]|$)/)||[])[1]||'';
|
||||
}
|
||||
// ===== 简繁通搜:单字对照(原词始终参与搜索,转换仅用于扩大召回)=====
|
||||
const _ST_S='们这个来国时会说过现还后经发对开关门问间见觉学实样应该让认识话语读书笔记词议论试课讲谁请谢谈译证评诉询详误谣谓爱万与丑专业丛东丝丢两严丧临为丽举义乌乐乔习乡买乱争亏亚产亩亲亿仅从仑仓仪价众优伛伞伟传伤伥伦伧伪体余佣侠侣侥侦侧侨侩侪侬俣俦俨俩俪俭债倾偬偻偾偿傥傧储傩儿兑兖党兰兴兹养兽冁内冈册写军农冯冲决况冻净凄准凉减凑凛几凤凫凭凯击凼凿刍划刘则刚创删别刬刭刹刽剀剂剐剑剥剧劝办务劢动励劲劳势勋勚匀匦匮区医华协单卖卢卤卫却卷厂厅历厉压厌厍厐厕厢厣厦厨厩县叁参叆双变叙叠叶号叹叽吁吓吕吗吣吨听启吴呒呓呕呖呗员呙呛呜咏咙咛咝响哑哒哓哔哕哗哙哜哝哟唛唝唠唡唢唤啧啬啭啮啰啴啸喷喽喾嗫嗳嘘嘤嘱噜嚣团园围囵图圆圣场坂坏块坚坛坜坝坞坟坠垄垅垦垩垫垭垲垴埘埙埚堑堕墙壮声壳壶壸处备复够头夸夹夺奁奂奋奖奥妆妇妈妩妪妫姗姹娄娅娆娇娈娱娲娴婳婴婵婶媪嫒嫔嫱嬷孙孪宁宝宠审宪宫宽宾寝寻导寿将尔尘尝尧尴尸层屃屉届属屡屦屿岁岂岖岗岘岙岚岛岭岽岿峄峡峣峤峥峦崂崃崄崭嵘嵚嵝巅巩巯币帅师帏帐帘帜带帧帮帱帻帼幂并幺广庄庆庐庑库庙庞废廪异弃张弥弪弯弹强归当录彝彦彻径徕忆忏忧忾怀态怂怃怅怆怜总怼怿恋恒恳恶恸恹恺恻恼恽悦悫悬悭悯惊惧惨惩惫惬惭惮惯愠愤愦愿慑懑懒懔戆戋戏戗战戬户扑执扩扪扫扬扰抚抛抟抠抡抢护报担拟拢拣拥拦拧拨择挂挚挛挜挝挞挟挠挡挢挣挤挥挦损捡换捣据掳掴掷掸掺掼揽揿搀搁搂搅携摄摅摆摇摈摊撄撑撵撷撸撺擞攒敌敛数斋斓斗斩断无旧旷旸昙昼昽显晋晒晓晔晕晖暂暧术机杀杂权杆条杨杩杰极构枞枢枣枥枧枨枪枫枭柜柠柽栀栈栉栊栋栌栎栏树栖栾桠桡桢档桤桥桦桧桩梦梼梾检棁棂椁椟椠椤椭楼榄榇榈榉槚槛槟槠横樯樱橥橱橹橼檩欢欤欧歼殁殇残殒殓殚殡殴毁毂毕毙毡毵氇气氢氩氲汇汉污汤汹沟没沣沤沥沦沧沩沪泞注泪泶泷泸泺泻泼泽泾洁洒洼浃浅浆浇浈浊测浍济浏浑浒浓浔涛涝涞涟涠涡涢涣涤润涧涨涩淀渊渌渍渐渑渔渖渗温游湾湿溃溅溆滗滚滞滟滠满滢滤滥滦滨滩漓潆潇潋潍澜濑濒灏灭灯灵灾灿炀炉炖炜炝点炼炽烁烂烃烛烟烦烧烨烩烫烬热焕焖焘煴爷牍牦牵牺犊状犷犸犹狈狍狝狞独狭狮狯狰狱狲猃猎猕猡猪猫献獭玑玛玮环玱玺珐珑珰珲琏琐琼瑶瑷璎瓒瓮瓯电画畅畴疖疗疟疠疡疬疮疯疱疴痈痉痒痖痨痪痫瘅瘗瘘瘪瘫瘾瘿癞癣癫皑皱皲盏盐监盖盗盘眍眦眬着睁睐睑瞒瞩矫矶矾矿砀码砖砗砚砜砺砻砾础硁硕硖硗硙确碍碜碱礼祃祎祢祯祷祸禀禄禅离秃秆种积称秽税稆稣稳穑穷窃窍窎窜窝窥窦窭竖竞笃笋笕笺笼笾筑筚筛筜筝筹签简箓箦箧箨箩箪箫篑篓篮篱簖籁籴类籼粜粝粤粪粮糁糇紧絷纟纠纡红纣纤纥约级纨纩纪纫纬纭纯纰纱纲纳纵纶纷纸纹纺纻纼纽纾线绀绁绂练组绅细织终绉绊绋绌绍绎绑绒结绔绕绖绗绘给绚绛络绝绞统绠绡绢绣绤绥绦继绨绩绪绫续绮绯绰绱绲绳维绵绶绷绸绹绺绻综绽绾绿缀缁缂缃缄缅缆缇缈缉缊缋缌缍缎缏缐缑缒缓缔缕编缗缘缙缚缛缜缝缞缟缠缡缢缣缤缥缦缧缨缩缪缫缬缭缮缯缰缱缲缳缴缵罂网罗罚罢罴羁羟翘耢耧耸耻聂聋职聍联聩聪肃肠肤肮肴肾肿胀胁胆胜胧胨胪胫脉脍脏脐脑脓脔脚脱脶脸腊腌腭腻腼腽腾膑臜舆舍舣舰舱舻艰艳艺节芈芗芜芦苁苍苎苏苹范茎茏茑茔茕茧荆荐荙荚荛荜荝荞荟荠荡荣荤荥荦荧荨荩荪荫荬荭荮药莅莱莲莳莴莶获莸莹莺萝萤营萦萧萨葱蒇蒉蒋蒌蓝蓟蓠蓣蓥蓦蔂蔷蔹蔺蔼蕲蕴薮藓蘖虏虑虚虫虬虱虽虾虿蚀蚁蚂蚃蚕蚬蛊蛎蛏蛮蛰蛱蛲蛳蛴蜕蜗蜡蝇蝈蝉蝼蝾螀螨蟏衅衔补衬衮袄袅袆袜袯装裆裈裢裣裤裥褛褴观觃规觅视觇览觊觋觌觍觎觏觐觑觞觯誉誊讠计订讣讥讦讧讨讪讫训讯讱讳讴讵讶讷许讹讻讼讽设访诀诂诃诅诇诈诊诋诌诎诏诒诓诔诖诗诘诙诚诛诜诞诟诠诡诣诤诧诨诩诫诬诮诰诱诲诳诵诶诸诹诺诼诽诿谀谂调谄谅谆谇谊谋谌谍谎谏谐谑谒谔谕谖谗谘谙谚谛谜谝谞谟谠谡谤谥谦谧谨谩谪谫谬谭谮谯谰谱谲谳谴谵谶贝贞负贠贡财责贤败账货质贩贪贫贬购贮贯贰贱贲贳贴贵贶贷贸费贺贻贼贽贾贿赀赁赂赃资赅赆赇赈赉赊赋赌赍赎赏赐赑赒赓赔赕赖赗赘赙赚赛赜赝赞赟赠赡赢赣赪赵赶趋趱趸跃跄跖跞践跷跸跹跻踊踌踪踬踯蹑蹒蹰蹿躏躯车轧轨轩轪轫转轭轮软轰轱轲轳轴轵轶轷轸轹轺轻轼载轾轿辁辂较辄辅辆辇辈辉辊辋辌辍辎辏辐辑辒输辔辕辖辗辘辙辚辞辩辫边辽达迁迈运进远违连迟迩迳迹适选逊递逦逻遗遥邓邝邬邮邹邺邻郏郐郑郓郦郧郸酂酦酱酽酾酿释里鉴銮錾钅钆钇针钉钊钋钌钍钎钏钐钒钓钔钕钖钗钘钙钚钛钜钝钞钟钠钡钢钣钤钥钦钧钨钩钪钫钬钭钮钯钰钱钲钳钴钵钶钷钸钹钺钻钼钽钾钿铀铁铂铃铄铅铆铇铈铉铊铋铌铍铎铏铐铑铒铓铔铕铖铗铘铙铚铛铜铝铞铟铠铡铢铣铤铥铦铧铨铩铪铫铬铭铮铯铰铱铲铳铴铵银铷铸铹铺铻铼铽链铿销锁锂锃锄锅锆锇锈锉锊锋锌锍锎锏锐锑锒锓锔锕锖锗错锚锛锝锞锟锡锢锣锤锥锦锧锨锩锪锫锬锭键锯锰锱锲锳锴锵锶锷锸锹锺锻锼锽锾锿镀镁镂镃镄镅镆镇镈镉镊镋镌镍镎镏镐镑镒镓镔镖镗镘镙镚镛镜镝镞镟镠镡镢镣镤镥镦镧镨镩镪镫镬镭镮镯镰镱镲镳镴镵镶长闩闪闫闬闭闯闰闱闲闳闵闶闷闸闹闺闻闼闽闾闿阀阁阂阃阄阅阆阇阈阉阊阋阌阍阎阏阐阑阒阓阔阕阖阗阘阙阚阛队阳阴阵阶际陆陇陈陉陕陧陨险随隐隶隽难雏雠雳雾霁霉霭靓静面靥鞑鞒鞯韦韧韨韩韪韫韬韵页顶顷顸项顺须顼顽顾顿颀颁颂颃预颅领颇颈颉颊颋颌颍颎颏颐频颒颓颔颕颖颗题颙颚颛颜额颞颟颠颡颢颤颥颦颧风飏飐飑飒飓飔飕飖飗飘飙飚飞飨餍饣饤饥饦饧饨饩饪饫饬饭饮饯饰饱饲饳饴饵饶饷饸饹饺饻饼饽饾饿馀馁馂馃馄馅馆馇馈馉馊馋馌馍馎馏馐馑馒馓馔馕马驭驮驯驰驱驲驳驴驵驶驷驸驹驺驻驼驽驾驿骀骁骂骃骄骅骆骇骈骉骊骋验骍骎骏骐骑骒骓骔骕骖骗骘骙骚骛骜骝骞骟骠骡骢骣骤骥骦骧髅髋髌鬓魇魉鱼鱽鱾鱿鲀鲁鲂鲄鲅鲆鲇鲈鲉鲊鲋鲌鲍鲎鲏鲐鲑鲒鲓鲔鲕鲖鲗鲘鲙鲚鲛鲜鲝鲞鲟鲠鲡鲢鲣鲤鲥鲦鲧鲨鲩鲪鲫鲬鲭鲮鲯鲰鲱鲲鲳鲴鲵鲶鲷鲸鲹鲺鲻鲼鲽鲾鲿鳀鳁鳂鳃鳄鳅鳆鳇鳈鳉鳊鳋鳌鳍鳎鳏鳐鳑鳒鳓鳔鳕鳖鳗鳘鳙鳚鳛鳜鳝鳞鳟鳠鳡鳢鸟鸠鸡鸢鸣鸤鸥鸦鸧鸨鸩鸪鸫鸬鸭鸮鸯鸰鸱鸲鸳鸴鸵鸶鸷鸸鸹鸺鸻鸼鸽鸾鸿鹀鹁鹂鹃鹄鹅鹆鹇鹈鹉鹊鹋鹌鹍鹎鹏鹐鹑鹒鹔鹕鹖鹗鹘鹙鹚鹛鹜鹝鹞鹟鹠鹡鹢鹣鹤鹥鹦鹧鹨鹩鹪鹫鹬鹭鹮鹯鹰鹱鹲鹳鹴鹾麦麸黄黉黡黩黪黾鼋鼌鼍鼹齐齑齿龀龁龂龃龄龅龆龇龈龉龊龋龌龙龚龛龟';
|
||||
const _ST_T='們這個來國時會說過現還後經發對開關門問間見覺學實樣應該讓認識話語讀書筆記詞議論試課講誰請謝談譯證評訴詢詳誤謠謂愛萬與醜專業叢東絲丟兩嚴喪臨為麗舉義烏樂喬習鄉買亂爭虧亞產畝親億僅從侖倉儀價眾優傴傘偉傳傷倀倫傖偽體餘傭俠侶僥偵側僑儈儕儂俁儔儼倆儷儉債傾傯僂僨償儻儐儲儺兒兌兗黨蘭興茲養獸囅內岡冊寫軍農馮沖決況凍淨淒準涼減湊凜幾鳳鳧憑凱擊氹鑿芻劃劉則剛創刪別剗剄剎劊剴劑剮劍剝劇勸辦務勱動勵勁勞勢勛勩勻匭匱區醫華協單賣盧鹵衛卻捲廠廳歷厲壓厭厙龐廁廂厴廈廚廄縣叄參靉雙變敘疊葉號嘆嘰籲嚇呂嗎唚噸聽啟吳嘸囈嘔嚦唄員咼嗆嗚詠嚨嚀噝響啞噠嘵嗶噦嘩噲嚌噥喲嘜嗊嘮啢嗩喚嘖嗇囀嚙囉嘽嘯噴嘍嚳囁噯噓嚶囑嚕囂團園圍圇圖圓聖場阪壞塊堅壇壢壩塢墳墜壟壠墾堊墊埡塏堖塒塤堝塹墮牆壯聲殼壺壼處備復夠頭誇夾奪奩奐奮獎奧妝婦媽嫵嫗媯姍奼婁婭嬈嬌孌娛媧嫻嫿嬰嬋嬸媼嬡嬪嬙嬤孫孿寧寶寵審憲宮寬賓寢尋導壽將爾塵嘗堯尷屍層屭屜屆屬屢屨嶼歲豈嶇崗峴嶴嵐島嶺崬巋嶧峽嶢嶠崢巒嶗崍嶮嶄嶸嶔嶁巔鞏巰幣帥師幃帳簾幟帶幀幫幬幘幗冪並么廣莊慶廬廡庫廟龐廢廩異棄張彌弳彎彈強歸當錄彞彥徹徑徠憶懺憂愾懷態慫憮悵愴憐總懟懌戀恆懇惡慟懨愷惻惱惲悅愨懸慳憫驚懼慘懲憊愜慚憚慣慍憤憒願懾懣懶懍戇戔戲戧戰戩戶撲執擴捫掃揚擾撫拋摶摳掄搶護報擔擬攏揀擁攔擰撥擇掛摯攣掗撾撻挾撓擋撟掙擠揮撏損撿換搗據擄摑擲撣摻摜攬撳攙擱摟攪攜攝攄擺搖擯攤攖撐攆擷擼攛擻攢敵斂數齋斕鬥斬斷無舊曠暘曇晝曨顯晉曬曉曄暈暉暫曖術機殺雜權桿條楊榪傑極構樅樞棗櫪梘棖槍楓梟櫃檸檉梔棧櫛櫳棟櫨櫟欄樹棲欒椏橈楨檔榿橋樺檜樁夢檮棶檢梲欞槨櫝槧欏橢樓欖櫬櫚櫸檟檻檳櫧橫檣櫻櫫櫥櫓櫞檁歡歟歐殲歿殤殘殞殮殫殯毆毀轂畢斃氈毿氌氣氫氬氳匯漢汙湯洶溝沒灃漚瀝淪滄溈滬濘註淚澩瀧瀘濼瀉潑澤涇潔灑窪浹淺漿澆湞濁測澮濟瀏渾滸濃潯濤澇淶漣潿渦溳渙滌潤澗漲澀澱淵淥漬漸澠漁瀋滲溫遊灣濕潰濺漵潷滾滯灩灄滿瀅濾濫灤濱灘灕瀠瀟瀲濰瀾瀨瀕灝滅燈靈災燦煬爐燉煒熗點煉熾爍爛烴燭煙煩燒燁燴燙燼熱煥燜燾熅爺牘氂牽犧犢狀獷獁猶狽麅獮獰獨狹獅獪猙獄猻獫獵獼玀豬貓獻獺璣瑪瑋環瑲璽琺瓏璫琿璉瑣瓊瑤璦瓔瓚甕甌電畫暢疇癤療瘧癘瘍癧瘡瘋皰痾癰痙癢瘂癆瘓癇癉瘞瘺癟癱癮癭癩癬癲皚皺皸盞鹽監蓋盜盤瞘眥矓著睜睞瞼瞞矚矯磯礬礦碭碼磚硨硯碸礪礱礫礎硜碩硤磽磑確礙磣鹼禮禡禕禰禎禱禍稟祿禪離禿稈種積稱穢稅穭穌穩穡窮竊竅窵竄窩窺竇窶豎競篤筍筧箋籠籩築篳篩簹箏籌簽簡籙簀篋籜籮簞簫簣簍籃籬籪籟糴類秈糶糲粵糞糧糝餱緊縶糹糾紆紅紂纖紇約級紈纊紀紉緯紜純紕紗綱納縱綸紛紙紋紡紵紖紐紓線紺紲紱練組紳細織終縐絆紼絀紹繹綁絨結絝繞絰絎繪給絢絳絡絕絞統綆綃絹繡綌綏絛繼綈績緒綾續綺緋綽緔緄繩維綿綬繃綢綯綹綣綜綻綰綠綴緇緙緗緘緬纜緹緲緝縕繢緦綞緞緶線緱縋緩締縷編緡緣縉縛縟縝縫縗縞纏縭縊縑繽縹縵縲纓縮繆繅纈繚繕繒韁繾繰繯繳纘罌網羅罰罷羆羈羥翹耮耬聳恥聶聾職聹聯聵聰肅腸膚骯餚腎腫脹脅膽勝朧腖臚脛脈膾髒臍腦膿臠腳脫腡臉臘醃齶膩靦膃騰臏臢輿捨艤艦艙艫艱豔藝節羋薌蕪蘆蓯蒼苧蘇蘋範莖蘢蔦塋煢繭荊薦薘莢蕘蓽萴蕎薈薺蕩榮葷滎犖熒蕁藎蓀蔭蕒葒葤藥蒞萊蓮蒔萵薟獲蕕瑩鶯蘿螢營縈蕭薩蔥蕆蕢蔣蔞藍薊蘺蕷鎣驀虆薔蘞藺藹蘄蘊藪蘚櫱虜慮虛蟲虯蝨雖蝦蠆蝕蟻螞蠁蠶蜆蠱蠣蟶蠻蟄蛺蟯螄蠐蛻蝸蠟蠅蟈蟬螻蠑螿蟎蠨釁銜補襯袞襖裊褘襪襏裝襠褌褳襝褲襇褸襤觀覎規覓視覘覽覬覡覿覥覦覯覲覷觴觶譽謄訁計訂訃譏訐訌討訕訖訓訊訒諱謳詎訝訥許訛訩訟諷設訪訣詁訶詛詗詐診詆謅詘詔詒誆誄詿詩詰詼誠誅詵誕詬詮詭詣諍詫諢詡誡誣誚誥誘誨誑誦誒諸諏諾諑誹諉諛諗調諂諒諄誶誼謀諶諜謊諫諧謔謁諤諭諼讒諮諳諺諦謎諞諝謨讜謖謗謚謙謐謹謾謫譾謬譚譖譙讕譜譎讞譴譫讖貝貞負貟貢財責賢敗賬貨質販貪貧貶購貯貫貳賤賁貰貼貴貺貸貿費賀貽賊贄賈賄貲賃賂贓資賅贐賕賑賚賒賦賭齎贖賞賜贔賙賡賠賧賴賵贅賻賺賽賾贗贊贇贈贍贏贛赬趙趕趨趲躉躍蹌蹠躒踐蹺蹕躚躋踴躊蹤躓躑躡蹣躕躥躪軀車軋軌軒軑軔轉軛輪軟轟軲軻轤軸軹軼軤軫轢軺輕軾載輊轎輇輅較輒輔輛輦輩輝輥輞輬輟輜輳輻輯轀輸轡轅轄輾轆轍轔辭辯辮邊遼達遷邁運進遠違連遲邇逕跡適選遜遞邐邏遺遙鄧鄺鄔郵鄒鄴鄰郟鄶鄭鄆酈鄖鄲酇醱醬釅釃釀釋裡鑒鑾鏨釒釓釔針釘釗釙釕釷釺釧釤釩釣鍆釹鍚釵鈃鈣鈈鈦鉅鈍鈔鐘鈉鋇鋼鈑鈐鑰欽鈞鎢鉤鈧鈁鈥鈄鈕鈀鈺錢鉦鉗鈷缽鈳鉕鈽鈸鉞鑽鉬鉭鉀鈿鈾鐵鉑鈴鑠鉛鉚鉋鈰鉉鉈鉍鈮鈹鐸鉶銬銠鉺鋩錏銪鋮鋏鋣鐃銍鐺銅鋁銱銦鎧鍘銖銑鋌銩銛鏵銓鎩鉿銚鉻銘錚銫鉸銥鏟銃鐋銨銀銣鑄鐒鋪鋙錸鋱鏈鏗銷鎖鋰鋥鋤鍋鋯鋨鏽銼鋝鋒鋅鋶鐦鐧銳銻鋃鋟鋦錒錆鍺錯錨錛鍀錁錕錫錮鑼錘錐錦鑕鍁錈鍃錇錟錠鍵鋸錳錙鍥鍈鍇鏘鍶鍔鍤鍬鍾鍛鎪鍠鍰鎄鍍鎂鏤鎡鐨鎇鏌鎮鎛鎘鑷钂鐫鎳鎿鎦鎬鎊鎰鎵鑌鏢鏜鏝鏍鏰鏞鏡鏑鏃鏇鏐鐔钁鐐鏷鑥鐓鑭鐠鑹鏹鐙鑊鐳鐶鐲鐮鐿鑔鑣鑞鑱鑲長閂閃閆閈閉闖閏闈閑閎閔閌悶閘鬧閨聞闥閩閭闓閥閣閡閫鬮閱閬闍閾閹閶鬩閿閽閻閼闡闌闃闠闊闋闔闐闒闕闞闤隊陽陰陣階際陸隴陳陘陝隉隕險隨隱隸雋難雛讎靂霧霽黴靄靚靜麵靨韃鞽韉韋韌韍韓韙韞韜韻頁頂頃頇項順須頊頑顧頓頎頒頌頏預顱領頗頸頡頰頲頜潁熲頦頤頻頮頹頷頴穎顆題顒顎顓顏額顳顢顛顙顥顫顬顰顴風颺颭颮颯颶颸颼颻飀飄飆飈飛饗饜飠飣飢飥餳飩餼飪飫飭飯飲餞飾飽飼飿飴餌饒餉餄餎餃餏餅餑餖餓餘餒餕餜餛餡館餷饋餶餿饞饁饃餺餾饈饉饅饊饌饢馬馭馱馴馳驅馹駁驢駔駛駟駙駒騶駐駝駑駕驛駘驍罵駰驕驊駱駭駢驫驪騁驗騂駸駿騏騎騍騅騌驌驂騙騭騤騷騖驁騮騫騸驃騾驄驏驟驥驦驤髏髖髕鬢魘魎魚魛魢魷魨魯魴魺鮁鮃鯰鱸鮋鮓鮒鮊鮑鱟鮍鮐鮭鮚鮳鮪鮞鮦鯽鮜鱠鱭鮫鮮鮺鯗鱘鯁鱺鰱鰹鯉鰣鰷鯀鯊鯇鮶鯽鯒鯖鯪鯕鯫鯡鯤鯧鯝鯢鯰鯛鯨鰺蝨鯔鱝鰈鰏鱨鯷鰮鰃鰓鱷鰍鰒鰉鰁鱂鯿鰠鰲鰭鰨鰥鰩鰟鰜鰳鰾鱈鱉鰻鰵鱅䲁鰼鱖鱔鱗鱒鱯鱤鱧鳥鳩雞鳶鳴鳲鷗鴉鶬鴇鴆鴣鶇鸕鴨鴞鴦鴒鴟鴝鴛鷽鴕鷥鷙鴯鴰鵂鴴鵃鴿鸞鴻鵐鵓鸝鵑鵠鵝鵒鷳鵜鵡鵲鶓鵪鵾鵯鵬鵮鶉鶊鷫鶘鶡鶚鶻鶖鶿鶥鶩鷊鷂鶲鶹鶺鷁鶼鶴鷖鸚鷓鷚鷯鷦鷲鷸鷺䴉鸇鷹鸌鸏鸛鸘鹺麥麩黃黌黶黷黲黽黿鼂鼉鼴齊齏齒齔齕齗齟齡齙齠齜齦齬齪齲齷龍龔龕龜';
|
||||
const _s2t={},_t2s={};
|
||||
for(let i=0;i<_ST_S.length;i++){_s2t[_ST_S[i]]=_ST_T[i];if(!(_ST_T[i] in _t2s))_t2s[_ST_T[i]]=_ST_S[i];}
|
||||
function toSimp(s){let o='';for(const c of String(s||''))o+=(_t2s[c]||c);return o}
|
||||
async function search(q,opts){
|
||||
opts=opts||{};
|
||||
const targetYear=opts.year?String(opts.year):''; // 从联想点击带入的年份;用于过滤掉明确属于其它年份的结果
|
||||
addSearchKwHistory(q);
|
||||
_catActive=false;
|
||||
const gen=++_searchGen; // 本次搜索的代际号;之后任何「离开搜索页」的操作都会让它失效
|
||||
// 进入搜索前,若当前在分类浏览态,快照分类内容/滚动位置,供返回时秒级还原
|
||||
if(content.dataset.mode==='category'&&last.length){
|
||||
_catSnapshot={site:activeSite,cat:activeCat,list:last.slice(),page,scrollY:window.scrollY||0,statusText:status.textContent};
|
||||
}
|
||||
_enterSearchHistory();
|
||||
if(navrowEl) navrowEl.classList.add('blurred');
|
||||
if(searchOverlayEl) searchOverlayEl.classList.add('show');
|
||||
if(appEl) appEl.classList.add('searching');
|
||||
await new Promise(r=>setTimeout(r,0));
|
||||
content.innerHTML='<div class="empty">聚合搜索中…</div>';
|
||||
const map=new Map();
|
||||
let arr=[],done=0,shown=false,flushTimer=null;
|
||||
function mergeOne(list){
|
||||
// 若用户从联想点了带年份的条目(targetYear),过滤掉「明确标了其它年份」的结果;
|
||||
// 没标年份的一律保留,避免把该年份但站点没标年份的资源误杀。
|
||||
if(targetYear){
|
||||
list=list.filter(x=>{const yr=extractYear(x);return !yr||yr===targetYear});
|
||||
}
|
||||
if(aggregate){
|
||||
// 不同站各自成卡;同站 + 同片名(忽略版本后缀) + 同年份 才合并成一张卡,
|
||||
// 同站同片的多个版本收进同一张卡的 sources,点开后在详情页按网盘线路分开列。
|
||||
// 年份不同(如《迷墙》1982 与 2026)分成两张。
|
||||
list.forEach(x=>{
|
||||
let yr=extractYear(x);
|
||||
let k=x.siteId+'|'+normName(toSimp(x.title))+'|'+yr;
|
||||
if(!map.has(k))map.set(k,Object.assign({},x,{sources:[x]}));else map.get(k).sources.push(x)
|
||||
});
|
||||
}else{
|
||||
list.forEach(x=>{let k=x.href||normName(toSimp(x.title));if(!map.has(k))map.set(k,x)});
|
||||
}
|
||||
}
|
||||
function reveal(){
|
||||
if(shown)return;shown=true;
|
||||
_clearSearchUI();
|
||||
const hb=document.getElementById('searchHistory');
|
||||
if(hb) hb.classList.remove('show');
|
||||
kw.blur();
|
||||
}
|
||||
function flush(total,extra){
|
||||
if(gen!==_searchGen)return; // 已经离开搜索页(点返回/切分类等),旧搜索结果作废,不再刷新当前页面
|
||||
arr=[...map.values()];
|
||||
renderGrid(arr,false);
|
||||
reveal();
|
||||
let st = done<total
|
||||
? `搜索:${q} · ${arr.length} 条 · 加载中 ${done}/${total}…`
|
||||
: `搜索:${q} · ${arr.length} 条`;
|
||||
if(extra)st+=extra;
|
||||
status.textContent = st;
|
||||
}
|
||||
// 聚合模式下站源很多,不再一次性把全部请求炸出去(互相抢占原生桥接/带宽,
|
||||
// 慢站容易超时失败而被静默吞掉),改用并发池限流,失败的任务记下来,
|
||||
// 第一轮跑完后用更长超时单独重试一次。
|
||||
// 注意:每个站源只生成一个任务(不再叠加简繁变体),避免同一站源被并发重复打多次而触发限流。
|
||||
const jobs = aggregate
|
||||
? SITES.map(s=>({site:s,q}))
|
||||
: [{site:site(),q}];
|
||||
const total=jobs.length;
|
||||
const AGG_CONCURRENCY=5;
|
||||
const siteCounts={}; // 诊断:记录每个站各自搜到几条('✕'=请求失败/超时)
|
||||
async function runJob(job,timeoutSec,failBucket){
|
||||
try{
|
||||
const _list=await searchSite(job.site,job.q,timeoutSec);
|
||||
siteCounts[job.site.id]=_list.length; // 成功:记录条数(0 表示请求通了但没结果)
|
||||
mergeOne(_list);
|
||||
}catch(e){
|
||||
// 记录失败的具体原因,便于区分「被拦截(CF盾)/超时/连不上/地址错」
|
||||
const msg=String((e&&e.message)||'');
|
||||
let tag='✕';
|
||||
if(/拦截|just a moment|cf-browser|access denied|无响应|为空/i.test(msg))tag='✕盾';
|
||||
else if(/abort|timeout|超时/i.test(msg))tag='✕超时';
|
||||
else if(/HTTP\s*4|HTTP\s*5|not found|404|403|503/i.test(msg))tag='✕'+(msg.match(/\d{3}/)||['错'])[0];
|
||||
else if(/failed|network|fetch|connect|无法|失败/i.test(msg))tag='✕断';
|
||||
siteCounts[job.site.id]=tag;
|
||||
failBucket.push(job);
|
||||
}finally{
|
||||
done++;
|
||||
if(done===total){flush(total);return} // 最后一个站源:无论有没有结果都要收尾
|
||||
if(map.size===0)return; // 还没有任何结果,继续等,避免提前显示「暂无内容」
|
||||
if(!shown){flush(total)} // 第一批有效结果,立刻展示
|
||||
else if(!flushTimer)flushTimer=setTimeout(()=>{flushTimer=null;flush(total)},400);
|
||||
}
|
||||
}
|
||||
async function runPool(list,timeoutSec,failBucket,conc){
|
||||
let i=0;
|
||||
async function worker(){while(i<list.length){await runJob(list[i++],timeoutSec,failBucket)}}
|
||||
await Promise.all(Array.from({length:Math.min(conc||AGG_CONCURRENCY,list.length)||1},worker));
|
||||
}
|
||||
let failed=[];
|
||||
await runPool(jobs,20,failed);
|
||||
if(flushTimer){clearTimeout(flushTimer);flushTimer=null}
|
||||
if(gen!==_searchGen)return; // 搜索期间已经离开了搜索页:到这里直接收手,不再动 DOM、不再关加载动画
|
||||
if(failed.length){
|
||||
// 给超时/失败的站源第二次机会:单独重试,超时放宽、并发降到2,减少再次抢占
|
||||
const retryJobs=failed;failed=[];
|
||||
done=total-retryJobs.length;
|
||||
await runPool(retryJobs,30,failed,3);
|
||||
if(flushTimer){clearTimeout(flushTimer);flushTimer=null}
|
||||
if(gen!==_searchGen)return;
|
||||
}
|
||||
const failedNames=[...new Set(failed.map(j=>j.site.name))];
|
||||
flush(total, failedNames.length?` · ${failedNames.join('、')}超时未返回`:'');
|
||||
reveal(); // 兜底:万一全程零结果也要把加载动画关掉
|
||||
content.classList.remove('searching');
|
||||
// 后台逐张验证:抓详情判断有没有网盘,把确认没网盘的空卡淡出隐藏(聚合模式才做)。
|
||||
if(aggregate && gen===_searchGen && arr.length) verifyAndHideEmpty(arr.slice(), gen);
|
||||
}
|
||||
function panType(u){
|
||||
u=String(u||'').toLowerCase();
|
||||
if(u.includes('pan.baidu'))return'百度';
|
||||
if(u.includes('quark'))return'夸克';
|
||||
if(u.includes('aliyun')||u.includes('alipan'))return'阿里';
|
||||
if(u.includes('xunlei'))return'迅雷';
|
||||
if(u.includes('115.com')||u.includes('115cdn'))return'115';
|
||||
// 123网盘官方域名较多且常变化(123684/123865/123912/123pan等),
|
||||
// 用域名模式匹配而非裸数字"123",避免站源自家域名/广告链接被误判
|
||||
if(/123(?:\d{2,4})?\.(?:com|cn|net)|123pan\.(?:com|cn)/.test(u))return'123';
|
||||
if(u.includes('cloud.189'))return'天翼';
|
||||
if(u.includes('caiyun')||u.includes('139'))return'移动';
|
||||
if(u.includes('drive.uc.cn')||u.includes('uc123')||/(?:^|[.\/])uc\.cn/.test(u))return'UC';
|
||||
return'网盘'
|
||||
}
|
||||
// ========== TMDB 海报获取 ==========
|
||||
const TMDB_KEY='d7040155454e7fdf547c4d889ebbcca7';
|
||||
// 用 api.tmdb.org(非官方 api.themoviedb.org,后者在国内被 DNS 污染,需代理才通)
|
||||
// 大部分地区可直连,无需代理;image.tmdb.org 一般可直接访问
|
||||
const TMDB_API='https://api.tmdb.org/3';
|
||||
const TMDB_IMG_LIST='https://image.tmdb.org/t/p/w342'; // 首页卡片网格:小图,省流量
|
||||
const TMDB_IMG_FULL='https://image.tmdb.org/t/p/original'; // 详情页大图:最高原始画质
|
||||
function tmdbCleanTitle(t){
|
||||
return clean(t)
|
||||
// 去掉成对括号及其中内容(如 (臻彩)/(4K)/(国语) 等标签),即只用前面的正片名去匹配
|
||||
.replace(/[\((\[【][^\))\]】]*[\))\]】]/g,'')
|
||||
// 去掉残留的孤立括号符号
|
||||
.replace(/[\[\]【】()()]/g,'')
|
||||
.replace(/第[一二三四五六七八九十\d]+[季部]|Season\s*\d+/ig,'')
|
||||
.replace(/\s{2,}/g,' ')
|
||||
// 去掉结尾残留的分隔符/标点
|
||||
.replace(/[\s·::\-—_、,,。.]+$/,'')
|
||||
.trim();
|
||||
}
|
||||
// TMDB 请求:优先走原生桥接 fm.req(App 内可直连 api.tmdb.org,绕过 WebView fetch 限制),
|
||||
// 无桥接时用带超时的 fetch;连续失败熔断,避免拖慢界面
|
||||
let _tmdbDead=false,_tmdbFail=0;
|
||||
async function tmdbJSON(url,timeoutSec=8){
|
||||
if(_tmdbDead)throw new Error('tmdb-skip');
|
||||
const fm=await fmReady();
|
||||
try{
|
||||
let data;
|
||||
if(fm&&fm.req){
|
||||
const r=await fm.req(url,{method:'GET',headers:{},responseType:'text',timeout:timeoutSec});
|
||||
if(!r||!r.ok)throw new Error((r&&r.error)||('HTTP '+(r?r.status:'?')));
|
||||
data=JSON.parse(r.body);
|
||||
}else{
|
||||
const ac=new AbortController();const t=setTimeout(()=>ac.abort(),timeoutSec*1000);
|
||||
try{const r=await fetch(url,{signal:ac.signal});data=await r.json()}finally{clearTimeout(t)}
|
||||
}
|
||||
_tmdbFail=0;
|
||||
return data;
|
||||
}catch(e){
|
||||
if(++_tmdbFail>=4)_tmdbDead=true;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
// 标题归一化:去空格/标点,转小写,用于相似度比较
|
||||
function tmdbNormForMatch(s){
|
||||
return (s||'').toLowerCase().replace(/[\s·::\-—_、,,。.!!??'"“”‘’()()\[\]【】]/g,'');
|
||||
}
|
||||
// 判断 TMDB 候选结果的标题是否与查询标题足够匹配
|
||||
// 规则:归一化后完全相等,或互相包含(处理"XX 第二季"清洗后与"XX"匹配的情况),
|
||||
// 短标题(≤2 个有效字符)要求完全相等,避免"玩偶""孤儿"等泛标题误配到无关条目
|
||||
function tmdbTitleMatches(query,candidate){
|
||||
const q=tmdbNormForMatch(query),c=tmdbNormForMatch(candidate);
|
||||
if(!q||!c)return false;
|
||||
if(q===c)return true;
|
||||
if(q.length<=2||c.length<=2)return q===c;
|
||||
return q.includes(c)||c.includes(q);
|
||||
}
|
||||
// 返回 TMDB 匹配到的完整条目(id / media_type / poster_path 等),由调用方按需取用
|
||||
async function tmdbMatchItem(title,year){
|
||||
const q=tmdbCleanTitle(title);
|
||||
if(!q)return null;
|
||||
try{
|
||||
let url=`${TMDB_API}/search/multi?api_key=${TMDB_KEY}&language=zh-CN&query=${encodeURIComponent(q)}`;
|
||||
let d=await tmdbJSON(url);
|
||||
let results=d.results||[];
|
||||
const yearOf=(x)=>String(x.release_date||x.first_air_date||'').slice(0,4);
|
||||
// 标题匹配且有海报的候选
|
||||
let titleMatched=results.filter(x=>x.poster_path&&tmdbTitleMatches(q,x.title||x.name||x.original_title||x.original_name||''));
|
||||
// 有年份时:优先选「标题匹配 且 年份一致」的那一版,确保海报对应正确年份(如《鹿鼎记》1992 ≠ 2008)
|
||||
if(year){
|
||||
let exact=titleMatched.find(x=>yearOf(x)===year);
|
||||
if(exact)return exact;
|
||||
}
|
||||
if(titleMatched.length)return titleMatched[0];
|
||||
// 兜底:冷门/非英语片 TMDB 可能没有匹配的中文译名
|
||||
let withPoster=results.filter(x=>x.poster_path);
|
||||
if(year){ let ey=withPoster.find(x=>yearOf(x)===year); if(ey)return ey; }
|
||||
if(results.length<=3&&withPoster.length)return withPoster[0];
|
||||
}catch(e){}
|
||||
return null;
|
||||
}
|
||||
// 匹配缓存(按清洗后的标题做 key,缓存完整条目,避免重复请求;海报/更新状态共用同一份缓存)
|
||||
const tmdbMatchCache=new Map();
|
||||
function tmdbMatchCached(title,year){
|
||||
const ct=tmdbCleanTitle(title);
|
||||
if(!ct)return Promise.resolve(null);
|
||||
const key=ct+'|'+(year||'');
|
||||
if(tmdbMatchCache.has(key))return tmdbMatchCache.get(key);
|
||||
const p=tmdbMatchItem(title,year);
|
||||
tmdbMatchCache.set(key,p);
|
||||
return p;
|
||||
}
|
||||
async function tmdbPathCached(title,year){
|
||||
const item=await tmdbMatchCached(title,year);
|
||||
return item?item.poster_path:null;
|
||||
}
|
||||
// 首页卡片网格用:小图
|
||||
async function tmdbPosterCached(title,year){
|
||||
const path=await tmdbPathCached(title,year);
|
||||
return path?TMDB_IMG_LIST+path:null;
|
||||
}
|
||||
// 详情页大图用:最高原始画质
|
||||
async function tmdbPosterFullCached(title,year){
|
||||
const path=await tmdbPathCached(title,year);
|
||||
return path?TMDB_IMG_FULL+path:null;
|
||||
}
|
||||
// 全屏沉浸式背景用:TMDB 横版剧照(backdrop),用 original 最高画质(高分屏平板不糊)
|
||||
async function tmdbBackdropCached(title,year){
|
||||
const item=await tmdbMatchCached(title,year);
|
||||
return item&&item.backdrop_path?(TMDB_IMG_FULL+item.backdrop_path):null;
|
||||
}
|
||||
// 详情页海报轮播图源:横屏用横版剧照(backdrops)、竖屏用竖版海报(posters),各自不裁切
|
||||
const TMDB_IMG_THUMB='https://image.tmdb.org/t/p/w780'; // 轮播缩略图:先出图,避免黑屏等待
|
||||
const tmdbImagesCache=new Map();
|
||||
async function tmdbImagesCached(title,year){
|
||||
const item=await tmdbMatchCached(title,year);
|
||||
if(!item||!item.id)return [];
|
||||
const landscape=window.innerWidth>=window.innerHeight;
|
||||
const type=(item.media_type==='tv'||item.first_air_date)?'tv':'movie';
|
||||
const ck=type+item.id+(landscape?'L':'P');
|
||||
if(tmdbImagesCache.has(ck))return tmdbImagesCache.get(ck);
|
||||
const p=(async()=>{
|
||||
try{
|
||||
const d=await tmdbJSON(`${TMDB_API}/${type}/${item.id}/images?api_key=${TMDB_KEY}`);
|
||||
let arr=((landscape?d.backdrops:d.posters)||[]).filter(b=>b.file_path);
|
||||
if(landscape && !arr.length) arr=(d.posters||[]).filter(b=>b.file_path); // 横屏没有横版剧照时,退回竖版海报(至少清晰,不用站源模糊图)
|
||||
arr.sort((a,b)=>(b.vote_average||0)-(a.vote_average||0)); // 高分图排前
|
||||
// 每张图返回 {thumb:w780, full:original},先 thumb 消除黑屏,full 后台静默替换
|
||||
return arr.slice(0,6).map(b=>({thumb:TMDB_IMG_THUMB+b.file_path, full:TMDB_IMG_FULL+b.file_path}));
|
||||
}catch(e){return []}
|
||||
})();
|
||||
tmdbImagesCache.set(ck,p);
|
||||
return p;
|
||||
}
|
||||
// 海报轮播:两图层 A/B 交替 crossfade;淡出时露出的是 detHeroBg 深色底,不会露主页
|
||||
let _bgRot={timer:null,active:'A',seq:0};
|
||||
function stopBgRotation(){ if(_bgRot.timer){clearInterval(_bgRot.timer);_bgRot.timer=null;} _bgRot.seq++; }
|
||||
// 预加载图片后执行回调;seq 确保已停止的轮播不再触发
|
||||
function _bgPreload(url,seq,cb){
|
||||
const img=new Image();
|
||||
img.onload=()=>{ if(_bgRot.seq===seq) cb(url); };
|
||||
img.onerror=()=>{ if(_bgRot.seq===seq) cb(url); };
|
||||
img.src=url;
|
||||
}
|
||||
// 渐进式加载:先显示 thumb(快速出图),再后台替换 full(高清无缝)
|
||||
function _bgProgressiveSet(layer,entry,seq){
|
||||
const thumb=entry&&entry.thumb?entry.thumb:(typeof entry==='string'?entry:null);
|
||||
const full=entry&&entry.full?entry.full:thumb;
|
||||
if(!thumb)return;
|
||||
_bgPreload(thumb,seq,u=>{
|
||||
layer.style.backgroundImage=`url("${u}")`;
|
||||
layer.style.opacity='1';
|
||||
// thumb 显示后,后台加载 full 无缝替换(同一图层直接覆盖,无黑屏)
|
||||
if(full&&full!==thumb) _bgPreload(full,seq,fu=>{ layer.style.backgroundImage=`url("${fu}")`; });
|
||||
});
|
||||
}
|
||||
function setBackdrops(entries){
|
||||
stopBgRotation(); // 自增 seq,作废上一批所有游离回调
|
||||
const mySeq=_bgRot.seq;
|
||||
const A=document.getElementById('detHeroBgA'),B=document.getElementById('detHeroBgB');
|
||||
if(!A||!B||!entries||!entries.length)return;
|
||||
const pos=window.innerWidth>=window.innerHeight?'center 12%':'center 28%'; // 横版偏上、竖版海报居中偏上
|
||||
A.style.backgroundPosition=pos;B.style.backgroundPosition=pos;
|
||||
// 第一张:先出 thumb 消除黑屏,full 后台替换
|
||||
_bgProgressiveSet(A,entries[0],mySeq);
|
||||
B.style.opacity='0';
|
||||
_bgRot.active='A';
|
||||
if(entries.length>1){
|
||||
let idx=0;
|
||||
_bgRot.timer=setInterval(()=>{
|
||||
idx=(idx+1)%entries.length;
|
||||
const cur=_bgRot.active==='A'?A:B, nxt=_bgRot.active==='A'?B:A;
|
||||
const nextActive=_bgRot.active==='A'?'B':'A';
|
||||
// 预加载下一张 thumb,加载完交叉淡入;full 在 nxt 显示后后台替换
|
||||
const entry=entries[idx];
|
||||
const thumbUrl=entry&&entry.thumb?entry.thumb:(typeof entry==='string'?entry:'');
|
||||
_bgPreload(thumbUrl,mySeq,u=>{
|
||||
nxt.style.backgroundImage=`url("${u}")`;
|
||||
nxt.style.opacity='1';cur.style.opacity='0';
|
||||
_bgRot.active=nextActive;
|
||||
// 切换完成后后台替换高清图
|
||||
const fullUrl=entry&&entry.full?entry.full:u;
|
||||
if(fullUrl&&fullUrl!==u) _bgPreload(fullUrl,mySeq,fu=>{ nxt.style.backgroundImage=`url("${fu}")`; });
|
||||
});
|
||||
},6500);
|
||||
}
|
||||
}
|
||||
// TMDB 类型(genre) ID→中文名(合并 movie 与 tv 两套),用于标题下方的影视类型标签
|
||||
const TMDB_GENRES={28:'动作',12:'冒险',16:'动画',35:'喜剧',80:'犯罪',99:'纪录',18:'剧情',10751:'家庭',14:'奇幻',36:'历史',27:'恐怖',10402:'音乐',9648:'悬疑',10749:'爱情',878:'科幻',10770:'电视电影',53:'惊悚',10752:'战争',37:'西部',10759:'动作冒险',10762:'儿童',10763:'新闻',10764:'真人秀',10765:'科幻奇幻',10766:'肥皂剧',10767:'脱口秀',10768:'战争政治'};
|
||||
async function tmdbGenresText(title,year){
|
||||
const item=await tmdbMatchCached(title,year);
|
||||
if(!item||!Array.isArray(item.genre_ids))return '';
|
||||
return item.genre_ids.map(id=>TMDB_GENRES[id]).filter(Boolean).slice(0,3).join(' · ');
|
||||
}
|
||||
// ===== TMDB 官方更新状态(连载中/已完结/更新至第N集),取代原先的「N条资源」角标 =====
|
||||
const tmdbStatusCache=new Map();
|
||||
async function tmdbStatusText(title,year){
|
||||
const key=tmdbCleanTitle(title)+'|'+(year||'');
|
||||
if(!tmdbCleanTitle(title))return null;
|
||||
if(tmdbStatusCache.has(key))return tmdbStatusCache.get(key);
|
||||
const p=(async()=>{
|
||||
try{
|
||||
const item=await tmdbMatchCached(title,year);
|
||||
if(!item||!item.id)return null;
|
||||
const type=item.media_type==='movie'?'movie':'tv'; // multi 搜索的非 tv/movie 结果(如 person)已在匹配阶段被海报条件过滤掉
|
||||
const url=`${TMDB_API}/${type}/${item.id}?api_key=${TMDB_KEY}&language=zh-CN`;
|
||||
const d=await tmdbJSON(url);
|
||||
if(type==='movie'){
|
||||
// 电影:用上映日期年份即可,状态意义不大
|
||||
const y=(d.release_date||'').slice(0,4);
|
||||
return d.status==='Released'?(y?`${y} 已上映`:'已上映'):(d.status?tmdbStatusZh(d.status):null);
|
||||
}
|
||||
// 剧集:在播给「更新至第N集」,完结/已取消给「已完结」
|
||||
if(d.in_production && (d.status==='Returning Series'||d.status==='In Production')){
|
||||
const last=d.last_episode_to_air;
|
||||
return last&&last.episode_number?`更新至第${last.episode_number}集`:'连载中';
|
||||
}
|
||||
if(d.status==='Ended'||d.status==='Canceled')return '已完结';
|
||||
return d.status?tmdbStatusZh(d.status):null;
|
||||
}catch(e){return null}
|
||||
})();
|
||||
tmdbStatusCache.set(key,p);
|
||||
return p;
|
||||
}
|
||||
function tmdbStatusZh(s){
|
||||
return ({Released:'已上映','Post Production':'制作后期',Planned:'制作中',
|
||||
'In Production':'制作中',Rumored:'传闻阶段','Returning Series':'连载中',
|
||||
Ended:'已完结',Canceled:'已取消'})[s]||null;
|
||||
}
|
||||
// 站源内容(标题/链接/资源等)保持不变,只异步替换卡片封面图
|
||||
// ===== 封面兜底链:原图正常则保留;缺图/裂图才依次尝试 TMDB → 站源详情页大图 → 占位 =====
|
||||
const _posterAltHost=(u)=> u.indexOf('://image.tmdb.org')!==-1 ? u.replace('://image.tmdb.org','://images.tmdb.org')
|
||||
: u.indexOf('://images.tmdb.org')!==-1 ? u.replace('://images.tmdb.org','://image.tmdb.org') : u;
|
||||
// 预加载 url,成功才把第 i 张卡封面换成它;href 防止异步返回时卡片已被新列表替换
|
||||
function posterTrySet(i,url,href){
|
||||
return new Promise(resolve=>{
|
||||
if(!url){resolve(false);return}
|
||||
const valid=()=>{const c=content.querySelector(`.card[data-i="${i}"]`);return (c&&(!href||c.dataset.href===href))?c:null};
|
||||
if(!valid()){resolve(false);return}
|
||||
const probe=new Image();let triedAlt=false;
|
||||
probe.referrerPolicy='no-referrer';
|
||||
probe.onload=()=>{
|
||||
const card=valid(); if(!card){resolve(false);return}
|
||||
const poster=card.querySelector('.poster'); if(!poster){resolve(false);return}
|
||||
let img=poster.querySelector('img');
|
||||
if(img){img.src=probe.src;img.style.display=''}
|
||||
else{img=document.createElement('img');img.loading='lazy';img.referrerPolicy='no-referrer';img.src=probe.src;poster.insertBefore(img,poster.firstChild)}
|
||||
const noimg=poster.querySelector('.noimg'); if(noimg)noimg.remove();
|
||||
resolve(true);
|
||||
};
|
||||
probe.onerror=()=>{ if(!triedAlt){triedAlt=true;const a=_posterAltHost(url);if(a!==url){probe.src=a;return}} resolve(false) };
|
||||
probe.src=url;
|
||||
});
|
||||
}
|
||||
// 二级兜底:到该卡对应站源的详情页抓大图
|
||||
async function posterFromDetail(v){
|
||||
const src=(v&&v.sources&&v.sources[0])||v;
|
||||
if(!src||!src.href)return'';
|
||||
const s=SITES.find(x=>x.id===src.siteId)||site();
|
||||
const r=await get(s,src.href),d=doc(r.html);
|
||||
const el=d.querySelector('.module-info-poster img,.detail-pic img,.module-item-pic img,img');
|
||||
if(!el)return'';
|
||||
let pic=el.getAttribute('data-original')||el.getAttribute('data-src')||el.getAttribute('src')||'';
|
||||
pic=imgUrl(pic); if(!pic)return'';
|
||||
pic=abs(r.base,pic);
|
||||
return pic===v.pic ? '' : pic; // 与已失败的列表图相同就不必再试
|
||||
}
|
||||
// 对第 i 张卡执行兜底链(自带去重,避免重复触发)
|
||||
async function posterFallback(i){
|
||||
const poster=content.querySelector(`.card[data-i="${i}"] .poster`);
|
||||
if(!poster||poster.dataset.fb)return; poster.dataset.fb='1';
|
||||
const v=(last||[])[i]; if(!v||!v.title)return;
|
||||
const href=v.href;
|
||||
try{const u=await tmdbPosterCached(v.title,extractYear(v)); if(u&&await posterTrySet(i,u,href))return}catch(e){}
|
||||
try{const u=await posterFromDetail(v); if(u&&await posterTrySet(i,u,href))return}catch(e){}
|
||||
// 都失败:保留“宝宝巴士”占位
|
||||
}
|
||||
// 列表图“加载成功”时检查:尺寸过小(1×1/占位/空白图)视为无效,转入兜底链
|
||||
window._posterOk=function(img){
|
||||
if(!img||img.dataset.failed||img.dataset.ok)return;
|
||||
if(img.naturalWidth&&img.naturalHeight&&img.naturalWidth<50&&img.naturalHeight<50){window._posterFail(img);return}
|
||||
img.dataset.ok='1';
|
||||
};
|
||||
// 列表图加载失败(裂图)时由 <img onerror> 调用:隐藏裂图、显示占位、启动兜底链
|
||||
window._posterFail=function(img){
|
||||
if(!img||img.dataset.failed)return; img.dataset.failed='1';
|
||||
const card=img.closest('.card'); if(!card)return;
|
||||
const poster=card.querySelector('.poster'); if(!poster)return;
|
||||
img.style.display='none';
|
||||
if(!poster.querySelector('.noimg')){const ph=document.createElement('div');ph.className='noimg';ph.textContent='宝宝巴士';poster.insertBefore(ph,poster.firstChild)}
|
||||
const i=parseInt(card.dataset.i);
|
||||
if(!isNaN(i))posterFallback(i);
|
||||
};
|
||||
async function enhanceGridPosters(list){
|
||||
const CONCURRENCY=4;
|
||||
let idx=0;
|
||||
function applyStatus(i,text){
|
||||
const badge=content.querySelector(`.card[data-i="${i}"] .source-badge`);
|
||||
if(!badge||!text)return;
|
||||
badge.textContent=text;
|
||||
badge.classList.add('show');
|
||||
}
|
||||
async function worker(){
|
||||
while(idx<list.length){
|
||||
const i=idx++;const v=list[i];
|
||||
if(!v||!v.title)continue;
|
||||
if(!v.pic)posterFallback(i); // 缺图:直接走兜底链(裂图由 onerror 触发,正常封面不动)
|
||||
else{
|
||||
// 有图:超时兜底——9 秒后仍未出图(卡住/空白)则转入兜底链
|
||||
const href=v.href, ii=i;
|
||||
setTimeout(()=>{
|
||||
const card=content.querySelector(`.card[data-i="${ii}"]`);
|
||||
if(!card||card.dataset.href!==href)return;
|
||||
const img=card.querySelector('.poster img');
|
||||
if(!img||img.dataset.ok||img.dataset.failed)return;
|
||||
if(!img.complete||!img.naturalWidth||img.naturalWidth<50)window._posterFail(img);
|
||||
},9000);
|
||||
}
|
||||
try{const st=await tmdbStatusText(v.title,extractYear(v));if(st)applyStatus(i,st)}catch(e){}
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({length:Math.min(CONCURRENCY,list.length)},worker));
|
||||
}
|
||||
// 复制链接工具
|
||||
async function copyText(text){
|
||||
try{await navigator.clipboard.writeText(text);alert('链接已复制!')}catch(e){alert('复制失败,请手动复制:'+text)}
|
||||
}
|
||||
// 探测一张卡点进去到底有没有网盘:抓详情页 HTML,跑网盘域名正则。
|
||||
// 返回 {ok:是否成功抓到详情, has:是否含网盘链接}。最多查前 3 个源,命中即停。
|
||||
const _PAN_TEST=/https?:\/\/(?:[\w.-]*\.)?(?:pan\.baidu\.com|pan\.quark\.cn|drive\.uc\.cn|aliyundrive\.com|alipan\.com|aliyunpan\.com|cloud\.189\.cn|115\.com|115cdn\.[a-z0-9]+|123[a-z0-9]*\.(?:com|cn|net)|caiyun\.139\.com|pan\.xunlei\.com|mypikpak\.com|lanzou[a-z]*\.[a-z]+|feijipan\.com)\//i;
|
||||
const _panProbeCache=new Map(); // 按 href 缓存探测结果,避免和详情页打开重复抓
|
||||
async function probePan(v){
|
||||
const sources=(v.sources||[v]).slice(0,3);
|
||||
let anyOk=false;
|
||||
for(const src of sources){
|
||||
const ck=src.href;
|
||||
if(ck&&_panProbeCache.has(ck)){ if(_panProbeCache.get(ck)) return {ok:true,has:true}; anyOk=true; continue; }
|
||||
try{
|
||||
const s=SITES.find(x=>x.id===src.siteId)||site();
|
||||
const r=await get(s,src.href,8,true);
|
||||
anyOk=true;
|
||||
const RAW=(r.html||'').replace(/\\\//g,'/');
|
||||
const hit=_PAN_TEST.test(RAW);
|
||||
if(ck)_panProbeCache.set(ck,hit);
|
||||
if(hit)return {ok:true,has:true};
|
||||
}catch(e){ if(ck)_panProbeCache.set(ck,false); }
|
||||
}
|
||||
return {ok:anyOk,has:false};
|
||||
}
|
||||
// 搜索后台验证:把「确认抓到了详情、但里面没有任何网盘链接」的空卡淡出隐藏。
|
||||
// 抓取失败/超时的卡一律保留,不冤枉。gen 用于在用户离开搜索后及时停止。
|
||||
async function verifyAndHideEmpty(list,gen){
|
||||
const CONC=6; // 验证在搜索请求结束后才跑,此时桥接空闲,可放开并发加快隐藏
|
||||
let idx=0;
|
||||
async function worker(){
|
||||
while(idx<list.length){
|
||||
const i=idx++;
|
||||
if(gen!==_searchGen)return;
|
||||
const v=list[i];
|
||||
if(!v)continue;
|
||||
let res;
|
||||
try{res=await probePan(v)}catch(e){res={ok:false,has:false}}
|
||||
if(gen!==_searchGen)return;
|
||||
if(res.ok&&!res.has){
|
||||
const card=content.querySelector(`.card[data-i="${i}"]`);
|
||||
if(card){
|
||||
card.style.transition='opacity .3s';
|
||||
card.style.opacity='0';
|
||||
setTimeout(()=>{if(card&&card.parentNode)card.style.display='none'},300);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({length:CONC},worker));
|
||||
}
|
||||
async function parseDetail(v){
|
||||
let sources=v.sources||[v];
|
||||
// 并发请求所有站源(最多5个),不再一个个串行等,详情页打开速度≈最慢那一个源,而不是全部加起来
|
||||
let results=await Promise.all(sources.slice(0,10).map(async src=>{
|
||||
let s=SITES.find(x=>x.id===src.siteId)||site();
|
||||
try{
|
||||
let r=await get(s,src.href),d=doc(r.html);
|
||||
let title=stripTitleNoise(clean(d.querySelector('h1,.title,.module-info-heading h1')?.textContent))||src.title;
|
||||
let pic=imgUrl(d.querySelector('.module-info-poster img,.detail-pic img,img')?.getAttribute('src')||src.pic);
|
||||
let desc=clean(d.querySelector('.module-info-introduction,.desc,.module-info-content')?.textContent)||'';
|
||||
let pans=[];
|
||||
let txtEls=[...d.querySelectorAll(s.detailPanSelector||'.module-row-info p')];
|
||||
txtEls.forEach(p=>{
|
||||
let t=clean(p.textContent);
|
||||
if(/^https?:/.test(t))pans.push({name:src.siteName+' · '+panType(t),url:t,type:panType(t),siteName:src.siteName})
|
||||
});
|
||||
[...d.querySelectorAll('a[href]')].forEach(a=>{
|
||||
let u=a.getAttribute('href')||'';
|
||||
if(/pan\.baidu|quark|aliyun|alipan|xunlei|115\.com|115cdn|123(?:\d{2,4})?\.(?:com|cn|net)|123pan\.(?:com|cn)|cloud\.189|caiyun|drive\.uc\.cn|magnet:/i.test(u))pans.push({name:src.siteName+' · '+(clean(a.textContent)||panType(u)),url:abs(r.base,u),type:panType(u),siteName:src.siteName})
|
||||
});
|
||||
// 终极兜底:直接对整页 HTML 源码做正则扫描。这样无论网盘链接是放在普通文本、
|
||||
// a 标签、复制按钮的 data-clipboard-text、输入框 value、还是 JS 字符串里,都能捞出来。
|
||||
// 先还原 JSON 里被转义的斜杠(\/→/),再按网盘域名匹配。
|
||||
const RAW=(r.html||'').replace(/\\\//g,'/');
|
||||
const PAN_RE=/https?:\/\/(?:[\w.-]*\.)?(?:pan\.baidu\.com|pan\.quark\.cn|drive\.uc\.cn|aliyundrive\.com|alipan\.com|aliyunpan\.com|cloud\.189\.cn|115\.com|115cdn\.[a-z0-9]+|123[a-z0-9]*\.(?:com|cn|net)|caiyun\.139\.com|pan\.xunlei\.com|mypikpak\.com|lanzou[a-z]*\.[a-z]+|feijipan\.com|f\.ws[a-z0-9.]+)\/[A-Za-z0-9\-_/?=&%.#~+]*/gi;
|
||||
let mm;
|
||||
while((mm=PAN_RE.exec(RAW))){
|
||||
let u=mm[0].replace(/&/g,'&').replace(/[,。、))]+$/,'');
|
||||
pans.push({name:src.siteName+' · '+panType(u),url:u,type:panType(u),siteName:src.siteName});
|
||||
}
|
||||
return{info:{title,pic:abs(r.base,pic),desc,siteName:src.siteName},pans};
|
||||
}catch(e){return null}
|
||||
}));
|
||||
// 按数组原本顺序(非返回先后)取第一个成功的源作为标题/海报/简介来源,避免并发后信息来源不稳定地跳动
|
||||
let main=(results.find(x=>x)||{}).info||v;
|
||||
let all=results.filter(x=>x).flatMap(x=>x.pans);
|
||||
let seen=new Set();
|
||||
all=all.filter(x=>x.url&&!seen.has(x.url)&&(seen.add(x.url)||true));
|
||||
return{info:main,pans:all}
|
||||
}
|
||||
let _currentDetailItem=null; // 当前详情页对应的条目;同时作为取消令牌:closeDetail 时置 null,_loadDetailContent 比较引用判废弃(参考 nostr-emby state.selected 模式)
|
||||
function openDetail(v){
|
||||
_currentDetailItem=v;
|
||||
if (location.hash !== '#detail') history.pushState({ wo: 'detail' }, '', '#detail');
|
||||
|
||||
const sheet=$('#sheet'), panel=$('#panel');
|
||||
const heroImg=$('#detHeroImg'), hero=$('#detHero'), topbar=$('#detTopbar'), topbarTitle=$('#detTopbarTitle');
|
||||
const heroCta=$('#detHeroCta');
|
||||
|
||||
// 重置滚动 & 顶栏状态
|
||||
sheet.scrollTop=0;
|
||||
topbar.classList.remove('scrolled');
|
||||
heroImg.classList.remove('loaded');
|
||||
heroImg.removeAttribute('src');
|
||||
hero.classList.add('landscape');
|
||||
sheet.classList.add('landscape-mode'); // 所有详情统一全屏沉浸,避免退回半屏的文档流海报
|
||||
{stopBgRotation();const bg=document.getElementById('detHeroBg');if(bg){delete bg.dataset.bd;bg.classList.add('show');}const A=document.getElementById('detHeroBgA'),B=document.getElementById('detHeroBgB');if(A){A.style.backgroundImage='';A.style.opacity='0';}if(B){B.style.backgroundImage='';B.style.opacity='0';}} // 立即显示深色背景层、清空旧海报,海报图加载好后再覆盖,避免空窗期露出主页
|
||||
heroCta.innerHTML='';
|
||||
|
||||
sheet.classList.add('active');
|
||||
// 详情页不需要"回到顶部"按钮(它的 z-index 高于详情页,会悬浮在上面遮挡内容)
|
||||
if(window._backTopReset) window._backTopReset();
|
||||
|
||||
// 显示加载状态
|
||||
panel.innerHTML='<div class="det-loading"><div class="det-spinner"></div><span>读取详情…</span></div>';
|
||||
|
||||
// 吸顶滚动监听
|
||||
const onScroll=()=>{
|
||||
topbar.classList.toggle('scrolled', sheet.scrollTop > 60);
|
||||
};
|
||||
sheet.addEventListener('scroll', onScroll, {passive:true});
|
||||
sheet._detScrollCb = onScroll;
|
||||
|
||||
// async 数据加载独立出去,用 item 引用守门(同 nostr-emby loadDetail 模式)
|
||||
_loadDetailContent(v);
|
||||
}
|
||||
async function _loadDetailContent(v){
|
||||
const sheet=$('#sheet'), panel=$('#panel');
|
||||
const heroImg=$('#detHeroImg'), topbarTitle=$('#detTopbarTitle');
|
||||
const heroCta=$('#detHeroCta');
|
||||
try{
|
||||
let d=await parseDetail(v);
|
||||
if (_currentDetailItem !== v) return; // 用户已返回/打开新详情,丢弃本次结果
|
||||
let info=d.info;
|
||||
const title=info.title||v.title||'';
|
||||
const sources=(v.sources||[v]).map(x=>x.siteName).join(' · ');
|
||||
const quality=v.quality||'';
|
||||
|
||||
topbarTitle.textContent=title;
|
||||
// 海报:先用站源图立即显示;TMDB 高清图在后台拿到后再静默替换(超时/失败则保持站源图)
|
||||
const altHost=(u)=> u.indexOf('://image.tmdb.org')!==-1 ? u.replace('://image.tmdb.org','://images.tmdb.org')
|
||||
: u.indexOf('://images.tmdb.org')!==-1 ? u.replace('://images.tmdb.org','://image.tmdb.org') : u;
|
||||
const setHero=(pic)=>{
|
||||
if(!pic)return;
|
||||
let triedAlt=false;
|
||||
heroImg.onload=()=>{
|
||||
if(_currentDetailItem!==v)return; // 图片加载完时可能已关闭详情
|
||||
const bg=document.getElementById('detHeroBg');
|
||||
// 站源图(无论横竖)都作为全屏背景兜底,TMDB 高清图就绪后会覆盖
|
||||
if(bg && !bg.dataset.bd){
|
||||
const A=document.getElementById('detHeroBgA');
|
||||
if(A){A.style.backgroundImage=`url("${heroImg.src}")`;A.style.opacity='1';}
|
||||
bg.classList.add('show');
|
||||
}
|
||||
heroImg.classList.add('loaded');
|
||||
};
|
||||
heroImg.onerror=()=>{
|
||||
if(!triedAlt){triedAlt=true;const a=altHost(heroImg.src||'');if(a&&a!==heroImg.src){heroImg.src=a;return}}
|
||||
heroImg.classList.remove('loaded');heroImg.removeAttribute('src');
|
||||
};
|
||||
heroImg.src=pic;
|
||||
};
|
||||
setHero(v.pic||info.pic||'');
|
||||
tmdbPosterFullCached(v.title||'',extractYear(v)).then(tp=>{if(_currentDetailItem!==v)return;if(tp)setHero(tp)}).catch(()=>{});
|
||||
// 影视类型标签(TMDB genres),标题下方居中
|
||||
tmdbGenresText(v.title||'',extractYear(v)).then(g=>{
|
||||
if(_currentDetailItem!==v)return;
|
||||
const el=document.getElementById('detGenres');
|
||||
if(el&&g)el.textContent=g;
|
||||
}).catch(()=>{});
|
||||
tmdbImagesCached(v.title||'',extractYear(v)).then(urls=>{
|
||||
if(_currentDetailItem!==v)return;
|
||||
const bg=document.getElementById('detHeroBg');
|
||||
if(bg&&urls&&urls.length){
|
||||
setBackdrops(urls); // 多张剧照轮播;只有一张时就静态显示
|
||||
bg.dataset.bd='1';
|
||||
bg.classList.add('show');
|
||||
sheet.classList.add('landscape-mode');
|
||||
}
|
||||
}).catch(()=>{});
|
||||
|
||||
// pills
|
||||
const pills=[
|
||||
sources && `<span class="det-pill">${esc(sources)}</span>`,
|
||||
quality && `<span class="det-pill accent">${esc(quality)}</span>`,
|
||||
d.pans.length && `<span class="det-pill">${d.pans.length} 条资源</span>`,
|
||||
].filter(Boolean).join('');
|
||||
|
||||
// 海报内部底部:播放按钮
|
||||
heroCta.innerHTML = d.pans.length?`<button class="det-play-btn" id="detPlayBtn" aria-label="立即播放">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||||
</button>`:'';
|
||||
|
||||
panel.innerHTML=`
|
||||
<div class="det-bigtitle">${esc(title)}</div>
|
||||
<div class="det-genres" id="detGenres"></div>
|
||||
<div class="det-collapse-row">
|
||||
<section class="det-collapse det-res">
|
||||
<div class="det-collapse-head">
|
||||
<div class="det-res-tabs">
|
||||
<button class="det-res-tab active" data-tab="netdisk" type="button">网盘资源<em>${d.pans.length||0}</em></button>
|
||||
<button class="det-res-tab" data-tab="pansou" type="button">盘搜资源</button>
|
||||
</div>
|
||||
<button id="panCfgBtn" class="pan-cfg-btn" title="盘搜设置(地址 + 网盘类型)">⚙</button>
|
||||
</div>
|
||||
<div class="det-collapse-body">
|
||||
<div class="det-res-pane" data-pane="netdisk">
|
||||
<div class="pan-list">
|
||||
${d.pans.map((p,i)=>`
|
||||
<div class="pan-item">
|
||||
<span class="pan-type">${esc(p.type)}</span>
|
||||
<div class="pan-info">
|
||||
<div class="pan-name">${esc(p.name)}</div>
|
||||
<div class="pan-url">${esc(p.url)}</div>
|
||||
</div>
|
||||
<div class="pan-actions">
|
||||
<button class="pan-open-btn" data-i="${i}">打开</button>
|
||||
</div>
|
||||
</div>`).join('')||'<div class="empty">未提取到网盘链接</div>'}
|
||||
</div>
|
||||
</div>
|
||||
<div class="det-res-pane" data-pane="pansou" hidden>
|
||||
<div class="pan-search-box" id="panSearchBox"><div class="pan-search-status">正在聚合搜索网盘…</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>`;
|
||||
|
||||
// 单条资源框:tab 切换「网盘资源 / 盘搜资源」,箭头/头部空白处展开收起(默认收起,露出海报)
|
||||
const resBox=panel.querySelector('.det-res');
|
||||
const resHead=resBox.querySelector('.det-collapse-head');
|
||||
resBox.querySelectorAll('.det-res-tab').forEach(tab=>{
|
||||
tab.addEventListener('click',(e)=>{
|
||||
e.stopPropagation();
|
||||
resBox.querySelectorAll('.det-res-tab').forEach(t=>t.classList.toggle('active',t===tab));
|
||||
resBox.querySelectorAll('.det-res-pane').forEach(p=>{p.hidden=(p.dataset.pane!==tab.dataset.tab)});
|
||||
resBox.classList.add('open'); // 点 tab 自动展开
|
||||
});
|
||||
});
|
||||
resHead.addEventListener('click',(e)=>{
|
||||
if(e.target.closest('.det-res-tab')||e.target.closest('.pan-cfg-btn'))return;
|
||||
resBox.classList.toggle('open');
|
||||
});
|
||||
// 点击海报任意位置(资源框、播放按钮以外)自动收起资源框
|
||||
sheet.onclick=(e)=>{
|
||||
if(resBox.classList.contains('open') && !e.target.closest('.det-res') && !e.target.closest('.det-hero-cta')){
|
||||
resBox.classList.remove('open');
|
||||
}
|
||||
};
|
||||
|
||||
// 快捷播放第一条
|
||||
const playBtn=$('#detPlayBtn');
|
||||
if(playBtn&&d.pans.length) playBtn.onclick=()=>playPan(d.pans[0]);
|
||||
|
||||
// 复制 & 打开按钮
|
||||
panel.querySelectorAll('.copy-btn').forEach(btn=>{
|
||||
btn.onclick=()=>copyText(btn.dataset.link);
|
||||
});
|
||||
panel.querySelectorAll('.pan-open-btn[data-i]').forEach(b=>{
|
||||
b.onclick=()=>playPan(d.pans[parseInt(b.dataset.i)]);
|
||||
});
|
||||
|
||||
// 盘搜:异步聚合搜索网盘资源,不阻塞详情首屏
|
||||
const panBox=$('#panSearchBox');
|
||||
const panKw=panKeyword(title);
|
||||
if(panBox&&panKw) startPanSearch(panBox,panKw);
|
||||
const panCfgBtn=$('#panCfgBtn');
|
||||
if(panCfgBtn) panCfgBtn.onclick=async(e)=>{
|
||||
e.stopPropagation();
|
||||
const ok=await openPanConfig();
|
||||
if(ok && panBox && panKw) startPanSearch(panBox,panKw); // 保存后用新地址/类型重搜
|
||||
};
|
||||
|
||||
}catch(e){
|
||||
panel.innerHTML=`<div class="det-loading"><div class="empty">详情失败:${esc(e.message)}</div></div>`;
|
||||
}
|
||||
}
|
||||
// ===== 盘搜(PanSou 聚合网盘搜索)=====
|
||||
const PAN_CONFIG={
|
||||
apiBase:'https://jiduo-ps.jiduovox.de5.net/go/panso-jiduovox',
|
||||
diskTypes:['quark','aliyun','baidu','uc','tianyi','xunlei','123','115','mobile','guangya','magnet','ed2k']
|
||||
};
|
||||
try{ const saved=localStorage.getItem('wo_pan_api'); if(saved) PAN_CONFIG.apiBase=saved; }catch(e){}
|
||||
try{ const t=JSON.parse(localStorage.getItem('wo_pan_types')||'null'); if(Array.isArray(t)&&t.length) PAN_CONFIG.diskTypes=t; }catch(e){}
|
||||
// 一次性迁移:把新增类型补进老用户已保存的选择里(每个类型只补一次,之后尊重用户勾选)
|
||||
try{
|
||||
if(localStorage.getItem('wo_pan_types')){
|
||||
let migrated=[]; try{migrated=JSON.parse(localStorage.getItem('wo_pan_types_migrated')||'[]')}catch(_){}
|
||||
if(!Array.isArray(migrated))migrated=[];
|
||||
// 兼容旧的磁力迁移标记:视为 magnet/ed2k 已迁移
|
||||
if(localStorage.getItem('wo_pan_magnet_migrated')==='1'){['magnet','ed2k'].forEach(t=>{if(migrated.indexOf(t)===-1)migrated.push(t)})}
|
||||
let changed=false;
|
||||
['magnet','ed2k','guangya'].forEach(t=>{
|
||||
if(migrated.indexOf(t)===-1){
|
||||
if(PAN_CONFIG.diskTypes.indexOf(t)===-1){PAN_CONFIG.diskTypes.push(t);changed=true}
|
||||
migrated.push(t);
|
||||
}
|
||||
});
|
||||
if(changed) localStorage.setItem('wo_pan_types',JSON.stringify(PAN_CONFIG.diskTypes));
|
||||
localStorage.setItem('wo_pan_types_migrated',JSON.stringify(migrated));
|
||||
}
|
||||
}catch(e){}
|
||||
// 全部可选网盘类型(顺序即弹窗里的排列顺序)
|
||||
const PAN_ALL_TYPES=['quark','aliyun','baidu','uc','tianyi','xunlei','123','115','mobile','guangya','magnet','ed2k'];
|
||||
const PAN_TYPE_LABEL={quark:'夸克',aliyun:'阿里',baidu:'百度',uc:'UC',tianyi:'天翼',xunlei:'迅雷',123:'123',115:'115',mobile:'移动',guangya:'光鸭',magnet:'磁力',ed2k:'电驴'};
|
||||
const PAN_CHECK_TYPES=new Set(['aliyun','quark','uc','baidu','tianyi','123','xunlei','115','mobile']);
|
||||
function panTypeLabel(t){return PAN_TYPE_LABEL[t]||t||'网盘'}
|
||||
// 盘搜设置弹窗:接口地址 + 网盘类型多选;确定后写入本地存储,返回 true 表示已保存
|
||||
function openPanConfig(){
|
||||
return new Promise(resolve=>{
|
||||
const mask=$('#panCfgMask'),input=$('#panCfgInput'),okBtn=$('#panCfgOk'),cancelBtn=$('#panCfgCancel');
|
||||
const titleEl=mask&&mask.querySelector('.custom-modal-title');
|
||||
const typeCfg=$('#panTypeCfg'),grid=$('#panTypeGrid'),allBtn=$('#panTypeAll');
|
||||
if(!mask){resolve(false);return}
|
||||
if(titleEl) titleEl.textContent='盘搜设置';
|
||||
input.value=PAN_CONFIG.apiBase||'';
|
||||
const sel=new Set(PAN_CONFIG.diskTypes);
|
||||
function paintAll(){ allBtn.textContent = sel.size>=PAN_ALL_TYPES.length ? '全不选' : '全选'; }
|
||||
if(typeCfg) typeCfg.style.display='block';
|
||||
grid.innerHTML=PAN_ALL_TYPES.map(t=>
|
||||
`<button type="button" class="pan-type-chip${sel.has(t)?' on':''}" data-t="${esc(t)}">`
|
||||
+`<svg class="tick" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>`
|
||||
+`<span>${esc(panTypeLabel(t))}</span></button>`).join('');
|
||||
grid.querySelectorAll('.pan-type-chip').forEach(chip=>{
|
||||
chip.onclick=()=>{
|
||||
const t=chip.dataset.t;
|
||||
if(sel.has(t))sel.delete(t);else sel.add(t);
|
||||
chip.classList.toggle('on',sel.has(t));
|
||||
paintAll();
|
||||
};
|
||||
});
|
||||
allBtn.onclick=()=>{
|
||||
if(sel.size>=PAN_ALL_TYPES.length)sel.clear();
|
||||
else PAN_ALL_TYPES.forEach(t=>sel.add(t));
|
||||
grid.querySelectorAll('.pan-type-chip').forEach(c=>c.classList.toggle('on',sel.has(c.dataset.t)));
|
||||
paintAll();
|
||||
};
|
||||
paintAll();
|
||||
mask.classList.add('show');
|
||||
requestAnimationFrame(()=>{input.focus()});
|
||||
let done=false;
|
||||
const finish=(ok)=>{
|
||||
if(done)return;done=true;
|
||||
mask.classList.remove('show');
|
||||
okBtn.onclick=null;cancelBtn.onclick=null;mask.onclick=null;input.onkeydown=null;allBtn.onclick=null;
|
||||
if(ok){
|
||||
const url=input.value.trim();
|
||||
if(url) PAN_CONFIG.apiBase=url;
|
||||
let arr=PAN_ALL_TYPES.filter(t=>sel.has(t));
|
||||
if(!arr.length) arr=PAN_ALL_TYPES.slice(); // 至少保留全部,避免搜不到任何资源
|
||||
PAN_CONFIG.diskTypes=arr;
|
||||
try{localStorage.setItem('wo_pan_api',PAN_CONFIG.apiBase)}catch(e){}
|
||||
try{localStorage.setItem('wo_pan_types',JSON.stringify(arr))}catch(e){}
|
||||
}
|
||||
resolve(ok);
|
||||
};
|
||||
okBtn.onclick=()=>finish(true);
|
||||
cancelBtn.onclick=()=>finish(false);
|
||||
mask.onclick=(e)=>{ if(e.target===mask) finish(false); };
|
||||
input.onkeydown=(e)=>{ if(e.key==='Enter'){e.preventDefault();finish(true)} else if(e.key==='Escape'){e.preventDefault();finish(false)} };
|
||||
});
|
||||
}
|
||||
function panApi(path){return PAN_CONFIG.apiBase.replace(/\/+$/,'')+path}
|
||||
function panKeyword(t){return clean(String(t||'').replace(/[《》\[\]【】]/g,'').replace(/[((].*?[))]/g,''))}
|
||||
function panNormUrl(u){return String(u||'').trim().replace(/[?&]+$/,'')}
|
||||
|
||||
async function panReq(path,body){
|
||||
const url=panApi(path);
|
||||
const fm=await fmReady();
|
||||
if(fm&&fm.req){
|
||||
let r=await fm.req(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body),responseType:'json',timeout:12});
|
||||
if(!r.ok)throw new Error(r.error||('HTTP '+r.status));
|
||||
return typeof r.body==='string'?JSON.parse(r.body):r.body;
|
||||
}
|
||||
let resp=await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
|
||||
if(!resp.ok)throw new Error('HTTP '+resp.status);
|
||||
return await resp.json();
|
||||
}
|
||||
|
||||
async function panSearch(kw){
|
||||
const data=await panReq('/api/search',{kw,res:'merge',src:'all',cloud_types:PAN_CONFIG.diskTypes});
|
||||
const byType=(data&&data.data&&data.data.merged_by_type)||(data&&data.merged_by_type)||{};
|
||||
let out=[];
|
||||
Object.keys(byType).forEach(type=>{
|
||||
if(PAN_CONFIG.diskTypes.indexOf(type)===-1)return;
|
||||
(byType[type]||[]).forEach(item=>{
|
||||
const url=item.url||item.share_url||'';
|
||||
if(!url)return;
|
||||
out.push({
|
||||
type,
|
||||
title:clean(item.note||item.title||item.name||kw),
|
||||
url,
|
||||
password:item.password||item.pwd||'',
|
||||
datetime:item.datetime||item.time||''
|
||||
});
|
||||
});
|
||||
});
|
||||
let seen=new Set(),dedup=[];
|
||||
out.forEach(x=>{const k=x.type+'|'+panNormUrl(x.url);if(!seen.has(k)){seen.add(k);dedup.push(x)}});
|
||||
dedup.forEach((x,i)=>{x._q=panQualityScore(x.title);x._i=i});
|
||||
dedup.sort((a,b)=>b._q-a._q||a._i-b._i); // 画质关键词命中多的靠前;命中数相同保持原有相对顺序
|
||||
return dedup;
|
||||
}
|
||||
|
||||
// 画质/版本关键词打分:标题里命中的关键词越多,分数越高,排序越靠前
|
||||
const PAN_QUALITY_KEYWORDS=[
|
||||
'4k','8k','2160p','1080p',
|
||||
'hdr','hdr10','hdr10+','dolby vision','dovi',
|
||||
'杜比视界','杜比全景声','atmos','dolby atmos','杜比',
|
||||
'remux','bluray','blu-ray','blueray','蓝光原盘','原盘',
|
||||
'uhd','webdl','web-dl','bdrip','x265','h265','hevc','x264','h264',
|
||||
'60帧','60fps','高码率','无水印','国语','粤语','国粤','中字','双语'
|
||||
];
|
||||
function panQualityScore(title){
|
||||
const t=String(title||'').toLowerCase();
|
||||
let score=0;
|
||||
PAN_QUALITY_KEYWORDS.forEach(kw=>{ if(t.indexOf(kw.toLowerCase())!==-1) score++; });
|
||||
return score;
|
||||
}
|
||||
|
||||
// 盘搜类型标签排序优先级;未列出的按全量清单顺序排在后面
|
||||
const PAN_TYPE_ORDER=['quark','baidu','123','uc','115','aliyun','tianyi','xunlei'];
|
||||
function panTypeRank(t){
|
||||
const i=PAN_TYPE_ORDER.indexOf(t);
|
||||
if(i>=0)return i;
|
||||
const j=PAN_ALL_TYPES.indexOf(t);
|
||||
return 100+(j<0?99:j);
|
||||
}
|
||||
function panTypesPresent(list){
|
||||
let m=new Map();
|
||||
list.forEach(x=>m.set(x.type,(m.get(x.type)||0)+1));
|
||||
return[...m.entries()].sort((a,b)=>panTypeRank(a[0])-panTypeRank(b[0]));
|
||||
}
|
||||
|
||||
function renderPanSearchBox(box,allList,activeType){
|
||||
if(!box)return;
|
||||
if(!allList.length){
|
||||
box.innerHTML='<div class="pan-search-status">未搜索到网盘资源</div>';
|
||||
return;
|
||||
}
|
||||
const types=panTypesPresent(allList);
|
||||
if(!activeType||!types.some(t=>t[0]===activeType))activeType=types[0][0];
|
||||
const tabsHtml=types.map(([t,n])=>`<button class="pan-search-tab${t===activeType?' active':''}" data-type="${esc(t)}" data-label="${esc(panTypeLabel(t))}">${esc(panTypeLabel(t))} ${n}</button>`).join('');
|
||||
const list=allList.filter(x=>x.type===activeType);
|
||||
box.innerHTML=`
|
||||
<div class="pan-search-tabs">${tabsHtml}</div>
|
||||
<div class="pan-list">
|
||||
${list.map(p=>`
|
||||
<div class="pan-item">
|
||||
<span class="pan-health-dot" data-state="${PAN_CHECK_TYPES.has(p.type)?'idle':'unsupported'}"></span>
|
||||
<span class="pan-type">${esc(panTypeLabel(p.type))}</span>
|
||||
<div class="pan-info">
|
||||
<div class="pan-name">${esc(p.title)}</div>
|
||||
<div class="pan-url">${esc(p.url)}${p.password?' · 提取码 '+esc(p.password):''}</div>
|
||||
</div>
|
||||
<div class="pan-actions">
|
||||
<button class="pan-open-btn" data-type="${esc(p.type)}" data-url="${esc(p.url)}" data-pwd="${esc(p.password)}" data-title="${esc(p.title)}">打开</button>
|
||||
</div>
|
||||
</div>`).join('')}
|
||||
</div>`;
|
||||
box.querySelectorAll('.pan-search-tab').forEach(btn=>{
|
||||
btn.onclick=(e)=>{e.stopPropagation();renderPanSearchBox(box,allList,btn.dataset.type)};
|
||||
});
|
||||
box.querySelectorAll('.copy-btn').forEach(btn=>btn.onclick=(e)=>{e.stopPropagation();copyText(btn.dataset.link)});
|
||||
box.querySelectorAll('.pan-open-btn').forEach(btn=>{
|
||||
btn.onclick=(e)=>{e.stopPropagation();playPanSearch({type:btn.dataset.type,url:btn.dataset.url,password:btn.dataset.pwd,title:btn.dataset.title})};
|
||||
});
|
||||
panHealthCheck(box,list);
|
||||
}
|
||||
|
||||
// 按路灯(健康检测)结果过滤:仅隐藏“确认非有效”的(失效/需权限/测不准);
|
||||
// 绿灯有效、尚未检测(idle)、无法检测(unsupported,如磁力/电驴) 一律保留
|
||||
function applyPanHealthFilter(box){
|
||||
if(!box)return;
|
||||
let shown=0;
|
||||
box.querySelectorAll('.pan-item').forEach(it=>{
|
||||
const dot=it.querySelector('.pan-health-dot');
|
||||
const st=dot?dot.dataset.state:'';
|
||||
const hide=(st==='bad'||st==='locked'||st==='uncertain');
|
||||
it.style.display=hide?'none':'';
|
||||
if(!hide)shown++;
|
||||
});
|
||||
// 当前激活标签的数字改为“有效条数”(仅已检测的当前类型可知,其余标签仍显示总数)
|
||||
const at=box.querySelector('.pan-search-tab.active');
|
||||
if(at&&at.dataset.label)at.textContent=at.dataset.label+' '+shown;
|
||||
let note=box.querySelector('.pan-empty-note');
|
||||
if(shown===0){
|
||||
const listEl=box.querySelector('.pan-list');
|
||||
if(!note&&listEl){note=document.createElement('div');note.className='pan-search-status pan-empty-note';listEl.appendChild(note)}
|
||||
if(note)note.textContent='该类暂无有效资源';
|
||||
}else if(note){note.remove()}
|
||||
}
|
||||
// 检测支持类型的全部资源(不再限制每批最多30条),分批每批10条,且需先确认 App「网盘检测」开关开启
|
||||
async function panHealthCheck(box,list){
|
||||
const fm=await fmReady();
|
||||
if(!fm||!fm.pan||!fm.pan.check)return; // 无检测能力(如纯浏览器):不过滤,保持全显示
|
||||
let cfg;
|
||||
try{ cfg=await fm.config(); }catch(e){ return; }
|
||||
if(!cfg||!cfg.driveCheck)return; // 未开启「网盘检测」:不过滤,保持全显示
|
||||
const checkable=list.map((p,i)=>({p,i})).filter(o=>PAN_CHECK_TYPES.has(o.p.type));
|
||||
if(!checkable.length){applyPanHealthFilter(box);return}
|
||||
const dots=box.querySelectorAll('.pan-health-dot');
|
||||
for(let s=0;s<checkable.length;s+=10){
|
||||
const batch=checkable.slice(s,s+10);
|
||||
try{
|
||||
const res=await fm.pan.check(batch.map(o=>({type:o.p.type,url:o.p.url,password:o.p.password||''})));
|
||||
(res.results||[]).forEach((r,k)=>{ const dot=dots[batch[k].i]; if(dot)dot.dataset.state=r.state||'uncertain'; });
|
||||
}catch(e){}
|
||||
applyPanHealthFilter(box); // 每批检完即刷新过滤
|
||||
}
|
||||
}
|
||||
|
||||
async function playPanSearch(p){
|
||||
if(_currentDetailItem) addHistory(_currentDetailItem);
|
||||
const fm=await fmReady();
|
||||
if(fm&&fm.pan&&fm.pan.play){
|
||||
try{ await fm.pan.play({type:p.type,url:p.url,password:p.password||'',title:p.title||''}); return; }catch(e){}
|
||||
}
|
||||
if(fm&&fm.play){ await fm.play(p.url.startsWith('magnet:')?p.url:'push://'+p.url,p.title||''); return; }
|
||||
location.href=p.url;
|
||||
}
|
||||
|
||||
let _panToken=0;
|
||||
function startPanSearch(box,kw){
|
||||
const tk=++_panToken;
|
||||
box.innerHTML='<div class="pan-search-status">正在聚合搜索网盘…</div>';
|
||||
panSearch(kw).then(list=>{
|
||||
if(tk!==_panToken)return;
|
||||
renderPanSearchBox(box,list,'');
|
||||
}).catch(e=>{
|
||||
if(tk!==_panToken)return;
|
||||
box.innerHTML='<div class="pan-search-status">盘搜失败:'+esc(e.message)+'</div>';
|
||||
});
|
||||
}
|
||||
|
||||
async function playPan(p){
|
||||
if(_currentDetailItem) addHistory(_currentDetailItem);
|
||||
let u=p.url;
|
||||
if(window.fm&&fm.play){
|
||||
await fm.play(u.startsWith('magnet:')?u:'push://'+u,p.name);
|
||||
return
|
||||
}
|
||||
location.href=u
|
||||
}
|
||||
function closeDetail(){
|
||||
// 走 history 栈,popstate 监听器负责关闭 UI
|
||||
history.back();
|
||||
}
|
||||
// 主题切换
|
||||
|
||||
searchForm.onsubmit=e=>{e.preventDefault();hideSugg();let q=kw.value.trim();q?search(q):(_inSearchMode()?history.back():loadCategory())};
|
||||
|
||||
// ========== 搜索联想(TMDB:小海报 + 年份)==========
|
||||
const searchSugg=$('#searchSugg');
|
||||
const TMDB_IMG_SUGG='https://image.tmdb.org/t/p/w154';
|
||||
let _suggSeq=0,_suggTimer=null;
|
||||
const _suggCache=new Map();
|
||||
|
||||
function hideSugg(){searchSugg.classList.remove('show');searchSugg.innerHTML='';if(window._searchHistUpdate)_searchHistUpdate()}
|
||||
function mediaTypeLabel(t){return t==='tv'?'剧集':t==='movie'?'电影':''}
|
||||
|
||||
function tmdbSuggest(q){
|
||||
const key=q.toLowerCase();
|
||||
if(_suggCache.has(key))return _suggCache.get(key);
|
||||
const url=`${TMDB_API}/search/multi?api_key=${TMDB_KEY}&language=zh-CN&include_adult=false&page=1&query=${encodeURIComponent(q)}`;
|
||||
const p=tmdbJSON(url).then(d=>(d.results||[])
|
||||
.filter(x=>x.media_type==='movie'||x.media_type==='tv')
|
||||
.map(x=>({
|
||||
title:x.title||x.name||x.original_title||x.original_name||'',
|
||||
year:(x.release_date||x.first_air_date||'').slice(0,4),
|
||||
poster:x.poster_path?TMDB_IMG_SUGG+x.poster_path:'',
|
||||
type:x.media_type,
|
||||
pop:x.popularity||0
|
||||
}))
|
||||
.filter(x=>x.title)
|
||||
.sort((a,b)=>b.pop-a.pop)
|
||||
.slice(0,8)
|
||||
).catch(()=>[]);
|
||||
_suggCache.set(key,p);
|
||||
return p;
|
||||
}
|
||||
|
||||
function renderSugg(list){
|
||||
if(!list.length){hideSugg();return}
|
||||
searchSugg.innerHTML=list.map(s=>`
|
||||
<div class="sugg-item">
|
||||
<div class="sugg-poster">${s.poster?`<img loading="lazy" referrerpolicy="no-referrer" src="${esc(s.poster)}" alt="" onerror="this.onerror=null;this.outerHTML='<span class=\\'ph\\'>🎬</span>'">`:'<span class="ph">🎬</span>'}</div>
|
||||
<div class="sugg-info">
|
||||
<div class="sugg-title">${esc(s.title)}</div>
|
||||
<div class="sugg-meta">
|
||||
${s.type?`<span class="sugg-type">${mediaTypeLabel(s.type)}</span>`:''}
|
||||
${s.year?`<span class="sugg-year">${esc(s.year)}</span>`:''}
|
||||
</div>
|
||||
</div>
|
||||
</div>`).join('');
|
||||
[...searchSugg.children].forEach((el,i)=>{
|
||||
el.addEventListener('click',()=>{
|
||||
const s=list[i];
|
||||
kw.value=s.title;
|
||||
hideSugg();
|
||||
kw.blur();
|
||||
search(s.title, s.year?{year:s.year}:undefined); // 把联想条目的年份带进搜索,过滤掉别的年份
|
||||
});
|
||||
});
|
||||
searchSugg.classList.add('show');
|
||||
if(window._searchHistUpdate)document.getElementById('searchHistory').classList.remove('show');
|
||||
}
|
||||
|
||||
async function onSuggInput(){
|
||||
const q=kw.value.trim();
|
||||
const minLen=/[\u4e00-\u9fa5]/.test(q)?1:2;
|
||||
if(q.length<minLen){hideSugg();return}
|
||||
const seq=++_suggSeq;
|
||||
try{
|
||||
const list=await tmdbSuggest(q);
|
||||
if(seq!==_suggSeq)return; // 丢弃过期请求
|
||||
if(kw.value.trim()!==q)return; // 输入已变化
|
||||
const sorted=list.slice().sort((a,b)=>b.pop-a.pop);
|
||||
renderSugg(sorted.slice(0,8));
|
||||
}catch(e){hideSugg()}
|
||||
}
|
||||
|
||||
kw.addEventListener('input',()=>{clearTimeout(_suggTimer);_suggTimer=setTimeout(onSuggInput,260);_searchHistUpdate()});
|
||||
kw.addEventListener('focus',()=>{
|
||||
document.documentElement.classList.add('search-focused');
|
||||
if (location.hash !== '#search' && !_inSearchMode())
|
||||
history.pushState({ wo: 'searchFocus' }, '', '#search');
|
||||
if(kw.value.trim())onSuggInput();
|
||||
_searchHistUpdate();
|
||||
});
|
||||
|
||||
// ========== 搜索记录 ==========
|
||||
(function(){
|
||||
const shEl=document.getElementById('searchHistory');
|
||||
if(!shEl)return;
|
||||
const CLOSE_ICON=`<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><path d="M18 6 6 18"/><path d="M6 6l12 12"/></svg>`;
|
||||
const CLOCK_ICON=`<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/></svg>`;
|
||||
|
||||
function render(){
|
||||
if(!searchKwHistory.length){
|
||||
shEl.innerHTML='<div class="sh-empty">暂无搜索记录</div>';
|
||||
return;
|
||||
}
|
||||
shEl.innerHTML=`<div class="sh-head"><span>搜索记录</span><span class="sh-clear" id="shClearAll">清空</span></div>`+
|
||||
searchKwHistory.map((q,i)=>`
|
||||
<div class="sh-item" data-i="${i}">
|
||||
<span class="sh-kw">${CLOCK_ICON}${esc(q)}</span>
|
||||
<span class="sh-del" data-del="${i}">${CLOSE_ICON}</span>
|
||||
</div>`).join('');
|
||||
shEl.querySelectorAll('.sh-item').forEach(el=>{
|
||||
el.addEventListener('click',e=>{
|
||||
if(e.target.closest('.sh-del'))return;
|
||||
const q=searchKwHistory[+el.dataset.i];
|
||||
if(!q)return;
|
||||
kw.value=q;hideSugg();shEl.classList.remove('show');kw.blur();search(q);
|
||||
});
|
||||
});
|
||||
shEl.querySelectorAll('.sh-del').forEach(el=>{
|
||||
el.addEventListener('click',e=>{
|
||||
e.stopPropagation();
|
||||
const i=+el.dataset.del;
|
||||
searchKwHistory.splice(i,1);
|
||||
saveSearchKwHistory();
|
||||
render();
|
||||
});
|
||||
});
|
||||
const clearBtn=shEl.querySelector('#shClearAll');
|
||||
if(clearBtn)clearBtn.addEventListener('click',e=>{
|
||||
e.stopPropagation();
|
||||
searchKwHistory.length=0;
|
||||
saveSearchKwHistory();
|
||||
render();
|
||||
});
|
||||
}
|
||||
|
||||
function show(){ render(); shEl.classList.add('show'); }
|
||||
function hide(){ shEl.classList.remove('show'); }
|
||||
|
||||
window._searchHistUpdate=function(){
|
||||
const q=kw.value.trim();
|
||||
const focused=document.documentElement.classList.contains('search-focused');
|
||||
if(focused&&!q&&!searchSugg.classList.contains('show')){show()}
|
||||
else{hide()}
|
||||
};
|
||||
})();
|
||||
window._searchHistUpdate=window._searchHistUpdate||function(){};
|
||||
function _closeSearchBar(){
|
||||
hideSugg();
|
||||
kw.blur();
|
||||
const shEl=document.getElementById('searchHistory');
|
||||
if(shEl)shEl.classList.remove('show');
|
||||
document.documentElement.classList.remove('search-focused');
|
||||
if(location.hash==='#search'&&!_inSearchMode())
|
||||
history.replaceState({wo:'home'},'',location.pathname+location.search);
|
||||
}
|
||||
document.getElementById('searchFocusMask').addEventListener('pointerdown',function(e){
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
_closeSearchBar();
|
||||
// 吞掉这次抬手触发的后续 click,防止穿透点击到背景内容
|
||||
function swallow(ev){ ev.preventDefault(); ev.stopPropagation(); }
|
||||
document.addEventListener('click',swallow,{capture:true,once:true});
|
||||
setTimeout(()=>document.removeEventListener('click',swallow,true),400);
|
||||
});
|
||||
kw.addEventListener('keydown',e=>{if(e.key==='Escape')_closeSearchBar()});
|
||||
// 只有点击搜索区域之外才收起联想(点击联想项由其自身的 click 处理)
|
||||
document.addEventListener('pointerdown',e=>{if(!searchForm.contains(e.target))hideSugg()},true);
|
||||
const AGG_ICON=`<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="M5 6l7 6-7 6"/><path d="M12 6l7 6-7 6"/></svg>`;
|
||||
const SINGLE_ICON=`<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="M12 6l7 6-7 6"/></svg>`;
|
||||
aggBtn.innerHTML=AGG_ICON;
|
||||
aggBtn.onclick=()=>{aggregate=!aggregate;aggBtn.innerHTML=aggregate?AGG_ICON:SINGLE_ICON};
|
||||
window.__woClose=closeDetail;
|
||||
|
||||
/* ===== 底部搜索胶囊:点击 → focus 顶部原版搜索框 ===== */
|
||||
(function(){
|
||||
const srcSearchBtn = document.getElementById('srcSearchBtn');
|
||||
const srcRow = document.getElementById('srcRow');
|
||||
if(!srcSearchBtn) return;
|
||||
srcSearchBtn.addEventListener('click', function(e){
|
||||
e.stopPropagation();
|
||||
srcRow.classList.remove('expanded');
|
||||
// 直接 focus 原版 kw,触发原有 search-focused 逻辑
|
||||
setTimeout(()=>{ kw.focus(); kw.select(); }, 60);
|
||||
});
|
||||
})();
|
||||
|
||||
// ========== 回顶按钮(可拖动 · 贴边吸附 · 记忆位置)==========
|
||||
(function(){
|
||||
const btn=document.getElementById('backTop');
|
||||
if(!btn)return;
|
||||
const KEY='wo_backtop_pos';
|
||||
|
||||
// 还原上次拖动后的位置
|
||||
function applyPos(p){
|
||||
if(!p)return;
|
||||
btn.style.top=p.top+'px';btn.style.bottom='auto';
|
||||
if(p.edge==='left'){btn.style.left='0';btn.style.right='auto';btn.classList.add('left')}
|
||||
else{btn.style.right='0';btn.style.left='auto';btn.classList.remove('left')}
|
||||
}
|
||||
let _saved=null;try{_saved=JSON.parse(localStorage.getItem(KEY)||'null')}catch(e){}
|
||||
if(_saved)applyPos(_saved);
|
||||
|
||||
// 滚动约 4 排后出现(原 9 排过晚)
|
||||
function getThreshold(){const cardH=(window.innerWidth/3)*(4/3)+52;return cardH*4}
|
||||
let _ticking=false;
|
||||
function onScroll(){
|
||||
if(_ticking)return;_ticking=true;
|
||||
requestAnimationFrame(()=>{
|
||||
const y=window.scrollY||document.documentElement.scrollTop;
|
||||
btn.classList.toggle('show',y>getThreshold());
|
||||
_ticking=false;
|
||||
});
|
||||
}
|
||||
window.addEventListener('scroll',onScroll,{passive:true});
|
||||
|
||||
// 拖动逻辑:拖动结束按就近吸附到左/右边缘并记忆
|
||||
let dragging=false,moved=false,sx=0,sy=0,ox=0,oy=0,curX=0,curY=0,justDragged=false;
|
||||
const sz=()=>btn.offsetWidth||50;
|
||||
function down(e){
|
||||
const t=e.touches?e.touches[0]:e;
|
||||
const r=btn.getBoundingClientRect();
|
||||
dragging=true;moved=false;ox=r.left;oy=r.top;sx=t.clientX;sy=t.clientY;
|
||||
btn.classList.add('dragging');
|
||||
}
|
||||
function move(e){
|
||||
if(!dragging)return;
|
||||
const t=e.touches?e.touches[0]:e;
|
||||
const dx=t.clientX-sx,dy=t.clientY-sy;
|
||||
if(!moved&&(Math.abs(dx)>6||Math.abs(dy)>6))moved=true;
|
||||
if(!moved)return;
|
||||
if(e.cancelable)e.preventDefault();
|
||||
const S=sz();
|
||||
curX=Math.max(0,Math.min(window.innerWidth-S,ox+dx));
|
||||
curY=Math.max(60,Math.min(window.innerHeight-S-10,oy+dy));
|
||||
btn.style.left=curX+'px';btn.style.right='auto';
|
||||
btn.style.top=curY+'px';btn.style.bottom='auto';
|
||||
}
|
||||
function up(){
|
||||
if(!dragging)return;
|
||||
dragging=false;btn.classList.remove('dragging');
|
||||
if(!moved)return; // 视为点按,交给 click 处理(回顶)
|
||||
justDragged=true;setTimeout(()=>{justDragged=false},400);
|
||||
const S=sz();
|
||||
const left=(curX+S/2)<window.innerWidth/2;
|
||||
const top=Math.max(60,Math.min(window.innerHeight-S-10,curY));
|
||||
const p={edge:left?'left':'right',top};
|
||||
applyPos(p);
|
||||
try{localStorage.setItem(KEY,JSON.stringify(p))}catch(e){}
|
||||
}
|
||||
btn.addEventListener('touchstart',down,{passive:true});
|
||||
btn.addEventListener('touchmove',move,{passive:false});
|
||||
btn.addEventListener('touchend',up,{passive:true});
|
||||
btn.addEventListener('touchcancel',()=>{dragging=false;btn.classList.remove('dragging')},{passive:true});
|
||||
// 桌面端鼠标拖动
|
||||
btn.addEventListener('mousedown',e=>{
|
||||
down(e);
|
||||
const mm=ev=>move(ev),mu=ev=>{up(ev);document.removeEventListener('mousemove',mm);document.removeEventListener('mouseup',mu)};
|
||||
document.addEventListener('mousemove',mm);document.addEventListener('mouseup',mu);
|
||||
});
|
||||
|
||||
btn.addEventListener('click',e=>{
|
||||
if(justDragged){e.preventDefault();e.stopPropagation();justDragged=false;return}
|
||||
window.scrollTo({top:0,behavior:'smooth'});
|
||||
});
|
||||
// 切页/切分类时隐藏
|
||||
window._backTopReset=function(){ btn.classList.remove('show') };
|
||||
})();
|
||||
|
||||
// ========== 左右滑动切换分类(非站源)+ 长按卡片收藏 ==========
|
||||
(function(){
|
||||
if(!content)return;
|
||||
let x0=0,y0=0,t0=0,tracking=false,decided=false,horizontal=false;
|
||||
let lpTimer=null,lpCard=null;
|
||||
const TH=56; // 触发切换的最小水平位移
|
||||
const SLOP=12; // 判定方向前的容差
|
||||
const MAXT=600; // 超过此时长视为拖拽/犹豫,不触发
|
||||
const LP=600; // 长按触发收藏的时长(ms),调长以避免轻点被误判为收藏
|
||||
|
||||
// 仅在分类浏览态可滑:搜索结果页、详情页、搜索聚焦态一律不响应
|
||||
function swipable(){
|
||||
return _catActive
|
||||
&& !$('#sheet').classList.contains('active')
|
||||
&& !document.documentElement.classList.contains('search-focused');
|
||||
}
|
||||
// 长按收藏:分类页与搜索结果页都允许(详情页/搜索聚焦态除外)
|
||||
function cardActive(){
|
||||
return !$('#sheet').classList.contains('active')
|
||||
&& !document.documentElement.classList.contains('search-focused');
|
||||
}
|
||||
function clearLP(){ if(lpTimer){clearTimeout(lpTimer);lpTimer=null} lpCard=null; }
|
||||
function edgeBounce(dir){
|
||||
content.classList.remove('edge-l','edge-r');
|
||||
void content.offsetWidth; // 重启动画
|
||||
content.classList.add(dir>0?'edge-l':'edge-r');
|
||||
content.addEventListener('animationend',()=>content.classList.remove('edge-l','edge-r'),{once:true});
|
||||
}
|
||||
// dir = +1 下一类(手指向左滑),-1 上一类(手指向右滑)
|
||||
function go(dir){
|
||||
const cats=effCats(site());
|
||||
let i=cats.findIndex(c=>c[0]===activeCat);
|
||||
if(i<0)i=0;
|
||||
const n=i+dir;
|
||||
if(n<0||n>=cats.length){edgeBounce(dir);return}
|
||||
activateCategory(cats[n][0], dir>0?'l':'r');
|
||||
}
|
||||
|
||||
content.addEventListener('touchstart',e=>{
|
||||
clearLP();
|
||||
_swiped=false; // 新触摸开始:清掉上一次的点击拦截标记,保证轻点永不被吞
|
||||
if(e.touches.length!==1){tracking=false;return}
|
||||
const t=e.touches[0];
|
||||
x0=t.clientX;y0=t.clientY;t0=Date.now();
|
||||
tracking=swipable();decided=false;horizontal=false;
|
||||
// 长按收藏计时
|
||||
if(cardActive()&&e.target.closest){
|
||||
const cd=e.target.closest('.card');
|
||||
if(cd){
|
||||
lpCard=cd;
|
||||
lpTimer=setTimeout(()=>{
|
||||
lpTimer=null;
|
||||
const card=lpCard;lpCard=null;tracking=false; // 已转为长按,取消滑动判定
|
||||
if(!card)return; // 防御:已被清理则不处理
|
||||
const item=(last||[]).find(x=>x.href===card.dataset.href);
|
||||
if(item){
|
||||
_swiped=true; // 一次性:仅拦截这次长按抬手产生的 click
|
||||
if(navigator.vibrate){try{navigator.vibrate(15)}catch(_){}}
|
||||
toggleFav(item);
|
||||
}
|
||||
},LP);
|
||||
}
|
||||
}
|
||||
},{passive:true});
|
||||
|
||||
content.addEventListener('touchmove',e=>{
|
||||
const t=e.touches[0];
|
||||
const dx=t.clientX-x0,dy=t.clientY-y0;
|
||||
if(Math.abs(dx)>8||Math.abs(dy)>8)clearLP(); // 一旦移动即取消长按
|
||||
if(!tracking)return;
|
||||
if(!decided){
|
||||
if(Math.abs(dx)<SLOP&&Math.abs(dy)<SLOP)return;
|
||||
decided=true;
|
||||
horizontal=Math.abs(dx)>Math.abs(dy)*1.3; // 横向意图明显才接管
|
||||
}
|
||||
},{passive:true});
|
||||
|
||||
content.addEventListener('touchend',e=>{
|
||||
clearLP();
|
||||
if(!tracking)return;
|
||||
tracking=false;
|
||||
if(!horizontal)return;
|
||||
const t=e.changedTouches[0];
|
||||
const dx=t.clientX-x0,dy=t.clientY-y0,dt=Date.now()-t0;
|
||||
if(dt>MAXT)return;
|
||||
if(Math.abs(dx)<TH||Math.abs(dx)<Math.abs(dy)*1.3)return;
|
||||
_swiped=true; // 一次性:拦截这次滑动落点产生的 click
|
||||
go(dx<0?1:-1); // 左滑→下一类,右滑→上一类
|
||||
},{passive:true});
|
||||
|
||||
content.addEventListener('touchcancel',()=>{clearLP();tracking=false},{passive:true});
|
||||
|
||||
// 捕获阶段拦截:滑动/长按结束时落点恰在卡片上,阻止误触发详情(一次性,消费后立即复位)
|
||||
content.addEventListener('click',e=>{
|
||||
if(_swiped){_swiped=false;e.stopPropagation();e.preventDefault()}
|
||||
},true);
|
||||
})();
|
||||
|
||||
renderTabs();
|
||||
(async()=>{
|
||||
const s=site();
|
||||
const cats=await ensureCats(s);
|
||||
activeCat=firstRealCat(cats);
|
||||
renderChips();
|
||||
loadCategory();
|
||||
})();
|
||||
// 建立初始哨兵,防止第一次 back 穿透退出
|
||||
_ensureHome();
|
||||
})();
|
||||
|
||||
/* ===== 长按搜索图标:循环切换主题(默认橙 + 7 种配色)=====
|
||||
- 长按搜索按钮 600ms 触发,依次循环到下一个主题,震动反馈 + 顶部小提示
|
||||
- 主题持久化到 localStorage,跨会话保留 */
|
||||
(function(){
|
||||
'use strict';
|
||||
const THEME_KEY='wo_theme';
|
||||
const btn=document.querySelector('.search .btn.primary');
|
||||
if(!btn)return;
|
||||
|
||||
// 主题列表:cls 为空字符串代表默认橙色主题(不附加 class)
|
||||
const THEMES=[
|
||||
{cls:'', name:'默认橙色'},
|
||||
{cls:'neon', name:'霓虹蓝'},
|
||||
{cls:'theme-amber', name:'琥珀金'},
|
||||
{cls:'theme-rose', name:'玫瑰金'},
|
||||
{cls:'theme-indigo',name:'午夜蓝紫'},
|
||||
{cls:'theme-teal', name:'雾蓝青'},
|
||||
{cls:'theme-sand', name:'香槛灰金'},
|
||||
{cls:'theme-jade', name:'墨绿'}
|
||||
];
|
||||
const THEME_CLASSES=THEMES.map(t=>t.cls).filter(Boolean);
|
||||
|
||||
function applyTheme(idx){
|
||||
THEME_CLASSES.forEach(c=>document.body.classList.remove(c));
|
||||
const t=THEMES[idx];
|
||||
if(t.cls)document.body.classList.add(t.cls);
|
||||
}
|
||||
function currentIndex(){
|
||||
const i=THEMES.findIndex(t=>t.cls && document.body.classList.contains(t.cls));
|
||||
return i<0 ? 0 : i;
|
||||
}
|
||||
// 启动时读取已保存主题
|
||||
let themeIdx=0;
|
||||
try{
|
||||
const saved=parseInt(localStorage.getItem(THEME_KEY),10);
|
||||
if(!isNaN(saved)&&saved>=0&&saved<THEMES.length){
|
||||
themeIdx=saved;
|
||||
applyTheme(themeIdx);
|
||||
}
|
||||
}catch(e){}
|
||||
|
||||
let toastEl=null;
|
||||
function toast(text){
|
||||
if(!toastEl){
|
||||
toastEl=document.createElement('div');
|
||||
toastEl.style.cssText='position:fixed;top:calc(var(--safe-top) + 14px);left:50%;'
|
||||
+'transform:translateX(-50%) translateY(-8px);z-index:90;'
|
||||
+'background:rgba(20,24,32,.92);backdrop-filter:blur(14px);-webkit-backdrop-filter:blur(14px);'
|
||||
+'border:1px solid rgba(255,255,255,.12);border-radius:999px;'
|
||||
+'padding:7px 16px;font-size:13px;font-weight:700;color:#fff;'
|
||||
+'opacity:0;transition:opacity .22s ease,transform .22s ease;pointer-events:none;white-space:nowrap';
|
||||
document.body.appendChild(toastEl);
|
||||
}
|
||||
toastEl.textContent=text;
|
||||
toastEl.style.opacity='1';
|
||||
toastEl.style.transform='translateX(-50%) translateY(0)';
|
||||
clearTimeout(toastEl._t);
|
||||
toastEl._t=setTimeout(()=>{
|
||||
toastEl.style.opacity='0';
|
||||
toastEl.style.transform='translateX(-50%) translateY(-8px)';
|
||||
},1400);
|
||||
}
|
||||
|
||||
const LP=600; // 长按触发时长,与卡片收藏长按保持一致手感
|
||||
const MOVE_TOL=10; // 超出此移动距离视为滑动/取消,不触发
|
||||
let timer=null,sx=0,sy=0,fired=false;
|
||||
|
||||
function clear(){ if(timer){clearTimeout(timer);timer=null} }
|
||||
|
||||
function start(x,y){
|
||||
fired=false;
|
||||
sx=x;sy=y;
|
||||
clear();
|
||||
timer=setTimeout(()=>{
|
||||
timer=null;fired=true;
|
||||
themeIdx=(currentIndex()+1)%THEMES.length;
|
||||
applyTheme(themeIdx);
|
||||
try{localStorage.setItem(THEME_KEY,String(themeIdx))}catch(e){}
|
||||
if(navigator.vibrate){try{navigator.vibrate([12,40,12])}catch(_){}}
|
||||
toast('已切换:'+THEMES[themeIdx].name+'主题');
|
||||
},LP);
|
||||
}
|
||||
function move(x,y){
|
||||
if(!timer)return;
|
||||
if(Math.abs(x-sx)>MOVE_TOL||Math.abs(y-sy)>MOVE_TOL)clear();
|
||||
}
|
||||
function end(e){
|
||||
clear();
|
||||
if(fired){ e.preventDefault(); e.stopPropagation(); } // 已触发主题切换,拦截这次抬手的 click/submit
|
||||
}
|
||||
|
||||
btn.addEventListener('touchstart',e=>{
|
||||
const t=e.touches[0];start(t.clientX,t.clientY);
|
||||
},{passive:true});
|
||||
btn.addEventListener('touchmove',e=>{
|
||||
const t=e.touches[0];move(t.clientX,t.clientY);
|
||||
},{passive:true});
|
||||
btn.addEventListener('touchend',end,{passive:false});
|
||||
btn.addEventListener('touchcancel',clear,{passive:true});
|
||||
|
||||
// 桌面端鼠标长按(便于在电脑上测试)
|
||||
btn.addEventListener('mousedown',e=>{start(e.clientX,e.clientY)});
|
||||
btn.addEventListener('mousemove',e=>{move(e.clientX,e.clientY)});
|
||||
btn.addEventListener('mouseup',end);
|
||||
btn.addEventListener('mouseleave',clear);
|
||||
|
||||
|
||||
// 长按触发后,阻止这次抬手连带的表单提交(搜索按钮在 <form> 内)
|
||||
btn.addEventListener('click',e=>{
|
||||
if(fired){fired=false;e.preventDefault();e.stopPropagation()}
|
||||
},true);
|
||||
})();
|
||||
|
||||
</script>
|
||||
<script>
|
||||
/* ===== Android TV / 电视遥控器(D-pad)适配 =====
|
||||
- 方向键在卡片/按钮/输入框间移动焦点(空间导航,按屏幕位置就近选择)
|
||||
- OK/确定键触发当前焦点元素
|
||||
- 返回键交给 history(自动关弹窗/详情/退出搜索)
|
||||
- 焦点高亮放大,电视远看也清晰
|
||||
- 仅在电视环境启用(带触摸的平板/手机不受影响);可用 ?tv=1 强制、?tv=0 关闭 */
|
||||
(function(){
|
||||
'use strict';
|
||||
var ua=navigator.userAgent;
|
||||
var isTV=/\b(TV|BRAVIA|AFT[A-Z]|MiBOX|MiTV|SHIELD|GoogleTV|AndroidTV|SmartTV|NetCast|Web0S|webOS|Tizen|HbbTV|DTV)\b/i.test(ua)
|
||||
|| (/Android/i.test(ua) && !('ontouchstart' in window) && !/Mobile/i.test(ua));
|
||||
if(/[?&]tv=0/.test(location.search)) return;
|
||||
if(!isTV && !/[?&]tv=1/.test(location.search)) return;
|
||||
document.documentElement.classList.add('tv-mode');
|
||||
|
||||
// 焦点高亮样式
|
||||
var st=document.createElement('style');
|
||||
st.textContent=[
|
||||
'.tv-mode :focus{outline:none}',
|
||||
'.tv-mode .card,.tv-mode .src-tab,.tv-mode .chip,.tv-mode .det-res-tab,.tv-mode .pan-item,.tv-mode .pan-open-btn,.tv-mode .det-play-btn,.tv-mode #kw,.tv-mode .pan-cfg-btn,.tv-mode .pan-search-tab,.tv-mode .custom-modal-btn{transition:transform .12s ease,box-shadow .12s ease}',
|
||||
'.tv-mode .card:focus,.tv-mode .src-tab:focus,.tv-mode .chip:focus,.tv-mode .det-res-tab:focus,.tv-mode .pan-item:focus,.tv-mode .pan-open-btn:focus,.tv-mode .det-play-btn:focus,.tv-mode #kw:focus,.tv-mode .pan-cfg-btn:focus,.tv-mode .pan-search-tab:focus,.tv-mode .custom-modal-btn:focus{outline:3px solid var(--accent);outline-offset:3px;box-shadow:0 0 0 6px rgba(var(--accent-rgb),.35),0 10px 32px rgba(0,0,0,.55);transform:scale(1.05);z-index:6;position:relative;border-radius:14px}',
|
||||
'.tv-mode .det-play-btn:focus{transform:scale(1.12)}',
|
||||
'.tv-mode .card:focus{transform:scale(1.06)}'
|
||||
].join('\n');
|
||||
document.head.appendChild(st);
|
||||
|
||||
var SEL='#kw,.src-tab,.chip,.card,.det-play-btn,.det-res-tab,.pan-item,.pan-open-btn,.pan-cfg-btn,.pan-search-tab,.custom-modal-btn';
|
||||
function vis(el){
|
||||
var r=el.getBoundingClientRect();
|
||||
if(r.width<=0||r.height<=0) return false;
|
||||
if(r.bottom<-2||r.top>innerHeight+2||r.right<-2||r.left>innerWidth+2) return false;
|
||||
var s=getComputedStyle(el);
|
||||
if(s.visibility==='hidden'||s.display==='none'||parseFloat(s.opacity||'1')===0) return false;
|
||||
return true;
|
||||
}
|
||||
function scope(){
|
||||
var modal=document.querySelector('.custom-modal-mask');
|
||||
if(modal&&getComputedStyle(modal).display!=='none'&&vis(modal)) return modal;
|
||||
var sheet=document.getElementById('sheet');
|
||||
if(sheet&&sheet.classList.contains('active')) return sheet;
|
||||
return document;
|
||||
}
|
||||
function focusables(){
|
||||
var root=scope();
|
||||
var els=Array.prototype.slice.call(root.querySelectorAll(SEL)).filter(vis);
|
||||
if(root===document){
|
||||
var sheet=document.getElementById('sheet');
|
||||
if(sheet) els=els.filter(function(el){return !sheet.contains(el);});
|
||||
}
|
||||
els.forEach(function(el){if(!el.hasAttribute('tabindex'))el.setAttribute('tabindex','0');});
|
||||
return els;
|
||||
}
|
||||
function ctr(el){var r=el.getBoundingClientRect();return{x:r.left+r.width/2,y:r.top+r.height/2};}
|
||||
function moveFocus(dir){
|
||||
var els=focusables(); if(!els.length) return;
|
||||
var cur=document.activeElement;
|
||||
if(!cur||els.indexOf(cur)<0){els[0].focus();sf(els[0]);return;}
|
||||
var c=ctr(cur),best=null,bs=Infinity;
|
||||
for(var i=0;i<els.length;i++){
|
||||
var el=els[i]; if(el===cur) continue;
|
||||
var t=ctr(el),dx=t.x-c.x,dy=t.y-c.y,valid,along,ortho;
|
||||
if(dir==='ArrowLeft'){valid=dx<-2;along=-dx;ortho=Math.abs(dy);}
|
||||
else if(dir==='ArrowRight'){valid=dx>2;along=dx;ortho=Math.abs(dy);}
|
||||
else if(dir==='ArrowUp'){valid=dy<-2;along=-dy;ortho=Math.abs(dx);}
|
||||
else{valid=dy>2;along=dy;ortho=Math.abs(dx);}
|
||||
if(!valid) continue;
|
||||
var score=along+ortho*1.7;
|
||||
if(score<bs){bs=score;best=el;}
|
||||
}
|
||||
if(best){best.focus();sf(best);}
|
||||
}
|
||||
function sf(el){try{el.scrollIntoView({block:'nearest',inline:'nearest',behavior:'smooth'});}catch(e){try{el.scrollIntoView();}catch(_){}}}
|
||||
|
||||
document.addEventListener('keydown',function(e){
|
||||
var k=e.key,ae=document.activeElement;
|
||||
if(k==='ArrowLeft'||k==='ArrowRight'||k==='ArrowUp'||k==='ArrowDown'){
|
||||
if(ae&&ae.id==='kw'&&(k==='ArrowLeft'||k==='ArrowRight')) return;
|
||||
moveFocus(k); e.preventDefault();
|
||||
}else if(k==='Enter'){
|
||||
if(ae&&ae.id==='kw') return; // 搜索框回车走原提交逻辑
|
||||
if(ae&&ae!==document.body){ae.click();e.preventDefault();}
|
||||
}else if(k==='Backspace'||k==='Escape'||k==='BrowserBack'||k==='GoBack'||e.keyCode===4||e.keyCode===10009){
|
||||
if(ae&&ae.id==='kw'&&k==='Backspace') return; // 搜索框退格删字
|
||||
if(history.length>1){history.back();e.preventDefault();}
|
||||
}
|
||||
});
|
||||
|
||||
// 自动聚焦:详情打开→播放按钮;列表/弹窗刷新→第一个可聚焦项
|
||||
var t=null;
|
||||
var mo=new MutationObserver(function(){
|
||||
clearTimeout(t);
|
||||
t=setTimeout(function(){
|
||||
var sheet=document.getElementById('sheet');
|
||||
if(sheet&&sheet.classList.contains('active')){
|
||||
if(!sheet.contains(document.activeElement)){
|
||||
var pb=sheet.querySelector('.det-play-btn')||sheet.querySelector('.det-res-tab');
|
||||
if(pb)pb.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
// 主页:若焦点丢失,落到第一个卡片/控件
|
||||
if(!document.activeElement||document.activeElement===document.body){
|
||||
var els=focusables(); if(els.length) els[0].focus();
|
||||
}
|
||||
},140);
|
||||
});
|
||||
mo.observe(document.body,{attributes:true,subtree:true,attributeFilter:['class']});
|
||||
})();
|
||||
</script>
|
||||
<script defer src="https://static.cloudflareinsights.com/beacon.min.js/v4513226cdae34746b4dedf0b4dfa099e1781791509496" integrity="sha512-ZE9pZaUXND66v380QUtch/5sE9tPFh2zg45pR2PB0CVkCtOREv2AJKkSidISWkysEuQ0EH8faUU5du78bx87UQ==" data-cf-beacon='{"version":"2024.11.0","token":"34fed34fc7e4404e80e2cb1bca1b6856","r":1,"server_timing":{"name":{"cfCacheStatus":true,"cfEdge":true,"cfExtPri":true,"cfL4":true,"cfOrigin":true,"cfSpeedBrain":true},"location_startswith":null}}' crossorigin="anonymous"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user