Files
tvbox_collect/Tomorrow/html/盘链.html
T
2026-07-26 11:22:44 +00:00

9620 lines
573 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta 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);
/* 故意不加 transitionhero-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">&#8595;</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">加载中&#8230;</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">&#9654;</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">正在读取资源&#8230;</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="关闭">&#10005;</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";
/* ============================================================
业务代码:语法基线 ES2017async/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"); });
}
/* ---------- 网络:带内置 CookieAPI 需伪装成 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;
}
/* ---------- 账号密码登录 / 自动续登 ---------- */
/* 表单 POSTapplication/x-www-form-urlencodedPHP $_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 CookieManagerfm.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·rdata-form 都是 yhm3u8)共用的外挂播放器。
这类线路的 player_aaaa.url 只是个 token(形如 JD-xxxxx),要拼到这个播放器地址后面才能换到真视频地址。
正常情况下地址会从站点的 MacPlayerConfig 里读出来,读不到时用这个兜底。 */
var FY_PARSE_FALLBACK = "https://fgsrg.hzqingshan.com/player/?url=";
/* 站点的播放器配置 JSMacCMS 主题一般把 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_aaaaurl(可能 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.phpbody=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=1base64 -> 与 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=2base64 -> 取每 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=3a/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.playtype=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 + " 部";
}
/* ============================================================
森中有林 / SeedHubsidhub.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(/&amp;/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(/&#0*47;/g, "/").replace(/&#x0*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 idwatch_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 吃同一份 _tmdbCatCachekey 都是 "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 吃同一份 _tmdbCatCachekey 都是 "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);
/* 森中有林 / SeedHubsidhub.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(/&nbsp;/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 = "&#10005;";
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 = "&#10005;";
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 直接赋值,而不是 scrollIntoViewscrollIntoView 在部分
// 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.onerrorTMDB 图片域名(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 挂 .searchingCSS 据此把标题换成搜索框、
// 把胶囊移到下一行),并把当前关键词填进搜索框按钮里。离开搜索则摘掉。放在这里是因为本函数
// 在每次视图切换(首页/分类/搜索/最近)时都会被调用,能可靠地跟着 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; }
}
/* 当前视口宽高:优先 visualViewportWebView 里旋转后它比 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 ? "&#9829;" : "&#9825;";
}
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.depthcloseRoute() 时记录
// “关掉这一层后期望达到的深度”,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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;"); }
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 = showHomeclick 事件对象会被当成 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>