9620 lines
573 KiB
HTML
9620 lines
573 KiB
HTML
|
||
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
|
||
<title>盘链</title>
|
||
|
||
<!-- 提前对默认几个数据源域名做 DNS/TLS 预连接,减少首批请求的握手延迟;
|
||
若用户在设置里改了源,boot() 里还会针对实际配置的域名再补一次。 -->
|
||
<link rel="preconnect" href="https://pinglian.lol" crossorigin>
|
||
<link rel="dns-prefetch" href="https://pinglian.lol">
|
||
<link rel="preconnect" href="https://api.tmdb.org" crossorigin>
|
||
<link rel="dns-prefetch" href="https://api.tmdb.org">
|
||
<link rel="preconnect" href="https://images.tmdb.org" crossorigin>
|
||
<link rel="dns-prefetch" href="https://images.tmdb.org">
|
||
<link rel="preconnect" href="https://so.252035.xyz" crossorigin>
|
||
<link rel="dns-prefetch" href="https://so.252035.xyz">
|
||
<link rel="preconnect" href="https://4kzn.cc" crossorigin>
|
||
<link rel="dns-prefetch" href="https://4kzn.cc">
|
||
<link rel="preconnect" href="https://www.qwmp4.com" crossorigin>
|
||
<link rel="dns-prefetch" href="https://www.qwmp4.com">
|
||
|
||
<!-- ============================================================
|
||
兼容引导层:文档第一个内联脚本,只用 ES5(var + function)。
|
||
旧内核(Chromium 50-70)先跑它。见开发文档 15.5。
|
||
============================================================ -->
|
||
<script>
|
||
(function () {
|
||
"use strict";
|
||
var W = window, D = document, P;
|
||
if (!Element.prototype.matches) Element.prototype.matches = Element.prototype.msMatchesSelector || Element.prototype.webkitMatchesSelector;
|
||
if (!Element.prototype.closest) Element.prototype.closest = function (s) { var el = this; while (el && el.nodeType === 1) { if (el.matches(s)) return el; el = el.parentElement; } return null; };
|
||
if (!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]); };
|
||
if (W.NodeList && !NodeList.prototype.forEach) NodeList.prototype.forEach = Array.prototype.forEach;
|
||
if (W.HTMLCollection && !HTMLCollection.prototype.forEach) HTMLCollection.prototype.forEach = Array.prototype.forEach;
|
||
if (!Object.values) Object.values = function (o) { var r = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) r.push(o[k]); return r; };
|
||
if (!Object.entries) Object.entries = function (o) { var r = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) r.push([k, o[k]]); return r; };
|
||
if (!Array.from) Array.from = function (a) { return Array.prototype.slice.call(a); };
|
||
if (!Array.prototype.includes) Array.prototype.includes = function (v) { return this.indexOf(v) !== -1; };
|
||
if (!String.prototype.includes) String.prototype.includes = function (s) { return this.indexOf(s) !== -1; };
|
||
if (!String.prototype.startsWith) String.prototype.startsWith = function (s) { return this.slice(0, s.length) === s; };
|
||
if (!String.prototype.endsWith) String.prototype.endsWith = function (s) { return this.slice(this.length - s.length) === s; };
|
||
if (W.Promise && !Promise.prototype.finally) Promise.prototype.finally = function (cb) { var p = this.constructor; return this.then(function (v) { return p.resolve(cb()).then(function () { return v; }); }, function (e) { return p.resolve(cb()).then(function () { throw e; }); }); };
|
||
|
||
var root = D.documentElement;
|
||
function supports(prop, val) { try { return !!(W.CSS && CSS.supports && CSS.supports(prop, val)); } catch (e) { return false; } }
|
||
if (!supports("width", "min(1px, 2px)")) root.className += " no-css-functions";
|
||
P = D.createElement("div");
|
||
P.style.cssText = "position:absolute;left:-9999px;width:60px;aspect-ratio:2/3;";
|
||
root.appendChild(P);
|
||
var ar = P.offsetHeight;
|
||
if (P.parentNode) P.parentNode.removeChild(P);
|
||
if (!(ar > 70 && ar < 110)) root.className += " no-aspect-ratio";
|
||
if (W.fongmiBridge || W.fm || W.fongmi) root.className += " fm-native";
|
||
try { var _t0 = localStorage.getItem("pl_theme"); if (_t0 && _t0 !== "default") { root.setAttribute("data-theme", _t0); } } catch (e0) {}
|
||
})();
|
||
</script>
|
||
|
||
<style>
|
||
/* ===== 令牌 —— 默认「极夜蓝」主题 ===== */
|
||
:root {
|
||
--gut: 16px;
|
||
--card-gut: 8px;
|
||
--panel-rgb: 22, 32, 66;
|
||
--panel: rgba(var(--panel-rgb), .62);
|
||
--panel-soft: rgba(var(--panel-rgb), .46);
|
||
--panel-strong: rgba(18, 26, 54, .90);
|
||
--line: rgba(120, 160, 255, .22);
|
||
--line-strong: rgba(160, 190, 255, .5);
|
||
--ink: #eaf0ff;
|
||
--ink-dim: rgba(234, 240, 255, .66);
|
||
--ink-faint: rgba(234, 240, 255, .42);
|
||
--accent: #2ea6ff;
|
||
--accent-2: #2f7bff;
|
||
--accent-ink: #04121f;
|
||
--grad: linear-gradient(120deg, #2fd0e8 0%, #39c0ff 28%, #2f7bff 52%, #5468ff 76%, #2fd0e8 100%);
|
||
--accent-soft: rgba(46, 166, 255, .18);
|
||
--accent-line: rgba(46, 166, 255, .5);
|
||
--sheet-bg: #000000;
|
||
--dyn-bg: var(--sheet-bg); --dyn-bg-rgb: 0, 0, 0;
|
||
--bg1: #0c1533; --bg2: #0a1024; --bg3: #070b1a;
|
||
--badge-bg: rgba(46, 166, 255, .92);
|
||
--badge-border: rgba(160, 210, 255, .5);
|
||
--badge-shadow: rgba(46, 166, 255, .35);
|
||
--btn-shadow: rgba(46, 166, 255, .34);
|
||
--ok: #4bd08a; --bad: #ff5c66; --lock: #ffc24d; --gray: #8b96b5;
|
||
--radius: 12px;
|
||
--safe-top: 12px; --safe-bottom: 12px; --safe-left: 0px; --safe-right: 0px;
|
||
}
|
||
/* ===== 内置主题:日落珊瑚(长按 Logo 切换)===== */
|
||
:root[data-theme="ember"] {
|
||
--panel-rgb: 80, 42, 32;
|
||
--panel-strong: rgba(46, 22, 16, .92);
|
||
--line: rgba(255, 175, 130, .24);
|
||
--line-strong: rgba(255, 190, 150, .5);
|
||
--ink: #fff4ec;
|
||
--ink-dim: rgba(255, 244, 236, .68);
|
||
--ink-faint: rgba(255, 244, 236, .42);
|
||
--accent: #ff7a45;
|
||
--accent-2: #ffb23e;
|
||
--accent-ink: #2a0e02;
|
||
--grad: linear-gradient(135deg, #ff9a56 0%, #ff5f6d 100%);
|
||
--accent-soft: rgba(255, 122, 69, .18);
|
||
--accent-line: rgba(255, 122, 69, .5);
|
||
--sheet-bg: #0f0605;
|
||
--dyn-bg: var(--sheet-bg); --dyn-bg-rgb: 15, 6, 5;
|
||
--bg1: #241210; --bg2: #180b09; --bg3: #0f0605;
|
||
--badge-bg: rgba(255, 122, 69, .92);
|
||
--badge-border: rgba(255, 190, 150, .5);
|
||
--badge-shadow: rgba(255, 122, 69, .35);
|
||
--btn-shadow: rgba(255, 122, 69, .34);
|
||
}
|
||
/* ===== 内置主题:极光靛蓝(长按 Logo 切换)===== */
|
||
:root[data-theme="aurora"] {
|
||
--panel-rgb: 60, 45, 120;
|
||
--panel-strong: rgba(28, 18, 58, .92);
|
||
--line: rgba(155, 135, 255, .26);
|
||
--line-strong: rgba(180, 160, 255, .55);
|
||
--ink: #f0eeff;
|
||
--ink-dim: rgba(240, 238, 255, .66);
|
||
--ink-faint: rgba(240, 238, 255, .4);
|
||
--accent: #8b5cf6;
|
||
--accent-2: #22d3ee;
|
||
--accent-ink: #0a0620;
|
||
--grad: linear-gradient(135deg, #8b5cf6 0%, #22d3ee 100%);
|
||
--accent-soft: rgba(139, 92, 246, .18);
|
||
--accent-line: rgba(139, 92, 246, .5);
|
||
--sheet-bg: #05030f;
|
||
--dyn-bg: var(--sheet-bg); --dyn-bg-rgb: 5, 3, 15;
|
||
--bg1: #140a33; --bg2: #0d0724; --bg3: #05030f;
|
||
--badge-bg: rgba(139, 92, 246, .92);
|
||
--badge-border: rgba(190, 175, 255, .5);
|
||
--badge-shadow: rgba(139, 92, 246, .35);
|
||
--btn-shadow: rgba(139, 92, 246, .34);
|
||
}
|
||
/* ===== 内置主题:苹果白(Apple TV+ 风格,白按钮黑字,长按 Logo 切换)=====
|
||
页面背景保持黑色:大部分文字(标题/简介/卡片名等)直接铺在黑背景或图片上,用浅色;
|
||
只有自身就是白色磨砂面板的小组件(搜索框、图标按钮、通知条、按钮、网盘资源行、
|
||
设置面板、搜索联想、底部导航栏、toast)单独把文字/边框改回深色,跟它们自己的白底匹配。 */
|
||
:root[data-theme="appletv"] {
|
||
--panel-rgb: 120, 120, 128;
|
||
--panel: rgba(255, 255, 255, .74);
|
||
--panel-soft: rgba(255, 255, 255, .55);
|
||
--panel-strong: rgba(255, 255, 255, .96);
|
||
--line: rgba(255, 255, 255, .16);
|
||
--line-strong: rgba(255, 255, 255, .30);
|
||
--ink: #f5f5f7;
|
||
--ink-dim: rgba(245, 245, 247, .68);
|
||
--ink-faint: rgba(245, 245, 247, .42);
|
||
--accent: #0071e3;
|
||
--accent-2: #0058b0;
|
||
--accent-ink: #111114;
|
||
--grad: linear-gradient(135deg, #ffffff 0%, #f0f0f2 100%);
|
||
--accent-soft: rgba(0, 113, 227, .14);
|
||
--accent-line: rgba(0, 113, 227, .4);
|
||
--sheet-bg: #000000;
|
||
--dyn-bg: var(--sheet-bg); --dyn-bg-rgb: 0, 0, 0;
|
||
--bg1: #0c1533; --bg2: #0a1024; --bg3: #070b1a;
|
||
--badge-bg: rgba(255, 255, 255, .92);
|
||
--badge-border: rgba(0, 0, 0, .12);
|
||
--badge-shadow: rgba(0, 0, 0, .22);
|
||
--btn-shadow: rgba(0, 0, 0, .16);
|
||
}
|
||
:root[data-theme="appletv"] .search-box,
|
||
:root[data-theme="appletv"] .cat-searchbar,
|
||
:root[data-theme="appletv"] .hdr-ico,
|
||
:root[data-theme="appletv"] .clear-search,
|
||
:root[data-theme="appletv"] .notice,
|
||
:root[data-theme="appletv"] .search-suggest,
|
||
:root[data-theme="appletv"] .btn,
|
||
:root[data-theme="appletv"] .panel-sheet,
|
||
:root[data-theme="appletv"] .toast,
|
||
:root[data-theme="appletv"] .chips {
|
||
--ink: #1d1d1f;
|
||
--ink-dim: rgba(29, 29, 31, .65);
|
||
--ink-faint: rgba(29, 29, 31, .38);
|
||
--line: rgba(0, 0, 0, .12);
|
||
--line-strong: rgba(0, 0, 0, .22);
|
||
}
|
||
:root[data-theme="appletv"] .cat-back { color: #ffffff; }
|
||
:root[data-theme="appletv"] .chips {
|
||
background: rgba(255, 255, 255, .88); border-color: rgba(0, 0, 0, .08);
|
||
-webkit-backdrop-filter: none; backdrop-filter: none;
|
||
box-shadow: none;
|
||
}
|
||
:root[data-theme="appletv"] .chip.active { background: rgba(0, 113, 227, .12); color: var(--accent); box-shadow: none; }
|
||
/* 类型筛选标签(分类页顶部的"全部/剧情/喜剧..."那一排)选中态:苹果白主题整体走"白底黑字"的
|
||
按钮质感(跟 .btn / .chips 一样),这里单独覆盖成纯白底 + 深色字,不再沿用蓝色半透明底,
|
||
跟主题基调统一,也比蓝底在黑色页面背景上更醒目。 */
|
||
:root[data-theme="appletv"] .genre-chip.active { background: #ffffff; border-color: #ffffff; color: #1d1d1f; font-weight: 700; }
|
||
:root[data-theme="appletv"] .panel-close { background: rgba(0, 0, 0, .05); }
|
||
:root[data-theme="appletv"] .set-group { background: rgba(0, 0, 0, .03); }
|
||
:root[data-theme="appletv"] .field input { background: rgba(0, 0, 0, .045); }
|
||
:root[data-theme="appletv"] .switch { background: rgba(0, 0, 0, .14); }
|
||
:root[data-theme="appletv"] .pan-sub .q { color: #ffffff; font-weight: 700; }
|
||
:root[data-theme="appletv"] .cast-cell.sel { border-color: rgba(255, 255, 255, .7); animation: castSelBreatheWhite 2.2s ease-in-out infinite; }
|
||
@keyframes castSelBreatheWhite {
|
||
0%, 100% { box-shadow: 0 0 0 1px rgba(255,255,255,.22), inset 0 0 10px 0 rgba(255,255,255,.16); }
|
||
50% { box-shadow: 0 0 0 1px rgba(255,255,255,.4), inset 0 0 16px 2px rgba(255,255,255,.26); }
|
||
}
|
||
:root[data-theme="appletv"] .pan-item { background: transparent; border-color: rgba(255, 255, 255, .22); }
|
||
:root[data-theme="appletv"] .pan-play { color: #ffffff; }
|
||
:root[data-theme="appletv"] .pan-name { color: #ffffff; }
|
||
:root[data-theme="appletv"] .pan-sub { color: rgba(255, 255, 255, .68); }
|
||
:root[data-theme="appletv"] .ed-works-head { color: #f5f5f7; }
|
||
:root[data-theme="appletv"] .mh-more { color: var(--ink-dim); }
|
||
|
||
/* ===== 苹果白主题专属:底部导航栏 + 播放按钮,内置七彩流光呼吸 =====
|
||
只加效果,不改原有形状:.chips 还是胶囊长条,.ed-play 还是圆角长条。
|
||
做法:用 ::before 放一层模糊的七彩渐变(在元素内部、被圆角裁掉多余部分),
|
||
::after 盖一层半透明白让效果显得含蓄,真正的图标/文字再用 z-index 提到最上层。 */
|
||
@keyframes appletvRainbowFlowX { 0%, 100% { background-position: 0% 50%; } 50% { background-position: 100% 50%; } }
|
||
@keyframes appletvRainbowBreatheBar { 0%, 100% { opacity: .4; transform: scale(1); } 50% { opacity: .78; transform: scale(1.03); } }
|
||
@keyframes appletvRainbowSpin { from { filter: blur(20px) hue-rotate(0deg); } to { filter: blur(20px) hue-rotate(360deg); } }
|
||
@keyframes appletvRainbowBreatheSoft { 0%, 100% { opacity: .26; transform: scale(.9); } 50% { opacity: .52; transform: scale(1.04); } }
|
||
|
||
/* 导航栏:七彩左右均匀分布 + 缓慢流动 + 呼吸(对应预览里的 B-2 方案) */
|
||
:root[data-theme="appletv"] .chips {
|
||
position: fixed; overflow: hidden;
|
||
background: rgba(255, 255, 255, .55);
|
||
}
|
||
:root[data-theme="appletv"] .chips::before {
|
||
content: ""; position: absolute; inset: -60% 0; z-index: 0; pointer-events: none;
|
||
background: linear-gradient(90deg, #ff3b3b, #ff9d3b, #ffe23b, #3bff6a, #3bd4ff, #7a3bff, #ff3bbf, #ff3b3b);
|
||
background-size: 200% 100%; filter: blur(18px);
|
||
animation: appletvRainbowFlowX 6s ease-in-out infinite, appletvRainbowBreatheBar 3s ease-in-out infinite;
|
||
}
|
||
:root[data-theme="appletv"] .chips::after {
|
||
content: ""; position: absolute; inset: 0; z-index: 0; pointer-events: none;
|
||
background: rgba(255, 255, 255, .7);
|
||
}
|
||
:root[data-theme="appletv"] .chip { position: relative; z-index: 1; }
|
||
|
||
/* 播放按钮:保持长条形状与原有布局,仅把内部换成七彩微呼吸(关掉原本的蓝色流光,避免叠加) */
|
||
:root[data-theme="appletv"] .ed-play {
|
||
position: relative; overflow: hidden; animation: none;
|
||
background: rgba(255, 255, 255, .92);
|
||
}
|
||
:root[data-theme="appletv"] .ed-play::before {
|
||
content: ""; position: absolute; inset: -40%; z-index: 0; pointer-events: none;
|
||
background: conic-gradient(from 0deg, #ff3b3b, #ff9d3b, #ffe23b, #3bff6a, #3bd4ff, #7a3bff, #ff3bbf, #ff3b3b);
|
||
animation: appletvRainbowSpin 6s linear infinite, appletvRainbowBreatheSoft 4s ease-in-out infinite;
|
||
}
|
||
:root[data-theme="appletv"] .ed-play::after {
|
||
content: ""; position: absolute; inset: 0; z-index: 0; pointer-events: none;
|
||
background: rgba(255, 255, 255, .8);
|
||
}
|
||
:root[data-theme="appletv"] .ed-play .tri,
|
||
:root[data-theme="appletv"] .ed-play #edPlayLabel {
|
||
position: relative; z-index: 1;
|
||
}
|
||
@media (prefers-reduced-motion: reduce) {
|
||
:root[data-theme="appletv"] .chips::before,
|
||
:root[data-theme="appletv"] .ed-play::before { animation: none; }
|
||
}
|
||
.no-css-functions:root { --safe-top: 0px; --safe-bottom: 0px; }
|
||
:root {
|
||
--safe-top: min(max(var(--fm-safe-top, 0px), env(safe-area-inset-top, 0px)), 64px);
|
||
--safe-bottom: min(max(var(--fm-safe-bottom, 0px), env(safe-area-inset-bottom, 0px)), 40px);
|
||
--safe-left: max(var(--fm-safe-left, 0px), env(safe-area-inset-left, 0px));
|
||
--safe-right: max(var(--fm-safe-right, 0px), env(safe-area-inset-right, 0px));
|
||
}
|
||
/* 非全屏(normal):原生顶部/底部 UI 已避让系统栏,WebHome 不再叠加安全区,避免顶部大空白 */
|
||
html.chrome-normal { --safe-top: 0px; --safe-bottom: 0px; }
|
||
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
|
||
html, body {
|
||
margin: 0; min-height: 100%; color: var(--ink);
|
||
background: var(--dyn-bg, #000000);
|
||
/* 故意不加 transition:hero-shade / hero-blend 两层融合渐变本身就是瞬时跟着 --dyn-bg-rgb 变的
|
||
(见下面 .hero-blend 的注释),如果这里的页面背景还带着延迟过渡,融合层已经瞬间到位、
|
||
页面背景却还在慢慢追,两者交界处(融合层贴着页面背景那条边)就会露出一条"追赶中"的接缝——
|
||
不管是自动轮播还是手动拖拽,只要颜色一变就会看见。两边都改成瞬时,才能真正无缝衔接。 */
|
||
font-family: "PingFang SC", "HarmonyOS Sans SC", "Microsoft YaHei", "Noto Sans CJK SC", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||
font-size: 15px; line-height: 1.5; -webkit-font-smoothing: antialiased;
|
||
}
|
||
html:not(.fm-native), html:not(.fm-native) body {
|
||
background: var(--dyn-bg, #000000);
|
||
}
|
||
.mono { font-family: ui-monospace, "SF Mono", "Roboto Mono", "DejaVu Sans Mono", Consolas, monospace; letter-spacing: .04em; }
|
||
/* 首页 / 分类页 这种"整页切换"原来是纯粹的 style.display 硬切,一帧内直接消失/出现,
|
||
没有任何过渡。这里给刚显示出来的那个视图补一个轻微的"弹入"动画(略微放大回弹 + 上浮淡入),
|
||
由 JS 在切换时手动重新触发(先移除类名强制重排,再加回去),不影响原有 display 切换逻辑,
|
||
也不需要两个视图同时占位,所以不会有布局跳动的风险。 */
|
||
@keyframes viewPopIn { from { opacity: 0; transform: translateY(14px) scale(.965); } to { opacity: 1; transform: translateY(0) scale(1); } }
|
||
.view-pop { animation: viewPopIn .46s cubic-bezier(.34, 1.56, .64, 1); }
|
||
@media (prefers-reduced-motion: reduce) { .view-pop { animation: none; } }
|
||
/* TV 端:遥控操作追求最简单直接的响应,所有装饰性 keyframe 动画(呼吸光、流光、shimmer 加载条、
|
||
弹入弹出)统一关掉,跟 prefers-reduced-motion 用同一套思路,只是不看系统设置、只认 tv-mode。
|
||
注意这里只关 animation,不动 transition——面板/详情页开合那种短过渡还留着,纯瞬切会显得很生硬。 */
|
||
.tv-mode * { animation: none !important; }
|
||
.app { min-height: 100vh; min-height: var(--fm-web-height, 100vh); background: transparent; padding-left: var(--safe-left); padding-right: var(--safe-right); }
|
||
/* 苹果橡皮筋回弹:越界拉伸时 JS 给容器加 translateY,松手用这条弹性过渡弹回。
|
||
.rubber-snap 只在"松手弹回"那一刻加上(回弹用带过冲的缓动),跟手拖拽时不加过渡(实时跟手)。 */
|
||
.rubber-snap { transition: transform .42s cubic-bezier(.22, 1, .36, 1); }
|
||
#detailSheet.rubber-snap { transition: transform .42s cubic-bezier(.22, 1, .36, 1); }
|
||
button { font: inherit; color: inherit; cursor: pointer; border: 0; background: none; padding: 0; margin: 0; appearance: none; -webkit-appearance: none; text-align: inherit; }
|
||
input { font: inherit; color: inherit; }
|
||
img { display: block; border: 0; }
|
||
|
||
.focusable:focus, .focusable.focus { outline: none; }
|
||
.tv-mode .focusable:focus, .tv-mode .focusable.focus { transform: scale(1.06); }
|
||
|
||
/* 头部 */
|
||
.header { position: sticky; top: 0; z-index: 30; padding-top: calc(var(--safe-top) + 8px); padding-bottom: 8px; background: transparent; }
|
||
.fm-native .header { background: transparent; }
|
||
.header-row { display: flex; align-items: center; padding: 0 16px; }
|
||
.brand { flex: 0 0 auto; display: flex; align-items: center; background: none; border: 0; padding: 4px 2px; cursor: pointer; }
|
||
.brand:active { transform: scale(.94); }
|
||
.tv-mode .brand.focus, .tv-mode .brand:focus { transform: scale(1.08); }
|
||
.brand-mark { flex: 0 0 auto; display: block; }
|
||
.brand .name { font-size: 18px; font-weight: 800; margin-left: 9px; letter-spacing: 1px; }
|
||
.hdr-spacer { flex: 1 1 auto; }
|
||
/* 搜索面板(顶部固定浮层,搜索时出现) */
|
||
.search-panel { position: fixed; top: 0; left: 0; right: 0; z-index: 60; visibility: hidden; padding: calc(var(--safe-top) + 10px) 16px 12px;
|
||
background: transparent;
|
||
opacity: 0; -webkit-transform: translateY(-14px); transform: translateY(-14px); pointer-events: none;
|
||
transition: opacity .22s ease, -webkit-transform .3s cubic-bezier(.34,1.56,.64,1), transform .3s cubic-bezier(.34,1.56,.64,1), visibility 0s linear .26s; }
|
||
.search-panel.open { visibility: visible; opacity: 1; -webkit-transform: translateY(0); transform: translateY(0); pointer-events: auto;
|
||
transition: opacity .2s ease, -webkit-transform .32s cubic-bezier(.34,1.56,.64,1), transform .32s cubic-bezier(.34,1.56,.64,1); }
|
||
/* 搜索面板打开时的背景遮罩:模糊 + 半透明,禁止背景滑动,点击空白处回弹关闭 */
|
||
.search-mask { position: fixed; top: 0; right: 0; bottom: 0; left: 0; inset: 0; z-index: 55; visibility: hidden; opacity: 0;
|
||
background: rgba(6,10,22,.46); -webkit-backdrop-filter: blur(0px); backdrop-filter: blur(0px);
|
||
-ms-touch-action: none; touch-action: none;
|
||
transition: opacity .24s ease, visibility 0s linear .24s; }
|
||
.search-mask.open { visibility: visible; opacity: 1; -webkit-backdrop-filter: blur(16px) saturate(1.3); backdrop-filter: blur(16px) saturate(1.3);
|
||
transition: opacity .2s ease, -webkit-backdrop-filter .3s ease, backdrop-filter .3s ease; }
|
||
body.search-lock { position: fixed; left: 0; right: 0; width: 100%; }
|
||
.search-box { display: flex; align-items: center; background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 0 6px 0 12px; min-width: 0; position: relative; }
|
||
.search-ico { flex: 0 0 auto; display: flex; color: var(--ink-faint); margin-right: 8px; }
|
||
.search-input { flex: 1 1 auto; min-width: 0; background: transparent; border: 0; outline: none; padding: 10px 0; color: var(--ink); font-size: 15px; }
|
||
.search-input::placeholder { color: var(--ink-faint); }
|
||
.search-go { flex: 0 0 auto; padding: 7px 15px; border-radius: 9px; background: var(--grad); color: var(--accent-ink); font-weight: 800; font-size: 13px; background-size: 220% 220%; animation: gradFlow 8s ease-in-out infinite; }
|
||
/* 统一的圆角图标按钮底座:背景/描边/按下反馈只在这里定义一次,
|
||
hdr-ico / cat-back / recent-clear-btn 都基于它,只覆盖各自的尺寸和默认字色。 */
|
||
.icon-btn { flex: 0 0 auto; display: flex; align-items: center; justify-content: center;
|
||
background: var(--panel); border: 1px solid var(--line); color: var(--ink-dim);
|
||
outline: none; -webkit-tap-highlight-color: transparent; }
|
||
.icon-btn::-moz-focus-inner { border: 0; }
|
||
.icon-btn:active { transform: scale(.92); background: var(--accent-soft); border-color: var(--accent-line); }
|
||
.icon-btn.active { background: var(--accent-soft); border-color: var(--accent-line); color: var(--accent); }
|
||
.tv-mode .icon-btn.focus, .tv-mode .icon-btn:focus { transform: scale(1.12); }
|
||
.hdr-ico { width: 40px; height: 40px; border-radius: 11px; }
|
||
.search-go svg { display: block; }
|
||
.tv-mode .search-go.focus, .tv-mode .search-go:focus { transform: scale(1.08); }
|
||
/* emby 式首页多行 */
|
||
#homeRows { padding-top: 6px; }
|
||
.mh-section { margin-bottom: 22px; }
|
||
.mh-section-head { display: flex; align-items: baseline; justify-content: space-between; padding: 0 16px; margin-bottom: 10px; }
|
||
.mh-section-title { font-size: 17px; font-weight: 800; letter-spacing: -.02em; }
|
||
.mh-more { font-size: 13px; font-weight: 700; color: var(--accent); background: none; border: 0; display: inline-flex; align-items: center; gap: 2px; padding: 2px 0; }
|
||
.mh-more:active { opacity: .6; }
|
||
.mh-row { display: flex; gap: var(--card-gut); overflow-x: auto; padding: 0 var(--card-gut) 4px; scrollbar-width: none; -webkit-overflow-scrolling: touch; }
|
||
.mh-row::-webkit-scrollbar { display: none; }
|
||
.mh-row .card { width: 128px; flex: 0 0 auto; margin: 0; }
|
||
/* 继续观看:横版剧照比例(16:9),而不是竖版海报比例 */
|
||
.mh-row-recent .card { width: 208px; }
|
||
.mh-row-recent .poster { padding-top: 56.25%; }
|
||
.mh-row-recent .poster img { object-position: center 25%; }
|
||
.grid.grid-recent { grid-template-columns: repeat(2, 1fr); }
|
||
.grid-recent .poster { padding-top: 56.25%; }
|
||
.grid-recent .poster img { object-position: center 25%; }
|
||
@media (min-width: 600px) { .grid.grid-recent { grid-template-columns: repeat(3, 1fr); } }
|
||
.mh-row-empty { padding: 4px var(--gut); color: var(--ink-faint); font-size: 12.5px; }
|
||
/* 分类页头 */
|
||
/* 分类页头:原来写死纯黑(#000000),跟首页/详情页现在都跟着海报自动变色的暖色调背景放一起,
|
||
顶上突兀地卡一条黑条,很显眼很尴尬。改成跟 --dyn-bg-rgb 同色系的半透明磨砂条——颜色跟着
|
||
当前页面的主题色走,同时留一点模糊(backdrop-filter)方便下面滚动的内容衬着文字看得清,
|
||
观感上就是"同一块背景延伸上去",而不是另外扣一块黑色盖子上去。 */
|
||
.cat-head { display: flex; align-items: center; gap: 8px; padding: calc(var(--safe-top) + 8px) var(--gut) 8px;
|
||
position: sticky; top: 0; z-index: 20;
|
||
background: rgba(var(--dyn-bg-rgb, 0,0,0), .82);
|
||
-webkit-backdrop-filter: blur(20px) saturate(1.15);
|
||
backdrop-filter: blur(20px) saturate(1.15); }
|
||
/* 返回按钮:原来直接吃 .icon-btn 的通用方形底座(10px 圆角、纯色板 + 细描边),
|
||
跟其它功能性图标按钮长一个样,放在页头左上角显得很"工具栏",不够精致。
|
||
改成苹果系统里常见的圆形毛玻璃返回键:正圆形、更强的模糊透光、外投影给一点浮起的
|
||
厚度感,再叠一条极淡的内上高光模拟玻璃球面反光。按压时用回弹曲线代替瞬间跳变的
|
||
scale,松手时的手感更软。箭头本身在 24 视口里其实比几何中心略靠右一点点,
|
||
这里用 svg 自身的 translateX 往左挪 1px,视觉上更居中。 */
|
||
.cat-back {
|
||
width: 36px; height: 36px; border-radius: 50%; color: var(--ink);
|
||
background: var(--panel-soft);
|
||
border: 1px solid var(--line);
|
||
box-shadow: 0 3px 12px rgba(0, 0, 0, .22), 0 1px 2px rgba(0, 0, 0, .16), inset 0 1px 0 rgba(255, 255, 255, .1);
|
||
-webkit-backdrop-filter: blur(16px) saturate(1.4);
|
||
backdrop-filter: blur(16px) saturate(1.4);
|
||
transition: transform .38s cubic-bezier(.34, 1.56, .64, 1), background .2s ease, box-shadow .2s ease, border-color .2s ease;
|
||
}
|
||
.cat-back svg { transform: translateX(-1px); }
|
||
.cat-back:active {
|
||
transform: scale(.86);
|
||
background: var(--accent-soft);
|
||
border-color: var(--accent-line);
|
||
box-shadow: 0 1px 4px rgba(0, 0, 0, .18), inset 0 1px 0 rgba(255, 255, 255, .06);
|
||
}
|
||
.tv-mode .cat-back.focus, .tv-mode .cat-back:focus {
|
||
transform: scale(1.14);
|
||
box-shadow: 0 0 0 3px var(--accent-line), 0 3px 12px rgba(0, 0, 0, .22);
|
||
}
|
||
.cat-title { font-size: 18px; font-weight: 800; flex: 0 0 auto; max-width: 40%; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
/* 搜索结果页头:把"搜索:关键词"这行纯文字标题换成一个"搜索框"样式的按钮——
|
||
里面放一个放大镜图标 + 当前关键词,点它重新打开搜索面板改词(参考设计图,搜完仍看得到搜索框本身,
|
||
而不是只剩一行标题文字)。同时把站源筛选胶囊单独挪到下一行。默认不显示,只有 #catView.searching 时出现。 */
|
||
.cat-searchbar { display: none; align-items: center; gap: 8px; flex: 1 1 auto; min-width: 0; cursor: pointer;
|
||
background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 9px 10px 9px 14px; color: var(--ink);
|
||
-webkit-backdrop-filter: blur(16px) saturate(1.3); backdrop-filter: blur(16px) saturate(1.3);
|
||
-webkit-tap-highlight-color: transparent;
|
||
-webkit-user-select: none; user-select: none; transition: background .18s ease, border-color .18s ease; }
|
||
.cat-searchbar .csb-ico { flex: 0 0 auto; display: flex; color: var(--ink-faint); }
|
||
.cat-searchbar .csb-kw { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 15px; text-align: left; }
|
||
.cat-searchbar:active { background: var(--accent-soft); border-color: var(--accent-line); }
|
||
#catView.searching .cat-title { display: none; }
|
||
#catView.searching .cat-searchbar { display: flex; }
|
||
#catView.searching .cat-head { flex-wrap: wrap; row-gap: 10px; }
|
||
/* 胶囊掉到第二行、整行左对齐;order 拉大保证它排在这一行所有元素最后(即换行到下一行) */
|
||
#catView.searching #genreChips { order: 99; flex: 0 0 100%; max-width: 100%; width: 100%; margin: 0; justify-content: flex-start; }
|
||
.hero-wrap { position: relative; }
|
||
.hero-wrap[hidden] { display: none; }
|
||
.hero { position: relative; width: 100%; height: 58vh; min-height: 340px; max-height: 560px; overflow: hidden; margin: 0 0 6px; background: var(--sheet-bg); }
|
||
.hero:empty { display: none; }
|
||
@media (orientation: landscape) { .hero { height: 85vh; min-height: 280px; } }
|
||
/* 平板竖屏(宽度够大且竖屏):轮播海报加高 8vh,画面更饱满。max-height 也放宽以免被 560px 卡住 */
|
||
@media (min-width: 600px) and (orientation: portrait) { .hero { height: 66vh; max-height: 720px; } }
|
||
/* 图片层:不参与左右翻页位移,只在当前帧/目标帧之间做透明度交叉渐隐渐显,
|
||
叠色(hero-shade/hero-blend)跟着 --dyn-bg-rgb 同步瞬时变化,观感上颜色融合和图片过渡完全同步。 */
|
||
.hero-imgs { position: absolute; inset: 0; width: 100%; height: 100%; overflow: hidden; z-index: 0; }
|
||
.hero-track { position: relative; z-index: 1; display: flex; height: 100%; transition: transform .5s cubic-bezier(.4, 0, .2, 1); will-change: transform; }
|
||
.hero-slide { position: relative; flex: 0 0 100%; height: 100%; overflow: hidden; display: flex; flex-direction: column; justify-content: flex-end; }
|
||
.hero-bg { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; object-position: center 20%; opacity: 0; transition: opacity .5s cubic-bezier(.4, 0, .2, 1); }
|
||
.hero-bg.active { opacity: 1; }
|
||
/* 顶部渐变原来用 --dyn-bg-rgb(全图众数主色)去染顶部/次顶部区域,
|
||
问题:主色是"整张海报"的投票结果,跟顶部那一小条局部像素的真实颜色(比如深色头发/五官)
|
||
完全没有对应关系,容易出现"局部本来是黑的,却被强行染成主色(红/蓝等)"的违和感。
|
||
改成不跟随主色、只用于状态栏文字可读性的极淡中性色,不再随海报变色。 */
|
||
.hero-shade { position: absolute; inset: 0; background:
|
||
linear-gradient(to bottom, rgba(0,0,0,.28) 0%, transparent 22%); }
|
||
/* 底部融合层:不是拿一块纯色硬盖上去(那样不管颜色多准,照片的清晰细节和纯色之间还是有一条能看出来的边),
|
||
而是用 backdrop-filter 把这块区域"背后的图"本身模糊掉,再叠一层从透明到接近实色的动态色渐变——
|
||
越往下越糊、越往下颜色越重,模糊+调色是同步渐进的,所以看不出"从这里开始模糊/从这里开始变色"的分界线。
|
||
最底部渐变到 .94 的不透明度,正好接近下方页面 --dyn-bg 的实色,和页面背景连成一片。
|
||
自身再用 mask-image 做一次由上到下的显现渐变,让这层"变糊变色"的过渡本身也是渐进出现的,不是一整块突然出现。 */
|
||
/* 底部融合:主页轮播跟着视差滚动,用【单层】backdrop-filter(滚动流畅,不闪)。
|
||
关键是 mask 的透明度要【全程平缓爬升】——之前 62% 就到全黑(#000),导致下半截是一整块满模糊、
|
||
和上面渐变段之间有明显硬分界。这里改成从顶部一路多档缓升,直到贴底才接近全实,
|
||
模糊强度全程渐进,看不出"分界带"。 */
|
||
.hero-blend {
|
||
position: absolute; left: 0; right: 0; bottom: -2px; height: calc(56% + 2px); pointer-events: none;
|
||
-webkit-backdrop-filter: blur(22px) saturate(1.12); backdrop-filter: blur(22px) saturate(1.12);
|
||
background: linear-gradient(to bottom, rgba(var(--dyn-bg-rgb), 0) 0%, rgba(var(--dyn-bg-rgb), .22) 34%, rgba(var(--dyn-bg-rgb), .6) 58%, rgba(var(--dyn-bg-rgb), .9) 80%, rgba(var(--dyn-bg-rgb), 1) 92%, rgba(var(--dyn-bg-rgb), 1) 100%);
|
||
-webkit-mask-image: linear-gradient(to bottom, transparent 0%, rgba(0,0,0,.04) 12%, rgba(0,0,0,.12) 26%, rgba(0,0,0,.26) 40%, rgba(0,0,0,.45) 54%, rgba(0,0,0,.66) 68%, rgba(0,0,0,.85) 82%, #000 96%);
|
||
mask-image: linear-gradient(to bottom, transparent 0%, rgba(0,0,0,.04) 12%, rgba(0,0,0,.12) 26%, rgba(0,0,0,.26) 40%, rgba(0,0,0,.45) 54%, rgba(0,0,0,.66) 68%, rgba(0,0,0,.85) 82%, #000 96%);
|
||
}
|
||
/* 横屏(平板/手机横放):屏幕更矮更宽,56% 高度占比过大会糊到海报主体。调矮到 44%,
|
||
模糊区更靠下,不遮画面。渐变/mask 比例是跟着高度百分比自动收缩的,只是整体下移。 */
|
||
@media (orientation: landscape) { .hero-blend { height: calc(44% + 2px); -webkit-backdrop-filter: blur(18px) saturate(1.12); backdrop-filter: blur(18px) saturate(1.12); } }
|
||
.hero-info { position: relative; z-index: 1; padding: 0 20px 36px; text-align: center; }
|
||
|
||
/* ============================================================
|
||
首页轮播「异步滑动」视差(自 emby 首页移植)
|
||
目标:下滑时只有海报图(.hero-bg)慢半拍上移,.hero-shade / .hero-blend / .hero-info
|
||
全部保持页面常速、和下面的内容行一起走 —— 融合渐变便会自下而上把慢下来的海报逐渐"淹没",
|
||
出现景深分层感;而信息文字始终贴着内容,不会脱节割裂。
|
||
|
||
为什么用滚动驱动动画(animation-timeline)而不是 JS 监听 scroll 写 transform:
|
||
滚动本身跑在合成器线程,JS 改 transform 跑在主线程,海报位置永远比内容慢一帧 → 肉眼可见抖动,
|
||
这是原理性的,调参数消不掉。绑到滚动时间轴上则整条动画由浏览器在合成器线程驱动,与滚动同帧。
|
||
|
||
⚠ 两个坑(都已避开):
|
||
1) 时间轴用 scroll(root block) —— 本页是「整个窗口」在滚(window.scrollTo/pageYOffset),
|
||
根滚动器就是文档。这里绝不能用 scroll(nearest):.hero 和 .hero-slide 都带 overflow:hidden,
|
||
它们本身就算"滚动容器",nearest 会命中它们,而它们根本不滚 → 进度恒为 0 → 完全没效果。
|
||
2) 位移只加在 .hero-bg 上,绝不碰 .hero-track —— 那一层的 transform 是轮播左右翻页在用的,
|
||
动它会把轮播搞坏。
|
||
============================================================ */
|
||
@supports (animation-timeline: scroll()) {
|
||
/* 动画区间要知道 hero 实际高度:用与 .hero 完全相同的算式复刻(含 min/max-height → clamp) */
|
||
.hero { --hero-h: clamp(340px, 58vh, 560px); }
|
||
@media (orientation: landscape) { .hero { --hero-h: clamp(280px, 85vh, 560px); } }
|
||
@media (min-width: 600px) and (orientation: portrait) { .hero { --hero-h: clamp(340px, 66vh, 720px); } }
|
||
|
||
/* 海报:慢速下压(视差) + 极轻微推近(景深)。整条动画在合成器上跑,带 scale 也依然丝滑 */
|
||
@keyframes heroParallax {
|
||
from { transform: translate3d(0, 0, 0) scale(1); }
|
||
to { transform: translate3d(0, calc(var(--hero-h, 58vh) * .42), 0) scale(1.06); }
|
||
}
|
||
/* 信息层:前 30% 完全不动、保持清晰,之后随滚动优雅淡出(只改透明度、不改位移,
|
||
所以依旧与内容同速,不会重现"脱节"的割裂感) */
|
||
@keyframes heroInfoFade {
|
||
0%, 30% { opacity: 1; }
|
||
100% { opacity: 0; }
|
||
}
|
||
/* 注意声明顺序:animation 简写会把 animation-timeline 重置为 auto,
|
||
所以 animation-timeline / animation-range 必须写在简写「之后」,否则又变成不动。 */
|
||
.hero-bg {
|
||
animation: heroParallax linear both;
|
||
animation-timeline: scroll(root block);
|
||
animation-range: 0px var(--hero-h, 58vh);
|
||
will-change: transform; backface-visibility: hidden;
|
||
}
|
||
.hero-info {
|
||
animation: heroInfoFade linear both;
|
||
animation-timeline: scroll(root block);
|
||
animation-range: 0px calc(var(--hero-h, 58vh) * .78);
|
||
}
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.hero-bg { animation: none; }
|
||
.hero-info { animation: none; opacity: 1; }
|
||
}
|
||
}
|
||
.hero-title-wrap { display: flex; justify-content: center; }
|
||
.hero-title { font-size: 30px; font-weight: 900; letter-spacing: -.02em; line-height: 1.08; text-shadow: 0 2px 18px rgba(0,0,0,.55); max-width: 90%; margin: 0 auto 10px; }
|
||
.hero-logo { display: block; max-width: 74%; max-height: 84px; width: auto; height: auto; margin: 0 auto 12px; object-fit: contain; filter: drop-shadow(0 4px 16px rgba(0,0,0,.6)); }
|
||
.hero-meta { display: flex; align-items: center; justify-content: center; gap: 8px; flex-wrap: wrap; font-size: 13px; color: var(--ink-dim); text-shadow: 0 1px 6px rgba(0,0,0,.6); }
|
||
.hero-meta { color: #d8def0; }
|
||
.hero-ov { margin: 9px auto 0; font-size: 13px; color: #c7cddf; line-height: 1.55; max-width: 94%; text-shadow: 0 1px 6px rgba(0,0,0,.6); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; text-overflow: ellipsis; }
|
||
/* 横版(手机横屏)时轮播信息改左对齐:标题、徽标、元信息、简介都靠左,不再居中,
|
||
跟竖版是两套独立的对齐方式,互不影响。 */
|
||
@media (orientation: landscape) {
|
||
.hero-info { text-align: left; }
|
||
.hero-title-wrap { justify-content: flex-start; }
|
||
.hero-title { margin: 0 0 10px; max-width: 60%; }
|
||
.hero-logo { margin: 0 0 12px; }
|
||
.hero-meta { justify-content: flex-start; }
|
||
.hero-ov { margin: 9px 0 0; max-width: 60%; }
|
||
}
|
||
/* 轮播指示器(圆点) */
|
||
.hero-dots { position: absolute; left: 0; right: 0; bottom: 13px; z-index: 4; display: flex; align-items: center; justify-content: center; gap: 6px; pointer-events: none; }
|
||
.hero-dots:empty { display: none; }
|
||
.hero-dot { width: 6px; height: 6px; border-radius: 50%; background: rgba(255,255,255,.4); box-shadow: 0 1px 4px rgba(0,0,0,.4); transition: width .25s ease, background-color .25s ease, opacity .25s ease; }
|
||
.hero-dot.on { width: 16px; background: #fff; }
|
||
.chips { position: fixed; left: 50%; bottom: calc(10px + var(--safe-bottom)); transform: translateX(-50%);
|
||
width: auto; max-width: 94%; display: inline-flex; align-items: center; gap: 2px; justify-content: center; z-index: 45;
|
||
background: rgba(18, 26, 54, 0.62); -webkit-backdrop-filter: blur(24px) saturate(1.7); backdrop-filter: blur(24px) saturate(1.7);
|
||
border: 1px solid rgba(160, 190, 255, 0.16); border-radius: 999px; padding: 5px 7px; overflow: visible;
|
||
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.42), inset 0 1px 0 rgba(255, 255, 255, 0.10);
|
||
-webkit-user-select: none; user-select: none; -webkit-touch-callout: none; }
|
||
.chip { flex: 0 0 auto; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px;
|
||
padding: 7px 22px; border-radius: 999px; background: none; border: 0; color: var(--ink-dim); font-size: 10px; font-weight: 700; white-space: nowrap; transition: color .16s, background .16s;
|
||
-webkit-user-select: none; user-select: none; -webkit-touch-callout: none; -webkit-tap-highlight-color: transparent; }
|
||
.chip .chip-ico { width: 23px; height: 23px; display: block; }
|
||
.chip[data-nav="settings"] .chip-ico { width: 22px; height: 22px; }
|
||
.chip.active { background: var(--accent-soft); color: var(--accent); }
|
||
.chip:active { transform: scale(.94); }
|
||
.tv-mode .chip.focus, .tv-mode .chip:focus { transform: scale(1.08); }
|
||
/* 给内容留出底栏空间;详情/设置打开时隐藏底栏 */
|
||
main { padding-bottom: calc(78px + var(--safe-bottom)); }
|
||
body.detail-active .chips { display: none; }
|
||
.chips::-webkit-scrollbar { display: none; }
|
||
/* 二级类型筛选 chips:横向滑动这里不再自己用 JS 接管 touchmove 手搓惯性/橡皮筋——
|
||
之前 touch-action: none 会把这块区域的滚动完全交给 JS 主线程处理,每一帧 touchmove
|
||
都要走一遍 JS(还强制 preventDefault),走不了浏览器/系统那条硬件加速的合成线程滚动
|
||
通道,参数调得再准也比不过原生滚动的跟手度,低端设备上还会明显掉帧、延迟,这才是
|
||
"不灵敏"的根子,不是触摸区域大小的问题。
|
||
改成 touch-action: pan-x:告诉浏览器"这个元素只接管横向平移手势",横向滑动直接走原生
|
||
滚动(自带真实的惯性衰减和越界橡皮筋回弹,观感就是 iOS 原生的样子),竖直方向的手势
|
||
不被这里拦截,会正常冒泡去滚整个页面,不会出现"划到这排标签就滑不动整页"的问题。 */
|
||
.genre-chips { display: flex; flex: 1 1 auto; min-width: 0; justify-content: safe center; overflow-x: auto; white-space: nowrap; margin-left: 0; padding: 2px 0; -webkit-overflow-scrolling: touch; scrollbar-width: none; touch-action: pan-x; overscroll-behavior-x: contain; }
|
||
.genre-chips::-webkit-scrollbar { display: none; }
|
||
.genre-chips:empty { display: none; margin: 0; }
|
||
.genre-chip { flex: 0 0 auto; margin-right: 6px; padding: 4px 11px; border-radius: 8px; font-size: 12px; background: rgba(255,255,255,.05); border: 1px solid var(--line); color: var(--ink-dim); }
|
||
.genre-chip.active { background: var(--accent-soft); border-color: var(--accent-line); color: var(--ink); font-weight: 600; }
|
||
/* 搜索结果站源筛选栏复用 .genre-chip,额外挂一个计数徽标(如截图里"全部 75"那种圆角数字)。
|
||
分组标题 .search-group-title 直接放进 #grid 里,用 grid-column:1/-1 撑满整行,
|
||
不需要另起一个网格容器——分组之间共用同一个网格的列宽/间距,视觉上更连贯。 */
|
||
.genre-chip .chip-count { margin-left: 5px; font-size: 10.5px; font-weight: 700; line-height: 15px; min-width: 15px;
|
||
display: inline-block; text-align: center; padding: 0 5px; border-radius: 999px; background: rgba(255,255,255,.12); color: inherit; }
|
||
.genre-chip.active .chip-count { background: rgba(0,0,0,.18); }
|
||
.search-group-title { grid-column: 1 / -1; display: flex; align-items: baseline; gap: 8px;
|
||
font-size: 15px; font-weight: 700; color: var(--ink); margin: 18px 0 2px; }
|
||
.search-group-title:first-child { margin-top: 2px; }
|
||
.search-group-title .cnt { font-size: 12px; font-weight: 500; color: var(--ink-dim); }
|
||
|
||
/* 区块 */
|
||
.section { padding: 12px 0 0; }
|
||
.section-head { display: flex; align-items: center; margin-bottom: 10px; padding: 0 var(--gut); }
|
||
.section-title { display: none; }
|
||
.section-sub { display: none; }
|
||
.clear-search { flex: 0 0 auto; width: 26px; height: 26px; margin-left: 2px; border-radius: 9px;
|
||
display: flex; align-items: center; justify-content: center;
|
||
background: var(--panel); border: 1px solid var(--line); color: var(--ink-faint);
|
||
-webkit-tap-highlight-color: transparent; transition: background .15s ease, border-color .15s ease, color .15s ease; }
|
||
.clear-search:active { background: var(--accent-soft); border-color: var(--accent-line); color: var(--accent); }
|
||
.clear-search svg { display: block; }
|
||
|
||
.rail { display: flex; gap: 6px; overflow-x: auto; white-space: nowrap; padding-bottom: 4px; -webkit-overflow-scrolling: touch; scrollbar-width: none; }
|
||
.rail::-webkit-scrollbar { display: none; }
|
||
.rail .card { flex: 0 0 auto; width: 132px; margin-right: 0; white-space: normal; }
|
||
|
||
.grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--card-gut); margin: 0; padding: 0 var(--card-gut); align-items: start; width: 100%; min-width: 0; overflow: hidden; }
|
||
/* 左右滑动切换分类/筛选标签时,新内容的方向性动画(见 gridSwipeAnim)。
|
||
之前用多阶段关键帧模拟"弹簧回弹",位移会先冲过头再往回缩一点,视觉上是内容在原地
|
||
小幅度弹跳/抖动,不是真的"流动过来"的感觉,观感反而更别扭。
|
||
现在改回最直接的思路:内容整体从滑动方向的那一侧,用单段纯位移 + 减速缓动
|
||
(不过冲、不回弹)滑到位——手指往左划,新内容就从右边平滑流进来;往右划,
|
||
就从左边流进来。没有缩放、没有透明度变化,只有一次干净利落的横向位移,
|
||
最贴近"内容左右流动过来"这种直观的滑动手感。 */
|
||
@keyframes gridSwipeInL { from { transform: translateX(64px); } to { transform: translateX(0); } }
|
||
@keyframes gridSwipeInR { from { transform: translateX(-64px); } to { transform: translateX(0); } }
|
||
.grid.grid-swipe-l { animation: gridSwipeInL .36s cubic-bezier(.22, 1, .36, 1); will-change: transform; }
|
||
.grid.grid-swipe-r { animation: gridSwipeInR .36s cubic-bezier(.22, 1, .36, 1); will-change: transform; }
|
||
@media (prefers-reduced-motion: reduce) { .grid.grid-swipe-l, .grid.grid-swipe-r { animation: none; } }
|
||
.grid .card { width: auto; max-width: none; min-width: 0; margin: 0; box-sizing: border-box; }
|
||
|
||
.poster { position: relative; width: 100%; padding-top: 150%; border-radius: 12px; overflow: hidden; background: linear-gradient(135deg, rgba(255,255,255,.06), rgba(255,255,255,.02)); border: 1px solid var(--line); box-shadow: 0 4px 12px rgba(8,24,64,.38); }
|
||
.poster img { position: absolute; top: 0; left: 0; width: 100%; height: 100%; object-fit: cover; }
|
||
.poster .ph { position: absolute; top: 0; left: 0; width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; color: var(--ink-faint); font-size: 26px; font-weight: 800; text-align: center; padding: 8px; }
|
||
.card-del { position: absolute; top: 5px; right: 5px; width: 19px; height: 19px; border-radius: 50%; border: none; background: rgba(0,0,0,.55); color: #fff; font-size: 10px; line-height: 1; display: flex; align-items: center; justify-content: center; z-index: 3; -webkit-backdrop-filter: blur(2px); backdrop-filter: blur(2px); }
|
||
.card-del:active { background: var(--bad, #ff5c66); }
|
||
.recent-clear-btn { margin-left: auto; width: 34px; height: 34px; border-radius: 10px; }
|
||
.recent-clear-btn:active { background: rgba(255,92,102,.14); border-color: rgba(255,92,102,.5); color: var(--bad, #ff5c66); }
|
||
.badge { position: absolute; top: 7px; left: 7px; padding: 2px 7px; border-radius: 6px; background: var(--badge-bg); border: 1px solid var(--badge-border); color: var(--accent-ink); font-size: 9.5px; font-weight: 800; letter-spacing: .02em; box-shadow: 0 2px 8px var(--badge-shadow); }
|
||
.card-title { margin-top: 8px; font-size: 14px; font-weight: 700; letter-spacing: -.2px; line-height: 1.3; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.card-meta { display: none; }
|
||
.card { transition: transform .12s ease; }
|
||
.tv-mode .card.focus { transform: scale(1.06); }
|
||
|
||
.notice { margin: 12px 16px; padding: 16px; background: var(--panel-soft); border: 1px solid var(--line); border-radius: var(--radius); }
|
||
.notice h4 { margin: 0 0 6px; font-size: 14px; }
|
||
.notice p { margin: 0 0 10px; font-size: 13px; color: var(--ink-dim); }
|
||
.notice .link { color: var(--accent); font-weight: 700; }
|
||
.loading-row { padding: 26px 16px; text-align: center; color: var(--ink-faint); font-size: 13px; }
|
||
|
||
/* 详情 / 资源浮层 —— 满幅海报英雄区(参考 HBO/Emby)
|
||
打开/关闭走苹果风格的弹性过渡:sheet 本身只负责整体淡入淡出 + 可见性开关
|
||
(不能给 .sheet 自己加 transform,它内部 .ed-hero 是 position:fixed 贴合视口的背景层,
|
||
一旦祖先元素有了 transform 就会变成新的包含块,.ed-hero 会跟着祖先一起动而不是贴着视口,
|
||
动画过程中背景会明显错位跳动);.ed-hero 自己也只淡入淡出(opacity),不能再给它加
|
||
transform —— 它的子层 .ed-hero-blend 用了 backdrop-filter 模糊,只要“背景模糊层的祖先”
|
||
在做 transform 动画,模糊层的合成边界每帧都要重新采样,很容易撑不住,表现为开合时闪几下
|
||
白块、甚至整个过渡看着像被跳过、瞬间完成。真正需要放大回弹手感的,放到 .ed-hero-imgs
|
||
(纯图片层,没有 backdrop-filter 后代)上就安全了;.ed-body 同理没有 fixed/模糊后代,
|
||
可以放心加 transform。 */
|
||
.sheet { position: fixed; top: 0; right: 0; bottom: 0; left: 0; inset: 0; z-index: 60; background: transparent; overflow-y: auto; -webkit-overflow-scrolling: touch; overscroll-behavior-y: contain;
|
||
visibility: hidden; opacity: 0; pointer-events: none;
|
||
transition: opacity .22s ease, visibility 0s linear .3s; }
|
||
.sheet.active { visibility: visible; opacity: 1; pointer-events: auto; transition: opacity .26s ease; }
|
||
.sheet .ed-hero { opacity: 0; transition: opacity .3s ease; }
|
||
.sheet.active .ed-hero { opacity: 1; transition: opacity .34s ease; }
|
||
.sheet .ed-hero-imgs { transform: scale(1.05); transition: transform .3s ease; }
|
||
.sheet.active .ed-hero-imgs { transform: scale(1); transition: transform .5s cubic-bezier(.22, 1, .36, 1); }
|
||
.sheet .ed-body { transform: translateY(26px); transition: transform .26s ease; }
|
||
.sheet.active .ed-body { transform: translateY(0); transition: transform .48s cubic-bezier(.34, 1.56, .64, 1); }
|
||
body.detail-active .app { visibility: hidden; pointer-events: none; }
|
||
body.detail-active { background: var(--ed-bg, var(--sheet-bg)); transition: background-color 1.1s ease; }
|
||
|
||
.ed-hero { position: fixed; inset: 0; z-index: -2; overflow: hidden; background: var(--ed-bg, #070b1a); transform: translateZ(0); backface-visibility: hidden; }
|
||
.ed-hero-imgs { position: absolute; inset: 0; }
|
||
.ed-hero-imgs img { position: absolute; inset: -1px; width: calc(100% + 2px); height: calc(100% + 2px); object-fit: cover; object-position: center 18%;
|
||
transform: scale(1.06) translateZ(0); backface-visibility: hidden; opacity: 0; transition: opacity 1.1s ease, filter .2s linear; }
|
||
.ed-hero-imgs img.active { opacity: 1; }
|
||
.ed-hero-imgs img.active.first-in { animation: heroIn 1.1s cubic-bezier(.2,.7,.2,1) forwards, heroBreathe 11s ease-in-out 1.1s infinite; }
|
||
.ed-hero-imgs img.active:not(.first-in) { animation: heroBreathe 11s ease-in-out infinite; }
|
||
@keyframes heroIn { from { transform: scale(1.14); opacity: 0; } to { transform: scale(1.06); opacity: 1; } }
|
||
@keyframes heroBreathe { 0%, 100% { transform: scale(1.06); } 50% { transform: scale(1.12); } }
|
||
@media (prefers-reduced-motion: reduce) { .ed-hero-imgs img.active, .ed-hero-imgs img.active.first-in { animation: none; } }
|
||
.ed-hero.contain .ed-hero-imgs img { object-fit: contain; object-position: center top; }
|
||
/* 贴底那截跟首页轮播同一套思路:颜色不再写死 rgba(6,5,10,.85),改用当前海报自动取色算出来的
|
||
--ed-bg-rgb / --ed-bg,让详情页背景也跟着每张海报的主色走,不再是固定藏青色兜底。
|
||
没算出颜色前用 6,5,10 / var(--sheet-bg) 兜底,跟改之前效果一致,不会有空档。
|
||
之前这里用 radial-gradient(椭圆,中心在顶部)来同时兼顾顶部轻微压暗和底部融合,
|
||
代价是椭圆形状离中心越远(也就是画面两侧靠下的角落)衰减越快,中间又相对"漏"得更久,
|
||
肉眼看上去底部是一个"U 型"的糊/暗区,跟首页轮播那种不分左右、只看高度的"平行"糊
|
||
完全是两套观感,而且这一层本身根本没用 backdrop-filter,只是纯色渐变压暗,压根没有模糊。
|
||
这里拆成两层,跟首页 .hero-shade / .hero-blend 的结构一一对应:
|
||
ed-hero-shade 只负责顶部一丝丝压暗(不模糊,纯黑到透明,跟首页 hero-shade 的顶部渐变同理),
|
||
ed-hero-blend 负责底部——真正用 backdrop-filter 把背后的海报本身模糊掉,再叠一层从透明到
|
||
实色的动态色渐变,且渐变方向是 to bottom(水平等高线,不分左右),这样详情页底部就和
|
||
首页轮播的底部融合是同一种"平行模糊",不再是圆形衰减出来的 U 型效果。 */
|
||
.ed-hero-shade { position: absolute; inset: 0; z-index: 1; pointer-events: none;
|
||
background: linear-gradient(to bottom, rgba(0,0,0,.32) 0%, rgba(0,0,0,.08) 18%, transparent 30%); }
|
||
/* 底部融合改成"渐变模糊":单层 backdrop-filter 的模糊强度是均匀的,看着就是一整块糊。
|
||
这里叠 3 层,每层模糊强度不同、且用 mask 限制在不同高度带——顶部只有最轻的一层生效,
|
||
越往下越重的层依次叠加,于是模糊从上到下由轻到重平滑过渡(顶部几乎不糊、贴底最糊)。
|
||
色彩渐变(把底部染成背景色、和下方页面连成一片)单独放在 .ed-hero-blend 自己身上。 */
|
||
.ed-hero-blend {
|
||
position: absolute; left: -1px; right: -1px; bottom: -1px; height: 72%; z-index: 1; pointer-events: none;
|
||
transform: translateZ(0); backface-visibility: hidden;
|
||
background: linear-gradient(to bottom,
|
||
rgba(var(--ed-bg-rgb, 6,5,10), 0) 0%,
|
||
rgba(var(--ed-bg-rgb, 6,5,10), .18) 22%,
|
||
rgba(var(--ed-bg-rgb, 6,5,10), .5) 40%,
|
||
rgba(var(--ed-bg-rgb, 6,5,10), .84) 56%,
|
||
rgba(var(--ed-bg-rgb, 6,5,10), 1) 70%,
|
||
rgba(var(--ed-bg-rgb, 6,5,10), 1) 100%);
|
||
}
|
||
/* 三层模糊:轻(顶)、中、重(底),各自 mask 到不同区间 */
|
||
.ed-hero-blend::before, .ed-hero-blend::after, .ed-blur-mid {
|
||
content: ""; position: absolute; inset: 0; pointer-events: none; transform: translateZ(0); backface-visibility: hidden;
|
||
}
|
||
.ed-hero-blend::before { /* 轻模糊,覆盖上半段,从顶部淡入 */
|
||
-webkit-backdrop-filter: blur(8px) saturate(1.08); backdrop-filter: blur(8px) saturate(1.08);
|
||
-webkit-mask-image: linear-gradient(to bottom, transparent 0%, rgba(0,0,0,.5) 20%, #000 45%, #000 100%);
|
||
mask-image: linear-gradient(to bottom, transparent 0%, rgba(0,0,0,.5) 20%, #000 45%, #000 100%);
|
||
}
|
||
.ed-blur-mid { /* 中模糊,覆盖中下段 */
|
||
-webkit-backdrop-filter: blur(20px) saturate(1.1); backdrop-filter: blur(20px) saturate(1.1);
|
||
-webkit-mask-image: linear-gradient(to bottom, transparent 0%, transparent 32%, rgba(0,0,0,.6) 50%, #000 66%, #000 100%);
|
||
mask-image: linear-gradient(to bottom, transparent 0%, transparent 32%, rgba(0,0,0,.6) 50%, #000 66%, #000 100%);
|
||
}
|
||
.ed-hero-blend::after { /* 重模糊,只在贴底那段 */
|
||
-webkit-backdrop-filter: blur(44px) saturate(1.14); backdrop-filter: blur(44px) saturate(1.14);
|
||
-webkit-mask-image: linear-gradient(to bottom, transparent 0%, transparent 55%, rgba(0,0,0,.6) 72%, #000 85%);
|
||
mask-image: linear-gradient(to bottom, transparent 0%, transparent 55%, rgba(0,0,0,.6) 72%, #000 85%);
|
||
}
|
||
.ed-hero-spacer { width: 100%; height: 62vh; min-height: 380px; }
|
||
@media (orientation: landscape) { .ed-hero-spacer { height: 75vh; min-height: 320px; } }
|
||
/* 横屏时屏幕更矮更宽,原来 72% 高度的模糊区域相对占比更大,容易糊到人脸。
|
||
这里单独把横屏下的模糊层调矮一些、往下移,渐变/遮罩的比例是跟着这个高度百分比自动收缩的,
|
||
所以只是模糊起始的位置下移了,不影响下面页面信息(spacer 高度没变,内容位置不动)。 */
|
||
@media (orientation: landscape) { .ed-hero-blend { height: 55%; } }
|
||
|
||
.ed-back { position: fixed; top: calc(var(--safe-top) + 8px); left: 14px; z-index: 3;
|
||
width: 40px; height: 40px; border-radius: 50%; font-size: 24px; line-height: 1;
|
||
display: flex; align-items: center; justify-content: center;
|
||
background: rgba(7,11,26,.5); color: #fff; border: 1px solid rgba(160,190,255,.22);
|
||
-webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px); }
|
||
.ed-logo { width: 100%; text-align: center; margin: -104px 0 14px; position: relative; z-index: 3; }
|
||
.ed-logo:empty { display: none; }
|
||
.ed-logo img { display: inline-block; max-width: 72%; max-height: 78px; width: auto; height: auto; filter: drop-shadow(0 3px 14px rgba(0,0,0,.7)); }
|
||
|
||
.ed-body { position: relative; z-index: 2; margin-top: -60px; padding: 0 18px calc(var(--safe-bottom) + 26px);
|
||
display: flex; flex-direction: column; align-items: center; text-align: center; }
|
||
.ed-title { font-size: 30px; font-weight: 900; line-height: 1.14; letter-spacing: -.02em; margin-bottom: 13px; text-shadow: 0 2px 20px rgba(0,0,0,.75); }
|
||
.ed-title.hidden { display: none; }
|
||
|
||
.ed-chips { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; margin-bottom: 2px; }
|
||
.ed-chips .t { display: inline-flex; align-items: center; margin: 0 0 6px; padding: 0; border: 0; background: none;
|
||
font-size: 12.5px; font-weight: 600; letter-spacing: .03em; color: var(--ink-dim); }
|
||
.ed-chips .t:not(:last-child)::after { content: "\2022"; margin: 0 6px; color: var(--ink-faint); font-size: 10px; }
|
||
.ed-chips .t.cert { color: var(--ink); font-weight: 700; padding: 2px 7px; border: 1px solid rgba(255,255,255,.32); border-radius: 3px; letter-spacing: .04em; }
|
||
|
||
.ed-play { width: 100%; max-width: 320px; margin: 2px 0 0; padding: 11px; border-radius: 14px;
|
||
background: var(--grad); color: var(--accent-ink); font-weight: 800; font-size: 16px;
|
||
display: flex; align-items: center; justify-content: center;
|
||
-webkit-mask-image: -webkit-radial-gradient(white, black);
|
||
transform: translateZ(0); -webkit-transform: translateZ(0);
|
||
backface-visibility: hidden; -webkit-backface-visibility: hidden; }
|
||
.ed-play .tri { font-size: 13px; margin-right: 10px; }
|
||
.ed-play:active { transform: scale(.99) translateZ(0); }
|
||
.ed-play[disabled] { opacity: .55; }
|
||
.tv-mode .ed-play.focus, .tv-mode .ed-play:focus { transform: scale(1.05); }
|
||
|
||
.ed-actions { display: flex; justify-content: center; align-items: center; margin: 8px 0 2px; }
|
||
.ed-ico { display: flex; align-items: center; justify-content: center; width: 44px; height: 40px; margin: 0 21px; font-size: 20px; color: var(--ink-dim);
|
||
transition: transform .12s ease, color .15s ease; }
|
||
.ed-ico svg { width: 20px; height: 20px; display: block; }
|
||
.ed-ico.on { color: #ff5d8f; }
|
||
.ed-ico:active { transform: scale(.88); }
|
||
.tv-mode .ed-ico.focus, .tv-mode .ed-ico:focus { transform: scale(1.2); }
|
||
|
||
.overview { margin: 8px 2px 2px; font-size: 14px; color: var(--ink-dim); line-height: 1.7; text-align: left; align-self: stretch; position: relative; }
|
||
.overview.clamp { display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; }
|
||
/* 横版详情页简介也改居中,跟标题/元信息等其它横版居中的区块保持一致 */
|
||
@media (orientation: landscape) { .overview { text-align: center; } }
|
||
|
||
/* 演职员(TMDB) */
|
||
.ed-cast { align-self: stretch; margin: 10px 0 0; }
|
||
.ed-cast:empty { display: none; }
|
||
.ed-cast-head { font-size: 15px; font-weight: 700; margin: 0 0 9px; }
|
||
.ed-cast-row { display: flex; align-items: stretch; overflow-x: auto; white-space: nowrap; -webkit-overflow-scrolling: touch; scrollbar-width: none; padding: 4px 18px; margin: 0 -18px; }
|
||
.ed-cast-row::-webkit-scrollbar { display: none; }
|
||
.cast-cell { flex: 0 0 auto; max-width: 240px; margin-right: 12px; display: flex; align-items: center; gap: 11px; text-align: left; background: transparent; border: 1px solid var(--line); border-radius: 999px; padding: 2px 18px 2px 6px; }
|
||
.cast-av { flex: 0 0 auto; width: 48px; height: 48px; border-radius: 50%; overflow: hidden; background: var(--panel-soft); border: none; position: relative; }
|
||
.cast-cell.tappable { cursor: pointer; }
|
||
.cast-cell.sel { border-color: var(--accent); animation: castSelBreathe 2.2s ease-in-out infinite; }
|
||
@keyframes castSelBreathe {
|
||
0%, 100% { box-shadow: 0 0 0 1px var(--accent-soft), inset 0 0 10px 0 var(--accent-soft); }
|
||
50% { box-shadow: 0 0 0 1px var(--accent-line), inset 0 0 16px 2px var(--accent-soft); }
|
||
}
|
||
.cast-av img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||
.cast-av.ph { display: flex; align-items: center; justify-content: center; color: var(--ink-faint); font-size: 20px; font-weight: 800; }
|
||
.cast-info { min-width: 0; flex: 1 1 auto; }
|
||
.cast-name { font-size: 13.5px; font-weight: 700; line-height: 1.3; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.cast-orig { margin-top: 2px; font-size: 11px; color: var(--ink-faint); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.cast-role { margin-top: 2px; font-size: 11px; color: var(--ink-faint); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
|
||
/* 演员相关作品(展开横排) */
|
||
.ed-works { align-self: stretch; }
|
||
.ed-works:empty { display: none; }
|
||
.ed-works-head { font-size: 13px; font-weight: 700; color: var(--accent); margin: 14px 0 9px; }
|
||
.ed-works-row { display: flex; align-items: flex-start; overflow-x: auto; white-space: nowrap; -webkit-overflow-scrolling: touch; scrollbar-width: none; padding: 0 18px; margin: 0 -18px; }
|
||
.ed-works-row::-webkit-scrollbar { display: none; }
|
||
.work-cell { flex: 0 0 auto; width: 104px; margin-right: 11px; text-align: left; }
|
||
.work-poster { position: relative; width: 104px; height: 156px; border-radius: 10px; overflow: hidden; background: var(--panel-soft); border: 1px solid var(--line); box-shadow: 0 6px 18px rgba(8,24,64,.45); }
|
||
.work-poster img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||
.work-title { margin-top: 7px; font-size: 12px; line-height: 1.3; color: var(--ink-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.tv-mode .work-cell.focus .work-poster { transform: scale(1.06); }
|
||
|
||
/* 搜索联想下拉 */
|
||
.search-box { position: relative; }
|
||
.search-suggest { position: absolute; top: calc(100% + 6px); left: 0; right: 0; z-index: 40; max-height: 60vh; overflow-y: auto;
|
||
background: var(--panel-strong); border: 1px solid var(--line); border-radius: 12px; padding: 6px; box-shadow: 0 18px 44px rgba(0,0,0,.5);
|
||
-webkit-backdrop-filter: blur(12px); backdrop-filter: blur(12px); }
|
||
.search-suggest:empty { display: none; }
|
||
.sug-item { display: flex; align-items: center; padding: 6px; border-radius: 9px; }
|
||
.sug-item:active { background: var(--accent-soft); }
|
||
.sug-pos { flex: 0 0 auto; width: 34px; height: 50px; border-radius: 6px; overflow: hidden; background: var(--panel-soft); margin-right: 10px; }
|
||
.sug-pos img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||
.sug-body { min-width: 0; flex: 1 1 auto; }
|
||
.sug-title { font-size: 13.5px; color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.sug-meta { font-size: 11px; color: var(--ink-faint); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.sug-empty { padding: 10px 8px; font-size: 12px; color: var(--ink-faint); }
|
||
.ed-qtabs { display: flex; flex: 1 1 auto; min-width: 0; flex-wrap: nowrap; margin: 0 0 0 10px; overflow-x: auto; -webkit-overflow-scrolling: touch; scrollbar-width: none; }
|
||
.ed-qtabs::-webkit-scrollbar { display: none; }
|
||
.ed-qtab { flex: 0 0 auto; white-space: nowrap; margin: 0 6px 0 0; padding: 4px 11px; border-radius: 8px; font-size: 12px; font-weight: 600;
|
||
background: rgba(255,255,255,.06); border: 1px solid var(--line); color: var(--ink-dim); }
|
||
.ed-qtab.active { background: var(--grad); color: var(--accent-ink); border-color: transparent; }
|
||
.tv-mode .ed-qtab.focus, .tv-mode .ed-qtab:focus { transform: scale(1.08); }
|
||
|
||
.pan-head { display: flex; align-items: center; align-self: stretch; margin: 14px 0 8px; }
|
||
.pan-head .title { font-size: 15px; font-weight: 700; flex: 0 0 auto; }
|
||
.pan-head .sub { margin-left: 8px; font-size: 11px; color: var(--ink-faint); }
|
||
.pan-list { align-self: stretch; min-height: 40px; }
|
||
.btn { padding: 11px 24px; border-radius: 11px; font-size: 14px; font-weight: 700; background: var(--panel-soft); border: 1px solid var(--line); color: var(--ink); text-align: center; }
|
||
.btn.primary { background: var(--grad); border-color: transparent; color: var(--accent-ink); box-shadow: 0 4px 16px var(--btn-shadow); }
|
||
.btn.danger { background: var(--bad, #ff5c66); border-color: transparent; color: #fff; }
|
||
.confirm-sheet { max-width: 320px; text-align: center; }
|
||
.confirm-title { margin: 0; font-size: 17px; color: var(--ink); }
|
||
.confirm-text { color: var(--ink-dim); font-size: 14px; line-height: 1.6; margin: 8px 0 0; }
|
||
.btn:active { transform: scale(.985); }
|
||
.tv-mode .btn.focus, .tv-mode .btn:focus { transform: scale(1.05); }
|
||
|
||
.pan-head { display: flex; align-items: center; margin: 14px 0 8px; }
|
||
.pan-head .title { font-size: 15px; font-weight: 700; flex: 0 0 auto; }
|
||
.pan-head .sub { margin-left: 8px; font-size: 11px; color: var(--ink-faint); }
|
||
.pan-list { min-height: 60px; max-height: 480px; overflow-y: auto; overscroll-behavior: contain; -webkit-overflow-scrolling: touch;
|
||
border: 1px solid var(--line); border-radius: 12px; padding: 8px 6px 8px 8px; background: rgba(255,255,255,.02); }
|
||
.pan-list::-webkit-scrollbar { width: 4px; }
|
||
.pan-list::-webkit-scrollbar-track { background: transparent; }
|
||
.pan-list::-webkit-scrollbar-thumb { background: var(--line-strong); border-radius: 4px; }
|
||
.pan-item { display: flex; align-items: center; padding: 11px 12px; margin-bottom: 8px; background: rgba(var(--panel-rgb), .13); border: 1px solid var(--line); border-radius: 10px; cursor: pointer; -webkit-tap-highlight-color: transparent; }
|
||
.pan-item:last-child { margin-bottom: 0; }
|
||
.pan-item:active { background: rgba(var(--panel-rgb), .26); }
|
||
.tv-mode .pan-item.focus, .tv-mode .pan-item:focus { transform: scale(1.03); }
|
||
.pan-dot { flex: 0 0 auto; width: 9px; height: 9px; border-radius: 50%; margin-right: 10px; background: var(--gray); }
|
||
.pan-dot.ok { background: var(--ok); } .pan-dot.bad { background: var(--bad); }
|
||
.pan-dot.locked { background: var(--lock); } .pan-dot.uncertain, .pan-dot.unsupported { background: var(--gray); }
|
||
.pan-dot.checking { background: var(--accent); animation: pulse 1s infinite; }
|
||
@keyframes pulse { 0%,100%{opacity:.45} 50%{opacity:.9} }
|
||
.pan-body { flex: 1 1 auto; min-width: 0; }
|
||
.pan-name { font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.pan-sub { font-size: 11px; color: var(--ink-faint); margin-top: 2px; }
|
||
.pan-sub .q { color: #9cc7ff; margin-right: 6px; }
|
||
.pan-sub .site-tag { display: inline-block; padding: 0 6px; margin-right: 6px; border-radius: 5px; border: 1px solid; font-size: 10px; font-weight: 800; line-height: 16px; vertical-align: 1px; }
|
||
.pan-sub .q-tag { display: inline-block; padding: 0 5px; margin-right: 4px; border-radius: 5px; border: 1px solid; font-size: 10px; line-height: 15px; vertical-align: 1px; font-weight: 700; }
|
||
.pan-sub .pwd { margin-right: 6px; }
|
||
.pan-sub .st { color: var(--ink-faint); }
|
||
.pan-play { flex: 0 0 auto; margin-left: 10px; width: 26px; height: 26px;
|
||
display: flex; align-items: center; justify-content: center;
|
||
font-size: 15px; color: var(--accent); }
|
||
.pan-empty, .pan-loading { padding: 22px 14px; text-align: center; color: var(--ink-faint); font-size: 13px; }
|
||
/* 详情页骨架屏:概览 / 演员 / 网盘资源在数据到手前先铺占位条 + 流光动效,避免长时间空白或干等文案。
|
||
三处都直接把骨架节点塞进真实容器里(#detailOverview / #edCast / #panList),容器本身没有独立文本节点,
|
||
等真实内容用 textContent= 或 replaceChildren(...) 覆盖时,骨架节点自然被替换掉,不用额外维护显隐状态。 */
|
||
.detail-skel-bar { border-radius: 5px; background: rgba(255,255,255,.07);
|
||
background-image: linear-gradient(100deg, rgba(255,255,255,.05) 30%, rgba(255,255,255,.15) 50%, rgba(255,255,255,.05) 70%);
|
||
background-size: 420px 100%; animation: skelShimmer 1.3s linear infinite; }
|
||
.ov-skel { display: flex; flex-direction: column; gap: 8px; padding: 2px 0 4px; }
|
||
.ov-skel .detail-skel-bar { height: 13px; }
|
||
.ov-skel .detail-skel-bar:nth-child(1) { width: 97%; }
|
||
.ov-skel .detail-skel-bar:nth-child(2) { width: 89%; }
|
||
.ov-skel .detail-skel-bar:nth-child(3) { width: 58%; }
|
||
.cast-skel { display: flex; gap: 14px; overflow: hidden; padding: 4px 18px; margin: 0 -18px; }
|
||
.cast-skel-item { flex: 0 0 auto; width: 54px; display: flex; flex-direction: column; align-items: center; gap: 7px; }
|
||
.cast-skel-item .detail-skel-bar.av { width: 52px; height: 52px; border-radius: 50%; }
|
||
.cast-skel-item .detail-skel-bar.nm { width: 40px; height: 9px; }
|
||
.pan-skel { display: flex; flex-direction: column; gap: 12px; padding: 14px 2px; }
|
||
.pan-skel-item { display: flex; align-items: center; gap: 11px; }
|
||
.pan-skel-item .detail-skel-bar.ic { width: 34px; height: 34px; border-radius: 9px; flex: 0 0 auto; }
|
||
.pan-skel-body { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 6px; }
|
||
.pan-skel-body .detail-skel-bar.t1 { width: 76%; height: 12px; }
|
||
.pan-skel-body .detail-skel-bar.t2 { width: 42%; height: 10px; }
|
||
/* 详情页大海报:图还没到手时(站内无海报 / TMDB 图集没回来)铺一块流光占位,不留纯黑/纯色空屏 */
|
||
.ed-hero-skel { position: absolute; inset: 0;
|
||
background-image: linear-gradient(100deg, rgba(255,255,255,.03) 30%, rgba(255,255,255,.09) 50%, rgba(255,255,255,.03) 70%);
|
||
background-size: 420px 100%; animation: skelShimmer 1.3s linear infinite; }
|
||
/* 「剧集」单集卡骨架:跟 .ep-tcard 同款尺寸(148px 宽 + 16:9 缩略图 + 两行标题),分季/翻季时先垫上 */
|
||
.ep-skel-rail { display: flex; gap: 12px; overflow: hidden; padding: 4px 18px 6px; margin: 0 -18px; }
|
||
.ep-skel-card { flex: 0 0 auto; width: 176px; }
|
||
.ep-skel-card .detail-skel-bar.thumb { width: 176px; aspect-ratio: 16/9; border-radius: 10px; }
|
||
.ep-skel-card .detail-skel-bar.line1 { width: 92%; height: 11px; margin-top: 8px; }
|
||
.ep-skel-card .detail-skel-bar.line2 { width: 58%; height: 11px; margin-top: 6px; }
|
||
.pan-item.pan-hit { animation: panHitFlash 1.6s ease; }
|
||
@keyframes panHitFlash {
|
||
0% { background: var(--accent-soft); box-shadow: 0 0 0 2px var(--accent) inset; }
|
||
70% { background: var(--accent-soft); box-shadow: 0 0 0 2px var(--accent) inset; }
|
||
100% { background: rgba(var(--panel-rgb), .13); box-shadow: none; }
|
||
}
|
||
|
||
/* 集数网格(日历式格子):枫叶这类"第N集"线路用它,方便快速点选 */
|
||
.ep-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(72px, 1fr)); gap: 8px; padding: 2px; }
|
||
.ep-cell { position: relative; display: flex; align-items: center; justify-content: center; min-height: 46px; padding: 8px 6px; border-radius: 10px; background: rgba(var(--panel-rgb), .16); border: 1px solid var(--line); color: var(--ink); font-size: 14px; font-weight: 600; text-align: center; line-height: 1.15; cursor: pointer; -webkit-tap-highlight-color: transparent; transition: transform .06s ease; }
|
||
.ep-cell:active { transform: scale(.94); }
|
||
.ep-cell.playing { background: var(--grad); color: var(--accent-ink); border-color: transparent; }
|
||
.ep-cell.resolving { color: var(--accent); border-color: var(--accent); }
|
||
.ep-cell.bad { opacity: .45; }
|
||
.ep-cell .ep-dot { position: absolute; top: 5px; right: 6px; width: 6px; height: 6px; border-radius: 50%; background: transparent; }
|
||
.ep-cell .ep-dot.ok { background: var(--ok); } .ep-cell .ep-dot.bad { background: var(--bad); } .ep-cell .ep-dot.checking { background: var(--accent); animation: pulse 1s infinite; }
|
||
.tv-mode .ep-cell.focus, .tv-mode .ep-cell:focus { transform: scale(1.08); }
|
||
|
||
/* 详情页「剧集」区:TMDB 分季 + 单集卡(类似 Emby),放在简介和演职人员之间 */
|
||
.ed-episodes { align-self: stretch; margin-top: 4px; }
|
||
.ed-episodes:empty { display: none; }
|
||
.ed-episodes-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin: 14px 0 9px; }
|
||
.ed-episodes-head .ed-works-head { margin: 0; flex: 0 0 auto; font-size: 15px; color: var(--ink); }
|
||
.ep-season-tabs { display: flex; flex: 1 1 auto; min-width: 0; gap: 6px; overflow-x: auto; -webkit-overflow-scrolling: touch; scrollbar-width: none; justify-content: flex-start; }
|
||
.ep-season-tabs::-webkit-scrollbar { display: none; }
|
||
.ep-season-chip { flex: 0 0 auto; white-space: nowrap; padding: 5px 13px; border-radius: 999px; font-size: 12px; font-weight: 700;
|
||
background: rgba(255,255,255,.06); border: 1px solid var(--line); color: var(--ink-dim); }
|
||
.ep-season-chip.active { background: var(--grad); color: var(--accent-ink); border-color: transparent; }
|
||
.tv-mode .ep-season-chip.focus, .tv-mode .ep-season-chip:focus { transform: scale(1.08); }
|
||
.ep-rail { display: flex; align-items: flex-start; gap: 12px; overflow-x: auto; white-space: nowrap; -webkit-overflow-scrolling: touch; scrollbar-width: none; padding: 4px 18px 6px; margin: 0 -18px; }
|
||
.ep-rail::-webkit-scrollbar { display: none; }
|
||
.ep-tcard { flex: 0 0 auto; width: 176px; white-space: normal; cursor: pointer; -webkit-tap-highlight-color: transparent; }
|
||
.ep-tcard:active { transform: scale(.98); }
|
||
.ep-tcard-img { position: relative; width: 176px; aspect-ratio: 16/9; border-radius: 10px; overflow: hidden; background: var(--panel-soft); border: 1px solid var(--line); }
|
||
.no-aspect-ratio .ep-tcard-img { height: 83px; }
|
||
.ep-tcard-img img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||
.ep-tcard-num { position: absolute; left: 6px; bottom: 6px; padding: 1px 7px; border-radius: 6px; background: rgba(0,0,0,.6); color: #fff; font-size: 10.5px; font-weight: 700; }
|
||
.ep-tcard-title { margin-top: 6px; font-size: 12px; line-height: 1.35; color: var(--ink-dim); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||
.tv-mode .ep-tcard.focus .ep-tcard-img, .tv-mode .ep-tcard:focus .ep-tcard-img { transform: scale(1.06); }
|
||
.ep-loading { padding: 14px 4px; color: var(--ink-faint); font-size: 13px; white-space: normal; }
|
||
|
||
/* 设置 —— 居中独立弹出卡片 */
|
||
/* 弹窗打开/关闭改成苹果风格的弹性缩放:遮罩只做淡入淡出,.panel-sheet 本身带一点
|
||
过冲的回弹(cubic-bezier 峰值超过 1 再回落),比原来的 display none/flex 硬切换更跟手。 */
|
||
.panel-overlay { position: fixed; top: 0; right: 0; bottom: 0; left: 0; inset: 0; z-index: 80; display: flex; align-items: center; justify-content: center; padding: 24px calc(16px + var(--safe-right)) calc(16px + var(--safe-bottom)) calc(16px + var(--safe-left)); background: rgba(8,11,15,.6);
|
||
visibility: hidden; opacity: 0; pointer-events: none;
|
||
transition: opacity .2s ease, visibility 0s linear .26s; }
|
||
.panel-overlay.active { visibility: visible; opacity: 1; pointer-events: auto; transition: opacity .22s ease; }
|
||
.panel-sheet { position: relative; width: 100%; max-width: 460px; max-height: 100%; background: var(--panel-strong); border: 1px solid var(--line); border-radius: 18px; overflow-y: auto; padding: 18px 16px calc(var(--safe-bottom) + 14px); box-shadow: 0 24px 64px rgba(0,0,0,.5);
|
||
transform: translateY(18px) scale(.94); transition: transform .24s ease; }
|
||
.panel-overlay.active .panel-sheet { transform: translateY(0) scale(1); transition: transform .46s cubic-bezier(.34, 1.56, .64, 1); }
|
||
.panel-close { position: absolute; top: 14px; right: 14px; width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; border-radius: 999px; color: var(--ink-dim); font-size: 16px; line-height: 1; background: rgba(255,255,255,.06); }
|
||
.panel-close:active { transform: scale(.88); }
|
||
.tv-mode .panel-close.focus, .tv-mode .panel-close:focus { transform: scale(1.15); }
|
||
.set-hd { display: flex; align-items: center; margin-bottom: 12px; padding-right: 30px; }
|
||
.set-hd h3 { margin: 0; font-size: 17px; color: var(--ink); }
|
||
.set-group { background: rgba(255,255,255,.03); border: 1px solid var(--line); border-radius: 13px; padding: 12px 12px 4px; margin-bottom: 10px; }
|
||
.set-group-title { font-size: 11px; font-weight: 800; letter-spacing: .1em; color: var(--accent); margin-bottom: 10px; text-transform: uppercase; }
|
||
.set-2col { display: flex; margin: 0 -5px; }
|
||
.set-2col .field { flex: 1 1 0; min-width: 0; margin: 0 5px 10px; }
|
||
.field { margin-bottom: 10px; }
|
||
.field label { display: block; font-size: 12px; color: var(--ink-dim); margin-bottom: 5px; }
|
||
.field label .hint { color: var(--ink-faint); font-size: 10.5px; font-weight: 400; }
|
||
.field input { width: 100%; box-sizing: border-box; padding: 10px 12px; border-radius: 9px; background: rgba(0,0,0,.28); border: 1px solid var(--line); outline: none; color: var(--ink); font-size: 13px; }
|
||
.field input:focus, .field input.focus { border-color: var(--accent); }
|
||
.pan-type-wrap { display: flex; flex-wrap: wrap; gap: 8px; }
|
||
.pan-type-chip { flex: 0 0 auto; padding: 6px 13px; border-radius: 999px; font-size: 12.5px; font-weight: 600; border: 1px solid var(--line); background: rgba(255,255,255,.04); color: var(--ink-faint); }
|
||
.pan-type-chip.on { background: var(--accent-soft); border-color: var(--accent-line); color: var(--ink); }
|
||
.tv-mode .pan-type-chip.focus, .tv-mode .pan-type-chip:focus { transform: scale(1.08); }
|
||
.toggle-row { display: flex; align-items: center; justify-content: space-between; padding: 4px 2px 8px; }
|
||
.toggle-row .lbl { font-size: 13.5px; }
|
||
.toggle-row .lbl small { display: block; font-size: 11px; color: var(--ink-faint); margin-top: 2px; }
|
||
.login-row { display: flex; align-items: center; gap: 10px; margin: 2px 0 10px; }
|
||
.btn.small { flex: 0 0 auto; padding: 8px 16px; font-size: 13px; border-radius: 9px; }
|
||
.login-state { font-size: 11.5px; color: var(--ink-faint); min-width: 0; }
|
||
.login-state.ok { color: var(--ok); }
|
||
.login-state.bad { color: var(--bad); }
|
||
.login-state.busy { color: var(--accent); }
|
||
.switch { width: 46px; height: 26px; border-radius: 999px; background: rgba(255,255,255,.16); position: relative; flex: 0 0 auto; }
|
||
.switch.on { background: var(--grad); }
|
||
/* 动态流光:主色渐变缓慢流动(约 8s 一轮,蓝调) */
|
||
@keyframes gradFlow { 0% { background-position: 0% 50%; } 50% { background-position: 100% 50%; } 100% { background-position: 0% 50%; } }
|
||
.ed-play, .ed-qtab.active, .btn.primary, .switch.on {
|
||
background-size: 220% 220%;
|
||
animation: gradFlow 8s ease-in-out infinite;
|
||
}
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.ed-play, .ed-qtab.active, .btn.primary, .switch.on { animation: none; }
|
||
}
|
||
.switch .knob { position: absolute; top: 3px; left: 3px; width: 20px; height: 20px; border-radius: 50%; background: #fff; transition: left .15s; }
|
||
.switch.on .knob { left: 23px; }
|
||
.panel-actions { display: flex; justify-content: center; margin-top: 16px; }
|
||
.panel-actions .btn { min-width: 116px; }
|
||
.panel-actions .btn + .btn { margin-left: 12px; }
|
||
/* ===== 设置页改为「正常页面」而非弹窗:铺满整个视口、顶部对齐、不带半透明遮罩,
|
||
跟 .sheet(详情页)观感一致,而不是浮在中间的对话框卡片。confirmOverlay 仍保留
|
||
原来居中弹窗的样式,这里只单独覆盖 #settingsOverlay。 ===== */
|
||
#settingsOverlay { padding: 0; align-items: stretch; justify-content: stretch; background: var(--sheet-bg); }
|
||
/* 设置页是"整页"而不是居中卡片,弹入方式跟 .panel-sheet 默认的缩放不一样:
|
||
改成从底部带过冲地滑上来,跟详情页 sheet 的滑入手感统一。 */
|
||
#settingsOverlay .panel-sheet { width: 100%; max-width: none; height: 100%; max-height: none; border-radius: 0; border: none; box-shadow: none; background: var(--panel-strong); padding: calc(var(--safe-top) + 18px) calc(16px + var(--safe-right)) calc(var(--safe-bottom) + 18px) calc(16px + var(--safe-left));
|
||
transform: translateY(5%); }
|
||
#settingsOverlay.active .panel-sheet { transform: translateY(0); transition: transform .48s cubic-bezier(.34, 1.56, .64, 1); }
|
||
#settingsOverlay .panel-close { top: calc(var(--safe-top) + 14px); right: calc(var(--safe-right) + 14px); }
|
||
.set-status { display: grid; grid-template-columns: 1fr 1fr; gap: 4px 10px; padding: 10px 0; }
|
||
.status-line { font-size: 11.5px; color: var(--ink-faint); margin: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.status-line b { color: var(--ink-dim); font-weight: 600; }
|
||
.status-line .on { color: var(--ok); } .status-line .off { color: var(--bad); }
|
||
.divider { height: 1px; background: var(--line); margin: 16px 0; }
|
||
|
||
.toast { position: fixed; left: 50%; bottom: calc(var(--safe-bottom) + 22px); transform: translateX(-50%); z-index: 90; max-width: 84%; padding: 10px 16px; border-radius: 999px; background: var(--panel-strong); border: 1px solid var(--line-strong); color: var(--ink); font-size: 13px; opacity: 0; pointer-events: none; transition: opacity .2s; }
|
||
.toast.top { top: calc(var(--safe-top) + 12px); bottom: auto; }
|
||
.toast.show { opacity: 1; }
|
||
|
||
/* 下拉刷新 */
|
||
.ptr { position: fixed; left: 0; right: 0; top: 0; z-index: 25; height: 54px; display: flex; align-items: center; justify-content: center;
|
||
margin-top: var(--safe-top); color: var(--ink-dim); font-size: 13px; opacity: 0; pointer-events: none;
|
||
transform: translateY(-10px); transition: opacity .15s; }
|
||
.ptr .ptr-ico { display: inline-block; margin-right: 8px; transition: transform .2s; }
|
||
.ptr.ready .ptr-ico { transform: rotate(180deg); }
|
||
.ptr.spin .ptr-ico { animation: ptrspin .8s linear infinite; }
|
||
@keyframes ptrspin { from { transform: rotate(0); } to { transform: rotate(360deg); } }
|
||
|
||
/* 自适应 */
|
||
@media (min-width: 600px) { .grid { grid-template-columns: repeat(auto-fill, minmax(132px, 1fr)); } }
|
||
@media (min-width: 1180px) { .grid { grid-template-columns: repeat(auto-fill, minmax(156px, 1fr)); } }
|
||
.tv-mode .grid .card { width: calc(16.666% - var(--card-gut)); max-width: calc(16.666% - var(--card-gut)); }
|
||
@media (min-width: 1600px) { .tv-mode .grid .card { width: calc(12.5% - var(--card-gut)); max-width: calc(12.5% - var(--card-gut)); } }
|
||
.tv-mode .rail .card { width: 150px; }
|
||
.tv-mode body, .tv-mode .app { font-size: 17px; }
|
||
.tv-mode .card-title { font-size: 15px; }
|
||
.tv-mode .section { padding: 20px 40px 0; }
|
||
.tv-mode .header-row, .tv-mode .chips { padding-left: 40px; padding-right: 40px; }
|
||
@supports (height: 100dvh) { .app { min-height: 100dvh; min-height: var(--fm-web-height, 100dvh); } }
|
||
|
||
/* ===== 主页整页骨架屏(首次加载遮罩,中央显示"宝宝巴士")===== */
|
||
@keyframes skelShimmer { 0% { background-position: -420px 0; } 100% { background-position: 420px 0; } }
|
||
@keyframes skelFade { to { opacity: 0; visibility: hidden; } }
|
||
.home-skeleton { position: absolute; inset: 0; z-index: 30; background: var(--dyn-bg, #0c0b12); padding: 0 var(--gut); overflow: hidden; }
|
||
.home-skeleton.hiding { animation: skelFade .45s ease forwards; pointer-events: none; }
|
||
.home-skeleton .sk-hero { width: 100%; height: 46vh; min-height: 260px; max-height: 460px; border-radius: 16px; margin: 8px 0 22px; }
|
||
.home-skeleton .sk-rowtitle { width: 120px; height: 18px; border-radius: 6px; margin: 18px 0 12px; }
|
||
.home-skeleton .sk-cards { display: flex; gap: var(--card-gut, 10px); }
|
||
.home-skeleton .sk-card { flex: 0 0 auto; width: 30vw; max-width: 128px; }
|
||
.home-skeleton .sk-poster { width: 100%; aspect-ratio: 2/3; border-radius: 12px; }
|
||
.home-skeleton .sk-line { height: 12px; border-radius: 5px; margin-top: 8px; }
|
||
.home-skeleton .sk-line.w70 { width: 70%; } .home-skeleton .sk-line.w45 { width: 45%; }
|
||
.home-skeleton .sk-shimmer { background: linear-gradient(100deg, rgba(255,255,255,.05) 30%, rgba(255,255,255,.12) 50%, rgba(255,255,255,.05) 70%); background-size: 840px 100%; animation: skelShimmer 1.3s linear infinite; }
|
||
.home-skeleton .sk-brand { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; pointer-events: none; }
|
||
.home-skeleton .sk-brand-text { font-size: 30px; font-weight: 900; letter-spacing: 4px; background: var(--grad); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; color: transparent; background-size: 220% 220%; animation: gradFlow 5s ease-in-out infinite; text-shadow: 0 2px 20px rgba(0,0,0,.3); }
|
||
.home-skeleton .sk-brand-sub { margin-top: 10px; font-size: 12px; color: var(--ink-faint); letter-spacing: 2px; }
|
||
.home-skeleton .sk-brand-dots { margin-top: 14px; display: flex; gap: 6px; }
|
||
.home-skeleton .sk-brand-dots span { width: 7px; height: 7px; border-radius: 50%; background: var(--accent, #6ea8fe); opacity: .35; animation: skelDotPulse 1.1s ease-in-out infinite; }
|
||
.home-skeleton .sk-brand-dots span:nth-child(2) { animation-delay: .18s; } .home-skeleton .sk-brand-dots span:nth-child(3) { animation-delay: .36s; }
|
||
@keyframes skelDotPulse { 0%,100% { opacity: .25; transform: translateY(0); } 50% { opacity: 1; transform: translateY(-4px); } }
|
||
/* ===== 轮播图骨架屏 ===== */
|
||
.hero-skeleton { position: relative; width: 100%; height: 58vh; min-height: 340px; max-height: 560px;
|
||
background: linear-gradient(135deg, rgba(46,166,255,.04) 0%, rgba(22,32,66,.55) 60%, rgba(7,11,26,.7) 100%);
|
||
overflow: hidden; margin: 0 0 6px; }
|
||
@media (orientation: landscape) { .hero-skeleton { height: 85vh; min-height: 280px; } }
|
||
.hero-skeleton::after { content: ""; position: absolute; inset: 0;
|
||
background: linear-gradient(90deg, transparent 0%, rgba(255,255,255,.07) 50%, transparent 100%);
|
||
-webkit-animation: heroSkelShimmer 1.8s ease-in-out infinite;
|
||
animation: heroSkelShimmer 1.8s ease-in-out infinite; }
|
||
@-webkit-keyframes heroSkelShimmer { 0%{-webkit-transform:translateX(-100%);transform:translateX(-100%)} 100%{-webkit-transform:translateX(100%);transform:translateX(100%)} }
|
||
@keyframes heroSkelShimmer { 0%{transform:translateX(-100%)} 100%{transform:translateX(100%)} }
|
||
.hero-skel-body { position: absolute; bottom: 40px; left: 0; right: 0;
|
||
display: -webkit-flex; display: flex; -webkit-flex-direction: column; flex-direction: column;
|
||
-webkit-align-items: center; align-items: center; gap: 10px; padding: 0 20px; z-index: 1; }
|
||
.hero-skel-title { width: 52%; height: 26px; border-radius: 7px; background: rgba(255,255,255,.09); }
|
||
.hero-skel-meta { width: 36%; height: 12px; border-radius: 5px; background: rgba(255,255,255,.06); }
|
||
.hero-skel-dots { position: absolute; bottom: 13px; left: 0; right: 0; z-index: 1;
|
||
display: -webkit-flex; display: flex; -webkit-align-items: center; align-items: center;
|
||
-webkit-justify-content: center; justify-content: center; gap: 6px; }
|
||
.hero-skel-d { width: 6px; height: 6px; border-radius: 50%; background: rgba(255,255,255,.18); }
|
||
.hero-skel-d.a { width: 16px; background: rgba(255,255,255,.35); border-radius: 3px; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<div class="app" id="app">
|
||
<div class="ptr" id="ptr"><span class="ptr-ico" id="ptrIco">↓</span><span id="ptrText">下拉刷新</span></div>
|
||
<div class="search-mask" id="searchMask"></div>
|
||
<div class="search-panel" id="searchPanel">
|
||
<div class="search-box">
|
||
<span class="search-ico"><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7.5"/><path d="M21 21l-4.3-4.3"/></svg></span>
|
||
<input id="search" class="search-input focusable" type="text" placeholder="搜片名,找网盘资源">
|
||
<button id="searchGo" class="search-go focusable" type="button" aria-label="搜索">搜索</button>
|
||
<div class="search-suggest" id="searchSuggest"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<main>
|
||
<div id="homeView">
|
||
<div class="hero-wrap" id="heroWrap" hidden>
|
||
<div class="hero" id="heroCarousel" aria-label="推荐"></div>
|
||
<div class="hero-dots" id="heroDots"></div>
|
||
</div>
|
||
<div id="homeRows"></div>
|
||
</div>
|
||
<section class="section" id="catView" style="display:none">
|
||
<div class="cat-head">
|
||
<button id="catBack" class="icon-btn cat-back focusable" type="button" aria-label="返回">
|
||
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 18l-6-6 6-6"/></svg>
|
||
</button>
|
||
<span class="cat-title" id="catTitle">全部资源</span>
|
||
<div id="searchBar" class="cat-searchbar focusable" role="button" tabindex="0" aria-label="修改搜索词">
|
||
<span class="csb-ico"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7.5"/><path d="M21 21l-4.3-4.3"/></svg></span>
|
||
<span class="csb-kw" id="searchBarKw"></span>
|
||
<button id="clearSearch" class="clear-search focusable" type="button" aria-label="清空搜索词" style="display:none">
|
||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6L6 18"/><path d="M6 6l12 12"/></svg>
|
||
</button>
|
||
</div>
|
||
<div class="genre-chips" id="genreChips"></div>
|
||
<span class="section-sub" id="browseSub"></span>
|
||
<button id="clearRecent" class="icon-btn recent-clear-btn focusable" type="button" aria-label="清空全部记录" style="display:none">
|
||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2"/><path d="M19 6l-1 14a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>
|
||
</button>
|
||
</div>
|
||
<div class="section-head">
|
||
<span class="section-title" id="browseTitle" style="display:none"></span>
|
||
</div>
|
||
<div class="grid" id="grid"></div>
|
||
<div id="browseNotice"></div>
|
||
<div id="browseLoading" class="loading-row" style="display:none">加载中…</div>
|
||
</section>
|
||
</main>
|
||
<nav class="chips" id="chips" aria-label="导航"></nav>
|
||
</div>
|
||
|
||
<div class="sheet" id="detailSheet" aria-hidden="true">
|
||
<div class="ed-hero" id="edHero">
|
||
<div class="ed-hero-imgs" id="edHeroImgs"></div>
|
||
<div class="ed-hero-shade"></div>
|
||
<div class="ed-hero-blend"><div class="ed-blur-mid"></div></div>
|
||
</div>
|
||
<div class="ed-hero-spacer"></div>
|
||
<div class="ed-body">
|
||
<div class="ed-logo" id="edLogo"></div>
|
||
<div class="ed-title" id="detailTitle"></div>
|
||
<div class="ed-chips" id="detailTags"></div>
|
||
<button id="edPlay" class="ed-play focusable" type="button" data-focus-row="detailBtn">
|
||
<span class="tri">▶</span><span id="edPlayLabel">播放</span>
|
||
</button>
|
||
<div class="ed-actions">
|
||
<button id="nativeSearch" class="ed-ico focusable" type="button" data-focus-row="detailAction" aria-label="网盘搜索">
|
||
<svg viewBox="-1 -1 26 26" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M17.5 19H9a7 7 0 1 1 6.71-9h.79a4.5 4.5 0 1 1 0 9Z" transform="translate(11.5,12) scale(1.2) translate(-11.5,-12)"/></svg>
|
||
</button>
|
||
<button id="appSearchBtn" class="ed-ico focusable" type="button" data-focus-row="detailAction" aria-label="App 内搜索">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
|
||
</button>
|
||
<button id="edRefresh" class="ed-ico focusable" type="button" data-focus-row="detailAction" aria-label="刷新资源">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-2.64-6.36"/><path d="M21 3v6h-6"/></svg>
|
||
</button>
|
||
</div>
|
||
<div class="overview clamp" id="detailOverview"></div>
|
||
<div class="ed-episodes" id="edEpisodes"></div>
|
||
<div class="ed-cast" id="edCast"></div>
|
||
<div class="ed-works" id="edActorWorks"></div>
|
||
<div class="pan-head"><span class="title" id="panTitle">网盘资源</span><div class="ed-qtabs" id="qTabs"></div><span class="sub" id="panSub" style="display:none"></span></div>
|
||
<div class="pan-list" id="panList"><div class="pan-empty">正在读取资源…</div></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="panel-overlay" id="settingsOverlay" aria-hidden="true">
|
||
<div class="panel-sheet">
|
||
<button id="cfgCloseX" class="panel-close focusable" type="button" aria-label="关闭">✕</button>
|
||
<div class="set-hd"><h3>设置</h3></div>
|
||
|
||
<div class="set-group">
|
||
<div class="set-2col">
|
||
<div class="field">
|
||
<label>账号 / 邮箱</label>
|
||
<input id="cfgAccount" class="focusable" type="text" autocomplete="off" placeholder="邮箱或用户名">
|
||
</div>
|
||
<div class="field">
|
||
<label>密码</label>
|
||
<input id="cfgPassword" class="focusable" type="password" autocomplete="off" placeholder="登录密码">
|
||
</div>
|
||
</div>
|
||
<div class="login-row">
|
||
<button id="cfgLogin" class="btn small focusable" type="button">立即登录</button>
|
||
<span id="loginState" class="login-state">配了账号密码,Cookie 过期会自动重登</span>
|
||
</div>
|
||
<div class="field">
|
||
<label>登录 Cookie <span class="hint">可留空;填了账号密码优先自动登录</span></label>
|
||
<input id="cfgCookie" class="focusable" type="text" placeholder="留空用内置 / 自动登录">
|
||
</div>
|
||
<div class="field">
|
||
<label>站点地址</label>
|
||
<input id="cfgBase" class="focusable" type="text" placeholder="https://pinglian.lol">
|
||
</div>
|
||
<div class="set-2col">
|
||
<div class="field">
|
||
<label>TMDB Key</label>
|
||
<input id="cfgTmdb" class="focusable" type="password" autocomplete="off" placeholder="留空用站点海报">
|
||
</div>
|
||
<div class="field">
|
||
<label>TMDB 图片域名</label>
|
||
<input id="cfgTmdbImg" class="focusable" type="text" placeholder="image.tmdb.org">
|
||
</div>
|
||
</div>
|
||
<div class="field">
|
||
<label>TMDB 数据接口域名 <span class="hint">详情页简介/演职员这些数据走这个域名;默认 api.tmdb.org(国内免代理可用),官方主域 api.themoviedb.org 在国内被墙;留空用默认</span></label>
|
||
<input id="cfgTmdbApi" class="focusable" type="text" placeholder="api.tmdb.org">
|
||
</div>
|
||
<div class="field">
|
||
<label>盘搜地址 <span class="hint">详情页「网盘搜索」用</span></label>
|
||
<input id="cfgPanApi" class="focusable" type="text" placeholder="https://so.252035.xyz">
|
||
</div>
|
||
<div class="field">
|
||
<label>指南站点 <span class="hint">豆瓣高分 / 合集 / 详情页补充网盘资源</span></label>
|
||
<input id="cfgZn4k" class="focusable" type="text" placeholder="https://4kzn.cc">
|
||
</div>
|
||
<div class="field">
|
||
<label>七味站点 <span class="hint">最新片单 / 详情页补充网盘资源(磁力/夸克/迅雷/百度等)</span></label>
|
||
<input id="cfgQw" class="focusable" type="text" placeholder="https://www.qwmp4.com">
|
||
</div>
|
||
<div class="field">
|
||
<label>蜗牛站点 <span class="hint">网盘资源站(115/夸克等)</span></label>
|
||
<input id="cfgWn" class="focusable" type="text" placeholder="https://zmi.kdns.fr">
|
||
</div>
|
||
<div class="field">
|
||
<label>SeedHub 站点 <span class="hint">网盘资源站(夸克/百度/迅雷等)</span></label>
|
||
<input id="cfgSh" class="focusable" type="text" placeholder="https://sidhub.cc">
|
||
</div>
|
||
<div class="field">
|
||
<label>玩偶站点 <span class="hint">网盘资源站</span></label>
|
||
<input id="cfgWo" class="focusable" type="text" placeholder="https://wogg.xxooo.cf">
|
||
</div>
|
||
<div class="field">
|
||
<label>木偶站点 <span class="hint">网盘资源站</span></label>
|
||
<input id="cfgMo" class="focusable" type="text" placeholder="https://666.666291.xyz">
|
||
</div>
|
||
<div class="field">
|
||
<label>pomo 站点 <span class="hint">网盘资源站</span></label>
|
||
<input id="cfgPm" class="focusable" type="text" placeholder="https://pomo.mom">
|
||
</div>
|
||
</div>
|
||
|
||
<div class="set-group">
|
||
<div class="set-group-title">网盘类别</div>
|
||
<div class="field">
|
||
<label>选择要显示的网盘类别 <span class="hint">默认全部显示;取消的类别不会出现在网盘资源里(某类别最终没有资源时也会自动隐藏)</span></label>
|
||
<div class="pan-type-wrap" id="cfgPanTypes"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="set-group">
|
||
<div class="set-group-title">详情页</div>
|
||
<div class="toggle-row">
|
||
<div class="lbl">剧集单集列表<small>剧/综艺/动漫详情页里的分季单集卡片;关闭后不显示该区块,只保留网盘资源</small></div>
|
||
<button id="cfgShowEpisodes" type="button" class="switch focusable" role="switch" aria-checked="true"><span class="knob"></span></button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="set-group set-status">
|
||
<div id="statusBlock"></div>
|
||
</div>
|
||
|
||
<div class="panel-actions">
|
||
<button id="cfgSave" class="btn primary focusable" type="button">保存</button>
|
||
<button id="cfgClose" class="btn focusable" type="button">关闭</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="panel-overlay" id="confirmOverlay" aria-hidden="true">
|
||
<div class="panel-sheet confirm-sheet">
|
||
<h3 class="confirm-title" id="confirmTitle">确认</h3>
|
||
<p class="confirm-text" id="confirmText"></p>
|
||
<div class="panel-actions">
|
||
<button id="confirmOk" class="btn danger focusable" type="button">确定</button>
|
||
<button id="confirmCancel" class="btn focusable" type="button">取消</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="toast" id="toast"></div>
|
||
|
||
<script>
|
||
"use strict";
|
||
/* ============================================================
|
||
业务代码:语法基线 ES2017(async/await 及以下)。
|
||
不使用 ?. / ?? / 逻辑赋值 / .replaceAll / Promise.allSettled /
|
||
可选 catch 绑定 / 私有字段(语法错误无法 try/catch,一处即白屏)。
|
||
============================================================ */
|
||
var $ = function (id) { return document.getElementById(id); };
|
||
var root = document.documentElement;
|
||
|
||
var DEFAULT_BASE = "https://pinglian.lol";
|
||
var BROWSER_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36 Edg/149.0.0.0";
|
||
/* 内置登录态(Cookie)。session_token 为长期令牌,PHPSESSID 可能轮换,服务器会通过 Set-Cookie 刷新 */
|
||
var AUTH_COOKIE = ""; /* 不再内置真实登录态,请在“设置”页填入你自己的 Cookie(含 session_token/PHPSESSID) */
|
||
|
||
/* 盘链分类:t=1电影 2剧集 3综艺 4动漫;空=全部 */
|
||
var CAT_ICONS = {
|
||
"1": '<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M3 9h18M8 4v16M16 4v16"/>',
|
||
"2": '<rect x="3" y="5" width="18" height="12" rx="2"/><path d="M8 21h8M12 17v4"/>',
|
||
"3": '<path d="M12 3l2.3 5.3L20 9l-4 3.6L17 19l-5-3-5 3 1-6.4L4 9l5.7-.7z"/>',
|
||
"4": '<circle cx="12" cy="12" r="9"/><path d="M9 10h.01M15 10h.01M8.5 15a4 4 0 007 0"/>',
|
||
"__recent__": '<path d="M3 3v5h5"/><path d="M3.05 13A9 9 0 1 0 6 5.3L3 8"/><path d="M12 7v5l3 3"/>'
|
||
};
|
||
var CATEGORIES = [
|
||
{ slug: "1", name: "电影" },
|
||
{ slug: "2", name: "剧集" },
|
||
{ slug: "3", name: "综艺" },
|
||
{ slug: "4", name: "动漫" },
|
||
{ slug: "__recent__", name: "历史" }
|
||
];
|
||
|
||
/* 网盘 host -> type(用于 fm.pan.play / fm.pan.check) */
|
||
var HOSTMAP = [
|
||
[/pan\.quark\.cn/, "quark"],
|
||
[/drive\.uc\.cn|fast\.uc\.cn/, "uc"],
|
||
[/alipan\.com|aliyundrive\.com/, "aliyun"],
|
||
[/pan\.baidu\.com|yun\.baidu\.com/, "baidu"],
|
||
[/cloud\.189\.cn/, "tianyi"],
|
||
[/pan\.xunlei\.com/, "xunlei"],
|
||
[/115\.com|115cdn\.com|anxia\.com/, "115"],
|
||
[/123[0-9a-z]*\.com|123pan\.cn/, "123"],
|
||
[/caiyun\.139\.com|yun\.139\.com|caiyun\.feixin\.10086\.cn/, "mobile"]
|
||
];
|
||
var CHECKABLE = ["quark", "aliyun", "baidu", "uc", "tianyi", "xunlei", "123", "115", "mobile"];
|
||
/* 盘链网盘类型 -> 显示名 + fm.pan.play 的 type */
|
||
var PAN_META = {
|
||
quark: { label: "夸克", play: "quark" },
|
||
baidu: { label: "百度", play: "baidu" },
|
||
xunlei: { label: "迅雷", play: "xunlei" },
|
||
uc: { label: "UC", play: "uc" },
|
||
aliyun: { label: "阿里", play: "aliyun" },
|
||
"123": { label: "123", play: "123" },
|
||
"115": { label: "115", play: "115" },
|
||
tianyi: { label: "天翼", play: "tianyi" },
|
||
mobile: { label: "移动云", play: "mobile" },
|
||
guangya: { label: "光鸭盘", play: "http" }, // 新出的小众网盘:不在 App 检测支持表内(不检测);可走 fm.pan.play(type=http) 尝试播放,盘搜也常返回它
|
||
pikpak: { label: "PikPak", play: "pikpak" },
|
||
magnet: { label: "磁力", play: "magnet" },
|
||
fy: { label: "枫叶", play: "" }, // 在线点播(m3u8 直链),不走网盘解析,播放走 fyPlayDirect
|
||
http: { label: "网盘", play: "" }
|
||
};
|
||
var TYPE_LABEL = { quark: "夸克", uc: "UC", aliyun: "阿里", baidu: "百度", tianyi: "天翼", xunlei: "迅雷", "115": "115", "123": "123", mobile: "移动云", magnet: "磁力", http: "网页" };
|
||
/* 网盘 tab 固定顺序:显式写死,不依赖 PAN_META 对象的声明顺序。
|
||
各站资源是异步陆续到达的,谁先回来谁的类型就会先出现在 state.panItems 里——如果 tab 顺序
|
||
跟着"谁先到"走,同一部片子这次打开是"夸克/百度/UC",下次因为响应快慢不同变成"UC/夸克/百度",
|
||
看着就像顺序自己在跳。这里固定按下面这个顺序显示,不认到达先后,后续资源只在各自的 tab
|
||
里往里加,tab 本身的位置和前后顺序不会再变。 */
|
||
var PAN_GROUP_ORDER = ["夸克", "百度", "123", "UC", "115", "迅雷", "阿里", "天翼", "移动云", "PikPak", "光鸭盘"];
|
||
/* 设置页「网盘类别」可屏蔽的可选项:覆盖 PAN_META 里实际会出现的网盘类型标签。
|
||
枫叶在线(线路名是站点动态给的,不是固定网盘类型)不放进来。 */
|
||
var PAN_TYPE_OPTIONS = ["夸克", "百度", "123", "UC", "115", "迅雷", "阿里", "天翼", "移动云", "PikPak", "光鸭盘", "磁力", "网盘"];
|
||
/* 全局不展示的网盘类型:微云/蓝奏/坚果这几个基本已经没人用、也没接检测/播放,从入口就滤掉,
|
||
不管哪个站源返回这几个类型都不会出现在网盘资源里(不只是设置项里去掉) */
|
||
var PAN_TYPE_GLOBAL_EXCLUDE = ["weiyun", "lanzou", "jianguo"];
|
||
/* 某个网盘类别是否被用户在设置里屏蔽了;config.panBlockedTypes 为空数组(默认)时不屏蔽任何类别 */
|
||
function isPanTypeBlocked(q) {
|
||
return !!(config.panBlockedTypes && config.panBlockedTypes.length && config.panBlockedTypes.indexOf(q) >= 0);
|
||
}
|
||
/* 站源 -> 徽标色(用于详情页网盘条目上标注"这条资源来自哪个站",字体色跟着站源走,一眼区分)。
|
||
分组仍然只按网盘类型(PAN_META.label)来,不再按站源拆分 tab —— 站源只在条目里当一个小标签展示。 */
|
||
var SITE_META = {
|
||
"本站": { c: "#2ea6ff" },
|
||
"蜗牛": { c: "#3ddc97" },
|
||
"SeedHub": { c: "#a78bfa" },
|
||
"玩偶": { c: "#ff9f43" },
|
||
"木偶": { c: "#ff6b6b" },
|
||
"pomo": { c: "#ffd166" },
|
||
"4K指南": { c: "#f5c451" },
|
||
"盘搜": { c: "#6ba7ff" },
|
||
"枫叶": { c: "#ff7a45" },
|
||
"枫叶 · 在线": { c: "#ff7a45" },
|
||
"七味": { c: "#4fd1c5" },
|
||
"TMDB": { c: "#01b4e4" }
|
||
};
|
||
function siteMetaOf(name) { return SITE_META[name] || { c: "#8b96b5" }; }
|
||
/* 从资源标题里识别画质关键字(分辨率 / HDR·杜比视界 / 片源 / 码率 / 字幕),派两个用途:
|
||
①算一个画质分数,同一网盘类型 tab 内按分数从高到低排(4K/杜比视界/HDR/原盘/高码 靠前);
|
||
②在条目上显示对应徽标,不用点进去、光看列表就知道这条资源的画质信息。
|
||
同一条资源标题不会变,解析一次缓存在 item._qInfo 上,不用每次重算。 */
|
||
var QTAG_COLOR = {
|
||
"8K": "#ff5f6d", "4K": "#ff8a3d", "1080P": "#ffb23e", "720P": "#c9a06a", "SD": "#8b96b5",
|
||
"REMUX": "#3ddc97", "原盘": "#3ddc97", "蓝光": "#5ec9a8", "WEB-DL": "#7a8bb5",
|
||
"DV": "#a78bfa", "HDR10+": "#7c9bff", "HDR": "#6a8bff",
|
||
"高码": "#ff5fa8", "中字": "#f5c451", "双语": "#f5c451", "国语": "#f5c451"
|
||
};
|
||
function parseQuality(title) {
|
||
var t = String(title || "");
|
||
var tags = [], score = 0;
|
||
if (/8K|4320P/i.test(t)) { tags.push("8K"); score += 500; }
|
||
else if (/4K|2160P/i.test(t)) { tags.push("4K"); score += 400; }
|
||
else if (/1080P|FHD/i.test(t)) { tags.push("1080P"); score += 300; }
|
||
else if (/720P/i.test(t)) { tags.push("720P"); score += 200; }
|
||
else if (/\bSD\b/i.test(t)) { tags.push("SD"); score += 100; }
|
||
if (/REMUX/i.test(t)) { tags.push("REMUX"); score += 90; }
|
||
else if (/蓝光原盘|原盘/i.test(t)) { tags.push("原盘"); score += 90; }
|
||
else if (/BluRay|蓝光/i.test(t)) { tags.push("蓝光"); score += 40; }
|
||
else if (/WEB-?DL/i.test(t)) { tags.push("WEB-DL"); score += 15; }
|
||
if (/杜比视界|dolby\s*vision|DOVI|\bDV\b/i.test(t)) { tags.push("DV"); score += 60; }
|
||
if (/HDR10\+/i.test(t)) { tags.push("HDR10+"); score += 45; }
|
||
else if (/HDR/i.test(t)) { tags.push("HDR"); score += 30; }
|
||
if (/高码率?|HI-?BIT-?RATE/i.test(t)) { tags.push("高码"); score += 15; }
|
||
if (/中字|中文字幕|内封字幕/i.test(t)) tags.push("中字");
|
||
else if (/双语/i.test(t)) tags.push("双语");
|
||
else if (/国语/i.test(t)) tags.push("国语");
|
||
return { tags: tags, score: score };
|
||
}
|
||
function qualityInfo(it) {
|
||
if (!it._qInfo) it._qInfo = parseQuality((it.title || "") + " " + (it.groupName || ""));
|
||
return it._qInfo;
|
||
}
|
||
function qualityTagsHTML(it) {
|
||
var html = "";
|
||
qualityInfo(it).tags.forEach(function (tg) {
|
||
var c = QTAG_COLOR[tg] || "#8b96b5";
|
||
html += "<span class='q-tag' style='color:" + c + ";border-color:" + c + "55;background:" + c + "1f'>" + escapeHtml(tg) + "</span>";
|
||
});
|
||
return html;
|
||
}
|
||
/* 网盘条目「副标题」统一构造:网盘类型徽标 + 站源徽标(各站固定配色)+ 画质徽标 + 提取码 + 状态文案。
|
||
paintResources(首次渲染)和 setItemState(检测状态更新)共用,避免两处样式跑偏。 */
|
||
function buildPanSubHTML(item) {
|
||
var lab = (PAN_META[item.type] ? PAN_META[item.type].label : item.type);
|
||
var siteName = item.source || "本站";
|
||
var sm = siteMetaOf(siteName);
|
||
var html = "<span class='q'>" + escapeHtml(lab) + "</span>";
|
||
html += "<span class='site-tag' style='background:" + sm.c + ";border-color:" + sm.c + ";color:#0b0e14'>" + escapeHtml(siteName) + "</span>";
|
||
html += qualityTagsHTML(item);
|
||
if (item.password) html += "<span class='pwd'>提取码 " + escapeHtml(item.password) + "</span>";
|
||
if (item.state === "resolving" || item.state === "bad") html += "<span class='st'>" + escapeHtml(stateText(item.state)) + "</span>";
|
||
return html;
|
||
}
|
||
/* 非盘搜(站点自身网盘结果)默认排序:夸克 > 百度 > 123 > 115,其余类型顺序不限 */
|
||
var PAN_SORT_PRIORITY = { quark: 0, baidu: 1, "123": 2, "115": 3 };
|
||
function sortPanDefault(list) {
|
||
var withIndex = (list || []).map(function (it, i) { return { it: it, i: i }; });
|
||
withIndex.sort(function (a, b) {
|
||
var pa = PAN_SORT_PRIORITY.hasOwnProperty(a.it.type) ? PAN_SORT_PRIORITY[a.it.type] : 99;
|
||
var pb = PAN_SORT_PRIORITY.hasOwnProperty(b.it.type) ? PAN_SORT_PRIORITY[b.it.type] : 99;
|
||
if (pa !== pb) return pa - pb;
|
||
return a.i - b.i; // 同优先级/其余类型保持原始相对顺序,顺序不做强制要求
|
||
});
|
||
return withIndex.map(function (p) { return p.it; });
|
||
}
|
||
|
||
var DEFAULT_TMDB_KEY = "d7040155454e7fdf547c4d889ebbcca7"; /* 内置公共 TMDB key(可在设置里换成你自己的)。这个 key + api.tmdb.org 域名在国内免代理可用 */
|
||
var config = { base: DEFAULT_BASE, homeFull: true, tmdbKey: DEFAULT_TMDB_KEY, tmdbImg: "images.tmdb.org", tmdbApi: "", cookie: "", account: "", password: "", panApi: "https://so.252035.xyz", zn4kBase: "https://4kzn.cc", qwBase: "https://www.qwmp4.com", fyBase: "https://www.cd-zj.com", wnBase: "https://zmi.kdns.fr", shBase: "https://sidhub.cc", woBases: { wo: "", mo: "", pm: "" }, panBlockedTypes: [], showEpisodes: true };
|
||
var PANSOU_TYPES = ["quark", "aliyun", "baidu", "uc", "tianyi", "xunlei", "123", "115", "mobile"];
|
||
var PANSOU_KEEP = PANSOU_TYPES.concat(["guangya"]); // 客户端保留的类型:比请求多一个光鸭盘(盘搜会返回,但它不在标准 cloud_types 里)
|
||
var state = {
|
||
sdkReady: false, isTv: false, isMobile: false, driveCheck: true,
|
||
activeCat: "1", activeGenre: "", searchMode: false, searchKw: "", searchSourceFilter: "",
|
||
nav: "home", view: "home",
|
||
heroItems: [], heroIndex: 0, heroTimer: null, heroToken: 0, heroLoading: false, heroRawCache: null,
|
||
dHeroImages: [], dHeroIdx: 0, dHeroTimer: null, dHeroMode: "",
|
||
page: 1, source: [], rendered: 0, loading: false, noMore: false, autoPages: 0,
|
||
recentItems: [],
|
||
reqToken: 0,
|
||
currentItem: null, currentDetail: null, panItems: [], panToken: 0, panObserver: null,
|
||
expandedActorId: 0, suggestTimer: 0, suggestToken: 0,
|
||
loginPromise: null, useCookieManager: false, loggedInOnce: false, reloginTried: false,
|
||
bestResource: null, qFilter: null, qFilterManual: false, qGroups: [], favs: {},
|
||
focusScope: "home", homeReturn: null, viewReturn: null, theme: "default",
|
||
homeCatCache: {}, homeCatMore: {}, homeHeroLoaded: false,
|
||
searchPanelOpen: false, scrollLockY: 0, stackDepth: 0, expectedDepth: null,
|
||
zn4kColCache: {}, zn4kCol: null,
|
||
qwCatCache: {}, qwCatSel: "1", qwPage: 1, qwGoodBase: "",
|
||
fyCache: {}, fyCat: "1", fyPage: 1, fyLastUrl: "", fyLastPlayApi: "",
|
||
wnCache: {}, wnCat: "1", wnPage: 1,
|
||
shCache: {}, shCat: "1", shPage: 1,
|
||
woCache: {}, woSite: "wo", woCat: "", woPage: 1, woGood: {}, woBad: {},
|
||
cfgPanBlockedTmp: []
|
||
};
|
||
|
||
/* ---------- 存储 ---------- */
|
||
function storeGet(key) {
|
||
if (state.sdkReady && window.fm && fm.cache) return fm.cache.get(key, "pinglian").then(function (v) { return v || ""; }, function () { return ""; });
|
||
try { return Promise.resolve(localStorage.getItem("pl_" + key) || ""); } catch (e) { return Promise.resolve(""); }
|
||
}
|
||
function storeSet(key, val) {
|
||
if (state.sdkReady && window.fm && fm.cache) return fm.cache.set(key, val, "pinglian").then(function () {}, function () {});
|
||
try { localStorage.setItem("pl_" + key, val); } catch (e) {}
|
||
return Promise.resolve();
|
||
}
|
||
|
||
/* ---------- 首页提速:磁盘缓存 + 后台预取队列 + 请求去重 ----------
|
||
1) 磁盘缓存(stale-while-revalidate):首页每一行的第 1 页结果落盘,下次进 App 直接从盘里秒出,
|
||
不用干等网络;落盘超过 HOME_DISK_STALE 就在后台悄悄重抓一遍,回来了再无感覆盖那一行。
|
||
2) 预取队列:网盘站那几行(豆瓣高分/蜗牛/SeedHub/玩偶/木偶)原先是"滑到才请求",第一次滑下去必然干等。
|
||
现在首屏一渲染完就用并发上限 2 的后台队列提前抓,滑到时基本已经就位;真滑到了还没轮到就立刻插队跑,
|
||
不会比原来慢。并发上限保证它们不跟首屏的分类请求抢带宽。
|
||
3) 请求去重 + 短期缓存:各站的 HTML 抓取(列表页/搜索页/详情页)按 URL 做在途去重 + 3 分钟缓存,
|
||
首页行、搜索合并、详情页补资源经常撞同一个 URL,撞上就不再打网络。 */
|
||
var HOME_DISK_KEY = "homecache_v1";
|
||
var HOME_DISK_TTL = 24 * 60 * 60 * 1000; // 超过 1 天的旧缓存不再拿来占位
|
||
var HOME_DISK_STALE = 10 * 60 * 1000; // 超过 10 分钟就在后台重新抓一遍
|
||
var _homeDiskTs = 0, _homeDiskTimer = 0;
|
||
function homeCacheStale() { return !_homeDiskTs || (Date.now() - _homeDiskTs) > HOME_DISK_STALE; }
|
||
function loadHomeDisk() {
|
||
return storeGet(HOME_DISK_KEY).then(function (raw) {
|
||
if (!raw) return;
|
||
var d = null; try { d = JSON.parse(raw); } catch (e) { return; }
|
||
if (!d || !d.ts || (Date.now() - d.ts) > HOME_DISK_TTL) return;
|
||
_homeDiskTs = d.ts;
|
||
if (d.cat) state.homeCatCache = d.cat;
|
||
if (d.wn) state.wnCache = d.wn;
|
||
if (d.sh) state.shCache = d.sh;
|
||
if (d.wo) state.woCache = d.wo;
|
||
if (d.zn) state.zn4kColCache = d.zn;
|
||
if (d.qw) state.qwCatCache = d.qw;
|
||
}, function () {});
|
||
}
|
||
function saveHomeDisk() {
|
||
if (_homeDiskTimer) return; // 同一批更新合并成一次写盘
|
||
_homeDiskTimer = setTimeout(function () {
|
||
_homeDiskTimer = 0;
|
||
try {
|
||
var pack = { ts: Date.now(), cat: {}, wn: {}, sh: {}, wo: {}, zn: {}, qw: {} };
|
||
function put(dst, src, n) {
|
||
Object.keys(src || {}).forEach(function (k) {
|
||
var l = src[k];
|
||
if (l && l.length) dst[k] = l.slice(0, n);
|
||
});
|
||
}
|
||
put(pack.cat, state.homeCatCache, 24);
|
||
put(pack.wn, state.wnCache, 12);
|
||
put(pack.sh, state.shCache, 12);
|
||
put(pack.wo, state.woCache, 12);
|
||
put(pack.zn, state.zn4kColCache, 12);
|
||
put(pack.qw, state.qwCatCache, 12);
|
||
_homeDiskTs = pack.ts;
|
||
storeSet(HOME_DISK_KEY, JSON.stringify(pack));
|
||
} catch (e) {}
|
||
}, 800);
|
||
}
|
||
function clearHomeDisk() { _homeDiskTs = 0; if (_homeDiskTimer) { clearTimeout(_homeDiskTimer); _homeDiskTimer = 0; } storeSet(HOME_DISK_KEY, ""); }
|
||
/* 后台预取队列(并发上限 2) */
|
||
var _pfQueue = [], _pfRunning = 0, PF_MAX = 2, _pfOpen = false;
|
||
function homePrefetch(fn) { _pfQueue.push(fn); if (_pfOpen) pumpPrefetch(); }
|
||
function resetPrefetch() { _pfQueue = []; _pfOpen = false; }
|
||
function startHomePrefetch() { if (_pfOpen) return; _pfOpen = true; pumpPrefetch(); }
|
||
function _pfDone() { _pfRunning--; setTimeout(pumpPrefetch, 0); }
|
||
function pumpPrefetch() {
|
||
if (!_pfOpen) return;
|
||
while (_pfRunning < PF_MAX && _pfQueue.length) {
|
||
var fn = _pfQueue.shift(), p = null;
|
||
_pfRunning++;
|
||
try { p = fn(); } catch (e) { p = null; }
|
||
Promise.resolve(p).then(_pfDone, _pfDone);
|
||
}
|
||
}
|
||
/* 首页某一行的加载:滑到就立刻加载,没滑到也会在后台队列里排队预取,两条路只会真正跑一次 */
|
||
function rowLoad(section, fn) {
|
||
var ran = false;
|
||
function once() { if (ran) return null; ran = true; return fn(); }
|
||
lazyWhenVisible(section, function () { once(); });
|
||
homePrefetch(once);
|
||
}
|
||
/* 各站 HTML 抓取:在途去重 + 3 分钟短期缓存(同一 URL 不重复打网络) */
|
||
var _txtMemo = {}, _txtInflight = {}, TXT_TTL = 3 * 60 * 1000, TXT_MAX = 40;
|
||
function _txtGc() {
|
||
var ks = Object.keys(_txtMemo);
|
||
if (ks.length <= TXT_MAX) return;
|
||
ks.sort(function (a, b) { return _txtMemo[a].ts - _txtMemo[b].ts; });
|
||
for (var i = 0; i < ks.length - TXT_MAX; i++) delete _txtMemo[ks[i]];
|
||
}
|
||
function httpTextMemo(key, fn) {
|
||
var hit = _txtMemo[key];
|
||
if (hit && (Date.now() - hit.ts) < TXT_TTL) return Promise.resolve(hit.v);
|
||
if (_txtInflight[key]) return _txtInflight[key];
|
||
var p = fn().then(function (v) {
|
||
delete _txtInflight[key];
|
||
var body = (v && v.html) ? v.html : v;
|
||
if (body && String(body).length < 600000) { _txtMemo[key] = { v: v, ts: Date.now() }; _txtGc(); }
|
||
return v;
|
||
}, function (e) { delete _txtInflight[key]; throw e; });
|
||
_txtInflight[key] = p;
|
||
return p;
|
||
}
|
||
function clearTextMemo() { _txtMemo = {}; }
|
||
|
||
/* ---------- 主题(内置:默认极夜蓝 / 日落珊瑚 / 极光靛蓝,长按 Logo 切换) ---------- */
|
||
var THEME_LIST = ["default", "ember", "aurora", "appletv"];
|
||
var THEME_NAMES = { "default": "极夜蓝", "ember": "日落珊瑚", "aurora": "极光靛蓝", "appletv": "苹果白" };
|
||
function applyTheme(t) {
|
||
if (THEME_LIST.indexOf(t) === -1) t = "default";
|
||
if (t === "default") root.removeAttribute("data-theme"); else root.setAttribute("data-theme", t);
|
||
state.theme = t;
|
||
}
|
||
function cycleTheme() {
|
||
var i = THEME_LIST.indexOf(state.theme);
|
||
var next = THEME_LIST[(i + 1) % THEME_LIST.length];
|
||
applyTheme(next);
|
||
try { localStorage.setItem("pl_theme", next); } catch (e) {}
|
||
storeSet("theme", next);
|
||
toast("已切换主题:" + THEME_NAMES[next], "top");
|
||
}
|
||
function loadTheme() {
|
||
var local = "";
|
||
try { local = localStorage.getItem("pl_theme") || ""; } catch (e) {}
|
||
return storeGet("theme").then(function (t) { applyTheme(t || local || "default"); }, function () { applyTheme(local || "default"); });
|
||
}
|
||
|
||
/* ---------- 网络:带内置 Cookie;API 需伪装成 AJAX(否则返回“非法请求”) ---------- */
|
||
// 原生 fm.req 分支自带 timeout:20(秒),但浏览器/无原生桥的 fetch() 兜底分支此前完全没有超时——
|
||
// 弱网或服务器无响应时 fetch 会一直挂着不 resolve/reject,页面上的“读取中…”会永远转下去。
|
||
// 这里统一给 fetch 包一层超时,和原生分支的 20s 对齐,超时就 abort 并 reject,让上层的 catch 正常显示错误提示。
|
||
var FETCH_TIMEOUT_MS = 20000;
|
||
function fetchTimeout(url, opts) {
|
||
opts = opts || {};
|
||
var ac = (typeof AbortController !== "undefined") ? new AbortController() : null;
|
||
if (ac) opts.signal = ac.signal;
|
||
var timer = ac ? setTimeout(function () { try { ac.abort(); } catch (e) {} }, FETCH_TIMEOUT_MS) : null;
|
||
return fetch(url, opts).then(function (r) { if (timer) clearTimeout(timer); return r; }, function (err) {
|
||
if (timer) clearTimeout(timer);
|
||
if (err && err.name === "AbortError") throw new Error("请求超时");
|
||
throw err;
|
||
});
|
||
}
|
||
function authCookie() { if (state.useCookieManager) return ""; return config.cookie || AUTH_COOKIE; }
|
||
function pageHeaders() { var h = { "User-Agent": BROWSER_UA }; var ck = authCookie(); if (ck) h["Cookie"] = ck; return h; }
|
||
function apiHeaders(refId) {
|
||
var h = {
|
||
"User-Agent": BROWSER_UA,
|
||
"X-Requested-With": "XMLHttpRequest", "Accept": "application/json, text/plain, */*",
|
||
"Referer": (refId == null || refId === "") ? (DEFAULT_BASE + "/index.php") : (DEFAULT_BASE + "/pages/video.php?id=" + refId)
|
||
};
|
||
var ck = authCookie(); if (ck) h["Cookie"] = ck;
|
||
return h;
|
||
}
|
||
function httpText(url) {
|
||
if (state.sdkReady && window.fm && fm.req) {
|
||
return fm.req(url, { method: "GET", responseType: "text", timeout: 20, headers: pageHeaders(), credentials: "include" })
|
||
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.body; });
|
||
}
|
||
return fetchTimeout(url, { credentials: "include" }).then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.text(); });
|
||
}
|
||
function apiGet(url, refId) {
|
||
if (state.sdkReady && window.fm && fm.req) {
|
||
return fm.req(url, { method: "GET", responseType: "json", timeout: 20, headers: apiHeaders(refId), credentials: "include" })
|
||
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.body; });
|
||
}
|
||
return fetchTimeout(url, { credentials: "include", headers: { "X-Requested-With": "XMLHttpRequest" } }).then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); });
|
||
}
|
||
function apiPost(url, bodyObj, refId) {
|
||
var body = JSON.stringify(bodyObj);
|
||
if (state.sdkReady && window.fm && fm.req) {
|
||
var h = apiHeaders(refId); h["Content-Type"] = "application/json";
|
||
return fm.req(url, { method: "POST", responseType: "json", timeout: 20, headers: h, body: body, credentials: "include" })
|
||
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.body; });
|
||
}
|
||
return fetchTimeout(url, { method: "POST", credentials: "include", headers: { "X-Requested-With": "XMLHttpRequest", "Content-Type": "application/json" }, body: body }).then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); });
|
||
}
|
||
/* 图片:盘链源站图片用 no-referrer 防盗链,绝不能带 Referer */
|
||
function imgSrc(url) {
|
||
if (!url) return "";
|
||
if (state.sdkReady && window.fm && fm.res) return fm.res(url, { headers: { "Referer": "" } });
|
||
return url;
|
||
}
|
||
|
||
/* ---------- 账号密码登录 / 自动续登 ---------- */
|
||
/* 表单 POST(application/x-www-form-urlencoded;PHP $_POST 通用),返回完整响应以便读 Set-Cookie */
|
||
function postForm(url, body) {
|
||
var headers = {
|
||
"User-Agent": BROWSER_UA,
|
||
"X-Requested-With": "XMLHttpRequest", "Accept": "application/json, text/plain, */*",
|
||
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
|
||
"Referer": DEFAULT_BASE + "/pages/login.php", "Origin": DEFAULT_BASE
|
||
};
|
||
var ck = authCookie(); if (ck) headers["Cookie"] = ck;
|
||
if (state.sdkReady && window.fm && fm.req) {
|
||
return fm.req(url, { method: "POST", responseType: "json", timeout: 20, headers: headers, body: body, credentials: "include" })
|
||
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r; });
|
||
}
|
||
return fetchTimeout(url, { method: "POST", credentials: "include", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: body })
|
||
.then(function (r) { return r.json().then(function (b) { return { ok: r.ok, status: r.status, body: b, headers: {} }; }); });
|
||
}
|
||
/* 从 Set-Cookie 合并出新的 Cookie 串(保留 session_token/PHPSESSID 等) */
|
||
function mergeCookie(oldCk, setCk) {
|
||
var jar = {};
|
||
(oldCk || "").split(";").forEach(function (kv) { var i = kv.indexOf("="); if (i > 0) jar[kv.slice(0, i).trim()] = kv.slice(i + 1).trim(); });
|
||
var parts = Array.isArray(setCk) ? setCk : String(setCk).split(/,(?=[^;]+=)/);
|
||
parts.forEach(function (c) { var first = c.split(";")[0]; var i = first.indexOf("="); if (i > 0) jar[first.slice(0, i).trim()] = first.slice(i + 1).trim(); });
|
||
return Object.keys(jar).map(function (k) { return k + "=" + jar[k]; }).join("; ");
|
||
}
|
||
function readSetCookie(res) {
|
||
var h = res && res.headers; if (!h) return "";
|
||
return h["set-cookie"] || h["Set-Cookie"] || h["setCookie"] || "";
|
||
}
|
||
/* 登录:POST 账号密码 -> 服务器下发新 Cookie */
|
||
function login() {
|
||
if (state.loginPromise) return state.loginPromise;
|
||
var u = (config.account || "").trim(), pw = config.password || "";
|
||
if (!u || !pw) return Promise.reject(new Error("未配置账号密码"));
|
||
var body = "username=" + encodeURIComponent(u) + "&password=" + encodeURIComponent(pw) + "&remember=on";
|
||
state.loginPromise = postForm(apiUrl("login.php"), body).then(function (res) {
|
||
state.loginPromise = null;
|
||
var d = res.body;
|
||
if (!d || !d.success) throw new Error(d && d.message ? d.message : "登录失败");
|
||
var sc = readSetCookie(res);
|
||
if (sc) { config.cookie = mergeCookie(authCookie(), sc); state.useCookieManager = false; persistConfig(); }
|
||
else { state.useCookieManager = true; } // 读不到 Set-Cookie:改用 WebView CookieManager(fm.req 已持久化)
|
||
state.loggedInOnce = true;
|
||
return true;
|
||
}, function (e) { state.loginPromise = null; throw e; });
|
||
return state.loginPromise;
|
||
}
|
||
/* 有账号密码就先登录(失败不阻塞,退回已有 Cookie) */
|
||
function ensureLogin() {
|
||
if (!(config.account && config.password)) return Promise.resolve(false);
|
||
return login().then(function () { return true; }, function () { return false; });
|
||
}
|
||
function persistConfig() {
|
||
return storeSet("config", JSON.stringify({ base: config.base, homeFull: config.homeFull, tmdbKey: config.tmdbKey, tmdbImg: config.tmdbImg, tmdbApi: config.tmdbApi, cookie: config.cookie, account: config.account, password: config.password, panApi: config.panApi, zn4kBase: config.zn4kBase, qwBase: config.qwBase, fyBase: config.fyBase, wnBase: config.wnBase, shBase: config.shBase, woBases: config.woBases, panBlockedTypes: config.panBlockedTypes, showEpisodes: config.showEpisodes }));
|
||
}
|
||
/* TMDB 用(外站,不带盘链 Cookie) */
|
||
function httpJson(url) {
|
||
if (state.sdkReady && window.fm && fm.req) {
|
||
return fm.req(url, { method: "GET", responseType: "json", timeout: 20 })
|
||
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.body; });
|
||
}
|
||
return fetchTimeout(url).then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); });
|
||
}
|
||
|
||
/* ---------- URL 构造 ---------- */
|
||
function listUrl(cat, page) {
|
||
var base = trimSlash(config.base);
|
||
if (!cat) return base + "/index.php"; // 全部:首页服务端直出卡片
|
||
return base + "/all-videos.php?t=" + cat + "&page=" + (page || 1);
|
||
}
|
||
function searchUrl(kw, page) {
|
||
var base = trimSlash(config.base);
|
||
return base + "/all-videos.php?search=" + encodeURIComponent(kw) + "&page=" + (page || 1);
|
||
}
|
||
function detailUrl(id) { return trimSlash(config.base) + "/pages/video.php?id=" + id; }
|
||
function apiUrl(path) { return trimSlash(config.base) + "/api/" + path; }
|
||
|
||
/* ---------- HTML 解析(DOMParser,不污染当前页,不加载资源) ---------- */
|
||
function parseDoc(html) {
|
||
try { return new DOMParser().parseFromString(html, "text/html"); }
|
||
catch (e) { return null; }
|
||
}
|
||
function parseList(html) {
|
||
var doc = parseDoc(html);
|
||
if (!doc) return [];
|
||
var out = [], seen = {};
|
||
Array.from(doc.querySelectorAll("a.video-card, a[href*='video.php?id=']")).forEach(function (a) {
|
||
var href = a.getAttribute("href") || "";
|
||
var m = href.match(/video\.php\?id=(-?\d+)/);
|
||
if (!m) return;
|
||
var id = m[1];
|
||
if (id.charAt(0) === "-") return; // 负数 id = 未解锁占位,跳过
|
||
if (seen[id]) return; seen[id] = 1;
|
||
var img = a.querySelector("img");
|
||
var poster = img ? (img.getAttribute("data-src") || img.getAttribute("src") || "") : "";
|
||
var tEl = a.querySelector(".video-title, .title, h3");
|
||
var title = ((tEl ? tEl.textContent : "") || (img ? img.getAttribute("alt") : "") || a.getAttribute("title") || "").trim();
|
||
if (!title) return;
|
||
var metaEl = a.querySelector(".video-meta, .meta");
|
||
var statusEl = a.querySelector(".video-status");
|
||
out.push({ id: id, title: title, poster: poster, meta: metaEl ? metaEl.textContent.trim() : "", status: statusEl ? statusEl.textContent.trim() : "" });
|
||
});
|
||
return out;
|
||
}
|
||
function parseDetail(html) {
|
||
var doc = parseDoc(html);
|
||
if (!doc) return { title: "", poster: "", overview: "", resources: [] };
|
||
var h1 = doc.querySelector("h1.premium-title, h1.video-title, .video-detail-title, h1");
|
||
var title = h1 ? h1.textContent.trim() : "";
|
||
var cover = doc.querySelector(".video-poster img, .detail-poster img, .premium-poster img, .video-cover img");
|
||
var poster = cover ? (cover.getAttribute("data-src") || cover.getAttribute("src") || "") : "";
|
||
var descEl = doc.querySelector("meta[name='description']");
|
||
var overview = descEl ? (descEl.getAttribute("content") || "") : "";
|
||
var sEl = doc.querySelector(".video-description, .premium-description, .video-summary, .description");
|
||
if (sEl && sEl.textContent.trim()) overview = sEl.textContent.trim();
|
||
// 资源不在 HTML 里,走 search_pan_links API
|
||
return { title: title, poster: poster, overview: overview, resources: [] };
|
||
}
|
||
|
||
/* ============================================================
|
||
4K指南(4kzn.cc)集成:把该站的搜索接口 + 站内网盘资源接进来。
|
||
- 详情页:按标题去 4K指南 搜同名,解析其站内网盘链接,合并进「网盘资源」,
|
||
不在搜索结果里单独列一个站源("搜索结果不用分站源,在详情页体现")。
|
||
- 首页:新增「豆瓣高分」榜单行 +「合集」行,数据来自指南站。
|
||
============================================================ */
|
||
var ZN4K_DEFAULT_BASE = "https://4kzn.cc"; // 默认域名;可在设置里改(config.zn4kBase),换域名不用改代码
|
||
/* 当前生效的指南站地址(跟着设置走) */
|
||
function zn4kBase() { return trimSlash(config.zn4kBase || ZN4K_DEFAULT_BASE); }
|
||
/* 这张图是不是指南站的(图片要带指南 Referer 才不被防盗链拦):按当前配置的域名判断,换域名自动跟着变 */
|
||
function zn4kHost() { return zn4kBase().replace(/^https?:\/\//, "").split("/")[0]; }
|
||
function isZn4kUrl(u) { var h = zn4kHost(); return !!(u && h && String(u).indexOf(h) >= 0); }
|
||
var ZN4K_CAT_SLUG = "__zn4kcol__"; // 合集/榜单在分类视图里的内部 slug
|
||
var ZN4K_UA = "Mozilla/5.0 (Linux; Android 10) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Mobile Safari/537.36";
|
||
/* 4K指南 的豆瓣高分:作为独立分类行(放在 TMDB 热播 下面),是常规分类页,可翻页 */
|
||
var ZN4K_TOP250 = { slug: "top250", name: "豆瓣高分", paged: true };
|
||
/* 首页「合集」固定入口(对应 4K指南 站点 /books/{slug} 列表页),一页到底的固定名单 */
|
||
var ZN4K_COLLECTIONS = [
|
||
{ slug: "manwei", name: "漫威合集" },
|
||
{ slug: "hanniba", name: "汉尼拔合集" },
|
||
{ slug: "mztkn", name: "柯南剧场版合集" },
|
||
{ slug: "xszrhj", name: "行尸走肉合集" }
|
||
];
|
||
/* 指南列表页 URL:第 1 页 /books/{slug},后续 /books/{slug}/page/N/ */
|
||
function zn4kListUrl(slug, page) {
|
||
var base = zn4kBase();
|
||
return (page > 1) ? (base + "/books/" + slug + "/page/" + page + "/") : (base + "/books/" + slug);
|
||
}
|
||
|
||
/* 指南站请求:不带盘链 Cookie,只用浏览器 UA + 指南自身 Referer */
|
||
function zn4kHttpText(url) { return httpTextMemo("zn|" + url, function () { return zn4kHttpTextRaw(url); }); }
|
||
function zn4kHttpTextRaw(url) {
|
||
if (state.sdkReady && window.fm && fm.req) {
|
||
return fm.req(url, { method: "GET", responseType: "text", timeout: 20, headers: { "User-Agent": ZN4K_UA, "Referer": zn4kBase() + "/" } })
|
||
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.body; });
|
||
}
|
||
return fetchTimeout(url, { headers: {} }).then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.text(); });
|
||
}
|
||
/* 指南站图片要带指南 Referer(防盗链) */
|
||
function zn4kImgSrc(url) {
|
||
if (!url) return "";
|
||
if (state.sdkReady && window.fm && fm.res) return fm.res(url, { headers: { "Referer": zn4kBase() + "/" } });
|
||
return url;
|
||
}
|
||
/* 解析前切掉大段内联 script/style/注释,减轻 DOMParser 负担 */
|
||
function zn4kStripHeavy(html) {
|
||
try { return String(html).replace(/<script[\s\S]*?<\/script>/gi, "").replace(/<style[\s\S]*?<\/style>/gi, "").replace(/<!--[\s\S]*?-->/g, ""); }
|
||
catch (e) { return html; }
|
||
}
|
||
function zn4kParseDoc(html) {
|
||
try { return new DOMParser().parseFromString(zn4kStripHeavy(html), "text/html"); } catch (e) { return null; }
|
||
}
|
||
function zn4kDiskType(u) {
|
||
for (var i = 0; i < HOSTMAP.length; i++) if (HOSTMAP[i][0].test(u)) return HOSTMAP[i][1];
|
||
if (u.indexOf("magnet:") === 0) return "magnet";
|
||
if (u.indexOf("ed2k:") === 0) return "ed2k";
|
||
if (u.indexOf("thunder:") === 0) return "thunder";
|
||
return "http";
|
||
}
|
||
/* 指南列表页/合集页 -> [{id,title,poster,meta}] */
|
||
function zn4kParseList(html) {
|
||
var doc = zn4kParseDoc(html);
|
||
if (!doc) return [];
|
||
var out = [], seen = {};
|
||
function add(id, title, poster, meta) { if (!id || seen[id] || !title) return; seen[id] = 1; out.push({ id: id, title: title, poster: poster, meta: meta || "" }); }
|
||
Array.from(doc.querySelectorAll("article.book-item, article.posts-item")).forEach(function (a) {
|
||
var link = a.querySelector("a.item-image, .item-title a, a[href*='/book/']");
|
||
if (!link) return;
|
||
var m = (link.getAttribute("href") || "").match(/\/book\/(\d+)\.html/);
|
||
if (!m) return;
|
||
var img = a.querySelector("img");
|
||
var poster = img ? (img.getAttribute("data-src") || img.getAttribute("data-original") || img.getAttribute("src") || "") : "";
|
||
if (poster && poster.indexOf(".svg") >= 0 && img.getAttribute("data-src")) poster = img.getAttribute("data-src");
|
||
var tEl = a.querySelector(".item-title a, .item-title, h3 a");
|
||
var title = ((tEl ? tEl.textContent : "") || (img ? img.getAttribute("alt") : "") || "").trim();
|
||
var metaEl = a.querySelector(".text-muted");
|
||
add(m[1], title, poster, metaEl ? metaEl.textContent.trim() : "");
|
||
});
|
||
if (!out.length) {
|
||
Array.from(doc.querySelectorAll("a[href*='/book/']")).forEach(function (link) {
|
||
var m = (link.getAttribute("href") || "").match(/\/book\/(\d+)\.html/);
|
||
if (!m) return;
|
||
var scope = link.closest("article, li, .module-item, .public-list-box, .item, .col") || link.parentElement || link;
|
||
var img = link.querySelector("img") || (scope ? scope.querySelector("img") : null);
|
||
if (!img) return;
|
||
var poster = img.getAttribute("data-src") || img.getAttribute("data-original") || img.getAttribute("src") || "";
|
||
if (poster && poster.indexOf(".svg") >= 0 && img.getAttribute("data-src")) poster = img.getAttribute("data-src");
|
||
var tEl = scope ? scope.querySelector(".item-title, .title, h3, h2") : null;
|
||
var title = ((tEl ? tEl.textContent : "") || img.getAttribute("alt") || link.getAttribute("title") || link.textContent || "").trim();
|
||
add(m[1], title, poster, "");
|
||
});
|
||
}
|
||
return out;
|
||
}
|
||
/* 指南详情页 -> { title, poster, overview, resources:[{url,label,quality,type}] } */
|
||
function zn4kParseDetail(html) {
|
||
var doc = zn4kParseDoc(html);
|
||
if (!doc) return { title: "", poster: "", overview: "", resources: [] };
|
||
var h1 = doc.querySelector("h1.site-name, .book-info h1, h1");
|
||
var title = h1 ? h1.textContent.trim() : "";
|
||
var cover = doc.querySelector(".book-cover img, .item-media img");
|
||
var poster = cover ? (cover.getAttribute("data-src") || cover.getAttribute("src") || "") : "";
|
||
var descEl = doc.querySelector("meta[name='description']");
|
||
var overview = descEl ? (descEl.getAttribute("content") || "") : "";
|
||
var res = [], seen = {};
|
||
function pushLink(a, quality) {
|
||
var url = (a.getAttribute("href") || "").trim();
|
||
if (!url || !/^https?:|^magnet:|^ed2k:|^thunder:/.test(url)) return;
|
||
if (seen[url]) return; seen[url] = 1;
|
||
var bn = a.querySelector(".b-name");
|
||
var label = ((bn ? bn.textContent : "") || a.getAttribute("title") || "").trim();
|
||
res.push({ url: url, label: label, quality: quality || "", type: zn4kDiskType(url) });
|
||
}
|
||
Array.from(doc.querySelectorAll(".mt-n2")).forEach(function (b) {
|
||
var q = b.querySelector("p");
|
||
var quality = q ? q.textContent.trim() : "";
|
||
Array.from(b.querySelectorAll(".site-go a[href]")).forEach(function (a) { pushLink(a, quality); });
|
||
});
|
||
if (!res.length) {
|
||
Array.from(doc.querySelectorAll(".site-go a[href], .book-down a[href], a.btn[href*='pan.'], a.btn[href*='drive.']")).forEach(function (a) { pushLink(a, ""); });
|
||
}
|
||
return { title: title, poster: poster, overview: overview, resources: res };
|
||
}
|
||
|
||
/* 指南解析出的资源 -> 盘链网盘条目(只保留 App 能处理的网盘类型 + 磁力) */
|
||
function zn4kResList(resources) {
|
||
var out = [], seen = {};
|
||
(resources || []).forEach(function (r) {
|
||
var type = r.type;
|
||
if (PANSOU_KEEP.indexOf(type) < 0 && type !== "magnet") return;
|
||
var url = r.url || "";
|
||
if (!url) return;
|
||
var k = type + "|" + normPanUrl(url);
|
||
if (seen[k]) return; seen[k] = 1;
|
||
var lab = PAN_META[type] ? PAN_META[type].label : (TYPE_LABEL[type] || type);
|
||
out.push({
|
||
type: type, url: url, password: "",
|
||
title: r.label || "",
|
||
groupName: lab, quality: lab,
|
||
source: "4K指南", state: "idle", _zn4k: true
|
||
});
|
||
});
|
||
return out;
|
||
}
|
||
/* 详情页里已经并进来的指南资源(刷新/重载时保留,跟 panSouKeep 同理) */
|
||
function zn4kKeep() { return (state.panItems || []).filter(function (it) { return it._zn4k; }); }
|
||
/* 把一批指南资源合并进当前详情页网盘列表(去重 + 重建 tab) */
|
||
function zn4kMerge(pans, token) {
|
||
if (token !== state.panToken || !pans || !pans.length) return;
|
||
var have = {};
|
||
state.panItems.forEach(function (it) { have[it.type + "|" + normPanUrl(it.url || it.token)] = 1; });
|
||
var add = [];
|
||
pans.forEach(function (p) { var k = p.type + "|" + normPanUrl(p.url); if (!have[k]) { have[k] = 1; add.push(p); } });
|
||
if (!add.length) return;
|
||
renderResources(state.panItems.concat(add));
|
||
}
|
||
/* 指南站搜索:/?post_type=book&s=关键词 -> 列表 */
|
||
function zn4kSearch(kw) {
|
||
var u = zn4kBase() + "/?post_type=book&s=" + encodeURIComponent(kw);
|
||
return zn4kHttpText(u).then(function (html) { return zn4kParseList(html); }, function () { return []; });
|
||
}
|
||
/* 从指南搜索结果里挑与本片最匹配的一条(标题相关度优先,年份加权) */
|
||
function zn4kPickBest(list, rawTitle, year) {
|
||
var key = cleanTitle(rawTitle);
|
||
var best = null, bestScore = 99;
|
||
(list || []).forEach(function (it) {
|
||
var ts = titleScore(it.title, key);
|
||
if (ts > 3) return; // 完全不含关键词的丢掉
|
||
var s = ts;
|
||
if (year && ((it.title || "").indexOf(year) >= 0 || (it.meta || "").indexOf(year) >= 0)) s -= 0.3;
|
||
if (s < bestScore) { bestScore = s; best = it; }
|
||
});
|
||
return best;
|
||
}
|
||
/* 详情页(盘链原生条目):按标题去指南搜同名,把站内网盘资源并进来 */
|
||
function zn4kEnrich(rawTitle, token) {
|
||
var kw = cleanTitle(rawTitle);
|
||
if (!kw) return;
|
||
var year = yearOf(rawTitle);
|
||
zn4kSearch(kw).then(function (list) {
|
||
if (token !== state.panToken || !list.length) return null;
|
||
var best = zn4kPickBest(list, rawTitle, year);
|
||
if (!best) return null;
|
||
return zn4kHttpText(zn4kBase() + "/book/" + best.id + ".html").then(function (html) {
|
||
if (token !== state.panToken) return;
|
||
zn4kMerge(zn4kResList(zn4kParseDetail(html).resources), token);
|
||
});
|
||
}).then(null, function () {});
|
||
}
|
||
/* 详情页(指南来源条目):已知指南书籍 id,直接取其站内网盘资源 */
|
||
function zn4kLoadById(id, token) {
|
||
if (!id) return;
|
||
zn4kHttpText(zn4kBase() + "/book/" + id + ".html").then(function (html) {
|
||
if (token !== state.panToken) return;
|
||
var d = zn4kParseDetail(html);
|
||
if (state.currentDetail && !state.currentDetail.tmdb) {
|
||
if (d.title) { state.currentDetail.rawTitle = d.title; showTitle(d.title); }
|
||
var ov = $("detailOverview"); if (ov && !ov.textContent && !(state.currentDetail && state.currentDetail.tmdb) && d.overview && !isJunkOverview(d.overview)) { ov.textContent = d.overview; state.currentDetail.overviewFromSite = true; }
|
||
if (d.poster && !state.currentDetail.poster && !(state.currentDetail && state.currentDetail.tmdb)) { state.currentDetail.poster = d.poster; setDetailHeroImages([d.poster], false); }
|
||
}
|
||
var pans = zn4kResList(d.resources);
|
||
if (pans.length) renderResources(sortPanDefault(pans).concat(panSouKeep()));
|
||
}, function () {});
|
||
}
|
||
/* 指南来源条目:顺便按标题反查盘链本站,命中就升级成原生条目(走完整资源+反向补指南) */
|
||
function zn4kReverseLookup(item, token) {
|
||
var title = item.title, yr = item.year || yearOf(item.title);
|
||
apiGet(apiUrl("get_videos.php") + "?wd=" + encodeURIComponent(cleanTitle(title)) + "&pg=1&_t=" + Date.now(), "").then(function (d) {
|
||
if (token !== state.panToken || state.currentItem !== item) return;
|
||
var list = ((d && (d.list || d.data)) || []).map(mapVod);
|
||
function score(it) { var s = 0; if (it.title === title) s += 8; else if (titleCloseContain(it.title, title)) s += 4; if (yr && it.year === yr) s += 3; return s; }
|
||
var good = list.filter(function (it) { return score(it) >= 4; });
|
||
if (good.length) openDetail(good.slice().sort(function (a, b) { return score(b) - score(a); })[0]);
|
||
}, function () {});
|
||
}
|
||
|
||
/* ---------- 首页「合集」(放在首页最下面一行) ---------- */
|
||
function renderZn4kCollections(box) {
|
||
var shell = makeRowShell("合集", null);
|
||
box.appendChild(shell.section);
|
||
var row = shell.row, frag = document.createDocumentFragment();
|
||
ZN4K_COLLECTIONS.forEach(function (c, i) {
|
||
var card = document.createElement("button");
|
||
card.type = "button"; card.className = "card focusable";
|
||
card.setAttribute("data-card-index", String(i));
|
||
card.setAttribute("data-focus-row", "row");
|
||
var poster = document.createElement("div"); poster.className = "poster";
|
||
var ph = document.createElement("div"); ph.className = "ph"; ph.textContent = c.name.replace("合集", ""); poster.appendChild(ph);
|
||
var badge = document.createElement("span"); badge.className = "badge mono"; badge.textContent = c.paged ? "榜单" : "合集"; poster.appendChild(badge);
|
||
card.appendChild(poster);
|
||
var title = document.createElement("div"); title.className = "card-title"; title.textContent = c.name; card.appendChild(title);
|
||
var meta = document.createElement("div"); meta.className = "card-meta"; meta.textContent = "指南"; card.appendChild(meta);
|
||
card.onclick = (function (col) { return function () { openZn4kCollection(col.slug, col.name, col.paged); }; })(c);
|
||
frag.appendChild(card);
|
||
(function (slug, posterEl, phEl) { rowLoad(shell.section, function () { return zn4kCollectionCover(slug, posterEl, phEl); }); })(c.slug, poster, ph);
|
||
});
|
||
row.replaceChildren(frag);
|
||
}
|
||
/* 首页「豆瓣高分」一行(4K指南 的豆瓣高分 分类,紧跟在 TMDB 热播 下面) */
|
||
function renderZn4kTop250(box) {
|
||
var c = ZN4K_TOP250;
|
||
var shell = makeRowShell(c.name, function () { openZn4kCollection(c.slug, c.name, c.paged); });
|
||
box.appendChild(shell.section);
|
||
var cached = state.zn4kColCache[c.slug];
|
||
var hasCache = !!(cached && cached.length);
|
||
if (hasCache) fillRow(shell.row, cached.slice(0, 12), function (it) { openDetail(it); });
|
||
else shell.row.replaceChildren(el("div", "mh-row-empty", "加载中…"));
|
||
var load = function () {
|
||
if (!document.body.contains(shell.row)) return null;
|
||
return zn4kFetchPage(c.slug, 1).then(function (members) {
|
||
if (!document.body.contains(shell.row)) return;
|
||
if (!members.length) { if (!hasCache) shell.row.replaceChildren(el("div", "mh-row-empty", "暂无数据")); return; }
|
||
state.zn4kColCache[c.slug] = members; saveHomeDisk();
|
||
fillRow(shell.row, members.slice(0, 12), function (it) { openDetail(it); });
|
||
}, function () { if (!hasCache) shell.row.replaceChildren(el("div", "mh-row-empty", "加载失败")); });
|
||
};
|
||
if (hasCache) { if (homeCacheStale()) homePrefetch(load); return; } // 有缓存:先秒显,旧了才后台刷新
|
||
rowLoad(shell.section, load);
|
||
}
|
||
/* 抓指南列表页某一页 -> 成员数组(带 _zn4k 标记,点开走指南详情逻辑) */
|
||
function zn4kFetchPage(slug, page) {
|
||
return zn4kHttpText(zn4kListUrl(slug, page)).then(function (html) {
|
||
return zn4kParseList(html).map(function (m) { m._zn4k = true; m._zn4kId = m.id; return m; });
|
||
});
|
||
}
|
||
/* 封面懒加载:用该合集/榜单第一页里首个有海报的成员当封面;第一页结果缓存,点进去时秒开 */
|
||
function zn4kCollectionCover(slug, posterEl, phEl) {
|
||
function setCover(members) {
|
||
var first = (members || []).filter(function (m) { return m.poster; })[0];
|
||
if (!first || !posterEl) return;
|
||
var img = document.createElement("img");
|
||
img.alt = ""; img.loading = "lazy"; img.setAttribute("decoding", "async");
|
||
img.onerror = function () { img.style.display = "none"; };
|
||
img.src = zn4kImgSrc(first.poster);
|
||
if (phEl) phEl.style.display = "none";
|
||
posterEl.insertBefore(img, posterEl.firstChild);
|
||
}
|
||
var cached = state.zn4kColCache[slug];
|
||
if (cached && cached.length) { setCover(cached); return null; }
|
||
return zn4kFetchPage(slug, 1).then(function (members) {
|
||
state.zn4kColCache[slug] = members;
|
||
saveHomeDisk();
|
||
setCover(members);
|
||
}, function () {});
|
||
}
|
||
/* 打开某个指南合集/榜单:复用「查看全部」分类视图铺影片。
|
||
paged=true(豆瓣高分 这类常规分类页)滑到底会继续翻页;固定名单的合集一页到底。 */
|
||
function openZn4kCollection(slug, name, paged) {
|
||
saveHomeReturn();
|
||
state.view = "category";
|
||
$("homeView").style.display = "none"; $("catView").style.display = "";
|
||
$("genreChips").style.display = "none"; $("genreChips").replaceChildren();
|
||
$("catTitle").textContent = name || "合集";
|
||
$("clearSearch").style.display = "none"; $("clearRecent").style.display = "none";
|
||
setNav(null); updateHeroVisible(); window.scrollTo(0, 0);
|
||
navigate("category");
|
||
state.searchMode = false; state.searchKw = "";
|
||
state.activeCat = ZN4K_CAT_SLUG; state.activeGenre = "";
|
||
state.zn4kCol = { slug: slug, name: name, paged: !!paged, page: 1, fetching: false };
|
||
state.reqToken++; var token = state.reqToken;
|
||
state.source = []; state.rendered = 0; state.noMore = !paged; state.loading = false; state.autoPages = 0;
|
||
var grid = $("grid"); grid.replaceChildren(); grid.classList.remove("grid-recent");
|
||
$("browseNotice").replaceChildren();
|
||
$("browseSub").textContent = "加载中";
|
||
$("browseLoading").style.display = "block";
|
||
var cached = state.zn4kColCache[slug];
|
||
if (cached) { zn4kShowCollection(cached, token); return; }
|
||
zn4kFetchPage(slug, 1).then(function (members) {
|
||
if (token !== state.reqToken) return;
|
||
state.zn4kColCache[slug] = members;
|
||
zn4kShowCollection(members, token);
|
||
}, function () {
|
||
if (token !== state.reqToken) return;
|
||
$("browseLoading").style.display = "none";
|
||
$("browseSub").textContent = "";
|
||
$("grid").replaceChildren(el("div", "mh-row-empty", "加载失败,返回重试"));
|
||
});
|
||
}
|
||
function zn4kShowCollection(members, token) {
|
||
if (token !== state.reqToken) return;
|
||
$("browseLoading").style.display = "none";
|
||
state.source = (members || []).slice(); state.rendered = 0;
|
||
$("grid").replaceChildren();
|
||
if (!state.source.length) { $("grid").replaceChildren(el("div", "mh-row-empty", "该分类暂无内容")); $("browseSub").textContent = ""; state.noMore = true; return; }
|
||
appendGrid();
|
||
$("browseSub").textContent = state.source.length + " 部";
|
||
}
|
||
/* 指南榜单(豆瓣高分)触底续页:由 loadList(false) 转发过来 */
|
||
function zn4kLoadMore() {
|
||
var c = state.zn4kCol;
|
||
if (!c || !c.paged || c.fetching || state.noMore) return;
|
||
c.fetching = true; state.loading = true;
|
||
var token = state.reqToken, page = c.page + 1;
|
||
$("browseLoading").style.display = "block";
|
||
zn4kFetchPage(c.slug, page).then(function (list) {
|
||
c.fetching = false; state.loading = false;
|
||
if (token !== state.reqToken) return;
|
||
$("browseLoading").style.display = "none";
|
||
var have = {}; state.source.forEach(function (s) { have[s.id] = 1; });
|
||
var fresh = (list || []).filter(function (it) { return !have[it.id]; });
|
||
if (!fresh.length) { state.noMore = true; return; }
|
||
c.page = page;
|
||
state.source = state.source.concat(fresh);
|
||
appendGrid();
|
||
$("browseSub").textContent = state.source.length + " 部";
|
||
}, function () {
|
||
c.fetching = false; state.loading = false;
|
||
if (token !== state.reqToken) return;
|
||
$("browseLoading").style.display = "none";
|
||
state.noMore = true;
|
||
});
|
||
}
|
||
|
||
/* ============================================================
|
||
七味网(qmp4.com)集成 —— 完全仿照上面 4K指南 的接法:
|
||
把该站的搜索接口 + 站内下载资源(磁力 + 夸克/迅雷/百度等网盘)接进来。
|
||
- 详情页:按标题去七味搜同名,解析下载区,把网盘/磁力并进「网盘资源」,
|
||
不在搜索结果里单独列站源(同 zn4kEnrich)。
|
||
- 搜索:并发搜七味,只有盘链、指南都没有的片子才补一张七味来源卡片。
|
||
- 首页:新增「七味」一行(数据来自七味电影分类第一页,可翻页)。
|
||
============================================================ */
|
||
var QW_DEFAULT_BASE = "https://www.qwmp4.com"; // 默认用可直连的亚太镜像(官方永久域名 qmp4.com 需科学上网,不适合做默认)
|
||
/* 已知镜像域名(发布页公布的亚太/全球线路),主域连不上时按顺序自动切换,命中后记在 state.qwGoodBase。
|
||
这类站换域名很勤,把镜像都列上,任一存活就能用;用户也可在设置里手动指定「七味站点」。 */
|
||
var QW_MIRRORS = [
|
||
"https://www.qwmp4.com", "https://www.qwmkv.com", "https://www.qwfilm.com",
|
||
"https://www.qwavi.com", "https://www.qwfun.com",
|
||
"https://www.qnmp4.com", "https://www.qnnull.com", "https://www.qnhot.com",
|
||
"https://www.qncool.com", "https://www.qwfree.com", "https://www.qmp4.com"
|
||
];
|
||
/* 用户配置/默认的「首选」域名(设置里填了就用填的) */
|
||
function qwPreferredBase() { return trimSlash(config.qwBase || QW_DEFAULT_BASE); }
|
||
/* 当前生效域名:优先用已探测到能用的镜像(state.qwGoodBase),否则用首选。
|
||
URL 构造和图片 Referer 都走它,保证请求域名和 Referer 一致。 */
|
||
function qwBase() { return trimSlash(state.qwGoodBase || qwPreferredBase()); }
|
||
function qwHost() { return qwBase().replace(/^https?:\/\//, "").split("/")[0]; }
|
||
function isQwUrl(u) { var h = qwHost(); return !!(u && h && String(u).indexOf(h) >= 0); }
|
||
/* 七味海报放在 vvmp4 图床上,带防盗链,判定时把图床也算作七味的图 */
|
||
function isQwPoster(u) { return isQwUrl(u) || /vvmp4\.com/.test(String(u || "")); }
|
||
var QW_UA = ZN4K_UA; // 跟指南同一套移动端 UA
|
||
var QW_CAT_SLUG = "__qwcat__"; // 七味分类在分类视图里的内部 slug
|
||
/* 七味顶级分类(对应 /vt/{id}.html):电影1 / 剧集2 / 综艺3 / 动漫4 / 短剧30 */
|
||
var QW_CATS = [
|
||
{ t: "1", name: "电影" }, { t: "2", name: "剧集" }, { t: "3", name: "综艺" },
|
||
{ t: "4", name: "动漫" }, { t: "30", name: "短剧" }
|
||
];
|
||
/* 列表页 URL:第 1 页 /vt/{cat}.html,后续 /vt/{cat}-{page}.html */
|
||
function qwListUrl(cat, page) {
|
||
var base = qwBase();
|
||
return (page > 1) ? (base + "/vt/" + cat + "-" + page + ".html") : (base + "/vt/" + cat + ".html");
|
||
}
|
||
/* 七味搜索:GET 表单 action=/vs/-------------.html,关键词走 ?wd= */
|
||
function qwSearchUrl(kw) { return qwBase() + "/vs/-------------.html?wd=" + encodeURIComponent(kw); }
|
||
function qwDetailUrl(id) { return qwBase() + "/mv/" + id + ".html"; }
|
||
|
||
/* 候选域名列表:上次成功的 -> 配置/默认首选 -> 其余镜像(去重) */
|
||
function qwCandidateBases() {
|
||
var out = [], seen = {};
|
||
function add(b) { b = trimSlash(b || ""); if (b && !seen[b]) { seen[b] = 1; out.push(b); } }
|
||
add(state.qwGoodBase); add(config.qwBase); add(QW_DEFAULT_BASE);
|
||
QW_MIRRORS.forEach(add);
|
||
return out;
|
||
}
|
||
function qwPath(url) { return String(url || "").replace(/^https?:\/\/[^/]+/, "") || "/"; }
|
||
/* 抓一次七味页面:从候选域名里逐个试,返回第一个"看着像七味页面"的结果,
|
||
并把命中的域名记进 state.qwGoodBase(之后 URL/Referer 都用它,不再逐个试)。 */
|
||
function qwFetchOne(base, path) {
|
||
var full = base + path;
|
||
if (state.sdkReady && window.fm && fm.req) {
|
||
return fm.req(full, { method: "GET", responseType: "text", timeout: 12, headers: { "User-Agent": QW_UA, "Referer": base + "/" } })
|
||
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.body; });
|
||
}
|
||
return fetchTimeout(full, { headers: {} }).then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.text(); });
|
||
}
|
||
function qwLooksValid(html) {
|
||
// 旧版要求页面里出现 '/mv/'、'pic-list'、'down-link' 或 '七味' 这几个正向标记才算"像七味页面"——
|
||
// 但一次真正的搜索如果关键词在该站确实 0 条匹配,结果页往往只是个空的 <ul class="pic-list"></ul>
|
||
// 或者干脆不渲染这几个 class/文案,导致这条检测把"域名没问题、只是没搜到"误判成"域名失效",
|
||
// 从而白白把 11 个镜像域名挨个试一遍(每个最多等 12 秒),要么把这次搜索拖到没意义,
|
||
// 要么真凑巧所有镜像的空结果页都通不过检测,最终整条搜索直接返回空。
|
||
// 改成排除法:只要响应不是空/太短、也不是停放页/跳转提示/人机校验页,就认为域名可用。
|
||
if (!html || html.length < 200) return false;
|
||
if (/该域名(已|可)(出售|转让)|域名过期|停止解析|正在跳转|即将跳转|window\.location\s*=|_guard\/|jsl_clearance|检测中|请稍候|访问异常|异常流量|人机验证|安全验证|访问验证|验证码|captcha|access denied|forbidden|频繁访问|非法访问|请开启\s*(javascript|cookie)|enable\s*javascript|bot\s*detect|blocked|unusual traffic/i.test(html)) return false;
|
||
return true;
|
||
}
|
||
/* 七味排障开关:打开后搜索完在屏幕底部弹一个可点关闭的浮层,列出每个候选域名的请求结果
|
||
(字节数/判定结果),用来分辨到底是"域名连不上""域名连上了但判定成无效""还是解析出 0 条"。
|
||
排查完记得关回 false。 */
|
||
var QW_SEARCH_DIAG = false;
|
||
function qwSDiag(line) {
|
||
if (!QW_SEARCH_DIAG) return;
|
||
if (!state.qwDiagList) state.qwDiagList = [];
|
||
if (state.qwDiagList.length < 30) state.qwDiagList.push(line);
|
||
}
|
||
function qwSDiagShow() {
|
||
if (!QW_SEARCH_DIAG) return;
|
||
var box = document.getElementById("qwSDiagBox");
|
||
if (!box) {
|
||
box = document.createElement("div");
|
||
box.id = "qwSDiagBox";
|
||
box.style.cssText = "position:fixed;left:8px;right:8px;bottom:96px;z-index:70;max-height:44vh;overflow:auto;" +
|
||
"padding:10px 12px;border-radius:12px;background:rgba(10,14,24,.94);color:#cfe0ff;font-size:11px;" +
|
||
"line-height:1.5;white-space:pre-wrap;text-align:left;box-shadow:0 8px 30px rgba(0,0,0,.5);" +
|
||
"border:1px solid rgba(160,190,255,.22);-webkit-user-select:text;user-select:text;";
|
||
box.addEventListener("click", function () { box.style.display = "none"; });
|
||
document.body.appendChild(box);
|
||
}
|
||
box.textContent = "【七味搜索诊断】(点一下关闭)\n" + (state.qwDiagList || []).join("\n");
|
||
box.style.display = "block";
|
||
}
|
||
|
||
/* 七味站请求:不带盘链 Cookie,只用浏览器 UA + 七味自身 Referer;带镜像自动切换。
|
||
给整条镜像级联一个总预算,别让一串连不上的镜像把这次抓取拖到失去意义。 */
|
||
function qwSnippet(t) { return String(t == null ? "" : t).replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim().slice(0, 200); }
|
||
function qwHttpText(url) { return httpTextMemo("qw|" + url, function () { return qwHttpTextRaw(url); }); }
|
||
function qwHttpTextRaw(url) {
|
||
var cands = qwCandidateBases(), path = qwPath(url);
|
||
function tryAt(i) {
|
||
if (i >= cands.length) { qwSDiag("——— 全部 " + cands.length + " 个镜像都失败/判定无效"); return Promise.reject(new Error("qw: all mirrors failed")); }
|
||
return qwFetchOne(cands[i], path).then(function (html) {
|
||
var ok = qwLooksValid(html);
|
||
// 字节数偏小(<4000)时多半是拦截页/验证页而不是真的搜索结果页,把去标签后的文字片段打出来,
|
||
// 不管判定成有效还是无效都打,避免"判定有效"掩盖了它其实是页错误页这件事。
|
||
qwSDiag(cands[i] + "\n → " + (html ? html.length + " 字节" : "空") + (ok ? " ✓ 判定有效" : " ✕ 判定无效,换下一个") +
|
||
((html && html.length < 4000) ? ("\n 内容片段:" + qwSnippet(html)) : ""));
|
||
if (ok) { state.qwGoodBase = cands[i]; return html; } // 记住可用镜像
|
||
return tryAt(i + 1); // landing/空页:换下一个
|
||
}, function (e) {
|
||
qwSDiag(cands[i] + "\n → 请求失败:" + ((e && e.message) || "未知"));
|
||
return tryAt(i + 1);
|
||
});
|
||
}
|
||
if (path.indexOf("/vs/") === 0) state.qwDiagList = []; // 每次新搜索前清空诊断缓冲
|
||
var p = tryAt(0);
|
||
if (path.indexOf("/vs/") === 0) p = p.then(function (html) {
|
||
var n = qwParseList(html).length;
|
||
qwSDiag("———\n最终用的响应解析出 " + n + " 条");
|
||
qwSDiagShow();
|
||
return html;
|
||
}, function (e) { qwSDiag("———\n最终失败:" + ((e && e.message) || "未知")); qwSDiagShow(); throw e; });
|
||
return qwWithBudget(p, 20000);
|
||
}
|
||
/* 给镜像级联一个总时间预算:到点还没个结果,直接判失败,不管还剩几个镜像没试完
|
||
(不像 woWithBudget 那样"超时就当空结果",这里要保留 reject,好让上层区分
|
||
"真的搜不到"和"没来得及搜完",避免把后者误当成前者缓存下来)。 */
|
||
function qwWithBudget(promise, ms) {
|
||
return new Promise(function (resolve, reject) {
|
||
var done = false;
|
||
var timer = setTimeout(function () { if (!done) { done = true; reject(new Error("qw: budget exceeded")); } }, ms);
|
||
promise.then(
|
||
function (v) { if (!done) { done = true; clearTimeout(timer); resolve(v); } },
|
||
function (e) { if (!done) { done = true; clearTimeout(timer); reject(e); } }
|
||
);
|
||
});
|
||
}
|
||
/* 七味图片要带七味 Referer(防盗链) */
|
||
|
||
function qwImgSrc(url) {
|
||
if (!url) return "";
|
||
if (state.sdkReady && window.fm && fm.res) return fm.res(url, { headers: { "Referer": qwBase() + "/" } });
|
||
return url;
|
||
}
|
||
function qwStripHeavy(html) {
|
||
try { return String(html).replace(/<script[\s\S]*?<\/script>/gi, "").replace(/<style[\s\S]*?<\/style>/gi, "").replace(/<!--[\s\S]*?-->/g, ""); }
|
||
catch (e) { return html; }
|
||
}
|
||
function qwParseDoc(html) {
|
||
try { return new DOMParser().parseFromString(qwStripHeavy(html), "text/html"); } catch (e) { return null; }
|
||
}
|
||
function qwDiskType(u) {
|
||
for (var i = 0; i < HOSTMAP.length; i++) if (HOSTMAP[i][0].test(u)) return HOSTMAP[i][1];
|
||
if (u.indexOf("magnet:") === 0) return "magnet";
|
||
if (u.indexOf("ed2k:") === 0) return "ed2k";
|
||
if (u.indexOf("thunder:") === 0) return "thunder";
|
||
return "http";
|
||
}
|
||
/* 列表/搜索结果页 -> [{id,title,poster,meta,year}]
|
||
七味卡片结构:ul.pic-list > li > a[href*='/mv/'] > article>img[alt] + h3>b(标题) + p(年份/地区/类型) */
|
||
function qwParseList(html) {
|
||
var doc = qwParseDoc(html);
|
||
if (!doc) return [];
|
||
var out = [], seen = {};
|
||
function add(id, title, poster, meta, year) { if (!id || seen[id] || !title) return; seen[id] = 1; out.push({ id: id, title: title, poster: poster, meta: meta || "", year: year || "" }); }
|
||
Array.from(doc.querySelectorAll(".pic-list li a[href*='/mv/'], ul.pic-list li a[href*='/mv/']")).forEach(function (a) {
|
||
var m = (a.getAttribute("href") || "").match(/\/mv\/(\d+)\.html/);
|
||
if (!m) return;
|
||
var img = a.querySelector("img");
|
||
var poster = img ? (img.getAttribute("data-src") || img.getAttribute("data-original") || img.getAttribute("src") || "") : "";
|
||
var tEl = a.querySelector("h3 b, h3");
|
||
var title = ((tEl ? tEl.textContent : "") || a.getAttribute("title") || (img ? img.getAttribute("alt") : "") || "").trim();
|
||
var pEl = a.querySelector("p");
|
||
var meta = pEl ? pEl.textContent.trim().replace(/\s+/g, " ") : "";
|
||
var ym = meta.match(/(19|20)\d{2}/);
|
||
add(m[1], title, poster, meta, ym ? ym[0] : "");
|
||
});
|
||
if (!out.length) { // 兜底:模板变了也能抓到 /mv/ 链接
|
||
Array.from(doc.querySelectorAll("a[href*='/mv/']")).forEach(function (a) {
|
||
var m = (a.getAttribute("href") || "").match(/\/mv\/(\d+)\.html/);
|
||
if (!m) return;
|
||
var scope = a.closest("li, .item, article") || a;
|
||
var img = a.querySelector("img") || (scope ? scope.querySelector("img") : null);
|
||
var poster = img ? (img.getAttribute("data-src") || img.getAttribute("src") || "") : "";
|
||
var title = (a.getAttribute("title") || (img ? img.getAttribute("alt") : "") || a.textContent || "").trim();
|
||
add(m[1], title, poster, "", "");
|
||
});
|
||
}
|
||
return out;
|
||
}
|
||
/* 详情页 -> { title, poster, overview, resources:[{url,label,quality,type}] }
|
||
下载区结构:#donLink 里 .nav-tabs .title 是画质分组名,.gdt.content 里每个 a[href] 是一条下载/网盘链接 */
|
||
function qwParseDetail(html) {
|
||
var doc = qwParseDoc(html);
|
||
if (!doc) return { title: "", poster: "", overview: "", resources: [] };
|
||
var h1 = doc.querySelector("h1");
|
||
var title = h1 ? h1.textContent.trim() : "";
|
||
var descEl = doc.querySelector("meta[name='description']");
|
||
var overview = descEl ? (descEl.getAttribute("content") || "") : "";
|
||
var poster = "";
|
||
var imgs = Array.from(doc.querySelectorAll("img"));
|
||
for (var i = 0; i < imgs.length; i++) {
|
||
var s = imgs[i].getAttribute("data-src") || imgs[i].getAttribute("src") || "";
|
||
if (/upload\/vod|vvmp4/.test(s)) { poster = s; break; }
|
||
}
|
||
var res = [], seen = {};
|
||
function pushLink(url, label, quality) {
|
||
url = (url || "").trim();
|
||
if (!url || !/^https?:|^magnet:|^ed2k:|^thunder:/.test(url)) return;
|
||
if (seen[url]) return; seen[url] = 1;
|
||
res.push({ url: url, label: (label || "").trim(), quality: quality || "", type: qwDiskType(url) });
|
||
}
|
||
// 七味详情页有多个下载面板(磁力/BT 一个、网盘一个),且都复用 id="donLink"(重复 id),
|
||
// 不能只取第一个,必须遍历所有 .down-link 面板,各自的 tab(画质/网盘名)配各自的资源列表。
|
||
var panels = Array.from(doc.querySelectorAll(".down-link"));
|
||
if (!panels.length) panels = [doc];
|
||
panels.forEach(function (panel) {
|
||
var tabs = Array.from(panel.querySelectorAll(".nav-tabs .title, .tab-title .title"));
|
||
var lists = Array.from(panel.querySelectorAll(".gdt.content, ul.gdt"));
|
||
function qualOf(idx) {
|
||
var t = tabs[idx]; if (!t) return "";
|
||
var c = t.cloneNode(true); var b = c.querySelector(".badge"); if (b && b.parentNode) b.parentNode.removeChild(b);
|
||
return (c.textContent || "").trim();
|
||
}
|
||
lists.forEach(function (ul, idx) {
|
||
var q = qualOf(idx);
|
||
Array.from(ul.querySelectorAll("a[href]")).forEach(function (a) { pushLink(a.getAttribute("href"), a.textContent, q); });
|
||
});
|
||
});
|
||
// 网盘链接全页兜底扫描:无条件执行(不再只在面板一无所获时才跑),
|
||
// 这样即便磁力面板先命中、或某些镜像的网盘区用了不同的类名/结构,
|
||
// 只要页面里出现真实网盘域名前缀的 <a>,都会被补进来(pushLink 已按 url 去重)。
|
||
// 只匹配已知网盘域名 + 磁力,广告/诱导链接(非网盘域名)自然不会被收。
|
||
var sweep = "a[href^='magnet:'], a[href*='pan.baidu.com'], a[href*='pan.quark.cn'], "
|
||
+ "a[href*='pan.xunlei.com'], a[href*='drive.uc.cn'], a[href*='cloud.189.cn'], "
|
||
+ "a[href*='123pan.com'], a[href*='123684.com'], a[href*='123865.com'], a[href*='123912.com'], "
|
||
+ "a[href*='aliyundrive.com'], a[href*='alipan.com'], a[href*='115.com'], a[href*='115cdn.com'], "
|
||
+ "a[href*='mypikpak.com'], a[href*='caiyun'], a[href*='139.com']";
|
||
Array.from(doc.querySelectorAll(sweep)).forEach(function (a) {
|
||
// 尽量给个画质/网盘名标签:优先取所在面板 tab,其次取链接自身文字
|
||
var q = "";
|
||
var host = a.closest ? a.closest(".gdt.content, ul.gdt, li, p") : null;
|
||
pushLink(a.getAttribute("href"), a.textContent, q);
|
||
});
|
||
return { title: title, poster: poster, overview: overview, resources: res };
|
||
}
|
||
/* 七味解析出的资源 -> 盘链网盘条目(只保留 App 能处理的网盘类型 + 磁力) */
|
||
function qwResList(resources) {
|
||
var out = [], seen = {};
|
||
(resources || []).forEach(function (r) {
|
||
var type = r.type;
|
||
if (PANSOU_KEEP.indexOf(type) < 0 && type !== "magnet") return;
|
||
var url = r.url || ""; if (!url) return;
|
||
var k = type + "|" + normPanUrl(url);
|
||
if (seen[k]) return; seen[k] = 1;
|
||
var lab = PAN_META[type] ? PAN_META[type].label : (TYPE_LABEL[type] || type);
|
||
out.push({
|
||
type: type, url: url, password: "",
|
||
title: r.label || "",
|
||
groupName: lab, quality: lab,
|
||
source: "七味", state: "idle", _qw: true
|
||
});
|
||
});
|
||
return out;
|
||
}
|
||
/* 详情页里已经并进来的七味资源(刷新/重载时保留,跟 zn4kKeep 同理) */
|
||
function qwKeep() { return (state.panItems || []).filter(function (it) { return it._qw; }); }
|
||
/* 把一批七味资源合并进当前详情页网盘列表(去重 + 重建 tab) */
|
||
function qwMerge(pans, token) {
|
||
if (token !== state.panToken || !pans || !pans.length) return;
|
||
var have = {};
|
||
state.panItems.forEach(function (it) { have[it.type + "|" + normPanUrl(it.url || it.token)] = 1; });
|
||
var add = [];
|
||
pans.forEach(function (p) { var k = p.type + "|" + normPanUrl(p.url); if (!have[k]) { have[k] = 1; add.push(p); } });
|
||
if (!add.length) return;
|
||
renderResources(state.panItems.concat(add));
|
||
}
|
||
/* 七味搜索:/vs/...html?wd=关键词 -> 列表 */
|
||
function qwSearch(kw) {
|
||
return qwHttpText(qwSearchUrl(kw)).then(function (html) { return qwParseList(html); }, function () { return []; });
|
||
}
|
||
/* 从七味搜索结果里挑与本片最匹配的一条(标题相关度优先,年份加权) */
|
||
function qwPickBest(list, rawTitle, year) {
|
||
var key = cleanTitle(rawTitle), best = null, bestScore = 99;
|
||
(list || []).forEach(function (it) {
|
||
var ts = titleScore(it.title, key);
|
||
if (ts > 3) return;
|
||
var s = ts;
|
||
if (year && ((it.title || "").indexOf(year) >= 0 || (it.meta || "").indexOf(year) >= 0 || it.year === year)) s -= 0.3;
|
||
if (s < bestScore) { bestScore = s; best = it; }
|
||
});
|
||
return best;
|
||
}
|
||
/* 详情页(盘链原生条目):按标题去七味搜同名,把站内网盘/磁力并进来 */
|
||
function qwEnrich(rawTitle, token) {
|
||
var kw = cleanTitle(rawTitle);
|
||
if (!kw) return;
|
||
var year = yearOf(rawTitle);
|
||
qwSearch(kw).then(function (list) {
|
||
if (token !== state.panToken || !list.length) return null;
|
||
var best = qwPickBest(list, rawTitle, year);
|
||
if (!best) return null;
|
||
return qwHttpText(qwDetailUrl(best.id)).then(function (html) {
|
||
if (token !== state.panToken) return;
|
||
qwMerge(qwResList(qwParseDetail(html).resources), token);
|
||
});
|
||
}).then(null, function () {});
|
||
}
|
||
/* 详情页(七味来源条目):已知七味影片 id,直接取其站内下载资源 */
|
||
function qwLoadById(id, token) {
|
||
if (!id) return;
|
||
qwHttpText(qwDetailUrl(id)).then(function (html) {
|
||
if (token !== state.panToken) return;
|
||
var d = qwParseDetail(html);
|
||
if (state.currentDetail && !state.currentDetail.tmdb) {
|
||
if (d.title) { state.currentDetail.rawTitle = d.title; showTitle(d.title); }
|
||
var ov = $("detailOverview"); if (ov && !ov.textContent && d.overview && !isJunkOverview(d.overview)) { ov.textContent = d.overview; state.currentDetail.overviewFromSite = true; }
|
||
if (d.poster && !state.currentDetail.poster) { state.currentDetail.poster = d.poster; setDetailHeroImages([d.poster], false); }
|
||
}
|
||
var pans = qwResList(d.resources);
|
||
if (pans.length) renderResources(sortPanDefault(pans).concat(panSouKeep()));
|
||
}, function () {});
|
||
}
|
||
/* 七味来源条目:顺便按标题反查盘链本站,命中就升级成原生条目 */
|
||
function qwReverseLookup(item, token) {
|
||
var title = item.title, yr = item.year || yearOf(item.title);
|
||
apiGet(apiUrl("get_videos.php") + "?wd=" + encodeURIComponent(cleanTitle(title)) + "&pg=1&_t=" + Date.now(), "").then(function (d) {
|
||
if (token !== state.panToken || state.currentItem !== item) return;
|
||
var list = ((d && (d.list || d.data)) || []).map(mapVod);
|
||
function score(it) { var s = 0; if (it.title === title) s += 8; else if (titleCloseContain(it.title, title)) s += 4; if (yr && it.year === yr) s += 3; return s; }
|
||
var good = list.filter(function (it) { return score(it) >= 4; });
|
||
if (good.length) openDetail(good.slice().sort(function (a, b) { return score(b) - score(a); })[0]);
|
||
}, function () {});
|
||
}
|
||
/* 抓七味分类某一页 -> 成员数组(带 _qw 标记,点开走七味详情逻辑) */
|
||
function qwFetchPage(cat, page) {
|
||
return qwHttpText(qwListUrl(cat, page)).then(function (html) {
|
||
return qwParseList(html).map(function (m) { m._qw = true; m._qwId = m.id; return m; });
|
||
});
|
||
}
|
||
/* 首页「七味」一行:点标题/更多进「查看全部」(含电影/剧集/综艺/动漫/短剧分类切换) */
|
||
function renderQwHomeRow(box) {
|
||
var shell = makeRowShell("七味", function () { openQwCategory("1", "七味"); });
|
||
box.appendChild(shell.section);
|
||
var cached = state.qwCatCache["1"];
|
||
var hasCache = !!(cached && cached.length);
|
||
if (hasCache) fillRow(shell.row, cached.slice(0, 12), function (it) { openDetail(it); });
|
||
else shell.row.replaceChildren(el("div", "mh-row-empty", "加载中…"));
|
||
var load = function () {
|
||
if (!document.body.contains(shell.row)) return null;
|
||
return qwFetchPage("1", 1).then(function (members) {
|
||
if (!document.body.contains(shell.row)) return;
|
||
if (!members.length) { if (!hasCache) shell.row.replaceChildren(el("div", "mh-row-empty", "暂无数据")); return; }
|
||
state.qwCatCache["1"] = members; saveHomeDisk();
|
||
fillRow(shell.row, members.slice(0, 12), function (it) { openDetail(it); });
|
||
}, function () { if (!hasCache) shell.row.replaceChildren(el("div", "mh-row-empty", "加载失败")); });
|
||
};
|
||
if (hasCache) { if (homeCacheStale()) homePrefetch(load); return; }
|
||
rowLoad(shell.section, load);
|
||
}
|
||
/* 打开七味「查看全部」:顶部分类 chips(电影/剧集/综艺/动漫/短剧),滑到底续页(同蜗牛 wnLoadCat) */
|
||
function openQwCategory(t, name) {
|
||
saveHomeReturn();
|
||
state.view = "category";
|
||
$("homeView").style.display = "none"; $("catView").style.display = "";
|
||
$("catTitle").textContent = name || "七味";
|
||
$("clearSearch").style.display = "none"; $("clearRecent").style.display = "none";
|
||
setNav(null); updateHeroVisible(); window.scrollTo(0, 0);
|
||
navigate("category");
|
||
state.searchMode = false; state.searchKw = "";
|
||
state.activeCat = QW_CAT_SLUG; state.qwCatSel = t || "1"; state.activeGenre = "";
|
||
$("genreChips").style.display = ""; renderQwGenreChips();
|
||
qwLoadCat(true);
|
||
}
|
||
function renderQwGenreChips() {
|
||
var box = $("genreChips"); if (!box) return;
|
||
var frag = document.createDocumentFragment();
|
||
QW_CATS.forEach(function (c, i) {
|
||
var b = document.createElement("button");
|
||
b.type = "button";
|
||
b.className = "genre-chip focusable" + (state.qwCatSel === c.t ? " active" : "");
|
||
b.textContent = c.name; b.setAttribute("data-genre", c.name);
|
||
b.setAttribute("data-focus-row", "genreChips"); b.setAttribute("data-focus-index", String(i));
|
||
b.onclick = (function (cc) { return function () { state.qwCatSel = cc.t; renderQwGenreChips(); window.scrollTo(0, 0); qwLoadCat(true); }; })(c);
|
||
frag.appendChild(b);
|
||
});
|
||
box.replaceChildren(frag);
|
||
}
|
||
function qwLoadCat(reset) {
|
||
var t = state.qwCatSel || "1";
|
||
if (reset) {
|
||
state.reqToken++;
|
||
state.qwPage = 1; state.source = []; state.rendered = 0; state.noMore = false; state.loading = false; state.autoPages = 0;
|
||
$("grid").replaceChildren(); $("grid").classList.remove("grid-recent"); $("browseNotice").replaceChildren();
|
||
var c = state.qwCatCache[t];
|
||
if (c && c.length) { qwPaintCat(c, state.reqToken, 1); return; }
|
||
}
|
||
if (state.loading || state.noMore) return;
|
||
var token = state.reqToken, page = reset ? 1 : (state.qwPage + 1);
|
||
state.loading = true; $("browseLoading").style.display = "block"; if (reset) $("browseSub").textContent = "加载中";
|
||
qwFetchPage(t, page).then(function (list) {
|
||
state.loading = false;
|
||
if (token !== state.reqToken) return;
|
||
$("browseLoading").style.display = "none";
|
||
if (page === 1) state.qwCatCache[t] = list;
|
||
qwPaintCat(list, token, page);
|
||
}, function () {
|
||
state.loading = false;
|
||
if (token !== state.reqToken) return;
|
||
$("browseLoading").style.display = "none"; state.noMore = true;
|
||
if (!state.source.length) $("grid").replaceChildren(el("div", "mh-row-empty", "加载失败,返回重试"));
|
||
});
|
||
}
|
||
function qwPaintCat(list, token, page) {
|
||
if (token !== state.reqToken) return;
|
||
$("browseLoading").style.display = "none";
|
||
var have = {}; state.source.forEach(function (s) { have[s.id] = 1; });
|
||
var fresh = (list || []).filter(function (it) { return !have[it.id]; });
|
||
if (!fresh.length) { state.noMore = true; if (!state.source.length) { $("grid").replaceChildren(el("div", "mh-row-empty", "该分类暂无内容")); $("browseSub").textContent = ""; } return; }
|
||
state.qwPage = page; state.source = state.source.concat(fresh); appendGrid();
|
||
$("browseSub").textContent = state.source.length + " 部";
|
||
}
|
||
|
||
/* ============================================================
|
||
枫叶4K影院(cd-zj.com)集成:这是在线点播站(MacCMS / CupFox 主题),不走网盘,
|
||
播放页里就是 m3u8 直链,属于"即点即播"。
|
||
- 站点结构:列表卡片 .public-list-box;详情 /detail/{id}.html;
|
||
播放页 /play/{id}-{线路号}-{集号}.html,页面里有标准的 player_aaaa(含 m3u8 直链);
|
||
搜索 /cupfox-search/-------------.html?wd=关键词;分类 /type/{t}.html。
|
||
- 首页:新增「枫叶」一行 + 完整分类页(电影/剧集/综艺/动漫/短剧,可翻页)。
|
||
- 详情页:枫叶的线路/剧集直接并进资源列表(线路做 tab、每集一行,点播放即播),
|
||
和网盘资源共存;搜索结果同样不分站源,同名的只在详情页体现。
|
||
============================================================ */
|
||
var FY_DEFAULT_BASE = "https://www.cd-zj.com";
|
||
var FY_CAT_SLUG = "__fy__"; // 枫叶在分类视图里的内部 slug
|
||
var FY_UA = "Mozilla/5.0 (Linux; Android 10) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Mobile Safari/537.36";
|
||
/* 站点各线路(至臻4k / 蓝光-2k / 自营t·y·r,data-form 都是 yhm3u8)共用的外挂播放器。
|
||
这类线路的 player_aaaa.url 只是个 token(形如 JD-xxxxx),要拼到这个播放器地址后面才能换到真视频地址。
|
||
正常情况下地址会从站点的 MacPlayerConfig 里读出来,读不到时用这个兜底。 */
|
||
var FY_PARSE_FALLBACK = "https://fgsrg.hzqingshan.com/player/?url=";
|
||
/* 站点的播放器配置 JS(MacCMS 主题一般把 MacPlayerConfig 放在这类文件里,而不是内联在播放页) */
|
||
var FY_CONFIG_JS = ["/static/js/playerconfig.js", "/template/e/js/playerconfig.js", "/static/js/player.js"];
|
||
var _fyParseCache = null; // { from -> parse }
|
||
function fyBase() { return trimSlash(config.fyBase || FY_DEFAULT_BASE); }
|
||
function fyHost() { return fyBase().replace(/^https?:\/\//, "").split("/")[0]; }
|
||
/* 枫叶站分类(对应 /type/{t}.html) */
|
||
var FY_CATS = [
|
||
{ t: "1", name: "电影" },
|
||
{ t: "2", name: "剧集" },
|
||
{ t: "3", name: "综艺" },
|
||
{ t: "4", name: "动漫" },
|
||
{ t: "5", name: "短剧" }
|
||
];
|
||
/* 枫叶站的「来源标签」页(/label/{tag}.html)——按视频来源平台分,正好对应腾讯/优酷/B站。
|
||
注意:这是来源标签,不是内容分类,标签名跟里面是电影还是剧集无关,所以行标题直接用平台名。 */
|
||
var FY_LABELS = [
|
||
{ tag: "qq", name: "腾讯" },
|
||
{ tag: "youku", name: "优酷" },
|
||
{ tag: "bli", name: "哔哩" }
|
||
];
|
||
|
||
function fyHttpText(url) {
|
||
if (state.sdkReady && window.fm && fm.req) {
|
||
return fm.req(url, { method: "GET", responseType: "text", timeout: 20, headers: { "User-Agent": FY_UA, "Referer": fyBase() + "/" } })
|
||
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.body; });
|
||
}
|
||
return fetchTimeout(url, { headers: {} }).then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.text(); });
|
||
}
|
||
function fyParseDoc(html) {
|
||
try {
|
||
var s = String(html).replace(/<script[\s\S]*?<\/script>/gi, "").replace(/<style[\s\S]*?<\/style>/gi, "").replace(/<!--[\s\S]*?-->/g, "");
|
||
return new DOMParser().parseFromString(s, "text/html");
|
||
} catch (e) { return null; }
|
||
}
|
||
/* 列表页 / 搜索页卡片 -> 影片对象。海报走百度图片代理,站点自己就是 no-referrer,用通用 imgSrc 即可 */
|
||
function fyParseList(html) {
|
||
var doc = fyParseDoc(html);
|
||
if (!doc) return [];
|
||
var out = [], seen = {};
|
||
Array.from(doc.querySelectorAll(".public-list-box")).forEach(function (box) {
|
||
var a = box.querySelector("a.public-list-exp, a[href*='/detail/']");
|
||
if (!a) return;
|
||
var m = (a.getAttribute("href") || "").match(/\/detail\/(\d+)\.html/);
|
||
if (!m) return;
|
||
var id = m[1]; if (seen[id]) return;
|
||
var img = box.querySelector("img");
|
||
var poster = img ? (img.getAttribute("data-src") || img.getAttribute("data-original") || img.getAttribute("src") || "") : "";
|
||
var tEl = box.querySelector(".time-title, .public-list-button a");
|
||
var title = ((a.getAttribute("title") || "") || (tEl ? tEl.textContent : "") || (img ? img.getAttribute("alt") : "") || "").trim();
|
||
if (!title) return;
|
||
var rk = box.querySelector(".public-list-prb i, .public-list-prb");
|
||
var status = rk ? rk.textContent.trim() : "";
|
||
var sub = box.querySelector(".public-list-subtitle");
|
||
seen[id] = 1;
|
||
out.push({
|
||
id: "fy_" + id, title: title, poster: poster, meta: status, status: status,
|
||
overview: sub ? sub.textContent.trim() : "",
|
||
_fy: true, _fyId: id
|
||
});
|
||
});
|
||
return out;
|
||
}
|
||
/* 详情页 -> { title, poster, overview, lines:[线路名], eps:[{sid,nid,name}] }
|
||
剧集不依赖 DOM 里各线路区块的先后顺序(站点会按"排序"按钮改顺序),
|
||
直接从 /play/{id}-{sid}-{nid}.html 链接里读 sid/nid,稳。 */
|
||
function fyParseDetail(html, id) {
|
||
var doc = fyParseDoc(html);
|
||
if (!doc) return null;
|
||
var h3 = doc.querySelector(".slide-info-title, .detail-info h3, h1");
|
||
var title = h3 ? h3.textContent.trim() : "";
|
||
var cover = doc.querySelector(".detail-pic img");
|
||
var poster = cover ? (cover.getAttribute("data-src") || cover.getAttribute("src") || "") : "";
|
||
var ovEl = doc.querySelector("#height_limit, .switch-box .text");
|
||
var overview = ovEl ? ovEl.textContent.replace(/^简介\s*[::]\s*/, "").trim() : "";
|
||
if (!overview) {
|
||
var mt = doc.querySelector("meta[name='description']");
|
||
overview = mt ? (mt.getAttribute("content") || "") : "";
|
||
}
|
||
var lines = [];
|
||
Array.from(doc.querySelectorAll(".anthology-tab .swiper-slide")).forEach(function (s) {
|
||
var c = s.cloneNode(true);
|
||
var b = c.querySelector(".badge"); if (b && b.parentNode) b.parentNode.removeChild(b); // 去掉"(5)"这种集数角标
|
||
lines.push((c.textContent || "").replace(/\s+/g, " ").trim());
|
||
});
|
||
var eps = [], eseen = {};
|
||
Array.from(doc.querySelectorAll("a[href*='/play/']")).forEach(function (a) {
|
||
var mm = (a.getAttribute("href") || "").match(/\/play\/(\d+)-(\d+)-(\d+)\.html/);
|
||
if (!mm) return;
|
||
if (String(mm[1]) !== String(id)) return; // 只要本片的,排除"猜你喜欢"里别的片子
|
||
var k = mm[2] + "-" + mm[3]; if (eseen[k]) return; eseen[k] = 1;
|
||
eps.push({ sid: parseInt(mm[2], 10), nid: parseInt(mm[3], 10), name: (a.textContent || "").trim() || ("第" + mm[3] + "集") });
|
||
});
|
||
eps.sort(function (a, b) { return (a.sid - b.sid) || (a.nid - b.nid); });
|
||
return { title: title, poster: poster, overview: overview, lines: lines, eps: eps };
|
||
}
|
||
/* 详情 -> 资源条目。按需求:只保留「至臻4k」这条线路(它对应 player_aaaa.url 直接是 m3u8,
|
||
解析快、能秒播;自营/蓝光那几条走外挂解析、慢且常失败,全部丢掉),其余交给盘搜兜底。 */
|
||
function fyIsZhizhen(lineName) {
|
||
var s = String(lineName || "").replace(/\s/g, "").toLowerCase();
|
||
return s.indexOf("至臻") >= 0 || s.indexOf("4k") >= 0; // "至臻4k"
|
||
}
|
||
function fyResList(d, id) {
|
||
if (!d || !d.eps || !d.eps.length) return [];
|
||
var out = [];
|
||
d.eps.forEach(function (e) {
|
||
var line = d.lines[e.sid - 1] || ("线路" + e.sid);
|
||
if (!fyIsZhizhen(line)) return; // 只留至臻4k
|
||
out.push({
|
||
type: "fy", url: "", password: "",
|
||
title: e.name,
|
||
groupName: line, quality: line,
|
||
source: "枫叶 · 在线",
|
||
state: "idle",
|
||
_fy: true, _fyId: id, _fySid: e.sid, _fyNid: e.nid,
|
||
_fyPlayPage: fyBase() + "/play/" + id + "-" + e.sid + "-" + e.nid + ".html"
|
||
});
|
||
});
|
||
return out;
|
||
}
|
||
/* ============ 枫叶播放地址破解(移植自站点通用解法,覆盖至臻4k / 蓝光-2k / 自营 t·y·r) ============
|
||
流程和一个能跑通的第三方实现完全对齐:
|
||
1) 播放页里读 player_aaaa:url(可能 urlencode/base64)、encrypt、from
|
||
2) url 本身就是 .m3u8/.mp4 直链 -> 直接用
|
||
3) 否则读站点 /static/js/playerconfig.js,按 from 找到该线路的 parse(解析页)地址,找不到用兜底
|
||
4) 请求 "parse + 待解析url" 得到解析页 iframe,从 #player-data 读 data-te(token) 和 data-bt
|
||
5) POST 到 {解析页域名}{bt}mplayer.php,body=url=...&token=...,拿到 realUrl 和 urlmode
|
||
6) 按 urlmode 做 1/2/3 三种解密(含 WyJ 开头的多轮 jsDecrypt3),得到真正的 m3u8/mp4
|
||
7) 播放:fm.play(realUrl, title, { referer: 站点 }) —— 注意是位置参数,不是对象! */
|
||
var FY_PARSE_FALLBACK = "https://fgsrg.hzqingshan.com/player/?url=";
|
||
var FY_MD5_TEST = "098f6bcd4621d373cade4e832627b4f6"; // md5("test"),jsDecrypt1 用;避免引 CryptoJS 直接写死
|
||
|
||
function fyFixB64(s) { if (!s) return s; var miss = s.length % 4; if (miss) s += new Array(4 - miss + 1).join("="); return s; }
|
||
function fyB64Decode(s) { try { return atob(fyFixB64(s)); } catch (e) { return ""; } }
|
||
/* urlmode=1:base64 -> 与 md5("test") 异或 -> 再 base64 */
|
||
function fyDecrypt1(data) {
|
||
try {
|
||
var decoded = fyB64Decode(data), key = FY_MD5_TEST, out = "";
|
||
for (var i = 0; i < decoded.length; i++) out += String.fromCharCode(decoded.charCodeAt(i) ^ key.charCodeAt(i % key.length));
|
||
return atob(out);
|
||
} catch (e) { return data; }
|
||
}
|
||
/* urlmode=2:base64 -> 取每 3 个字符里的第 2 个,在固定表里 +59 位移 */
|
||
function fyDecrypt2(data) {
|
||
var chars = "PXhw7UT1B0a9kQDKZsjIASmOezxYG4CHo5Jyfg2b8FLpEvRr3WtVnlqMidu6cN";
|
||
try {
|
||
var dec = fyB64Decode(data), out = "";
|
||
for (var i = 1; i < dec.length; i += 3) {
|
||
var ch = dec[i], idx = chars.indexOf(ch);
|
||
out += (idx !== -1) ? chars[(idx + 59) % 62] : ch;
|
||
}
|
||
return out;
|
||
} catch (e) { return data; }
|
||
}
|
||
/* urlmode=3:a/b/cipher 三段,用字典表逐字符映射 */
|
||
function fyDecrypt3(data) {
|
||
try {
|
||
var parts = String(data).split("/");
|
||
if (parts.length >= 3) {
|
||
var arr1 = JSON.parse(fyB64Decode(parts[0]));
|
||
var arr2 = JSON.parse(fyB64Decode(parts[1]));
|
||
var cipher = fyB64Decode(parts.slice(2).join("/")), result = "";
|
||
for (var i = 0; i < cipher.length; i++) {
|
||
var c = cipher[i], idx = arr2.indexOf(c);
|
||
result += (idx !== -1) ? arr1[idx] : c;
|
||
}
|
||
return result;
|
||
}
|
||
} catch (e) {}
|
||
return data;
|
||
}
|
||
/* 通用文本请求(可带 method/headers/body),走 App 的 fm.req,退回 fetch */
|
||
function fyReq(url, options) {
|
||
options = options || {};
|
||
if (state.sdkReady && window.fm && fm.req) {
|
||
return fm.req(url, {
|
||
method: options.method || "GET",
|
||
responseType: "text",
|
||
timeout: options.timeout || 25,
|
||
headers: Object.assign({ "User-Agent": FY_UA, "Referer": fyBase() + "/" }, options.headers || {}),
|
||
credentials: "include",
|
||
body: options.body
|
||
}).then(function (r) {
|
||
var ok = (r.ok !== undefined) ? r.ok : (r.status >= 200 && r.status < 300);
|
||
if (!ok && r.status !== 200) throw new Error(r.error || ("HTTP " + r.status));
|
||
return r.body || r.content || r.data || r.text || "";
|
||
});
|
||
}
|
||
var fo = { headers: Object.assign({ "Referer": fyBase() + "/" }, options.headers || {}) };
|
||
if (options.method === "POST") { fo.method = "POST"; fo.body = options.body; }
|
||
return fetchTimeout(url, fo).then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.text(); });
|
||
}
|
||
/* 播放页 -> player_aaaa 对象 */
|
||
function fyParsePlayerInfo(html) {
|
||
var h = String(html);
|
||
var m = h.match(/var\s+player_aaaa\s*=\s*(\{[\s\S]*?\})\s*<\/script>/);
|
||
if (!m) m = h.match(/player_aaaa\s*=\s*(\{[\s\S]*?\})\s*;/);
|
||
if (!m) return null;
|
||
var obj = null;
|
||
try { obj = JSON.parse(m[1]); } catch (e) { return null; }
|
||
return obj;
|
||
}
|
||
function fyIsDirect(u) { return /^https?:\/\//i.test(u) && /\.(m3u8|mp4|flv|ts)(\?|$|#)/i.test(u); }
|
||
|
||
/* 核心:破解一集的真实播放地址(对齐第三方可用实现) */
|
||
function fyCrackPlayUrl(playPageUrl) {
|
||
return fyReq(playPageUrl).then(function (html) {
|
||
var pd = fyParsePlayerInfo(html);
|
||
if (!pd) return null;
|
||
var durl = pd.url || "";
|
||
var encrypt = parseInt(pd.encrypt, 10) || 0;
|
||
try {
|
||
if (encrypt === 1) durl = decodeURIComponent(durl);
|
||
else if (encrypt === 2) durl = decodeURIComponent(atob(durl));
|
||
} catch (e) {}
|
||
// 2) 本身就是直链
|
||
if (/^http/i.test(durl) && (durl.indexOf(".m3u8") >= 0 || durl.indexOf(".mp4") >= 0)) return durl;
|
||
// 3) 读 playerconfig.js,按 from 找 parse
|
||
return fyReq(fyBase() + "/static/js/playerconfig.js").then(function (cfg) { return cfg; }, function () { return ""; }).then(function (configJs) {
|
||
var parseApi = "", fromFlag = pd.from || "";
|
||
if (fromFlag && configJs) {
|
||
var re = new RegExp('"' + fromFlag + '":\\{[^}]*"parse":"([^"]+)"');
|
||
var mm = configJs.match(re);
|
||
if (mm) parseApi = mm[1].replace(/\\\//g, "/");
|
||
}
|
||
if (!parseApi && configJs) { var m2 = configJs.match(/"parse":"(http[^"]+)"/); if (m2) parseApi = m2[1].replace(/\\\//g, "/"); }
|
||
if (!parseApi) parseApi = FY_PARSE_FALLBACK;
|
||
// 4) 请求解析页 iframe
|
||
var iframeUrl = parseApi + encodeURIComponent(durl);
|
||
return fyReq(iframeUrl, { headers: { Referer: playPageUrl } }).then(function (iframeHtml) {
|
||
var div = document.createElement("div");
|
||
div.innerHTML = String(iframeHtml).replace(/<script[\s\S]*?<\/script>/gi, function (x) { return x; });
|
||
var pde = div.querySelector("#player-data");
|
||
if (!pde) {
|
||
// 没有 player-data:解析页可能直接就把地址写在里面了
|
||
var direct = fyDeepFindUrl(iframeHtml);
|
||
return direct || iframeUrl;
|
||
}
|
||
var token = pde.getAttribute("data-te") || "";
|
||
var bt = pde.getAttribute("data-bt") || "/player/";
|
||
var apiHost;
|
||
try { apiHost = new URL(parseApi).origin; } catch (e) { apiHost = parseApi.replace(/(https?:\/\/[^\/]+).*/, "$1"); }
|
||
var apiUrl = apiHost + bt + "mplayer.php";
|
||
var postData = "url=" + encodeURIComponent(durl) + "&token=" + encodeURIComponent(token);
|
||
// 5) POST mplayer.php
|
||
return fyReq(apiUrl, {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
|
||
"X-Requested-With": "XMLHttpRequest",
|
||
"Referer": iframeUrl,
|
||
"Origin": apiHost
|
||
},
|
||
body: postData
|
||
}).then(function (apiResText) {
|
||
var apiJson = null;
|
||
try { apiJson = JSON.parse(apiResText); } catch (err) { return null; }
|
||
var realUrl = apiJson.url || (apiJson.data && apiJson.data.url) || "";
|
||
var urlmode = String(apiJson.urlmode || (apiJson.data && apiJson.data.urlmode) || "");
|
||
// 6) 按 urlmode 解密
|
||
if (urlmode === "1") realUrl = fyDecrypt1(realUrl);
|
||
else if (urlmode === "2") realUrl = fyDecrypt2(realUrl);
|
||
else if (urlmode === "3") realUrl = fyDecrypt3(realUrl);
|
||
for (var i = 0; i < 3; i++) {
|
||
if (typeof realUrl === "string" && realUrl.indexOf("WyJ") === 0 && realUrl.indexOf("/") >= 0) realUrl = fyDecrypt3(realUrl);
|
||
else break;
|
||
}
|
||
if (realUrl && (realUrl.indexOf(".m3u8") >= 0 || realUrl.indexOf(".mp4") >= 0)) return realUrl;
|
||
return null;
|
||
}, function () { return null; });
|
||
});
|
||
});
|
||
}).then(null, function () { return null; });
|
||
}
|
||
/* 破解失败时的普通嗅探:video/iframe/m3u8/mp4 兜底 */
|
||
function fyRegexUrl(t) {
|
||
var m = String(t).replace(/\\\//g, "/").match(/https?:\/\/[^\s"'\\<>]+\.(?:m3u8|mp4|flv)[^\s"'\\<>]*/i);
|
||
return m ? m[0] : "";
|
||
}
|
||
function fyDeepFindUrl(text) {
|
||
var t = String(text || ""); if (!t) return "";
|
||
var plain = t.replace(/\\\//g, "/").replace(/\\u002[fF]/g, "/");
|
||
var u = fyRegexUrl(plain); if (u) return u;
|
||
try { u = fyRegexUrl(decodeURIComponent(plain)); if (u) return u; } catch (e) {}
|
||
var cands = plain.match(/[A-Za-z0-9+/=]{24,}/g) || [];
|
||
for (var i = 0; i < cands.length && i < 60; i++) {
|
||
var raw = fyB64Decode(cands[i]); if (!raw) continue;
|
||
var one = fyRegexUrl(raw); if (one) return one;
|
||
try { one = fyRegexUrl(decodeURIComponent(raw)); if (one) return one; } catch (e2) {}
|
||
}
|
||
return "";
|
||
}
|
||
function fySniff(playPageUrl) {
|
||
return fyReq(playPageUrl).then(function (html) {
|
||
var v = String(html).match(/<video[^>]+src="([^"]+)"/i); if (v) return v[1];
|
||
var m3 = String(html).match(/https?:\/\/[^\s"'<>]+\.m3u8[^\s"'<>]*/i); if (m3) return m3[0];
|
||
var mp = String(html).match(/https?:\/\/[^\s"'<>]+\.mp4[^\s"'<>]*/i); if (mp) return mp[0];
|
||
var ifr = String(html).match(/<iframe[^>]+src="([^"]+)"/i); if (ifr) return ifr[1];
|
||
return "";
|
||
}, function () { return ""; });
|
||
}
|
||
/* 对外:解析一集 -> 真实地址(先破解,失败再嗅探) */
|
||
function fyResolve(item) {
|
||
return fyCrackPlayUrl(item._fyPlayPage).then(function (real) {
|
||
if (real) { item.url = real; item._fyReferer = fyBase() + "/"; return real; }
|
||
return fySniff(item._fyPlayPage).then(function (u) {
|
||
if (u) { item.url = u; item._fyReferer = fyBase() + "/"; return u; }
|
||
throw new Error("线路「" + (item.groupName || "") + "」没解析到播放地址");
|
||
});
|
||
});
|
||
}
|
||
|
||
/* 即点即播:拿到 m3u8 直链后交给 App 播放器。
|
||
不同 App 版本暴露的入口不一样,按可用性依次探测,最后退回 fm.pan.play(type=http,
|
||
跟网盘直链走同一条播放通道)。都没有就当浏览器预览,只提示地址。 */
|
||
/* 播放:这个 App 的 fm.play 是【位置参数】签名 —— fm.play(url, title, { referer }),
|
||
不是传一个对象!之前"暂无播放数据"就是因为把对象当第一个参数传进去,url 变成了 undefined。
|
||
这里以位置参数为主,另留几个常见变体做兜底(不同 App 版本命名不同)。 */
|
||
function fyPlayEntries() {
|
||
var out = [];
|
||
if (!window.fm) return out;
|
||
if (typeof fm.play === "function") out.push("fm.play");
|
||
if (typeof fm.playUrl === "function") out.push("fm.playUrl");
|
||
if (fm.player && typeof fm.player.play === "function") out.push("fm.player.play");
|
||
if (fm.video && typeof fm.video.play === "function") out.push("fm.video.play");
|
||
if (fm.pan && typeof fm.pan.play === "function") out.push("fm.pan.play");
|
||
return out;
|
||
}
|
||
function fyPlayDirect(url, title, pic, referer, meta) {
|
||
if (!window.fm) { toast("已解析:" + url); return Promise.resolve(); }
|
||
var ref = referer || fyBase();
|
||
var header = { "User-Agent": FY_UA, "Referer": fyBase() + "/" };
|
||
// 对象式 payload:带剧名、海报、整季选集——支持的 App(如 FongMi 系)会用它渲染标题和"选集"
|
||
var rich = Object.assign({
|
||
url: url, title: title || "枫叶资源", name: (meta && meta.name) || title, pic: pic || "",
|
||
type: "http", referer: ref, header: header
|
||
}, meta || {});
|
||
try {
|
||
// 优先走能吃对象(含 episodes)的入口
|
||
if (fm.player && typeof fm.player.play === "function") { state.fyLastPlayApi = "fm.player.play(obj)"; return Promise.resolve(fm.player.play(rich)); }
|
||
if (fm.video && typeof fm.video.play === "function") { state.fyLastPlayApi = "fm.video.play(obj)"; return Promise.resolve(fm.video.play(rich)); }
|
||
if (typeof fm.playVod === "function") { state.fyLastPlayApi = "fm.playVod(obj)"; return Promise.resolve(fm.playVod(rich)); }
|
||
// 回退:位置参数单集播放(参考实现验证可用),标题仍是剧名+集名,不显示 url
|
||
if (typeof fm.play === "function") { state.fyLastPlayApi = "fm.play"; return Promise.resolve(fm.play(url, title || "枫叶资源", { referer: ref, name: (meta && meta.name) || title, pic: pic || "", header: header })); }
|
||
if (typeof fm.playUrl === "function") { state.fyLastPlayApi = "fm.playUrl"; return Promise.resolve(fm.playUrl(url, title || "枫叶资源", { referer: ref })); }
|
||
if (fm.pan && typeof fm.pan.play === "function") { state.fyLastPlayApi = "fm.pan.play"; return Promise.resolve(fm.pan.play(rich)); }
|
||
} catch (e) { return Promise.reject(e); }
|
||
toast("App 没有可用的播放接口"); return Promise.reject(new Error("no play api"));
|
||
}
|
||
|
||
/* ---------- 枫叶:列表 / 搜索 / 详情 ---------- */
|
||
/* 分类页分页 URL:站点用的是 MacCMS 那套,不同主题路径不一样,
|
||
这里按常见的两种依次试(第 1 页固定 /type/{t}.html) */
|
||
function fyListUrls(t, page) {
|
||
var b = fyBase();
|
||
// 来源标签页:t 形如 "label:qq",第 1 页 /label/{tag}.html,后续 /label/{tag}/page/{N}
|
||
if (String(t).indexOf("label:") === 0) {
|
||
var tag = t.slice(6);
|
||
if (!page || page <= 1) return [b + "/label/" + tag + ".html"];
|
||
return [b + "/label/" + tag + "/page/" + page + ".html", b + "/label/" + tag + "/page/" + page];
|
||
}
|
||
if (!page || page <= 1) return [b + "/type/" + t + ".html"];
|
||
return [b + "/type/" + t + "-" + page + ".html", b + "/show/" + t + "--------" + page + "---.html"];
|
||
}
|
||
function fyFetchList(t, page) {
|
||
var urls = fyListUrls(t, page);
|
||
function tryUrl(i) {
|
||
if (i >= urls.length) return Promise.resolve([]);
|
||
return fyHttpText(urls[i]).then(function (html) {
|
||
var list = fyParseList(html);
|
||
if (list.length) return list;
|
||
return tryUrl(i + 1);
|
||
}, function () { return tryUrl(i + 1); });
|
||
}
|
||
return tryUrl(0);
|
||
}
|
||
function fySearch(kw) {
|
||
var u = fyBase() + "/cupfox-search/-------------.html?wd=" + encodeURIComponent(kw);
|
||
return fyHttpText(u).then(function (html) { return fyParseList(html); }, function () { return []; });
|
||
}
|
||
/* 详情页:枫叶条目自己的播放列表 */
|
||
function fyLoadById(id, token) {
|
||
if (!id) return;
|
||
fyHttpText(fyBase() + "/detail/" + id + ".html").then(function (html) {
|
||
if (token !== state.panToken) return;
|
||
var d = fyParseDetail(html, id);
|
||
if (!d) { renderResources(panSouKeep().concat(zn4kKeep()).concat(qwKeep())); return; }
|
||
if (state.currentDetail && !state.currentDetail.tmdb) {
|
||
if (d.title) { state.currentDetail.rawTitle = d.title; showTitle(d.title); }
|
||
var ov = $("detailOverview"); if (ov && !ov.textContent && !(state.currentDetail && state.currentDetail.tmdb) && d.overview && !isJunkOverview(d.overview)) { ov.textContent = d.overview; state.currentDetail.overviewFromSite = true; }
|
||
if (d.poster && !state.currentDetail.poster && !(state.currentDetail && state.currentDetail.tmdb)) { state.currentDetail.poster = d.poster; setDetailHeroImages([d.poster], false); }
|
||
}
|
||
renderResources(fyResList(d, id).concat(panSouKeep()).concat(zn4kKeep()).concat(qwKeep()));
|
||
}, function () {
|
||
if (token !== state.panToken) return;
|
||
renderResources(panSouKeep().concat(zn4kKeep()).concat(qwKeep()));
|
||
});
|
||
}
|
||
/* 已并入详情页的枫叶播放条目(刷新时保留,同 panSouKeep / zn4kKeep) */
|
||
function fyKeep() { return (state.panItems || []).filter(function (it) { return it._fy; }); }
|
||
/* 盘链/指南来源的条目:按标题去枫叶搜同名,命中就把在线播放线路并进资源列表 */
|
||
/* ============================================================
|
||
蜗牛4K影院(zmi.kdns.fr)集成:MacCMS 站,/voddetail/{id}/、/vodsearch/、/vodshow/ 分页。
|
||
⚠ 它不是在线点播站——详情页给的是 115/夸克 这类【网盘下载链接】(就在静态 HTML 里),
|
||
所以解析出来并进「网盘资源」,走盘链同一套播放/检测/密码逻辑,不需要在线解析。
|
||
同样"探测可达才显示"(该站可能需要代理):首页一行 + 搜索合并 + 详情网盘资源。
|
||
============================================================ */
|
||
var WN_DEFAULT_BASE = "https://zmi.kdns.fr";
|
||
var WN_CAT_SLUG = "__wn__";
|
||
var WN_UA = "Mozilla/5.0 (Linux; Android 10) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Mobile Safari/537.36";
|
||
function wnBase() { return trimSlash(config.wnBase || WN_DEFAULT_BASE); }
|
||
var WN_CATS = [
|
||
{ t: "1", name: "电影" },
|
||
{ t: "2", name: "剧集" },
|
||
{ t: "3", name: "综艺" },
|
||
{ t: "4", name: "动漫" }
|
||
];
|
||
function wnHttpText(url) { return httpTextMemo("wn|" + url, function () { return wnHttpTextRaw(url); }); }
|
||
function wnHttpTextRaw(url) {
|
||
if (state.sdkReady && window.fm && fm.req) {
|
||
return fm.req(url, { method: "GET", responseType: "text", timeout: 20, headers: { "User-Agent": WN_UA, "Referer": wnBase() + "/" } })
|
||
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.body; });
|
||
}
|
||
return fetchTimeout(url, { headers: {} }).then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.text(); });
|
||
}
|
||
function wnParseDoc(html) {
|
||
try { return new DOMParser().parseFromString(String(html).replace(/<script[\s\S]*?<\/script>/gi, "").replace(/<style[\s\S]*?<\/style>/gi, ""), "text/html"); }
|
||
catch (e) { return null; }
|
||
}
|
||
function wnAbs(u) { if (!u) return ""; if (/^https?:/i.test(u)) return u; if (u.indexOf("//") === 0) return "https:" + u; return wnBase() + (u.charAt(0) === "/" ? "" : "/") + u; }
|
||
/* 列表/搜索卡片 -> 影片对象
|
||
2026-07 站点改版为 panlian_dark v2.3 模板:卡片外层直接是 <a class="video-card" href="/voddetail/{id}/">,
|
||
标题/分数/画质分别在 .video-title / .video-score / .video-episode / .video-meta 里;
|
||
旧模板 .module-item 结构不再存在,这里同时兼容新旧两套选择器,任一模板都能解析出来。 */
|
||
function wnParseList(html) {
|
||
var doc = wnParseDoc(html);
|
||
if (!doc) return [];
|
||
var out = [], seen = {};
|
||
Array.from(doc.querySelectorAll(".video-card, .module-item")).forEach(function (b) {
|
||
var a = (b.tagName === "A") ? b : b.querySelector("a[href*='/voddetail/']");
|
||
if (!a) return;
|
||
var m = (a.getAttribute("href") || "").match(/\/voddetail\/(\d+)/);
|
||
if (!m) return;
|
||
var id = m[1]; if (seen[id]) return;
|
||
var img = b.querySelector("img");
|
||
var poster = img ? (img.getAttribute("data-original") || img.getAttribute("data-src") || img.getAttribute("src") || "") : "";
|
||
var tEl = b.querySelector(".video-title, .module-item-title, .module-poster-item-title, .title");
|
||
var title = sanitizeSiteTitle(((tEl ? tEl.textContent : "") || a.getAttribute("title") || (img ? img.getAttribute("alt") : "") || ""));
|
||
if (!title) return;
|
||
var note = b.querySelector(".video-episode, .video-meta, .module-item-note, .module-item-text");
|
||
seen[id] = 1;
|
||
out.push({
|
||
id: "wn_" + id, title: title, poster: wnAbs(poster),
|
||
meta: note ? note.textContent.trim() : "", status: note ? note.textContent.trim() : "",
|
||
_wn: true, _wnId: id
|
||
});
|
||
});
|
||
return out;
|
||
}
|
||
/* 详情页信息 + 网盘链接。蜗牛不是在线点播站——它详情页给的是 115/夸克 这类网盘下载链接,
|
||
所以解析出来并进「网盘资源」,走盘链同一套播放/检测。
|
||
2026-07 站点改版为 panlian_dark v2.3 模板:资源区块变成 .pan-group(组名 .pan-group-title,
|
||
如"百度资源"/"115离线下载")+ .pan-link-item(标题 .pan-link-title,链接在 .pan-link-actions a[href]),
|
||
旧的 .module-row-one 结构不再存在,这里按新结构精确解析,同时保留旧结构 / 通用兜底以防再次改版。 */
|
||
function wnParseDetail(html, id) {
|
||
var doc = wnParseDoc(html);
|
||
if (!doc) return null;
|
||
var h1 = doc.querySelector(".module-info-heading h1, .video-info-header h1, h1");
|
||
var title = h1 ? h1.textContent.trim() : "";
|
||
var pic = doc.querySelector(".premium-poster img, .module-item-pic img, .video-cover img, .module-info-poster img");
|
||
var poster = pic ? (pic.getAttribute("data-original") || pic.getAttribute("data-src") || pic.getAttribute("src") || "") : "";
|
||
var ov = doc.querySelector("#detailDescText, .detail-desc-text, .module-info-introduction-content, .video-info-content, .content");
|
||
var overview = ov ? ov.textContent.replace(/\s+/g, " ").trim() : "";
|
||
// 网盘链接:优先新模板 .pan-group,拿不到再退回旧 .module-row-one,最后是通用 href / data-clipboard-text 兜底
|
||
var pans = [], seen = {};
|
||
function pushPan(url, name) {
|
||
url = String(url || "").trim();
|
||
var mm = url.match(/https?:\/\/[^\s"'<>]+/);
|
||
if (!mm) return; url = mm[0];
|
||
var type = wnDiskType(url); if (!type) return;
|
||
// 密码:?password= / &pwd= / 提取码
|
||
var pwd = "";
|
||
var pm = url.match(/[?&](?:password|pwd|pass|code)=([0-9a-zA-Z]+)/);
|
||
if (pm) pwd = pm[1];
|
||
var key = type + "|" + url.replace(/[?#].*$/, "");
|
||
if (seen[key]) return; seen[key] = 1;
|
||
pans.push({
|
||
type: type, url: url, password: pwd,
|
||
title: (name || "").trim(),
|
||
groupName: (PAN_META[type] ? PAN_META[type].label : type),
|
||
quality: (PAN_META[type] ? PAN_META[type].label : type),
|
||
source: "蜗牛", state: "idle", _wn: true
|
||
});
|
||
}
|
||
Array.from(doc.querySelectorAll(".pan-group")).forEach(function (grp) {
|
||
Array.from(grp.querySelectorAll(".pan-link-item")).forEach(function (item) {
|
||
var nameEl = item.querySelector(".pan-link-title");
|
||
var linkEl = item.querySelector(".pan-link-actions a[href]");
|
||
var url = linkEl ? linkEl.getAttribute("href") : "";
|
||
if (!url) { var cp = item.querySelector("[data-copy]"); url = cp ? cp.getAttribute("data-copy") : ""; }
|
||
if (!url) { var metaEl = item.querySelector(".pan-link-meta"); url = metaEl ? metaEl.textContent : ""; }
|
||
pushPan(url, nameEl ? nameEl.textContent : "");
|
||
});
|
||
});
|
||
if (!pans.length) Array.from(doc.querySelectorAll(".module-row-one, .module-row-info")).forEach(function (row) {
|
||
var a = row.querySelector("[data-clipboard-text]");
|
||
var url = a ? a.getAttribute("data-clipboard-text") : "";
|
||
if (!url) { var ln = row.querySelector(".module-row-title i, a[href*='115.com'], a[href*='pan.']"); url = ln ? (ln.textContent || ln.getAttribute("href")) : ""; }
|
||
var h4 = row.querySelector(".module-row-title h4, h4");
|
||
pushPan(url, h4 ? h4.textContent : ""); // 下载区里的链接:通用类型也收(原盘站会用小众网盘)
|
||
});
|
||
if (!pans.length) {
|
||
// 通用兜底一:直接扫全页 <a href>,按已知网盘域名收,避免站内链接误当资源
|
||
Array.from(doc.querySelectorAll("a[href]")).forEach(function (a) {
|
||
var u = a.getAttribute("href") || "";
|
||
if (!/^https?:\/\//i.test(u)) return;
|
||
if (wnDiskType(u) === "http" || !wnDiskType(u)) return; // 兜底阶段只收明确网盘域名
|
||
var box = a.closest ? (a.closest("li") || a.closest("div")) : null;
|
||
var nameEl = box ? box.querySelector("h3, h4, strong, .title, .name") : null;
|
||
pushPan(u, (nameEl ? nameEl.textContent : "") || a.textContent || "");
|
||
});
|
||
}
|
||
if (!pans.length) {
|
||
// 通用兜底二:旧模板 data-clipboard-text,只认明确的网盘域名
|
||
Array.from(doc.querySelectorAll("[data-clipboard-text]")).forEach(function (a) {
|
||
var u = a.getAttribute("data-clipboard-text") || "";
|
||
var mm = u.match(/https?:\/\/[^\s"'<>]+/); if (!mm) return;
|
||
if (wnDiskType(mm[0]) === "http") return; // 兜底阶段不收通用链接
|
||
pushPan(u, a.getAttribute("title") || "");
|
||
});
|
||
}
|
||
return { title: title, poster: wnAbs(poster), overview: overview, pans: pans };
|
||
}
|
||
/* 网盘类型识别:已知网盘归类;原盘高清这类站还会用光雅盘等小众网盘,
|
||
未知的也保留(归为通用 http),让用户至少能复制到链接,不要直接丢掉。 */
|
||
function wnDiskType(u) {
|
||
for (var i = 0; i < HOSTMAP.length; i++) if (HOSTMAP[i][0].test(u)) return HOSTMAP[i][1];
|
||
if (/guangyapan\.com/i.test(u)) return "guangya";
|
||
if (/magnet:/i.test(u)) return "magnet";
|
||
if (/^https?:/i.test(u)) return "http"; // 其它网盘/直链:保留为通用
|
||
return "";
|
||
}
|
||
function wnFetchList(t, page) {
|
||
var b = wnBase();
|
||
var urls;
|
||
if (!page || page <= 1) {
|
||
urls = [b + "/vodtype/" + t + "/", b + "/vodshow/" + t + "-----------/"];
|
||
} else if (state._wnPageFn) {
|
||
// 已经探到能用的分页写法,直接用它,不再逐个试
|
||
urls = [state._wnPageFn(t, page)];
|
||
} else {
|
||
urls = [
|
||
b + "/vodtype/" + t + "-" + page + "/",
|
||
b + "/vodshow/" + t + "--------" + page + "---/",
|
||
b + "/vodshow/" + t + "-----" + page + "------/",
|
||
b + "/vodshow/" + t + "-" + page + "----------/",
|
||
b + "/vodshow/" + t + "-------" + page + "----/",
|
||
b + "/vodtype/" + t + "/page/" + page + "/",
|
||
b + "/vodtype/" + t + ".html?page=" + page
|
||
];
|
||
}
|
||
// 记住每个候选对应的"生成函数",命中后固化,后续页直接套用
|
||
var gens = [
|
||
function (tt, p) { return b + "/vodtype/" + tt + "-" + p + "/"; },
|
||
function (tt, p) { return b + "/vodshow/" + tt + "--------" + p + "---/"; },
|
||
function (tt, p) { return b + "/vodshow/" + tt + "-----" + p + "------/"; },
|
||
function (tt, p) { return b + "/vodshow/" + tt + "-" + p + "----------/"; },
|
||
function (tt, p) { return b + "/vodshow/" + tt + "-------" + p + "----/"; },
|
||
function (tt, p) { return b + "/vodtype/" + tt + "/page/" + p + "/"; },
|
||
function (tt, p) { return b + "/vodtype/" + tt + ".html?page=" + p; }
|
||
];
|
||
function tryUrl(i) {
|
||
if (i >= urls.length) return Promise.resolve([]);
|
||
return wnHttpText(urls[i]).then(function (html) {
|
||
var list = wnParseList(html);
|
||
if (list.length) {
|
||
if (page > 1 && !state._wnPageFn && urls.length > 1) state._wnPageFn = gens[i];
|
||
return list;
|
||
}
|
||
return tryUrl(i + 1);
|
||
}, function () { return tryUrl(i + 1); });
|
||
}
|
||
return tryUrl(0);
|
||
}
|
||
function wnSearch(kw) {
|
||
// MacCMS 搜索:/vodsearch/{wd}-------------/
|
||
var u = wnBase() + "/vodsearch/" + encodeURIComponent(kw) + "-------------/";
|
||
return wnHttpText(u).then(function (html) { return wnParseList(html); }, function () { return []; });
|
||
}
|
||
/* 已并入详情页的蜗牛网盘条目(刷新保留) */
|
||
function wnKeep() { return (state.panItems || []).filter(function (it) { return it._wn; }); }
|
||
/* 详情页:蜗牛条目——信息补全 + 把它的网盘链接并进「网盘资源」 */
|
||
function wnLoadById(id, token) {
|
||
wnHttpText(wnBase() + "/voddetail/" + id + "/").then(function (html) {
|
||
if (token !== state.panToken) return;
|
||
var d = wnParseDetail(html, id);
|
||
if (d && state.currentDetail && !state.currentDetail.tmdb) {
|
||
if (d.title) { state.currentDetail.rawTitle = d.title; showTitle(d.title); }
|
||
var ovv = $("detailOverview"); if (ovv && !ovv.textContent && !(state.currentDetail && state.currentDetail.tmdb) && d.overview && !isJunkOverview(d.overview)) { ovv.textContent = d.overview; state.currentDetail.overviewFromSite = true; }
|
||
if (d.poster && !state.currentDetail.poster && !(state.currentDetail && state.currentDetail.tmdb)) { state.currentDetail.poster = d.poster; setDetailHeroImages([d.poster], false); }
|
||
}
|
||
var pans = (d && d.pans) || [];
|
||
renderResources(sortPanDefault(pans).concat(fyKeep()).concat(panSouKeep()).concat(zn4kKeep()).concat(qwKeep()));
|
||
}, function () { if (token === state.panToken) renderResources(fyKeep().concat(panSouKeep()).concat(zn4kKeep()).concat(qwKeep())); });
|
||
}
|
||
/* 盘链/其它来源条目:按标题去蜗牛搜同名,把它的网盘链接并进「网盘资源」 */
|
||
function wnEnrich(rawTitle, token) {
|
||
wnEnrichDo(rawTitle, token);
|
||
}
|
||
function wnEnrichDo(rawTitle, token) {
|
||
var kw = cleanTitle(rawTitle);
|
||
if (!kw) return;
|
||
var year = yearOf(rawTitle);
|
||
wnSearch(kw).then(function (list) {
|
||
if (token !== state.panToken || !list.length) return;
|
||
var best = null, bestScore = 99;
|
||
list.forEach(function (it) {
|
||
var ts = titleScore(it.title, kw); if (ts > 3) return;
|
||
var s = ts; if (year && (it.title || "").indexOf(year) >= 0) s -= 0.3;
|
||
if (s < bestScore) { bestScore = s; best = it; }
|
||
});
|
||
if (!best) return;
|
||
wnHttpText(wnBase() + "/voddetail/" + best._wnId + "/").then(function (html) {
|
||
if (token !== state.panToken) return;
|
||
var d = wnParseDetail(html, best._wnId);
|
||
var pans = (d && d.pans) || [];
|
||
if (!pans.length) return;
|
||
var have = {};
|
||
state.panItems.forEach(function (it) { have[it.type + "|" + normPanUrl(it.url || it.token)] = 1; });
|
||
var add = pans.filter(function (p) { return !have[p.type + "|" + normPanUrl(p.url)]; });
|
||
if (add.length) renderResources(state.panItems.concat(add));
|
||
}, function () {}); // 蜗牛4K 不进反查诊断(诊断只显示 pomo)
|
||
}, function () {});
|
||
}
|
||
/* 首页「蜗牛4K」一行:探测可达才显示 */
|
||
/* 元素滚动到快可见时才执行 fn(懒加载)。用于首页靠下的网盘站行——
|
||
一进主页就并发抓十几个站会挤爆带宽、拖慢首屏,这里改成滑到附近才请求。 */
|
||
function lazyWhenVisible(el, fn) {
|
||
if (!el || typeof IntersectionObserver === "undefined") { fn(); return; }
|
||
var done = false;
|
||
var io = new IntersectionObserver(function (entries) {
|
||
for (var i = 0; i < entries.length; i++) {
|
||
if (entries[i].isIntersecting && !done) { done = true; io.disconnect(); fn(); return; }
|
||
}
|
||
}, { rootMargin: "600px 0px" }); // 提前 600px 预加载,滑到时已就绪
|
||
io.observe(el);
|
||
}
|
||
function renderWnRow(box) {
|
||
var shell = makeRowShell("蜗牛", function () { openWnCategory("1", "蜗牛"); });
|
||
box.appendChild(shell.section);
|
||
var cached = state.wnCache["1"];
|
||
var hasCache = !!(cached && cached.length);
|
||
if (hasCache) fillRow(shell.row, cached.slice(0, 12), function (it) { openDetail(it); });
|
||
else shell.row.replaceChildren(el("div", "mh-row-empty", "加载中…"));
|
||
var load = function () {
|
||
if (!document.body.contains(shell.row)) return null;
|
||
return wnFetchList("1", 1).then(function (list) {
|
||
if (!document.body.contains(shell.row)) return;
|
||
if (!list.length) { if (!hasCache) shell.row.replaceChildren(el("div", "mh-row-empty", "暂无数据")); return; }
|
||
state.wnCache["1"] = list; saveHomeDisk();
|
||
fillRow(shell.row, list.slice(0, 12), function (it) { openDetail(it); });
|
||
}, function () { if (!hasCache) shell.row.replaceChildren(el("div", "mh-row-empty", "加载失败")); });
|
||
};
|
||
if (hasCache) { if (homeCacheStale()) homePrefetch(load); return; }
|
||
rowLoad(shell.section, load);
|
||
}
|
||
function openWnCategory(t, name) {
|
||
saveHomeReturn();
|
||
state.view = "category";
|
||
$("homeView").style.display = "none"; $("catView").style.display = "";
|
||
$("catTitle").textContent = name || "蜗牛";
|
||
$("clearSearch").style.display = "none"; $("clearRecent").style.display = "none";
|
||
setNav(null); updateHeroVisible(); window.scrollTo(0, 0);
|
||
navigate("category");
|
||
state.searchMode = false; state.searchKw = "";
|
||
state.activeCat = WN_CAT_SLUG; state.wnCat = t || "1"; state.activeGenre = "";
|
||
$("genreChips").style.display = ""; renderWnGenreChips();
|
||
wnLoadCat(true);
|
||
}
|
||
function renderWnGenreChips() {
|
||
var box = $("genreChips"); if (!box) return;
|
||
var frag = document.createDocumentFragment();
|
||
WN_CATS.forEach(function (c, i) {
|
||
var b = document.createElement("button");
|
||
b.type = "button";
|
||
b.className = "genre-chip focusable" + (state.wnCat === c.t ? " active" : "");
|
||
b.textContent = c.name; b.setAttribute("data-genre", c.name);
|
||
b.setAttribute("data-focus-row", "genreChips"); b.setAttribute("data-focus-index", String(i));
|
||
b.onclick = (function (cc) { return function () { state.wnCat = cc.t; renderWnGenreChips(); window.scrollTo(0, 0); wnLoadCat(true); }; })(c);
|
||
frag.appendChild(b);
|
||
});
|
||
box.replaceChildren(frag);
|
||
}
|
||
function wnLoadCat(reset) {
|
||
var t = state.wnCat || "1";
|
||
if (reset) {
|
||
state.reqToken++;
|
||
state.wnPage = 1; state.source = []; state.rendered = 0; state.noMore = false; state.loading = false; state.autoPages = 0;
|
||
$("grid").replaceChildren(); $("grid").classList.remove("grid-recent"); $("browseNotice").replaceChildren();
|
||
var c = state.wnCache[t];
|
||
if (c && c.length) { wnPaintCat(c, state.reqToken, 1); return; }
|
||
}
|
||
if (state.loading || state.noMore) return;
|
||
var token = state.reqToken, page = reset ? 1 : (state.wnPage + 1);
|
||
state.loading = true; $("browseLoading").style.display = "block"; if (reset) $("browseSub").textContent = "加载中";
|
||
wnFetchList(t, page).then(function (list) {
|
||
state.loading = false;
|
||
if (token !== state.reqToken) return;
|
||
$("browseLoading").style.display = "none";
|
||
if (page === 1) state.wnCache[t] = list;
|
||
wnPaintCat(list, token, page);
|
||
}, function () {
|
||
state.loading = false;
|
||
if (token !== state.reqToken) return;
|
||
$("browseLoading").style.display = "none"; state.noMore = true;
|
||
if (!state.source.length) $("grid").replaceChildren(el("div", "mh-row-empty", "加载失败,返回重试"));
|
||
});
|
||
}
|
||
function wnPaintCat(list, token, page) {
|
||
if (token !== state.reqToken) return;
|
||
$("browseLoading").style.display = "none";
|
||
var have = {}; state.source.forEach(function (s) { have[s.id] = 1; });
|
||
var fresh = (list || []).filter(function (it) { return !have[it.id]; });
|
||
if (!fresh.length) { state.noMore = true; if (!state.source.length) { $("grid").replaceChildren(el("div", "mh-row-empty", "该分类暂无内容")); $("browseSub").textContent = ""; } return; }
|
||
state.wnPage = page; state.source = state.source.concat(fresh); appendGrid();
|
||
$("browseSub").textContent = state.source.length + " 部";
|
||
}
|
||
|
||
/* ============================================================
|
||
森中有林 / SeedHub(sidhub.cc)集成:影视&动漫网盘分享站,跟蜗牛一个路子(网盘资源站,
|
||
不在线播放)。区别在网盘链接是两跳——详情页 .pan-links 里给的是中转页
|
||
/link_start/?redirect_to=pan_id_xxx&movie_title=... ,要请求它才拿到真实网盘分享链接。
|
||
详情 /movies/{id}/;卡片 ul.pictures>li>a[href*='/movies/']。首页一行 + 搜索合并 + 详情网盘资源。
|
||
============================================================ */
|
||
var SH_DEFAULT_BASE = "https://sidhub.cc";
|
||
var SH_CAT_SLUG = "__sh__";
|
||
var SH_UA = "Mozilla/5.0 (Linux; Android 10) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Mobile Safari/537.36";
|
||
function shBase() { return trimSlash(config.shBase || SH_DEFAULT_BASE); }
|
||
/* SeedHub 分类:/categories/{id}/movies/ */
|
||
var SH_CATS = [
|
||
{ id: "1", name: "电影" },
|
||
{ id: "3", name: "剧集" },
|
||
{ id: "2", name: "动漫" }
|
||
];
|
||
function shHttpText(url) { return httpTextMemo("sh|" + url, function () { return shHttpTextRaw(url); }); }
|
||
function shHttpTextRaw(url) {
|
||
if (state.sdkReady && window.fm && fm.req) {
|
||
return fm.req(url, { method: "GET", responseType: "text", timeout: 20, headers: { "User-Agent": SH_UA, "Referer": shBase() + "/" } })
|
||
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.body; });
|
||
}
|
||
return fetchTimeout(url, { headers: {} }).then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.text(); });
|
||
}
|
||
function shParseDoc(html) {
|
||
try { return new DOMParser().parseFromString(String(html).replace(/<script[\s\S]*?<\/script>/gi, "").replace(/<style[\s\S]*?<\/style>/gi, ""), "text/html"); }
|
||
catch (e) { return null; }
|
||
}
|
||
function shAbs(u) { if (!u) return ""; if (/^https?:/i.test(u)) return u; if (u.indexOf("//") === 0) return "https:" + u; return shBase() + (u.charAt(0) === "/" ? "" : "/") + u; }
|
||
/* 列表/搜索/推荐卡片 -> 影片对象。卡片是 ul.pictures>li>a[href*='/movies/'] + img + 标题 */
|
||
function shParseList(html) {
|
||
var doc = shParseDoc(html);
|
||
if (!doc) return [];
|
||
var out = [], seen = {};
|
||
Array.from(doc.querySelectorAll("a[href*='/movies/']")).forEach(function (a) {
|
||
var m = (a.getAttribute("href") || "").match(/\/movies\/(\d+)\//);
|
||
if (!m) return;
|
||
var id = m[1]; if (seen[id]) return;
|
||
// 卡片里要有图才算(排除面包屑/锚点)
|
||
var scope = a.closest("li") || a.parentElement || a;
|
||
var img = a.querySelector("img") || (scope ? scope.querySelector("img") : null);
|
||
if (!img) return;
|
||
var poster = img.getAttribute("data-original") || img.getAttribute("data-src") || img.getAttribute("src") || "";
|
||
var title = sanitizeSiteTitle((a.getAttribute("title") || img.getAttribute("alt") || ""));
|
||
if (!title) { var td = scope ? scope.querySelector("div a, .title, h2, h3") : null; title = td ? td.textContent.trim() : ""; }
|
||
title = sanitizeSiteTitle(title.replace(/\s*-\s*SeedHub.*$/i, ""));
|
||
if (!title) return;
|
||
seen[id] = 1;
|
||
out.push({ id: "sh_" + id, title: title, poster: shAbs(poster), meta: "", _sh: true, _shId: id });
|
||
});
|
||
return out;
|
||
}
|
||
/* 详情页 -> 信息 + 网盘中转链接(.pan-links)。真实链接需再请求 /link_start 解析。 */
|
||
function shParseDetail(html, id) {
|
||
var doc = shParseDoc(html);
|
||
if (!doc) return null;
|
||
var mt = doc.querySelector("meta[name='description']");
|
||
var overview = mt ? (mt.getAttribute("content") || "") : "";
|
||
var h1 = doc.querySelector("h1");
|
||
var title = (h1 ? h1.textContent : "").replace(/\s*-\s*SeedHub.*$/i, "").trim();
|
||
var cover = doc.querySelector("meta[property='og:image']");
|
||
var poster = cover ? cover.getAttribute("content") : "";
|
||
if (!poster) { var im = doc.querySelector(".cover img, #cover img, .pictures img"); poster = im ? (im.getAttribute("src") || "") : ""; }
|
||
var links = [], perType = {}, seenHref = {};
|
||
function addLink(href, type, label) {
|
||
if (!href || seenHref[href]) return;
|
||
perType[type] = (perType[type] || 0);
|
||
if (perType[type] >= 20) return; // 每种网盘最多 20 条,够用又不至于几百条卡死
|
||
perType[type]++; seenHref[href] = 1;
|
||
links.push({ transit: shAbs(href), type: type, label: label });
|
||
}
|
||
// 网盘分享链接(夸克/百度/UC/迅雷等,走 /link_start?redirect_to=pan_id_xxx 中转)
|
||
Array.from(doc.querySelectorAll("ul.pan-links li a[href*='link_start']")).forEach(function (a) {
|
||
var href = a.getAttribute("href") || "";
|
||
var dl = a.getAttribute("data-link") || "";
|
||
var type = shDiskType(dl) || shDiskTypeFromText(a.getAttribute("title") || "") || "http";
|
||
addLink(href, type, (a.getAttribute("title") || a.textContent || "").trim());
|
||
});
|
||
// 磁力/BT 种子(.seeds 区块,走 /link_start?seed_id=xxx 中转到 magnet)
|
||
Array.from(doc.querySelectorAll("ul.seeds li a[href*='link_start'], ul.seeds li a[href^='magnet']")).forEach(function (a) {
|
||
var href = a.getAttribute("href") || "";
|
||
addLink(href, "magnet", (a.getAttribute("title") || a.textContent || "").trim());
|
||
});
|
||
return { title: title, poster: shAbs(poster), overview: overview, links: links };
|
||
}
|
||
function shDiskType(dl) {
|
||
var s = String(dl || "");
|
||
if (/quark/i.test(s)) return "quark";
|
||
if (/baidu/i.test(s)) return "baidu";
|
||
if (/uc\.cn/i.test(s)) return "uc";
|
||
if (/xunlei/i.test(s)) return "xunlei";
|
||
if (/aliyun|alipan/i.test(s)) return "aliyun";
|
||
if (/189/i.test(s)) return "tianyi";
|
||
if (/123/i.test(s)) return "123";
|
||
if (/115/i.test(s)) return "115";
|
||
if (/139|caiyun/i.test(s)) return "mobile";
|
||
if (/guangya/i.test(s)) return "guangya";
|
||
return "";
|
||
}
|
||
function shDiskTypeFromText(t) {
|
||
t = String(t || "");
|
||
if (/夸克/.test(t)) return "quark"; if (/百度/.test(t)) return "baidu"; if (/迅雷/.test(t)) return "xunlei";
|
||
if (/UC/i.test(t)) return "uc"; if (/阿里/.test(t)) return "aliyun"; if (/天翼/.test(t)) return "tianyi";
|
||
if (/夸克/.test(t)) return "quark"; return "";
|
||
}
|
||
/* 请求中转页拿真实网盘链接。SeedHub 中转页结构(已按真实页面对齐):
|
||
真实链接在 <a class="direct-pan" href="...">;提取码在 .tqm-value(百度)/.tqmxl-value(迅雷),
|
||
UC/夸克通常免提取码这些 span 为空。返回 { url, password }。 */
|
||
function shResolveTransit(transitUrl) {
|
||
return shHttpText(transitUrl).then(function (html) {
|
||
var doc = shParseDoc(html);
|
||
var url = "", pwd = "";
|
||
if (doc) {
|
||
var a = doc.querySelector("a.direct-pan[href], a.direct-link[href], a[href^='magnet:'], a[href*='uc.cn'], a[href*='quark'], a[href*='baidu']");
|
||
if (a) url = a.getAttribute("href") || "";
|
||
var pv = doc.querySelector(".tqm-value, .tqmxl-value, .tqm3-value");
|
||
if (pv) { var t = (pv.textContent || "").trim(); if (/^[0-9a-zA-Z]{2,8}$/.test(t)) pwd = t; }
|
||
}
|
||
if (!url) { var mg = String(html).match(/magnet:\?xt=urn:[a-z0-9:]+[^\s"'<>]*/i); if (mg) url = mg[0]; }
|
||
if (!url) url = shFindPanUrl(html); // 兜底:正则扫明链
|
||
if (!url) {
|
||
var mm = String(html).replace(/\\\//g, "/").match(/(?:location\.href\s*=\s*['"]|url=|href=['"])(https?:\/\/[^\s"'<>]+)/i);
|
||
if (mm && shDiskTypeFromUrl(mm[1])) url = mm[1];
|
||
}
|
||
if (!pwd && url) { var pm = url.match(/[?&](?:pwd|password|code)=([0-9a-zA-Z]+)/); if (pm) pwd = pm[1]; }
|
||
return url ? { url: url, password: pwd } : "";
|
||
}, function () { return ""; });
|
||
}
|
||
function shDiskTypeFromUrl(u) {
|
||
if (/^magnet:/i.test(u)) return "magnet";
|
||
for (var i = 0; i < HOSTMAP.length; i++) if (HOSTMAP[i][0].test(u)) return HOSTMAP[i][1];
|
||
if (/guangyapan\.com/i.test(u)) return "guangya";
|
||
return "";
|
||
}
|
||
function shFindPanUrl(html) {
|
||
var m = String(html).replace(/\\\//g, "/").match(/https?:\/\/(?:pan\.quark\.cn|pan\.baidu\.com|drive\.uc\.cn|fast\.uc\.cn|pan\.xunlei\.com|(?:www\.)?alipan\.com|aliyundrive\.com|cloud\.189\.cn|123[0-9a-z]*\.com|115\.com|caiyun\.139\.com|yun\.139\.com|guangyapan\.com)\/[^\s"'<>]+/i);
|
||
return m ? m[0] : "";
|
||
}
|
||
function shListUrls(cat, page) {
|
||
var b = shBase(), c = cat || "1";
|
||
if (!page || page <= 1) return [b + "/categories/" + c + "/movies/"];
|
||
return [b + "/categories/" + c + "/movies/?page=" + page];
|
||
}
|
||
function shFetchList(cat, page) {
|
||
var urls = shListUrls(cat, page);
|
||
function tryUrl(i) {
|
||
if (i >= urls.length) return Promise.resolve([]);
|
||
return shHttpText(urls[i]).then(function (html) { var list = shParseList(html); return list.length ? list : tryUrl(i + 1); }, function () { return tryUrl(i + 1); });
|
||
}
|
||
return tryUrl(0);
|
||
}
|
||
function shSearch(kw) {
|
||
var b = shBase();
|
||
var urls = [b + "/?s=" + encodeURIComponent(kw), b + "/search/" + encodeURIComponent(kw) + "/", b + "/search/?q=" + encodeURIComponent(kw)];
|
||
function tryUrl(i) {
|
||
if (i >= urls.length) return Promise.resolve([]);
|
||
return shHttpText(urls[i]).then(function (html) { var list = shParseList(html); return list.length ? list : tryUrl(i + 1); }, function () { return tryUrl(i + 1); });
|
||
}
|
||
return tryUrl(0);
|
||
}
|
||
function shKeep() { return (state.panItems || []).filter(function (it) { return it._sh; }); }
|
||
/* 把 SeedHub 详情的中转链接解析成真实网盘链接,并进「网盘资源」。
|
||
并发解析(一次最多 6 个),每解出一条就渐进渲染进去,不必等全部解完——快且有反馈。 */
|
||
function shResolveAndMerge(links, token, resetBase) {
|
||
var pending = (links || []).slice();
|
||
var CONC = 8, active = 0;
|
||
function haveKey(p) { return p.type + "|" + normPanUrl(p.url); }
|
||
function mergeOne(p) {
|
||
if (token !== state.panToken) return;
|
||
var have = {};
|
||
state.panItems.forEach(function (it) { have[it.type + "|" + normPanUrl(it.url || it.token)] = 1; });
|
||
if (have[haveKey(p)]) return;
|
||
renderResources(state.panItems.concat([p])); // 渐进:解出一条就并进去
|
||
}
|
||
function pump() {
|
||
if (token !== state.panToken) return;
|
||
while (active < CONC && pending.length) {
|
||
var lk = pending.shift(); active++;
|
||
shResolveTransit(lk.transit).then(function (cap) {
|
||
return function (real) {
|
||
active--;
|
||
if (token !== state.panToken) return;
|
||
var realUrl = real && real.url ? real.url : "";
|
||
if (realUrl) {
|
||
var type = shDiskTypeFromUrl(realUrl) || cap.type || "http";
|
||
var pwd = (real.password || "") || (realUrl.match(/[?&](?:pwd|password|code)=([0-9a-zA-Z]+)/) || [])[1] || "";
|
||
mergeOne({
|
||
type: type, url: realUrl, password: pwd, title: cap.label || "",
|
||
groupName: (PAN_META[type] ? PAN_META[type].label : type), quality: (PAN_META[type] ? PAN_META[type].label : type),
|
||
source: "SeedHub", state: "idle", _sh: true
|
||
});
|
||
}
|
||
pump();
|
||
};
|
||
}(lk), function () { active--; pump(); });
|
||
}
|
||
}
|
||
pump();
|
||
}
|
||
function shLoadById(id, token) {
|
||
shHttpText(shBase() + "/movies/" + id + "/").then(function (html) {
|
||
if (token !== state.panToken) return;
|
||
var d = shParseDetail(html, id);
|
||
if (d && state.currentDetail && !state.currentDetail.tmdb) {
|
||
if (d.title) { state.currentDetail.rawTitle = d.title; showTitle(d.title); }
|
||
var ovv = $("detailOverview"); if (ovv && !ovv.textContent && !(state.currentDetail && state.currentDetail.tmdb) && d.overview && !isJunkOverview(d.overview)) { ovv.textContent = d.overview; state.currentDetail.overviewFromSite = true; }
|
||
if (d.poster && !state.currentDetail.poster && !(state.currentDetail && state.currentDetail.tmdb)) { state.currentDetail.poster = d.poster; setDetailHeroImages([d.poster], false); }
|
||
}
|
||
var links = (d && d.links) || [];
|
||
if (!links.length) { renderResources(fyKeep().concat(panSouKeep()).concat(zn4kKeep()).concat(qwKeep())); return; }
|
||
renderResources(fyKeep().concat(panSouKeep()).concat(zn4kKeep()).concat(qwKeep())); // 先出兜底,再逐条解析中转链并入
|
||
shResolveAndMerge(links, token, true);
|
||
}, function () { if (token === state.panToken) renderResources(fyKeep().concat(panSouKeep()).concat(zn4kKeep()).concat(qwKeep())); });
|
||
}
|
||
/* 其它来源条目:按标题去 SeedHub 搜同名,把它的网盘链接并进「网盘资源」 */
|
||
function shEnrich(rawTitle, token) {
|
||
var kw = cleanTitle(rawTitle); if (!kw) return;
|
||
var year = yearOf(rawTitle);
|
||
shSearch(kw).then(function (list) {
|
||
if (token !== state.panToken || !list.length) return;
|
||
var best = null, bestScore = 99;
|
||
list.forEach(function (it) { var ts = titleScore(it.title, kw); if (ts > 3) return; var sc = ts; if (year && (it.title || "").indexOf(year) >= 0) sc -= 0.3; if (sc < bestScore) { bestScore = sc; best = it; } });
|
||
if (!best) return;
|
||
shHttpText(shBase() + "/movies/" + best._shId + "/").then(function (html) {
|
||
if (token !== state.panToken) return;
|
||
var d = shParseDetail(html, best._shId);
|
||
if (d && d.links && d.links.length) shResolveAndMerge(d.links, token, false);
|
||
}, function () {});
|
||
}, function () {});
|
||
}
|
||
/* 首页「SeedHub」一行 */
|
||
function renderShRow(box) {
|
||
var shell = makeRowShell("SeedHub", function () { openShCategory("1", "SeedHub"); });
|
||
box.appendChild(shell.section);
|
||
var cached = state.shCache["1"];
|
||
var hasCache = !!(cached && cached.length);
|
||
if (hasCache) fillRow(shell.row, cached.slice(0, 12), function (it) { openDetail(it); });
|
||
else shell.row.replaceChildren(el("div", "mh-row-empty", "加载中…"));
|
||
var load = function () {
|
||
if (!document.body.contains(shell.row)) return null;
|
||
return shFetchList("1", 1).then(function (list) {
|
||
if (!document.body.contains(shell.row)) return;
|
||
if (!list.length) { if (!hasCache) shell.row.replaceChildren(el("div", "mh-row-empty", "暂无数据")); return; }
|
||
state.shCache["1"] = list; saveHomeDisk();
|
||
fillRow(shell.row, list.slice(0, 12), function (it) { openDetail(it); });
|
||
}, function () { if (!hasCache) shell.row.replaceChildren(el("div", "mh-row-empty", "加载失败")); });
|
||
};
|
||
if (hasCache) { if (homeCacheStale()) homePrefetch(load); return; }
|
||
rowLoad(shell.section, load);
|
||
}
|
||
function openShCategory(cat, name) {
|
||
saveHomeReturn();
|
||
state.view = "category";
|
||
$("homeView").style.display = "none"; $("catView").style.display = "";
|
||
$("catTitle").textContent = name || "SeedHub";
|
||
$("clearSearch").style.display = "none"; $("clearRecent").style.display = "none";
|
||
setNav(null); updateHeroVisible(); window.scrollTo(0, 0);
|
||
navigate("category");
|
||
state.searchMode = false; state.searchKw = "";
|
||
state.activeCat = SH_CAT_SLUG; state.shCat = cat || "1"; state.activeGenre = "";
|
||
$("genreChips").style.display = ""; renderShGenreChips();
|
||
shLoadCat(true);
|
||
}
|
||
function renderShGenreChips() {
|
||
var box = $("genreChips"); if (!box) return;
|
||
var frag = document.createDocumentFragment();
|
||
SH_CATS.forEach(function (c, i) {
|
||
var b = document.createElement("button");
|
||
b.type = "button";
|
||
b.className = "genre-chip focusable" + (state.shCat === c.id ? " active" : "");
|
||
b.textContent = c.name; b.setAttribute("data-genre", c.name);
|
||
b.setAttribute("data-focus-row", "genreChips"); b.setAttribute("data-focus-index", String(i));
|
||
b.onclick = (function (cc) { return function () { state.shCat = cc.id; renderShGenreChips(); window.scrollTo(0, 0); shLoadCat(true); }; })(c);
|
||
frag.appendChild(b);
|
||
});
|
||
box.replaceChildren(frag);
|
||
}
|
||
function shLoadCat(reset) {
|
||
var cat = state.shCat || "1";
|
||
if (reset) {
|
||
state.reqToken++;
|
||
state.shPage = 1; state.source = []; state.rendered = 0; state.noMore = false; state.loading = false; state.autoPages = 0;
|
||
$("grid").replaceChildren(); $("grid").classList.remove("grid-recent"); $("browseNotice").replaceChildren();
|
||
var c = state.shCache[cat];
|
||
if (c && c.length) { shPaintCat(c, state.reqToken, 1); return; }
|
||
}
|
||
if (state.loading || state.noMore) return;
|
||
var token = state.reqToken, page = reset ? 1 : (state.shPage + 1);
|
||
state.loading = true; $("browseLoading").style.display = "block"; if (reset) $("browseSub").textContent = "加载中";
|
||
shFetchList(cat, page).then(function (list) {
|
||
state.loading = false;
|
||
if (token !== state.reqToken) return;
|
||
$("browseLoading").style.display = "none";
|
||
if (page === 1) state.shCache[cat] = list;
|
||
shPaintCat(list, token, page);
|
||
}, function () {
|
||
state.loading = false;
|
||
if (token !== state.reqToken) return;
|
||
$("browseLoading").style.display = "none"; state.noMore = true;
|
||
if (!state.source.length) $("grid").replaceChildren(el("div", "mh-row-empty", "加载失败,返回重试"));
|
||
});
|
||
}
|
||
function shPaintCat(list, token, page) {
|
||
if (token !== state.reqToken) return;
|
||
$("browseLoading").style.display = "none";
|
||
var have = {}; state.source.forEach(function (s) { have[s.id] = 1; });
|
||
var fresh = (list || []).filter(function (it) { return !have[it.id]; });
|
||
if (!fresh.length) { state.noMore = true; if (!state.source.length) { $("grid").replaceChildren(el("div", "mh-row-empty", "该分类暂无内容")); $("browseSub").textContent = ""; } return; }
|
||
state.shPage = page; state.source = state.source.concat(fresh); appendGrid();
|
||
$("browseSub").textContent = state.source.length + " 部";
|
||
}
|
||
|
||
function fyEnrich(rawTitle, token) {
|
||
fyEnrichDo(rawTitle, token);
|
||
}
|
||
|
||
/* ============================================================
|
||
玩偶 / 木偶(MacCMS 网盘明链站,结构同原盘高清)。两站结构完全一致,
|
||
只是域名/分类不同,用一个通用构造器生成,避免重复代码。
|
||
详情页网盘链接是明链(整页正则扫出夸克/百度/UC/迅雷/阿里/115/123/天翼/移动/磁力)。
|
||
============================================================ */
|
||
var WO_SITES = {
|
||
wo: {
|
||
key: "wo", name: "玩偶", slug: "__wo__",
|
||
domains: ["https://wogg.xxooo.cf", "https://www.wogg.net"],
|
||
categoryUrl: "/vodshow/{cat}--------{page}---.html", // 玩偶专用伪静态
|
||
// 玩偶站的搜索地址是已验证的固定格式(同 wogg 里的配置):直接给出,woSearch 就会走
|
||
// "own" 分支只打这一个地址,不用先猜 ajax/suggest 接口在不在、也不用把 6 种通用格式
|
||
// 挨个试一遍(那样跨 2 个域名 × 6 种格式,最坏情况会拖到超过 22 秒的搜索预算,
|
||
// 表现就是"搜了但一直不出玩偶结果")。
|
||
searchUrls: ["/vodsearch/-------------.html?wd={kw}&page=1"],
|
||
cats: [["44", "臻彩"], ["1", "电影"], ["2", "电视剧"], ["3", "动漫"], ["4", "综艺"], ["6", "短剧"], ["46", "纪录片"]]
|
||
},
|
||
mo: {
|
||
key: "mo", name: "木偶", slug: "__mo__",
|
||
domains: ["https://666.666291.xyz"],
|
||
// 木偶没有专用 categoryUrl,走下面的通用候选(MacCMS10 index.php 优先)
|
||
cats: [["25", "臻选"], ["1", "电影"], ["2", "电视剧"], ["3", "动漫"], ["4", "纪录片"], ["29", "综艺"], ["30", "原盘"]]
|
||
},
|
||
pm: {
|
||
key: "pm", name: "pomo", slug: "__pm__",
|
||
domains: ["https://pomo.mom"],
|
||
categoryUrl: function (cat, page) { // 首页无后缀,翻页加 /page/N
|
||
var base = cat ? ("/" + cat) : "";
|
||
return page > 1 ? [base + "/page/" + page] : [base + "/", base || "/"];
|
||
},
|
||
searchUrls: ["/?keyword={kw}"],
|
||
cats: [["", "最新"], ["huayurm", "华语热门"], ["jiating", "家庭影院"],
|
||
["donghuadadiany", "动画电影"], ["lengmenjiapian", "冷门佳片"],
|
||
["paihangbang", "TOP250"], ["dianshiju", "电视剧"]]
|
||
}
|
||
};
|
||
/* 所有网盘明链站,按定义顺序 */
|
||
function woAll() { var a = [], k; for (k in WO_SITES) if (WO_SITES.hasOwnProperty(k)) a.push(WO_SITES[k]); return a; }
|
||
var WO_UA = "Mozilla/5.0 (Linux; Android 10) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Mobile Safari/537.36";
|
||
var WO_PAN_RE = /https?:\/\/(?:[\w.-]*\.)?(?:pan\.baidu\.com|pan\.quark\.cn|drive\.uc\.cn|fast\.uc\.cn|aliyundrive\.com|alipan\.com|aliyunpan\.com|cloud\.189\.cn|115\.com|115cdn\.[a-z0-9]+|123[a-z0-9]*\.(?:com|cn|net)|caiyun\.139\.com|yun\.139\.com|pan\.xunlei\.com|mypikpak\.com|guangyapan\.com)\/[A-Za-z0-9\-_/?=&%.#~+]*/gi;
|
||
/* "别的片子"的区块名(整词匹配,见 inOtherBlock 的注释) */
|
||
var WO_OTHER_BLOCK_RE = /(?:^|[-_])(?:related|relates|relate|recommend|recommends|recommended|guess|guesslike|tuijian|xgvod|sidebar|footer|comment|comments)(?:$|[-_])/i;
|
||
/* 当前生效域名:优先用设置里手填的;否则用上次探测成功的;再否则第一个候选 */
|
||
function woBase(site) {
|
||
var custom = config.woBases && config.woBases[site.key];
|
||
if (custom) return trimSlash(custom);
|
||
if (state.woGood[site.key]) return trimSlash(state.woGood[site.key]);
|
||
return trimSlash(site.domains[0]);
|
||
}
|
||
/* 该站所有候选域名(生效的排最前) */
|
||
function woDomains(site) {
|
||
var list = [];
|
||
var cur = woBase(site);
|
||
list.push(cur);
|
||
site.domains.forEach(function (d) { if (trimSlash(d) !== cur) list.push(trimSlash(d)); });
|
||
return list;
|
||
}
|
||
/* 连不上(超时/网络级失败)的域名短期黑名单。
|
||
同一次搜索里,没有专属搜索地址的站(玩偶/木偶)会拿 6 种猜测格式反复打同几个域名;
|
||
若某域名根本连不通,第一种格式打一次就记下来,后面 5 种格式直接跳过它,
|
||
避免"6 格式 × 死域名 × 15 秒"层层叠加成几分钟。
|
||
关键:只有连接级失败(超时/网络)才拉黑;HTTP 404 / 空页 / 人机校验挡板都说明
|
||
域名是活的、只是这个路径/格式不对,绝不能因此拉黑域名。 */
|
||
var WO_BAD_TTL = 90 * 1000;
|
||
function woDomainBad(dom) { var t = state.woBad && state.woBad[dom]; return !!(t && (Date.now() - t) < WO_BAD_TTL); }
|
||
function woMarkBad(dom) { if (!state.woBad) state.woBad = {}; state.woBad[dom] = Date.now(); }
|
||
function woClearBad(dom) { if (state.woBad && state.woBad[dom]) delete state.woBad[dom]; }
|
||
/* 给一条链一个总时间预算:不管里面几种格式、几个域名怎么试,到点就收工返回兜底值
|
||
(搜索场景通常是空数组),个别死站最多拖到预算上限,不会把"读取中"拖成几分钟。 */
|
||
function woWithBudget(promise, ms, fallback) {
|
||
return new Promise(function (resolve) {
|
||
var done = false;
|
||
var timer = setTimeout(function () { if (!done) { done = true; resolve(fallback); } }, ms);
|
||
promise.then(
|
||
function (v) { if (!done) { done = true; clearTimeout(timer); resolve(v); } },
|
||
function () { if (!done) { done = true; clearTimeout(timer); resolve(fallback); } }
|
||
);
|
||
});
|
||
}
|
||
/* 请求:给定"路径",自动在候选域名间切换,哪个通用哪个,并记住成功的域名 */
|
||
function woHttpPath(site, path) { return httpTextMemo("wo|" + site.key + "|" + path, function () { return woHttpPathRaw(site, path); }); }
|
||
function woDiag(line) {
|
||
if (!state.woDiagList) state.woDiagList = [];
|
||
if (state.woDiagList.length < 12) state.woDiagList.push(line);
|
||
}
|
||
function woHttpPathRaw(site, path) {
|
||
var all = woDomains(site);
|
||
// 跳过近期确认连不上的域名;但若全被拉黑了,仍整批试一遍,避免误杀(比如唯一域名、或黑名单过期边界)
|
||
var live = all.filter(function (d) { return !woDomainBad(d); });
|
||
var domains = live.length ? live : all;
|
||
function tryOne(i) {
|
||
if (i >= domains.length) return Promise.reject(new Error("all domains failed"));
|
||
var dom = domains[i];
|
||
var url = dom + path;
|
||
var req;
|
||
if (state.sdkReady && window.fm && fm.req) {
|
||
req = fm.req(url, { method: "GET", responseType: "text", timeout: 15, credentials: "include",
|
||
headers: { "User-Agent": WO_UA, "Referer": dom + "/" } })
|
||
.then(function (r) { if (!r.ok) { var e = new Error("HTTP " + r.status); e.woAlive = true; throw e; } return r.body; });
|
||
// r.ok=false 是服务器有应答(域名活着,只是这个路径不对)→ 标记 woAlive,不拉黑域名
|
||
} else {
|
||
req = fetchTimeout(url, { headers: {} }).then(function (r) { if (!r.ok) { var e = new Error("HTTP " + r.status); e.woAlive = true; throw e; } return r.text(); });
|
||
}
|
||
return req.then(function (html) {
|
||
var guard = html && /_guard\/|easy_click|jsl_clearance|检测中|请稍候/i.test(html);
|
||
woDiag(path + " " + (html ? html.length + "字节" : "空") + (guard ? " ← 人机校验挡板页" : "") +
|
||
(html && html.length < 400 && !guard ? ("\n 内容:" + String(html).replace(/\s+/g, " ").slice(0, 200)) : ""));
|
||
if (guard) woDiag(" 该站需先在 App 内置浏览器打开一次通过校验,之后本页会复用其 Cookie");
|
||
if (!html || html.length < 300) { var e = new Error("empty"); e.woAlive = true; throw e; } // 有应答但内容空/太短:域名活着,路径不对
|
||
state.woGood[site.key] = dom; // 记住能用的域名
|
||
woClearBad(dom); // 之前若误判过,这次通了就洗白
|
||
return { html: html, base: dom };
|
||
}, function (e) {
|
||
woDiag(path + " 失败 " + ((e && e.message) || "未知"));
|
||
// 只有连接级失败(超时 / 网络 / 无应答)才拉黑域名;有应答的 HTTP/空页错误(woAlive)不拉黑。
|
||
if (!(e && e.woAlive)) woMarkBad(dom);
|
||
return tryOne(i + 1);
|
||
});
|
||
}
|
||
return tryOne(0);
|
||
}
|
||
/* 兼容旧调用:woHttp(site, 完整url) -> 只取 path 再走多域名 */
|
||
function woHttp(site, url) {
|
||
var path = url;
|
||
woDomains(site).forEach(function (d) { if (url.indexOf(d) === 0) path = url.slice(d.length); });
|
||
if (/^https?:/i.test(path)) { var m = path.match(/^https?:\/\/[^/]+(\/.*)$/); path = m ? m[1] : "/"; }
|
||
return woHttpPath(site, path).then(function (r) { return r.html; });
|
||
}
|
||
function woDoc(html) {
|
||
try { return new DOMParser().parseFromString(String(html).replace(/<script[\s\S]*?<\/script>/gi, "").replace(/<style[\s\S]*?<\/style>/gi, ""), "text/html"); }
|
||
catch (e) { return null; }
|
||
}
|
||
function woAbs(site, u) { if (!u) return ""; if (/^https?:/i.test(u)) return u; if (u.indexOf("//") === 0) return "https:" + u; return woBase(site) + (u.charAt(0) === "/" ? "" : "/") + u; }
|
||
function woDiskType(u) {
|
||
if (/^magnet:/i.test(u)) return "magnet";
|
||
for (var i = 0; i < HOSTMAP.length; i++) if (HOSTMAP[i][0].test(u)) return HOSTMAP[i][1];
|
||
if (/guangyapan\.com/i.test(u)) return "guangya";
|
||
if (/^https?:/i.test(u)) return "http";
|
||
return "";
|
||
}
|
||
function woParseList(site, html, base) {
|
||
var d = woDoc(html); if (!d) return [];
|
||
var out = [], seen = {};
|
||
var els = d.querySelectorAll(".module-item, .module-search-item, #main .module-item, .module-items .module-item, .stui-vodlist__box, ul.stui-vodlist > li, .public-list-box, .video-info, ul.pic-list > li, .sr_lists > ul > li, .bg-cardbg");
|
||
Array.from(els).forEach(function (el) {
|
||
// 照 wogg 的 cardFrom:取任意第一个 <a href>,不假设 /voddetail/ 或 index.php 格式
|
||
var a = el.querySelector("a[href]") || (el.closest ? el.closest("a[href]") : null) || el;
|
||
var href = a && a.getAttribute ? (a.getAttribute("href") || "") : "";
|
||
if (!href || href.charAt(0) === "#") return;
|
||
var abs = woAbs2(base || woBase(site), href);
|
||
if (seen[abs]) return;
|
||
var img = el.querySelector("img");
|
||
var poster = img ? (img.getAttribute("data-src") || img.getAttribute("data-original") || img.getAttribute("src") || "") : "";
|
||
var tEl = el.querySelector(".module-item-title, .module-poster-item-title, .module-search-item-title, .video-title, .title, .video-name, h3 b, h3");
|
||
var title = sanitizeSiteTitle((a && a.getAttribute && a.getAttribute("title")) || (img && img.getAttribute("alt")) || (tEl ? tEl.textContent : "") || (a ? a.textContent : "") || "");
|
||
if (!title) return;
|
||
seen[abs] = 1;
|
||
out.push({ id: site.key + "_" + (out.length + 1) + "_" + Date.now(), title: title.slice(0, 60), poster: woAbs2(base || woBase(site), poster), meta: "", _wo: true, _woSite: site.key, _woHref: abs });
|
||
});
|
||
// 兜底:pomo 这类自定义主题(纯 Tailwind 工具类,没有 module-item / stui-vodlist 这些
|
||
// MacCMS 约定类名)上面一条都选不中。改按结构认卡片:带海报图的站内链接就是一张卡。
|
||
if (!out.length) out = woParseListLoose(site, d, base);
|
||
return out;
|
||
}
|
||
/* 结构化兜底解析:<a href="站内详情页"> 里包一张 <img> 就当作一张影片卡。
|
||
标题优先 a[title] -> img[alt] -> 卡片内的 h1~h4/.title。 */
|
||
function woParseListLoose(site, d, base) {
|
||
var out = [], seen = {};
|
||
var rootBase = trimSlash(base || woBase(site));
|
||
var imgs = d.querySelectorAll("a[href] img, a[href] > img");
|
||
Array.from(imgs).forEach(function (img) {
|
||
var a = img.closest ? img.closest("a[href]") : null;
|
||
if (!a) return;
|
||
var href = a.getAttribute("href") || "";
|
||
if (!href || href.charAt(0) === "#" || /^(javascript|mailto|tel):/i.test(href)) return;
|
||
var abs = woAbs2(rootBase, href);
|
||
var path = "";
|
||
try { path = new URL(abs).pathname; } catch (e) { path = abs.replace(/^https?:\/\/[^/]+/, ""); }
|
||
if (!path || path === "/") return; // 站标 / 回首页的链接
|
||
if (/\.(jpg|jpeg|png|webp|gif|css|js)$/i.test(path)) return;
|
||
if (/^\/(?:page|tag|search|login|register|user|about)\b/i.test(path)) return;
|
||
if (seen[abs]) return;
|
||
var poster = img.getAttribute("data-src") || img.getAttribute("data-original") || img.getAttribute("src") || "";
|
||
if (/logo|avatar|icon|placeholder/i.test(poster)) return; // 站标/头像不是海报
|
||
var card = a.parentElement || a;
|
||
var tEl = a.querySelector("h1,h2,h3,h4,.title,.name") || card.querySelector("h1,h2,h3,h4,.title,.name");
|
||
var title = sanitizeSiteTitle(a.getAttribute("title") || img.getAttribute("alt") ||
|
||
(tEl ? tEl.textContent : "") || a.textContent || "");
|
||
if (!title) return;
|
||
seen[abs] = 1;
|
||
out.push({ id: site.key + "_L" + (out.length + 1) + "_" + Date.now(), title: title.slice(0, 60),
|
||
poster: woAbs2(rootBase, poster), meta: "", _wo: true, _woSite: site.key, _woHref: abs });
|
||
});
|
||
return out;
|
||
}
|
||
function woAbs2(base, u) { if (!u) return ""; if (/^https?:/i.test(u)) return u; if (u.indexOf("//") === 0) return "https:" + u; try { return new URL(u, trimSlash(base) + "/").href; } catch (e) { return trimSlash(base) + (u.charAt(0) === "/" ? "" : "/") + u; } }
|
||
function woParseDetail(site, html) {
|
||
var d = woDoc(html);
|
||
var title = "", poster = "", overview = "";
|
||
if (d) {
|
||
var h1 = d.querySelector(".module-info-heading h1, .video-info-header h1, h1, .movie-title, h2");
|
||
title = h1 ? h1.textContent.trim() : "";
|
||
var pic = d.querySelector(".module-info-poster img, .module-item-pic img, .detail-pic img");
|
||
poster = pic ? (pic.getAttribute("data-original") || pic.getAttribute("data-src") || pic.getAttribute("src") || "") : "";
|
||
var ov = d.querySelector(".module-info-introduction-content, .module-info-content, .video-info-content, .content");
|
||
overview = ov ? ov.textContent.replace(/\s+/g, " ").trim() : "";
|
||
}
|
||
// 网盘链接:只在"下载/资源区块"里扫,不整页扫——整页扫会把详情页底部"相关推荐/其他影片"
|
||
// 里别的片子的网盘链接也抓进来(就是"药神详情页里混进犬夜叉全集"的原因)。
|
||
var pans = [], seen = {};
|
||
function pushOne(u, rowTitle, primary) {
|
||
u = String(u || "").replace(/&/g, "&").replace(/[,。、))"'<]+$/, "");
|
||
if (!u) return;
|
||
var type = woDiskType(u);
|
||
var k = type + "|" + normPanUrl(u);
|
||
if (seen[k]) return; seen[k] = 1;
|
||
var pwd = (u.match(/[?&](?:pwd|password|code)=([0-9a-zA-Z]+)/) || [])[1] || "";
|
||
pans.push({
|
||
type: type, url: u, password: pwd, title: (rowTitle || "").replace(/\s+/g, " ").trim(),
|
||
groupName: (PAN_META[type] ? PAN_META[type].label : type), quality: (PAN_META[type] ? PAN_META[type].label : type),
|
||
source: site.name, state: "idle", _wo: true, _woPrimary: !!primary
|
||
});
|
||
}
|
||
function pushFrom(scopeEl, rowTitle) {
|
||
var raw = (scopeEl ? scopeEl.innerHTML : "");
|
||
raw = String(raw).replace(/\\\//g, "/");
|
||
var hits = [], mm;
|
||
WO_PAN_RE.lastIndex = 0;
|
||
while ((mm = WO_PAN_RE.exec(raw))) hits.push(mm[0]);
|
||
// 磁力:不少站写在 data-url / 文本里而不是 href,所以按文本扫
|
||
var MAG_RE = /magnet:\?xt=urn:btih:[A-Za-z0-9]{32,40}[^"'\s<>]*/gi, mg;
|
||
while ((mg = MAG_RE.exec(raw))) hits.push(mg[0]);
|
||
for (var hi = 0; hi < hits.length; hi++) pushOne(hits[hi], rowTitle, false);
|
||
}
|
||
/* 节点是否落在"相关推荐/猜你喜欢/侧栏/页脚"这类别的片子的区块里。
|
||
注意必须按 class 的"整个词"判断:早先用子串匹配,Tailwind 的 relative
|
||
会被 relat 命中,导致整页都算推荐区,头部的网盘按钮就被误杀了。 */
|
||
function inOtherBlock(n) {
|
||
for (var p = n; p && p.tagName; p = p.parentElement) {
|
||
var tag = String(p.tagName).toLowerCase();
|
||
if (tag === "footer" || tag === "aside") return true;
|
||
var cn = p.className;
|
||
cn = (cn && cn.baseVal !== undefined) ? cn.baseVal : cn; // SVG 元素的 className 是对象
|
||
var toks = String(cn || "").split(/\s+/).concat(String(p.id || "").split(/\s+/));
|
||
for (var ti = 0; ti < toks.length; ti++) {
|
||
if (toks[ti] && WO_OTHER_BLOCK_RE.test(toks[ti])) return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
if (d) {
|
||
// ① 显式声明的网盘链接(href / data-url 等属性里直接写着的),无论它在页面哪儿都收。
|
||
// pomo 的"一键转存"图标按钮就挂在标题区,不在任何资源行里 —— 只扫资源行会漏掉它。
|
||
// 这些是页面自己声明的链接,不是正文里蒙出来的,所以可信,标记 primary 不参与片名过滤。
|
||
var ATTRS = ["href", "data-url", "data-link", "data-href", "data-clipboard-text"];
|
||
var linkNodes = d.querySelectorAll("a[href], [data-url], [data-link], [data-href], [data-clipboard-text]");
|
||
Array.from(linkNodes).forEach(function (n) {
|
||
if (inOtherBlock(n)) return;
|
||
for (var ai = 0; ai < ATTRS.length; ai++) {
|
||
var v = n.getAttribute(ATTRS[ai]);
|
||
if (!v) continue;
|
||
v = String(v).replace(/\\\//g, "/").trim();
|
||
var isMag = /^magnet:\?xt=urn:btih:[A-Za-z0-9]{32,40}/i.test(v);
|
||
WO_PAN_RE.lastIndex = 0;
|
||
var isPan = WO_PAN_RE.test(v);
|
||
if (!isMag && !isPan) continue;
|
||
var t = n.getAttribute("title") || n.textContent || "";
|
||
if (!t.trim()) { // 图标按钮没文字,往上找一层标题
|
||
var host = n.closest ? (n.closest(".download-item") || n.closest("li") || n.parentElement) : n.parentElement;
|
||
var th = host && host.querySelector ? host.querySelector(".x-dbjs-download-link, h4, .title, strong") : null;
|
||
t = th ? th.textContent : "";
|
||
}
|
||
pushOne(v, t, true);
|
||
}
|
||
});
|
||
// ② 每条资源行(含自己的标题),逐行扫——顺带拿到"这条资源叫什么",好过滤不相关的
|
||
var rows = d.querySelectorAll(".module-row-one, .module-row-info, .module-row-text, .downlist li, .down-list li, .module-blocklist .module-row-one, .download-item, .x-dbjs-accordion-inner .download-links");
|
||
if (rows.length) {
|
||
Array.from(rows).forEach(function (row) {
|
||
var t = row.querySelector(".module-row-title, .module-row-text h4, h4, .title, strong, .x-dbjs-download-link, a[data-url]");
|
||
pushFrom(row, t ? t.textContent : "");
|
||
});
|
||
}
|
||
// ③ 资源区整块(有的模板资源不在 module-row 里,而在一个下载 tab 容器里)
|
||
if (!pans.length) {
|
||
var box = d.querySelector(".module-tab-content, .module-downlist, .module-play-list, #download, .downurl, .pan-box");
|
||
if (box) pushFrom(box, "");
|
||
}
|
||
}
|
||
// 实在没有资源区块结构,才退回整页扫(老模板兜底)
|
||
if (!pans.length) pushFrom(d && d.body ? d.body : { innerHTML: html }, "");
|
||
// ④ 源码兜底:有的站(pomo 就是)把网盘链接放在 <script> 里的 JSON,或由 JS 运行时才插进
|
||
// DOM —— woDoc 解析前会剥掉 <script>,这类链接在解析出的 DOM 里压根不存在,
|
||
// 上面几轮怎么扫都扫不到(现象就是:磁力一堆、网盘零条)。
|
||
// 所以只要 DOM 里一条"网盘"都没有(磁力不算),就直接对原始 HTML 文本再扫一遍。
|
||
var hasDisk = false;
|
||
for (var pi = 0; pi < pans.length; pi++) if (pans[pi].type !== "magnet") { hasDisk = true; break; }
|
||
if (!hasDisk) {
|
||
var rawAll = String(html || "")
|
||
.replace(/\\\//g, "/") // JSON 里的 \/
|
||
.replace(/\\u002[fF]/g, "/") // JSON 里的 \u002F
|
||
.replace(/�*47;/g, "/").replace(/�*2[fF];/g, "/");
|
||
var hits2 = [], m2;
|
||
WO_PAN_RE.lastIndex = 0;
|
||
while ((m2 = WO_PAN_RE.exec(rawAll))) hits2.push(m2[0]);
|
||
for (var qi = 0; qi < hits2.length; qi++) pushOne(hits2[qi], "", true);
|
||
}
|
||
// 过滤:资源标题里若明显是"别的片子"(含另一个书名号片名、且跟当前片名完全不沾边),丢掉
|
||
var selfKey = normTitleKey(title);
|
||
if (selfKey) {
|
||
pans = pans.filter(function (p) {
|
||
var rt = p.title || ""; if (!rt) return true; // 没标题的保留(无法判断)
|
||
var mk = rt.match(/[《【]([^》】]{2,30})[》】]/); // 资源标题里被书名号/方括号包起来的片名
|
||
if (!mk) return true;
|
||
var other = normTitleKey(mk[1]);
|
||
if (!other) return true;
|
||
// 资源里的片名 与 当前片名 互不包含 => 是别的片,丢弃
|
||
return other.indexOf(selfKey) >= 0 || selfKey.indexOf(other) >= 0;
|
||
});
|
||
}
|
||
return { title: title, poster: woAbs(site, poster), overview: overview, pans: pans };
|
||
}
|
||
function woFetchList(site, cat, page) {
|
||
var urls;
|
||
if (typeof site.categoryUrl === "function") { // 站点自己按页码拼(首页和翻页格式常常不同)
|
||
urls = [].concat(site.categoryUrl(cat, page));
|
||
} else if (site.categoryUrl) {
|
||
urls = [site.categoryUrl.replace("{cat}", cat).replace("{page}", page)];
|
||
} else {
|
||
urls = [
|
||
"/index.php/vod/show/id/" + cat + "/page/" + page + ".html",
|
||
"/vodshow/" + cat + "--------" + page + "---.html",
|
||
"/index.php/vod/show/id/" + cat + ".html",
|
||
"/vodtype/" + cat + (page > 1 ? "-" + page : "") + ".html"
|
||
];
|
||
}
|
||
state.woDiagList = [];
|
||
function tryUrl(i) {
|
||
if (i >= urls.length) return Promise.resolve([]);
|
||
return woHttpPath(site, urls[i]).then(function (r) {
|
||
var list = woParseList(site, r.html, r.base);
|
||
woDiag(" ↑ 解析出 " + list.length + " 条");
|
||
return list.length ? list : tryUrl(i + 1);
|
||
}, function () { return tryUrl(i + 1); });
|
||
}
|
||
return tryUrl(0);
|
||
}
|
||
/* MacCMS 通用联想接口 /index.php/ajax/suggest?mid=1&wd=... —— 返回 JSON({list:[{id,name,pic}]})。
|
||
很多轻量 App 搜这类站就是走它:不吃 HTML 搜索页那套伪静态/WAF 挡板,命中率通常最高。
|
||
木偶的分类就是走 /index.php/vod/show/...(index.php 路由是开着的),所以它的 suggest 和
|
||
详情页 /index.php/vod/detail/id/{id}.html 也走得通。玩偶等站若没有这接口,解析不出就返回空、
|
||
自动退回下面的 6 种 HTML 搜索页格式,不会有副作用。 */
|
||
/* —— 木偶搜索排障 ——(定位完把 WO_SEARCH_DIAG 关回 false 即可)
|
||
只针对木偶(mo):把每次尝试(联想接口 + 各 HTML 搜索页)的域名/地址/字节/返回片段/解析条数
|
||
收集起来,搜完在屏幕底部弹一个可点关闭的浮层。玩偶用的是同一套 woSearch 却能出,说明代码没问题,
|
||
差异只可能在木偶服务器本身(域名死了、或搜索接口跟玩偶不一样),得看它实际返回什么才能配死。 */
|
||
var WO_SEARCH_DIAG = false;
|
||
function woSDiag(site, line) {
|
||
if (!WO_SEARCH_DIAG || !site || site.key !== "mo") return;
|
||
if (!state.woSDiag) state.woSDiag = [];
|
||
if (state.woSDiag.length < 60) state.woSDiag.push(line);
|
||
}
|
||
function woSDiagShow(site) {
|
||
if (!WO_SEARCH_DIAG || !site || site.key !== "mo") return;
|
||
var box = document.getElementById("woSDiagBox");
|
||
if (!box) {
|
||
box = document.createElement("div");
|
||
box.id = "woSDiagBox";
|
||
box.style.cssText = "position:fixed;left:8px;right:8px;bottom:96px;z-index:70;max-height:44vh;overflow:auto;" +
|
||
"padding:10px 12px;border-radius:12px;background:rgba(10,14,24,.94);color:#cfe0ff;font-size:11px;" +
|
||
"line-height:1.5;white-space:pre-wrap;text-align:left;box-shadow:0 8px 30px rgba(0,0,0,.5);" +
|
||
"border:1px solid rgba(160,190,255,.22);-webkit-user-select:text;user-select:text;";
|
||
box.addEventListener("click", function () { box.style.display = "none"; });
|
||
document.body.appendChild(box);
|
||
}
|
||
box.textContent = "【木偶搜索诊断】(点一下关闭)\n" + (state.woSDiag || []).join("\n");
|
||
box.style.display = "block";
|
||
}
|
||
function woSnippet(t) { return String(t == null ? "" : t).replace(/\s+/g, " ").slice(0, 160); }
|
||
function woSuggestParse(site, txt, dom) {
|
||
var j; try { j = JSON.parse(txt); } catch (e) { return []; }
|
||
var arr = j && (j.list || j.data || (j.info && j.info.list));
|
||
if (!arr || !arr.length) return [];
|
||
var base = trimSlash(dom || woBase(site)), out = [];
|
||
arr.forEach(function (v) {
|
||
var id = v.id || v.vod_id || v.ID; if (!id) return;
|
||
var name = v.name || v.vod_name || v.title || ""; if (!name) return;
|
||
var pic = v.pic || v.vod_pic || "";
|
||
out.push({ id: site.key + "_s" + id, title: sanitizeSiteTitle(String(name)).slice(0, 60),
|
||
poster: woAbs2(base, pic), meta: "", _wo: true, _woSite: site.key,
|
||
_woHref: base + "/index.php/vod/detail/id/" + id + ".html" });
|
||
});
|
||
return out;
|
||
}
|
||
function woSuggest(site, ek) {
|
||
var path = "/index.php/ajax/suggest?mid=1&limit=30&wd=" + ek;
|
||
var all = woDomains(site);
|
||
var live = all.filter(function (d) { return !woDomainBad(d); });
|
||
var domains = live.length ? live : all;
|
||
function tryOne(i) {
|
||
if (i >= domains.length) return Promise.resolve([]);
|
||
var dom = domains[i], url = dom + path, req;
|
||
if (state.sdkReady && window.fm && fm.req) {
|
||
req = fm.req(url, { method: "GET", responseType: "text", timeout: 12, credentials: "include",
|
||
headers: { "User-Agent": WO_UA, "Referer": dom + "/", "X-Requested-With": "XMLHttpRequest" } })
|
||
.then(function (r) { if (!r.ok) { var e = new Error("HTTP " + r.status); e.woAlive = true; throw e; } return r.body; });
|
||
} else {
|
||
req = fetchTimeout(url, { headers: { "X-Requested-With": "XMLHttpRequest" } })
|
||
.then(function (r) { if (!r.ok) { var e = new Error("HTTP " + r.status); e.woAlive = true; throw e; } return r.text(); });
|
||
}
|
||
return req.then(function (txt) {
|
||
state.woGood[site.key] = dom; woClearBad(dom);
|
||
var list = woSuggestParse(site, txt, dom);
|
||
if (!list.length) {
|
||
// 有应答但没解析出:可能这个域名的接口挂了/返回的不是预期 JSON,继续换下一个域名再试
|
||
woSDiag(site, "联想接口 " + dom + "\n → " + (txt ? txt.length + "字节" : "空") + ",解析出 0 条\n 返回片段:" + woSnippet(txt));
|
||
return tryOne(i + 1);
|
||
}
|
||
woSDiag(site, "联想接口 " + dom + "\n → " + txt.length + "字节,解析出 " + list.length + " 条\n 片名:" +
|
||
list.map(function (x) { return x.title; }).join(" / "));
|
||
return list;
|
||
}, function (e) {
|
||
woSDiag(site, "联想接口 " + dom + " 失败:" + ((e && e.message) || "未知"));
|
||
// 只有连接级失败(超时/网络/无应答)才拉黑域名;HTTP 500/404 说明域名活着、只是这个接口报错,
|
||
// 不能拉黑(否则后面的 HTML 搜索页也会跳过它)。两种情况都继续换下一个域名。
|
||
if (!(e && e.woAlive)) woMarkBad(dom);
|
||
return tryOne(i + 1);
|
||
});
|
||
}
|
||
return tryOne(0);
|
||
}
|
||
/* 搜索总预算:6 种格式 × 多域名再怎么试,最多拖到这个上限就返回已有结果(通常为空),
|
||
其余数据源各自并发、各自到各自渲染,不会被某个死站拖着一起转圈。 */
|
||
var WO_SEARCH_BUDGET_MS = 22000;
|
||
function woSearch(site, kw) {
|
||
var ek = encodeURIComponent(kw);
|
||
var inner;
|
||
if (site.searchUrls && site.searchUrls.length) { // 站点自带搜索地址优先
|
||
var own = site.searchUrls.map(function (u) { return u.replace("{kw}", ek); });
|
||
inner = (function tryOwn(i) {
|
||
if (i >= own.length) return Promise.resolve([]);
|
||
return woHttpPath(site, own[i]).then(function (r) {
|
||
var list = woParseList(site, r.html, r.base);
|
||
return list.length ? list : tryOwn(i + 1);
|
||
}, function () { return tryOwn(i + 1); });
|
||
})(0);
|
||
} else {
|
||
var urls = [
|
||
"/index.php/vod/search/page/1/wd/" + ek + ".html",
|
||
"/vodsearch/-------------.html?wd=" + ek + "&page=1",
|
||
"/index.php/vod/search/wd/" + ek + ".html",
|
||
"/vodsearch/" + ek + "----------1---.html",
|
||
"/index.php/vod/search.html?wd=" + ek,
|
||
"/search.html?wd=" + ek
|
||
];
|
||
var tryHtml = function tryUrl(i) {
|
||
if (i >= urls.length) return Promise.resolve([]);
|
||
return woHttpPath(site, urls[i]).then(function (r) {
|
||
var list = woParseList(site, r.html, r.base);
|
||
woSDiag(site, "搜索页 " + urls[i] + "\n → " + (r.html ? r.html.length + "字节" : "空") + ",解析出 " + list.length + " 条");
|
||
return list.length ? list : tryUrl(i + 1);
|
||
}, function () { woSDiag(site, "搜索页 " + urls[i] + " 全部域名失败/被挡"); return tryUrl(i + 1); });
|
||
};
|
||
if (site.key === "mo") state.woSDiag = []; // 每次木偶搜索前清空诊断缓冲
|
||
// 先走 MacCMS 联想接口(最稳),没命中再退回 6 种 HTML 搜索页格式。
|
||
inner = woSuggest(site, ek).then(function (list) {
|
||
return (list && list.length) ? list : tryHtml(0);
|
||
}, function () { return tryHtml(0); }).then(function (list) {
|
||
if (site.key === "mo") {
|
||
woSDiag(site, "———\n最终:" + ((list && list.length) || 0) + " 条");
|
||
woSDiagShow(site);
|
||
}
|
||
return list;
|
||
});
|
||
}
|
||
return woWithBudget(inner, WO_SEARCH_BUDGET_MS, []);
|
||
}
|
||
function woKeep() { return (state.panItems || []).filter(function (it) { return it._wo; }); }
|
||
/* 详情:直接请求列表卡片里存的完整 href(不假设 URL 格式,照 wogg 做法) */
|
||
function woHttpAbs(site, absUrl) {
|
||
if (state.sdkReady && window.fm && fm.req) {
|
||
return fm.req(absUrl, { method: "GET", responseType: "text", timeout: 15, headers: { "User-Agent": WO_UA, "Referer": absUrl } })
|
||
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.body; });
|
||
}
|
||
return fetchTimeout(absUrl, { headers: {} }).then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.text(); });
|
||
}
|
||
function woLoadByHref(site, href, token) {
|
||
woDiagPan("本站详情页 " + href, site);
|
||
woHttpAbs(site, href).then(function (html) {
|
||
if (token !== state.panToken) return;
|
||
var guard = html && /_guard\/|easy_click|jsl_clearance|检测中|请稍候/i.test(html);
|
||
woDiagPan(" 返回 " + (html ? html.length : 0) + " 字节" + (guard ? " ← 人机校验挡板页" : ""), site);
|
||
var d = woParseDetail(site, html);
|
||
var hist = {}, hk = [];
|
||
d.pans.forEach(function (p) { if (!hist[p.type]) { hist[p.type] = 0; hk.push(p.type); } hist[p.type]++; });
|
||
woDiagPan(" 解析出盘链 " + d.pans.length + " 条" +
|
||
(hk.length ? ":" + hk.map(function (t) { return t + "×" + hist[t]; }).join(" ") : ""), site);
|
||
if (state.currentDetail && !state.currentDetail.tmdb) {
|
||
if (d.title) { state.currentDetail.rawTitle = d.title; showTitle(d.title); }
|
||
var ovv = $("detailOverview"); if (ovv && !ovv.textContent && !(state.currentDetail && state.currentDetail.tmdb) && d.overview && !isJunkOverview(d.overview)) { ovv.textContent = d.overview; state.currentDetail.overviewFromSite = true; }
|
||
if (d.poster && !state.currentDetail.poster && !(state.currentDetail && state.currentDetail.tmdb)) { state.currentDetail.poster = d.poster; setDetailHeroImages([d.poster], false); }
|
||
}
|
||
renderResources(sortPanDefault(d.pans).concat(fyKeep()).concat(panSouKeep()).concat(zn4kKeep()).concat(qwKeep()));
|
||
}, function (e) {
|
||
if (token !== state.panToken) return;
|
||
woDiagPan(" 详情页请求失败:" + ((e && e.message) || "未知"), site);
|
||
renderResources(fyKeep().concat(panSouKeep()).concat(zn4kKeep()).concat(qwKeep()));
|
||
});
|
||
}
|
||
function panDiagBox() {
|
||
var box = document.getElementById("panDiagBox");
|
||
if (!box) {
|
||
var host = document.getElementById("panList");
|
||
if (!host) return null;
|
||
box = el("div", "pan-empty", "");
|
||
box.id = "panDiagBox";
|
||
box.style.whiteSpace = "pre-wrap"; box.style.fontSize = "11px";
|
||
box.style.textAlign = "left"; box.style.opacity = ".75"; box.style.marginTop = "10px";
|
||
host.appendChild(box);
|
||
}
|
||
return box;
|
||
}
|
||
/* 反查诊断只保留 pomo 一站,避免多站刷屏把它挤掉。
|
||
平时关掉不显示;要再排查时把 PAN_DIAG_ON 改成 true 即可。 */
|
||
var PAN_DIAG_ON = false;
|
||
var PAN_DIAG_KEY = "pm";
|
||
function woDiagPan(line, site) {
|
||
if (!PAN_DIAG_ON) return;
|
||
if (!site || site.key !== PAN_DIAG_KEY) return;
|
||
if (!state.panDiag) state.panDiag = [];
|
||
if (state.panDiag.length < 40) state.panDiag.push(line);
|
||
var box = panDiagBox();
|
||
if (box) { box.textContent = "【反查诊断 · pomo】\n" + state.panDiag.join("\n"); box.style.display = "block"; }
|
||
}
|
||
function woEnrich(site, rawTitle, token) {
|
||
var kw = cleanTitle(rawTitle); if (!kw) return;
|
||
var year = yearOf(rawTitle);
|
||
woDiagPan("搜「" + kw + "」…", site);
|
||
woSearch(site, kw).then(function (list) {
|
||
if (token !== state.panToken) return;
|
||
woDiagPan(" 命中 " + ((list && list.length) || 0) + " 条" +
|
||
(list && list.length ? ":" + list.slice(0, 4).map(function (x) { return (x.title || "").slice(0, 18); }).join(" / ") : ""), site);
|
||
if (!list.length) return;
|
||
var best = null, bestScore = 99;
|
||
list.forEach(function (it) { var ts = titleScore(it.title, kw); if (ts > 3) return; var sc = ts; if (year && (it.title || "").indexOf(year) >= 0) sc -= 0.3; if (sc < bestScore) { bestScore = sc; best = it; } });
|
||
if (!best || !best._woHref) { woDiagPan(" 没有匹配上(标题差异过大)", site); return; }
|
||
woDiagPan(" 选中:" + (best.title || "").slice(0, 22), site);
|
||
woHttpAbs(site, best._woHref).then(function (html) {
|
||
if (token !== state.panToken) return;
|
||
var d = woParseDetail(site, html);
|
||
woDiagPan(" 详情页 " + (html ? html.length : 0) + "字节,盘链 " + d.pans.length + " 条", site);
|
||
if (!d.pans.length) return;
|
||
var have = {};
|
||
state.panItems.forEach(function (it) { have[it.type + "|" + normPanUrl(it.url || it.token)] = 1; });
|
||
var add = d.pans.filter(function (p) { return !have[p.type + "|" + normPanUrl(p.url)]; });
|
||
if (add.length) renderResources(state.panItems.concat(add));
|
||
}, function (e) { woDiagPan(" 详情页取不到:" + ((e && e.message) || "未知"), site); });
|
||
}, function (e) { woDiagPan(" 搜索失败:" + ((e && e.message) || "未知"), site); });
|
||
}
|
||
/* 首页行 */
|
||
function renderWoRow(box, site) {
|
||
var shell = makeRowShell(site.name, function () { openWoCategory(site, site.cats[0][0], site.name); });
|
||
box.appendChild(shell.section);
|
||
var ck = site.key + ":" + site.cats[0][0];
|
||
var cached = state.woCache[ck];
|
||
var hasCache = !!(cached && cached.length);
|
||
if (hasCache) fillRow(shell.row, cached.slice(0, 12), function (it) { openDetail(it); });
|
||
else shell.row.replaceChildren(el("div", "mh-row-empty", "加载中…"));
|
||
var load = function () {
|
||
if (!document.body.contains(shell.row)) return null;
|
||
return woFetchList(site, site.cats[0][0], 1).then(function (list) {
|
||
if (!document.body.contains(shell.row)) return;
|
||
if (!list.length) { if (!hasCache) shell.row.replaceChildren(el("div", "mh-row-empty", "暂无数据")); return; }
|
||
state.woCache[ck] = list; saveHomeDisk();
|
||
fillRow(shell.row, list.slice(0, 12), function (it) { openDetail(it); });
|
||
}, function () { if (!hasCache) shell.row.replaceChildren(el("div", "mh-row-empty", "加载失败")); });
|
||
};
|
||
if (hasCache) { if (homeCacheStale()) homePrefetch(load); return; }
|
||
rowLoad(shell.section, load);
|
||
}
|
||
function openWoCategory(site, cat, name) {
|
||
saveHomeReturn();
|
||
state.view = "category";
|
||
$("homeView").style.display = "none"; $("catView").style.display = "";
|
||
$("catTitle").textContent = name || site.name;
|
||
$("clearSearch").style.display = "none"; $("clearRecent").style.display = "none";
|
||
setNav(null); updateHeroVisible(); window.scrollTo(0, 0);
|
||
navigate("category");
|
||
state.searchMode = false; state.searchKw = "";
|
||
state.activeCat = site.slug; state.woSite = site.key; state.woCat = cat || site.cats[0][0]; state.activeGenre = "";
|
||
$("genreChips").style.display = ""; renderWoGenreChips(site);
|
||
woLoadCat(true);
|
||
}
|
||
function renderWoGenreChips(site) {
|
||
var box = $("genreChips"); if (!box) return;
|
||
var frag = document.createDocumentFragment();
|
||
site.cats.forEach(function (c, i) {
|
||
var b = document.createElement("button");
|
||
b.type = "button";
|
||
b.className = "genre-chip focusable" + (state.woCat === c[0] ? " active" : "");
|
||
b.textContent = c[1]; b.setAttribute("data-genre", c[1]);
|
||
b.setAttribute("data-focus-row", "genreChips"); b.setAttribute("data-focus-index", String(i));
|
||
b.onclick = (function (cc) { return function () { state.woCat = cc[0]; renderWoGenreChips(site); window.scrollTo(0, 0); woLoadCat(true); }; })(c);
|
||
frag.appendChild(b);
|
||
});
|
||
box.replaceChildren(frag);
|
||
}
|
||
function woLoadCat(reset) {
|
||
var site = WO_SITES[state.woSite]; if (!site) return;
|
||
var cat = state.woCat || site.cats[0][0];
|
||
var ck = site.key + ":" + cat;
|
||
if (reset) {
|
||
state.reqToken++;
|
||
state.woPage = 1; state.source = []; state.rendered = 0; state.noMore = false; state.loading = false; state.autoPages = 0;
|
||
$("grid").replaceChildren(); $("grid").classList.remove("grid-recent"); $("browseNotice").replaceChildren();
|
||
var c = state.woCache[ck];
|
||
if (c && c.length) { woPaintCat(c, state.reqToken, 1); return; }
|
||
}
|
||
if (state.loading || state.noMore) return;
|
||
var token = state.reqToken, page = reset ? 1 : (state.woPage + 1);
|
||
state.loading = true; $("browseLoading").style.display = "block"; if (reset) $("browseSub").textContent = "加载中";
|
||
woFetchList(site, cat, page).then(function (list) {
|
||
state.loading = false;
|
||
if (token !== state.reqToken) return;
|
||
$("browseLoading").style.display = "none";
|
||
if (page === 1) state.woCache[ck] = list;
|
||
woPaintCat(list, token, page);
|
||
}, function () {
|
||
state.loading = false;
|
||
if (token !== state.reqToken) return;
|
||
$("browseLoading").style.display = "none"; state.noMore = true;
|
||
if (!state.source.length) $("grid").replaceChildren(el("div", "mh-row-empty", "加载失败,返回重试"));
|
||
});
|
||
}
|
||
function woPaintCat(list, token, page) {
|
||
if (token !== state.reqToken) return;
|
||
$("browseLoading").style.display = "none";
|
||
var have = {}; state.source.forEach(function (s) { have[s.id] = 1; });
|
||
var fresh = (list || []).filter(function (it) { return !have[it.id]; });
|
||
if (!fresh.length) {
|
||
state.noMore = true;
|
||
if (!state.source.length) {
|
||
var box = el("div", "mh-row-empty", "该分类暂无内容");
|
||
var lines = PAN_DIAG_ON ? (state.woDiagList || []) : [];
|
||
if (lines.length) {
|
||
var d = el("div", "mh-row-empty", "");
|
||
d.style.whiteSpace = "pre-wrap"; d.style.fontSize = "11px"; d.style.opacity = ".8"; d.style.textAlign = "left";
|
||
d.textContent = "【请求诊断】\n" + lines.join("\n");
|
||
$("grid").replaceChildren(box, d);
|
||
} else $("grid").replaceChildren(box);
|
||
$("browseSub").textContent = "";
|
||
}
|
||
return;
|
||
}
|
||
state.woPage = page; state.source = state.source.concat(fresh); appendGrid();
|
||
$("browseSub").textContent = state.source.length + " 部";
|
||
}
|
||
function fyEnrichDo(rawTitle, token) {
|
||
var kw = cleanTitle(rawTitle);
|
||
if (!kw) return;
|
||
var year = yearOf(rawTitle);
|
||
fySearch(kw).then(function (list) {
|
||
if (token !== state.panToken || !list.length) return null;
|
||
var best = null, bestScore = 99;
|
||
list.forEach(function (it) {
|
||
var ts = titleScore(it.title, kw);
|
||
if (ts > 3) return;
|
||
var s = ts;
|
||
if (year && (it.title || "").indexOf(year) >= 0) s -= 0.3;
|
||
if (s < bestScore) { bestScore = s; best = it; }
|
||
});
|
||
if (!best) return null;
|
||
return fyHttpText(fyBase() + "/detail/" + best._fyId + ".html").then(function (html) {
|
||
if (token !== state.panToken) return;
|
||
var d = fyParseDetail(html, best._fyId);
|
||
var res = fyResList(d, best._fyId);
|
||
if (!res.length) return;
|
||
var have = {};
|
||
state.panItems.forEach(function (it) { if (it._fy) have[it._fyId + "-" + it._fySid + "-" + it._fyNid] = 1; });
|
||
var add = res.filter(function (r) { return !have[r._fyId + "-" + r._fySid + "-" + r._fyNid]; });
|
||
if (add.length) renderResources(state.panItems.concat(add));
|
||
});
|
||
}).then(null, function () {});
|
||
}
|
||
/* 枫叶来源条目:顺便反查盘链本站,命中就升级成原生条目(网盘资源 + 枫叶在线并存) */
|
||
function fyReverseLookup(item, token) {
|
||
var title = item.title, yr = item.year || yearOf(item.title);
|
||
apiGet(apiUrl("get_videos.php") + "?wd=" + encodeURIComponent(cleanTitle(title)) + "&pg=1&_t=" + Date.now(), "").then(function (d) {
|
||
if (token !== state.panToken || state.currentItem !== item) return;
|
||
var list = ((d && (d.list || d.data)) || []).map(mapVod);
|
||
function score(it) { var s = 0; if (it.title === title) s += 8; else if (titleCloseContain(it.title, title)) s += 4; if (yr && it.year === yr) s += 3; return s; }
|
||
var good = list.filter(function (it) { return score(it) >= 4; });
|
||
if (!good.length) return;
|
||
// 本站有同名片:把网盘资源并进来,但保留已经在列表里的枫叶在线线路,两边共存
|
||
var keepFy = fyKeep();
|
||
var tok = state.panToken;
|
||
var kw = cleanTitle(title);
|
||
var best = good.slice().sort(function (a, b) { return score(b) - score(a); })[0];
|
||
apiGet(apiUrl("search_pan_links.php") + "?keyword=" + encodeURIComponent(kw) + "&vod_id=" + encodeURIComponent(best.id) + "&_t=" + Date.now(), best.id).then(function (data) {
|
||
if (tok !== state.panToken || !data || !data.success) return;
|
||
var groups = data.data || {}, pans = [];
|
||
Object.keys(groups).forEach(function (type) {
|
||
var grp = groups[type], links = (grp && grp.links) || [];
|
||
links.forEach(function (lk) {
|
||
pans.push({
|
||
type: type, groupName: (grp && grp.name) || (PAN_META[type] ? PAN_META[type].label : type),
|
||
title: lk.title || "", password: lk.password || "", token: lk.token || "", source: lk.source || "",
|
||
tier: lk.user_tier || 0, quality: (PAN_META[type] ? PAN_META[type].label : type), state: "idle"
|
||
});
|
||
});
|
||
});
|
||
if (pans.length) renderResources(keepFy.concat(sortPanDefault(pans)));
|
||
}, function () {});
|
||
}, function () {});
|
||
}
|
||
|
||
/* ---------- 首页「枫叶」一行 + 分类页 ---------- */
|
||
function renderFyRow(box) {
|
||
var shell = makeRowShell("枫叶4K影院", function () { openFyCategory("1", "枫叶4K影院"); });
|
||
box.appendChild(shell.section);
|
||
var cached = state.fyCache["1"];
|
||
if (cached) { fillRow(shell.row, cached.slice(0, 12), function (it) { openDetail(it); }); return; }
|
||
fyFetchList("1", 1).then(function (list) {
|
||
state.fyCache["1"] = list;
|
||
if (!list.length) { shell.row.replaceChildren(el("div", "mh-row-empty", "暂无数据")); return; }
|
||
fillRow(shell.row, list.slice(0, 12), function (it) { openDetail(it); });
|
||
}, function () { shell.row.replaceChildren(el("div", "mh-row-empty", "加载失败")); });
|
||
}
|
||
/* 首页:腾讯视频 / 优酷 / 哔哩哔哩 三行(枫叶站按来源平台分的标签页) */
|
||
/* 首页「枫叶」一行(腾讯/优酷/哔哩三个来源合成一个入口,默认铺腾讯,查看全部里用标签切换) */
|
||
function renderFyLabelRows(box) {
|
||
var first = FY_LABELS[0], t = "label:" + first.tag;
|
||
var shell = makeRowShell("枫叶", function () { openFyCategory("label:" + first.tag, "枫叶"); });
|
||
box.appendChild(shell.section);
|
||
var cached = state.fyCache[t];
|
||
if (cached && cached.length) { fillRow(shell.row, cached.slice(0, 12), function (it) { openDetail(it); }); return; }
|
||
shell.row.replaceChildren(el("div", "mh-row-empty", "加载中…"));
|
||
fyFetchList(t, 1).then(function (list) {
|
||
state.fyCache[t] = list;
|
||
if (!list.length) { shell.row.replaceChildren(el("div", "mh-row-empty", "暂无数据")); return; }
|
||
fillRow(shell.row, list.slice(0, 12), function (it) { openDetail(it); });
|
||
}, function () { shell.row.replaceChildren(el("div", "mh-row-empty", "加载失败")); });
|
||
}
|
||
/* 枫叶分类页:顶部筛选标签就是站点的 电影/剧集/综艺/动漫/短剧,滑到底自动翻页 */
|
||
function openFyCategory(t, name) {
|
||
saveHomeReturn();
|
||
state.view = "category";
|
||
$("homeView").style.display = "none"; $("catView").style.display = "";
|
||
$("catTitle").textContent = name || "枫叶4K影院";
|
||
$("clearSearch").style.display = "none"; $("clearRecent").style.display = "none";
|
||
setNav(null); updateHeroVisible(); window.scrollTo(0, 0);
|
||
navigate("category");
|
||
state.searchMode = false; state.searchKw = "";
|
||
state.activeCat = FY_CAT_SLUG;
|
||
state.fyCat = t || "1";
|
||
state.activeGenre = "";
|
||
if (String(t).indexOf("label:") === 0) {
|
||
// 来源标签页(枫叶:腾讯/优酷/哔哩)——用标签切换来源
|
||
$("genreChips").style.display = "";
|
||
renderFyLabelChips();
|
||
} else {
|
||
$("genreChips").style.display = "";
|
||
renderFyGenreChips();
|
||
}
|
||
fyLoadCat(true);
|
||
}
|
||
/* 枫叶查看全部页的来源标签:腾讯 / 优酷 / 哔哩,点了切换来源重新加载 */
|
||
function renderFyLabelChips() {
|
||
var box = $("genreChips"); if (!box) return;
|
||
var frag = document.createDocumentFragment();
|
||
FY_LABELS.forEach(function (lb, i) {
|
||
var t = "label:" + lb.tag;
|
||
var b = document.createElement("button");
|
||
b.type = "button";
|
||
b.className = "genre-chip focusable" + (state.fyCat === t ? " active" : "");
|
||
b.textContent = lb.name;
|
||
b.setAttribute("data-genre", lb.name);
|
||
b.setAttribute("data-focus-row", "genreChips");
|
||
b.setAttribute("data-focus-index", String(i));
|
||
b.onclick = (function (tt) { return function () { state.fyCat = tt; renderFyLabelChips(); window.scrollTo(0, 0); fyLoadCat(true); }; })(t);
|
||
frag.appendChild(b);
|
||
});
|
||
box.replaceChildren(frag);
|
||
}
|
||
function renderFyGenreChips() {
|
||
var box = $("genreChips"); if (!box) return;
|
||
var frag = document.createDocumentFragment();
|
||
FY_CATS.forEach(function (c, i) {
|
||
var b = document.createElement("button");
|
||
b.type = "button";
|
||
b.className = "genre-chip focusable" + (state.fyCat === c.t ? " active" : "");
|
||
b.textContent = c.name;
|
||
b.setAttribute("data-genre", c.name);
|
||
b.setAttribute("data-focus-row", "genreChips");
|
||
b.setAttribute("data-focus-index", String(i));
|
||
b.onclick = (function (cc) { return function () { state.fyCat = cc.t; renderFyGenreChips(); window.scrollTo(0, 0); fyLoadCat(true); }; })(c);
|
||
frag.appendChild(b);
|
||
});
|
||
box.replaceChildren(frag);
|
||
}
|
||
function fyLoadCat(reset) {
|
||
var t = state.fyCat || "1";
|
||
if (reset) {
|
||
state.reqToken++;
|
||
state.fyPage = 1; state.source = []; state.rendered = 0; state.noMore = false; state.loading = false; state.autoPages = 0;
|
||
$("grid").replaceChildren(); $("grid").classList.remove("grid-recent");
|
||
$("browseNotice").replaceChildren();
|
||
var cachedR = state.fyCache[t];
|
||
if (cachedR && cachedR.length) { fyPaintCat(cachedR, state.reqToken, 1); return; }
|
||
}
|
||
if (state.loading || state.noMore) return;
|
||
var token = state.reqToken, page = reset ? 1 : (state.fyPage + 1);
|
||
state.loading = true;
|
||
$("browseLoading").style.display = "block";
|
||
if (reset) $("browseSub").textContent = "加载中";
|
||
fyFetchList(t, page).then(function (list) {
|
||
state.loading = false;
|
||
if (token !== state.reqToken) return;
|
||
$("browseLoading").style.display = "none";
|
||
if (page === 1) state.fyCache[t] = list;
|
||
fyPaintCat(list, token, page);
|
||
}, function () {
|
||
state.loading = false;
|
||
if (token !== state.reqToken) return;
|
||
$("browseLoading").style.display = "none";
|
||
state.noMore = true;
|
||
if (!state.source.length) $("grid").replaceChildren(el("div", "mh-row-empty", "加载失败,返回重试"));
|
||
});
|
||
}
|
||
function fyPaintCat(list, token, page) {
|
||
if (token !== state.reqToken) return;
|
||
$("browseLoading").style.display = "none";
|
||
var have = {}; state.source.forEach(function (s) { have[s.id] = 1; });
|
||
var fresh = (list || []).filter(function (it) { return !have[it.id]; });
|
||
if (!fresh.length) {
|
||
state.noMore = true;
|
||
if (!state.source.length) { $("grid").replaceChildren(el("div", "mh-row-empty", "该分类暂无内容")); $("browseSub").textContent = ""; }
|
||
return;
|
||
}
|
||
state.fyPage = page;
|
||
state.source = state.source.concat(fresh);
|
||
appendGrid();
|
||
$("browseSub").textContent = state.source.length + " 部";
|
||
}
|
||
|
||
/* ---------- 设备形态 ---------- */
|
||
function detectDevice() {
|
||
var client = window.fongmiClient;
|
||
if (client) { state.isTv = !!client.isLeanback; state.isMobile = client.mode === "mobile" && !client.isLeanback; }
|
||
else {
|
||
var w = window.innerWidth || 360, coarse = false;
|
||
try { coarse = window.matchMedia && matchMedia("(pointer: coarse)").matches; } catch (e) {}
|
||
state.isMobile = w < 720 && coarse; state.isTv = false;
|
||
}
|
||
root.classList.remove("tv-mode", "mobile-mode", "browser-mode");
|
||
root.classList.add(state.isTv ? "tv-mode" : (state.isMobile ? "mobile-mode" : "browser-mode"));
|
||
}
|
||
function whenSdk() {
|
||
return new Promise(function (resolve) {
|
||
if (window.fm && window.fongmi) { resolve(true); return; }
|
||
var done = false;
|
||
function fire() { if (done) return; done = true; resolve(!!window.fm); }
|
||
window.addEventListener("fmsdk", fire);
|
||
setTimeout(function () { if (window.fm) fire(); }, 60);
|
||
setTimeout(fire, 1500);
|
||
});
|
||
}
|
||
var toastTimer = null;
|
||
function toast(msg, pos) { var t = $("toast"); t.textContent = msg; t.classList.toggle("top", pos === "top"); t.classList.add("show"); if (toastTimer) clearTimeout(toastTimer); toastTimer = setTimeout(function () { t.classList.remove("show"); }, 2200); }
|
||
|
||
/* ---------- 底部导航(首页/搜索/历史)+ 视图切换 ---------- */
|
||
var NAV_ITEMS = [
|
||
{ key: "home", name: "首页", icon: '<path d="M3 11l9-8 9 8"/><path d="M5 10v10h5v-6h4v6h5V10"/>' },
|
||
{ key: "settings", name: "设置", icon: '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06a1.65 1.65 0 00.33-1.82 1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06a1.65 1.65 0 001.82.33H9a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06a1.65 1.65 0 00-.33 1.82V9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/>' },
|
||
{ key: "search", name: "搜索", icon: '<circle cx="11" cy="11" r="7.5"/><path d="M21 21l-4.3-4.3"/>' }
|
||
];
|
||
var CAT_LIST = [
|
||
{ slug: "1", name: "电影" }, { slug: "2", name: "剧集" }, { slug: "3", name: "综艺" }, { slug: "4", name: "动漫" }
|
||
];
|
||
var TMDB_CAT_SLUG = "__tmdbtrend__";
|
||
/* 平台标签对应 TMDB watch provider id(watch_region 用 US,海外平台绝大多数以美区数据最全)。
|
||
provider 字段存在即视为"流媒体平台"标签,选中后 fetchTmdbProviderPage 会改走 discover 接口,
|
||
而不是本地对 trending 结果做语言粗筛。 */
|
||
var TMDB_TREND_GENRES = [
|
||
{ name: "全部", value: "" },
|
||
{ name: "电影", value: "电影" },
|
||
{ name: "剧集", value: "剧集" },
|
||
{ name: "Netflix", value: "netflix", provider: 8 },
|
||
{ name: "Disney+", value: "disney", provider: 337 },
|
||
{ name: "HBO", value: "hbo", provider: 1899 },
|
||
{ name: "Hulu", value: "hulu", provider: 15 },
|
||
{ name: "Prime Video", value: "prime", provider: 9 },
|
||
{ name: "Apple TV+", value: "appletv", provider: 350 }
|
||
];
|
||
function tmdbProviderConf(g) {
|
||
for (var i = 0; i < TMDB_TREND_GENRES.length; i++) if (TMDB_TREND_GENRES[i].value === g && TMDB_TREND_GENRES[i].provider) return TMDB_TREND_GENRES[i];
|
||
return null;
|
||
}
|
||
/* TMDB 热播缓存 */
|
||
var _trendingCache = null;
|
||
var _trendingCacheTs = 0;
|
||
var TRENDING_TTL = 10 * 60 * 1000; // 10 分钟
|
||
function catName(slug) { var c = CAT_LIST.filter(function (x) { return x.slug === slug; })[0]; return c ? c.name : "分类"; }
|
||
function renderChips() {
|
||
var box = $("chips"); var frag = document.createDocumentFragment();
|
||
NAV_ITEMS.forEach(function (n, i) {
|
||
var b = document.createElement("button"); b.type = "button";
|
||
b.className = "chip focusable" + (state.nav === n.key ? " active" : "");
|
||
b.innerHTML = '<svg class="chip-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' + n.icon + '</svg><span>' + n.name + '</span>';
|
||
b.setAttribute("data-nav", n.key); b.setAttribute("data-focus-row", "chips"); b.setAttribute("data-focus-index", String(i));
|
||
b.onclick = function () { onNav(n.key); };
|
||
frag.appendChild(b);
|
||
});
|
||
box.replaceChildren(frag);
|
||
}
|
||
function setNav(key) { state.nav = key; var c = $("chips").children; for (var i = 0; i < c.length; i++) c[i].classList.toggle("active", c[i].getAttribute("data-nav") === key); }
|
||
function markActiveChip() { setNav(state.nav); }
|
||
function onNav(key) {
|
||
if (key === "home") showHome();
|
||
else if (key === "search") openSearchPanel();
|
||
else if (key === "settings") { if (state.chipLpFired) { state.chipLpFired = false; return; } openSettings(); }
|
||
}
|
||
|
||
/* ---------- TMDB 热播(trending/all/week) ---------- */
|
||
/* TMDB 单条结果 -> 内部影片对象。_popularity 供平台标签(Netflix/HBO…)合并 电影+剧集 两路
|
||
discover 结果后按热度重新排序用 */
|
||
function mapTmdbTrendItem(r) {
|
||
var title = r.title || r.name || "";
|
||
var yr = (r.release_date || r.first_air_date || "").slice(0, 4);
|
||
var isMovie = r.media_type === "movie";
|
||
var poster = r.poster_path ? ("https://image.tmdb.org/t/p/w1280" + r.poster_path) : "";
|
||
return {
|
||
id: "__tmdb__" + r.id,
|
||
title: title,
|
||
poster: poster,
|
||
meta: [yr, isMovie ? "电影" : "剧集"].filter(function (x) { return x; }).join(" / "),
|
||
status: "",
|
||
year: yr,
|
||
typeName: isMovie ? "电影" : "剧集",
|
||
area: "",
|
||
_tmdbId: r.id,
|
||
_mediaType: r.media_type,
|
||
_tmdbItem: r,
|
||
_popularity: r.popularity || 0,
|
||
overview: r.overview || ""
|
||
};
|
||
}
|
||
/* discover 结果没有 media_type 字段(movie/tv 是分开查的),这里补上再复用 mapTmdbTrendItem */
|
||
function mapTmdbDiscoverItem(r, mediaType) {
|
||
r.media_type = mediaType;
|
||
return mapTmdbTrendItem(r);
|
||
}
|
||
function tmdbTrending() {
|
||
var now = Date.now();
|
||
if (_trendingCache && (now - _trendingCacheTs < TRENDING_TTL)) return Promise.resolve(_trendingCache);
|
||
if (!config.tmdbKey) return Promise.resolve([]);
|
||
return httpJson(tmdbApi("/trending/all/week", {})).then(function (d) {
|
||
var results = (d && d.results) || [];
|
||
var items = results.map(mapTmdbTrendItem).filter(function (x) { return x.title && x.poster; });
|
||
_trendingCache = items;
|
||
_trendingCacheTs = now;
|
||
return items;
|
||
}, function () { return []; });
|
||
}
|
||
/* "TMDB 热播"查看全部:走跟下面分类一样的分页/无限滚动逻辑,按 page 拉 trending/all/week。
|
||
同一页 3 分钟内重复进入直接吃缓存,翻到 TMDB 报告的最后一页就置 state.noMore */
|
||
var _tmdbCatCache = {};
|
||
function fetchTmdbTrendPage(page) {
|
||
var p = page || 1;
|
||
if (!config.tmdbKey) { state.noMore = true; return Promise.resolve([]); }
|
||
var conf = tmdbProviderConf(state.activeGenre);
|
||
if (conf) return fetchTmdbProviderPage(conf, p);
|
||
var key = "p" + p;
|
||
var hit = _tmdbCatCache[key];
|
||
if (hit && (Date.now() - hit.ts) < TRENDING_TTL) {
|
||
if (hit.noMore) state.noMore = true;
|
||
state.lastDiag = "调试:TMDB trending 第" + p + "页 · " + hit.list.length + " 条 · 缓存";
|
||
return Promise.resolve(hit.list.slice());
|
||
}
|
||
return httpJson(tmdbApi("/trending/all/week", { page: p })).then(function (d) {
|
||
var results = (d && d.results) || [];
|
||
var totalPages = (d && d.total_pages) || p;
|
||
var items = results.map(mapTmdbTrendItem).filter(function (x) { return x.title && x.poster; });
|
||
var noMoreNow = p >= totalPages;
|
||
_tmdbCatCache[key] = { list: items, ts: Date.now(), noMore: noMoreNow };
|
||
if (noMoreNow) state.noMore = true;
|
||
state.lastDiag = "调试:TMDB trending 第" + p + "/" + totalPages + "页 · " + items.length + " 条";
|
||
return items;
|
||
}, function () { state.noMore = true; return []; });
|
||
}
|
||
/* 平台标签(Netflix/HBO/Hulu…):trending 接口本身不支持按平台筛,必须换成 discover 接口,
|
||
同时分别查 电影/剧集 两路(discover 没有 all 合并入口),拉回来后按热度合并排序。
|
||
总页数取两路较大的一个,避免其中一路先到底就提前把另一路截断。 */
|
||
function fetchTmdbProviderPage(conf, p) {
|
||
var key = "prov" + conf.provider + "_p" + p;
|
||
var hit = _tmdbCatCache[key];
|
||
if (hit && (Date.now() - hit.ts) < TRENDING_TTL) {
|
||
if (hit.noMore) state.noMore = true;
|
||
state.lastDiag = "调试:TMDB " + conf.name + " 第" + p + "页 · " + hit.list.length + " 条 · 缓存";
|
||
return Promise.resolve(hit.list.slice());
|
||
}
|
||
var params = { page: p, with_watch_providers: conf.provider, watch_region: "US", sort_by: "popularity.desc" };
|
||
return Promise.all([
|
||
httpJson(tmdbApi("/discover/movie", params)),
|
||
httpJson(tmdbApi("/discover/tv", params))
|
||
]).then(function (res) {
|
||
var dMovie = res[0], dTv = res[1];
|
||
var movies = ((dMovie && dMovie.results) || []).map(function (r) { return mapTmdbDiscoverItem(r, "movie"); });
|
||
var tvs = ((dTv && dTv.results) || []).map(function (r) { return mapTmdbDiscoverItem(r, "tv"); });
|
||
var items = movies.concat(tvs).filter(function (x) { return x.title && x.poster; });
|
||
items.sort(function (a, b) { return (b._popularity || 0) - (a._popularity || 0); });
|
||
var totalPages = Math.max((dMovie && dMovie.total_pages) || p, (dTv && dTv.total_pages) || p);
|
||
var noMoreNow = p >= totalPages;
|
||
_tmdbCatCache[key] = { list: items, ts: Date.now(), noMore: noMoreNow };
|
||
if (noMoreNow) state.noMore = true;
|
||
state.lastDiag = "调试:TMDB " + conf.name + " 第" + p + "/" + totalPages + "页 · " + items.length + " 条";
|
||
return items;
|
||
}, function () { state.noMore = true; return []; });
|
||
}
|
||
/* 供轮播候选池用:跟 fetchTmdbTrendPage 吃同一份 _tmdbCatCache(key 都是 "p"+页码),
|
||
缓存过的页直接秒开、不重复占带宽;但不碰 state.noMore / state.lastDiag 这些分类页翻页专用的全局状态,
|
||
避免轮播预抓多页时把分类页的"到底了"提前标记,道理跟 fetchListOnceSilent 对站内素材的处理一样。 */
|
||
function fetchTmdbTrendPageSilent(page) {
|
||
var p = page || 1;
|
||
if (!config.tmdbKey) return Promise.resolve([]);
|
||
var key = "p" + p;
|
||
var hit = _tmdbCatCache[key];
|
||
if (hit && (Date.now() - hit.ts) < TRENDING_TTL) return Promise.resolve(hit.list.slice());
|
||
return httpJson(tmdbApi("/trending/all/week", { page: p })).then(function (d) {
|
||
var results = (d && d.results) || [];
|
||
var totalPages = (d && d.total_pages) || p;
|
||
var items = results.map(mapTmdbTrendItem).filter(function (x) { return x.title && x.poster; });
|
||
_tmdbCatCache[key] = { list: items, ts: Date.now(), noMore: p >= totalPages };
|
||
return items;
|
||
}, function () { return []; });
|
||
}
|
||
function fetchTmdbTrendPagesSilent(pages) {
|
||
if (!config.tmdbKey) return Promise.resolve([]);
|
||
return Promise.all(pages.map(function (p) { return fetchTmdbTrendPageSilent(p); })).then(function (arr) {
|
||
return arr.reduce(function (acc, a) { return acc.concat(a); }, []);
|
||
});
|
||
}
|
||
/* 供轮播候选池用:跟 fetchTmdbProviderPage 吃同一份 _tmdbCatCache(key 都是 "prov"+id+"_p"+页码),
|
||
但不碰 state.noMore / state.lastDiag —— 分类页翻页专用的全局状态,轮播这边不该动。
|
||
轮播原来只吃笼统的 trending/all/week,压根没碰过 Netflix/HBO/Disney+ 这些平台标签自己的 discover 结果,
|
||
池子小、来来回回就那几部;这个函数把某个平台的某一页拉回来,供下面混进轮播候选池。 */
|
||
function fetchTmdbProviderPageSilent(conf, page) {
|
||
var p = page || 1;
|
||
if (!config.tmdbKey) return Promise.resolve([]);
|
||
var key = "prov" + conf.provider + "_p" + p;
|
||
var hit = _tmdbCatCache[key];
|
||
if (hit && (Date.now() - hit.ts) < TRENDING_TTL) return Promise.resolve(hit.list.slice());
|
||
var params = { page: p, with_watch_providers: conf.provider, watch_region: "US", sort_by: "popularity.desc" };
|
||
return Promise.all([
|
||
httpJson(tmdbApi("/discover/movie", params)),
|
||
httpJson(tmdbApi("/discover/tv", params))
|
||
]).then(function (res) {
|
||
var dMovie = res[0], dTv = res[1];
|
||
var movies = ((dMovie && dMovie.results) || []).map(function (r) { return mapTmdbDiscoverItem(r, "movie"); });
|
||
var tvs = ((dTv && dTv.results) || []).map(function (r) { return mapTmdbDiscoverItem(r, "tv"); });
|
||
var items = movies.concat(tvs).filter(function (x) { return x.title && x.poster; });
|
||
var totalPages = Math.max((dMovie && dMovie.total_pages) || p, (dTv && dTv.total_pages) || p);
|
||
_tmdbCatCache[key] = { list: items, ts: Date.now(), noMore: p >= totalPages };
|
||
return items;
|
||
}, function () { return []; });
|
||
}
|
||
/* 把 Netflix / Disney+ / HBO / Hulu / Prime Video / Apple TV+ 这些平台标签的某一页各拉一份、合并去重,
|
||
专供轮播候选池混入——这样轮播才是真的从"全部加进去的平台"里抽,而不是只看 trending 那一小池。 */
|
||
function fetchTmdbPlatformsPoolSilent(page) {
|
||
if (!config.tmdbKey) return Promise.resolve([]);
|
||
var provs = TMDB_TREND_GENRES.filter(function (g) { return g.provider; });
|
||
return Promise.all(provs.map(function (g) { return fetchTmdbProviderPageSilent(g, page); })).then(function (arr) {
|
||
return dedupById(arr.reduce(function (acc, a) { return acc.concat(a); }, []));
|
||
});
|
||
}
|
||
/* 只读一眼 _tmdbCatCache 里"TMDB 热播"某一页还在不在有效期内,纯内存查表、不发任何请求。
|
||
给轮播用:如果用户之前逛过"TMDB 热播"分类页,这份缓存大概率还热乎着,轮播可以不等站内
|
||
电影/剧集这两路网络请求,直接先拿这批现成的顶上去,比每次都从零开始拼快得多。 */
|
||
function peekTmdbTrendCache(page) {
|
||
if (!config.tmdbKey) return null;
|
||
var hit = _tmdbCatCache["p" + (page || 1)];
|
||
if (hit && (Date.now() - hit.ts) < TRENDING_TTL) return hit.list.slice();
|
||
return null;
|
||
}
|
||
/* 点热播条目:跟点普通条目一样,先立刻用TMDB数据把详情页开起来(不等任何网络请求);
|
||
盘链搜片名挪到后台去做——搜到更准的真实资源版就原地升级(openDetail 已经会用 replace,
|
||
不会多开一层历史记录),搜不到/搜索失败就留在 TMDB 兜底版。
|
||
之前的写法是先 apiGet 搜盘链、等结果回来才 openDetail,点击那一刻界面毫无反馈,
|
||
首次加载后跟盘链 API 还没热连接(DNS/TLS 握手都要重来),这一两秒的“没反应”就是这么来的。 */
|
||
function openTrendingDetail(tmdbItem) {
|
||
var title = tmdbItem.title;
|
||
var yr = tmdbItem.year;
|
||
var fallbackItem = { id: String(tmdbItem._tmdbId) + "__tmdb", title: title, poster: tmdbItem.poster, meta: tmdbItem.meta, status: "", year: yr, typeName: tmdbItem.typeName, area: "" };
|
||
openDetail(fallbackItem);
|
||
var openToken = state.panToken; // openDetail 内部已经 panToken++,记下这一次打开的代次
|
||
apiGet(apiUrl("get_videos.php") + "?wd=" + encodeURIComponent(cleanTitle(title)) + "&pg=1&_t=" + Date.now(), "").then(function (d) {
|
||
if (openToken !== state.panToken || state.currentItem !== fallbackItem) return; // 用户已经手动切走/关闭详情页,这次搜索结果作废
|
||
var arr = (d && (d.list || d.data)) || [];
|
||
var list = arr.map(mapVod);
|
||
function score(it) {
|
||
var s = 0;
|
||
if (it.title === title) s += 8; else if (titleCloseContain(it.title, title)) s += 4;
|
||
if (yr && it.year === yr) s += 3; else if (yr && it.year && Math.abs((parseInt(it.year, 10) || 0) - (parseInt(yr, 10) || 0)) <= 1) s += 1;
|
||
return s;
|
||
}
|
||
var good = list.filter(function (it) { return score(it) >= 4; }); // 必须标题也搭上边,光年份对上不算
|
||
if (good.length) openDetail(good.slice().sort(function (a, b) { return score(b) - score(a); })[0]);
|
||
}, function () {});
|
||
}
|
||
function lockBodyScroll() {
|
||
state.scrollLockY = window.pageYOffset || document.documentElement.scrollTop || 0;
|
||
document.body.classList.add("search-lock");
|
||
document.body.style.top = (-state.scrollLockY) + "px";
|
||
}
|
||
function unlockBodyScroll() {
|
||
document.body.classList.remove("search-lock");
|
||
document.body.style.top = "";
|
||
window.scrollTo(0, state.scrollLockY || 0);
|
||
}
|
||
function openSearchPanel() {
|
||
var p = $("searchPanel"); if (!p) return;
|
||
if (!state.searchPanelOpen) {
|
||
state.searchPanelOpen = true;
|
||
lockBodyScroll();
|
||
var m = $("searchMask"); if (m) m.classList.add("open");
|
||
navigate("searchpanel");
|
||
}
|
||
p.classList.add("open");
|
||
var t = $("searchToggle"); if (t) t.classList.add("active");
|
||
setNav("search");
|
||
var s = $("search"); if (s) { s.removeAttribute("readonly"); setTimeout(function () { try { s.focus(); } catch (e) {} }, 20); }
|
||
}
|
||
function popInView(el) {
|
||
if (!el) return;
|
||
el.classList.remove("view-pop");
|
||
void el.offsetHeight; // 强制重排,让下面重新加的类名能重新触发一次 animation
|
||
el.classList.add("view-pop");
|
||
}
|
||
function showHome(fromPopstate) {
|
||
var wasPushedView = (state.view === "category" || state.view === "recent");
|
||
state.searchMode = false; state.searchKw = ""; state.view = "home";
|
||
$("homeView").style.display = ""; $("catView").style.display = "none";
|
||
$("clearSearch").style.display = "none";
|
||
$("clearRecent").style.display = "none";
|
||
// 注意:这里只对 #homeRows 做弹入动画,不能碰 #homeView 或 #heroWrap 本身——
|
||
// heroWrap 里的 .hero-blend 用了 backdrop-filter 模糊,一旦祖先元素带 transform 做动画,
|
||
// 模糊层的合成边界跟不上缩放,滚动条/轮播交界处会闪一条白线(这就是之前反馈的那个问题)。
|
||
popInView($("homeRows"));
|
||
setNav("home"); updateHeroVisible(); renderHomeRows();
|
||
// 从分类/最近观看回来时把滚动位置还原。
|
||
// 首页的行是异步填内容的,刚 render 完页面高度还不够,scrollTo 会被夹到底部,
|
||
// 所以要在后续几帧里重试,直到目标位置真的够得着。
|
||
if (wasPushedView) restoreHomeReturnSoon();
|
||
if (wasPushedView && !fromPopstate) closeRoute();
|
||
}
|
||
function restoreHomeReturnSoon() {
|
||
// 双 rAF:不在 showHome() 同一个 tick 里同步恢复,而是等浏览器/WebView 这一帧的布局、
|
||
// 绘制都做完了再恢复。如果宿主 WebView 不认 history.scrollRestoration='manual'(部分老内核
|
||
// 确实不支持这个 API),它自己那次异步的滚动恢复通常发生在本帧或下一帧,这里故意排在后面,
|
||
// 才能保证我们恢复的位置是最后生效、不被它盖掉的那一次。
|
||
requestAnimationFrame(function () { requestAnimationFrame(restoreHomeReturn); });
|
||
}
|
||
function openCategory(slug, name) {
|
||
saveHomeReturn(); // 「查看全部」原来漏了这一句,返回首页时无处可恢复
|
||
state.view = "category"; $("homeView").style.display = "none"; $("catView").style.display = "";
|
||
$("genreChips").style.display = ""; $("catTitle").textContent = name || catName(slug);
|
||
popInView($("catView"));
|
||
setNav(null); updateHeroVisible(); window.scrollTo(0, 0);
|
||
navigate("category");
|
||
selectCat(slug);
|
||
}
|
||
function showRecentView() {
|
||
saveHomeReturn();
|
||
state.view = "recent"; $("homeView").style.display = "none"; $("catView").style.display = "";
|
||
$("genreChips").style.display = "none"; $("genreChips").replaceChildren();
|
||
$("catTitle").textContent = "最近观看"; $("clearSearch").style.display = "none";
|
||
popInView($("catView"));
|
||
setNav("recent"); updateHeroVisible(); window.scrollTo(0, 0);
|
||
navigate("category");
|
||
state.activeCat = "__recent__"; state.searchMode = false;
|
||
state.reqToken++; state.loading = false; renderRecentGrid();
|
||
}
|
||
function reloadCurrent() {
|
||
if (state.view === "home") { renderHomeRows(true); loadHero(); }
|
||
else if (state.view === "recent") renderRecentGrid();
|
||
else loadList(true);
|
||
}
|
||
/* 首页多行(继续观看 + 各分类横滑) */
|
||
function fillMissingRecentPosters(row, items) {
|
||
items.forEach(function (it, idx) {
|
||
if (it.poster) return;
|
||
var t = (it.title || "").trim(); if (!t) return;
|
||
apiGet(apiUrl("get_videos.php") + "?wd=" + encodeURIComponent(t) + "&pg=1&_t=" + Date.now(), "").then(function (d) {
|
||
var arr = (d && (d.data || d.list)) || []; if (!arr.length) return;
|
||
var mapped = arr.map(mapVod);
|
||
var m = mapped.filter(function (x) { return x.title === t; })[0] || mapped[0];
|
||
if (!m || !m.poster) return;
|
||
var card = row.children[idx]; if (!card) return;
|
||
var pd = card.querySelector(".poster"); if (!pd) return;
|
||
var ph = pd.querySelector(".ph"); if (ph) ph.parentNode.removeChild(ph);
|
||
var im = pd.querySelector("img");
|
||
if (!im) { im = document.createElement("img"); im.loading = "lazy"; im.setAttribute("decoding", "async"); im.style.position = "absolute"; im.style.inset = "0"; im.style.width = "100%"; im.style.height = "100%"; im.style.objectFit = "cover"; pd.insertBefore(im, pd.firstChild); }
|
||
im.style.display = ""; im.src = imgSrc(m.poster);
|
||
persistRecentPoster(it.id, m.poster);
|
||
}, function () {});
|
||
});
|
||
}
|
||
function persistRecentPoster(id, poster) {
|
||
var changed = false;
|
||
(state.recentItems || []).forEach(function (h) { if (String(h.id) === String(id) && !h.poster) { h.poster = poster; changed = true; } });
|
||
if (changed) storeSet("recent", JSON.stringify(state.recentItems));
|
||
}
|
||
function persistRecentBackdrop(id, backdrop) {
|
||
var changed = false;
|
||
(state.recentItems || []).forEach(function (h) { if (String(h.id) === String(id) && !h.backdrop) { h.backdrop = backdrop; changed = true; } });
|
||
if (changed) storeSet("recent", JSON.stringify(state.recentItems));
|
||
}
|
||
/* 兜底:老记录没有横版剧照时,配了 TMDB Key 就后台静默补全 */
|
||
function fillMissingRecentBackdrops(row, origItems) {
|
||
if (!config.tmdbKey) return;
|
||
origItems.forEach(function (h, idx) {
|
||
if (h.backdrop) return;
|
||
var t = (h.title || "").trim(); if (!t) return;
|
||
var wantTv = /剧|集|综艺|动漫|动画/.test(h.note || "");
|
||
tmdbSearchBest(titleCandidates(t), "", wantTv).then(function (pick) {
|
||
if (!pick) return;
|
||
httpJson(tmdbApi("/" + pick.media_type + "/" + pick.id, {
|
||
append_to_response: "images", include_image_language: "zh,en,null"
|
||
})).then(function (det) {
|
||
if (!det) return;
|
||
var imgs = det.images || {};
|
||
var bdPath = tmdbBestImg(imgs.backdrops, null) || det.backdrop_path || "";
|
||
if (!bdPath) return;
|
||
var url = tmdbImgUrl("w780", bdPath);
|
||
persistRecentBackdrop(h.id, url);
|
||
// 更新首页横滑行对应卡片
|
||
var card = row.children[idx]; if (!card) return;
|
||
var pd = card.querySelector(".poster"); if (!pd) return;
|
||
var im = pd.querySelector("img");
|
||
if (!im) {
|
||
im = document.createElement("img");
|
||
im.loading = "lazy"; im.setAttribute("decoding", "async");
|
||
im.style.cssText = "position:absolute;inset:0;width:100%;height:100%;object-fit:cover;";
|
||
im.onerror = function () { im.style.display = "none"; };
|
||
var ph2 = pd.querySelector(".ph"); if (ph2) ph2.style.display = "none";
|
||
pd.insertBefore(im, pd.firstChild);
|
||
}
|
||
setTmdbImg(im, url);
|
||
}, function () {});
|
||
}, function () {});
|
||
});
|
||
}
|
||
/* 分类排序:用最近观看记录里的类型关键词(note,如"电影/剧集/动漫")做简单加权,
|
||
算出用户偏好的分类排序。收藏(state.favs)未记录类型信息,暂不参与加权。 */
|
||
function computePreferredCats() {
|
||
var freq = {};
|
||
(state.recentItems || []).forEach(function (h) {
|
||
var note = h.note || "";
|
||
CAT_LIST.forEach(function (c) { if (note.indexOf(c.name) >= 0) freq[c.slug] = (freq[c.slug] || 0) + 1; });
|
||
});
|
||
return CAT_LIST.map(function (c) { return { slug: c.slug, name: c.name, score: freq[c.slug] || 0 }; })
|
||
.sort(function (a, b) { return b.score - a.score; });
|
||
}
|
||
function showHomeSkeleton() {
|
||
var host = $("homeView"); if (!host) return;
|
||
if ($("homeSkeleton")) return; // 已经在显示
|
||
// 首页已经有「真卡片」才别盖:磁盘缓存命中时内容会先渲染出来,
|
||
// 这时候再压一层骨架屏,观感就是"加载好了→黑屏→又加载一次"。
|
||
// 注意判据必须是卡片,不能是 children.length —— 那数的是行外壳,
|
||
// 外壳里可能全是「加载中…」占位,这时候不盖反而露出一片空页。
|
||
var rows = $("homeRows");
|
||
if (rows && rows.querySelector(".card")) return;
|
||
var sk = document.createElement("div");
|
||
sk.className = "home-skeleton"; sk.id = "homeSkeleton";
|
||
var html = '<div class="sk-hero sk-shimmer"></div>';
|
||
for (var r = 0; r < 2; r++) {
|
||
html += '<div class="sk-rowtitle sk-shimmer"></div><div class="sk-cards">';
|
||
for (var c = 0; c < 5; c++) html += '<div class="sk-card"><div class="sk-poster sk-shimmer"></div><div class="sk-line w70 sk-shimmer"></div><div class="sk-line w45 sk-shimmer"></div></div>';
|
||
html += '</div>';
|
||
}
|
||
sk.innerHTML = html;
|
||
host.style.position = host.style.position || "relative";
|
||
host.appendChild(sk);
|
||
}
|
||
function hideHomeSkeleton() {
|
||
var sk = $("homeSkeleton"); if (!sk) return;
|
||
if (state.isTv) { sk.parentNode.removeChild(sk); return; } // 没有淡出动画,直接摘掉,不用等
|
||
sk.classList.add("hiding");
|
||
setTimeout(function () { if (sk && sk.parentNode) sk.parentNode.removeChild(sk); }, 500);
|
||
}
|
||
function renderHomeRows(force) {
|
||
var box = $("homeRows"); if (!box) return;
|
||
// 从分类/详情返回首页时,如果内容没变化,直接复用现有 DOM,不重建、不重发请求(返回即秒回)
|
||
var sig = (state.recentItems || []).map(function (h) { return h.id; }).join(",") + "|" + (config.tmdbKey ? 1 : 0);
|
||
if (!force && box.children.length && state._homeSig === sig) { startHomePrefetch(); hideHomeSkeleton(); return; }
|
||
state._homeSig = sig;
|
||
if (force) { // 下拉刷新 / 换源:所有缓存作废,这次全部重抓
|
||
state.homeCatCache = {}; state.zn4kColCache = {}; state.qwCatCache = {}; state.wnCache = {}; state.shCache = {}; state.woCache = {};
|
||
_homeDiskTs = 0; clearTextMemo();
|
||
}
|
||
resetPrefetch(); // 旧的一批预取任务指向已被移除的行,作废
|
||
box.replaceChildren();
|
||
if (state.recentItems && state.recentItems.length) {
|
||
var rec = state.recentItems.map(function (h) { return { id: String(h.id), title: h.title || "", poster: h.backdrop || h.poster || "", status: "", year: "", typeName: "", meta: "", payload: h.payload }; });
|
||
var recShell = makeRowShell("继续观看", showRecentView);
|
||
recShell.row.classList.add("mh-row-recent");
|
||
box.appendChild(recShell.section);
|
||
fillRow(recShell.row, rec, function (it) { openDetail(it); });
|
||
fillMissingRecentPosters(recShell.row, rec);
|
||
fillMissingRecentBackdrops(recShell.row, state.recentItems);
|
||
}
|
||
/* TMDB 热播行(仅配了 TMDB Key 时才显示) */
|
||
if (config.tmdbKey) {
|
||
var trendShell = makeRowShell("TMDB 热播", function () { openCategory(TMDB_CAT_SLUG, "TMDB 热播"); });
|
||
box.appendChild(trendShell.section);
|
||
tmdbTrending().then(function (items) {
|
||
if (!items.length) { trendShell.row.replaceChildren(el("div", "mh-row-empty", "暂无数据")); return; }
|
||
var frag = document.createDocumentFragment();
|
||
items.slice(0, 12).forEach(function (it, i) {
|
||
var card = document.createElement("button");
|
||
card.type = "button"; card.className = "card focusable";
|
||
card.setAttribute("data-card-index", String(i));
|
||
card.setAttribute("data-focus-row", "row");
|
||
var poster = document.createElement("div"); poster.className = "poster";
|
||
var img = document.createElement("img");
|
||
img.alt = it.title; img.loading = i < 4 ? "eager" : "lazy"; img.setAttribute("decoding", "async");
|
||
setTmdbImg(img, it.poster);
|
||
poster.appendChild(img);
|
||
var badge = document.createElement("span"); badge.className = "badge mono"; badge.textContent = it.typeName || "HD";
|
||
poster.appendChild(badge);
|
||
card.appendChild(poster);
|
||
var titleEl = document.createElement("div"); titleEl.className = "card-title"; titleEl.textContent = it.title; card.appendChild(titleEl);
|
||
var metaEl = document.createElement("div"); metaEl.className = "card-meta"; metaEl.textContent = it.meta || ""; card.appendChild(metaEl);
|
||
card.onclick = (function (x) { return function () { openTrendingDetail(x); }; })(it);
|
||
frag.appendChild(card);
|
||
});
|
||
trendShell.row.replaceChildren(frag);
|
||
}, function () { trendShell.row.replaceChildren(el("div", "mh-row-empty", "加载失败")); });
|
||
}
|
||
/* 豆瓣高分(4K指南):独立一行,位置在 TMDB 热播 和 电影 之间 */
|
||
renderZn4kTop250(box);
|
||
renderQwHomeRow(box);
|
||
var preferredCats = computePreferredCats();
|
||
var HOME_FIRST = 8; // 首屏每行先展示这么多,图片少、上屏快
|
||
preferredCats.forEach(function (c, ci) {
|
||
var shell = makeRowShell(c.name, function () { openCategory(c.slug, c.name); });
|
||
box.appendChild(shell.section);
|
||
var cached = state.homeCatCache[c.slug];
|
||
var hasCache = !!(cached && cached.length);
|
||
var load = function () {
|
||
if (state.view !== "home" || !document.body.contains(shell.row)) return null; // 已经离开首页就不用再抓了
|
||
return fetchListOnce(c.slug, 1).then(function (list) {
|
||
if (!document.body.contains(shell.row)) return;
|
||
state.homeCatCache[c.slug] = list || [];
|
||
saveHomeDisk();
|
||
fillRow(shell.row, (list || []).slice(0, HOME_FIRST), function (it) { openDetail(it); });
|
||
warmupCategoryHome(c.slug, list || [], shell.row, HOME_FIRST);
|
||
}, function () { if (!hasCache) shell.row.replaceChildren(el("div", "mh-row-empty", "加载失败")); });
|
||
};
|
||
if (hasCache) {
|
||
// 磁盘/内存里已有上次的结果:先秒显,缓存旧了才在后台悄悄重抓一遍覆盖
|
||
fillRow(shell.row, cached.slice(0, HOME_FIRST), function (it) { openDetail(it); });
|
||
if (homeCacheStale()) homePrefetch(load);
|
||
return;
|
||
}
|
||
setTimeout(load, ci === 0 ? 0 : ci * 60); // 第一行立刻发请求;其余错开一点,不与首行抢带宽
|
||
});
|
||
/* 蜗牛(zmi.kdns.fr):网盘资源站 */
|
||
renderWnRow(box);
|
||
/* 森中有林 / SeedHub(sidhub.cc):网盘资源站 */
|
||
renderShRow(box);
|
||
/* 玩偶 / 木偶(MacCMS 网盘明链站) */
|
||
woAll().forEach(function (st) { renderWoRow(box, st); });
|
||
/* 合集(指南站各系列合集)——放在首页最底部 */
|
||
renderZn4kCollections(box);
|
||
// 首屏的分类请求已经发出去了,稍等一下再开后台预取队列(抓网盘站那几行),不跟首屏抢带宽。
|
||
// 有缓存时首屏本来就不打网络,可以更早开始。
|
||
setTimeout(startHomePrefetch, homeCacheStale() ? 700 : 150);
|
||
// 收骨架屏的条件必须是「真的有卡片」,不能只看行外壳。
|
||
// 首页 12 行外壳是先建好、里面填「加载中…」占位的,卡片才是异步填进去的;
|
||
// 早先按 box.children.length 判断,磁盘缓存为空时外壳一建好就收屏,
|
||
// 露出的是满屏「加载中…」的深色空页——看着就是黑屏,卡片到位后又像重加载了一次。
|
||
hideSkeletonWhenContent();
|
||
}
|
||
/* 等到首页真的出现卡片再收骨架屏;一直没有内容就交给 boot 里的 6 秒兜底 */
|
||
function hideSkeletonWhenContent() {
|
||
if (!$("homeSkeleton")) return;
|
||
var box = $("homeRows"); if (!box) return;
|
||
if (box.querySelector(".card")) { hideHomeSkeleton(); return; }
|
||
if (state._skWatch) return; // 已经有一个在等了,别开第二个
|
||
state._skWatch = setInterval(function () {
|
||
var b = $("homeRows");
|
||
if (!$("homeSkeleton") || !b || b.querySelector(".card")) {
|
||
clearInterval(state._skWatch); state._skWatch = 0;
|
||
hideHomeSkeleton();
|
||
}
|
||
}, 120);
|
||
}
|
||
/* 首页某一行先展示前 HOME_FIRST 个之后:
|
||
1) 该分类第 1 页里剩下的(已经抓到手、只是还没上屏的)稍等一下自动补进这一行;
|
||
2) 紧接着悄悄再多抓 1 页放进缓存,供用户点“查看全部”时秒开用,不占首屏时间 */
|
||
function warmupCategoryHome(slug, page1List, row, firstCount) {
|
||
var more = state.homeCatMore[slug] || (state.homeCatMore[slug] = { items: [], nextPage: 1, noMore: false, fetching: false });
|
||
more.items = (page1List || []).slice(); more.nextPage = 2; more.noMore = (page1List || []).length < firstCount;
|
||
if (page1List && page1List.length > firstCount) {
|
||
setTimeout(function () {
|
||
if (state.view !== "home" || !row || !document.body.contains(row)) return;
|
||
var frag = document.createDocumentFragment();
|
||
page1List.slice(firstCount).forEach(function (it, i) { frag.appendChild(mediaCard(it, firstCount + i, "row", function () { openDetail(it); })); });
|
||
row.appendChild(frag);
|
||
}, 260);
|
||
}
|
||
if (!more.noMore) homePrefetch(function () { fetchMoreHomePages(slug, 2); return null; }); // 续页预抓排进后台队列,不跟首屏抢带宽
|
||
}
|
||
/* 悄悄给某分类续抓 pagesToFetch 页,塞进 state.homeCatMore 缓存,不改动首页已经展示的内容 */
|
||
function fetchMoreHomePages(slug, pagesToFetch) {
|
||
var more = state.homeCatMore[slug]; if (!more || more.noMore || more.fetching) return;
|
||
more.fetching = true;
|
||
var page = more.nextPage;
|
||
fetchListOnceSilent(slug, page).then(function (list) {
|
||
more.fetching = false;
|
||
if (!list || !list.length) { more.noMore = true; return; }
|
||
more.items = more.items.concat(list);
|
||
more.nextPage = page + 1;
|
||
if (pagesToFetch > 1) fetchMoreHomePages(slug, pagesToFetch - 1);
|
||
}, function () { more.fetching = false; });
|
||
}
|
||
function makeRowShell(title, onMore) {
|
||
var section = el("div", "mh-section");
|
||
var head = el("div", "mh-section-head");
|
||
head.appendChild(el("div", "mh-section-title", title));
|
||
if (onMore) {
|
||
var more = document.createElement("button"); more.type = "button"; more.className = "mh-more focusable";
|
||
more.innerHTML = '查看全部<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M9 6l6 6-6 6"/></svg>';
|
||
more.onclick = onMore; head.appendChild(more);
|
||
}
|
||
section.appendChild(head);
|
||
var row = el("div", "mh-row"); row.appendChild(el("div", "mh-row-empty", "加载中…")); section.appendChild(row);
|
||
return { section: section, row: row };
|
||
}
|
||
function fillRow(row, items, onClick, onDelete) {
|
||
row.replaceChildren();
|
||
if (!items.length) { row.appendChild(el("div", "mh-row-empty", "暂无内容")); return; }
|
||
items.forEach(function (it, i) { row.appendChild(mediaCard(it, i, "row", (function (x) { return function () { onClick(x); }; })(it), onDelete)); });
|
||
}
|
||
function makeRow(title, items, onMore, onClick) {
|
||
var shell = makeRowShell(title, onMore);
|
||
fillRow(shell.row, items, onClick);
|
||
return shell.section;
|
||
}
|
||
function selectCat(slug) {
|
||
state.searchMode = false; state.searchKw = "";
|
||
state.activeCat = slug; state.activeGenre = "";
|
||
$("genreChips").replaceChildren();
|
||
if (slug === "__recent__") { state.reqToken++; state.loading = false; renderRecentGrid(); return; }
|
||
$("clearSearch").style.display = "none";
|
||
$("clearRecent").style.display = "none";
|
||
var warm = state.homeCatMore[slug];
|
||
if (warm && warm.items && warm.items.length) {
|
||
// 首页已经在后台悄悄抓过这个分类了,直接拿缓存秒开,不用再等一次网络请求
|
||
state.reqToken++; state.page = warm.nextPage;
|
||
state.source = warm.items.slice(); state.rendered = 0; state.noMore = warm.noMore;
|
||
state.autoPages = 0; state.loading = false;
|
||
$("grid").replaceChildren(); $("grid").classList.remove("grid-recent"); $("browseNotice").replaceChildren();
|
||
appendGrid();
|
||
renderGenreChips();
|
||
$("browseSub").textContent = state.source.length + " 部";
|
||
// 展示完缓存内容后,紧接着再往后多接一页,让用户往下滑时基本不用等
|
||
if (!state.noMore) loadList(false);
|
||
return;
|
||
}
|
||
loadList(true);
|
||
}
|
||
|
||
/* ---------- 列表 / 搜索 加载 ---------- */
|
||
/* 搜索兜底:盘链无有效搜索接口,逐页扫 get_videos.php + 本地按片名过滤,边扫边出结果 */
|
||
/* 搜索:优先直接复用联想接口的完整结果(联想能出的说明这个接口是准的,直接铺满结果列表,不用一页页慢慢扫) */
|
||
function searchViaSuggestAPI(kw) {
|
||
var k = (kw || "").trim(); if (!k) return;
|
||
state.page = 1; state.source = []; state.rendered = 0; state.noMore = false; state.autoPages = 0; state.loading = false;
|
||
state.reqToken++; var token = state.reqToken;
|
||
$("grid").replaceChildren(); $("grid").classList.remove("grid-recent"); $("browseNotice").replaceChildren();
|
||
$("browseLoading").style.display = "block";
|
||
$("browseSub").textContent = "加载中";
|
||
// 一进来就并发去 TMDB 搜,不等站内搜完;等站内那条路走到底再拿这份结果做合并(同名的丢掉,只补站内没有的)。
|
||
var tmdbP = tmdbSearchList(k);
|
||
// 同时并发搜 4K指南。结果不单独分站源展示:同名的只保留盘链自己的卡片(指南的网盘资源会在
|
||
// 详情页由 zn4kEnrich 合并进「网盘资源」),只有盘链完全没有的片子才补一张指南来源的卡片。
|
||
var zn4kP = zn4kSearch(k).then(function (list) {
|
||
return (list || []).map(function (m) { m._zn4k = true; m._zn4kId = m.id; return m; });
|
||
}, function () { return []; });
|
||
// 七味同理:并发搜,同名的不另开卡片(它的网盘/磁力会在详情页由 qwEnrich 并进资源),
|
||
// 只有盘链、指南都没有的片子才补一张七味来源卡片。
|
||
var qwP = qwSearch(k).then(function (list) {
|
||
return (list || []).map(function (m) { m._qw = true; m._qwId = m.id; return m; });
|
||
}, function () { return []; });
|
||
// 枫叶(在线点播站)同理:同名的不另开卡片(它的播放线路会在详情页并进资源列表),
|
||
// 只有盘链、指南都没有的片子才补一张枫叶卡片——它即点即播,比纯 TMDB 信息卡有用。
|
||
var wnP = wnSearch(k).then(function (list) { return list || []; }, function () { return []; });
|
||
var shP = shSearch(k).then(function (list) { return list || []; }, function () { return []; });
|
||
var woPs = woAll().map(function (st) {
|
||
return woSearch(st, k).then(function (list) { return list || []; }, function () { return []; });
|
||
});
|
||
// 站内那条路(联想 / wd / 慢扫)走到终点时统一调它:把 TMDB 里站内没有的补在下面。
|
||
// 之前这里是 Promise.all(全部7路) 等全部结束才一次性 filter+渲染——网络上虽然是并发发出去的,
|
||
// 但显示是"卡"死在最慢那一路上:随便一路慢/卡住,其余6路哪怕早就回来了也一起被拖着不显示,
|
||
// 看着就像"1不出,2345都不出"。改成每一路各自 .then 各自处理、各自到就各自渲染一次,
|
||
// 谁先回来谁先补上屏幕,不用互相等。
|
||
// 顺带修了个之前漏掉的问题:woAll() 实际有三个站(玩偶/木偶/pomo),但原来只手写读了
|
||
// arr[4]/arr[5] 两个位置(玩偶、木偶),第三个 pomo(arr[6])从来没被读取过——等于 pomo
|
||
// 请求白发了,搜到东西也从来没显示过。这里改成跟着 woAll() 的实际站点数动态处理,不再写死几路。
|
||
function mergeTmdb() {
|
||
state.noMore = true; // 合并结果不分页,提前定住,避免触底逻辑误触发
|
||
var hadLocal = state.source.length;
|
||
// 不再跨来源去重:每个站的结果都独立成组显示,哪怕跟盘链/玩偶重名也各自保留(去重只在每个站内部做)。
|
||
var jobs = [
|
||
{ name: "4K指南", p: zn4kP },
|
||
{ name: "七味", p: qwP },
|
||
{ name: "蜗牛", p: wnP },
|
||
{ name: "SeedHub", p: shP }
|
||
];
|
||
woAll().forEach(function (st, i) { jobs.push({ name: st.name, p: woPs[i] }); });
|
||
jobs.push({ name: "TMDB", p: tmdbP, isTmdb: true }); // TMDB 不做 titleScore 过滤,保留原逻辑
|
||
var settled = 0, extraCounts = {};
|
||
function updateStatus() {
|
||
var extra = jobs.filter(function (j) { return extraCounts[j.name]; })
|
||
.map(function (j) { return "+" + extraCounts[j.name] + " " + j.name; });
|
||
var total = state.source.length;
|
||
$("browseSub").textContent = hadLocal
|
||
? (hadLocal + " 部" + (extra.length ? " · " + extra.join(" ") : ""))
|
||
: (total ? (total + " 部" + (extra.length ? " · " + extra.join(" ") : "")) : "");
|
||
}
|
||
// TMDB 只是信息卡兜底(没有可播资源):让资源站先各自渲染,TMDB 这一路排在最后再显示,
|
||
// 保证它出现在结果最下面(顺序上排最后),不抢在资源站前面。
|
||
var otherPs = jobs.filter(function (j) { return !j.isTmdb; }).map(function (j) { return j.p; });
|
||
jobs.forEach(function (job) {
|
||
var readyP = job.isTmdb ? Promise.all(otherPs).then(function () { return job.p; }) : job.p;
|
||
readyP.then(function (list) {
|
||
if (token !== state.reqToken) return; // 搜索词已经变了,这份结果作废
|
||
var localSeen = {};
|
||
var add = (list || []).filter(function (it) {
|
||
var key = normTitleKey(it.title);
|
||
if (!key || localSeen[key]) return false; // 只在本站结果内部去重,避免同一个站重复同名
|
||
if (!job.isTmdb && titleScore(it.title, k) > 3) return false; // 相关性过滤保留:跟搜索词太不相关的才丢
|
||
localSeen[key] = 1; return true;
|
||
});
|
||
settled++;
|
||
if (add.length) {
|
||
state.source = state.source.concat(sortByRelevance(add, k));
|
||
extraCounts[job.name] = (extraCounts[job.name] || 0) + add.length;
|
||
renderSearchGrid(); renderSearchSourceChips();
|
||
}
|
||
if (settled === jobs.length && !state.source.length) { renderEmpty(""); return; }
|
||
updateStatus();
|
||
});
|
||
});
|
||
}
|
||
function finish(items) {
|
||
if (token !== state.reqToken) return;
|
||
state.loading = false; $("browseLoading").style.display = "none";
|
||
state.noMore = true;
|
||
state.source = (items && items.length) ? sortByRelevance(items, k) : []; // 完全同名的排最前,别被 xx僵尸 淹没
|
||
if (state.source.length) { renderSearchGrid(); renderSearchSourceChips(); $("browseSub").textContent = state.source.length + " 部"; }
|
||
mergeTmdb(); // 无论站内有没有,都拿并发那份 TMDB 补一遍
|
||
}
|
||
apiGet(apiUrl("search_suggestions.php") + "?q=" + encodeURIComponent(k) + "&_t=" + Date.now(), "").then(function (d) {
|
||
if (token !== state.reqToken) return;
|
||
var raw = (d && (d.data || d.suggestions || d.list)) || [];
|
||
var items = normSuggest(raw, k).filter(function (it) { return it.id && !it.keyword; });
|
||
if (items.length) { finish(items); return; }
|
||
fallbackWd();
|
||
}, function () { fallbackWd(); });
|
||
function fallbackWd() {
|
||
if (token !== state.reqToken) return;
|
||
apiGet(apiUrl("get_videos.php") + "?wd=" + encodeURIComponent(k) + "&pg=1&_t=" + Date.now(), "").then(function (d) {
|
||
if (token !== state.reqToken) return;
|
||
var list = (d && (d.data || d.list)) || [];
|
||
var seen = {}, uniq = [];
|
||
list.map(mapVod).forEach(function (it) { if (it.title.indexOf(k) >= 0 && !seen[it.id]) { seen[it.id] = 1; uniq.push(it); } });
|
||
if (uniq.length) { finish(uniq); return; }
|
||
// 两个快接口都没找到,最后才用慢扫兜底;扫完由 mergeTmdb 把 TMDB 补上
|
||
state.loading = false; $("browseLoading").style.display = "none";
|
||
runSearchScan(k, token, mergeTmdb);
|
||
}, function () {
|
||
state.loading = false; $("browseLoading").style.display = "none";
|
||
runSearchScan(k, token, mergeTmdb);
|
||
});
|
||
}
|
||
}
|
||
function runSearchScan(kw, extToken, onDone) {
|
||
var k = (kw || "").trim(); if (!k) return;
|
||
state.page = 1; state.source = []; state.rendered = 0; state.noMore = false; state.autoPages = 0; state.loading = false;
|
||
// 有外层 token 就复用(并发合并要求前后用同一个 token),没有才自增
|
||
var token = extToken || (++state.reqToken);
|
||
var grid = $("grid"); grid.replaceChildren(); grid.classList.remove("grid-recent"); $("browseNotice").replaceChildren();
|
||
$("browseLoading").style.display = "block";
|
||
$("browseSub").textContent = "扫描中…";
|
||
var CAP = 40; // 最多扫多少页
|
||
var CONC = 6; // 除首页外,其余页用这个并发度同时拉,别一次把几十个请求全炸出去
|
||
var finished = false;
|
||
function done() {
|
||
if (finished) return; finished = true;
|
||
if (token !== state.reqToken) return;
|
||
state.noMore = true; state.loading = false; $("browseLoading").style.display = "none";
|
||
if (state.source.length) {
|
||
// 并发扫回来的顺序不等于页码顺序,统一重排一次,让完全同名的(那部《僵尸》)浮到最前
|
||
state.source = sortByRelevance(state.source, k);
|
||
grid.replaceChildren(); state.rendered = 0; renderSearchGrid(); renderSearchSourceChips();
|
||
}
|
||
$("browseSub").textContent = state.source.length ? (state.source.length + " 部") : "";
|
||
if (onDone) onDone(); // 站内扫完,交给外层用并发那份 TMDB 补站内没有的
|
||
}
|
||
function fetchPage(pg) {
|
||
return apiGet(apiUrl("get_videos.php") + "?pg=" + pg + "&q=" + encodeURIComponent(k) + "&_t=" + Date.now(), "");
|
||
}
|
||
// 把一页的结果并进 state.source + 渲染;返回该页汇报的总页数 & 服务器是否已按 q 过滤
|
||
function ingest(d, pg) {
|
||
if (token !== state.reqToken) return { pc: pg, serverFiltered: false };
|
||
var raw = ((d && (d.list || d.data)) || []).map(mapVod);
|
||
var pc = (d && d.pagecount) || pg;
|
||
var serverFiltered = raw.length > 0 && raw.every(function (it) { return it.title.indexOf(k) >= 0; });
|
||
var have = {}; state.source.forEach(function (s) { have[s.id] = 1; });
|
||
var hits = raw.filter(function (it) { return it.title.indexOf(k) >= 0 && !have[it.id]; });
|
||
if (hits.length) {
|
||
state.source = state.source.concat(hits);
|
||
var frag = document.createDocumentFragment();
|
||
hits.forEach(function (it) { frag.appendChild(mediaCard(it, state.rendered++)); });
|
||
grid.appendChild(frag);
|
||
$("browseNotice").replaceChildren();
|
||
}
|
||
return { pc: pc, serverFiltered: serverFiltered };
|
||
}
|
||
// 第一页单独打:既要看结果,也要靠它判断服务器是不是已经按 q 过滤了(判定了就不用再扫别的页)、
|
||
// 以及总页数是多少(决定后面并发池要跑几页)。
|
||
fetchPage(1).then(function (d) {
|
||
if (token !== state.reqToken) return;
|
||
var meta = ingest(d, 1);
|
||
state.lastDiag = "调试:搜“" + k + "” 扫 1/" + Math.min(meta.pc, CAP) + "页 · 找到 " + state.source.length + " 部";
|
||
$("browseSub").textContent = state.source.length ? (state.source.length + " 部 · 扫描中") : ("扫描中 1/" + Math.min(meta.pc, CAP));
|
||
if (meta.serverFiltered) { done(); return; } // 服务器其实按 q 过滤了 -> 一页即可,不用再扫
|
||
var lastPage = Math.min(meta.pc, CAP);
|
||
if (lastPage <= 1) { done(); return; }
|
||
// 剩下第 2..lastPage 页,起一个小并发池同时拉,取代原来"等上一页回来才发下一页"的串行扫描
|
||
var pages = []; var p2; for (p2 = 2; p2 <= lastPage; p2++) pages.push(p2);
|
||
var idx = 0, finishedCount = 0, totalJobs = pages.length;
|
||
function worker() {
|
||
if (idx >= pages.length || token !== state.reqToken) return Promise.resolve();
|
||
var pg = pages[idx++];
|
||
return fetchPage(pg).then(function (d2) {
|
||
if (token === state.reqToken) ingest(d2, pg);
|
||
}, function () { /* 单页失败不影响其它页,跳过即可 */ }).then(function () {
|
||
finishedCount++;
|
||
if (token === state.reqToken) {
|
||
$("browseSub").textContent = state.source.length
|
||
? (state.source.length + " 部 · 扫描中 " + finishedCount + "/" + totalJobs)
|
||
: ("扫描中 " + finishedCount + "/" + totalJobs);
|
||
}
|
||
return worker();
|
||
});
|
||
}
|
||
var workers = [], w; for (w = 0; w < Math.min(CONC, pages.length); w++) workers.push(worker());
|
||
Promise.all(workers).then(done);
|
||
}, function () { if (token === state.reqToken) done(); });
|
||
}
|
||
/* TMDB 搜索补充:每次搜索都并发调 TMDB /search/multi(不等站内、不看站内命中数),
|
||
站内那条路走完后再把 TMDB 里站内没有的补在结果下面。
|
||
不要求配 TMDB Key(没配就静默跳过,直接空结果)。结果标题带年份,供用户自己甄别是不是想要的那部;
|
||
点击后走跟"TMDB 热播"一样的套路——openTrendingDetail 会先用 TMDB 数据把详情页开起来,
|
||
后台再拿片名去盘链反查,搜到就原地升级成真实可播资源,搜不到就留在 TMDB 信息页(无法播放)。*/
|
||
function tmdbSearchList(kw) {
|
||
if (!config.tmdbKey) return Promise.resolve([]);
|
||
var k = (kw || "").trim(); if (!k) return Promise.resolve([]);
|
||
// 跟 Eclipse 一样分开查 movie + tv(各 20 条名额),比 /search/multi 更容易把冷门的完全同名片捞回来
|
||
var mvP = httpJson(tmdbApi("/search/movie", { query: k, include_adult: "false" })).then(function (d) { return (d && d.results) || []; }, function () { return []; });
|
||
var tvP = httpJson(tmdbApi("/search/tv", { query: k, include_adult: "false" })).then(function (d) { return (d && d.results) || []; }, function () { return []; });
|
||
return Promise.all([mvP, tvP]).then(function (arr) {
|
||
var all = arr[0].map(function (r) { return mapTmdbDiscoverItem(r, "movie"); })
|
||
.concat(arr[1].map(function (r) { return mapTmdbDiscoverItem(r, "tv"); }));
|
||
all.sort(function (a, b) { return (b._popularity || 0) - (a._popularity || 0); });
|
||
var seen = {}, out = [];
|
||
all.forEach(function (x) {
|
||
if (!x.title || !x.poster) return;
|
||
var key = normTitleKey(x.title) + "|" + (x.year || "");
|
||
if (seen[key]) return; seen[key] = 1; out.push(x);
|
||
});
|
||
return out;
|
||
});
|
||
}
|
||
/* 标题归一化:去空白/常见括号符号 + 小写,用来判断 TMDB 结果和站内结果是不是同一部(去重用)。 */
|
||
/* 归一化标题做去重 key:先净化站点 SEO 后缀,再去掉年份/季号/画质/空格标点,
|
||
这样"爱情公寓2(2011)"和"立刻播放爱情公寓2"会归一成同一个 key,自动合并成一部。 */
|
||
function normTitleKey(t) {
|
||
var s = sanitizeSiteTitle(String(t || ""));
|
||
s = s.replace(/[((]\s*(?:19|20)\d{2}\s*[))]/g, "").replace(/(?:19|20)\d{2}/g, ""); // 去年份
|
||
s = s.replace(/第\s*[0-9〇一二两三四五六七八九十百]+\s*[季部]/g, ""); // 去"第X季"
|
||
return s.replace(/[\s《》「」【】\[\]()()·::\-—_.]/g, "").toLowerCase();
|
||
}
|
||
/* 判断一段简介是不是站点的 SEO 营销话术(不是真剧情),是的话就别往详情页塞——
|
||
典型特征:含"点击获取/点击播放/磁力链接/提供修复/资源+/扫码/关注"等推广词,或整体太短。 */
|
||
function isJunkOverview(t) {
|
||
t = String(t || "").trim();
|
||
if (!t) return true;
|
||
if (/点击(获取|播放|观看|下载)|扫码|关注|公众号|提供修复|资源\+|磁力链接|networkdisk|获取\d?K?资源|群|加入我们|更多资源|在线播放|立即观看/i.test(t)) return true;
|
||
return false;
|
||
}
|
||
/* 站点卡片标题净化:去掉各站为 SEO 塞在片名后的营销词/站名后缀,
|
||
比如"XXX立即播放"、"XXX在线观看"、"XXX迅雷下载"、"XXX - SeedHub"、"XXX详情介绍"等,
|
||
只保留真正的片名。搜索结果、卡片、详情标题都用它,避免脏字进片名、也帮 TMDB 匹配。 */
|
||
function sanitizeSiteTitle(t) {
|
||
t = String(t || "");
|
||
// 去开头的营销前缀:"立刻播放/立即播放/在线观看XXX" 里的动词前缀
|
||
t = t.replace(/^\s*(?:立刻播放|立即播放|立刻观看|立即观看|在线播放|在线观看|免费观看|免费播放|高清观看|点击播放|极速播放|手机观看)\s*/g, "");
|
||
// 去站名/分隔后缀: - SeedHub | 蜗牛 | 影视&动漫分享 之类
|
||
t = t.replace(/\s*[-_|·—]\s*(?:SeedHub|蜗牛|木偶|玩偶|原盘高清|影视.*$|.*分享.*$).*$/i, "");
|
||
// 去常见 SEO 尾巴(从这些词开始,后面整段砍掉)
|
||
t = t.replace(/(立即播放|立刻播放|立即观看|立刻观看|在线播放|在线观看|高清观看|免费观看|免费播放|极速播放|手机观看|点击播放|迅雷下载|BT下载|磁力下载|全集观看|全集播放|详情介绍|剧情介绍|完整版在线|高清在线|_高清|—高清).*$/g, "");
|
||
// 去残留的书名号包裹(有的站把片名包在《》里再接后缀)
|
||
var mm = t.match(/《([^》]{1,40})》/); if (mm) t = mm[1];
|
||
return t.replace(/\s+/g, " ").trim();
|
||
}
|
||
/* 网盘条目行标题净化:玩偶/木偶这类站,条目标题原样是页面上"复制链接"按钮的文案,
|
||
形如"复制《金谍行动》第1集下载地址"、"复制《金谍行动》玩偶哥哥地址"——带着"复制/地址"这些操作性
|
||
文字,观感很怪。直接取《》里包着的片名展示("金谍行动"),比集数/分组名更直观。只用于展示,
|
||
不改 item.title 本身(原始文本还要拿来跟片名比对、判断是不是别的片子)。 */
|
||
function cleanPanRowTitle(t) {
|
||
var orig = String(t || "").trim();
|
||
if (!orig) return orig;
|
||
var mk = orig.match(/[《【]([^》】]{1,40})[》】]/);
|
||
if (mk && mk[1].trim()) return mk[1].trim();
|
||
var s = orig;
|
||
s = s.replace(/^\s*(?:复制|点击复制|一键复制|获取)\s*/, ""); // 去掉开头的操作动词
|
||
s = s.replace(/\s*(?:下载地址|地址|下载链接|链接|下载)\s*$/, ""); // 去掉结尾的"地址/链接/下载"
|
||
s = s.replace(/\s+/g, " ").trim();
|
||
return s || orig; // 清没了就回退原文,别显示空白
|
||
}
|
||
/* 相关度打分:完全同名 < 以关键词开头 < 以关键词结尾 < 中间包含 < 不含。数字越小越靠前。
|
||
比较用归一化后的标题,避免《僵尸》和 僵尸 因书名号被判成不一致。 */
|
||
function titleScore(t, k) {
|
||
var nt = normTitleKey(t), nk = normTitleKey(k);
|
||
if (!nk) return 4;
|
||
if (nt === nk) return 0;
|
||
if (nt.indexOf(nk) === 0) return 1;
|
||
if (nt.length >= nk.length && nt.slice(nt.length - nk.length) === nk) return 2;
|
||
// “中间包含”只有在多出的字数很少(≤3,容个别集数/清晰度尾巴)时才算同一部;
|
||
// 差太多说明是完全不同的作品,标题只是凑巧含有这几个字(比如搜“幸运女神”
|
||
// 命中《刚下山被幸运女神拉去领证》),不能当匹配处理,否则会把风马牛不相及的
|
||
// 网盘资源并进详情页。
|
||
if (nt.indexOf(nk) >= 0 && (nt.length - nk.length) <= 3) return 3;
|
||
return 4;
|
||
}
|
||
/* 网盘资源标题校验:关键词必须出现在资源标题靠前的位置(允许最前面有《「"『【[ 等一两个
|
||
包裹符号),才认为这条资源确实是这部片子的。像"刚下山被幸运女神拉去领证"这种关键词被埋在
|
||
一大段无关内容后面的,直接判定不是同一部——真正对应的资源标题结构基本都是"片名 + 集数/
|
||
清晰度/发布人尾巴",片名不会被塞在别的完整句子中间。 */
|
||
/* 跟在片名后面的"发行版本/配音/修复"类后缀词:这些是同一部片的不同发行版本,
|
||
不是新片名的一部分。用来把"功夫修复版/功夫数码修复版/功夫粤语版"里的版本尾巴剥掉,
|
||
避免它们被下面的"同头不同片"判定误伤。长词在前,保证 $ 锚定时优先吃掉最长的一段。 */
|
||
var EDITION_TAIL_RE = /(数码修复版?|数字修复版?|数码修复|数字修复|修复版?|重制版?|重置版?|国配版?|中配版?|港版|台版|美版|日版|内地版?|大陆版?|完全版?|完整版?|特别版?|纪念版?|典藏版?|珍藏版?|收藏版?|导演剪辑版?|导演版?|剧场版?|无删减版?|未删减版?|加长版?|双语版?|国语版?|粤语版?|中字版?|高清版?|超清版?|清晰版?|无水印版?|重映版?|重映|洗版|版)$/;
|
||
function resourceTitleLikelyMatches(resTitle, kw, year) {
|
||
var raw = String(resTitle || "");
|
||
// 这是个视频网盘 App,不收漫画/小说/绘本这类印刷品——即使标题字面完全对上(比如同名老漫画
|
||
// 《幸运女神》),也不是这部片子要的东西,而且这种"同名但不同媒介"的撞车,光靠标题位置判断
|
||
// 根本分不出来,只能按内容类型直接排除。
|
||
if (/漫画|小说|绘本|连环画|轻小说|作者[::]|\btxt\b/i.test(raw)) return false;
|
||
var nk = normTitleKey(kw);
|
||
if (!nk) return true; // 没有关键词可比对,不过滤
|
||
var nt = normTitleKey(raw); // normTitleKey 本身就会把书名号/括号/空格等结构符号全部清掉
|
||
var idx = nt.indexOf(nk);
|
||
if (idx < 0) return false; // 压根不含关键词,肯定不是
|
||
var hasYear = !!(year && raw.indexOf(String(year)) >= 0);
|
||
|
||
// 「同头不同片」防撞:搜「功夫」却召回《功夫熊猫》《功夫瑜伽》《功夫足球》——关键词只是人家
|
||
// 更长片名的开头,而"功夫"两个字同样贴在标题最前面(idx<=3),光看位置根本分不出来。
|
||
// 办法:先用 cleanTitle 把画质/版本/语言/年份/集数这些发行标签统统剥掉,得到真正的「核心片名」,
|
||
// 再拿核心片名跟关键词比:
|
||
// ① 核心片名 == 关键词 → 就是这部;
|
||
// ② 关键词是核心片名前缀,剥掉版本尾巴后还黏着 ≥2 个成词汉字(熊猫/瑜伽/足球)→ 另一部同头的片,排除。
|
||
// cleanTitle 剥得掉「4K/蓝光/粤语/修复版」这类标签,却剥不掉「熊猫」这种真片名的一部分,
|
||
// 正好用它来区分「功夫粤语版4K」(是这部)和「功夫熊猫」(不是这部)。带本片年份的破例保留。
|
||
var core = normTitleKey(cleanTitle(raw)) || nt;
|
||
if (core !== nk && core.indexOf(nk) === 0) {
|
||
var tail = core.slice(nk.length);
|
||
while (EDITION_TAIL_RE.test(tail)) { var nx = tail.replace(EDITION_TAIL_RE, ""); if (nx === tail) break; tail = nx; }
|
||
var tailCjk = tail.replace(/[^\u3400-\u9fff\uf900-\ufaff]/g, ""); // 尾巴里还剩几个汉字
|
||
if (tailCjk.length >= 2 && !hasYear) return false; // 后面还跟着成词的汉字 → 是同头的另一部片
|
||
}
|
||
|
||
if (idx <= 3) return true; // 关键词紧贴标题开头(容3个字残留前缀),直接认
|
||
// 前缀比较长时,先别急着当"蹭关键词"处理——很多真实资源发布者习惯在片名前加频道/分类前缀,
|
||
// 比如"美剧速递:幸运女神(2026)",只要标题里明确带着这部片子的年份,年份+关键词同时命中,
|
||
// 基本能排除是《刚下山被幸运女神拉去领证》这种完全不相关、也不会带这个年份的内容。
|
||
if (hasYear) return true;
|
||
return false;
|
||
}
|
||
/* 宽松包含判断:谁包含谁都行,但多出的字数要≤3,跟 titleScore 里“中间包含”那档用同一把尺,
|
||
给几个各自维护自己打分逻辑的反查函数(zn4kReverseLookup/fyReverseLookup/openTrendingDetail/
|
||
panReverseLookup)复用,避免它们各自的 indexOf 判断比 titleScore 还松,绕开了上面的收紧。 */
|
||
function titleCloseContain(a, b) {
|
||
a = String(a || ""); b = String(b || "");
|
||
if (!a || !b) return false;
|
||
if (a.indexOf(b) >= 0) return (a.length - b.length) <= 3;
|
||
if (b.indexOf(a) >= 0) return (b.length - a.length) <= 3;
|
||
return false;
|
||
}
|
||
/* 按相关度稳定排序:只把更贴近纯关键词的提到前面,同一档内保持原顺序(不打乱服务器给的排序)。
|
||
解决「搜 僵尸 时,那部就叫《僵尸》的两字片被一堆 xx僵尸/僵尸xx 淹没」的问题。 */
|
||
function sortByRelevance(list, k) {
|
||
return list
|
||
.map(function (x, i) { return { x: x, i: i, s: titleScore(x.title, k) }; })
|
||
.sort(function (a, b) { return (a.s - b.s) || (a.i - b.i); })
|
||
.map(function (o) { return o.x; });
|
||
}
|
||
function loadList(reset) {
|
||
if (state.searchMode) {
|
||
if (reset) searchViaSuggestAPI(state.searchKw);
|
||
return;
|
||
}
|
||
// 指南合集/榜单(豆瓣高分):数据来自 4K指南,不走盘链的分类接口,续页交给 zn4kLoadMore
|
||
if (state.activeCat === ZN4K_CAT_SLUG) {
|
||
if (!reset) zn4kLoadMore();
|
||
return;
|
||
}
|
||
// 七味分类(电影/剧集/综艺/动漫/短剧):数据来自七味镜像,续页交给 qwLoadCat
|
||
if (state.activeCat === QW_CAT_SLUG) {
|
||
qwLoadCat(!!reset);
|
||
return;
|
||
}
|
||
// 枫叶:数据来自 cd-zj(在线点播站),同样不走盘链接口,续页交给 fyLoadCat
|
||
if (state.activeCat === FY_CAT_SLUG) {
|
||
fyLoadCat(!!reset);
|
||
return;
|
||
}
|
||
// 蜗牛:数据来自 zmi.kdns.fr,续页交给 wnLoadCat
|
||
if (state.activeCat === WN_CAT_SLUG) {
|
||
wnLoadCat(!!reset);
|
||
return;
|
||
}
|
||
// 森中有林:数据来自 sidhub.cc,续页交给 shLoadCat
|
||
if (state.activeCat === SH_CAT_SLUG) {
|
||
shLoadCat(!!reset);
|
||
return;
|
||
}
|
||
// 玩偶/木偶:数据来自各自站,续页交给 woLoadCat
|
||
if (woAll().some(function (st) { return state.activeCat === st.slug; })) {
|
||
woLoadCat(!!reset);
|
||
return;
|
||
}
|
||
if (reset) {
|
||
state.page = 1; state.source = []; state.rendered = 0; state.noMore = false; state.autoPages = 0;
|
||
state.loading = false; // 关键:切换/刷新时复位,旧的在途请求会因 token 失配被忽略
|
||
$("grid").replaceChildren();
|
||
$("grid").classList.remove("grid-recent");
|
||
$("browseNotice").replaceChildren();
|
||
state.reqToken++;
|
||
}
|
||
if (state.loading || state.noMore) return;
|
||
var token = state.reqToken;
|
||
state.loading = true;
|
||
$("browseLoading").style.display = state.rendered === 0 ? "block" : "block";
|
||
$("browseSub").textContent = state.source.length ? (state.source.length + " 部") : "加载中";
|
||
|
||
var p = state.searchMode ? fetchSearch(state.searchKw, state.page) : fetchList(state.activeCat, state.page);
|
||
p.then(function (items) {
|
||
if (token !== state.reqToken) return;
|
||
if (!items || !items.length) {
|
||
state.noMore = true;
|
||
if (state.source.length === 0) renderEmpty(state.searchMode ? "" : "__nores__");
|
||
} else {
|
||
var have = {};
|
||
state.source.forEach(function (s) { have[s.id] = 1; });
|
||
var fresh = items.filter(function (it) { return !have[it.id]; });
|
||
if (!fresh.length) state.noMore = true;
|
||
state.source = state.source.concat(fresh);
|
||
appendGrid();
|
||
renderGenreChips();
|
||
if (state.activeGenre) { var vg = visSource(); $("browseSub").textContent = vg.length + " 部(" + state.activeGenre + ")"; if (vg.length) $("browseNotice").replaceChildren(); }
|
||
else $("browseSub").textContent = state.source.length + " 部";
|
||
}
|
||
}, function (err) {
|
||
if (token !== state.reqToken) return;
|
||
state.lastDiag = "调试:" + (err && err.message ? err.message : "网络异常") + (state.sdkReady ? " · App" : " · 预览");
|
||
if (state.source.length === 0) renderEmpty(String(err && err.message).indexOf("登录") >= 0 ? "__auth__" : ("加载失败:" + (err && err.message ? err.message : "网络异常")));
|
||
}).finally(function () {
|
||
if (token !== state.reqToken) return;
|
||
state.loading = false;
|
||
$("browseLoading").style.display = "none";
|
||
if (state.activeGenre && visSource().length === 0 && (state.noMore || state.autoPages >= 40)) renderEmpty("__nogenre__");
|
||
});
|
||
}
|
||
/* 影片对象映射 */
|
||
function stripHtml(s) { return String(s || "").replace(/<[^>]*>/g, "").replace(/ /g, " ").replace(/\u3000/g, " ").replace(/\s+/g, " ").trim(); }
|
||
function mapVod(v) {
|
||
return {
|
||
id: String(v.vod_id),
|
||
title: v.vod_name || "",
|
||
poster: v.vod_pic || "",
|
||
typeName: v.type_name || "",
|
||
area: v.vod_area || "",
|
||
year: (v.vod_year != null ? String(v.vod_year) : "").replace(/[^0-9].*$/, ""),
|
||
meta: [v.vod_year, v.vod_area, v.type_name].filter(function (x) { return x; }).join(" / "),
|
||
status: v.vod_remarks || "",
|
||
overview: stripHtml(v.vod_blurb || v.vod_content || "")
|
||
};
|
||
}
|
||
/* 取列表:get_videos.php?pg=&t=(真分类 + 翻页)。全部=不带 t
|
||
同一 分类+页码 短期内(3 分钟)重复请求,直接用内存缓存,不再打网络:
|
||
首页各分类行、轮播候选池、分类页翻页,很多时候会撞上同一页,缓存后来回切换/重新打开都秒开。
|
||
手动下拉刷新(refreshCurrent)会清空这份缓存,保证那次是真刷新。 */
|
||
var _listCache = {};
|
||
var LIST_CACHE_TTL = 3 * 60 * 1000;
|
||
function _listCacheKey(cat, page) { return (cat || "") + "|" + (page || 1); }
|
||
function fetchListOnce(cat, page) {
|
||
var key = _listCacheKey(cat, page);
|
||
var hit = _listCache[key];
|
||
if (hit && (Date.now() - hit.ts) < LIST_CACHE_TTL) {
|
||
state.lastDiag = "调试:get_videos t=" + (cat || "全部") + " 第" + (page || 1) + "/" + hit.pagecount + "页 · " + hit.list.length + " 条 · 缓存";
|
||
if ((page || 1) >= hit.pagecount) state.noMore = true;
|
||
return Promise.resolve(hit.list.slice());
|
||
}
|
||
var url = apiUrl("get_videos.php") + "?pg=" + (page || 1) + (cat ? "&t=" + encodeURIComponent(cat) : "");
|
||
return apiGet(url, "").then(function (d) {
|
||
if (!d) throw new Error("空响应");
|
||
if (d.success === false || (d.code != null && d.code !== 1 && !(d.list && d.list.length))) throw new Error(d.msg || d.message || "接口失败");
|
||
var list = d.list || d.data || [];
|
||
var pagecount = d.pagecount || (list.length ? (page || 1) + 1 : (page || 1));
|
||
var mapped = list.map(mapVod);
|
||
_listCache[key] = { list: mapped, pagecount: pagecount, ts: Date.now() };
|
||
state.lastDiag = "调试:get_videos t=" + (cat || "全部") + " 第" + (page || 1) + "/" + pagecount + "页 · " + mapped.length + " 条" + (state.sdkReady ? " · App" : " · 预览");
|
||
if ((page || 1) >= pagecount) state.noMore = true;
|
||
return mapped;
|
||
});
|
||
}
|
||
/* 供轮播候选池用:只取数据,不动 state.noMore / state.lastDiag 这些分类列表翻页共用的全局状态,
|
||
避免轮播预抓多页时把“到底了”提前标记,影响分类页正常的下拉加载更多。同样吃上面那份缓存。 */
|
||
function fetchListOnceSilent(cat, page) {
|
||
var key = _listCacheKey(cat, page);
|
||
var hit = _listCache[key];
|
||
if (hit && (Date.now() - hit.ts) < LIST_CACHE_TTL) return Promise.resolve(hit.list.slice());
|
||
var url = apiUrl("get_videos.php") + "?pg=" + (page || 1) + (cat ? "&t=" + encodeURIComponent(cat) : "");
|
||
return apiGet(url, "").then(function (d) {
|
||
if (!d) throw new Error("空响应");
|
||
if (d.success === false || (d.code != null && d.code !== 1 && !(d.list && d.list.length))) throw new Error(d.msg || d.message || "接口失败");
|
||
var list = d.list || d.data || [];
|
||
var pagecount = d.pagecount || (list.length ? (page || 1) + 1 : (page || 1));
|
||
var mapped = list.map(mapVod);
|
||
_listCache[key] = { list: mapped, pagecount: pagecount, ts: Date.now() };
|
||
return mapped;
|
||
});
|
||
}
|
||
function fetchList(cat, page) {
|
||
if (cat === TMDB_CAT_SLUG) return fetchTmdbTrendPage(page);
|
||
return fetchListOnce(cat, page).then(function (list) {
|
||
// 首页取回 0 条且配了账号密码 -> 疑似登录失效,自动续登重试一次
|
||
if (!list.length && (page || 1) === 1 && config.account && config.password && !state.reloginTried) {
|
||
state.reloginTried = true;
|
||
return login().then(function () { return fetchListOnce(cat, page); }, function () { return list; });
|
||
}
|
||
state.reloginTried = false;
|
||
return list;
|
||
}, function (e) {
|
||
if (!(config.account && config.password) || state.reloginTried) throw e;
|
||
state.reloginTried = true;
|
||
return login().then(function () { return fetchListOnce(cat, page); }, function () { throw e; });
|
||
});
|
||
}
|
||
/* 搜索:get_videos.php 带 wd(关键词参数名未确认,先用 wd;不对再调) */
|
||
function fetchSearch(kw, page) {
|
||
var k = (kw || "").trim();
|
||
// 真搜索接口:get_videos.php?wd=关键词 -> {success:true, data:[...]}
|
||
var url = apiUrl("get_videos.php") + "?wd=" + encodeURIComponent(k) + "&pg=" + (page || 1) + "&_t=" + Date.now();
|
||
return apiGet(url, "").then(function (d) {
|
||
var arr = (d && (d.data || d.list)) || [];
|
||
var pagecount = (d && d.pagecount) || 1;
|
||
state.lastDiag = "调试:搜“" + k + "” 第" + (page || 1) + "/" + pagecount + "页 · " + arr.length + " 条";
|
||
if ((page || 1) >= pagecount) state.noMore = true;
|
||
var mapped = arr.map(mapVod);
|
||
// 保险:若接口没按关键词过滤(返回整页无关),本地再过滤一次
|
||
var anyHit = mapped.some(function (it) { return it.title.indexOf(k) >= 0; });
|
||
if (mapped.length && !anyHit) { state.noMore = true; return runFallbackScanReturn(k); }
|
||
return mapped;
|
||
}, function () {
|
||
state.noMore = true;
|
||
return runFallbackScanReturn(k);
|
||
});
|
||
}
|
||
/* 兜底(接口异常时才用):扫几页本地过滤,返回结果 */
|
||
function runFallbackScanReturn(k) {
|
||
return fetchList("", 1).then(function (all) { return all.filter(function (it) { return it.title.indexOf(k) >= 0; }); }, function () { return []; });
|
||
}
|
||
function renderEmpty(errMsg) {
|
||
var box = $("browseNotice");
|
||
var div = document.createElement("div");
|
||
div.className = "notice";
|
||
if (errMsg === "__auth__") {
|
||
div.innerHTML = "<h4>登录态已失效</h4><p>内置的 Cookie 过期了。请抓一份新的 Cookie(含 session_token / PHPSESSID),在设置里更新后即可恢复。</p>";
|
||
var open = document.createElement("button");
|
||
open.type = "button"; open.className = "btn focusable"; open.textContent = "打开设置更新 Cookie"; open.style.maxWidth = "220px";
|
||
open.onclick = openSettings;
|
||
div.appendChild(open);
|
||
} else if (errMsg === "__nogenre__") {
|
||
div.innerHTML = "<h4>已加载内容里暂无该类型</h4><p>清空该类型筛选、或多翻几页再筛。</p>";
|
||
var cg = document.createElement("button");
|
||
cg.type = "button"; cg.className = "btn focusable"; cg.textContent = "清空筛选"; cg.style.maxWidth = "120px";
|
||
cg.onclick = function () { selectGenre(""); };
|
||
div.appendChild(cg);
|
||
} else if (errMsg === "__nores__") {
|
||
div.innerHTML = "<h4>暂时没有内容</h4><p>接口没有返回影片。下拉可刷新;若持续为空,可能登录态失效,去设置更新 Cookie。</p>";
|
||
var rf = document.createElement("button");
|
||
rf.type = "button"; rf.className = "btn focusable"; rf.textContent = "刷新"; rf.style.maxWidth = "120px";
|
||
rf.onclick = function () { loadList(true); };
|
||
div.appendChild(rf);
|
||
} else if (errMsg === "__nocards__") {
|
||
div.innerHTML = "<h4>没解析到影片</h4><p>这个列表可能由脚本动态加载,或链接结构不同。已切到「全部」可正常浏览;此分类需要它的接口。</p>";
|
||
var goAll = document.createElement("button");
|
||
goAll.type = "button"; goAll.className = "btn focusable"; goAll.textContent = "回到首页"; goAll.style.maxWidth = "140px";
|
||
goAll.onclick = function () { showHome(); };
|
||
div.appendChild(goAll);
|
||
} else if (errMsg) {
|
||
div.innerHTML = "<h4>暂时没有内容</h4><p>" + escapeHtml(errMsg) + "。检查网络,或在设置里更新登录 Cookie。</p>";
|
||
var retry = document.createElement("button");
|
||
retry.type = "button"; retry.className = "btn focusable"; retry.textContent = "重试"; retry.style.maxWidth = "120px";
|
||
retry.onclick = function () { loadList(true); };
|
||
div.appendChild(retry);
|
||
} else if (state.searchMode) {
|
||
div.innerHTML = "<h4>没有找到“" + escapeHtml(state.searchKw) + "”</h4><p>换个片名或更短的关键词再试。</p>";
|
||
} else {
|
||
div.innerHTML = "<h4>这个分类暂时是空的</h4><p>可能登录态失效,去设置更新 Cookie;或换个分类看看。</p>";
|
||
}
|
||
box.replaceChildren(div);
|
||
if (state.lastDiag) { var dbg = document.createElement("p"); dbg.style.cssText = "margin-top:10px;font-size:11px;color:var(--ink-faint)"; dbg.textContent = state.lastDiag; div.appendChild(dbg); }
|
||
}
|
||
|
||
/* 动漫分类固定二级标签:不再按站点抓到的原始类型词随机罗列,只保留这三档,
|
||
同一档内用「二级分类名 type_name」优先、「地区 vod_area」兜底两路匹配,兼容不同站点命名习惯 */
|
||
var ANIME_GENRES = [
|
||
{ name: "日漫", typeRe: /日漫|日本动画|日本动漫/, areaRe: /日本/ },
|
||
{ name: "国漫", typeRe: /国漫|国产动画|国产动漫/, areaRe: /大陆|中国/ },
|
||
{ name: "美漫", typeRe: /美漫|欧美动画|欧美动漫/, areaRe: /美国|欧美/ }
|
||
];
|
||
/* ---------- 搜索结果按站源分组 ----------
|
||
之前搜索结果是一条 state.source 拼出来的平铺网格,各站的卡片混在一起,
|
||
哪条来自哪站只能靠详情页里的 site-tag 才看得出来。改成跟"筛选类型"chip 同一套交互:
|
||
#genreChips 复用出一排"全部 N / 站源 N"的胶囊,点哪个筛哪个;"全部"态下网格按站源分组,
|
||
每组前插一条跨列的分组标题(.search-group-title 用 grid-column:1/-1 撑满,不用另起一个网格容器)。
|
||
分组顺序固定为 mergeTmdb 里补数据的顺序(本站 -> 4K指南 -> 七味 -> 蜗牛 -> SeedHub -> 玩偶/木偶/pomo -> TMDB),
|
||
跟"这条结果是怎么补进来的"保持一致,不随到达顺序抖动。 */
|
||
var SEARCH_SOURCE_ORDER = ["本站", "4K指南", "七味", "蜗牛", "SeedHub", "玩偶", "木偶", "pomo", "TMDB"];
|
||
function itemSourceName(it) {
|
||
if (it._zn4k) return "4K指南";
|
||
if (it._qw) return "七味";
|
||
if (it._wn) return "蜗牛";
|
||
if (it._sh) return "SeedHub";
|
||
if (it._wo) return (WO_SITES[it._woSite] && WO_SITES[it._woSite].name) || "玩偶";
|
||
if (it._tmdbId) return "TMDB";
|
||
return "本站";
|
||
}
|
||
/* 展示用的来源名:内部分组键仍叫"本站"(itemSourceName / SEARCH_SOURCE_ORDER / 筛选 key 都不动),
|
||
只在给用户看的胶囊和分组标题上把它显示成"盘链"。 */
|
||
function searchSourceLabel(s) { return s === "本站" ? "盘链" : s; }
|
||
function searchGroups() {
|
||
var groups = {}, counts = {};
|
||
state.source.forEach(function (it) {
|
||
var s = itemSourceName(it);
|
||
if (!groups[s]) { groups[s] = []; counts[s] = 0; }
|
||
groups[s].push(it); counts[s]++;
|
||
});
|
||
var order = SEARCH_SOURCE_ORDER.filter(function (s) { return groups[s]; });
|
||
Object.keys(groups).forEach(function (s) { if (order.indexOf(s) < 0) order.push(s); }); // 兜底:以后加了没登记的来源也不漏
|
||
return { groups: groups, counts: counts, order: order };
|
||
}
|
||
/* 搜索模式下 #genreChips 的筛选栏:只有一个来源时(比如只有"本站")没有分组意义,直接隐藏 */
|
||
function renderSearchSourceChips() {
|
||
var box = $("genreChips"); if (!box) return;
|
||
var g = searchGroups();
|
||
if (g.order.length < 2) { box.style.display = "none"; box.replaceChildren(); return; }
|
||
box.style.display = ""; // startSearch 进入搜索时把它设成了 none,这里真正有胶囊要显示时得改回来,否则胶囊塞进去了容器还是隐藏的
|
||
var frag = document.createDocumentFragment();
|
||
function makeChip(label, count, key, i) {
|
||
var b = document.createElement("button");
|
||
b.type = "button";
|
||
b.className = "genre-chip focusable" + (state.searchSourceFilter === key ? " active" : "");
|
||
b.innerHTML = escapeHtml(label) + "<span class='chip-count'>" + count + "</span>";
|
||
b.setAttribute("data-focus-row", "genreChips");
|
||
b.setAttribute("data-focus-index", String(i));
|
||
b.onclick = function () {
|
||
state.searchSourceFilter = (state.searchSourceFilter === key ? "" : key);
|
||
renderSearchSourceChips(); renderSearchGrid();
|
||
};
|
||
return b;
|
||
}
|
||
frag.appendChild(makeChip("全部", state.source.length, "", 0));
|
||
g.order.forEach(function (s, i) { frag.appendChild(makeChip(searchSourceLabel(s), g.counts[s], s, i + 1)); });
|
||
box.replaceChildren(frag);
|
||
}
|
||
/* 搜索结果网格:数据量小、一次性到齐(不像分类页要分页),每次新数据到位就整体重画一次,
|
||
不需要 appendGrid 那套"批量渲染+触底翻页"的逻辑。 */
|
||
function renderSearchGrid() {
|
||
var grid = $("grid"); if (!grid) return;
|
||
var frag = document.createDocumentFragment();
|
||
var idx = 0;
|
||
function addItem(it) {
|
||
var onClick = it._tmdbId ? (function (x) { return function () { openTrendingDetail(x); }; })(it) : undefined;
|
||
frag.appendChild(mediaCard(it, idx++, undefined, onClick));
|
||
}
|
||
var g = searchGroups();
|
||
if (state.searchSourceFilter) {
|
||
(g.groups[state.searchSourceFilter] || []).forEach(addItem);
|
||
} else {
|
||
g.order.forEach(function (s) {
|
||
var head = el("div", "search-group-title");
|
||
head.appendChild(document.createTextNode(searchSourceLabel(s)));
|
||
head.appendChild(el("span", "cnt", g.counts[s] + " 部"));
|
||
frag.appendChild(head);
|
||
g.groups[s].forEach(addItem);
|
||
});
|
||
}
|
||
grid.replaceChildren(frag);
|
||
state.rendered = state.source.length;
|
||
}
|
||
function visSource() {
|
||
if (!state.activeGenre) return state.source;
|
||
var g = state.activeGenre;
|
||
if (state.activeCat === "4") {
|
||
var conf = null;
|
||
for (var i = 0; i < ANIME_GENRES.length; i++) if (ANIME_GENRES[i].name === g) { conf = ANIME_GENRES[i]; break; }
|
||
if (conf) return pickKind(state.source, conf.typeRe, conf.areaRe);
|
||
}
|
||
if (state.activeCat === TMDB_CAT_SLUG && tmdbProviderConf(g)) {
|
||
// 平台标签:state.source 已经是 fetchTmdbProviderPage 按该平台单独拉回的数据,无需再本地筛一遍
|
||
return state.source;
|
||
}
|
||
return state.source.filter(function (it) { return it.typeName === g; });
|
||
}
|
||
function appendGrid() {
|
||
if (state.activeCat === "__recent__") return;
|
||
var grid = $("grid");
|
||
var vis = visSource();
|
||
var batch = state.rendered === 0 ? (state.isTv ? 18 : 12) : (state.isTv ? 12 : 9);
|
||
var end = Math.min(state.rendered + batch, vis.length);
|
||
var frag = document.createDocumentFragment();
|
||
for (var i = state.rendered; i < end; i++) {
|
||
var _it = vis[i];
|
||
var _onClick = _it._tmdbId ? (function (x) { return function () { openTrendingDetail(x); }; })(_it) : undefined;
|
||
frag.appendChild(mediaCard(_it, i, undefined, _onClick));
|
||
}
|
||
grid.appendChild(frag);
|
||
state.rendered = end;
|
||
// 需要更多:已渲染到已加载末尾且还有下一页 -> 取下一页
|
||
// 筛选态也翻页(凑够该类型的结果),用 autoPages 上限防止请求风暴,滑到底会重置继续
|
||
if (state.rendered >= vis.length && !state.noMore && !state.loading && state.autoPages < 40) {
|
||
state.autoPages++;
|
||
state.page++;
|
||
loadList(false);
|
||
} else {
|
||
// 内容装不满一屏时页面根本没有滚动条,"触底"滚动事件永远不会触发,
|
||
// 之前只能靠手动下拉刷新(复位 autoPages)才能继续加载;这里渲染完主动补一次检查,
|
||
// 装不满就自动接着翻页,不用等用户刷新页面
|
||
setTimeout(ensureFilled, 30);
|
||
}
|
||
}
|
||
/* 装不满一屏(没有可滚动的空间)时自动继续翻页,直到装满屏或确实没有更多数据 */
|
||
function ensureFilled() {
|
||
if (state.focusScope !== "home" || state.searchMode || state.activeCat === "__recent__") return;
|
||
if (state.loading || state.noMore) return;
|
||
var vis = visSource();
|
||
if (state.rendered < vis.length) { appendGrid(); return; } // 本地已有数据但还没渲染完,先渲染
|
||
var doc = document.documentElement;
|
||
var filled = doc.scrollHeight > window.innerHeight + 80;
|
||
if (!filled) { state.autoPages = 0; appendGrid(); }
|
||
}
|
||
/* 二级类型筛选 */
|
||
function renderGenreChips() {
|
||
var box = $("genreChips"); if (!box) return;
|
||
if (state.searchMode || state.activeCat === "__recent__") { box.replaceChildren(); return; }
|
||
if (state.activeCat === "4") { renderAnimeGenreChips(box); return; }
|
||
if (state.activeCat === TMDB_CAT_SLUG) { renderTmdbGenreChips(box); return; }
|
||
var seen = {}, list = [];
|
||
state.source.forEach(function (it) { var t = it.typeName; if (t && !seen[t]) { seen[t] = 1; list.push(t); } });
|
||
if (list.length < 2) { box.replaceChildren(); return; }
|
||
var frag = document.createDocumentFragment();
|
||
list.forEach(function (t, i) {
|
||
var b = document.createElement("button");
|
||
b.type = "button";
|
||
b.className = "genre-chip focusable" + (state.activeGenre === t ? " active" : "");
|
||
b.textContent = t;
|
||
b.setAttribute("data-genre", t);
|
||
b.setAttribute("data-focus-row", "genreChips");
|
||
b.setAttribute("data-focus-index", String(i));
|
||
b.onclick = function () { selectGenre(state.activeGenre === t ? "" : t); };
|
||
frag.appendChild(b);
|
||
});
|
||
box.replaceChildren(frag);
|
||
}
|
||
/* 动漫分类:固定渲染「日漫 / 国漫 / 美漫」三个标签,不随抓到的原始类型词变化 */
|
||
function renderAnimeGenreChips(box) {
|
||
var frag = document.createDocumentFragment();
|
||
ANIME_GENRES.forEach(function (g, i) {
|
||
var b = document.createElement("button");
|
||
b.type = "button";
|
||
b.className = "genre-chip focusable" + (state.activeGenre === g.name ? " active" : "");
|
||
b.textContent = g.name;
|
||
b.setAttribute("data-genre", g.name);
|
||
b.setAttribute("data-focus-row", "genreChips");
|
||
b.setAttribute("data-focus-index", String(i));
|
||
b.onclick = function () { selectGenre(state.activeGenre === g.name ? "" : g.name); };
|
||
frag.appendChild(b);
|
||
});
|
||
box.replaceChildren(frag);
|
||
}
|
||
/* TMDB 热播分类:固定「全部 / 电影 / 剧集 / Netflix / Disney+ / HBO / Hulu / Prime Video / Apple TV+」标签。
|
||
“全部”对应空筛选值,点击即回到未筛选列表;电影、剧集复用通用 typeName 精确匹配(下面 visSource 的兜底分支);
|
||
各平台标签走 tmdbProviderConf 命中的专门分支,切换时改拉 discover 接口(见 selectGenre 里的重置逻辑)。
|
||
这几个标签点击直接切到对应值,不做“再点一次取消”那套 toggle,因为“全部”本身就承担了取消筛选的角色 */
|
||
function renderTmdbGenreChips(box) {
|
||
var frag = document.createDocumentFragment();
|
||
TMDB_TREND_GENRES.forEach(function (g, i) {
|
||
var b = document.createElement("button");
|
||
b.type = "button";
|
||
b.className = "genre-chip focusable" + (state.activeGenre === g.value ? " active" : "");
|
||
b.textContent = g.name;
|
||
b.setAttribute("data-genre", g.name);
|
||
b.setAttribute("data-focus-row", "genreChips");
|
||
b.setAttribute("data-focus-index", String(i));
|
||
b.onclick = function () { selectGenre(g.value); };
|
||
frag.appendChild(b);
|
||
});
|
||
box.replaceChildren(frag);
|
||
}
|
||
function selectGenre(t) {
|
||
state.activeGenre = t;
|
||
window.scrollTo(0, 0);
|
||
if (state.activeCat === TMDB_CAT_SLUG) {
|
||
// TMDB 这边「电影/剧集」是本地筛已加载数据,但平台标签(Netflix/HBO/葫芦…)背后是完全不同的
|
||
// discover 接口,必须重新拉取,所以这里统一走 loadList(true) 重置分页
|
||
renderGenreChips();
|
||
loadList(true);
|
||
return;
|
||
}
|
||
state.autoPages = 0;
|
||
state.rendered = 0;
|
||
$("grid").replaceChildren();
|
||
$("browseNotice").replaceChildren();
|
||
renderGenreChips();
|
||
appendGrid();
|
||
var vis = visSource();
|
||
$("browseSub").textContent = (t ? vis.length + " 部(" + t + ")" : state.source.length + " 部");
|
||
if (t && vis.length === 0 && state.noMore) renderEmpty("__nogenre__");
|
||
}
|
||
function mediaCard(item, index, row, onClick, onDelete) {
|
||
var card = document.createElement("button");
|
||
card.type = "button";
|
||
card.className = "card focusable";
|
||
card.setAttribute("data-card-index", String(index));
|
||
card.setAttribute("data-focus-row", row || "grid");
|
||
var poster = document.createElement("div");
|
||
poster.className = "poster";
|
||
if (item.poster) {
|
||
var img = document.createElement("img");
|
||
img.alt = item.title; img.loading = index < 6 ? "eager" : "lazy"; img.setAttribute("decoding", "async");
|
||
if (/tmdb\.org|themoviedb\.org/.test(item.poster)) { setTmdbImg(img, item.poster); }
|
||
else if (item._zn4k) { img.src = zn4kImgSrc(item.poster); img.onerror = function () { img.style.display = "none"; }; }
|
||
else if (item._qw) { img.src = qwImgSrc(item.poster); img.onerror = function () { img.style.display = "none"; }; }
|
||
else if (item._wn && state.sdkReady && window.fm && fm.res) { img.src = fm.res(item.poster, { headers: { "Referer": wnBase() + "/" } }); img.onerror = function () { img.style.display = "none"; }; }
|
||
else if (item._sh && state.sdkReady && window.fm && fm.res) { img.src = fm.res(item.poster, { headers: { "Referer": shBase() + "/" } }); img.onerror = function () { img.style.display = "none"; }; }
|
||
else if (item._wo && state.sdkReady && window.fm && fm.res) { img.src = fm.res(item.poster, { headers: { "Referer": woBase(WO_SITES[item._woSite] || WO_SITES.wo) + "/" } }); img.onerror = function () { img.style.display = "none"; }; }
|
||
else { img.src = imgSrc(item.poster); img.onerror = function () { img.style.display = "none"; }; }
|
||
poster.appendChild(img);
|
||
} else {
|
||
var ph = document.createElement("div"); ph.className = "ph"; ph.textContent = (item.title || "?").slice(0, 6); poster.appendChild(ph);
|
||
}
|
||
var badge = document.createElement("span");
|
||
badge.className = "badge mono";
|
||
badge.textContent = item.status || ((item.meta && item.meta.indexOf("4K") >= 0) ? "4K" : "HD");
|
||
poster.appendChild(badge);
|
||
if (onDelete) {
|
||
var del = document.createElement("button");
|
||
del.type = "button"; del.className = "card-del"; del.setAttribute("aria-label", "删除这条记录");
|
||
del.innerHTML = "✕";
|
||
del.onclick = function (ev) { ev.preventDefault(); ev.stopPropagation(); onDelete(item); };
|
||
poster.appendChild(del);
|
||
}
|
||
card.appendChild(poster);
|
||
var title = document.createElement("div"); title.className = "card-title"; title.textContent = item.title; card.appendChild(title);
|
||
var meta = document.createElement("div"); meta.className = "card-meta"; meta.textContent = item.meta || ""; card.appendChild(meta);
|
||
card.onclick = onClick || function () { openDetail(item); };
|
||
bindPrewarmTrigger(card, function () { prewarmTmdb(item); });
|
||
return card;
|
||
}
|
||
|
||
/* ---------- 最近观看(仅记录本站内实际播放过的资源,并入分类,仅在有记录时出现) ---------- */
|
||
function loadRecent() {
|
||
return storeGet("recent").then(function (raw) {
|
||
var list = [];
|
||
if (raw) { try { list = JSON.parse(raw) || []; } catch (e) { list = []; } }
|
||
state.recentItems = list;
|
||
ensureRecentChip(list.length > 0);
|
||
}, function () {
|
||
state.recentItems = [];
|
||
ensureRecentChip(false);
|
||
});
|
||
}
|
||
function recordRecent(id, title, poster, backdrop, payload, note) {
|
||
if (!id || !payload || !payload.url) return;
|
||
var list = (state.recentItems || []).filter(function (h) { return String(h.id) !== String(id); });
|
||
list.unshift({ id: id, title: title || "", poster: poster || "", backdrop: backdrop || "", payload: payload, note: note || "", ts: Date.now() });
|
||
if (list.length > 30) list = list.slice(0, 30);
|
||
state.recentItems = list;
|
||
storeSet("recent", JSON.stringify(list));
|
||
ensureRecentChip(true);
|
||
if (state.activeCat === "__recent__") renderRecentGrid();
|
||
}
|
||
function ensureRecentChip(show) {
|
||
if (state.view === "home" && typeof renderHomeRows === "function") renderHomeRows();
|
||
}
|
||
function ensureRecentChipOld(show) {
|
||
var box = $("chips");
|
||
var existing = box.querySelector("[data-cat='__recent__']");
|
||
if (show && !existing) {
|
||
var b = document.createElement("button");
|
||
b.type = "button"; b.className = "chip focusable";
|
||
b.textContent = "继续观看";
|
||
b.setAttribute("data-cat", "__recent__");
|
||
b.setAttribute("data-focus-row", "chips");
|
||
b.onclick = function () { selectCat("__recent__"); };
|
||
box.insertBefore(b, box.firstChild);
|
||
reindexChips();
|
||
} else if (!show && existing) {
|
||
if (state.activeCat === "__recent__") selectCat("");
|
||
existing.parentNode.removeChild(existing);
|
||
reindexChips();
|
||
}
|
||
}
|
||
function reindexChips() {
|
||
var chips = $("chips").children;
|
||
for (var i = 0; i < chips.length; i++) chips[i].setAttribute("data-focus-index", String(i));
|
||
}
|
||
function removeRecent(id) {
|
||
var list = (state.recentItems || []).filter(function (h) { return String(h.id) !== String(id); });
|
||
state.recentItems = list;
|
||
storeSet("recent", JSON.stringify(list));
|
||
ensureRecentChip(list.length > 0);
|
||
if (state.activeCat === "__recent__") renderRecentGrid();
|
||
}
|
||
function openConfirm(title, text, onOk) {
|
||
$("confirmTitle").textContent = title || "确认";
|
||
$("confirmText").textContent = text || "";
|
||
var ov = $("confirmOverlay");
|
||
ov.classList.add("active"); ov.setAttribute("aria-hidden", "false");
|
||
$("confirmOk").onclick = function () { closeConfirm(); if (onOk) onOk(); };
|
||
}
|
||
function closeConfirm() {
|
||
var ov = $("confirmOverlay");
|
||
ov.classList.remove("active"); ov.setAttribute("aria-hidden", "true");
|
||
}
|
||
function clearAllRecent() {
|
||
if (!(state.recentItems || []).length) return;
|
||
openConfirm("清空观看记录", "确定清空全部观看记录?此操作不可撤销。", function () {
|
||
state.recentItems = [];
|
||
storeSet("recent", JSON.stringify([]));
|
||
ensureRecentChip(false);
|
||
if (state.activeCat === "__recent__") renderRecentGrid();
|
||
});
|
||
}
|
||
function renderRecentGrid() {
|
||
$("browseTitle").textContent = "最近观看";
|
||
$("browseSub").textContent = state.recentItems.length ? (state.recentItems.length + " 部") : "";
|
||
$("clearSearch").style.display = "none";
|
||
$("clearRecent").style.display = state.recentItems.length ? "" : "none";
|
||
$("clearRecent").onclick = clearAllRecent;
|
||
$("browseLoading").style.display = "none";
|
||
$("browseNotice").replaceChildren();
|
||
var grid = $("grid");
|
||
grid.classList.add("grid-recent");
|
||
if (!state.recentItems.length) {
|
||
grid.replaceChildren();
|
||
var div = el("div", "notice"); div.innerHTML = "<h4>暂无观看记录</h4><p>播放过的片子会出现在这里,点开直接进播放器。</p>";
|
||
$("browseNotice").replaceChildren(div);
|
||
return;
|
||
}
|
||
var frag = document.createDocumentFragment();
|
||
state.recentItems.forEach(function (h, i) {
|
||
var card = document.createElement("button");
|
||
card.type = "button"; card.className = "card focusable";
|
||
card.setAttribute("data-card-index", String(i)); card.setAttribute("data-focus-row", "grid");
|
||
var poster = document.createElement("div"); poster.className = "poster";
|
||
var recentImgUrl = h.backdrop || h.poster || "";
|
||
if (recentImgUrl) { var img = document.createElement("img"); img.alt = h.title; img.loading = i < 6 ? "eager" : "lazy"; img.setAttribute("decoding", "async"); setTmdbImg(img, recentImgUrl); poster.appendChild(img); }
|
||
else { var ph = document.createElement("div"); ph.className = "ph"; ph.textContent = (h.title || "").slice(0, 6); poster.appendChild(ph); }
|
||
var del = document.createElement("button");
|
||
del.type = "button"; del.className = "card-del"; del.setAttribute("aria-label", "删除这条记录");
|
||
del.innerHTML = "✕";
|
||
del.onclick = function (ev) { ev.preventDefault(); ev.stopPropagation(); removeRecent(h.id); };
|
||
poster.appendChild(del);
|
||
card.appendChild(poster);
|
||
var t = document.createElement("div"); t.className = "card-title"; t.textContent = h.title || ""; card.appendChild(t);
|
||
var m = document.createElement("div"); m.className = "card-meta"; m.textContent = h.note || "继续观看"; card.appendChild(m);
|
||
card.onclick = function () { playRecent(h); };
|
||
frag.appendChild(card);
|
||
});
|
||
grid.replaceChildren(frag);
|
||
}
|
||
function playRecent(h) {
|
||
if (!h || !h.payload || !h.payload.url) { toast("该记录已失效"); return; }
|
||
if (h.payload.wallPic) delete h.payload.wallPic;
|
||
state.pendingPlayWatch = { ts: Date.now(), kind: "recent", item: h };
|
||
if (window.fm && fm.pan && fm.pan.play) {
|
||
toast("正在打开播放器…");
|
||
fm.pan.play(h.payload).then(function () {}, function () { toast("播放失败,链接可能已失效"); });
|
||
} else {
|
||
toast("已解析:" + h.payload.url);
|
||
}
|
||
}
|
||
|
||
/* ---------- 搜索 ---------- */
|
||
function startSearch(kw) {
|
||
kw = (kw || "").trim();
|
||
if (!kw) return;
|
||
var wasSearch = state.searchMode;
|
||
var panelWasOpen = state.searchPanelOpen;
|
||
state.searchMode = true; state.searchKw = kw; state.searchSourceFilter = "";
|
||
state.activeGenre = ""; $("genreChips").replaceChildren();
|
||
$("search").value = kw;
|
||
$("browseTitle").textContent = "搜索:" + kw;
|
||
$("clearSearch").style.display = "";
|
||
$("clearRecent").style.display = "none";
|
||
state.view = "search";
|
||
$("homeView").style.display = "none"; $("catView").style.display = "";
|
||
$("genreChips").style.display = "none"; $("genreChips").replaceChildren();
|
||
$("catTitle").textContent = "搜索:" + kw;
|
||
setNav("search");
|
||
if (typeof updateHeroVisible === "function") updateHeroVisible();
|
||
window.scrollTo(0, 0);
|
||
// 若是从搜索面板发起的搜索,用 replace 把“面板”这一层直接顶替成“搜索结果”,
|
||
// 不额外新增历史深度;closeSearchPanel 这里跳过它自己的归位,避免重复处理。
|
||
if (!wasSearch) navigate("search", panelWasOpen);
|
||
closeSearchPanel(true);
|
||
loadList(true);
|
||
}
|
||
function closeSearchPanel(skipRoute) {
|
||
var p = $("searchPanel"); if (p) p.classList.remove("open");
|
||
var m = $("searchMask"); if (m) m.classList.remove("open");
|
||
var wasOpen = state.searchPanelOpen;
|
||
if (state.searchPanelOpen) { state.searchPanelOpen = false; unlockBodyScroll(); }
|
||
var t = $("searchToggle"); if (t) t.classList.remove("active");
|
||
hideSuggest();
|
||
var s = $("search"); if (s) { try { s.blur(); } catch (e) {} }
|
||
if (!state.searchMode) setNav(state.view === "recent" ? "recent" : "home");
|
||
if (wasOpen && !skipRoute) closeRoute();
|
||
}
|
||
function clearSearch(fromPopstate) {
|
||
var wasSearchMode = state.searchMode;
|
||
$("search").value = "";
|
||
hideSuggest();
|
||
showHome(true); // 这里的历史归位由 clearSearch 自己负责,避免 showHome 重复处理
|
||
if (wasSearchMode && !fromPopstate) closeRoute();
|
||
}
|
||
/* 搜索联想 */
|
||
function runSuggest() {
|
||
var kw = ($("search").value || "").trim();
|
||
if (kw.length < 1) { hideSuggest(); return; }
|
||
var token = ++state.suggestToken;
|
||
// 站内联想 + TMDB 联想并发跑,谁先回来先画,两边都回来再合并去重、按相关度排序(完全同名置顶)。
|
||
// 这样像 Eclipse 一样,输入「僵尸」时那部两字《僵尸》能被 TMDB 干净命中并顶到最前。
|
||
var siteItems = null, tmdbItems = null;
|
||
function paint() {
|
||
if (token !== state.suggestToken) return;
|
||
if (siteItems === null && tmdbItems === null) return;
|
||
var merged = mergeSuggest(siteItems || [], tmdbItems || [], kw);
|
||
if (merged.length) renderSuggest(merged.slice(0, 8));
|
||
else if (siteItems !== null && tmdbItems !== null) hideSuggest(); // 两边都回来了且都空 -> 收起
|
||
}
|
||
// 站内:专用联想接口 search_suggestions.php?q=,空/失败退回 get_videos.php?wd=
|
||
apiGet(apiUrl("search_suggestions.php") + "?q=" + encodeURIComponent(kw) + "&_t=" + Date.now(), "").then(function (d) {
|
||
if (token !== state.suggestToken) return;
|
||
var items = normSuggest((d && (d.data || d.suggestions || d.list)) || [], kw);
|
||
if (items.length) { siteItems = items; paint(); }
|
||
else suggestViaSearch(kw, token, function (u) { siteItems = u; paint(); });
|
||
}, function () { suggestViaSearch(kw, token, function (u) { siteItems = u; paint(); }); });
|
||
// TMDB:直接问 TMDB(没配 Key 就是空,静默)
|
||
tmdbSearchList(kw).then(function (items) {
|
||
if (token !== state.suggestToken) return;
|
||
tmdbItems = items || []; paint();
|
||
}, function () { if (token === state.suggestToken) { tmdbItems = []; paint(); } });
|
||
}
|
||
/* 合并站内 + TMDB 联想:先放能点开详情的真实条目(站内优先、按标题去重),
|
||
再补 TMDB 里站内没有的,最后才是站内的纯关键词建议;整体按相关度排(完全同名最前)。 */
|
||
function mergeSuggest(siteItems, tmdbItems, kw) {
|
||
var out = [], seen = {};
|
||
siteItems.forEach(function (it) { if (it.keyword || !it.id) return; var key = normTitleKey(it.title); if (key && !seen[key]) { seen[key] = 1; out.push(it); } });
|
||
tmdbItems.forEach(function (it) { var key = normTitleKey(it.title); if (key && !seen[key]) { seen[key] = 1; out.push(it); } });
|
||
siteItems.forEach(function (it) { if (!(it.keyword || !it.id)) return; var key = "kw:" + normTitleKey(it.title); if (normTitleKey(it.title) && !seen[key] && !seen[normTitleKey(it.title)]) { seen[key] = 1; out.push(it); } });
|
||
return sortByRelevance(out, kw);
|
||
}
|
||
/* 联想结果归一化:既支持字符串关键词,也支持影片对象 */
|
||
function normSuggest(raw, kw) {
|
||
var out = [], seen = {};
|
||
(raw || []).forEach(function (x) {
|
||
if (typeof x === "string") { if (x && !seen["s:" + x]) { seen["s:" + x] = 1; out.push({ id: "", title: x, poster: "", meta: "", status: "", keyword: true }); } return; }
|
||
if (x && (x.vod_id != null || x.vod_name)) { var it = mapVod(x); if (!seen[it.id]) { seen[it.id] = 1; out.push(it); } return; }
|
||
if (x && (x.name || x.title || x.word)) { var w = x.name || x.title || x.word; if (!seen["s:" + w]) { seen["s:" + w] = 1; out.push({ id: "", title: w, poster: "", meta: "", status: "", keyword: true }); } }
|
||
});
|
||
return out;
|
||
}
|
||
function suggestViaSearch(kw, token, cb) {
|
||
apiGet(apiUrl("get_videos.php") + "?wd=" + encodeURIComponent(kw) + "&pg=1&_t=" + Date.now(), "").then(function (d) {
|
||
if (token !== state.suggestToken) return;
|
||
var list = (d && (d.data || d.list)) || [];
|
||
var seen = {}, uniq = [];
|
||
list.map(mapVod).forEach(function (it) { if (it.title.indexOf(kw) >= 0 && !seen[it.id]) { seen[it.id] = 1; uniq.push(it); } });
|
||
cb(uniq);
|
||
}, function () { if (token === state.suggestToken) cb([]); });
|
||
}
|
||
function renderSuggest(items) {
|
||
var box = $("searchSuggest"); if (!box) return;
|
||
if (!items || !items.length) { box.replaceChildren(); return; }
|
||
var frag = document.createDocumentFragment();
|
||
items.forEach(function (it) {
|
||
var row = document.createElement("div"); row.className = "sug-item";
|
||
var po = document.createElement("div"); po.className = "sug-pos";
|
||
if (it.poster) { var im = document.createElement("img"); im.alt = ""; im.loading = "lazy"; im.setAttribute("decoding", "async"); if (/tmdb\.org|themoviedb\.org/.test(it.poster)) { setTmdbImg(im, it.poster); } else { im.src = imgSrc(it.poster); im.onerror = function () { im.style.display = "none"; }; } po.appendChild(im); }
|
||
row.appendChild(po);
|
||
var body = document.createElement("div"); body.className = "sug-body";
|
||
var t = document.createElement("div"); t.className = "sug-title"; t.textContent = it.title; body.appendChild(t);
|
||
var m = document.createElement("div"); m.className = "sug-meta"; m.textContent = [it.meta, it.status].filter(function (x) { return x; }).join(" · "); body.appendChild(m);
|
||
row.appendChild(body);
|
||
row.onclick = function () { hideSuggest(); if (it._tmdbId) { openTrendingDetail(it); } else if (it.keyword || !it.id) { $("search").value = it.title; startSearch(it.title); } else { openDetail(it); } };
|
||
frag.appendChild(row);
|
||
});
|
||
box.replaceChildren(frag);
|
||
}
|
||
function hideSuggest() { state.suggestToken++; var b = $("searchSuggest"); if (b) b.replaceChildren(); }
|
||
function refreshCurrent() {
|
||
_listCache = {}; clearTextMemo(); clearHomeDisk(); // 手动下拉刷新:清掉短期列表缓存 + 各站 HTML 缓存 + 磁盘缓存,这次必须是最新数据
|
||
if (state.view === "home") { state.heroRawCache = null; renderHomeRows(true); loadHero(); return; }
|
||
if (state.activeCat === "__recent__") { loadRecent().then(renderRecentGrid); return; }
|
||
loadList(true);
|
||
}
|
||
/* 左右滑动切换分类/筛选标签时给 #grid 加一次方向性的滑入渐显动画,纯视觉、不影响任何加载逻辑。
|
||
delta>0(左滑,进下一个)新内容从右侧滑入;delta<0(右滑,进上一个)从左侧滑入。
|
||
grid 的内容随后会被 selectCat/swipeGenre 触发的 replaceChildren+appendGrid 正常替换,
|
||
动画只作用在容器本身的 transform/opacity 上,不关心内容何时真正换好,异步加载也不会打断视觉效果。 */
|
||
function gridSwipeAnim(delta) {
|
||
var grid = $("grid"); if (!grid) return;
|
||
grid.classList.remove("grid-swipe-l", "grid-swipe-r");
|
||
void grid.offsetWidth; // 强制重排,保证连续同方向滑动时动画能重新触发
|
||
grid.classList.add(delta > 0 ? "grid-swipe-l" : "grid-swipe-r");
|
||
}
|
||
function swipeCategory(delta) {
|
||
var chips = Array.from($("chips").children);
|
||
if (!chips.length) return;
|
||
var cats = chips.map(function (c) { return c.getAttribute("data-cat"); });
|
||
if (cats.length && cats[0] == null) return; // 底栏已是导航(首页/搜索/历史),不做分类切换
|
||
var cur = state.searchMode ? -1 : cats.indexOf(state.activeCat);
|
||
if (cur < 0) cur = delta > 0 ? -1 : chips.length; // 搜索态:右滑进第一个 / 左滑进最后一个
|
||
var ni = cur + delta;
|
||
if (ni < 0 || ni >= chips.length) return;
|
||
gridSwipeAnim(delta);
|
||
selectCat(cats[ni]);
|
||
var el = chips[ni];
|
||
if (el && el.scrollIntoView) { try { el.scrollIntoView({ inline: "center", block: "nearest" }); } catch (e) {} }
|
||
}
|
||
/* 筛选标签栏(#genreChips)的横向滑动现在完全交给浏览器原生滚动处理(见上面 .genre-chips
|
||
的 touch-action: pan-x 注释),不再需要这里手搓 touchmove 接管 + JS 惯性/橡皮筋模拟——
|
||
原生滚动自带的跟手度和物理效果本来就比手搓的更准更顺,这个函数留空/移除,调用点也一并去掉。 */
|
||
|
||
/* “查看全部”页左右滑动切换筛选标签(剧情/喜剧/动作…)。三套 genreChips 渲染分支
|
||
(renderGenreChips / renderAnimeGenreChips / renderTmdbGenreChips) 各自的 onclick 语义不完全一样
|
||
(有的是“再点一次取消”的 toggle,有的是直接赋值),这里不重新猜测语义,直接找到目标 chip
|
||
调用它自己的 click(),跟手指真的点了那个标签效果完全一致;没有筛选标签(如只有一种类型)时
|
||
#genreChips 是空的,函数直接返回,什么都不做。 */
|
||
function swipeGenre(delta) {
|
||
var box = $("genreChips"); if (!box) return;
|
||
var chips = box.children; if (!chips.length) return;
|
||
var ci = -1;
|
||
for (var i = 0; i < chips.length; i++) { if (chips[i].classList.contains("active")) { ci = i; break; } }
|
||
if (ci < 0) ci = delta > 0 ? -1 : chips.length; // 当前未筛选:右滑进第一个 / 左滑进最后一个
|
||
var ni = ci + delta;
|
||
if (ni < 0 || ni >= chips.length) return;
|
||
var target = chips[ni];
|
||
gridSwipeAnim(delta);
|
||
if (target.click) target.click();
|
||
// 上面 target.click() 触发的 selectGenre() 会调用 renderGenreChips(),把 #genreChips
|
||
// 的子节点整体 replaceChildren 重建一遍——这时候 target 已经变成不在文档里的游离节点,
|
||
// 对它调用 scrollIntoView 不会有任何效果(浏览器找不到它现在该在哪)。这里重新从容器里
|
||
// 按同样的位置索引取一次“活”的节点(顺序、数量都没变,索引仍然对得上),保险起见找不到
|
||
// 就退回去直接找当前 .active 的那个,再让它滚到可视区域,标签栏就能跟手势联动滚动了。
|
||
var live = box.children[ni];
|
||
if (!live || !live.classList || !live.classList.contains("genre-chip")) live = box.querySelector(".genre-chip.active");
|
||
// 这里改成手动算 scrollLeft 直接赋值,而不是 scrollIntoView:scrollIntoView 在部分
|
||
// WebView 里对这种横向滚动容器会触发一次自带动画的滚动,滚到头/尾时会有越界回弹,
|
||
// 手势连续左右滑页面时这排(返回按钮+筛选标签)看起来像弹簧一样跳一下,不好看。
|
||
// 直接算好目标位置一次性赋值 scrollLeft 是瞬间跳转,没有任何动画/回弹,观感干净。
|
||
if (live) {
|
||
try {
|
||
var tgt = live.offsetLeft + live.offsetWidth / 2 - box.clientWidth / 2;
|
||
tgt = Math.max(0, Math.min(tgt, box.scrollWidth - box.clientWidth));
|
||
box.scrollLeft = tgt;
|
||
} catch (e) {}
|
||
}
|
||
}
|
||
|
||
/* ---------- 详情 ---------- */
|
||
/* 「网盘资源」记忆:一个条目的资源要跨 5~6 个站聚合,全部回来要几秒。这里把上次聚合到的结果按条目存住,
|
||
再次打开同一条目时先把上次的秒显出来(播放按钮立刻可用),后台照常重新抓一遍,回来了再覆盖。 */
|
||
var _panMemo = {}, PAN_MEMO_TTL = 10 * 60 * 1000, PAN_MEMO_MAX = 30;
|
||
function panMemoKey(item) {
|
||
if (!item) return "";
|
||
var pre = item._wo ? ("wo:" + item._woSite) : item._sh ? "sh" : item._wn ? "wn" : item._zn4k ? "zn" : item._qw ? "qw" : "lo";
|
||
return pre + "|" + String(item.id || item._woHref || item._wnId || item._shId || item._zn4kId || item._qwId || item.title || "");
|
||
}
|
||
function panMemoSave(items) {
|
||
var k = panMemoKey(state.currentItem);
|
||
if (!k || !items || !items.length) return;
|
||
_panMemo[k] = { ts: Date.now(), items: items.slice() };
|
||
var ks = Object.keys(_panMemo);
|
||
if (ks.length > PAN_MEMO_MAX) { ks.sort(function (a, b) { return _panMemo[a].ts - _panMemo[b].ts; }); delete _panMemo[ks[0]]; }
|
||
}
|
||
function panMemoGet(item) {
|
||
var m = _panMemo[panMemoKey(item)];
|
||
if (!m || (Date.now() - m.ts) > PAN_MEMO_TTL) return null;
|
||
// 复制一份,并把检测状态复位(上次的"检测中"不该带到这次)
|
||
return m.items.map(function (p) { var q = {}; for (var kk in p) q[kk] = p[kk]; q._checking = false; q._checked = false; q.state = "idle"; return q; });
|
||
}
|
||
/* 网盘资源跨站聚合:按标题去除"本站"外的所有站源搜同名,把各自的网盘/在线资源都并进「网盘资源」。
|
||
本站资源已在 openDetail 里先加载(排最前),这里补齐其余站,做到"共用所有站源的资源匹配、本站优先"。
|
||
except 标记哪个是本站,避免对本站重复搜一遍。 */
|
||
function enrichAllSites(title, token, except) {
|
||
except = except || {};
|
||
if (!except.zn4k) zn4kEnrich(title, token); // 4K指南
|
||
if (!except.qw) qwEnrich(title, token); // 七味
|
||
if (!except.wn) wnEnrich(title, token); // 原盘高清
|
||
if (!except.sh) shEnrich(title, token); // SeedHub
|
||
woAll().forEach(function (st) { // 各网盘明链站
|
||
if (except.wo !== st.key) woEnrich(st, title, token);
|
||
});
|
||
// 盘链本站:非本站来源时也反查一遍,把本站网盘资源补进来
|
||
if (!except.local) panReverseLookup(title, token);
|
||
}
|
||
/* 盘链本站按标题反查网盘资源(供其它站来源的详情页补本站资源用) */
|
||
function panReverseLookup(title, token) {
|
||
var kw = cleanTitle(title); if (!kw) return;
|
||
apiGet(apiUrl("get_videos.php") + "?wd=" + encodeURIComponent(kw) + "&pg=1&_t=" + Date.now(), "").then(function (dd) {
|
||
if (token !== state.panToken) return;
|
||
var list = ((dd && (dd.list || dd.data)) || []).map(mapVod);
|
||
var yr = yearOf(title);
|
||
function score(it) { var s = 0; if (it.title === title) s += 8; else if (titleCloseContain(it.title, kw)) s += 4; if (yr && it.year === yr) s += 3; return s; }
|
||
var good = list.filter(function (it) { return score(it) >= 4; }).sort(function (a, b) { return score(b) - score(a); });
|
||
if (!good.length) return;
|
||
var best = good[0], tok = state.panToken;
|
||
apiGet(apiUrl("search_pan_links.php") + "?keyword=" + encodeURIComponent(kw) + "&vod_id=" + encodeURIComponent(best.id) + "&_t=" + Date.now(), best.id).then(function (data) {
|
||
if (tok !== state.panToken || !data || !data.success) return;
|
||
var groups = data.data || {}, pans = [];
|
||
Object.keys(groups).forEach(function (type) {
|
||
var grp = groups[type], links = (grp && grp.links) || [];
|
||
links.forEach(function (lk) {
|
||
pans.push({ type: type, groupName: (grp && grp.name) || (PAN_META[type] ? PAN_META[type].label : type), title: lk.title || "", password: lk.password || "", token: lk.token || "", source: lk.source || "本站", tier: lk.user_tier || 0, quality: (PAN_META[type] ? PAN_META[type].label : type), state: "idle" });
|
||
});
|
||
});
|
||
if (!pans.length) return;
|
||
var have = {};
|
||
state.panItems.forEach(function (it) { have[it.type + "|" + normPanUrl(it.url || it.token)] = 1; });
|
||
var add = pans.filter(function (p) { return !have[p.type + "|" + normPanUrl(p.token || p.url)]; });
|
||
if (add.length) renderResources(state.panItems.concat(add));
|
||
}, function () {});
|
||
}, function () {});
|
||
}
|
||
function openDetail(item) {
|
||
saveViewReturn();
|
||
state.currentItem = item;
|
||
state.currentDetail = { id: item.id, title: cleanTitle(item.title), rawTitle: item.title, poster: item.poster || "", art: null, tmdb: null };
|
||
state.bestResource = null;
|
||
state.resumeRecord = null;
|
||
state.panItems = []; // 先清空:下面 updatePlayButton() 要按 id 找"上次播的那条",
|
||
// 不清的话会拿上一个条目残留的资源列表去比对,可能配错
|
||
state.qFilter = null; state.qGroups = []; state.qFilterManual = false;
|
||
|
||
// 先用站点海报占位,秒开;这里是切换到"另一个条目",必须立刻显示,不能柔和过渡(否则会看见上一个条目的海报残留一瞬)
|
||
setDetailHeroImages(item.poster ? [item.poster] : [], false);
|
||
state.dHeroMode = ""; // 新条目:等 TMDB 图集到手后按当前方向重新定模式
|
||
$("edLogo").replaceChildren();
|
||
showTitle(item.title);
|
||
renderChipsMeta(null, item);
|
||
$("detailOverview").replaceChildren(overviewSkeleton());
|
||
updatePlayButton();
|
||
|
||
$("panSub").textContent = ""; $("panTitle").textContent = "网盘资源";
|
||
$("qTabs").replaceChildren();
|
||
if ($("edCast")) $("edCast").replaceChildren(castSkeleton());
|
||
if ($("edEpisodes")) $("edEpisodes").replaceChildren();
|
||
state.epToken = (state.epToken || 0) + 1; // 让上一个条目还没返回的分季请求失效,避免串到新条目头上
|
||
if (window.resetActorWorks) window.resetActorWorks();
|
||
hideSuggest();
|
||
state.panItems = [];
|
||
state.panToken++; state.panDiag = [];
|
||
var memoPans = panMemoGet(item); // 上次看过这条:先把上次聚合到的资源秒显出来,后台照常重抓覆盖
|
||
if (memoPans && memoPans.length) renderResources(memoPans);
|
||
else $("panList").replaceChildren(panListSkeleton());
|
||
|
||
// 详情页内部再跳到另一个条目(演员相关作品、合集/系列成员等)时,面板本身并没有关闭过,
|
||
// 不能再往历史栈里 push 新记录——否则历史栈会不断堆积“幽灵”记录,导致返回键要按好几次
|
||
// 才能把这些空记录耗尽,才能真正退出(这正是“主页按返回好几次退不出全屏”的根源)。
|
||
// 只有详情页从关闭状态被打开时才 push,已经打开着就原地 replace,历史栈深度始终等于实际打开的层数。
|
||
var detailAlreadyOpen = $("detailSheet").classList.contains("active");
|
||
navigate("detail", detailAlreadyOpen);
|
||
document.body.classList.add("detail-active");
|
||
var sheet = $("detailSheet"); sheet.classList.add("active"); sheet.setAttribute("aria-hidden", "false");
|
||
sheet.scrollTop = 0;
|
||
if (window.resetHeroScrollDim) window.resetHeroScrollDim();
|
||
state.focusScope = "detail"; setAppFocusable(false); focusFirst(sheet);
|
||
if (window.fm && fm.ui && fm.ui.setChrome) fm.ui.setChrome({ mode: "immersive", restoreAffordance: "none" }).then(function () {}, function () {});
|
||
|
||
// 网盘资源跨站聚合:先加载"本站"资源(排第一),再统一按标题补齐其余所有站源。
|
||
var t = state.panToken;
|
||
if (item._wo) {
|
||
woLoadByHref(WO_SITES[item._woSite] || WO_SITES.wo, item._woHref, t);
|
||
enrichAllSites(item.title, t, { wo: item._woSite }); // 本站是玩偶或木偶,其余全补
|
||
} else if (item._sh) {
|
||
shLoadById(item._shId, t);
|
||
enrichAllSites(item.title, t, { sh: 1 });
|
||
} else if (item._wn) {
|
||
wnLoadById(item._wnId, t);
|
||
enrichAllSites(item.title, t, { wn: 1 });
|
||
} else if (item._zn4k) {
|
||
zn4kLoadById(item._zn4kId, t);
|
||
zn4kReverseLookup(item, t);
|
||
enrichAllSites(item.title, t, { zn4k: 1 });
|
||
} else if (item._qw) {
|
||
qwLoadById(item._qwId, t);
|
||
qwReverseLookup(item, t);
|
||
enrichAllSites(item.title, t, { qw: 1 });
|
||
} else {
|
||
// 本站(盘链)来源:本站资源排第一,其余站全补
|
||
loadResources(item);
|
||
enrichAllSites(item.title, t, { local: 1 });
|
||
}
|
||
loadTmdb(item);
|
||
if (config.panApi) { var pt = state.panToken; setTimeout(function () { if (pt === state.panToken) runPanSou(true); }, 350); } // 进详情自动盘搜(静默)
|
||
}
|
||
function loadResources(item) {
|
||
var token = state.panToken;
|
||
var kw = cleanTitle(item.title);
|
||
var ryear = item.year || yearOf(item.title);
|
||
// 详情页是前端渲染的空壳,直接调 search_pan_links 取真链
|
||
var api = apiUrl("search_pan_links.php") + "?keyword=" + encodeURIComponent(kw) + "&vod_id=" + encodeURIComponent(item.id) + "&_t=" + Date.now();
|
||
apiGet(api, item.id).then(function (data) {
|
||
if (token !== state.panToken) return;
|
||
if (!data || !data.success) { renderResources(woKeep().concat(shKeep()).concat(wnKeep()).concat(fyKeep()).concat(panSouKeep()).concat(zn4kKeep()).concat(qwKeep())); return; }
|
||
var groups = data.data || {};
|
||
var list = [];
|
||
Object.keys(groups).forEach(function (type) {
|
||
var grp = groups[type];
|
||
var links = (grp && grp.links) || [];
|
||
links.forEach(function (lk) {
|
||
// search_pan_links 是服务端按 keyword 做模糊检索的,关键词只要出现在标题任意位置就会被
|
||
// 服务端召回——这会混进《刚下山被幸运女神拉去领证》这种标题里凑巧含有关键词、但其实是
|
||
// 完全不同一部作品的资源。这里只保留关键词出现在标题靠前位置(容书名号/引号等一两个
|
||
// 前缀字符)的条目:真正同名资源的片名一般就在标题最前面,后面跟集数/清晰度/发布人这类
|
||
// 附加信息;关键词前面顶着一大段别的内容,基本可以判定是别的作品蹭了关键词。
|
||
if (lk.title && !resourceTitleLikelyMatches(lk.title, kw, ryear)) return;
|
||
list.push({
|
||
type: type,
|
||
groupName: (grp && grp.name) || (PAN_META[type] ? PAN_META[type].label : type),
|
||
title: lk.title || "",
|
||
password: lk.password || "",
|
||
token: lk.token || "",
|
||
source: lk.source || "",
|
||
tier: lk.user_tier || 0,
|
||
quality: (PAN_META[type] ? PAN_META[type].label : type), // 用固定网盘名作为分组(tab),不用服务端各异的grp.name,避免"迅雷"/"迅雷云盘"拆成两个tab
|
||
state: "idle"
|
||
});
|
||
});
|
||
});
|
||
renderResources(sortPanDefault(list).concat(woKeep()).concat(shKeep()).concat(wnKeep()).concat(fyKeep()).concat(panSouKeep()).concat(zn4kKeep()).concat(qwKeep()));
|
||
}, function (err) {
|
||
if (token !== state.panToken) return;
|
||
$("panList").replaceChildren(el("div", "pan-empty", "取资源失败:" + (err && err.message ? err.message : "网络异常") + "。可能登录态过期,去设置更新 Cookie。"));
|
||
updatePlayButton();
|
||
});
|
||
}
|
||
|
||
/* ---------- TMDB:高清海报 + 元数据 ---------- */
|
||
function cleanTitleLight(t) {
|
||
// 跟 cleanTitle 类似,但保留年份和"第X季"——很多资源标题本身就带年份/季号,
|
||
// 完全清洗掉反而搜不到"xxx2026"、"xxx 第2季"这类资源,所以单独留一份轻量清洗版做候选词。
|
||
t = String(t || "");
|
||
t = t.replace(/4K|2160P|1080P|蓝光原盘|蓝光|原盘|REMUX|HDR10?\+?|杜比视界|DV|WEB-?DL|WEB|内封|国粤双语|中英双语|国语|粤语|双语|中字|高清|超清|合集/gi, " ");
|
||
return t.replace(/\s+/g, " ").trim();
|
||
}
|
||
/* 盘搜关键词候选:优先保留年份/季号的"精确版",再退到完全清洗的"通用版",两版都搜、结果合并,
|
||
这样带年份(xxx2026)、带季号(xxx 第2季)的资源和普通资源都尽量覆盖到 */
|
||
function panSouKeywords(rawTitle) {
|
||
var out = [];
|
||
var light = cleanTitleLight(rawTitle);
|
||
var full = cleanTitle(rawTitle);
|
||
if (light) out.push(light);
|
||
if (full && out.indexOf(full) < 0) out.push(full);
|
||
return out;
|
||
}
|
||
function cleanTitle(t) {
|
||
t = String(t || "");
|
||
// 去掉各种括号里的修饰内容:(臻彩)(真彩)【4K】[HDR][2024] 等——这些是站点给资源名加的标签,
|
||
// 不是片名的一部分,带着去 TMDB 搜必然匹配不到。中英文括号、方括号、书名号内注释统一清掉。
|
||
t = t.replace(/[((]\s*(?:臻彩|真彩|甄彩|臻藏|收藏|珍藏|典藏|完整|全集|完结|会员|VIP|抢先|independent|[0-9]{1,3}集?)\s*[))]/gi, " ");
|
||
t = t.replace(/【[^】]*】/g, " "); // 【...】整块去掉(画质/语言/字幕标签)
|
||
t = t.replace(/\[[^\]]*\]/g, " "); // [...]整块去掉
|
||
t = t.replace(/[((][^()()]{0,20}(?:版|4K|2160P|1080P|蓝光|原盘|HDR|DV|双语|中字|国语|粤语|高码|臻彩|真彩)[^()()]{0,20}[))]/gi, " "); // 含画质/版本关键词的括号
|
||
t = t.replace(/[((]\s*\d{4}\s*[))]/g, " "); // 去年份括号
|
||
t = t.replace(/(^|[^0-9])(19|20)\d{2}(?!\d)/g, "$1 "); // 去裸年份(负向前瞻,避免老内核 lookbehind 报错)
|
||
t = t.replace(/第\s*[0-9〇一二两三四五六七八九十百]+\s*[季部]/g, " ");
|
||
t = t.replace(/\bseason\s*\d+\b/gi, " ");
|
||
t = t.replace(/\bS\d{1,2}(?:E\d{1,3})?\b/gi, " ");
|
||
// 画质/来源/语言/字幕/编码等一大票发布组杂词
|
||
t = t.replace(/4K|8K|2160P|1440P|1080P|720P|UHD|蓝光原盘|蓝光|原盘|REMUX|BDRip|BluRay|HDR10?\+?|杜比视界|杜比|DoVi|DV|HQ|高码率?|高码|H\.?26[45]|x26[45]|AAC|DTS|Atmos|WEB-?DL|WEBRip|WEB|HDTV|内封|内嵌|外挂|国粤双语|中英双语|中英|国英|国语|粤语|英语|日语|韩语|双语|中字|简中|繁中|简繁|字幕|无水印|去水印|纯净版?|收藏版?|珍藏版?|完整版?|未删减|加长版?|导演剪辑版?|剧场版|高清|超清|流畅|臻彩|真彩|甄彩|合集|全集|完结|更新至?|第?\d{1,4}集/gi, " ");
|
||
t = t.replace(/\d+(?:\.\d+)?\s*[GMT]B?\b/gi, " "); // 文件大小 77.74G / 12GB
|
||
t = t.replace(/\bE\d{1,3}\b/gi, " "); // 单集号 E08
|
||
t = t.replace(/\bEP\d{1,3}\b/gi, " "); // EP01
|
||
t = t.replace(/\b(?:CHS|CHT|ENG|JPN|KOR|GB|BIG5|简|繁)\b/gi, " "); // 语言/字幕码
|
||
t = t.replace(/60FPS|30FPS|MAX/gi, " ");
|
||
t = t.replace(/(^|[^0-9])(19|20)\d{2}(?!\d)/g, "$1 "); // 再去一次裸年份(前面清标签后可能暴露出来)
|
||
t = t.replace(/[..·。、,,&]+/g, " "); // 残留标点转空格
|
||
t = t.replace(/\s版\s|\s版$/g, " "); // 孤立的"版"字
|
||
t = t.replace(/\s+/g, " ").trim();
|
||
return t;
|
||
}
|
||
function yearOf(t) { var m = String(t || "").match(/(19|20)\d{2}/); return m ? m[0] : ""; }
|
||
function tmdbApi(path, params) {
|
||
var q = "api_key=" + encodeURIComponent(config.tmdbKey) + "&language=zh-CN";
|
||
if (params) for (var k in params) if (Object.prototype.hasOwnProperty.call(params, k)) q += "&" + k + "=" + encodeURIComponent(params[k]);
|
||
var host = (config.tmdbApi || "").trim() || "api.tmdb.org"; // 默认走 api.tmdb.org:这个别名域名国内免代理可用;官方主域 api.themoviedb.org 在国内被墙。可在设置里改成自己的反代/镜像
|
||
if (host === "api.themoviedb.org") host = "api.tmdb.org"; // 有人手填了被墙的官方主域,自动纠正到国内可用的别名域名
|
||
return "https://" + host + "/3" + path + "?" + q;
|
||
}
|
||
/* ---------- 顶部推荐轮播:站内随机 电影 + 美剧 + 国产剧 ---------- */
|
||
function loadHero() {
|
||
if (state.isTv) { updateHeroVisible(); return; } // TV 端首页不展示轮播,不必抓数据
|
||
var box = $("heroCarousel"); if (!box) return;
|
||
loadHeroLocal();
|
||
}
|
||
function shuffleArr(a) {
|
||
for (var i = a.length - 1; i > 0; i--) { var j = Math.floor(Math.random() * (i + 1)); var t = a[i]; a[i] = a[j]; a[j] = t; }
|
||
return a;
|
||
}
|
||
/* TMDB 候选去重(按 id,没 id 退回标题)——多页合并时避免同一部片重复占位 */
|
||
function dedupById(list) {
|
||
var seen = {}, out = [];
|
||
(list || []).forEach(function (it) {
|
||
var k = String((it && (it.tmdbId || it.id)) || (it && it.title) || "");
|
||
if (!k || seen[k]) return; seen[k] = 1; out.push(it);
|
||
});
|
||
return out;
|
||
}
|
||
/* 判断一条剧集属于哪个地区:优先看站点自己的二级分类名 type_name(比如“美剧/国产剧”),
|
||
没命中再退回 vod_area(比如“美国/大陆”);两边都做容错,兼容不同站点的命名习惯 */
|
||
function pickKind(list, typeRe, areaRe) {
|
||
return list.filter(function (it) { return typeRe.test(it.typeName || "") || areaRe.test(it.area || ""); });
|
||
}
|
||
/* 并发抓取多页并合并,单页失败不影响其它页(fetchListOnce 已自带 catch) */
|
||
function fetchPagesConcat(cat, pages) {
|
||
return Promise.all(pages.map(function (p) {
|
||
return fetchListOnceSilent(cat, p).then(function (a) { return a || []; }, function () { return []; });
|
||
})).then(function (arr) { return arr.reduce(function (acc, a) { return acc.concat(a); }, []); });
|
||
}
|
||
/* 轮播加载中立刻渲染骨架屏,防止空白闪烁 */
|
||
function showHeroSkeleton() {
|
||
var box = $("heroCarousel"); if (!box) return;
|
||
state.heroLoading = true;
|
||
var skel = document.createElement("div"); skel.className = "hero-skeleton";
|
||
var body = document.createElement("div"); body.className = "hero-skel-body";
|
||
body.appendChild(el("div", "hero-skel-title"));
|
||
body.appendChild(el("div", "hero-skel-meta"));
|
||
skel.appendChild(body);
|
||
var dots = document.createElement("div"); dots.className = "hero-skel-dots";
|
||
for (var i = 0; i < 4; i++) { var d = document.createElement("span"); d.className = "hero-skel-d" + (i === 0 ? " a" : ""); dots.appendChild(d); }
|
||
skel.appendChild(dots);
|
||
box.replaceChildren(skel);
|
||
$("heroDots").replaceChildren();
|
||
var wrap = $("heroWrap"); if (wrap) wrap.hidden = false;
|
||
}
|
||
/* 站内随机:电影(t=1)+ 剧集(t=2,按类型/地区拆成美剧、国产剧、韩剧)+ TMDB 热门(trending/all/week)五路混合轮播,
|
||
TMDB 热门这一路是主力、其余四路是点缀:每一路各取几部不是写死的固定数字,而是每次刷新在一个区间里
|
||
随机抽(见 randHeroTake),TMDB 权重明显更高,其余四路时多时少、有时甚至抽到 0 个,这样每次进首页
|
||
看到的轮播构成都不一样,不会总是那几个固定位置。
|
||
TMDB 热门这一路直接点开走 openTrendingDetail(先站内搜片名,搜不到再退回 TMDB 详情),跟"TMDB 热播"分类页逻辑一致。
|
||
每一路各多抓一批候选(不止要用的那几个),交给 fillHeroSlots 逐个试配高清图,配不到就换下一个顶上。
|
||
候选池特意多抓几页(电影 3 页 / 剧集 4 页 / TMDB 3 页),避免池子太小、每次刷新都撞见同一批;
|
||
TMDB 这一路吃的是 _tmdbCatCache(跟"TMDB 热播"分类页翻页共用),缓存过的页不会重复请求。 */
|
||
function loadHeroLocal() {
|
||
var box = $("heroCarousel"); if (!box) return;
|
||
showHeroSkeleton();
|
||
var cache = state.heroRawCache;
|
||
if (cache && cache.movies.length && cache.drama.length) {
|
||
buildHeroFromRaw(cache.movies, cache.drama, cache.tmdb || []); // 已经抓过原始素材,直接用缓存秒开,不用再等网络
|
||
return;
|
||
}
|
||
// 优先用"TMDB 热播"分类页留下的缓存顶一版:如果这份缓存还热乎着(比如刚从那个分类页返回),
|
||
// 不用等站内电影/剧集这两路网络请求,先拿这批现成的把轮播占上;等下面正常的五路请求回来后
|
||
// 会用完整的混合结果整体覆盖一次,这里只是让首屏更快出图,不影响最终效果。
|
||
var warmTmdb = peekTmdbTrendCache(1);
|
||
if (warmTmdb && warmTmdb.length) buildHeroFromRaw([], [], warmTmdb);
|
||
// 秒开优先:先只抓电影 + 剧集各 1 页 + TMDB 第 1 页,有什么就先拼出来展示,不等后面几页
|
||
var moviesP = fetchPagesConcat("1", [1]);
|
||
var dramaP = fetchPagesConcat("2", [1]);
|
||
// TMDB 这一路首屏就把"笼统热播"和"Netflix/HBO/Disney+ 等平台热门"一起拉回来揉在一个池子里,
|
||
// 不再只看 trending/all/week 那一小池——这样一开始展示的就不止那几部老面孔。
|
||
var tmdbP = Promise.all([fetchTmdbTrendPagesSilent([1, 2]), fetchTmdbPlatformsPoolSilent(1)]).then(function (arr) {
|
||
return dedupById(arr[0].concat(arr[1]));
|
||
});
|
||
Promise.all([moviesP, dramaP, tmdbP]).then(function (arr) {
|
||
state.heroRawCache = { movies: arr[0].slice(), drama: arr[1].slice(), tmdb: arr[2].slice() };
|
||
buildHeroFromRaw(arr[0], arr[1], arr[2]);
|
||
// 后台再悄悄多抓几页扩充候选池,供下次刷新轮播时用更大的池子挑。
|
||
// 延迟 2.5 秒再发——此时首屏内容已加载完,不跟首屏请求抢带宽,避免拖慢主页首次加载。
|
||
setTimeout(function () {
|
||
var tmdbMoreP = Promise.all([fetchTmdbTrendPagesSilent([3, 4, 5]), fetchTmdbPlatformsPoolSilent(2)]).then(function (a2) {
|
||
return dedupById(a2[0].concat(a2[1]));
|
||
});
|
||
Promise.all([fetchPagesConcat("1", [2, 3]), fetchPagesConcat("2", [2, 3]), tmdbMoreP]).then(function (more) {
|
||
if (!state.heroRawCache) return;
|
||
state.heroRawCache.movies = state.heroRawCache.movies.concat(more[0]);
|
||
state.heroRawCache.drama = state.heroRawCache.drama.concat(more[1]);
|
||
state.heroRawCache.tmdb = dedupById(state.heroRawCache.tmdb.concat(more[2]));
|
||
}, function () {});
|
||
}, 2500);
|
||
}, function () { if (!warmTmdb || !warmTmdb.length) { box.replaceChildren(); state.heroItems = []; state.heroLoading = false; buildHeroDots(0); updateHeroVisible(); } });
|
||
}
|
||
/* 返回 [min, max] 区间内的随机整数(含两端) */
|
||
function randInt(min, max) { return min + Math.floor(Math.random() * (max - min + 1)); }
|
||
/* 拿到手的电影/剧集/TMDB 原始素材(不管是刚抓的第 1 页,还是缓存里更大的池子)统一在这里分拣成五路候选并渲染。
|
||
每一路要取几个在这里现抽:TMDB 热门是主力,区间明显更高;其余四路是辅助点缀,区间低、还带 0,
|
||
池子本身不够也不会报错(take 会自动被 slice 截到池子实际长度)。 */
|
||
function buildHeroFromRaw(moviesRaw, dramaRaw, tmdbRaw) {
|
||
var box = $("heroCarousel"); if (!box) return;
|
||
var movies = shuffleArr((moviesRaw || []).filter(function (it) { return it.poster; }));
|
||
var dramas = (dramaRaw || []).filter(function (it) { return it.poster; });
|
||
var us = shuffleArr(pickKind(dramas, /美剧|欧美剧/, /美国/));
|
||
var cn = shuffleArr(pickKind(dramas, /国产剧|大陆剧/, /大陆/));
|
||
var kr = shuffleArr(pickKind(dramas, /韩剧|韩国剧/, /韩国/));
|
||
var tmdbPool = shuffleArr((tmdbRaw || []).filter(function (it) { return it.poster; }));
|
||
state.lastDiag = "轮播:站内候选 电影" + movies.length + " · 美剧" + us.length + " · 国产剧" + cn.length + " · 韩剧" + kr.length + " · TMDB热门" + tmdbPool.length;
|
||
var pools = [
|
||
{ list: movies, label: "电影", take: randInt(0, 1) },
|
||
{ list: us, label: "美剧", take: randInt(0, 1) },
|
||
{ list: cn, label: "国产剧", take: randInt(0, 1) },
|
||
{ list: kr, label: "韩剧", take: randInt(0, 1) },
|
||
{ list: tmdbPool, label: "TMDB热门", tmdb: true, take: randInt(6, 8) } // 主力:每次从一大池 TMDB 热播里随机抽 6~8 个(池子已抓多页,所以每次抽到的都不同)
|
||
];
|
||
if (!movies.length && !us.length && !cn.length && !kr.length && !tmdbPool.length) { box.replaceChildren(); state.heroItems = []; buildHeroDots(0); updateHeroVisible(); return; }
|
||
fillHeroSlots(pools);
|
||
}
|
||
/* 先用站内素材(各路取各自随机抽到的 take 个)立刻渲染轮播,完全不等待 TMDB;
|
||
TMDB 高清图/铭牌随后在后台并行请求,谁先回来就先静默替换谁,互不阻塞、也不阻塞首屏。 */
|
||
function fillHeroSlots(pools) {
|
||
var box = $("heroCarousel"); if (!box) return;
|
||
var token = ++state.heroToken;
|
||
var picked = [];
|
||
pools.forEach(function (p) {
|
||
p.list.slice(0, p.take).forEach(function (raw) {
|
||
picked.push({
|
||
title: raw.title, year: raw.year, typeName: p.tmdb ? (raw.typeName || p.label) : p.label, status: raw.status,
|
||
overview: raw.overview, poster: raw.poster, backdrop: "", logo: "",
|
||
opener: (function (x, isTmdb) { return function () { if (isTmdb) openTrendingDetail(x); else openDetail(x); }; })(raw, !!p.tmdb),
|
||
_raw: raw, _label: p.label, _isTmdb: !!p.tmdb
|
||
});
|
||
});
|
||
});
|
||
if (!picked.length) { box.replaceChildren(); state.heroItems = []; state.heroLoading = false; buildHeroDots(0); updateHeroVisible(); return; }
|
||
shuffleArr(picked);
|
||
state.lastDiag = "轮播:站内候选 " + pools.map(function (p) { return p.list.length + "(" + p.label + ")"; }).join(" · ");
|
||
renderHeroSlides(picked); // 立即展示,用的是站源自带封面图(TMDB 这一路本身自带 TMDB 海报,不用等)
|
||
if (!config.tmdbKey) return;
|
||
// 后台并行给每一帧去配 TMDB 高清图,互不等待、随到随换;TMDB 池本身已有 id,直接按 id 拉图,不用再搜标题
|
||
picked.forEach(function (it, idx) {
|
||
var resolver = it._isTmdb ? resolveHeroItemTmdb(it._raw) : resolveHeroItem(it._raw, it._label);
|
||
resolver.then(function (res) {
|
||
if (token !== state.heroToken) return; // 期间又刷新过一次,这批结果作废
|
||
if (res.hd) upgradeHeroSlide(idx, res.item);
|
||
}, function () {});
|
||
});
|
||
}
|
||
/* 把某一帧的背景图/铭牌从站内海报静默换成 TMDB 高清图:
|
||
先在内存里把新图完整预加载好,再做一次淡出淡入,避免用户看到加载过程中的闪烁或空白。
|
||
这里的预加载探测图跟页面里真正显示的 <img>(setTmdbImg 那套)不是一回事——之前只写了
|
||
pre.onload,没写 pre.onerror:TMDB 图片域名(api.tmdb.org / image.tmdb.org)有一个在
|
||
当前网络下打不通,这张探测图就直接挂在“加载中”,onload 永远不触发,轮播也就永远停在
|
||
站内低清封面上,界面上完全看不出任何报错。这里补上跟 setTmdbImg 一样的“一个域名不通就
|
||
自动换另一个域名再试一次”的兜底,两个域名都失败才死心,保留原图。 */
|
||
function upgradeHeroSlide(idx, item) {
|
||
var box = $("heroCarousel"); if (!box || !box._imgMap) return;
|
||
var rec = box._imgMap[idx]; if (!rec || !rec.img) return;
|
||
if (state.heroItems && state.heroItems[idx]) {
|
||
state.heroItems[idx].backdrop = item.backdrop; state.heroItems[idx].logo = item.logo;
|
||
}
|
||
if (item.backdrop) {
|
||
var host = config.tmdbImg || "images.tmdb.org";
|
||
var altHost = host.indexOf("images.tmdb.org") >= 0 ? "image.tmdb.org" : "images.tmdb.org";
|
||
var url = "https://" + host + "/t/p/original" + item.backdrop;
|
||
var altUrl = "https://" + altHost + "/t/p/original" + item.backdrop;
|
||
(function () {
|
||
var tried = false;
|
||
var wasActive = rec.img.classList.contains("active"); // 换图期间用内联样式接管淡入淡出,换完再交还给 active 类控制
|
||
var pre = new Image();
|
||
pre.onload = function () {
|
||
rec.img.style.transition = "opacity .35s";
|
||
rec.img.style.opacity = "0";
|
||
var finalUrl = tried ? altUrl : url;
|
||
setTimeout(function () {
|
||
setTmdbImg(rec.img, finalUrl);
|
||
rec.img.style.opacity = wasActive ? "1" : "0";
|
||
setTimeout(function () { rec.img.style.transition = ""; rec.img.style.opacity = ""; }, 400);
|
||
}, 180);
|
||
};
|
||
pre.onerror = function () {
|
||
if (tried) return; // 两个域名都失败,放弃,保留原来的站内封面
|
||
tried = true;
|
||
pre.src = imgSrc(altUrl);
|
||
};
|
||
pre.src = imgSrc(url);
|
||
})();
|
||
}
|
||
if (item.logo) setHeroLogo(idx, item.logo);
|
||
}
|
||
/* 单条候选是否"配得上高清图":没配 TMDB Key 时不做这层过滤,直接沿用老行为(站内海报,永远算成功);
|
||
配了 Key 则必须搜到条目且拿到 backdrop 大图才算成功,否则返回 hd:false 交给上层换下一个候选 */
|
||
function resolveHeroItem(it, label) {
|
||
var base = { title: it.title, year: it.year, typeName: label, status: it.status, overview: it.overview, poster: it.poster, backdrop: "", logo: "", opener: function () { openDetail(it); } };
|
||
if (!config.tmdbKey) return Promise.resolve({ item: base, hd: true });
|
||
var yr = String(it.year || yearOf(it.title) || "").slice(0, 4);
|
||
var wantTv = label !== "电影";
|
||
return tmdbSearchBest(titleCandidates(it.title), yr, wantTv).then(function (pick) {
|
||
if (!pick) return { item: base, hd: false };
|
||
return tmdbHeroImages(pick, base);
|
||
}, function () { return { item: base, hd: false }; });
|
||
}
|
||
/* TMDB 热门候选池自带 _tmdbId/_mediaType,不用再像上面那样按标题模糊搜索去猜是哪部——
|
||
直接拿 id 查详情+images,命中率更高、也更快,出来的结构跟 resolveHeroItem 一致,供 fillHeroSlots 统一调用 */
|
||
function resolveHeroItemTmdb(raw) {
|
||
var base = { title: raw.title, year: raw.year, typeName: raw.typeName, status: raw.status, overview: raw.overview, poster: raw.poster, backdrop: "", logo: "", opener: function () { openTrendingDetail(raw); } };
|
||
if (!config.tmdbKey || !raw._tmdbId || !raw._mediaType) return Promise.resolve({ item: base, hd: !!config.tmdbKey });
|
||
return tmdbHeroImages({ media_type: raw._mediaType, id: raw._tmdbId }, base);
|
||
}
|
||
/* 给定 {media_type, id} 拉详情图,取最佳横版大图 + 铭牌,供 resolveHeroItem / resolveHeroItemTmdb 共用 */
|
||
function tmdbHeroImages(pick, base) {
|
||
return httpJson(tmdbApi("/" + pick.media_type + "/" + pick.id, {
|
||
append_to_response: "images",
|
||
include_image_language: "zh,en,null"
|
||
})).then(function (det) {
|
||
if (!det) return { item: base, hd: false };
|
||
var imgs = det.images || {};
|
||
var backdropPath = tmdbBestImg(imgs.backdrops, null) || det.backdrop_path || "";
|
||
if (!backdropPath) return { item: base, hd: false }; // 拿不到高清横版大图,判失败,换下一部
|
||
var logoPath = tmdbBestImg(imgs.logos, "zh") || tmdbBestImg(imgs.logos, "en") || tmdbBestImg(imgs.logos, null) || ((imgs.logos && imgs.logos[0]) ? imgs.logos[0].file_path : "");
|
||
return {
|
||
hd: true,
|
||
item: { title: base.title, year: base.year, typeName: base.typeName, status: base.status, overview: base.overview, poster: base.poster,
|
||
backdrop: backdropPath, logo: logoPath ? tmdbImgUrl("original", logoPath) : "", opener: base.opener }
|
||
};
|
||
}, function () { return { item: base, hd: false }; });
|
||
}
|
||
function tmdbBestImg(arr, wantLang) {
|
||
if (!arr || !arr.length) return "";
|
||
var byLang = arr.filter(function (x) { return x.iso_639_1 === wantLang; });
|
||
var pool = byLang.length ? byLang : arr;
|
||
pool.sort(function (a, b) { return (b.vote_average || 0) - (a.vote_average || 0); });
|
||
return pool[0].file_path;
|
||
}
|
||
function tmdbImgUrl(size, path) { return path ? ("https://" + (config.tmdbImg || "image.tmdb.org") + "/t/p/" + size + path) : ""; }
|
||
/* 构建轮播 DOM:首尾各补一张克隆帧,实现「一直往同一方向转」的无缝循环,不会跳回开头。
|
||
之前这里是一次性把所有帧(含首尾克隆,通常十几张)的 <img> 全部同时 src=…,弱网下
|
||
浏览器会把这十几个图片请求全塞到一起抢带宽(同域名并发数还有限),结果连用户第一眼
|
||
就看到的那一帧也跟着被拖慢,表现就是刚进主页轮播一片黑、迟迟不出图。
|
||
这里按"离当前可见帧的远近"分三档错峰加载:第 0 帧(一进来就看到的那帧)立刻加载,
|
||
不等任何东西;它两侧用来做无缝循环的克隆帧(可能手指一滑就看到)延迟一小会;
|
||
其余离得远、要好几屏之后才可能划到的帧最后再补,用 requestIdleCallback(没有就退化成
|
||
setTimeout)见缝插针地加载,不跟第 0 帧抢带宽。这样"秒出现"的只需要保证一张图,
|
||
其余的在后台慢慢补齐,用户几乎感觉不到。 */
|
||
function renderHeroSlides(items) {
|
||
var box = $("heroCarousel"); if (!box) return;
|
||
hideHomeSkeleton(); // 轮播出图了,收起首屏骨架屏
|
||
state.heroItems = items; state.heroIndex = 0; state.heroLoading = false;
|
||
var n = items.length;
|
||
function scheduleLoad(fn, tier) {
|
||
if (tier === 0) { fn(); return; }
|
||
if (tier === 1) { setTimeout(fn, 260); return; }
|
||
if (window.requestIdleCallback) requestIdleCallback(fn, { timeout: 2500 });
|
||
else setTimeout(fn, 900);
|
||
}
|
||
// 图片层:图片本身不参与左右位移,每张图直接按逻辑序号建一层,靠 opacity 交叉淡入淡出切换,
|
||
// 不需要像信息层那样首尾克隆帧(交叉渐隐渐显没有"循环边界"的问题,直接淡到目标帧即可)。
|
||
var imgsBox = document.createElement("div"); imgsBox.className = "hero-imgs";
|
||
var imgMap = {};
|
||
function makeImgLayer(it, logicalIdx, tier) {
|
||
var img = document.createElement("img"); img.className = "hero-bg"; img.alt = "";
|
||
img.crossOrigin = "anonymous"; // 取色要读像素,不加这个非同源图片会让 canvas 变"脏",读不出颜色(读不出就静默放弃,不影响原本的显示)
|
||
img.loading = tier === 0 ? "eager" : "lazy";
|
||
img.decoding = "async";
|
||
try { img.setAttribute("fetchpriority", tier === 0 ? "high" : "low"); } catch (e) {}
|
||
img.onerror = function () { img.style.display = "none"; };
|
||
var loaded = false;
|
||
function doLoad() {
|
||
if (loaded) return; loaded = true;
|
||
if (it.backdrop) setTmdbImg(img, "https://" + (config.tmdbImg || "image.tmdb.org") + "/t/p/original" + it.backdrop);
|
||
else img.src = imgSrc(it.poster || "");
|
||
}
|
||
img._forceLoad = doLoad; // 翻页/手滑到这一帧时可以跳过下面的排队延迟,立刻补加载
|
||
scheduleLoad(doLoad, tier);
|
||
imgMap[logicalIdx] = { img: img };
|
||
return img;
|
||
}
|
||
items.forEach(function (it, idx) { imgsBox.appendChild(makeImgLayer(it, idx, idx === 0 ? 0 : 2)); });
|
||
imgsBox.appendChild(el("div", "hero-shade"));
|
||
imgsBox.appendChild(el("div", "hero-blend"));
|
||
// 信息层:徽标/标题/元信息/简介继续走原来的左右滑动轨道,首尾各补一张克隆帧维持无缝循环手感
|
||
var track = document.createElement("div"); track.className = "hero-track";
|
||
var slideMap = {};
|
||
function makeSlide(it, logicalIdx) {
|
||
var slide = document.createElement("div"); slide.className = "hero-slide";
|
||
var info = document.createElement("div"); info.className = "hero-info";
|
||
var titleWrap = document.createElement("div"); titleWrap.className = "hero-title-wrap";
|
||
var titleText = el("div", "hero-title", it.title);
|
||
titleWrap.appendChild(titleText);
|
||
info.appendChild(titleWrap);
|
||
info.appendChild(el("div", "hero-meta", [it.year, it.typeName, it.status].filter(function (x) { return x; }).join(" · ")));
|
||
if (it.overview) info.appendChild(el("div", "hero-ov", it.overview));
|
||
slide.appendChild(info);
|
||
slide.onclick = function () { if (it.opener) it.opener(); };
|
||
slideMap[logicalIdx] = (slideMap[logicalIdx] || []).concat([{ titleWrap: titleWrap, titleText: titleText }]);
|
||
return slide;
|
||
}
|
||
if (n > 1) track.appendChild(makeSlide(items[n - 1], n - 1)); // 首部克隆最后一张(仅信息层需要)
|
||
items.forEach(function (it, idx) { track.appendChild(makeSlide(it, idx)); });
|
||
if (n > 1) track.appendChild(makeSlide(items[0], 0)); // 尾部克隆第一张(仅信息层需要)
|
||
box.replaceChildren(imgsBox, track);
|
||
box._track = track; box._n = n; box._slideMap = slideMap; box._imgMap = imgMap;
|
||
buildHeroDots(n);
|
||
bindHeroSwipe(box);
|
||
goHeroInstant(0);
|
||
startHeroAuto();
|
||
updateHeroVisible();
|
||
// .hero-bg 到这里才真正存在,此时才能实测 CSS 滚动驱动视差有没有跑起来
|
||
requestAnimationFrame(syncHeroParallaxMode);
|
||
}
|
||
|
||
/* ── Hero 视差:JS 兜底路径 ───────────────────────────────────────────
|
||
首选实现是上面样式表里的滚动驱动动画(@supports (animation-timeline: scroll())),
|
||
它跑在合成器线程、与滚动同帧,完全不抖。这里这套 JS 只给不支持该特性的老内核兜底:
|
||
rAF 写 transform 跑在主线程,与合成器滚动之间存在一帧差,会有轻微抖动(原理性的)。
|
||
所以只要 CSS 那套真的生效,下面整段就不启用,避免两套机制争同一个 transform。
|
||
|
||
判定不能只看 CSS.supports:内核"认识"这个属性但时间轴没绑成功时它也返回 true,
|
||
结果 CSS 不动、JS 又被关掉 → 两边都不干活,视差彻底消失。这里改成「实测」:
|
||
真的去问元素身上有没有一个挂着 timeline 的动画在跑,跑得起来才把活交给 CSS。 */
|
||
var HERO_PLX = 0.42; // 海报滞后系数:净上移 ≈ (1-0.42)=0.58×(与 CSS 关键帧保持一致)
|
||
var _heroPlxCss = false, _heroPlxRaf = 0, _heroPlxBound = false;
|
||
function heroCssParallaxWorks() {
|
||
try {
|
||
if (!window.CSS || !CSS.supports || !CSS.supports("animation-timeline", "scroll()")) return false;
|
||
var el = document.querySelector(".hero-bg");
|
||
if (!el || !el.getAnimations) return false;
|
||
return el.getAnimations().some(function (a) {
|
||
return a.animationName === "heroParallax" && a.timeline && a.timeline !== document.timeline;
|
||
});
|
||
} catch (e) { return false; }
|
||
}
|
||
function heroParallaxApply() {
|
||
if (_heroPlxCss) return; // CSS 已接管,JS 不插手
|
||
var box = $("heroCarousel"); if (!box) return;
|
||
var hero = box.getBoundingClientRect();
|
||
var imgs = box.getElementsByClassName("hero-bg");
|
||
var reduce = false;
|
||
try { reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches; } catch (e) {}
|
||
var st = Math.max(0, window.pageYOffset || document.documentElement.scrollTop || 0);
|
||
// 海报被 .hero/.hero-slide 的 overflow:hidden 上下裁切,下压不会露边,无需额外放大兜底
|
||
var tf = (reduce || hero.height <= 0) ? "" : "translate3d(0," + (st * HERO_PLX).toFixed(2) + "px,0)";
|
||
for (var i = 0; i < imgs.length; i++) imgs[i].style.transform = tf;
|
||
}
|
||
function syncHeroParallaxMode() {
|
||
_heroPlxCss = heroCssParallaxWorks();
|
||
if (_heroPlxCss) return; // 合成器接管:连滚动监听都不用绑
|
||
if (!_heroPlxBound) {
|
||
_heroPlxBound = true;
|
||
window.addEventListener("scroll", function () {
|
||
if (_heroPlxRaf) return;
|
||
_heroPlxRaf = requestAnimationFrame(function () { _heroPlxRaf = 0; heroParallaxApply(); });
|
||
}, { passive: true });
|
||
}
|
||
heroParallaxApply();
|
||
}
|
||
/* 徽标图加载成功后替换掉文字标题;失败则保留文字 */
|
||
function setHeroLogo(logicalIdx, url) {
|
||
var box = $("heroCarousel"); if (!box || !box._slideMap) return;
|
||
var recs = box._slideMap[logicalIdx]; if (!recs) return;
|
||
recs.forEach(function (rec) {
|
||
var img = new Image();
|
||
img.className = "hero-logo"; img.alt = "";
|
||
img.onload = function () { rec.titleText.style.display = "none"; rec.titleWrap.appendChild(img); };
|
||
setTmdbImg(img, url);
|
||
});
|
||
}
|
||
/* 复用给"轮播补图"和"详情页拉资料"共用的检索逻辑:nameList 是一组候选搜索词(见 titleCandidates),
|
||
按顺序逐个去 TMDB 搜,命中一个有效结果就停,不会像之前那样一开始就武断砍标题导致搜不到 */
|
||
function tmdbSearchBest(nameList, yr, wantTv) {
|
||
var kind = wantTv ? "tv" : "movie";
|
||
function score(r, name) {
|
||
var s = 0;
|
||
var t = r.title || r.name || "";
|
||
if (t === name) s += 6; else if (t.indexOf(name) >= 0 || name.indexOf(t) >= 0) s += 3;
|
||
if (wantTv && r.media_type === "tv") s += 4;
|
||
if (!wantTv && r.media_type === "movie") s += 2;
|
||
var ry = (r.release_date || r.first_air_date || "").slice(0, 4);
|
||
if (yr && ry) {
|
||
if (ry === yr) s += 9;
|
||
else { var diff = Math.abs((parseInt(ry, 10) || 0) - (parseInt(yr, 10) || 0)); s -= (diff <= 1 ? 1.5 : 7); }
|
||
}
|
||
s += Math.min(3, (r.popularity || 0) / 40);
|
||
return s;
|
||
}
|
||
// 从"逐个候选词排队试、前一个失败才发下一个"改成"所有候选词并行发起",
|
||
// 把原本最多 N 轮串行往返压到最多 2 轮并行往返(精确搜索一轮 + 兜底 multi 一轮)
|
||
function pickBestAcrossEntries(entries) {
|
||
var best = null, bestScore = -Infinity;
|
||
entries.forEach(function (e) {
|
||
(e.results || []).filter(function (r) { return r && (r.media_type === "movie" || r.media_type === "tv"); })
|
||
.forEach(function (r) {
|
||
var s = score(r, e.name);
|
||
if (s > bestScore) { bestScore = s; best = r; }
|
||
});
|
||
});
|
||
return best;
|
||
}
|
||
var names = (Array.isArray(nameList) ? nameList : [nameList]).filter(Boolean);
|
||
if (!names.length) return Promise.resolve(null);
|
||
|
||
var wave1 = names.map(function (name) {
|
||
if (!yr) return Promise.resolve({ name: name, results: [] });
|
||
var p = { query: name, include_adult: "false" };
|
||
p[wantTv ? "first_air_date_year" : "primary_release_year"] = yr;
|
||
return httpJson(tmdbApi("/search/" + kind, p)).then(function (data) {
|
||
return { name: name, results: (data && data.results ? data.results : []).map(function (r) { r.media_type = kind; return r; }) };
|
||
}, function () { return { name: name, results: [] }; });
|
||
});
|
||
return Promise.all(wave1).then(function (entries) {
|
||
var best = pickBestAcrossEntries(entries);
|
||
if (best) return best;
|
||
var wave2 = names.map(function (name) {
|
||
return httpJson(tmdbApi("/search/multi", { query: name, include_adult: "false" })).then(function (d2) {
|
||
return { name: name, results: d2 && d2.results ? d2.results : [] };
|
||
}, function () { return { name: name, results: [] }; });
|
||
});
|
||
return Promise.all(wave2).then(pickBestAcrossEntries);
|
||
});
|
||
}
|
||
/* 从原始标题派生一组搜索候选词:完整清洗后标题优先(保留『·』『:』等分隔符——很多片名本身就带,
|
||
比如人名"哈利·波特"、副标题"碟中谍6:全面瓦解",缺了这部分反而搜不到);
|
||
完整标题搜不到时,再退回分隔符前面的主标题、以及分隔符后面的部分依次兜底 */
|
||
function titleCandidates(rawTitle) {
|
||
var full = cleanTitle(rawTitle);
|
||
var out = [];
|
||
function add(x) { x = (x || "").replace(/\s+/g, " ").trim(); if (x && x.length >= 1 && out.indexOf(x) < 0) out.push(x); }
|
||
add(full);
|
||
// 冒号/破折号分隔的主副标题,两段都试
|
||
var m = full.match(/^(.*?)[::·\-—_|](.+)$/);
|
||
if (m) { add(m[1]); add(m[2]); }
|
||
// 中英混排(如"误杀 Sheep Without a Shepherd"):分别取纯中文段、纯英文段
|
||
var cjk = full.match(/[\u4e00-\u9fa5][\u4e00-\u9fa50-9]*/g);
|
||
if (cjk && cjk.length) { var longest = cjk.slice().sort(function (a, b) { return b.length - a.length; })[0]; add(longest); }
|
||
var eng = full.match(/[A-Za-z][A-Za-z0-9'&:.\s]{1,}/);
|
||
if (eng) add(eng[0]);
|
||
// 去掉末尾的纯数字续集号再试一次("误杀3" -> "误杀"),有时 TMDB 只收录系列主条目
|
||
add(full.replace(/\s*\d+\s*$/, ""));
|
||
return out.slice(0, 6);
|
||
}
|
||
function buildHeroDots(n) {
|
||
var box = $("heroCarousel");
|
||
var dotsBox = $("heroDots"); if (!dotsBox) return;
|
||
var frag = document.createDocumentFragment();
|
||
for (var i = 0; i < n; i++) { var d = el("span", "hero-dot" + (i === 0 ? " on" : "")); frag.appendChild(d); }
|
||
dotsBox.replaceChildren(frag);
|
||
if (box) box._dots = dotsBox;
|
||
}
|
||
function setDots(i) { var box = $("heroCarousel"); if (!box || !box._dots) return; var ds = box._dots.children; for (var k = 0; k < ds.length; k++) ds[k].classList.toggle("on", k === i); }
|
||
/* 立即(无动画)定位到某一逻辑帧,用于初始化和无缝循环的瞬间归位 */
|
||
function forceLoadHeroFrame(box, logicalIdx) {
|
||
var rec = box._imgMap && box._imgMap[logicalIdx]; if (!rec) return;
|
||
if (rec.img && rec.img._forceLoad) rec.img._forceLoad();
|
||
}
|
||
/* 首页背景跟随轮播海报变色:canvas 缩到很小的尺寸采样,取图中出现次数最多的颜色(众数),
|
||
而不是所有像素的平均色——平均色容易被大片高光/阴影"拉"成一坨灰,众数能更准地反映
|
||
海报里那块观众一眼认定的"主色"(比如大片天空蓝、大片西装黑)。
|
||
取色需要读像素,图片必须"同源"或对方开了 CORS(前面 makeSlide 里已给 hero-bg 加了
|
||
crossOrigin=anonymous);盘链自家图片站/未开 CORS 的图会让 canvas 变"脏",读取时会抛
|
||
SecurityError —— 这里 try/catch 直接吞掉,取色失败就维持当前背景色不动,不会报错也不会
|
||
影响轮播本身。同一张图算过一次就缓存,翻页来回滑不会重复计算。 */
|
||
var heroBgCache = {};
|
||
/* 之前踩过两个坑:
|
||
1) 整图等权平均——取到的是海报"整体"基调,但真正要衔接背景的只是最下面那一条,
|
||
如果海报本身底部渐隐成浅色,整图平均会被中上部"拉"深,背景比实际底边暗,衔接处露馅。
|
||
而且"平均"本身就容易被画面里大片天空/大片阴影这类极端色"拖"成一坨不上不下的灰,
|
||
跟画面里真正视觉主导的颜色对不上。
|
||
2) 改成只采样底部一截之后又矫枉过正——很多海报底部本来就压了一层深色渐变蒙版方便盖标题字
|
||
(这是海报设计自带的,不是页面加的),采样底部结果拿到的几乎是纯黑灰,色相被蒙版"洗掉"了,
|
||
算出来的背景看起来就是灰扑扑一坨,看不出海报本该有的蓝/青/暖色调。
|
||
现在把"色相该是什么颜色"和"亮度该有多暗"拆开来分别采样:色相/饱和度取自海报中上部更大范围
|
||
(不受底部蒙版影响,能反映真实基调,比如路口戏该有的青蓝街景),亮度单独取贴底的那一条边缘
|
||
(因为亮度才是决定衔接是否有台阶的关键)。两个区域各自都不再用"平均",而是把像素按颜色
|
||
量化分桶,找出出现次数最多的那个桶(众数),再用桶内像素的均值代表这个众数颜色,
|
||
既避免了量化造成的色块生硬,又不会被少数极端色像素带偏。两块众数色再用 HSL 重新组合:
|
||
色相饱和度来自"氛围",亮度来自"边缘",既保留了海报本身最主导的调子,又保证亮度和
|
||
背景衔接处精确匹配。 */
|
||
function rgbToHsl(r, g, b) {
|
||
r /= 255; g /= 255; b /= 255;
|
||
var max = Math.max(r, g, b), min = Math.min(r, g, b);
|
||
var h = 0, s = 0, l = (max + min) / 2;
|
||
if (max !== min) {
|
||
var d = max - min;
|
||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||
if (max === r) h = (g - b) / d + (g < b ? 6 : 0);
|
||
else if (max === g) h = (b - r) / d + 2;
|
||
else h = (r - g) / d + 4;
|
||
h /= 6;
|
||
}
|
||
return [h, s, l];
|
||
}
|
||
function hslToRgb(h, s, l) {
|
||
if (s === 0) { var v = Math.round(l * 255); return [v, v, v]; }
|
||
function hue2rgb(p, q, t) {
|
||
if (t < 0) t += 1; if (t > 1) t -= 1;
|
||
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
||
if (t < 1 / 2) return q;
|
||
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
||
return p;
|
||
}
|
||
var q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||
var p = 2 * l - q;
|
||
return [Math.round(hue2rgb(p, q, h + 1 / 3) * 255), Math.round(hue2rgb(p, q, h) * 255), Math.round(hue2rgb(p, q, h - 1 / 3) * 255)];
|
||
}
|
||
/* 按"色相"给像素分桶,而不是按 RGB 立方体分桶——同一片渐变天空/背景,明暗从亮到暗一路变化,
|
||
但色相基本不变;如果按 RGB 分桶,明暗渐变会把这片蓝硬拆成好几个桶,每个桶票数都不多,
|
||
反而让画面里一小块颜色很"平"(比如衣服、肤色)的区域在众数投票里意外胜出——这正是之前
|
||
"明明整张图很蓝,背景却取不到蓝"的根因。改成只按色相(+ 是否低饱和度的"中性/灰黑白"类别)
|
||
分桶后,蓝天不管明暗怎么渐变都会被计入同一个桶,票数才能如实反映"画面里蓝色占比最大"。
|
||
饱和度很低的像素(灰、黑、白)色相本身没有意义,单独按亮度分桶,避免被强行拉进某个色相桶里。 */
|
||
var HERO_HUE_BINS = 20; // 色相环分 20 桶,每桶 18°
|
||
var HERO_NEUTRAL_SAT = 0.12; // 饱和度低于这个值算"中性色",不参与色相投票
|
||
var HERO_NEUTRAL_LUM_BINS = 6; // 中性色再按亮度分 6 桶
|
||
function bumpHueBucket(buckets, r, g, b) {
|
||
var hsl = rgbToHsl(r, g, b);
|
||
var key = hsl[1] < HERO_NEUTRAL_SAT
|
||
? "n" + Math.min(HERO_NEUTRAL_LUM_BINS - 1, (hsl[2] * HERO_NEUTRAL_LUM_BINS) | 0)
|
||
: "h" + Math.min(HERO_HUE_BINS - 1, (hsl[0] * HERO_HUE_BINS) | 0);
|
||
var bk = buckets[key];
|
||
if (bk) { bk.n++; bk.r += r; bk.g += g; bk.b += b; }
|
||
else buckets[key] = { n: 1, r: r, g: g, b: b };
|
||
}
|
||
/* 选出票数最多的桶,用桶内像素的真实均值作为代表色——分桶只用来"数票",
|
||
不用桶的色相中心值直接当颜色,避免看起来一块块的量化色差。
|
||
补丁:色相桶是按 18° 一刀切死的,同一片肉眼看着连续的颜色(比如整片深红墙面)
|
||
如果色相刚好落在两个桶的分界线附近,会被硬拆成相邻两个桶,各自票数被"腰斩",
|
||
于是明明是画面里占比最大的颜色,排名时却输给了某个没有被拆分、票数完整的次要颜色——
|
||
这是"看着很红,取色却不红"的另一个根因(跟顶部遮罩是两回事)。
|
||
修复:排名时把每个色相桶和它左右相邻的桶合并计票(色相环首尾相接),
|
||
相邻本就是相近色相,合并后既纠正了边界切分造成的票数损失,颜色均值也更能代表
|
||
"这一片色系",而不是某一刀切下来的窄条量化色。中性色(灰黑白)桶不参与这个合并,
|
||
因为相邻的是亮度桶,本身没有色相连续性可言。 */
|
||
function topColorBucket(buckets, hueBins) {
|
||
hueBins = hueBins || HERO_HUE_BINS;
|
||
var merged = {};
|
||
for (var k in buckets) {
|
||
if (!Object.prototype.hasOwnProperty.call(buckets, k)) continue;
|
||
if (k.charAt(0) !== "h") { merged[k] = buckets[k]; continue; }
|
||
var idx = parseInt(k.slice(1), 10);
|
||
var n = 0, r = 0, g = 0, b = 0;
|
||
for (var d = -1; d <= 1; d++) {
|
||
var nb = buckets["h" + ((idx + d + hueBins) % hueBins)];
|
||
if (nb) { n += nb.n; r += nb.r; g += nb.g; b += nb.b; }
|
||
}
|
||
merged[k] = { n: n, r: r, g: g, b: b };
|
||
}
|
||
var best = null;
|
||
for (var k2 in merged) { if (Object.prototype.hasOwnProperty.call(merged, k2) && (!best || merged[k2].n > best.n)) best = merged[k2]; }
|
||
if (!best) return null;
|
||
return [best.r / best.n, best.g / best.n, best.b / best.n];
|
||
}
|
||
/* 还原 .hero-bg 实际用的 object-fit:cover + object-position:center 20% 会显示出来的那一块矩形,
|
||
取色只在这块矩形里采样——原来是把整张原图不分青红皂白地压扁进 56×56 画布,海报如果
|
||
本身长宽比例跟 .hero 容器(58vh,横屏/平板还各有一套比例)差得远,会把屏幕上其实被裁掉、
|
||
根本没显示出来的那部分(比如很宽的横版剧照两侧留白)也一起计入色相投票,
|
||
采出来的颜色容易跟屏幕上实际看到的画面对不上。 */
|
||
function coverSampleRect(imgW, imgH, boxW, boxH, posYPercent) {
|
||
if (!imgW || !imgH || !boxW || !boxH) return { sx: 0, sy: 0, sw: imgW, sh: imgH };
|
||
var scale = Math.max(boxW / imgW, boxH / imgH);
|
||
var overflowX = Math.max(0, imgW * scale - boxW);
|
||
var overflowY = Math.max(0, imgH * scale - boxH);
|
||
var sx = (overflowX * 0.5) / scale; // 水平始终居中,跟 CSS 的 center 一致
|
||
var sy = (overflowY * (posYPercent / 100)) / scale;
|
||
var sw = Math.min(imgW, boxW / scale), sh = Math.min(imgH, boxH / scale);
|
||
sx = Math.min(Math.max(0, sx), imgW - sw);
|
||
sy = Math.min(Math.max(0, sy), imgH - sh);
|
||
return { sx: sx, sy: sy, sw: sw, sh: sh };
|
||
}
|
||
function sampleImageDominant(img, cb) {
|
||
try {
|
||
var w = 56, h = 56; // 从 40 提到 56:格数越多,色相分桶时同一片颜色的统计样本越充分,量化噪声占比越小
|
||
var box = $("heroCarousel");
|
||
var boxW = (box && box.clientWidth) || img.naturalWidth;
|
||
var boxH = (box && box.clientHeight) || img.naturalHeight;
|
||
// 20 对应 CSS 里 .hero-bg { object-position: center 20% },两边必须保持一致
|
||
var rect = coverSampleRect(img.naturalWidth, img.naturalHeight, boxW, boxH, 20);
|
||
var cv = document.createElement("canvas"); cv.width = w; cv.height = h;
|
||
var ctx = cv.getContext("2d", { willReadFrequently: true });
|
||
ctx.imageSmoothingEnabled = false; // 关掉缩放平滑:平滑插值会把蓝天和人像边缘"糊"出过渡杂色,稀释真实主色的票数
|
||
ctx.drawImage(img, rect.sx, rect.sy, rect.sw, rect.sh, 0, 0, w, h);
|
||
var data = ctx.getImageData(0, 0, w, h).data;
|
||
var moodBuckets = {}, edgeBuckets = {}; // 氛围:0~85% 高度,不含贴底那截蒙版区;边缘:最底部 15%,只用来定亮度
|
||
var moodN = 0, edgeN = 0;
|
||
var edgeStartRow = Math.round(h * 0.85);
|
||
for (var y = 0; y < h; y++) {
|
||
for (var x = 0; x < w; x++) {
|
||
var i = (y * w + x) * 4;
|
||
if (data[i + 3] < 16) continue; // 忽略透明像素
|
||
if (y < edgeStartRow) { bumpHueBucket(moodBuckets, data[i], data[i + 1], data[i + 2]); moodN++; }
|
||
else { bumpHueBucket(edgeBuckets, data[i], data[i + 1], data[i + 2]); edgeN++; }
|
||
}
|
||
}
|
||
if (!moodN && !edgeN) { cb(null); return; }
|
||
var moodRgb = topColorBucket(moodN ? moodBuckets : edgeBuckets);
|
||
var edgeRgb = topColorBucket(edgeN ? edgeBuckets : moodBuckets) || moodRgb;
|
||
if (!moodRgb) { cb(null); return; }
|
||
var moodHsl = rgbToHsl(moodRgb[0], moodRgb[1], moodRgb[2]);
|
||
var edgeHsl = rgbToHsl(edgeRgb[0], edgeRgb[1], edgeRgb[2]);
|
||
// 色相/饱和度用氛围区的众数色;亮度不能直接照抄边缘区——如果边缘本身压得很暗(比如底部蒙版接近黑),
|
||
// 亮度一低,饱和度再高乘出来也看不出色相(暗到发黑,肉眼分不出是青是灰)。所以亮度用边缘为主、
|
||
// 氛围为辅做加权(7:3),既大体贴着边缘真实亮度(保衔接),又留一点提升空间让色相能被看出来。
|
||
var L = edgeHsl[2] * 0.7 + moodHsl[2] * 0.3;
|
||
cb(hslToRgb(moodHsl[0], moodHsl[1], L));
|
||
} catch (e) { cb(null); } // 跨域取色失败:静默放弃,背景保持原样
|
||
}
|
||
function heroBgFromRgb(rgb) {
|
||
// 上面取色时亮度已经直接取自贴底边缘的真实值,这里不用再猜一个压暗比例去接近它——
|
||
// 只留一道安全阀:万一边缘本身就很亮(比如海报底部是白色地板/白墙),也不能让它把整页
|
||
// 背景拖得太亮,否则页面里大量浅色文字/图标会失去对比度。压暗时保持色相/色调不变。
|
||
var r = rgb[0], g = rgb[1], b = rgb[2];
|
||
var finalLum = 0.299 * r + 0.587 * g + 0.114 * b;
|
||
var maxLum = 104;
|
||
if (finalLum > maxLum) {
|
||
var k = maxLum / finalLum;
|
||
r = Math.round(r * k); g = Math.round(g * k); b = Math.round(b * k);
|
||
} else {
|
||
r = Math.round(r); g = Math.round(g); b = Math.round(b);
|
||
}
|
||
return { css: "rgb(" + r + "," + g + "," + b + ")", rgbList: r + "," + g + "," + b };
|
||
}
|
||
function setDynBg(bg) {
|
||
try {
|
||
document.documentElement.style.setProperty("--dyn-bg", bg.css);
|
||
document.documentElement.style.setProperty("--dyn-bg-rgb", bg.rgbList);
|
||
} catch (e) {}
|
||
}
|
||
/* 自动播放/点点/翻页这几种"非拖拽"切换,颜色原来是瞬间跳变的,但 .hero-bg 的淡入淡出和
|
||
.hero-track 的位移都是 .5s cubic-bezier——图还在半透明交叉的时候,hero-blend/hero-shade
|
||
已经先一步跳成了下一张的颜色,色彩和画面对不上,像是"颜色抢跑"。这里用 rAF 把 rgb
|
||
从旧值线性过渡到新值,时长对齐同一个 .5s,让"海报换完"和"颜色换完"落在同一时刻。
|
||
拖拽跟手那条路径(bindHeroSwipe 里)已经是逐帧按手指进度手动插值,不需要也不会走这里。 */
|
||
var __dynBgAnim = { raf: 0, token: 0 };
|
||
function setDynBgAnimated(bg, duration) {
|
||
if (!bg) return;
|
||
var fromRgb = parseRgbList(getComputedStyle(document.documentElement).getPropertyValue("--dyn-bg-rgb"));
|
||
var toRgb = parseRgbList(bg.rgbList);
|
||
if (!fromRgb || !toRgb) { setDynBg(bg); return; } // 没有旧值可插(比如刚打开首页),直接应用
|
||
if (__dynBgAnim.raf) cancelAnimationFrame(__dynBgAnim.raf);
|
||
var myToken = ++__dynBgAnim.token;
|
||
var start = performance.now();
|
||
duration = duration || 500;
|
||
function step(now) {
|
||
if (myToken !== __dynBgAnim.token) return; // 过渡中途又切到别的帧了,这轮插值作废
|
||
var t = Math.min(1, (now - start) / duration);
|
||
var mixed = lerpRgb(fromRgb, toRgb, t);
|
||
setDynBg({ css: "rgb(" + mixed.join(",") + ")", rgbList: mixed.join(",") });
|
||
if (t < 1) __dynBgAnim.raf = requestAnimationFrame(step);
|
||
else __dynBgAnim.raf = 0;
|
||
}
|
||
__dynBgAnim.raf = requestAnimationFrame(step);
|
||
}
|
||
function applyHeroBgColor(logical, animate) {
|
||
var it = state.heroItems && state.heroItems[logical]; if (!it) return;
|
||
var key = (it.id != null ? it.id : it.title) + "";
|
||
var apply = animate === false ? setDynBg : setDynBgAnimated;
|
||
if (heroBgCache[key]) { apply(heroBgCache[key]); return; }
|
||
var box = $("heroCarousel"); if (!box || !box._imgMap) return;
|
||
var rec = box._imgMap[logical]; var img = rec && rec.img; if (!img) return;
|
||
function go() {
|
||
sampleImageDominant(img, function (rgb) {
|
||
if (!rgb) return;
|
||
var css = heroBgFromRgb(rgb);
|
||
heroBgCache[key] = css;
|
||
if (state.heroIndex === logical) apply(css); // 采样是异步的,回来时可能已经翻到别的页了,得核对一下
|
||
});
|
||
}
|
||
if (img.complete && img.naturalWidth) go(); else img.addEventListener("load", go, { once: true });
|
||
}
|
||
/* 预热:只算色、存进 heroBgCache,不调 setDynBg —— 提前给"还没翻到"的相邻帧把颜色算好,
|
||
等真的翻过去时 applyHeroBgColor 直接命中缓存同步生效,不用现场等图片解码+采样。
|
||
配合 forceLoadHeroFrame 一起在"刚翻到当前帧"那一刻就对左右邻居触发,趁着自动播放的 5.2s
|
||
间隔(或用户划走前的停留时间)把网络请求和采样都提前做完,真正翻页时观感才是"同步融合"。
|
||
已经缓存过的帧直接跳过,不重复采样。 */
|
||
function precomputeHeroBgColor(logical) {
|
||
var it = state.heroItems && state.heroItems[logical]; if (!it) return;
|
||
var key = (it.id != null ? it.id : it.title) + "";
|
||
if (heroBgCache[key]) return;
|
||
var box = $("heroCarousel"); if (!box || !box._imgMap) return;
|
||
var rec = box._imgMap[logical]; var img = rec && rec.img; if (!img) return;
|
||
function go() {
|
||
sampleImageDominant(img, function (rgb) {
|
||
if (!rgb) return;
|
||
var css = heroBgFromRgb(rgb);
|
||
heroBgCache[key] = css;
|
||
// 极端时序下采样回来时刚好已经翻到了这一帧,能追上就立刻追上,不用等下一轮翻页
|
||
if (state.heroIndex === logical) setDynBg(css);
|
||
});
|
||
}
|
||
if (img.complete && img.naturalWidth) go(); else img.addEventListener("load", go, { once: true });
|
||
}
|
||
/* 图片层交叉淡入淡出:animate=false 时瞬时切换(初始化/无缝循环归位用),
|
||
animate=true 时走 .hero-bg 自带的 opacity transition,与信息层的左右滑动同时长、同步完成。
|
||
先清掉拖拽过程中可能残留的内联 opacity,把控制权交还给 .active 类,避免两边打架。 */
|
||
function setHeroImageActive(logical, animate) {
|
||
var box = $("heroCarousel"); if (!box || !box._imgMap) return;
|
||
Object.keys(box._imgMap).forEach(function (k) {
|
||
var rec = box._imgMap[k]; if (!rec || !rec.img) return;
|
||
if (!animate) rec.img.style.transition = "none";
|
||
rec.img.style.opacity = "";
|
||
rec.img.classList.toggle("active", Number(k) === logical);
|
||
if (!animate) { void rec.img.offsetHeight; rec.img.style.transition = ""; }
|
||
});
|
||
}
|
||
function goHeroInstant(logical) {
|
||
var box = $("heroCarousel"); if (!box || !box._track) return;
|
||
var n = box._n || 0; var domIdx = n > 1 ? logical + 1 : 0;
|
||
var track = box._track;
|
||
track.style.transition = "none"; void track.offsetHeight;
|
||
track.style.transform = "translateX(-" + (domIdx * 100) + "%)";
|
||
void track.offsetHeight; track.style.transition = "";
|
||
setHeroImageActive(logical, false);
|
||
state.heroIndex = logical; setDots(logical);
|
||
applyHeroBgColor(logical, false); // 无痕跳回真实帧,颜色也要瞬时对齐,不能跟着渐变一下
|
||
if (n > 1) { precomputeHeroBgColor((logical + 1) % n); precomputeHeroBgColor((logical - 1 + n) % n); }
|
||
}
|
||
/* 翻页:i 是未取模的目标序号(当前 index ± 1)。越过首尾时先动画滑到克隆帧,
|
||
动画结束后瞬间无痕跳回真实帧,实现「一直往前/往后」的连续循环,不会倒退回开头 */
|
||
function goHero(i) {
|
||
var box = $("heroCarousel"); var n = state.heroItems.length; if (!box || !n || !box._track) return;
|
||
var track = box._track;
|
||
var newLogical = ((i % n) + n) % n;
|
||
forceLoadHeroFrame(box, newLogical);
|
||
forceLoadHeroFrame(box, ((newLogical + 1) % n));
|
||
forceLoadHeroFrame(box, ((newLogical - 1 + n) % n));
|
||
var forwardWrap = n > 1 && i === n;
|
||
var backwardWrap = n > 1 && i === -1;
|
||
if (forwardWrap) {
|
||
track.style.transform = "translateX(-" + ((n + 1) * 100) + "%)";
|
||
onceTransitionEnd(track, function () { goHeroInstant(0); });
|
||
} else if (backwardWrap) {
|
||
track.style.transform = "translateX(0%)";
|
||
onceTransitionEnd(track, function () { goHeroInstant(n - 1); });
|
||
} else {
|
||
var domIdx = n > 1 ? newLogical + 1 : 0;
|
||
track.style.transform = "translateX(-" + (domIdx * 100) + "%)";
|
||
}
|
||
setHeroImageActive(newLogical, true);
|
||
state.heroIndex = newLogical; setDots(newLogical);
|
||
applyHeroBgColor(newLogical);
|
||
if (n > 1) { precomputeHeroBgColor((newLogical + 1) % n); precomputeHeroBgColor((newLogical - 1 + n) % n); }
|
||
startHeroAuto();
|
||
}
|
||
function onceTransitionEnd(track, cb) {
|
||
function handler(e) { if (e.target !== track || (e.propertyName && e.propertyName !== "transform")) return; track.removeEventListener("transitionend", handler); cb(); }
|
||
track.addEventListener("transitionend", handler);
|
||
}
|
||
function startHeroAuto() {
|
||
if (state.heroTimer) { clearTimeout(state.heroTimer); state.heroTimer = null; }
|
||
if (!state.heroItems || state.heroItems.length < 2) return;
|
||
state.heroTimer = setTimeout(function () { goHero(state.heroIndex + 1); }, 5200);
|
||
}
|
||
/* 颜色插值用:把 "r,g,b" 字符串解析成数组;两个 rgb 数组按比例 t(0~1) 混合。
|
||
拖拽跟手变色靠这两个小工具从"起始色"渐变到"目标邻居的色"。 */
|
||
function parseRgbList(s) {
|
||
var m = (s || "").split(",").map(function (x) { return parseInt(x, 10); });
|
||
if (m.length !== 3 || m.some(function (x) { return isNaN(x); })) return null;
|
||
return m;
|
||
}
|
||
function lerpRgb(a, b, t) {
|
||
return [Math.round(a[0] + (b[0] - a[0]) * t), Math.round(a[1] + (b[1] - a[1]) * t), Math.round(a[2] + (b[2] - a[2]) * t)];
|
||
}
|
||
/* 跟手拖拽轮播:手指按住实时跟随移动,松手按位移/速度判定翻页或回弹(含首尾无缝衔接)
|
||
注意:box(heroCarousel) 是持久节点,每次轮播刷新只会替换内部的 track,不会重建 box 本身;
|
||
只在首次绑定一次事件,避免每次 loadHero() 都叠加一套监听器(叠加后一次滑动会被触发多次,
|
||
表现为手动滑动“跳过一两张”,而自动轮播只用一个 timer 所以看不出问题)。 */
|
||
function bindHeroSwipe(box) {
|
||
if (box._swipeBound) return;
|
||
box._swipeBound = true;
|
||
var sx = 0, sy = 0, curX = 0, w = 1, dragging = false, moved = false, lockedOut = false, lastT = 0, lastX = 0, vx = 0;
|
||
var dragStartRgb = null; // 按下瞬间的背景色,作为插值起点
|
||
function widthPx() { return box.getBoundingClientRect().width || 1; }
|
||
function baseDomPct() { var n = box._n || 0; return (n > 1 ? state.heroIndex + 1 : 0) * 100; }
|
||
// 拖拽期间图片层交叉渐隐渐显跟手指走:关掉 CSS transition 直接改 opacity,避免和手指位移有延迟感;
|
||
// 松手后再把 transition 还回去,交给 goHero/goHeroInstant 里的 class 切换来完成"归位"或"继续过渡到目标帧"。
|
||
function setHeroImgTransition(t) {
|
||
if (!box._imgMap) return;
|
||
Object.keys(box._imgMap).forEach(function (k) { var rec = box._imgMap[k]; if (rec && rec.img) rec.img.style.transition = t; });
|
||
}
|
||
function dragImageProgress(fromLogical, toLogical, progress) {
|
||
if (!box._imgMap) return;
|
||
Object.keys(box._imgMap).forEach(function (k) {
|
||
var idx = Number(k); var rec = box._imgMap[k]; if (!rec || !rec.img) return;
|
||
var op = idx === fromLogical ? (1 - progress) : (idx === toLogical ? progress : 0);
|
||
rec.img.style.opacity = op;
|
||
});
|
||
}
|
||
box.addEventListener("touchstart", function (e) {
|
||
var t = e.touches[0]; if (!t) return;
|
||
sx = curX = lastX = t.clientX; sy = t.clientY; w = widthPx();
|
||
dragging = true; moved = false; lockedOut = false; vx = 0; lastT = Date.now();
|
||
if (state.heroTimer) { clearTimeout(state.heroTimer); state.heroTimer = null; }
|
||
dragStartRgb = parseRgbList(getComputedStyle(document.documentElement).getPropertyValue("--dyn-bg-rgb"));
|
||
// 手指一按下就把左右邻居的颜色提前备好,防止真的划出去时缓存还没算完、插值没有目标色可用
|
||
var n0 = state.heroItems ? state.heroItems.length : 0;
|
||
if (n0 > 1) { precomputeHeroBgColor((state.heroIndex + 1) % n0); precomputeHeroBgColor((state.heroIndex - 1 + n0) % n0); }
|
||
}, { passive: true });
|
||
box.addEventListener("touchmove", function (e) {
|
||
if (!dragging || lockedOut) return;
|
||
var t = e.touches[0]; if (!t) return;
|
||
var dx = t.clientX - sx, dy = t.clientY - sy;
|
||
if (!moved) {
|
||
if (Math.abs(dy) > Math.abs(dx) && Math.abs(dy) > 8) { lockedOut = true; return; }
|
||
if (Math.abs(dx) > 6) { moved = true; if (box._track) box._track.style.transition = "none"; setHeroImgTransition("none"); }
|
||
}
|
||
if (!moved) return;
|
||
curX = t.clientX;
|
||
var now = Date.now(); if (now > lastT) { vx = (curX - lastX) / (now - lastT); lastT = now; lastX = curX; }
|
||
// 只有信息层(徽标/标题/简介)继续按位移左右滑动;图片本身不跟着位移,只用同一个 progress 做交叉渐隐渐显
|
||
if (box._track) box._track.style.transform = "translateX(calc(-" + baseDomPct() + "% + " + dx + "px))";
|
||
var n = state.heroItems ? state.heroItems.length : 0;
|
||
var progress = Math.min(1, Math.abs(dx) / w);
|
||
var targetLogical = n > 1 ? ((dx < 0 ? state.heroIndex + 1 : state.heroIndex - 1) % n + n) % n : state.heroIndex;
|
||
if (n > 1) dragImageProgress(state.heroIndex, targetLogical, progress);
|
||
// 背景色按同一个 progress,从起始色实时插值到"正在滑向的那个邻居"的色,图片、色彩、信息层三者同步走,
|
||
// 而不是等松手那一刻才跳变到最终颜色
|
||
if (n > 1 && dragStartRgb) {
|
||
var targetItem = state.heroItems[targetLogical];
|
||
var key = targetItem ? (targetItem.id != null ? targetItem.id : targetItem.title) + "" : null;
|
||
var targetBg = key ? heroBgCache[key] : null;
|
||
if (targetBg) {
|
||
var targetRgb = parseRgbList(targetBg.rgbList);
|
||
if (targetRgb) {
|
||
var mixed = lerpRgb(dragStartRgb, targetRgb, progress);
|
||
setDynBg({ css: "rgb(" + mixed.join(",") + ")", rgbList: mixed.join(",") });
|
||
}
|
||
}
|
||
}
|
||
}, { passive: true });
|
||
function finish() {
|
||
if (!dragging) return; dragging = false;
|
||
if (box._track) box._track.style.transition = "";
|
||
setHeroImgTransition("");
|
||
if (!moved) { startHeroAuto(); return; }
|
||
var dx = curX - sx;
|
||
var far = Math.abs(dx) > w * 0.16, fast = Math.abs(vx) > 0.45 && Math.abs(dx) > 12;
|
||
if (far || fast) goHero(state.heroIndex + (dx < 0 ? 1 : -1));
|
||
else goHero(state.heroIndex);
|
||
}
|
||
box.addEventListener("touchend", finish, { passive: true });
|
||
box.addEventListener("touchcancel", finish, { passive: true });
|
||
}
|
||
/* 搜索时隐藏轮播,浏览/历史时显示;TV 端首页固定隐藏轮播(遥控器场景更适合直接看行列表) */
|
||
function updateHeroVisible() {
|
||
// 搜索结果页的头部改造:进入搜索结果时给 #catView 挂 .searching(CSS 据此把标题换成搜索框、
|
||
// 把胶囊移到下一行),并把当前关键词填进搜索框按钮里。离开搜索则摘掉。放在这里是因为本函数
|
||
// 在每次视图切换(首页/分类/搜索/最近)时都会被调用,能可靠地跟着 state.view 同步。
|
||
var cv = $("catView");
|
||
if (cv) {
|
||
if (state.view === "search") {
|
||
cv.classList.add("searching");
|
||
var kwEl = $("searchBarKw"); if (kwEl) kwEl.textContent = state.searchKw || "";
|
||
} else {
|
||
cv.classList.remove("searching");
|
||
}
|
||
}
|
||
var wrap = $("heroWrap"); if (!wrap) return;
|
||
var hide = state.isTv || state.searchMode || state.view !== "home" || (!state.heroLoading && (!state.heroItems || !state.heroItems.length));
|
||
wrap.hidden = hide;
|
||
if (hide) { if (state.heroTimer) { clearTimeout(state.heroTimer); state.heroTimer = null; } }
|
||
else startHeroAuto();
|
||
}
|
||
/* 标题->TMDB(media_type,id) 命中缓存:session 内存 + 落盘(fm.cache),
|
||
同一部片第二次进详情页直接跳过"搜索定位"那一步,只发一次详情请求 */
|
||
var _tmdbPickCache = {};
|
||
var _tmdbPickCacheLoaded = false;
|
||
var _tmdbPickInflight = {}; // cacheKey -> Promise<pick|null>:预热和正式打开详情页如果撞在一起,只发一轮搜索,谁先发谁替后面的人把活干了
|
||
var _tmdbDetailCache = {}; // "mediaType:id" -> 完整详情 JSON,只留内存(数据量大、命中率主要在"刚预热完就点进去"这种场景,不必落盘)
|
||
var _tmdbDetailInflight = {}; // "mediaType:id" -> Promise<detail|null>
|
||
function _tmdbPickKey(name, yr, wantTv) { return (name || "").toLowerCase() + "|" + (yr || "") + "|" + (wantTv ? 1 : 0); }
|
||
function loadTmdbPickCacheOnce() {
|
||
if (_tmdbPickCacheLoaded) return Promise.resolve();
|
||
_tmdbPickCacheLoaded = true;
|
||
return storeGet("tmdbPickCache").then(function (v) {
|
||
if (v) { try { _tmdbPickCache = JSON.parse(v) || {}; } catch (e) {} }
|
||
}, function () {});
|
||
}
|
||
function saveTmdbPickCacheDebounced() {
|
||
clearTimeout(saveTmdbPickCacheDebounced._t);
|
||
saveTmdbPickCacheDebounced._t = setTimeout(function () {
|
||
try { storeSet("tmdbPickCache", JSON.stringify(_tmdbPickCache)); } catch (e) {}
|
||
}, 800);
|
||
}
|
||
/* 算出某个条目在 TMDB 侧要用的候选词/年份/影或剧——预热和正式打开详情页必须用同一套推断逻辑,
|
||
两边算出来的 cacheKey 对不上的话,预热等于白热。 */
|
||
function tmdbLookupArgs(item) {
|
||
var yr = String(item.year || yearOf(item.title) || "").slice(0, 4);
|
||
var wantTv = state.activeCat === "2" || state.activeCat === "4" || /第.?季|剧集|全集|季/.test(item.title || "") || /剧|集|动漫/.test(item.meta || "") || (item.typeName && /剧|综艺|动漫|动画/.test(item.typeName));
|
||
var names = titleCandidates(item.title);
|
||
return { names: names, yr: yr, wantTv: wantTv, cacheKey: _tmdbPickKey(names[0] || item.title, yr, wantTv) };
|
||
}
|
||
/* 拿 {media_type,id}:落盘缓存 -> 在飞请求 -> 真发搜索,三级短路。
|
||
预热(按下卡片)和正式打开详情页(loadTmdb)共用这一个函数,谁先触发谁把网络请求发出去,
|
||
后来的人(不管是预热撞见已打开的详情页,还是详情页撞见还没跑完的预热)都是白捡。 */
|
||
function tmdbResolvePick(args) {
|
||
return loadTmdbPickCacheOnce().then(function () {
|
||
var cached = _tmdbPickCache[args.cacheKey];
|
||
if (cached) return cached;
|
||
if (_tmdbPickInflight[args.cacheKey]) return _tmdbPickInflight[args.cacheKey];
|
||
var p = tmdbSearchBest(args.names, args.yr, args.wantTv).then(function (pick) {
|
||
delete _tmdbPickInflight[args.cacheKey];
|
||
if (pick) { _tmdbPickCache[args.cacheKey] = { media_type: pick.media_type, id: pick.id }; saveTmdbPickCacheDebounced(); }
|
||
return pick || null;
|
||
}, function () { delete _tmdbPickInflight[args.cacheKey]; return null; });
|
||
_tmdbPickInflight[args.cacheKey] = p;
|
||
return p;
|
||
}, function () { return null; });
|
||
}
|
||
/* 拿完整详情 JSON:同样内存缓存 -> 在飞请求 -> 真发请求三级短路 */
|
||
function tmdbResolveDetail(pick) {
|
||
if (!pick) return Promise.resolve(null);
|
||
var key = pick.media_type + ":" + pick.id;
|
||
if (_tmdbDetailCache[key]) return Promise.resolve(_tmdbDetailCache[key]);
|
||
if (_tmdbDetailInflight[key]) return _tmdbDetailInflight[key];
|
||
var p = httpJson(tmdbApi("/" + pick.media_type + "/" + pick.id, {
|
||
append_to_response: "images,content_ratings,release_dates,credits",
|
||
include_image_language: "zh,en,null"
|
||
})).then(function (det) {
|
||
delete _tmdbDetailInflight[key];
|
||
_tmdbDetailCache[key] = det;
|
||
return det;
|
||
}, function (e) { delete _tmdbDetailInflight[key]; throw e; });
|
||
_tmdbDetailInflight[key] = p;
|
||
return p;
|
||
}
|
||
function loadTmdb(item) {
|
||
if (!config.tmdbKey) return;
|
||
var token = state.panToken;
|
||
var args = tmdbLookupArgs(item);
|
||
tmdbResolvePick(args).then(function (pick) {
|
||
if (!pick || token !== state.panToken) return null;
|
||
return tmdbResolveDetail(pick).then(function (det) {
|
||
if (token !== state.panToken) return;
|
||
applyTmdb(det, pick.media_type);
|
||
});
|
||
}).then(function () {}, function () {});
|
||
}
|
||
/* 卡片按下就预热:手指刚触到卡片(比 click 早那么几十上百毫秒,且这时候用户点开的意图已经很明确了)
|
||
就提前把"这是哪部片"+完整详情摸回来,等真正点进详情页时,loadTmdb() 大概率直接读缓存,
|
||
一次网络往返都不用等——跟 loadResources()/enrichAllSites() 这些本来就没有身份确认这一步的国内源
|
||
打平了。纯后台预热,不碰任何 UI、不占 state.panToken,就算用户按下后没点进去也没关系,
|
||
缓存留着,下次刷到同一部片(哪怕换了个位置/换了个源)一样能命中。 */
|
||
function prewarmTmdb(item) {
|
||
if (!config.tmdbKey || !item || !item.title) return;
|
||
var args = tmdbLookupArgs(item);
|
||
tmdbResolvePick(args).then(function (pick) { return pick ? tmdbResolveDetail(pick) : null; }).then(function () {}, function () {});
|
||
}
|
||
/* 绑定"按下"触发:触屏用 touchstart,鼠标用 mousedown,两个都绑是因为老内核/部分设备
|
||
触屏事件不一定完整触发到 mousedown,二选一漏了就退化成没预热;fired 防一次物理按下触发两次。 */
|
||
function bindPrewarmTrigger(elm, fn) {
|
||
var fired = false;
|
||
function trigger() { if (fired) return; fired = true; fn(); }
|
||
elm.addEventListener("touchstart", trigger, { passive: true });
|
||
elm.addEventListener("mousedown", trigger);
|
||
}
|
||
/* 是不是"摸屏幕"的设备(手机、平板都算,靠指针精度判断,不用 state.isMobile 那个按宽度分的手机专用标记——
|
||
平板横屏宽度往往超过 720,会被 state.isMobile 判成"不是手机",但平板一样存在"竖版海报被横屏裁掉一大截"的问题)。
|
||
鼠标操作的桌面浏览器 pointer 是 fine,不会命中,维持原来"浏览器恒用竖版海报"的设计不受影响。 */
|
||
function isTouchDevice() {
|
||
if (state.isMobile) return true;
|
||
try { return !!(window.matchMedia && matchMedia("(pointer: coarse)").matches); } catch (e) { return false; }
|
||
}
|
||
/* 当前视口宽高:优先 visualViewport(WebView 里旋转后它比 window.innerWidth/Height 更早刷新),
|
||
再退到 window.inner*,最后退到文档元素的 client*。三者在不同壳子里各有失灵的时候,取先拿到的那个。 */
|
||
function viewportWH() {
|
||
var vv = window.visualViewport;
|
||
var w = (vv && vv.width) || window.innerWidth || (document.documentElement && document.documentElement.clientWidth) || 0;
|
||
var h = (vv && vv.height) || window.innerHeight || (document.documentElement && document.documentElement.clientHeight) || 0;
|
||
return { w: w, h: h };
|
||
}
|
||
/* 大图轮播是否该用横版剧照:横屏用横版剧照,竖屏用竖版海报。
|
||
之前这里优先信 CSS 媒体查询 (orientation: landscape),量尺寸只在拿不到 mq 结果时才用——
|
||
但部分设备(尤其是平板)的 WebView 里这条媒体查询本身就不准/不跟手,会一直报"竖屏",
|
||
结果哪怕实际画面已经是横屏、宽>高,也照样判成竖屏、继续用竖版海报——这才是
|
||
"平板横屏还加载竖版海报"的根源:判断依据从一开始就没信真正的像素宽高。
|
||
现在反过来:真实宽高(w>h)才是用户眼睛看到的那个方向,优先信它;
|
||
只有宽高都拿不到(=0,极少见)时才退回媒体查询兜底。
|
||
旋转瞬间宽高可能还没刷新的问题,交给下面 scheduleHeroRebuild 的多次重试(0/120/350/800ms)去兜底,
|
||
而不是靠一个本身可能不准的 mq 硬顶上去。 */
|
||
function wantBackdropHero() {
|
||
var v = viewportWH();
|
||
if (v.w && v.h) return v.w > v.h;
|
||
try { if (window.matchMedia) return matchMedia("(orientation: landscape)").matches; } catch (e) {}
|
||
return false;
|
||
}
|
||
/* 根据当前 wantBackdropHero() 从已经存好的图集里重新挑一批图,不需要重新请求 TMDB。
|
||
手机旋转屏幕时调用这个来"热切换"轮播池。 */
|
||
function buildDetailHeroList() {
|
||
var pools = state.currentDetail && state.currentDetail.heroPools;
|
||
if (!pools) return null;
|
||
var useBackdrop = wantBackdropHero();
|
||
var primary = useBackdrop ? (pools.backdropPrimary || pools.posterPrimary) : (pools.posterPrimary || pools.backdropPrimary);
|
||
var poolRaw = useBackdrop ? (pools.backdrops || []) : ((pools.posters && pools.posters.length) ? pools.posters : (pools.backdrops || []));
|
||
var heroList = [];
|
||
if (primary) heroList.push(primary);
|
||
poolRaw.slice().sort(function (a, b) { return (b.vote_average || 0) - (a.vote_average || 0); })
|
||
.slice(0, 6).forEach(function (p) {
|
||
var u = pools.tImg(p.file_path);
|
||
if (u && heroList.indexOf(u) < 0) heroList.push(u);
|
||
});
|
||
return heroList.slice(0, 6);
|
||
}
|
||
/* 手机/平板横竖屏切换时,如果详情页正开着且已经有 TMDB 图集,就按新的方向重新挑一批图热切换。
|
||
force=true 时无视"模式没变"的短路(旋转过渡中尺寸/媒体查询可能先后脚到,重试要能真正跑一次)。 */
|
||
function rebuildDetailHeroForOrientation(force) {
|
||
if (!state.currentDetail || !state.currentDetail.heroPools) return;
|
||
var mode = wantBackdropHero() ? "backdrop" : "poster";
|
||
if (!force && state.dHeroMode === mode) return; // 方向没变(比如只是软键盘弹出导致的 resize),不折腾
|
||
var list = buildDetailHeroList();
|
||
if (!list || !list.length) return;
|
||
var cur = state.dHeroImages || [];
|
||
if (cur.length === list.length && cur[0] === list[0]) { state.dHeroMode = mode; return; } // 图集实际没变(比如该片只有横版图),不重复淡入
|
||
state.dHeroMode = mode;
|
||
softUpdateDetailHeroImages(list, false);
|
||
}
|
||
/* 旋转防抖 + 多次重试:WebView 旋转后视口尺寸是分几帧陆续刷新的,只测一次很容易读到旧值。
|
||
这里在 0 / 120 / 350 / 800ms 各校验一次,方向已经切过来了就重建,已经是目标模式的自然跳过(幂等)。 */
|
||
var _heroRotTimers = [];
|
||
function scheduleHeroRebuild() {
|
||
_heroRotTimers.forEach(function (t) { clearTimeout(t); });
|
||
_heroRotTimers = [0, 120, 350, 800].map(function (ms) {
|
||
return setTimeout(function () { rebuildDetailHeroForOrientation(false); }, ms);
|
||
});
|
||
}
|
||
function applyTmdb(det, mtype) {
|
||
if (!det) return;
|
||
var imgs = det.images || {};
|
||
function bestImg(arr, wantLang) {
|
||
if (!arr || !arr.length) return "";
|
||
var byLang = arr.filter(function (x) { return x.iso_639_1 === wantLang; });
|
||
var pool = byLang.length ? byLang : arr;
|
||
pool.sort(function (a, b) { return (b.vote_average || 0) - (a.vote_average || 0); });
|
||
return pool[0].file_path;
|
||
}
|
||
var posterPath = bestImg(imgs.posters, "zh") || bestImg(imgs.posters, "en") || det.poster_path || "";
|
||
var backdropPath = bestImg(imgs.backdrops, null) || det.backdrop_path || "";
|
||
var logoPath = bestImg(imgs.logos, "zh") || bestImg(imgs.logos, "en") || bestImg(imgs.logos, null) || ((imgs.logos && imgs.logos[0]) ? imgs.logos[0].file_path : "");
|
||
function tImg(size, path) { return path ? ("https://" + (config.tmdbImg || "image.tmdb.org") + "/t/p/" + size + path) : ""; }
|
||
var tmdb = {
|
||
poster: tImg("original", posterPath),
|
||
backdrop: tImg("original", backdropPath),
|
||
logo: tImg("original", logoPath),
|
||
genres: (det.genres || []).map(function (g) { return g.name; }),
|
||
year: (det.release_date || det.first_air_date || "").slice(0, 4),
|
||
seasons: det.number_of_seasons || 0,
|
||
runtime: det.runtime || (det.episode_run_time && det.episode_run_time[0]) || 0,
|
||
status: det.status || "",
|
||
overview: det.overview || "",
|
||
cert: pickCert(det, mtype),
|
||
mtype: mtype,
|
||
cast: (det.credits && det.credits.cast ? det.credits.cast : []).slice(0, 6).map(function (c) {
|
||
return { id: c.id, name: c.name || "", origName: c.original_name || "", role: c.character || "", pic: c.profile_path ? tImg("w185", c.profile_path) : "" };
|
||
})
|
||
};
|
||
state.currentDetail.tmdb = tmdb;
|
||
state.currentDetail.tmdbRawDet = det;
|
||
state.currentDetail.tmdbRawMtype = mtype;
|
||
|
||
// 大海报轮播:TV 恒用横版剧照池;手机/浏览器平时用竖版海报池(跟原来"选一张最佳图"的取向一致),
|
||
// 但手机横屏时改用横版剧照池,避免竖版海报被裁掉一大截、显示不全(见 wantBackdropHero)。
|
||
// 主图(原来单图逻辑选出的最佳那张)排轮播第一位,再从对应图集里按评分挑几张凑成候选池,自动去重。
|
||
// 这里把原始图集(posters/backdrops)存到 currentDetail.heroPools 里,手机旋转屏幕时不用重新请求
|
||
// TMDB,直接用 buildDetailHeroList() 就能按新方向重新挑图、热切换。
|
||
state.currentDetail.heroPools = {
|
||
posters: imgs.posters || [], backdrops: imgs.backdrops || [],
|
||
posterPrimary: tmdb.poster, backdropPrimary: tmdb.backdrop,
|
||
tImg: function (path) { return tImg("original", path); } // 横版剧照池 / 竖版海报池统一用 original,选最高清的图
|
||
};
|
||
var heroList = buildDetailHeroList();
|
||
state.dHeroMode = wantBackdropHero() ? "backdrop" : "poster"; // 记下这批图是按哪个方向挑的,供旋转时判断要不要换池
|
||
if (heroList && heroList.length) softUpdateDetailHeroImages(heroList, false);
|
||
// 名称:有徽标用徽标图,否则用文字标题(都在播放键上方的正文区,始终完整)
|
||
if (tmdb.logo) { var lg = document.createElement("img"); lg.alt = ""; setTmdbImg(lg, tmdb.logo); $("edLogo").replaceChildren(lg); showTitle(null); }
|
||
else { $("edLogo").replaceChildren(); showTitle(state.currentDetail.title || state.currentDetail.rawTitle); }
|
||
|
||
renderChipsMeta(tmdb, state.currentItem);
|
||
if (tmdb.overview && !state.currentDetail.overviewFromSite) { $("detailOverview").textContent = tmdb.overview; state.currentDetail.overviewSet = true; }
|
||
if (state.currentDetail.poster === "" && tmdb.poster) state.currentDetail.poster = tmdb.poster;
|
||
renderCast(tmdb.cast);
|
||
// 「剧集」单集卡:只有剧/综艺/动漫(TMDB mtype === "tv")才有分季,电影没有,直接清空该区块;
|
||
// 设置里关掉「剧集单集列表」的话同样直接清空,不构建
|
||
if (config.showEpisodes !== false && mtype === "tv" && det.id && det.seasons && det.seasons.length) initEpisodesSection(det.id, det.seasons);
|
||
else if ($("edEpisodes")) $("edEpisodes").replaceChildren();
|
||
}
|
||
/* 初始化「剧集」区:过滤掉没有集数的季(比如空的特别篇),默认选中第 1 季(没有就选列表第一个),
|
||
拉这一季的分集列表。季选择器只在有 2 季及以上时才显示,跟网盘资源的画质 tab 一个逻辑。 */
|
||
function initEpisodesSection(tvId, seasonsRaw) {
|
||
var box = $("edEpisodes"); if (!box) return;
|
||
var seasons = (seasonsRaw || []).filter(function (s) { return (s.episode_count || 0) > 0; });
|
||
if (!seasons.length) { box.replaceChildren(); return; }
|
||
state.currentDetail.tvId = tvId;
|
||
state.currentDetail.tvSeasons = seasons;
|
||
var def = seasons.filter(function (s) { return s.season_number === 1; })[0] || seasons[0];
|
||
renderEpisodesShell(seasons, def.season_number);
|
||
loadSeasonEpisodes(tvId, def.season_number);
|
||
}
|
||
/* 「剧集」区外壳:标题 + 分季 tab(复用 ed-qtab 同款交互),下面接一个占位的 ep-rail 等分集数据回来 */
|
||
function renderEpisodesShell(seasons, activeNum) {
|
||
var box = $("edEpisodes"); if (!box) return;
|
||
box.replaceChildren();
|
||
var head = document.createElement("div"); head.className = "ed-episodes-head";
|
||
head.appendChild(el("span", "ed-works-head", "剧集"));
|
||
if (seasons.length > 1) {
|
||
var tabs = document.createElement("div"); tabs.className = "ep-season-tabs";
|
||
seasons.forEach(function (s) {
|
||
var b = document.createElement("button");
|
||
b.type = "button";
|
||
b.className = "ep-season-chip focusable" + (s.season_number === activeNum ? " active" : "");
|
||
b.textContent = s.season_number === 0 ? "特别篇" : ("第 " + s.season_number + " 季");
|
||
b.onclick = function () {
|
||
if (state.currentDetail.epSeason === s.season_number) return;
|
||
var kids = tabs.children; for (var i = 0; i < kids.length; i++) kids[i].classList.remove("active");
|
||
b.classList.add("active");
|
||
loadSeasonEpisodes(state.currentDetail.tvId, s.season_number);
|
||
};
|
||
tabs.appendChild(b);
|
||
});
|
||
head.appendChild(tabs);
|
||
// 去掉 flex-end 右对齐后默认停在最左边,这里手动把当前季滚到可见范围内(不用整个居中,
|
||
// 露出来就行,紧挨着的前后一两个也顺带露出来,让人知道还能往哪边滑)
|
||
var activeChip = tabs.querySelector(".ep-season-chip.active");
|
||
if (activeChip) {
|
||
requestAnimationFrame(function () {
|
||
var left = activeChip.offsetLeft - (tabs.clientWidth - activeChip.offsetWidth) / 2;
|
||
tabs.scrollLeft = Math.max(0, left);
|
||
});
|
||
}
|
||
}
|
||
box.appendChild(head);
|
||
var rail = document.createElement("div"); rail.className = "ep-rail"; rail.id = "epRail";
|
||
rail.appendChild(epRailSkeleton());
|
||
box.appendChild(rail);
|
||
}
|
||
/* 拉某一季的分集(TMDB /tv/{id}/season/{n}),带 token 防止切季快、旧请求后到把新一季的结果覆盖掉 */
|
||
function loadSeasonEpisodes(tvId, seasonNum) {
|
||
state.currentDetail.epSeason = seasonNum;
|
||
state.epToken = (state.epToken || 0) + 1;
|
||
var myToken = state.epToken;
|
||
var rail = $("epRail"); if (rail) rail.replaceChildren(epRailSkeleton());
|
||
httpJson(tmdbApi("/tv/" + tvId + "/season/" + seasonNum, {})).then(function (d) {
|
||
if (myToken !== state.epToken || !state.currentDetail) return;
|
||
paintEpisodeRail((d && d.episodes) || []);
|
||
}, function () {
|
||
if (myToken !== state.epToken) return;
|
||
var r = $("epRail"); if (r) r.replaceChildren(el("div", "ep-loading", "剧集加载失败,可稍后重试"));
|
||
});
|
||
}
|
||
/* 画单集卡:剧照(stills) + 集号 + 标题。TMDB 没图时给个占位。点一下滚到下面「网盘资源」,
|
||
提示按第几集去找对应资源——单集卡本身只是 TMDB 的剧集信息,跟哪条网盘资源对应不了结构化关系。 */
|
||
function paintEpisodeRail(episodes) {
|
||
var rail = $("epRail"); if (!rail) return;
|
||
if (!episodes || !episodes.length) { rail.replaceChildren(el("div", "ep-loading", "暂无剧集信息")); return; }
|
||
var frag = document.createDocumentFragment();
|
||
episodes.forEach(function (e) {
|
||
var card = document.createElement("div"); card.className = "ep-tcard";
|
||
var thumb = document.createElement("div"); thumb.className = "ep-tcard-img";
|
||
if (e.still_path) {
|
||
var img = document.createElement("img"); img.loading = "lazy"; img.alt = e.name || "";
|
||
setTmdbImg(img, "https://" + (config.tmdbImg || "image.tmdb.org") + "/t/p/w300" + e.still_path);
|
||
thumb.appendChild(img);
|
||
} else {
|
||
thumb.classList.add("ph");
|
||
}
|
||
var num = document.createElement("span"); num.className = "ep-tcard-num"; num.textContent = "第 " + e.episode_number + " 集";
|
||
thumb.appendChild(num);
|
||
card.appendChild(thumb);
|
||
card.appendChild(el("div", "ep-tcard-title", e.name || ("第" + e.episode_number + "集")));
|
||
card.onclick = function () { jumpToEpisodeResource(e.episode_number); };
|
||
frag.appendChild(card);
|
||
});
|
||
rail.replaceChildren(frag);
|
||
}
|
||
/* 在「115」类别的网盘资源里,按标题里的 SxxExx(比如"雀骨 (2026) S01E09 ...")匹配当前季 + 单集卡点的这一集。
|
||
只在 115 里找,是因为这种一集一条、标题里带 SxxExx 的命名规律目前只有 115 这类资源稳定带;
|
||
数字前面允许有 0(S1/S01 都认,E9/E09 都认),后面用负向前瞻卡住,避免把第 9 集错认成第 91 集这种。 */
|
||
function findEpisodeResource(seasonNum, epNum) {
|
||
if (!state.panItems || !state.panItems.length) return null;
|
||
var re = new RegExp("s0*" + seasonNum + "e0*" + epNum + "(?!\\d)", "i");
|
||
for (var i = 0; i < state.panItems.length; i++) {
|
||
var it = state.panItems[i];
|
||
if ((it.quality || "") !== "115") continue;
|
||
if (it.state === "bad") continue;
|
||
if (isPanTypeBlocked(it.quality || "其他")) continue;
|
||
var hay = (it.title || "") + " " + (it.groupName || "");
|
||
if (re.test(hay)) return { item: it, index: i };
|
||
}
|
||
return null;
|
||
}
|
||
/* 单集卡点击:优先在 115 网盘资源里按 SxxExx 精确匹配到这一集,匹配到就切到 115 这个 tab、
|
||
滚过去高亮一下、直接播放(跟下面「枫叶」在线集数网格点一下就播是同一个交互逻辑)。
|
||
115 里没有对应这集的资源时退回旧行为:滚到网盘资源区提示一下,不瞎猜。 */
|
||
function jumpToEpisodeResource(num) {
|
||
var seasonNum = (state.currentDetail && state.currentDetail.epSeason) || 1;
|
||
var hit = findEpisodeResource(seasonNum, num);
|
||
var panHead = document.querySelector("#detailSheet .pan-head");
|
||
if (!hit) {
|
||
if (panHead && panHead.scrollIntoView) panHead.scrollIntoView({ behavior: "smooth", block: "start" });
|
||
toast("115 网盘里没找到第 " + num + " 集,可换个网盘类别看看");
|
||
return;
|
||
}
|
||
if (state.qFilter !== "115" && state.qGroups && state.qGroups.indexOf("115") >= 0) {
|
||
state.qFilter = "115";
|
||
state.qFilterManual = true; // 点单集卡也是用户主动选择,同样不许被之后的异步同步冲掉
|
||
renderQTabs();
|
||
paintResources();
|
||
}
|
||
if (panHead && panHead.scrollIntoView) panHead.scrollIntoView({ behavior: "smooth", block: "start" });
|
||
var row = document.querySelector('#panList .pan-item[data-pan-index="' + hit.index + '"]');
|
||
if (row) { row.classList.add("pan-hit"); setTimeout(function () { row.classList.remove("pan-hit"); }, 1600); }
|
||
playResource(hit.item);
|
||
}
|
||
function renderCast(cast) {
|
||
var box = $("edCast"); if (!box) return;
|
||
if (window.resetActorWorks) window.resetActorWorks();
|
||
if (!cast || !cast.length) { box.replaceChildren(); return; }
|
||
var frag = document.createDocumentFragment();
|
||
var row = document.createElement("div"); row.className = "ed-cast-row";
|
||
cast.forEach(function (c) {
|
||
var cell = document.createElement("div"); cell.className = "cast-cell" + (c.id ? " tappable" : "");
|
||
var av = document.createElement("div"); av.className = "cast-av";
|
||
if (c.pic) { var im = document.createElement("img"); im.alt = c.name; im.loading = "lazy"; setTmdbImg(im, c.pic); av.appendChild(im); }
|
||
else { av.className += " ph"; av.textContent = (c.name || "?").slice(0, 1); }
|
||
if (c.id) { cell.setAttribute("data-actor-id", String(c.id)); cell.onclick = function () { toggleActorWorks(c, cell); }; }
|
||
cell.appendChild(av);
|
||
var info = document.createElement("div"); info.className = "cast-info";
|
||
var nm = document.createElement("div"); nm.className = "cast-name"; nm.textContent = c.name; info.appendChild(nm);
|
||
// 原名只在跟中文名不一样时才加一行,避免西方演员两行重复显示同一个名字
|
||
if (c.origName && c.origName !== c.name) { var on = document.createElement("div"); on.className = "cast-orig"; on.textContent = c.origName; info.appendChild(on); }
|
||
if (c.role) { var rl = document.createElement("div"); rl.className = "cast-role"; rl.textContent = c.role; info.appendChild(rl); }
|
||
cell.appendChild(info);
|
||
row.appendChild(cell);
|
||
});
|
||
frag.appendChild(row);
|
||
box.replaceChildren(frag);
|
||
}
|
||
/* 点头像 -> 展开该演员相关作品(TMDB combined_credits),再点收起 */
|
||
function toggleActorWorks(actor, cell) {
|
||
var box = $("edActorWorks"); if (!box) return;
|
||
var row = $("edCast");
|
||
function markSel(on) {
|
||
var cells = row.querySelectorAll(".cast-cell"); for (var i = 0; i < cells.length; i++) cells[i].classList.remove("sel");
|
||
if (on && cell) cell.classList.add("sel");
|
||
}
|
||
if (state.expandedActorId === actor.id) { state.expandedActorId = 0; box.replaceChildren(); markSel(false); return; }
|
||
state.expandedActorId = actor.id;
|
||
markSel(true);
|
||
box.replaceChildren(el("div", "ed-works-head", actor.name + " · 相关作品 · 加载中…"));
|
||
if (!config.tmdbKey) { box.replaceChildren(el("div", "ed-works-head", "需配置 TMDB Key 才能显示相关作品")); return; }
|
||
var reqId = actor.id;
|
||
httpJson(tmdbApi("/person/" + actor.id + "/combined_credits", {})).then(function (d) {
|
||
if (state.expandedActorId !== reqId) return;
|
||
var arr = (d && d.cast ? d.cast : []).filter(function (x) { return (x.media_type === "movie" || x.media_type === "tv") && x.poster_path && (x.title || x.name); });
|
||
var seen = {}, works = [];
|
||
arr.forEach(function (x) { if (seen[x.id]) return; seen[x.id] = 1; works.push(x); });
|
||
works.sort(function (a, b) { return (b.popularity || 0) - (a.popularity || 0); });
|
||
works = works.slice(0, 18);
|
||
if (!works.length) { box.replaceChildren(el("div", "ed-works-head", actor.name + " · 暂无相关作品")); return; }
|
||
var frag = document.createDocumentFragment();
|
||
frag.appendChild(el("div", "ed-works-head", actor.name + " · 相关作品"));
|
||
var wr = document.createElement("div"); wr.className = "ed-works-row";
|
||
works.forEach(function (x) {
|
||
var title = x.title || x.name || "";
|
||
var yr = (x.release_date || x.first_air_date || "").slice(0, 4);
|
||
var cardEl = document.createElement("button"); cardEl.type = "button"; cardEl.className = "work-cell focusable";
|
||
var po = document.createElement("div"); po.className = "work-poster";
|
||
var im = document.createElement("img"); im.alt = title; im.loading = "lazy"; setTmdbImg(im, "https://" + (config.tmdbImg || "image.tmdb.org") + "/t/p/w185" + x.poster_path); po.appendChild(im);
|
||
cardEl.appendChild(po);
|
||
var t = document.createElement("div"); t.className = "work-title"; t.textContent = title + (yr ? " (" + yr + ")" : ""); cardEl.appendChild(t);
|
||
cardEl.onclick = function () { openWorkDetail(x, title, yr); };
|
||
wr.appendChild(cardEl);
|
||
});
|
||
frag.appendChild(wr);
|
||
box.replaceChildren(frag);
|
||
}, function () {
|
||
if (state.expandedActorId !== reqId) return;
|
||
box.replaceChildren(el("div", "ed-works-head", "相关作品加载失败"));
|
||
});
|
||
}
|
||
/* 点相关作品 -> 先在盘链查该片名,命中则直接打开真实资源详情;
|
||
盘链未收录时不再只是toast提示,而是拿 combined_credits 里自带的 TMDB id/media_type
|
||
直接兜底进详情页——详情页里 loadTmdb 走 TMDB 元数据、loadResources+runPanSou 走盘搜兜底,
|
||
即使最终没有网盘资源也统一能看到 TMDB 简介/海报/演员表等信息,不至于点了没反应。 */
|
||
function openWorkDetail(work, title, year) {
|
||
var t = (title || "").trim(); if (!t) return;
|
||
var mediaType = (work && work.media_type === "movie") ? "movie" : "tv";
|
||
var typeName = mediaType === "movie" ? "电影" : "剧集";
|
||
var fallbackPoster = (work && work.poster_path)
|
||
? ("https://" + (config.tmdbImg || "image.tmdb.org") + "/t/p/w1280" + work.poster_path)
|
||
: "";
|
||
function openTmdbFallback() {
|
||
if (!work || !work.id) { toast("盘链未收录《" + t + "》"); return; }
|
||
openDetail({
|
||
id: String(work.id) + "__tmdb",
|
||
title: t,
|
||
poster: fallbackPoster,
|
||
meta: [year, typeName].filter(function (x) { return x; }).join(" / "),
|
||
status: "",
|
||
year: year,
|
||
typeName: typeName,
|
||
area: ""
|
||
});
|
||
}
|
||
toast("查找《" + t + "》…");
|
||
apiGet(apiUrl("get_videos.php") + "?wd=" + encodeURIComponent(t) + "&pg=1&_t=" + Date.now(), "").then(function (d) {
|
||
var arr = (d && (d.list || d.data)) || [];
|
||
var list = arr.map(mapVod);
|
||
if (!list.length) { openTmdbFallback(); return; }
|
||
var y = String(year || "");
|
||
function score(it) {
|
||
var s = 0;
|
||
if (it.title === t) s += 6; else if (it.title.indexOf(t) >= 0) s += 3; else if (t.indexOf(it.title) >= 0) s += 2;
|
||
if (y && it.year === y) s += 3; else if (y && it.year && Math.abs((parseInt(it.year, 10) || 0) - (parseInt(y, 10) || 0)) <= 1) s += 1;
|
||
return s;
|
||
}
|
||
var best = list.slice().sort(function (a, b) { return score(b) - score(a); })[0];
|
||
openDetail(best);
|
||
}, function () { openTmdbFallback(); });
|
||
}
|
||
/* 兼容旧调用:回首页搜索 */
|
||
function openWorkSearch(title) {
|
||
if (typeof closeDetail === "function") closeDetail();
|
||
document.body.classList.remove("detail-active");
|
||
var sheet = $("detailSheet"); if (sheet) { sheet.classList.remove("active"); sheet.setAttribute("aria-hidden", "true"); }
|
||
state.focusScope = "home"; setAppFocusable(true);
|
||
startSearch(title);
|
||
}
|
||
window.resetActorWorks = function () { state.expandedActorId = 0; var b = $("edActorWorks"); if (b) b.replaceChildren(); };
|
||
function pickCert(det, mtype) {
|
||
try {
|
||
if (mtype === "tv" && det.content_ratings && det.content_ratings.results) {
|
||
var r = det.content_ratings.results;
|
||
var cn = r.filter(function (x) { return x.iso_3166_1 === "CN"; })[0];
|
||
var us = r.filter(function (x) { return x.iso_3166_1 === "US"; })[0];
|
||
var pickR = cn && cn.rating ? cn : us;
|
||
if (pickR && pickR.rating) return pickR.rating;
|
||
}
|
||
if (mtype === "movie" && det.release_dates && det.release_dates.results) {
|
||
var rr = det.release_dates.results;
|
||
var u = rr.filter(function (x) { return x.iso_3166_1 === "US"; })[0];
|
||
if (u && u.release_dates) { for (var i = 0; i < u.release_dates.length; i++) if (u.release_dates[i].certification) return u.release_dates[i].certification; }
|
||
}
|
||
} catch (e) {}
|
||
return "";
|
||
}
|
||
function statusText(s) {
|
||
if (s === "Returning Series" || s === "In Production" || s === "Planned") return "连载中";
|
||
if (s === "Ended" || s === "Canceled") return "已完结";
|
||
return "";
|
||
}
|
||
function renderChipsMeta(tmdb, item) {
|
||
var box = $("detailTags"); box.replaceChildren();
|
||
var chips = [];
|
||
if (tmdb) {
|
||
if (tmdb.year) chips.push({ t: tmdb.year });
|
||
if (tmdb.mtype === "tv" && tmdb.seasons) chips.push({ t: tmdb.seasons + " 季" });
|
||
if (tmdb.mtype === "movie" && tmdb.runtime) chips.push({ t: tmdb.runtime + " 分钟" });
|
||
var st = statusText(tmdb.status); if (st) chips.push({ t: st });
|
||
tmdb.genres.slice(0, 2).forEach(function (g) { chips.push({ t: g }); });
|
||
if (tmdb.cert) chips.push({ t: tmdb.cert, cert: true });
|
||
}
|
||
if (!chips.length) {
|
||
var yr = yearOf((item && item.title) || "");
|
||
if (yr) chips.push({ t: yr });
|
||
var m = item && item.meta ? item.meta : "";
|
||
chips.push({ t: m && m.indexOf("4K") >= 0 ? "4K 蓝光" : (m || "高清"), cert: true });
|
||
}
|
||
chips.forEach(function (c) {
|
||
var s = document.createElement("span");
|
||
s.className = "t" + (c.cert ? " cert" : "");
|
||
s.textContent = c.t;
|
||
box.appendChild(s);
|
||
});
|
||
}
|
||
function showTitle(text) {
|
||
var el = $("detailTitle");
|
||
if (text == null || text === "") { el.classList.add("hidden"); el.textContent = ""; }
|
||
else { el.classList.remove("hidden"); el.textContent = text; }
|
||
}
|
||
/* TMDB 图片有多个可能域名(images.tmdb.org / image.tmdb.org / api.tmdb.org),网络环境不同哪个通不一定;
|
||
这里统一给出"另一个域名",供 setTmdbImg 的 onerror 兜底、以及详情页海报升级前的预加载探测共用。
|
||
images.tmdb.org 与 image.tmdb.org 在国内免代理可用(参考站实测),优先互切这两个。 */
|
||
function tmdbAltUrl(u) {
|
||
if (!u || u.indexOf("tmdb.org") < 0) return "";
|
||
if (u.indexOf("images.tmdb.org") >= 0) return u.replace("images.tmdb.org", "image.tmdb.org");
|
||
if (u.indexOf("image.tmdb.org") >= 0) return u.replace("image.tmdb.org", "images.tmdb.org");
|
||
if (u.indexOf("api.tmdb.org") >= 0) return u.replace("api.tmdb.org", "images.tmdb.org");
|
||
return "";
|
||
}
|
||
function setTmdbImg(img, url) {
|
||
img._orig = url; img._swapped = false;
|
||
img.onerror = function () {
|
||
var alt = img._swapped ? "" : tmdbAltUrl(img._orig || "");
|
||
if (!alt) { img.style.display = "none"; return; }
|
||
img._swapped = true;
|
||
img._orig = alt; img.style.display = ""; img.src = imgSrc(alt);
|
||
};
|
||
img.style.display = "";
|
||
// 4K指南 / 七味 站的图有防盗链,必须带各自的 Referer,不能用盘链那套 no-referrer
|
||
img.src = isZn4kUrl(url) ? zn4kImgSrc(url) : (isQwPoster(url) ? qwImgSrc(url) : imgSrc(url));
|
||
}
|
||
/* 详情页海报 → 背景 自动取色:跟首页轮播用的是同一套采样算法(sampleImageDominant /
|
||
heroBgFromRgb),但用独立的一套变量(--ed-bg / --ed-bg-rgb)和独立缓存(edBgCache),
|
||
不跟首页轮播的 --dyn-bg 混在一起 —— 两边是各自独立的轮播/状态,混用一个变量的话,
|
||
首页轮播还在后台跑定时器时会互相覆盖对方算出来的颜色。缓存 key 直接用图片 url(详情页
|
||
这里手头只有 url 列表,没有条目 id),同一张图翻回来不用重复采样。 */
|
||
var edBgCache = {};
|
||
function setDetailDynBg(bg) {
|
||
try {
|
||
document.documentElement.style.setProperty("--ed-bg", bg.css);
|
||
document.documentElement.style.setProperty("--ed-bg-rgb", bg.rgbList);
|
||
} catch (e) {}
|
||
}
|
||
/* 立刻应用:img 已经在 DOM 里(可能还没加载完,会自动等 load)。cacheKey 命中缓存就同步生效,
|
||
没命中就现场采样,采样是异步的,回来后不再校验"是不是还是这张"——详情页海报翻页间隔够长
|
||
(5.2s 或用户停留时长),实际不会出现算完发现已经翻走好几张的情况;真出现了也只是这一次
|
||
背景没跟手,下一次自动切换会自然纠正,不会一直错下去。 */
|
||
function applyDetailBgColor(img, cacheKey) {
|
||
if (!img) return;
|
||
if (cacheKey && edBgCache[cacheKey]) { setDetailDynBg(edBgCache[cacheKey]); return; }
|
||
function go() {
|
||
sampleImageDominant(img, function (rgb) {
|
||
if (!rgb) return;
|
||
var css = heroBgFromRgb(rgb);
|
||
if (cacheKey) edBgCache[cacheKey] = css;
|
||
setDetailDynBg(css);
|
||
});
|
||
}
|
||
if (img.complete && img.naturalWidth) go(); else img.addEventListener("load", go, { once: true });
|
||
}
|
||
/* 预热:只算色存缓存,不应用——用来提前给"轮播池里还没轮到"的其余几张图把颜色算好,
|
||
等 startDetailHeroRotate 真的切过去时直接命中缓存。 */
|
||
function precomputeDetailBgColor(img, cacheKey) {
|
||
if (!img || !cacheKey || edBgCache[cacheKey]) return;
|
||
function go() {
|
||
sampleImageDominant(img, function (rgb) {
|
||
if (!rgb) return;
|
||
edBgCache[cacheKey] = heroBgFromRgb(rgb);
|
||
});
|
||
}
|
||
if (img.complete && img.naturalWidth) go(); else img.addEventListener("load", go, { once: true });
|
||
}
|
||
/* 详情页“大海报”自动轮播:dHero* 系列 state 字段专属这个轮播,跟首页大轮播(heroXxx)是两套互不影响的东西。
|
||
多张图叠放在 #edHeroImgs 里,同一时刻只有 .active 那张 opacity:1,定时切换 active 类实现自动淡入淡出。 */
|
||
function stopDetailHeroRotate() {
|
||
if (state.dHeroTimer) { clearInterval(state.dHeroTimer); state.dHeroTimer = null; }
|
||
}
|
||
function startDetailHeroRotate() {
|
||
stopDetailHeroRotate();
|
||
if ((state.dHeroImages || []).length < 2) return;
|
||
state.dHeroTimer = setInterval(function () {
|
||
var box = $("edHeroImgs"); if (!box) return;
|
||
var imgs = box.children; if (imgs.length < 2) return;
|
||
var cur = state.dHeroIdx || 0;
|
||
var next = (cur + 1) % imgs.length;
|
||
imgs[cur].classList.remove("active", "first-in");
|
||
imgs[next].classList.add("active");
|
||
state.dHeroIdx = next;
|
||
applyDetailBgColor(imgs[next], state.dHeroImages && state.dHeroImages[next]);
|
||
// 顺手预热再下一张,跟首页轮播预热左右邻居是同一个道理
|
||
var after = (next + 1) % imgs.length;
|
||
if (imgs[after]) precomputeDetailBgColor(imgs[after], state.dHeroImages && state.dHeroImages[after]);
|
||
}, 5200);
|
||
}
|
||
/* 硬切换:打开"另一个条目"详情页时用,立刻显示新条目第一张图(入场缩放动画),
|
||
不能让上一个条目的图残留哪怕一瞬间,所以直接清空重建整套图层 */
|
||
function setDetailHeroImages(urls, contain) {
|
||
stopDetailHeroRotate();
|
||
var hero = $("edHero"), box = $("edHeroImgs");
|
||
if (!box) return;
|
||
if (contain) hero.classList.add("contain"); else hero.classList.remove("contain");
|
||
var list = (urls || []).filter(function (u) { return u; });
|
||
state.dHeroImages = list; state.dHeroIdx = 0;
|
||
box.replaceChildren();
|
||
if (!list.length) { box.appendChild(el("div", "ed-hero-skel")); return; }
|
||
list.forEach(function (u, i) {
|
||
var img = document.createElement("img");
|
||
img.alt = ""; img.className = "ed-hero-img" + (i === 0 ? " active first-in" : "");
|
||
img.loading = i === 0 ? "eager" : "lazy"; img.setAttribute("decoding", "async");
|
||
img.crossOrigin = "anonymous"; // 取色要读像素,跟首页轮播海报一个道理,不加这个跨域图会让 canvas 变"脏"
|
||
setTmdbImg(img, u);
|
||
box.appendChild(img);
|
||
if (i === 0) applyDetailBgColor(img, u); else precomputeDetailBgColor(img, u);
|
||
});
|
||
startDetailHeroRotate();
|
||
}
|
||
/* 软升级:同一条目内"站内海报 → TMDB 多图轮播池"到手后调用。
|
||
先把新的第一张图预加载完整,再和旧图同时叠一帧做一次 opacity 交叉淡出淡入,
|
||
过渡完再把旧图(占位用的站内海报)移除,只留下完整的轮播池继续自动播放。
|
||
注意:pre.onload 是异步的,图片下载这段时间里用户完全可能已经手动切到下一个条目——
|
||
#edHeroImgs 是所有详情页共用的同一个容器,调用方(loadTmdb/applyTmdb)只在“发起请求那一刻”
|
||
校验过 panToken,并不能保证图片下载完成时条目没变。这里把发起时的 panToken 记下来,
|
||
真正要写 DOM 的这一刻(apply)必须重新校验一次,代次不对就直接丢弃,绝不能把上一个条目
|
||
的图插进已经属于新条目的容器里——这才是“切下一部残留上一部海报”的根源。 */
|
||
function softUpdateDetailHeroImages(urls, contain) {
|
||
var hero = $("edHero"), box = $("edHeroImgs");
|
||
if (!box) return;
|
||
var list = (urls || []).filter(function (u) { return u; });
|
||
if (!list.length) return;
|
||
var myToken = state.panToken;
|
||
if (contain) hero.classList.add("contain"); else hero.classList.remove("contain");
|
||
function apply(firstUrl) {
|
||
if (myToken !== state.panToken) return; // 详情页已切换到别的条目,这批图作废
|
||
var oldImgs = Array.prototype.slice.call(box.children);
|
||
var newImgs = list.map(function (u, i) {
|
||
var img = document.createElement("img");
|
||
img.alt = ""; img.className = "ed-hero-img";
|
||
img.crossOrigin = "anonymous"; // 取色要读像素,跟首页轮播海报一个道理,不加这个跨域图会让 canvas 变"脏"
|
||
setTmdbImg(img, i === 0 ? firstUrl : u); // 第一张已经在下面验证过真的能加载出来了,直接用验证通过的那个域名;其余几张各自走 setTmdbImg 自带的域名兜底
|
||
box.appendChild(img);
|
||
return img;
|
||
});
|
||
void box.offsetHeight; // 强制回流:让新插入的 img 先以 opacity:0 被浏览器"看见"一次,紧接着切 active 类才能真正触发过渡
|
||
oldImgs.forEach(function (im) { im.classList.remove("active", "first-in"); });
|
||
newImgs[0].classList.add("active");
|
||
stopDetailHeroRotate();
|
||
state.dHeroImages = list; state.dHeroIdx = 0;
|
||
applyDetailBgColor(newImgs[0], list[0]);
|
||
newImgs.slice(1).forEach(function (im, idx) { precomputeDetailBgColor(im, list[idx + 1]); });
|
||
setTimeout(function () {
|
||
oldImgs.forEach(function (im) { if (im.parentNode) im.parentNode.removeChild(im); });
|
||
startDetailHeroRotate();
|
||
}, 1150);
|
||
}
|
||
// 先把新的第一张图完整预加载好、真正确认能显示出来了,再做交叉淡入淡出;
|
||
// 加载失败先按域名兜底重试一次,两个域名都失败就放弃这次升级、继续显示原来那张(哪怕模糊
|
||
// 但至少能看清)的旧图——不能像直接 apply() 那样,图还没影子就把旧图撤了,露出黑屏。
|
||
var applied = false;
|
||
var pre = new Image();
|
||
var triedAlt = false;
|
||
pre.onload = function () { if (applied) return; applied = true; apply(pre.src); };
|
||
pre.onerror = function () {
|
||
if (triedAlt) return; // 两个域名都失败:放弃这次升级,旧图原样保留
|
||
var alt = tmdbAltUrl(list[0]);
|
||
if (!alt) return;
|
||
triedAlt = true;
|
||
pre.src = imgSrc(alt);
|
||
};
|
||
pre.src = imgSrc(list[0]);
|
||
if (pre.complete && pre.naturalWidth) { applied = true; apply(pre.src); }
|
||
}
|
||
/* 详情页滚动 → 固定海报渐暗/轻微模糊(几何不变,避免播放键白线闪烁);
|
||
filter 加在整个图层容器上,同一时刻只有 .active 那张可见,效果跟只加在单张图上一致 */
|
||
(function bindHeroScrollDim() {
|
||
var sheet = $("detailSheet"), heroImgs = $("edHeroImgs"), dimEnd = 0, raf = 0;
|
||
function measure() { var sp = sheet ? sheet.querySelector(".ed-hero-spacer") : null; var h = (sp && sp.offsetHeight) || (window.innerHeight * 0.68); dimEnd = Math.max(1, h * 0.7); }
|
||
function apply() { raf = 0; if (!dimEnd) measure(); var t = Math.min(1, Math.max(0, sheet.scrollTop / dimEnd)); heroImgs.style.filter = "brightness(" + (1 - t * 0.55).toFixed(3) + ") blur(" + (t * 5).toFixed(1) + "px)"; }
|
||
if (sheet && heroImgs) sheet.addEventListener("scroll", function () { if (raf) return; raf = requestAnimationFrame(apply); }, { passive: true });
|
||
window.resetHeroScrollDim = function () { dimEnd = 0; if (heroImgs) heroImgs.style.filter = ""; };
|
||
})();
|
||
function nativeChromeVisible() {
|
||
var m = "";
|
||
try { m = (getComputedStyle(document.documentElement).getPropertyValue("--fm-chrome-mode") || "").trim(); } catch (e) {}
|
||
if (m) return m === "normal" || m === "tv-normal"; // 原生顶部/底部 UI 可见时不叠安全区
|
||
return !config.homeFull; // 无该变量时退回开关
|
||
}
|
||
function syncSafeAreaClass() {
|
||
document.documentElement.classList.toggle("chrome-normal", nativeChromeVisible());
|
||
}
|
||
function applyChromeMode() {
|
||
syncSafeAreaClass();
|
||
if (window.fm && fm.ui && fm.ui.setChrome) fm.ui.setChrome({ mode: config.homeFull ? "edge" : "normal", statusBarStyle: "light", navigationBarStyle: "light", startup: true }).then(function () { setTimeout(syncSafeAreaClass, 60); }, function () {});
|
||
}
|
||
function closeDetail(fromPopstate) {
|
||
var sheet = $("detailSheet");
|
||
if (!fromPopstate && !sheet.classList.contains("active")) return; // 已经关过了,避免重复消耗历史深度
|
||
document.body.classList.remove("detail-active");
|
||
sheet.classList.remove("active"); sheet.setAttribute("aria-hidden", "true");
|
||
state.currentDetail = null; state.focusScope = "home"; setAppFocusable(true);
|
||
// 关闭时让还在飞的异步请求(比如"查看全部→TMDB 热播"进详情时的兜底搜索)自动作废:
|
||
// 否则慢请求晚到时,openTrendingDetail 里的"用户是否已关闭"判断会误判成"还在看",
|
||
// 悄悄把详情页重新弹出来,还会多 push 一条历史记录,导致返回键和真实历史深度错位失灵。
|
||
state.currentItem = null; state.panToken++;
|
||
stopDetailHeroRotate();
|
||
if (state.panObserver) { try { state.panObserver.disconnect(); } catch (e) {} state.panObserver = null; }
|
||
applyChromeMode();
|
||
restoreViewReturn();
|
||
if (!fromPopstate) closeRoute();
|
||
}
|
||
|
||
/* ---------- 资源渲染 + 检测 + 播放 ---------- */
|
||
function renderCollection(members) {
|
||
state.isCollection = true;
|
||
state.panItems = []; state.bestResource = null; state.resumeRecord = null;
|
||
state.qGroups = []; state.qFilter = null; state.qFilterManual = false;
|
||
$("qTabs").replaceChildren();
|
||
$("panTitle").textContent = "合集内容";
|
||
$("panSub").textContent = members.length + " 部";
|
||
var btn = $("edPlay"); btn.setAttribute("disabled", "disabled"); $("edPlayLabel").textContent = "合集 · 请选择影片";
|
||
var grid = document.createElement("div"); grid.className = "grid coll-grid";
|
||
members.forEach(function (it, i) {
|
||
grid.appendChild(mediaCard(it, i, "coll", (function (m) { return function () { openDetail(m); }; })(it)));
|
||
});
|
||
$("panList").replaceChildren(grid);
|
||
}
|
||
function renderResources(resources) {
|
||
state.isCollection = false;
|
||
state.panItems = (resources || []).filter(function (r) { return PAN_TYPE_GLOBAL_EXCLUDE.indexOf(r.type) < 0; });
|
||
panMemoSave(state.panItems);
|
||
if (!state.panItems.length) {
|
||
$("qTabs").replaceChildren();
|
||
$("panList").replaceChildren(el("div", "pan-empty", "没有找到网盘资源。可点刷新重试。"));
|
||
$("panSub").textContent = "";
|
||
if (state.panDiag && state.panDiag.length) {
|
||
var b0 = panDiagBox();
|
||
if (b0) b0.textContent = "【反查诊断 · pomo】\n" + state.panDiag.join("\n");
|
||
}
|
||
updatePlayButton();
|
||
return;
|
||
}
|
||
// 画质分组(对应参考里的分季):先收集出现过的网盘类型,再按固定顺序排列——
|
||
// 不按"谁先到达"排,避免同一部片子每次打开 tab 顺序不一样、看着像自己在跳。
|
||
var groups = [];
|
||
state.panItems.forEach(function (r) { var q = r.quality || "其他"; if (isPanTypeBlocked(q)) return; if (groups.indexOf(q) < 0) groups.push(q); });
|
||
// 用户在设置里屏蔽的类别都没有剩余资源(或者全部资源刚好都是被屏蔽的类别):
|
||
// 提示一下,而不是显示一个空的资源列表让人以为是没搜到。
|
||
if (!groups.length) {
|
||
$("qTabs").replaceChildren();
|
||
$("panList").replaceChildren(el("div", "pan-empty", "根据你在设置中选择的网盘类别,没有可显示的资源。可在设置 -「网盘类别」里调整。"));
|
||
$("panSub").textContent = "";
|
||
updatePlayButton();
|
||
return;
|
||
}
|
||
var groupSeenOrder = groups.slice();
|
||
groups.sort(function (a, b) {
|
||
var ia = PAN_GROUP_ORDER.indexOf(a); if (ia < 0) ia = PAN_GROUP_ORDER.length;
|
||
var ib = PAN_GROUP_ORDER.indexOf(b); if (ib < 0) ib = PAN_GROUP_ORDER.length;
|
||
if (ia !== ib) return ia - ib;
|
||
return groupSeenOrder.indexOf(a) - groupSeenOrder.indexOf(b); // 老内核 sort 非稳定排序,同优先级手动按首次出现顺序兜底
|
||
});
|
||
state.qGroups = groups;
|
||
/* 网盘资源是分批异步聚合的(蜗牛/SeedHub/玩偶/木偶/4K指南/盘搜等站点结果陆续到达),
|
||
每来一批新结果都会重新调用一次本函数。之前这里无条件把 qFilter 重置成 groups[0],
|
||
导致用户刚点到后面某个画质 tab,下一批异步结果一到就把选择冲掉、跳回第一个——
|
||
打断了用户正在进行的操作。现在只在两种情况下才重置:①还没选过(初次渲染),
|
||
②当前选中的画质在新分组里已经不存在了(比如那个画质的资源全部被过滤掉);
|
||
其余情况下保留用户已经选中的 tab,不去动它。 */
|
||
if (state.qFilter == null || groups.indexOf(state.qFilter) < 0) {
|
||
state.qFilter = groups.length > 1 ? groups[0] : null;
|
||
}
|
||
$("panSub").textContent = state.panItems.length + " 条";
|
||
renderQTabs();
|
||
paintResources();
|
||
if (state.panDiag && state.panDiag.length) {
|
||
var b1 = panDiagBox();
|
||
if (b1) b1.textContent = "【反查诊断 · pomo】\n" + state.panDiag.join("\n");
|
||
}
|
||
updatePlayButton();
|
||
}
|
||
function renderQTabs() {
|
||
var box = $("qTabs");
|
||
if (!state.qGroups || state.qGroups.length < 2) { box.replaceChildren(); return; }
|
||
var frag = document.createDocumentFragment();
|
||
state.qGroups.forEach(function (q, i) {
|
||
var b = document.createElement("button");
|
||
b.type = "button";
|
||
b.className = "ed-qtab focusable" + (q === state.qFilter ? " active" : "");
|
||
b.textContent = q;
|
||
b.setAttribute("data-focus-row", "qtabs");
|
||
b.setAttribute("data-focus-index", String(i));
|
||
b.onclick = function () {
|
||
state.qFilter = q;
|
||
state.qFilterManual = true; // 用户手动点过 tab:后面异步到达的批次/继续播放同步都不许再冲掉这个选择
|
||
var tabs = box.children; for (var j = 0; j < tabs.length; j++) tabs[j].classList.remove("active");
|
||
b.classList.add("active");
|
||
paintResources();
|
||
};
|
||
frag.appendChild(b);
|
||
});
|
||
box.replaceChildren(frag);
|
||
}
|
||
function visibleItems() {
|
||
var out = [];
|
||
state.panItems.forEach(function (it, i) {
|
||
if (it.state === "bad") return; // 网盘检测失效:不显示
|
||
if (isPanTypeBlocked(it.quality || "其他")) return; // 用户在设置里屏蔽的网盘类别:不显示
|
||
if (state.qFilter && (it.quality || "其他") !== state.qFilter) return;
|
||
out.push({ it: it, i: i });
|
||
});
|
||
// 同一 tab 内按画质从高到低排(4K/杜比视界/HDR/原盘/高码 靠前),画质分相同的保持原有先后顺序
|
||
out.sort(function (a, b) {
|
||
var sa = qualityInfo(a.it).score, sb = qualityInfo(b.it).score;
|
||
return sa !== sb ? (sb - sa) : (a.i - b.i);
|
||
});
|
||
return out;
|
||
}
|
||
function paintResources() {
|
||
var list = $("panList");
|
||
var vis = visibleItems();
|
||
// 当前分组若全是枫叶在线剧集("第N集"这类),铺成日历式格子,方便点选;
|
||
// 网盘/盘搜那种带标题+提取码的,仍走下面的行列表。
|
||
var allFyEp = vis.length > 0 && vis.every(function (p) { return p.it._fy; });
|
||
if (allFyEp) { paintEpisodeGrid(list, vis); scheduleCheck(); return; }
|
||
var frag = document.createDocumentFragment();
|
||
vis.forEach(function (pair) {
|
||
var item = pair.it, i = pair.i;
|
||
var row = document.createElement("div");
|
||
row.className = "pan-item focusable";
|
||
row.setAttribute("data-pan-index", String(i));
|
||
row.setAttribute("data-focus-row", "panList"); row.setAttribute("data-focus-index", String(i));
|
||
row.setAttribute("role", "button"); row.setAttribute("tabindex", "0");
|
||
var dot = document.createElement("span"); dot.className = "pan-dot " + (item.state || "idle"); row.appendChild(dot);
|
||
var body = document.createElement("div"); body.className = "pan-body";
|
||
var nm = document.createElement("div"); nm.className = "pan-name";
|
||
nm.textContent = (item.title ? cleanPanRowTitle(item.title) : "") || ((PAN_META[item.type] ? PAN_META[item.type].label : item.type) + "网盘");
|
||
var sub = document.createElement("div"); sub.className = "pan-sub";
|
||
sub.innerHTML = buildPanSubHTML(item);
|
||
body.appendChild(nm); body.appendChild(sub); row.appendChild(body);
|
||
// 播放动作绑在整行上,右侧只留一个不可单独点击的提示图标(见 .pan-play 样式),
|
||
// 不再单独放一个播放按钮,减少一行两个可点目标的重复
|
||
var play = document.createElement("span");
|
||
play.className = "pan-play"; play.setAttribute("aria-hidden", "true"); play.textContent = "\u25B6";
|
||
row.appendChild(play);
|
||
row.onclick = function () { playResource(item); };
|
||
frag.appendChild(row);
|
||
});
|
||
list.replaceChildren(frag);
|
||
scheduleCheck();
|
||
}
|
||
/* 集数网格:每集一个格子,格里显示集号(能提取到数字就显示"01",否则显示原集名),点即播 */
|
||
function paintEpisodeGrid(list, vis) {
|
||
var grid = document.createElement("div");
|
||
grid.className = "ep-grid";
|
||
var frag = document.createDocumentFragment();
|
||
vis.forEach(function (pair) {
|
||
var item = pair.it, i = pair.i;
|
||
var cell = document.createElement("button");
|
||
cell.type = "button";
|
||
cell.className = "ep-cell focusable" + (item.state === "resolving" ? " resolving" : "") + (item.state === "bad" ? " bad" : "");
|
||
cell.setAttribute("data-pan-index", String(i));
|
||
cell.setAttribute("data-focus-row", "panList");
|
||
cell.setAttribute("data-focus-index", String(i));
|
||
var m = String(item.title || "").match(/\d+/);
|
||
cell.textContent = m ? (m[0].length < 2 ? "0" + m[0] : m[0]) : (cleanPanRowTitle(item.title) || ("第" + (item._fyNid || (i + 1)) + "集"));
|
||
if (item.state && item.state !== "idle") { var dot = document.createElement("span"); dot.className = "ep-dot " + item.state; cell.appendChild(dot); }
|
||
cell.onclick = function () { playResource(item); };
|
||
frag.appendChild(cell);
|
||
});
|
||
grid.appendChild(frag);
|
||
list.replaceChildren(grid);
|
||
}
|
||
/* 网盘资源候选顺序:按分组(跟「网盘资源」里 tab 的先后一致)排列,组内按画质从高到低排——
|
||
这个顺序跟每个 tab 下面实际显示给用户看的顺序完全一致;画质相同的保留原始到达顺序。
|
||
主播放按钮默认播这个顺序里的第一条;点开又很快返回(没看够 30 秒)就依次换下一条,
|
||
一直换到最后一条后再从头循环,见 findNextResource。 */
|
||
function sortedPanCandidates() {
|
||
var groups = state.qGroups || [];
|
||
var list = (state.panItems || []).filter(function (it) {
|
||
if (it.state === "bad") return false; // 已确认失效的不参与候选
|
||
if (isPanTypeBlocked(it.quality || "其他")) return false; // 用户在设置里屏蔽的类别不参与
|
||
return true;
|
||
});
|
||
list.sort(function (a, b) {
|
||
var ga = groups.indexOf(a.quality || "其他"); if (ga < 0) ga = groups.length;
|
||
var gb = groups.indexOf(b.quality || "其他"); if (gb < 0) gb = groups.length;
|
||
if (ga !== gb) return ga - gb;
|
||
var sa = qualityInfo(a).score, sb = qualityInfo(b).score;
|
||
if (sa !== sb) return sb - sa;
|
||
return state.panItems.indexOf(a) - state.panItems.indexOf(b);
|
||
});
|
||
return list;
|
||
}
|
||
function pickBest() {
|
||
var list = sortedPanCandidates();
|
||
return list.length ? list[0] : null;
|
||
}
|
||
/* 上次播过的那条记录(按当前详情页的 id 找) */
|
||
function recentFor(id) {
|
||
var list = state.recentItems || [];
|
||
for (var i = 0; i < list.length; i++) {
|
||
if (String(list[i].id) === String(id) && list[i].payload && list[i].payload.url) return list[i];
|
||
}
|
||
return null;
|
||
}
|
||
/* 把记录里的播放地址映射回当前资源列表里的那一条。
|
||
记录存的是播放时用的地址(网盘分享链接或解析后的直链),列表里是原始链接,
|
||
字面不一定完全相同,所以先按 normPanUrl 比对,再退一步按当时的资源名比对。 */
|
||
function matchResumeItem(rec) {
|
||
var items = state.panItems || [], i, iu;
|
||
var key = normPanUrl((rec.payload && rec.payload.url) || "");
|
||
if (key) {
|
||
for (i = 0; i < items.length; i++) {
|
||
iu = items[i].url || items[i].token || "";
|
||
if (iu && normPanUrl(iu) === key) return items[i];
|
||
}
|
||
}
|
||
if (rec.note) {
|
||
for (i = 0; i < items.length; i++) {
|
||
if ((items[i].title || items[i].groupName || "") === rec.note) return items[i];
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
/* 接着播的那条如果在别的网盘类型 tab 里,把 tab 切过去,让用户看得见会播哪条 */
|
||
function syncTabTo(item) {
|
||
if (!item) return;
|
||
if (state.qFilterManual) return; // 用户已经手动选过 tab:这是"继续播放"每次重渲染都会走到的自动同步,
|
||
// 不能因为它把用户刚点的 115/123 又拽回资源本来所在的 tab(通常是夸克),
|
||
// 之前就是这里在每批异步资源到达后一次次把选择冲掉,感觉像连跳好几次
|
||
var q = item.quality || "其他";
|
||
if (!state.qFilter || state.qFilter === q) return;
|
||
if ((state.qGroups || []).indexOf(q) < 0) return;
|
||
state.qFilter = q;
|
||
if (typeof renderQTabs === "function") renderQTabs();
|
||
if (typeof paintResources === "function") paintResources();
|
||
}
|
||
function updatePlayButton() {
|
||
var btn = $("edPlay"), label = $("edPlayLabel");
|
||
var curId = state.currentDetail ? state.currentDetail.id : "";
|
||
// 播过的片子:主按钮直接接着上次那条资源,而不是重新按排序挑"最佳"——
|
||
// 否则点"继续播放"很可能换成了另一条链接,等于从头再来。
|
||
var rec = curId ? recentFor(curId) : null;
|
||
state.resumeRecord = rec;
|
||
var resumed = rec ? matchResumeItem(rec) : null;
|
||
var best = resumed || pickBest();
|
||
state.bestResource = best;
|
||
state.bestIsResume = !!resumed; // 记住这次是不是“接着上次那条播”,quick-return 顺移逻辑要避开它
|
||
if (!best && !rec) { btn.setAttribute("disabled", "disabled"); label.textContent = "暂无可播放资源"; return; }
|
||
btn.removeAttribute("disabled");
|
||
label.textContent = rec ? "继续播放" : "播放";
|
||
if (resumed) syncTabTo(resumed);
|
||
}
|
||
function playBest() {
|
||
if (state.bestResource) { playResource(state.bestResource, state.bestIsResume); return; }
|
||
// 列表里已经没有这条了(站点换链接/资源下架),就直接用记录里存的地址播
|
||
if (state.resumeRecord) { playRecent(state.resumeRecord); return; }
|
||
toast("资源还在加载…");
|
||
}
|
||
|
||
/* 收藏(本地) */
|
||
function favKey() { return state.currentDetail ? ("fav_" + state.currentDetail.id) : ""; }
|
||
function refreshFavIcon() {
|
||
var on = state.currentDetail && state.favs[state.currentDetail.id];
|
||
var b = $("edFav"); if (!b) return; if (on) b.classList.add("on"); else b.classList.remove("on");
|
||
b.innerHTML = on ? "♥" : "♡";
|
||
}
|
||
function toggleFav() {
|
||
if (!state.currentDetail) return;
|
||
var id = state.currentDetail.id;
|
||
if (state.favs[id]) delete state.favs[id]; else state.favs[id] = { title: state.currentDetail.rawTitle, poster: state.currentDetail.poster };
|
||
storeSet("favs", JSON.stringify(state.favs));
|
||
refreshFavIcon();
|
||
toast(state.favs[id] ? "已收藏" : "已取消收藏");
|
||
}
|
||
function stateText(s) {
|
||
if (s === "resolving") return "解析中…"; if (s === "bad") return "解析失败"; return "点播放打开";
|
||
}
|
||
/* ---------- 网盘检测(自动,受“增强功能 -> 网盘检测”开关控制) ---------- */
|
||
function scheduleCheck() {
|
||
if (!state.driveCheck || !state.sdkReady || !window.fm || !fm.pan || !fm.pan.check) return;
|
||
var list = $("panList"); if (!list) return;
|
||
if ("IntersectionObserver" in window) {
|
||
if (!state.panObserver) {
|
||
state.panObserver = new IntersectionObserver(function (entries) {
|
||
var due = [];
|
||
entries.forEach(function (en) {
|
||
var idx = parseInt(en.target.getAttribute("data-pan-index"), 10);
|
||
var it = state.panItems[idx];
|
||
if (!en.isIntersecting) return;
|
||
state.panObserver.unobserve(en.target);
|
||
if (!it || it._checked || it._checking) return;
|
||
if (CHECKABLE.indexOf(it.type) < 0) { it._checked = true; return; } // 仅检测 App 支持的网盘类型
|
||
due.push(it);
|
||
});
|
||
if (due.length) queuePanCheck(due);
|
||
}, { root: null, rootMargin: "200px 0px", threshold: 0.01 });
|
||
}
|
||
var rows = list.querySelectorAll(".pan-item[data-pan-index]");
|
||
for (var i = 0; i < rows.length; i++) {
|
||
var idx = parseInt(rows[i].getAttribute("data-pan-index"), 10);
|
||
var it = state.panItems[idx];
|
||
if (it && !it._checked && !it._checking) state.panObserver.observe(rows[i]);
|
||
}
|
||
} else {
|
||
// 不支持 IntersectionObserver 的旧内核兜底:直接检测当前可见列表(数量本身有限)
|
||
var fallback = [];
|
||
state.panItems.forEach(function (it) { if (!it._checked && !it._checking && CHECKABLE.indexOf(it.type) >= 0) fallback.push(it); });
|
||
if (fallback.length) queuePanCheck(fallback);
|
||
}
|
||
}
|
||
function queuePanCheck(items) {
|
||
var token = state.panToken;
|
||
items.forEach(function (it) { it._checking = true; it.state = "checking"; });
|
||
updateDots();
|
||
function resolveOne(it) {
|
||
if (it.url) return Promise.resolve(it);
|
||
if (!it.token) { it._resolveFailed = true; return Promise.resolve(it); }
|
||
return resolveToken(it.token).then(function (url) { it.url = url; return it; }, function () { it._resolveFailed = true; return it; });
|
||
}
|
||
Promise.all(items.map(resolveOne)).then(function (resolved) {
|
||
if (token !== state.panToken) return;
|
||
var checkable = [], skipped = [];
|
||
resolved.forEach(function (it) { if (it.url && !it._resolveFailed) checkable.push(it); else skipped.push(it); });
|
||
skipped.forEach(function (it) { it._checking = false; it._checked = true; it.state = "bad"; });
|
||
if (skipped.length) refreshPanRows(skipped);
|
||
if (!checkable.length) { updatePlayButton(); return; }
|
||
function runBatch(start) {
|
||
var batch = checkable.slice(start, start + 10); // 单批最多约 10 条
|
||
if (!batch.length) { updatePlayButton(); return; }
|
||
var payload = batch.map(function (it) { return { type: it.type, url: it.url, password: it.password || "" }; });
|
||
fm.pan.check(payload).then(function (res) {
|
||
if (token !== state.panToken) return;
|
||
var results = (res && res.results) || [];
|
||
batch.forEach(function (it, i) {
|
||
var r = results[i];
|
||
it._checking = false; it._checked = true;
|
||
it.state = (r && r.state) || "uncertain";
|
||
});
|
||
refreshPanRows(batch);
|
||
runBatch(start + 10);
|
||
}, function () {
|
||
if (token !== state.panToken) return;
|
||
batch.forEach(function (it) { it._checking = false; it._checked = true; it.state = "uncertain"; });
|
||
refreshPanRows(batch);
|
||
runBatch(start + 10);
|
||
});
|
||
}
|
||
runBatch(0);
|
||
});
|
||
}
|
||
function updateDots() {
|
||
var rows = $("panList").querySelectorAll(".pan-item");
|
||
for (var i = 0; i < rows.length; i++) {
|
||
var idx = parseInt(rows[i].getAttribute("data-pan-index"), 10);
|
||
var it = state.panItems[idx]; if (!it) continue;
|
||
var dot = rows[i].querySelector(".pan-dot"); if (dot) dot.className = "pan-dot " + (it.state || "idle");
|
||
}
|
||
}
|
||
/* 检测结果原地刷新:不重排、不整表重绘,只更新这批条目自己的那一行——
|
||
失效(bad)的直接从 DOM 里摘掉那一行,其余条目的位置和内容原封不动,
|
||
用户正在看/正准备点的资源不会因为别的条目检测完而被冲到别的位置。 */
|
||
function refreshPanRows(items) {
|
||
var list = $("panList");
|
||
var rows = list.querySelectorAll(".pan-item");
|
||
var byIdx = {};
|
||
items.forEach(function (it) {
|
||
var idx = state.panItems.indexOf(it);
|
||
if (idx >= 0) byIdx[idx] = it;
|
||
});
|
||
for (var i = 0; i < rows.length; i++) {
|
||
var idx = parseInt(rows[i].getAttribute("data-pan-index"), 10);
|
||
var it = byIdx[idx]; if (!it) continue;
|
||
if (it.state === "bad") { if (rows[i].parentNode) rows[i].parentNode.removeChild(rows[i]); continue; }
|
||
var dot = rows[i].querySelector(".pan-dot"); if (dot) dot.className = "pan-dot " + (it.state || "idle");
|
||
var sub = rows[i].querySelector(".pan-sub"); if (sub) sub.innerHTML = buildPanSubHTML(it);
|
||
}
|
||
if (!list.querySelector(".pan-item") && !list.querySelector(".ep-cell") && visibleItems().length === 0 && state.panItems.length) {
|
||
list.replaceChildren(el("div", "pan-empty", "没有找到网盘资源。可点刷新重试。"));
|
||
}
|
||
}
|
||
|
||
/* token -> 真实网盘地址 */
|
||
function resolveToken(token) {
|
||
return apiPost(apiUrl("resolve_token.php"), { token: token }, state.currentDetail ? state.currentDetail.id : "")
|
||
.then(function (r) {
|
||
if (r && r.success && r.url) return r.url;
|
||
throw new Error((r && r.message) ? r.message : "解析失败");
|
||
});
|
||
}
|
||
function setItemState(item, st) {
|
||
item.state = st;
|
||
updateDots();
|
||
var rows = $("panList").querySelectorAll(".pan-item");
|
||
for (var i = 0; i < rows.length; i++) {
|
||
var idx = parseInt(rows[i].getAttribute("data-pan-index"), 10);
|
||
if (state.panItems[idx] === item) { var sub = rows[i].querySelector(".pan-sub"); if (sub) sub.innerHTML = buildPanSubHTML(item); }
|
||
}
|
||
}
|
||
/* ---------- 盘搜 PanSou(详情页「网盘搜索」按钮) ---------- */
|
||
function normPanUrl(u) {
|
||
u = u || "";
|
||
// 磁力全都是 magnet:?xt=... ,按 ? 截断会把所有磁力算成同一条,这里改用 btih 哈希做键
|
||
var mg = u.match(/btih:([A-Za-z0-9]+)/i);
|
||
if (mg) return "magnet:" + mg[1].toLowerCase();
|
||
return u.split("?")[0].replace(/\/+$/, "");
|
||
}
|
||
function extPost(url, bodyObj) {
|
||
var body = JSON.stringify(bodyObj);
|
||
var headers = { "Content-Type": "application/json", "Accept": "application/json" };
|
||
if (config.panToken) headers["Authorization"] = "Bearer " + config.panToken;
|
||
if (state.sdkReady && window.fm && fm.req) {
|
||
return fm.req(url, { method: "POST", responseType: "json", timeout: 20, headers: headers, body: body }).then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.body; });
|
||
}
|
||
return fetchTimeout(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: body }).then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); });
|
||
}
|
||
function panSouSearch(kw, year) {
|
||
var api = (config.panApi || "https://so.252035.xyz").replace(/\/+$/, "");
|
||
var body = { kw: kw, res: "merge", src: "all", cloud_types: PANSOU_TYPES, channels: [] };
|
||
return extPost(api + "/api/search", body).then(function (d) {
|
||
var merged = (d && d.data && d.data.merged_by_type) || (d && d.merged_by_type) || {};
|
||
var out = [];
|
||
Object.keys(merged).forEach(function (t) {
|
||
if (PANSOU_KEEP.indexOf(t) < 0) return; // 保留 App 支持的类型 + 光鸭盘
|
||
var lab = PAN_META[t] ? PAN_META[t].label : t;
|
||
(merged[t] || []).forEach(function (it) {
|
||
var url = it.url || it.link || "";
|
||
if (!url) return;
|
||
var rtitle = it.note || it.title || it.name || "";
|
||
// 盘搜是聚合了一大堆第三方资源站的关键词全文检索,同样会把关键词只是凑巧出现在标题
|
||
// 中间的不相关资源也搜出来(比如混进《刚下山被幸运女神拉去领证》),这里用跟本站
|
||
// 资源同一把尺子过滤一遍,只保留关键词出现在标题靠前位置、或者标题里带着年份的。
|
||
if (rtitle && !resourceTitleLikelyMatches(rtitle, kw, year)) return;
|
||
out.push({
|
||
type: t, url: url,
|
||
password: it.password || it.pwd || it.code || "",
|
||
title: rtitle,
|
||
groupName: lab, quality: lab,
|
||
source: "盘搜", state: "idle"
|
||
});
|
||
});
|
||
});
|
||
return out;
|
||
});
|
||
}
|
||
function panSouKeep() { return (state.panItems || []).filter(function (it) { return it.source === "盘搜"; }); }
|
||
function runPanSou(silent) {
|
||
var d = state.currentDetail; if (!d) return;
|
||
var rawKw = d.rawTitle || d.title || (state.currentItem && state.currentItem.title) || "";
|
||
if (!rawKw) { if (!silent) toast("无标题,无法盘搜"); return; }
|
||
var rawYear = (state.currentItem && state.currentItem.year) || yearOf(rawKw) || (d.tmdb && d.tmdb.year) || "";
|
||
var kwList = panSouKeywords(rawKw);
|
||
var btn = $("nativeSearch"); if (btn && btn.getAttribute("data-busy") === "1") return;
|
||
if (btn) btn.setAttribute("data-busy", "1");
|
||
var token = state.panToken;
|
||
if (!silent) toast("盘搜中…");
|
||
Promise.all(kwList.map(function (kw) { return panSouSearch(kw, rawYear).then(function (r) { return r; }, function () { return []; }); })).then(function (results) {
|
||
if (btn) btn.removeAttribute("data-busy");
|
||
if (token !== state.panToken) return; // 详情已切换,丢弃
|
||
var have = {};
|
||
state.panItems.forEach(function (it) { have[it.type + "|" + normPanUrl(it.url || it.token)] = 1; });
|
||
var add = [];
|
||
results.forEach(function (list) {
|
||
(list || []).forEach(function (it) { var k = it.type + "|" + normPanUrl(it.url); if (!have[k]) { have[k] = 1; add.push(it); } });
|
||
});
|
||
if (!add.length) {
|
||
if (!silent) { toast("盘搜没有新增结果"); return; }
|
||
// 静默模式(自动触发)下,如果官方资源本来就是空的,盘搜也确实一无所获,
|
||
// 原来这里直接 return,界面停留在 loadResources 留下的"没有找到网盘资源"占位文案,
|
||
// 跟"盘搜压根没跑过"长得一模一样,没法区分,容易被误以为自动搜索失效了。
|
||
// 这里补一句更明确的提示,证明盘搜确实跑过、只是没搜到。
|
||
if (!state.panItems.length) {
|
||
$("panList").replaceChildren(el("div", "pan-empty", "官方资源和盘搜都没有找到,可点刷新重试。"));
|
||
}
|
||
return;
|
||
}
|
||
renderResources(state.panItems.concat(add)); // 合并盘搜结果,重建 tab
|
||
if (!silent) toast("盘搜新增 " + add.length + " 条");
|
||
}, function (e) {
|
||
if (btn) btn.removeAttribute("data-busy");
|
||
if (!silent && token === state.panToken) toast("盘搜失败:" + (e && e.message ? e.message : "检查盘搜地址"));
|
||
});
|
||
}
|
||
function playResource(item, isResume) {
|
||
if (item._fy) { playFyResource(item, isResume); return; } // 枫叶:在线点播,解析播放页拿 m3u8 直链后直接播
|
||
if (item.url) { doPlay(item, item.url, isResume); return; } // 盘搜等直链,直接播放
|
||
if (!item.token) { toast("该链接无地址,无法播放"); return; }
|
||
if (item._resolving) return;
|
||
item._resolving = true;
|
||
setItemState(item, "resolving");
|
||
toast("解析链接中…");
|
||
resolveToken(item.token).then(function (url) {
|
||
item._resolving = false;
|
||
setItemState(item, "idle");
|
||
doPlay(item, url, isResume);
|
||
}, function (err) {
|
||
item._resolving = false;
|
||
setItemState(item, "bad");
|
||
toast("解析失败:" + (err && err.message ? err.message : "换一个链接试试"));
|
||
});
|
||
}
|
||
function doPlay(item, url, isResume) {
|
||
var d = state.currentDetail || {};
|
||
var tmdb = d.tmdb || {};
|
||
var pic = (tmdb.poster || d.poster) || "";
|
||
var wall = (tmdb.backdrop || "");
|
||
var ptype = (PAN_META[item.type] && PAN_META[item.type].play) ? PAN_META[item.type].play : diskTypeFromUrl(url);
|
||
var payload = { type: ptype, url: url, password: item.password || "", title: d.title || item.title || "盘链资源", pic: pic };
|
||
var note = item.title || item.groupName || (PAN_META[item.type] ? PAN_META[item.type].label : "");
|
||
// 不在这里立即写入播放记录:如果 30 秒内又返回,说明大概率没看,等确认看够时长再记录。
|
||
state.pendingPlayWatch = {
|
||
ts: Date.now(), kind: "resource", item: item, isResume: !!isResume,
|
||
recordInfo: d.id ? { id: d.id, title: payload.title, pic: pic, wall: wall, payload: payload, note: note } : null
|
||
};
|
||
if (window.fm && fm.pan && fm.pan.play) { toast("正在打开播放器…"); fm.pan.play(payload).then(function () {}, function () { toast("播放失败,试试下一个链接"); }); }
|
||
else toast("已解析:" + url);
|
||
}
|
||
/* 枫叶(在线点播):按开发文档用 fm.vodInline 推送——
|
||
① vod_name 用剧名(播放页标题就是剧名,不再是那串 url);
|
||
② episodes 是整条至臻4k线路的全集,原生播放页会显示集数/上一集/下一集;
|
||
③ 每集 resolve:true + pageUrl,进播放页不批量解析,点哪集才回调 __fmWebHomeInlineResolver
|
||
去破解那一集的真实 m3u8 —— 这才是"点一集播一集、像在网站上直接点播"的正确做法,
|
||
也避免了之前"进详情就想把所有集都解析、慢且常失败"的问题。 */
|
||
function playFyResource(item, isResume) {
|
||
var d = state.currentDetail || {};
|
||
var tmdb = d.tmdb || {};
|
||
var pic = (tmdb.poster || d.poster || item.poster || "");
|
||
var wall = (tmdb.backdrop || "");
|
||
var showName = (d.title || d.rawTitle || "").trim() || (item.title || "枫叶资源");
|
||
var ref = fyBase() + "/";
|
||
|
||
if (!window.fm || typeof fm.vodInline !== "function") {
|
||
// 没有 vodInline(旧版/浏览器):退回单集解析后直链播放
|
||
playFyResourceFallback(item, showName, pic);
|
||
return;
|
||
}
|
||
// 整条至臻4k线路的全集(同 quality),按集号排序
|
||
var sameLine = (state.panItems || []).filter(function (it) {
|
||
return it._fy && it._fyId === item._fyId && (it.quality || "") === (item.quality || "");
|
||
}).sort(function (a, b) { return (a._fySid - b._fySid) || (a._fyNid - b._fyNid); });
|
||
if (!sameLine.length) sameLine = [item];
|
||
|
||
var episodes = sameLine.map(function (it) {
|
||
var nm = it.title || ("第" + it._fyNid + "集");
|
||
return {
|
||
name: nm,
|
||
url: it._fyPlayPage, // 集数页面地址(不是最终媒体)
|
||
pageUrl: it._fyPlayPage, // 按集解析时建议填
|
||
resolve: true, // 点该集才回调解析真实地址
|
||
ext: { sid: it._fySid, nid: it._fyNid, id: it._fyId }
|
||
};
|
||
});
|
||
var mark = item.title || ("第" + item._fyNid + "集");
|
||
|
||
// 注册页面级 resolver:播放页点某一集时,Native 调它,返回 { url, format, headers }
|
||
window.__fmWebHomeInlineResolver = function (episode) {
|
||
var page = (episode && (episode.pageUrl || episode.url)) || "";
|
||
if (!page) return Promise.reject(new Error("缺少播放页地址"));
|
||
// 复用现成的破解逻辑(player_aaaa -> parse -> mplayer.php -> 解密),拿到真实 m3u8
|
||
return fyCrackPlayUrl(page).then(function (real) {
|
||
if (real) return real;
|
||
return fySniff(page); // 破解失败退回普通嗅探
|
||
}).then(function (real) {
|
||
if (!real) throw new Error("没解析到播放地址");
|
||
state.fyLastUrl = real;
|
||
return {
|
||
url: real,
|
||
format: /\.m3u8/i.test(real) ? "hls" : "",
|
||
headers: { "User-Agent": FY_UA, "Referer": ref }
|
||
};
|
||
});
|
||
};
|
||
|
||
if (d.id) {
|
||
// 记录最近观看用当前集信息(剧名,不是 url)
|
||
state.pendingPlayWatch = {
|
||
ts: Date.now(), kind: "resource", item: item, isResume: !!isResume,
|
||
recordInfo: { id: d.id, title: showName, pic: pic, wall: wall, note: "枫叶 · " + (item.groupName || "至臻4k") }
|
||
};
|
||
}
|
||
|
||
toast("正在打开播放器…");
|
||
try {
|
||
fm.vodInline({
|
||
vod_id: "fy_" + item._fyId,
|
||
vod_name: showName, // ← 剧名推到播放页
|
||
vod_pic: pic,
|
||
wallPic: wall,
|
||
vod_play_from: "枫叶·至臻4k",
|
||
mark: mark, // 默认选中你点的这一集
|
||
headers: { "User-Agent": FY_UA, "Referer": ref },
|
||
episodes: episodes // ← 整季选集
|
||
});
|
||
} catch (e) {
|
||
playFyResourceFallback(item, showName, pic);
|
||
}
|
||
}
|
||
/* 兜底:无 vodInline 时,解析当前单集后用 fm.play 直链播放(标题仍用剧名+集名) */
|
||
function playFyResourceFallback(item, showName, pic) {
|
||
if (item._resolving) return;
|
||
var epName = (item.title || "").trim();
|
||
var title = showName + (epName ? " " + epName : "");
|
||
var ref = fyBase() + "/";
|
||
item._resolving = true;
|
||
setItemState(item, "resolving");
|
||
toast("解析 " + (epName || "播放地址") + "…");
|
||
fyResolve(item).then(function (url) {
|
||
item._resolving = false; setItemState(item, "idle"); state.fyLastUrl = url;
|
||
if (window.fm && typeof fm.play === "function") {
|
||
state.fyLastPlayApi = "fm.play";
|
||
fm.play(url, title, { pic: pic, headers: { "User-Agent": FY_UA, "Referer": ref } });
|
||
} else toast("已解析:" + url);
|
||
}, function (err) {
|
||
item._resolving = false; setItemState(item, "bad");
|
||
toast("解析失败:" + (err && err.message ? err.message : "换一集试试"));
|
||
});
|
||
}
|
||
/* ---------- 播放返回过快:准备好下一条资源,但交给用户自己点播放 ---------- */
|
||
function findNextResource(afterItem) {
|
||
var list = sortedPanCandidates();
|
||
var pos = list.indexOf(afterItem);
|
||
if (pos < 0) return null;
|
||
if (pos + 1 < list.length) return list[pos + 1]; // 顺移到下一条
|
||
return list.length > 1 ? list[0] : null; // 已经是最后一条:从头循环
|
||
}
|
||
function prepareNextResource(afterItem) {
|
||
var next = findNextResource(afterItem);
|
||
if (!next) return;
|
||
var nq = next.quality || "其他";
|
||
if (state.qFilter && nq !== state.qFilter) { // 下一条在别的网盘类型tab里,同步切过去
|
||
state.qFilter = nq;
|
||
if (typeof renderQTabs === "function") renderQTabs();
|
||
if (typeof paintResources === "function") paintResources();
|
||
}
|
||
state.bestResource = next; // 主播放按钮改成播放这条
|
||
state.resumeRecord = null; // 已经换目标了,别再回落到上次那条
|
||
state.bestIsResume = false; // 这是新挑的资源,不是接着上次那条播
|
||
var btn = $("edPlay"), label = $("edPlayLabel");
|
||
if (btn && label) {
|
||
btn.removeAttribute("disabled");
|
||
var curId = state.currentDetail ? state.currentDetail.id : "";
|
||
var seen = curId ? (state.recentItems || []).some(function (h) { return String(h.id) === String(curId); }) : false;
|
||
label.textContent = seen ? "继续播放" : "播放"; // 只显示普通文案,不带别的
|
||
}
|
||
}
|
||
function handleAutoNextResume(pausedMs) {
|
||
var w = state.pendingPlayWatch;
|
||
if (!w) return;
|
||
state.pendingPlayWatch = null;
|
||
if (typeof pausedMs !== "number") pausedMs = Date.now() - w.ts;
|
||
if (pausedMs >= 30000) { // 观看时间够长,正常返回,这时才真正写入播放记录
|
||
if (w.kind === "resource" && w.recordInfo) {
|
||
var r = w.recordInfo;
|
||
recordRecent(r.id, r.title, r.pic, r.wall || "", r.payload, r.note);
|
||
}
|
||
return;
|
||
}
|
||
// 不足 30 秒:不写入播放记录。但如果这次是“继续播放”(接着上次记住的那条),
|
||
// 不去顺移换下一条——继续播放必须始终重试同一条链接,换源的逻辑只在“从头挑新资源”时生效。
|
||
// 不自动跳转播放页,等用户自己点“播放”。
|
||
if (w.kind === "resource" && !w.isResume) prepareNextResource(w.item);
|
||
}
|
||
function diskTypeFromUrl(u) {
|
||
for (var i = 0; i < HOSTMAP.length; i++) if (HOSTMAP[i][0].test(u)) return HOSTMAP[i][1];
|
||
if (/guangyapan\.com/i.test(u)) return "http"; // 光鸭盘:无法归类,按开发文档用 http 走 push 链路
|
||
if (u.indexOf("magnet:") === 0) return "magnet";
|
||
if (/^https?:/i.test(u)) return "http"; // 其它未知网盘也用 http,别再默认当夸克
|
||
return "quark";
|
||
}
|
||
|
||
/* ---------- 设置 ---------- */
|
||
function renderStatus() {
|
||
var box = $("statusBlock"); box.replaceChildren();
|
||
function line(label, val, cls) { var d = document.createElement("div"); d.className = "status-line"; d.innerHTML = "<b>" + escapeHtml(label) + ":</b><span class='" + (cls || "") + "'>" + escapeHtml(val) + "</span>"; box.appendChild(d); }
|
||
line("运行环境", state.sdkReady ? (state.isTv ? "App · 电视端" : (state.isMobile ? "App · 手机端" : "App")) : "浏览器预览", state.sdkReady ? "on" : "off");
|
||
line("网盘检测", state.driveCheck ? "已开启" : "已关闭", state.driveCheck ? "on" : "off");
|
||
line("指南站点", zn4kBase().replace(/^https?:\/\//, ""), "on");
|
||
line("七味", qwBase().replace(/^https?:\/\//, ""), "on");
|
||
line("蜗牛", wnBase().replace(/^https?:\/\//, ""), "on");
|
||
line("SeedHub", shBase().replace(/^https?:\/\//, ""), "on");
|
||
woAll().forEach(function (st) { line(st.name, woBase(st).replace(/^https?:\/\//, ""), "on"); });
|
||
// 枫叶是在线直链播放,跟网盘走的不是同一条播放通道。App 到底提供了哪些播放接口、
|
||
// 上一次实际用的是哪个、解析出来的地址长什么样,都摆出来,播不出来时一眼能定位。
|
||
var apis = (typeof fyPlayEntries === "function") ? fyPlayEntries() : [];
|
||
var apiLine = document.createElement("div");
|
||
apiLine.className = "status-line"; apiLine.style.gridColumn = "1 / -1";
|
||
apiLine.innerHTML = "<b>播放接口:</b><span class='" + (apis.length ? "on" : "off") + "'>" + escapeHtml(apis.length ? apis.join("、") : "未检测到(浏览器预览?)") + "</span>";
|
||
box.appendChild(apiLine);
|
||
if (state.fyLastUrl) {
|
||
var urlLine = document.createElement("div");
|
||
urlLine.className = "status-line"; urlLine.style.gridColumn = "1 / -1";
|
||
urlLine.innerHTML = "<b>枫叶最近解析:</b><span class='" + (state.fyLastPlayApi ? "on" : "") + "'>" +
|
||
escapeHtml((state.fyLastPlayApi ? "[" + state.fyLastPlayApi + "] " : "") + state.fyLastUrl) + "</span>";
|
||
box.appendChild(urlLine);
|
||
}
|
||
var loginWay = (config.account && config.password) ? (state.loggedInOnce ? "账号密码(已登录)" : "账号密码(待登录)") : (config.cookie ? "自定义 Cookie" : "内置 Cookie");
|
||
var loginLine = document.createElement("div");
|
||
loginLine.className = "status-line"; loginLine.style.gridColumn = "1 / -1";
|
||
loginLine.innerHTML = "<b>登录方式:</b><span class='" + ((config.account && config.password && state.loggedInOnce) ? "on" : "") + "'>" + escapeHtml(loginWay) + "</span>";
|
||
box.appendChild(loginLine);
|
||
}
|
||
function openSettings() {
|
||
// 和 openDetail() 同理:如果设置页已经打开(比如遥控器按键连发、点击和长按松开的
|
||
// “settings”分支几乎同时触发),只是重新刷新一次面板内容,并没有新增一层界面,
|
||
// 这时绝不能再 push 一条历史记录——否则会在历史栈里留下一条没有对应界面的幽灵记录,
|
||
// 之后关闭这一层时只会 pop 一次,深度对不上,需要多按一次返回才能耗尽。
|
||
var alreadyOpen = $("settingsOverlay").classList.contains("active");
|
||
if (!alreadyOpen) saveViewReturn();
|
||
$("cfgBase").value = config.base;
|
||
$("cfgCookie").value = config.cookie || "";
|
||
$("cfgAccount").value = config.account || "";
|
||
$("cfgPassword").value = config.password || "";
|
||
$("cfgTmdb").value = config.tmdbKey || "";
|
||
$("cfgTmdbImg").value = config.tmdbImg || "images.tmdb.org";
|
||
if ($("cfgTmdbApi")) $("cfgTmdbApi").value = config.tmdbApi || "";
|
||
if ($("cfgPanApi")) $("cfgPanApi").value = config.panApi || "";
|
||
if ($("cfgZn4k")) $("cfgZn4k").value = config.zn4kBase || "";
|
||
if ($("cfgQw")) $("cfgQw").value = config.qwBase || "";
|
||
if ($("cfgFy")) $("cfgFy").value = config.fyBase || "";
|
||
if ($("cfgWn")) $("cfgWn").value = config.wnBase || "";
|
||
if ($("cfgSh")) $("cfgSh").value = config.shBase || "";
|
||
if ($("cfgWo")) $("cfgWo").value = (config.woBases && config.woBases.wo) || "";
|
||
if ($("cfgMo")) $("cfgMo").value = (config.woBases && config.woBases.mo) || "";
|
||
if ($("cfgPm")) $("cfgPm").value = (config.woBases && config.woBases.pm) || "";
|
||
state.cfgPanBlockedTmp = (config.panBlockedTypes || []).slice();
|
||
renderPanTypeChips();
|
||
if ($("cfgShowEpisodes")) setSwitch($("cfgShowEpisodes"), config.showEpisodes !== false);
|
||
setLoginState((config.account && config.password) ? (state.loggedInOnce ? "ok" : "") : "", (config.account && config.password) ? (state.loggedInOnce ? "已登录,过期会自动重登" : "已配账号,保存或点登录即生效") : "填账号密码后可自动续登");
|
||
renderStatus();
|
||
navigate("settings", alreadyOpen);
|
||
var ov = $("settingsOverlay"); ov.classList.add("active"); ov.setAttribute("aria-hidden", "false");
|
||
state.focusScope = "settings"; setAppFocusable(false); focusFirst(ov);
|
||
}
|
||
/* 渲染「网盘类别」开关芯片:点一下切换该类别在 state.cfgPanBlockedTmp(编辑态,未保存前不生效)
|
||
里的屏蔽状态;亮色 = 显示,暗色 = 屏蔽。真正落地要等点「保存」。 */
|
||
function renderPanTypeChips() {
|
||
var box = $("cfgPanTypes");
|
||
if (!box) return;
|
||
var frag = document.createDocumentFragment();
|
||
PAN_TYPE_OPTIONS.forEach(function (name) {
|
||
var b = document.createElement("button");
|
||
b.type = "button";
|
||
var on = state.cfgPanBlockedTmp.indexOf(name) < 0;
|
||
b.className = "pan-type-chip focusable" + (on ? " on" : "");
|
||
b.textContent = name;
|
||
b.setAttribute("aria-pressed", on ? "true" : "false");
|
||
b.onclick = function () {
|
||
var idx = state.cfgPanBlockedTmp.indexOf(name);
|
||
if (idx < 0) state.cfgPanBlockedTmp.push(name); else state.cfgPanBlockedTmp.splice(idx, 1);
|
||
var nowOn = state.cfgPanBlockedTmp.indexOf(name) < 0;
|
||
b.classList.toggle("on", nowOn);
|
||
b.setAttribute("aria-pressed", nowOn ? "true" : "false");
|
||
};
|
||
frag.appendChild(b);
|
||
});
|
||
box.replaceChildren(frag);
|
||
}
|
||
function setLoginState(cls, msg) { var el = $("loginState"); if (!el) return; el.className = "login-state " + (cls || ""); el.textContent = msg; }
|
||
function doLoginTest() {
|
||
var acc = $("cfgAccount").value.trim(), pw = $("cfgPassword").value;
|
||
if (!acc || !pw) { setLoginState("bad", "请先填账号和密码"); return; }
|
||
config.account = acc; config.password = pw; state.useCookieManager = false;
|
||
setLoginState("busy", "登录中…");
|
||
persistConfig();
|
||
login().then(function () {
|
||
setLoginState("ok", "登录成功,Cookie 已更新" + (state.useCookieManager ? "(用系统 Cookie)" : ""));
|
||
renderStatus();
|
||
selectCat(state.activeCat);
|
||
}, function (e) {
|
||
setLoginState("bad", "登录失败:" + (e && e.message ? e.message : "请检查账号密码"));
|
||
});
|
||
}
|
||
function closeSettings(fromPopstate) {
|
||
var ov = $("settingsOverlay");
|
||
if (!fromPopstate && !ov.classList.contains("active")) return; // 已经关过了,避免重复消耗历史深度
|
||
ov.classList.remove("active"); ov.setAttribute("aria-hidden", "true");
|
||
state.focusScope = state.currentDetail ? "detail" : "home";
|
||
setAppFocusable(state.focusScope === "home");
|
||
restoreViewReturn();
|
||
if (!fromPopstate) closeRoute();
|
||
}
|
||
function setSwitch(btn, on) { if (on) btn.classList.add("on"); else btn.classList.remove("on"); btn.setAttribute("data-on", on ? "1" : "0"); if (btn.hasAttribute("aria-checked")) btn.setAttribute("aria-checked", on ? "true" : "false"); }
|
||
function saveConfig() {
|
||
var b = trimSlash($("cfgBase").value.trim()) || DEFAULT_BASE;
|
||
if (!/^https?:\/\//.test(b)) b = "https://" + b.replace(/^\/+/, "");
|
||
config.base = b;
|
||
config.tmdbKey = $("cfgTmdb").value.trim();
|
||
config.cookie = $("cfgCookie").value.trim();
|
||
var newAcc = $("cfgAccount").value.trim(), newPw = $("cfgPassword").value;
|
||
var credChanged = (newAcc !== config.account) || (newPw !== config.password);
|
||
config.account = newAcc; config.password = newPw;
|
||
config.tmdbImg = ($("cfgTmdbImg").value.trim() || "image.tmdb.org").replace(/^https?:\/\//, "").replace(/\/.*$/, "");
|
||
if ($("cfgTmdbApi")) config.tmdbApi = $("cfgTmdbApi").value.trim().replace(/^https?:\/\//, "").replace(/\/.*$/, "");
|
||
if ($("cfgPanApi")) config.panApi = $("cfgPanApi").value.trim() || "https://so.252035.xyz";
|
||
if ($("cfgZn4k")) {
|
||
var z = trimSlash($("cfgZn4k").value.trim()) || ZN4K_DEFAULT_BASE;
|
||
if (!/^https?:\/\//.test(z)) z = "https://" + z.replace(/^\/+/, "");
|
||
config.zn4kBase = z;
|
||
}
|
||
if ($("cfgQw")) {
|
||
var qw = trimSlash($("cfgQw").value.trim()) || QW_DEFAULT_BASE;
|
||
if (!/^https?:\/\//.test(qw)) qw = "https://" + qw.replace(/^\/+/, "");
|
||
config.qwBase = qw;
|
||
}
|
||
if ($("cfgFy")) {
|
||
var f = trimSlash($("cfgFy").value.trim()) || FY_DEFAULT_BASE;
|
||
if (!/^https?:\/\//.test(f)) f = "https://" + f.replace(/^\/+/, "");
|
||
config.fyBase = f;
|
||
}
|
||
if ($("cfgWn")) {
|
||
var w = trimSlash($("cfgWn").value.trim()) || WN_DEFAULT_BASE;
|
||
if (!/^https?:\/\//.test(w)) w = "https://" + w.replace(/^\/+/, "");
|
||
config.wnBase = w;
|
||
}
|
||
if ($("cfgSh")) {
|
||
var sh = trimSlash($("cfgSh").value.trim()) || SH_DEFAULT_BASE;
|
||
if (!/^https?:\/\//.test(sh)) sh = "https://" + sh.replace(/^\/+/, "");
|
||
config.shBase = sh;
|
||
}
|
||
if (!config.woBases) config.woBases = {};
|
||
if ($("cfgWo")) { var wo = trimSlash($("cfgWo").value.trim()) || WO_SITES.wo.domains[0]; if (!/^https?:\/\//.test(wo)) wo = "https://" + wo.replace(/^\/+/, ""); config.woBases.wo = wo; }
|
||
if ($("cfgMo")) { var mo = trimSlash($("cfgMo").value.trim()) || WO_SITES.mo.domains[0]; if (!/^https?:\/\//.test(mo)) mo = "https://" + mo.replace(/^\/+/, ""); config.woBases.mo = mo; }
|
||
if ($("cfgPm")) { var pm = trimSlash($("cfgPm").value.trim()) || WO_SITES.pm.domains[0]; if (!/^https?:\/\//.test(pm)) pm = "https://" + pm.replace(/^\/+/, ""); config.woBases.pm = pm; }
|
||
config.panBlockedTypes = (state.cfgPanBlockedTmp || []).slice();
|
||
if ($("cfgShowEpisodes")) {
|
||
config.showEpisodes = $("cfgShowEpisodes").classList.contains("on");
|
||
// 立即生效:用打开详情页时缓存下来的 TMDB 原始数据重绘/清空,不用重新拉网络
|
||
var cd = state.currentDetail;
|
||
if (cd && $("edEpisodes")) {
|
||
if (!config.showEpisodes) $("edEpisodes").replaceChildren();
|
||
else if (cd.tmdbRawMtype === "tv" && cd.tmdbRawDet && cd.tmdbRawDet.id && cd.tmdbRawDet.seasons && cd.tmdbRawDet.seasons.length) {
|
||
initEpisodesSection(cd.tmdbRawDet.id, cd.tmdbRawDet.seasons);
|
||
}
|
||
}
|
||
}
|
||
// 网盘类别屏蔽是纯前端展示过滤,当前详情页已经聚合好的资源不用重新请求网络,
|
||
// 直接用已有的 state.panItems 按新设置重画一遍网盘资源区即可立即生效。
|
||
if (state.currentDetail && !state.isCollection && state.panItems && state.panItems.length) renderResources(state.panItems);
|
||
state.heroRawCache = null; state.homeCatCache = {}; state.homeCatMore = {}; state.zn4kColCache = {}; state.qwCatCache = {}; state.qwGoodBase = ""; state.fyCache = {}; state.wnCache = {}; state.shCache = {}; state.woCache = {}; _listCache = {}; clearTextMemo(); clearHomeDisk(); // 换源/改配置后旧缓存作废(含磁盘缓存),下次刷新重新抓
|
||
persistConfig().then(function () {
|
||
toast("已保存");
|
||
applyChromeMode();
|
||
if (config.account && config.password && (credChanged || !state.loggedInOnce)) {
|
||
state.useCookieManager = false;
|
||
login().then(function () { state.loggedInOnce = true; reloadCurrent(); },
|
||
function (e) { toast("登录失败:" + (e && e.message ? e.message : "检查账号密码")); reloadCurrent(); });
|
||
} else {
|
||
reloadCurrent();
|
||
}
|
||
closeSettings(); // 点保存即自动关闭
|
||
});
|
||
}
|
||
function loadConfig() {
|
||
return storeGet("config").then(function (raw) {
|
||
if (raw) { try { var o = JSON.parse(raw); if (o.base) config.base = o.base; if (o.tmdbKey) config.tmdbKey = o.tmdbKey; if (o.tmdbImg) config.tmdbImg = o.tmdbImg; if (typeof o.tmdbApi === "string") config.tmdbApi = o.tmdbApi; if (typeof o.cookie === "string") config.cookie = o.cookie; if (typeof o.account === "string") config.account = o.account; if (typeof o.password === "string") config.password = o.password; if (typeof o.panApi === "string" && o.panApi) config.panApi = o.panApi; if (typeof o.zn4kBase === "string" && o.zn4kBase) config.zn4kBase = o.zn4kBase; if (typeof o.qwBase === "string" && o.qwBase) config.qwBase = o.qwBase; if (typeof o.fyBase === "string" && o.fyBase) config.fyBase = o.fyBase; if (typeof o.wnBase === "string" && o.wnBase) config.wnBase = o.wnBase; if (typeof o.shBase === "string" && o.shBase) config.shBase = o.shBase; if (o.woBases && typeof o.woBases === "object") { if (o.woBases.wo) config.woBases.wo = o.woBases.wo; if (o.woBases.mo) config.woBases.mo = o.woBases.mo; } if (Object.prototype.toString.call(o.panBlockedTypes) === "[object Array]") { config.panBlockedTypes = o.panBlockedTypes.filter(function (v) { return typeof v === "string"; }); } if (typeof o.showEpisodes === "boolean") config.showEpisodes = o.showEpisodes; } catch (e) {} }
|
||
}).then(function () {
|
||
return storeGet("favs");
|
||
}).then(function (raw2) {
|
||
if (raw2) { try { state.favs = JSON.parse(raw2) || {}; } catch (e) { state.favs = {}; } }
|
||
});
|
||
}
|
||
|
||
/* ---------- 路由 / 返回 ---------- */
|
||
function navigate(route, replace) {
|
||
if (!replace) state.stackDepth = (state.stackDepth || 0) + 1;
|
||
try {
|
||
var st = { route: route, depth: state.stackDepth };
|
||
if (replace) history.replaceState(st, "", "#" + route);
|
||
else history.pushState(st, "", "#" + route);
|
||
} catch (e) {}
|
||
}
|
||
// 关闭某一层时调用:之前用 replaceState 把记录“抹平”,看似不新增记录,
|
||
// 但 replaceState 根本不会让浏览器/WebView 的真实历史深度减 1——它只是把当前这条
|
||
// 记录的内容换成了空白,深度其实还留在原地没有被弹出。这样每次“非手势关闭”(点按钮、
|
||
// 跳到另一个条目等)都会在原生历史栈里悄悄多出一条不对应任何界面的“幽灵记录”。
|
||
// 手势返回完全由系统/WebView 按自己的历史深度(canGoBack)判断,不看这里的 state,
|
||
// 一旦深度因为这些幽灵记录而错位,某次手势返回就可能判断“没有可返回的页面”,
|
||
// 于是走了原生兜底逻辑——直接退出沉浸式全屏,而不是把事件交回页面触发 popstate。
|
||
// 修正:真正调用 history.back() 把这条记录弹出去,让深度和实际打开的层数严格一致;
|
||
// 用 suppressNextPop 标记这是“我方主动触发”的一次回退,避免这次 popstate 再重复关闭一遍
|
||
// (界面在调用 closeRoute 之前已经被调用方自己关掉了)。
|
||
// 之前用 suppressNextPop 这个单次布尔标志“猜测”下一次 popstate 是否是 closeRoute()
|
||
// 自己触发的,靠 400ms 超时兜底复位。问题是:popstate 的到达时机并不可靠——
|
||
// 部分 WebView/系统在没有更多历史记录时会直接走原生兜底(比如退出沉浸式全屏)而不派发
|
||
// popstate,或者 popstate 因主线程繁忙而延迟到超时之后才到——这些情况下超时会把标志
|
||
// 提前复位为 false,但真实的历史记录并没有被确认弹出;如果这个窗口期内又发生一次关闭,
|
||
// closeRoute() 会因为标志还是 true 而直接 return、不调用 history.back(),可 UI 已经关了,
|
||
// 于是产生“幽灵记录”,需要连按多次返回才能耗尽,表现为“点好几次返回都退不出全屏”。
|
||
//
|
||
// 改为深度比对:navigate() 时把逻辑深度写进 history.state.depth,closeRoute() 时记录
|
||
// “关掉这一层后期望达到的深度”,popstate 到来时直接用 event.state.depth 和期望值核对,
|
||
// 不再依赖“这次 popstate 是不是我触发的”这种时序猜测,无论 popstate 延迟、丢失还是被
|
||
// 原生兜底吞掉,下一次任何来源的 popstate 都能用真实深度自我纠正,不会累积幽灵记录。
|
||
function closeRoute() {
|
||
state.expectedDepth = (state.stackDepth || 0) - 1;
|
||
try { history.back(); } catch (e) { state.expectedDepth = null; }
|
||
}
|
||
window.addEventListener("popstate", function (event) {
|
||
var incomingDepth = (event.state && typeof event.state.depth === "number") ? event.state.depth : 0;
|
||
state.stackDepth = incomingDepth;
|
||
// 该关的这一层已经关过了(不管这次 popstate 来自我们自己的 back(),还是姗姗来迟、
|
||
// 或是用户手势追加触发的),深度已经达到或低于期望值,直接消费掉,不再重复关闭下一层。
|
||
if (state.expectedDepth != null && incomingDepth <= state.expectedDepth) {
|
||
state.expectedDepth = null;
|
||
return;
|
||
}
|
||
state.expectedDepth = null;
|
||
// 返回手势:按当前打开的层从上到下关闭一层(浏览器已经弹出了历史记录,这里只需同步关闭对应界面)
|
||
if ($("settingsOverlay").classList.contains("active")) { closeSettings(true); return; }
|
||
if ($("detailSheet").classList.contains("active")) { closeDetail(true); return; }
|
||
if (state.searchPanelOpen) { closeSearchPanel(true); return; } // 搜索面板打开中:回弹关闭,恢复主页
|
||
if (state.searchMode) { clearSearch(true); return; }
|
||
if (state.view === "category" || state.view === "recent") { showHome(true); return; }
|
||
});
|
||
function handleBack() {
|
||
if (state.focusScope === "settings") { closeSettings(); return true; }
|
||
if (state.focusScope === "detail") { closeDetail(); return true; }
|
||
if (state.searchPanelOpen) { closeSearchPanel(); return true; } // 搜索面板打开中:回弹关闭,恢复主页
|
||
if (state.searchMode) { clearSearch(); return true; }
|
||
return false;
|
||
}
|
||
|
||
/* ---------- 焦点 / 遥控器 ---------- */
|
||
function isVisibleFocusable(el) {
|
||
if (!el || el.disabled) return false;
|
||
if (el.getAttribute("tabindex") === "-1") return false;
|
||
if (el.closest("[aria-hidden='true']")) return false;
|
||
var r = el.getBoundingClientRect();
|
||
return r.width > 0 && r.height > 0;
|
||
}
|
||
function focusRemoteTarget(el) {
|
||
if (!el) return;
|
||
try { el.focus({ preventScroll: true }); } catch (e) { try { el.focus(); } catch (e2) {} }
|
||
requestAnimationFrame(function () { scrollIntoViewSafe(el); });
|
||
}
|
||
function scrollIntoViewSafe(el) {
|
||
var r = el.getBoundingClientRect(), vh = window.innerHeight || 800;
|
||
if (r.top < 70) window.scrollBy(0, r.top - 80);
|
||
else if (r.bottom > vh - 20) window.scrollBy(0, r.bottom - vh + 40);
|
||
scrollHorizIntoView(el);
|
||
}
|
||
/* 横向滚动容器(mh-row / chips / genreChips / cast-row / works-row / qTabs / ep-rail 等)
|
||
遥控左右移动焦点时不会像鼠标滚轮那样自动带着容器滚,这里手动找最近的可横滚祖先,
|
||
把焦点卡片顶到可视区域内,不够的话左右各留 8px 缓冲 */
|
||
function scrollHorizIntoView(el) {
|
||
var host = el.parentElement;
|
||
while (host && host !== document.body) {
|
||
if (host.scrollWidth > host.clientWidth + 2) {
|
||
var hr = host.getBoundingClientRect(), er = el.getBoundingClientRect();
|
||
if (er.left < hr.left + 8) host.scrollLeft -= (hr.left + 8 - er.left);
|
||
else if (er.right > hr.right - 8) host.scrollLeft += (er.right - (hr.right - 8));
|
||
return;
|
||
}
|
||
host = host.parentElement;
|
||
}
|
||
}
|
||
function focusFirst(scopeEl) {
|
||
var el = scopeEl.querySelector(".focusable");
|
||
if (state.isTv && el && el.id === "search") { var chips = $("chips").querySelector(".focusable"); if (chips) el = chips; }
|
||
if (el) focusRemoteTarget(el);
|
||
}
|
||
function setAppFocusable(on) {
|
||
var app = $("app");
|
||
var els = app.querySelectorAll(".focusable, input, button");
|
||
for (var i = 0; i < els.length; i++) { if (on) els[i].removeAttribute("tabindex"); else els[i].setAttribute("tabindex", "-1"); }
|
||
app.setAttribute("aria-hidden", on ? "false" : "true");
|
||
}
|
||
function currentScopeEl() {
|
||
if (state.focusScope === "settings") return $("settingsOverlay");
|
||
if (state.focusScope === "detail") return $("detailSheet");
|
||
return $("app");
|
||
}
|
||
function nearestFocusable(fromEl, dir) {
|
||
var scope = currentScopeEl();
|
||
var all = Array.from(scope.querySelectorAll(".focusable")).filter(isVisibleFocusable);
|
||
if (!all.length) return null;
|
||
var fr = fromEl.getBoundingClientRect(), fx = fr.left + fr.width / 2, fy = fr.top + fr.height / 2;
|
||
var best = null, bestScore = Infinity;
|
||
all.forEach(function (el) {
|
||
if (el === fromEl) return;
|
||
var r = el.getBoundingClientRect(), cx = r.left + r.width / 2, cy = r.top + r.height / 2;
|
||
var dx = cx - fx, dy = cy - fy, ok = false;
|
||
if (dir === "up" && dy < -4) ok = true;
|
||
if (dir === "down" && dy > 4) ok = true;
|
||
if (dir === "left" && dx < -4) ok = true;
|
||
if (dir === "right" && dx > 4) ok = true;
|
||
if (!ok) return;
|
||
var primary = (dir === "up" || dir === "down") ? Math.abs(dy) : Math.abs(dx);
|
||
var cross = (dir === "up" || dir === "down") ? Math.abs(dx) : Math.abs(dy);
|
||
var score = primary + cross * 2.2;
|
||
if (score < bestScore) { bestScore = score; best = el; }
|
||
});
|
||
return best;
|
||
}
|
||
var gridColsCache = { key: "", cols: 3 };
|
||
function gridColumns() {
|
||
var w = window.innerWidth || 360;
|
||
var key = w + "|" + (state.isTv ? "tv" : (state.isMobile ? "m" : "b"));
|
||
if (gridColsCache.key === key) return gridColsCache.cols;
|
||
var cols = state.isTv ? (w >= 1600 ? 8 : 6) : (w >= 1280 ? 8 : w >= 1040 ? 7 : w >= 820 ? 6 : w >= 700 ? 5 : w >= 560 ? 4 : 3);
|
||
gridColsCache = { key: key, cols: cols };
|
||
return cols;
|
||
}
|
||
function handleArrow(dir, active) {
|
||
var row = active.getAttribute("data-focus-row"), target = null;
|
||
if (row === "grid") {
|
||
var idx = parseInt(active.getAttribute("data-card-index"), 10), cols = gridColumns(), cards = $("grid").children, ni = -1;
|
||
if (dir === "left") ni = idx - 1; else if (dir === "right") ni = idx + 1;
|
||
else if (dir === "down") ni = idx + cols; else if (dir === "up") ni = idx - cols;
|
||
if (dir === "up" && idx < cols) target = $("chips").querySelector(".focusable");
|
||
else if (ni >= 0 && ni < cards.length) target = cards[ni];
|
||
if (ni >= state.rendered - cols) appendGrid();
|
||
} else if (row === "chips") {
|
||
var chips = Array.from($("chips").children), ci = chips.indexOf(active);
|
||
if (dir === "left" && ci > 0) target = chips[ci - 1];
|
||
else if (dir === "right" && ci < chips.length - 1) target = chips[ci + 1];
|
||
else if (dir === "down") target = $("genreChips").querySelector(".focusable") || $("grid").querySelector(".focusable");
|
||
else if (dir === "up") target = $("search");
|
||
} else if (row === "genreChips") {
|
||
var gcs = Array.from($("genreChips").children), gi = gcs.indexOf(active);
|
||
if (dir === "left" && gi > 0) target = gcs[gi - 1];
|
||
else if (dir === "right" && gi < gcs.length - 1) target = gcs[gi + 1];
|
||
else if (dir === "down") target = $("grid").querySelector(".focusable");
|
||
else if (dir === "up") target = $("chips").querySelector(".focusable");
|
||
} else if (row === "detailBtn") {
|
||
if (dir === "down") target = $("nativeSearch");
|
||
} else if (row === "detailAction") {
|
||
var acts = [$("nativeSearch"), $("appSearchBtn"), $("edRefresh")], ai = acts.indexOf(active);
|
||
if (dir === "left" && ai > 0) target = acts[ai - 1];
|
||
else if (dir === "right" && ai < acts.length - 1) target = acts[ai + 1];
|
||
else if (dir === "up") target = $("edPlay");
|
||
else if (dir === "down") target = $("qTabs").querySelector(".focusable") || $("panList").querySelector(".focusable");
|
||
} else if (row === "qtabs") {
|
||
var qs = Array.from($("qTabs").children), qi = qs.indexOf(active);
|
||
if (dir === "left" && qi > 0) target = qs[qi - 1];
|
||
else if (dir === "right" && qi < qs.length - 1) target = qs[qi + 1];
|
||
else if (dir === "up") target = $("nativeSearch");
|
||
else if (dir === "down") target = $("panList").querySelector(".focusable");
|
||
} else if (row === "coll") {
|
||
var cc = Array.from($("panList").querySelectorAll(".card")), ci2 = cc.indexOf(active);
|
||
var cols2 = state.isTv ? 6 : (function(){var w=window.innerWidth||360;return w>=1280?8:w>=1040?7:w>=820?6:w>=700?5:w>=560?4:3;})();
|
||
var ni2 = -1;
|
||
if (dir === "left") ni2 = ci2 - 1; else if (dir === "right") ni2 = ci2 + 1;
|
||
else if (dir === "down") ni2 = ci2 + cols2; else if (dir === "up") ni2 = ci2 - cols2;
|
||
if (dir === "up" && ci2 < cols2) target = $("nativeSearch");
|
||
else if (ni2 >= 0 && ni2 < cc.length) target = cc[ni2];
|
||
} else if (row === "row") {
|
||
var rc = active.parentElement ? Array.from(active.parentElement.children).filter(function (c) { return c.classList && c.classList.contains("focusable"); }) : [];
|
||
var ri = rc.indexOf(active);
|
||
if (dir === "left" && ri > 0) target = rc[ri - 1];
|
||
else if (dir === "right" && ri < rc.length - 1) target = rc[ri + 1];
|
||
// 上/下(换到别的行)交给下面的 nearestFocusable 几何兜底,同一行内左右已经精确处理了
|
||
} else if (row === "panList") {
|
||
var rows = Array.from($("panList").querySelectorAll(".focusable")), pi = rows.indexOf(active);
|
||
if (dir === "down" && pi < rows.length - 1) target = rows[pi + 1];
|
||
else if (dir === "up") target = pi > 0 ? rows[pi - 1] : ($("qTabs").querySelector(".focusable") || $("nativeSearch"));
|
||
}
|
||
if (!target) target = nearestFocusable(active, dir);
|
||
if (target) { focusRemoteTarget(target); return true; }
|
||
return false;
|
||
}
|
||
function onKeydown(e) {
|
||
var key = e.key || "", code = e.keyCode || 0;
|
||
var active = document.activeElement;
|
||
var editingText = active && (active.tagName === "INPUT" || active.tagName === "TEXTAREA") && !active.hasAttribute("readonly");
|
||
var isBackspace = key === "Backspace" || code === 8;
|
||
// 退格:在可编辑输入框里是删字,不当返回;其它情况(TV 遥控)才当返回
|
||
var isBack = key === "GoBack" || code === 4 || code === 27 || key === "Escape" || (isBackspace && !editingText);
|
||
var isOk = key === "Enter" || key === "OK" || code === 13 || code === 23;
|
||
var dir = "";
|
||
if (key === "ArrowUp" || code === 38) dir = "up";
|
||
else if (key === "ArrowDown" || code === 40) dir = "down";
|
||
else if (key === "ArrowLeft" || code === 37) dir = "left";
|
||
else if (key === "ArrowRight" || code === 39) dir = "right";
|
||
if (isBack) { if (handleBack()) { e.preventDefault(); e.stopImmediatePropagation(); } return; }
|
||
if (editingText && !isOk && !dir) return; // 输入框内的普通按键(含退格)交给输入框
|
||
if (isOk && active && active.classList && active.classList.contains("focusable")) {
|
||
if (active.tagName === "INPUT" && active.hasAttribute("readonly")) { active.removeAttribute("readonly"); try { active.focus(); } catch (e2) {} e.preventDefault(); return; }
|
||
if (active.tagName !== "INPUT" && active.click) { active.click(); e.preventDefault(); return; }
|
||
}
|
||
if (!dir || !state.isTv) return;
|
||
if (!active || !isVisibleFocusable(active) || !active.classList || !active.classList.contains("focusable")) {
|
||
var first = currentScopeEl().querySelector(".focusable"); if (first) { focusRemoteTarget(first); e.preventDefault(); } return;
|
||
}
|
||
if (active.tagName === "INPUT" && !active.hasAttribute("readonly")) return;
|
||
if (handleArrow(dir, active)) e.preventDefault();
|
||
}
|
||
function wireInputs() {
|
||
var inputs = document.querySelectorAll("input.focusable");
|
||
for (var i = 0; i < inputs.length; i++) {
|
||
(function (inp) {
|
||
if (state.isTv) inp.setAttribute("readonly", "readonly"); // TV 默认只读,防止焦点即弹软键盘
|
||
inp.addEventListener("blur", function () { if (state.isTv) inp.setAttribute("readonly", "readonly"); });
|
||
inp.addEventListener("click", function () { inp.removeAttribute("readonly"); });
|
||
inp.addEventListener("touchstart", function () { inp.removeAttribute("readonly"); }, { passive: true });
|
||
})(inputs[i]);
|
||
}
|
||
}
|
||
function saveHomeReturn() {
|
||
if (state.focusScope !== "home") return;
|
||
var a = document.activeElement;
|
||
state.homeReturn = { key: a && a.getAttribute ? a.getAttribute("data-card-index") : null, scrollY: window.pageYOffset || 0 };
|
||
}
|
||
function restoreHomeReturn() {
|
||
if (state.focusScope !== "home") return;
|
||
var hr = state.homeReturn; if (!hr) return;
|
||
scrollHomeTo(hr.scrollY || 0, hr.key);
|
||
}
|
||
/* 详情页/设置页关闭后"原地恢复"用的单独槽位——跟 homeReturn 是两回事,不能共用:
|
||
homeReturn 专门记"从分类/最近观看页离开时,主页当时滚到哪了",只在真正回到主页时使用,
|
||
且主页内容是异步渲染的,需要 scrollHomeTo 的重试逻辑才够得着目标位置。
|
||
而这里的 viewReturn 记的是"打开详情/设置这一刻,当前页面(不管是主页还是分类页)滚到哪了",
|
||
关闭后原地恢复即可,当前页面早就渲染好了,不需要重试。
|
||
以前两者共用 homeReturn 一个变量,一旦在分类页里点开详情,openDetail() 就会把分类页的
|
||
滚动位置覆盖写进 homeReturn,等真正退出分类页回主页时用到的就是这个被污染的错误值,
|
||
表现为"查看全部页返回主页后莫名回到顶部"。拆开后各管各的,互不覆盖。 */
|
||
function saveViewReturn() {
|
||
if (state.focusScope !== "home") return;
|
||
var a = document.activeElement;
|
||
state.viewReturn = { key: a && a.getAttribute ? a.getAttribute("data-card-index") : null, scrollY: window.pageYOffset || 0 };
|
||
}
|
||
function restoreViewReturn() {
|
||
if (state.focusScope !== "home") return;
|
||
var vr = state.viewReturn; if (!vr) return;
|
||
window.scrollTo(0, vr.scrollY || 0);
|
||
if (state.isTv && vr.key != null) {
|
||
var scopeEl = (state.view === "category" || state.view === "recent") ? $("grid") : null;
|
||
if (scopeEl) { var card = scopeEl.querySelector("[data-card-index='" + vr.key + "']"); if (card) focusRemoteTarget(card); }
|
||
}
|
||
}
|
||
/* 滚回指定位置,直到页面真的够高为止。
|
||
为什么不能只 scrollTo 一次:首页各行的卡片是异步填进去的,文档高度是一点点长起来的,
|
||
刚返回那一刻页面可能只有几百像素高,scrollTo(0, 1450) 会被浏览器夹到当时的底部,
|
||
看起来就是"又回到顶部了"。所以按高度重试,够得着了才算数。
|
||
用户中途自己滑动/触摸就立刻放手,绝不跟人抢滚动。 */
|
||
function scrollHomeTo(targetY, key) {
|
||
if (state._scrollTask) { clearInterval(state._scrollTask); state._scrollTask = 0; }
|
||
function focusCard() {
|
||
if (state.isTv && key != null) { var c = $("grid") && $("grid").querySelector("[data-card-index='" + key + "']"); if (c) focusRemoteTarget(c); }
|
||
}
|
||
if (!targetY || targetY <= 0) { window.scrollTo(0, 0); focusCard(); return; }
|
||
var tries = 0, abort = false, settledTicks = 0;
|
||
var SETTLE_NEEDED = 6; // 到位后再稳稳保持这么多个 tick(约 600ms)才算数
|
||
function bail() { abort = true; }
|
||
window.addEventListener("touchstart", bail, { passive: true });
|
||
window.addEventListener("wheel", bail, { passive: true });
|
||
function stop() {
|
||
clearInterval(state._scrollTask); state._scrollTask = 0;
|
||
window.removeEventListener("touchstart", bail);
|
||
window.removeEventListener("wheel", bail);
|
||
}
|
||
function tick() {
|
||
if (abort || state.view !== "home") { stop(); return; }
|
||
var doc = Math.max(document.documentElement.scrollHeight || 0, document.body ? document.body.scrollHeight : 0);
|
||
var max = Math.max(0, doc - (window.innerHeight || 0));
|
||
// 到位了也不立刻收手、更不能停止赋值:宿主 WebView 有时会在这之后再自己触发一次异步的
|
||
// 滚动恢复,把我们刚设好的位置又盖回顶部;这里持续按目标值纠偏一小段时间,每 tick 都重新
|
||
// 赋值一次,谁盖谁没用,我们这边最后一次赋值才是最终生效的那个。
|
||
window.scrollTo(0, Math.min(targetY, max));
|
||
var now = window.pageYOffset || document.documentElement.scrollTop || 0;
|
||
if (Math.abs(now - targetY) < 4) {
|
||
if (++settledTicks >= SETTLE_NEEDED) { stop(); focusCard(); return; }
|
||
} else {
|
||
settledTicks = 0;
|
||
}
|
||
if (++tries > 100) { stop(); return; } // 约 10 秒后认了:内容确实没那么长,或者有别的东西一直在抢
|
||
}
|
||
tick();
|
||
state._scrollTask = setInterval(tick, 100);
|
||
}
|
||
|
||
/* ---------- 工具 ---------- */
|
||
function el(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }
|
||
/* 详情页骨架屏:概览 3 行 / 演员 6 个头像 / 网盘资源 4 条,纯占位 DOM,不含文本节点
|
||
(所以不会影响调用方 `!ov.textContent` 这类"是否已有内容"的判断),数据到手后被真实内容覆盖即可。 */
|
||
function overviewSkeleton() {
|
||
var wrap = el("div", "ov-skel");
|
||
for (var i = 0; i < 3; i++) wrap.appendChild(el("div", "detail-skel-bar"));
|
||
return wrap;
|
||
}
|
||
function castSkeleton() {
|
||
var wrap = el("div", "cast-skel");
|
||
for (var i = 0; i < 6; i++) {
|
||
var item = el("div", "cast-skel-item");
|
||
item.appendChild(el("div", "detail-skel-bar av"));
|
||
item.appendChild(el("div", "detail-skel-bar nm"));
|
||
wrap.appendChild(item);
|
||
}
|
||
return wrap;
|
||
}
|
||
function panListSkeleton() {
|
||
var wrap = el("div", "pan-skel");
|
||
for (var i = 0; i < 4; i++) {
|
||
var item = el("div", "pan-skel-item");
|
||
item.appendChild(el("div", "detail-skel-bar ic"));
|
||
var body = el("div", "pan-skel-body");
|
||
body.appendChild(el("div", "detail-skel-bar t1"));
|
||
body.appendChild(el("div", "detail-skel-bar t2"));
|
||
item.appendChild(body);
|
||
wrap.appendChild(item);
|
||
}
|
||
return wrap;
|
||
}
|
||
function epRailSkeleton() {
|
||
var rail = el("div", "ep-skel-rail");
|
||
for (var i = 0; i < 4; i++) {
|
||
var card = el("div", "ep-skel-card");
|
||
card.appendChild(el("div", "detail-skel-bar thumb"));
|
||
card.appendChild(el("div", "detail-skel-bar line1"));
|
||
card.appendChild(el("div", "detail-skel-bar line2"));
|
||
rail.appendChild(card);
|
||
}
|
||
return rail;
|
||
}
|
||
function trimSlash(s) { return (s || "").replace(/\/+$/, ""); }
|
||
/* 给某个域名动态插入 preconnect 提示(用户在设置里改了源/图床/网盘接口时用得上),
|
||
同一个域名只插一次,避免重复。静默失败:老内核不认 rel=preconnect 也没关系,不影响功能 */
|
||
var _preconnected = {};
|
||
function addPreconnect(url) {
|
||
try {
|
||
var m = /^https?:\/\/[^\/]+/.exec(url || ""); if (!m) return;
|
||
var origin = m[0]; if (_preconnected[origin]) return;
|
||
_preconnected[origin] = true;
|
||
var l1 = document.createElement("link"); l1.rel = "preconnect"; l1.href = origin; l1.crossOrigin = "anonymous"; document.head.appendChild(l1);
|
||
var l2 = document.createElement("link"); l2.rel = "dns-prefetch"; l2.href = origin; document.head.appendChild(l2);
|
||
} catch (e) {}
|
||
}
|
||
function escapeHtml(s) { return String(s == null ? "" : s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'"); }
|
||
function hashKey(s) { s = String(s || ""); var h = 5381, i = s.length; while (i) { h = (h * 33) ^ s.charCodeAt(--i); } return (h >>> 0).toString(16); }
|
||
|
||
/* ---------- 事件 ---------- */
|
||
/* ============ 苹果橡皮筋回弹 ============
|
||
Android WebView 默认没有 iOS 那种越界回弹。这里用 JS 在滚动"到边了还继续拉"时,
|
||
给滚动容器加一个带阻尼的 translateY(越拉越难,橡皮筋手感),松手加 .rubber-snap
|
||
过渡弹回原位。只在越界那一刻介入,正常滚动完全不接管,不影响性能和惯性。
|
||
作用对象:主页/分类页(window 滚动的 .app)的底部越界;详情页 sheet 的顶/底越界。
|
||
主页顶部已有"下拉刷新",那块不重复做,交给下拉刷新处理。 */
|
||
function initRubberBand() {
|
||
var MAX = 120; // 最大拉伸位移
|
||
function damp(d) { return MAX * (1 - Math.exp(-Math.abs(d) / MAX)) * (d < 0 ? -1 : 1); } // 指数阻尼:越拉越慢
|
||
|
||
// —— 详情页 sheet:按需求不做橡皮筋 ——
|
||
// —— 主页/分类:window 滚动,套在 .app 上,只做底部(顶部让给下拉刷新)——
|
||
bindWindow($("app"));
|
||
|
||
function bindOne(scroller, doTop, doBottom) {
|
||
if (!scroller) return;
|
||
var startY = 0, startX = 0, pulling = 0, active = false, dir = 0, axis = 0;
|
||
scroller.addEventListener("touchstart", function (e) {
|
||
if (e.touches.length !== 1) { active = false; return; }
|
||
startY = e.touches[0].clientY; startX = e.touches[0].clientX; pulling = 0; active = true; dir = 0; axis = 0;
|
||
scroller.classList.remove("rubber-snap");
|
||
}, { passive: true });
|
||
scroller.addEventListener("touchmove", function (e) {
|
||
if (!active) return;
|
||
var dy = e.touches[0].clientY - startY;
|
||
var dx = e.touches[0].clientX - startX;
|
||
if (axis === 0) {
|
||
if (Math.abs(dx) > 8 || Math.abs(dy) > 8) axis = Math.abs(dx) > Math.abs(dy) ? 1 : 2;
|
||
}
|
||
if (axis === 1) { active = false; return; } // 横向手势:放手给横排滚动
|
||
if (axis !== 2 && pulling === 0) return;
|
||
var top = scroller.scrollTop;
|
||
var atTop = top <= 0;
|
||
var atBottom = top + scroller.clientHeight >= scroller.scrollHeight - 1;
|
||
if (dy > 0 && atTop && doTop) dir = 1;
|
||
else if (dy < 0 && atBottom && doBottom) dir = -1;
|
||
else if (pulling === 0) { dir = 0; return; } // 不在越界方向,交给正常滚动
|
||
if (dir === 0) return;
|
||
// 越界拉伸:阻止默认滚动,改用 transform
|
||
if (e.cancelable) e.preventDefault();
|
||
pulling = damp(dy);
|
||
scroller.style.transform = "translateY(" + pulling + "px)";
|
||
}, { passive: false });
|
||
function end() {
|
||
if (!active) { axis = 0; return; } active = false;
|
||
if (pulling !== 0) {
|
||
scroller.classList.add("rubber-snap");
|
||
scroller.style.transform = "translateY(0)";
|
||
pulling = 0;
|
||
var el = scroller;
|
||
setTimeout(function () { el.classList.remove("rubber-snap"); el.style.transform = ""; }, 460);
|
||
}
|
||
axis = 0;
|
||
}
|
||
scroller.addEventListener("touchend", end, { passive: true });
|
||
scroller.addEventListener("touchcancel", end, { passive: true });
|
||
}
|
||
|
||
// window 滚动版:位移作用在 .app 上,底部越界回弹
|
||
function bindWindow(appEl) {
|
||
if (!appEl) return;
|
||
// .chips 底部导航栏是 position:fixed,但它是 #app 的子元素——CSS 规定祖先一旦带 transform,
|
||
// 内部 fixed 元素就会变成相对该祖先定位,不再"焊死"在视口上。所以 #app 橡皮筋位移的时候,
|
||
// 导航栏会跟着一起被拖动、松手时再跟着弹一下,看着像"跳动"。这里给它叠加一个反向的
|
||
// translateY,实时抵消掉 #app 的位移,视觉上导航栏全程纹丝不动。
|
||
var chipsEl = $("chips");
|
||
var startY = 0, startX = 0, pulling = 0, active = false, armed = false, axis = 0;
|
||
// 回弹结束后延时460ms才彻底清transform;如果这460ms内用户又快速滑了一次,
|
||
// 旧的这个setTimeout不会自动取消,到时间照样会触发,把新手势/新回弹中途的transform强行清空——
|
||
// 这就是"连续快速上下滑动导航栏还会跳一下"的根子。用snapTimer记着,每次开始新手势或
|
||
// 触发新一轮回弹前,先取消上一个还没触发的清理定时器,保证同一时刻只有最新这一个在生效。
|
||
var snapTimer = null;
|
||
function clearSnapTimer() { if (snapTimer) { clearTimeout(snapTimer); snapTimer = null; } }
|
||
function scrollY() { return window.pageYOffset || document.documentElement.scrollTop || 0; }
|
||
function maxScroll() { return Math.max(0, (document.documentElement.scrollHeight || 0) - (window.innerHeight || 0)); }
|
||
document.addEventListener("touchstart", function (e) {
|
||
if (e.touches.length !== 1) { active = false; return; }
|
||
// 详情页开着、搜索面板、或手指在横滚区/弹窗里,都不接管
|
||
if (state.focusScope === "detail" || state.searchPanelOpen) { active = false; return; }
|
||
if (e.target && e.target.closest && e.target.closest(".chips, .genre-chips, .rail, .sheet, .panel-overlay, .search-mask, .search-panel, .ed-cast-row, .ed-qtabs, .pan-list")) { active = false; return; }
|
||
startY = e.touches[0].clientY; startX = e.touches[0].clientX; pulling = 0; active = true; armed = false; axis = 0;
|
||
clearSnapTimer();
|
||
appEl.classList.remove("rubber-snap");
|
||
if (chipsEl) chipsEl.classList.remove("rubber-snap");
|
||
// 只在"确有残留 transform"时才清空。普通滑动本就没有残留,无谓地写 .chips 的 transform
|
||
// 会让它的 backdrop-filter 合成层被拆掉重建,视觉上闪一下(消失一帧再出现)。
|
||
// 读 .style.transform 只反映行内值:没有行内 transform 时它是空串,直接跳过、绝不去碰。
|
||
if (appEl.style.transform) appEl.style.transform = "";
|
||
if (chipsEl && chipsEl.style.transform) chipsEl.style.transform = "";
|
||
}, { passive: true });
|
||
document.addEventListener("touchmove", function (e) {
|
||
if (!active) return;
|
||
var dy = e.touches[0].clientY - startY;
|
||
var dx = e.touches[0].clientX - startX;
|
||
// 先锁定主方向:横向手势明显时直接放弃竖向橡皮筋,交给左右滑/横排滚动
|
||
if (axis === 0) {
|
||
if (Math.abs(dx) > 8 || Math.abs(dy) > 8) axis = Math.abs(dx) > Math.abs(dy) ? 1 : 2;
|
||
}
|
||
if (axis === 1) { active = false; return; } // 横向:不接管
|
||
if (axis !== 2) return; // 方向还没定,先不动
|
||
// 只处理"到底了还往上拉"(dy<0 且已在最底部)
|
||
if (dy < 0 && scrollY() >= maxScroll() - 1) armed = true;
|
||
if (!armed) return;
|
||
if (e.cancelable) e.preventDefault();
|
||
pulling = damp(dy);
|
||
appEl.style.transform = "translateY(" + pulling + "px)";
|
||
if (chipsEl) chipsEl.style.transform = "translateX(-50%) translateY(" + (-pulling) + "px)";
|
||
}, { passive: false });
|
||
function end() {
|
||
if (!active) { armed = false; axis = 0; return; }
|
||
active = false;
|
||
clearSnapTimer();
|
||
if (pulling !== 0) {
|
||
// 这一次确实拉动过:带过冲缓动弹回 0,460ms 后彻底清掉 transform。
|
||
appEl.classList.add("rubber-snap");
|
||
appEl.style.transform = "translateY(0)";
|
||
if (chipsEl) { chipsEl.classList.add("rubber-snap"); chipsEl.style.transform = "translateX(-50%) translateY(0)"; }
|
||
var el = appEl;
|
||
// 回弹动画结束后彻底清掉 transform —— 否则 #app 长期带着 transform,
|
||
// 会让内部 position:fixed 的底部导航栏(.chips)失去"相对视口固定",变成跟着页面滚动。
|
||
snapTimer = setTimeout(function () {
|
||
snapTimer = null;
|
||
el.classList.remove("rubber-snap"); el.style.transform = "";
|
||
if (chipsEl) { chipsEl.classList.remove("rubber-snap"); chipsEl.style.transform = ""; }
|
||
}, 460);
|
||
} else {
|
||
// 这一次没拉动(典型:到底猛拉→松手→没等回弹结束就反手上滑,把上一轮清理定时器取消掉)。
|
||
// 若上一轮在 #app 上残留了 transform(哪怕是 translateY(0)),会一直破坏 .chips 的 fixed,
|
||
// 表现为"导航栏掉下去跟着页面滚",所以要清。但同样只在确有残留时才写,避免无谓触碰
|
||
// .chips 的 transform 引起 backdrop-filter 闪烁。
|
||
appEl.classList.remove("rubber-snap");
|
||
if (chipsEl) chipsEl.classList.remove("rubber-snap");
|
||
if (appEl.style.transform) appEl.style.transform = "";
|
||
if (chipsEl && chipsEl.style.transform) chipsEl.style.transform = "";
|
||
}
|
||
pulling = 0; armed = false; axis = 0;
|
||
}
|
||
document.addEventListener("touchend", end, { passive: true });
|
||
document.addEventListener("touchcancel", end, { passive: true });
|
||
}
|
||
}
|
||
function bindEvents() {
|
||
// 筛选标签栏横向滑动已改为原生滚动(见 .genre-chips 的 touch-action: pan-x),不再需要手动绑定拖动
|
||
var sm = $("searchMask"); if (sm) sm.addEventListener("click", function () { if (state.searchPanelOpen) closeSearchPanel(); });
|
||
$("searchGo").onclick = function () { hideSuggest(); startSearch($("search").value); };
|
||
$("search").addEventListener("keydown", function (e) { if ((e.key === "Enter" || e.keyCode === 13) && !this.hasAttribute("readonly")) { hideSuggest(); startSearch(this.value); } });
|
||
$("search").addEventListener("input", function () { if (state.suggestTimer) clearTimeout(state.suggestTimer); state.suggestTimer = setTimeout(runSuggest, 260); });
|
||
$("search").addEventListener("blur", function () { setTimeout(function () { hideSuggest(); if (!state.searchMode) { $("search").value = ""; closeSearchPanel(); } }, 220); });
|
||
$("clearSearch").onclick = function (e) { if (e && e.stopPropagation) e.stopPropagation(); clearSearch(); }; // 同上:不能直接引用,否则 click 事件会被当 fromPopstate 传入,closeRoute() 被跳过;stopPropagation 防止冒泡到外层搜索框触发 openSearchPanel
|
||
// 查看全部/分类页 左上角返回:同样不能直接写 onclick = showHome,click 事件对象会被当成 fromPopstate 参数传进去,
|
||
// 导致 showHome() 里 wasPushedView && !fromPopstate 判断为假,closeRoute() 被跳过、历史栈和 UI 深度错位。
|
||
var catBackBtn = $("catBack");
|
||
if (catBackBtn) catBackBtn.addEventListener("click", function () { showHome(); });
|
||
// 搜索结果页头部的"搜索框"按钮:点它重新打开搜索面板(面板里的输入框仍保留着上次的关键词,可直接改)。
|
||
var searchBarBtn = $("searchBar");
|
||
if (searchBarBtn) searchBarBtn.addEventListener("click", function () { openSearchPanel(); });
|
||
(function () {
|
||
var box = $("chips");
|
||
if (!box) return;
|
||
var LONG_MS = 550, lpTimer = null, lpTouchHandled = false, lpResetTimer = null;
|
||
function settingsTarget(t) { return t && t.closest ? t.closest("[data-nav='settings']") : null; }
|
||
function lpStart() {
|
||
clearTimeout(lpTimer);
|
||
lpTimer = setTimeout(function () {
|
||
state.chipLpFired = true;
|
||
cycleTheme();
|
||
if (navigator.vibrate) { try { navigator.vibrate(12); } catch (e) {} }
|
||
}, LONG_MS);
|
||
}
|
||
function lpCancel() { clearTimeout(lpTimer); }
|
||
function lpEnd(e) { clearTimeout(lpTimer); if (state.chipLpFired && e && e.cancelable) e.preventDefault(); }
|
||
box.addEventListener("touchstart", function (e) {
|
||
if (!settingsTarget(e.target)) return;
|
||
lpTouchHandled = true; clearTimeout(lpResetTimer); lpStart();
|
||
}, { passive: true });
|
||
box.addEventListener("touchmove", lpCancel, { passive: true });
|
||
box.addEventListener("touchcancel", lpCancel, { passive: true });
|
||
box.addEventListener("touchend", function (e) {
|
||
if (!settingsTarget(e.target)) { lpCancel(); return; }
|
||
lpEnd(e);
|
||
lpResetTimer = setTimeout(function () { lpTouchHandled = false; state.chipLpFired = false; }, 500);
|
||
}, { passive: false });
|
||
box.addEventListener("mousedown", function (e) {
|
||
if (lpTouchHandled || !settingsTarget(e.target)) return;
|
||
lpStart();
|
||
});
|
||
box.addEventListener("mouseup", function (e) {
|
||
if (!settingsTarget(e.target)) return;
|
||
lpEnd(e);
|
||
});
|
||
box.addEventListener("mouseleave", lpCancel);
|
||
box.addEventListener("contextmenu", function (e) { if (settingsTarget(e.target)) e.preventDefault(); });
|
||
})();
|
||
$("edPlay").onclick = playBest;
|
||
$("detailOverview").onclick = function () { this.classList.toggle("clamp"); };
|
||
$("edRefresh").onclick = function () {
|
||
var it = state.currentItem; if (!it) return;
|
||
$("panList").replaceChildren(panListSkeleton());
|
||
state.panItems = [];
|
||
state.panToken++; state.panDiag = [];
|
||
if (it._zn4k) { zn4kLoadById(it._zn4kId, state.panToken); }
|
||
else if (it._qw) { qwLoadById(it._qwId, state.panToken); }
|
||
else { loadResources(it); zn4kEnrich(it.title, state.panToken); qwEnrich(it.title, state.panToken); }
|
||
loadTmdb(it);
|
||
if (config.panApi) { var pt = state.panToken; setTimeout(function () { if (pt === state.panToken) runPanSou(true); }, 350); }
|
||
};
|
||
$("nativeSearch").onclick = function () { runPanSou(); }; /* 盘搜 PanSou;同上不能直接引用,否则 click 事件会被当 silent=true 传入,正常点击时的提示会被吞掉 */
|
||
$("appSearchBtn").onclick = function () {
|
||
var d = state.currentDetail;
|
||
if (d && window.fm && fm.search) {
|
||
Promise.resolve(fm.search(d.rawTitle || d.title, { direct: true, pic: d.poster }))
|
||
.then(function () {}, function () { toast("搜索失败,请稍后重试"); });
|
||
} else if (d) {
|
||
toast("请在 App 内使用原生搜索");
|
||
}
|
||
};
|
||
$("cfgSave").onclick = saveConfig;
|
||
if ($("cfgShowEpisodes")) $("cfgShowEpisodes").onclick = function () { setSwitch(this, !this.classList.contains("on")); };
|
||
$("cfgLogin").onclick = doLoginTest;
|
||
// 注意:不能直接 `$("cfgClose").onclick = closeSettings`——浏览器触发 click 时会把
|
||
// 事件对象当第一个参数传进去,等于变成 closeSettings(clickEvent),而 clickEvent 恒为
|
||
// truthy,会被当成 fromPopstate=true,导致 `if (!fromPopstate) closeRoute()` 永远不执行,
|
||
// 点“关闭”/“✕”关掉设置页时历史记录永远不会被弹出,每开关一次就净增一条幽灵记录。
|
||
// 必须用无参调用包一层。
|
||
$("cfgClose").onclick = function () { closeSettings(); };
|
||
$("cfgCloseX").onclick = function () { closeSettings(); };
|
||
$("settingsOverlay").addEventListener("click", function (e) { if (e.target === this) closeSettings(); });
|
||
$("confirmCancel").onclick = function () { closeConfirm(); };
|
||
$("confirmOverlay").addEventListener("click", function (e) { if (e.target === this) closeConfirm(); });
|
||
document.addEventListener("keydown", onKeydown, true);
|
||
window.addEventListener("fmviewport", function () { gridColsCache.key = ""; if (typeof syncSafeAreaClass === "function") syncSafeAreaClass(); });
|
||
window.addEventListener("resize", function () { gridColsCache.key = ""; });
|
||
// 手机横竖屏切换:详情页大图轮播要跟着换池(竖版海报 <-> 横版剧照),见 wantBackdropHero /
|
||
// rebuildDetailHeroForOrientation。用 matchMedia 监听比 resize/orientationchange 更准,
|
||
// 键盘弹出之类的高度变化不会误触发;resize 兜底一下不支持 matchMedia 变更监听的旧浏览器。
|
||
(function () {
|
||
try {
|
||
var mq = window.matchMedia && matchMedia("(orientation: landscape)");
|
||
if (mq && mq.addEventListener) mq.addEventListener("change", scheduleHeroRebuild);
|
||
else if (mq && mq.addListener) mq.addListener(scheduleHeroRebuild); // 老 Safari / 老内核兜底
|
||
} catch (e) {}
|
||
window.addEventListener("orientationchange", scheduleHeroRebuild);
|
||
window.addEventListener("resize", scheduleHeroRebuild);
|
||
// App 内嵌 WebView 旋转时,方向变化是通过 fmviewport 事件下发的(原生重新测量后才通知页面)。
|
||
// 之前这个事件只用来清网格列数缓存,没接详情页大图,所以 App 里手机/平板转横屏时轮播池根本不会换 ——
|
||
// 横版剧照不生效的主因。这里补上。
|
||
window.addEventListener("fmviewport", scheduleHeroRebuild);
|
||
// visualViewport 在 WebView 里比 window.resize 更早、更准地反映旋转后的真实视口
|
||
try { if (window.visualViewport && window.visualViewport.addEventListener) window.visualViewport.addEventListener("resize", scheduleHeroRebuild); } catch (e) {}
|
||
})();
|
||
window.addEventListener("fmresume", function (event) {
|
||
loadRecent();
|
||
if (window.fm && fm.ctrl) { try { fm.ctrl("stop"); } catch (e) {} }
|
||
var pausedMs = event && event.detail ? event.detail.pausedMs : undefined;
|
||
handleAutoNextResume(pausedMs);
|
||
});
|
||
|
||
// 下拉刷新:严格只在首页(state.view === "home")生效,"查看全部"分类页/最近观看/
|
||
// 搜索结果页一律不响应——这些页面要么有横向筛选手势会跟下拉打架,要么下拉刷新语义上就不对。
|
||
var ptr = $("ptr"), pStartY = 0, pDist = 0, pActive = false, pRefreshing = false;
|
||
var PT = 72; // 触发阈值
|
||
function atTop() { return (window.pageYOffset || document.documentElement.scrollTop || 0) <= 2; }
|
||
function ptrStart(e) {
|
||
if (pRefreshing || state.focusScope !== "home" || state.searchPanelOpen || state.view !== "home") return;
|
||
var t = e.touches && e.touches[0]; if (!t) return;
|
||
if (!atTop()) { pActive = false; return; }
|
||
if (e.target && e.target.closest && e.target.closest(".chips, .genre-chips, .rail, .sheet, .panel-overlay, .search-mask, .search-panel")) { pActive = false; return; }
|
||
pStartY = t.clientY; pDist = 0; pActive = true;
|
||
}
|
||
function ptrMove(e) {
|
||
if (!pActive || pRefreshing) return;
|
||
var t = e.touches && e.touches[0]; if (!t) return;
|
||
var dy = t.clientY - pStartY;
|
||
if (dy <= 0 || !atTop()) { pDist = 0; ptr.style.opacity = "0"; ptr.style.transform = "translateY(-10px)"; ptr.classList.remove("ready"); return; }
|
||
pDist = dy;
|
||
var pull = Math.min(dy * 0.5, 80);
|
||
ptr.style.opacity = String(Math.min(1, dy / PT));
|
||
ptr.style.transform = "translateY(" + (pull - 10) + "px)";
|
||
if (dy >= PT) { ptr.classList.add("ready"); $("ptrText").textContent = "释放刷新"; }
|
||
else { ptr.classList.remove("ready"); $("ptrText").textContent = "下拉刷新"; }
|
||
}
|
||
function ptrEnd() {
|
||
if (!pActive || pRefreshing) { pActive = false; return; }
|
||
pActive = false;
|
||
if (pDist >= PT) {
|
||
pRefreshing = true;
|
||
ptr.classList.remove("ready"); ptr.classList.add("spin");
|
||
ptr.style.opacity = "1"; ptr.style.transform = "translateY(6px)"; $("ptrText").textContent = "刷新中…";
|
||
refreshCurrent();
|
||
setTimeout(function () {
|
||
pRefreshing = false; ptr.classList.remove("spin");
|
||
ptr.style.opacity = "0"; ptr.style.transform = "translateY(-10px)"; $("ptrText").textContent = "下拉刷新";
|
||
}, 900);
|
||
} else {
|
||
ptr.style.opacity = "0"; ptr.style.transform = "translateY(-10px)"; ptr.classList.remove("ready");
|
||
}
|
||
}
|
||
document.addEventListener("touchstart", ptrStart, { passive: true });
|
||
document.addEventListener("touchmove", ptrMove, { passive: true });
|
||
document.addEventListener("touchend", ptrEnd, { passive: true });
|
||
|
||
initRubberBand();
|
||
|
||
// 左右滑动切换:主页切换分类;“查看全部”页(state.view==="category")切换筛选标签;
|
||
// “最近观看”页没有筛选标签可切,不响应。阈值较高,避免误触;忽略横滚/输入/详情
|
||
var sx = 0, sy = 0, sTrack = false, sMoved = false;
|
||
function swStart(e) {
|
||
if (state.focusScope !== "home" || state.searchPanelOpen || state.view === "recent") { sTrack = false; return; }
|
||
var t = e.touches && e.touches[0]; if (!t) { sTrack = false; return; }
|
||
if (e.target && e.target.closest && e.target.closest(".chips, .genre-chips, .rail, input, .sheet, .panel-overlay, .ed-cast-row, .ed-qtabs, .hero-wrap, .search-mask, .search-panel")) { sTrack = false; return; }
|
||
sx = t.clientX; sy = t.clientY; sMoved = false; sTrack = true;
|
||
}
|
||
function swMove(e) {
|
||
if (!sTrack) return;
|
||
var t = e.touches && e.touches[0]; if (!t) return;
|
||
if (Math.abs(t.clientX - sx) > 6 || Math.abs(t.clientY - sy) > 6) sMoved = true;
|
||
}
|
||
function swEnd(e) {
|
||
if (!sTrack || !sMoved) { sTrack = false; return; }
|
||
var t = e.changedTouches && e.changedTouches[0]; sTrack = false; if (!t) return;
|
||
var dx = t.clientX - sx, dy = t.clientY - sy;
|
||
if (Math.abs(dx) > 70 && Math.abs(dx) > Math.abs(dy) * 2) {
|
||
var delta = dx < 0 ? 1 : -1;
|
||
if (state.view === "category") swipeGenre(delta); else swipeCategory(delta);
|
||
}
|
||
}
|
||
document.addEventListener("touchstart", swStart, { passive: true });
|
||
document.addEventListener("touchmove", swMove, { passive: true });
|
||
document.addEventListener("touchend", swEnd, { passive: true });
|
||
// 触底加载
|
||
window.addEventListener("scroll", function () {
|
||
if (state.focusScope !== "home") return;
|
||
var sc = window.pageYOffset || 0, vh = window.innerHeight || 800, dh = document.body.scrollHeight || 0;
|
||
if (sc + vh > dh - 400) { state.autoPages = 0; appendGrid(); }
|
||
}, { passive: true });
|
||
}
|
||
|
||
/* ---------- 启动 ---------- */
|
||
function boot() {
|
||
// 关掉浏览器自带的滚动恢复。默认 'auto' 时,popstate 之后浏览器会自己滚一次;
|
||
// 而进分类页时首页被 display:none、文档高度塌掉,它记下的位置早被夹成 0,
|
||
// 那一次异步的自动恢复会把我们刚恢复好的位置盖回顶部。
|
||
try { if (window.history && "scrollRestoration" in history) history.scrollRestoration = "manual"; } catch (e) {}
|
||
detectDevice();
|
||
loadTheme();
|
||
renderChips();
|
||
bindEvents();
|
||
// 骨架屏必须在这里就位:往下要等 SDK 就绪、拉配置、登录,实测 1.7 秒才轮到渲染内容。
|
||
// 原来骨架屏是等这些都跑完、内容已经画出来之后才盖上去的,
|
||
// 观感就成了"加载好了 → 黑屏 → 又加载一次"。
|
||
showHomeSkeleton();
|
||
whenSdk().then(function (ready) {
|
||
state.sdkReady = ready;
|
||
if (ready) {
|
||
root.classList.add("fm-native");
|
||
detectDevice();
|
||
if (fm.ui && fm.ui.setChrome) fm.ui.setChrome({ mode: config.homeFull ? "edge" : "normal", statusBarStyle: "light", navigationBarStyle: "light", startup: true }).then(function () {}, function () {});
|
||
if (fm.config) fm.config().then(function (c) { state.driveCheck = c && c.driveCheck !== false; }, function () {});
|
||
}
|
||
wireInputs();
|
||
return loadConfig();
|
||
}).then(function () {
|
||
applyChromeMode(); // 用加载后的真实 homeFull 校正 chrome + 安全区 class
|
||
addPreconnect(config.base);
|
||
addPreconnect("https://" + (config.tmdbImg || "image.tmdb.org"));
|
||
addPreconnect("https://" + (config.tmdbApi || "api.tmdb.org"));
|
||
addPreconnect(config.panApi);
|
||
addPreconnect(zn4kBase());
|
||
addPreconnect(qwBase());
|
||
addPreconnect(wnBase());
|
||
addPreconnect(shBase());
|
||
woAll().forEach(function (st) { addPreconnect(woBase(st)); });
|
||
return ensureLogin(); // 配了账号密码则先登录拿新 Cookie(失败不阻塞)
|
||
}).then(function () {
|
||
// 先把上次的首页内容 + 观看记录读回来(并行),首屏直接有内容,不用干等网络
|
||
return Promise.all([loadHomeDisk(), loadRecent()]);
|
||
}).then(function () {
|
||
loadHero();
|
||
showHome();
|
||
// 兜底:无论如何 6 秒后收起骨架屏,避免网络异常时一直盖着
|
||
setTimeout(hideHomeSkeleton, 6000);
|
||
if (state.isTv) { var chip = $("chips").querySelector(".focusable"); if (chip) focusRemoteTarget(chip); }
|
||
});
|
||
}
|
||
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", boot);
|
||
else boot();
|
||
</script>
|
||
</body>
|
||
</html> |