Sync all projects
This commit is contained in:
@@ -0,0 +1,719 @@
|
||||
<!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">
|
||||
<title>6v影视 · 高清在线</title>
|
||||
<style>
|
||||
:root{--panel-rgb:42,50,58;--panel:rgba(var(--panel-rgb),.62);--panel-soft:rgba(var(--panel-rgb),.44);--panel-strong:rgba(var(--panel-rgb),.86);--line:rgba(255,255,255,.16);--text:#f7fbff;--muted:rgba(247,251,255,.68);--accent:#ff6b35;--accent2:#ffb86b;--bad:#ff5c7a;--ok:#68e39a;--radius:18px;--shadow:0 18px 50px rgba(0,0,0,.28)}
|
||||
*{box-sizing:border-box} html,body{margin:0;min-height:100%;background:transparent;color:var(--text);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",Arial,sans-serif} html:not(.fm-native),html:not(.fm-native) body{background:#222a32}.app{min-height:100vh;background:transparent;padding:10px 10px 28px}
|
||||
.top{position:relative;z-index:1;background:rgba(var(--panel-rgb),0.75);border-radius:20px;padding:24px 16px;margin:10px auto 20px;max-width:800px;display:flex;flex-direction:column;align-items:center;gap:16px;box-shadow:var(--shadow)}
|
||||
.bar{display:flex;flex-direction:row;align-items:center;justify-content:center;gap:16px;width:100%}
|
||||
.brand{font-size:28px;font-weight:900;letter-spacing:2px;text-shadow:0 4px 14px rgba(0,0,0,.35);white-space:nowrap}
|
||||
.brand b{color:var(--accent2)}
|
||||
.search{flex:1;width:100%;max-width:500px;display:flex;gap:8px;align-items:center;background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.12);border-radius:999px;padding:6px 6px 6px 18px;box-shadow:inset 0 1px 0 rgba(255,255,255,.05)}
|
||||
.search input{flex:1;min-width:0;background:transparent;border:0;outline:0;color:var(--text);font-size:15px}
|
||||
.search input::placeholder{color:var(--muted)}
|
||||
button,.btn{border:1px solid var(--line);background:var(--panel-soft);color:var(--text);border-radius:999px;padding:8px 16px;font-size:14px;cursor:pointer;white-space:nowrap}
|
||||
.btn.primary,button.primary{border-color:rgba(255,107,53,.45);background:linear-gradient(135deg,rgba(255,107,53,.90),rgba(255,184,107,.72));color:#17110d;font-weight:800;padding:10px 20px}
|
||||
.tabs{display:flex;flex-wrap:wrap;gap:10px;justify-content:center;width:100%;padding:0}
|
||||
.tab{border-radius:999px;padding:8px 18px;font-size:15px;background:rgba(255,255,255,.08);border:2px solid transparent;color:var(--muted);transition:all 0.2s}
|
||||
.tab.active{background:transparent;color:var(--text);border-color:rgba(255,184,107,0.8);font-weight:800}
|
||||
.hero{margin:10px 0 16px;padding:18px;border-radius:var(--radius);background:linear-gradient(135deg,rgba(255,107,53,.28),rgba(42,50,58,.54));border:1px solid var(--line);box-shadow:var(--shadow)}
|
||||
.hero h1{margin:0 0 8px;font-size:24px}
|
||||
.hero p{margin:0;color:var(--muted);line-height:1.6}
|
||||
.status{margin:10px 2px;color:var(--muted);font-size:14px;text-align:center}
|
||||
.grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}
|
||||
.card{position:relative;min-width:0;border-radius:15px;overflow:hidden;background:linear-gradient(180deg,rgba(255,255,255,.09),rgba(255,255,255,.045));border:1px solid rgba(255,255,255,.12);box-shadow:0 10px 28px rgba(0,0,0,.22);cursor:pointer;transition:transform 0.2s}
|
||||
.poster{aspect-ratio:3/4;background:rgba(255,255,255,.08);overflow:hidden;position:relative;padding-bottom:133.33%}
|
||||
.poster img{position:absolute;top:0;left:0;width:100%;height:100%;object-fit:cover;display:block;filter:saturate(1.04) contrast(1.03)}
|
||||
.poster .noimg{position:absolute;top:0;left:0;height:100%;width:100%;display:flex;align-items:center;justify-content:center;color:var(--muted);font-size:28px}
|
||||
.meta{padding:8px 8px 10px;background:linear-gradient(180deg,rgba(18,24,30,.15),rgba(18,24,30,.44))}
|
||||
.name{font-weight:800;font-size:16px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.remark{margin-top:4px;color:var(--muted);font-size:14px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.section{margin:16px 0}
|
||||
.section-title{display:flex;align-items:center;justify-content:space-between;margin:0 2px 10px}
|
||||
.section-title h2{margin:0;font-size:17px;letter-spacing:.3px}
|
||||
.sheet{position:fixed;top:0;right:0;bottom:0;left:0;z-index:40;display:none;background:transparent;overflow:hidden;width:100%;height:100%}
|
||||
.sheet.active{display:block}
|
||||
.sheet-inner{height:100vh;min-height:100vh;padding:12px;background:transparent;overflow-y:auto;-webkit-overflow-scrolling:touch;overscroll-behavior:contain;touch-action:pan-y}
|
||||
.detail-panel{background:var(--panel);border:1px solid var(--line);border-radius:22px;box-shadow:var(--shadow);padding:14px;margin-bottom:42px;touch-action:pan-y}
|
||||
.detail-head{display:grid;grid-template-columns:118px 1fr;gap:14px}
|
||||
.detail-pic{width:118px;height:157px;border-radius:14px;object-fit:cover;background:rgba(255,255,255,.08)}
|
||||
.detail-title{font-size:22px;font-weight:900;margin:2px 0 8px}
|
||||
.detail-info{color:var(--muted);line-height:1.65;font-size:14px;min-width:0}
|
||||
.desc{margin-top:12px;color:rgba(255,255,255,.82);line-height:1.72;max-height:8.6em;overflow:auto}
|
||||
.line-tabs{display:flex;gap:8px;overflow-x:auto;margin:12px 0 8px;padding-bottom:2px}
|
||||
.line-tab{border-radius:999px;padding:8px 12px;background:rgba(255,255,255,.08);border:1px solid var(--line);white-space:nowrap}
|
||||
.line-tab.active{background:rgba(255,107,53,.36);border-color:rgba(255,184,107,.55);font-weight:800}
|
||||
.episodes{display:flex;gap:8px;flex-wrap:wrap;margin-top:12px;max-height:none;overflow:visible;touch-action:pan-y}
|
||||
.ep{border-radius:12px;padding:9px 11px;background:rgba(255,255,255,.09);border:1px solid var(--line);cursor:pointer;min-width:68px}
|
||||
.empty{padding:30px 18px;text-align:center;color:var(--muted);background:var(--panel-soft);border:1px dashed var(--line);border-radius:var(--radius)}
|
||||
.loading{opacity:.75}
|
||||
/* 遥控器焦点样式 */
|
||||
.focusable:focus,.card:focus,.rank-item:focus,.ep:focus,button:focus,.tab:focus,input:focus{outline:2px solid rgba(255,184,107,.76);outline-offset:2px;transform:translateY(-1px)}
|
||||
/* TV 模式下更大更醒目的焦点 */
|
||||
html.tv-mode .focusable:focus,html.tv-mode .card:focus,html.tv-mode .ep:focus,html.tv-mode button:focus,html.tv-mode .tab:focus{
|
||||
outline:4px solid var(--accent2) !important;outline-offset:4px !important;
|
||||
transform:scale(1.05);box-shadow:0 0 20px rgba(255,184,107,.35);
|
||||
z-index:10;position:relative}
|
||||
html.tv-mode .card:focus .poster img{transform:scale(1.08)}
|
||||
html.tv-mode .grid{gap:14px}
|
||||
html.tv-mode .name{font-size:18px}
|
||||
html.tv-mode .tab{padding:10px 24px;font-size:17px}
|
||||
html.tv-mode .ep{padding:12px 18px;font-size:16px;min-width:80px}
|
||||
html.tv-mode .brand{font-size:34px}
|
||||
html.tv-mode .search input{font-size:18px}
|
||||
body.detail-active .app{visibility:hidden;pointer-events:none}
|
||||
@media (min-width:768px) and (orientation:landscape){.app{padding:18px 28px 44px;max-width:1380px;margin:0 auto}.top{padding:40px;margin:30px auto;border-radius:32px;gap:24px;max-width:900px}.bar{flex-direction:row;gap:24px}.brand{font-size:36px}.search{padding:8px 8px 8px 24px;max-width:650px}.search input{font-size:18px}.btn.primary,button.primary{padding:12px 28px;font-size:16px}.tabs{gap:14px;max-width:700px}.tab{padding:10px 24px;font-size:16px}.grid{grid-template-columns:repeat(6,minmax(0,1fr));gap:16px}.detail-head{grid-template-columns:160px 1fr}.detail-pic{width:160px;height:213px}.focusable:focus,.card:focus,.ep:focus,button:focus,.tab:focus,input:focus{transform:scale(1.05);outline:4px solid #ffb86b;outline-offset:4px}}
|
||||
@media (min-width:1200px) and (orientation:landscape){.grid{grid-template-columns:repeat(8,minmax(0,1fr))}}
|
||||
@media (max-width:480px),(orientation:portrait){.app{padding:8px 9px 24px}.top{padding:20px 12px;margin:10px auto;border-radius:20px;gap:16px}.bar{flex-direction:row;gap:10px}.brand{font-size:22px}.search{padding:4px 4px 4px 14px}.search input{font-size:14px}.btn.primary,button.primary{padding:8px 16px;font-size:14px}.tabs{gap:8px}.tab{padding:8px 14px;font-size:14px}.grid{grid-template-columns:repeat(3,minmax(0,1fr));gap:9px}.meta{padding:7px 7px 9px}.name{font-size:13px}.remark{font-size:11px}.detail-head{grid-template-columns:104px 1fr;gap:12px}.detail-pic{width:104px;height:138px}.detail-title{font-size:20px}.sheet-inner{padding:10px}.detail-panel{border-radius:20px;padding:12px}.ep{min-width:64px;padding:8px 9px}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app" id="app">
|
||||
<div class="top">
|
||||
<div class="bar">
|
||||
<div class="brand">6v<b>影视</b></div>
|
||||
<form class="search" id="searchForm">
|
||||
<input id="kw" class="focusable" type="text" placeholder="搜索影片、电视剧…" autocomplete="off">
|
||||
<button class="primary focusable" type="submit">搜索</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="tabs" id="tabs"></div>
|
||||
</div>
|
||||
<div class="status" id="status">准备加载…</div>
|
||||
<main id="content"></main>
|
||||
</div>
|
||||
<div class="sheet" id="detailSheet"><div class="sheet-inner"><div class="detail-panel" id="detailPanel"></div></div></div>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
'use strict';
|
||||
|
||||
const BASE = 'https://www.xb6v.org';
|
||||
const CATS = [
|
||||
['首页', '/'],
|
||||
['喜剧片', '/xijupian/'],
|
||||
['动作片', '/dongzuopian/'],
|
||||
['爱情片', '/aiqingpian/'],
|
||||
['科幻片', '/kehuanpian/'],
|
||||
['恐怖片', '/kongbupian/'],
|
||||
['剧情片', '/juqingpian/'],
|
||||
['战争片', '/zhanzhengpian/'],
|
||||
['纪录片', '/jilupian/'],
|
||||
['动画片', '/donghuapian/'],
|
||||
['电视剧', '/dianshiju/'],
|
||||
['综艺', '/ZongYi/']
|
||||
];
|
||||
const UA = 'Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 Chrome/120 Mobile Safari/537.36';
|
||||
const FETCH_HEADERS = {
|
||||
'User-Agent': UA,
|
||||
'Referer': BASE + '/',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.5',
|
||||
'Cache-Control': 'no-cache'
|
||||
};
|
||||
let fmReady = false;
|
||||
let currentUrl = BASE + '/';
|
||||
let lastFocus = null;
|
||||
|
||||
const $ = s => document.querySelector(s);
|
||||
const content = $('#content');
|
||||
const statusEl = $('#status');
|
||||
const tabsDiv = $('#tabs');
|
||||
|
||||
/* ---------- TV 模式检测 ---------- */
|
||||
function detectTV() {
|
||||
var el = document.documentElement;
|
||||
// Method 1: fongmiClient
|
||||
if (window.fongmiClient && window.fongmiClient.isLeanback) {
|
||||
el.classList.add('tv-mode');
|
||||
return;
|
||||
}
|
||||
// Method 2: fm.device()
|
||||
var fm = window.fm || window.fongmi;
|
||||
if (fm && fm.device) {
|
||||
fm.device().then(function(dev) {
|
||||
if (dev && (Number(dev.type) === 0 || String(dev.mode).toLowerCase() === 'leanback')) {
|
||||
el.classList.add('tv-mode');
|
||||
}
|
||||
}).catch(function(){});
|
||||
}
|
||||
}
|
||||
/* ---------- SDK 等待 ---------- */
|
||||
function markNative() {
|
||||
if (window.fongmiBridge || window.fm || window.fongmi)
|
||||
document.documentElement.classList.add('fm-native');
|
||||
}
|
||||
function waitFm(ms) {
|
||||
if (ms === undefined) ms = 1600;
|
||||
markNative();
|
||||
if (window.fm) {
|
||||
fmReady = true;
|
||||
return Promise.resolve(window.fm);
|
||||
}
|
||||
return new Promise(function(resolve) {
|
||||
var done = false;
|
||||
var ok = function() {
|
||||
if (done) return;
|
||||
done = true;
|
||||
markNative();
|
||||
fmReady = !!window.fm;
|
||||
resolve(window.fm || null);
|
||||
};
|
||||
window.addEventListener('fmsdk', ok, { once: true });
|
||||
setTimeout(ok, ms);
|
||||
});
|
||||
}
|
||||
|
||||
function abs(url) {
|
||||
if (!url) return '';
|
||||
try { return new URL(url, BASE + '/').href; } catch(e) { return url; }
|
||||
}
|
||||
function esc(s) {
|
||||
if (!s) return '';
|
||||
return String(s).replace(/[&<>'"]/g, function(c) {
|
||||
if (c === '&') return '&';
|
||||
if (c === '<') return '<';
|
||||
if (c === '>') return '>';
|
||||
if (c === "'") return ''';
|
||||
if (c === '"') return '"';
|
||||
return c;
|
||||
});
|
||||
}
|
||||
function clean(s) {
|
||||
return String(s || '').replace(/\s+/g, ' ').replace(/[\uE000-\uF8FF]/g, '').trim();
|
||||
}
|
||||
function cleanDesc(s) {
|
||||
// 去 HTML 标签、解码实体、清理空白
|
||||
return String(s || '')
|
||||
.replace(/<[^>]+>/g, ' ') // 去标签
|
||||
.replace(/&ldquo;/g, '"') // 左引号
|
||||
.replace(/&rdquo;/g, '"') // 右引号
|
||||
.replace(/&middot;/g, '·') // 中点
|
||||
.replace(/&mdash;/g, '—') // 破折号
|
||||
.replace(/&nbsp;/g, ' ') // 空格
|
||||
.replace(/&raquo;|&laquo;/g, '') // 书名号残留
|
||||
.replace(/&/g, '&') // 其他实体前缀
|
||||
.replace(/&[a-z]+;/g, '') // 剩余未知实体
|
||||
.replace(/\s+/g, ' ') // 合并空白
|
||||
.replace(/^\s*[::]\s*/, '') // 开头的冒号
|
||||
.trim();
|
||||
}
|
||||
function imgUrl(u) {
|
||||
u = abs(u);
|
||||
if (!u) return '';
|
||||
try {
|
||||
if (window.fm && fm.res) return fm.res(u, { headers: { Referer: BASE + '/' } });
|
||||
} catch(e) {}
|
||||
return u;
|
||||
}
|
||||
|
||||
/* ---------- 三级降级请求(修复 520 问题) ---------- */
|
||||
async function reqText(url, options) {
|
||||
if (!options) options = {};
|
||||
url = abs(url);
|
||||
statusEl.textContent = '加载:' + url;
|
||||
var headers = Object.assign({}, FETCH_HEADERS, options.headers || {});
|
||||
var lastErr;
|
||||
|
||||
// Try 1: fm.req (native OkHttp, 无 CORS 限制)
|
||||
try {
|
||||
if (window.fm && fm.req) {
|
||||
var resp = await fm.req(url, {
|
||||
method: options.method || 'GET',
|
||||
headers: headers,
|
||||
responseType: 'text',
|
||||
timeout: options.timeout || 25,
|
||||
credentials: 'include',
|
||||
body: options.body
|
||||
});
|
||||
if (resp && (resp.ok || resp.status === 200)) {
|
||||
return resp.body || resp.content || resp.data || resp.text || '';
|
||||
}
|
||||
// 520 → 换 Referer 重试一次
|
||||
if (String(resp && resp.status) === '520') {
|
||||
var retryHeaders = Object.assign({}, headers, { Referer: 'https://www.baidu.com/', Accept: 'text/html,*/*' });
|
||||
var retryUrl = url + (url.indexOf('?') >= 0 ? '&' : '?') + '_t=' + Date.now();
|
||||
var retryResp = await fm.req(retryUrl, {
|
||||
method: options.method || 'GET',
|
||||
headers: retryHeaders,
|
||||
responseType: 'text',
|
||||
timeout: 30,
|
||||
credentials: 'include',
|
||||
body: options.body
|
||||
});
|
||||
if (retryResp && (retryResp.ok || retryResp.status === 200)) {
|
||||
return retryResp.body || retryResp.content || retryResp.data || retryResp.text || '';
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) { lastErr = e; console.warn('[6v] fm.req failed:', e.message); }
|
||||
|
||||
// Try 2: fetch (WebView 内置浏览器引擎,SSL 行为不同)
|
||||
try {
|
||||
var fetchOpts = { headers: headers, cache: 'no-store', credentials: 'include' };
|
||||
if (options.method === 'POST') {
|
||||
fetchOpts.method = 'POST';
|
||||
fetchOpts.body = options.body;
|
||||
}
|
||||
var r = await fetch(url, fetchOpts);
|
||||
if (r.ok) return await r.text();
|
||||
} catch (e) { lastErr = e; console.warn('[6v] fetch failed:', e.message); }
|
||||
|
||||
// Try 3: no-cors fetch (opaque response, 最后手段)
|
||||
try {
|
||||
var r2 = await fetch(url, { headers: headers, cache: 'no-store', mode: 'no-cors', credentials: 'include' });
|
||||
if (r2) {
|
||||
var t2 = await r2.text();
|
||||
if (t2 && t2.length > 50) return t2;
|
||||
}
|
||||
} catch (e) { lastErr = e; }
|
||||
|
||||
statusEl.textContent = '加载失败';
|
||||
throw lastErr || new Error('HTTP 请求全部失败');
|
||||
}
|
||||
|
||||
// 从播放页提取真实 m3u8
|
||||
async function getRealVideoUrl(playPageUrl) {
|
||||
try {
|
||||
var html = await reqText(playPageUrl);
|
||||
// 1. 直接匹配 m3u8
|
||||
var m3u8 = html.match(/https?:\/\/[^\s"'<>]+\.m3u8[^\s"'<>]*/i);
|
||||
if (m3u8) return m3u8[0];
|
||||
// 2. 匹配 iframe 再提取
|
||||
var iframeMatch = html.match(/<iframe[^>]+src\s*=\s*"([^"]+)"/i);
|
||||
if (iframeMatch) {
|
||||
var iframeUrl = iframeMatch[1];
|
||||
if (!iframeUrl.startsWith('http')) iframeUrl = BASE + iframeUrl;
|
||||
var frameHtml = await reqText(iframeUrl);
|
||||
// 2a. iframe 中直接匹配完整 m3u8 URL
|
||||
var m3u8InFrame = frameHtml.match(/https?:\/\/[^\s"'<>]+\.m3u8[^\s"'<>]*/i);
|
||||
if (m3u8InFrame) return m3u8InFrame[0];
|
||||
// 2b. iframe 中匹配 const url = "...m3u8" 变量(相对路径)
|
||||
var constUrl = frameHtml.match(/const\s+url\s*=\s*"([^"]*\.m3u8[^"]*)"/i);
|
||||
if (constUrl) {
|
||||
var m3u8Url = constUrl[1];
|
||||
if (!m3u8Url.startsWith('http')) {
|
||||
// 相对于 iframe 的 base URL 解析
|
||||
var iframeBase = iframeUrl.substring(0, iframeUrl.lastIndexOf('/') + 1);
|
||||
if (m3u8Url.startsWith('/')) {
|
||||
var u = new URL(iframeUrl);
|
||||
m3u8Url = u.protocol + '//' + u.host + m3u8Url;
|
||||
} else {
|
||||
m3u8Url = iframeBase + m3u8Url;
|
||||
}
|
||||
}
|
||||
return m3u8Url;
|
||||
}
|
||||
// 2c. iframe 中匹配 url: "...m3u8" 变量
|
||||
var urlVar2 = frameHtml.match(/url\s*:\s*"([^"]+\.m3u8[^"]*)"/i);
|
||||
if (urlVar2) {
|
||||
var m3u8Url2 = urlVar2[1];
|
||||
if (!m3u8Url2.startsWith('http')) {
|
||||
var iframeBase2 = iframeUrl.substring(0, iframeUrl.lastIndexOf('/') + 1);
|
||||
if (m3u8Url2.startsWith('/')) {
|
||||
var u2 = new URL(iframeUrl);
|
||||
m3u8Url2 = u2.protocol + '//' + u2.host + m3u8Url2;
|
||||
} else {
|
||||
m3u8Url2 = iframeBase2 + m3u8Url2;
|
||||
}
|
||||
}
|
||||
return m3u8Url2;
|
||||
}
|
||||
}
|
||||
// 3. 播放页中的 url 变量
|
||||
var urlVar = html.match(/url\s*:\s*"([^"]+\.m3u8[^"]*)"/i);
|
||||
if (urlVar) return urlVar[1];
|
||||
var constUrl2 = html.match(/const\s+url\s*=\s*"([^"]*\.m3u8[^"]*)"/i);
|
||||
if (constUrl2) {
|
||||
var m3u8Url3 = constUrl2[1];
|
||||
if (!m3u8Url3.startsWith('http')) m3u8Url3 = abs(m3u8Url3);
|
||||
return m3u8Url3;
|
||||
}
|
||||
return null;
|
||||
} catch(e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 从详情页提取播放链接(按线路分组 + 分集)
|
||||
async function parseDetailLines(detailUrl) {
|
||||
try {
|
||||
var html = await reqText(detailUrl);
|
||||
var lines = [];
|
||||
var h3Regex = /<h3[^>]*>([^<]*播放地址[^<]*)<\/h3>/gi;
|
||||
var h3Matches = [];
|
||||
var m;
|
||||
while ((m = h3Regex.exec(html))) {
|
||||
h3Matches.push({ name: clean(m[1]), index: m.index });
|
||||
}
|
||||
// 提取 h3 和各 h3 之间的内容
|
||||
for (var i = 0; i < h3Matches.length; i++) {
|
||||
var h3 = h3Matches[i];
|
||||
var sectionStart = h3.index;
|
||||
var sectionEnd = (i + 1 < h3Matches.length) ? h3Matches[i + 1].index : html.length;
|
||||
var sectionHtml = html.substring(sectionStart, sectionEnd);
|
||||
// 从 section 中提取所有 /e/DownSys/play/ 链接(可能有 href= "..." 空格情况)
|
||||
var epRegex = /<a\s+(?:[^>]*?\s+)?href\s*=\s*"([^"]*\/e\/DownSys\/play\/[^"]+)"[^>]*>([^<]+)<\/a>/gi;
|
||||
var eps = [];
|
||||
while ((m = epRegex.exec(sectionHtml))) {
|
||||
var epUrl = m[1];
|
||||
if (!epUrl.startsWith('http')) epUrl = BASE + epUrl;
|
||||
eps.push({ href: epUrl, title: clean(m[2]) });
|
||||
}
|
||||
if (eps.length > 0) {
|
||||
lines.push({
|
||||
sid: String(lines.length + 1),
|
||||
name: h3.name,
|
||||
eps: eps
|
||||
});
|
||||
}
|
||||
}
|
||||
// 降级:如果没有 h3 分组,直接提取所有 /e/DownSys/play/ 链接
|
||||
if (lines.length === 0) {
|
||||
var flatRegex = /<a\s+(?:[^>]*?\s+)?href\s*=\s*"([^"]*\/e\/DownSys\/play\/[^"]+)"[^>]*>([^<]+)<\/a>/gi;
|
||||
var allEps = [];
|
||||
while ((m = flatRegex.exec(html))) {
|
||||
var flatUrl = m[1];
|
||||
if (!flatUrl.startsWith('http')) flatUrl = BASE + flatUrl;
|
||||
allEps.push({ href: flatUrl, title: clean(m[2]) });
|
||||
}
|
||||
if (allEps.length > 0) {
|
||||
lines.push({
|
||||
sid: '1',
|
||||
name: '在线播放',
|
||||
eps: allEps
|
||||
});
|
||||
}
|
||||
}
|
||||
// 最终保底:如果没有 /e/DownSys/play/ 链接,尝试其他 /play/ 链接
|
||||
if (lines.length === 0) {
|
||||
var altRegex = /<a\s+(?:[^>]*?\s+)?href\s*=\s*"([^"]*\/play\/[^"]+)"[^>]*>([^<]+)<\/a>/gi;
|
||||
var altEps = [];
|
||||
while ((m = altRegex.exec(html))) {
|
||||
var altUrl = m[1];
|
||||
if (!altUrl.startsWith('http')) altUrl = BASE + altUrl;
|
||||
altEps.push({ href: altUrl, title: clean(m[2]) });
|
||||
}
|
||||
if (altEps.length > 0) {
|
||||
lines.push({
|
||||
sid: '1',
|
||||
name: '在线播放',
|
||||
eps: altEps
|
||||
});
|
||||
}
|
||||
}
|
||||
// === 提取下载地址 ===
|
||||
var downloadIdx = html.indexOf('【下载地址】');
|
||||
if (downloadIdx >= 0) {
|
||||
var downloadStart = downloadIdx;
|
||||
// 下载区结束:下一个 h3、下一个 widget、或下一个 script
|
||||
var downloadEnd = html.length;
|
||||
var nextH3 = html.indexOf('<h3', downloadIdx + 6);
|
||||
var nextWidget = html.indexOf('<div class="widget', downloadIdx + 6);
|
||||
if (nextH3 > 0 && nextH3 < downloadEnd) downloadEnd = nextH3;
|
||||
if (nextWidget > 0 && nextWidget < downloadEnd) downloadEnd = nextWidget;
|
||||
var downloadHtml = html.substring(downloadStart, downloadEnd);
|
||||
// 提取下载区所有链接
|
||||
var downRegex = /<a\s+(?:[^>]*?\s+)?href\s*=\s*"([^"]+)"[^>]*>([^<]+)<\/a>/gi;
|
||||
var downEps = [];
|
||||
var seenUrls = {};
|
||||
while ((m = downRegex.exec(downloadHtml))) {
|
||||
var downUrl = m[1];
|
||||
var downTitle = clean(m[2]);
|
||||
// 过滤重复 URL 和无关链接(如 评论、分类 等)
|
||||
if (seenUrls[downUrl]) continue;
|
||||
if (downTitle.length < 2 && downUrl.indexOf('magnet:') !== 0) continue;
|
||||
if (downUrl.indexOf('#respond') >= 0 || downUrl.indexOf('category') >= 0) continue;
|
||||
seenUrls[downUrl] = true;
|
||||
// 给下载链接一个有意义的标题
|
||||
if (downTitle === '链接' || downTitle.length < 2 || downUrl.indexOf(downTitle) >= 0) {
|
||||
if (downUrl.indexOf('quark') > 0) downTitle = '夸克网盘';
|
||||
else if (downUrl.indexOf('xunlei') > 0) downTitle = '迅雷网盘';
|
||||
else if (downUrl.indexOf('magnet:') === 0) downTitle = '磁力链接';
|
||||
else downTitle = '下载';
|
||||
}
|
||||
downEps.push({ href: downUrl, title: downTitle, isDownload: true });
|
||||
}
|
||||
if (downEps.length > 0) {
|
||||
lines.push({
|
||||
sid: String(lines.length + 1),
|
||||
name: '📥 下载地址',
|
||||
eps: downEps
|
||||
});
|
||||
}
|
||||
}
|
||||
console.log('[6v] 提取到线路:', lines.length, '条,总剧集:', lines.reduce(function(s, l) { return s + l.eps.length; }, 0));
|
||||
return lines;
|
||||
} catch(e) {
|
||||
console.error(e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function playEpisode(playUrl, title, isDownload) {
|
||||
if (isDownload) {
|
||||
// 下载地址:直接打开(磁力链用 fm.play,网盘用 fm.openUrl)
|
||||
statusEl.textContent = '打开:' + title;
|
||||
if (playUrl.indexOf('magnet:') === 0) {
|
||||
if (window.fm && fm.play) fm.play(playUrl, title, { referer: BASE });
|
||||
else window.open(playUrl, '_blank');
|
||||
} else if (playUrl.indexOf('quark.cn') > 0 || playUrl.indexOf('xunlei.com') > 0 || playUrl.indexOf('pan.baidu.com') > 0) {
|
||||
// 网盘链接 → push:// 调用 TVBox 云盘推送功能
|
||||
if (window.fm && fm.play) {
|
||||
fm.play('push://' + playUrl, title);
|
||||
statusEl.textContent = '已推送:' + title;
|
||||
} else {
|
||||
window.open(playUrl, '_blank');
|
||||
}
|
||||
} else {
|
||||
if (window.fm && fm.openUrl) fm.openUrl(playUrl);
|
||||
else window.open(playUrl, '_blank');
|
||||
}
|
||||
return;
|
||||
}
|
||||
statusEl.textContent = '正在解析播放地址…';
|
||||
var realUrl = await getRealVideoUrl(playUrl);
|
||||
if (realUrl) {
|
||||
if (window.fm && fm.play) {
|
||||
fm.play(realUrl, title, { referer: BASE, headers: { Referer: BASE + '/' } });
|
||||
statusEl.textContent = '已发送播放:' + title;
|
||||
} else {
|
||||
window.open(realUrl, '_blank');
|
||||
}
|
||||
} else {
|
||||
statusEl.textContent = '未获取到直链,打开原网页';
|
||||
if (window.fm && fm.openUrl) fm.openUrl(playUrl);
|
||||
else window.open(playUrl, '_blank');
|
||||
}
|
||||
}
|
||||
|
||||
// 解析列表页(含图片提取)
|
||||
function parseList(html, keyword) {
|
||||
if (!keyword) keyword = '';
|
||||
var items = [];
|
||||
// 匹配整个 post 区块:thumbnail (含图片) + h2 (含标题+链接)
|
||||
var postRegex = /<li[^>]*class="[^"]*\bpost\b[^"]*"[^>]*>[\s\S]*?<div class="thumbnail">[\s\S]*?<a[\s\S]*?href\s*=\s*"([^"]+\.html)"[\s\S]*?<img[^>]*src="([^"]+)"[^>]*>[\s\S]*?<h2>[\s\S]*?<a[\s\S]*?href\s*=\s*"[^"]*"[^>]*>([^<]+)<\/a>/gi;
|
||||
var match;
|
||||
var seen = {};
|
||||
while ((match = postRegex.exec(html))) {
|
||||
var href = match[1];
|
||||
var img = match[2];
|
||||
var title = match[3].trim();
|
||||
if (href.indexOf('/e/') === 0) continue;
|
||||
if (href === '/' || href === '/index.html') continue;
|
||||
if (title.length < 2) continue;
|
||||
var fullUrl = href.startsWith('http') ? href : BASE + href;
|
||||
if (seen[fullUrl]) continue;
|
||||
seen[fullUrl] = true;
|
||||
var badge = '';
|
||||
var yearMatch = title.match(/\b(19|20)\d{2}\b/);
|
||||
if (yearMatch) badge = yearMatch[0];
|
||||
items.push({ href: fullUrl, title: clean(title), remark: badge, image: abs(img) });
|
||||
}
|
||||
// 降级:如果 post 正则没匹配到,回退到纯链接匹配(无图)
|
||||
if (items.length === 0) {
|
||||
var regex = /<a\s+(?:[^>]*?\s+)?href="([^"]+\.html)"[^>]*>([^<]+)<\/a>/gi;
|
||||
while ((match = regex.exec(html))) {
|
||||
var href = match[1];
|
||||
var title = match[2].trim();
|
||||
if (href.indexOf('/e/') === 0) continue;
|
||||
if (href === '/' || href === '/index.html') continue;
|
||||
if (!href.match(/\/[a-zA-Z0-9_-]+\/\d+\.html/) && !href.match(/\/\d+\.html/)) continue;
|
||||
if (title.length < 2) continue;
|
||||
var fullUrl = href.startsWith('http') ? href : BASE + href;
|
||||
if (seen[fullUrl]) continue;
|
||||
seen[fullUrl] = true;
|
||||
var badge = '';
|
||||
var yearMatch = title.match(/\b(19|20)\d{2}\b/);
|
||||
if (yearMatch) badge = yearMatch[0];
|
||||
items.push({ href: fullUrl, title: clean(title), remark: badge, image: '' });
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function renderSections(items, keyword) {
|
||||
if (!keyword) keyword = '';
|
||||
if (!items.length) {
|
||||
content.innerHTML = '<div class="empty">暂无内容</div>';
|
||||
return;
|
||||
}
|
||||
var title = keyword ? '搜索:' + keyword : '影片列表';
|
||||
var gridHtml = '\n <section class="section">\n <div class="section-title"><h2>' + esc(title) + '</h2></div>\n <div class="grid">\n ' + items.map(function(it) {
|
||||
return '\n <article class="card focusable" tabindex="0" data-href="' + esc(it.href) + '">\n <div class="poster">\n ' + (it.image ? '<img loading="lazy" referrerpolicy="no-referrer" src="' + esc(imgUrl(it.image)) + '" alt="' + esc(it.title) + '">' : '<div class="noimg">6v</div>') + '\n </div>\n <div class="meta">\n <div class="name">' + esc(it.title) + '</div>\n <div class="remark">' + esc(it.remark || '点击查看详情') + '</div>\n </div>\n </article>\n ';
|
||||
}).join('') + '\n </div>\n </section>\n ';
|
||||
content.innerHTML = gridHtml;
|
||||
Array.from(content.querySelectorAll('.card')).forEach(function(card) {
|
||||
card.onclick = function() { openDetail(card.dataset.href); };
|
||||
});
|
||||
// TV 模式自动聚焦第一张卡片
|
||||
if (document.documentElement.classList.contains('tv-mode')) {
|
||||
setTimeout(function() {
|
||||
var firstCard = content.querySelector('.card');
|
||||
if (firstCard) firstCard.focus({ preventScroll: true });
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPage(url) {
|
||||
currentUrl = abs(url);
|
||||
Array.from(tabsDiv.querySelectorAll('.tab')).forEach(function(btn) {
|
||||
if (abs(btn.dataset.url) === currentUrl) btn.classList.add('active');
|
||||
else btn.classList.remove('active');
|
||||
});
|
||||
content.innerHTML = '<div class="empty loading">正在加载内容…</div>';
|
||||
try {
|
||||
var html = await reqText(currentUrl);
|
||||
var items = parseList(html);
|
||||
renderSections(items);
|
||||
statusEl.textContent = '已加载 ' + items.length + ' 条内容';
|
||||
restoreFocus();
|
||||
} catch(e) {
|
||||
content.innerHTML = '<div class="empty">加载失败:' + esc(e.message) + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
async function search(q) {
|
||||
q = clean(q);
|
||||
if (!q) return;
|
||||
history.pushState({ route: 'search', q: q }, '', '#search=' + encodeURIComponent(q));
|
||||
Array.from(tabsDiv.querySelectorAll('.tab')).forEach(function(btn) { btn.classList.remove('active'); });
|
||||
content.innerHTML = '<div class="empty loading">搜索中…</div>';
|
||||
var searchUrl = BASE + '/e/search/11index.php';
|
||||
var postData = 'keyboard=' + encodeURIComponent(q) + '&show=title&tempid=1&tbname=article&mid=1&dopost=search&submit=';
|
||||
var html;
|
||||
try {
|
||||
html = await reqText(searchUrl, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: postData });
|
||||
} catch(e) {
|
||||
content.innerHTML = '<div class="empty">搜索失败,请检查网络</div>';
|
||||
statusEl.textContent = '搜索失败:' + q;
|
||||
return;
|
||||
}
|
||||
var items = parseList(html);
|
||||
if (!items.length) {
|
||||
content.innerHTML = '<div class="empty">没有找到相关结果</div>';
|
||||
statusEl.textContent = '无搜索结果:' + q;
|
||||
return;
|
||||
}
|
||||
renderSections(items, q);
|
||||
statusEl.textContent = '搜索完成:' + q;
|
||||
}
|
||||
|
||||
async function openDetail(detailUrl) {
|
||||
lastFocus = document.activeElement;
|
||||
document.body.classList.add('detail-active');
|
||||
$('#detailSheet').classList.add('active');
|
||||
$('#detailPanel').innerHTML = '<div class="empty loading">正在读取详情…</div>';
|
||||
history.pushState({ route: 'detail', url: detailUrl }, '', '#detail=' + encodeURIComponent(detailUrl));
|
||||
try {
|
||||
var html = await reqText(detailUrl);
|
||||
var titleMatch = html.match(/<title>([^-]+)-/);
|
||||
var title = titleMatch ? titleMatch[1].trim() : '未知影片';
|
||||
var imgMatch = html.match(/<img[^>]+src="([^"]+\.jpg)"/i);
|
||||
var image = imgMatch ? imgMatch[1] : '';
|
||||
var descMatch = html.match(/◎简 介\s*([\s\S]*?)(?:◎|$)/);
|
||||
var desc = descMatch ? cleanDesc(descMatch[1]) : '暂无简介';
|
||||
var lines = await parseDetailLines(detailUrl);
|
||||
if (lines.length === 0) {
|
||||
$('#detailPanel').innerHTML = '<button class="focusable" id="closeDetail">返回</button><div class="empty">未找到播放链接</div>';
|
||||
$('#closeDetail').onclick = closeDetail;
|
||||
return;
|
||||
}
|
||||
var lineTabsHtml = lines.map(function(ln, idx) { return '<button class="line-tab focusable ' + (idx===0?'active':'') + '" data-sid="' + esc(ln.sid) + '">' + esc(ln.name) + '(' + ln.eps.length + ')</button>'; }).join('');
|
||||
var panelHtml = '\n <button class="focusable" id="closeDetail">返回</button>\n <div class="detail-head">\n <img class="detail-pic" referrerpolicy="no-referrer" src="' + esc(image) + '" onerror="this.style.display=\'none\'">\n <div>\n <div class="detail-title">' + esc(title) + '</div>\n <div class="detail-info">6v影视资源</div>\n <p class="desc">' + esc(desc) + '</p>\n </div>\n </div>\n <h3>播放线路</h3>\n <div class="line-tabs">' + lineTabsHtml + '</div>\n <div class="episodes" id="episodeBox"></div>\n ';
|
||||
$('#detailPanel').innerHTML = panelHtml;
|
||||
$('#closeDetail').onclick = closeDetail;
|
||||
function renderLine(sid) {
|
||||
var line = lines.find(function(l) { return l.sid == sid; }) || lines[0];
|
||||
if (!line) return;
|
||||
var epHtml = line.eps.map(function(ep) { return '<button class="ep focusable" data-href="' + esc(ep.href) + '"' + (ep.isDownload ? ' data-download="true"' : '') + '>' + esc(ep.title) + '</button>'; }).join('');
|
||||
$('#episodeBox').innerHTML = epHtml || '<div class="empty">暂无剧集</div>';
|
||||
Array.from($('#episodeBox').querySelectorAll('.ep')).forEach(function(btn) {
|
||||
btn.onclick = function() { playEpisode(btn.dataset.href, title + ' ' + btn.textContent, btn.dataset.download === 'true'); };
|
||||
});
|
||||
var firstEp = $('#episodeBox').querySelector('.ep');
|
||||
if (firstEp) firstEp.focus({ preventScroll: true });
|
||||
}
|
||||
var lineTabs = $('#detailPanel').querySelectorAll('.line-tab');
|
||||
lineTabs.forEach(function(tab) {
|
||||
tab.onclick = function() {
|
||||
lineTabs.forEach(function(t) { t.classList.remove('active'); });
|
||||
tab.classList.add('active');
|
||||
renderLine(tab.dataset.sid);
|
||||
};
|
||||
});
|
||||
renderLine(lines[0].sid);
|
||||
setTimeout(function() {
|
||||
var fe = $('#episodeBox') && $('#episodeBox').querySelector('.ep');
|
||||
if (!fe) $('#closeDetail') && $('#closeDetail').focus();
|
||||
}, 80);
|
||||
} catch(e) {
|
||||
$('#detailPanel').innerHTML = '<button class="focusable" id="closeDetail">返回</button><div class="empty">详情加载失败:' + esc(e.message) + '</div>';
|
||||
$('#closeDetail').onclick = closeDetail;
|
||||
}
|
||||
}
|
||||
|
||||
function closeDetail() {
|
||||
document.body.classList.remove('detail-active');
|
||||
$('#detailSheet').classList.remove('active');
|
||||
if (location.hash.startsWith('#detail')) history.back();
|
||||
if (lastFocus && lastFocus.focus) setTimeout(function() { lastFocus.focus({ preventScroll: true }); }, 50);
|
||||
}
|
||||
window.__fyClose = closeDetail;
|
||||
|
||||
function renderTabs() {
|
||||
tabsDiv.innerHTML = CATS.map(function(cat) {
|
||||
var name = cat[0], path = cat[1];
|
||||
var url = abs(path);
|
||||
var active = (url === currentUrl) ? 'active' : '';
|
||||
return '<button class="tab focusable ' + active + '" data-url="' + esc(url) + '">' + esc(name) + '</button>';
|
||||
}).join('');
|
||||
Array.from(tabsDiv.querySelectorAll('.tab')).forEach(function(btn) {
|
||||
btn.onclick = function() { loadPage(btn.dataset.url); };
|
||||
});
|
||||
}
|
||||
|
||||
function restoreFocus() {
|
||||
setTimeout(function() {
|
||||
var el = document.querySelector('.tab.active') || document.querySelector('.card');
|
||||
if (el) el.focus({ preventScroll: true });
|
||||
}, 50);
|
||||
}
|
||||
|
||||
function handleKeys(e) {
|
||||
if (e.key === 'Escape' || e.key === 'Backspace') {
|
||||
if ($('#detailSheet').classList.contains('active')) {
|
||||
e.preventDefault();
|
||||
closeDetail();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (e.key === 'Enter' && document.activeElement && document.activeElement.classList.contains('focusable') && document.activeElement.tagName !== 'INPUT') {
|
||||
document.activeElement.click();
|
||||
}
|
||||
}
|
||||
|
||||
function boot() {
|
||||
detectTV();
|
||||
statusEl.textContent = '正在初始化…';
|
||||
renderTabs();
|
||||
$('#searchForm').onsubmit = function(e) { e.preventDefault(); search($('#kw').value); };
|
||||
document.addEventListener('keydown', handleKeys, true);
|
||||
window.addEventListener('popstate', function() {
|
||||
if ($('#detailSheet').classList.contains('active')) closeDetail();
|
||||
else loadPage(currentUrl);
|
||||
});
|
||||
loadPage(BASE + '/');
|
||||
}
|
||||
|
||||
setTimeout(function() {
|
||||
try { if (statusEl && statusEl.textContent === '准备加载…') statusEl.textContent = '正在初始化 WebHome…'; } catch(e) {}
|
||||
}, 800);
|
||||
waitFm().then(function() { try { boot(); } catch(e) { statusEl.textContent = '启动失败:' + e.message; } }).catch(function() { boot(); });
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,4255 @@
|
||||
|
||||
<!DOCTYPE html>
|
||||
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta content="width=device-width, initial-scale=1, viewport-fit=cover" name="viewport"/>
|
||||
<meta content="#080808" name="theme-color"/>
|
||||
<title>影视库</title>
|
||||
<script>
|
||||
// App 环境检测:在 CSS 应用前同步打标,避免背景闪烁(参考 nostr-合并.html)
|
||||
if (window.fongmiBridge || window.fongmiClient) {
|
||||
document.documentElement.classList.add("fm-native");
|
||||
}
|
||||
// ── 老 WebView 兼容垫片(Android 9 自带 WebView ≈ Chrome 66-70)──
|
||||
// Promise.allSettled: Chrome 76+ 才有,缺失会让 boot/详情加载 TypeError
|
||||
if (typeof Promise !== 'undefined' && !Promise.allSettled) {
|
||||
Promise.allSettled = function (promises) {
|
||||
return Promise.all(
|
||||
Array.prototype.map.call(promises, function (p) {
|
||||
return Promise.resolve(p).then(
|
||||
function (value) { return { status: 'fulfilled', value: value }; },
|
||||
function (reason) { return { status: 'rejected', reason: reason }; }
|
||||
);
|
||||
})
|
||||
);
|
||||
};
|
||||
}
|
||||
// Element.replaceChildren: Chrome 86+(本页已避免无参调用,留作兜底)
|
||||
if (typeof Element !== 'undefined' && !Element.prototype.replaceChildren) {
|
||||
Element.prototype.replaceChildren = function () {
|
||||
while (this.firstChild) this.removeChild(this.firstChild);
|
||||
for (var i = 0; i < arguments.length; i++) {
|
||||
this.appendChild(arguments[i] instanceof Node ? arguments[i] : document.createTextNode(String(arguments[i])));
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
|
||||
|
||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||
|
||||
:root{
|
||||
--bg:transparent;
|
||||
--surface:#111111;
|
||||
--surface2:#1a1a1a;
|
||||
--text:#f0ede8;
|
||||
--muted:#595959;
|
||||
--accent:#e8c97a;
|
||||
--accent2:#c85c3a;
|
||||
--green:#3ddc84;
|
||||
--red:#ff453a;
|
||||
--yellow:#ffd60a;
|
||||
--blue:#6688aa;
|
||||
--nav-h:52px;
|
||||
--safe-t:env(safe-area-inset-top,0px);
|
||||
--safe-b:env(safe-area-inset-bottom,0px);
|
||||
--r:3px;
|
||||
--serif:-apple-system,BlinkMacSystemFont,'SF Pro Display','SF Pro Text','PingFang SC','Helvetica Neue',Arial,sans-serif;
|
||||
--mono:-apple-system,BlinkMacSystemFont,'SF Pro Rounded','SF Mono','Helvetica Neue',Arial,sans-serif;
|
||||
}
|
||||
|
||||
/* 页面级背景默认透明(让 App 原生壁纸透出) */
|
||||
html,body{background:transparent;color:var(--text);font-family:var(--serif);-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}
|
||||
body{min-height:100vh;min-height:100dvh;overflow-x:hidden}
|
||||
/* 非 App(普通浏览器)兜底:深色背景,避免透明导致白底 */
|
||||
html:not(.fm-native),
|
||||
html:not(.fm-native) body{ background:#080808; }
|
||||
/* App 环境:强制透明 */
|
||||
html.fm-native,
|
||||
html.fm-native body{ background:transparent; }
|
||||
|
||||
/* scanline(仅非 App 环境显示,App 下隐藏避免压在壁纸上) */
|
||||
body::before{content:'';position:fixed;inset:0;z-index:0;pointer-events:none;
|
||||
background:repeating-linear-gradient(0deg,transparent,transparent 3px,rgba(0,0,0,.04) 3px,rgba(0,0,0,.04) 4px)}
|
||||
html.fm-native body::before{ display:none; }
|
||||
|
||||
/* ── NAV ── */
|
||||
#nav{
|
||||
position:fixed;top:0;left:0;right:0;z-index:100;
|
||||
height:calc(var(--nav-h) + var(--safe-t));
|
||||
padding-top:var(--safe-t);
|
||||
background:rgba(8,8,8,.96);
|
||||
backdrop-filter:blur(24px) saturate(1.3);
|
||||
border-bottom:1px solid rgba(255,255,255,.06);
|
||||
display:flex;align-items:center;
|
||||
}
|
||||
/* App 环境:导航栏半透明毛玻璃,让壁纸透出 */
|
||||
html.fm-native #nav{
|
||||
background:rgba(10,10,12,.42);
|
||||
backdrop-filter:blur(18px) saturate(1.1);
|
||||
border-bottom:1px solid rgba(255,255,255,.1);
|
||||
}
|
||||
.ntabs{
|
||||
display:flex;align-items:center;flex:1;
|
||||
overflow-x:auto;overflow-y:hidden;
|
||||
padding:0 4px 0 14px;gap:2px;scrollbar-width:none;
|
||||
}
|
||||
.ntabs::-webkit-scrollbar{display:none}
|
||||
.ntab{
|
||||
display:inline-flex;align-items:center;gap:7px;
|
||||
font-family:var(--serif);font-size:14px;font-weight:500;letter-spacing:.01em;
|
||||
color:var(--text);
|
||||
padding:6px 14px 6px 10px;
|
||||
border-radius:6px;cursor:pointer;
|
||||
background:none;border:none;white-space:nowrap;position:relative;
|
||||
transition:color .15s,background .15s,transform .12s;
|
||||
flex-shrink:0;
|
||||
}
|
||||
.ntab-ico{
|
||||
width:17px;height:17px;flex-shrink:0;opacity:.8;
|
||||
transition:opacity .15s;
|
||||
}
|
||||
.ntab-lbl{line-height:1}
|
||||
.ntab.on{
|
||||
color:var(--accent);
|
||||
background:linear-gradient(135deg,rgba(232,201,122,.18) 0%,rgba(232,201,122,.08) 100%);
|
||||
box-shadow:inset 0 0 0 1px rgba(232,201,122,.2);
|
||||
outline:none;
|
||||
}
|
||||
.ntab.on .ntab-ico{opacity:1;filter:drop-shadow(0 0 4px rgba(232,201,122,.5))}
|
||||
.ntab.on::after{
|
||||
content:'›';
|
||||
position:absolute;right:5px;top:50%;transform:translateY(-50%);
|
||||
font-size:15px;color:var(--accent);opacity:.65;line-height:1;
|
||||
}
|
||||
.ntab:hover{color:var(--text);background:rgba(255,255,255,.06);transform:translateY(-1px)}
|
||||
.ntab:hover .ntab-ico{opacity:.8}
|
||||
.ntab:active{transform:scale(.94) translateY(0);background:rgba(232,201,122,.1)}
|
||||
.ntab:focus{outline:none;color:var(--accent)}
|
||||
.nbtn{
|
||||
width:var(--nav-h);height:var(--nav-h);
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
background:none;border:none;cursor:pointer;color:var(--text);flex-shrink:0;
|
||||
transition:color .12s,transform .1s;border-radius:3px;
|
||||
}
|
||||
.nbtn:hover{color:var(--accent);transform:scale(1.12)}
|
||||
.nbtn:active{transform:scale(.9)}
|
||||
.nbtn:focus{outline:none;color:var(--accent)}
|
||||
.nbtn svg{width:23px;height:23px;stroke-width:2.4}
|
||||
|
||||
/* ── MAIN ── */
|
||||
#main{
|
||||
padding-top:calc(var(--nav-h) + var(--safe-t));
|
||||
padding-bottom:calc(36px + var(--safe-b));
|
||||
position:relative;z-index:1;
|
||||
}
|
||||
|
||||
/* ── HERO CAROUSEL ── */
|
||||
#heroWrap{
|
||||
position:relative;overflow:hidden;
|
||||
min-height:41vw;max-height:410px;
|
||||
/* 去掉硬边框,底部用渐变融入页面背景 */
|
||||
}
|
||||
/* individual slides */
|
||||
.hero-slide{
|
||||
position:absolute;inset:0;
|
||||
opacity:0;transition:opacity .8s ease;pointer-events:none;
|
||||
}
|
||||
.hero-slide.active{opacity:1;pointer-events:auto;cursor:pointer;}
|
||||
.hero-slide-bg{
|
||||
position:absolute;inset:0;
|
||||
background-size:cover;background-position:center 20%;
|
||||
transition:transform 8s ease;
|
||||
}
|
||||
.hero-slide.active .hero-slide-bg{transform:scale(1.04)}
|
||||
.hero-slide-grad{
|
||||
position:absolute;inset:0;
|
||||
background:
|
||||
linear-gradient(90deg,rgba(8,8,8,.92) 0%,rgba(8,8,8,.6) 36%,rgba(8,8,8,.1) 64%,transparent 100%),
|
||||
linear-gradient(0deg,#080808 0%,rgba(8,8,8,.85) 18%,rgba(8,8,8,.35) 42%,transparent 70%);
|
||||
}
|
||||
/* App 透明环境:底部渐隐到透明(融入壁纸/页面),而非纯黑 */
|
||||
html.fm-native .hero-slide-grad{
|
||||
background:
|
||||
linear-gradient(90deg,rgba(8,8,8,.85) 0%,rgba(8,8,8,.5) 36%,rgba(8,8,8,.08) 64%,transparent 100%),
|
||||
linear-gradient(0deg,rgba(8,8,8,.92) 0%,rgba(8,8,8,.55) 22%,rgba(8,8,8,.15) 48%,transparent 72%);
|
||||
}
|
||||
.hero-slide-content{
|
||||
position:absolute;
|
||||
bottom:clamp(30px,4vw,58px);
|
||||
left:0;z-index:2;
|
||||
padding:0 clamp(20px,4vw,54px);
|
||||
max-width:min(500px,54%);
|
||||
display:flex;flex-direction:column;
|
||||
transform:translateY(12px);opacity:0;
|
||||
transition:transform .6s ease .3s, opacity .6s ease .3s;
|
||||
}
|
||||
.hero-slide.active .hero-slide-content{transform:translateY(0);opacity:1}
|
||||
.hero-title{
|
||||
font-size:clamp(20px,2.4vw,36px);font-weight:700;line-height:1.1;margin-bottom:6px;
|
||||
text-shadow:0 2px 24px rgba(0,0,0,.8);letter-spacing:-.02em;
|
||||
display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;
|
||||
}
|
||||
/* 轮播徽标(TMDB logo 替换标题文字) */
|
||||
.hero-logo-wrap{
|
||||
margin-bottom:8px;min-height:42px;
|
||||
display:flex;align-items:flex-end;
|
||||
}
|
||||
.hero-logo-img{
|
||||
max-width:min(340px,70%);
|
||||
max-height:clamp(56px,12vw,108px);
|
||||
width:auto;height:auto;object-fit:contain;object-position:left bottom;
|
||||
filter:drop-shadow(0 3px 16px rgba(0,0,0,.85)) drop-shadow(0 1px 3px rgba(0,0,0,.6));
|
||||
display:block;
|
||||
opacity:0;transition:opacity .4s ease;
|
||||
}
|
||||
.hero-logo-img.loaded{opacity:1}
|
||||
/* 有徽标时隐藏文字标题 */
|
||||
.hero-title.has-logo{display:none}
|
||||
.hero-meta{
|
||||
font-family:var(--mono);font-size:12px;color:rgba(240,237,232,.55);
|
||||
margin-bottom:8px;display:flex;align-items:center;flex-wrap:nowrap;overflow:hidden;
|
||||
}
|
||||
.hero-meta .hm-item{display:flex;align-items:center;white-space:nowrap}
|
||||
.hero-meta .hm-item + .hm-item::before{content:'·';margin:0 6px;opacity:.4}
|
||||
.hero-meta .star{color:var(--accent);font-weight:700}
|
||||
.hero-play-btn{
|
||||
display:inline-flex;align-items:center;gap:6px;
|
||||
height:32px;padding:0 16px;
|
||||
background:var(--accent);color:#000;
|
||||
font-family:var(--mono);font-size:13px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;
|
||||
border:none;cursor:pointer;border-radius:var(--r);width:fit-content;
|
||||
box-shadow:0 2px 10px rgba(232,201,122,.25);
|
||||
transition:background .12s,transform .12s,box-shadow .12s;
|
||||
}
|
||||
.hero-play-btn:hover{background:#f5de8a;transform:translateY(-2px);box-shadow:0 6px 20px rgba(232,201,122,.35)}
|
||||
.hero-play-btn svg{width:10px;height:10px;flex-shrink:0}
|
||||
/* dots */
|
||||
#heroDots{
|
||||
position:absolute;bottom:14px;right:20px;z-index:5;
|
||||
display:flex;gap:6px;align-items:center;
|
||||
}
|
||||
.hero-dot{
|
||||
width:6px;height:6px;border-radius:50%;
|
||||
background:rgba(255,255,255,.3);border:none;cursor:pointer;padding:0;
|
||||
transition:background .2s,transform .2s,width .25s;
|
||||
}
|
||||
.hero-dot.active{background:var(--accent);width:20px;border-radius:3px;transform:none}
|
||||
/* progress bar */
|
||||
#heroProgress{
|
||||
position:absolute;bottom:0;left:0;z-index:5;
|
||||
height:2px;background:var(--accent);opacity:.6;
|
||||
width:0%;transition:width linear;
|
||||
}
|
||||
|
||||
@media(min-width:900px){.hero-slide-content{max-width:min(540px,48%)}}
|
||||
@media(min-width:1200px){#heroWrap{max-height:450px}.hero-title{font-size:clamp(22px,2.4vw,38px)}.ntab{font-size:16px;padding:6px 15px}}
|
||||
|
||||
/* ── SECTION ── */
|
||||
.sec{padding:0 20px 24px;border-bottom:1px solid rgba(255,255,255,.04)}
|
||||
.sechd{
|
||||
display:flex;align-items:center;gap:10px;
|
||||
padding:20px 0 12px;
|
||||
}
|
||||
.sectitle{
|
||||
font-family:var(--serif);font-size:15px;font-weight:600;letter-spacing:.04em;
|
||||
text-transform:none;color:var(--text);white-space:nowrap;
|
||||
}
|
||||
.secline{flex:1;height:1px;background:rgba(255,255,255,.07)}
|
||||
.seccnt{font-family:var(--mono);font-size:12px;color:var(--muted)}
|
||||
|
||||
/* ── GRID ── */
|
||||
.grid{
|
||||
display:grid;
|
||||
grid-template-columns:repeat(3,1fr);
|
||||
gap:6px;
|
||||
}
|
||||
@media(min-width:480px){.grid{grid-template-columns:repeat(4,1fr);gap:8px}}
|
||||
@media(min-width:640px){.grid{grid-template-columns:repeat(5,1fr);gap:9px}}
|
||||
@media(min-width:900px){.grid{grid-template-columns:repeat(7,1fr);gap:10px}}
|
||||
@media(min-width:1200px){.grid{grid-template-columns:repeat(9,1fr);gap:12px}}
|
||||
@media(min-width:1600px){.grid{grid-template-columns:repeat(11,1fr);gap:14px}}
|
||||
|
||||
/* landscape for TV shows */
|
||||
.grid-land{grid-template-columns:repeat(2,1fr)}
|
||||
@media(min-width:480px){.grid-land{grid-template-columns:repeat(3,1fr)}}
|
||||
@media(min-width:640px){.grid-land{grid-template-columns:repeat(4,1fr)}}
|
||||
@media(min-width:900px){.grid-land{grid-template-columns:repeat(5,1fr)}}
|
||||
@media(min-width:1200px){.grid-land{grid-template-columns:repeat(6,1fr)}}
|
||||
|
||||
/* ── CARD ── */
|
||||
.card{
|
||||
cursor:pointer;position:relative;
|
||||
border-radius:var(--r);overflow:hidden;
|
||||
background:var(--surface);outline:none;
|
||||
transition:transform .14s cubic-bezier(.2,.8,.4,1);
|
||||
}
|
||||
.card:hover,.card:focus{transform:scale(1.06);z-index:3}
|
||||
.card-img{
|
||||
position:relative;overflow:hidden;
|
||||
background:var(--surface2);
|
||||
/* 老 WebView 兼容:padding-top 撑高度(2:3 → 150%);新引擎用下方 aspect-ratio 覆盖 */
|
||||
height:0;padding-top:150%;
|
||||
}
|
||||
.card-img.land{padding-top:56.25%} /* 16:9 */
|
||||
/* 支持 aspect-ratio 的引擎:恢复用 aspect-ratio,去掉 padding hack */
|
||||
@supports (aspect-ratio:1/1){
|
||||
.card-img{height:auto;padding-top:0;aspect-ratio:2/3}
|
||||
.card-img.land{padding-top:0;aspect-ratio:16/9}
|
||||
}
|
||||
.card-img img{
|
||||
position:absolute;top:0;left:0;
|
||||
width:100%;height:100%;object-fit:cover;display:block;
|
||||
transition:opacity .3s;
|
||||
}
|
||||
.card-img img[data-loading]{opacity:0}
|
||||
.card-img img.loaded{opacity:1}
|
||||
.card-overlay{
|
||||
position:absolute;inset:0;
|
||||
background:linear-gradient(180deg,transparent 55%,rgba(0,0,0,.8) 100%);
|
||||
opacity:0;transition:opacity .14s;
|
||||
}
|
||||
.card:hover .card-overlay,.card:focus .card-overlay{opacity:1}
|
||||
|
||||
/* rating badge */
|
||||
.card-rating{
|
||||
position:absolute;top:5px;left:5px;z-index:2;
|
||||
font-family:var(--mono);font-size:10px;font-weight:700;
|
||||
background:rgba(0,0,0,.75);color:var(--accent);
|
||||
padding:2px 5px;border-radius:2px;
|
||||
backdrop-filter:blur(4px);
|
||||
}
|
||||
|
||||
/* pan status dot */
|
||||
.card-dot{
|
||||
position:absolute;top:5px;right:5px;z-index:2;
|
||||
width:7px;height:7px;border-radius:50%;
|
||||
border:1.5px solid rgba(0,0,0,.5);display:none;
|
||||
}
|
||||
.card-dot.ok{background:var(--green);display:block}
|
||||
.card-dot.bad{background:var(--red);display:block}
|
||||
.card-dot.locked{background:var(--yellow);display:block}
|
||||
.card-dot.uncertain{background:var(--blue);display:block}
|
||||
|
||||
.card-body{padding:6px 7px 8px}
|
||||
.card-name{
|
||||
font-size:13.5px;font-weight:600;
|
||||
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
|
||||
color:var(--text);line-height:1.35;letter-spacing:-.01em;
|
||||
}
|
||||
.card-sub{
|
||||
font-family:var(--mono);font-size:11px;color:var(--muted);
|
||||
margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
|
||||
}
|
||||
.card-badge{
|
||||
display:inline-block;font-family:var(--mono);font-size:10px;
|
||||
color:var(--accent2);background:rgba(200,92,58,.12);
|
||||
padding:1px 4px;border-radius:2px;margin-top:3px;
|
||||
}
|
||||
|
||||
/* ── SKELETON ── */
|
||||
.sk{
|
||||
background:linear-gradient(90deg,var(--surface) 25%,var(--surface2) 50%,var(--surface) 75%);
|
||||
background-size:200% 100%;
|
||||
animation:sk 1.5s infinite linear;
|
||||
border-radius:var(--r);
|
||||
}
|
||||
@keyframes sk{to{background-position:-200% 0}}
|
||||
.sk-card{height:0;padding-top:150%}
|
||||
.sk-card.land{padding-top:56.25%}
|
||||
@supports (aspect-ratio:1/1){
|
||||
.sk-card{height:auto;padding-top:0;aspect-ratio:2/3}
|
||||
.sk-card.land{padding-top:0;aspect-ratio:16/9}
|
||||
}
|
||||
|
||||
/* ── SEARCH OVERLAY ── */
|
||||
#srch{
|
||||
position:fixed;inset:0;z-index:200;
|
||||
background:rgba(8,8,8,.97);backdrop-filter:blur(28px);
|
||||
display:flex;flex-direction:column;
|
||||
transform:translateY(-100%);transition:transform .28s cubic-bezier(.4,0,.2,1);
|
||||
}
|
||||
#srch.on{transform:translateY(0)}
|
||||
.srch-top{
|
||||
padding:calc(var(--safe-t) + 14px) 18px 0;
|
||||
display:flex;align-items:center;gap:10px;
|
||||
}
|
||||
.srch-wrap{flex:1;position:relative}
|
||||
.srch-inp{
|
||||
width:100%;background:var(--surface);
|
||||
border:1px solid rgba(255,255,255,.07);
|
||||
border-radius:var(--r);color:var(--text);
|
||||
font-family:var(--serif);font-size:19px;
|
||||
padding:11px 52px 11px 42px;outline:none;
|
||||
transition:border-color .12s;
|
||||
-webkit-appearance:none;appearance:none;
|
||||
}
|
||||
.srch-inp::-webkit-search-cancel-button{display:none}
|
||||
.srch-inp:focus{border-color:var(--accent)}
|
||||
.srch-inp::placeholder{color:var(--muted)}
|
||||
.srch-ico{
|
||||
position:absolute;left:13px;top:50%;transform:translateY(-50%);
|
||||
color:var(--muted);width:17px;height:17px;pointer-events:none;
|
||||
}
|
||||
.srch-cancel{
|
||||
font-family:var(--mono);font-size:13px;color:var(--muted);
|
||||
background:none;border:none;cursor:pointer;padding:8px 0;
|
||||
white-space:nowrap;transition:color .12s;flex-shrink:0;
|
||||
}
|
||||
.srch-cancel:hover{color:var(--text)}
|
||||
.srch-submit-btn{
|
||||
position:absolute;right:10px;top:50%;transform:translateY(-50%);
|
||||
background:var(--accent);border:none;cursor:pointer;
|
||||
width:32px;height:32px;border-radius:6px;
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
color:#000;transition:background .12s,transform .1s;flex-shrink:0;
|
||||
}
|
||||
.srch-submit-btn:hover{background:#f5de8a;transform:translateY(-50%) scale(1.06)}
|
||||
.srch-submit-btn:active{transform:translateY(-50%) scale(.93)}
|
||||
.srch-label{
|
||||
font-family:var(--mono);font-size:11px;letter-spacing:.2em;
|
||||
text-transform:uppercase;color:var(--muted);
|
||||
padding:18px 18px 8px;
|
||||
}
|
||||
.srch-hot{list-style:none;padding:0 18px;overflow-y:auto}
|
||||
.srch-hot li{
|
||||
display:flex;align-items:center;gap:10px;
|
||||
padding:11px 0;border-bottom:1px solid rgba(255,255,255,.04);
|
||||
cursor:pointer;transition:color .12s;
|
||||
}
|
||||
.srch-hot li:hover,.srch-hot li:focus{color:var(--accent);outline:none}
|
||||
.srch-hot li svg{width:15px;height:15px;opacity:.35;flex-shrink:0}
|
||||
.srch-hot .st{font-size:17px;flex:1}
|
||||
.srch-hot .sm{font-family:var(--mono);font-size:11px;color:var(--muted)}
|
||||
/* ── SEARCH ACTIVE: 主页内容模糊淡出 ── */
|
||||
html.search-active #main > *:not(#s-search){
|
||||
filter:blur(5px) brightness(.45);
|
||||
pointer-events:none;
|
||||
user-select:none;
|
||||
transition:filter .25s ease;
|
||||
}
|
||||
html.search-active #s-search{
|
||||
position:relative;z-index:5;
|
||||
}
|
||||
/* ── 搜索结果列表 ── */
|
||||
#srch-list{
|
||||
display:flex;flex-direction:column;gap:8px;
|
||||
padding:4px 0 32px;
|
||||
}
|
||||
.sli{
|
||||
display:flex;align-items:flex-start;gap:12px;
|
||||
width:100%;padding:12px 14px;border-radius:12px;
|
||||
background:rgba(255,255,255,.06);
|
||||
border:1px solid rgba(255,255,255,.09);
|
||||
text-align:left;color:var(--text);cursor:pointer;
|
||||
transition:background .14s;
|
||||
box-sizing:border-box;
|
||||
}
|
||||
.sli:hover,.sli:focus{background:rgba(255,255,255,.11);outline:none}
|
||||
.sli:active{background:rgba(255,255,255,.04)}
|
||||
.sli-poster{
|
||||
width:60px;min-width:60px;height:90px;
|
||||
object-fit:cover;border-radius:6px;
|
||||
background:var(--surface2);flex-shrink:0;
|
||||
}
|
||||
.sli-body{flex:1;min-width:0}
|
||||
.sli-title{
|
||||
display:block;font-size:17px;font-weight:600;
|
||||
line-height:1.3;color:var(--text);
|
||||
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
|
||||
}
|
||||
.sli-meta{
|
||||
display:block;font-size:13px;color:var(--muted);
|
||||
margin-top:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
|
||||
}
|
||||
.sli-stars{display:flex;align-items:center;gap:5px;margin-top:6px}
|
||||
.sli-star-full{color:#f5a623;font-size:15px}
|
||||
.sli-star-half{color:#f5a623;font-size:13px}
|
||||
.sli-star-empty{color:rgba(255,255,255,.2);font-size:15px}
|
||||
.sli-rating{font-size:15px;font-weight:700;color:#f5a623}
|
||||
.sli-overview{
|
||||
display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;
|
||||
font-size:13px;color:var(--muted);line-height:1.55;margin-top:6px;
|
||||
}
|
||||
/* loading / empty */
|
||||
.srch-searching{
|
||||
display:flex;flex-direction:column;align-items:center;
|
||||
padding-top:72px;gap:14px;
|
||||
}
|
||||
.srch-searching-spinner{
|
||||
width:30px;height:30px;border-radius:50%;
|
||||
border:3px solid rgba(232,201,122,.15);
|
||||
border-top-color:var(--accent);
|
||||
animation:srch-spin .7s linear infinite;
|
||||
}
|
||||
@keyframes srch-spin{to{transform:rotate(360deg)}}
|
||||
.srch-searching-text{font-size:15px;color:var(--muted)}
|
||||
.srch-empty{
|
||||
display:flex;flex-direction:column;align-items:center;
|
||||
padding-top:72px;gap:10px;color:var(--muted);font-size:16px;text-align:center;
|
||||
}
|
||||
.srch-empty-ico{font-size:38px;line-height:1;margin-bottom:4px}
|
||||
|
||||
/* ── 搜索历史 & 联想 ── */
|
||||
.srch-section-hd{
|
||||
display:flex;align-items:center;justify-content:space-between;
|
||||
padding:18px 18px 8px;
|
||||
}
|
||||
.srch-section-hd .srch-label{padding:0;margin:0}
|
||||
.srch-clear-btn{
|
||||
font-family:var(--mono);font-size:11px;color:var(--muted);
|
||||
background:none;border:none;cursor:pointer;letter-spacing:.06em;
|
||||
padding:3px 6px;border-radius:3px;transition:color .12s,background .12s;
|
||||
}
|
||||
.srch-clear-btn:hover{color:var(--red);background:rgba(255,69,58,.08)}
|
||||
/* 历史 tag 行 */
|
||||
.srch-hist-tags{
|
||||
display:flex;flex-wrap:wrap;gap:7px;
|
||||
padding:0 18px 4px;
|
||||
}
|
||||
.srch-hist-tag{
|
||||
display:inline-flex;align-items:center;gap:5px;
|
||||
padding:5px 11px 5px 12px;
|
||||
background:rgba(255,255,255,.06);
|
||||
border:1px solid rgba(255,255,255,.1);
|
||||
border-radius:99px;cursor:pointer;
|
||||
font-size:14px;color:var(--text);
|
||||
transition:background .12s,border-color .12s,transform .1s;
|
||||
max-width:180px;
|
||||
}
|
||||
.srch-hist-tag:hover{background:rgba(255,255,255,.11);border-color:rgba(255,255,255,.22);transform:translateY(-1px)}
|
||||
.srch-hist-tag:active{transform:scale(.95)}
|
||||
.srch-hist-tag-x{
|
||||
color:var(--muted);font-size:12px;line-height:1;flex-shrink:0;
|
||||
padding:1px;border-radius:50%;transition:color .1s,background .1s;
|
||||
}
|
||||
.srch-hist-tag:hover .srch-hist-tag-x{color:var(--red)}
|
||||
/* 联想下拉列表 */
|
||||
.srch-suggest-list{
|
||||
list-style:none;padding:6px 0;overflow-y:auto;
|
||||
flex:1;
|
||||
}
|
||||
.srch-suggest-item{
|
||||
display:flex;align-items:center;gap:10px;
|
||||
padding:10px 18px;cursor:pointer;
|
||||
transition:background .1s;
|
||||
}
|
||||
.srch-suggest-item:hover,.srch-suggest-item.active{
|
||||
background:rgba(255,255,255,.06);
|
||||
}
|
||||
.srch-suggest-item svg{width:15px;height:15px;flex-shrink:0;opacity:.35}
|
||||
.srch-suggest-item .sg-kw{font-size:16px;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.srch-suggest-item .sg-kw em{color:var(--accent);font-style:normal}
|
||||
.srch-suggest-item .sg-fill{
|
||||
font-size:12px;color:var(--muted);flex-shrink:0;
|
||||
opacity:0;transition:opacity .12s;
|
||||
font-family:var(--mono);
|
||||
}
|
||||
.srch-suggest-item:hover .sg-fill{opacity:.6}
|
||||
|
||||
/* ── DETAIL PANEL ── */
|
||||
#det{
|
||||
position:fixed;inset:0;z-index:150;
|
||||
background:#06111b;
|
||||
overflow-y:scroll; /* 强制scroll而非auto,避免部分WebView失效 */
|
||||
overflow-x:hidden;
|
||||
-webkit-overflow-scrolling:touch; /* iOS惯性滚动 */
|
||||
transform:translateX(100%);
|
||||
transition:transform .26s cubic-bezier(.4,0,.2,1);
|
||||
}
|
||||
#det.on{transform:translateX(0)}
|
||||
|
||||
|
||||
|
||||
/* ── DETAIL INNER ── */
|
||||
#detInner{
|
||||
position:relative;
|
||||
/* 不设min-height,让内容自然撑高以支持滚动 */
|
||||
}
|
||||
|
||||
/* ── IMMERSIVE DETAIL HERO ── */
|
||||
/* 背景图固定:相对 #det 容器(有 transform)定位,不随内容滚动 */
|
||||
.det-hero{
|
||||
position:fixed;top:0;left:0;right:0;
|
||||
height:100vh;height:100dvh;
|
||||
z-index:0;
|
||||
pointer-events:none;overflow:hidden;
|
||||
}
|
||||
.det-hero img,
|
||||
.det-hero-img{
|
||||
position:absolute;top:0;left:0;
|
||||
width:100%;height:100%;object-fit:cover;object-position:center top;
|
||||
filter:saturate(1.05) brightness(1.1);
|
||||
opacity:0;transition:opacity 1.2s ease;
|
||||
}
|
||||
.det-hero-img.active{opacity:1}
|
||||
/* 手机竖屏:全屏渐变遮罩 — 顶部更透明,让图片透出 */
|
||||
.det-hero::after{
|
||||
content:'';position:absolute;inset:0;
|
||||
background:
|
||||
linear-gradient(180deg,rgba(6,17,27,.1) 0%,rgba(6,17,27,.45) 40%,rgba(6,17,27,.92) 100%),
|
||||
linear-gradient(90deg,rgba(6,17,27,.6) 0%,rgba(6,17,27,.05) 100%);
|
||||
}
|
||||
/* 宽屏桌面(≥1100):左侧深色文字区,右侧图片大量透出 */
|
||||
@media(min-width:1100px){
|
||||
.det-hero::after{
|
||||
background:
|
||||
linear-gradient(90deg,rgba(6,17,27,.95) 0%,rgba(6,17,27,.88) 28%,rgba(6,17,27,.55) 48%,rgba(6,17,27,.08) 68%,transparent 100%),
|
||||
linear-gradient(180deg,rgba(6,17,27,.0) 0%,rgba(6,17,27,.1) 60%,rgba(6,17,27,.5) 100%);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── DETAIL INNER: 自适应布局 ── */
|
||||
.det-inner{
|
||||
position:relative;z-index:2;
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
min-height:100vh;
|
||||
min-height:100dvh;
|
||||
width:100%;
|
||||
padding:calc(var(--safe-t) + 20px) 18px 48px;
|
||||
box-sizing:border-box;
|
||||
}
|
||||
|
||||
/* 平板 / 中等宽度(700-1100):单列铺满全宽,信息不被截断 */
|
||||
@media(min-width:700px){
|
||||
.det-inner{
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
width:100%;
|
||||
max-width:100%;
|
||||
padding:calc(var(--safe-t) + 36px) clamp(34px,5vw,74px) 64px clamp(34px,5vw,74px);
|
||||
}
|
||||
}
|
||||
|
||||
/* 宽屏桌面(≥1100):双列,左侧信息 + 右侧留给背景图 */
|
||||
@media(min-width:1100px){
|
||||
.det-inner{
|
||||
display:grid;
|
||||
grid-template-columns:minmax(0,640px) 1fr;
|
||||
column-gap:0;
|
||||
align-items:start;
|
||||
padding-left:clamp(50px,6vw,98px);
|
||||
padding-right:0;
|
||||
}
|
||||
.det-inner > *{
|
||||
grid-column:1;
|
||||
min-width:0; /* 防止grid子项溢出,保证overflow-x:auto生效 */
|
||||
}
|
||||
}
|
||||
@media(min-width:1500px){
|
||||
.det-inner{
|
||||
grid-template-columns:minmax(0,760px) 1fr;
|
||||
padding-left:clamp(66px,7vw,122px);
|
||||
}
|
||||
}
|
||||
|
||||
/* 横屏手机(短屏宽屏):内容铺满全宽单列,不做双列 */
|
||||
@media(max-height:500px) and (min-width:600px){
|
||||
.det-inner{
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
max-width:100%;
|
||||
padding:calc(var(--safe-t) + 12px) 20px 32px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── POSTER ROW ── */
|
||||
.det-poster-row{
|
||||
display:flex;gap:16px;align-items:flex-end;
|
||||
margin-bottom:18px;
|
||||
}
|
||||
/* 手机竖屏:顶部留出背景图空间 + 返回按钮空间 */
|
||||
@media(max-width:699px){
|
||||
.det-poster-row{margin-top:clamp(142px,45vw,262px)}
|
||||
}
|
||||
/* 平板单列(700-1100):留中等顶部空间露出背景图 */
|
||||
@media(min-width:700px) and (max-width:1099px){
|
||||
.det-poster-row{margin-top:0;padding-top:clamp(180px,34vh,360px)}
|
||||
}
|
||||
/* 宽屏桌面双列:不需要顶部留白,从 padding 开始 */
|
||||
@media(min-width:1100px){
|
||||
.det-poster-row{margin-top:0;padding-top:max(20px, 8vh)}
|
||||
}
|
||||
|
||||
|
||||
.det-title-block{flex:1;min-width:0;padding-bottom:4px}
|
||||
.det-eyebrow{
|
||||
font-family:var(--mono);font-size:11px;letter-spacing:.2em;
|
||||
text-transform:uppercase;color:var(--accent2);margin-bottom:6px;
|
||||
display:flex;align-items:center;gap:6px;
|
||||
}
|
||||
.det-eyebrow::before{content:'';display:inline-block;width:14px;height:1px;background:var(--accent2)}
|
||||
.det-logo-wrap{margin-bottom:6px;min-height:48px;display:flex;align-items:flex-end}
|
||||
.det-logo-img{
|
||||
max-width:min(380px,80vw);max-height:clamp(82px,16vw,162px);
|
||||
width:auto;height:auto;object-fit:contain;object-position:left bottom;
|
||||
filter:drop-shadow(0 4px 20px rgba(0,0,0,.8)) drop-shadow(0 1px 4px rgba(0,0,0,.6));
|
||||
display:block;
|
||||
}
|
||||
.det-title-fallback{/* fallback text title, shown until logo loads */}
|
||||
.det-title{
|
||||
font-size:clamp(24px,5vw,50px);font-weight:900;line-height:1.08;
|
||||
margin-bottom:6px;letter-spacing:-.01em;
|
||||
text-shadow:0 3px 16px rgba(0,0,0,.6);
|
||||
}
|
||||
.det-orig-title{
|
||||
font-family:var(--mono);font-size:13px;color:rgba(240,237,232,.4);
|
||||
margin-bottom:10px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
|
||||
}
|
||||
.det-score-row{
|
||||
display:flex;align-items:baseline;gap:5px;margin-bottom:4px;
|
||||
}
|
||||
.det-score{
|
||||
font-family:var(--mono);font-size:24px;font-weight:700;color:var(--accent);
|
||||
text-shadow:0 2px 8px rgba(232,201,122,.3);
|
||||
}
|
||||
.det-score-max{
|
||||
font-family:var(--mono);font-size:13px;color:rgba(240,237,232,.35);
|
||||
}
|
||||
.det-votes{font-family:var(--mono);font-size:11px;color:var(--muted);margin-left:4px}
|
||||
|
||||
/* meta pills */
|
||||
.det-pills{display:flex;flex-wrap:wrap;gap:6px;margin:12px 0 18px}
|
||||
.det-pill{
|
||||
padding:4px 10px;border-radius:999px;
|
||||
color:var(--text);background:rgba(255,255,255,.1);
|
||||
border:1px solid rgba(255,255,255,.22);
|
||||
font-family:var(--mono);font-size:13px;line-height:1.3;
|
||||
}
|
||||
.det-pill.strong{
|
||||
color:#fff;border-color:rgba(255,255,255,.4);
|
||||
background:rgba(255,255,255,.18);font-weight:700;
|
||||
}
|
||||
|
||||
/* action buttons */
|
||||
.det-actions{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:20px;align-items:center}
|
||||
.btn-p{
|
||||
display:inline-flex;align-items:center;gap:7px;
|
||||
background:var(--accent);color:#000;
|
||||
font-family:var(--mono);font-size:13px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;
|
||||
border:none;cursor:pointer;padding:11px 20px;border-radius:999px;
|
||||
transition:background .12s,transform .1s,box-shadow .12s;
|
||||
box-shadow:0 4px 20px rgba(232,201,122,.3);
|
||||
}
|
||||
.btn-p:hover{background:#f0d890;transform:translateY(-1px);box-shadow:0 8px 28px rgba(232,201,122,.4)}
|
||||
.btn-p:active{transform:scale(.95)}
|
||||
.btn-s{
|
||||
display:inline-flex;align-items:center;gap:7px;
|
||||
background:rgba(255,255,255,.1);color:rgba(240,246,252,.92);
|
||||
font-family:var(--mono);font-size:13px;letter-spacing:.06em;text-transform:uppercase;
|
||||
border:1px solid rgba(255,255,255,.22);cursor:pointer;padding:11px 18px;border-radius:999px;
|
||||
backdrop-filter:blur(16px);
|
||||
transition:background .12s,border-color .12s,transform .1s;
|
||||
box-shadow:0 1px 0 rgba(255,255,255,.18) inset,0 4px 16px rgba(0,0,0,.28);
|
||||
}
|
||||
.btn-s:hover{background:rgba(255,255,255,.17);border-color:rgba(255,255,255,.35);transform:translateY(-1px)}
|
||||
.btn-s:active{transform:scale(.95)}
|
||||
|
||||
/* overview */
|
||||
.det-overview{
|
||||
font-size:16px;line-height:1.75;color:rgba(240,237,232,.75);
|
||||
margin-bottom:24px;
|
||||
}
|
||||
|
||||
/* section label */
|
||||
.det-sec-label{
|
||||
font-family:var(--mono);font-size:11px;letter-spacing:.22em;
|
||||
text-transform:uppercase;color:var(--muted);
|
||||
margin:22px 0 10px;
|
||||
display:flex;align-items:center;gap:10px;
|
||||
}
|
||||
.det-sec-label::after{content:'';flex:1;height:1px;background:rgba(255,255,255,.07)}
|
||||
|
||||
/* cast — 始终单行横向滚动,不换行 */
|
||||
#detCast{min-width:0;width:100%;}
|
||||
.det-cast-rail{
|
||||
display:flex;gap:12px;overflow-x:auto;overflow-y:hidden;
|
||||
padding:2px 0 10px;scrollbar-width:none;margin-bottom:8px;
|
||||
flex-wrap:nowrap;
|
||||
-webkit-overflow-scrolling:touch;
|
||||
width:100%;
|
||||
}
|
||||
.det-cast-rail::-webkit-scrollbar{display:none}
|
||||
|
||||
.det-cast-card{
|
||||
flex:0 0 72px;width:72px;
|
||||
display:flex;flex-direction:column;align-items:center;
|
||||
cursor:pointer;transition:transform .14s;
|
||||
}
|
||||
.det-cast-card:hover{transform:scale(1.07)}
|
||||
|
||||
/* 圆形头像 */
|
||||
.det-cast-img{
|
||||
width:72px;height:72px;border-radius:50%;
|
||||
object-fit:cover;object-position:center top;
|
||||
background:var(--surface2);
|
||||
border:2px solid rgba(255,255,255,.14);
|
||||
display:block;flex-shrink:0;
|
||||
}
|
||||
.det-cast-name{
|
||||
font-size:12px;font-weight:700;
|
||||
padding:5px 2px 2px;
|
||||
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
|
||||
text-align:center;width:80px;
|
||||
}
|
||||
.det-cast-char{
|
||||
font-family:var(--mono);font-size:11px;color:var(--muted);
|
||||
padding:0 2px 6px;
|
||||
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
|
||||
text-align:center;width:80px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* similar / recs */
|
||||
.det-recs-label{
|
||||
font-family:var(--mono);font-size:11px;letter-spacing:.22em;
|
||||
text-transform:uppercase;color:var(--muted);
|
||||
margin:20px 0 8px;
|
||||
display:flex;align-items:center;gap:10px;
|
||||
}
|
||||
.det-recs-label::after{content:'';flex:1;height:1px;background:rgba(255,255,255,.07)}
|
||||
|
||||
/*
|
||||
推荐区:单行横向滚动,卡片宽度由 JS 按首页卡片像素宽固定
|
||||
*/
|
||||
#detRecs{
|
||||
min-width:0; /* grid子项防止溢出撑开 */
|
||||
width:100%;
|
||||
}
|
||||
#detRecs .det-recs-grid{
|
||||
display:flex;
|
||||
flex-wrap:nowrap;
|
||||
overflow-x:auto;
|
||||
overflow-y:hidden;
|
||||
gap:6px;
|
||||
padding-bottom:8px;
|
||||
scrollbar-width:none;
|
||||
-webkit-overflow-scrolling:touch;
|
||||
width:100%;
|
||||
box-sizing:border-box;
|
||||
}
|
||||
#detRecs .det-recs-grid::-webkit-scrollbar{display:none}
|
||||
/* 卡片宽度由 JS 写入 --card-w 变量 */
|
||||
#detRecs .det-recs-grid .card{
|
||||
flex:0 0 var(--card-w, 100px);
|
||||
width:var(--card-w, 100px);
|
||||
min-width:0;
|
||||
}
|
||||
|
||||
/* ── PAN SEARCH ── */
|
||||
.pan-block{display:none;margin-top:18px}
|
||||
.pan-block.on{display:block}
|
||||
.pan-head{
|
||||
display:flex;align-items:center;gap:10px;margin-bottom:8px;
|
||||
}
|
||||
.pan-head-title{font-family:var(--mono);font-size:12px;letter-spacing:.15em;text-transform:uppercase;color:var(--muted)}
|
||||
.pan-hint{font-family:var(--mono);font-size:12px;color:var(--muted);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.pan-tabs-row{display:flex;gap:6px;flex-wrap:wrap;padding:3px 0 8px;overflow-x:auto;scrollbar-width:none}
|
||||
.pan-tabs-row::-webkit-scrollbar{display:none}
|
||||
.pan-chip{
|
||||
flex:0 0 auto;padding:4px 11px;border-radius:99px;
|
||||
font-family:var(--mono);font-size:12px;font-weight:700;
|
||||
background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.1);
|
||||
color:#fff;cursor:pointer;transition:all .12s;
|
||||
}
|
||||
.pan-chip.on{background:rgba(232,201,122,.12);border-color:var(--accent);color:var(--accent)}
|
||||
.pan-chip:hover{color:#fff;opacity:.8}
|
||||
.pan-list{
|
||||
display:grid;gap:8px;
|
||||
max-height:50vh;min-height:200px;
|
||||
overflow-y:auto;scrollbar-width:none;
|
||||
padding-right:2px;
|
||||
}
|
||||
.pan-list::-webkit-scrollbar{display:none}
|
||||
.pan-item{
|
||||
width:100%;text-align:left;
|
||||
display:grid;gap:5px;padding:10px 12px;
|
||||
background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.07);
|
||||
border-radius:var(--r);cursor:pointer;transition:background .12s,border-color .12s;
|
||||
}
|
||||
.pan-item:hover,.pan-item:focus{background:rgba(255,255,255,.08);border-color:rgba(255,255,255,.18);outline:none}
|
||||
.pan-item.bad{opacity:.5}
|
||||
.pan-item-title{
|
||||
display:flex;align-items:center;gap:7px;
|
||||
font-size:14px;font-weight:700;line-height:1.3;
|
||||
overflow:hidden;color:#fff;
|
||||
}
|
||||
.pan-item-title span:first-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}
|
||||
.pan-health{
|
||||
flex:0 0 auto;width:.5rem;height:.5rem;border-radius:50%;
|
||||
background:#64748b;box-shadow:0 0 0 1px rgba(255,255,255,.15);
|
||||
}
|
||||
.pan-health.ok{background:#22c55e}
|
||||
.pan-health.bad{background:#ef4444}
|
||||
.pan-health.locked{background:#f59e0b}
|
||||
.pan-health.pending{background:#60a5fa;animation:ph-pulse 1.1s ease-in-out infinite}
|
||||
@keyframes ph-pulse{0%,100%{opacity:.45;transform:scale(.9)}50%{opacity:1;transform:scale(1.1)}}
|
||||
.pan-item-meta{font-family:var(--mono);font-size:11px;color:var(--muted)}
|
||||
.pan-item-url{font-family:var(--mono);font-size:11px;color:rgba(240,237,232,.35);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.pan-empty{font-family:var(--mono);font-size:13px;color:var(--muted);padding:20px 0;text-align:center}
|
||||
|
||||
/* ── CONNECTION PANEL FIELDS ── */
|
||||
.conn-field{
|
||||
width:100%;background:rgba(255,255,255,.05);
|
||||
border:1px solid rgba(255,255,255,.1);
|
||||
border-radius:var(--r);color:var(--text);
|
||||
font-family:var(--mono);font-size:13px;
|
||||
padding:8px 10px;outline:none;
|
||||
transition:border-color .12s;resize:none;
|
||||
}
|
||||
.conn-field:focus{border-color:var(--accent)}
|
||||
.conn-field::placeholder{color:rgba(240,237,232,.3)}
|
||||
.conn-label{
|
||||
display:grid;gap:4px;font-family:var(--mono);font-size:11px;
|
||||
letter-spacing:.12em;text-transform:uppercase;color:var(--muted);
|
||||
}
|
||||
.conn-disk-grid{
|
||||
display:grid;grid-template-columns:repeat(auto-fill,minmax(56px,1fr));gap:6px;
|
||||
margin:4px 0;
|
||||
}
|
||||
.conn-disk-label{
|
||||
display:flex;align-items:center;gap:5px;
|
||||
font-family:var(--mono);font-size:12px;color:var(--muted);cursor:pointer;
|
||||
padding:5px 6px;border-radius:3px;border:1px solid rgba(255,255,255,.07);
|
||||
transition:border-color .12s;
|
||||
}
|
||||
.conn-disk-label:hover{border-color:rgba(255,255,255,.2);color:var(--text)}
|
||||
.conn-disk-label input{width:12px;height:12px;accent-color:var(--accent);cursor:pointer}
|
||||
.conn-sep{height:1px;background:rgba(255,255,255,.07);margin:10px 0}
|
||||
|
||||
/* ── TOAST ── */
|
||||
#toast{
|
||||
position:fixed;bottom:calc(28px + var(--safe-b));left:50%;
|
||||
transform:translateX(-50%) translateY(16px);
|
||||
background:var(--surface2);border:1px solid rgba(255,255,255,.1);
|
||||
color:var(--text);font-family:var(--mono);font-size:13px;
|
||||
padding:7px 16px;border-radius:2px;z-index:999;
|
||||
opacity:0;transition:opacity .2s,transform .2s;pointer-events:none;white-space:nowrap;
|
||||
}
|
||||
#toast.on{opacity:1;transform:translateX(-50%) translateY(0)}
|
||||
|
||||
/* ── LOADER ── */
|
||||
#ldr{
|
||||
position:fixed;inset:0;z-index:500;background:var(--bg);
|
||||
display:flex;align-items:center;justify-content:center;flex-direction:column;gap:14px;
|
||||
transition:opacity .5s;
|
||||
}
|
||||
#ldr.done{opacity:0;pointer-events:none}
|
||||
html.fm-native #ldr{ background:rgba(8,8,8,.4); }
|
||||
.ldr-logo{font-family:var(--mono);font-size:20px;font-weight:700;letter-spacing:.3em;color:var(--accent)}
|
||||
.ldr-bar{width:100px;height:2px;background:var(--surface2);border-radius:2px;overflow:hidden}
|
||||
.ldr-fill{
|
||||
height:100%;background:linear-gradient(90deg,var(--accent2),var(--accent));
|
||||
width:0%;animation:lf 1.4s ease forwards;
|
||||
}
|
||||
@keyframes lf{to{width:100%}}
|
||||
|
||||
/* ── MISC ── */
|
||||
.accent-line{
|
||||
height:2px;width:100%;
|
||||
background:linear-gradient(90deg,var(--accent2),var(--accent),transparent);
|
||||
}
|
||||
|
||||
/* ── STATUS DOT ── */
|
||||
.dot{width:8px;height:8px;border-radius:50%;background:var(--muted);flex-shrink:0}
|
||||
.dot.ok{background:#3ddc84}
|
||||
.dot.warn{background:#ffcc4d}
|
||||
.dot.bad{background:#ff453a}
|
||||
|
||||
/* ── CONNECTION DOCK ── */
|
||||
.conn-dock{position:relative;z-index:200;flex-shrink:0}
|
||||
.conn-toggle{
|
||||
display:flex;align-items:center;gap:6px;
|
||||
height:var(--nav-h);padding:0 14px;
|
||||
background:none;border:none;cursor:pointer;
|
||||
color:var(--muted);font-family:var(--mono);font-size:13px;
|
||||
letter-spacing:.1em;white-space:nowrap;
|
||||
transition:color .12s;
|
||||
}
|
||||
.conn-toggle:hover,.conn-toggle:focus{color:var(--text);outline:none}
|
||||
.conn-arrow{font-size:15px;transition:transform .16s;opacity:.6}
|
||||
.conn-dock.open .conn-arrow{transform:rotate(180deg)}
|
||||
.conn-body{
|
||||
display:none;position:absolute;right:0;top:calc(100% + 4px);
|
||||
width:min(calc(100vw - 28px),380px);
|
||||
background:rgba(17,17,17,.96);
|
||||
border:1px solid rgba(255,255,255,.09);
|
||||
border-radius:var(--r);
|
||||
box-shadow:0 16px 48px rgba(0,0,0,.5);
|
||||
backdrop-filter:blur(20px);
|
||||
padding:14px;z-index:300;
|
||||
transform:translateX(var(--conn-shift,0px));
|
||||
max-height:80vh;overflow-y:auto;scrollbar-width:none;
|
||||
}
|
||||
.conn-body::-webkit-scrollbar{display:none}
|
||||
.conn-body.open{display:block}
|
||||
.conn-grid{display:grid;gap:8px;margin-bottom:12px}
|
||||
.conn-item{
|
||||
display:grid;grid-template-columns:minmax(52px,70px) 1fr;
|
||||
gap:8px;align-items:baseline;font-size:13px;line-height:1.45;
|
||||
}
|
||||
.conn-item b{color:var(--muted);font-family:var(--mono);font-weight:700;font-size:12px}
|
||||
.conn-item span{word-break:break-word;color:var(--text);font-family:var(--mono);font-size:12px}
|
||||
.conn-actions{display:flex;gap:6px;flex-wrap:wrap;margin-top:10px;padding-top:10px;border-top:1px solid rgba(255,255,255,.06)}
|
||||
.conn-btn{
|
||||
font-family:var(--mono);font-size:13px;letter-spacing:.08em;
|
||||
padding:6px 12px;background:var(--surface);color:var(--text);
|
||||
border:1px solid rgba(255,255,255,.1);border-radius:var(--r);
|
||||
cursor:pointer;transition:border-color .12s,background .12s;
|
||||
}
|
||||
.conn-btn:hover{border-color:rgba(255,255,255,.3);background:var(--surface2)}
|
||||
.conn-section-title{
|
||||
font-family:var(--mono);font-size:11px;letter-spacing:.2em;text-transform:uppercase;
|
||||
color:var(--muted);margin-bottom:6px;margin-top:10px;
|
||||
}
|
||||
|
||||
/* ── MIRROR CONFIG (官方 demo 移植) ── */
|
||||
.conn-mirror-config {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid rgba(255,255,255,.12);
|
||||
}
|
||||
.conn-mirror-config label {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing:.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.conn-mirror-config .conn-field {
|
||||
width: 100%;
|
||||
min-height: 36px;
|
||||
padding: 8px 10px;
|
||||
font-size: 11px;
|
||||
font-family: var(--mono);
|
||||
}
|
||||
.conn-mirror-config textarea.conn-field {
|
||||
min-height: 64px;
|
||||
resize: vertical;
|
||||
}
|
||||
.mirror-progress {
|
||||
min-height: 52px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid rgba(255,255,255,.12);
|
||||
border-radius: var(--r);
|
||||
color: var(--text);
|
||||
background: rgba(0,0,0,.2);
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-line;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════
|
||||
TV / 遥控器 专属样式
|
||||
仅在 body.tv-mode 下生效,不影响手机/桌面
|
||||
══════════════════════════════════════════ */
|
||||
body.tv-mode *:focus{ outline:none }
|
||||
|
||||
/* 统一焦点环:金色描边 + 轻微放大 */
|
||||
body.tv-mode .tv-focusable:focus,
|
||||
body.tv-mode .tv-focusable.tv-focus{
|
||||
outline: 2.5px solid var(--accent) !important;
|
||||
outline-offset: 2px;
|
||||
box-shadow: 0 0 0 4px rgba(232,201,122,.22) !important;
|
||||
z-index: 10;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 卡片焦点:放大 + 金色边框 */
|
||||
body.tv-mode .card.tv-focus{
|
||||
transform: scale(1.10) !important;
|
||||
outline: 2.5px solid var(--accent) !important;
|
||||
box-shadow: 0 0 0 4px rgba(232,201,122,.25), 0 8px 32px rgba(0,0,0,.6) !important;
|
||||
z-index: 20;
|
||||
}
|
||||
body.tv-mode .card.tv-focus .card-overlay{ opacity:1 }
|
||||
|
||||
/* 导航 tab 焦点 */
|
||||
body.tv-mode .ntab.tv-focus{
|
||||
color: var(--accent) !important;
|
||||
background: rgba(232,201,122,.18) !important;
|
||||
outline: 2px solid var(--accent) !important;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* 搜索结果条目焦点 */
|
||||
body.tv-mode .sli.tv-focus{
|
||||
background: rgba(255,255,255,.14) !important;
|
||||
outline: 2px solid var(--accent) !important;
|
||||
}
|
||||
|
||||
/* 联想列表焦点 */
|
||||
body.tv-mode .srch-suggest-item.tv-focus,
|
||||
body.tv-mode .srch-hot li.tv-focus{
|
||||
background: rgba(255,255,255,.1) !important;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* Hero 焦点 */
|
||||
body.tv-mode .hero-play-btn.tv-focus{
|
||||
background: #f5de8a !important;
|
||||
transform: translateY(-2px) scale(1.05) !important;
|
||||
outline: 2px solid var(--accent) !important;
|
||||
}
|
||||
|
||||
/* 详情页按钮焦点 */
|
||||
body.tv-mode .btn-p.tv-focus,
|
||||
body.tv-mode .btn-s.tv-focus{
|
||||
outline: 2.5px solid var(--accent) !important;
|
||||
outline-offset: 2px;
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
|
||||
/* TV 游标指示器(屏幕右下角显示方向键提示,仅 TV 模式) */
|
||||
#tv-hint{
|
||||
display:none;
|
||||
position:fixed;bottom:calc(14px + var(--safe-b));right:18px;z-index:9999;
|
||||
font-family:var(--mono);font-size:11px;color:rgba(240,237,232,.35);
|
||||
pointer-events:none;letter-spacing:.05em;
|
||||
}
|
||||
body.tv-mode #tv-hint{ display:block }
|
||||
|
||||
/* ══════════════════════════════════════════
|
||||
TV 性能优化(参考开发文档 22.6 / 22.7)
|
||||
- 关闭高成本视觉效果,优先稳定帧率
|
||||
- 卡片自包含,缩小布局/样式影响范围
|
||||
- 焦点反馈不改尺寸/边框,避免布局抖动
|
||||
══════════════════════════════════════════ */
|
||||
|
||||
/* 1. TV 模式关闭大面积 blur / backdrop-filter / 复杂阴影 / 图片 filter */
|
||||
body.tv-mode #nav{ backdrop-filter:none; background:rgba(8,8,8,.98); }
|
||||
body.tv-mode #srch{ backdrop-filter:none; background:rgba(8,8,8,.99); }
|
||||
body.tv-mode .conn-body{ backdrop-filter:none; background:rgba(17,17,17,.99); }
|
||||
body.tv-mode #quickpan{ backdrop-filter:none; }
|
||||
body.tv-mode .card-rating{ backdrop-filter:none; }
|
||||
body.tv-mode .det-hero img{ filter:none; }
|
||||
body.tv-mode body::before{ display:none; } /* 关扫描线叠层 */
|
||||
|
||||
/* 2. 卡片 / 列表项自包含,减少重排范围 */
|
||||
body.tv-mode .card,
|
||||
body.tv-mode .sli,
|
||||
body.tv-mode .pan-item,
|
||||
body.tv-mode .det-cast-card,
|
||||
body.tv-mode .srch-suggest-item{
|
||||
contain: layout style;
|
||||
}
|
||||
|
||||
/* 3. 焦点反馈:用 outline + 轻微 transform,不触发布局抖动
|
||||
(覆盖上面用 box-shadow 的高成本写法,TV 下走更轻的样式)*/
|
||||
body.tv-mode .card.tv-focus{
|
||||
transform: scale(1.06);
|
||||
outline: 3px solid var(--accent);
|
||||
outline-offset: -1px;
|
||||
box-shadow: none !important;
|
||||
z-index: 20;
|
||||
}
|
||||
body.tv-mode .tv-focusable.tv-focus,
|
||||
body.tv-mode .tv-focusable:focus{
|
||||
box-shadow: none !important;
|
||||
outline: 2.5px solid var(--accent) !important;
|
||||
}
|
||||
body.tv-mode .btn-p.tv-focus,
|
||||
body.tv-mode .btn-s.tv-focus{
|
||||
filter: none;
|
||||
background: rgba(232,201,122,.2);
|
||||
}
|
||||
body.tv-mode .hero-play-btn.tv-focus{
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
|
||||
</style>
|
||||
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
</head>
|
||||
<body>
|
||||
<!-- Loader -->
|
||||
|
||||
<div id="tv-hint">↑↓←→ 导航 OK 确认 ← 返回</div>
|
||||
<!-- Nav -->
|
||||
<nav id="nav">
|
||||
<div class="ntabs" id="ntabs">
|
||||
<button class="ntab on" data-sec="s-new" onclick="scrollSec('s-new')"><svg class="ntab-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg><span class="ntab-lbl">最新上线</span></button>
|
||||
<button class="ntab" data-sec="s-movie" onclick="scrollSec('s-movie')"><svg class="ntab-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="20" rx="2.18" ry="2.18"/><line x1="7" y1="2" x2="7" y2="22"/><line x1="17" y1="2" x2="17" y2="22"/><line x1="2" y1="12" x2="22" y2="12"/><line x1="2" y1="7" x2="7" y2="7"/><line x1="2" y1="17" x2="7" y2="17"/><line x1="17" y1="17" x2="22" y2="17"/><line x1="17" y1="7" x2="22" y2="7"/></svg><span class="ntab-lbl">电影</span></button>
|
||||
<button class="ntab" data-sec="s-cn" onclick="scrollSec('s-cn')"><svg class="ntab-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg><span class="ntab-lbl">国产剧</span></button>
|
||||
<button class="ntab" data-sec="s-hk" onclick="scrollSec('s-hk')"><svg class="ntab-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg><span class="ntab-lbl">港台剧</span></button>
|
||||
<button class="ntab" data-sec="s-jpkr" onclick="scrollSec('s-jpkr')"><svg class="ntab-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 18v-6a9 9 0 0 1 18 0v6"/><path d="M21 19a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3zM3 19a2 2 0 0 0 2 2h1a2 2 0 0 0 2-2v-3a2 2 0 0 0-2-2H3z"/></svg><span class="ntab-lbl">日韩剧</span></button>
|
||||
<button class="ntab" data-sec="s-us" onclick="scrollSec('s-us')"><svg class="ntab-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="7" width="20" height="15" rx="2" ry="2"/><polyline points="17 2 12 7 7 2"/></svg><span class="ntab-lbl">欧美剧</span></button>
|
||||
<button class="ntab" data-sec="s-doc" onclick="scrollSec('s-doc')"><svg class="ntab-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg><span class="ntab-lbl">纪录片</span></button>
|
||||
<button class="ntab" data-sec="s-ani" onclick="scrollSec('s-ani')"><svg class="ntab-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg><span class="ntab-lbl">动漫</span></button>
|
||||
<button class="ntab" data-sec="s-var" onclick="scrollSec('s-var')"><svg class="ntab-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/><circle cx="12" cy="12" r="3" fill="currentColor" stroke="none"/></svg><span class="ntab-lbl">综艺</span></button>
|
||||
</div>
|
||||
<button aria-label="搜索" class="nbtn" onclick="openSrch()">
|
||||
<svg fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewbox="0 0 24 24">
|
||||
<circle cx="11" cy="11" r="8"></circle><line x1="21" x2="16.65" y1="21" y2="16.65"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- 状态按钮 -->
|
||||
<div class="conn-dock" id="connDock">
|
||||
<button aria-expanded="false" class="conn-toggle" id="connToggle" onclick="toggleConn(event)">
|
||||
<span class="dot warn" id="connDot"></span>
|
||||
<span>状态</span>
|
||||
<span class="conn-arrow">⌃</span>
|
||||
</button>
|
||||
<div class="conn-body" id="connBody">
|
||||
<!-- 连接状态 -->
|
||||
<div class="conn-section-title">连接状态</div>
|
||||
<div class="conn-grid">
|
||||
<div class="conn-item"><b>App SDK</b><span id="stSdk">检测中</span></div>
|
||||
<div class="conn-item"><b>TMDB</b><span id="stTmdb">等待请求</span></div>
|
||||
<div class="conn-item"><b>站点</b><span id="stSite">未配置</span></div>
|
||||
<div class="conn-item"><b>盘搜</b><span id="stPan">未搜索</span></div>
|
||||
</div>
|
||||
<div class="conn-sep"></div>
|
||||
<!-- TMDB 配置 -->
|
||||
<div class="conn-section-title">TMDB 配置</div>
|
||||
<label class="conn-label" style="margin-top:6px">TMDB API Key
|
||||
<input autocomplete="off" class="conn-field" id="tmdbKeyInp" placeholder="留空使用内置 Key" type="password"/>
|
||||
</label>
|
||||
<div class="conn-sep"></div>
|
||||
<!-- 盘搜配置 -->
|
||||
<div class="conn-section-title">盘搜配置</div>
|
||||
<label class="conn-label" style="margin-top:6px">盘搜地址
|
||||
<input class="conn-field" id="panBaseInp" placeholder="https://so.252035.xyz" type="url"/>
|
||||
</label>
|
||||
<label class="conn-label" style="margin-top:8px">账号(未启用认证可留空)
|
||||
<input autocomplete="username" class="conn-field" id="panUserInp" placeholder="用户名" type="text"/>
|
||||
</label>
|
||||
<label class="conn-label" style="margin-top:8px">密码
|
||||
<input autocomplete="current-password" class="conn-field" id="panPassInp" placeholder="密码" type="password"/>
|
||||
</label>
|
||||
<label class="conn-label" style="margin-top:8px">启用网盘类型
|
||||
<div class="conn-disk-grid" id="connDiskGrid"></div>
|
||||
</label>
|
||||
<div class="conn-sep"></div>
|
||||
<!-- 镜像 Relay 配置 (官方 demo 移植) -->
|
||||
<div class="conn-section-title">镜像 Relay</div>
|
||||
<div class="conn-mirror-config">
|
||||
<label>镜像源 Relays
|
||||
<textarea class="conn-field" id="mirrorSourceInput" rows="3" placeholder="每行一个源 relay" readonly></textarea>
|
||||
</label>
|
||||
<label>镜像目标 Relays
|
||||
<textarea class="conn-field" id="mirrorTargetInput" rows="2" placeholder="每行一个目标 relay,例如 wss://your-relay.example" readonly></textarea>
|
||||
</label>
|
||||
<div class="mirror-progress" id="mirrorProgress">镜像未执行</div>
|
||||
<button class="conn-btn" id="mirrorRelayBtn" onclick="mirrorHotEventsToRelays()">镜像榜单</button>
|
||||
</div>
|
||||
<div class="conn-sep"></div>
|
||||
<div class="conn-actions">
|
||||
<button class="conn-btn" onclick="saveConnConfig()">保存配置</button>
|
||||
<button class="conn-btn" onclick="setConnOpen(false)">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<!-- Main -->
|
||||
<main id="main">
|
||||
<div class="accent-line"></div>
|
||||
<!-- Search Result Section (inline, hidden by default) -->
|
||||
<section class="sec" id="s-search" style="display:none">
|
||||
<div class="sechd">
|
||||
<span class="sectitle" id="srchResTitle">搜索结果</span>
|
||||
<div class="secline"></div>
|
||||
<span class="seccnt" id="cnt-search"></span>
|
||||
<button onclick="closeSrchResult()" style="background:none;border:none;cursor:pointer;color:var(--muted);font-size:13px;padding:4px 8px;border-radius:4px;white-space:nowrap;flex-shrink:0">✕ 清除</button>
|
||||
</div>
|
||||
<div id="srch-list"></div>
|
||||
</section>
|
||||
<!-- Hero Carousel -->
|
||||
<div id="heroWrap">
|
||||
<div id="heroSlides"></div>
|
||||
<div id="heroDots"></div>
|
||||
<div id="heroProgress"></div>
|
||||
</div>
|
||||
<!-- Sections -->
|
||||
<section class="sec" id="s-new">
|
||||
<div class="sechd"><span class="sectitle">最新上线</span><div class="secline"></div><span class="seccnt" id="cnt-new"></span></div>
|
||||
<div class="grid" id="g-new"></div>
|
||||
</section>
|
||||
<section class="sec" id="s-movie">
|
||||
<div class="sechd"><span class="sectitle">电影</span><div class="secline"></div><span class="seccnt" id="cnt-movie"></span></div>
|
||||
<div class="grid" id="g-movie"></div>
|
||||
</section>
|
||||
<section class="sec" id="s-cn">
|
||||
<div class="sechd"><span class="sectitle">国产剧</span><div class="secline"></div><span class="seccnt" id="cnt-cn"></span></div>
|
||||
<div class="grid grid-land" id="g-cn"></div>
|
||||
</section>
|
||||
<section class="sec" id="s-hk">
|
||||
<div class="sechd"><span class="sectitle">港台剧</span><div class="secline"></div><span class="seccnt" id="cnt-hk"></span></div>
|
||||
<div class="grid grid-land" id="g-hk"></div>
|
||||
</section>
|
||||
<section class="sec" id="s-jpkr">
|
||||
<div class="sechd"><span class="sectitle">日韩剧</span><div class="secline"></div><span class="seccnt" id="cnt-jpkr"></span></div>
|
||||
<div class="grid grid-land" id="g-jpkr"></div>
|
||||
</section>
|
||||
<section class="sec" id="s-us">
|
||||
<div class="sechd"><span class="sectitle">欧美剧</span><div class="secline"></div><span class="seccnt" id="cnt-us"></span></div>
|
||||
<div class="grid grid-land" id="g-us"></div>
|
||||
</section>
|
||||
<section class="sec" id="s-doc">
|
||||
<div class="sechd"><span class="sectitle">纪录片</span><div class="secline"></div><span class="seccnt" id="cnt-doc"></span></div>
|
||||
<div class="grid" id="g-doc"></div>
|
||||
</section>
|
||||
<section class="sec" id="s-ani">
|
||||
<div class="sechd"><span class="sectitle">动漫</span><div class="secline"></div><span class="seccnt" id="cnt-ani"></span></div>
|
||||
<div class="grid" id="g-ani"></div>
|
||||
</section>
|
||||
<section class="sec" id="s-var">
|
||||
<div class="sechd"><span class="sectitle">综艺</span><div class="secline"></div><span class="seccnt" id="cnt-var"></span></div>
|
||||
<div class="grid grid-land" id="g-var"></div>
|
||||
</section>
|
||||
</main>
|
||||
<!-- Search -->
|
||||
<div aria-label="搜索" aria-modal="true" id="srch" role="dialog">
|
||||
<div class="srch-top">
|
||||
<div class="srch-wrap">
|
||||
<svg class="srch-ico" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24">
|
||||
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||
</svg>
|
||||
<input autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"
|
||||
class="srch-inp" id="srchInp"
|
||||
placeholder="搜索电影、剧集…"
|
||||
type="search"
|
||||
enterkeyhint="search"
|
||||
inputmode="search"
|
||||
oninput="onSrchInput(event)"
|
||||
onkeydown="onSrchKey(event)"/>
|
||||
<button class="srch-submit-btn" id="srchSubmit" onclick="submitSrch()" aria-label="搜索">
|
||||
<svg fill="none" width="15" height="15" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2.2" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<button class="srch-cancel" onclick="closeSrch()">取消</button>
|
||||
</div>
|
||||
<div id="srchBody">
|
||||
<div class="srch-label">热门搜索</div>
|
||||
<ul class="srch-hot" id="srchHot"></ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detail -->
|
||||
<div id="det">
|
||||
<div id="detInner"></div>
|
||||
</div>
|
||||
<!-- Toast -->
|
||||
<div id="toast"></div>
|
||||
<script>
|
||||
'use strict';
|
||||
|
||||
// ── CONFIG ──
|
||||
let TMDB_KEY = 'd7040155454e7fdf547c4d889ebbcca7';
|
||||
const TMDB = 'https://api.tmdb.org/3';
|
||||
const IMG_W500 = 'https://image.tmdb.org/t/p/w780'; // 升级到 w780
|
||||
const IMG_W780 = 'https://image.tmdb.org/t/p/w1280'; // 升级到 w1280
|
||||
const IMG_ORIG = 'https://image.tmdb.org/t/p/original';
|
||||
const LANG = 'zh-CN';
|
||||
|
||||
// ── SDK ──
|
||||
const IS_APP = !!window.fongmiBridge;
|
||||
const IS_TV = window.fongmiClient ? window.fongmiClient.isLeanback : false;
|
||||
// fm-native 已在 <head> 顶部同步打标;此处兜底(SDK 延迟注入时再确认一次)
|
||||
if((IS_APP || window.fongmiClient) && !document.documentElement.classList.contains('fm-native')){
|
||||
document.documentElement.classList.add('fm-native');
|
||||
}
|
||||
let siteKey = '';
|
||||
let heroData = null;
|
||||
let srchTimer = null;
|
||||
let detData = null;
|
||||
let _homeToolbarRestored = false; // 主页是否已退出全屏(控制返回行为)
|
||||
|
||||
let _nativeSdkWait = null;
|
||||
function waitForNativeSdk(timeout){
|
||||
if(window.fm || !window.fongmiBridge) return Promise.resolve(!!window.fm);
|
||||
if(_nativeSdkWait) return _nativeSdkWait;
|
||||
_nativeSdkWait = new Promise(resolve=>{
|
||||
let done = false;
|
||||
const finish = ()=>{
|
||||
if(done) return; done = true;
|
||||
window.removeEventListener('fmsdk', finish);
|
||||
_nativeSdkWait = null;
|
||||
resolve(!!window.fm);
|
||||
};
|
||||
window.addEventListener('fmsdk', finish);
|
||||
setTimeout(finish, timeout == null ? 1500 : timeout);
|
||||
});
|
||||
return _nativeSdkWait;
|
||||
}
|
||||
|
||||
function addPanPassword(url, password, opts){
|
||||
// 简单拼接提取码(浏览器 fallback 用)
|
||||
if(!url) return url;
|
||||
if(password && !/[\?&]pwd=/.test(url)){
|
||||
const sep = url.includes('?') ? '&' : '?';
|
||||
return url + sep + 'pwd=' + encodeURIComponent(password);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
function sdk(){
|
||||
if(window.fm){
|
||||
const pan = window.fm.pan || {};
|
||||
return Object.assign({}, window.fm, { pan, check: pan.check || window.fm.check });
|
||||
}
|
||||
// 浏览器 / SDK 未注入时的完整 fallback —— 永远可用
|
||||
const check = async (items)=>({ results: (items||[]).map(item=>({
|
||||
type: item.type, url: item.url, normalized_url: item.url,
|
||||
state:'idle', summary:'浏览器预览不检测', cache_hit:false,
|
||||
checked_at: Date.now(), expires_at: Date.now()+300000
|
||||
}))});
|
||||
// fallback pan.play:Native SDK 未注入时禁止播放,明确报错,不静默失败
|
||||
const play = async (payload)=>{
|
||||
console.error('[Eclipse] pan.play fallback triggered — Native SDK not injected', payload);
|
||||
throw new Error('Native SDK 未注入,无法调起原生播放器。请确认在 FongMi App 内打开此页面。');
|
||||
};
|
||||
return {
|
||||
req: async (url, opts)=>{ const r=await fetch(url); return {ok:r.ok, body:await r.json()}; },
|
||||
res: (url)=>url,
|
||||
play: async (url)=>{ throw new Error('Native SDK 未注入'); },
|
||||
search: async (kw, opts)=>toast('原生搜索'+(opts&&opts.direct?'(直接播放)':'')+':'+kw),
|
||||
openLive: async ()=>toast('请在 App 中打开直播'),
|
||||
openKeep: async ()=>toast('请在 App 中打开收藏'),
|
||||
history: async ()=>[],
|
||||
vod: async (siteKey, vodId, title)=>toast('原生播放:'+(title||vodId||siteKey||'')),
|
||||
pan: { check, play },
|
||||
check,
|
||||
ui: { setToolbar: async ()=>{} },
|
||||
cache: {
|
||||
get: async (key)=>localStorage.getItem('fm_'+key)||'',
|
||||
set: async (key,value)=>localStorage.setItem('fm_'+key,value),
|
||||
del: async (key)=>localStorage.removeItem('fm_'+key)
|
||||
},
|
||||
site: async ()=>({key:'browser',name:'浏览器预览'}),
|
||||
config: async ()=>({id:'browser',url:location.href,driveCheck:false}),
|
||||
device: async ()=>({type:1})
|
||||
};
|
||||
}
|
||||
|
||||
function sdkReady(cb){
|
||||
if(window.fm){ cb(); return; }
|
||||
if(!window.fongmiBridge){ cb(); return; }
|
||||
window.addEventListener('fmsdk', cb, {once:true});
|
||||
}
|
||||
|
||||
// ── TMDB FETCH ──
|
||||
async function tmdb(path, params={}){
|
||||
const u = new URL(TMDB+path);
|
||||
u.searchParams.set('api_key', TMDB_KEY);
|
||||
u.searchParams.set('language', LANG);
|
||||
for(const [k,v] of Object.entries(params)) u.searchParams.set(k,v);
|
||||
|
||||
// 优先走 sdk().req(Native OkHttp),绕开 WebView CORS 限制
|
||||
// 浏览器模式降级:直接 fetch(TMDB 支持浏览器跨域,api_key 模式允许 CORS)
|
||||
try{
|
||||
const r = await sdk().req(u.toString(), {responseType:'text', timeout:18});
|
||||
if(r && r.error) throw new Error(r.error);
|
||||
const body = r && r.body;
|
||||
const data = typeof body === 'string' ? JSON.parse(body || '{}') : (body || {});
|
||||
updateConnStatus('tmdb','成功');
|
||||
return data;
|
||||
}catch(e){
|
||||
// sdk().req 失败(CORS / 未注入)时,尝试直接 fetch
|
||||
try{
|
||||
const resp = await fetch(u.toString(), {
|
||||
headers:{ 'Accept':'application/json' }
|
||||
});
|
||||
if(!resp.ok) throw new Error('HTTP '+resp.status);
|
||||
const data = await resp.json();
|
||||
updateConnStatus('tmdb','成功');
|
||||
return data;
|
||||
}catch(e2){
|
||||
updateConnStatus('tmdb','请求失败');
|
||||
throw e2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── IMG URL ──
|
||||
function imgUrl(path, size=IMG_W500){
|
||||
if(!path) return '';
|
||||
return size + path;
|
||||
}
|
||||
function resUrl(path, size=IMG_W500){
|
||||
if(!path) return '';
|
||||
return size + path;
|
||||
}
|
||||
|
||||
// ── ESCAPE ──
|
||||
function esc(s){ return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,'''); }
|
||||
|
||||
// ── SKELETON ──
|
||||
function sks(n, land=false){
|
||||
return Array.from({length:n},()=>`<div class="sk sk-card${land?' land':''}"></div>`).join('');
|
||||
}
|
||||
|
||||
// ── COLOR FROM ID ──
|
||||
function hue(id){ return (id * 137) % 360; }
|
||||
|
||||
// ── HERO CAROUSEL ──
|
||||
let heroItems = [];
|
||||
let heroCurrent = 0;
|
||||
let heroTimer = null;
|
||||
const HERO_INTERVAL = 5000;
|
||||
|
||||
function buildHeroSlide(item, idx){
|
||||
const isTV = !!item.name;
|
||||
const title = item.title || item.name || '';
|
||||
const backdrop = item.backdrop_path;
|
||||
const overview = item.overview || '';
|
||||
const year = (item.release_date||item.first_air_date||'').slice(0,4);
|
||||
const rating = item.vote_average ? item.vote_average.toFixed(1) : '';
|
||||
const bgUrl = backdrop ? resUrl(backdrop, IMG_ORIG) : '';
|
||||
|
||||
return `<div class="hero-slide${idx===0?' active':''}" data-idx="${idx}" onclick="openDet(heroItems[${idx}])">
|
||||
<div class="hero-slide-bg" style="${bgUrl?`background-image:url(${bgUrl})`:''}"></div>
|
||||
<div class="hero-slide-grad"></div>
|
||||
<div class="hero-slide-content">
|
||||
<div class="hero-logo-wrap" id="heroLogoWrap${idx}">
|
||||
<div class="hero-title" id="heroTitle${idx}">${esc(title)}</div>
|
||||
</div>
|
||||
<div class="hero-meta">${[
|
||||
year ? `<span class="hm-item">${year}</span>` : '',
|
||||
rating ? `<span class="hm-item star">★ ${rating}</span>` : '',
|
||||
`<span class="hm-item">${isTV?'剧集':'电影'}</span>`
|
||||
].filter(Boolean).join('')}</div>
|
||||
<button class="hero-play-btn" onclick="event.stopPropagation();heroPlayItem(heroItems[${idx}])">
|
||||
<svg fill="currentColor" viewBox="0 0 24 24"><path d="M5 3l14 9-14 9V3z"/></svg>
|
||||
搜索播放
|
||||
</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
|
||||
// 异步加载轮播徽标(TMDB logo),成功则替换文字标题
|
||||
async function loadHeroLogos(items){
|
||||
items.forEach(async (item, idx)=>{
|
||||
try{
|
||||
const isTV = !!item.name;
|
||||
const endpoint = isTV ? `/tv/${item.id}/images` : `/movie/${item.id}/images`;
|
||||
const imgs = await tmdb(endpoint, {include_image_language:'zh,en,null'});
|
||||
const logos = (imgs.logos||[]).sort((a,b)=>(b.vote_average||0)-(a.vote_average||0));
|
||||
const logo = logos.find(l=>l.file_path&&l.iso_639_1==='zh')
|
||||
|| logos.find(l=>l.file_path&&l.iso_639_1==='en')
|
||||
|| logos[0];
|
||||
if(!logo || !logo.file_path) return;
|
||||
const wrap = document.getElementById('heroLogoWrap'+idx);
|
||||
const title = document.getElementById('heroTitle'+idx);
|
||||
if(!wrap) return;
|
||||
const img = document.createElement('img');
|
||||
img.className = 'hero-logo-img';
|
||||
img.alt = item.title || item.name || '';
|
||||
img.onload = ()=>{
|
||||
if(title) title.classList.add('has-logo');
|
||||
img.classList.add('loaded');
|
||||
};
|
||||
img.onerror = ()=>{ img.remove(); }; // 加载失败保留文字标题
|
||||
img.src = IMG_ORIG + logo.file_path;
|
||||
wrap.appendChild(img);
|
||||
}catch(e){ /* 静默失败,保留文字标题 */ }
|
||||
});
|
||||
}
|
||||
|
||||
function buildHeroDots(){
|
||||
const el = document.getElementById('heroDots');
|
||||
if(!el) return;
|
||||
el.innerHTML = heroItems.map((_,i)=>
|
||||
`<button class="hero-dot${i===heroCurrent?' active':''}" onclick="event.stopPropagation();goHero(${i})"></button>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
function goHero(idx){
|
||||
const slides = document.querySelectorAll('.hero-slide');
|
||||
const dots = document.querySelectorAll('.hero-dot');
|
||||
if(!slides.length) return;
|
||||
if(slides[heroCurrent])slides[heroCurrent].classList.remove('active');
|
||||
if(dots[heroCurrent])dots[heroCurrent].classList.remove('active');
|
||||
heroCurrent = (idx + heroItems.length) % heroItems.length;
|
||||
if(slides[heroCurrent])slides[heroCurrent].classList.add('active');
|
||||
if(dots[heroCurrent])dots[heroCurrent].classList.add('active');
|
||||
heroData = heroItems[heroCurrent];
|
||||
resetHeroProgress();
|
||||
}
|
||||
|
||||
function resetHeroProgress(){
|
||||
const bar = document.getElementById('heroProgress');
|
||||
if(!bar) return;
|
||||
bar.style.transition = 'none';
|
||||
bar.style.width = '0%';
|
||||
requestAnimationFrame(()=>{
|
||||
requestAnimationFrame(()=>{
|
||||
bar.style.transition = `width ${HERO_INTERVAL}ms linear`;
|
||||
bar.style.width = '100%';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function startHeroTimer(){
|
||||
clearInterval(heroTimer);
|
||||
heroTimer = setInterval(()=>goHero(heroCurrent+1), HERO_INTERVAL);
|
||||
resetHeroProgress();
|
||||
}
|
||||
|
||||
function initHeroCarousel(items){
|
||||
heroItems = items;
|
||||
heroData = items[0];
|
||||
heroCurrent = 0;
|
||||
const slidesEl = document.getElementById('heroSlides');
|
||||
if(slidesEl) slidesEl.innerHTML = items.map((item,i)=>buildHeroSlide(item,i)).join('');
|
||||
buildHeroDots();
|
||||
startHeroTimer();
|
||||
loadHeroLogos(items);
|
||||
// pause on hover
|
||||
const wrap = document.getElementById('heroWrap');
|
||||
if(wrap){
|
||||
wrap.addEventListener('mouseenter',()=>clearInterval(heroTimer));
|
||||
wrap.addEventListener('mouseleave',()=>startHeroTimer());
|
||||
}
|
||||
}
|
||||
|
||||
function heroClick(){ if(heroData) openDet(heroData); }
|
||||
function heroPlay(){ if(heroData) openDet(heroData); }
|
||||
async function heroPlayItem(item){
|
||||
if(!item) return;
|
||||
await openDet(item);
|
||||
// slight delay so detail panel is ready
|
||||
setTimeout(()=>detPlay(), 300);
|
||||
}
|
||||
|
||||
// ── CARD HTML ──
|
||||
function cardHtml(item, land=false){
|
||||
const isTV = !!item.name;
|
||||
const title = item.title || item.name || '';
|
||||
const year = (item.release_date||item.first_air_date||'').slice(0,4);
|
||||
const rating = item.vote_average ? item.vote_average.toFixed(1) : '';
|
||||
const poster = land ? item.backdrop_path : item.poster_path;
|
||||
const imgSrc = poster ? resUrl(poster, land ? IMG_W780 : IMG_W500) : '';
|
||||
const bg = `hsl(${hue(item.id)},18%,13%)`;
|
||||
|
||||
return `<div class="card" tabindex="0" role="button"
|
||||
data-id="${item.id}" data-tv="${isTV?1:0}"
|
||||
onclick="openDet(ITEMS[${item.id}])"
|
||||
onkeydown="if(event.key==='Enter')openDet(ITEMS[${item.id}])"
|
||||
oncontextmenu="event.preventDefault();lpLongPress(${item.id})">
|
||||
<div class="card-img${land?' land':''}" style="background:${bg}">
|
||||
${imgSrc?`<img src="${imgSrc}" alt="" loading="lazy" decoding="async" data-loading onload="this.classList.add('loaded');this.removeAttribute('data-loading')">`:``}
|
||||
<div class="card-overlay"></div>
|
||||
${rating?`<div class="card-rating">★${rating}</div>`:''}
|
||||
<div class="card-dot" id="dot-${item.id}"></div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="card-name">${esc(title)}</div>
|
||||
<div class="card-sub">${year}${isTV?' · 剧集':' · 电影'}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Global item registry
|
||||
const ITEMS = {};
|
||||
|
||||
function renderGrid(gridId, cntId, items, land=false){
|
||||
items.forEach(i=>ITEMS[i.id]=i);
|
||||
const g = document.getElementById(gridId);
|
||||
if(g) g.innerHTML = items.map(i=>cardHtml(i,land)).join('');
|
||||
const c = document.getElementById(cntId);
|
||||
if(c) c.textContent = items.length + ' 部';
|
||||
}
|
||||
|
||||
// ── LOAD ALL ──
|
||||
// 动态日期工具(参考 nostr 版逻辑)
|
||||
function relDate(offsetDays){
|
||||
const d = new Date();
|
||||
if(offsetDays) d.setDate(d.getDate() + offsetDays);
|
||||
return d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0')+'-'+String(d.getDate()).padStart(2,'0');
|
||||
}
|
||||
|
||||
async function loadAll(){
|
||||
// Skeletons
|
||||
document.getElementById('g-new').innerHTML = sks(24);
|
||||
document.getElementById('g-movie').innerHTML = sks(24);
|
||||
document.getElementById('g-cn').innerHTML = sks(20, true);
|
||||
document.getElementById('g-hk').innerHTML = sks(20, true);
|
||||
document.getElementById('g-jpkr').innerHTML = sks(20, true);
|
||||
document.getElementById('g-us').innerHTML = sks(20, true);
|
||||
document.getElementById('g-doc').innerHTML = sks(20);
|
||||
document.getElementById('g-ani').innerHTML = sks(20);
|
||||
document.getElementById('g-var').innerHTML = sks(20, true);
|
||||
|
||||
const get = r => (r.status==='fulfilled' && r.value && r.value.results) ? r.value.results : [];
|
||||
function merge(...arrays){
|
||||
const seen = new Set(); const out = [];
|
||||
for(const arr of arrays)
|
||||
for(const item of arr)
|
||||
if(item && item.id && !seen.has(item.id)){ seen.add(item.id); out.push(item); }
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── 并行所有请求 ──
|
||||
const [
|
||||
trendWeek,
|
||||
trendDay,
|
||||
nowPlay1, nowPlay2,
|
||||
movPop1, movPop2, movUpcoming,
|
||||
cnNew1, cnNew2,
|
||||
hkPop, hkNew,
|
||||
twPop, twNew,
|
||||
krPop, krNew,
|
||||
jpPop, jpNew,
|
||||
usPop1, usPop2, usNew,
|
||||
docPop, docTv,
|
||||
aniJP, aniZH,
|
||||
// 综艺
|
||||
varCN1, varCN2,
|
||||
varHK, varTW,
|
||||
varKR, varJP,
|
||||
] = await Promise.allSettled([
|
||||
tmdb('/trending/all/week', {page:'1'}),
|
||||
tmdb('/trending/movie/day', {page:'1'}),
|
||||
|
||||
// 电影:院线 + 热门
|
||||
tmdb('/movie/now_playing', {page:'1'}),
|
||||
tmdb('/movie/now_playing', {page:'2'}),
|
||||
tmdb('/movie/popular', {page:'1'}),
|
||||
tmdb('/movie/popular', {page:'2'}),
|
||||
tmdb('/movie/upcoming', {page:'1'}),
|
||||
|
||||
// 国产剧:with_type=4连续剧, without_genres排除综艺10764/真人秀10766/脱口秀10767
|
||||
tmdb('/discover/tv', {
|
||||
with_original_language:'zh', with_origin_country:'CN',
|
||||
without_genres:'10764,10766,10767',
|
||||
with_type:'4',
|
||||
sort_by:'popularity.desc',
|
||||
include_null_first_air_dates:'false',
|
||||
'first_air_date.gte': relDate(-180),
|
||||
'first_air_date.lte': relDate(7),
|
||||
}),
|
||||
tmdb('/discover/tv', {
|
||||
with_original_language:'zh', with_origin_country:'CN',
|
||||
without_genres:'10764,10766,10767',
|
||||
with_type:'4',
|
||||
sort_by:'first_air_date.desc',
|
||||
include_null_first_air_dates:'false',
|
||||
'first_air_date.gte': relDate(-180),
|
||||
'first_air_date.lte': relDate(7),
|
||||
}),
|
||||
|
||||
// 港剧
|
||||
tmdb('/discover/tv', {
|
||||
with_original_language:'zh', with_origin_country:'HK',
|
||||
without_genres:'16,99,10764,10766,10767',
|
||||
sort_by:'popularity.desc',
|
||||
include_null_first_air_dates:'false',
|
||||
'first_air_date.gte': relDate(-730),
|
||||
'first_air_date.lte': relDate(30),
|
||||
}),
|
||||
tmdb('/discover/tv', {
|
||||
with_original_language:'zh', with_origin_country:'HK',
|
||||
without_genres:'16,99,10764,10766,10767',
|
||||
sort_by:'first_air_date.desc',
|
||||
include_null_first_air_dates:'false',
|
||||
'first_air_date.gte': relDate(-365),
|
||||
'first_air_date.lte': relDate(30),
|
||||
}),
|
||||
|
||||
// 台剧
|
||||
tmdb('/discover/tv', {
|
||||
with_original_language:'zh', with_origin_country:'TW',
|
||||
without_genres:'16,99,10764,10766,10767',
|
||||
sort_by:'popularity.desc',
|
||||
include_null_first_air_dates:'false',
|
||||
'first_air_date.gte': relDate(-730),
|
||||
'first_air_date.lte': relDate(30),
|
||||
}),
|
||||
tmdb('/discover/tv', {
|
||||
with_original_language:'zh', with_origin_country:'TW',
|
||||
without_genres:'16,99,10764,10766,10767',
|
||||
sort_by:'first_air_date.desc',
|
||||
include_null_first_air_dates:'false',
|
||||
'first_air_date.gte': relDate(-365),
|
||||
'first_air_date.lte': relDate(30),
|
||||
}),
|
||||
|
||||
// 韩剧(by language ko)
|
||||
tmdb('/discover/tv', {
|
||||
with_original_language:'ko',
|
||||
without_genres:'16,99,10764,10766,10767',
|
||||
sort_by:'popularity.desc',
|
||||
include_null_first_air_dates:'false',
|
||||
'first_air_date.gte': relDate(-730),
|
||||
'first_air_date.lte': relDate(60),
|
||||
}),
|
||||
tmdb('/discover/tv', {
|
||||
with_original_language:'ko',
|
||||
without_genres:'16,99,10764,10766,10767',
|
||||
sort_by:'first_air_date.desc',
|
||||
include_null_first_air_dates:'false',
|
||||
'first_air_date.gte': relDate(-365),
|
||||
'first_air_date.lte': relDate(60),
|
||||
}),
|
||||
|
||||
// 日剧(by language ja)
|
||||
tmdb('/discover/tv', {
|
||||
with_original_language:'ja',
|
||||
without_genres:'16,99,10764,10766,10767',
|
||||
sort_by:'popularity.desc',
|
||||
include_null_first_air_dates:'false',
|
||||
'first_air_date.gte': relDate(-730),
|
||||
'first_air_date.lte': relDate(60),
|
||||
}),
|
||||
tmdb('/discover/tv', {
|
||||
with_original_language:'ja',
|
||||
without_genres:'16,99,10764,10766,10767',
|
||||
sort_by:'first_air_date.desc',
|
||||
include_null_first_air_dates:'false',
|
||||
'first_air_date.gte': relDate(-365),
|
||||
'first_air_date.lte': relDate(60),
|
||||
}),
|
||||
|
||||
// 欧美剧:US主流流媒体近90天 + GB + 补充
|
||||
tmdb('/discover/tv', {
|
||||
with_original_language:'en', with_origin_country:'US',
|
||||
without_genres:'16,10764,10766,10767',
|
||||
with_watch_providers:'8|337|350|384|9|386',
|
||||
watch_region:'US',
|
||||
sort_by:'popularity.desc',
|
||||
include_null_first_air_dates:'false',
|
||||
'first_air_date.gte': relDate(-90),
|
||||
'first_air_date.lte': relDate(14),
|
||||
}),
|
||||
tmdb('/discover/tv', {
|
||||
with_original_language:'en', with_origin_country:'GB',
|
||||
without_genres:'16,10764,10766,10767',
|
||||
with_watch_providers:'8|337|350|384|9|386',
|
||||
watch_region:'US',
|
||||
sort_by:'popularity.desc',
|
||||
include_null_first_air_dates:'false',
|
||||
'first_air_date.gte': relDate(-90),
|
||||
'first_air_date.lte': relDate(14),
|
||||
}),
|
||||
tmdb('/discover/tv', {
|
||||
with_original_language:'en', with_origin_country:'US',
|
||||
without_genres:'16,10764,10766,10767',
|
||||
sort_by:'popularity.desc',
|
||||
include_null_first_air_dates:'false',
|
||||
'first_air_date.gte': relDate(-60),
|
||||
'first_air_date.lte': relDate(14),
|
||||
'vote_count.gte':'10',
|
||||
}),
|
||||
|
||||
// 纪录片
|
||||
tmdb('/discover/movie', {with_genres:'99', sort_by:'popularity.desc', 'vote_count.gte':'50', language:'zh-CN'}),
|
||||
tmdb('/discover/tv', {with_genres:'99', sort_by:'popularity.desc', 'vote_count.gte':'20', language:'zh-CN'}),
|
||||
|
||||
// 动漫:日本动画 + 中文动画,近120天
|
||||
tmdb('/discover/tv', {
|
||||
with_genres:'16', with_original_language:'ja', with_origin_country:'JP',
|
||||
sort_by:'first_air_date.desc',
|
||||
include_null_first_air_dates:'false',
|
||||
'first_air_date.gte': relDate(-120),
|
||||
'first_air_date.lte': relDate(30),
|
||||
}),
|
||||
tmdb('/discover/tv', {
|
||||
with_genres:'16', with_original_language:'zh',
|
||||
sort_by:'first_air_date.desc',
|
||||
include_null_first_air_dates:'false',
|
||||
'first_air_date.gte': relDate(-120),
|
||||
'first_air_date.lte': relDate(30),
|
||||
}),
|
||||
|
||||
// 综艺:中国内地(genre 10764=真人秀 + 10767=脱口秀)热度 + 最新
|
||||
tmdb('/discover/tv', {
|
||||
with_genres:'10764', with_original_language:'zh', with_origin_country:'CN',
|
||||
sort_by:'popularity.desc',
|
||||
include_null_first_air_dates:'false',
|
||||
'first_air_date.gte': relDate(-180),
|
||||
'first_air_date.lte': relDate(14),
|
||||
}),
|
||||
tmdb('/discover/tv', {
|
||||
with_genres:'10764', with_original_language:'zh', with_origin_country:'CN',
|
||||
sort_by:'first_air_date.desc',
|
||||
include_null_first_air_dates:'false',
|
||||
'first_air_date.gte': relDate(-180),
|
||||
'first_air_date.lte': relDate(14),
|
||||
}),
|
||||
|
||||
// 综艺:香港
|
||||
tmdb('/discover/tv', {
|
||||
with_genres:'10764,10767', with_original_language:'zh', with_origin_country:'HK',
|
||||
sort_by:'first_air_date.desc',
|
||||
include_null_first_air_dates:'false',
|
||||
'first_air_date.gte': relDate(-180),
|
||||
'first_air_date.lte': relDate(30),
|
||||
}),
|
||||
|
||||
// 综艺:台湾
|
||||
tmdb('/discover/tv', {
|
||||
with_genres:'10764,10767', with_original_language:'zh', with_origin_country:'TW',
|
||||
sort_by:'first_air_date.desc',
|
||||
include_null_first_air_dates:'false',
|
||||
'first_air_date.gte': relDate(-180),
|
||||
'first_air_date.lte': relDate(30),
|
||||
}),
|
||||
|
||||
// 综艺:韩国(genre 10764)
|
||||
tmdb('/discover/tv', {
|
||||
with_genres:'10764', with_original_language:'ko',
|
||||
sort_by:'popularity.desc',
|
||||
include_null_first_air_dates:'false',
|
||||
'first_air_date.gte': relDate(-180),
|
||||
'first_air_date.lte': relDate(14),
|
||||
}),
|
||||
|
||||
// 综艺:日本
|
||||
tmdb('/discover/tv', {
|
||||
with_genres:'10764', with_original_language:'ja',
|
||||
sort_by:'popularity.desc',
|
||||
include_null_first_air_dates:'false',
|
||||
'first_air_date.gte': relDate(-180),
|
||||
'first_air_date.lte': relDate(14),
|
||||
}),
|
||||
]);
|
||||
|
||||
// ── 合并各分类 ──
|
||||
const newItems = merge(get(trendWeek), get(nowPlay1), get(nowPlay2)).slice(0,24);
|
||||
const movieItems = merge(get(nowPlay1), get(nowPlay2), get(movPop1), get(movPop2), get(trendDay), get(movUpcoming))
|
||||
.filter(i=>i.title||i.original_title).slice(0,24);
|
||||
|
||||
// 国产剧:popularity + first_air_date 双排序交叉
|
||||
const cnItems = merge(get(cnNew1), get(cnNew2)).slice(0,24);
|
||||
|
||||
// 港台剧:港剧+台剧 四路合并
|
||||
const hkItems = merge(get(hkPop), get(hkNew), get(twPop), get(twNew)).slice(0,24);
|
||||
|
||||
// 日韩剧:韩+日各双路
|
||||
const jpkrItems = merge(get(krPop), get(krNew), get(jpPop), get(jpNew)).slice(0,24);
|
||||
|
||||
// 欧美剧:三路合并
|
||||
const usItems = merge(get(usPop1), get(usPop2), get(usNew)).slice(0,24);
|
||||
|
||||
// 纪录片
|
||||
const docItems = merge(get(docPop), get(docTv)).slice(0,24);
|
||||
|
||||
// 动漫
|
||||
const aniItems = merge(get(aniJP), get(aniZH)).slice(0,24);
|
||||
|
||||
// ── Hero carousel ──
|
||||
const heroPool = merge(get(trendWeek), get(trendDay), get(nowPlay1), get(movPop1))
|
||||
.filter(i=>i.backdrop_path && i.poster_path && i.overview);
|
||||
const today2 = new Date();
|
||||
const dateSeed = today2.getFullYear()*10000 + (today2.getMonth()+1)*100 + today2.getDate();
|
||||
function seededShuffle(arr, seed){
|
||||
const a = arr.slice(); let s = seed >>> 0;
|
||||
for(let i=a.length-1;i>0;i--){
|
||||
s = (Math.imul(s,1664525)+1013904223)>>>0;
|
||||
const j = s%(i+1); [a[i],a[j]]=[a[j],a[i]];
|
||||
}
|
||||
return a;
|
||||
}
|
||||
const heroSix = seededShuffle(heroPool, dateSeed).slice(0,6);
|
||||
if(heroSix.length) initHeroCarousel(heroSix);
|
||||
|
||||
renderGrid('g-new', 'cnt-new', newItems);
|
||||
renderGrid('g-movie', 'cnt-movie', movieItems);
|
||||
renderGrid('g-cn', 'cnt-cn', cnItems, true);
|
||||
renderGrid('g-hk', 'cnt-hk', hkItems, true);
|
||||
renderGrid('g-jpkr', 'cnt-jpkr', jpkrItems, true);
|
||||
renderGrid('g-us', 'cnt-us', usItems, true);
|
||||
renderGrid('g-doc', 'cnt-doc', docItems);
|
||||
renderGrid('g-ani', 'cnt-ani', aniItems);
|
||||
|
||||
// 综艺:中国+港+台+韩+日 六路合并
|
||||
const varItems = merge(get(varCN1), get(varCN2), get(varHK), get(varTW), get(varKR), get(varJP)).slice(0,24);
|
||||
renderGrid('g-var', 'cnt-var', varItems, true);
|
||||
}
|
||||
|
||||
// ── NAV SCROLL ACTIVE ──
|
||||
function scrollSec(id){
|
||||
const tv = document.body.classList.contains('tv-mode');
|
||||
var _secEl=document.getElementById(id); if(_secEl)_secEl.scrollIntoView({behavior: tv ? 'auto' : 'smooth'});
|
||||
document.querySelectorAll('.ntab').forEach(t=>t.classList.toggle('on', t.dataset.sec===id));
|
||||
// TV:切换分类后把焦点移到该 section 第一张卡片,方便继续下导航
|
||||
if(tv){
|
||||
requestAnimationFrame(()=>{
|
||||
const sec = document.getElementById(id);
|
||||
const first = sec && sec.querySelector('.card');
|
||||
if(first && typeof _tvSetFocus === 'function') _tvSetFocus(first, false);
|
||||
});
|
||||
}
|
||||
}
|
||||
const secObs = new IntersectionObserver(entries=>{
|
||||
entries.forEach(e=>{
|
||||
if(e.isIntersecting){
|
||||
const id = e.target.id;
|
||||
document.querySelectorAll('.ntab').forEach(t=>t.classList.toggle('on', t.dataset.sec===id));
|
||||
}
|
||||
});
|
||||
},{rootMargin:'-40% 0px -55% 0px'});
|
||||
['s-new','s-movie','s-cn','s-hk','s-jpkr','s-us','s-doc','s-ani','s-var'].forEach(id=>{
|
||||
const el = document.getElementById(id);
|
||||
if(el) secObs.observe(el);
|
||||
});
|
||||
|
||||
// ── SEARCH ──
|
||||
const HOT = ['星际穿越','流浪地球','肖申克的救赎','寄生虫','蜘蛛侠','复仇者联盟','黑暗骑士','千与千寻'];
|
||||
|
||||
function openSrch(){
|
||||
const overlay = document.getElementById('srch');
|
||||
const inp = document.getElementById('srchInp');
|
||||
overlay.classList.add('on');
|
||||
inp.value = '';
|
||||
_lastSuggestKw = '';
|
||||
clearTimeout(_suggestTimer);
|
||||
showHome();
|
||||
history.pushState({eclipse:'search'}, '');
|
||||
setTimeout(()=>{ inp.focus(); inp.click(); }, 120);
|
||||
}
|
||||
function _doCloseSrch(){
|
||||
document.getElementById('srch').classList.remove('on');
|
||||
document.getElementById('srchInp').blur();
|
||||
}
|
||||
function closeSrch(){
|
||||
_doCloseSrch();
|
||||
}
|
||||
|
||||
// ── SEARCH HISTORY ──
|
||||
const HIST_KEY = 'eclipse_srch_hist';
|
||||
const HIST_MAX = 20;
|
||||
|
||||
function loadHist(){
|
||||
try{ return JSON.parse(localStorage.getItem(HIST_KEY)||'[]'); }catch(e){ return []; }
|
||||
}
|
||||
function saveHist(list){
|
||||
try{ localStorage.setItem(HIST_KEY, JSON.stringify(list.slice(0,HIST_MAX))); }catch(e){}
|
||||
}
|
||||
function addHist(kw){
|
||||
kw = kw.trim();
|
||||
if(!kw) return;
|
||||
let h = loadHist().filter(s=>s!==kw);
|
||||
h.unshift(kw);
|
||||
saveHist(h);
|
||||
}
|
||||
function removeHist(kw){
|
||||
saveHist(loadHist().filter(s=>s!==kw));
|
||||
showHome();
|
||||
}
|
||||
function clearHist(){
|
||||
try{ localStorage.removeItem(HIST_KEY); }catch(e){}
|
||||
showHome();
|
||||
}
|
||||
|
||||
// ── SEARCH SUGGEST (TMDB /search/multi debounced) ──
|
||||
let _suggestTimer = null;
|
||||
let _suggestAbort = null;
|
||||
let _lastSuggestKw = '';
|
||||
|
||||
function onSrchInput(e){
|
||||
const kw = e.target.value.trim();
|
||||
if(!kw){ showHome(); return; }
|
||||
// 立即显示历史过滤,同时发起联想
|
||||
clearTimeout(_suggestTimer);
|
||||
_suggestTimer = setTimeout(()=> fetchSuggest(kw), 220);
|
||||
}
|
||||
|
||||
// ── 拼音首字母映射(基于 Unicode 码点区间,覆盖 GB2312 全部 6763 一级汉字)──
|
||||
// 区间表:[起始码点, 声母],按码点升序排列,取最后一个 ≤ cp 的声母
|
||||
const _PY_RANGES = (()=>{
|
||||
// 每个元素 [unicodeCodePoint, initial],代表"从此码点起声母变为 initial"
|
||||
// 数据来源:GB2312 一级汉字拼音分区(经过实测验证的主流版本)
|
||||
const raw = [
|
||||
[0x554A,'a'],[0x9F44,'a'],// 啊
|
||||
[0x516B,'b'],[0x6253,'b'],// 八...把
|
||||
[0x64E6,'c'],[0x6491,'c'],// 擦...插
|
||||
[0x5927,'d'],[0x4ED8,'d'],// 大...打(注:d 区从 搭 开始)
|
||||
// 使用更可靠的 Intl 方案替代硬编码区间
|
||||
];
|
||||
// 实际使用 Intl.Collator 参照锚字做声母判断(精确锚点来自 Unicode Han Database)
|
||||
// 声母锚字(每个声母拼音最小的一个常用汉字,按 Unicode 拼音排序)
|
||||
const ANCHORS = [
|
||||
[0x5E00,'z'],[0x4E2B,'y'],[0x5915,'x'],[0x5A03,'w'],
|
||||
[0x5854,'t'],[0x4EE8,'s'],[0x7136,'r'],[0x671F,'q'],
|
||||
[0x5478,'p'],[0x54E6,'o'],[0x62FF,'n'],[0x5988,'m'],
|
||||
[0x5783,'l'],[0x54B3,'k'],[0x51FB,'j'],[0x54C8,'h'],
|
||||
[0x5676,'g'],[0x53D1,'f'],[0x9E45,'e'],[0x5927,'d'],
|
||||
[0x64E6,'c'],[0x516B,'b'],[0x554A,'a'],
|
||||
];
|
||||
const col = typeof Intl!=='undefined'
|
||||
? new Intl.Collator('zh-Hans-CN',{sensitivity:'base'})
|
||||
: null;
|
||||
return function getInitial(ch){
|
||||
if(!ch) return '';
|
||||
const cp = ch.codePointAt(0);
|
||||
// 非 CJK 统一汉字区:直接取字母
|
||||
if(cp < 0x4E00 || cp > 0x9FFF){
|
||||
return /[a-zA-Z]/.test(ch) ? ch.toLowerCase() : '';
|
||||
}
|
||||
if(!col) return ''; // 降级:无 Intl 环境
|
||||
for(const [anchor, ini] of ANCHORS){
|
||||
if(col.compare(ch, String.fromCodePoint(anchor)) >= 0) return ini;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
})();
|
||||
|
||||
// 取一段文本的拼音首字母串,例如 "流浪地球" → "lldq"
|
||||
function getPinyinInitials(text){
|
||||
let s = '';
|
||||
for(const ch of text){
|
||||
const ini = _PY_RANGES(ch);
|
||||
if(ini) s += ini;
|
||||
else if(/[a-zA-Z0-9]/.test(ch)) s += ch.toLowerCase();
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
// 判断输入 kw 是否纯拼音首字母(全为 a-z 且长度 ≤ 8)
|
||||
function isPinyinInitialQuery(kw){
|
||||
return /^[a-z]{1,8}$/i.test(kw);
|
||||
}
|
||||
|
||||
// 从候选标题列表中找出拼音首字母匹配的项
|
||||
// 返回 [{label, initials, matchStart}],matchStart 是首字母串中首次匹配的位置(用于高亮整词)
|
||||
function filterByInitials(titles, kw){
|
||||
const q = kw.toLowerCase();
|
||||
return titles.filter(t=>{
|
||||
const ini = getPinyinInitials(t);
|
||||
return ini.includes(q);
|
||||
});
|
||||
}
|
||||
|
||||
// 高亮:支持普通子串 + 拼音首字母两种模式
|
||||
// 拼音首字母模式:把每个匹配的汉字首字包裹 <em>,其余正常
|
||||
function highlightKw(text, kw){
|
||||
if(!kw) return esc(text);
|
||||
// 普通子串匹配
|
||||
const idx = text.toLowerCase().indexOf(kw.toLowerCase());
|
||||
if(idx >= 0){
|
||||
return esc(text.slice(0,idx))
|
||||
+ `<em>${esc(text.slice(idx,idx+kw.length))}</em>`
|
||||
+ esc(text.slice(idx+kw.length));
|
||||
}
|
||||
// 拼音首字母匹配:逐字高亮
|
||||
if(isPinyinInitialQuery(kw)){
|
||||
const q = kw.toLowerCase();
|
||||
const initials = getPinyinInitials(text);
|
||||
const start = initials.indexOf(q);
|
||||
if(start < 0) return esc(text);
|
||||
// 把 initials[start..start+q.length] 对应回原字符串下标
|
||||
// initials[i] 对应 text 中第 i 个"有首字母"的字符
|
||||
let iPos = 0; // initials 计数
|
||||
let matchStart = -1, matchEnd = -1;
|
||||
for(let ci = 0; ci < text.length; ci++){
|
||||
const ch = text[ci];
|
||||
const ini = _PY_RANGES(ch) || (/[a-zA-Z0-9]/.test(ch) ? ch.toLowerCase() : '');
|
||||
if(ini){
|
||||
if(iPos === start) matchStart = ci;
|
||||
if(iPos === start + q.length - 1) { matchEnd = ci + 1; break; }
|
||||
iPos++;
|
||||
}
|
||||
}
|
||||
if(matchStart < 0) return esc(text);
|
||||
return esc(text.slice(0,matchStart))
|
||||
+ `<em>${esc(text.slice(matchStart,matchEnd))}</em>`
|
||||
+ esc(text.slice(matchEnd));
|
||||
}
|
||||
return esc(text);
|
||||
}
|
||||
|
||||
async function fetchSuggest(kw){
|
||||
if(kw === _lastSuggestKw) return;
|
||||
_lastSuggestKw = kw;
|
||||
|
||||
// 纯拼音首字母:先用本地历史快速渲染,同时并行发 TMDB 请求
|
||||
const isInitial = isPinyinInitialQuery(kw);
|
||||
const hist = loadHist();
|
||||
const localMatches = filterByInitials(hist, kw);
|
||||
|
||||
// 先渲染骨架(带本地历史命中)
|
||||
renderSuggest(kw, null, localMatches);
|
||||
|
||||
try{
|
||||
// 拼音首字母模式:同时搜索每个首字母可能对应的汉字变体,TMDB 用 kw 直接搜(会匹配英文/数字)
|
||||
// 另外 TMDB 支持中文,所以对纯首字母也直接发请求,让 TMDB 自己处理
|
||||
const data = await tmdb('/search/multi', {query:kw, page:'1'});
|
||||
const currentKw = document.getElementById('srchInp').value.trim();
|
||||
if(kw !== currentKw) return; // 竞态保护
|
||||
renderSuggest(kw, data.results||[], localMatches);
|
||||
}catch(e){
|
||||
if(kw !== document.getElementById('srchInp').value.trim()) return;
|
||||
renderSuggest(kw, [], localMatches);
|
||||
}
|
||||
}
|
||||
|
||||
function renderSuggest(kw, results, localMatches){
|
||||
localMatches = localMatches || [];
|
||||
const body = document.getElementById('srchBody');
|
||||
const isInitial = isPinyinInitialQuery(kw);
|
||||
|
||||
if(!results){
|
||||
// loading 骨架:若有本地历史命中,先展示它们
|
||||
let html = '';
|
||||
if(localMatches.length){
|
||||
html += `<div class="srch-label" style="padding-top:14px;padding-bottom:6px">历史记录</div>
|
||||
<ul class="srch-suggest-list">
|
||||
${localMatches.slice(0,4).map(t=>`
|
||||
<li class="srch-suggest-item" tabindex="0"
|
||||
onclick="tapSuggest('${esc(t).replace(/'/g,'\\\'')}')"
|
||||
onkeydown="if(event.key==='Enter')tapSuggest('${esc(t).replace(/'/g,'\\\'')}')">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 8v4l3 3m6-3a9 9 0 1 1-18 0 9 9 0 0 1 18 0z"/></svg>
|
||||
<span class="sg-kw">${highlightKw(t, kw)}</span>
|
||||
<span class="sg-fill">搜索</span>
|
||||
</li>`).join('')}
|
||||
</ul>`;
|
||||
}
|
||||
html += `<ul class="srch-suggest-list">
|
||||
${[0,1,2,3].map(i=>`
|
||||
<li class="srch-suggest-item">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
<span class="sg-kw sk" style="width:${90+i*22}px;height:15px;border-radius:3px;display:inline-block"></span>
|
||||
</li>`).join('')}
|
||||
</ul>`;
|
||||
body.innerHTML = html;
|
||||
return;
|
||||
}
|
||||
|
||||
// 构建候选列表:TMDB 结果 + 本地历史,去重
|
||||
const titles = [];
|
||||
const seen = new Set();
|
||||
|
||||
// 第一条:原始关键词(用于直接搜索)
|
||||
titles.push({label:kw, exact:true, src:'query'});
|
||||
seen.add(kw.toLowerCase());
|
||||
|
||||
// 本地历史命中(拼音首字母匹配)—— 排在联想前面
|
||||
for(const t of localMatches){
|
||||
if(seen.has(t.toLowerCase())) continue;
|
||||
seen.add(t.toLowerCase());
|
||||
titles.push({label:t, exact:false, src:'hist'});
|
||||
if(titles.length >= 5) break;
|
||||
}
|
||||
|
||||
// TMDB 联想结果
|
||||
for(const r of (results||[])){
|
||||
const t = r.title||r.name||'';
|
||||
if(!t || seen.has(t.toLowerCase())) continue;
|
||||
// 拼音首字母模式:只保留首字母也匹配的项(或中英文直接包含 kw 的)
|
||||
if(isInitial){
|
||||
const ini = getPinyinInitials(t);
|
||||
const directMatch = t.toLowerCase().includes(kw.toLowerCase());
|
||||
if(!ini.includes(kw.toLowerCase()) && !directMatch) continue;
|
||||
}
|
||||
seen.add(t.toLowerCase());
|
||||
titles.push({label:t, exact:false, src:'tmdb'});
|
||||
if(titles.length >= 9) break;
|
||||
}
|
||||
|
||||
if(titles.length <= 1 && !localMatches.length){
|
||||
body.innerHTML = `
|
||||
<ul class="srch-suggest-list">
|
||||
<li class="srch-suggest-item" onclick="doSrch('${esc(kw).replace(/'/g,'\\\'')}')" tabindex="0">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
<span class="sg-kw"><em>${esc(kw)}</em></span>
|
||||
<span class="sg-fill">搜索</span>
|
||||
</li>
|
||||
</ul>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const srcIcon = {
|
||||
query: `<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>`,
|
||||
hist: `<path d="M12 8v4l3 3m6-3a9 9 0 1 1-18 0 9 9 0 0 1 18 0z"/>`,
|
||||
tmdb: `<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>`,
|
||||
};
|
||||
|
||||
body.innerHTML = `<ul class="srch-suggest-list" id="suggestList">
|
||||
${titles.map((t,i)=>`
|
||||
<li class="srch-suggest-item${t.exact?' active':''}" tabindex="0"
|
||||
data-idx="${i}" data-kw="${esc(t.label)}"
|
||||
onclick="tapSuggest('${esc(t.label).replace(/'/g,'\\\'')}')"
|
||||
onkeydown="if(event.key==='Enter')tapSuggest('${esc(t.label).replace(/'/g,'\\\'')}')">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
${srcIcon[t.src]||srcIcon.query}
|
||||
</svg>
|
||||
<span class="sg-kw">${t.exact ? `<em>${esc(t.label)}</em>` : highlightKw(t.label, kw)}</span>
|
||||
<span class="sg-fill">搜索</span>
|
||||
</li>`).join('')}
|
||||
</ul>`;
|
||||
}
|
||||
|
||||
function tapSuggest(kw){
|
||||
const inp = document.getElementById('srchInp');
|
||||
inp.value = kw;
|
||||
doSrch(kw);
|
||||
}
|
||||
|
||||
// ── 首页(历史 + 热门)──
|
||||
function showHome(){
|
||||
_lastSuggestKw = '';
|
||||
const hist = loadHist();
|
||||
const body = document.getElementById('srchBody');
|
||||
let html = '';
|
||||
if(hist.length){
|
||||
html += `
|
||||
<div class="srch-section-hd">
|
||||
<div class="srch-label" style="padding:0">最近搜索</div>
|
||||
<button class="srch-clear-btn" onclick="clearHist()">全部清除</button>
|
||||
</div>
|
||||
<div class="srch-hist-tags">
|
||||
${hist.map(s=>`
|
||||
<span class="srch-hist-tag" tabindex="0"
|
||||
onclick="tapHist('${esc(s).replace(/'/g,'\\\'')}')"
|
||||
onkeydown="if(event.key==='Enter')tapHist('${esc(s).replace(/'/g,'\\\'')}')">
|
||||
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(s)}</span>
|
||||
<span class="srch-hist-tag-x" onclick="event.stopPropagation();removeHist('${esc(s).replace(/'/g,'\\\'')}')" title="删除">✕</span>
|
||||
</span>`).join('')}
|
||||
</div>`;
|
||||
}
|
||||
html += `
|
||||
<div class="srch-label" style="padding-top:${hist.length?16:18}px">热门搜索</div>
|
||||
<ul class="srch-hot">${HOT.map(s=>`
|
||||
<li tabindex="0" onclick="tapHot(this,'${esc(s)}')" onkeydown="if(event.key==='Enter')tapHot(this,'${esc(s)}')">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
<span class="st">${esc(s)}</span>
|
||||
</li>`).join('')}</ul>`;
|
||||
body.innerHTML = html;
|
||||
}
|
||||
|
||||
function tapHist(kw){
|
||||
const inp = document.getElementById('srchInp');
|
||||
inp.value = kw;
|
||||
doSrch(kw);
|
||||
}
|
||||
|
||||
function showHot(){ showHome(); }
|
||||
|
||||
function tapHot(el, kw){
|
||||
const inp = document.getElementById('srchInp');
|
||||
inp.value = kw;
|
||||
doSrch(kw);
|
||||
}
|
||||
|
||||
function submitSrch(){
|
||||
const v = document.getElementById('srchInp').value.trim();
|
||||
if(v) doSrch(v);
|
||||
}
|
||||
|
||||
function onSrchKey(e){
|
||||
if(e.key === 'Escape'){ closeSrch(); return; }
|
||||
if(e.key === 'Enter'){
|
||||
const v = e.target.value.trim();
|
||||
if(v){ e.preventDefault(); doSrch(v); }
|
||||
}
|
||||
// 上下键在联想列表中导航
|
||||
if(e.key === 'ArrowDown' || e.key === 'ArrowUp'){
|
||||
const list = document.getElementById('suggestList');
|
||||
if(!list) return;
|
||||
const items = list.querySelectorAll('.srch-suggest-item');
|
||||
if(!items.length) return;
|
||||
e.preventDefault();
|
||||
let idx = [...items].findIndex(i=>i.classList.contains('active'));
|
||||
if(items[idx])items[idx].classList.remove('active');
|
||||
idx = e.key==='ArrowDown' ? (idx+1)%items.length : (idx-1+items.length)%items.length;
|
||||
items[idx].classList.add('active');
|
||||
// 填入输入框预览
|
||||
const inp = document.getElementById('srchInp');
|
||||
inp.value = items[idx].dataset.kw || inp.value;
|
||||
}
|
||||
}
|
||||
|
||||
async function doSrch(kw){
|
||||
kw = kw.trim();
|
||||
if(!kw) return;
|
||||
_lastSuggestKw = '';
|
||||
clearTimeout(_suggestTimer);
|
||||
// 记录历史
|
||||
addHist(kw);
|
||||
// 搜索框内显示 loading
|
||||
const body = document.getElementById('srchBody');
|
||||
body.innerHTML = `
|
||||
<div class="srch-searching">
|
||||
<div class="srch-searching-spinner"></div>
|
||||
<div class="srch-searching-text">搜索「${esc(kw)}」…</div>
|
||||
</div>`;
|
||||
try{
|
||||
const [mv, tv] = await Promise.all([
|
||||
tmdb('/search/movie', {query:kw}),
|
||||
tmdb('/search/tv', {query:kw}),
|
||||
]);
|
||||
const results = [...(mv.results||[]), ...(tv.results||[])]
|
||||
.filter(i => i.poster_path)
|
||||
.slice(0, 24);
|
||||
results.forEach(i => ITEMS[i.id] = i);
|
||||
|
||||
if(!results.length){
|
||||
body.innerHTML = `<div class="srch-empty"><div class="srch-empty-ico">🔍</div><div>「${esc(kw)}」暂无结果</div></div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
// 关闭搜索覆盖层,push 一条 search-result 条目
|
||||
// 栈:[sentinel, search, search-result]
|
||||
// popstate 逐层弹出关闭,最终回主页
|
||||
_doCloseSrch();
|
||||
history.pushState({eclipse:'search-result'}, '');
|
||||
showSrchResult(kw, results);
|
||||
|
||||
}catch(e){
|
||||
body.innerHTML = `<div class="srch-empty"><div class="srch-empty-ico">⚠️</div><div>搜索失败,请检查网络</div></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderStars(score){
|
||||
const n = Number(score) || 0;
|
||||
const full = Math.min(5, Math.floor(n/2));
|
||||
const half = n%2 >= 1 ? 1 : 0;
|
||||
const empty = 5 - full - half;
|
||||
const s = (n,cls,ch)=> n ? `<span class="${cls}">${ch.repeat(n)}</span>` : '';
|
||||
return s(full,'sli-star-full','★') +
|
||||
(half ? '<span class="sli-star-half">½</span>' : '') +
|
||||
s(empty,'sli-star-empty','★');
|
||||
}
|
||||
|
||||
function buildSliHtml(item){
|
||||
const isTV = !!item.name;
|
||||
const title = item.title || item.name || '';
|
||||
const year = (item.release_date||item.first_air_date||'').slice(0,4);
|
||||
const rating = item.vote_average ? Number(item.vote_average).toFixed(1) : '';
|
||||
const genre = (item.genres&&item.genres[0]&&item.genres[0].name) || '';
|
||||
const media = isTV ? '剧集' : '电影';
|
||||
const meta = [media, year, genre].filter(Boolean).join(' · ');
|
||||
const poster = item.poster_path ? (IMG_W500 + item.poster_path) : '';
|
||||
const overview = (item.overview||'').slice(0, 90) + ((item.overview||'').length>90?'…':'');
|
||||
const stars = rating ? renderStars(Number(item.vote_average)) : '';
|
||||
return `<button class="sli" tabindex="0" onclick="openDet(ITEMS[${item.id}])">
|
||||
${poster ? `<img class="sli-poster" src="${poster}" alt="" loading="lazy">` : `<div class="sli-poster"></div>`}
|
||||
<div class="sli-body">
|
||||
<span class="sli-title">${esc(title)}</span>
|
||||
<span class="sli-meta">${esc(meta)}</span>
|
||||
${stars ? `<div class="sli-stars">${stars}<span class="sli-rating">${esc(rating)}</span></div>` : ''}
|
||||
${overview ? `<div class="sli-overview">${esc(overview)}</div>` : ''}
|
||||
</div>
|
||||
</button>`;
|
||||
}
|
||||
|
||||
function showSrchResult(kw, results){
|
||||
const sec = document.getElementById('s-search');
|
||||
const list = document.getElementById('srch-list');
|
||||
const title = document.getElementById('srchResTitle');
|
||||
const cnt = document.getElementById('cnt-search');
|
||||
|
||||
title.textContent = `「${kw}」`;
|
||||
cnt.textContent = results.length + ' 个结果';
|
||||
list.innerHTML = results.map(i => buildSliHtml(i)).join('');
|
||||
sec.style.display = '';
|
||||
|
||||
// 主页内容模糊
|
||||
document.documentElement.classList.add('search-active');
|
||||
|
||||
// 滚到顶部显示结果
|
||||
window.scrollTo({top: 0, behavior: 'smooth'});
|
||||
}
|
||||
|
||||
function clearSrchResult(){
|
||||
document.getElementById('s-search').style.display = 'none';
|
||||
document.getElementById('srch-list').innerHTML = '';
|
||||
document.documentElement.classList.remove('search-active');
|
||||
}
|
||||
|
||||
function closeSrchResult(){
|
||||
clearSrchResult();
|
||||
}
|
||||
|
||||
// ── 详情页背景剧照轮播 ──
|
||||
let _detBdTimer = null;
|
||||
let _detBdToken = '';
|
||||
|
||||
function _clearDetBackdrop(){
|
||||
if(_detBdTimer){ clearInterval(_detBdTimer); _detBdTimer = null; }
|
||||
_detBdToken = '';
|
||||
}
|
||||
|
||||
// 用多张剧照启动背景轮播(每 5 秒淡入淡出)
|
||||
function _startDetBackdrop(backdrops, token){
|
||||
_clearDetBackdrop();
|
||||
if(!backdrops || backdrops.length < 2) return; // 少于2张不轮播
|
||||
_detBdToken = token;
|
||||
const hero = document.getElementById('detHero');
|
||||
if(!hero) return;
|
||||
|
||||
// 已有首张静态图(active),把剩余剧照追加为隐藏层
|
||||
const urls = backdrops.map(b=>IMG_ORIG + b.file_path);
|
||||
// 去重:避免和首张重复
|
||||
const firstSrc = (hero.querySelector('.det-hero-img') || {}).src || '';
|
||||
const extra = urls.filter(u=>u && firstSrc.indexOf(u.split('/').pop())<0);
|
||||
// 预创建所有图层(最多 6 张,避免内存压力)
|
||||
const all = [];
|
||||
const firstImg = hero.querySelector('.det-hero-img');
|
||||
if(firstImg) all.push(firstImg);
|
||||
extra.slice(0,5).forEach(u=>{
|
||||
const img = document.createElement('img');
|
||||
img.className = 'det-hero-img';
|
||||
img.loading = 'eager';
|
||||
img.src = u;
|
||||
hero.appendChild(img);
|
||||
all.push(img);
|
||||
});
|
||||
if(all.length < 2) return;
|
||||
|
||||
let cur = 0;
|
||||
_detBdTimer = setInterval(()=>{
|
||||
if(_detBdToken !== token){ _clearDetBackdrop(); return; }
|
||||
if(!document.getElementById('det').classList.contains('on')){ _clearDetBackdrop(); return; }
|
||||
const prev = all[cur];
|
||||
cur = (cur + 1) % all.length;
|
||||
const next = all[cur];
|
||||
if(prev) prev.classList.remove('active');
|
||||
if(next) next.classList.add('active');
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// ── DETAIL ──
|
||||
async function openDet(item){
|
||||
if(!item) return;
|
||||
detData = item;
|
||||
// 推入一条历史记录,让 Android 返回手势/按键能被 popstate 拦截,而不是退出 App
|
||||
history.pushState({eclipse:'detail'}, '');
|
||||
const isTV = !!item.name;
|
||||
const title = item.title||item.name||'';
|
||||
const poster = item.poster_path;
|
||||
const backdrop = item.backdrop_path;
|
||||
const bg = `hsl(${hue(item.id)},18%,13%)`;
|
||||
const backdropUrl = backdrop ? resUrl(backdrop, IMG_ORIG) : '';
|
||||
|
||||
const panel = document.getElementById('det');
|
||||
const inner = document.getElementById('detInner');
|
||||
panel.scrollTop = 0;
|
||||
|
||||
// Set immersive background — handled by det-hero img inside detInner
|
||||
|
||||
inner.innerHTML = `
|
||||
<div class="det-hero" id="detHero">
|
||||
${backdrop ? `<img class="det-hero-img active" src="${resUrl(backdrop, IMG_ORIG)}" alt="" loading="eager">` : ``}
|
||||
</div>
|
||||
|
||||
<div class="det-inner">
|
||||
<div class="det-poster-row">
|
||||
<div class="det-title-block" style="padding-bottom:4px;width:100%">
|
||||
<div class="det-eyebrow">${isTV ? '剧集' : '电影'}</div>
|
||||
<div class="det-logo-wrap" id="detLogoWrap">
|
||||
<div class="det-title det-title-fallback" id="detTitleFallback">${esc(title)}</div>
|
||||
</div>
|
||||
<div class="det-orig-title" id="detOrigTitle"></div>
|
||||
<div class="det-score-row">
|
||||
<span class="det-score sk" style="width:52px;height:22px;border-radius:3px"> </span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="det-pills" id="detPills">
|
||||
<span class="det-pill sk" style="width:50px;height:22px"> </span>
|
||||
<span class="det-pill sk" style="width:70px;height:22px"> </span>
|
||||
</div>
|
||||
<div class="det-actions">
|
||||
<button class="btn-p" id="detContinueBtn" onclick="detContinue()" style="display:none">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M5 3l14 9-14 9V3z"/></svg>
|
||||
<span id="detContinueText">继续观看</span>
|
||||
</button>
|
||||
<button class="btn-p" id="detPlayBtn" onclick="detPlay()">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M5 3l14 9-14 9V3z"/></svg>
|
||||
<span id="detPlayText">搜索播放</span>
|
||||
</button>
|
||||
<button class="btn-s" id="panSearchBtn" onclick="openPanSearch()">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6.2 18.3h11.6a4.1 4.1 0 0 0 .42-8.18 6.2 6.2 0 0 0-11.9 1.68A3.28 3.28 0 0 0 6.2 18.3Z"/></svg>
|
||||
盘搜
|
||||
</button>
|
||||
</div>
|
||||
<div class="det-overview sk" id="detOverview" style="height:80px;border-radius:4px"></div>
|
||||
<div id="panBlock" class="pan-block">
|
||||
<div class="pan-head">
|
||||
<span class="pan-head-title">盘搜资源</span>
|
||||
<span class="pan-hint" id="panHint"></span>
|
||||
<button class="btn-s" style="padding:5px 12px;font-size:12px" onclick="closePanSearch()">✕ 关闭</button>
|
||||
</div>
|
||||
<div class="pan-tabs-row" id="panTabs"></div>
|
||||
<div class="pan-list" id="panList"></div>
|
||||
</div>
|
||||
<div id="detCast"></div>
|
||||
<div id="detRecs"></div>
|
||||
</div>`;
|
||||
|
||||
panel.classList.add('on');
|
||||
try{ sdk().ui.setToolbar(false); }catch(e){}
|
||||
_homeToolbarRestored = false; // 重新进入全屏,复位标志
|
||||
|
||||
// 继续观看:拉历史匹配(并行,不阻塞详情加载)
|
||||
_checkDetContinue(item);
|
||||
|
||||
// Load full details
|
||||
try{
|
||||
const endpoint = isTV ? `/tv/${item.id}` : `/movie/${item.id}`;
|
||||
const [d, credits, recs] = await Promise.allSettled([
|
||||
tmdb(endpoint, {append_to_response:'credits'}),
|
||||
tmdb(endpoint+'/credits'),
|
||||
tmdb(endpoint+'/recommendations'),
|
||||
]);
|
||||
|
||||
const det = d.status==='fulfilled' ? d.value : {};
|
||||
// TMDB Logo (replaces title text) + 背景剧照轮播
|
||||
try{
|
||||
const imagesEndpoint = isTV ? `/tv/${item.id}/images` : `/movie/${item.id}/images`;
|
||||
const imgs = await tmdb(imagesEndpoint, {include_image_language:'zh,en,null'});
|
||||
const logos = (imgs.logos||[]).sort((a,b)=>(b.vote_average||0)-(a.vote_average||0));
|
||||
const logo = logos.find(l=>l.file_path&&l.iso_639_1==='zh') || logos.find(l=>l.file_path&&l.iso_639_1==='en') || logos[0];
|
||||
const logoWrap = document.getElementById('detLogoWrap');
|
||||
const titleFallback = document.getElementById('detTitleFallback');
|
||||
if(logo && logo.file_path && logoWrap){
|
||||
const logoImg = document.createElement('img');
|
||||
logoImg.className = 'det-logo-img';
|
||||
logoImg.src = IMG_ORIG + logo.file_path;
|
||||
logoImg.alt = title;
|
||||
logoImg.onload = ()=>{
|
||||
if(titleFallback) titleFallback.style.display='none';
|
||||
logoWrap.appendChild(logoImg);
|
||||
};
|
||||
logoImg.onerror = ()=>{}; // keep fallback title
|
||||
}
|
||||
// 背景剧照轮播:优先无文字剧照(iso_639_1 为 null),按评分排序
|
||||
if(detData === item){ // 确认仍是当前影片
|
||||
const backdrops = (imgs.backdrops||[])
|
||||
.filter(b=>b.file_path)
|
||||
.sort((a,b)=>{
|
||||
// 无语言文字的剧照优先(更干净),再按评分
|
||||
const an = a.iso_639_1 ? 1 : 0, bn = b.iso_639_1 ? 1 : 0;
|
||||
if(an !== bn) return an - bn;
|
||||
return (b.vote_average||0) - (a.vote_average||0);
|
||||
})
|
||||
.slice(0, 6);
|
||||
_startDetBackdrop(backdrops, (isTV?'tv':'mv')+':'+item.id);
|
||||
}
|
||||
}catch(e){}
|
||||
|
||||
const year = (det.release_date||det.first_air_date||'').slice(0,4);
|
||||
const rating = det.vote_average ? det.vote_average.toFixed(1) : '';
|
||||
const voteCount = det.vote_count ? det.vote_count.toLocaleString() : '';
|
||||
const genres = (det.genres||[]).map(g=>g.name).slice(0,4);
|
||||
const overview = det.overview || item.overview || '';
|
||||
const runtime = det.runtime ? `${det.runtime} min` : '';
|
||||
const eps = det.number_of_episodes || 0;
|
||||
const seasons = det.number_of_seasons || 0;
|
||||
const origTitle = det.original_title || det.original_name || '';
|
||||
const lang = det.original_language ? det.original_language.toUpperCase() : '';
|
||||
const status = det.status || '';
|
||||
const _pc0 = (det.production_countries||[])[0]; const country = (det.origin_country||[])[0] || (_pc0 && _pc0.iso_3166_1) || '';
|
||||
const budget = det.budget ? `$${(det.budget/1e6).toFixed(0)}M` : '';
|
||||
|
||||
// Orig title
|
||||
const origEl = document.getElementById('detOrigTitle');
|
||||
if(origEl && origTitle && origTitle !== title) origEl.textContent = origTitle;
|
||||
|
||||
// Score
|
||||
const scoreRow = inner.querySelector('.det-score-row');
|
||||
if(scoreRow){
|
||||
scoreRow.innerHTML = rating
|
||||
? `<span class="det-score">★ ${rating}</span><span class="det-score-max">/ 10</span><span class="det-votes">${voteCount} votes</span>`
|
||||
: '';
|
||||
}
|
||||
|
||||
// Pills
|
||||
const pillsEl = document.getElementById('detPills');
|
||||
if(pillsEl){
|
||||
pillsEl.innerHTML = [
|
||||
year && `<span class="det-pill strong">${year}</span>`,
|
||||
runtime && `<span class="det-pill">${runtime}</span>`,
|
||||
seasons && `<span class="det-pill">${seasons}季 ${eps}集</span>`,
|
||||
country && `<span class="det-pill">${country}</span>`,
|
||||
lang && `<span class="det-pill">${lang}</span>`,
|
||||
...genres.map(g=>`<span class="det-pill">${g}</span>`),
|
||||
].filter(Boolean).join('');
|
||||
}
|
||||
|
||||
// Overview
|
||||
const ovEl = document.getElementById('detOverview');
|
||||
if(ovEl){ ovEl.className='det-overview'; ovEl.style=''; ovEl.textContent=overview; }
|
||||
|
||||
|
||||
|
||||
// Cast
|
||||
const castData = d.status==='fulfilled' && d.value.credits ? d.value.credits
|
||||
: credits.status==='fulfilled' ? credits.value : null;
|
||||
const castList = ((castData && castData.cast)||[]).filter(c=>c.profile_path).slice(0,16);
|
||||
if(castList.length){
|
||||
const castEl = document.getElementById('detCast');
|
||||
if(castEl) castEl.innerHTML = `
|
||||
<div class="det-sec-label">主演</div>
|
||||
<div class="det-cast-rail">${castList.map(c=>`
|
||||
<div class="det-cast-card">
|
||||
<img class="det-cast-img" src="${resUrl(c.profile_path, IMG_W500)}" alt="" loading="lazy">
|
||||
<div class="det-cast-name">${esc(c.name)}</div>
|
||||
<div class="det-cast-char">${esc(c.character||'')}</div>
|
||||
</div>`).join('')}</div>`;
|
||||
}
|
||||
|
||||
// Recommendations
|
||||
const recItems = (recs.status==='fulfilled' ? ((recs.value && recs.value.results)||[]) : []).slice(0,12);
|
||||
recItems.forEach(i=>ITEMS[i.id]=i);
|
||||
const recsEl = document.getElementById('detRecs');
|
||||
if(recItems.length && recsEl){
|
||||
recsEl.innerHTML = `
|
||||
<div class="det-recs-label">猜你喜欢</div>
|
||||
<div class="det-recs-grid" id="detRecsGrid">${recItems.map(i=>cardHtml(i)).join('')}</div>`;
|
||||
requestAnimationFrame(()=>requestAnimationFrame(updateRecsGrid));
|
||||
}
|
||||
}catch(e){
|
||||
console.warn('detail load failed', e);
|
||||
}
|
||||
}
|
||||
|
||||
// ── RECS GRID:卡片宽度 = 首页同视口下的单卡像素宽 ──
|
||||
function updateRecsGrid(){
|
||||
const g = document.getElementById('detRecsGrid');
|
||||
if(!g) return;
|
||||
const vw = window.innerWidth;
|
||||
// 首页断点列数和gap
|
||||
let cols, gap;
|
||||
if(vw >= 1600){ cols=11; gap=14; }
|
||||
else if(vw >= 1200){ cols=9; gap=12; }
|
||||
else if(vw >= 900) { cols=7; gap=10; }
|
||||
else if(vw >= 640) { cols=5; gap=9; }
|
||||
else if(vw >= 480) { cols=4; gap=8; }
|
||||
else { cols=3; gap=6; }
|
||||
// 首页单张卡片像素宽
|
||||
const cardW = Math.floor((vw - 40 - gap*(cols-1)) / cols);
|
||||
g.style.setProperty('--card-w', cardW + 'px');
|
||||
g.style.gap = gap + 'px';
|
||||
}
|
||||
window.addEventListener('resize', updateRecsGrid);
|
||||
|
||||
function _doCloseDet(){
|
||||
const panel = document.getElementById('det');
|
||||
panel.classList.remove('on');
|
||||
panel.style.removeProperty('--det-hero-bg');
|
||||
_clearDetBackdrop(); // 停止背景剧照轮播
|
||||
_detContinueItem = null;
|
||||
setTimeout(()=>{ panel.scrollTop=0; document.getElementById('detInner').innerHTML=''; }, 300);
|
||||
// 不清 playbackReturn,保留给重新打开盘搜时复原用
|
||||
resetPanState();
|
||||
// 关闭详情页保持全屏(不恢复工具栏);退出全屏只在返回到主页之下时处理
|
||||
}
|
||||
|
||||
function closeDet(){
|
||||
_doCloseDet();
|
||||
}
|
||||
|
||||
// ── HISTORY 哨兵 ──
|
||||
// 栈结构示例(从底到顶):[sentinel, detail, pan]
|
||||
// 业务层各自 pushState 自己的条目;popstate 弹出后只关闭对应 UI,
|
||||
// 不再操作 history,让栈自然变浅。
|
||||
// 唯一例外:弹到最底层哨兵时,再补一条哨兵,防止 back 穿透退出 App。
|
||||
function _ensureSentinel(){
|
||||
// 仅当栈顶不是任何业务层(即已回到主页)时才补哨兵
|
||||
history.pushState({eclipse:'sentinel'}, '');
|
||||
}
|
||||
|
||||
// 页面初始化:底层哨兵
|
||||
history.replaceState({eclipse:'sentinel'}, '');
|
||||
|
||||
// popstate:Android 返回手势 / 浏览器回退 拦截
|
||||
window.addEventListener('popstate', (e)=>{
|
||||
const st = (e.state && e.state.eclipse) || '';
|
||||
const panOpen = document.getElementById('panBlock').classList.contains('on');
|
||||
const detOpen = document.getElementById('det').classList.contains('on');
|
||||
const srchOpen = document.getElementById('srch').classList.contains('on');
|
||||
const srchResultEl = document.getElementById('s-search');
|
||||
const srchResultVisible = srchResultEl && srchResultEl.style.display !== 'none';
|
||||
|
||||
// 逐层关闭:只关 UI,不操作 history(栈已被 popstate 自然弹出)
|
||||
if(panOpen){ _doClosePanSearch(); return; }
|
||||
if(detOpen){ _doCloseDet(); return; }
|
||||
if(srchOpen){ _doCloseSrch(); return; }
|
||||
if(srchResultVisible){ clearSrchResult(); return; }
|
||||
|
||||
// 没有任何层打开 = 回到了主页(弹出了底部哨兵)
|
||||
// 第一次在主页按返回:退出全屏(恢复工具栏),并补哨兵继续拦截,不退出 App
|
||||
// 工具栏已恢复后再按返回:放行给 App(退出)
|
||||
if(!_homeToolbarRestored){
|
||||
_homeToolbarRestored = true;
|
||||
try{ sdk().ui.setToolbar(true); }catch(e){}
|
||||
_ensureSentinel(); // 补哨兵,拦下这次返回
|
||||
return;
|
||||
}
|
||||
// 工具栏已恢复,正常放行(不补哨兵 = 让 App 处理返回退出)
|
||||
});
|
||||
|
||||
function detPlay(){
|
||||
if(!detData) return;
|
||||
const title = detData.title || detData.name || '';
|
||||
if(!title){ toast('没有影片标题'); return; }
|
||||
if(!window.fm){ toast('播放功能需在 App 内使用'); return; }
|
||||
try{
|
||||
// 与 demo 一致:发起原生聚合搜索并直接播放(direct:true)
|
||||
// App 会在各站源搜索该片名并自动进入播放,而非用 TMDB id 点播某个固定站点
|
||||
const r = sdk().search(title, { direct: true });
|
||||
if(r && r.catch) r.catch(e=>toast('搜索播放失败:'+(e.message||'unknown')));
|
||||
toast('已发起搜索播放');
|
||||
}catch(e){
|
||||
toast('搜索播放失败:'+(e.message||'unknown'));
|
||||
}
|
||||
}
|
||||
|
||||
// ── 继续观看(移植自 demo,适配 TMDB detData)──
|
||||
let _detContinueItem = null; // 当前详情匹配到的历史记录
|
||||
|
||||
// 标题归一化:去季/部、空格、标点,便于模糊匹配
|
||||
function _normTitle(t){
|
||||
return String(t||'')
|
||||
.toLowerCase()
|
||||
.replace(/[第][一二三四五六七八九十0-9]+[季部]?/g,'')
|
||||
.replace(/\s+/g,'')
|
||||
.replace(/[·::,,.。!!??'"“”‘’《》<>【】()\[\]{}_-]/g,'')
|
||||
.trim();
|
||||
}
|
||||
|
||||
// 历史项是否可续播(必须有 siteKey + vodId + 标题,排除推送/直链类型)
|
||||
function _historyRestorable(h){
|
||||
const key = String(h && h.key || '');
|
||||
const parts = key.split('@@@');
|
||||
const siteKey = (h && h.siteKey) || parts[0] || '';
|
||||
const vodId = (h && h.vodId) || parts.slice(1).join('@@@') || '';
|
||||
const title = String(h && (h.vodName||h.title||h.name) || '').trim();
|
||||
if(!siteKey || !vodId || !title) return false;
|
||||
if(siteKey === 'push_agent') return false;
|
||||
if(/^(push|https?|file|magnet|ed2k|thunder|video):/i.test(siteKey)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// 从历史项提取标准字段
|
||||
function _historyParts(h){
|
||||
const key = String(h && h.key || '');
|
||||
const parts = key.split('@@@');
|
||||
return {
|
||||
siteKey: (h && h.siteKey) || parts[0] || '',
|
||||
vodId: (h && h.vodId) || parts.slice(1).join('@@@') || '',
|
||||
title: String(h && (h.vodName||h.title||h.name) || '').trim(),
|
||||
pic: (h && (h.vodPic||h.pic)) || '',
|
||||
createTime: Number(h && h.createTime || 0)
|
||||
};
|
||||
}
|
||||
|
||||
// 判断历史记录是否匹配当前详情影片
|
||||
function _historyMatches(item, h){
|
||||
if(!item || !h || !_historyRestorable(h)) return false;
|
||||
const p = _historyParts(h);
|
||||
// TMDB id 直接匹配(历史 vodId 含 tmdb id 时)
|
||||
const tmdbId = String(item.id||'').trim();
|
||||
const vid = String(p.vodId||'').trim();
|
||||
if(tmdbId && vid && (vid === tmdbId || (/tmdb/i.test(vid) && vid.split(/[^a-z0-9]+/i).includes(tmdbId)))) return true;
|
||||
// 标题模糊匹配
|
||||
const htitle = _normTitle(p.title);
|
||||
if(!htitle) return false;
|
||||
const cands = [item.title, item.name, item.original_title, item.original_name].map(_normTitle).filter(Boolean);
|
||||
return cands.some(c=>{
|
||||
if(c === htitle) return true;
|
||||
return c.length>=4 && htitle.length>=4 && (c.includes(htitle) || htitle.includes(c));
|
||||
});
|
||||
}
|
||||
|
||||
// 详情页打开后:拉历史,匹配则显示「继续观看」
|
||||
async function _checkDetContinue(item){
|
||||
_detContinueItem = null;
|
||||
const btn = document.getElementById('detContinueBtn');
|
||||
const playText = document.getElementById('detPlayText');
|
||||
if(btn) btn.style.display = 'none';
|
||||
if(playText) playText.textContent = '搜索播放';
|
||||
if(!window.fm) return; // 浏览器预览无历史
|
||||
try{
|
||||
const list = await sdk().history();
|
||||
if(detData !== item) return; // 已切换影片
|
||||
const arr = (Array.isArray(list) ? list : [])
|
||||
.slice()
|
||||
.sort((a,b)=>Number(b&&b.createTime||0)-Number(a&&a.createTime||0));
|
||||
const match = arr.find(h=>_historyMatches(item, h));
|
||||
if(!match) return;
|
||||
_detContinueItem = _historyParts(match);
|
||||
if(btn) btn.style.display = '';
|
||||
// 命中续播时,搜索按钮降级为「搜索」+ 次要样式
|
||||
if(playText) playText.textContent = '搜索';
|
||||
const playBtn = document.getElementById('detPlayBtn');
|
||||
if(playBtn){ playBtn.classList.remove('btn-p'); playBtn.classList.add('btn-s'); }
|
||||
}catch(e){ /* 历史读取失败,保持搜索播放 */ }
|
||||
}
|
||||
|
||||
function detContinue(){
|
||||
const it = _detContinueItem;
|
||||
if(!it || !it.siteKey || !it.vodId){ toast('没有可继续观看的记录'); return; }
|
||||
if(!window.fm){ toast('播放功能需在 App 内使用'); return; }
|
||||
try{
|
||||
const r = sdk().vod(it.siteKey, it.vodId, it.title, it.pic);
|
||||
if(r && r.catch) r.catch(e=>toast('继续观看失败:'+(e.message||'unknown')));
|
||||
toast('继续观看');
|
||||
}catch(e){
|
||||
toast('继续观看失败:'+(e.message||'unknown'));
|
||||
}
|
||||
}
|
||||
|
||||
function playEp(n){ toast(`第 ${n} 集`); }
|
||||
|
||||
// ── TOAST ──
|
||||
let _tt;
|
||||
function toast(msg){
|
||||
const el = document.getElementById('toast');
|
||||
el.textContent = msg; el.classList.add('on');
|
||||
clearTimeout(_tt);
|
||||
_tt = setTimeout(()=>el.classList.remove('on'), 2000);
|
||||
}
|
||||
|
||||
// ── KEYBOARD ──
|
||||
document.addEventListener('keydown', e=>{
|
||||
const srchOpen = document.getElementById('srch').classList.contains('on');
|
||||
const detOpen = document.getElementById('det').classList.contains('on');
|
||||
if(e.key==='Escape'||e.key==='Back'){
|
||||
if(srchOpen){ closeSrch(); return; }
|
||||
if(detOpen){ closeDet(); return; }
|
||||
}
|
||||
if((e.key==='f'||e.key==='F') && !srchOpen && !detOpen){ openSrch(); }
|
||||
});
|
||||
|
||||
// ── PAN CONFIG CONSTANTS ──
|
||||
const PAN_API_BASE_DEFAULT = 'https://so.252035.xyz';
|
||||
const PAN_DISK_TYPES = [
|
||||
{id:'quark',name:'夸克'},{id:'aliyun',name:'阿里'},{id:'baidu',name:'百度'},
|
||||
{id:'uc',name:'UC'},{id:'tianyi',name:'天翼'},{id:'xunlei',name:'迅雷'},
|
||||
{id:'123',name:'123'},{id:'115',name:'115'},{id:'mobile',name:'移动'},
|
||||
{id:'pikpak',name:'PikPak'},{id:'guangya',name:'光鸭'},{id:'magnet',name:'磁力'},{id:'ed2k',name:'电驴'}
|
||||
];
|
||||
const PAN_CHECKABLE = new Set(['quark','aliyun','baidu','uc','tianyi','xunlei','123','115','mobile']);
|
||||
const PAN_DEFAULT_TYPES = ['quark','aliyun','baidu','uc','tianyi','xunlei','123','115','mobile','pikpak','magnet','ed2k'];
|
||||
const PAN_HEALTH_PRI = {ok:0,locked:1,pending:2,idle:3,unsupported:4,uncertain:5,bad:6};
|
||||
|
||||
// ── CONNECTION / PAN CONFIG STATE ──
|
||||
let panConfig = {
|
||||
apiBase: PAN_API_BASE_DEFAULT,
|
||||
diskTypes: PAN_DEFAULT_TYPES.slice(),
|
||||
user: '', pass: '', token: '', tokenExpiresAt: 0
|
||||
};
|
||||
|
||||
const connStatus = { sdk:'检测中', tmdb:'等待请求', site:'未配置', pan:'未搜索' };
|
||||
|
||||
function updateConnDot(){
|
||||
const dot = document.getElementById('connDot');
|
||||
if(!dot) return;
|
||||
const ok = connStatus.tmdb.includes('成功')||connStatus.tmdb.includes('完成');
|
||||
const bad = connStatus.tmdb.includes('失败');
|
||||
dot.className = 'dot ' + (ok ? 'ok' : bad ? 'bad' : 'warn');
|
||||
}
|
||||
|
||||
function updateConnStatus(key, val){
|
||||
connStatus[key] = val;
|
||||
const el = document.getElementById('st'+key.charAt(0).toUpperCase()+key.slice(1));
|
||||
if(el) el.textContent = val;
|
||||
updateConnDot();
|
||||
}
|
||||
|
||||
// Build disk type checkboxes
|
||||
function buildDiskGrid(){
|
||||
const grid = document.getElementById('connDiskGrid');
|
||||
if(!grid) return;
|
||||
grid.innerHTML = PAN_DISK_TYPES.map(d=>`
|
||||
<label class="conn-disk-label">
|
||||
<input type="checkbox" value="${d.id}" ${panConfig.diskTypes.includes(d.id)?'checked':''} onchange="markDiskDirty()">
|
||||
${d.name}
|
||||
</label>`).join('');
|
||||
}
|
||||
|
||||
function markDiskDirty(){ /* just let save collect values */ }
|
||||
|
||||
function readConnFormConfig(){
|
||||
const tmdbKey = (document.getElementById('tmdbKeyInp')||{}).value||'';
|
||||
const apiBase = ((document.getElementById('panBaseInp')||{}).value||'').trim() || PAN_API_BASE_DEFAULT;
|
||||
const user = ((document.getElementById('panUserInp')||{}).value||'').trim();
|
||||
const pass = ((document.getElementById('panPassInp')||{}).value||'').trim();
|
||||
const checked = Array.from(document.querySelectorAll('#connDiskGrid input[type=checkbox]:checked')).map(c=>c.value);
|
||||
return { tmdbKey, apiBase, user, pass, diskTypes: checked.length ? checked : PAN_DEFAULT_TYPES.slice() };
|
||||
}
|
||||
|
||||
function populateConnForm(){
|
||||
const b = document.getElementById('panBaseInp'); if(b) b.value = panConfig.apiBase||'';
|
||||
const u = document.getElementById('panUserInp'); if(u) u.value = panConfig.user||'';
|
||||
const p = document.getElementById('panPassInp'); if(p) p.value = panConfig.pass||'';
|
||||
buildDiskGrid();
|
||||
}
|
||||
|
||||
async function loadConnConfig(){
|
||||
try{
|
||||
try{
|
||||
const saved = await sdk().cache.get('film_lib_pan_config');
|
||||
if(saved){ const p = JSON.parse(saved); if(p) Object.assign(panConfig, p); }
|
||||
}catch(e){}
|
||||
}catch(e){}
|
||||
populateConnForm();
|
||||
}
|
||||
|
||||
async function saveConnConfig(){
|
||||
const cfg = readConnFormConfig();
|
||||
if(cfg.tmdbKey){ TMDB_KEY = cfg.tmdbKey; }
|
||||
panConfig = Object.assign(panConfig, {
|
||||
apiBase: cfg.apiBase, user: cfg.user, pass: cfg.pass, diskTypes: cfg.diskTypes,
|
||||
token:'', tokenExpiresAt:0
|
||||
});
|
||||
try{
|
||||
const str = JSON.stringify(panConfig);
|
||||
await sdk().cache.set('film_lib_pan_config', str);
|
||||
toast('配置已保存');
|
||||
updateConnStatus('pan','已配置');
|
||||
}catch(e){ toast('保存失败: '+(e.message||'')); }
|
||||
// 重新读取 driveCheck
|
||||
try{
|
||||
const cfg = await sdk().config();
|
||||
panState.checkEnabled = !!(cfg && cfg.driveCheck);
|
||||
}catch(e){}
|
||||
}
|
||||
|
||||
function setConnOpen(open){
|
||||
const dock = document.getElementById('connDock');
|
||||
const body = document.getElementById('connBody');
|
||||
const toggle = document.getElementById('connToggle');
|
||||
if(!dock||!body) return;
|
||||
dock.classList.toggle('open', open);
|
||||
body.classList.toggle('open', open);
|
||||
toggle.setAttribute('aria-expanded', open?'true':'false');
|
||||
if(open){
|
||||
populateConnForm();
|
||||
requestAnimationFrame(()=>{
|
||||
const rect = body.getBoundingClientRect();
|
||||
const pad = 8;
|
||||
let shift = 0;
|
||||
if(rect.left < pad) shift = pad - rect.left;
|
||||
if(rect.right + shift > window.innerWidth - pad) shift = window.innerWidth - pad - rect.right;
|
||||
body.style.setProperty('--conn-shift', shift+'px');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function toggleConn(e){
|
||||
if(e){ e.preventDefault(); e.stopPropagation(); }
|
||||
setConnOpen(!document.getElementById('connDock').classList.contains('open'));
|
||||
}
|
||||
|
||||
document.addEventListener('click', e=>{
|
||||
const dock = document.getElementById('connDock');
|
||||
if(dock && !dock.contains(e.target)) setConnOpen(false);
|
||||
});
|
||||
|
||||
// ── PAN SEARCH STATE ──
|
||||
const panState = {
|
||||
loading:false, keyword:'', results:[], activeType:'', health:{}, pending:{},
|
||||
queued:new Map(), inFlight:new Set(), flushTimer:0, pollTimers:[], viewToken:'',
|
||||
checkEnabled:false, observer:null,
|
||||
playbackReturn:null, focusKey:'', focusMode:''
|
||||
};
|
||||
const PAN_PLAYBACK_RETURN_TTL = 5 * 60 * 1000; // 5分钟
|
||||
|
||||
function panNormType(t){
|
||||
const v = String(t||'').trim().toLowerCase();
|
||||
if(v==='ali'||v==='alipan') return 'aliyun';
|
||||
if(v==='123pan') return '123';
|
||||
if(v==='139'||v==='caiyun') return 'mobile';
|
||||
if(v==='pikpakdrive') return 'pikpak';
|
||||
if(v==='guangyapan') return 'guangya';
|
||||
if(v==='magnetlink'||v==='magnet:?'||v==='bt') return 'magnet';
|
||||
if(v==='ed2k:') return 'ed2k';
|
||||
if(v==='other') return 'others';
|
||||
return v;
|
||||
}
|
||||
|
||||
function panDiskName(id){ return (PAN_DISK_TYPES.find(d=>d.id===panNormType(id))||{name:id}).name||id; }
|
||||
|
||||
function panApi(path){ return (panConfig.apiBase||PAN_API_BASE_DEFAULT).replace(/\/+$/,'')+path; }
|
||||
|
||||
async function ensurePanToken(){
|
||||
const cfg = panConfig;
|
||||
if(!cfg.user||!cfg.pass) return {};
|
||||
if(cfg.token && cfg.tokenExpiresAt > Date.now()+60000) return {Authorization:'Bearer '+cfg.token};
|
||||
try{
|
||||
const url = panApi('/api/auth/token');
|
||||
let d;
|
||||
if(window.fm){
|
||||
const res = await sdk().req(url, {
|
||||
method:'POST',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({username:cfg.user, password:cfg.pass}),
|
||||
responseType:'json', timeout:15
|
||||
});
|
||||
d = res.body || {};
|
||||
} else {
|
||||
const r = await fetch(url, {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:cfg.user,password:cfg.pass})});
|
||||
d = await r.json();
|
||||
}
|
||||
panConfig.token = d.token||d.access_token||'';
|
||||
panConfig.tokenExpiresAt = d.expires_at||0;
|
||||
return panConfig.token ? {Authorization:'Bearer '+panConfig.token} : {};
|
||||
}catch(e){ return {}; }
|
||||
}
|
||||
|
||||
function resetPanState(){
|
||||
if(panState.observer){ panState.observer.disconnect(); panState.observer=null; }
|
||||
panState.pollTimers.forEach(t=>clearTimeout(t));
|
||||
if(panState.flushTimer){ clearTimeout(panState.flushTimer); panState.flushTimer=0; }
|
||||
Object.assign(panState,{loading:false,keyword:'',results:[],activeType:'',health:{},pending:{},viewToken:'',pollTimers:[],focusKey:'',focusMode:''});
|
||||
panState.queued.clear(); panState.inFlight.clear();
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
// ── 长按封面:直接调用播放器搜索播放 ──
|
||||
function lpLongPress(itemId){
|
||||
const item = ITEMS[itemId];
|
||||
if(!item) return;
|
||||
const title = item.title || item.name || '';
|
||||
if(!title) return;
|
||||
if(!window.fm){ toast('播放功能需在 App 内使用'); return; }
|
||||
try{
|
||||
const r = sdk().search(title, { direct: true });
|
||||
if(r && r.catch) r.catch(e=>toast('搜索播放失败:'+(e.message||'unknown')));
|
||||
toast('已发起搜索播放');
|
||||
}catch(e){
|
||||
toast('搜索播放失败:'+(e.message||'unknown'));
|
||||
}
|
||||
}
|
||||
|
||||
async function openPanSearch(){
|
||||
const kw = detData ? (detData.title||detData.name||'') : '';
|
||||
if(!kw){ toast('没有影片标题'); return; }
|
||||
document.getElementById('panBlock').classList.add('on');
|
||||
history.pushState({eclipse:'pan'}, '');
|
||||
|
||||
// 同一部影片已有结果 → 直接复原,不重复搜索(官方 demo 同款逻辑)
|
||||
if(panState.keyword === kw && panState.results.length > 0){
|
||||
document.getElementById('panHint').textContent = kw + ' · 共 ' + panState.results.length + ' 条';
|
||||
renderPanUI();
|
||||
// 尝试恢复上次的滚动位置和焦点
|
||||
await restorePanPlaybackReturn();
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('panList').innerHTML = '<div class="pan-empty">搜索中…</div>';
|
||||
document.getElementById('panTabs').innerHTML = '';
|
||||
document.getElementById('panHint').textContent = kw + ' · 搜索中';
|
||||
doSearchPan(kw);
|
||||
}
|
||||
|
||||
function _doClosePanSearch(){
|
||||
document.getElementById('panBlock').classList.remove('on');
|
||||
// 只停止轮询/检测,保留 results + keyword 供下次打开复原
|
||||
if(panState.observer){ panState.observer.disconnect(); panState.observer=null; }
|
||||
panState.pollTimers.forEach(t=>clearTimeout(t));
|
||||
if(panState.flushTimer){ clearTimeout(panState.flushTimer); panState.flushTimer=0; }
|
||||
panState.loading = false;
|
||||
panState.viewToken = '';
|
||||
panState.queued.clear(); panState.inFlight.clear();
|
||||
}
|
||||
function closePanSearch(){
|
||||
_doClosePanSearch();
|
||||
}
|
||||
|
||||
// 统一 HTTP 请求:App 内优先用 fm.req(绕 CORS),浏览器预览降级 fetch
|
||||
async function panFetch(url, payload, headers){
|
||||
const body = JSON.stringify(payload);
|
||||
const reqHeaders = Object.assign({'Content-Type':'application/json'}, headers);
|
||||
|
||||
// App 环境:用 fm.req 走 Native OkHttp
|
||||
if(window.fm){
|
||||
try{
|
||||
const res = await sdk().req(url, {
|
||||
method: 'POST',
|
||||
headers: reqHeaders,
|
||||
body,
|
||||
responseType: 'json',
|
||||
timeout: 30
|
||||
});
|
||||
if(!res.ok) throw new Error('HTTP ' + res.status);
|
||||
return res.body;
|
||||
}catch(e){ throw e; }
|
||||
}
|
||||
|
||||
// 浏览器预览降级 fetch
|
||||
const resp = await fetch(url, {method:'POST', headers:reqHeaders, body});
|
||||
if(!resp.ok) throw new Error('HTTP ' + resp.status);
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
async function doSearchPan(kw){
|
||||
resetPanState();
|
||||
panState.loading = true;
|
||||
panState.keyword = kw;
|
||||
const token = 'pan-'+Date.now();
|
||||
panState.viewToken = token;
|
||||
updateConnStatus('pan','搜索 '+kw);
|
||||
try{
|
||||
const authHeaders = await ensurePanToken();
|
||||
const payload = {kw, res:'merge', src:'all', cloud_types: panConfig.diskTypes.map(panNormType).filter(Boolean)};
|
||||
const body = await panFetch(panApi('/api/search'), payload, authHeaders);
|
||||
if(panState.viewToken!==token) return;
|
||||
const results = applyPanBody(body, kw);
|
||||
updateConnStatus('pan', results.length ? '找到 '+results.length+' 条' : '无资源');
|
||||
renderPanUI();
|
||||
// 轮询两次补充结果
|
||||
[4500, 9000].forEach((delay,i)=>{
|
||||
const t = setTimeout(()=>pollPan(token, kw, i+1), delay);
|
||||
panState.pollTimers.push(t);
|
||||
});
|
||||
}catch(e){
|
||||
if(panState.viewToken!==token) return;
|
||||
panState.results = [];
|
||||
const list = document.getElementById('panList');
|
||||
if(list) list.innerHTML = '<div class="pan-empty">盘搜失败:'+esc(e.message||'网络错误')+'</div>';
|
||||
updateConnStatus('pan','搜索失败');
|
||||
}finally{
|
||||
if(panState.viewToken===token) panState.loading=false;
|
||||
}
|
||||
}
|
||||
|
||||
async function pollPan(token, kw, round){
|
||||
if(panState.viewToken!==token||!panState.keyword) return;
|
||||
try{
|
||||
updateConnStatus('pan','更新中 '+round);
|
||||
const authHeaders = await ensurePanToken();
|
||||
const payload = {kw, res:'merge', src:'all', cloud_types: panConfig.diskTypes.map(panNormType).filter(Boolean)};
|
||||
const body = await panFetch(panApi('/api/search'), payload, authHeaders);
|
||||
if(panState.viewToken!==token) return;
|
||||
const before = panState.results.length;
|
||||
const results = applyPanBody(body, kw);
|
||||
const added = results.length - before;
|
||||
updateConnStatus('pan', added>0 ? `新增${added}条共${results.length}` : `已更新${results.length}条`);
|
||||
renderPanUI();
|
||||
}catch(e){ if(panState.viewToken===token) updateConnStatus('pan','更新失败'); }
|
||||
}
|
||||
|
||||
// ── PAN RELEVANCE SCORING ──
|
||||
|
||||
// 可信来源站点列表(部分已知盘搜源)
|
||||
const PAN_TRUSTED_SOURCES = new Set([
|
||||
'nostr','nostr推荐','so.252035.xyz','xiaobaipan','小白盘','阿虎盘搜',
|
||||
'panso','盘搜搜','xiaoxiaopan','小小盘','pansearch','labi','猫盘'
|
||||
]);
|
||||
|
||||
/**
|
||||
* 计算单条盘搜结果的相关性得分
|
||||
* @param {object} item - 盘搜结果条目
|
||||
* @param {string} keyword - 当前搜索关键词
|
||||
* @param {string} [year] - 影片年份(4位字符串,可选)
|
||||
* @returns {number} score
|
||||
*/
|
||||
function relevanceScore(item, keyword, year){
|
||||
const raw = String(item.title||'').trim();
|
||||
const title = raw.toLowerCase();
|
||||
const kw = String(keyword||'').trim().toLowerCase();
|
||||
let score = 0;
|
||||
|
||||
if(!kw) return 0;
|
||||
|
||||
// ── 标题完全匹配 +100 ──
|
||||
if(title === kw) score += 100;
|
||||
|
||||
// ── 标题包含关键词 +50 ──
|
||||
else if(title.includes(kw)) score += 50;
|
||||
|
||||
// ── 标题以关键词开头 +30(在"包含"之外额外加分)──
|
||||
if(title.startsWith(kw)) score += 30;
|
||||
|
||||
// ── 年份匹配 +20 ──
|
||||
if(year && raw.includes(year)) score += 20;
|
||||
|
||||
// ── 来源可信 +10 ──
|
||||
const src = String(item.source||'').trim().toLowerCase();
|
||||
if(src && PAN_TRUSTED_SOURCES.has(src)) score += 10;
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
/**
|
||||
* 垃圾结果过滤
|
||||
* - 无标题资源
|
||||
* - 标题长度 < 2
|
||||
* - 标题仅数字
|
||||
*/
|
||||
function isPanJunk(item){
|
||||
const t = String(item.title||'').trim();
|
||||
if(!t || t.length < 2) return true;
|
||||
if(/^\d+$/.test(t)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function applyPanBody(body, kw){
|
||||
const data = body&&body.data ? body.data : body;
|
||||
const merged = data&&data.merged_by_type || {};
|
||||
const enabled = new Set(panConfig.diskTypes.map(panNormType));
|
||||
// 从 detData 取年份用于评分(TMDB release_date 格式 "2023-07-15")
|
||||
const year = detData && ((detData.release_date||detData.first_air_date||'').slice(0,4)) || '';
|
||||
const seen = new Set();
|
||||
const incoming = [];
|
||||
Object.entries(merged).forEach(([type,links])=>{
|
||||
const dt = panNormType(type);
|
||||
if(!enabled.has(dt)||!Array.isArray(links)) return;
|
||||
links.forEach((link,i)=>{
|
||||
const url = String(link&&(link.url||link.link||link.href)||'').trim();
|
||||
if(!url) return;
|
||||
const key = dt+'|'+url;
|
||||
if(seen.has(key)) return;
|
||||
seen.add(key);
|
||||
const title = String(link.note||link.name||link.title||link.work_title||kw||'盘搜资源').trim();
|
||||
const entry = {
|
||||
key,
|
||||
diskType: dt,
|
||||
url,
|
||||
password: String(link.password||link.pwd||link.passcode||link.code||'').trim(),
|
||||
title,
|
||||
source: String(link.source||link.channel||'').trim(),
|
||||
normalizedUrl: String(link.normalized_url||link.normalizedUrl||'').trim(),
|
||||
updatedAt: Number(link.updated_at||link.updatedAt||link.created_at||0),
|
||||
_rawIndex: incoming.length + i/1000,
|
||||
};
|
||||
// 挂载相关性得分(供排序和调试用)
|
||||
entry._score = relevanceScore(entry, kw, year);
|
||||
incoming.push(entry);
|
||||
});
|
||||
});
|
||||
|
||||
// ── 去重合并 ──
|
||||
const map = new Map(panState.results.map(r=>[r.key,r]));
|
||||
incoming.forEach(r=>{
|
||||
const ex = map.get(r.key);
|
||||
if(ex){
|
||||
// 更新可变字段,但保留已有健康状态关联 key
|
||||
Object.assign(ex, r);
|
||||
} else {
|
||||
map.set(r.key, r);
|
||||
}
|
||||
});
|
||||
|
||||
// ── 垃圾过滤 ──
|
||||
let all = Array.from(map.values()).filter(r => !isPanJunk(r));
|
||||
|
||||
panState.results = all;
|
||||
panState._scoreKeyword = kw;
|
||||
ensurePanActiveType();
|
||||
return panState.results;
|
||||
}
|
||||
|
||||
function ensurePanActiveType(){
|
||||
const types = getPanTypes();
|
||||
if(!types.length){ panState.activeType=''; return ''; }
|
||||
if(!panState.activeType||!types.some(t=>t.id===panState.activeType)) panState.activeType=types[0].id;
|
||||
return panState.activeType;
|
||||
}
|
||||
|
||||
function getPanTypes(){
|
||||
const counts = new Map();
|
||||
panState.results.forEach(r=>{ const t=panNormType(r.diskType); if(t) counts.set(t,(counts.get(t)||0)+1); });
|
||||
return PAN_DISK_TYPES.filter(d=>counts.has(d.id)).map(d=>({id:d.id,name:d.name,count:counts.get(d.id)||0}));
|
||||
}
|
||||
|
||||
function renderPanUI(){
|
||||
const tabs = document.getElementById('panTabs');
|
||||
const list = document.getElementById('panList');
|
||||
const hint = document.getElementById('panHint');
|
||||
if(!tabs||!list) return;
|
||||
const types = getPanTypes();
|
||||
const active = ensurePanActiveType();
|
||||
// tabs
|
||||
tabs.innerHTML = types.map(t=>`
|
||||
<button class="pan-chip${t.id===active?' on':''}" onclick="selectPanType('${esc(t.id)}')">${esc(t.name)} ${t.count}</button>`).join('');
|
||||
// hint
|
||||
if(hint) hint.textContent = panState.keyword ? `${panState.keyword} · 共 ${panState.results.length} 条` : '';
|
||||
|
||||
// ── 多维排序 ──
|
||||
// 1. _score DESC(相关性)
|
||||
// 2. health state ASC(ok=0 最优)
|
||||
// 3. updatedAt DESC(越新越靠前)
|
||||
// 前20条只展示高相关(_score > 0),其余补充在后面
|
||||
const filtered = panState.results.filter(r => !active || panNormType(r.diskType) === active);
|
||||
|
||||
const getHealthPri = r => {
|
||||
const h = panState.health[panHealthKey(r)];
|
||||
var _pri = PAN_HEALTH_PRI[(h && h.state) || 'idle']; return (_pri===undefined||_pri===null) ? 99 : _pri;
|
||||
};
|
||||
|
||||
const sortFn = (a, b) => {
|
||||
// score DESC
|
||||
const sd = (b._score || 0) - (a._score || 0);
|
||||
if(sd !== 0) return sd;
|
||||
// health ASC(ok 优先)
|
||||
const hd = getHealthPri(a) - getHealthPri(b);
|
||||
if(hd !== 0) return hd;
|
||||
// updatedAt DESC
|
||||
return (b.updatedAt || 0) - (a.updatedAt || 0);
|
||||
};
|
||||
|
||||
// 分高相关(score > 0)和低相关(score === 0)两组分别排序,然后合并
|
||||
// 前20条取高相关,超出部分 + 零分全部追加到后面
|
||||
const highRel = filtered.filter(r => (r._score || 0) > 0).sort(sortFn);
|
||||
const lowRel = filtered.filter(r => !(r._score || 0)).sort(sortFn);
|
||||
const top20 = highRel.slice(0, 20);
|
||||
const rest = [...highRel.slice(20), ...lowRel];
|
||||
const items = [...top20, ...rest];
|
||||
|
||||
if(!items.length){
|
||||
list.innerHTML = '<div class="pan-empty">当前网盘暂无资源</div>';
|
||||
return;
|
||||
}
|
||||
const renderKey = items.map(i=>panHealthKey(i)+'='+(panState.health[panHealthKey(i)]||{}).state+'='+i._score).join('|');
|
||||
if(list.dataset.renderKey === renderKey) return;
|
||||
list.dataset.renderKey = renderKey;
|
||||
|
||||
// 官方 demo 方式:用 addEventListener 传真实 item 对象,避免 innerHTML 内联 onclick 的双重转义问题
|
||||
while(list.firstChild)list.removeChild(list.firstChild);
|
||||
items.forEach(item=>{
|
||||
const hk = panHealthKey(item);
|
||||
const h = panState.health[hk] || {state:'idle'};
|
||||
const canCheck = isPanCheckable(item);
|
||||
const isPending = !!panState.pending[hk] || panState.inFlight.has(hk);
|
||||
const dotState = isPending ? 'pending' : h.state;
|
||||
const healthDot = canCheck
|
||||
? `<span class="pan-health ${esc(dotState)}" title="${esc(panHealthLabel(dotState))}"></span>`
|
||||
: '';
|
||||
const isBad = h.state === 'bad';
|
||||
const isFocus = panState.focusKey === hk;
|
||||
const scoreTag = (item._score > 0)
|
||||
? `<span style="font-size:10px;color:var(--accent);opacity:.6;margin-left:4px">${item._score}分</span>`
|
||||
: '';
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'pan-item' + (isBad ? ' bad' : '');
|
||||
btn.dataset.panKey = hk;
|
||||
btn.tabIndex = 0;
|
||||
if(isFocus) btn.autofocus = true;
|
||||
btn.innerHTML = `
|
||||
<div class="pan-item-title"><span>${esc(item.title||panState.keyword||'盘搜资源')}</span>${scoreTag}${healthDot}</div>
|
||||
<div class="pan-item-meta">${esc(panDiskName(item.diskType))}${item.password?' · 提取码 '+esc(item.password):''}${item.source?' · '+esc(item.source):''}</div>
|
||||
<div class="pan-item-url">${esc(item.normalizedUrl||item.url)}</div>`;
|
||||
btn.addEventListener('focus', ()=>{ panState.focusKey = hk; panState.focusMode = 'results'; });
|
||||
// 直接绑定 item 对象,与官方 demo 完全一致,绕开 key 序列化/反序列化问题
|
||||
btn.addEventListener('click', ()=> playPanResult(item));
|
||||
list.appendChild(btn);
|
||||
});
|
||||
|
||||
// 启动可见性检测
|
||||
observePanVisibleItems();
|
||||
}
|
||||
|
||||
function panHealthLabel(s){
|
||||
return ({ok:'链接有效',bad:'链接失效',locked:'需要提取码',unsupported:'暂不支持',uncertain:'结果不确定',pending:'检测中',idle:'未检测'})[s]||'未检测';
|
||||
}
|
||||
|
||||
function selectPanType(typeId){
|
||||
panState.activeType = typeId;
|
||||
renderPanUI();
|
||||
}
|
||||
|
||||
// ── PAN PLAYBACK RETURN ──
|
||||
async function rememberPanPlaybackReturn(item){
|
||||
const list = document.getElementById('panList');
|
||||
const det = document.getElementById('det');
|
||||
const hk = panHealthKey(item);
|
||||
panState.playbackReturn = {
|
||||
key: hk,
|
||||
activeType: panState.activeType || '',
|
||||
keyword: panState.keyword || '',
|
||||
detScrollTop: det ? Math.round(det.scrollTop || 0) : 0,
|
||||
listScrollTop: list ? Math.round(list.scrollTop || 0) : 0,
|
||||
at: Date.now()
|
||||
};
|
||||
panState.focusKey = hk;
|
||||
panState.focusMode = 'results';
|
||||
try{
|
||||
const str = JSON.stringify(panState.playbackReturn);
|
||||
sdk().cache.set('film_lib_pan_return', str).catch(()=>{});
|
||||
}catch(e){}
|
||||
}
|
||||
|
||||
async function restorePanPlaybackReturn(){
|
||||
// 尝试从缓存读取
|
||||
let saved = panState.playbackReturn;
|
||||
if(!saved){
|
||||
try{
|
||||
let str;
|
||||
str = await sdk().cache.get('film_lib_pan_return').catch(()=>null);
|
||||
if(str) saved = JSON.parse(str);
|
||||
}catch(e){}
|
||||
}
|
||||
if(!saved) return false;
|
||||
if(Date.now() - Number(saved.at||0) > PAN_PLAYBACK_RETURN_TTL){
|
||||
panState.playbackReturn = null;
|
||||
return false;
|
||||
}
|
||||
// 需要detail页和pan面板都在打开状态
|
||||
const det = document.getElementById('det');
|
||||
const block = document.getElementById('panBlock');
|
||||
if(!det || !det.classList.contains('on') || !block || !block.classList.contains('on')) return false;
|
||||
|
||||
if(saved.activeType) panState.activeType = saved.activeType;
|
||||
panState.focusKey = saved.key || panState.focusKey || '';
|
||||
panState.focusMode = 'results';
|
||||
renderPanUI();
|
||||
|
||||
const apply = ()=>{
|
||||
const d = document.getElementById('det');
|
||||
const l = document.getElementById('panList');
|
||||
if(d) d.scrollTop = Number(saved.detScrollTop || 0);
|
||||
if(l) l.scrollTop = Number(saved.listScrollTop || 0);
|
||||
// 恢复焦点到上次点击的条目
|
||||
if(saved.key && l){
|
||||
const target = l.querySelector(`[data-pan-key="${CSS.escape(saved.key)}"]`);
|
||||
if(target) target.focus();
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(apply);
|
||||
setTimeout(apply, 80);
|
||||
setTimeout(apply, 240);
|
||||
panState.playbackReturn = null;
|
||||
// 清理缓存
|
||||
try{
|
||||
sdk().cache.set('film_lib_pan_return','').catch(()=>{});
|
||||
}catch(e){}
|
||||
// 播放器返回时原生层可能消耗了一次 history.back,
|
||||
// 若栈顶已不是 pan 条目,补回一条,保证 UI(盘搜面板) 与栈一致,
|
||||
// 这样后续返回手势能逐层关闭:pan → detail → 主页
|
||||
if(!history.state || history.state.eclipse !== 'pan'){
|
||||
history.pushState({eclipse:'pan'}, '');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── PAN PLAY → push_agent/pvideo 链路 ──
|
||||
function buildPanPlayPayload(item){
|
||||
let url = String(item && (item.normalizedUrl || item.normalized_url || item.url) || '').trim();
|
||||
if(!url) return null;
|
||||
// 去掉 push:// 前缀(App内部会处理,但统一清理)
|
||||
if(/^push:\/\//i.test(url)) url = url.replace(/^push:\/\//i,'');
|
||||
return {
|
||||
type: panNormType(item.diskType),
|
||||
url,
|
||||
password: item.password || '',
|
||||
title: item.title || panState.keyword || url
|
||||
};
|
||||
}
|
||||
|
||||
async function playPanItem(hkEsc){
|
||||
// hkEsc 是 panHealthKey(item) 经 esc() 编码后的值
|
||||
const hk = hkEsc.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,"'");
|
||||
const item = panState.results.find(r=>panHealthKey(r)===hk);
|
||||
if(!item){ toast('资源不存在'); return; }
|
||||
await playPanResult(item);
|
||||
}
|
||||
|
||||
// 与官方 demo 完全一致的播放入口 —— 始终通过 sdk() 调用
|
||||
async function playPanResult(item){
|
||||
const payload = buildPanPlayPayload(item);
|
||||
if(!payload){ toast('无效链接'); return; }
|
||||
|
||||
// ── 诊断日志(生产环境可按需移除)──
|
||||
console.log('[Eclipse] window.fm =', window.fm);
|
||||
console.log('[Eclipse] sdk() =', sdk());
|
||||
console.log('[Eclipse] typeof sdk().pan.play =', typeof (sdk().pan && sdk().pan.play));
|
||||
console.log('[Eclipse] playPanResult payload =', payload);
|
||||
|
||||
// ── Native SDK 检测:进入 fallback 时明确提示,禁止静默失败 ──
|
||||
if(!window.fm){
|
||||
const msg = 'Native SDK 未注入,请在 FongMi App 内使用盘搜播放功能。';
|
||||
console.error('[Eclipse]', msg);
|
||||
toast(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
await rememberPanPlaybackReturn(item);
|
||||
try{
|
||||
const pan = sdk().pan || {};
|
||||
console.log('[Eclipse] pan.play exists =', typeof pan.play);
|
||||
if(typeof pan.play !== 'function') throw new Error('当前 App 不支持 pan.play(' + typeof pan.play + ')');
|
||||
await pan.play(payload);
|
||||
toast('已交给原生播放');
|
||||
}catch(e){
|
||||
console.error('[Eclipse] pan.play 调用失败:', e);
|
||||
toast('播放失败:'+(e.message||'unknown'));
|
||||
}
|
||||
}
|
||||
|
||||
// ── PAN CHECK:完整队列式 IntersectionObserver 实现 ──
|
||||
function panHealthKey(item){
|
||||
return panNormType(item.diskType)+'|'+(item.normalizedUrl||item.url||'');
|
||||
}
|
||||
function isPanCheckable(item){
|
||||
return PAN_CHECKABLE.has(panNormType(item.diskType));
|
||||
}
|
||||
function canCheckPanLinks(){
|
||||
// checkEnabled는 init에서 세팅되지만, fm.pan.check가 있으면 최소한 시도 가능
|
||||
if(!window.fm) return false;
|
||||
try{
|
||||
const pan = sdk().pan || {};
|
||||
if(typeof pan.check !== 'function') return false;
|
||||
if(panState.checkEnabled === false) return false;
|
||||
return true;
|
||||
}catch(e){ return false; }
|
||||
}
|
||||
|
||||
function queuePanCheck(item){
|
||||
if(!item || !canCheckPanLinks() || !isPanCheckable(item)) return;
|
||||
const hk = panHealthKey(item);
|
||||
if(!hk || panState.health[hk] || panState.pending[hk] || panState.queued.has(hk) || panState.inFlight.has(hk)) return;
|
||||
panState.queued.set(hk, item);
|
||||
schedulePanCheckFlush();
|
||||
}
|
||||
|
||||
function schedulePanCheckFlush(){
|
||||
if(panState.flushTimer) return;
|
||||
panState.flushTimer = setTimeout(()=>{ panState.flushTimer=0; flushPanCheckQueue(); }, 180);
|
||||
}
|
||||
|
||||
async function flushPanCheckQueue(){
|
||||
if(!canCheckPanLinks()){ panState.queued.clear(); return; }
|
||||
if(!panState.queued.size) return;
|
||||
const entries = Array.from(panState.queued.entries()).slice(0,10);
|
||||
entries.forEach(([hk])=>panState.queued.delete(hk));
|
||||
entries.forEach(([hk])=>{ panState.pending[hk]=true; panState.inFlight.add(hk); });
|
||||
renderPanUI();
|
||||
try{
|
||||
const pan = sdk().pan || {};
|
||||
if(!pan.check) throw new Error('当前 App 不支持 pan.check');
|
||||
const response = await pan.check(entries.map(([,item])=>({type:item.diskType,url:item.url,password:item.password})));
|
||||
const results = response && Array.isArray(response.results) ? response.results : [];
|
||||
entries.forEach(([origHk,fallback],i)=>{
|
||||
const res = results[i] || {};
|
||||
const normUrl = String(res.normalized_url||res.normalizedUrl||'').trim();
|
||||
if(normUrl) fallback.normalizedUrl = normUrl;
|
||||
const health = {
|
||||
state: res.state || 'uncertain',
|
||||
summary: res.summary || '',
|
||||
checkedAt: res.checked_at || Date.now(),
|
||||
expiresAt: res.expires_at || Date.now() + 300000
|
||||
};
|
||||
panState.health[origHk] = health;
|
||||
// 也用新 normalizedUrl 的 key 存一份
|
||||
const newHk = panHealthKey({diskType: panNormType(res.type||fallback.diskType), url: res.url||fallback.url});
|
||||
if(newHk && newHk !== origHk) panState.health[newHk] = health;
|
||||
});
|
||||
const checked = panState.results.filter(r=>isPanCheckable(r)&&!!panState.health[panHealthKey(r)]).length;
|
||||
const total = panState.results.filter(isPanCheckable).length;
|
||||
updateConnStatus('pan', `已检测 ${checked}/${total}`);
|
||||
}catch(e){
|
||||
entries.forEach(([,item])=>{
|
||||
panState.health[panHealthKey(item)] = {state:'uncertain',summary:e.message||'检测失败',checkedAt:Date.now(),expiresAt:Date.now()+300000};
|
||||
});
|
||||
}finally{
|
||||
entries.forEach(([hk])=>{ delete panState.pending[hk]; panState.inFlight.delete(hk); });
|
||||
renderPanUI();
|
||||
scheduleVisiblePanCheck();
|
||||
if(panState.queued.size) schedulePanCheckFlush();
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleVisiblePanCheck(){
|
||||
clearTimeout(scheduleVisiblePanCheck._t);
|
||||
scheduleVisiblePanCheck._t = setTimeout(queueVisiblePanChecks, 90);
|
||||
}
|
||||
|
||||
function queueVisiblePanChecks(){
|
||||
if(!canCheckPanLinks()) return;
|
||||
const list = document.getElementById('panList');
|
||||
if(!list) return;
|
||||
const listRect = list.getBoundingClientRect();
|
||||
const bottomLimit = listRect.bottom + Math.max(80, listRect.height * .4);
|
||||
Array.from(list.querySelectorAll('.pan-item')).some(node=>{
|
||||
const rect = node.getBoundingClientRect();
|
||||
if(rect.bottom < listRect.top || rect.top > bottomLimit) return false;
|
||||
const hk = node.dataset.panKey;
|
||||
const item = panState.results.find(r=>panHealthKey(r)===hk);
|
||||
queuePanCheck(item);
|
||||
return panState.queued.size + panState.inFlight.size >= 10;
|
||||
});
|
||||
}
|
||||
|
||||
function observePanVisibleItems(){
|
||||
const list = document.getElementById('panList');
|
||||
if(panState.observer) panState.observer.disconnect();
|
||||
if(!canCheckPanLinks() || !list || !('IntersectionObserver' in window)){
|
||||
queueVisiblePanChecks();
|
||||
return;
|
||||
}
|
||||
panState.observer = new IntersectionObserver(entries=>{
|
||||
entries.forEach(e=>{
|
||||
if(!e.isIntersecting || e.intersectionRatio < 0.25) return;
|
||||
const hk = e.target.dataset.panKey;
|
||||
const item = panState.results.find(r=>panHealthKey(r)===hk);
|
||||
queuePanCheck(item);
|
||||
});
|
||||
},{root:list, threshold:[0.25, 0.6]});
|
||||
list.querySelectorAll('.pan-item').forEach(node=>panState.observer.observe(node));
|
||||
scheduleVisiblePanCheck();
|
||||
}
|
||||
|
||||
function checkVisiblePanItems(items){
|
||||
// 兼容旧调用点,现在由 observePanVisibleItems 统一管理
|
||||
observePanVisibleItems();
|
||||
}
|
||||
|
||||
// ── RESUME (App background restore) ──
|
||||
window.addEventListener('fmresume', async ()=>{
|
||||
const restored = await restorePanPlaybackReturn();
|
||||
if(!restored && window.fm){
|
||||
sdk().history().then(hist=>{
|
||||
if(!hist||!hist.length) return;
|
||||
}).catch(()=>{});
|
||||
}
|
||||
});
|
||||
|
||||
// ── INIT ──
|
||||
|
||||
// ── RELAY MIRROR (官方 demo 完整移植) ──
|
||||
const RELAY_MIRROR_PAGE_LIMIT = 1000;
|
||||
const RELAY_MIRROR_MAX_PAGES_PER_SOURCE = 120;
|
||||
|
||||
// 默认源 relay 列表(与官方 demo 一致)
|
||||
const DEFAULT_MIRROR_RELAYS = [
|
||||
'wss://relay-sgp.signedbyme.com',
|
||||
'wss://kotukonostr.onrender.com',
|
||||
'wss://nostr.spicyz.io',
|
||||
'wss://relay.wellorder.net'
|
||||
];
|
||||
|
||||
function normalizeRelayUrl(value){
|
||||
const text = String(value||'').trim();
|
||||
if(!text) return '';
|
||||
try{
|
||||
const url = new URL(text);
|
||||
if(url.protocol!=='wss:' && url.protocol!=='ws:') return '';
|
||||
url.hash = '';
|
||||
return url.toString().replace(/\/$/,'');
|
||||
}catch(e){ return ''; }
|
||||
}
|
||||
|
||||
function parseRelayList(value){
|
||||
return Array.from(new Set(String(value||'')
|
||||
.split(/[\s,,]+/)
|
||||
.map(normalizeRelayUrl)
|
||||
.filter(Boolean)));
|
||||
}
|
||||
|
||||
function shortRelay(url){
|
||||
return String(url||'').replace(/^wss?:\/\//,'').replace(/\/$/, '');
|
||||
}
|
||||
|
||||
function setupMirrorDefaults(){
|
||||
const src = document.getElementById('mirrorSourceInput');
|
||||
const tgt = document.getElementById('mirrorTargetInput');
|
||||
if(src && !src.value.trim()) src.value = DEFAULT_MIRROR_RELAYS.join('\n');
|
||||
if(tgt) tgt.value = tgt.value || '';
|
||||
// enable editing on click (官方 disablePanelTextEditing 模式)
|
||||
[src, tgt].forEach(el=>{
|
||||
if(!el) return;
|
||||
el.readOnly = true;
|
||||
el.addEventListener('click', ()=>{ el.readOnly=false; el.focus(); });
|
||||
el.addEventListener('blur', ()=>{ el.readOnly=true; });
|
||||
});
|
||||
}
|
||||
|
||||
function setMirrorProgress(lines){
|
||||
const el = document.getElementById('mirrorProgress');
|
||||
if(!el) return;
|
||||
el.textContent = Array.isArray(lines)
|
||||
? lines.filter(Boolean).join('\n')
|
||||
: String(lines||'镜像未执行');
|
||||
}
|
||||
|
||||
// 判断一个 Nostr 事件是否可镜像(保留接口,具体逻辑视 nostr 集成而定)
|
||||
function isMirrorableEvent(event){
|
||||
return !!(event && event.id && event.pubkey && event.sig);
|
||||
}
|
||||
|
||||
function mirrorEventKey(event){
|
||||
// 优先用 d-tag 地址作 key,没有则用 id
|
||||
const d = event && event.tags && event.tags.find(t=>t[0]==='d');
|
||||
if(d && d[1]) return (event.kind||'') + ':' + event.pubkey + ':' + d[1];
|
||||
return String(event && event.id || '');
|
||||
}
|
||||
|
||||
function mirrorEventIsNewer(next, old){
|
||||
if(!old) return true;
|
||||
const nc = Number(next && next.created_at || 0);
|
||||
const oc = Number(old && old.created_at || 0);
|
||||
if(nc !== oc) return nc > oc;
|
||||
return String(next && next.id||'') > String(old && old.id||'');
|
||||
}
|
||||
|
||||
// 从单个 relay 拉取事件(WebSocket REQ 协议)
|
||||
async function queryMirrorEventsFromRelay(relay, since, progressNote){
|
||||
const byId = new Map();
|
||||
let until = Math.floor(Date.now()/1000);
|
||||
let pages = 0;
|
||||
const LIMIT = RELAY_MIRROR_PAGE_LIMIT;
|
||||
while(until > since && pages < RELAY_MIRROR_MAX_PAGES_PER_SOURCE){
|
||||
pages++;
|
||||
setMirrorProgress([
|
||||
`拉取中:${shortRelay(relay)}`,
|
||||
`第 ${pages} 页,已收到 ${byId.size} 条`,
|
||||
progressNote || ''
|
||||
]);
|
||||
const events = await new Promise((resolve)=>{
|
||||
let done = false;
|
||||
const finish = (evts)=>{ if(done)return; done=true; resolve(evts); };
|
||||
const collected = [];
|
||||
try{
|
||||
const ws = new WebSocket(relay);
|
||||
const subId = 'mirror_' + Math.random().toString(36).slice(2,8);
|
||||
const timer = setTimeout(()=>{ try{ws.close();}catch(e){} finish(collected); }, 12000);
|
||||
ws.onopen = ()=>{
|
||||
ws.send(JSON.stringify(['REQ', subId,
|
||||
{ kinds:[1], since, until, limit:LIMIT }
|
||||
]));
|
||||
};
|
||||
ws.onmessage = (msg)=>{
|
||||
try{
|
||||
const data = JSON.parse(msg.data);
|
||||
if(data[0]==='EVENT' && data[1]===subId && data[2]) collected.push(data[2]);
|
||||
if(data[0]==='EOSE' && data[1]===subId){ clearTimeout(timer); try{ws.close();}catch(e){} finish(collected); }
|
||||
}catch(e){}
|
||||
};
|
||||
ws.onerror = ()=>{ clearTimeout(timer); finish(collected); };
|
||||
ws.onclose = ()=>finish(collected);
|
||||
}catch(e){ finish([]); }
|
||||
});
|
||||
events.filter(isMirrorableEvent).forEach(ev=>byId.set(ev.id, ev));
|
||||
if(!events.length) break;
|
||||
collected_complete = events.length >= LIMIT;
|
||||
let oldest = until;
|
||||
for(const ev of events) oldest = Math.min(oldest, Number(ev.created_at||oldest));
|
||||
if(events.length < LIMIT || oldest >= until) break;
|
||||
until = oldest - 1;
|
||||
}
|
||||
return Array.from(byId.values());
|
||||
}
|
||||
|
||||
// 向单个 relay 发布一个事件
|
||||
async function publishMirrorEventToRelay(relay, event, timeout){
|
||||
return new Promise(resolve=>{
|
||||
let settled = false;
|
||||
const done = (ok, text)=>{ if(settled)return; settled=true; resolve({ok, text:text||''}); };
|
||||
try{
|
||||
const ws = new WebSocket(relay);
|
||||
const timer = setTimeout(()=>{ try{ws.close();}catch(e){} done(false,'超时'); }, timeout||9000);
|
||||
ws.onopen = ()=> ws.send(JSON.stringify(['EVENT', event]));
|
||||
ws.onmessage = (msg)=>{
|
||||
try{
|
||||
const data = JSON.parse(msg.data);
|
||||
if(data[0]!=='OK') return;
|
||||
clearTimeout(timer); try{ws.close();}catch(e){}
|
||||
done(!!data[2], data[2] ? 'OK' : data[3]||'拒绝');
|
||||
}catch(e){}
|
||||
};
|
||||
ws.onerror = ()=>{ clearTimeout(timer); done(false,'失败'); };
|
||||
ws.onclose = ()=> done(false,'断开');
|
||||
}catch(e){ done(false, e.message||'失败'); }
|
||||
});
|
||||
}
|
||||
|
||||
async function mirrorHotEventsToRelays(){
|
||||
if(mirrorHotEventsToRelays.busy) return;
|
||||
setupMirrorDefaults();
|
||||
const srcEl = document.getElementById('mirrorSourceInput');
|
||||
const tgtEl = document.getElementById('mirrorTargetInput');
|
||||
const sources = parseRelayList(srcEl && srcEl.value || DEFAULT_MIRROR_RELAYS.join('\n'));
|
||||
const targets = parseRelayList(tgtEl && tgtEl.value || '');
|
||||
if(!sources.length){ toast('请填写源 relay'); return; }
|
||||
if(!targets.length){ toast('请填写目标 relay'); return; }
|
||||
mirrorHotEventsToRelays.busy = true;
|
||||
const btn = document.getElementById('mirrorRelayBtn');
|
||||
if(btn) btn.textContent = '镜像中…';
|
||||
try{
|
||||
const since = Math.floor(Date.now()/1000) - 7*24*3600; // 近7天
|
||||
const eventsByAddress = new Map();
|
||||
for(const relay of sources){
|
||||
const events = await queryMirrorEventsFromRelay(relay, since,
|
||||
`源 ${sources.indexOf(relay)+1}/${sources.length} · 用户 ${eventsByAddress.size} 个`);
|
||||
events.forEach(ev=>{
|
||||
const key = mirrorEventKey(ev);
|
||||
if(key && mirrorEventIsNewer(ev, eventsByAddress.get(key))) eventsByAddress.set(key, ev);
|
||||
});
|
||||
setMirrorProgress([
|
||||
`源完成:${shortRelay(relay)}`,
|
||||
`本源有效 ${events.length} 条`,
|
||||
`按用户去重后 ${eventsByAddress.size} 条`
|
||||
]);
|
||||
}
|
||||
const events = Array.from(eventsByAddress.values())
|
||||
.sort((a,b)=>Number(b.created_at||0)-Number(a.created_at||0));
|
||||
if(!events.length){
|
||||
setMirrorProgress(['镜像完成','没有从源 relay 拉到可镜像的事件']);
|
||||
toast('没有可镜像事件'); return;
|
||||
}
|
||||
let ok=0, rejected=0, failed=0, done=0;
|
||||
for(const target of targets){
|
||||
let tOk=0, tRej=0, tFail=0;
|
||||
for(const ev of events){
|
||||
done++;
|
||||
setMirrorProgress([
|
||||
`发布中:${shortRelay(target)}`,
|
||||
`事件 ${done}/${events.length*targets.length}`,
|
||||
`总计 OK ${ok} · 拒绝 ${rejected} · 失败 ${failed}`
|
||||
]);
|
||||
const result = await publishMirrorEventToRelay(target, ev, 9000);
|
||||
if(result.ok || /duplicate|exists|already|newer|older|replace/i.test(result.text||'')){
|
||||
ok++; tOk++;
|
||||
}else if((result.text||'').includes('拒绝')){
|
||||
rejected++; tRej++;
|
||||
}else{
|
||||
failed++; tFail++;
|
||||
}
|
||||
}
|
||||
setMirrorProgress([
|
||||
`目标完成:${shortRelay(target)}`,
|
||||
`OK ${tOk} · 拒绝 ${tRej} · 失败 ${tFail}`,
|
||||
`总计 OK ${ok} · 拒绝 ${rejected} · 失败 ${failed}`
|
||||
]);
|
||||
}
|
||||
setMirrorProgress([
|
||||
'镜像完成',
|
||||
`源 relay ${sources.length} 个,目标 relay ${targets.length} 个`,
|
||||
`最新用户事件 ${events.length} 条`,
|
||||
`发布 OK ${ok} · 拒绝 ${rejected} · 失败 ${failed}`
|
||||
]);
|
||||
toast(`镜像完成:${events.length} 条事件`);
|
||||
}finally{
|
||||
mirrorHotEventsToRelays.busy = false;
|
||||
if(btn) btn.textContent = '镜像榜单';
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// TV 遥控器导航模块
|
||||
// 职责:
|
||||
// 1. 检测 TV 模式(IS_TV 或 URL ?tv=1)
|
||||
// 2. 给所有可聚焦元素加 tv-focusable 类
|
||||
// 3. 拦截方向键 / OK / 返回 做空间导航
|
||||
// 4. 搜索框特殊处理(方向键不拦截,只拦截 OK/返回)
|
||||
// 5. 完全不修改现有业务逻辑
|
||||
// ══════════════════════════════════════════════════════════
|
||||
const TV_MODE = IS_TV || /[?&]tv=1/.test(location.search);
|
||||
if(TV_MODE) document.body.classList.add('tv-mode');
|
||||
|
||||
// ── 焦点区域定义(按层级优先级) ──
|
||||
// 每个"层"是一组可以被方向键导航的元素集合
|
||||
// 当某层打开时,只在该层内导航
|
||||
|
||||
let _tvFocus = null; // 当前 tv-focus 元素
|
||||
|
||||
function _tvEl(el){
|
||||
return el && el.offsetParent !== null && !el.disabled;
|
||||
}
|
||||
|
||||
// ── 列数缓存(按视口宽度 + grid 类型)──
|
||||
// 避免每次方向键都 getComputedStyle 读 grid-template-columns
|
||||
let _tvColCache = { w:0, normal:3, land:2 };
|
||||
function _tvCols(isLand){
|
||||
const w = window.innerWidth;
|
||||
if(_tvColCache.w !== w){
|
||||
// 与 CSS 媒体查询断点保持一致
|
||||
let normal, land;
|
||||
if(w>=1600){ normal=11; land=6; }
|
||||
else if(w>=1200){ normal=9; land=6; }
|
||||
else if(w>=900){ normal=7; land=5; }
|
||||
else if(w>=640){ normal=5; land=4; }
|
||||
else if(w>=480){ normal=4; land=3; }
|
||||
else { normal=3; land=2; }
|
||||
_tvColCache = { w, normal, land };
|
||||
}
|
||||
return isLand ? _tvColCache.land : _tvColCache.normal;
|
||||
}
|
||||
|
||||
// 获取当前活跃层的所有可导航元素(仅在需要时调用,结果可缓存)
|
||||
function _tvGetItems(){
|
||||
const panOpen = document.getElementById('panBlock').classList.contains('on');
|
||||
const qpOpen = document.getElementById('quickpan') && document.getElementById('quickpan').classList.contains('on');
|
||||
const detOpen = document.getElementById('det').classList.contains('on');
|
||||
const srchOpen = document.getElementById('srch').classList.contains('on');
|
||||
const srchResultVisible = (() => {
|
||||
const el = document.getElementById('s-search');
|
||||
return el && el.style.display !== 'none';
|
||||
})();
|
||||
|
||||
if(srchOpen){
|
||||
return [...document.querySelectorAll('#srchBody .srch-suggest-item, #srchBody .srch-hot li, #srchBody .srch-hist-tag')].filter(_tvEl);
|
||||
}
|
||||
if(qpOpen){
|
||||
return [...document.querySelectorAll('#qpList .pan-item, #qpTabs .pan-chip, #quickpan .qp-back')].filter(_tvEl);
|
||||
}
|
||||
if(panOpen){
|
||||
return [...document.querySelectorAll('#panList .pan-item, #panTabs .pan-chip, #panBlock .btn-s')].filter(_tvEl);
|
||||
}
|
||||
if(detOpen){
|
||||
return [...document.querySelectorAll('#det .btn-p, #det .btn-s, #det .det-cast-card, #detRecsGrid .card')].filter(_tvEl);
|
||||
}
|
||||
if(srchResultVisible){
|
||||
return [...document.querySelectorAll('#srch-list .sli')].filter(_tvEl);
|
||||
}
|
||||
const tabs = [...document.querySelectorAll('.ntab')].filter(_tvEl);
|
||||
const activeTab = document.querySelector('.ntab.on');
|
||||
let cards = [];
|
||||
if(activeTab){
|
||||
const secId = activeTab.dataset.sec;
|
||||
const sec = document.getElementById(secId);
|
||||
if(sec) cards = [...sec.querySelectorAll('.card')].filter(_tvEl);
|
||||
}
|
||||
return [...tabs, ...cards];
|
||||
}
|
||||
|
||||
// ── 快速路径:主页 grid 卡片用索引导航 ──
|
||||
// 返回 null 表示当前不在 grid 内,需走几何兜底
|
||||
function _tvGridFastNav(cur, dir){
|
||||
if(!cur || !cur.classList.contains('card')) return null;
|
||||
// 当前卡片所在的 grid(同一 section 的 .grid)
|
||||
const grid = cur.closest('.grid');
|
||||
if(!grid) return null;
|
||||
const cards = [...grid.querySelectorAll('.card')].filter(_tvEl);
|
||||
const idx = cards.indexOf(cur);
|
||||
if(idx < 0) return null;
|
||||
const isLand = grid.classList.contains('grid-land');
|
||||
const cols = _tvCols(isLand);
|
||||
|
||||
if(dir==='left'){
|
||||
// 行首向左 → 不动(保持,或交给几何兜底跳 tab,这里返回 self 阻止乱跳)
|
||||
if(idx % cols === 0) return cur;
|
||||
return cards[idx-1] || cur;
|
||||
}
|
||||
if(dir==='right'){
|
||||
if(idx % cols === cols-1) return cur;
|
||||
return cards[idx+1] || cur;
|
||||
}
|
||||
if(dir==='down'){
|
||||
const nxt = cards[idx+cols];
|
||||
if(nxt) return nxt;
|
||||
return cur; // 已是最后一行
|
||||
}
|
||||
if(dir==='up'){
|
||||
if(idx < cols){
|
||||
// 首行向上 → 回到导航 tab(取当前激活的)
|
||||
const tab = document.querySelector('.ntab.on') || document.querySelector('.ntab');
|
||||
return tab || cur;
|
||||
}
|
||||
return cards[idx-cols] || cur;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── 几何最近邻(兜底,仅在非规则区域使用)──
|
||||
function _tvCenter(el){
|
||||
const r = el.getBoundingClientRect();
|
||||
return { x: r.left + r.width/2, y: r.top + r.height/2 };
|
||||
}
|
||||
function _tvNearest(cur, items, dir){
|
||||
if(!cur) return items[0] || null;
|
||||
const cc = _tvCenter(cur);
|
||||
let best = null, bestScore = Infinity;
|
||||
for(const el of items){
|
||||
if(el === cur) continue;
|
||||
const ec = _tvCenter(el);
|
||||
const dx = ec.x - cc.x, dy = ec.y - cc.y;
|
||||
const inDir = (
|
||||
(dir==='right' && dx > 2) ||
|
||||
(dir==='left' && dx < -2) ||
|
||||
(dir==='down' && dy > 2) ||
|
||||
(dir==='up' && dy < -2)
|
||||
);
|
||||
if(!inDir) continue;
|
||||
let primary, side;
|
||||
if(dir==='right'||dir==='left'){ primary=Math.abs(dx); side=Math.abs(dy); }
|
||||
else { primary=Math.abs(dy); side=Math.abs(dx); }
|
||||
const score = primary + side * 2.5;
|
||||
if(score < bestScore){ bestScore=score; best=el; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
// ── 焦点设置 + rAF 滚动合帧 ──
|
||||
let _tvScrollTarget = null;
|
||||
let _tvScrollRAF = 0;
|
||||
function _tvScheduleScroll(el){
|
||||
_tvScrollTarget = el;
|
||||
if(_tvScrollRAF) return;
|
||||
_tvScrollRAF = requestAnimationFrame(()=>{
|
||||
_tvScrollRAF = 0;
|
||||
const t = _tvScrollTarget;
|
||||
_tvScrollTarget = null;
|
||||
if(!t) return;
|
||||
// 列表内部优先滚动容器,整页只在必要时滚
|
||||
t.scrollIntoView({ block:'nearest', inline:'nearest' });
|
||||
});
|
||||
}
|
||||
|
||||
function _tvSetFocus(el, scroll=true){
|
||||
if(_tvFocus && _tvFocus !== el){
|
||||
_tvFocus.classList.remove('tv-focus');
|
||||
}
|
||||
_tvFocus = el;
|
||||
if(!el) return;
|
||||
el.classList.add('tv-focus');
|
||||
// 原生 focus 不触发滚动,滚动统一走 rAF
|
||||
if(el.focus){ try{ el.focus({ preventScroll:true }); }catch(_){ } }
|
||||
if(scroll) _tvScheduleScroll(el);
|
||||
}
|
||||
|
||||
// 初始化:页面加载完成后给首个元素聚焦
|
||||
function _tvInit(){
|
||||
if(!TV_MODE) return;
|
||||
setTimeout(()=>{
|
||||
const items = _tvGetItems();
|
||||
if(items.length) _tvSetFocus(items[0], false);
|
||||
}, 800);
|
||||
}
|
||||
|
||||
// 搜索框是否处于文字输入状态
|
||||
function _tvInSearchInput(){
|
||||
const inp = document.getElementById('srchInp');
|
||||
return document.activeElement === inp || (inp && inp === document.querySelector(':focus'));
|
||||
}
|
||||
|
||||
// 主键盘拦截(TV 模式专用)
|
||||
function _tvKeydown(e){
|
||||
if(!TV_MODE) return;
|
||||
|
||||
// 老 WebView / 部分 TV 固件只给 keyCode,不给标准 e.key,做归一化兜底
|
||||
var key = e.key;
|
||||
if(!key || key === 'Unidentified'){
|
||||
var kc = e.keyCode || e.which;
|
||||
var KC = {37:'ArrowLeft',38:'ArrowUp',39:'ArrowRight',40:'ArrowDown',
|
||||
13:'Enter',23:'Enter',66:'Enter', // 23=DPAD_CENTER, 66=ENTER
|
||||
27:'Escape',8:'Escape',4:'Escape',461:'Escape'}; // 4=BACK, 461=LG返回
|
||||
if(KC[kc]) key = KC[kc];
|
||||
}
|
||||
const srchOpen = document.getElementById('srch').classList.contains('on');
|
||||
|
||||
// 搜索框输入状态:只拦截 Enter(OK) 和 Escape/GoBack,方向键交给输入法
|
||||
if(srchOpen && _tvInSearchInput()){
|
||||
if(key === 'Enter'){ submitSrch(); e.preventDefault(); return; }
|
||||
if(key === 'Escape' || key === 'GoBack' || key === 'Back'){
|
||||
closeSrch(); e.preventDefault(); return;
|
||||
}
|
||||
// ArrowUp/Down 在联想列表中已由 onSrchKey 处理,此处不重复拦截
|
||||
return;
|
||||
}
|
||||
|
||||
// 方向键 / OK / 返回
|
||||
const DIR_KEYS = { ArrowUp:'up', ArrowDown:'down', ArrowLeft:'left', ArrowRight:'right' };
|
||||
if(DIR_KEYS[key]){
|
||||
e.preventDefault();
|
||||
const dir = DIR_KEYS[key];
|
||||
const cur = _tvFocus && _tvEl(_tvFocus) ? _tvFocus : null;
|
||||
|
||||
// 快速路径:主页 grid 卡片用索引计算,避免全局几何搜索
|
||||
if(cur){
|
||||
const fast = _tvGridFastNav(cur, dir);
|
||||
if(fast){ if(fast !== cur) _tvSetFocus(fast); return; }
|
||||
}
|
||||
|
||||
// 兜底:几何最近邻(仅在非规则区域或跨区域时)
|
||||
const items = _tvGetItems();
|
||||
if(!items.length) return;
|
||||
const c2 = cur && items.includes(cur) ? cur : null;
|
||||
const next = _tvNearest(c2, items, dir);
|
||||
if(next) _tvSetFocus(next);
|
||||
return;
|
||||
}
|
||||
|
||||
if(key === 'Enter' || key === 'Accept'){
|
||||
e.preventDefault();
|
||||
const el = _tvFocus;
|
||||
if(!el) return;
|
||||
// 搜索框 OK → 打开搜索
|
||||
const srchResultVisible = document.getElementById('s-search').style.display !== 'none';
|
||||
if(!srchOpen && !document.getElementById('det').classList.contains('on') && !srchResultVisible){
|
||||
// 检测是否在导航 tab
|
||||
if(el.classList.contains('ntab')){
|
||||
el.click(); return;
|
||||
}
|
||||
}
|
||||
el.click();
|
||||
return;
|
||||
}
|
||||
|
||||
// 返回键
|
||||
if(key === 'Escape' || key === 'GoBack' || key === 'Back' || key === 'BrowserBack'){
|
||||
// 由现有 keyboard handler 处理,此处只重置焦点
|
||||
setTimeout(()=>{
|
||||
const items = _tvGetItems();
|
||||
if(items.length) _tvSetFocus(items[0], false);
|
||||
}, 300);
|
||||
return;
|
||||
}
|
||||
|
||||
// 彩色键 / 快捷键
|
||||
if(key === 'ColorF0Red' || key === 'F1'){ openSrch(); e.preventDefault(); return; }
|
||||
}
|
||||
|
||||
// 监听层切换后自动重置焦点(用 MutationObserver 监听面板 class 变化)
|
||||
function _tvWatchPanels(){
|
||||
if(!TV_MODE) return;
|
||||
const panels = [
|
||||
document.getElementById('srch'),
|
||||
document.getElementById('det'),
|
||||
document.getElementById('panBlock'),
|
||||
document.getElementById('quickpan'),
|
||||
document.getElementById('s-search'),
|
||||
];
|
||||
const mo = new MutationObserver(()=>{
|
||||
setTimeout(()=>{
|
||||
const items = _tvGetItems();
|
||||
if(_tvFocus && items.includes(_tvFocus)) return;
|
||||
_tvSetFocus(items[0] || null, false);
|
||||
}, 180);
|
||||
});
|
||||
panels.forEach(el=>{
|
||||
if(el) mo.observe(el, { attributes:true, attributeFilter:['class'] });
|
||||
});
|
||||
}
|
||||
|
||||
// 窗口尺寸变化时让列数缓存失效(resize 在 TV 上极少发生,开销可忽略)
|
||||
window.addEventListener('resize', ()=>{ _tvColCache.w = 0; }, {passive:true});
|
||||
|
||||
// 注册 TV 键盘拦截(在现有 keydown 之前处理)
|
||||
document.addEventListener('keydown', _tvKeydown, true); // capture 阶段,优先于现有 handler
|
||||
|
||||
_tvWatchPanels();
|
||||
// boot 完成后初始化焦点
|
||||
// (放在 boot() 之后由 setTimeout 触发,见 _tvInit)
|
||||
|
||||
async function boot(){
|
||||
updateConnStatus('sdk', window.fongmiBridge ? '连接中…' : '浏览器模式');
|
||||
if(window.fongmiBridge) await waitForNativeSdk(1500);
|
||||
updateConnStatus('sdk', window.fm ? 'App 已连接' : (window.fongmiBridge ? 'SDK 未就绪' : '浏览器模式'));
|
||||
try{
|
||||
const s = await sdk().site();
|
||||
siteKey = s && s.key || '';
|
||||
updateConnStatus('site', siteKey ? siteKey : '未配置');
|
||||
}catch(e){ updateConnStatus('site', window.fm ? '获取失败' : '浏览器预览'); }
|
||||
try{ sdk().ui.setToolbar(false); }catch(e){}
|
||||
try{
|
||||
const cfg = await sdk().config();
|
||||
panState.checkEnabled = !!(cfg && cfg.driveCheck);
|
||||
}catch(e){ panState.checkEnabled = false; }
|
||||
if(panState.checkEnabled){
|
||||
updateConnStatus('pan','检测已开启');
|
||||
}
|
||||
await loadConnConfig();
|
||||
setupMirrorDefaults();
|
||||
loadAll();
|
||||
_tvInit();
|
||||
setTimeout(()=>restorePanPlaybackReturn().catch(()=>{}), 300);
|
||||
}
|
||||
boot();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+11523
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,268 @@
|
||||
<!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" />
|
||||
<title>枫叶影院 WebHome</title>
|
||||
<style>
|
||||
:root{--panel-rgb:42,50,58;--panel:rgba(var(--panel-rgb),.62);--panel-soft:rgba(var(--panel-rgb),.44);--panel-strong:rgba(var(--panel-rgb),.86);--line:rgba(255,255,255,.16);--text:#f7fbff;--muted:rgba(247,251,255,.68);--accent:#ff6b35;--accent2:#ffb86b;--bad:#ff5c7a;--ok:#68e39a;--radius:18px;--shadow:0 18px 50px rgba(0,0,0,.28)}
|
||||
*{box-sizing:border-box} html,body{margin:0;min-height:100%;background:transparent;color:var(--text);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",Arial,sans-serif} html:not(.fm-native),html:not(.fm-native) body{background:#222a32}.app{min-height:100vh;background:transparent;padding:10px 10px 28px}.top{position:relative;z-index:1;background:transparent;backdrop-filter:none;padding:8px 0 10px;border-bottom:0}.bar{display:flex;align-items:center;gap:8px}.brand{flex:0 0 auto;font-size:21px;font-weight:900;letter-spacing:.5px;text-shadow:0 2px 14px rgba(0,0,0,.35)}.brand b{color:var(--accent2)}.search{flex:1;display:flex;gap:8px;align-items:center;background:rgba(255,255,255,.10);border:1px solid rgba(255,255,255,.14);border-radius:999px;padding:6px 7px 6px 13px;box-shadow:inset 0 1px 0 rgba(255,255,255,.05)}.search input{flex:1;min-width:0;background:transparent;border:0;outline:0;color:var(--text);font-size:15px}.search input::placeholder{color:var(--muted)}button,.btn{border:1px solid var(--line);background:var(--panel-soft);color:var(--text);border-radius:999px;padding:8px 12px;font-size:14px;cursor:pointer}.btn.primary,button.primary{border-color:rgba(255,107,53,.45);background:linear-gradient(135deg,rgba(255,107,53,.90),rgba(255,184,107,.72));color:#17110d;font-weight:800}.tabs{display:flex;gap:8px;overflow:auto;padding:10px 2px 0;scrollbar-width:none}.tabs::-webkit-scrollbar{display:none}.tab{white-space:nowrap;border-radius:999px;padding:8px 14px;background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.12);color:var(--muted)}.tab.active{background:linear-gradient(135deg,rgba(255,107,53,.55),rgba(255,184,107,.30));color:var(--text);border-color:rgba(255,184,107,.58);font-weight:800}.hero{margin:10px 0 16px;padding:18px;border-radius:var(--radius);background:linear-gradient(135deg,rgba(255,107,53,.28),rgba(42,50,58,.54));border:1px solid var(--line);box-shadow:var(--shadow)}.hero h1{margin:0 0 8px;font-size:24px}.hero p{margin:0;color:var(--muted);line-height:1.6}.status{margin:10px 2px;color:var(--muted);font-size:14px}.grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}.card{position:relative;min-width:0;border-radius:15px;overflow:hidden;background:linear-gradient(180deg,rgba(255,255,255,.09),rgba(255,255,255,.045));border:1px solid rgba(255,255,255,.12);box-shadow:0 10px 28px rgba(0,0,0,.22);cursor:pointer}.poster{aspect-ratio:3/4;background:rgba(255,255,255,.08);overflow:hidden;position:relative}.poster img{width:100%;height:100%;object-fit:cover;display:block;filter:saturate(1.04) contrast(1.03)}.poster .noimg{height:100%;display:flex;align-items:center;justify-content:center;color:var(--muted);font-size:28px}.meta{padding:8px 8px 10px;background:linear-gradient(180deg,rgba(18,24,30,.15),rgba(18,24,30,.44))}.name{font-weight:800;font-size:13.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.remark{margin-top:4px;color:var(--muted);font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.section{margin:16px 0}.section-title{display:flex;align-items:center;justify-content:space-between;margin:0 2px 10px}.section-title h2{margin:0;font-size:17px;letter-spacing:.3px}.rank{display:grid;gap:8px}.rank-item{display:flex;gap:10px;align-items:center;padding:11px 12px;border-radius:14px;background:var(--panel-soft);border:1px solid var(--line);cursor:pointer}.rank-no{width:32px;height:32px;display:grid;place-items:center;border-radius:10px;background:rgba(255,107,53,.22);font-weight:900;color:var(--accent2)}.rank-title{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.sheet{position:fixed;inset:0;z-index:40;display:none;background:transparent;overflow:hidden}.sheet.active{display:block}.sheet-inner{height:100vh;min-height:100vh;padding:12px;background:transparent;overflow-y:auto;-webkit-overflow-scrolling:touch;overscroll-behavior:contain;touch-action:pan-y}.detail-panel{background:linear-gradient(135deg,rgba(64,35,30,.68),rgba(30,24,24,.54));border:1px solid rgba(255,255,255,.10);border-radius:22px;box-shadow:var(--shadow);padding:18px 14px 22px;margin-bottom:42px;touch-action:pan-y}.detail-head{display:grid;grid-template-columns:38% 1fr;gap:16px;align-items:start}.detail-pic{width:100%;aspect-ratio:3/4;border-radius:16px;object-fit:cover;background:rgba(255,255,255,.08);box-shadow:0 12px 30px rgba(0,0,0,.32)}.detail-title{font-size:25px;font-weight:500;margin:0 0 9px;line-height:1.25;color:rgba(255,255,255,.94)}.detail-info{color:rgba(255,255,255,.76);line-height:1.85;font-size:18px;font-weight:500}.detail-info b{color:rgba(255,255,255,.68);font-weight:800;margin-right:8px}.detail-date{font-size:20px;color:rgba(255,255,255,.88);margin:2px 0 14px}.intro-pill{display:inline-flex;margin:28px 0 18px;padding:11px 25px;border-radius:999px;background:#25b86b;color:white;font-size:18px;font-weight:700;box-shadow:0 8px 24px rgba(37,184,107,.22)}.desc{margin:0 4px 18px;color:rgba(255,255,255,.66);font-size:20px;line-height:1.95;max-height:none;overflow:visible}.play-title{margin:18px 2px 8px;font-size:18px;font-weight:800;color:rgba(255,255,255,.9)}.line-tabs{display:flex;gap:8px;overflow-x:auto;margin:8px 0 8px;padding-bottom:2px}.line-tab{border-radius:999px;padding:8px 12px;background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.14);white-space:nowrap;color:rgba(255,255,255,.82)}.line-tab.active{background:rgba(255,107,53,.36);border-color:rgba(255,184,107,.55);font-weight:800}.episodes{display:flex;gap:8px;flex-wrap:wrap;margin-top:12px;max-height:none;overflow:visible;touch-action:pan-y}.ep{border-radius:12px;padding:9px 11px;background:rgba(255,255,255,.09);border:1px solid rgba(255,255,255,.14);cursor:pointer;min-width:68px;color:rgba(255,255,255,.86)}.empty{padding:30px 18px;text-align:center;color:var(--muted);background:var(--panel-soft);border:1px dashed var(--line);border-radius:var(--radius)}.loading{opacity:.75}.focusable:focus,.card:focus,.rank-item:focus,.ep:focus,button:focus,.tab:focus,input:focus{outline:2px solid rgba(255,184,107,.76);outline-offset:2px;transform:translateY(-1px)}
|
||||
body.detail-active .app{visibility:hidden;pointer-events:none}@media (min-width:720px){.grid{grid-template-columns:repeat(5,minmax(0,1fr))}.app{padding:18px 28px 44px}.hero h1{font-size:30px}.detail-head{grid-template-columns:160px 1fr}.detail-pic{width:160px}}@media (min-width:1100px){.grid{grid-template-columns:repeat(7,minmax(0,1fr))}.app{max-width:1380px;margin:0 auto}.sheet-inner{max-width:1180px;margin:0 auto}}
|
||||
@media (max-width:480px){.app{padding:8px 9px 24px}.brand{font-size:20px}.search input{font-size:14px}.search button{padding:7px 10px}.grid{grid-template-columns:repeat(3,minmax(0,1fr));gap:9px}.meta{padding:7px 7px 9px}.name{font-size:13px}.remark{font-size:11px}.detail-head{grid-template-columns:38% 1fr;gap:14px}.detail-title{font-size:24px}.detail-date{font-size:20px}.detail-info{font-size:18px;line-height:1.9}.desc{font-size:20px;line-height:1.92}.sheet-inner{padding:10px}.detail-panel{border-radius:20px;padding:16px 12px}.ep{min-width:64px;padding:8px 9px}}
|
||||
|
||||
/* detail refine overrides */
|
||||
#closeDetail{margin:0 0 18px 0;padding:10px 20px;border-radius:999px;background:rgba(255,255,255,.10);border:1px solid rgba(255,184,107,.55)}
|
||||
.detail-head{align-items:start!important;grid-template-columns:38% 1fr!important;gap:16px!important}
|
||||
.detail-title{font-size:24px!important;font-weight:600!important;line-height:1.25!important;margin:0 0 10px!important;word-break:break-all}
|
||||
.detail-info:empty{display:none!important}
|
||||
.detail-info div{margin:4px 0}
|
||||
.intro-pill{margin-top:26px!important}
|
||||
.episodes{display:grid!important;grid-template-columns:repeat(4,minmax(0,1fr))!important;gap:12px!important;width:100%!important}
|
||||
.ep{width:100%!important;min-width:0!important;text-align:center!important;padding:10px 4px!important;border-radius:14px!important}
|
||||
@media(max-width:480px){.episodes{grid-template-columns:repeat(4,minmax(0,1fr))!important;gap:10px!important}.ep{font-size:15px!important;padding:9px 2px!important}.detail-title{font-size:23px!important}}
|
||||
|
||||
/* pager */
|
||||
.pager-wrap{display:flex;align-items:center;flex-wrap:nowrap;gap:4px;margin:12px 0}
|
||||
.pager-btn{flex:1 1 0;min-width:0;padding:6px 2px;font-size:12px;border-radius:10px;border:1px solid var(--line);background:rgba(255,255,255,.06);color:var(--text);white-space:nowrap}
|
||||
.pager-text{flex:1 1 0;min-width:0;text-align:center;font-size:12px;white-space:nowrap;color:var(--accent2)}
|
||||
|
||||
/* detail final tune */
|
||||
.detail-side{max-height:100%;overflow:hidden}
|
||||
.detail-info{font-size:15px!important;line-height:1.55!important;font-weight:500!important;max-height:calc((100vw - 44px) * .38 * 1.333 - 44px);overflow:hidden}
|
||||
.detail-info b{font-size:15px!important;margin-right:3px!important}
|
||||
.detail-title{font-size:22px!important;margin-bottom:8px!important;font-weight:650!important}
|
||||
.desc{font-size:17px!important;line-height:1.75!important;color:rgba(255,255,255,.68)!important}
|
||||
.intro-pill{background:rgba(255,255,255,.10)!important;border:1px solid rgba(255,184,107,.55)!important;color:rgba(255,255,255,.94)!important;box-shadow:none!important;padding:10px 22px!important;font-size:17px!important}
|
||||
@media(max-width:480px){.detail-info{font-size:15px!important;line-height:1.55!important;max-height:calc((100vw - 44px) * .38 * 1.333 - 42px)}.detail-info b{font-size:15px!important}.detail-title{font-size:22px!important}.desc{font-size:17px!important;line-height:1.75!important}}
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app" id="app">
|
||||
<div class="top">
|
||||
<div class="bar">
|
||||
<div class="brand">枫叶<b>影院</b></div>
|
||||
<form class="search" id="searchForm">
|
||||
<input id="kw" class="focusable" type="text" placeholder="搜索影片、动漫、短剧…" autocomplete="off" />
|
||||
<button class="primary focusable" type="submit">搜索</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="tabs" id="tabs"></div>
|
||||
</div>
|
||||
<div class="status" id="status">准备加载…</div>
|
||||
<main id="content"></main>
|
||||
</div>
|
||||
<div class="sheet" id="detailSheet"><div class="sheet-inner"><div class="detail-panel" id="detailPanel"></div></div></div>
|
||||
<script>
|
||||
(function(){
|
||||
'use strict';
|
||||
setTimeout(()=>{try{var el=document.getElementById('status');if(el&&el.textContent==='准备加载…')el.textContent='脚本已启动,正在等待 SDK/加载首页…'}catch(e){}},100);
|
||||
const BASE='https://www.ht10010.com';
|
||||
const CATS=[['首页','/'],['电视剧','/type/2.html'],['电影','/type/1.html'],['动漫','/type/4.html'],['综艺','/type/3.html'],['短剧','/type/5.html'],['腾讯','/label/qq.html'],['优酷','/label/youku.html'],['B站','/label/bli.html']];
|
||||
const UA='Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 Chrome/120 Mobile Safari/537.36';
|
||||
let fmReady=false,currentUrl=BASE+'/',lastFocus=null;
|
||||
let activeCard='';
|
||||
let pageInfo={prev:'',next:'',current:1,total:1};
|
||||
const $=s=>document.querySelector(s), content=$('#content'), status=$('#status'), tabs=$('#tabs');
|
||||
function markNative(){ if(window.fongmiBridge||window.fm||window.fongmi) document.documentElement.classList.add('fm-native'); }
|
||||
function waitFm(ms=1600){ markNative(); if(window.fm){fmReady=true;return Promise.resolve(window.fm)} return new Promise(r=>{let done=false;const ok=()=>{if(done)return;done=true;markNative();fmReady=!!window.fm;r(window.fm||null)};window.addEventListener('fmsdk',ok,{once:true});setTimeout(ok,ms);});}
|
||||
function abs(u){try{return new URL(u,BASE+'/').href}catch(e){return u||''}}
|
||||
function esc(s){return String(s||'').replace(/[&<>'"]/g,c=>({'&':'&','<':'<','>':'>',"'":''','"':'"'}[c]))}
|
||||
function clean(s){return String(s||'').replace(/\s+/g,' ').replace(/[]/g,'').trim()}
|
||||
function htmlDecode(s){if(!s) return '';const d=document.createElement('textarea');d.innerHTML=s;return d.value}
|
||||
function imgUrl(u){u=abs(u||''); if(!u) return ''; try{return window.fm&&fm.res?fm.res(u,{headers:{Referer:BASE+'/'}}):u}catch(e){return u}}
|
||||
async function reqText(url){url=abs(url);status.textContent='加载:'+url;try{if(window.fm&&fm.req){const r=await fm.req(url,{method:'GET',headers:{'User-Agent':UA,'Referer':BASE+'/'},responseType:'text',timeout:25,credentials:'include'});if(!r.ok)throw new Error(r.error||('HTTP '+r.status));return r.body||'';}const r=await fetch(url,{headers:{'Referer':BASE+'/'}});return await r.text();}catch(e){status.textContent='加载失败:'+e.message;throw e}}
|
||||
function doc(html){return new DOMParser().parseFromString(html,'text/html')}
|
||||
function cardFromA(a){const href=abs(a.getAttribute('href')||'');let img=a.querySelector('img');let image='';if(img){let src=img.getAttribute('data-src')||img.getAttribute('data-original')||img.getAttribute('src')||'';if(src){src=htmlDecode(src);image=abs(src)}}let title=a.getAttribute('title')||img?.getAttribute('alt')||'';let text=clean(a.textContent); if(!title) title=text.replace(/^(4K)?(今日更新)?(更新至|已完结|HD|全).*?/,'').slice(0,24)||text;let remark=text.replace(title,'').slice(0,40);return {title:clean(title),remark:clean(remark),href,image}}
|
||||
function unique(arr){const m=new Map();arr.forEach(x=>{if(x&&x.href&&x.title&&!m.has(x.href))m.set(x.href,x)});return [...m.values()]}
|
||||
function parsePager(html){const d=doc(html);const box=d.querySelector('#page')||d.querySelector('.page')||d.querySelector('.pagination');if(!box){return{home:'',prev:'',next:'',last:'',current:1,total:1}}const get=(sel,attr='href')=>{const el=box.querySelector(sel);return el?abs(el.getAttribute(attr)):''};const currentEl=d.querySelector('.page-current')||d.querySelector('.active');return{home:get('a[title="首页"]'),prev:get('a[title="上一页"]'),next:get('a[title="下一页"]'),last:get('a[title="尾页"]'),current:currentEl?parseInt(currentEl.textContent)||1:1,total:1}}
|
||||
function renderPager(){document.querySelectorAll('.pager-wrap').forEach(e=>e.remove());const wrap=document.createElement('div');wrap.className='pager-wrap';wrap.innerHTML=`<button class="pager-btn" id="homePage">首页</button><button class="pager-btn" id="prevPage">上一页</button><span class="pager-text">第 ${pageInfo.current||1} 页</span><button class="pager-btn" id="nextPage">下一页</button><button class="pager-btn" id="lastPage">尾页</button>`;content.appendChild(wrap);const markActive=(btn)=>{if(btn)btn.style.border="1px solid #c084fc"};const home=wrap.querySelector('#homePage'),prev=wrap.querySelector('#prevPage'),next=wrap.querySelector('#nextPage'),last=wrap.querySelector('#lastPage');home.onclick=()=>pageInfo.home&&loadPage(pageInfo.home);prev.onclick=()=>pageInfo.prev&&loadPage(pageInfo.prev);next.onclick=()=>pageInfo.next&&loadPage(pageInfo.next);last.onclick=()=>pageInfo.last&&loadPage(pageInfo.last);if(!pageInfo.prev)prev.style.opacity=0.4;if(!pageInfo.next)next.style.opacity=0.4;if(pageInfo.home)markActive(home);if(pageInfo.last)markActive(last)}
|
||||
function getCatId(u){const m=abs(u).match(/type\/(\d+)/)||abs(u).match(/label\/(new|hot|qq|youku|bli)/);return m?m[1]:''}
|
||||
function parseList(html,keyword=''){
|
||||
const d=doc(html), sections=[];
|
||||
const badHost=el=>!!el.closest('.pop-list-body,.search-hot,.history,.footer,.head-more,.slide-time,.banner,.swiper,.large-swiper,.popup');
|
||||
const isGood=x=>x&&x.href&&x.title&&x.image&&!/^点击观看$/.test(x.title);
|
||||
let heads=[...d.querySelectorAll('.title-a,.module-title,.title')].map(h=>({el:h,title:clean(h.textContent).replace('更多','')})).filter(x=>x.title&&!/搜索|播放记录|搜索历史|热门搜索|更多/.test(x.title));
|
||||
for(const h of heads){
|
||||
let box=h.el.closest('.box-width,.module,.module-main,.layout-box,.box')||h.el.parentElement;
|
||||
let items=[];
|
||||
for(let i=0;i<4&&box;i++,box=box.parentElement){
|
||||
items=unique([...box.querySelectorAll('a[href*="/detail/"]')].filter(a=>!badHost(a)).map(cardFromA)).filter(isGood).slice(0,24);
|
||||
if(items.length>=3)break;
|
||||
}
|
||||
if(items.length)sections.push({title:h.title,items});
|
||||
}
|
||||
if(!sections.length){
|
||||
let items=unique([...d.querySelectorAll('a[href*="/detail/"]')].filter(a=>!badHost(a)).map(cardFromA)).filter(isGood);
|
||||
if(keyword) items=items.filter(x=>x.title.includes(keyword)||x.remark.includes(keyword));
|
||||
items=items.slice(0,60);
|
||||
sections.push({title:keyword?('搜索:'+keyword):'影片列表',items});
|
||||
}
|
||||
return sections.filter(s=>s.items.length)
|
||||
}
|
||||
function parseDetail(html,url){
|
||||
const d=doc(html);
|
||||
const title=clean(d.querySelector('h1')?.textContent||d.title.replace(/《|》|免费观看.*$/g,'')).replace(/(电视剧|电影|动漫|综艺|短剧|连续剧|国产剧|动画)$/,'');
|
||||
let pic=d.querySelector('.detail-pic img,.vod-pic img,.poster img,img[alt]');
|
||||
let image=pic?(pic.getAttribute('data-src')||pic.getAttribute('src')):'';
|
||||
const raw=(d.body?.innerText||'').replace(/\r/g,'');
|
||||
const lines=raw.split('\n').map(x=>clean(x)).filter(Boolean);
|
||||
function pickLine(label){
|
||||
const labels=['类型','导演','演员','语言','连载','更新'];
|
||||
const re=new RegExp('^'+label+'\\s*[::]?\\s*(.*)$');
|
||||
const line=lines.find(x=>re.test(x));
|
||||
if(!line) return '';
|
||||
let v=(line.match(re)||[])[1]||'';
|
||||
v=v.replace(/^(:|:)/,'').trim();
|
||||
const others=labels.filter(x=>x!==label).join('|');
|
||||
v=v.replace(new RegExp('\\s*(?:'+others+')\\s*[::].*$'), '').trim();
|
||||
return v.length>120?v.slice(0,120)+'…':v;
|
||||
}
|
||||
const fields=[
|
||||
['类型',pickLine('类型')],
|
||||
['导演',pickLine('导演')],
|
||||
['演员',pickLine('演员')],
|
||||
['连载',pickLine('连载')],
|
||||
['更新',pickLine('更新')]
|
||||
].filter(x=>x[1]);
|
||||
if(!fields.length){
|
||||
const text=raw.replace(/\s+/g,' ');
|
||||
function pickAny(label,next){const re=new RegExp(label+'\\s*[::]\\s*(.*?)(?='+next+'|简介|播放线路|$)');const m=text.match(re);return m?clean(m[1]).slice(0,120):''}
|
||||
const fs=[['类型',pickAny('类型','导演')],['导演',pickAny('导演','演员')],['演员',pickAny('演员','连载')],['连载',pickAny('连载','更新')],['更新',pickAny('更新','简介')]].filter(x=>x[1]);
|
||||
fields.push(...fs);
|
||||
}
|
||||
let desc='';
|
||||
let i=lines.findIndex(x=>x==='简介'||x.startsWith('简介'));
|
||||
if(i>=0){
|
||||
let arr=[];
|
||||
for(let j=i+1;j<lines.length;j++){
|
||||
if(/^(播放线路|资源列表|热播推荐|⚠️|重要提醒|排序|至臻|蓝光|自营)/.test(lines[j])) break;
|
||||
if(lines[j] && lines[j] !== '展开') arr.push(lines[j]);
|
||||
}
|
||||
desc=clean(arr.join(' ')).replace(/展开$/,'');
|
||||
}
|
||||
if(!desc){
|
||||
let m=raw.match(/简介\s*([\s\S]*?)(?:播放线路|资源列表|热播推荐|⚠️)/);
|
||||
desc=m?clean(m[1]).replace(/展开$/,''):'暂无简介';
|
||||
}
|
||||
desc=desc.replace(/\s*展开\s*$/,''); if(desc.length>360) desc=desc.slice(0,360)+'…';
|
||||
let eps=unique([...d.querySelectorAll('a[href*="/play/"]')].map(a=>({title:clean(a.textContent)||'播放',href:abs(a.getAttribute('href')),remark:'',image:''}))).slice(0,260);
|
||||
let lineNames=[...raw.matchAll(/([^\s\n()]{2,12})\((\d+)\)/g)].map(m=>clean(m[1])).filter(x=>!/提醒|搜索|播放|记录/.test(x));
|
||||
lineNames=[...new Set(lineNames)];
|
||||
const groups={}, lineOrder=[];
|
||||
function epNo(ep){let m=(ep.href||'').match(/\/play\/\d+-\d+-(\d+)\.html/); if(m) return parseInt(m[1],10)||0; let t=(ep.title||'').match(/(\d+)/); return t?parseInt(t[1],10):0;}
|
||||
eps.forEach(ep=>{
|
||||
let m=ep.href.match(/\/play\/\d+-(\d+)-\d+\.html/);
|
||||
let sid=m?m[1]:'1';
|
||||
if(!groups[sid]){groups[sid]=[];lineOrder.push(sid)}
|
||||
groups[sid].push(ep)
|
||||
});
|
||||
lineOrder.forEach(sid=>groups[sid].sort((a,b)=>epNo(a)-epNo(b)));
|
||||
let linesGroup=lineOrder.map((sid,i)=>({sid,name:lineNames[i]||('线路'+sid),eps:groups[sid]}));
|
||||
return {title,image:abs(image),fields,desc,eps,lines:linesGroup,url}
|
||||
}
|
||||
function parsePlay(html,playPage){let p=null;const m=html.match(/var\s+player_aaaa\s*=\s*(\{[\s\S]*?\})\s*<\/script>/);if(m){try{p=JSON.parse(m[1].replace(/\\\//g,'/'))}catch(e){}}const d=doc(html);let iframe=d.querySelector('iframe[src*="url="]');let src=iframe?abs(iframe.getAttribute('src')):'';if(!src&&p&&p.url) src='https://fgsrg.hzqingshan.com/player/?url='+encodeURIComponent(p.url);return {src,token:p&&p.url,title:p?.vod_data?.vod_name||document.title||'枫叶播放',from:p?.from||'',page:playPage}}
|
||||
function setStatus(t){status.textContent=t||''}
|
||||
function renderTabs(active){const aid=getCatId(active);tabs.innerHTML=CATS.map(([n,p])=>{const catUrl=p.startsWith('/')?BASE+p:p;const pid=getCatId(catUrl);const match=pid===aid;return`<button class="tab focusable ${match?'active':''}" data-url="${esc(catUrl)}">${esc(n)}</button>`}).join('');tabs.querySelectorAll('.tab').forEach(b=>b.onclick=()=>loadPage(b.dataset.url))}
|
||||
function renderSections(sections){content.innerHTML=sections.map(sec=>`<section class="section"><div class="section-title"><h2>${esc(sec.title)}</h2></div><div class="grid">${sec.items.map(it=>`<article class="card focusable ${it.href===activeCard?'active':''}" tabindex="0" data-href="${esc(it.href)}"><div class="poster">${it.image?`<img loading="lazy" referrerpolicy="no-referrer" src="${esc(imgUrl(it.image))}" alt="${esc(it.title)}">`:`<div class="noimg">枫</div>`}</div><div class="meta"><div class="name">${esc(it.title)}</div><div class="remark">${esc(it.remark||'点击查看详情')}</div></div></article>`).join('')}</div></section>`).join('');content.querySelectorAll('.card').forEach(c=>{c.onclick=()=>{activeCard=c.dataset.href;document.querySelectorAll('.card.active').forEach(x=>x.classList.remove('active'));c.classList.add('active');openDetail(c.dataset.href)};c.onkeydown=e=>{if(e.key==='Enter')c.click()}})}
|
||||
async function loadPage(url){currentUrl=abs(url);renderTabs(currentUrl);content.innerHTML='<div class="empty loading">正在加载内容…</div>';try{const html=await reqText(currentUrl);pageInfo=parsePager(html);const sections=parseList(html);renderSections(sections);if(pageInfo&&(pageInfo.prev||pageInfo.next||pageInfo.home||pageInfo.last)){renderPager()}setStatus('已加载 '+sections.reduce((n,s)=>n+s.items.length,0)+' 条内容');restoreFocus()}catch(e){content.innerHTML='<div class="empty">加载失败:'+esc(e.message)+'</div>'}}
|
||||
async function search(q){
|
||||
q=clean(q);if(!q)return;
|
||||
history.pushState({route:'search',q},'', '#search='+encodeURIComponent(q));
|
||||
renderTabs('');
|
||||
content.innerHTML='<div class="empty loading">搜索中…</div>';
|
||||
try{
|
||||
const urls=[
|
||||
BASE+'/cupfox-search/-------------.html?wd='+encodeURIComponent(q),
|
||||
BASE+'/cupfox-search/-'+encodeURIComponent(q)+'------------.html'
|
||||
];
|
||||
let sections=[];
|
||||
for(const url of urls){
|
||||
const html=await reqText(url);
|
||||
pageInfo=parsePager(html);
|
||||
sections=parseList(html,q);
|
||||
if(sections.length&§ions[0].items.length) break;
|
||||
}
|
||||
if(!sections.length||!sections[0].items.length){
|
||||
content.innerHTML='<div class="empty">没有找到相关结果</div>';
|
||||
setStatus('无搜索结果:'+q);
|
||||
return;
|
||||
}
|
||||
sections[0].title='搜索:'+q;
|
||||
renderSections(sections);
|
||||
if(pageInfo&&(pageInfo.prev||pageInfo.next||pageInfo.home||pageInfo.last)){renderPager()}
|
||||
setStatus('搜索完成:'+q);
|
||||
}catch(e){
|
||||
content.innerHTML='<div class="empty">搜索失败,请换个关键词重试。</div>';
|
||||
setStatus('搜索失败:'+e.message);
|
||||
}
|
||||
}
|
||||
async function openDetail(url){lastFocus=document.activeElement;document.body.classList.add('detail-active');$('#detailSheet').classList.add('active');$('#detailPanel').innerHTML='<div class="empty loading">正在读取详情…</div>';history.pushState({route:'detail',url},'', '#detail='+encodeURIComponent(url));try{const html=await reqText(url);const d=parseDetail(html,url);let firstLine=d.lines[0]||{sid:'1',name:'默认线路',eps:d.eps};
|
||||
function trimFieldValue(label,value){
|
||||
const labels=['类型','导演','演员','语言','连载','更新'].filter(x=>x!==label).join('|');
|
||||
return String(value||'').replace(new RegExp('\\s*(?:'+labels+')\\s*[::].*$'),'').trim();
|
||||
}
|
||||
const shownFields=d.fields.filter(x=>x[0]!=='更新').map(x=>[x[0],trimFieldValue(x[0],x[1])]).filter(x=>x[1]);
|
||||
$('#detailPanel').innerHTML=`<button class="focusable" id="closeDetail">返回</button><div class="detail-head"><img class="detail-pic" referrerpolicy="no-referrer" src="${esc(imgUrl(d.image))}" onerror="this.style.display='none'"><div class="detail-side"><div class="detail-title">${esc(d.title)}</div><div class="detail-info">${shownFields.map(x=>`<div><b>${esc(x[0])}:</b>${esc(x[1])}</div>`).join('')||''}</div></div></div><div class="intro-pill">简介</div><p class="desc">${esc(d.desc||'暂无简介')}</p><div class="play-title">播放线路</div><div class="line-tabs">${(d.lines.length?d.lines:[firstLine]).map((ln,i)=>`<button class="line-tab focusable ${i===0?'active':''}" data-sid="${esc(ln.sid)}">${esc(ln.name)}(${ln.eps.length})</button>`).join('')}</div><div class="episodes" id="episodeBox"></div>`;
|
||||
function renderLine(sid){const ln=d.lines.find(x=>x.sid==sid)||firstLine;$('#episodeBox').innerHTML=ln.eps.map(ep=>`<button class="ep focusable" data-href="${esc(ep.href)}">${esc(ep.title)}</button>`).join('')||'<div class="empty">暂无分集</div>';$('#episodeBox').querySelectorAll('.ep').forEach(b=>b.onclick=()=>playEpisode(b.dataset.href,d.title,b.textContent,ln.name,ln.eps,d));}
|
||||
$('#closeDetail').onclick=closeDetail;$('#detailPanel').querySelectorAll('.line-tab').forEach(b=>b.onclick=()=>{$('#detailPanel').querySelectorAll('.line-tab').forEach(x=>x.classList.remove('active'));b.classList.add('active');renderLine(b.dataset.sid)});renderLine(firstLine.sid);$('#closeDetail').focus({preventScroll:true})}catch(e){$('#detailPanel').innerHTML='<button class="focusable" onclick="window.__fyClose()">返回</button><div class="empty">详情加载失败:'+esc(e.message)+'</div>'}}
|
||||
async function playEpisode(url, vodName, epName, flagName, epsList, detailObj) {
|
||||
let playUrl = abs(url);
|
||||
if (playUrl && !playUrl.startsWith('video://')) {
|
||||
playUrl = 'video://' + playUrl;
|
||||
}
|
||||
try {
|
||||
if (window.fm) {
|
||||
if (typeof window.fm.vodInline === "function") {
|
||||
var nativeEps = epsList.map(function (e) {
|
||||
return { name: e.title, url: 'video://' + abs(e.href) };
|
||||
});
|
||||
|
||||
var allLines = detailObj.lines && detailObj.lines.length ? detailObj.lines : [{ name: flagName, eps: epsList }];
|
||||
var fromArray = [];
|
||||
var urlArray = [];
|
||||
|
||||
allLines.forEach(function (ln) {
|
||||
fromArray.push(ln.name);
|
||||
var epStrings = ln.eps.map(function (e) {
|
||||
return e.title + '$video://' + abs(e.href);
|
||||
});
|
||||
urlArray.push(epStrings.join('#'));
|
||||
});
|
||||
|
||||
await window.fm.vodInline({
|
||||
vod_id: String(detailObj.url || ""),
|
||||
vod_name: vodName,
|
||||
vod_pic: detailObj.image || "",
|
||||
vod_play_from: fromArray.join('$$$'),
|
||||
vod_play_url: urlArray.join('$$$'),
|
||||
play_from: flagName,
|
||||
mark: epName,
|
||||
episodes: nativeEps,
|
||||
url: playUrl
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (fm.play) {
|
||||
await fm.play(playUrl, vodName + " " + epName);
|
||||
return;
|
||||
}
|
||||
}
|
||||
location.href = playUrl;
|
||||
} catch (e) {
|
||||
console.error("Play error:", e);
|
||||
location.href = playUrl;
|
||||
}
|
||||
}
|
||||
|
||||
function closeDetail(){document.body.classList.remove('detail-active');$('#detailSheet').classList.remove('active');if(location.hash.startsWith('#detail=')) history.back(); if(lastFocus&&lastFocus.focus) setTimeout(()=>lastFocus.focus({preventScroll:true}),50)}window.__fyClose=closeDetail;
|
||||
async function nativeSearch(q){q=q||clean($('#kw').value);if(!q)return;if(window.fm&&fm.search){try{await fm.search(q,{direct:true});return}catch(e){}}search(q)}
|
||||
function restoreFocus(){setTimeout(()=>{const el=document.querySelector('.card')||document.querySelector('.tab.active'); if(el) el.focus({preventScroll:true})},30)}
|
||||
function handleKeys(e){if(e.key==='Escape'||e.key==='Backspace'){if($('#detailSheet').classList.contains('active')){e.preventDefault();closeDetail();return}}if(e.key==='Enter'&&document.activeElement?.classList.contains('focusable')&&document.activeElement.tagName!=='INPUT'){document.activeElement.click();}}
|
||||
function boot(){setStatus('正在初始化…');renderTabs(BASE+'/');$('#searchForm').onsubmit=e=>{e.preventDefault();search($('#kw').value)};const ns=$('#nativeSearch'); if(ns) ns.onclick=()=>nativeSearch();document.addEventListener('keydown',handleKeys,true);window.addEventListener('popstate',()=>{if($('#detailSheet').classList.contains('active')){document.body.classList.remove('detail-active');$('#detailSheet').classList.remove('active')}else loadPage(currentUrl)});loadPage(BASE+'/')}
|
||||
setTimeout(()=>{try{if(status&&status.textContent==='准备加载…')status.textContent='正在初始化 WebHome…'}catch(e){}},800);
|
||||
waitFm().then(()=>{try{boot()}catch(e){status.textContent='启动失败:'+e.message;console.error(e)}});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user