Files
tvbox_collect/tvbox/lib/nostr-emby.html
T
2026-06-29 03:41:14 +00:00

12501 lines
495 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"><meta name="theme-color" content="#101419"><title>Nostr推荐</title><script>
window.WEBHOME_PAGE_OPENED_AT = Date.now();
if (window.fongmiBridge) document.documentElement.classList.add("fm-native");
(function () {
var protoList = [
window.Element && Element.prototype,
window.Document && Document.prototype,
window.DocumentFragment && DocumentFragment.prototype
];
for (var p = 0; p < protoList.length; p++) {
var proto = protoList[p];
if (!proto) continue;
if (proto.replaceChildren) continue;
proto.replaceChildren = function () {
while (this.firstChild) this.removeChild(this.firstChild);
for (var i = 0; i < arguments.length; i++) {
var child = arguments[i];
this.appendChild(child && child.nodeType ? child : document.createTextNode(String(child)));
}
};
}
if (window.Element && !Element.prototype.matches) {
Element.prototype.matches = Element.prototype.msMatchesSelector || Element.prototype.webkitMatchesSelector;
}
if (window.Element && !Element.prototype.closest) {
Element.prototype.closest = function (selector) {
var el = this;
while (el && el.nodeType === 1) {
if (el.matches && el.matches(selector)) return el;
el = el.parentElement || el.parentNode;
}
return null;
};
}
if (!Object.values) {
Object.values = function (obj) {
return Object.keys(obj || {}).map(function (key) { return obj[key]; });
};
}
if (!Object.entries) {
Object.entries = function (obj) {
return Object.keys(obj || {}).map(function (key) { return [key, obj[key]]; });
};
}
if (!Array.from) {
Array.from = function (value, mapFn, thisArg) {
var result = [];
if (value == null) return result;
var iterator = typeof Symbol !== "undefined" && value[Symbol.iterator];
var i = 0;
if (typeof iterator === "function") {
var step;
var iter = iterator.call(value);
while (!(step = iter.next()).done) {
result.push(mapFn ? mapFn.call(thisArg, step.value, i++) : step.value);
}
return result;
}
var length = Number(value.length) || 0;
for (; i < length; i++) {
result.push(mapFn ? mapFn.call(thisArg, value[i], i) : value[i]);
}
return result;
};
}
if (window.NodeList && !NodeList.prototype.forEach) {
NodeList.prototype.forEach = Array.prototype.forEach;
}
if (window.HTMLCollection && !HTMLCollection.prototype.forEach) {
HTMLCollection.prototype.forEach = Array.prototype.forEach;
}
if (!Array.prototype.includes) {
Array.prototype.includes = function (value, fromIndex) {
return this.indexOf(value, fromIndex || 0) !== -1;
};
}
if (!Array.prototype.flat) {
Array.prototype.flat = function (depth) {
var maxDepth = depth == null ? 1 : Number(depth) || 0;
var flatten = function (items, level) {
var acc = [];
for (var i = 0; i < items.length; i++) {
var item = items[i];
if (Array.isArray(item) && level < maxDepth) {
var nested = flatten(item, level + 1);
for (var n = 0; n < nested.length; n++) acc.push(nested[n]);
} else {
acc.push(item);
}
}
return acc;
};
return flatten(this, 0);
};
}
if (!Array.prototype.flatMap) {
Array.prototype.flatMap = function (callback, thisArg) {
return this.map(callback, thisArg).flat();
};
}
if (!String.prototype.includes) {
String.prototype.includes = function (search, start) {
return this.indexOf(search, start || 0) !== -1;
};
}
if (!String.prototype.startsWith) {
String.prototype.startsWith = function (search, start) {
return this.substr(start || 0, search.length) === search;
};
}
if (!String.prototype.endsWith) {
String.prototype.endsWith = function (search, length) {
var value = String(this);
var end = length == null ? value.length : Math.min(Number(length) || 0, value.length);
return value.substring(end - search.length, end) === search;
};
}
if (window.Promise && !Promise.prototype.finally) {
Promise.prototype.finally = function (callback) {
var P = this.constructor;
return this.then(function (value) {
return P.resolve(callback()).then(function () { return value; });
}, function (reason) {
return P.resolve(callback()).then(function () { throw reason; });
});
};
}
})();
</script><style>
:root {
color-scheme: dark;
--bg: transparent;
--panel-rgb: 76, 88, 98;
--panel: rgba(var(--panel-rgb), .42);
--panel-soft: rgba(var(--panel-rgb), .28);
--panel-strong: rgba(var(--panel-rgb), .62);
--panel-2: rgba(var(--panel-rgb), .46);
--text: #f4f7fb;
--muted: rgba(226, 234, 242, .72);
--line: rgba(255, 255, 255, .2);
--line-strong: rgba(255, 255, 255, .34);
--control: rgba(34, 43, 51, .42);
--control-active: rgba(48, 61, 70, .58);
--accent: rgba(245, 249, 252, .86);
--accent-2: rgba(221, 232, 241, .66);
--focus-line: rgba(168, 218, 255, .72);
--focus-line-soft: rgba(168, 218, 255, .42);
--focus-glow: rgba(96, 165, 250, .24);
--focus-surface: rgba(255, 255, 255, .12);
--ok: #9fd8ad;
--danger: #f09d9d;
--shadow: 0 16px 42px rgba(0, 0, 0, .24);
--radius: 8px;
--fm-web-height: 100dvh;
--fm-safe-bottom: 20px;
--keyboard-height: 0px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "PingFang SC", "Microsoft YaHei", sans-serif;
}
* { box-sizing: border-box; }
html { -webkit-tap-highlight-color: transparent; }
html, body {
margin: 0;
width: 100%;
max-width: 100%;
min-height: var(--fm-web-height);
background: transparent;
color: var(--text);
overflow-x: hidden;
touch-action: pan-x pan-y;
}
html:not(.fm-native),
html:not(.fm-native) body {
background: transparent;
}
html.fm-native,
html.fm-native body {
background: transparent;
}
body {
overflow-x: hidden;
letter-spacing: 0;
position: relative;
overscroll-behavior-x: none;
}
button, input, textarea, select {
font: inherit;
color: inherit;
}
button {
border: 0;
cursor: pointer;
touch-action: manipulation;
margin: 0;
padding: 0;
appearance: none;
-webkit-appearance: none;
background: transparent;
text-align: inherit;
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
}
img {
display: block;
max-width: 100%;
background: transparent;
}
.app {
width: 100%;
max-width: 100%;
min-width: 0;
min-height: var(--fm-web-height);
overflow-x: hidden;
padding: max(12px, var(--safe-t)) 14px calc(18px + var(--fm-safe-bottom) + env(safe-area-inset-bottom));
background: transparent;
}
/* 详情页激活期间:主页内容完全隐藏,resize 时也不会透出 */
body.detail-active .app,
body.detail-closing .app {
visibility: hidden;
pointer-events: none;
/* 防止 resize 触发重排时内容短暂出现 */
opacity: 0;
}
body.detail-active .back-top {
opacity: 0;
visibility: hidden;
pointer-events: none;
}
body.episode-active #detailSheet {
visibility: hidden;
pointer-events: none;
}
.icon-btn {
width: 42px;
height: 42px;
display: grid;
place-items: center;
border-radius: var(--radius);
background: var(--control);
border: 1px solid var(--line);
color: var(--text);
text-shadow: 0 1px 2px rgba(0, 0, 0, .38);
/* backdrop-filter 已移到需要的具体场景(详情页/浮层),避免每个按钮都创建合成层 */
}
.icon {
width: 19px;
height: 19px;
fill: none;
stroke: currentColor;
stroke-width: 2.2;
stroke-linecap: round;
stroke-linejoin: round;
}
.pill {
width: fit-content;
color: var(--text);
background: var(--control-active);
border: 1px solid var(--line);
padding: 5px 9px;
border-radius: 999px;
font-size: 12px;
font-weight: 800;
text-shadow: 0 1px 2px rgba(0, 0, 0, .38);
}
.actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
/* 有「继续观看」时,三个按钮沿用「无继续观看」页的统一样式:
flex 自适应排列 + 20px 圆角胶囊 + 42px 高(继承 .actions / .btn 基础样式),
只保留 nowrap 防止「继续观看」断行;不再用 grid 把按钮拉伸成整行大方块。 */
#panSearchBtn {
position: relative;
overflow: hidden;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, .14),
inset 0 0 0 1px rgba(255, 255, 255, .04) !important;
}
#panSearchBtn::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
background: linear-gradient(180deg, rgba(255, 255, 255, .14) 0%, rgba(255, 255, 255, 0) 48%);
pointer-events: none;
}
.actions .btn {
white-space: nowrap;
}
#detailContinueBtn {
position: relative;
overflow: hidden;
color: #fff;
background: linear-gradient(135deg, #12d7ff 0%, #0b91ff 58%, #0867f7 100%);
border-color: rgba(255, 255, 255, .15);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, .12),
inset 0 0 0 1px rgba(255, 255, 255, .04);
font-weight: 800;
text-shadow: none;
}
#detailContinueBtn::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
background: linear-gradient(180deg, rgba(255, 255, 255, .10) 0%, rgba(255, 255, 255, 0) 48%);
pointer-events: none;
}
#detailSearchBtn,
#panSearchBtn {
/* 实际背景被 Liquid-Glass 的 var(--lg-base)!important 接管成深色玻璃,
故文字用白色才可读(原 #111827 深色字会与深玻璃糊在一起看不清)。 */
position: relative;
overflow: hidden;
color: #fff;
background: rgba(255, 255, 255, .98);
border-color: rgba(255, 255, 255, .8);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, .32),
inset 0 0 0 1px rgba(255, 255, 255, .08);
font-weight: 800;
text-shadow: none;
}
#detailSearchBtn::after,
#panSearchBtn::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
background: linear-gradient(180deg, rgba(255, 255, 255, .18) 0%, rgba(255, 255, 255, 0) 48%);
pointer-events: none;
}
.btn {
min-height: 42px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
padding: 0 14px;
border-radius: var(--radius);
background: var(--control);
border: 1px solid var(--line);
color: var(--text);
font-weight: 760;
text-decoration: none;
text-shadow: 0 1px 2px rgba(0, 0, 0, .38);
}
.btn.primary {
color: var(--text);
background: var(--control-active);
border-color: var(--line-strong);
}
.btn.blue {
background: var(--control-active);
color: var(--text);
border-color: var(--line-strong);
}
.btn.danger {
color: #fff0f0;
background: rgba(240, 157, 157, .2);
border-color: rgba(255, 220, 220, .24);
}
.search {
width: 100%;
min-width: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
margin: 14px 0 8px;
position: relative;
z-index: 20;
}
.search.suggesting {
z-index: 95;
}
.suggest-panel {
position: absolute;
left: 0;
right: 0;
top: calc(100% + 8px);
transform: none;
z-index: 200;
display: none;
max-height: min(60vh, 500px);
overflow-y: auto;
padding: 6px;
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, .10);
background: rgba(8, 13, 22, .88);
box-shadow: 0 24px 60px rgba(0, 0, 0, .55), inset 0 1px 0 rgba(255, 255, 255, .08);
backdrop-filter: blur(20px) saturate(150%);
-webkit-backdrop-filter: blur(20px) saturate(150%);
scrollbar-width: none;
}
.suggest-panel.open { display: flex; flex-direction: column; gap: 2px; }
.suggest-panel::-webkit-scrollbar { display: none; }
/* TV 端:保持跟随搜索框(absolute),不居中 */
html.tv-mode .suggest-panel {
position: absolute !important;
left: 0 !important;
right: 0 !important;
top: calc(100% + 8px) !important;
transform: none !important;
}
.suggest-item {
min-width: 0;
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
min-height: 44px;
padding: 8px 12px;
border-radius: 11px;
color: var(--text);
text-align: left;
background: transparent;
border: none;
transition: background .12s ease;
}
.suggest-item:first-child {
background: rgba(96, 165, 250, .10);
}
.suggest-item:hover,
.suggest-item:focus {
background: rgba(255, 255, 255, .07);
outline: none;
}
.suggest-item:first-child:hover,
.suggest-item:first-child:focus {
background: rgba(96, 165, 250, .16);
}
/* 左侧图标块 */
.suggest-icon {
width: 28px;
height: 28px;
flex-shrink: 0;
border-radius: 8px;
background: rgba(255, 255, 255, .08);
display: flex;
align-items: center;
justify-content: center;
}
.suggest-item:first-child .suggest-icon {
background: rgba(96, 165, 250, .18);
}
/* 热搜图标橙色 */
.suggest-icon-hot {
background: rgba(251, 146, 60, .15) !important;
color: #fb923c;
}
.suggest-item b {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
font-weight: 500;
line-height: 1.25;
}
/* 关键词高亮 */
.suggest-item mark {
background: none;
color: #60a5fa;
font-weight: 600;
}
.suggest-item span {
color: var(--muted);
font-size: 12px;
white-space: nowrap;
}
.field {
width: 100%;
min-height: 44px;
border-radius: 999px;
border: 1px solid var(--line);
background: var(--control);
color: var(--text);
padding: 0 12px;
outline: none;
text-shadow: 0 1px 2px rgba(0, 0, 0, .35);
}
textarea.field {
min-height: 74px;
padding: 10px 16px;
border-radius: 24px;
resize: vertical;
}
.field::placeholder { color: rgba(226, 234, 242, .5); }
/* 搜索框单独胶囊圆角 */
#searchInput { border-radius: 999px; }
/* 搜索框聚焦光晕 */
#searchInput:focus {
border-color: var(--focus-line);
box-shadow: 0 0 0 3px var(--focus-glow);
transition: border-color .16s ease, box-shadow .16s ease;
}
/* ══════════════════════════════════════
导航栏:一体长条设计(参考图样式)
══════════════════════════════════════ */
/* .chips 用于 panTabs / seasonTabs 等内部 chip 列表,保持原有宽度行为 */
.chips {
width: 100%;
max-width: 100%;
min-width: 0;
display: flex;
align-items: center;
gap: 0;
overflow-x: auto;
padding: 3px 0 8px;
overscroll-behavior-x: contain;
position: relative;
z-index: 18;
background: transparent;
margin-bottom: -4px;
scrollbar-width: none;
-ms-overflow-style: none;
}
.chips::-webkit-scrollbar { display: none; }
/* 主导航栏 backdrop-filter via CSS class — TV 端可被 html.tv-mode * 覆盖 */
#chips {
backdrop-filter: blur(18px) saturate(1.1);
-webkit-backdrop-filter: blur(18px) saturate(1.1);
}
/* 主导航栏 #chips:宽度由内容决定,不撑满 */
#chips {
width: auto !important;
min-width: unset !important;
max-width: none !important;
display: inline-flex !important;
overflow-x: visible !important;
padding: 0 !important;
margin-bottom: 0 !important;
background: transparent !important;
}
/* chips-wrapper 负责滚动容器 */
#chips-wrapper {
width: 100%;
max-width: 100%;
overflow-x: auto;
overflow-y: visible; /* 允许 chip outline 在纵向溢出 */
scrollbar-width: none;
-ms-overflow-style: none;
position: relative;
z-index: 18;
margin-top: 16px;
margin-bottom: 4px;
/* 上下 padding 足够大,让 outline + outline-offset 有空间显示,不被裁剪 */
padding: 8px 4px 12px;
}
#chips-wrapper::-webkit-scrollbar { display: none; }
#chips-bar {
display: flex;
align-items: center;
width: max-content;
min-width: 100%;
background: transparent;
border: none;
border-radius: 999px;
padding: 3px 4px;
gap: 2px;
backdrop-filter: none;
-webkit-backdrop-filter: none;
box-shadow: none;
/* 允许子 chip 的 outline 焦点环溢出容器,否则会被裁剪 */
overflow: visible;
}
.chip {
flex: 0 0 auto;
min-height: 34px;
padding: 0 14px;
border-radius: 999px;
background: transparent;
border: none;
color: rgba(255,255,255,.58);
font-weight: 600;
font-size: 13px;
text-shadow: none;
backdrop-filter: none;
-webkit-backdrop-filter: none;
position: relative;
/* overflow visible 允许焦点 outline 溢出,手机/TV 都不被容器裁剪 */
overflow: visible;
transition: color .2s ease, background .2s ease;
display: flex;
align-items: center;
gap: 5px;
white-space: nowrap;
}
.chip:not(.active)::before { display: none; }
.chip.active {
background: rgba(255,255,255,.16);
color: rgba(255,255,255,.98);
border: none;
box-shadow:
0 1px 8px rgba(0,0,0,.28),
inset 0 1px 0 rgba(255,255,255,.18),
inset 0 -1px 0 rgba(0,0,0,.12);
font-weight: 760;
}
.chip:hover:not(.active) {
color: rgba(255,255,255,.80);
background: rgba(255,255,255,.06);
}
.chip:active {
transform: scale(0.95);
opacity: .85;
transition: transform .08s ease, opacity .08s ease;
}
/* chip 图标 */
.chip-icon {
width: 15px;
height: 15px;
flex-shrink: 0;
opacity: .75;
}
.chip.active .chip-icon { opacity: 1; }
/* 搜索按钮在最右边 */
.chip-search-btn {
margin-left: auto;
flex-shrink: 0;
}
.section {
width: 100%;
max-width: 100%;
min-width: 0;
margin-top: 24px;
overflow: hidden;
}
#recommendSection {
overflow: visible;
}
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
position: relative;
overflow: visible;
}
.section h3 {
margin: 0;
font-size: 18px;
line-height: 1.2;
}
.section small {
color: var(--muted);
font-size: 12px;
white-space: nowrap;
}
.section-actions {
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
}
.mini-icon-btn {
width: 32px;
height: 32px;
display: grid;
place-items: center;
border-radius: var(--radius);
background: var(--control);
border: 1px solid var(--line);
color: var(--text);
backdrop-filter: blur(12px) saturate(1.05);
}
.mini-icon-btn .icon {
width: 16px;
height: 16px;
}
.stack {
display: block;
gap: 18px;
min-width: 0;
}
.list-block {
width: 100%;
max-width: 100%;
min-width: 0;
overflow: hidden;
margin-top: 18px;
}
.list-block:first-child,
.list-panel:first-child {
margin-top: 18px;
}
.subsection-head {
min-width: 0;
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin-bottom: 0;
}
.subsection-head h4 {
margin: 0;
font-size: 15px;
line-height: 1.2;
}
.subsection-head span {
color: var(--muted);
font-size: 12px;
white-space: nowrap;
}
.rail {
width: 100%;
max-width: 100%;
min-width: 0;
display: flex;
gap: 12px;
overflow-x: auto;
overflow-y: hidden;
overscroll-behavior-x: contain;
scroll-snap-type: x proximity;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
touch-action: pan-x pan-y;
padding: 2px 0 5px;
}
.media-grid {
width: 100%;
max-width: 100%;
min-width: 0;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.infinite-sentinel {
width: 100%;
height: 1px;
pointer-events: none;
overflow-anchor: none;
}
.card {
flex: 0 0 142px;
min-width: 0;
max-width: 172px;
position: relative;
border-radius: var(--radius);
background: var(--panel-soft);
border: 1px solid var(--line);
overflow: hidden;
padding: 0;
text-align: left;
color: var(--text);
scroll-snap-align: start;
contain: layout style;
}
.media-grid .card {
width: 100%;
max-width: none;
flex: none;
}
.poster {
aspect-ratio: 2 / 3;
width: 100%;
object-fit: cover;
background: linear-gradient(110deg,
rgba(255,255,255,.04) 0%,
rgba(255,255,255,.09) 45%,
rgba(255,255,255,.04) 100%);
background-size: 200% 100%;
}
.poster.loaded {
background: transparent;
}
.card > .poster {
width: calc(100% + 2px);
max-width: calc(100% + 2px);
margin: -1px -1px 0;
}
.card-body {
padding: 10px;
background: rgba(18,24,38,.15);
border-top: 1px solid var(--line);
}
.card-title {
font-size: 12px;
font-weight: 760;
line-height: 1.2;
min-height: 15px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-shadow: 0 1px 2px rgba(0, 0, 0, .42);
}
.rating-badge {
position: absolute;
right: 7px;
top: 7px;
min-width: 34px;
height: 24px;
display: grid;
place-items: center;
border-radius: 999px;
background: rgba(6, 9, 12, .48);
border: 1px solid rgba(255, 255, 255, .18);
color: rgba(255, 255, 255, .94);
font-size: 12px;
font-weight: 800;
line-height: 1;
backdrop-filter: blur(8px);
}
.card-title-row {
display: flex;
align-items: center;
gap: 4px;
}
.card-title-row .card-title {
flex: 1;
min-width: 0;
}
.rating-badge-inline {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 28px;
height: 18px;
padding: 0 5px;
border-radius: 999px;
background: rgba(255, 193, 7, .18);
border: 1px solid rgba(255, 193, 7, .36);
color: rgba(255, 220, 80, .95);
font-size: 10px;
font-weight: 800;
line-height: 1;
letter-spacing: 0.3px;
}
/* ── 长按菜单 ── */
.lp-backdrop {
position: fixed;
inset: 0;
z-index: 9998;
background: transparent;
-webkit-tap-highlight-color: transparent;
touch-action: none;
}
.card-long-press-menu {
position: fixed;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 4px;
padding: 6px;
background: rgba(28, 36, 44, .92);
border: 1px solid var(--line-strong);
border-radius: 14px;
backdrop-filter: blur(24px) saturate(1.2);
-webkit-backdrop-filter: blur(24px) saturate(1.2);
box-shadow: 0 12px 40px rgba(0,0,0,.5), 0 2px 8px rgba(0,0,0,.3);
min-width: 148px;
transform-origin: top center;
animation: lp-in .15s cubic-bezier(.34,1.56,.64,1) both;
}
@keyframes lp-in {
from { opacity: 0; transform: scale(.88) translateY(-6px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
.card-long-press-menu .lp-btn {
display: flex;
align-items: center;
gap: 10px;
padding: 11px 14px;
border: none;
border-radius: 10px;
background: transparent;
color: var(--text);
font-size: 14px;
font-weight: 600;
text-align: left;
cursor: pointer;
white-space: nowrap;
width: 100%;
-webkit-tap-highlight-color: transparent;
transition: background .12s;
}
.card-long-press-menu .lp-btn:active {
background: rgba(255,255,255,.1);
}
.card-long-press-menu .lp-btn svg {
flex-shrink: 0;
opacity: .88;
}
.card-long-press-menu .lp-divider {
height: 1px;
background: var(--line);
margin: 2px 6px;
}
/* ── 详情徽标:位于影视名上方 ── */
.detail-logo-wrap {
display: none; /* JS sets to "flex" when logo available */
position: relative;
z-index: 2;
pointer-events: none;
align-items: flex-start;
justify-content: flex-start;
max-width: 60vw;
margin-bottom: 10px;
}
.detail-logo-img {
max-height: 76px;
max-width: 100%;
width: auto;
height: auto;
object-fit: contain;
object-position: left bottom;
filter: drop-shadow(0 4px 16px rgba(0,0,0,.72)) drop-shadow(0 1px 4px rgba(0,0,0,.5));
opacity: 0;
transition: opacity .35s ease .1s;
}
.detail-logo-img.loaded {
opacity: 1;
}
@media (min-width: 600px) {
.detail-logo-wrap { max-width: 50vw; }
.detail-logo-img { max-height: 96px; }
}
.card-meta {
margin-top: 6px;
color: var(--muted);
font-size: 12px;
line-height: 1.3;
text-shadow: 0 1px 2px rgba(0, 0, 0, .38);
}
.card-meta:empty {
display: none;
}
.card.recent-card {
background: rgba(var(--panel-rgb), .26);
}
.card.recent-card > .poster {
width: calc(100% + 2px);
max-width: calc(100% + 2px);
margin: -1px;
}
.recent-card .card-body {
position: absolute;
left: 0;
right: 0;
bottom: 0;
z-index: 2;
padding: 28px 9px 9px;
border-top: 0;
background: linear-gradient(180deg, rgba(0, 0, 0, 0), rgba(5, 7, 9, .72) 46%, rgba(5, 7, 9, .88));
}
.recent-card .card-title,
.recent-card .card-meta,
.recent-badge {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.recent-card .card-title {
min-height: 0;
font-size: 12px;
line-height: 1.2;
}
.recent-card .card-meta {
max-width: 100%;
margin-top: 4px;
color: rgba(238, 244, 250, .74);
font-size: 11px;
line-height: 1.2;
}
.recent-badge {
position: absolute;
top: 7px;
right: 7px;
z-index: 2;
max-width: calc(100% - 14px);
min-height: 22px;
display: inline-flex;
align-items: center;
padding: 0 7px;
border-radius: 999px;
background: rgba(6, 9, 12, .46);
border: 1px solid rgba(255, 255, 255, .16);
color: rgba(255, 255, 255, .9);
font-size: 11px;
font-weight: 780;
line-height: 1;
text-shadow: 0 1px 2px rgba(0, 0, 0, .48);
backdrop-filter: blur(8px);
}
.recent-progress {
position: absolute;
left: 0;
right: 0;
bottom: 0;
z-index: 3;
height: 3px;
background: rgba(255, 255, 255, .2);
}
.recent-progress span {
display: block;
width: var(--recent-progress, 0%);
height: 100%;
background: rgba(255, 255, 255, .82);
}
.card.has-people .card-body {
padding-right: 42px;
}
.card-people {
position: absolute;
right: 10px;
bottom: 10px;
color: rgba(226, 234, 242, .64);
font-size: 11px;
font-weight: 760;
line-height: 1;
text-shadow: 0 1px 2px rgba(0, 0, 0, .46);
pointer-events: none;
}
.empty {
min-height: 92px;
display: grid;
place-items: center;
padding: 14px;
border-radius: var(--radius);
border: 1px dashed rgba(255, 255, 255, .18);
color: var(--muted);
background: rgba(255, 255, 255, .03);
font-size: 13px;
text-align: center;
}
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.metric {
flex: 0 0 210px;
max-width: 260px;
min-height: 82px;
padding: 12px;
border-radius: var(--radius);
background: var(--panel);
border: 1px solid var(--line);
}
.metric b {
display: block;
font-size: 20px;
line-height: 1.1;
}
.metric span {
display: block;
margin-top: 8px;
color: var(--muted);
font-size: 12px;
line-height: 1.35;
}
.sheet {
display: none;
position: fixed;
inset: 0;
z-index: 60;
width: 100%;
max-width: 100%;
min-width: 0;
min-height: var(--fm-web-height);
background: transparent;
backdrop-filter: none;
overflow-y: auto;
overflow-x: hidden;
padding: 0 14px calc(22px + var(--fm-safe-bottom) + env(safe-area-inset-bottom) + var(--keyboard-height));
}
/* Full-screen blurred cover art backdrop for detail sheet */
/* ── Emby-style hero background ──
真实 div#detailHeroBg)替代 ::beforeJS 可直接操控 filter ── */
.sheet::before {
display: none; /* 废弃,改用 #detailHeroBg 真实元素 */
}
.detail-hero-bg {
position: fixed;
inset: 0;
z-index: -2;
background-size: cover;
background-position: center 10%;
filter: brightness(0.55) saturate(1.05);
opacity: 0;
transition: opacity 0.45s cubic-bezier(.25,.8,.30,1),
filter 0.18s ease;
pointer-events: none;
will-change: opacity, filter;
}
#detailSheet.active .detail-hero-bg {
opacity: 1;
}
/* sheet-closing:整体淡出 sheet */
#detailSheet.sheet-closing .detail-hero-bg {
opacity: 0 !important;
}
/* Scrim removed — no darkening overlay, blur only */
.sheet::after { display: none; }
/* Progressive darkening on lower portion — 给文本区一个影院式渐变压暗暗角。
单层连续渐变:从屏幕中上部透明,向下逐渐压暗到底部,
明度自上而下递减,文字区获得稳定的深色底。
(原为多层 backdrop-filter 渐变模糊,已改为渐变压暗,省去昂贵的 GPU 合成层)
想更暗/更浅:整体调 rgba 透明度;想压暗起点更高/更低:调各 color-stop 百分比。 */
.sheet-blur-layer {
display: none;
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
background: linear-gradient(180deg,
transparent 0%,
transparent 26%,
rgba(0, 0, 0, .22) 46%,
rgba(0, 0, 0, .48) 66%,
rgba(0, 0, 0, .72) 85%,
rgba(0, 0, 0, .84) 100%);
}
/* span 占位保留(供沉浸 / TV 等规则引用),压暗已由容器渐变承担 */
.sheet-blur-layer span {
position: absolute;
inset: 0;
display: block;
}
.sheet.active .sheet-blur-layer {
display: block;
}
.sheet.active { display: block; }
/* 双重保险:无论 JS inline style 如何,sheet 没有 active/closing 类时强制隐藏
防止 resize / orientation change 时 sheet 意外漏出到主页面 */
.sheet:not(.active):not(.sheet-closing) {
display: none !important;
visibility: hidden !important;
pointer-events: none !important;
}
/* 关闭淡出:整个 sheet 整体透明度过渡,避免内容和背景不同步 */
.sheet.sheet-closing {
opacity: 0;
pointer-events: none;
transition: opacity 0.22s ease !important;
}
.detail-cover {
display: none !important;
}
/* TV 端保持 detail-cover */
html.tv-mode .detail-cover { display: block; }
/* TV / 大屏详情页:显示海报轮播区域 */
#detailSheet.detail-large .detail-cover {
display: block !important;
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
max-height: 45vh;
border-radius: 12px;
overflow: hidden;
background: rgba(255,255,255,.05);
margin-bottom: 16px;
flex-shrink: 0;
}
.detail-cover.loading::before {
content: "";
position: absolute;
inset: 0;
background: rgba(255,255,255,.06);
}
.detail-cover img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0;
transition: none;
}
.detail-cover img.active {
opacity: 1;
filter: none;
}
.detail-cover.poster-mode {
background: rgba(var(--panel-rgb), .38);
}
.detail-cover.poster-mode::before {
content: "";
position: absolute;
inset: -18px;
background-image: var(--detail-cover-bg, none);
background-size: cover;
background-position: center;
filter: blur(18px) saturate(1.08);
opacity: .42;
transform: none;
}
.detail-cover.poster-mode img {
inset: 0;
width: 100%;
height: 100%;
object-fit: contain;
z-index: 1;
}
.detail-info {
padding: 0 2px 12px;
}
.detail-info h2 {
margin: 0 0 8px;
font-size: clamp(22px, 6vw, 32px);
line-height: 1.12;
}
.detail-info p {
margin: 0;
color: rgba(244, 247, 251, .82);
line-height: 1.5;
font-size: 14px;
}
/* 演员简介字体与影视简介保持一致 */
.person-info p {
margin: 0;
color: rgba(244, 247, 251, .82);
line-height: 1.5;
font-size: 14px;
}
/* ── 可折叠简介 ── */
.collapsible-overview { margin: 0; }
.overview-toggle {
display: flex;
align-items: center;
gap: 6px;
background: none;
border: none;
padding: 0;
cursor: pointer;
color: var(--muted);
font-size: 13px;
font-weight: 600;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
transition: color .15s ease;
}
.overview-toggle:hover { color: var(--text); }
.overview-toggle-icon {
width: 16px;
height: 16px;
fill: none;
stroke: currentColor;
stroke-width: 2.2;
stroke-linecap: round;
stroke-linejoin: round;
transition: transform .22s ease;
flex-shrink: 0;
}
.collapsible-overview.open .overview-toggle-icon {
transform: rotate(180deg);
}
.overview-body {
overflow: hidden;
max-height: 0;
opacity: 0;
transition: max-height .3s cubic-bezier(.4,0,.2,1), opacity .25s ease, margin-top .3s ease;
margin-top: 0;
}
.collapsible-overview.open .overview-body {
max-height: 600px;
opacity: 1;
margin-top: 8px;
}
#detailText {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
cursor: pointer;
}
#detailText.expanded {
display: block;
-webkit-line-clamp: unset;
overflow: visible;
}
.detail-info .actions {
margin-top: 14px;
}
#detailTitleMeta {
font-size: 13px;
}
.detail-meta {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin: 14px 0 14px;
}
.meta-pill {
padding: 5px 8px;
border-radius: 999px;
color: rgba(226, 234, 242, .72);
background: rgba(18, 24, 38, .15);
border: 1px solid rgba(255, 255, 255, .10);
font-size: 12px;
}
/* 语义配色:年份偏蓝,类型偏紫,时长偏青,评分金色 */
.meta-pill.pill-year { color: #7cc4fa; border-color: rgba(124,196,250,.28); background: rgba(124,196,250,.10); }
.meta-pill.pill-genre { color: #e0cfff; border-color: rgba(224,207,255,.32); background: rgba(196,167,247,.14); }
.meta-pill.pill-runtime { color: #67d9c4; border-color: rgba(103,217,196,.25); background: rgba(103,217,196,.09); }
.meta-pill.pill-score { color: #fbbf24; border-color: rgba(251,191,36,.30); background: rgba(251,191,36,.10); }
.detail-block {
margin-top: 18px;
}
.detail-block h3 {
margin: 0 0 10px;
font-size: 17px;
line-height: 1.2;
}
.episode-card {
flex: 0 0 230px;
max-width: 260px;
min-height: 138px;
padding: 11px;
border-radius: var(--radius);
background: rgba(18,24,38,.15);
border: 1px solid var(--line);
color: var(--text);
text-align: left;
scroll-snap-align: start;
}
.episode-card.has-still {
padding: 0;
overflow: hidden;
}
.episode-card.has-still .episode-body {
padding: 10px;
}
.episode-still {
width: calc(100% + 2px);
max-width: calc(100% + 2px);
margin: -1px -1px 0;
aspect-ratio: 16 / 9;
object-fit: cover;
}
.episode-card b,
.person-card b {
display: block;
font-size: 14px;
line-height: 1.25;
}
.episode-card span,
.person-card span {
display: block;
margin-top: 6px;
color: var(--muted);
font-size: 12px;
line-height: 1.35;
}
.episode-card p {
margin: 8px 0 0;
color: rgba(244, 247, 251, .78);
font-size: 12px;
line-height: 1.45;
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
}
.person-card {
flex: 0 0 auto;
width: auto;
max-width: none;
min-width: 0;
display: flex;
align-items: center;
gap: 10px;
border-radius: 999px;
background: rgba(18,24,38,.15);
border: 1px solid var(--line);
color: var(--text);
overflow: hidden;
padding: 6px 14px 6px 6px;
text-align: left;
scroll-snap-align: start;
white-space: nowrap;
}
.person-card img {
width: 44px;
height: 44px;
min-width: 44px;
aspect-ratio: 1 / 1;
object-fit: cover;
border-radius: 50%;
margin: 0;
flex-shrink: 0;
background: rgba(255,255,255,.07);
}
.person-card div {
padding: 0;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.person-card b {
display: block;
font-size: 13px;
font-weight: 760;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 130px;
}
.person-card span {
display: block;
margin-top: 0;
color: var(--muted);
font-size: 11px;
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 130px;
}
.person-card .person-en-name {
font-size: 10px;
color: rgba(244,247,251,.38);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 130px;
}
.sync-panel {
display: grid;
gap: 12px;
padding: 12px;
border-radius: var(--radius);
background: var(--panel);
border: 1px solid var(--line);
}
.hint {
margin: 0;
color: var(--muted);
font-size: 13px;
line-height: 1.55;
}
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
word-break: break-all;
}
.toast {
position: fixed;
left: 50%;
bottom: max(18px, calc(var(--fm-safe-bottom) + env(safe-area-inset-bottom)));
z-index: 90;
transform: translateX(-50%) translateY(24px);
max-width: calc(100vw - 28px);
padding: 10px 12px;
border-radius: var(--radius);
background: rgba(6, 8, 10, .86);
border: 1px solid var(--line);
color: var(--text);
box-shadow: var(--shadow);
opacity: 0;
pointer-events: none;
transition: .18s ease;
font-size: 13px;
}
.toast.show {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
.back-top {
position: fixed;
right: max(14px, env(safe-area-inset-right));
bottom: max(76px, calc(58px + var(--fm-safe-bottom) + env(safe-area-inset-bottom)));
z-index: 55;
width: 44px;
height: 44px;
display: grid;
place-items: center;
border-radius: var(--radius);
background: rgba(34, 43, 51, .68);
border: 1px solid var(--line);
color: var(--text);
box-shadow: 0 12px 32px rgba(0, 0, 0, .24);
backdrop-filter: blur(12px) saturate(1.05);
opacity: 0;
visibility: hidden;
pointer-events: none;
transform: translateY(10px);
transition: opacity .16s ease, transform .16s ease, visibility .16s ease;
}
.back-top,
.back-top.show {
display: none !important;
opacity: 0 !important;
visibility: hidden !important;
pointer-events: none !important;
}
/* back-top 不参与液态玻璃高光层,避免 ::before 溢出产生幽灵点 */
.back-top {
overflow: hidden !important;
}
.back-top::before {
display: none !important;
}
.connection-dock {
position: relative;
z-index: 2000;
width: fit-content;
max-width: 100%;
justify-self: end;
margin-left: auto;
margin: 0;
border-radius: var(--radius);
border: none;
background: transparent;
box-shadow: none;
backdrop-filter: none;
overflow: visible;
}
.connection-toggle {
width: 42px;
height: 42px;
min-height: 42px;
display: grid;
place-items: center;
padding: 0;
color: var(--text);
text-align: center;
position: relative;
}
.connection-toggle::before {
content: '';
position: absolute;
inset: 0;
border-radius: 50%;
pointer-events: none;
background: repeating-radial-gradient(circle, rgba(255, 255, 255, .10) 0px, rgba(255, 255, 255, .10) 1px, transparent 1px, transparent 3px);
animation: connection-vinyl-spin 6s linear infinite;
}
.connection-toggle::after {
content: '';
position: absolute;
width: 200%;
height: 200%;
top: -50%;
left: -50%;
pointer-events: none;
background: conic-gradient(from 0deg, transparent 0%, rgba(255, 255, 255, .28) 8%, transparent 18%);
animation: connection-vinyl-spin 6s linear infinite;
}
@keyframes connection-vinyl-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.connection-title {
font-size: 13px;
font-weight: 800;
white-space: nowrap;
}
.connection-arrow {
color: var(--muted);
font-size: 13px;
transition: transform .16s ease;
}
.connection-dock.open .connection-arrow {
transform: rotate(180deg);
}
.connection-body {
display: none;
position: absolute;
right: 0;
top: calc(100% + 8px);
z-index: 1000;
width: min(calc(var(--fm-web-width, 100vw) - 28px), 420px);
max-width: calc(var(--fm-web-width, 100vw) - 28px);
margin: 0;
padding: 11px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: rgba(34, 43, 51, .72);
box-shadow: 0 14px 36px rgba(0, 0, 0, .24);
backdrop-filter: blur(14px) saturate(1.05);
transform: translateX(var(--connection-shift, 0px));
/* 不再设为定高内部滚动容器:面板随内容自然增高,由整页滚动承载,
这样滑动顺畅、底部输入框不会被键盘挡住(与 embytv 一致) */
}
.connection-body.open {
display: block;
}
.connection-grid {
display: grid;
gap: 8px;
margin-top: 10px;
}
.connection-item {
display: grid;
grid-template-columns: minmax(56px, 74px) minmax(0, 1fr);
gap: 8px;
align-items: baseline;
font-size: 12px;
line-height: 1.45;
}
.connection-item b {
color: var(--muted);
font-weight: 700;
}
.connection-item span {
min-width: 0;
word-break: break-word;
}
/* 刷新进度项:多行展示,左对齐 */
.connection-item.refresh-progress {
align-items: start;
}
.connection-item.refresh-progress span {
white-space: pre-line;
}
/* 状态面板内的分段开关(首页全屏等) */
.connection-item > .detail-style-segmented {
align-self: center;
min-width: 0;
word-break: break-word;
}
.detail-style-segmented {
min-width: 0;
display: inline-grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 2px;
padding: 2px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, .14);
background: rgba(255, 255, 255, .06);
}
.detail-style-option {
min-width: 0;
min-height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 8px;
border-radius: 6px;
border: 1px solid transparent;
color: rgba(244, 247, 251, .7);
font-size: 12px;
font-weight: 760;
line-height: 1;
white-space: nowrap;
}
.detail-style-option.active {
color: var(--text);
background: rgba(184, 226, 255, .2);
border-color: rgba(184, 226, 255, .34);
}
.detail-style-option:focus {
outline: none;
border-color: rgba(184, 226, 255, .55);
}
/* 镜像 relay 配置 */
.relay-mirror-config {
display: grid;
gap: 8px;
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid rgba(255, 255, 255, .12);
}
.relay-mirror-config label {
display: grid;
gap: 5px;
color: var(--muted);
font-size: 12px;
font-weight: 700;
}
.relay-mirror-config .field {
width: 100%;
min-height: 36px;
padding: 8px 10px;
font-size: 12px;
}
.relay-mirror-config textarea.field {
min-height: 68px;
}
.relay-mirror-progress {
min-height: 58px;
padding: 8px 10px;
border: 1px solid rgba(255, 255, 255, .12);
border-radius: var(--radius);
color: var(--text);
background: rgba(0, 0, 0, .16);
font-size: 12px;
line-height: 1.45;
white-space: pre-line;
word-break: break-word;
}
.connection-body .actions {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(96px, 1fr));
gap: 8px;
}
.connection-body .btn {
min-width: 0;
padding: 0 10px;
font-size: 12px;
white-space: nowrap;
}
.pan-config {
display: grid;
gap: 8px;
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid rgba(255, 255, 255, .12);
}
.pan-config label {
display: grid;
gap: 5px;
color: var(--muted);
font-size: 12px;
line-height: 1.35;
}
.pan-config .field {
min-height: 38px;
font-size: 12px;
}
.pan-disk-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(70px, 1fr));
gap: 6px;
}
.pan-disk-grid label {
min-width: 0;
min-height: 32px;
display: flex;
align-items: center;
gap: 6px;
padding: 0 8px;
border-radius: var(--radius);
border: 1px solid rgba(255, 255, 255, .14);
background: rgba(255, 255, 255, .04);
color: var(--text);
font-size: 12px;
font-weight: 720;
}
.pan-disk-grid input {
width: 14px;
height: 14px;
accent-color: rgba(244, 247, 251, .82);
}
.pan-disk-grid label:focus-within {
background: rgba(70, 85, 96, .72);
border-color: var(--focus-line);
color: var(--text);
}
.panel-editing {
background: rgba(24, 36, 46, .88) !important;
border-color: rgba(184, 226, 255, .9) !important;
}
.pan-search-block {
display: none;
margin-top: 18px;
}
.pan-search-block.active {
display: block;
}
.pan-result-list {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 9px;
max-height: min(calc(var(--fm-web-height) * .52), 560px);
min-height: 92px;
overflow-y: auto;
overscroll-behavior-y: contain;
padding-right: 2px;
scrollbar-width: none;
}
.pan-result-list::-webkit-scrollbar { display: none; }
.pan-tabs {
margin: 4px 0 12px;
padding: 6px 4px;
}
.pan-tabs:empty { display: none; }
.pan-tabs .chip {
min-height: 30px;
padding: 0 10px;
font-size: 12px;
position: relative;
z-index: 1;
overflow: visible; /* 允许焦点环溢出 */
}
.pan-tabs .chip.active {
z-index: 2; /* 浮在相邻 chip 上方,防止边框被遮 */
}
.pan-result-item {
width: 100%;
min-width: 0;
display: grid;
gap: 7px;
padding: 11px;
border-radius: var(--radius);
border: 1px solid rgba(255,255,255,.12);
background: rgba(10,14,22,0.62) !important;
color: var(--text);
text-align: left;
backdrop-filter: blur(14px) saturate(1.05);
-webkit-backdrop-filter: blur(14px) saturate(1.05);
}
.pan-result-item.is-bad {
opacity: .58;
}
.pan-result-title {
display: flex;
min-width: 0;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 800;
line-height: 1.3;
}
.pan-result-title span:first-child {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pan-result-meta,
.pan-result-url {
color: var(--muted);
font-size: 12px;
line-height: 1.35;
}
.pan-result-meta {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.pan-tag.quality {
display: inline-flex;
align-items: center;
line-height: 1;
padding: 2px 6px;
border-radius: 4px;
font-size: 10px;
font-weight: 700;
color: #e8c97a;
border: 1px solid rgba(232,201,122,.3);
background: rgba(232,201,122,.08);
}
.pan-tag.quality.dv { color: #b8a4f8; border-color: rgba(184,164,248,.35); background: rgba(184,164,248,.12); letter-spacing: .02em; }
.pan-tag.quality.hdrp { color: #f4a8d0; border-color: rgba(244,168,208,.3); background: rgba(244,168,208,.1); }
.pan-tag.quality.fhd { color: #7ec8e3; border-color: rgba(126,200,227,.3); background: rgba(126,200,227,.1); }
.pan-tag.quality.hd { color: #82c98a; border-color: rgba(130,201,138,.28); background: rgba(130,201,138,.09); }
.pan-tag.quality.sd { color: rgba(240,237,232,.4); border-color: rgba(255,255,255,.1); background: rgba(255,255,255,.04); }
.pan-result-url {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pan-health {
flex: 0 0 auto;
display: inline-block;
width: .55rem;
height: .55rem;
border-radius: 999px;
background: #94a3b8;
box-shadow: 0 0 0 1px rgba(255, 255, 255, .18);
}
.pan-health.ok { background: #22c55e; }
.pan-health.bad { background: #ef4444; }
.pan-health.locked { background: #f59e0b; }
.pan-health.pending {
background: #60a5fa;
animation: pulse-dot 1.1s ease-in-out infinite;
}
.pan-health.uncertain,
.pan-health.unsupported { background: #94a3b8; }
@keyframes pulse-dot {
0%, 100% { opacity: .45; transform: scale(.92); }
50% { opacity: 1; transform: scale(1.08); }
}
.dot {
width: 9px;
height: 9px;
border-radius: 999px;
background: var(--muted);
}
/* 状态按钮里的 dot — 黑胶唱片中心标签,随状态变色并带发光 */
.connection-toggle .dot {
width: 9px;
height: 9px;
position: relative;
z-index: 1;
}
.connection-toggle .dot.ok {
background: #4ade80;
box-shadow: 0 0 4px rgba(74, 222, 128, .6);
}
.connection-toggle .dot.warn {
background: #fbbf24;
box-shadow: 0 0 4px rgba(251, 191, 36, .6);
}
.connection-toggle .dot.bad {
background: #fb7185;
box-shadow: 0 0 4px rgba(251, 113, 133, .6);
}
.dot.ok { background: var(--ok); }
.dot.warn { background: #ffcc4d; }
.dot.bad { background: var(--danger); }
.image-viewer {
display: none;
position: fixed;
inset: 0;
z-index: 100;
min-height: var(--fm-web-height);
background: var(--panel-strong);
backdrop-filter: blur(8px) saturate(1.05);
padding: max(14px, var(--safe-t)) 14px calc(18px + var(--fm-safe-bottom) + env(safe-area-inset-bottom));
}
.image-viewer.episode-mode {
background: transparent;
backdrop-filter: none;
}
.image-viewer.active {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: 14px;
}
.image-content {
min-width: 0;
min-height: 0;
display: grid;
place-items: center;
overflow-y: auto;
scrollbar-width: none;
}
.image-content::-webkit-scrollbar { display: none; }
.image-content img {
align-self: center;
justify-self: center;
width: 100%;
height: 100%;
max-height: calc(var(--fm-web-height) - 86px - var(--fm-safe-bottom));
object-fit: contain;
background: transparent;
}
.episode-view {
width: min(860px, 100%);
min-width: 0;
align-self: start;
display: grid;
gap: 12px;
padding: 12px;
padding-bottom: 20px;
border-radius: var(--radius);
border: 1px solid var(--line);
background: var(--panel);
box-shadow: var(--shadow);
}
.episode-view img {
width: 100%;
height: auto;
max-height: min(48vh, 460px);
aspect-ratio: 16 / 9;
object-fit: cover;
border-radius: var(--radius);
border: 1px solid var(--line);
background: rgba(0, 0, 0, .18);
}
.episode-view h3 {
margin: 0;
font-size: 19px;
line-height: 1.3;
}
.episode-view span {
color: var(--muted);
font-size: 13px;
line-height: 1.4;
}
.episode-view p {
margin: 0;
color: rgba(244, 247, 251, .9);
font-size: 15px;
line-height: 1.65;
white-space: pre-wrap;
}
.focusable:focus,
button:focus,
input:focus,
textarea:focus {
outline: none;
border-color: var(--focus-line);
box-shadow: none;
z-index: 5;
transition: border-color .06s ease, background-color .06s ease, opacity .06s ease;
}
/* TV 模式焦点环更醒目,大屏远距可见 */
html.tv-mode .focusable:focus,
html.tv-mode button:focus {
box-shadow: 0 0 0 3px rgba(255,255,255,.95), 0 0 0 6px rgba(60,140,255,.85) !important;
border-color: rgba(255, 255, 255, .98) !important;
outline: none !important;
}
html.tv-mode .chip:focus,
html.tv-mode .btn:focus,
html.tv-mode .icon-btn:focus,
html.tv-mode .mini-icon-btn:focus {
box-shadow: 0 0 0 2px rgba(255,255,255,.90), 0 0 0 5px rgba(60,140,255,.80) !important;
border-color: rgba(255, 255, 255, .95) !important;
}
/* ── TV 性能优化:禁用动画、简化渲染,消除1-2秒操作延迟 ── */
/* 注意:焦点呼吸 glow 动画豁免,不在禁用范围内 */
html.tv-mode *:not(:focus),
html.tv-mode *:not(:focus)::before,
html.tv-mode *:not(:focus)::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.08s !important;
will-change: auto !important;
}
/* 焦点元素本身:恢复呼吸 glow 动画 */
html.tv-mode :focus {
animation-duration: 1.5s !important;
animation-iteration-count: infinite !important;
transition-duration: 0.08s !important;
}
/* TV 彻底禁用所有 backdrop-filter(最大性能杀手)*/
html.tv-mode * {
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
}
/* TV 禁用所有伪元素动画(包括呼吸光晕)*/
html.tv-mode *::before,
html.tv-mode *::after {
animation: none !important;
transition: none !important;
}
/* TV 模式:关闭高斯模糊和粒子,但保留背景轮播图 */
html.tv-mode .sheet-blur-layer {
display: none !important;
}
/* hero-bg TV 端 brightness 也由 JS 控制,初始正常亮度 */
html.tv-mode .detail-hero-bg {
filter: brightness(0.55) saturate(1.05) !important;
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
}
/* TV 模式 card 不需要 will-change transform,避免过多合成层 */
html.tv-mode .card {
will-change: auto !important;
}
/* TV 模式 chip 栏:导航栏适配 */
html.tv-mode #chips-wrapper nav,
html.tv-mode #chips {
min-width: unset !important;
width: 100% !important;
gap: 4px !important;
padding-bottom: 12px !important;
}
.focusable:not(.back-top):focus,
button:not(.back-top):focus,
input:focus,
textarea:focus {
position: relative;
}
.back-top:focus {
position: fixed;
z-index: 55;
}
/* 手机端返回顶部按钮更大更易点 */
@media (max-width: 719px) {
.back-top {
width: 48px;
height: 48px;
bottom: calc(20px + var(--fm-safe-bottom, 0px) + env(safe-area-inset-bottom, 0px));
right: 16px;
}
}
.card:focus,
.episode-card:focus,
/* 演职员表卡片:手机/平板不显示任何选中状态 */
.person-card:focus {
outline: none;
}
.card:focus img,
.episode-card:focus img {
filter: brightness(1.08) saturate(1.06);
}
/* 手机端 touch active 效果 */
.card:active {
transform: scale(0.97);
opacity: .88;
transition: transform .08s ease, opacity .08s ease;
}
.pan-result-item:focus {
outline: 2px solid rgba(168, 218, 255, .86);
outline-offset: -2px;
border-color: rgba(188, 228, 255, .9);
background: rgba(82, 100, 112, .86);
box-shadow: none;
}
.pan-result-item:focus .pan-result-title {
color: #fff;
}
/* chip 焦点背景只在键盘/遥控器导航时显示(focus-visible),
手机触摸滑动后不会残留灰色背景 */
.chip:focus-visible,
.btn:focus,
.mini-icon-btn:focus {
transform: translateY(-1px);
background: rgba(70, 85, 96, .72);
border-color: var(--focus-line);
}
.connection-toggle:focus {
transform: translateY(-1px);
border-color: var(--focus-line);
}
/* 非 focus-visible(触摸)时 chip 不显示焦点背景 */
.chip:focus:not(:focus-visible) {
background: transparent;
border-color: transparent;
transform: none;
outline: none;
}
.chip.active:focus-visible {
background: rgba(82, 98, 108, .78);
border-color: rgba(184, 226, 255, .8);
}
.chip.active:focus:not(:focus-visible) {
background: rgba(255,255,255,.14);
border-color: transparent;
transform: none;
outline: none;
}
input.focusable:focus,
textarea.focusable:focus,
.field:focus {
transform: none;
background: rgba(28, 38, 46, .72);
border-color: var(--focus-line);
box-shadow: 0 0 0 2px rgba(116,184,255,.25);
animation: none !important;
}
.suggest-item:focus {
background: rgba(60, 76, 90, .82);
border-color: var(--focus-line);
box-shadow: none;
}
.image-content:focus {
border-radius: var(--radius);
box-shadow: none;
}
.focusable:active,
button:active {
transform: translateY(1px);
}
@media (min-width: 720px) {
.app,
.sheet {
padding-left: 34px;
padding-right: 34px;
}
.grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.rail {
gap: 14px;
}
.card {
flex-basis: 176px;
}
.media-grid {
gap: 14px;
grid-template-columns: repeat(auto-fill, minmax(132px, 1fr));
}
}
@media (min-width: 1180px) {
.app,
.sheet {
padding-left: 56px;
padding-right: 56px;
}
.grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.media-grid {
grid-template-columns: repeat(auto-fill, minmax(156px, 1fr));
}
}
/* Hero bg cross-fade during cover switch */
.sheet.hero-bg-swap .detail-hero-bg {
opacity: 0.08 !important;
transition: opacity 0.12s ease !important;
}
</style><style id="modern-ui-overrides">
:root{
--safe-t: max(var(--fm-safe-top,0px),env(safe-area-inset-top,0px));
--bg: transparent;
--bg2: transparent;
--panel:rgba(18,24,38,.52);
--panel-soft:rgba(22,30,48,.36);
--line:rgba(255,255,255,.08);
--line-strong:rgba(255,255,255,.18);
--text:#f5f7fb;
--muted:#98a5bc;
--accent:#57a6ff;
--accent2:#8fbcff;
--focus:#74b8ff;
--shadow:0 18px 60px rgba(0,0,0,.45);
--radius:14px;
}
html,body{
background: transparent !important;
}
.suggest-item,.search-item{
background: rgba(255,255,255,0.04) !important;
border: 1px solid rgba(255,255,255,0.07) !important;
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
}
/* Aurora背景容器GPU提升 */
/* 解除头部的隔离限制,并提高整个头部的层级 */
.section-head {
position: relative;
z-index: 999 !important;
}
/* 减淡搜索总层透明度 */
.search-overlay,
.search-mask,
.search-result-wrap{
background: rgba(22,28,38,0.58) !important; /* 从0.72减淡约20% */
}
.suggest-panel,
.search-overlay::after,
.search-result-wrap::after{
background: rgba(18,22,30,0.22); /* 从0.28减淡约20% */
}
/* ── 详情页布局:内容下移,类 Emby 效果 ── */
#detailSheet.sheet {
display: none;
flex-direction: column;
justify-content: flex-start;
padding-top: 0 !important;
}
#detailSheet.sheet.active {
display: flex;
}
/* TV / 大屏详情页:侧边栏布局,左边封面右边信息 */
#detailSheet.detail-large.sheet {
display: flex !important;
flex-direction: row !important;
flex-wrap: wrap;
align-items: flex-start;
padding: max(16px, var(--safe-t)) 40px 40px !important;
gap: 32px;
overflow-y: auto;
}
#detailSheet.detail-large .detail-spacer {
display: none !important;
}
/* 返回按钮:小尺寸,不撑满全宽 */
#detailSheet.detail-large #closeDetailBtn {
width: auto !important;
flex: 0 0 auto !important;
}
/* 大屏/TV 模式:显示详情大图海报(由上方 #detailSheet.detail-large .detail-cover 规则定义 16:9 海报框)。
原先这里强制 display:none 只用全屏背景,导致 TV 端看不到大图海报,现已恢复显示。 */
/* info 占满整行 */
#detailSheet.detail-large .detail-info {
flex: 0 0 100%;
width: 100%;
min-width: 0;
padding: 0 0 16px;
}
/* actions 和 detail-block 都换行,占满整行 */
#detailSheet.detail-large .actions,
#detailSheet.detail-large .detail-block {
flex: 0 0 100%;
width: 100%;
}
#detailSheet.detail-large ~ * { flex-basis: 100%; }
/* ══════════════════════════════════════════════════════
TV / 大屏沉浸式详情海报(仅 #detailSheet.detail-large 生效)
手机端不会进入 detail-large,故移动端完全不受影响。
复用现有 #detailImage 与 JS 已设置的 --detail-cover-bg / data-cover-mode。
══════════════════════════════════════════════════════ */
#detailSheet.detail-large {
background: #06111b !important;
padding: clamp(46px, 7vh, 82px) clamp(42px, 5.4vw, 80px) calc(34px + var(--fm-safe-bottom, 0px)) !important;
}
/* 大屏下用 ::before/::after 渐变层承载背景,隐藏全屏 hero 元素与底部模糊层,
让左侧文字区是纯暗底、右侧才是海报 */
#detailSheet.detail-large .detail-hero-bg,
#detailSheet.detail-large .sheet-blur-layer {
display: none !important;
}
#detailSheet.detail-large::before,
#detailSheet.detail-large::after {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
z-index: 0;
}
#detailSheet.detail-large::before {
background-image:
linear-gradient(90deg, rgba(4,12,19,.98) 0%, rgba(4,12,19,.88) 30%, rgba(4,12,19,.42) 58%, rgba(4,12,19,.54) 100%),
linear-gradient(180deg, rgba(4,12,19,.08) 0%, rgba(4,12,19,.66) 72%, #06111b 100%),
var(--detail-cover-bg, none);
background-size: cover;
background-position: center, center, right top;
opacity: .98;
}
#detailSheet.detail-large::after {
background:
linear-gradient(90deg, rgba(6,17,27,.18) 0%, rgba(6,17,27,0) 46%, rgba(6,17,27,.3) 100%),
linear-gradient(180deg, transparent 0%, rgba(6,17,27,.78) 56%, #06111b 100%);
}
#detailSheet.detail-large > * { position: relative; z-index: 2; }
/* 右侧大图海报:固定占右侧、左缘遮罩渐隐融进背景,无边框无圆角 */
#detailSheet.detail-large .detail-cover {
display: block !important;
position: fixed !important;
top: 0;
right: 0;
left: auto;
z-index: 1;
width: min(68vw, 940px);
max-width: none;
height: min(66vh, 620px);
min-height: 360px;
max-height: none;
margin: 0;
aspect-ratio: auto;
border-radius: 0;
overflow: hidden;
pointer-events: none;
background: transparent;
}
#detailSheet.detail-large .detail-cover[data-cover-mode="landscape"] {
-webkit-mask-image: linear-gradient(90deg, transparent 0%, rgba(0,0,0,.28) 3%, rgba(0,0,0,.76) 7%, #000 12%, #000 100%);
mask-image: linear-gradient(90deg, transparent 0%, rgba(0,0,0,.28) 3%, rgba(0,0,0,.76) 7%, #000 12%, #000 100%);
-webkit-mask-repeat: no-repeat; mask-repeat: no-repeat;
-webkit-mask-size: 100% 100%; mask-size: 100% 100%;
}
#detailSheet.detail-large .detail-cover::before {
display: none !important; /* 关掉 poster-mode 自带的小模糊底,统一用下面的渐变 */
}
#detailSheet.detail-large .detail-cover::after {
content: "";
position: absolute;
inset: 0;
z-index: 2;
background:
linear-gradient(90deg, #06111b 0%, rgba(6,17,27,.78) 5%, rgba(6,17,27,.34) 13%, rgba(6,17,27,.1) 24%, rgba(6,17,27,.1) 100%),
linear-gradient(180deg, rgba(6,17,27,.1) 0%, rgba(6,17,27,.06) 56%, #06111b 100%);
}
#detailSheet.detail-large .detail-cover img {
position: absolute; inset: 0; width: 100%; height: 100%;
object-fit: cover !important;
object-position: center top;
filter: saturate(.92) contrast(.98) brightness(.55);
}
#detailSheet.detail-large .detail-cover.poster-mode img {
object-position: center 18%;
}
/* 左侧内容限宽,避让右侧海报,TV 端标题放大 */
#detailSheet.detail-large .detail-info,
#detailSheet.detail-large .detail-block {
max-width: min(760px, 54vw) !important;
}
/* 网页横版:演职员表 / 相关推荐 / 同主创作品 / 季度 等卡片横排放宽到整行,
不再被 54vw 限制截断(.rail 本身可横向滚动,放宽后一排能显示更多卡片)。
仅网页(非 TV)生效;描述正文 #detailText 仍保持限宽避让右侧海报;TV 端不受影响。 */
html:not(.tv-mode) #detailSheet.detail-large #castBlock,
html:not(.tv-mode) #detailSheet.detail-large #recommendBlock,
html:not(.tv-mode) #detailSheet.detail-large #personWorkBlock,
html:not(.tv-mode) #detailSheet.detail-large #seasonBlock {
max-width: none !important;
}
#detailSheet.detail-large .detail-info p#detailText {
max-width: min(620px, 42vw);
}
#detailSheet.detail-large .detail-info h2 {
font-size: clamp(30px, 4.4vw, 56px);
line-height: 1.06;
text-shadow: 0 3px 16px rgba(0,0,0,.48);
}
/* ══════════════════════════════════════════════════════
TV 端详情页:与 embytv 版本对齐(仅 html.tv-mode + detail-large 生效,
手机端不进入 detail-large,桌面端无 tv-mode,均完全不受影响)
══════════════════════════════════════════════════════ */
/* 1) 加大四周内边距,留出电视过扫描(overscan)安全边距:内容不贴边框,徽标加载后也不会顶到屏幕上沿 */
html.tv-mode #detailSheet.detail-large.sheet {
padding: clamp(72px, 9.5vh, 110px) clamp(56px, 6vw, 100px) calc(40px + var(--fm-safe-bottom, 0px)) !important;
}
/* 2) 顶部留白已由容器 padding 提供,detail-info 不再额外下压,避免整体偏低;
logo-wrap 本就是 position:relative 在文档流内,紧贴顶部留白下方,不会被裁切 */
html.tv-mode #detailSheet.detail-large .detail-info {
padding-top: 0;
}
/* 3) 信息标签(年份/类型/时长/状态)放大并保持胶囊型,与 embytv 版本一致;
仅改尺寸与圆角,保留本版语义配色(蓝/紫/青/金) */
html.tv-mode #detailSheet.detail-large .meta-pill {
min-height: 34px;
display: inline-flex;
align-items: center;
border-radius: 999px;
padding: 0 16px;
font-size: 16px;
}
/* 返回按钮:与其他操作按钮统一样式,位于 actions 行 */
#closeDetailBtn {
position: relative;
top: auto;
align-self: auto;
z-index: auto;
flex-shrink: 0;
margin: 0;
}
/* ══════════════════════════════════════════════════════
详情页沉浸全屏 — Toolbar 隐藏后背景图延伸到状态栏
══════════════════════════════════════════════════════ */
/* 沉浸模式:detail-hero-bg 已是 inset:0,天然覆盖全屏 */
/* 非沉浸模式(浏览器预览 / Toolbar 可见):正常间距 */
html.detail-immersive .detail-spacer {
flex: 1 0 38vh;
min-height: 38vh;
}
/* 确保 sheet 在沉浸模式下从屏幕最顶端开始 */
html.detail-immersive #detailSheet.sheet {
top: 0 !important;
padding-top: 0 !important;
}
/* detail-spacer 在沉浸模式下略微缩小,给内容更多空间 */
html.detail-immersive #detailSheet.sheet {
padding-bottom: calc(32px + var(--fm-safe-bottom, 20px) + env(safe-area-inset-bottom, 0px)) !important;
}
/* 背景图片在沉浸模式下覆盖底部导航栏区域 */
html.detail-immersive .detail-hero-bg {
bottom: 0 !important;
}
/* Transparent spacer that fills upper half so content sits in lower half */
.detail-spacer {
flex: 1 0 42vh;
min-height: 42vh;
pointer-events: none;
position: relative;
}
/* ── 轮播图随滚动渐变高斯模糊 ── */
/* detailSheet hero bg 已改用 #detailHeroBg 真实元素,见 .detail-hero-bg */
/* sheet-blur-layer 也跟着模糊,加深沉浸感 */
#detailSheet .sheet-blur-layer span {
transition: backdrop-filter 0.38s ease, -webkit-backdrop-filter 0.38s ease, opacity 0.38s ease;
}
</style><script>
window.TMDB_API_KEY = "d7040155454e7fdf547c4d889ebbcca7";
</script><style id="ios26-liquid-glass-fix">
/* ── 柔光新拟态按钮变量 ── */
:root {
--lg-base: linear-gradient(145deg, rgba(255,255,255,.07), rgba(255,255,255,.02));
--lg-border: rgba(255,255,255,.09);
--lg-border-inner: rgba(255,255,255,.22);
--lg-blur: blur(16px) saturate(140%) brightness(1.03);
--lg-blur-heavy: blur(22px) saturate(160%) brightness(1.05);
--lg-shadow: 2px 2px 6px rgba(0,0,0,.28), inset 0 1px 0 rgba(255,255,255,.04);
--lg-shadow-raised: 4px 4px 10px rgba(0,0,0,.32), inset 0 1px 0 rgba(255,255,255,.05);
--lg-radius-btn: 999px;
--lg-radius-chip: 999px;
--lg-transition: all .18s cubic-bezier(.25,.8,.25,1);
}
.app,
.section,
.search,
.connection-dock,
.sync-panel,
.metric {
background:transparent !important;
box-shadow:none !important;
}
.app{
position:relative;
z-index:1;
}
/* ── Liquid Glass 按钮基础层 ── */
.btn,
.icon-btn,
.mini-icon-btn,
.connection-toggle,
.back-top {
position: relative;
/* overflow visible:允许 outline 焦点环在父容器外显示 */
overflow: visible;
border-radius: var(--lg-radius-btn) !important;
background: var(--lg-base) !important;
border: 1px solid var(--lg-border) !important;
backdrop-filter: var(--lg-blur) !important;
-webkit-backdrop-filter: var(--lg-blur) !important;
box-shadow: var(--lg-shadow) !important;
transition: var(--lg-transition) !important;
isolation: isolate;
}
/* 状态按钮单独圆形 */
.connection-toggle {
border-radius: 999px !important;
overflow: hidden !important;
background: transparent !important;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, .18),
inset 0 -2px 4px rgba(0, 0, 0, .2) !important;
}
/* chip 导航栏:新一体长条设计,透明底 + active 高光 */
.chip {
position: relative;
/* overflow visible:允许 outline 焦点环溢出,不被 #chips-bar 裁剪 */
overflow: visible;
border-radius: 999px !important;
background: transparent !important;
border: none !important;
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
box-shadow: none !important;
transition: color .18s ease, background .18s ease !important;
isolation: isolate;
}
/* ── chip.active 呼吸光晕(已合并到下方主规则)── */
/* Hover — 柔光提亮 */
.btn:hover,
.icon-btn:hover,
.mini-icon-btn:hover,
.back-top:hover {
background: linear-gradient(145deg, rgba(255,255,255,.12), rgba(255,255,255,.03)) !important;
border-color: rgba(255,255,255,.14) !important;
}
.connection-toggle:hover {
border-color: rgba(255,255,255,.14) !important;
}
/* Focus */
.btn:focus,
.icon-btn:focus,
.mini-icon-btn:focus,
.chip:focus,
.back-top:focus {
background: linear-gradient(145deg, rgba(255,255,255,.13), rgba(255,255,255,.03)) !important;
border-color: rgba(140,200,255,.45) !important;
outline: none !important;
}
.connection-toggle:focus {
border-color: rgba(140,200,255,.45) !important;
outline: none !important;
}
.btn:active,
.icon-btn:active,
.mini-icon-btn:active,
.chip:active,
.back-top:active {
background: linear-gradient(145deg, rgba(255,255,255,.03), rgba(255,255,255,.01)) !important;
box-shadow: inset 4px 4px 10px rgba(0,0,0,.4), inset -2px -2px 6px rgba(255,255,255,.03) !important;
transform: scale(.99) !important;
transition: all .08s ease !important;
}
.connection-toggle:active {
transform: scale(.99) !important;
transition: all .08s ease !important;
}
/* 主要/选中态 — 柔光蓝玻璃 */
.btn.primary,
.btn.blue {
background: linear-gradient(145deg, rgba(120,180,255,.30), rgba(120,180,255,.10)) !important;
border-color: rgba(160,205,255,.35) !important;
box-shadow: none !important;
}
/* TV 端才启用蓝光外发光 */
html.tv-mode .btn.primary,
html.tv-mode .btn.blue {
box-shadow: 6px 6px 16px rgba(40,90,200,.35), -3px -3px 10px rgba(255,255,255,.05), inset 0 1px 0 rgba(255,255,255,.18) !important;
}
.btn.primary:hover,
.btn.blue:hover {
background: linear-gradient(145deg, rgba(120,180,255,.38), rgba(120,180,255,.14)) !important;
border-color: rgba(160,205,255,.45) !important;
}
/* chip 选中态 — 扁平背景 + 呼吸光晕 */
.chip.active {
background: rgba(255,255,255,.14) !important;
border-color: rgba(255,255,255,.36) !important;
box-shadow: none !important;
color: #fff !important;
position: relative !important;
overflow: hidden !important;
}
.chip.active::before {
content: "" !important;
display: block !important;
position: absolute !important;
inset: 0 !important;
border-radius: inherit !important;
background: radial-gradient(ellipse at 50% 120%,
rgba(140,200,255,.70) 0%,
rgba(160,110,255,.45) 35%,
transparent 68%
) !important;
pointer-events: none !important;
z-index: 0 !important;
animation: chip-inner-breathe 2s ease-in-out infinite !important;
animation-delay: -1s !important;
}
@keyframes chip-inner-breathe {
0% { opacity: .62; transform: scaleY(.94); }
50% { opacity: 1; transform: scaleY(1); }
100% { opacity: .62; transform: scaleY(.94); }
}
/* 文字在光晕层上方 */
.chip.active > * {
position: relative !important;
z-index: 1 !important;
}
.chip.active:hover {
background: rgba(255,255,255,.20) !important;
border-color: rgba(255,255,255,.48) !important;
}
/* danger 按钮 */
.btn.danger {
background: linear-gradient(145deg, rgba(255,120,120,.20), rgba(255,80,80,.06)) !important;
border-color: rgba(255,160,160,.30) !important;
box-shadow: 6px 6px 14px rgba(120,30,30,.30), -3px -3px 10px rgba(255,255,255,.04), inset 0 1px 0 rgba(255,255,255,.10) !important;
}
/* ── 焦点效果(静态,无呼吸动画)── */
/* 触摸不显示chip焦点背景,只有键盘/遥控器(focus-visible)才显示 */
.chip:focus:not(:focus-visible),
.chip.active:focus:not(:focus-visible) {
outline: none !important;
border-color: transparent !important;
transform: none !important;
background: transparent !important;
box-shadow: none !important;
}
.chip.active:focus:not(:focus-visible) {
background: rgba(255,255,255,.14) !important;
}
.chip:focus-visible {
outline: none !important;
border-color: rgba(255,255,255,.5) !important;
transform: none !important;
background: rgba(70,85,96,.72) !important;
}
.chip.active:focus-visible {
background: rgba(82,98,108,.82) !important;
}
html.tv-mode .chip:focus {
background: rgba(255,255,255,.18) !important;
color: #fff !important;
transform: none !important;
border-color: rgba(255,255,255,.5) !important;
}
html.tv-mode .focusable:focus,
html.tv-mode button:focus,
html.tv-mode input:focus,
html.tv-mode .btn:focus {
outline: none !important;
border-color: rgba(255,255,255,.3) !important;
}
html.tv-mode .card:focus:not(.person-card) {
outline: none !important;
border-color: rgba(255,255,255,.2) !important;
transform: translateY(-4px) scale(1.04) !important;
}
html.tv-mode .person-card:focus {
outline: none !important;
border-color: rgba(255,255,255,.2) !important;
transform: translateY(-4px) scale(1.04) !important;
}
@media (hover:none),(pointer:coarse) {
button:focus:not(.chip):not(.field):not(.person-card),
.focusable:focus:not(.chip):not(.field):not(.person-card),
.btn:focus:not(.person-card) {
outline: none !important;
border-color: rgba(255,255,255,.3) !important;
}
}
.pan-result-item:focus {
outline: none !important;
border-color: transparent !important;
background: rgba(82,100,112,.86) !important;
}
/* ── 搜索结果激活时:虚化主页其他内容 ── */
html.search-active #chips-wrapper,
html.search-active #chips,
html.search-active #recommendSection,
html.search-active #listStack,
html.search-active .back-top {
filter: blur(6px) brightness(0.5);
pointer-events: none;
user-select: none;
transition: filter .28s ease, opacity .28s ease;
}
/* 聚焦搜索框时:主页内容高斯模糊 + 禁止滚动 */
html.search-focused #chips-wrapper,
html.search-focused #chips,
html.search-focused #recommendSection,
html.search-focused #listStack,
html.search-focused #searchSection,
html.search-focused .back-top {
filter: blur(8px) brightness(0.45);
pointer-events: none;
user-select: none;
transition: filter .25s ease;
}
html.search-focused body {
overflow: hidden;
touch-action: none;
}
/* 搜索框聚焦时:全屏蒙层,吞掉对背景内容的点击/滑动,
点击该蒙层会退出搜索聚焦态(document click 逻辑) */
#searchFocusMask {
display: none;
position: fixed;
inset: 0;
z-index: 90;
background: transparent;
touch-action: none;
}
html.search-focused #searchFocusMask {
display: block;
}
/* 状态面板打开时:全屏蒙层,吞掉对背景内容的点击/滑动,
防止点击穿透到主页卡片等元素(同时点击蒙层会关闭状态面板,逻辑见 document click */
#connectionFocusMask {
display: none;
position: fixed;
inset: 0;
z-index: 999;
background: transparent;
touch-action: none;
}
html.connection-open #connectionFocusMask {
display: block;
}
/* 搜索栏本身要保持在蒙层之上,可继续输入/操作 */
html.search-focused .search-bar-row {
z-index: 96;
}
html.search-active #searchSection {
position: relative;
z-index: 10;
padding-bottom: 65px;
}
/* sentinel 已通过 JS display:none 处理,这里加保险 */
html.search-active #infiniteSentinel {
display: none !important;
}
/* ── 搜索列表样式 ── */
#searchRail {
display: flex !important;
flex-direction: column !important;
gap: 10px !important;
overflow: visible !important;
padding: 2px 0 12px !important;
scroll-snap-type: none !important;
}
.search-list-item {
display: flex;
align-items: flex-start;
gap: 12px;
width: 100%;
padding: 14px 12px;
border-radius: 18px;
background: rgba(255,255,255,.06);
border: 1px solid rgba(255,255,255,.08);
box-shadow: 0 2px 12px rgba(0,0,0,.18), inset 0 1px 0 rgba(255,255,255,.06);
text-align: left;
color: var(--text);
cursor: pointer;
transition: background .16s ease, transform .18s ease, box-shadow .18s ease;
box-sizing: border-box;
}
.search-list-item:hover,
.search-list-item:focus {
background: rgba(255,255,255,.10);
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(0,0,0,.30), inset 0 1px 0 rgba(255,255,255,.10);
outline: none;
}
.search-list-item:active {
background: rgba(255,255,255,.05);
transform: translateY(0);
}
/* 右侧箭头图标 */
.sli-arrow {
flex-shrink: 0;
align-self: center;
width: 18px;
height: 18px;
opacity: .25;
margin-left: auto;
}
/* 类型 / 状态徽章 */
.sli-badge {
display: inline-block;
font-size: 10px;
font-weight: 600;
padding: 2px 7px;
border-radius: 6px;
margin-top: 7px;
letter-spacing: .03em;
background: rgba(96, 165, 250, .14);
color: #60a5fa;
border: .5px solid rgba(96, 165, 250, .28);
}
.sli-badge.green {
background: rgba(74, 222, 128, .12);
color: #4ade80;
border-color: rgba(74, 222, 128, .26);
}
.sli-poster {
width: 72px;
min-width: 72px;
height: 108px;
object-fit: cover;
border-radius: 8px;
background: rgba(255,255,255,.06);
flex-shrink: 0;
}
/* 右侧内容区:纯竖向堆叠,每行独占一行,间距用 margin-top 控制 */
.sli-body {
flex: 1;
min-width: 0;
display: block;
}
.sli-title {
display: block;
font-size: 16px;
font-weight: 700;
line-height: 1.35;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin: 0;
}
.sli-meta {
display: block;
font-size: 12px;
color: #fff;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 4px;
line-height: 1.5;
}
/* 星评行:flex 横排,严格左对齐 */
.sli-stars {
display: flex;
align-items: center;
gap: 6px;
margin-top: 8px;
line-height: 1;
}
.star {
font-style: normal;
letter-spacing: 1px;
font-size: 15px;
line-height: 1;
}
.star-full { color: #f5a623; }
.star-half { color: #f5a623; font-size: 13px; }
.star-empty { color: rgba(255,255,255,.25); }
.sli-rating {
font-size: 14px;
font-weight: 700;
color: #f5a623;
line-height: 1;
}
.sli-overview {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
font-size: 12px;
color: #fff;
line-height: 1.6;
margin-top: 8px;
}
html.tv-mode .chip::before,
html.tv-mode .chip.active::before,
html.tv-mode .btn::before,
html.tv-mode .icon-btn::before {
animation: none !important;
background: none !important;
display: none !important;
}
/* TV 模式过渡只保留焦点环,其余禁用以减少重绘 */
html.tv-mode .chip,
html.tv-mode .btn,
html.tv-mode .card {
transition: box-shadow 0.06s ease, border-color 0.06s ease !important;
}
/* ── TV 综合性能加固:禁用所有合成层、will-change ── */
html.tv-mode .btn,
html.tv-mode .icon-btn,
html.tv-mode .mini-icon-btn,
html.tv-mode .connection-toggle,
html.tv-mode .back-top {
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
will-change: auto !important;
box-shadow: 0 1px 4px rgba(0,0,0,.28) !important;
}
/* TV 导航栏:去掉 blur,保留半透明效果 */
html.tv-mode #chips,
html.tv-mode #chips-bar,
html.tv-mode .chips {
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
}
/* TV 盘搜结果:去掉 backdrop-filter */
html.tv-mode .pan-result-item {
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
}
/* TV 搜索建议面板:去掉 blur */
html.tv-mode .suggest-panel {
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
}
/* TV 连接面板:去掉 blur */
html.tv-mode .connection-body {
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
}
/* TV rating badge:去掉 blur */
html.tv-mode .rating-badge,
html.tv-mode .recent-badge {
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
}
/* TV 搜索模糊效果:TV 上禁用 filter blur(昂贵) */
html.tv-mode.search-active #chips-wrapper,
html.tv-mode.search-active #chips,
html.tv-mode.search-active #recommendSection,
html.tv-mode.search-active #listStack,
html.tv-mode.search-active .back-top {
filter: none !important;
opacity: 0.4;
}
/* TV hero-bg:关闭 will-change 避免提前合成 */
html.tv-mode .detail-hero-bg {
will-change: auto !important;
}
/* TV card focus:用 outline 替代 transform+scale 减少重排 */
html.tv-mode .card:focus:not(.person-card) {
transform: none !important;
outline: 2px solid rgba(255,255,255,.7) !important;
outline-offset: 2px !important;
border-color: rgba(255,255,255,.4) !important;
}
html.tv-mode .person-card:focus {
transform: none !important;
outline: 2px solid rgba(255,255,255,.7) !important;
outline-offset: 2px !important;
border-color: rgba(255,255,255,.4) !important;
}
/* TV mini-icon-btn 去掉 backdrop-filter */
html.tv-mode .mini-icon-btn {
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
}
/* TV 卡片长按菜单:去掉 blur */
html.tv-mode .card-long-press-menu {
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
}
/* TV toast:去掉背景模糊 */
html.tv-mode .toast {
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
}
/* TV 详情页 sheet-blur-layer 已通过上方规则 display:none */
</style><!-- ══════════════════════════════════════════════════════════════
TV 端终极性能修复(必须在所有样式块之后,确保覆盖 liquid-glass !important
根本原因:ios26-liquid-glass-fix 块对 .btn/.icon-btn 等施加了
backdrop-filter !important,此块在主 style 之后,优先级更高;
本块紧跟其后,用相同或更高优先级彻底清除所有 TV 端性能杀手。
手机端完全不受影响(所有规则均在 html.tv-mode 前缀下)。
══════════════════════════════════════════════════════════════ --><style id="tv-perf-final">
/* ── 1. 彻底禁用所有 backdrop-filter / filterGPU 合成层最大杀手)── */
html.tv-mode *,
html.tv-mode *::before,
html.tv-mode *::after {
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
will-change: auto !important;
}
/* ── 2. 彻底禁用所有非焦点元素的动画和过渡 ── */
html.tv-mode *:not(:focus),
html.tv-mode *:not(:focus)::before,
html.tv-mode *:not(:focus)::after {
animation: none !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.06s !important;
}
/* ── 3. 焦点元素只保留边框/阴影过渡,取消其他 ── */
html.tv-mode *:focus,
html.tv-mode *:focus::before,
html.tv-mode *:focus::after {
animation: none !important;
transition: box-shadow 0.06s ease, border-color 0.06s ease, outline-color 0.06s ease !important;
will-change: auto !important;
}
/* ── 4. chip.active::before 呼吸动画:彻底隐藏伪元素(display:none 比 animation:none 更彻底)── */
html.tv-mode .chip::before,
html.tv-mode .chip.active::before,
html.tv-mode .chip::after,
html.tv-mode .chip.active::after {
display: none !important;
content: none !important;
animation: none !important;
transition: none !important;
}
/* ── 5. liquid-glass 给 .btn/.icon-btn 等施加的 backdrop-filter 覆盖 ── */
html.tv-mode .btn,
html.tv-mode .icon-btn,
html.tv-mode .mini-icon-btn,
html.tv-mode .connection-toggle,
html.tv-mode .back-top,
html.tv-mode .chip,
html.tv-mode .chip.active {
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
will-change: auto !important;
/* 用简单 transition 替代 liquid-glass 的 all .18s */
transition: box-shadow 0.06s ease, border-color 0.06s ease !important;
/* TV Android WebView 中文字体 baseline 偏上,补偿对齐 */
padding-top: 3px !important;
padding-bottom: 0px !important;
line-height: 1 !important;
}
/* liquid-glass 高光伪元素(::before)在 TV 端完全隐藏 */
html.tv-mode .btn::before,
html.tv-mode .icon-btn::before,
html.tv-mode .mini-icon-btn::before,
html.tv-mode .connection-toggle::before,
html.tv-mode .connection-toggle::after,
html.tv-mode .back-top::before {
display: none !important;
content: none !important;
animation: none !important;
transition: none !important;
}
/* ── 6. 详情页高斯模糊层:TV 端彻底关闭 ── */
html.tv-mode .sheet-blur-layer,
html.tv-mode #detailSheet .sheet-blur-layer {
display: none !important;
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
transition: none !important;
}
/* ── 7. detail-hero-bgTV 端关闭 will-change + transition,只留 filter ── */
html.tv-mode .detail-hero-bg {
will-change: auto !important;
transition: opacity 0.3s ease !important; /* 只保留 opacity 过渡,去掉 filter 过渡避免滚动时每帧合成 */
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
}
/* ── 8. 搜索激活时的 filter blurTV 端用 opacity 替代 ── */
html.tv-mode.search-active #chips-wrapper,
html.tv-mode.search-active #chips,
html.tv-mode.search-active #recommendSection,
html.tv-mode.search-active #listStack,
html.tv-mode.search-active .back-top {
filter: none !important;
opacity: 0.35 !important;
transition: opacity 0.1s ease !important;
}
/* ── 9. pan-result-item、suggest-panel 等去掉所有 blur ── */
html.tv-mode .pan-result-item,
html.tv-mode .suggest-panel,
html.tv-mode .connection-body,
html.tv-mode .rating-badge,
html.tv-mode .recent-badge,
html.tv-mode .card-long-press-menu,
html.tv-mode .toast,
html.tv-mode .image-viewer {
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
transition: none !important;
}
/* ── 10. card focus:纯 outline,禁止 transform(会触发重排)── */
html.tv-mode .card:focus,
html.tv-mode .card:focus:not(.person-card),
html.tv-mode .person-card:focus {
transform: none !important;
outline: 3px solid rgba(255,255,255,.85) !important;
outline-offset: 2px !important;
border-color: rgba(255,255,255,.5) !important;
box-shadow: 0 0 0 5px rgba(60,140,255,.5) !important;
transition: outline-color 0.06s ease, border-color 0.06s ease !important;
}
/* ── 11. 禁用所有 CSS 动画关键帧在 TV 上的运行(pulse-dot、lp-in 等)── */
html.tv-mode {
--lg-transition: none !important;
}
/* ── 12. poster 骨架屏动画(background-size 动画)在 TV 端禁用 ── */
html.tv-mode .poster:not(.loaded) {
animation: none !important;
background: rgba(255,255,255,.06) !important;
}
</style></head><body><main class="app" id="home"><div class="search-focus-mask" id="searchFocusMask" aria-hidden="true"></div><div class="connection-focus-mask" id="connectionFocusMask" aria-hidden="true"></div><div class="search-bar-row" style="display:flex;align-items:center;gap:8px;position:relative;margin-top:12px;"><aside class="connection-dock" id="connectionDock" style="flex-shrink:0;position:relative"><button class="connection-toggle focusable" id="connectionToggle" type="button" aria-expanded="false" aria-controls="connectionBody"><span class="dot warn" id="connectionDot"></span></button><div class="connection-body" id="connectionBody" aria-hidden="true"><div class="connection-grid"><div class="connection-item"><b>App SDK</b><span id="statusSdk">检测中</span></div><div class="connection-item"><b>TMDB</b><span id="statusTmdb">等待请求</span></div><div class="connection-item"><b>Nostr</b><span id="statusNostr">等待连接</span></div><div class="connection-item"><b>盘搜</b><span id="statusPan">未搜索</span></div><div class="connection-item"><b>发布</b><span id="statusPublish">暂无发布</span></div><div class="connection-item"><b>身份</b><span class="mono" id="statusIdentity">未就绪</span></div><div class="connection-item refresh-progress"><b>刷新</b><span id="statusRefresh">未执行</span></div><div class="connection-item"><b>Relays</b><span id="statusRelays">未连接</span></div><div class="connection-item"><b>首页全屏</b><div class="detail-style-segmented" id="homeFullscreenSegmented" role="group" aria-label="首页全屏"><button class="detail-style-option focusable" id="homeFullscreenOnBtn" type="button" data-home-fullscreen="on">开启</button><button class="detail-style-option focusable" id="homeFullscreenOffBtn" type="button" data-home-fullscreen="off">关闭</button></div></div></div><div class="pan-config"><label for="tmdbKeyInput">TMDB Key<input class="field focusable mono" id="tmdbKeyInput" type="password" inputmode="text" autocomplete="off" placeholder="TMDB API Key" readonly></label><label for="panBaseInput">盘搜地址<input class="field focusable" id="panBaseInput" type="url" inputmode="url" placeholder="https://so.252035.xyz" readonly></label><label for="panChannelsInput">TG频道<textarea class="field focusable" id="panChannelsInput" rows="2" placeholder="可选,多个频道用逗号或换行分隔" readonly></textarea></label><label for="panUserInput">账号<input class="field focusable" id="panUserInput" type="text" autocomplete="username" placeholder="未启用认证可留空" readonly></label><label for="panPassInput">密码<input class="field focusable" id="panPassInput" type="password" autocomplete="current-password" placeholder="未启用认证可留空" readonly></label><div class="pan-disk-grid" id="panDiskGrid"></div><button class="btn focusable" id="savePanConfigBtn" type="button">保存配置</button></div><div class="relay-mirror-config"><label for="mirrorSourceInput">镜像源 Relays<textarea class="field focusable mono" id="mirrorSourceInput" rows="3" placeholder="每行一个源 relay" readonly></textarea></label><label for="mirrorTargetInput">镜像目标 Relays<textarea class="field focusable mono" id="mirrorTargetInput" rows="2" placeholder="每行一个目标 relay,例如 wss://your-relay.example" readonly></textarea></label><div class="relay-mirror-progress mono" id="mirrorProgress">镜像未执行</div><button class="btn focusable" id="mirrorRelayBtn" type="button">镜像榜单</button></div><div class="actions" style="margin-top:10px"><button class="btn focusable" id="refreshNostrBtn" type="button">刷新榜单</button><button class="btn focusable" id="syncBtn" type="button">同步身份</button><button class="btn danger focusable" id="deleteDataBtn" type="button">删除数据</button></div></div></aside><form class="search" id="searchForm" autocomplete="off" style="flex:1;min-width:0;margin:0"><input class="field focusable" id="searchInput" type="search" inputmode="search" enterkeyhint="search" placeholder="搜索关键词" aria-autocomplete="list" aria-controls="suggestPanel" readonly><button class="btn blue focusable" type="submit" aria-label="搜索" style="width:42px;height:42px;min-height:42px;flex-shrink:0;padding:0;"><svg class="icon" viewbox="0 0 24 24"><circle cx="11" cy="11" r="8" /><path d="M21 21l-4.35-4.35" /></svg></button></form><!-- 联想/热搜面板:作为 search-bar-row 的直接子元素定位,使其铺满整行宽度
(与下方内容左对齐、视觉居中),而不是只贴着输入框右侧 --><div class="suggest-panel" id="suggestPanel" role="listbox"></div></div><div id="chips-wrapper"><nav class="chips" id="chips" style="background:rgba(255,255,255,.07);border:1px solid rgba(255,255,255,.10);border-radius:999px;padding:3px 4px;gap:2px;box-shadow:0 2px 16px rgba(0,0,0,.22),inset 0 1px 0 rgba(255,255,255,.08)"></nav></div><section class="section" id="searchSection" style="display:none"><div class="section-head"><h3>搜索结果</h3></div><div class="rail" id="searchRail"></div></section><section class="section" id="recommendSection"><div class="section-head" style="display:none"><h3>推荐</h3></div><div class="media-grid" id="recommendRail"></div></section><section class="section" id="listSection"><div class="stack" id="listStack"></div></section><div class="infinite-sentinel" id="infiniteSentinel" aria-hidden="true"></div></main><section class="sheet" id="detailSheet" aria-hidden="true"><div class="detail-hero-bg" id="detailHeroBg"></div><div class="sheet-blur-layer"><span></span><span></span><span></span><span></span></div><div class="detail-spacer"></div><div class="detail-cover"><img id="detailImage" alt=""></div><div class="detail-info"><div class="detail-logo-wrap" id="detailLogoWrap" style="display:none"><img id="detailLogoImg" class="detail-logo-img" alt=""></div><div class="detail-title-row"><h2 id="detailTitle"></h2><span class="detail-title-meta" id="detailTitleMeta"></span></div><div class="actions"><button class="btn primary focusable" id="detailContinueBtn" type="button" style="display:none" aria-hidden="true"><svg class="icon" viewbox="0 0 24 24"><path d="M8 5v14l11-7Z" /></svg><span id="detailContinueText">继续观看</span></button><button class="btn primary focusable" id="detailSearchBtn" type="button"><svg class="icon" viewbox="0 0 24 24"><circle cx="11" cy="11" r="8" /><path d="M21 21l-4.35-4.35" /></svg><span id="detailSearchText">搜索播放</span></button><button class="btn focusable" id="panSearchBtn" type="button"><svg class="icon" viewbox="0 0 24 24"><path d="M6.2 18.3h11.6a4.1 4.1 0 0 0 .42-8.18 6.2 6.2 0 0 0-11.9 1.68A3.28 3.28 0 0 0 6.2 18.3Z" /></svg>盘搜</button><button class="btn focusable" id="closeDetailBtn" type="button" aria-label="返回" style="display:none"><svg class="icon" viewbox="0 0 24 24"><path d="M19 12H5" /><path d="M12 19l-7-7 7-7" /></svg>返回</button></div><div class="detail-meta" id="detailMeta"></div><div class="detail-overview-body" id="detailOverviewWrap" style="margin-top:18px"><p id="detailText"></p></div></div><div class="detail-block pan-search-block" id="panSearchBlock" style="display:none"><div class="subsection-head"><h4>盘搜资源</h4><span id="panSearchHint"></span></div><div class="chips pan-tabs" id="panTabs"></div><div class="pan-result-list" id="panResultList"></div></div><div class="detail-block" id="seasonBlock" style="display:none"><h3>分季 / 分集</h3><div class="chips" id="seasonTabs"></div><div class="rail" id="episodeRail"></div></div><div class="detail-block" id="castBlock" style="display:none;margin-top:12px"><h3>演职员表</h3><div class="rail" id="castRail"></div></div><div class="detail-block" id="personWorkBlock" style="display:none"><h3 id="personWorkTitle">简介</h3><div id="personInfo"></div><div class="rail" id="personWorkRail"></div></div><div class="detail-block" id="recommendBlock" style="display:none"><h3>相关推荐</h3><div class="rail" id="recommendWorkRail"></div></div></section><section class="sheet" id="syncSheet" aria-hidden="true"><button class="btn focusable" id="closeSyncBtn" type="button"><svg class="icon" viewbox="0 0 24 24"><path d="M19 12H5" /><path d="M12 19l-7-7 7-7" /></svg>返回</button><div class="sync-panel" style="margin-top:14px"><h3 style="margin:0">Nostr 同步身份</h3><p class="hint">页面会自动生成本机身份并把有效观看偏好签名发布到 relay。手机和电脑如果想同步同一个用户的推荐,请导入同一个 nsec。只看全网热度时不需要导入。</p><div><label class="hint" for="nsecInput">nsec 私钥</label><textarea class="field focusable mono" id="nsecInput" placeholder="nsec1..."></textarea></div><div class="actions"><button class="btn primary focusable" id="saveNsecBtn" type="button">导入身份</button><button class="btn focusable" id="newNsecBtn" type="button">生成新身份</button></div><p class="hint mono" id="identityText">身份未就绪</p><p class="hint" id="relayText">relay 未连接</p></div></section><section class="image-viewer" id="imageViewer" aria-hidden="true"><button class="btn focusable" id="closeImageBtn" type="button"><svg class="icon" viewbox="0 0 24 24"><path d="M19 12H5" /><path d="M12 19l-7-7 7-7" /></svg>返回</button><div class="image-content focusable" id="imageContent" tabindex="0"><img id="viewerImage" alt=""><div class="episode-view" id="episodeViewer" style="display:none"></div></div></section><div class="toast" id="toast"></div><button class="back-top focusable" id="backTopBtn" type="button" aria-label="返回顶部"><svg class="icon" viewbox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15" /></svg></button><script src="https://cdn.jsdelivr.net/npm/[email protected]/lib/nostr.bundle.js"></script><script>
window.WEBHOME_CONFIG = {
siteKey: "",
tmdb: {
apiKey: "d7040155454e7fdf547c4d889ebbcca7",
apiBase: "https://api.tmdb.org/3",
language: "zh-CN",
imageBase: "https://images.tmdb.org/t/p/w342",
backdropBase: "https://images.tmdb.org/t/p/w1920_and_h800_multi_faces",
lists: [
{
id: "all",
title: "推荐",
hint: "近期国内外最新上线",
mediaType: "all",
sources: [
{ endpoint: "discover/tv", mediaType: "tv", params: { with_original_language: "zh", with_origin_country: "CN", without_genres: "10764,10766,10767", sort_by: "popularity.desc", include_null_first_air_dates: "false", "first_air_date_gte": "today-90", "first_air_date_lte": "today+7", vote_count_gte: "2" } },
{ endpoint: "discover/movie", mediaType: "movie", params: { with_original_language: "zh", sort_by: "popularity.desc", "primary_release_date_gte": "today-90", "primary_release_date_lte": "today+7", vote_count_gte: "2" } },
{ endpoint: "movie/now_playing", mediaType: "movie", params: { language: "zh-CN" } },
{ endpoint: "discover/movie", mediaType: "movie", params: { with_original_language: "zh", sort_by: "popularity.desc", "primary_release_date_gte": "today-60", "primary_release_date_lte": "today+3", vote_count_gte: "5", language: "zh-CN" } },
{ endpoint: "discover/tv", mediaType: "tv", params: { with_original_language: "en", with_origin_country: "US|GB", without_genres: "10764,10767,10766,16", with_watch_providers: "8|337|350|384|9|15|386|531", watch_region: "US", sort_by: "popularity.desc", include_null_first_air_dates: "false", "first_air_date_gte": "today-60", "first_air_date_lte": "today+7" } },
{ endpoint: "discover/movie", mediaType: "movie", params: { with_original_language: "en", with_watch_providers: "8|337|350|384|9|15|386|531", watch_region: "US", sort_by: "popularity.desc", "primary_release_date_gte": "today-90", "primary_release_date_lte": "today+7", vote_count_gte: "5" } },
{ endpoint: "discover/tv", mediaType: "tv", params: { with_original_language: "ko", with_origin_country: "KR", without_genres: "10764,10767,10766,16", sort_by: "popularity.desc", include_null_first_air_dates: "false", "first_air_date_gte": "today-60", "first_air_date_lte": "today+7" } },
{ endpoint: "discover/tv", mediaType: "tv", params: { with_original_language: "ja", with_origin_country: "JP", without_genres: "10764,10767,10766,16", sort_by: "popularity.desc", include_null_first_air_dates: "false", "first_air_date_gte": "today-60", "first_air_date_lte": "today+7" } }
]
},
{
id: "cn-tv",
title: "华语剧",
hint: "大陆长篇电视剧 · 最新热播",
mediaType: "tv",
sources: [
{ endpoint: "discover/tv", mediaType: "tv", params: { with_original_language: "zh", with_origin_country: "CN", without_genres: "10764,10766,10767", with_type: "4", sort_by: "popularity.desc", include_null_first_air_dates: "false", "first_air_date_gte": "today-180", "first_air_date_lte": "today+7" } },
{ endpoint: "discover/tv", mediaType: "tv", params: { with_original_language: "zh", with_origin_country: "CN", without_genres: "10764,10766,10767", with_type: "4", sort_by: "first_air_date.desc", include_null_first_air_dates: "false", "first_air_date_gte": "today-180", "first_air_date_lte": "today+7" } }
]
},
{
id: "movie-cn",
title: "华语电影",
hint: "华语新片·热门·高口碑",
mediaType: "movie",
sources: [
{ endpoint: "discover/movie", mediaType: "movie", params: { with_original_language: "zh", sort_by: "primary_release_date.desc", "primary_release_date_gte": "today-180", "primary_release_date_lte": "today+30", vote_count_gte: "1", language: "zh-CN" } },
{ endpoint: "discover/movie", mediaType: "movie", params: { with_original_language: "zh", sort_by: "popularity.desc", "primary_release_date_gte": "today-90", "primary_release_date_lte": "today", vote_count_gte: "2", language: "zh-CN" } },
{ endpoint: "discover/movie", mediaType: "movie", params: { with_original_language: "zh", sort_by: "popularity.desc", "primary_release_date_gte": "today-365", "primary_release_date_lte": "today", vote_count_gte: "5", language: "zh-CN" } },
{ endpoint: "discover/movie", mediaType: "movie", params: { with_original_language: "zh", sort_by: "vote_average.desc", "primary_release_date_gte": "today-730", "primary_release_date_lte": "today", vote_count_gte: "30", language: "zh-CN" } },
{ endpoint: "discover/movie", mediaType: "movie", params: { with_original_language: "cn", sort_by: "popularity.desc", "primary_release_date_gte": "today-365", "primary_release_date_lte": "today", vote_count_gte: "3", language: "zh-CN" } }
]
},
{
id: "movie-foreign",
title: "国外电影",
hint: "全球新片·近三月热播",
mediaType: "movie",
excludeOriginalLanguage: ["zh", "cn"],
sources: [
{ endpoint: "movie/now_playing", mediaType: "movie", params: { region: "US", language: "zh-CN" } },
{ endpoint: "movie/upcoming", mediaType: "movie", params: { region: "US", language: "zh-CN" } },
{ endpoint: "discover/movie", mediaType: "movie", params: { with_original_language: "en", sort_by: "primary_release_date.desc", "primary_release_date_gte": "today-90", "primary_release_date_lte": "today+30", vote_count_gte: "10", language: "zh-CN" } },
{ endpoint: "discover/movie", mediaType: "movie", params: { with_original_language: "en", sort_by: "popularity.desc", "primary_release_date_gte": "today-90", "primary_release_date_lte": "today", vote_count_gte: "30", language: "zh-CN" } },
{ endpoint: "discover/movie", mediaType: "movie", params: { with_original_language: "ko", sort_by: "popularity.desc", "primary_release_date_gte": "today-90", "primary_release_date_lte": "today", vote_count_gte: "8", language: "zh-CN" } },
{ endpoint: "discover/movie", mediaType: "movie", params: { with_original_language: "ja", sort_by: "popularity.desc", "primary_release_date_gte": "today-90", "primary_release_date_lte": "today", vote_count_gte: "8", language: "zh-CN" } }
]
},
{
id: "jp-kr-tv",
title: "日韩剧",
hint: "韩国·日本最新剧集",
mediaType: "tv",
sources: [
{
endpoint: "discover/tv",
mediaType: "tv",
params: {
with_original_language: "ko",
sort_by: "first_air_date.desc",
first_air_date_gte: "today-120",
first_air_date_lte: "today+60",
include_null_first_air_dates: "false",
without_genres: "16,99"
}
},
{
endpoint: "discover/tv",
mediaType: "tv",
params: {
with_original_language: "ja",
sort_by: "first_air_date.desc",
first_air_date_gte: "today-120",
first_air_date_lte: "today+60",
include_null_first_air_dates: "false",
without_genres: "16,99"
}
}
]
},
{
id: "hk-tw-tv",
title: "港台剧",
hint: "香港·台湾最新剧集",
mediaType: "tv",
sources: [
{ endpoint: "discover/tv", mediaType: "tv", params: { with_original_language: "zh", with_origin_country: "HK", without_genres: "10764,10766,10767,16", sort_by: "first_air_date.desc", include_null_first_air_dates: "false", "first_air_date_gte": "today-180", "first_air_date_lte": "today+14" } },
{ endpoint: "discover/tv", mediaType: "tv", params: { with_original_language: "zh", with_origin_country: "TW", without_genres: "10764,10766,10767,16", sort_by: "first_air_date.desc", include_null_first_air_dates: "false", "first_air_date_gte": "today-180", "first_air_date_lte": "today+14" } },
{ endpoint: "discover/tv", mediaType: "tv", params: { with_original_language: "zh", with_origin_country: "HK", without_genres: "10764,10766,10767,16", sort_by: "popularity.desc", include_null_first_air_dates: "false", "first_air_date_gte": "today-365", "first_air_date_lte": "today+14" } }
]
},
{
id: "us-tv",
title: "欧美剧",
hint: "Netflix·HBO·Apple TV+·Disney+ 最新",
mediaType: "tv",
sources: [
{ endpoint: "discover/tv", mediaType: "tv", params: { with_original_language: "en", with_origin_country: "US", without_genres: "16", with_watch_providers: "8|337|350|384|9|386", watch_region: "US", sort_by: "popularity.desc", include_null_first_air_dates: "false", "first_air_date_gte": "today-90", "first_air_date_lte": "today+14" } },
{ endpoint: "discover/tv", mediaType: "tv", params: { with_original_language: "en", with_origin_country: "GB", without_genres: "16", with_watch_providers: "8|337|350|384|9|386", watch_region: "US", sort_by: "popularity.desc", include_null_first_air_dates: "false", "first_air_date_gte": "today-90", "first_air_date_lte": "today+14" } },
{ endpoint: "discover/tv", mediaType: "tv", params: { with_original_language: "en", with_origin_country: "US", without_genres: "16", sort_by: "popularity.desc", include_null_first_air_dates: "false", "first_air_date_gte": "today-60", "first_air_date_lte": "today+14", vote_count_gte: "10" } }
]
},
{
id: "variety-cn",
title: "国内综艺",
hint: "内地热门综艺节目",
mediaType: "tv",
sources: [
{ endpoint: "discover/tv", mediaType: "tv", params: { with_genres: "10764", with_original_language: "zh", with_origin_country: "CN", sort_by: "first_air_date.desc", include_null_first_air_dates: "false", "first_air_date_gte": "today-180", "first_air_date_lte": "today+60" } },
{ endpoint: "discover/tv", mediaType: "tv", params: { with_genres: "10767", with_original_language: "zh", with_origin_country: "CN", sort_by: "first_air_date.desc", include_null_first_air_dates: "false", "first_air_date_gte": "today-180", "first_air_date_lte": "today+60" } },
{ endpoint: "discover/tv", mediaType: "tv", params: { with_genres: "10764,10767", with_original_language: "zh", with_origin_country: "HK", sort_by: "first_air_date.desc", include_null_first_air_dates: "false", "first_air_date_gte": "today-180", "first_air_date_lte": "today+60" } },
{ endpoint: "discover/tv", mediaType: "tv", params: { with_genres: "10764,10767", with_original_language: "zh", with_origin_country: "TW", sort_by: "first_air_date.desc", include_null_first_air_dates: "false", "first_air_date_gte": "today-180", "first_air_date_lte": "today+60" } }
]
},
{
id: "anime-cn",
title: "国漫",
hint: "国产动画·剧集与电影",
mediaType: "tv",
sources: [
{ endpoint: "discover/tv", mediaType: "tv", params: { with_genres: "16", with_original_language: "zh", with_origin_country: "CN", sort_by: "first_air_date.desc", include_null_first_air_dates: "false", "first_air_date_gte": "today-180", "first_air_date_lte": "today+60" } },
{ endpoint: "discover/tv", mediaType: "tv", params: { with_genres: "16", with_original_language: "zh", with_origin_country: "CN", sort_by: "popularity.desc", include_null_first_air_dates: "false", "first_air_date_gte": "today-720", "first_air_date_lte": "today+60" } },
{ endpoint: "discover/movie", mediaType: "movie", params: { with_genres: "16", with_original_language: "zh", with_origin_country: "CN", sort_by: "primary_release_date.desc", "primary_release_date_gte": "today-365", "primary_release_date_lte": "today+60" } },
{ endpoint: "discover/movie", mediaType: "movie", params: { with_genres: "16", with_original_language: "zh", with_origin_country: "CN", sort_by: "popularity.desc", "primary_release_date_lte": "today+60" } }
]
},
{
id: "anime-jp",
title: "日漫",
hint: "日本动画·剧集与电影",
mediaType: "tv",
sources: [
{ endpoint: "discover/tv", mediaType: "tv", params: { with_genres: "16", with_original_language: "ja", with_origin_country: "JP", sort_by: "first_air_date.desc", include_null_first_air_dates: "false", "first_air_date_gte": "today-120", "first_air_date_lte": "today+30" } },
{ endpoint: "discover/tv", mediaType: "tv", params: { with_genres: "16", with_original_language: "ja", with_origin_country: "JP", sort_by: "popularity.desc", include_null_first_air_dates: "false", "first_air_date_gte": "today-720", "first_air_date_lte": "today+30" } },
{ endpoint: "discover/movie", mediaType: "movie", params: { with_genres: "16", with_original_language: "ja", sort_by: "primary_release_date.desc", "primary_release_date_gte": "today-120", "primary_release_date_lte": "today+30" } },
{ endpoint: "discover/movie", mediaType: "movie", params: { with_genres: "16", with_original_language: "ja", sort_by: "popularity.desc", "primary_release_date_lte": "today+30" } }
]
},
{
id: "documentary",
title: "纪录片",
hint: "全球最热门纪录片",
mediaType: "all",
sources: [
{ endpoint: "discover/movie", mediaType: "movie", params: { with_genres: "99", sort_by: "popularity.desc", vote_count_gte: "50", language: "zh-CN" } },
{ endpoint: "discover/tv", mediaType: "tv", params: { with_genres: "99", sort_by: "popularity.desc", vote_count_gte: "20", language: "zh-CN" } },
{ endpoint: "discover/movie", mediaType: "movie", params: { with_genres: "99", with_original_language: "zh", sort_by: "vote_average.desc", vote_count_gte: "20", language: "zh-CN" } }
]
},
{
id: "concert",
title: "演唱会",
hint: "演唱会·音乐现场·Live",
mediaType: "movie",
sources: [
{ endpoint: "discover/movie", mediaType: "movie", params: { with_keywords: "156205", with_origin_country: "CN|HK|TW", sort_by: "primary_release_date.desc", "primary_release_date_lte": "today+60", language: "zh-CN" } },
{ endpoint: "discover/movie", mediaType: "movie", params: { with_keywords: "156205", with_origin_country: "JP", sort_by: "primary_release_date.desc", "primary_release_date_lte": "today+60", language: "zh-CN" } },
{ endpoint: "discover/movie", mediaType: "movie", params: { with_keywords: "156205", with_origin_country: "KR", sort_by: "primary_release_date.desc", "primary_release_date_lte": "today+60", language: "zh-CN" } },
{ endpoint: "discover/movie", mediaType: "movie", params: { with_keywords: "156205", with_origin_country: "US", sort_by: "primary_release_date.desc", "primary_release_date_lte": "today+60", language: "zh-CN" } },
{ endpoint: "discover/movie", mediaType: "movie", params: { with_keywords: "156205", with_origin_country: "CN|HK|TW", sort_by: "popularity.desc", language: "zh-CN" } },
{ endpoint: "discover/movie", mediaType: "movie", params: { with_keywords: "156205", with_origin_country: "JP", sort_by: "popularity.desc", language: "zh-CN" } },
{ endpoint: "discover/movie", mediaType: "movie", params: { with_keywords: "156205", with_origin_country: "KR", sort_by: "popularity.desc", language: "zh-CN" } },
{ endpoint: "discover/movie", mediaType: "movie", params: { with_keywords: "156205", with_origin_country: "US", sort_by: "popularity.desc", language: "zh-CN" } },
{ endpoint: "discover/tv", mediaType: "tv", params: { with_keywords: "156205", with_origin_country: "CN|HK|TW|JP|KR|US", sort_by: "popularity.desc", language: "zh-CN" } }
]
}
]
},
nostr: {
kind: 30078,
tag: "fish2018-home-v1",
eventsKey: "fish2018_home_v1_events",
nsecKey: "fish2018_home_v1_nsec",
relays: [
"wss://relay-sgp.signedbyme.com",
"wss://relay.lovelana.org",
"wss://relay.nostr.moe",
"wss://nostr.spacecitynode.com",
// "ws://139.162.37.202",
// "wss://nos.lol",
// "wss://relay.snort.social",
// "wss://nostr.mom",
// "wss://relay.primal.net",
// "wss://nostr-01.yakihonne.com",
// "wss://nostr-02.yakihonne.com"
]
},
pan: {
apiBase: "https://so.252035.xyz",
cacheKey: "fish2018_home_v1_pan_config",
diskTypes: ["quark", "aliyun", "baidu", "uc", "tianyi", "xunlei", "123", "115", "mobile", "pikpak", "guangya", "magnet", "ed2k"],
checkableDiskTypes: ["quark", "aliyun", "baidu", "uc", "tianyi", "xunlei", "123", "115", "mobile"],
pollIntervals: [4500, 9000]
}
};
const $ = (id) => document.getElementById(id);
const PAN_DISK_TYPES = [
{ id: "quark", name: "夸克" },
{ id: "aliyun", name: "阿里" },
{ id: "baidu", name: "百度" },
{ id: "uc", name: "UC" },
{ id: "tianyi", name: "天翼" },
{ id: "xunlei", name: "迅雷" },
{ id: "123", name: "123" },
{ id: "115", name: "115" },
{ id: "mobile", name: "移动" },
{ id: "pikpak", name: "PikPak" },
{ id: "guangya", name: "光鸭" },
{ id: "magnet", name: "磁力" },
{ id: "ed2k", name: "电驴" }
];
const PAN_CHECKABLE_DISK_TYPES = new Set(window.WEBHOME_CONFIG.pan.checkableDiskTypes || ["quark", "aliyun", "baidu", "uc", "tianyi", "xunlei", "123", "115", "mobile"]);
const PAN_HEALTH_PRIORITY = { ok: 0, locked: 1, pending: 2, idle: 3, unsupported: 4, uncertain: 5, bad: 6 };
// 画质关键词优先级(score 越高排越前):
// DV/HDR10+ > HDR10/HLG > 4K/2160P > REMUX > BluRay > 高码率 > 杜比全景声 > 120fps > 60fps > 1080P > 720P > SDR
// 注意:DV 匹配时排除 DVD(负向前瞻,且大写 DV 用前缀排除 DVD)
const PAN_QUALITY_KEYWORDS = [
{ re: /(?<![A-Za-z])DV(?!D)|dolby.?vision|杜比视界/i, score: 10, label: "DolbyVision", cls: "dv" },
{ re: /hdr10\+|hdr10plus/i, score: 9, label: "HDR10+", cls: "hdrp" },
{ re: /hdr(?!10\+)|hlg|hdr10(?!\+)/i, score: 8, label: "HDR" },
{ re: /4k|2160p|uhd/i, score: 7, label: "4K" },
{ re: /remux/i, score: 6, label: "REMUX" },
{ re: /blu[\-.]?ray/i, score: 5, label: "BluRay" },
{ re: /高码|高码率/i, score: 4, label: "高码" },
{ re: /vivid/i, score: 4, label: "Vivid" },
{ re: /杜比(?!视界)|atmos/i, score: 3, label: "杜比全景声" },
{ re: /120fps|120p/i, score: 2, label: "120fps" },
{ re: /60fps|60p/i, score: 1, label: "60fps" },
{ re: /1080p|1080i|全高清|fhd/i, score: 2, label: "1080P", cls: "fhd" },
{ re: /720p/i, score: 1, label: "720P", cls: "hd" },
{ re: /480p|576p/i, score: 0, label: "SD", cls: "sd" },
{ re: /sdr/i, score: 0, label: "SDR" },
];
function panQualityInfo(item) {
const title = String((item && item.title) || "");
let count = 0, score = 0;
const tags = [];
PAN_QUALITY_KEYWORDS.forEach((q) => {
if (q.re.test(title)) { count++; score += q.score; tags.push({ label: q.label, cls: q.cls || null }); }
});
return { count, score, tags };
}
const PAGE_OPENED_AT = window.WEBHOME_PAGE_OPENED_AT || Date.now();
const GRID_INITIAL_ROWS = 12;
const GRID_APPEND_ROWS = 8;
const RECENT_UI_TTL_MS = 30000;
const state = {
site: null,
config: null,
identity: null,
selected: null,
detail: null,
activeList: "all",
catalog: {},
catalogPage: {},
recent: { items: [], loading: false, loaded: false, error: "", refreshedAt: 0 },
gridRender: {},
loadingMore: false,
chipFocusTimer: 0,
railScroll: {},
searchItems: [],
suggestions: { keyword: "", items: [], loading: false, timer: 0, seq: 0, controller: null },
hot: { db: null, dbPromise: null, idb: false, ready: false, items: [], media: new Map(), users: new Map(), ingestQueue: Promise.resolve(), refreshTimer: 0 },
relay: { connected: 0, published: 0, total: 0, lastOk: 0, lastDone: 0, statuses: {}, subscribeStarted: false, subscribeDone: 0, subscribeFinished: {}, subscribeToken: 0, refresh: null, backfillBusy: {}, backfillTimers: {}, backfillCandidates: {}, primaryBackfillRelay: "", fallbackReadyAt: 0, fallbackTimer: 0, fallbackPrefetchTimer: 0 },
status: {
sdk: "检测中",
tmdb: "等待请求",
nostr: "等待连接",
pan: "未搜索",
publish: "暂无发布",
identity: "未就绪",
refresh: "未执行"
},
deleteState: { loaded: false, users: {} },
tmdb: { config: null, configDirty: false },
uiPrefs: { loaded: false, homeFullscreenEnabled: true },
pan: { config: null, configDirty: false, loading: false, keyword: "", activeType: "", results: [], health: {}, pending: {}, queued: new Map(), inFlight: new Set(), observer: null, flushTimer: 0, pollTimers: [], pollRound: 0, viewToken: "", renderKeys: "", tabKeys: "", checkEnabled: false, focusKey: "", focusMode: "", playbackReturn: null, isPlaying: false },
watch: { item: null, timer: 0, bestMs: 0, durationMs: 0, lastAt: 0, published: false, intentAction: "" },
detailCover: { images: [], index: 0, timer: 0, token: 0, swipe: null },
deviceMode: "unknown",
detailReturn: null,
focusReturnEl: null,
remoteInitialFocused: false,
renderTimer: 0,
homeContentTimer: 0,
homeContentSeq: 0,
activeGridAppendTimer: 0,
activeGridAppendId: "",
scrollingUntil: 0,
infiniteObserver: null,
scrollLoadTimer: 0,
resume: { timer: 0, lastAt: 0 }
};
const WATCH_HEAT_MS = 10 * 60 * 1000;
const FALLBACK_PREFETCH_MS = 1000;
const FALLBACK_SHOW_MS = 1500;
const HOT_WINDOW_DAYS = 90;
const HOT_DAY_SECONDS = 24 * 60 * 60;
const HOT_WINDOW_SECONDS = HOT_WINDOW_DAYS * 24 * 60 * 60;
const HOT_WINDOW_MS = HOT_WINDOW_SECONDS * 1000;
const HOT_DB_NAME = "fish2018_home_v1_hot_compact_index";
const HOT_DB_VERSION = 1;
const HOT_VECTOR_D = "heat:user:90d:v2";
const HOT_VECTOR_VERSION = 5;
const HOT_USER_VECTOR_LIMIT = 300;
const HOT_TITLE_LIMIT = 60;
const HOT_POSTER_LIMIT = 96;
const HOT_PAGE_LIMIT = 1000;
const HOT_SUBSCRIBE_LIMIT = 500;
const HOT_RECENT_PAGES_PER_RELAY = 6;
const HOT_HISTORY_PAGES_PER_RELAY = 10;
const HOT_BACKFILL_IDLE_MS = 650;
const HOT_BACKFILL_RETRY_MS = 30000;
const HOT_BACKFILL_RETRY_MAX_MS = 5 * 60 * 1000;
const HOT_BACKUP_WINDOW_SECONDS = 7 * HOT_DAY_SECONDS;
const HOT_REFRESH_IDLE_MS = 260;
const HOT_REFRESH_BACKFILL_MS = 900;
const RELAY_MIRROR_PAGE_LIMIT = 500;
const RELAY_MIRROR_MAX_PAGES_PER_SOURCE = 40;
const DEFAULT_TMDB_API_KEY = String(window.WEBHOME_CONFIG.tmdb.apiKey || "").trim();
const HOT_PRUNE_BATCH = 1000;
const HOT_RENDER_LIMIT = 1000;
const UI_SNAPSHOT_TTL_MS = 2 * 60 * 60 * 1000;
const PAN_PLAYBACK_RETURN_TTL_MS = 10 * 60 * 1000;
const DELETE_REPUBLISH_BLOCK_MS = 24 * 60 * 60 * 1000;
const DELETE_TOMBSTONE_TTL_MS = HOT_WINDOW_MS + HOT_DAY_SECONDS * 1000;
let nativeSdkWait = null;
function waitForNativeSdk(timeout) {
if (window.fm || !window.fongmiBridge) return Promise.resolve(!!window.fm);
if (nativeSdkWait) return nativeSdkWait;
nativeSdkWait = new Promise((resolve) => {
let done = false;
const finish = () => {
if (done) return;
done = true;
window.removeEventListener("fmsdk", finish);
nativeSdkWait = null;
resolve(!!window.fm);
};
window.addEventListener("fmsdk", finish);
setTimeout(finish, timeout == null ? 1500 : timeout);
});
return nativeSdkWait;
}
function sdk() {
if (window.fm) {
const pan = window.fm.pan || {};
return Object.assign({}, window.fm, { pan, check: pan.check || window.fm.check });
}
const check = async (items) => ({ results: (items || []).map((item) => ({ type: item.type, url: item.url, normalized_url: item.url, state: "idle", summary: "浏览器预览不检测", cache_hit: false, checked_at: Date.now(), expires_at: Date.now() + 300000 })) });
const play = async (payload) => {
const item = payload || {};
const url = addPanPassword(String(item.url || ""), item.password || "", { diskType: item.type });
if (url) window.open(url, "_blank", "noopener,noreferrer");
};
return {
req: browserRequest,
res: (url) => url,
play: async (url, title) => window.open(url, "_blank", "noopener,noreferrer"),
search: async (kw) => toast("原生搜索:" + kw),
openLive: async () => toast("请在 App 中打开直播"),
openKeep: async () => toast("请在 App 中打开收藏"),
history: async () => [],
vod: async (siteKey, vodId, title) => toast("原生播放:" + (title || vodId || siteKey || "")),
pan: { check, play },
check,
ui: {
setToolbar: async () => {},
setChrome: async () => {},
restoreChrome: async () => {},
getViewport: async () => ({ width: innerWidth, height: innerHeight, safeTop: 0, safeRight: 0, safeBottom: 0, safeLeft: 0, chromeMode: "normal", systemBarsHidden: false })
},
cache: {
get: async (key) => localStorage.getItem("fm_" + key) || "",
set: async (key, value) => localStorage.setItem("fm_" + key, value),
del: async (key) => localStorage.removeItem("fm_" + key)
},
site: async () => ({ key: "browser", name: "浏览器预览" }),
config: async () => ({ id: "browser", url: location.href, driveCheck: false }),
device: async () => ({ type: 1 })
};
}
function isTvMode() {
return document.documentElement.classList.contains("tv-mode");
}
function isNativeMobileClient() {
const client = window.fongmiClient || {};
if (client.isMobile === true) return true;
if (String(client.mode || "").toLowerCase() === "mobile") return true;
return false;
}
function isNativeLeanbackClient() {
const client = window.fongmiClient || {};
if (client.isLeanback === true) return true;
if (String(client.mode || "").toLowerCase() === "leanback") return true;
return false;
}
function syncClientModeClasses() {
const nativeMobile = isNativeMobileClient();
document.documentElement.classList.toggle("native-mobile-app", nativeMobile);
}
function setTvMode(tv) {
document.documentElement.classList.toggle("tv-mode", !!tv);
syncClientModeClasses();
syncNativeToolbarForRoute();
}
function isHomeFullscreenEnabled() {
return !state.uiPrefs || state.uiPrefs.homeFullscreenEnabled !== false;
}
function setNativeToolbarVisible(visible, force) {
if (!force && !isTvMode() && !isNativeMobileClient()) return;
try {
const ui = sdk().ui || {};
if (isNativeLeanbackClient() && ui.setChrome) {
if (visible === true) {
ui.setChrome({ mode: isHomeFullscreenEnabled() ? "edge" : "tv-normal" });
return;
}
ui.setChrome({ mode: "tv-full" });
return;
}
if (isNativeMobileClient() && ui.setChrome) {
const home = visible === true;
ui.setChrome({
mode: home ? isHomeFullscreenEnabled() ? "edge" : "normal" : "immersive",
statusBarStyle: "light",
navigationBarStyle: "light",
restoreAffordance: home ? "auto" : "none",
startup: home
});
return;
}
if (ui.setToolbar) ui.setToolbar(visible);
} catch (e) {}
}
function syncNativeToolbarForRoute() {
setNativeToolbarVisible(uiSnapshotRoute() === "home");
}
// 是否有二级 sheet 处于打开状态(详情 / 同步 / 图片)
function _anySheetActive() {
return (
($("detailSheet") && $("detailSheet").classList.contains("active")) ||
($("syncSheet") && $("syncSheet").classList.contains("active")) ||
($("imageViewer") && $("imageViewer").classList.contains("active"))
);
}
async function detectDeviceMode() {
try {
if (window.fongmiBridge && !window.fm) await waitForNativeSdk(1500);
const api = sdk();
if (isNativeLeanbackClient()) {
state.deviceMode = "leanback";
setTvMode(true);
return true;
}
if (isNativeMobileClient()) state.deviceMode = "mobile";
if (!api.device) {
setNativeToolbarVisible(true, true);
document.documentElement.classList.remove("tv-mode");
syncClientModeClasses();
return false;
}
const device = await api.device();
const mode = String(device && device.mode || "").toLowerCase();
const tv = Number(device && device.type) === 0 || mode === "leanback";
const nativeClient = !!(window.fm || window.fongmiBridge || window.fongmiClient);
state.deviceMode = tv ? "leanback" : nativeClient && (Number(device && device.type) === 1 || mode === "mobile") ? "mobile" : "browser";
setTvMode(tv);
return tv;
} catch (e) {
setNativeToolbarVisible(true, true);
document.documentElement.classList.remove("tv-mode");
state.deviceMode = isNativeMobileClient() ? "mobile" : "browser";
syncClientModeClasses();
return false;
}
}
function cacheKey(type) {
if (type === "events") return window.WEBHOME_CONFIG.nostr.eventsKey || "fish2018_home_v1_events";
if (type === "nsec") return window.WEBHOME_CONFIG.nostr.nsecKey || "fish2018_home_v1_nsec";
if (type === "pan") return window.WEBHOME_CONFIG.pan.cacheKey || "fish2018_home_v1_pan_config";
if (type === "ui") return "fish2018_home_v1_ui_snapshot";
if (type === "tmdb") return "fish2018_home_v1_tmdb_config";
if (type === "uiPrefs") return "fish2018_home_v1_ui_prefs";
if (type === "deleteState") return "fish2018_home_v1_delete_state";
return type;
}
function defaultPanConfig() {
return {
apiBase: window.WEBHOME_CONFIG.pan.apiBase || "https://so.252035.xyz",
diskTypes: (window.WEBHOME_CONFIG.pan.diskTypes || []).slice(),
username: "",
password: "",
token: "",
tokenExpiresAt: 0,
channels: []
};
}
function normalizePanBase(value) {
let base = String(value || "").trim() || defaultPanConfig().apiBase;
if (!/^https?:\/\//i.test(base)) base = "https://" + base;
return base.replace(/\/+$/, "");
}
function normalizePanDiskType(type) {
const value = String(type || "").trim().toLowerCase();
if (value === "ali" || value === "alipan") return "aliyun";
if (value === "123pan") return "123";
if (value === "139" || value === "caiyun") return "mobile";
if (value === "pikpakdrive") return "pikpak";
if (value === "guangyapan") return "guangya";
if (value === "magnetlink" || value === "magnet:?" || value === "bt") return "magnet";
if (value === "ed2k:") return "ed2k";
if (value === "other") return "others";
return value;
}
function normalizePanChannels(value) {
const list = Array.isArray(value) ? value : String(value || "").split(/[\n,\s]+/);
return Array.from(new Set(list.map((item) => String(item || "").trim()).filter(Boolean)));
}
function isPanCheckSupported(item) {
return !!(item && PAN_CHECKABLE_DISK_TYPES.has(normalizePanDiskType(item.diskType)));
}
function canCheckPanLinks() {
const pan = sdk().pan || {};
return !!(state.pan.checkEnabled && pan.check);
}
function sanitizePanConfig(value) {
const fallback = defaultPanConfig();
const raw = value && typeof value === "object" ? value : {};
const allowed = new Set(PAN_DISK_TYPES.map((item) => item.id));
const disks = Array.isArray(raw.diskTypes) ? raw.diskTypes.map(normalizePanDiskType).filter((item) => allowed.has(item)) : fallback.diskTypes;
return {
apiBase: normalizePanBase(raw.apiBase || fallback.apiBase),
diskTypes: Array.from(new Set(disks.length ? disks : fallback.diskTypes)),
username: String(raw.username || "").trim(),
password: String(raw.password || ""),
token: String(raw.token || ""),
tokenExpiresAt: Number(raw.tokenExpiresAt || 0),
channels: normalizePanChannels(raw.channels || raw.tgChannels || raw.channel)
};
}
async function initPanConfig(options) {
options = options || {};
if (window.fongmiBridge && !window.fm) await waitForNativeSdk(options.timeout == null ? 1500 : options.timeout);
if (options.preserveDirty && state.pan.configDirty) return;
let saved = null;
try { saved = safeJson(await sdk().cache.get(cacheKey("pan")), null); } catch (e) { saved = null; }
state.pan.config = sanitizePanConfig(saved);
state.pan.configDirty = false;
renderPanConfigControls();
}
async function savePanConfig() {
if (window.fongmiBridge && !window.fm) await waitForNativeSdk(1500);
if (window.fongmiBridge && !window.fm) throw new Error("App SDK 未就绪,请稍后再保存");
const previousTmdbKey = tmdbApiKey();
state.tmdb.config = sanitizeTmdbConfig(readTmdbConfigControls());
window.WEBHOME_CONFIG.tmdb.apiKey = state.tmdb.config.apiKey;
state.pan.config = sanitizePanConfig(readPanConfigControls());
await sdk().cache.set(cacheKey("tmdb"), JSON.stringify(state.tmdb.config));
await sdk().cache.set(cacheKey("pan"), JSON.stringify(state.pan.config));
renderTmdbConfigControls();
renderPanConfigControls();
state.tmdb.configDirty = false;
state.pan.configDirty = false;
setPanStatus("配置已保存");
closeConnectionPanel();
toast("配置已保存");
if (previousTmdbKey !== tmdbApiKey()) {
state.status.tmdb = "等待请求";
loadInfo().catch(() => {});
loadCatalog().catch(() => {});
}
}
function readPanConfigControls() {
const checked = Array.from(document.querySelectorAll("#panDiskGrid input:checked")).map((input) => input.value);
return {
apiBase: $("panBaseInput") ? $("panBaseInput").value : "",
username: $("panUserInput") ? $("panUserInput").value : "",
password: $("panPassInput") ? $("panPassInput").value : "",
token: state.pan.config && state.pan.config.token || "",
tokenExpiresAt: state.pan.config && state.pan.config.tokenExpiresAt || 0,
diskTypes: checked,
channels: normalizePanChannels($("panChannelsInput") ? $("panChannelsInput").value : "")
};
}
function renderPanConfigControls() {
const config = state.pan.config || defaultPanConfig();
if ($("panBaseInput")) {
$("panBaseInput").value = config.apiBase;
disablePanelTextEditing($("panBaseInput"));
}
if ($("panChannelsInput")) {
$("panChannelsInput").value = (config.channels || []).join("\n");
disablePanelTextEditing($("panChannelsInput"));
}
if ($("panUserInput")) {
$("panUserInput").value = config.username || "";
disablePanelTextEditing($("panUserInput"));
}
if ($("panPassInput")) {
$("panPassInput").value = config.password || "";
disablePanelTextEditing($("panPassInput"));
}
const grid = $("panDiskGrid");
if (!grid) return;
const selected = new Set(config.diskTypes || []);
grid.replaceChildren(...PAN_DISK_TYPES.map((disk) => {
const label = document.createElement("label");
label.innerHTML = `<input class="focusable" type="checkbox" value="${escapeAttr(disk.id)}" ${selected.has(disk.id) ? "checked" : ""}><span>${escapeHtml(disk.name)}</span>`;
return label;
}));
}
function markPanConfigDirty(event) {
// 只标记实际改动的字段:改 TMDB Key 才标 tmdb 脏,其余算 pan 脏。
// 避免改盘搜设置时误把 tmdb 标脏,从而挡住 TMDB 配置的远端刷新(见 preserveDirty)。
const target = event && event.target;
if (target && target.id === "tmdbKeyInput") {
if (state.tmdb) state.tmdb.configDirty = true;
} else {
state.pan.configDirty = true;
}
}
function panDiskName(type) {
const normalized = normalizePanDiskType(type);
const item = PAN_DISK_TYPES.find((disk) => disk.id === normalized);
return item ? item.name : normalized || "网盘";
}
function panApi(path) {
const base = normalizePanBase((state.pan.config || defaultPanConfig()).apiBase);
if (base.endsWith("/api") && path.startsWith("/api/")) return base + path.slice(4);
return base + path;
}
function panSearchDiskTypes(config) {
const types = ((config && config.diskTypes) || []).map(normalizePanDiskType).filter(Boolean);
return Array.from(new Set(types));
}
function panSearchPayload(keyword, config) {
const payload = {
kw: keyword,
res: "merge",
src: "all",
cloud_types: panSearchDiskTypes(config)
};
const channels = normalizePanChannels(config && config.channels || []);
if (channels.length) payload.channels = channels;
return payload;
}
async function persistPanConfigQuietly() {
try { await sdk().cache.set(cacheKey("pan"), JSON.stringify(state.pan.config || defaultPanConfig())); } catch (e) {}
}
async function ensurePanAuthHeaders() {
const config = state.pan.config || defaultPanConfig();
if (!config.username || !config.password) return {};
if (config.token && Number(config.tokenExpiresAt || 0) * 1000 > Date.now() + 60000) return { Authorization: "Bearer " + config.token };
const response = await postJson(panApi("/api/auth/login"), { username: config.username, password: config.password }, 12, {});
const data = response && response.data ? response.data : response;
const token = data && data.token;
if (!token) throw new Error(response && (response.error || response.message) || "盘搜认证失败");
state.pan.config = sanitizePanConfig(Object.assign({}, config, { token, tokenExpiresAt: data.expires_at || 0 }));
await persistPanConfigQuietly();
return { Authorization: "Bearer " + token };
}
function openHotDb() {
if (!("indexedDB" in window)) return Promise.resolve(null);
if (state.hot.db) return Promise.resolve(state.hot.db);
if (state.hot.dbPromise) return state.hot.dbPromise;
state.hot.dbPromise = new Promise((resolve) => {
const req = indexedDB.open(HOT_DB_NAME, HOT_DB_VERSION);
req.onupgradeneeded = () => {
const db = req.result;
db.createObjectStore("media", { keyPath: "m" });
const store = db.createObjectStore("userVector", { keyPath: "u" });
store.createIndex("u", "u", { unique: false });
store.createIndex("e", "e", { unique: false });
store.createIndex("x", "x", { unique: false });
db.createObjectStore("relayCursor", { keyPath: "relay" });
};
req.onsuccess = () => {
state.hot.db = req.result;
state.hot.dbPromise = null;
state.hot.idb = true;
resolve(state.hot.db);
};
req.onerror = () => {
state.hot.dbPromise = null;
resolve(null);
};
req.onblocked = () => setStatus("nostr", "本地索引升级等待");
});
return state.hot.dbPromise;
}
function hotWindowStart() {
return Math.floor((Date.now() - HOT_WINDOW_MS) / 1000);
}
function hotBackupWindowStart() {
return Math.max(hotWindowStart(), hotNow() - HOT_BACKUP_WINDOW_SECONDS);
}
function hotNow() {
return Math.floor(Date.now() / 1000);
}
function hotToday() {
return Math.floor(hotNow() / HOT_DAY_SECONDS);
}
function hotExpiresAt(createdAt) {
return Number(createdAt || hotNow()) + HOT_WINDOW_SECONDS;
}
function hotExpiresDay(expiresAt) {
return Math.ceil(Number(expiresAt || hotNow()) / HOT_DAY_SECONDS);
}
function hotUserKey(pubkey) {
return String(pubkey || "").slice(0, 32);
}
function normalizeDeleteState(raw) {
const now = Date.now();
const users = {};
Object.entries(raw && raw.users || {}).forEach(([pubkey, marker]) => {
const key = String(pubkey || "").trim();
if (!key || !marker || typeof marker !== "object") return;
const expiresAt = Number(marker.expiresAt || 0);
if (expiresAt && expiresAt <= now) return;
users[key] = {
cutoff: Math.max(0, Number(marker.cutoff || 0)),
blockPublishUntil: Math.max(0, Number(marker.blockPublishUntil || 0)),
expiresAt: expiresAt || now + DELETE_TOMBSTONE_TTL_MS,
updatedAt: Math.max(0, Number(marker.updatedAt || 0))
};
});
return { loaded: true, users };
}
async function ensureDeleteStateLoaded() {
if (state.deleteState.loaded) return state.deleteState;
let saved = null;
try { saved = safeJson(await sdk().cache.get(cacheKey("deleteState")), null); } catch (e) { saved = null; }
state.deleteState = normalizeDeleteState(saved);
return state.deleteState;
}
async function persistDeleteState() {
await sdk().cache.set(cacheKey("deleteState"), JSON.stringify({ users: state.deleteState.users || {} }));
}
async function markIdentityDeletedLocally(identity) {
if (!identity || !identity.pubkey) return null;
await ensureDeleteStateLoaded();
const now = Date.now();
const marker = {
cutoff: hotNow() + 1,
blockPublishUntil: now + DELETE_REPUBLISH_BLOCK_MS,
expiresAt: now + DELETE_TOMBSTONE_TTL_MS,
updatedAt: now
};
state.deleteState.users[identity.pubkey] = marker;
await persistDeleteState().catch(() => {});
return marker;
}
function localDeleteMarker(pubkey) {
const marker = state.deleteState.users && state.deleteState.users[String(pubkey || "")];
if (!marker) return null;
if (Number(marker.expiresAt || 0) && Number(marker.expiresAt || 0) <= Date.now()) {
delete state.deleteState.users[String(pubkey || "")];
persistDeleteState().catch(() => {});
return null;
}
return marker;
}
function isLocallyDeletedPreferenceEvent(event) {
const pubkey = eventUserKey(event);
const marker = localDeleteMarker(pubkey);
if (!marker) return false;
return Number(event && event.created_at || 0) <= Number(marker.cutoff || 0);
}
async function isPreferencePublishBlocked() {
await ensureDeleteStateLoaded();
const pubkey = state.identity && state.identity.pubkey;
const marker = localDeleteMarker(pubkey);
return !!(marker && Number(marker.blockPublishUntil || 0) > Date.now());
}
function eventExpiration(event) {
const tag = (event && event.tags || []).find((item) => item && item[0] === "expiration");
const value = tag ? Number(tag[1]) : 0;
return Number.isFinite(value) ? value : 0;
}
function eventExpired(event) {
const expiration = eventExpiration(event);
return expiration > 0 && expiration <= hotNow();
}
function idbRequest(req) {
return new Promise((resolve, reject) => {
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
function idbDone(tx) {
return new Promise((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error);
});
}
async function hotStoreGet(storeName, key) {
const db = await openHotDb();
if (!db) return null;
return idbRequest(db.transaction(storeName, "readonly").objectStore(storeName).get(key)).catch(() => null);
}
async function hotLoadIndex() {
const db = await openHotDb();
if (!db) {
state.hot.ready = true;
return;
}
try {
const tx = db.transaction("media", "readonly");
const media = await idbRequest(tx.objectStore("media").getAll());
state.hot.media = new Map((media || []).filter((item) => item && item.m).map((item) => [item.m, item]));
state.hot.items = buildHotItemsFromIndex();
hotPruneExpired().catch(() => {});
state.hot.ready = true;
} catch (e) {
state.hot.ready = true;
}
scheduleRender();
}
async function hotIngestEvent(event) {
const changed = await hotIngestEvents([event]);
if (changed) hotRefreshItems();
return changed;
}
function hotIngestEvents(events) {
const batch = Array.isArray(events) ? events.slice() : [];
const run = () => hotIngestEventsNow(batch).catch(() => false);
state.hot.ingestQueue = state.hot.ingestQueue.then(run, run);
return state.hot.ingestQueue;
}
async function hotIngestEventsNow(events) {
await ensureDeleteStateLoaded();
const incoming = new Map();
for (const event of events || []) {
const vector = heatVectorFromEvent(event);
if (!vector) continue;
const old = incoming.get(vector.u);
if (!old || hotIsNewerVector(vector, old)) incoming.set(vector.u, vector);
}
if (!incoming.size) return false;
const vectors = Array.from(incoming.values());
const db = await openHotDb();
const existing = await hotGetVectors(vectors.map((vector) => vector.u), db);
const changedMedia = new Set();
const vectorWrites = [];
let changed = false;
for (const vector of vectors) {
const old = existing.get(vector.u) || (!db ? state.hot.users.get(vector.u) : null);
if (old && !hotIsNewerVector(vector, old)) continue;
if (!old && !vector.i.length) continue;
const mediaKeys = hotApplyVectorDiffToState(old, vector);
mediaKeys.forEach((key) => changedMedia.add(key));
vectorWrites.push(vector);
hotCacheVector(hotPersistVector(vector), db);
changed = true;
}
if (!changed) return false;
if (db) {
const tx = db.transaction(["media", "userVector"], "readwrite");
const mediaStore = tx.objectStore("media");
const vectorStore = tx.objectStore("userVector");
changedMedia.forEach((mediaKey) => {
const media = state.hot.media.get(mediaKey);
if (media && Number(media.c || 0) > 0) mediaStore.put(media);
else mediaStore.delete(mediaKey);
});
vectorWrites.forEach((vector) => vectorStore.put(hotPersistVector(vector)));
await idbDone(tx).catch(() => {});
}
return true;
}
function heatVectorFromEvent(event) {
if (eventExpired(event) || getD(event) !== HOT_VECTOR_D) return null;
if (isLocallyDeletedPreferenceEvent(event)) return null;
const content = eventContent(event);
if (!content || content.v !== HOT_VECTOR_VERSION) return null;
const pubkey = eventUserKey(event);
if (!pubkey || pubkey === "local") return null;
const userKey = hotUserKey(pubkey);
if (!userKey) return null;
const createdAt = Number(event.created_at || 0);
if (!Number.isFinite(createdAt) || createdAt <= 0) return null;
const expiresAt = eventExpiration(event) || hotExpiresAt(createdAt);
const expiresDay = hotExpiresDay(expiresAt);
if (expiresDay <= hotToday()) return null;
const wireItems = normalizeWireVectorItems(content.i || [], createdAt);
const items = wireItems.map(hotStoredVectorItem).filter(Boolean);
return {
u: userKey,
ts: createdAt,
id: event.id || "",
e: expiresDay,
x: hotVectorNextPruneDay(items, expiresDay),
i: items,
meta: wireItems
};
}
function normalizeWireVectorItems(items, createdAt) {
const map = new Map();
const fallbackDay = Math.floor(Number(createdAt || hotNow()) / HOT_DAY_SECONDS);
(Array.isArray(items) ? items : []).forEach((raw) => {
const item = hotNormalizeWireItem(raw, fallbackDay, false);
if (!item) return;
const key = hotVectorItemMediaKey(item);
const old = map.get(key);
if (!old || hotVectorItemDay(item) >= hotVectorItemDay(old)) map.set(key, item);
});
return Array.from(map.values())
.sort((a, b) => hotVectorItemDay(b) - hotVectorItemDay(a) || hotVectorItemMediaKey(a).localeCompare(hotVectorItemMediaKey(b)))
.slice(0, HOT_USER_VECTOR_LIMIT);
}
function normalizeStoredVectorItems(items, createdAt) {
const map = new Map();
const fallbackDay = Math.floor(Number(createdAt || hotNow()) / HOT_DAY_SECONDS);
(Array.isArray(items) ? items : []).forEach((raw) => {
const item = hotNormalizeStoredItem(raw, fallbackDay, false);
if (!item) return;
const key = hotVectorItemMediaKey(item);
const old = map.get(key);
if (!old || hotVectorItemDay(item) >= hotVectorItemDay(old)) map.set(key, item);
});
return Array.from(map.values())
.sort((a, b) => hotVectorItemDay(b) - hotVectorItemDay(a) || hotVectorItemMediaKey(a).localeCompare(hotVectorItemMediaKey(b)))
.slice(0, HOT_USER_VECTOR_LIMIT);
}
function hotNormalizeWireItem(raw, fallbackDay, keepExpired) {
const item = hotNormalizeVectorParts(raw, fallbackDay, keepExpired);
if (!item) return null;
const title = String(item.title || "").trim().slice(0, HOT_TITLE_LIMIT);
const poster = hotCompactPoster(item.poster || "").slice(0, HOT_POSTER_LIMIT);
if (!title || !poster) return null;
return [item.typeCode, item.tmdbId, item.day, title, poster];
}
function hotNormalizeStoredItem(raw, fallbackDay, keepExpired) {
const item = hotNormalizeVectorParts(raw, fallbackDay, keepExpired);
return item ? [item.typeCode, item.tmdbId, item.day] : null;
}
function hotNormalizeVectorParts(raw, fallbackDay, keepExpired) {
let mediaType = "";
let tmdbId = "";
let day = fallbackDay;
let title = "";
let poster = "";
if (Array.isArray(raw)) {
mediaType = String(raw[0] || "");
tmdbId = raw[1];
day = Number(raw[2] || fallbackDay);
title = String(raw[3] || "");
poster = String(raw[4] || "");
} else if (raw && typeof raw === "object") {
mediaType = String(raw.mt || raw.mediaType || raw.c || "");
tmdbId = raw.tid || raw.tmdbId || raw.id || "";
day = Number(raw.d || raw.day || fallbackDay);
title = String(raw.t || raw.title || "");
poster = String(raw.p || raw.pic || raw.poster || "");
}
const typeCode = hotMediaTypeCode(mediaType);
if (!typeCode || tmdbId === "" || tmdbId === null || tmdbId === undefined || !Number.isFinite(day)) return null;
const idNumber = Number(tmdbId);
tmdbId = Number.isInteger(idNumber) && idNumber > 0 ? idNumber : String(tmdbId);
day = Math.floor(day);
if (!keepExpired && day < hotMinItemDay()) return null;
if (day > hotToday()) day = hotToday();
return { typeCode, tmdbId, day, title, poster };
}
function hotStoredVectorItem(item) {
return hotNormalizeStoredItem(item, hotToday(), true);
}
function hotPersistVector(vector) {
return {
u: vector.u,
ts: vector.ts,
id: vector.id || "",
e: vector.e,
x: vector.x,
i: normalizeStoredVectorItems(vector.i || [], vector.ts || hotNow())
};
}
function hotMediaTypeCode(mediaType) {
const value = String(mediaType || "");
if (value === "t" || value === "tv") return "t";
if (value === "m" || value === "movie") return "m";
return "";
}
function hotMediaTypeFromCode(code) {
const value = String(code || "");
if (value === "t") return "tv";
if (value === "m") return "movie";
return value === "tv" || value === "movie" ? value : "";
}
function hotCompactPoster(url) {
const value = String(url || "").trim();
if (!value) return "";
if (value.startsWith("/")) return value;
const bases = [window.WEBHOME_CONFIG.tmdb.imageBase, window.WEBHOME_CONFIG.tmdb.backdropBase].filter(Boolean);
for (const base of bases) {
if (value.startsWith(base)) return value.slice(base.length) || "";
}
try {
const parsed = new URL(value);
if (parsed.hostname === "images.tmdb.org") return parsed.pathname.replace(/^\/t\/p\/[^/]+/, "") || parsed.pathname;
} catch (e) {}
return value;
}
function hotPosterUrl(path) {
const value = String(path || "").trim();
if (!value) return "";
if (/^https?:\/\//i.test(value)) return value;
return value.startsWith("/") ? imageUrl(value, false) : value;
}
function hotMinItemDay() {
return hotToday() - HOT_WINDOW_DAYS + 1;
}
function hotVectorNextPruneDay(items, expiresDay) {
let day = Number(expiresDay || hotToday() + HOT_WINDOW_DAYS);
(items || []).forEach((item) => {
day = Math.min(day, hotVectorItemDay(item) + HOT_WINDOW_DAYS);
});
return Math.max(1, Math.floor(day || hotToday() + HOT_WINDOW_DAYS));
}
function hotVectorItemMediaKey(item) {
const mediaType = hotMediaTypeFromCode(item && item[0] || "");
const tmdbId = item && item[1] !== undefined && item[1] !== null ? String(item[1]) : "";
return mediaType && tmdbId ? `tmdb:${mediaType}:${tmdbId}` : "";
}
function hotVectorItemDay(item) {
return Math.floor(Number(item && item[2] || 0));
}
function hotVectorItemLatest(item) {
return hotVectorItemDay(item) * HOT_DAY_SECONDS;
}
function hotVectorItemMedia(item) {
const mediaType = hotMediaTypeFromCode(item && item[0] || "");
const tmdbId = item && item[1] !== undefined && item[1] !== null ? String(item[1]) : "";
return {
m: hotVectorItemMediaKey(item),
t: item && item[3] || "",
mt: mediaType,
tid: tmdbId,
p: hotCompactPoster(item && item[4] || "")
};
}
function hotVectorItemMap(items) {
const map = new Map();
(Array.isArray(items) ? items : []).forEach((item) => {
const key = hotVectorItemMediaKey(item);
if (!key) return;
const old = map.get(key);
if (!old || hotVectorItemDay(item) >= hotVectorItemDay(old)) map.set(key, item);
});
return map;
}
function hotActiveVectorItems(vector) {
return normalizeStoredVectorItems(vector && vector.i || [], vector && vector.ts || hotNow());
}
function hotVectorHasMedia(vector, mediaKey) {
if (!vector || !mediaKey) return false;
return hotVectorItemMap(hotActiveVectorItems(vector)).has(mediaKey);
}
function hotIsNewerVector(next, old) {
if (!old) return true;
const nextTs = Number(next && next.ts || 0);
const oldTs = Number(old && old.ts || 0);
if (nextTs !== oldTs) return nextTs > oldTs;
return String(next && next.id || "") > String(old && old.id || "");
}
function hotIsLocalUserKey(userKey) {
return !!(state.identity && userKey && hotUserKey(state.identity.pubkey) === userKey);
}
function hotCacheVector(vector, db) {
if (!vector || !vector.u) return;
if (!db || hotIsLocalUserKey(vector.u)) state.hot.users.set(vector.u, vector);
else state.hot.users.delete(vector.u);
}
function hotApplyVectorDiffToState(oldVector, newVector) {
const oldMap = hotVectorItemMap(oldVector && oldVector.i || []);
const newMap = hotVectorItemMap(newVector && newVector.i || []);
const metaMap = hotVectorItemMap(newVector && newVector.meta || []);
const changedMedia = new Set();
oldMap.forEach((item, mediaKey) => {
if (newMap.has(mediaKey)) return;
const media = state.hot.media.get(mediaKey);
if (!media) return;
media.c = Math.max(0, Number(media.c || 0) - 1);
if (media.c > 0) state.hot.media.set(mediaKey, media);
else state.hot.media.delete(mediaKey);
changedMedia.add(mediaKey);
});
newMap.forEach((item, mediaKey) => {
const oldMedia = state.hot.media.get(mediaKey);
const delta = oldMap.has(mediaKey) && oldMedia ? 0 : 1;
const meta = metaMap.get(mediaKey) || item;
const media = mergeHotMedia(oldMedia, hotVectorItemMedia(meta), hotVectorItemLatest(item), delta);
if (media.c > 0 && media.t && media.p) state.hot.media.set(mediaKey, media);
changedMedia.add(mediaKey);
});
return changedMedia;
}
function hotGetVectors(userKeys, db) {
const uniqueKeys = Array.from(new Set((userKeys || []).filter(Boolean)));
const map = new Map();
if (!uniqueKeys.length) return Promise.resolve(map);
if (!db) {
uniqueKeys.forEach((key) => {
const vector = state.hot.users.get(key);
if (vector) map.set(key, vector);
});
return Promise.resolve(map);
}
return new Promise((resolve) => {
try {
const tx = db.transaction("userVector", "readonly");
const store = tx.objectStore("userVector");
uniqueKeys.forEach((key) => {
const req = store.get(key);
req.onsuccess = () => {
if (req.result) map.set(key, req.result);
};
});
tx.oncomplete = () => resolve(map);
tx.onerror = () => resolve(map);
tx.onabort = () => resolve(map);
} catch (e) {
resolve(map);
}
});
}
async function hotGetUserVector(userKey, db) {
if (!userKey) return null;
const map = await hotGetVectors([userKey], db || await openHotDb());
return map.get(userKey) || state.hot.users.get(userKey) || null;
}
async function hotGetMyVector() {
const identity = state.identity && state.identity.pubkey;
if (!identity) return null;
const userKey = hotUserKey(identity);
const vector = await hotGetUserVector(userKey);
if (vector) state.hot.users.set(userKey, vector);
return vector;
}
async function hotLoadMyVector() {
const vector = await hotGetMyVector();
renderMetrics();
return vector;
}
async function hotPruneExpired() {
const db = await openHotDb();
if (!db) return;
let changed = false;
for (;;) {
const vectors = await hotGetPrunableVectors(db, hotToday(), HOT_PRUNE_BATCH);
if (!vectors.length) break;
const changedMedia = new Set();
const vectorWrites = [];
const vectorDeletes = [];
for (const old of vectors) {
const expiresDay = Number(old && old.e || 0);
const nextItems = expiresDay > hotToday() ? normalizeStoredVectorItems(old.i || [], old.ts || hotNow()) : [];
const next = expiresDay > hotToday()
? Object.assign({}, old, { i: nextItems, x: hotVectorNextPruneDay(nextItems, expiresDay) })
: null;
if (next && hotSameVectorItems(old.i || [], next.i || []) && Number(old.x || 0) === Number(next.x || 0)) continue;
hotApplyVectorDiffToState(old, next).forEach((key) => changedMedia.add(key));
if (next) {
vectorWrites.push(next);
hotCacheVector(hotPersistVector(next), db);
} else {
vectorDeletes.push(old.u);
state.hot.users.delete(old.u);
}
changed = true;
}
if (changedMedia.size || vectorWrites.length || vectorDeletes.length) {
const tx = db.transaction(["media", "userVector"], "readwrite");
const mediaStore = tx.objectStore("media");
const vectorStore = tx.objectStore("userVector");
changedMedia.forEach((mediaKey) => {
const media = state.hot.media.get(mediaKey);
if (media && Number(media.c || 0) > 0) mediaStore.put(media);
else mediaStore.delete(mediaKey);
});
vectorWrites.forEach((vector) => vectorStore.put(hotPersistVector(vector)));
vectorDeletes.forEach((userKey) => vectorStore.delete(userKey));
await idbDone(tx).catch(() => {});
}
if (vectors.length < HOT_PRUNE_BATCH) break;
}
if (changed) scheduleHotRefresh();
}
function hotSameVectorItems(a, b) {
return JSON.stringify(a || []) === JSON.stringify(b || []);
}
function hotGetPrunableVectors(db, expiresDay, limit) {
return new Promise((resolve) => {
const vectors = [];
try {
const tx = db.transaction("userVector", "readonly");
const index = tx.objectStore("userVector").index("x");
const req = index.openCursor(IDBKeyRange.upperBound(expiresDay));
req.onsuccess = () => {
const cursor = req.result;
if (!cursor) return;
vectors.push(cursor.value);
if (limit && vectors.length >= limit) return;
cursor.continue();
};
tx.oncomplete = () => resolve(vectors);
tx.onerror = () => resolve(vectors);
tx.onabort = () => resolve(vectors);
} catch (e) {
resolve(vectors);
}
});
}
function hotRefreshItems() {
clearTimeout(state.hot.refreshTimer);
state.hot.refreshTimer = 0;
state.hot.items = buildHotItemsFromIndex();
}
function scheduleHotRefresh(delay) {
clearTimeout(state.hot.refreshTimer);
const busy = Object.keys(state.relay.backfillBusy || {}).length > 0;
const wait = Number.isFinite(delay) ? delay : busy ? HOT_REFRESH_BACKFILL_MS : HOT_REFRESH_IDLE_MS;
state.hot.refreshTimer = setTimeout(() => {
state.hot.refreshTimer = 0;
hotRefreshItems();
scheduleRender();
if (isNostrRefreshActive()) updateNostrRefreshProgress({ indexed: state.hot.items.length });
maybeFinishNostrRefresh();
}, wait);
}
function mergeHotMedia(oldMedia, media, latestAt, countDelta) {
const value = {
m: media && media.m || oldMedia && oldMedia.m || "",
t: media && media.t || oldMedia && oldMedia.t || "",
mt: media && media.mt || oldMedia && oldMedia.mt || "",
tid: media && media.tid || oldMedia && oldMedia.tid || "",
p: media && hotCompactPoster(media.p) || oldMedia && oldMedia.p || "",
c: Math.max(0, Number(oldMedia && oldMedia.c || 0) + Number(countDelta || 0)),
l: Math.max(Number(oldMedia && oldMedia.l || 0), Number(latestAt || 0))
};
return value;
}
function buildHotItemsFromIndex() {
return Array.from(state.hot.media.values())
.map((item) => {
const pic = hotPosterUrl(item.p || "");
const people = Number(item.c || 0);
return {
id: item.m,
mediaKey: item.m,
title: item.t || "",
mediaType: item.mt || "",
tmdbId: item.tid || "",
source: "tmdb",
pic,
image: pic,
people,
count: people,
latest: item.l || 0,
lastEventAt: item.l || 0,
remark: ""
};
})
.filter((item) => item && item.people > 0 && hasPoster(item))
.sort((a, b) => b.people - a.people || b.lastEventAt - a.lastEventAt)
.slice(0, HOT_RENDER_LIMIT);
}
async function hotClearIndex(onlyMine) {
const db = await openHotDb();
if (!db) {
state.hot.media.clear();
state.hot.users.clear();
state.hot.items = [];
return;
}
if (!onlyMine) {
const tx = db.transaction(["media", "userVector", "relayCursor"], "readwrite");
tx.objectStore("media").clear();
tx.objectStore("userVector").clear();
tx.objectStore("relayCursor").clear();
await idbDone(tx).catch(() => {});
state.hot.media.clear();
state.hot.users.clear();
state.hot.items = [];
return;
}
const identity = state.identity && state.identity.pubkey;
if (!identity) return;
const mine = await hotGetUserVector(hotUserKey(identity), db);
if (mine) await hotRemoveVectors([mine]);
}
async function hotRemoveVectors(vectors, options) {
const rows = (vectors || []).filter((vector) => vector && vector.u);
if (!rows.length) return;
const db = await openHotDb();
const changedMedia = new Set();
rows.forEach((vector) => {
hotApplyVectorDiffToState(vector, null).forEach((key) => changedMedia.add(key));
state.hot.users.delete(vector.u);
});
if (db) {
const tx = db.transaction(["media", "userVector"], "readwrite");
const mediaStore = tx.objectStore("media");
const vectorStore = tx.objectStore("userVector");
changedMedia.forEach((mediaKey) => {
const media = state.hot.media.get(mediaKey);
if (media && Number(media.c || 0) > 0) mediaStore.put(media);
else mediaStore.delete(mediaKey);
});
rows.forEach((vector) => vectorStore.delete(vector.u));
await idbDone(tx).catch(() => {});
}
if (!options || options.refresh !== false) scheduleHotRefresh();
}
function setStatus(key, value) {
state.status[key] = value;
renderConnection();
}
function setPanStatus(value) {
state.status.pan = value;
renderConnection();
}
// ── 刷新榜单进度(状态面板「刷新」项)────────────────────────────
function setRefreshStatus(value) {
state.status.refresh = value || "未执行";
renderConnection();
}
function updateNostrRefreshProgress(patch) {
const current = state.relay.refresh || {};
state.relay.refresh = Object.assign({
active: false,
phase: "未执行",
connected: 0,
subscribeDone: 0,
subEvents: 0,
recentPages: 0,
recentEvents: 0,
historyPages: 0,
historyEvents: 0,
indexed: 0,
relay: "",
startedAt: 0,
finishedAt: 0
}, current, patch || {});
const info = state.relay.refresh;
const relays = window.WEBHOME_CONFIG.nostr.relays;
const phase = info.phase || "刷新中";
const connected = Number(info.connected || state.relay.connected || 0);
const subscribeDone = Number(info.subscribeDone || state.relay.subscribeDone || 0);
const subEvents = Number(info.subEvents || 0);
const recentPages = Number(info.recentPages || 0);
const recentEvents = Number(info.recentEvents || 0);
const historyPages = Number(info.historyPages || 0);
const historyEvents = Number(info.historyEvents || 0);
const backfillBusy = Object.keys(state.relay.backfillBusy || {}).length;
const backfillTimers = Object.values(state.relay.backfillTimers || {}).filter(Boolean).length;
const parts = [];
if (phase === "完成") parts.push("已完成");
else if (info.active) parts.push(`进行中:${phase}`);
else parts.push(phase);
if (phase !== "未执行") {
parts.push(`连接 ${connected}/${relays.length},订阅完成 ${subscribeDone}/${relays.length}`);
parts.push(`订阅事件 ${subEvents} 条`);
parts.push(`近7天回填 ${recentPages} 页 / ${recentEvents} 条`);
parts.push(`历史回填 ${historyPages} 页 / ${historyEvents} 条`);
if (info.active && (backfillBusy || backfillTimers)) parts.push(`回填队列 ${backfillBusy} 个执行中 / ${backfillTimers} 个等待`);
if (info.active && state.hot.refreshTimer) parts.push("榜单列表待更新");
if (info.relay) parts.push(`当前 relay${info.relay}`);
}
parts.push(`当前榜单 ${state.hot.items.length} 条`);
setRefreshStatus(parts.join("\n"));
}
function isNostrRefreshActive(token) {
const info = state.relay.refresh;
return !!(info && info.active && (token == null || token === state.relay.subscribeToken));
}
function maybeFinishNostrRefresh(token) {
if (!isNostrRefreshActive(token)) return;
const relays = window.WEBHOME_CONFIG.nostr.relays;
const busy = Object.keys(state.relay.backfillBusy || {}).length;
const timers = Object.values(state.relay.backfillTimers || {}).filter(Boolean).length;
if (state.relay.subscribeDone < relays.length || busy || timers || state.hot.refreshTimer) return;
updateNostrRefreshProgress({ active: false, phase: "完成", finishedAt: Date.now(), indexed: state.hot.items.length });
toast(`榜单刷新完成:${state.hot.items.length}条`);
}
// 清理排行索引但保留本机身份向量,便于刷新后重新聚合
async function hotClearRankingIndexForRefresh() {
const db = await openHotDb();
const localKey = state.identity ? hotUserKey(state.identity.pubkey) : "";
const localVector = localKey ? await hotGetUserVector(localKey, db) : null;
const localMedia = [];
hotActiveVectorItems(localVector).forEach((item) => {
const mediaKey = hotVectorItemMediaKey(item);
const media = mediaKey ? state.hot.media.get(mediaKey) : null;
if (media && media.m) localMedia.push(Object.assign({}, media, { c: 1 }));
});
await hotClearIndex(false);
if (localVector && localVector.u) state.hot.users.set(localVector.u, localVector);
localMedia.forEach((media) => state.hot.media.set(media.m, media));
if (db && (localVector && localVector.u || localMedia.length)) {
const tx = db.transaction(["media", "userVector"], "readwrite");
const mediaStore = tx.objectStore("media");
const vectorStore = tx.objectStore("userVector");
if (localVector && localVector.u) vectorStore.put(localVector);
localMedia.forEach((media) => mediaStore.put(media));
await idbDone(tx).catch(() => {});
}
state.hot.items = buildHotItemsFromIndex();
}
function resetRelaySubscribeState() {
Object.values(state.relay.backfillTimers || {}).forEach((timer) => clearTimeout(timer));
state.relay.connected = 0;
state.relay.subscribeStarted = false;
state.relay.subscribeDone = 0;
state.relay.subscribeFinished = {};
state.relay.statuses = {};
state.relay.backfillBusy = {};
state.relay.backfillTimers = {};
state.relay.backfillCandidates = {};
state.relay.primaryBackfillRelay = "";
return ++state.relay.subscribeToken;
}
async function forceRefreshNostrRanking() {
if (forceRefreshNostrRanking.busy) return;
forceRefreshNostrRanking.busy = true;
const button = $("refreshNostrBtn");
if (button) button.textContent = "刷新中";
try {
setStatus("nostr", "刷新榜单中");
toast("开始刷新 Nostr 榜单");
updateNostrRefreshProgress({ active: true, phase: "清理索引", startedAt: Date.now(), finishedAt: 0, connected: 0, subscribeDone: 0, subEvents: 0, recentPages: 0, recentEvents: 0, historyPages: 0, historyEvents: 0, indexed: 0, relay: "" });
clearRelayBackfillTimers();
resetRelaySubscribeState();
await hotClearRankingIndexForRefresh();
renderMetrics();
if (state.activeList === "all") renderActiveGrid();
updateNostrRefreshProgress({ phase: "连接 relay", indexed: state.hot.items.length });
subscribeNostr();
} catch (e) {
setStatus("nostr", "刷新榜单失败");
updateNostrRefreshProgress({ active: false, phase: "刷新失败", finishedAt: Date.now(), indexed: state.hot.items.length });
toast(e.message || "刷新榜单失败");
} finally {
forceRefreshNostrRanking.busy = false;
renderConnection();
}
}
// ── TMDB Key 配置 ───────────────────────────────────────────────
function sanitizeTmdbConfig(value) {
const raw = value && typeof value === "object" ? value : {};
return { apiKey: String(raw.apiKey || "").trim() || DEFAULT_TMDB_API_KEY };
}
function tmdbApiKey() {
return String(window.WEBHOME_CONFIG.tmdb.apiKey || "").trim() || DEFAULT_TMDB_API_KEY;
}
async function initTmdbConfig(options) {
options = options || {};
if (window.fongmiBridge && !window.fm) await waitForNativeSdk(options.timeout == null ? 1500 : options.timeout);
if (options.preserveDirty && state.tmdb.configDirty) return;
let saved = null;
try { saved = safeJson(await sdk().cache.get(cacheKey("tmdb")), null); } catch (e) { saved = null; }
state.tmdb.config = sanitizeTmdbConfig(saved);
window.WEBHOME_CONFIG.tmdb.apiKey = state.tmdb.config.apiKey;
state.tmdb.configDirty = false;
renderTmdbConfigControls();
}
function readTmdbConfigControls() {
return { apiKey: $("tmdbKeyInput") ? $("tmdbKeyInput").value : "" };
}
function renderTmdbConfigControls() {
const config = state.tmdb.config || sanitizeTmdbConfig(null);
if ($("tmdbKeyInput")) {
$("tmdbKeyInput").value = config.apiKey || DEFAULT_TMDB_API_KEY;
disablePanelTextEditing($("tmdbKeyInput"));
}
}
// ── 界面偏好(首页全屏开关)────────────────────────────────────
function sanitizeUiPrefs(value) {
const raw = value && typeof value === "object" ? value : {};
return { homeFullscreenEnabled: raw.homeFullscreenEnabled === false ? false : true };
}
function applyUiPrefs() {
const prefs = sanitizeUiPrefs(state.uiPrefs || {});
state.uiPrefs = Object.assign({ loaded: state.uiPrefs && state.uiPrefs.loaded }, prefs);
renderUiPrefsControls();
syncNativeToolbarForRoute();
}
async function initUiPrefs(options) {
options = options || {};
if (window.fongmiBridge && !window.fm) await waitForNativeSdk(options.timeout == null ? 1500 : options.timeout);
let saved = null;
try { saved = safeJson(await sdk().cache.get(cacheKey("uiPrefs")), null); } catch (e) { saved = null; }
state.uiPrefs = Object.assign({ loaded: true }, sanitizeUiPrefs(saved));
applyUiPrefs();
}
function renderUiPrefsControls() {
const fullscreen = isHomeFullscreenEnabled() ? "on" : "off";
document.querySelectorAll("[data-home-fullscreen]").forEach((button) => {
const active = button.dataset.homeFullscreen === fullscreen;
button.classList.toggle("active", active);
button.setAttribute("aria-pressed", active ? "true" : "false");
});
}
async function setHomeFullscreenEnabled(enabled) {
state.uiPrefs = Object.assign({}, state.uiPrefs || {}, sanitizeUiPrefs(state.uiPrefs || {}), { homeFullscreenEnabled: enabled !== false, loaded: true });
applyUiPrefs();
if (window.fongmiBridge && !window.fm) await waitForNativeSdk(1200);
try { await sdk().cache.set(cacheKey("uiPrefs"), JSON.stringify(sanitizeUiPrefs(state.uiPrefs))); } catch (e) {}
syncNativeToolbarForRoute();
toast(isHomeFullscreenEnabled() ? "首页全屏:已开启" : "首页全屏:已关闭");
}
function setRelayStatus(relay, value) {
state.relay.statuses[relay] = value;
renderConnection();
}
function relayFailedAll() {
const relays = window.WEBHOME_CONFIG.nostr.relays;
const failed = relays.filter((relay) => {
const value = state.relay.statuses[relay];
return value === "失败" || value === "断开";
}).length;
return failed >= relays.length;
}
function finishRelaySubscribe(relay, status) {
if (state.relay.subscribeFinished[relay]) return;
state.relay.subscribeFinished[relay] = true;
if (status) setRelayStatus(relay, status);
state.relay.subscribeDone += 1;
if (isNostrRefreshActive()) updateNostrRefreshProgress({ phase: "回填同步", subscribeDone: state.relay.subscribeDone, relay: shortRelay(relay) });
if (state.relay.subscribeDone >= window.WEBHOME_CONFIG.nostr.relays.length) {
const primary = choosePrimaryBackfillRelay(true);
if (primary) scheduleRelayBackfill(primary);
}
if (state.activeList === "all") renderActiveGrid();
maybeFinishNostrRefresh();
}
function renderConnection() {
if (!$("connectionDot")) return;
const relays = window.WEBHOME_CONFIG.nostr.relays;
const relayValues = relays.map((relay) => `${shortRelay(relay)} ${state.relay.statuses[relay] || "等待"}`);
const connected = Object.values(state.relay.statuses).filter((value) => value === "已连接").length;
const failed = Object.values(state.relay.statuses).filter((value) => value === "失败" || value === "断开").length;
const tmdbOk = state.status.tmdb.includes("成功") || state.status.tmdb.includes("完成");
const nostrOk = connected > 0;
const bad = state.status.tmdb.includes("失败") || (!nostrOk && failed === relays.length);
$("statusSdk").textContent = state.status.sdk;
$("statusTmdb").textContent = state.status.tmdb;
$("statusNostr").textContent = `${state.status.nostr} (${connected}/${relays.length})`;
if ($("statusPan")) $("statusPan").textContent = state.status.pan || "未搜索";
$("statusPublish").textContent = state.status.publish;
$("statusIdentity").textContent = state.status.identity;
if ($("statusRefresh")) $("statusRefresh").textContent = state.status.refresh || "未执行";
$("statusRelays").textContent = relayValues.join(" · ");
if ($("refreshNostrBtn")) $("refreshNostrBtn").textContent = state.relay.refresh && state.relay.refresh.active ? "刷新中" : "刷新榜单";
$("connectionDot").className = "dot " + (nostrOk ? "ok" : bad ? "bad" : "warn");
fitConnectionPanel();
}
function fitConnectionPanel() {
const dock = $("connectionDock");
const body = $("connectionBody");
if (!dock || !body) return;
body.style.setProperty("--connection-shift", "0px");
if (!dock.classList.contains("open")) return;
const doFit = () => {
const dockRect = dock.getBoundingClientRect();
const viewW = window.innerWidth || document.documentElement.clientWidth || 0;
const pad = 8;
const bodyW = Math.min(viewW - pad * 2, 420);
body.style.setProperty("--connection-shift", "0px");
const rect = body.getBoundingClientRect();
let shift = 0;
if (rect.left < pad) shift = pad - rect.left;
else if (rect.right > viewW - pad) shift = viewW - pad - rect.right;
// clamp so panel never goes off left edge
const maxShift = dockRect.right - pad - bodyW;
const minShift = pad - dockRect.right + bodyW;
shift = Math.max(minShift, Math.min(maxShift, shift));
body.style.setProperty("--connection-shift", `${Math.round(shift)}px`);
};
requestAnimationFrame(doFit);
}
function setConnectionPanelOpen(open) {
const dock = $("connectionDock");
const body = $("connectionBody");
const toggle = $("connectionToggle");
if (!dock || !body) return;
dock.classList.toggle("open", !!open);
body.classList.toggle("open", !!open);
if (toggle) toggle.setAttribute("aria-expanded", open ? "true" : "false");
body.setAttribute("aria-hidden", open ? "false" : "true");
if (open) disableConnectionTextEditing();
else if (body.contains(document.activeElement) && toggle) requestAnimationFrame(() => focusRemoteTarget(toggle));
fitConnectionPanel();
scheduleUiSnapshotSave();
}
function toggleConnectionPanel(event) {
if (event) {
event.preventDefault();
event.stopPropagation();
}
setConnectionPanelOpen(!$("connectionDock").classList.contains("open"));
}
function clearRelayBackfillTimers() {
Object.values(state.relay.backfillTimers).forEach((timer) => clearTimeout(timer));
state.relay.backfillTimers = {};
clearTimeout(state.hot.refreshTimer);
state.hot.refreshTimer = 0;
}
function closeConnectionPanel() {
setConnectionPanelOpen(false);
}
function shortRelay(url) {
return String(url || "").replace(/^wss?:\/\//, "").replace(/\/$/, "");
}
async function browserRequest(url, options) {
const init = options || {};
try {
const response = await fetch(url, {
method: init.method || "GET",
headers: init.headers || {},
body: init.method && init.method !== "GET" && init.method !== "HEAD" ? init.body || "" : undefined,
credentials: init.credentials === "include" ? "include" : "same-origin"
});
const headers = {};
response.headers.forEach((value, key) => headers[key] = value);
const body = init.responseType === "json" ? await response.json() : await response.text();
return { ok: response.ok, status: response.status, url: response.url, headers, body };
} catch (e) {
throw e;
}
}
async function requestJson(url, timeout) {
const response = await sdk().req(url, { responseType: "text", timeout: timeout || 18 });
if (response && response.error) throw new Error(response.error);
const body = response && response.body;
if (typeof body === "string") return JSON.parse(body || "{}");
return body || {};
}
async function postJson(url, payload, timeout, headers) {
const response = await sdk().req(url, {
method: "POST",
responseType: "text",
timeout: timeout || 24,
headers: Object.assign({ "Content-Type": "application/json" }, headers || {}),
body: JSON.stringify(payload || {})
});
if (response && response.error) throw new Error(response.error);
if (response && response.ok === false) throw new Error("HTTP " + (response.status || 0));
const body = response && response.body;
const data = typeof body === "string" ? JSON.parse(body || "{}") : body || {};
if (data && data.code && Number(data.code) !== 0 && Number(data.code) !== 200) throw new Error(data.message || data.error || ("HTTP " + data.code));
return data;
}
function today() {
const date = new Date();
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
}
function tmdbUrl(list, page) {
const url = new URL(`${window.WEBHOME_CONFIG.tmdb.apiBase}/${list.endpoint}`);
url.searchParams.set("api_key", window.WEBHOME_CONFIG.tmdb.apiKey);
url.searchParams.set("language", window.WEBHOME_CONFIG.tmdb.language);
Object.entries(list.params || {}).forEach(([key, value]) => {
const param = key.replace(/_lte$/, ".lte").replace(/_gte$/, ".gte");
let val = value;
if (typeof val === "string") {
// 支持 "today" / "today+30" / "today-90" 动态日期
val = val.replace(/^today([+-]\d+)?$/, (_, offset) => {
const d = new Date();
if (offset) d.setDate(d.getDate() + parseInt(offset, 10));
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}`;
});
}
url.searchParams.set(param, val);
});
if (page) url.searchParams.set("page", String(page));
return url.toString();
}
function tmdbSearchUrl(keyword) {
const url = new URL(`${window.WEBHOME_CONFIG.tmdb.apiBase}/search/multi`);
url.searchParams.set("api_key", window.WEBHOME_CONFIG.tmdb.apiKey);
url.searchParams.set("language", window.WEBHOME_CONFIG.tmdb.language);
url.searchParams.set("query", keyword);
url.searchParams.set("include_adult", "false");
url.searchParams.set("page", "1");
return url.toString();
}
function suggestUrl(keyword) {
const url = new URL("https://suggest.video.iqiyi.com/");
url.searchParams.set("if", "mobile");
url.searchParams.set("key", keyword);
return url.toString();
}
function tmdbDetailUrl(item) {
const url = new URL(`${window.WEBHOME_CONFIG.tmdb.apiBase}/${item.mediaType}/${item.tmdbId}`);
url.searchParams.set("api_key", window.WEBHOME_CONFIG.tmdb.apiKey);
url.searchParams.set("language", window.WEBHOME_CONFIG.tmdb.language);
url.searchParams.set("append_to_response", item.mediaType === "tv" ? "credits,images,season/1" : "credits,images");
url.searchParams.set("include_image_language", "zh,null,en");
return url.toString();
}
function tmdbRecommendationsUrl(item) {
const url = new URL(`${window.WEBHOME_CONFIG.tmdb.apiBase}/${item.mediaType}/${item.tmdbId}/recommendations`);
url.searchParams.set("api_key", window.WEBHOME_CONFIG.tmdb.apiKey);
url.searchParams.set("language", window.WEBHOME_CONFIG.tmdb.language);
url.searchParams.set("page", "1");
return url.toString();
}
function tmdbSeasonUrl(item, seasonNumber) {
const url = new URL(`${window.WEBHOME_CONFIG.tmdb.apiBase}/tv/${item.tmdbId}/season/${seasonNumber}`);
url.searchParams.set("api_key", window.WEBHOME_CONFIG.tmdb.apiKey);
url.searchParams.set("language", window.WEBHOME_CONFIG.tmdb.language);
return url.toString();
}
function tmdbPersonUrl(personId) {
const url = new URL(`${window.WEBHOME_CONFIG.tmdb.apiBase}/person/${personId}/combined_credits`);
url.searchParams.set("api_key", window.WEBHOME_CONFIG.tmdb.apiKey);
url.searchParams.set("language", window.WEBHOME_CONFIG.tmdb.language);
return url.toString();
}
function tmdbPersonDetailUrl(personId) {
const url = new URL(`${window.WEBHOME_CONFIG.tmdb.apiBase}/person/${personId}`);
url.searchParams.set("api_key", window.WEBHOME_CONFIG.tmdb.apiKey);
url.searchParams.set("language", window.WEBHOME_CONFIG.tmdb.language);
url.searchParams.set("append_to_response", "combined_credits");
return url.toString();
}
function imageUrl(path, backdrop) {
if (!path) return "";
return (backdrop ? window.WEBHOME_CONFIG.tmdb.backdropBase : window.WEBHOME_CONFIG.tmdb.imageBase) + path;
}
// ── 触觉反馈(手机端轻震动)──
function haptic(type) {
try {
if (!navigator.vibrate) return;
if (type === "light") navigator.vibrate(8);
else if (type === "medium") navigator.vibrate(18);
else if (type === "heavy") navigator.vibrate([20, 10, 20]);
else if (type === "success") navigator.vibrate([10, 40, 10]);
} catch (e) { /* ignore */ }
}
// ── 统一更新详情页背景图(替代 CSS 变量 --detail-hero-bg
function setHeroBg(src) {
const heroBg = $("detailHeroBg");
if (!heroBg) return;
heroBg.style.backgroundImage = src ? `url("${cssUrl(src)}")` : "none";
}
// ── 直接更新详情页背景亮度+模糊
// ease 0→1:0=顶部正常亮度,1=完全压暗
function setHeroBlur(blurPx, ease) {
const heroBg = $("detailHeroBg");
if (!heroBg) return;
const e = (ease !== undefined) ? Math.min(1, Math.max(0, ease)) : Math.min(1, (blurPx || 0) / 20);
// 顶部 brightness=0.80(降暗20%),压暗到 0.34
const brightness = (0.80 - e * 0.46).toFixed(3);
// TV 端禁用 blur(昂贵的 GPU 操作),只保留亮度调节
if (isTvMode()) {
heroBg.style.filter = `brightness(${brightness}) saturate(1.05)`;
} else {
heroBg.style.filter = `brightness(${brightness}) saturate(1.05) blur(${(blurPx || 0).toFixed(1)}px)`;
}
}
function tmdbImageUrl(path, size) {
if (!path) return "";
const value = String(path || "").trim();
const imagePath = value.startsWith("/") ? value : tmdbImagePath(value);
if (!imagePath) return value;
return `https://images.tmdb.org/t/p/${size || "w342"}${imagePath}`;
}
function tmdbImagePath(url) {
const value = String(url || "").trim();
if (!value) return "";
if (value.startsWith("/")) return value;
try {
const parsed = new URL(value);
if (parsed.hostname !== "images.tmdb.org") return "";
return parsed.pathname.replace(/^\/t\/p\/[^/]+/, "") || "";
} catch (e) {
return "";
}
}
function isTmdbImage(url) {
return !!tmdbImagePath(url);
}
function displayImage(url, options) {
if (!url) return "";
const opts = options || {};
const tmdbPath = tmdbImagePath(url);
if (tmdbPath) {
// TV 模式:海报用 w780backdrop 保持 original
let size = opts.size || "w342";
if ((isTvMode() || isNativeLeanbackClient()) && size === "w342") size = "w780";
if ((isTvMode() || isNativeLeanbackClient()) && size === "w185") size = "w342";
return tmdbImageUrl(tmdbPath, size);
}
return nativeImage(url);
}
function nativeImage(url) {
if (!url) return "";
if (isTmdbImage(url)) return url;
// TV 端 sdk().res() 可能不支持或抛异常,安全降级为直接 URL
try {
const result = sdk().res(url, { credentials: "include" });
return result || url;
} catch (e) { return url; }
}
// 首屏计数器:每次 renderHomeContent 重置,前 6 张图片用 eager
let _imgEagerCount = 0;
function resetImgEagerCount() { _imgEagerCount = 0; }
function imageAttrs(src, options) {
const opts = options || {};
// TV 端:禁用 lazy loadingAndroid TV WebView 不支持,图片不会加载)
// 始终用 eager + high priority,避免 lazy 导致图片永久不加载
const tvMode = isTvMode() || isNativeLeanbackClient();
const isEager = tvMode || _imgEagerCount < 6;
_imgEagerCount++;
const loading = opts.loading || (isEager ? "eager" : "lazy");
const fpVal = opts.fetchPriority || (isEager ? "high" : "auto");
// fetchpriority 在部分 TV WebView 上会触发解析错误,TV 端不输出该属性
const fetchPriorityAttr = tvMode ? "" : ` fetchpriority="${escapeAttr(fpVal)}"`;
return `src="${escapeAttr(src)}" loading="${escapeAttr(loading)}" decoding="async"${fetchPriorityAttr}`;
}
function normalizeTmdb(item, list, index) {
const title = item.title || item.name || item.original_title || item.original_name || "未命名";
const date = item.release_date || item.first_air_date || "";
const mediaType = (list.mediaType && list.mediaType !== "all") ? list.mediaType : (item.media_type || list.mediaType || "movie");
return {
id: `tmdb:${mediaType}:${item.id}`,
source: "tmdb",
tmdbId: String(item.id),
mediaType,
listId: list.id || "search",
listTitle: list.title || "搜索",
title,
pic: imageUrl(item.poster_path, false),
landscape: imageUrl(item.backdrop_path, true),
image: imageUrl(item.backdrop_path, true) || imageUrl(item.poster_path, false),
remark: "",
desc: item.overview || `${list.title || "搜索"} · ${title}`,
releaseDate: date,
popularity: item.popularity || 0,
voteAverage: item.vote_average || 0,
originalLanguage: item.original_language || "",
baseRank: index + 1
};
}
// 列表级原始语言过滤:列表配置了 excludeOriginalLanguage 时,剔除这些语言的条目
function passesListLangFilter(item, list) {
const ex = list && list.excludeOriginalLanguage;
if (!ex || !ex.length) return true;
return ex.indexOf(item && item.originalLanguage || "") === -1;
}
function historyKeyParts(history) {
const key = String(history && history.key || "");
const parts = key.split("@@@");
return {
key,
siteKey: history && history.siteKey || parts[0] || "",
vodId: history && history.vodId || parts.slice(1).join("@@@") || ""
};
}
function validHistoryMs(value) {
const ms = Number(value || 0);
return Number.isFinite(ms) && ms > 0 && ms < 7 * 24 * 60 * 60 * 1000 ? ms : 0;
}
function isRestorableHistory(history) {
const parts = historyKeyParts(history);
const title = String(history && (history.vodName || history.title || history.name) || "").trim();
if (!parts.siteKey || !parts.vodId || !title) return false;
if (parts.siteKey === "push_agent") return false;
if (/^(push|https?|file|magnet|ed2k|thunder|video):/i.test(parts.siteKey)) return false;
return true;
}
function isExpiredHistory(history) {
const parts = historyKeyParts(history);
if (!isRestorableHistory(history)) return true;
const pic = String(history && (history.vodPic || history.pic || "") || "").trim();
if (isExpiredPosterSource(pic, parts)) return true;
const statusText = [history && history.vodRemarks, history && history.remark, history && history.desc, history && history.status, history && history.summary, history && history.episodeUrl]
.filter(Boolean)
.join(" ")
.toLowerCase();
if (/(已过期|已失效|链接失效|资源失效|分享已取消|取消分享|文件不存在|内容不存在|资源不存在|不存在|被删除|已删除|违规|notfound|not found|expired|invalid|forbidden|cancelled|canceled|shareexpirederror|sharenotfound|shareinfonotfound|filenotfound|foldernotfound)/i.test(statusText)) return true;
const title = String(history && (history.vodName || history.title || history.name) || "").trim();
return /^(已过期|已失效|链接失效|资源失效|文件不存在|内容不存在|资源不存在|不存在)$/.test(title);
}
function isExpiredPosterSource(pic, parts) {
const value = String(pic || "").trim();
if (!value) return false;
const lower = value.slice(0, 500).toLowerCase();
if (/(已过期|已失效|链接失效|资源失效|expired|notfound|not-found|invalid|forbidden)/i.test(lower)) return true;
if ((parts && parts.siteKey === "push_agent") && /^data:image\//i.test(value)) return true;
return /^data:image\/jpeg;base64,\/9j\/4AAQSkZJRgABAQEAYABgAAD\/2wBDAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQo/i.test(value);
}
function formatHistoryTime(ms) {
const total = Math.max(0, Math.floor(Number(ms || 0) / 1000));
const h = Math.floor(total / 3600);
const m = Math.floor(total % 3600 / 60);
const sec = total % 60;
if (h > 0) return h + ":" + String(m).padStart(2, "0") + ":" + String(sec).padStart(2, "0");
return m + ":" + String(sec).padStart(2, "0");
}
function historyProgressText(history) {
const position = validHistoryMs(history && history.position);
const duration = validHistoryMs(history && history.duration);
if (duration > 0 && position > 0) return "已看 " + Math.min(99, Math.max(1, Math.round(position / duration * 100))) + "%";
return position > 0 ? "看到 " + formatHistoryTime(position) : "";
}
function historyProgressPercent(history) {
const position = validHistoryMs(history && history.position);
const duration = validHistoryMs(history && history.duration);
if (duration <= 0 || position <= 0) return 0;
return Math.min(100, Math.max(1, Math.round(position / duration * 100)));
}
function normalizeHistoryItem(history, index) {
const parts = historyKeyParts(history);
const title = String(history && (history.vodName || history.title || history.name) || "最近观看").trim();
const pic = history && (history.vodPic || history.pic || "") || "";
const badge = String(history && history.vodRemarks || "").trim();
const progressText = historyProgressText(history);
return {
id: "history:" + (parts.key || index),
source: "history",
historyKey: parts.key,
siteKey: parts.siteKey,
vodId: parts.vodId,
title,
pic,
image: pic,
remark: progressText || (badge ? "继续观看" : ""),
badge,
progress: historyProgressPercent(history),
desc: [badge, progressText].filter(Boolean).join(" · ") || title,
releaseDate: "",
voteAverage: 0,
popularity: Number(history && history.createTime || 0),
baseRank: index + 1,
createTime: Number(history && history.createTime || 0)
};
}
function normalizeHistoryList(list) {
const seen = new Set();
return (Array.isArray(list) ? list : [])
.slice()
.sort((a, b) => Number(b && b.createTime || 0) - Number(a && a.createTime || 0))
.filter((history) => !isExpiredHistory(history))
.map(normalizeHistoryItem)
.filter((item) => {
const key = item.historyKey || (item.siteKey && item.vodId ? item.siteKey + "@@@" + item.vodId : item.title);
if (!key || seen.has(key)) return false;
seen.add(key);
return true;
});
}
function detailHistoryTitleCandidates(item) {
return [
item && item.title,
item && item.name,
item && item.query,
item && item.originalTitle,
item && item.original_name,
item && item.original_title
].map(normalizeTitle).filter(Boolean);
}
function detailHistoryVodIdMatchesTmdb(tmdbId, vodId) {
const id = String(tmdbId || "").trim();
const value = String(vodId || "").trim();
if (!id || !value) return false;
if (value === id) return true;
if (/tmdb/i.test(value) && value.split(/[^a-z0-9]+/i).includes(id)) return true;
return false;
}
function detailHistoryMatches(item, history) {
if (!item || !history || history.source !== "history" || !history.siteKey || !history.vodId) return false;
const tmdbId = item.tmdbId ? String(item.tmdbId) : item.id && /^tmdb:/i.test(String(item.id)) ? String(item.id).split(":").pop() : "";
if (detailHistoryVodIdMatchesTmdb(tmdbId, history.vodId)) return true;
const title = normalizeTitle(history.title || "");
if (!title) return false;
return detailHistoryTitleCandidates(item).some((candidate) => {
if (!candidate) return false;
if (candidate === title) return true;
return candidate.length >= 4 && title.length >= 4 && (candidate.includes(title) || title.includes(candidate));
});
}
function findDetailContinueHistory(item) {
const items = state.recent.items || [];
const result = items.find((history) => detailHistoryMatches(item, history)) || null;
console.log("[继续观看] findDetailContinueHistory:", {
selected_title: item && item.title,
selected_tmdbId: item && item.tmdbId,
recent_count: items.length,
recent_loaded: state.recent && state.recent.loaded,
match: result && result.title
});
if (!result && items.length > 0) {
console.log("[继续观看] history items:", items.slice(0, 3).map(h => ({
title: h.title, siteKey: h.siteKey, vodId: h.vodId, source: h.source
})));
}
return result;
}
function updateDetailContinueButton() {
const button = $("detailContinueBtn");
const searchText = $("detailSearchText");
if (!button) return;
const match = findDetailContinueHistory(state.selected);
const actions = button.closest(".actions");
button.__historyItem = match || null;
button.style.display = match ? "" : "none";
button.setAttribute("aria-hidden", match ? "false" : "true");
if (actions) actions.classList.toggle("has-continue", !!match);
if (searchText) searchText.textContent = match ? "搜索" : "搜索播放";
}
function shouldRefreshRecentList() {
return !state.recent.loaded || Date.now() - Number(state.recent.refreshedAt || 0) > RECENT_UI_TTL_MS;
}
async function loadRecentList(options) {
const opts = options || {};
if (state.recent.loading) return;
if (state.recent.loaded && !opts.refresh) return;
state.recent.loading = true;
state.recent.error = "";
if (!opts.silent) setStatus("tmdb", "读取最近观看");
if (state.activeList === "recent") renderActiveGrid();
try {
const list = await sdk().history();
state.recent.items = normalizeHistoryList(list);
state.recent.loaded = true;
state.recent.refreshedAt = Date.now();
if (!opts.silent) setStatus("tmdb", "最近观看 " + state.recent.items.length + " 条");
} catch (e) {
state.recent.items = [];
state.recent.loaded = true;
state.recent.error = e.message || "unknown";
if (!opts.silent) setStatus("tmdb", "最近观看读取失败");
} finally {
state.recent.loading = false;
if (state.activeList === "recent") renderActiveGrid();
if ($("detailSheet") && $("detailSheet").classList.contains("active")) updateDetailContinueButton();
}
}
async function loadInfo() {
try {
await detectDeviceMode();
state.site = await sdk().site();
state.config = await sdk().config();
state.pan.checkEnabled = !!(state.config && state.config.driveCheck);
setStatus("sdk", window.fm ? "App SDK 已连接" : "浏览器预览模式");
setPanStatus(state.pan.checkEnabled ? "检测已开启" : "检测关闭");
state.pan.renderKeys = "";
renderPanResults();
} catch (e) {
state.site = { name: "推荐首页" };
state.pan.checkEnabled = false;
setStatus("sdk", "SDK 获取失败:" + (e.message || "unknown"));
}
}
async function loadCatalog() {
setStatus("tmdb", "等待按需请求");
loadRecentList({ silent: true }).catch(() => {});
renderAll({ deferContent: true });
}
async function loadCatalogList(id) {
const list = getList(id);
const page = state.catalogPage[id];
if (!list || page && page.loading) return;
if (page && page.loaded) return;
state.catalogPage[id] = { page: 0, total: 1, loading: true, loaded: false };
setStatus("tmdb", `请求 ${list.title}`);
if (state.activeList === id) renderActiveGrid();
// 多数据源混合加载(推荐 tab 用)
if (list.sources && list.sources.length) {
try {
const fetches = list.sources.map((src) => {
const srcList = Object.assign({}, list, { endpoint: src.endpoint, params: src.params, mediaType: src.mediaType || list.mediaType });
return requestJson(tmdbUrl(srcList, 1), 18)
.then((body) => ({ items: (body.results || []).map((item, i) => normalizeTmdb(item, srcList, i)).filter(hasPoster), total: body.total_pages || 1 }))
.catch(() => ({ items: [], total: 1 }));
});
const results = await Promise.all(fetches);
// 交叉混排:依次从每个来源取一条,循环直到取完
const mixed = [];
const iters = results.map((r) => r.items[Symbol.iterator]());
let anyLeft = true;
while (anyLeft) {
anyLeft = false;
for (const iter of iters) {
const { value, done } = iter.next();
if (!done) { mixed.push(value); anyLeft = true; }
}
}
state.catalog[id] = uniqueMedia(mixed.filter((it) => passesListLangFilter(it, list)));
// 记录多源分页状态,以便继续加载第2页
const maxTotal = Math.max(...results.map((r) => r.total));
state.catalogPage[id] = { page: 1, total: maxTotal, loading: false, loaded: true, multiSrc: true };
setStatus("tmdb", `${list.title} 已加载 ${state.catalog[id].length} 条`);
} catch (e) {
state.catalog[id] = [];
state.catalogPage[id] = { page: 1, total: 1, loading: false, loaded: true, error: e.message || "unknown" };
setStatus("tmdb", `${list.title} 加载失败`);
}
if (state.activeList === id) renderActiveGrid();
return;
}
try {
const body = await requestJson(tmdbUrl(list, 1), 18);
const results = body.results || [];
state.catalog[id] = uniqueMedia(results.map((item, index) => normalizeTmdb(item, list, index)).filter(hasPoster).filter((it) => passesListLangFilter(it, list)));
state.catalogPage[id] = { page: 1, total: body.total_pages || 1, loading: false, loaded: true };
setStatus("tmdb", `${list.title} 已加载 ${state.catalog[id].length} 条`);
} catch (e) {
state.catalog[id] = [];
state.catalogPage[id] = { page: 1, total: 1, loading: false, loaded: true, error: e.message || "unknown" };
setStatus("tmdb", `${list.title} 加载失败`);
}
if (state.activeList === id) renderActiveGrid();
}
async function loadMoreCatalog(id) {
const list = getList(id);
const page = state.catalogPage[id];
if (!page || !page.loaded) return loadCatalogList(id);
if (!list || page.loading || page.page >= page.total) return;
page.loading = true;
state.catalogPage[id] = page;
try {
const next = page.page + 1;
// 多源列表:并发请求所有来源的下一页,交叉混排追加
if (page.multiSrc && list.sources && list.sources.length) {
const fetches = list.sources.map((src) => {
const srcList = Object.assign({}, list, { endpoint: src.endpoint, params: src.params, mediaType: src.mediaType || list.mediaType });
return requestJson(tmdbUrl(srcList, next), 18)
.then((body) => ({ items: (body.results || []).map((item, i) => normalizeTmdb(item, srcList, (next - 1) * 20 + i)).filter(hasPoster), total: body.total_pages || page.total }))
.catch(() => ({ items: [], total: page.total }));
});
const results = await Promise.all(fetches);
const mixed = [];
const iters = results.map((r) => r.items[Symbol.iterator]());
let anyLeft = true;
while (anyLeft) {
anyLeft = false;
for (const iter of iters) {
const { value, done } = iter.next();
if (!done) { mixed.push(value); anyLeft = true; }
}
}
const maxTotal = Math.max(...results.map((r) => r.total));
state.catalog[id] = uniqueMedia((state.catalog[id] || []).concat(mixed.filter((it) => passesListLangFilter(it, list))));
state.catalogPage[id] = { page: next, total: maxTotal, loading: false, loaded: true, multiSrc: true };
renderActiveGrid();
return;
}
const body = await requestJson(tmdbUrl(list, next), 18);
const items = (body.results || []).map((item, index) => normalizeTmdb(item, list, (next - 1) * 20 + index)).filter(hasPoster).filter((it) => passesListLangFilter(it, list));
state.catalog[id] = uniqueMedia((state.catalog[id] || []).concat(items));
state.catalogPage[id] = { page: next, total: body.total_pages || page.total || next, loading: false, loaded: true };
renderActiveGrid();
} catch (e) {
page.loading = false;
}
}
function loadMoreVisible() {
if (appendActiveGridBatch()) return;
if (state.loadingMore || state.activeList === "recent" || state.activeList === "live") return;
if (state.infiniteObserver) state.infiniteObserver.unobserve($("infiniteSentinel"));
state.loadingMore = true;
Promise.resolve(loadMoreCatalog(state.activeList))
.finally(() => {
state.loadingMore = false;
observeInfiniteScroll();
});
}
async function loadEvents() {
try {
await sdk().cache.del(cacheKey("events"));
} catch (e) {}
scheduleRender();
}
async function clearLocalEvents(onlyMine) {
await sdk().cache.del(cacheKey("events"));
await hotClearIndex(onlyMine);
scheduleRender();
}
function allItems() {
return Object.values(state.catalog).flat().filter(hasPoster);
}
function findItem(id) {
return allItems().concat(state.searchItems).find((item) => item.id === id);
}
function scoreItem(item) {
return Math.max(0, 18 - item.baseRank) + (item.voteAverage || 0) * 1.2 + Math.min(item.popularity || 0, 200) * .04;
}
function qualifiesHeat(content) {
return watchedTenMinutes(content) && hasHeatIntent(content);
}
function watchedTenMinutes(content) {
if (!content || content.action !== "watch") return false;
const watchMs = Math.max(0, Number(content.watchMs || content.position || 0));
return watchMs >= WATCH_HEAT_MS;
}
function hasHeatIntent(content) {
const intent = String(content.intentAction || content.intent || "");
return intent === "view" || intent === "search" || content.clicked === true || content.searched === true;
}
function eventUserKey(event) {
if (event.pubkey) return event.pubkey;
if (event.local && state.identity && state.identity.pubkey) return state.identity.pubkey;
return "local";
}
function ranked(items) {
return items.filter(hasPoster).slice().sort((a, b) => scoreItem(b) - scoreItem(a));
}
// 搜索结果年份(取 year 或 releaseDate/firstAirDate 的前 4 位)
function searchItemYear(item) {
const raw = item && (item.year || String(item.releaseDate || item.firstAirDate || "").slice(0, 4));
const y = parseInt(raw, 10);
return Number.isFinite(y) ? y : 0;
}
// 搜索结果排序:按年份由近到远,同年用原热度分兜底
function rankedByYear(items) {
return items.filter(hasPoster).slice().sort((a, b) => {
const dy = searchItemYear(b) - searchItemYear(a);
if (dy) return dy;
return scoreItem(b) - scoreItem(a);
});
}
function uniqueMedia(items) {
const map = new Map();
items.filter(hasPoster).forEach((item) => {
const key = mediaDomKey(item);
if (key && !map.has(key)) map.set(key, item);
});
return Array.from(map.values());
}
function getList(id) {
return (window.WEBHOME_CONFIG.tmdb.lists || []).find((list) => list.id === id);
}
function visibleTmdbLists() {
return (window.WEBHOME_CONFIG.tmdb.lists || []).filter((list) => !list.hidden && !(list.mobileHidden && isPhoneViewport()));
}
function isPhoneViewport() {
const width = Math.min(window.innerWidth || 0, document.documentElement.clientWidth || 0) || window.innerWidth || 0;
const height = Math.min(window.innerHeight || 0, document.documentElement.clientHeight || 0) || window.innerHeight || 0;
const coarse = window.matchMedia ? window.matchMedia("(pointer: coarse)").matches : false;
return width > 0 && width < 720 && (coarse || height > width);
}
function isBrowserPhoneClient() {
const width = Math.min(window.innerWidth || 0, document.documentElement.clientWidth || 0) || window.innerWidth || 0;
const coarse = window.matchMedia ? window.matchMedia("(pointer: coarse)").matches : false;
const ua = String(navigator.userAgent || "");
const mobileUa = /Android|iPhone|iPod|Mobile/i.test(ua);
return width > 0 && width < 720 && (coarse || mobileUa);
}
function useLargeDetailLayout() {
// 手机原生 App:竖版沉浸式(手机体验)。
if (state.deviceMode === "mobile" || isNativeMobileClient()) return false;
// TV / leanback:横版分栏详情(电视专属布局)。
if (state.deviceMode === "leanback" || isNativeLeanbackClient()) return true;
// 其余(浏览器 / 平板):以「触屏 vs 鼠标」区分——
// 触屏设备(手机、平板横屏)统一走沉浸式全屏壁纸,与手机体验一致;
// 仅鼠标设备(电脑)走横版分栏。
// 结果:手机+平板体验一致,电视单独,电脑横版。
const coarsePointer = !!(window.matchMedia && window.matchMedia("(pointer: coarse)").matches);
if (coarsePointer) return false;
return !isBrowserPhoneClient();
}
function syncDetailLayout() {
const sheet = $("detailSheet");
if (!sheet) return false;
const before = sheet.classList.contains("detail-large");
const large = useLargeDetailLayout();
sheet.classList.toggle("detail-large", large);
// 只有 sheet 处于激活状态时才同步 inline display
// 防止 resize 时意外恢复已关闭的 sheet(detail 漏出到主页面的根本原因)
const isOpen = sheet.classList.contains("active") || sheet.classList.contains("sheet-closing");
if (isOpen && sheet.style.display && sheet.style.display !== "none") {
sheet.style.display = large ? "flex" : "block";
}
return before !== large;
}
function isKnownList(id) {
return id === "all" || id === "recent" || id === "live" || !!getList(id);
}
function normalizeActiveListForViewport() {
if (!isKnownList(state.activeList)) state.activeList = "all";
}
function hasPoster(item) {
return !!(item && item.pic);
}
function renderSearch() {
const hasResults = state.searchItems.length > 0;
$("searchSection").style.display = hasResults ? "" : "none";
document.documentElement.classList.toggle("search-active", hasResults);
renderSearchList("searchRail", rankedByYear(state.searchItems).slice(0, 18));
// 搜索激活时:折叠背景元素高度,防止继续往下滚动看到主页内容
["recommendSection", "listStack", "chips"].forEach((id) => {
const el = $(id);
if (!el) return;
if (hasResults) {
el.dataset.searchSavedHeight = el.style.cssText || "";
el.style.maxHeight = "0";
el.style.overflow = "hidden";
el.style.marginTop = "0";
el.style.marginBottom = "0";
el.style.paddingTop = "0";
el.style.paddingBottom = "0";
} else {
el.style.cssText = el.dataset.searchSavedHeight || "";
delete el.dataset.searchSavedHeight;
}
});
// 搜索激活时停止无限滚动 sentinel,防止触发主页加载和滚动跳动
const sentinel = $("infiniteSentinel");
if (sentinel) {
if (hasResults) {
if (state.infiniteObserver) state.infiniteObserver.unobserve(sentinel);
sentinel.style.display = "none";
} else {
sentinel.style.display = "";
observeInfiniteScroll();
}
}
}
function clearSearchResults() {
if (location.hash === "#search") history.replaceState({ sheet: "home" }, "", location.pathname + location.search);
state.searchItems = [];
document.documentElement.classList.remove("search-active");
document.documentElement.classList.remove("search-focused");
if ($("searchInput")) { $("searchInput").value = ""; $("searchInput").blur(); }
hideSearchSuggest();
renderSearch();
scheduleUiSnapshotSave();
}
function openSearchFirstResult() {
const items = rankedByYear(state.searchItems);
if (!items.length) return;
openDetail(items[0]);
}
function renderAll(options) {
const opts = options || {};
clearTimeout(state.renderTimer);
state.renderTimer = 0;
normalizeActiveListForViewport();
renderChips();
if (opts.deferContent) scheduleHomeContentRender({ afterPaint: true });
else renderHomeContent();
renderMetrics();
normalizeRails();
}
function renderHomeContent() {
// 详情页动画期间不渲染主页,防止 resize 时内容透出
if (document.body.classList.contains("detail-active") ||
document.body.classList.contains("detail-closing") ||
state.detailClosing) return;
$("recommendSection").style.display = "none";
$("listSection").style.display = "";
renderLists();
observeInfiniteScroll();
requestAnimationFrame(ensureScrollablePage);
}
function scheduleHomeContentRender(options) {
const opts = options || {};
const seq = ++state.homeContentSeq;
if (state.homeContentTimer) {
cancelAnimationFrame(state.homeContentTimer);
state.homeContentTimer = 0;
}
const run = () => {
if (seq !== state.homeContentSeq) return;
state.homeContentTimer = 0;
renderHomeContent();
if (typeof opts.after === "function") opts.after();
};
if (!opts.afterPaint) {
run();
return;
}
state.homeContentTimer = requestAnimationFrame(() => {
if (seq !== state.homeContentSeq) return;
state.homeContentTimer = requestAnimationFrame(run);
});
}
function ensureActiveListData(id) {
if (!id || state.activeList !== id) return;
if (id === "recent") loadRecentList({ refresh: shouldRefreshRecentList() });
else if (id !== "live") loadCatalogList(id);
}
function scheduleRender() {
clearTimeout(state.renderTimer);
const delay = Date.now() < state.scrollingUntil ? 700 : 160;
state.renderTimer = setTimeout(() => {
if (Date.now() < state.scrollingUntil) {
scheduleRender();
return;
}
renderAll({ deferContent: uiSnapshotRoute() === "home" });
}, delay);
}
// 分类图标映射
const CHIP_ICONS = {
recent: `<svg class="chip-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>`,
all: `<svg class="chip-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>`,
movie: `<svg class="chip-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="20" rx="2.18" ry="2.18"/><line x1="7" y1="2" x2="7" y2="22"/><line x1="17" y1="2" x2="17" y2="22"/><line x1="2" y1="12" x2="22" y2="12"/><line x1="2" y1="7" x2="7" y2="7"/><line x1="17" y1="7" x2="22" y2="7"/><line x1="17" y1="17" x2="22" y2="17"/><line x1="2" y1="17" x2="7" y2="17"/></svg>`,
tv: `<svg class="chip-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="8" width="20" height="13" rx="2" ry="2"/><polyline points="9 2 12 5 15 2"/><line x1="8" y1="2" x2="12" y2="5"/><line x1="16" y1="2" x2="12" y2="5"/></svg>`,
music: `<svg class="chip-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>`,
photo: `<svg class="chip-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>`,
live: `<svg class="chip-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="2" y1="10" x2="2" y2="14"/><line x1="6" y1="6" x2="6" y2="18"/><line x1="10" y1="3" x2="10" y2="21"/><line x1="14" y1="8" x2="14" y2="16"/><line x1="18" y1="11" x2="18" y2="13"/><line x1="22" y1="10" x2="22" y2="14"/></svg>`,
variety: `<svg class="chip-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>`,
anime: `<svg class="chip-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><line x1="9" y1="9" x2="9.01" y2="9"/><line x1="15" y1="9" x2="15.01" y2="9"/></svg>`,
documentary: `<svg class="chip-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>`,
hot: `<svg class="chip-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2c0 0-4 4-4 8a4 4 0 0 0 8 0c0-1.5-.5-3-1.5-4C14 8 13 10 12 10c0 0-2-2-2-4 0 0 1 1 2 1z"/><path d="M12 22c-3.3 0-6-2.7-6-6 0-2 1-4 2.5-5.5C9 12 10.5 13 12 13s3-1 3.5-2.5C17 12 18 14 18 16c0 3.3-2.7 6-6 6z"/></svg>`,
};
function getChipIcon(id) {
if (id === "recent") return CHIP_ICONS.recent;
if (id === "all") return CHIP_ICONS.all;
if (id === "now-playing") return CHIP_ICONS.hot;
if (id.includes("variety")) return CHIP_ICONS.variety;
if (id.startsWith("anime")) return CHIP_ICONS.anime;
if (id === "documentary") return CHIP_ICONS.documentary;
if (id === "concert" || id.includes("concert") || id.includes("music")) return CHIP_ICONS.music;
const list = getList(id);
const mt = (list && list.mediaType) || "";
if (id.includes("movie") || mt === "movie") return CHIP_ICONS.movie;
if (id.includes("tv") || id.includes("series") || mt === "tv") return CHIP_ICONS.tv;
if (id.includes("music")) return CHIP_ICONS.music;
if (id.includes("photo")) return CHIP_ICONS.photo;
if (id.includes("live")) return CHIP_ICONS.live;
return CHIP_ICONS.all;
}
function renderChips() {
const chips = [{ id: "recent", title: "最近" }, { id: "all", title: "推荐" }]
.concat(visibleTmdbLists().filter((item) => item.id !== "all").map((item) => ({ id: item.id, title: item.title })));
const root = $("chips");
const keys = chips.map((chip) => chip.id + ":" + chip.title).join("\n");
if (root.dataset.renderKeys !== keys) {
root.dataset.renderKeys = keys;
root.replaceChildren(...chips.map((chip) => {
const button = document.createElement("button");
button.className = "chip focusable";
button.type = "button";
button.dataset.chipId = chip.id;
button.innerHTML = getChipIcon(chip.id) + `<span>${chip.title}</span>`;
button.addEventListener("click", () => selectChip(chip.id));
button.addEventListener("focus", () => {
scheduleChipFocusSelect(chip.id);
});
return button;
}));
}
Array.from(root.children).forEach((button) => button.classList.toggle("active", button.dataset.chipId === state.activeList));
// 首次加载时,刚插入 DOM 的 .active chip 其 ::before 呼吸光晕在部分 WebView 中
// 不会立即起步合成,导致看起来是「暗色块」,点击后才点亮。此处对当前选中 chip
// 做一次极轻量的合成层提升 → 还原,强制浏览器重新计算并启动光晕动画。
// TV 端 ::before 已被隐藏,无需处理。
if (!isTvMode()) {
const activeChip = root.querySelector(".chip.active");
if (activeChip) {
requestAnimationFrame(() => {
activeChip.style.willChange = "transform";
void activeChip.offsetWidth; // 触发同步重排
requestAnimationFrame(() => { activeChip.style.willChange = ""; });
});
}
}
}
function scheduleChipFocusSelect(id) {
clearTimeout(state.chipFocusTimer);
if (!id || state.activeList === id) return;
state.chipFocusTimer = setTimeout(() => selectChip(id, { keepFocus: true, fromFocus: true }), isTvMode() ? 280 : 45);
}
function selectChip(id, options) {
const opts = options || {};
if (!opts.fromFocus) clearTimeout(state.chipFocusTimer);
if (!id || state.activeList === id) return;
state.activeList = id;
renderChips();
scheduleHomeContentRender({ afterPaint: true, after: () => ensureActiveListData(id) });
scheduleUiSnapshotSave();
if (opts.keepFocus) requestAnimationFrame(() => {
const target = findByDataset($("chips"), "chipId", id);
if (target) focusRemoteTarget(target);
});
}
function renderRecommendations() {
// 推荐 tab 直接走和其他分类一样的 loadCatalogList 逻辑
const list = getList("all");
if (!list) return;
const panel = ensureListPanel("all", "推荐", "全平台热门");
const grid = panel.querySelector(".media-grid");
activateListPanel("all");
const page = state.catalogPage["all"];
if (page && page.loading) {
if (!grid.dataset.renderKeys) showGridStatus(grid, "加载中...");
} else if (page && page.error) {
showGridStatus(grid, "加载失败");
} else if (!page || !page.loaded) {
loadCatalogList("all");
showGridStatus(grid, "加载中...");
} else {
fillGrid(grid, state.catalog["all"] || []);
// 第一页加载完后立即预取第二页,保证内容足够多
if (page.loaded && !page.loading && page.page < page.total && (state.catalog["all"] || []).length < 40) {
setTimeout(() => loadMoreCatalog("all"), 200);
}
}
}
function renderActiveGrid() {
if (state.activeList === "all") renderRecommendations();
else renderLists();
observeInfiniteScroll();
requestAnimationFrame(ensureScrollablePage);
}
function renderLists() {
if (state.activeList === "recent") {
renderRecentList();
return;
}
if (state.activeList === "live") {
renderLiveEntry();
return;
}
const lists = visibleTmdbLists().filter((list) => list.id === state.activeList);
if (!lists.length) {
activateListPanel("");
return;
}
const list = lists[0];
const panel = ensureListPanel(list.id, list.title, list.hint || "");
const grid = panel.querySelector(".media-grid");
activateListPanel(list.id);
const page = state.catalogPage[list.id];
if (page && page.loading) {
if (!grid.dataset.renderKeys) showGridStatus(grid, "加载中...");
} else if (page && page.error) {
showGridStatus(grid, "加载失败");
} else if (!page || !page.loaded) {
showGridStatus(grid, "点击后加载片单");
} else {
fillGrid(grid, state.catalog[list.id] || []);
}
}
function renderLiveEntry() {
const panel = ensureListPanel("live", "直播", "电视台与直播源", { grid: false });
activateListPanel("live");
if (panel.dataset.liveReady === "1") return;
panel.dataset.liveReady = "1";
const body = document.createElement("div");
const card = document.createElement("button");
card.className = "live-entry-card focusable";
card.type = "button";
card.innerHTML = `
<span class="live-icon" aria-hidden="true">
<svg class="icon" viewBox="0 0 24 24"><rect x="3" y="6" width="18" height="13" rx="2"/><path d="M8 21h8"/><path d="M12 19v2"/><path d="m9 3 3 3 3-3"/></svg>
</span>
<div class="live-copy">
<h5>观看直播</h5>
<p>频道分组、节目单、收藏频道和多线路已接入。</p>
<div class="live-tags"><span class="live-tag">频道分组</span><span class="live-tag">节目单</span><span class="live-tag">收藏</span></div>
</div>
<span class="live-action">继续观看</span>
`;
card.addEventListener("click", openLiveHome);
body.appendChild(card);
panel.appendChild(body);
}
function renderRecentList() {
const panel = ensureListPanel("recent", "最近", "继续观看");
const grid = panel.querySelector(".media-grid");
activateListPanel("recent");
if (state.recent.loading && !state.recent.loaded) {
showGridStatus(grid, "读取最近观看...");
} else if (state.recent.error) {
showGridStatus(grid, "最近观看读取失败");
} else if (!state.recent.loaded) {
showGridStatus(grid, "点击后读取最近观看");
} else {
fillGrid(grid, state.recent.items || []);
}
}
function ensureListPanel(id, title, hint, options) {
const stack = $("listStack");
Array.from(stack.children).forEach((child) => {
if (!child.classList || !child.classList.contains("list-panel")) child.remove();
});
let panel = Array.from(stack.querySelectorAll(".list-panel")).find((item) => item.dataset.listId === id);
if (panel) return panel;
panel = document.createElement("div");
panel.className = "list-block list-panel";
panel.dataset.listId = id;
panel.hidden = true;
panel.innerHTML = `
<div class="subsection-head" style="display:none">
<h4></h4>
<span></span>
</div>
`;
if (!options || options.grid !== false) {
const grid = document.createElement("div");
grid.className = "media-grid";
grid.dataset.listId = id;
panel.appendChild(grid);
}
stack.appendChild(panel);
return panel;
}
function activateListPanel(id) {
const stack = $("listStack");
Array.from(stack.querySelectorAll(".list-panel")).forEach((panel) => {
panel.hidden = panel.dataset.listId !== id;
});
}
function renderRail(id, items) {
const rail = $(id);
fillRail(rail, items);
}
function renderSearchList(id, items) {
const rail = $(id);
if (!rail) return;
items = items.filter(hasPoster);
if (!items.length) {
rail.innerHTML = '<div class="empty">暂无结果</div>';
return;
}
rail.replaceChildren(...items.map((item) => {
const el = document.createElement("button");
el.className = "search-list-item focusable";
el.type = "button";
const poster = displayImage(item.pic, { size: "w185" });
const rating = item.voteAverage ? Number(item.voteAverage).toFixed(1) : "";
const stars = rating ? renderStars(Number(item.voteAverage)) : "";
const year = item.year || (item.releaseDate || "").slice(0, 4) || "";
const genre = (item.genres && item.genres[0]) || item.remark || "";
const mediaLabel = item.mediaType === "movie" ? "电影" : "剧集";
const meta = [mediaLabel, year, genre].filter(Boolean).join(" · ");
const overview = (item.overview || item.desc || "").slice(0, 80) + ((item.overview || item.desc || "").length > 80 ? "…" : "");
const arrowSvg = `<svg class="sli-arrow" viewBox="0 0 20 20" fill="none" aria-hidden="true"><path d="M7 10h6M10 7l3 3-3 3" stroke="white" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
const statusText = detailStatus(item.status || "");
const isEnded = statusText === "已完结" || (item.totalEpisodes && item.airedEpisodes >= item.totalEpisodes);
const badgeLabel = isEnded ? "已完结" : (item.mediaType === "movie" ? "电影" : "剧集");
const badgeClass = isEnded ? "sli-badge green" : "sli-badge";
el.innerHTML = `
<img class="sli-poster" alt="" src="${escapeAttr(poster)}" loading="lazy">
<div class="sli-body">
<div class="sli-title">${escapeHtml(item.title)}</div>
<div class="sli-meta">${escapeHtml(meta)}</div>
${stars ? `<div class="sli-stars">${stars}<span class="sli-rating">${escapeHtml(rating)}</span></div>` : ""}
${overview ? `<div class="sli-overview">${escapeHtml(overview)}</div>` : ""}
<span class="${badgeClass}">${badgeLabel}</span>
</div>
${arrowSvg}
`;
el.addEventListener("click", () => openDetail(item));
return el;
}));
}
function renderStars(score) {
const full = Math.floor(score / 2);
const half = score % 2 >= 1 ? 1 : 0;
const empty = 5 - full - half;
const s = (n, cls) => `<span class="star ${cls}">${"★".repeat(n)}</span>`;
return (
(full ? s(full, "star-full") : "") +
(half ? '<span class="star star-half">½</span>' : "") +
(empty ? s(empty, "star-empty") : "")
);
}
function fillRail(rail, items) {
items = items.filter(hasPoster);
if (!items.length) {
const empty = document.createElement("div");
empty.className = "empty";
empty.textContent = "暂无内容";
rail.replaceChildren(empty);
return;
}
const key = rail.dataset.railKey || rail.id || "";
if (key && rail.dataset.scrollBound) state.railScroll[key] = rail.scrollLeft;
const savedLeft = key ? state.railScroll[key] || 0 : 0;
const detailRail = !!($("detailSheet") && $("detailSheet").contains(rail));
rail.replaceChildren(...items.map((item, index) => mediaCard(item, index, { landscape: detailRail && useLargeDetailLayout() })));
if (savedLeft) requestAnimationFrame(() => rail.scrollLeft = savedLeft);
if (!rail.dataset.scrollBound) {
rail.dataset.scrollBound = "1";
rail.addEventListener("scroll", () => {
state.scrollingUntil = Date.now() + 900;
const railKey = rail.dataset.railKey || rail.id || "";
if (railKey) state.railScroll[railKey] = rail.scrollLeft;
}, { passive: true });
}
}
function fillGrid(grid, items) {
if (!grid) return;
const rawItems = Array.isArray(items) ? items : [];
const gridId = gridRenderId(grid);
const cached = state.gridRender[gridId];
if (cached && cached.source === rawItems && cached.sourceLength === rawItems.length && Number(cached.rendered || 0) > 0 && grid.children.length >= Number(cached.rendered || 0)) {
const need = Math.min(Number(cached.total || 0), initialGridBatchSize(grid));
if (Number(cached.rendered || 0) >= need) {
ensureRemoteInitialFocus();
return;
}
}
items = uniqueMedia(rawItems).filter(hasPoster);
if (!items.length) {
showGridStatus(grid, "暂无内容");
return;
}
const keys = items.map(mediaDomKey);
const renderKeys = items.map(mediaRenderKey);
const previousKeys = grid.dataset.itemKeys ? grid.dataset.itemKeys.split("\n") : [];
const previousRenderKeys = grid.dataset.renderKeys ? grid.dataset.renderKeys.split("\n") : [];
const samePrefix = previousKeys.length > 0 && previousKeys.every((key, index) => key && key === keys[index]);
let info = state.gridRender[gridId];
const oldRendered = Number(info && info.rendered || 0);
const oldTotal = Number(info && info.total || 0);
const renderedCount = Math.min(oldRendered, previousRenderKeys.length, renderKeys.length);
const sameRendered = samePrefix && renderedCount > 0 && previousRenderKeys.slice(0, renderedCount).every((key, index) => key === renderKeys[index]);
if (!info || !samePrefix || !sameRendered) {
info = state.gridRender[gridId] = { rendered: 0, total: 0, keys: "", items: [] };
grid.replaceChildren();
grid.dataset.renderKeys = "";
}
grid.dataset.itemKeys = keys.join("\n");
info.total = items.length;
info.keys = grid.dataset.itemKeys;
info.items = items;
info.source = rawItems;
info.sourceLength = rawItems.length;
let target = Math.min(items.length, Math.max(Number(info.rendered || 0), initialGridBatchSize(grid)));
if (sameRendered && items.length > oldTotal && oldRendered >= oldTotal) target = Math.min(items.length, oldRendered + appendGridBatchSize(grid));
appendGridItems(grid, items, target);
ensureRemoteInitialFocus();
}
function showGridStatus(grid, text) {
if (!grid) return;
const gridId = gridRenderId(grid);
delete state.gridRender[gridId];
grid.dataset.renderKeys = "";
grid.dataset.itemKeys = "";
grid.replaceChildren(emptyNode(text));
}
function gridRenderId(grid) {
return grid && (grid.dataset.listId || grid.id) || "grid";
}
// gridColumns 缓存:key = grid元素id + 视口宽度 + TV模式,避免每次按键触发 getComputedStyle
var _gridColsCache = {};
function gridColumns(grid) {
var cacheKey = (grid ? (grid.dataset.listId || grid.id || "") : "") + "|" + (window.innerWidth | 0) + "|" + (isTvMode() ? "tv" : "m");
if (_gridColsCache[cacheKey] !== undefined) return _gridColsCache[cacheKey];
const value = grid ? getComputedStyle(grid).gridTemplateColumns : "";
const count = value && value !== "none" ? value.split(" ").filter(Boolean).length : 0;
var result;
if (count > 0) result = count;
else if (isTvMode()) result = 5;
else if ((window.innerWidth || 0) >= 1180) result = 6;
else if ((window.innerWidth || 0) >= 720) result = 4;
else result = 3;
_gridColsCache[cacheKey] = result;
return result;
}
// 视口变化时清除gridColumns缓存
window.addEventListener("resize", function() { _gridColsCache = {}; }, { passive: true });
function initialGridBatchSize(grid) {
const cols = gridColumns(grid);
return Math.max(cols * GRID_INITIAL_ROWS, isTvMode() ? 12 : 12);
}
function appendGridBatchSize(grid) {
const cols = gridColumns(grid);
return Math.max(cols * GRID_APPEND_ROWS, isTvMode() ? 10 : 6);
}
function appendGridItems(grid, items, target) {
const gridId = gridRenderId(grid);
const info = state.gridRender[gridId] || (state.gridRender[gridId] = { rendered: 0, total: items.length, keys: "", items: [] });
const start = Math.max(0, Number(info.rendered || 0));
const end = Math.max(start, Math.min(items.length, target));
info.items = items;
info.total = items.length;
if (end <= start) return;
const fragment = document.createDocumentFragment();
for (let index = start; index < end; index++) fragment.appendChild(mediaCard(items[index], index));
grid.appendChild(fragment);
info.rendered = end;
grid.dataset.renderKeys = items.slice(0, end).map(mediaRenderKey).join("\n");
// TV 端:追加完卡片后强制触发 lazy 图片加载(部分 WebView 不自动加载)
if (isTvMode() || isNativeLeanbackClient()) {
requestAnimationFrame(function() {
grid.querySelectorAll("img.poster[loading]").forEach(function(img) {
if (!img.complete || img.naturalWidth === 0) {
var src = img.getAttribute("src");
if (src) { img.removeAttribute("loading"); img.src = src; }
}
});
});
}
}
function appendGridBatch(grid) {
if (!grid || grid.closest && grid.closest(".list-panel[hidden]")) return false;
const gridId = gridRenderId(grid);
if (gridId === "recommendRail" && state.activeList !== "all") return false;
if (grid.dataset.listId && grid.dataset.listId !== state.activeList) return false;
const info = state.gridRender[gridId];
if (!info || !info.total || Number(info.rendered || 0) >= Number(info.total || 0)) return false;
const items = Array.isArray(info.items) && info.items.length ? info.items : itemsForGrid(gridId);
if (!items.length) return false;
appendGridItems(grid, items, Math.min(items.length, Number(info.rendered || 0) + appendGridBatchSize(grid)));
return true;
}
function appendActiveGridBatch() {
const grid = activeMediaGrid();
if (appendGridBatch(grid)) {
observeInfiniteScroll();
return true;
}
return false;
}
function canAppendActiveGrid() {
const grid = activeMediaGrid();
if (!grid) return false;
const info = state.gridRender[gridRenderId(grid)];
return !!(info && Number(info.rendered || 0) < Number(info.total || 0));
}
function activeMediaGrid() {
if (state.activeList === "all") return $("recommendRail");
const panel = Array.from($("listStack").querySelectorAll(".list-panel")).find((item) => !item.hidden && item.dataset.listId === state.activeList);
return panel && panel.querySelector(".media-grid");
}
function itemsForGrid(gridId) {
if (gridId === "recommendRail" || gridId === "all") {
return uniqueMedia(state.catalog["all"] || []).filter(hasPoster);
}
if (gridId === "recent") return uniqueMedia(state.recent.items || []).filter(hasPoster);
return uniqueMedia(state.catalog[gridId] || []).filter(hasPoster);
}
function maybeAppendGridForFocus(card) {
const grid = card && card.closest && card.closest(".media-grid");
if (!grid) return;
const info = state.gridRender[gridRenderId(grid)];
if (!info || Number(info.rendered || 0) >= Number(info.total || 0)) return;
const cards = Array.from(grid.querySelectorAll(".card"));
const index = cards.indexOf(card);
if (index < 0) return;
const threshold = Math.max(0, Number(info.rendered || 0) - gridColumns(grid) * 2);
if (index >= threshold) scheduleGridAppend(grid);
}
function scheduleGridAppend(grid) {
const gridId = gridRenderId(grid);
if (state.activeGridAppendTimer && state.activeGridAppendId === gridId) return;
if (state.activeGridAppendTimer) cancelAnimationFrame(state.activeGridAppendTimer);
state.activeGridAppendId = gridId;
state.activeGridAppendTimer = requestAnimationFrame(() => {
state.activeGridAppendTimer = 0;
state.activeGridAppendId = "";
if (appendGridBatch(grid)) observeInfiniteScroll();
});
}
function ensureRemoteInitialFocus() {
// 详情页激活或正在关闭时不抢焦点到主页元素(会引起滚动跳顶)
if (document.body.classList.contains("detail-active") || state.detailClosing) return;
const active = document.activeElement;
if (state.remoteInitialFocused && isVisibleFocusable(active)) return;
const target = !state.remoteInitialFocused ? initialHomeFocus() || firstContentFocus() : firstContentFocus();
if (!target) return;
state.remoteInitialFocused = true;
requestAnimationFrame(() => focusRemoteTarget(target));
}
function ensureRemoteActiveFocus() {
const active = document.activeElement;
if (isVisibleFocusable(active)) return false;
if (restoreConnectionPanelFocus()) return true;
const target = uiSnapshotRoute() === "home" ? initialHomeFocus() || firstContentFocus() : firstContentFocus();
if (!target) return false;
focusRemoteTarget(target);
return true;
}
function focusInitialHomeNow(options) {
const target = initialHomeFocus();
focusHomeTopTarget(target, options);
}
function focusCurrentHomeTop(options) {
const target = currentHomeFocus() || initialHomeFocus();
focusHomeTopTarget(target, options);
}
function focusHomeTopTarget(target, options) {
if (!target) return;
const opts = options || {};
state.remoteInitialFocused = true;
if (opts.preventScroll) {
try {
target.focus({ preventScroll: true });
} catch (e) {
target.focus();
}
} else {
focusRemoteTarget(target);
}
}
function initialHomeFocus() {
if (focusScopeRoot() !== document) return null;
return findByDataset($("chips"), "chipId", "all") || $("chips").querySelector(".chip.active") || $("chips").querySelector(".chip");
}
function currentHomeFocus() {
if (focusScopeRoot() !== document) return null;
return findByDataset($("chips"), "chipId", state.activeList) || $("chips").querySelector(".chip.active") || initialHomeFocus();
}
function observeInfiniteScroll() {
// 搜索激活时停止无限滚动,防止触发主页加载
if (document.documentElement.classList.contains("search-active")) return;
const sentinel = $("infiniteSentinel");
if (!sentinel) return;
if (!("IntersectionObserver" in window)) return;
if (!state.infiniteObserver) {
state.infiniteObserver = new IntersectionObserver((entries) => {
if (entries.some((entry) => entry.isIntersecting)) loadMoreVisible();
}, { root: null, rootMargin: "1800px 0px", threshold: 0 });
}
state.infiniteObserver.unobserve(sentinel);
if (canAppendActiveGrid() || canLoadMore()) state.infiniteObserver.observe(sentinel);
}
function canLoadMore() {
if (state.loadingMore) return false;
if (state.activeList === "recent") return false;
if (state.activeList === "live") return false;
if (state.activeList === "all") { const p = state.catalogPage["all"]; return !!(p && !p.loading && p.page < p.total); }
const page = state.catalogPage[state.activeList];
return !!(page && !page.loading && page.page < page.total);
}
function ensureScrollablePage() {
// 搜索激活时不触发加载更多,避免跳回顶部
if (document.documentElement.classList.contains("search-active")) return;
const doc = document.documentElement;
if (doc.scrollHeight > window.innerHeight + 500) return;
loadMoreVisible();
}
function normalizeRails() {
document.querySelectorAll(".rail").forEach((rail) => {
rail.style.width = "100%";
rail.style.maxWidth = "100%";
rail.style.minWidth = "0";
});
}
function mediaCard(item, index, options) {
const opts = options || {};
const button = document.createElement("button");
const people = Number(item.people || 0);
const key = mediaDomKey(item);
const recent = item.source === "history";
const landscape = !!opts.landscape;
button.className = "card focusable" + (people > 0 ? " has-people" : "") + (recent ? " recent-card" : "") + (landscape ? " landscape-card" : "");
button.type = "button";
if (key) button.dataset.mediaKey = key;
if (index !== undefined) button.dataset.cardIndex = String(index); // TV快速路径用
const rating = item.voteAverage ? Number(item.voteAverage).toFixed(1) : "";
const poster = displayImage(landscape ? item.landscape || item.image || item.pic : item.pic, { size: landscape ? "original" : "w342" });
const imageOpt = { loading: index < initialGridBatchSize(null) ? "eager" : "lazy", fetchPriority: index < 8 ? "high" : "" };
const progress = Math.min(100, Math.max(0, Number(item.progress || 0)));
button.innerHTML = recent ? `
<img class="poster" alt="" ${imageAttrs(poster, imageOpt)}>
${item.badge ? `<span class="recent-badge">${escapeHtml(item.badge)}</span>` : ""}
<div class="card-body">
<div class="card-title">${escapeHtml(item.title)}</div>
<div class="card-meta">${escapeHtml(item.remark)}</div>
</div>
${progress > 0 ? `<div class="recent-progress" aria-hidden="true"><span style="--recent-progress:${escapeAttr(progress + "%")}"></span></div>` : ""}
` : `
<img class="poster" alt="" ${imageAttrs(poster, imageOpt)} onload="this.classList.add('loaded')" onerror="if(!this.dataset.retried){this.dataset.retried='1';var p=this.getAttribute('src');if(p&&p.indexOf('images.tmdb.org')<0&&p.indexOf('/t/p/')<0){var m=p.match(/\/(t\d|w\d+|original|h\d+)(\/.+)/);if(m)this.src='https://images.tmdb.org/t/p/'+m[1]+m[2];}this.classList.add('loaded');}">
${people > 0 ? `<span class="card-people">${escapeHtml(people)}人</span>` : ""}
<div class="card-body">
<div class="card-title-row">
<div class="card-title">${escapeHtml(item.title)}</div>
${rating ? `<span class="rating-badge-inline">${escapeHtml(rating)}</span>` : ""}
</div>
<div class="card-meta">${escapeHtml(item.remark)}</div>
</div>
`;
button.addEventListener("click", () => {
if (item.source === "history") openRecentItem(item);
else openDetail(item);
});
// Long press menu (搜索播放 + 盘搜)
if (item.source !== "history") {
let lpTimer = null;
let lpFired = false;
let lpStartX = 0, lpStartY = 0;
function lpStart(e) {
lpFired = false;
const t = e.touches ? e.touches[0] : e;
lpStartX = t.clientX; lpStartY = t.clientY;
lpTimer = setTimeout(() => {
lpFired = true;
haptic("medium");
showCardLongPressMenu(item, button);
}, 480);
}
function lpCancel() {
if (lpTimer) { clearTimeout(lpTimer); lpTimer = null; }
}
function lpMove(e) {
const t = e.touches ? e.touches[0] : e;
if (Math.abs(t.clientX - lpStartX) > 8 || Math.abs(t.clientY - lpStartY) > 8) lpCancel();
}
function lpEnd(e) {
lpCancel();
// If long-press fired, swallow this touchend so the click doesn't also fire
if (lpFired) { e.preventDefault(); e.stopPropagation(); lpFired = false; }
}
button.addEventListener("touchstart", lpStart, { passive: true });
button.addEventListener("touchmove", lpMove, { passive: true });
button.addEventListener("touchend", lpEnd, { passive: false });
button.addEventListener("touchcancel", lpCancel, { passive: true });
button.addEventListener("contextmenu", (e) => { e.preventDefault(); });
}
return button;
}
// ── 长按菜单:全局唯一实例管理 ──
const LongPressMenu = (function() {
let backdrop = null;
let menu = null;
function destroy() {
if (!backdrop && !menu) return; // 没有菜单打开时不写 body 样式(滚动热路径)
if (backdrop) { backdrop.remove(); backdrop = null; }
if (menu) { menu.remove(); menu = null; }
document.body.style.removeProperty("overflow");
}
function makeBtn(svgPath, label, onClick) {
const btn = document.createElement("button");
btn.className = "lp-btn focusable";
btn.type = "button";
btn.innerHTML = `<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${svgPath}</svg>${escapeHtml(label)}`;
btn.addEventListener("pointerdown", (e) => { e.stopPropagation(); });
btn.addEventListener("click", (e) => {
e.stopPropagation();
destroy();
onClick();
});
return btn;
}
return {
show: function(item, anchorEl) {
destroy();
// Full-screen backdrop to capture all outside taps
backdrop = document.createElement("div");
backdrop.className = "lp-backdrop";
backdrop.addEventListener("touchstart", (e) => { e.preventDefault(); destroy(); }, { passive: false });
backdrop.addEventListener("mousedown", (e) => { e.preventDefault(); destroy(); });
document.body.appendChild(backdrop);
menu = document.createElement("div");
menu.id = "cardLongPressMenu";
menu.className = "card-long-press-menu";
menu.setAttribute("role", "menu");
// 搜索播放 — same icon as detailSearchBtn
menu.appendChild(makeBtn(
'<circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/>',
"搜索播放",
() => {
rememberWatchIntent(item, "search");
startWatchTracking(item);
nativeSearch(item.title);
}
));
menu.appendChild(Object.assign(document.createElement("div"), { className: "lp-divider" }));
// 盘搜 — same icon as panSearchBtn
menu.appendChild(makeBtn(
'<path d="M6.2 18.3h11.6a4.1 4.1 0 0 0 .42-8.18 6.2 6.2 0 0 0-11.9 1.68A3.28 3.28 0 0 0 6.2 18.3Z"/>',
"盘搜",
() => {
openDetail(item, { keepPan: false });
setTimeout(() => searchPanResources(item), 300);
}
));
document.body.appendChild(menu);
// Position: prefer above card, fall back below
const rect = anchorEl.getBoundingClientRect();
requestAnimationFrame(() => {
const mw = menu.offsetWidth || 160;
const mh = menu.offsetHeight || 108;
let left = rect.left + rect.width / 2 - mw / 2;
let top = rect.top - mh - 10;
if (top < 8) top = rect.bottom + 10;
if (top + mh > window.innerHeight - 8) top = window.innerHeight - mh - 8;
if (left < 8) left = 8;
if (left + mw > window.innerWidth - 8) left = window.innerWidth - mw - 8;
menu.style.left = left + "px";
menu.style.top = top + "px";
});
},
destroy
};
})();
function showCardLongPressMenu(item, anchorEl) {
LongPressMenu.show(item, anchorEl);
}
async function openRecentItem(item) {
if (!item || !item.siteKey || !item.vodId) {
toast("最近观看缺少播放信息");
return;
}
try {
rememberWatchIntent(item, "view");
await sdk().vod(item.siteKey, item.vodId, item.title, item.pic);
startWatchTracking(item);
} catch (e) {
toast("打开最近观看失败:" + (e.message || "unknown"));
}
}
function mediaDomKey(item) {
if (!item) return "";
return item.id || (item.tmdbId && item.mediaType ? `tmdb:${item.mediaType}:${item.tmdbId}` : item.title || "");
}
function mediaRenderKey(item) {
if (!item) return "";
return [mediaDomKey(item), item.title, item.pic, item.remark, item.voteAverage, item.people, item.progress, item.badge]
.map((value) => String(value == null ? "" : value).replace(/[\r\n]+/g, " "))
.join("\t");
}
function mediaHeatKey(item) {
if (!item) return "";
const mediaType = item.mediaType || "";
const tmdbId = item.tmdbId || "";
if (mediaType && tmdbId) return `tmdb:${mediaType}:${tmdbId}`;
const title = normalizeTitle(item.title || item.query || "");
return title ? `title:${title}` : "";
}
function normalizeSnapshot(item) {
if (!item || typeof item !== "object") return {};
return {
id: item.id || (item.tmdbId && item.mediaType ? `tmdb:${item.mediaType}:${item.tmdbId}` : ""),
source: item.source || "tmdb",
tmdbId: item.tmdbId ? String(item.tmdbId) : "",
mediaType: item.mediaType || "",
listId: item.listId || "",
listTitle: item.listTitle || "",
title: item.title || "",
pic: item.pic || "",
landscape: item.landscape || "",
image: item.image || item.pic || "",
desc: item.desc || "",
releaseDate: item.releaseDate || "",
voteAverage: item.voteAverage || 0,
popularity: item.popularity || 0,
baseRank: item.baseRank || 99
};
}
function normalizeTitle(title) {
return String(title || "")
.toLowerCase()
.replace(/[第][一二三四五六七八九十0-9]+[季部]?/g, "")
.replace(/\s+/g, "")
.replace(/[·:,.。!?'"“”‘’《》<>【】()[\]{}_-]/g, "")
.trim();
}
function findMediaByContent(content) {
const title = normalizeTitle(content.title || content.query || "");
return allItems().concat(state.searchItems).find((item) => {
if (content.tmdbId && item.tmdbId && String(content.tmdbId) === String(item.tmdbId)) return true;
return title && normalizeTitle(item.title) === title;
});
}
function renderMetrics() {
const localKey = state.identity ? hotUserKey(state.identity.pubkey) : "";
const localVector = localKey ? state.hot.users.get(localKey) : null;
const local = localVector ? hotActiveVectorItems(localVector).length : 0;
$("identityText").textContent = state.identity ? `npub: ${state.identity.npub}
nsec: ${state.identity.nsec}` : "身份未就绪";
$("relayText").textContent = `relay: ${window.WEBHOME_CONFIG.nostr.relays.join(", ")}`;
setStatus("nostr", `已连接 ${state.relay.connected}/${window.WEBHOME_CONFIG.nostr.relays.length} · 本机 ${local} · 榜单 ${state.hot.items.length}`);
if (state.identity) state.status.identity = shortKey(state.identity.npub);
renderConnection();
}
function scheduleSearchSuggest() {
clearTimeout(state.suggestions.timer);
const input = $("searchInput");
const kw = input ? input.value.trim() : "";
if (!kw) {
// 输入清空时改为展示热搜词
if (document.activeElement === input) showHotSuggest();
else hideSearchSuggest();
return;
}
if (input && input.readOnly) return hideSearchSuggest();
const seq = ++state.suggestions.seq;
state.suggestions.timer = setTimeout(() => loadSearchSuggest(kw, seq), 180);
}
async function loadSearchSuggest(keyword, seq) {
const kw = String(keyword || "").trim();
if (!kw) return hideSearchSuggest();
if (seq !== state.suggestions.seq) return;
if (state.suggestions.keyword === kw && state.suggestions.items.length) {
if ($("searchInput") && $("searchInput").value.trim() === kw && !$("searchInput").readOnly) renderSearchSuggest();
return;
}
state.suggestions.loading = true;
try {
const body = await requestJson(suggestUrl(kw), 8);
if (seq !== state.suggestions.seq) return;
if (!$("searchInput") || $("searchInput").value.trim() !== kw || $("searchInput").readOnly) return;
state.suggestions.keyword = kw;
state.suggestions.items = normalizeSuggestItems(body).slice(0, 8);
renderSearchSuggest();
} catch (e) {
if (seq === state.suggestions.seq && $("searchInput") && $("searchInput").value.trim() === kw) hideSearchSuggest();
} finally {
state.suggestions.loading = false;
}
}
function normalizeSuggestItems(body) {
const raw = Array.isArray(body && body.data) ? body.data : Array.isArray(body) ? body : [];
const seen = new Set();
const items = [];
raw.forEach((item) => {
const title = String(item && (item.name || item.title || item.keyword || item.word) || "").trim();
if (!title || seen.has(title)) return;
seen.add(title);
const type = String(item.cname || item.channel_name || item.type || "").trim();
const year = item.year ? String(item.year) : "";
const actor = Array.isArray(item.main_actor) ? item.main_actor.slice(0, 2).join("/") : "";
items.push({ title, meta: [type, year, actor].filter(Boolean).join(" · ") });
});
return items;
}
function pickRandomHotItems(items, poolSize, count) {
const pool = items.slice(0, Math.min(poolSize, items.length));
for (let i = pool.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
const tmp = pool[i];
pool[i] = pool[j];
pool[j] = tmp;
}
return pool.slice(0, count);
}
function showHotSuggest() {
const panel = $("suggestPanel");
if (!panel) return;
// 从热搜池中随机抽取 8 条,每次打开面板都会变化
const hotItems = pickRandomHotItems(state.hot.items || [], 30, 8);
if (!hotItems.length) return;
closeConnectionPanel();
const fireIconSvg = `<svg width="13" height="13" viewBox="0 0 20 20" fill="none" aria-hidden="true"><path d="M10 2c0 0-1 3-1 5 0 1.1.9 2 2 2s2-.9 2-2c0 0 2 2 2 5a5 5 0 01-10 0c0-3 2-6 5-10z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round" fill="none"/></svg>`;
panel.replaceChildren(...hotItems.map((item, idx) => {
const button = document.createElement("button");
button.className = "suggest-item focusable";
button.type = "button";
button.setAttribute("role", "option");
const mediaLabel = item.mediaType === "movie" ? "电影" : item.mediaType === "tv" ? "剧集" : "";
const meta = [mediaLabel, item.people ? item.people + "人在看" : ""].filter(Boolean).join(" · ");
button.innerHTML = `<span class="suggest-icon suggest-icon-hot">${fireIconSvg}</span><b>${escapeHtml(item.title)}</b><span>${escapeHtml(meta)}</span>`;
button.addEventListener("click", () => {
const value = stripSeasonSuffix(item.title);
$("searchInput").value = value;
hideSearchSuggest();
searchTmdb(value);
});
return button;
}));
panel.classList.add("open");
if ($("searchForm")) $("searchForm").classList.add("suggesting");
}
function renderSearchSuggest() {
const panel = $("suggestPanel");
if (!panel) return;
const items = state.suggestions.items || [];
if (!items.length) return hideSearchSuggest();
closeConnectionPanel();
const kw = (state.suggestions.keyword || "").trim();
const searchIconSvg = `<svg width="13" height="13" viewBox="0 0 20 20" fill="none" aria-hidden="true"><circle cx="8.5" cy="8.5" r="5.5" stroke="currentColor" stroke-width="1.8"/><path d="M13 13l3.5 3.5" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>`;
panel.replaceChildren(...items.map((item, idx) => {
const button = document.createElement("button");
button.className = "suggest-item focusable";
button.type = "button";
button.setAttribute("role", "option");
// 关键词高亮
let titleHtml = escapeHtml(item.title);
if (kw) {
const escaped = kw.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
titleHtml = titleHtml.replace(new RegExp(escaped, "gi"), m => `<mark>${m}</mark>`);
}
button.innerHTML = `<span class="suggest-icon">${searchIconSvg}</span><b>${titleHtml}</b><span>${escapeHtml(item.meta || "")}</span>`;
button.addEventListener("click", () => applySearchSuggest(item.title));
return button;
}));
panel.classList.add("open");
if ($("searchForm")) $("searchForm").classList.add("suggesting");
}
function hideSearchSuggest() {
clearTimeout(state.suggestions.timer);
state.suggestions.timer = 0;
state.suggestions.seq += 1;
const panel = $("suggestPanel");
if (panel) {
panel.classList.remove("open");
panel.replaceChildren();
}
if ($("searchForm")) $("searchForm").classList.remove("suggesting");
state.suggestions.items = [];
state.suggestions.keyword = "";
if (state.suggestions.controller) {
try { state.suggestions.controller.abort(); } catch (e) {}
state.suggestions.controller = null;
}
}
// 去除标题中的「第x季/Season x/特别篇」等季号后缀,得到可被搜索接口匹配到的纯剧名
// 例:「怪奇物语 第四季」→「怪奇物语」;「Stranger Things Season 4」→「Stranger Things」
function stripSeasonSuffix(title) {
let s = String(title || "").trim();
s = s.replace(/[\s·:\-—]*第\s*[0-9一二三四五六七八九十百]+\s*季\s*$/u, "");
s = s.replace(/[\s·:\-—]*Season\s*\d+\s*$/i, "");
s = s.replace(/[\s·:\-—]*S\d{1,2}\s*$/i, "");
s = s.replace(/[\s·:\-—]*(特别篇|总集篇|番外篇)\s*$/u, "");
s = s.trim();
return s || String(title || "").trim();
}
function applySearchSuggest(title) {
const value = stripSeasonSuffix(title);
if (!value) return;
$("searchInput").value = value;
hideSearchSuggest();
searchTmdb(value);
}
function enableSearchEditing() {
if ($("searchInput")) $("searchInput").readOnly = false;
}
function disableSearchEditing() {
if ($("searchInput")) $("searchInput").readOnly = true;
}
function submitSearchInput() {
const input = $("searchInput");
if (!input) return;
enableSearchEditing();
searchTmdb(input.value);
}
async function searchTmdb(keyword) {
const kw = keyword.trim();
if (!kw) return;
hideSearchSuggest();
try {
setStatus("tmdb", `搜索 ${kw}`);
const body = await requestJson(tmdbSearchUrl(kw), 18);
const results = (body.results || []).filter((item) => item.media_type === "movie" || item.media_type === "tv");
state.searchItems = results.map((item, index) => normalizeTmdb(item, { id: "search", title: "搜索", mediaType: item.media_type }, index)).filter(hasPoster).slice(0, 18);
setStatus("tmdb", `搜索成功 ${state.searchItems.length} 条`);
renderSearch();
disableSearchEditing();
const searchEl = $("searchInput");
if (searchEl && document.activeElement === searchEl) searchEl.blur();
// 搜索结果出现时复用「聚焦」已压入的 #search 层;若尚未压入则补一条,
// 让返回键能整体关闭搜索板块(聚焦态 + 结果列表)一次回到主页。
if (state.searchItems.length && location.hash !== "#search" && location.hash !== "#detail") {
history.pushState({ sheet: "search" }, "", "#search");
_searchHistoryPushed = true;
}
scheduleUiSnapshotSave();
// 保留在搜索结果页,不自动打开详情
} catch (e) {
setStatus("tmdb", "搜索失败:" + (e.message || "unknown"));
toast("搜索失败");
}
}
// 滚动恢复由我们在 popstate 里手动处理(pushState 后立即还原)
if ("scrollRestoration" in history) {
history.scrollRestoration = "manual";
}
// 关闭详情时跨 history.back() 传递主页滚动位置
let _pendingHomeScrollY = 0;
// 标记 #pan 条目已推入 history,供 popstate 判断返回方向
let _panHistoryPushed = false;
// 标记搜索板块(聚焦态 + 结果页共用同一层 #search)已推入 history
let _searchHistoryPushed = false;
// 主页滚动位置 —— 开详情前保存,关详情后同步恢复
function openDetail(item, options) {
const opts = options || {};
state.selected = item;
state.detail = null;
if (!opts.keepPan) resetPanSearch();
ensureSheetViewport($("detailSheet"));
syncDetailLayout();
document.body.classList.add("detail-active");
$("detailSheet").classList.add("active");
$("detailSheet").setAttribute("aria-hidden", "false");
if (isNativeLeanbackClient()) setTvMode(true);
try {
renderDetailBase(item);
clearDetailExtras();
} catch (e) {
$("detailTitle").textContent = item && item.title || "详情";
$("detailText").textContent = "详情渲染失败:" + (e.message || "unknown");
}
if (!opts.skipHistory && location.hash !== "#detail") history.pushState({ sheet: "detail" }, "", "#detail");
if (!opts.restore) rememberWatchIntent(item, "view");
setNativeToolbarVisible(false, isNativeLeanbackClient() || isNativeMobileClient());
if (shouldRefreshRecentList()) loadRecentList({ silent: true }).catch(() => {});
loadDetail(item);
scheduleUiSnapshotSave();
if (!opts.restore) setTimeout(() => focusRemoteTarget(detailPrimaryActionButton() || $("closeDetailBtn")), 40);
}
function ensureSheetViewport(sheet, display) {
if (!sheet) return;
sheet.style.position = "fixed";
sheet.style.top = "0";
sheet.style.right = "0";
sheet.style.bottom = "0";
sheet.style.left = "0";
sheet.style.width = "100%";
sheet.style.height = "100vh";
// detail-large 用 flex 布局,不能强制 block
const useDisplay = display || (sheet.classList.contains("detail-large") ? "flex" : "block");
sheet.style.display = useDisplay;
sheet.style.zIndex = sheet.id === "imageViewer" ? "100" : "60";
}
function renderDetailBase(item) {
resetDetailTextClamp();
// 每次打开详情页时重置简介为折叠状态
const dt = $("detailText");
if (dt) dt.classList.remove("expanded");
// Reset logo
const logoWrap = $("detailLogoWrap");
const logoImg = $("detailLogoImg");
if (logoWrap) logoWrap.style.display = "none";
if (logoImg) { logoImg.src = ""; logoImg.classList.remove("loaded"); }
$("detailTitle").textContent = item.title;
$("detailTitle").style.display = "";
const fallbackText = item.desc || item.remark || "";
$("detailText").textContent = fallbackText || "";
renderDetailMeta(item, state.detail);
renderDetailTitleMeta(item, state.detail);
// 先用封面图(item.pic)显示,TMDB数据加载后 renderDetailFull 再换横图
const posterSrc = item.pic || "";
setDetailCoverCarousel(
item.landscape ? [item.landscape] : [],
posterSrc,
{ allowPoster: true } // 有海报就先用海报,比空白强
);
updateDetailContinueButton();
scheduleDetailTextClamp();
}
function renderDetailMeta(item, detail) {
if (isTvMode() && !useLargeDetailLayout()) {
$("detailMeta").replaceChildren();
return;
}
$("detailMeta").replaceChildren(...detailMeta(item, detail).map((meta) => {
const span = document.createElement("span");
const text = meta.text || meta;
let typeClass = "";
if (/^\d{4}$/.test(text)) typeClass = " pill-year";
else if (/分钟|每集/.test(text)) typeClass = " pill-runtime";
else if (/^\d/.test(text) && /分$/.test(text)) typeClass = " pill-score";
else if (text && !typeClass) typeClass = " pill-genre";
span.className = "meta-pill" + (meta.strong ? " strong" : "") + typeClass;
span.textContent = text;
return span;
}));
}
function renderDetailTitleMeta(item, detail) {
const root = $("detailTitleMeta");
const items = useLargeDetailLayout()
? detailTitleMeta(item, detail).filter((meta) => meta.type === "score").slice(0, 1)
: isTvMode() ? uniqueMeta(detailTitleMeta(item, detail).concat(detailMeta(item, detail))) : detailTitleMeta(item, detail);
root.replaceChildren(...items.flatMap((meta, index) => {
const span = document.createElement("span");
span.className = meta.type || "";
span.textContent = meta.text || meta;
if (index === 0) return [span];
const sep = document.createElement("span");
sep.className = "sep";
sep.textContent = "·";
return [sep, span];
}));
}
function setDetailCoverCarousel(landscapes, poster, options) {
const images = uniqueCoverImages(Array.isArray(landscapes) ? landscapes : [landscapes]);
pauseDetailCoverCarousel();
state.detailCover.images = images;
state.detailCover.index = 0;
state.detailCover.token += 1;
setDetailCoverImage(images[0] || "", poster, options);
updateDetailCoverControls();
if (images.length > 1) {
const token = state.detailCover.token;
state.detailCover.timer = setInterval(() => {
if (token !== state.detailCover.token || !$('detailSheet').classList.contains('active') || document.visibilityState === 'hidden') return;
switchDetailCover(1, { allowPoster: false, keepLoading: false });
}, 5200);
}
}
function updateDetailCoverControls() {
const cover = $("detailImage") && $("detailImage").parentElement;
if (!cover) return;
const enabled = state.detailCover.images.length > 1;
cover.classList.toggle("has-multiple", enabled);
}
function shiftDetailCover(delta) {
const images = state.detailCover.images || [];
if (images.length <= 1) return;
pauseDetailCoverCarousel();
switchDetailCover(delta, { allowPoster: false, keepLoading: false });
updateDetailCoverControls();
}
function switchDetailCover(delta, options) {
const images = state.detailCover.images || [];
if (images.length <= 1) return;
const start = Math.max(0, Math.min(Number(state.detailCover.index || 0), images.length - 1));
const step = delta < 0 ? -1 : 1;
const token = state.detailCover.token;
tryPreloadDetailCover(start, step, 0, token, options || {});
}
function tryPreloadDetailCover(current, step, attempts, token, options) {
const images = state.detailCover.images || [];
if (token !== state.detailCover.token || !$('detailSheet').classList.contains('active')) return;
if (!images.length || attempts >= images.length - 1) return;
const next = (current + step + images.length) % images.length;
const src = displayImage(images[next], { size: "original" });
if (!src) return tryPreloadDetailCover(next, step, attempts + 1, token, options);
// TV 端跳过预加载,直接切换(TV WebView 对 new Image() 预加载处理异常)
if (isTvMode() || isNativeLeanbackClient()) {
if (token !== state.detailCover.token || !$('detailSheet').classList.contains('active')) return;
state.detailCover.index = next;
setDetailCoverImage(images[next], "", Object.assign({}, options, { preloadedSrc: src }));
return;
}
preloadImage(src, () => {
if (token !== state.detailCover.token || !$('detailSheet').classList.contains('active')) return;
state.detailCover.index = next;
setDetailCoverImage(images[next], "", Object.assign({}, options, { preloadedSrc: src }));
}, () => tryPreloadDetailCover(next, step, attempts + 1, token, options));
}
function preloadImage(src, onload, onerror) {
const img = new Image();
img.onload = onload;
img.onerror = onerror;
img.src = src;
}
function restartDetailCoverCarousel() {
if (!$('detailSheet').classList.contains('active') || state.detailCover.timer || state.detailCover.images.length <= 1) return;
const images = state.detailCover.images.slice();
const current = Math.max(0, Math.min(state.detailCover.index, images.length - 1));
state.detailCover.images = [];
setDetailCoverCarousel(images.slice(current).concat(images.slice(0, current)), "", { allowPoster: false, keepLoading: false });
}
function setDetailCoverImage(landscape, poster, options) {
const opts = options || {};
const cover = $("detailImage").parentElement;
const image = $("detailImage");
const land = String(landscape || "").trim();
const post = String(poster || "").trim();
const allowPoster = opts.allowPoster !== false;
const usePoster = allowPoster && (!land || isSameImageSource(land, post));
const src = usePoster ? post || land : land;
const bgSrc = src ? displayImage(src, { size: "original" }) : "";
cover.classList.toggle("loading", !src && opts.keepLoading !== false);
cover.classList.toggle("poster-mode", !!usePoster && !!src);
cover.dataset.coverMode = usePoster ? "poster" : "landscape";
const coverBgValue = bgSrc ? `url("${cssUrl(bgSrc)}")` : "none";
cover.style.setProperty("--detail-cover-bg", coverBgValue);
const sheet = $("detailSheet");
// 同步到 sheet:大屏沉浸式背景的 ::before 渐变层需要在 sheet 层读到这张图
if (sheet) sheet.style.setProperty("--detail-cover-bg", coverBgValue);
setHeroBg(bgSrc);
if (src) {
const nextSrc = opts.preloadedSrc || bgSrc;
const applyLoaded = () => {
image.onload = null;
image.onerror = null;
image.src = nextSrc;
resetDetailCoverFrame(cover);
image.classList.add("active");
};
if (opts.preloadedSrc) {
applyLoaded();
return;
}
if (image.getAttribute("src") === nextSrc && image.classList.contains("active")) return;
// TV 端跳过 new Image() 预加载(TV WebView 对 new Image() 处理异常),直接赋值给 img.src
if (isTvMode() || isNativeLeanbackClient()) {
applyLoaded();
return;
}
preloadImage(nextSrc, applyLoaded, () => {
if (!image.getAttribute("src")) image.classList.remove("active");
});
} else {
image.onload = null;
image.onerror = null;
image.classList.remove("active");
image.removeAttribute("src");
resetDetailCoverFrame(cover);
}
}
function syncDetailCoverFrame(image) {
const cover = image && image.parentElement;
resetDetailCoverFrame(cover);
}
function resetDetailCoverFrame(cover) {
if (!cover) return;
cover.style.width = "";
cover.style.height = "";
cover.style.minHeight = "";
cover.style.maxHeight = "";
}
function pauseDetailCoverCarousel() {
if (state.detailCover.timer) clearInterval(state.detailCover.timer);
state.detailCover.timer = 0;
state.detailCover.token += 1;
}
function stopDetailCoverCarousel(clearImage) {
pauseDetailCoverCarousel();
state.detailCover.images = [];
state.detailCover.index = 0;
updateDetailCoverControls();
if (clearImage) {
const image = $("detailImage");
const cover = image.parentElement;
image.onload = null;
image.classList.remove("active");
image.removeAttribute("src");
cover.classList.remove("loading", "poster-mode");
delete cover.dataset.coverMode;
cover.style.setProperty("--detail-cover-bg", "none");
const sheetEl = $("detailSheet");
if (sheetEl) sheetEl.style.setProperty("--detail-cover-bg", "none");
setHeroBg("");
resetDetailCoverFrame(cover);
}
}
function uniqueCoverImages(items) {
const seen = new Set();
return (items || []).map((item) => String(item || "").trim()).filter((item) => {
const key = tmdbImagePath(item) || item;
if (!key || seen.has(key)) return false;
seen.add(key);
return true;
});
}
function isSameImageSource(a, b) {
const left = tmdbImagePath(a) || String(a || "").trim();
const right = tmdbImagePath(b) || String(b || "").trim();
return !!left && !!right && left === right;
}
function cssUrl(value) {
return String(value || "").replace(/["\\\n\r]/g, "");
}
function detailMeta(item, detail) {
const data = detail || {};
const mediaType = item.mediaType === "tv" ? "剧集" : "电影";
const date = data.first_air_date || data.release_date || item.releaseDate || "";
const genres = (data.genres || []).map((genre) => genre && genre.name).filter(Boolean).filter((name) => !/science fiction/i.test(name)).slice(0, 2);
const runtime = detailRuntime(data, item);
const seasonText = item.mediaType === "tv" && data.number_of_seasons ? `${data.number_of_seasons}季` : "";
if (useLargeDetailLayout()) {
const year = String(date || "").slice(0, 4);
const genreText = genres.length ? genres.slice(0, 3).join(" / ") : mediaType;
const episodeText = item.mediaType === "tv" ? detailLargeEpisodeLabel(data) : runtime;
const status = detailStatus(data.status || "");
return uniqueMeta([year, genreText, episodeText, status && status !== "已完结" ? status : "", runtime && item.mediaType === "tv" ? runtime : ""])
.slice(0, 6)
.map((meta) => typeof meta === "string" ? { text: meta } : meta);
}
const metas = [
detailDateLabel(item, date),
seasonText,
runtime
].filter(Boolean);
genres.forEach((name) => metas.push(name));
return uniqueMeta(metas).slice(0, 8).map((meta) => typeof meta === "string" ? { text: meta } : meta);
}
function detailTitleMeta(item, detail) {
const data = detail || {};
const mediaType = item.mediaType === "tv" ? "电视剧" : "电影";
const rating = Number(data.vote_average || item.voteAverage || 0);
const episodeProgress = item.mediaType === "tv" ? detailEpisodeProgress(data) : "";
const status = detailStatus(data.status || "");
return [
rating > 0 ? { text: `${rating.toFixed(1)}分`, type: "score" } : null,
{ text: mediaType },
episodeProgress ? { text: episodeProgress } : null,
status ? { text: status } : null
].filter(Boolean);
}
function detailRuntime(detail, item) {
const runtimes = item.mediaType === "tv" ? detail.episode_run_time || [] : detail.runtime ? [detail.runtime] : [];
const value = runtimes.find((runtime) => Number(runtime) > 0);
if (!value) return "";
return item.mediaType === "tv" ? `每集${Number(value)}分钟` : `${Number(value)}分钟`;
}
function detailEpisodeProgress(detail) {
const total = Number(detail.number_of_episodes || 0);
const aired = Math.max(Number(detail.last_episode_to_air && detail.last_episode_to_air.episode_number || 0), airedEpisodeFromSeason(detail));
if (aired && total) return `${aired}/${total}集`;
if (total) return `共${total}集`;
if (aired) return `更新至${aired}集`;
return "";
}
function detailLargeEpisodeLabel(detail) {
const total = Number(detail && detail.number_of_episodes || 0);
const aired = Math.max(Number(detail && detail.last_episode_to_air && detail.last_episode_to_air.episode_number || 0), airedEpisodeFromSeason(detail));
const status = detailStatus(detail && detail.status || "");
if (total && (status === "已完结" || aired >= total)) return `${total}集全`;
if (aired && total) return `${aired}/${total}集`;
if (aired) return `更新至${aired}集`;
if (total) return `${total}集`;
return "";
}
function airedEpisodeFromSeason(detail) {
const season = detail && (detail["season/1"] || detail.season_1);
const episodes = season && Array.isArray(season.episodes) ? season.episodes : [];
if (!episodes.length) return 0;
const todayMs = dateOnlyMs(today());
return episodes.reduce((max, ep) => {
const airMs = dateOnlyMs(ep && ep.air_date);
if (!airMs || airMs > todayMs) return max;
return Math.max(max, Number(ep.episode_number || 0));
}, 0);
}
function detailStatus(status) {
const map = {
"Returning Series": "更新中",
"Ended": "已完结",
"Canceled": "已取消",
"Cancelled": "已取消",
"In Production": "制作中",
"Planned": "计划中",
"Released": "已上映",
"Post Production": "后期制作"
};
return map[status] || status || "";
}
function detailDateLabel(item, date) {
if (!date) return "";
return `${item.mediaType === "tv" ? "首播" : "上映"} ${formatDateCn(date)}`;
}
function formatDateCn(date) {
const match = String(date || "").match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!match) return date || "";
return `${match[1]}${Number(match[2])}${Number(match[3])}日`;
}
function dateOnlyMs(date) {
const match = String(date || "").match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!match) return 0;
return new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3])).getTime();
}
function uniqueMeta(items) {
const seen = new Set();
return items.filter((item) => {
const text = typeof item === "string" ? item : item && item.text;
const key = String(text || "").trim();
if (!key || seen.has(key)) return false;
seen.add(key);
return true;
});
}
function clearDetailExtras() {
["seasonBlock", "castBlock", "personWorkBlock", "recommendBlock"].forEach((id) => $(id).style.display = "none");
["seasonTabs", "episodeRail", "castRail", "personInfo", "personWorkRail", "recommendWorkRail"].forEach((id) => $(id).replaceChildren());
}
async function loadDetail(item) {
try {
const body = await requestJson(tmdbDetailUrl(item), 18);
if (!body || state.selected !== item) return;
state.detail = body;
renderDetailExtras(item, body);
} catch (e) {
// 降级:使用本地 item 数据填充详情页
if (state.selected === item) {
const fallbackDesc = item.desc || item.remark || item.overview || "";
if (fallbackDesc) {
$("detailText").textContent = fallbackDesc;
scheduleDetailTextClamp();
}
renderDetailMeta(item, null);
renderDetailTitleMeta(item, null);
normalizeRails();
}
}
}
function renderDetailExtras(item, detail) {
const overview = detail.overview || item.desc || item.remark;
if (overview) $("detailText").textContent = overview;
renderDetailMeta(item, detail);
renderDetailTitleMeta(item, detail);
scheduleDetailTextClamp();
renderDetailCoverFromDetail(item, detail);
renderDetailLogo(detail);
renderCast(detail.credits && detail.credits.cast || []);
if (item.mediaType === "tv") {
renderSeasons(item, detail);
preloadPanForSeries(item); // 进剧集详情页即后台静默预搜盘搜,缩短点分集等待
} else if (item.mediaType === "movie") {
preloadPanForSeries(item); // 电影也后台预搜 + 预热夸克,配合"搜索播放"短按秒开
}
loadRecommendations(item);
normalizeRails();
updateDetailContinueButton();
updatePostPanFocusState();
}
function bestDetailLogo(detail) {
const logos = detail && detail.images && detail.images.logos || [];
if (!logos.length) return "";
// Prefer: zh first, then null (language-agnostic), then en; highest vote_average
const scored = logos
.filter((l) => l && l.file_path)
.map((l) => {
const lang = l.iso_639_1 || "";
const langScore = lang === "zh" ? 3 : lang === "" ? 2 : lang === "en" ? 1 : 0;
return { path: l.file_path, score: langScore * 1000 + Number(l.vote_average || 0) * 10 + (Number(l.width || 0) / 1000) };
})
.sort((a, b) => b.score - a.score);
return scored.length ? scored[0].path : "";
}
function renderDetailLogo(detail) {
const wrap = $("detailLogoWrap");
const img = $("detailLogoImg");
if (!wrap || !img) return;
const path = bestDetailLogo(detail);
if (!path) {
wrap.style.display = "none";
img.removeAttribute("src");
img.classList.remove("loaded");
return;
}
// Use w500 size (logos are typically transparent PNGs, w500 is fine)
// tmdbImageUrl handles the correct base URL with size in the path
const src = tmdbImageUrl(path, "w500");
if (!src) { wrap.style.display = "none"; return; }
// Use fm.res() to proxy through native http (bypass CORS, send cookies)
let finalSrc = src;
try {
if (typeof fm !== "undefined" && fm && typeof fm.res === "function") {
finalSrc = fm.res(src);
}
} catch (e) { /* ignore */ }
img.classList.remove("loaded");
wrap.style.display = "flex";
img.onload = () => {
img.classList.add("loaded");
const t = $("detailTitle");
if (t) t.style.display = "none";
};
img.onerror = () => {
wrap.style.display = "none";
img.classList.remove("loaded");
const t = $("detailTitle");
if (t) t.style.display = "";
};
img.src = finalSrc;
}
function detailPrimaryActionButton() {
return isVisibleFocusable($("detailContinueBtn")) ? $("detailContinueBtn") : $("detailSearchBtn");
}
function scheduleDetailTextClamp() {
requestAnimationFrame(() => requestAnimationFrame(updateDetailTextClamp));
}
function resetDetailTextClamp() {
const text = $("detailText");
if (text) {
text.classList.remove("clamped");
text.style.removeProperty("--detail-text-max");
}
}
function measureDetailMoreHeight() { return 0; }
function updateDetailTextClamp() {
// 已移除"更多"按钮,简介始终完整展示
const text = $("detailText");
if (text) {
text.classList.remove("clamped");
text.style.removeProperty("--detail-text-max");
}
}
function toggleDetailTextMore() { /* 已废弃 */ }
function renderDetailCoverFromDetail(item, detail) {
const detailLandscapes = bestDetailBackdrops(detail, 8);
const detailLandscape = detailLandscapes[0] || "";
const detailPoster = imageUrl(detail.poster_path, false) || item.pic;
const posterFallback = detailPoster || item.pic || "";
if (detailLandscapes.length) {
item.landscape = detailLandscape;
item.pic = item.pic || posterFallback;
setDetailCoverCarousel(detailLandscapes, posterFallback, { allowPoster: false });
} else if (posterFallback) {
item.landscape = "";
item.pic = item.pic || posterFallback;
setDetailCoverCarousel([], posterFallback, { allowPoster: true, keepLoading: false });
}
}
function bestDetailBackdrops(detail, limit) {
const candidates = [];
if (detail && detail.backdrop_path) candidates.push({ file_path: detail.backdrop_path, vote_average: 11, vote_count: Number.MAX_SAFE_INTEGER, width: 1280, height: 720 });
(detail && detail.images && detail.images.backdrops || []).forEach((img) => {
if (img && img.file_path) candidates.push(img);
});
return candidates
.filter((img) => img && img.file_path)
.sort((a, b) => backdropScore(b) - backdropScore(a))
.slice(0, limit || 8)
.map((img) => imageUrl(img.file_path, true));
}
function backdropScore(img) {
const vote = Number(img && img.vote_average || 0);
const count = Number(img && img.vote_count || 0);
const width = Number(img && img.width || 0);
const height = Number(img && img.height || 0);
const ratio = width > 0 && height > 0 ? width / height : 16 / 9;
const ratioPenalty = Math.abs(ratio - 16 / 9) * 1.4;
return vote * 1000 + Math.min(count, 1000) * 2 + Math.min(width, 3840) / 20 - ratioPenalty * 100;
}
function renderCast(cast) {
const people = cast.filter((person) => person.profile_path).slice(0, 20);
if (!people.length) return;
$("castBlock").style.display = "";
$("castRail").replaceChildren(...people.map((person) => {
const button = document.createElement("button");
button.className = "person-card focusable";
button.type = "button";
const profile = displayImage(imageUrl(person.profile_path, false), { size: "w185" });
const zhName = escapeHtml(person.name || "");
const enName = person.original_name && person.original_name !== person.name
? escapeHtml(person.original_name) : "";
const role = escapeHtml(person.character || person.known_for_department || "");
button.innerHTML = `
<img alt="${zhName}" ${imageAttrs(profile)}>
<div>
<b>${zhName}</b>
${enName ? `<span class="person-en-name">${enName}</span>` : ""}
${role ? `<span>${role}</span>` : ""}
</div>
`;
button.addEventListener("click", () => loadPersonWorks(person));
return button;
}));
}
function renderSeasons(item, detail) {
const seasons = (detail.seasons || []).filter((season) => season.season_number > 0);
if (!seasons.length) return;
$("seasonBlock").style.display = "";
const activeEl = document.activeElement;
const restoreSeason = activeEl && $("seasonTabs").contains(activeEl) && activeEl.dataset.seasonNumber || "";
$("seasonTabs").replaceChildren(...seasons.map((season, index) => {
const button = document.createElement("button");
button.className = "chip focusable" + (index === 0 ? " active" : "");
button.type = "button";
button.dataset.seasonNumber = String(season.season_number);
button.textContent = season.name || "第 " + season.season_number + " 季";
button.addEventListener("click", () => {
document.querySelectorAll("#seasonTabs .chip").forEach((el) => el.classList.remove("active"));
button.classList.add("active");
loadSeason(item, season.season_number);
});
return button;
}));
if (restoreSeason) requestAnimationFrame(() => focusRemoteTarget(findByDataset($("seasonTabs"), "seasonNumber", restoreSeason) || $("seasonTabs").querySelector(".chip.active") || $("seasonTabs").querySelector(".chip")));
const first = detail["season/1"] || null;
if (first && first.episodes) renderEpisodes(first.episodes);
else loadSeason(item, seasons[0].season_number);
}
async function loadSeason(item, seasonNumber) {
try {
const body = await requestJson(tmdbSeasonUrl(item, seasonNumber), 18);
renderEpisodes(body.episodes || []);
} catch (e) {
$("episodeRail").replaceChildren(emptyNode("分集加载失败"));
}
updatePostPanFocusState();
}
function renderEpisodes(episodes) {
const activeEl = document.activeElement;
const restoreEpisode = activeEl && $("episodeRail").contains(activeEl) && activeEl.dataset.episodeNumber || "";
if (!episodes.length) {
$("episodeRail").replaceChildren(emptyNode("暂无分集信息"));
return;
}
$("episodeRail").replaceChildren(...episodes.map((ep) => {
const stillSource = ep.still_path ? imageUrl(ep.still_path, true) : "";
const still = stillSource ? displayImage(stillSource, { size: "w780" }) : "";
const fullStill = stillSource ? displayImage(stillSource, { size: "w780" }) : "";
const button = document.createElement("button");
button.className = "episode-card focusable" + (still ? " has-still" : "");
button.type = "button";
button.dataset.episodeNumber = String(ep.episode_number || "");
button.innerHTML = `
<span class="episode-badge">第${escapeHtml(ep.episode_number || "?")}集</span>
${still ? `<img class="episode-still" alt="" ${imageAttrs(still)}>` : ""}
<div class="episode-body">
<b>${ep.episode_number}. ${escapeHtml(ep.name || "未命名")}</b>
<span>${escapeHtml(ep.air_date || "")}</span>
<p>${escapeHtml(ep.overview || "暂无剧情")}</p>
</div>
`;
button.addEventListener("click", () => playEpisodeViaPan(ep));
return button;
}));
if (restoreEpisode) requestAnimationFrame(() => focusRemoteTarget(findByDataset($("episodeRail"), "episodeNumber", restoreEpisode) || $("episodeRail").querySelector(".episode-card")));
updatePostPanFocusState();
}
function openEpisodeViewer(ep, still) {
const title = `${ep.episode_number || ""}${ep.episode_number ? ". " : ""}${ep.name || "未命名"}`;
const meta = [ep.air_date || "", state.selected && state.selected.title || ""].filter(Boolean).join(" · ");
const overview = ep.overview || "暂无剧情概要";
openImage(still || "", { episode: { title, meta, overview, still } });
}
// ── 点击分集 → 直接进播放器播放盘搜匹配到的「当前集」单集资源(优先 115)──
// 行为:先尝试直接播;匹配不到单集时回退打开盘搜并高亮,不再弹剧照预览页。
// 当前选中的季号(用于 SxxEyy 匹配);优先用 episode 自带 season_number。
function activeSeasonNumber() {
const el = $("seasonTabs") && $("seasonTabs").querySelector(".chip.active");
return el ? (Number(el.dataset.seasonNumber) || 0) : 0;
}
// 标题是否为「整季/区间合集」(这类无法定位到单集,单集自动播时排除)
function panItemIsRangePack(title) {
const t = String(title || "");
return /S?\d{1,2}\s*E\d{1,3}\s*[-~]\s*E?\d{1,3}/i.test(t) // S01E01-E24 / E01-E24
|| /第\s*\d{1,3}\s*[-~]\s*\d{1,3}\s*集/.test(t) // 第1-24集
|| /\bE\d{1,3}\s*[-~]\s*\d{1,3}\b/i.test(t) // E01-24
|| /全\s*\d{1,3}\s*集/.test(t) // 全24集
|| /合集|全集|完结合集|打包/.test(t);
}
// 单条盘搜资源是否精确命中「第 epNo 集」(季号 season
function panItemMatchesEpisode(item, season, epNo) {
const t = String(item && item.title || "");
if (!t) return false;
if (panItemIsRangePack(t)) return false; // 区间/整季包不算单集
const s = Number(season) || 1;
const n = Number(epNo) || 0;
if (!n) return false;
const reSE = new RegExp(`S0*${s}\\s*E0*${n}(?![0-9E])`, "i"); // S01E23
const reE = new RegExp(`(?:^|[^0-9A-Za-z])E0*${n}(?![0-9])`, "i");// 独立 E23
const reCN = new RegExp(`第\\s*0*${n}\\s*集(?!\\s*[-~])`); // 第23集
return reSE.test(t) || reE.test(t) || reCN.test(t);
}
// 候选排序:115 优先,其次其它单集源,同源按画质分
function panDiskOrder(item) {
const order = { "115": 0, "quark": 1, "aliyun": 1, "uc": 1, "123": 1, "tianyi": 1, "mobile": 1, "xunlei": 2, "baidu": 2 };
return order[normalizePanDiskType(item && item.diskType)] ?? 3;
}
function rankPanCandidates(arr) {
return arr.slice().sort((a, b) => {
const d = panDiskOrder(a) - panDiskOrder(b);
if (d) return d;
return panQualityInfo(b).score - panQualityInfo(a).score;
});
}
// 「全集/全包」字眼评分:标题越像完整合集分越高(优先选带「全集」字样的)
function panPackScore(item) {
const t = String(item && item.title || "");
let score = 0;
if (/全\s*\d{1,3}\s*集/.test(t)) score += 6; // 全24集
if (/全集/.test(t)) score += 5; // 全集
if (/完结|大结局|完结篇/.test(t)) score += 3; // 完结
if (/合集|打包/.test(t)) score += 2; // 合集/打包
if (/S?\d{1,2}\s*E\d{1,3}\s*[-~]\s*E?\d{1,3}/i.test(t)) score += 2; // S01E01-E24
if (/第\s*\d{1,3}\s*[-~]\s*\d{1,3}\s*集/.test(t)) score += 2; // 第1-24集
return score;
}
// 全集包排序:不限网盘、择优——优先「全集」字眼,其次画质,网盘仅作次要 tiebreak
function rankPanPacks(arr) {
return arr.slice().sort((a, b) => {
const ps = panPackScore(b) - panPackScore(a);
if (ps) return ps;
const q = panQualityInfo(b).score - panQualityInfo(a).score;
if (q) return q;
return panDiskOrder(a) - panDiskOrder(b);
});
}
// 当前搜索关键字拆词(用于「标题命中关键字越多越贴合本剧」的判断)
function panKeywordTokens() {
const kw = String(state.pan.keyword || "");
return kw
.split(/[\s·:\-—_,.。、/|()【】\[\]]+/)
.map((t) => t.trim().toLowerCase())
.filter((t) => t.length >= 1);
}
// 标题命中当前关键字 token 的数量(命中越多 → 越是本剧、资源越完整)
function panKeywordHitScore(item) {
const t = String(item && item.title || "").toLowerCase();
if (!t) return 0;
const tokens = panKeywordTokens();
if (!tokens.length) return 0;
let hit = 0;
tokens.forEach((tok) => { if (t.indexOf(tok) >= 0) hit++; });
return hit;
}
// 夸克全集包排序:画质优先(这样画质好),其次命中关键字越多越好,再看全集字眼/画质标签数
function rankQuarkPacks(arr) {
return arr.slice().sort((a, b) => {
const q = panQualityInfo(b).score - panQualityInfo(a).score; // 1) 画质分高优先
if (q) return q;
const kh = panKeywordHitScore(b) - panKeywordHitScore(a); // 2) 关键字命中多优先
if (kh) return kh;
const ps = panPackScore(b) - panPackScore(a); // 3) 越像完整全集优先
if (ps) return ps;
return panQualityInfo(b).count - panQualityInfo(a).count; // 4) 画质标签更多优先
});
}
// 标题是否为「本季全集/合集包」(可整包播放,进播放器后由用户自己选集)
function panItemIsSeasonPack(item, season) {
const t = String(item && item.title || "");
if (!panItemIsRangePack(t)) return false;
const s = Number(season) || 1;
if (new RegExp(`S0*${s}(?![0-9])`, "i").test(t)) return true; // 明确 S01
if (new RegExp(`第\\s*0*${s}\\s*季`).test(t)) return true; // 第1季
const mSe = /S0*(\d+)/i.exec(t);
if (mSe && Number(mSe[1]) !== s) return false; // 标题指向其它季 → 排除
const mCn = /第\s*0*(\d+)\s*季/.exec(t);
if (mCn && Number(mCn[1]) !== s) return false;
return true; // 无季号的全集 → 视为本季
}
// 收集当前集候选:精确单集(115 优先)+ 本季全集包(不限网盘、按「全集」字眼择优)
function episodePanCandidates(season, epNo) {
const singles = [], packs = [];
(state.pan.results || []).forEach((it) => {
if (panItemMatchesEpisode(it, season, epNo)) singles.push(it);
else if (panItemIsSeasonPack(it, season)) packs.push(it);
});
return { singles: rankPanCandidates(singles), packs: rankPanPacks(packs) };
}
function panCheckAvailable() {
const pan = sdk().pan || {};
return typeof pan.check === "function";
}
// 对少量候选做一次有效性校验,返回 key->state 并写回 state.pan.health
async function probePanHealth(items) {
const list = (items || []).filter((it) => it && isPanCheckSupported(it));
if (!list.length || !panCheckAvailable()) return {};
try {
const resp = await sdk().pan.check(list.map((it) => ({ type: it.diskType, url: it.url, password: it.password })));
const results = (resp && Array.isArray(resp.results)) ? resp.results : [];
const out = {};
list.forEach((it, i) => {
const r = results[i] || {};
const st = r.state || "uncertain";
out[it.key] = st;
state.pan.health[it.key] = { state: st, summary: r.summary || "", checkedAt: r.checked_at || Date.now(), expiresAt: r.expires_at || Date.now() + 300000 };
});
return out;
} catch (e) { return {}; }
}
// 从已排序候选里挑第一条「能播」的。
// requireOk=true:只取绿灯(ok / 无法检测的盘),灰灯(uncertain/idle)跳过 → 这样灰灯 115 不会卡住,会跳别的盘。
async function firstUsableCandidate(list, requireOk) {
let pool = (list || []).filter((it) => getPanHealth(it).state !== "bad");
if (!pool.length) return null;
if (panCheckAvailable()) {
const unprobed = pool.filter((it) => !getPanHealth(it).checkedAt && isPanCheckSupported(it)).slice(0, 12);
if (unprobed.length) await probePanHealth(unprobed);
pool = pool.filter((it) => getPanHealth(it).state !== "bad");
}
if (requireOk) {
const ok = pool.filter((it) => {
const st = getPanHealth(it).state;
return st === "ok" || st === "unsupported" || !isPanCheckSupported(it); // 绿灯 / 无法检测的盘
});
return ok[0] || null;
}
return pool[0] || null; // 兜底:非失效的第一条(含灰灯)
}
// ── 夸克/百度 Top5 候选池("搜索播放"首选 + 1 分钟内重复点击的随机来源)──
// 1 分钟内重复点击同一目标(同部影片,或同一集)则从前 5 里随机挑一个,
// 超过 1 分钟(或首次点击)则固定取排名第 1 的(即最优画质/最贴合关键字那条)。
const _repeatClickAt = {};
const REPEAT_CLICK_WINDOW_MS = 60000;
// 返回本次点击是否落在「上次点击同 key」的 1 分钟窗口内,并刷新该 key 的点击时间
function isRepeatClickWithinWindow(key) {
const now = Date.now();
const last = _repeatClickAt[key];
_repeatClickAt[key] = now;
return !!(last && (now - last) <= REPEAT_CLICK_WINDOW_MS);
}
function isQuarkOrBaidu(it) {
const t = normalizePanDiskType(it && it.diskType);
return t === "quark" || t === "baidu";
}
// 从盘搜结果里挑「夸克或百度」候选,按画质+关键字排序后取前 5:
// randomize=false → 取第 1(最优);randomize=true → 在前 5 里随机取一个。
async function pickQuarkBaiduTop5(item, randomize) {
let pool = (state.pan.results || []).filter(isQuarkOrBaidu);
if (!pool.length) return null;
// 剧集:优先整季/全集包(和"点单集"区分开,互不冲突);没有包再用任意单条
if (item && item.mediaType === "tv") {
const packs = pool.filter((it) => panItemIsRangePack(String(it.title || "")));
if (packs.length) pool = packs;
}
pool = rankQuarkPacks(pool); // 画质优先 + 关键字命中(夸克/百度通用)
const top5 = pool.slice(0, 5);
if (!top5.length) return null;
if (panCheckAvailable()) {
const unprobed = top5.filter((it) => !getPanHealth(it).checkedAt && isPanCheckSupported(it));
if (unprobed.length) await probePanHealth(unprobed);
}
const usable = top5.filter((it) => getPanHealth(it).state !== "bad");
const finalPool = usable.length ? usable : top5; // 都探测为失效时仍兜底用原前 5
return randomize ? finalPool[Math.floor(Math.random() * finalPool.length)] : finalPool[0];
}
// 针对「某一集」的夸克/百度候选(用于点分集 1 分钟内重复点击 → 换源):
// 优先精确命中本集的单集资源,没有单集再退到本季全集包;按画质排序后取前 5。
// randomize=false → 取第 1randomize=true → 前 5 里随机取一个。
async function pickQuarkBaiduEpisodeTop5(season, epNo, randomize) {
const { singles, packs } = episodePanCandidates(season, epNo);
const byQuality = (arr) => arr.filter(isQuarkOrBaidu).slice().sort((a, b) => panQualityInfo(b).score - panQualityInfo(a).score);
const qbSingles = byQuality(singles);
const qbPacks = rankQuarkPacks(packs.filter(isQuarkOrBaidu));
const pool = qbSingles.length ? qbSingles : qbPacks; // 单集优先,没有单集再用全集包
const top5 = pool.slice(0, 5);
if (!top5.length) return null;
if (panCheckAvailable()) {
const unprobed = top5.filter((it) => !getPanHealth(it).checkedAt && isPanCheckSupported(it));
if (unprobed.length) await probePanHealth(unprobed);
}
const usable = top5.filter((it) => getPanHealth(it).state !== "bad");
const finalPool = usable.length ? usable : top5;
const pick = randomize ? finalPool[Math.floor(Math.random() * finalPool.length)] : finalPool[0];
return pick ? { item: pick, kind: qbSingles.length ? "single" : "pack" } : null;
}
// 定时解析(按网盘优先级逐级回退,每级 ~3-4 秒及时切换):
// 1) 115 单集(精确命中本集)
// 2) 115 全集(115 没有单集时,找 115 本季全集/合集包)
// 3) 夸克全集(115 单集、全集都搜不出来时,播放夸克全集包)
// 4) 兜底:任意网盘的非失效单集/全集(含灰灯)
async function resolveEpisodePlayableTimed(season, epNo, item, seq) {
const SINGLE_115_WINDOW_MS = 3500; // 3-4s 内优先等 115 单集
const PACK_115_WINDOW_MS = 7000; // 之后等 115 全集
const QUARK_WINDOW_MS = 10500; // 再之后切夸克全集
const HARD_DEADLINE_MS = 13000; // 最终兜底
const start = Date.now();
const active = () => _episodeLoadSeq === seq && state.selected === item;
const is115 = (it) => normalizePanDiskType(it && it.diskType) === "115";
const isQuark = (it) => normalizePanDiskType(it && it.diskType) === "quark";
while (active()) {
const { singles, packs } = episodePanCandidates(season, epNo);
const elapsed = Date.now() - start;
const singles115 = singles.filter(is115);
const packs115 = packs.filter(is115);
const packsQuark = rankQuarkPacks(packs.filter(isQuark)); // 夸克:画质+关键字命中择优
const has115Any = singles115.length || packs115.length;
// 1) 115 单集(始终最优先)
const ok115Single = await firstUsableCandidate(singles115.slice(0, 6), true);
if (!active()) return null;
if (ok115Single) return { item: ok115Single, kind: "single" };
// 2) 过了单集窗口:115 全集
if (elapsed >= SINGLE_115_WINDOW_MS) {
const ok115Pack = await firstUsableCandidate(packs115.slice(0, 8), true);
if (!active()) return null;
if (ok115Pack) return { item: ok115Pack, kind: "pack" };
}
// 3) 115 都搜不出来(或等够时间仍无 115):夸克全集
if (elapsed >= PACK_115_WINDOW_MS || (elapsed >= SINGLE_115_WINDOW_MS && !has115Any)) {
const okQuarkPack = await firstUsableCandidate(packsQuark.slice(0, 8), true);
if (!active()) return null;
if (okQuarkPack) return { item: okQuarkPack, kind: "pack" };
}
// 4) 再退一步:任意网盘绿灯全集
if (elapsed >= QUARK_WINDOW_MS) {
const okAnyPack = await firstUsableCandidate(packs.slice(0, 12), true);
if (!active()) return null;
if (okAnyPack) return { item: okAnyPack, kind: "pack" };
}
// 5) 硬超时兜底:接受非失效(含灰灯)的单集/全集
if (elapsed >= HARD_DEADLINE_MS) {
const anySingle = await firstUsableCandidate(singles.slice(0, 6), false);
if (anySingle) return { item: anySingle, kind: "single" };
const anyPack = await firstUsableCandidate(packs.slice(0, 12), false);
return (active() && anyPack) ? { item: anyPack, kind: "pack" } : null;
}
await new Promise((r) => setTimeout(r, 300));
}
return null;
}
function openPanBlockIfNeeded() {
const block = $("panSearchBlock");
if (!block || block.classList.contains("active")) return;
block.classList.add("active");
block.style.display = "";
if (location.hash !== "#pan") { history.pushState({ sheet: "pan" }, "", "#pan"); _panHistoryPushed = true; }
renderPanResults();
centerPanSearchBlock();
}
// 回退:匹配不到单集 → 打开盘搜、切到 115(若有)、聚焦首条作为高亮,交给用户手动选
function fallbackHighlightEpisode(item, season, epNo) {
openPanBlockIfNeeded();
const has115 = (state.pan.results || []).some((it) => normalizePanDiskType(it.diskType) === "115");
if (has115 && state.pan.activeType !== "115") selectPanType("115");
else renderPanResults();
toast(`未找到第${epNo}集单集资源,已为你打开盘搜`);
requestAnimationFrame(() => {
const list = $("panResultList");
const target = list && list.querySelector(".pan-result-item");
if (target) {
try { target.scrollIntoView({ block: "center", behavior: "smooth" }); } catch (e) {}
focusRemoteTarget(target);
}
});
}
let _episodeLoadSeq = 0;
async function playEpisodeViaPan(ep) {
const item = state.selected;
if (!item) return;
const season = Number(ep && ep.season_number) || activeSeasonNumber() || 1;
const epNo = Number(ep && ep.episode_number) || 0;
if (!epNo) { toast("缺少集数信息"); return; }
const seq = ++_episodeLoadSeq;
const alive = () => _episodeLoadSeq === seq && state.selected === item;
toast(`正在加载第${epNo}集…`);
// 后台静默盘搜(不弹盘搜页);已是本剧结果则复用
const sameSeries = state.pan.keyword && panKeyword(item) && state.pan.keyword === panKeyword(item);
if (!(sameSeries && (state.pan.results || []).length)) {
searchPanResources(item, { silent: true }).catch(() => {});
}
// 1 分钟内重复点击「同一集」→ 自动换源:改用夸克/百度排名前 5 随机挑一个;
// 首次点击 / 超过 1 分钟 → 维持原逻辑(115 单集优先,逐级回退)
const clickKey = `ep:${item.tmdbId || item.title}:${season}:${epNo}`;
const isRepeat = isRepeatClickWithinWindow(clickKey);
if (isRepeat) {
const keyword = panKeyword(item);
const haveResults = () => state.pan.keyword === keyword && (state.pan.results || []).length;
if (!haveResults()) {
const deadline = Date.now() + 2500;
while (Date.now() < deadline && alive() && !haveResults()) {
if (state.pan.keyword === keyword && !state.pan.loading) break;
await new Promise((r) => setTimeout(r, 120));
}
}
if (!alive()) return;
const switched = await pickQuarkBaiduEpisodeTop5(season, epNo, true);
if (!alive()) return;
if (switched) {
if (switched.kind === "single") {
toast(`已换源播放 第${epNo}集 · ${panDiskName(switched.item.diskType)}`);
} else {
toast(`已换源加载本季全集 · ${panDiskName(switched.item.diskType)},请在播放器中选第${epNo}集`);
}
playPanResult(switched.item);
return;
}
// 没有夸克/百度可换源资源时,回退到下方原 115 优先逐级解析逻辑
}
// 5 秒内优先拿 115 单集;超时换任意网盘能播的全集;拿到即丢给原生播放器(由它转圈加载)
const resolved = await resolveEpisodePlayableTimed(season, epNo, item, seq);
if (!alive()) return;
if (resolved) {
if (resolved.kind === "single") {
toast(`播放 第${epNo}集 · ${panDiskName(resolved.item.diskType)}`);
} else {
toast(`第${epNo}集已加载本季全集 · ${panDiskName(resolved.item.diskType)},请在播放器中选第${epNo}集`);
}
playPanResult(resolved.item); // 原生播放器接管,自行显示加载/缓冲
return;
}
// 实在没有可播资源 → 打开盘搜页让用户手选
fallbackHighlightEpisode(item, season, epNo);
}
async function loadPersonWorks(person) {
try {
$("personWorkTitle").textContent = "简介";
$("personWorkBlock").style.display = "";
renderPersonInfo(person, null);
// 关联作品标题放在简介下方
const worksTitle = document.createElement("h3");
worksTitle.id = "personWorksLabel";
worksTitle.style.marginTop = "16px";
worksTitle.textContent = "关联作品";
const rail = $("personWorkRail");
if (rail && !$("personWorksLabel")) rail.before(worksTitle);
$("personWorkRail").replaceChildren(emptyNode("加载中..."));
const body = await requestJson(tmdbPersonDetailUrl(person.id), 18);
renderPersonInfo(person, body);
const credits = body.combined_credits || body;
const works = (credits.cast || []).filter((item) => (item.media_type === "movie" || item.media_type === "tv") && item.poster_path).slice(0, 18).map((value, index) => normalizeTmdb(value, { id: "person", title: "关联作品", mediaType: value.media_type }, index));
fillRail($("personWorkRail"), works);
updatePostPanFocusState();
} catch (e) {
renderPersonInfo(person, null);
$("personWorkRail").replaceChildren(emptyNode("关联作品加载失败"));
updatePostPanFocusState();
}
}
async function loadRecommendations(item) {
const block = $("recommendBlock");
const rail = $("recommendWorkRail");
if (!block || !rail || !item || !item.tmdbId || !item.mediaType) return;
try {
const body = await requestJson(tmdbRecommendationsUrl(item), 18);
if (state.selected !== item) return;
const works = (body.results || [])
.map((value, index) => {
const mediaType = value.media_type || item.mediaType;
if ((mediaType !== "movie" && mediaType !== "tv") || !value.poster_path) return null;
return normalizeTmdb(value, { id: "recommend", title: "相关推荐", mediaType }, index);
})
.filter(Boolean)
.slice(0, 18);
if (!works.length) {
block.style.display = "none";
rail.replaceChildren();
return;
}
block.style.display = "";
fillRail(rail, works);
updatePostPanFocusState();
} catch (e) {
if (state.selected === item) {
block.style.display = "none";
rail.replaceChildren();
}
}
}
function renderPersonInfo(person, detail) {
const root = $("personInfo");
if (!root) return;
const biography = detail && detail.biography || "暂无人物简介";
root.innerHTML = `
<div class="collapsible-overview" id="personOverviewWrap">
<button class="overview-toggle" id="personOverviewToggle" type="button" aria-expanded="false">
<svg class="overview-toggle-icon" viewBox="0 0 24 24"><path d="M6 9l6 6 6-6"/></svg>
<span>人物简介</span>
</button>
<div class="overview-body">
<div class="person-info" aria-live="polite">
<div class="person-info-body">
<p>${escapeHtml(biography)}</p>
</div>
</div>
</div>
</div>
`;
const toggle = root.querySelector("#personOverviewToggle");
const wrap = root.querySelector("#personOverviewWrap");
if (toggle && wrap) {
toggle.addEventListener("click", () => {
const open = wrap.classList.toggle("open");
toggle.setAttribute("aria-expanded", open ? "true" : "false");
});
}
}
function resetPanSearch() {
resetPanSearchState(true);
}
function resetPanSearchState(hideBlock) {
if (state.pan.observer) {
state.pan.observer.disconnect();
state.pan.observer = null;
}
clearTimeout(state.pan.flushTimer);
state.pan.flushTimer = 0;
state.pan.pollTimers.forEach((timer) => clearTimeout(timer));
state.pan.pollTimers = [];
state.pan.pollRound = 0;
state.pan.loading = false;
state.pan.results = [];
state.pan.activeType = "";
state.pan.health = {};
state.pan.pending = {};
state.pan.queued.clear();
state.pan.inFlight.clear();
state.pan.keyword = "";
state.pan.viewToken = "";
state.pan.renderKeys = "";
state.pan.tabKeys = "";
state.pan.focusKey = "";
state.pan.focusMode = "";
state.pan.lastTypeSelect = null;
state.pan.keepBlockPositionUntil = 0;
state.pan.playbackReturn = null;
if (hideBlock && $("panSearchBlock")) {
$("panSearchBlock").classList.remove("active");
$("panSearchBlock").style.display = "none";
}
if (hideBlock) updatePostPanFocusState();
if (hideBlock) {
if ($("panSearchHint")) $("panSearchHint").textContent = "";
if ($("panTabs")) $("panTabs").replaceChildren();
if ($("panResultList")) $("panResultList").replaceChildren();
}
}
function panKeyword(item) {
return String(item && item.title || "").trim();
}
// 进入剧集(tv)详情页即后台静默预搜盘搜资源,缩短点击分集后的等待时间。
// 仅预加载、不弹盘搜页、不抢焦点;点击分集时若已有同剧结果会直接复用
// (见 playEpisodeViaPan 的 sameSeries 判断),播放判断逻辑保持不变。
async function preloadPanForSeries(item) {
if (!item || (item.mediaType !== "tv" && item.mediaType !== "movie")) return; // 剧集 + 电影
if (state.selected !== item) return; // 详情页已切走则放弃
const keyword = panKeyword(item);
if (!keyword) return;
const warm = () => (item.mediaType === "movie" ? warmMoviePanHealth(item) : warmEpisodePanHealth(item));
// 已搜过同一标题且有结果 → 不重复搜,但仍预热一次有效性
if (state.pan.keyword === keyword && (state.pan.results || []).length) {
warm();
return;
}
if (state.pan.loading) return; // 已有搜索在进行
try {
await searchPanResources(item, { silent: true });
} catch (e) {}
warm(); // 搜完立刻预热,点分集/点搜索播放即可秒开
}
// 预热健康检查:进详情页后先把最可能用到的候选(115 单集 + 115/夸克/百度全集包)
// 的有效性查好并缓存(5 分钟内有效),点分集时 firstUsableCandidate 命中缓存即可秒开,
// 不必现场等待网盘探活。一次最多探 ~15 条,避免占用过多请求。
async function warmEpisodePanHealth(item) {
if (!item || state.selected !== item) return;
if (!panCheckAvailable()) return;
const results = state.pan.results || [];
if (!results.length) return;
const is115 = (it) => normalizePanDiskType(it && it.diskType) === "115";
const isQuark = (it) => normalizePanDiskType(it && it.diskType) === "quark";
const isBaidu = (it) => normalizePanDiskType(it && it.diskType) === "baidu";
const isPack = (it) => panItemIsRangePack(String(it && it.title || ""));
const singles115 = [], packs115 = [], packsQuark = [], packsBaidu = [];
results.forEach((it) => {
if (is115(it)) (isPack(it) ? packs115 : singles115).push(it);
else if (isQuark(it) && isPack(it)) packsQuark.push(it);
else if (isBaidu(it) && isPack(it)) packsBaidu.push(it);
});
const warm = []
.concat(rankPanCandidates(singles115).slice(0, 8)) // 115 单集最常用,多预热几条
.concat(rankPanPacks(packs115).slice(0, 3)) // 115 全集兜底
.concat(rankQuarkPacks(packsQuark).slice(0, 2)) // 夸克全集兜底
.concat(rankQuarkPacks(packsBaidu).slice(0, 2)) // 百度全集兜底
.filter((it) => it && isPanCheckSupported(it) && !getPanHealth(it).checkedAt)
.slice(0, 15);
if (warm.length && state.selected === item) {
probePanHealth(warm).catch(() => {});
}
}
// 电影预热:把画质最高的夸克/百度 + 115 资源先探活缓存,配合"搜索播放"短按秒开。
async function warmMoviePanHealth(item) {
if (!item || state.selected !== item || !panCheckAvailable()) return;
const results = state.pan.results || [];
if (!results.length) return;
const isQuark = (it) => normalizePanDiskType(it && it.diskType) === "quark";
const isBaidu = (it) => normalizePanDiskType(it && it.diskType) === "baidu";
const is115 = (it) => normalizePanDiskType(it && it.diskType) === "115";
const warm = []
.concat(rankQuarkPacks(results.filter(isQuark)).slice(0, 6)) // 夸克按画质+关键字择优
.concat(rankQuarkPacks(results.filter(isBaidu)).slice(0, 6)) // 百度按画质+关键字择优
.concat(rankPanCandidates(results.filter(is115)).slice(0, 4)) // 115 兜底
.filter((it) => it && isPanCheckSupported(it) && !getPanHealth(it).checkedAt)
.slice(0, 16);
if (warm.length && state.selected === item) {
probePanHealth(warm).catch(() => {});
}
}
// 直接播放盘搜准备好的夸克/百度资源("搜索播放"短按):
// 首次点击固定取排名第 1(最优画质/最贴合关键字);1 分钟内再次点击则在排名前 5 里随机换一个。
// 结果未就绪先静默搜一次;无夸克/百度资源则退回打开盘搜让用户手选。
async function playBestQuark(item) {
if (!item) return;
const keyword = panKeyword(item);
const ready = state.pan.keyword === keyword && (state.pan.results || []).length;
if (!ready) {
toast("正在搜索资源…");
// 预搜可能仍在进行:未在搜则触发一次,然后最多等 ~2.5s 让结果到位(避免过早回退到盘搜页)
if (!(state.pan.keyword === keyword && state.pan.loading)) {
searchPanResources(item, { silent: true }).catch(() => {});
}
const hasQuarkOrBaidu = () => (state.pan.results || []).some(isQuarkOrBaidu);
const deadline = Date.now() + 2500;
while (Date.now() < deadline) {
if (state.selected !== item) return;
if (hasQuarkOrBaidu()) break;
if (state.pan.keyword === keyword && !state.pan.loading) break;
await new Promise((r) => setTimeout(r, 120));
}
if (state.selected !== item) return;
}
if (!(state.pan.results || []).some(isQuarkOrBaidu)) {
toast("暂无夸克/百度资源,打开盘搜");
searchPanResources(item); // 退回原盘搜
return;
}
// 1 分钟内重复点击同一部 → 排名前 5 的夸克/百度资源里随机换一个;
// 首次点击 / 超过 1 分钟 → 固定取排名第 1
const clickKey = `qb:${item.tmdbId || item.title}`;
const isRepeat = isRepeatClickWithinWindow(clickKey);
const pick = await pickQuarkBaiduTop5(item, isRepeat);
if (state.selected !== item || !pick) return;
if (isRepeat) toast(`已换源 · ${panDiskName(pick.diskType)}`);
playPanResult(pick);
}
async function searchPanResources(item, opts) {
if (!item || state.pan.loading) return;
const silent = !!(opts && opts.silent);
const keyword = panKeyword(item);
if (!keyword) { if (!silent) toast("缺少搜索标题"); return; }
const hadResults = state.pan.results.length > 0 && $("panSearchBlock") && $("panSearchBlock").classList.contains("active");
resetPanSearchState(false);
state.pan.loading = true;
state.pan.keyword = keyword;
const token = `pan-${Date.now()}`;
state.pan.viewToken = token;
state.pan.results = [];
state.pan.activeType = "";
state.pan.health = {};
state.pan.pending = {};
state.pan.queued.clear();
state.pan.inFlight.clear();
state.pan.focusMode = "tabs";
state.pan.pollTimers.forEach((timer) => clearTimeout(timer));
state.pan.pollTimers = [];
state.pan.pollRound = 0;
// silent:后台静默搜索,不打开盘搜页、不压 #pan、不抢焦点(用于点分集直连播放)
if (!silent) {
if ($("panSearchBlock")) {
$("panSearchBlock").classList.add("active");
$("panSearchBlock").style.display = "";
}
// 推入盘搜 history 条目,确保返回键从盘搜→详情→主页的顺序
if (location.hash !== "#pan") {
history.pushState({ sheet: "pan" }, "", "#pan");
_panHistoryPushed = true;
}
updatePostPanFocusState();
centerPanSearchBlock();
if (!hadResults && $("panTabs")) $("panTabs").replaceChildren();
if (!hadResults && $("panResultList")) $("panResultList").replaceChildren(emptyNode("正在搜索盘搜资源..."));
$("panSearchHint").textContent = `${keyword} · 搜索中`;
}
setPanStatus(`搜索 ${keyword}`);
try {
const config = state.pan.config || defaultPanConfig();
const payload = panSearchPayload(keyword, config);
const headers = await ensurePanAuthHeaders();
const body = await postJson(panApi("/api/search"), payload, 28, headers);
if (state.pan.viewToken !== token || state.selected !== item) return;
const results = applyPanSearchBody(body, keyword);
setPanStatus(results.length ? `找到 ${results.length} 条` : "无资源");
if (!silent) {
renderPanResults();
focusFirstPanTab();
}
scheduleUiSnapshotSave();
schedulePanPolling(token, item);
} catch (e) {
if (state.pan.viewToken !== token || state.selected !== item) return;
if (String(e.message || "").includes("401") && state.pan.config) {
state.pan.config.token = "";
state.pan.config.tokenExpiresAt = 0;
await persistPanConfigQuietly();
}
state.pan.results = [];
if (!silent && $("panResultList")) $("panResultList").replaceChildren(emptyNode("盘搜失败:" + (e.message || "unknown")));
setPanStatus("搜索失败");
} finally {
if (state.pan.viewToken === token) state.pan.loading = false;
}
}
function focusFirstPanResult() {
const item = $("panResultList") && $("panResultList").querySelector(".pan-result-item");
if (item) requestAnimationFrame(() => focusRemoteTarget(item));
}
function applyPanSearchBody(body, keyword) {
const data = body && body.data ? body.data : body;
const incoming = normalizePanResults(data && data.merged_by_type || {}, keyword);
const map = new Map(state.pan.results.map((item) => [item.key, item]));
incoming.forEach((item) => {
const existing = map.get(item.key);
if (existing) Object.assign(existing, item);
else map.set(item.key, item);
});
state.pan.results = Array.from(map.values());
ensureActivePanType();
scheduleUiSnapshotSave();
return state.pan.results;
}
function schedulePanPolling(token, item) {
const intervals = window.WEBHOME_CONFIG.pan.pollIntervals || [];
intervals.forEach((delay, index) => {
const timer = setTimeout(() => pollPanResources(token, item, index + 1), delay);
state.pan.pollTimers.push(timer);
});
}
async function pollPanResources(token, item, round) {
if (state.pan.viewToken !== token || state.selected !== item || !state.pan.keyword) return;
try {
state.pan.pollRound = round;
setPanStatus(`更新中 ${round}`);
const config = state.pan.config || defaultPanConfig();
const headers = await ensurePanAuthHeaders();
const body = await postJson(panApi("/api/search"), panSearchPayload(state.pan.keyword, config), 28, headers);
if (state.pan.viewToken !== token || state.selected !== item) return;
const before = state.pan.results.length;
const results = applyPanSearchBody(body, state.pan.keyword);
const added = results.length - before;
setPanStatus(added > 0 ? `新增 ${added} 条,共 ${results.length}` : `已更新 ${results.length} 条`);
renderPanResults();
scheduleUiSnapshotSave();
} catch (e) {
if (String(e.message || "").includes("401") && state.pan.config) {
state.pan.config.token = "";
state.pan.config.tokenExpiresAt = 0;
await persistPanConfigQuietly();
}
if (state.pan.viewToken === token) setPanStatus("更新失败");
}
}
function normalizePanResults(merged, keyword) {
const config = state.pan.config || defaultPanConfig();
const enabled = new Set((config.diskTypes || []).map(normalizePanDiskType));
const seen = new Set();
const items = [];
Object.entries(merged || {}).forEach(([type, links]) => {
const diskType = normalizePanDiskType(type);
if (!enabled.has(diskType) || !Array.isArray(links)) return;
links.forEach((link, index) => {
const url = String(link && (link.url || link.link || link.href) || "").trim();
if (!url) return;
const key = `${diskType}|${normalizePanUrl(url)}`;
if (seen.has(key)) return;
seen.add(key);
items.push({
key,
diskType,
url,
password: String(link.password || link.pwd || link.passcode || link.code || "").trim(),
title: String(link.note || link.name || link.title || link.work_title || keyword || "盘搜资源").trim(),
source: String(link.source || link.channel || link.plugin || "").trim(),
datetime: String(link.datetime || link.time || link.created_at || "").trim(),
normalizedUrl: String(link.normalized_url || link.normalizedUrl || "").trim(),
index: items.length + index / 1000
});
});
});
return items;
}
function normalizePanUrl(url) {
try {
const parsed = new URL(url);
parsed.hash = "";
parsed.hostname = parsed.hostname.toLowerCase();
return parsed.toString();
} catch (e) {
return String(url || "").trim();
}
}
function panHealthKey(item) {
return item ? `${item.diskType}|${normalizePanUrl(item.url)}` : "";
}
function getPanHealth(item) {
const key = panHealthKey(item);
if (state.pan.pending[key]) return { state: "pending", summary: "检测中" };
return state.pan.health[key] || { state: "idle", summary: "未检测" };
}
function panHealthPriority(item) {
const health = getPanHealth(item);
const value = health && health.state || "idle";
return PAN_HEALTH_PRIORITY[value] == null ? PAN_HEALTH_PRIORITY.idle : PAN_HEALTH_PRIORITY[value];
}
function panAvailableTypes() {
const counts = new Map();
state.pan.results.forEach((item) => {
const type = normalizePanDiskType(item.diskType);
if (type) counts.set(type, (counts.get(type) || 0) + 1);
});
const known = new Set(PAN_DISK_TYPES.map((item) => item.id));
const ordered = PAN_DISK_TYPES
.filter((type) => counts.has(type.id))
.map((type) => ({ id: type.id, name: type.name, count: counts.get(type.id) || 0 }));
const extras = Array.from(counts.entries())
.filter(([id]) => !known.has(id))
.sort(([a], [b]) => a.localeCompare(b))
.map(([id, count]) => ({ id, name: panDiskName(id), count }));
return ordered.concat(extras);
}
function ensureActivePanType() {
const types = panAvailableTypes();
if (!types.length) {
state.pan.activeType = "";
return "";
}
if (!state.pan.activeType || !types.some((type) => type.id === state.pan.activeType)) {
state.pan.activeType = types[0].id;
}
return state.pan.activeType;
}
function rankedPanResults(type) {
const activeType = type || "";
const source = state.pan.results.filter((item) => {
if (activeType && normalizePanDiskType(item.diskType) !== activeType) return false;
const health = getPanHealth(item);
if (health && health.state === "bad") return false; // 失效资源直接不显示
return true;
});
return source
.map((item, index) => ({ item, index, priority: panHealthPriority(item), quality: panQualityInfo(item) }))
.sort((a, b) =>
(b.quality.count - a.quality.count) ||
(b.quality.score - a.quality.score) ||
(a.priority - b.priority) ||
(Number(a.item.index || a.index) - Number(b.item.index || b.index)) ||
(a.index - b.index)
)
.map(({ item }) => item);
}
function renderPanTabs() {
const tabs = $("panTabs");
if (!tabs) return;
const types = panAvailableTypes();
const active = ensureActivePanType();
const activeEl = document.activeElement;
const restoreId = activeEl && tabs.contains(activeEl) && getPanTypeFromElement(activeEl) || "";
const tabKeys = types.map((type) => `${type.id}:${type.count}`).join("\n");
if (!types.length) {
state.pan.tabKeys = "";
tabs.replaceChildren();
return;
}
if (tabKeys === state.pan.tabKeys && tabs.children.length === types.length) {
Array.from(tabs.children).forEach((button) => {
const buttonType = getPanTypeFromElement(button);
const isActive = buttonType === active;
button.classList.toggle("active", isActive);
const type = types.find((item) => item.id === buttonType);
if (type) button.textContent = `${type.name} ${type.count}`;
});
if (state.pan.focusMode === "tabs") lockPanFocus(findByDataset(tabs, "panType", active) || tabs.querySelector(".chip.active") || tabs.querySelector(".chip"), "tabs");
return;
}
state.pan.tabKeys = tabKeys;
tabs.replaceChildren(...types.map((type) => {
const button = document.createElement("button");
button.className = "chip focusable" + (type.id === active ? " active" : "");
button.type = "button";
button.dataset.panType = type.id;
button.setAttribute("data-pan-type", type.id);
button.textContent = `${type.name} ${type.count}`;
button.addEventListener("focus", () => { state.pan.focusMode = "tabs"; });
return button;
}));
const restoreTarget = restoreId ? findByDataset(tabs, "panType", restoreId) : state.pan.focusMode === "tabs" ? tabs.querySelector(".chip.active") || tabs.querySelector(".chip") : null;
if (restoreTarget) lockPanFocus(restoreTarget, "tabs");
}
function getPanTypeFromElement(el) {
return el ? String(el.getAttribute("data-pan-type") || el.dataset && el.dataset.panType || "") : "";
}
function handlePanTabEvent(event) {
const tabs = $("panTabs");
if (!tabs) return false;
const target = event && event.target && closestPanTab(event.target);
if (!target || !tabs.contains(target)) return false;
const typeId = getPanTypeFromElement(target);
if (!typeId) return false;
event.preventDefault();
event.stopPropagation();
if (event.stopImmediatePropagation) event.stopImmediatePropagation();
selectPanType(typeId);
return true;
}
function closestPanTab(el) {
while (el && el !== document && el !== $("panTabs")) {
if (el.getAttribute && el.getAttribute("data-pan-type")) return el;
el = el.parentNode;
}
return null;
}
function selectPanType(typeId) {
if (!typeId) return;
const now = Date.now();
if (state.pan.lastTypeSelect && state.pan.lastTypeSelect.type === typeId && now - state.pan.lastTypeSelect.at < 520) return;
state.pan.lastTypeSelect = { type: typeId, at: now };
if (state.pan.activeType === typeId) {
state.pan.focusMode = "results";
focusFirstPanResult();
return;
}
const detailTop = $("detailSheet") ? $("detailSheet").scrollTop : 0;
state.pan.keepBlockPositionUntil = now + 600;
state.pan.activeType = typeId;
state.pan.renderKeys = "";
state.pan.focusMode = "tabs";
const list = $("panResultList");
if (list) list.scrollTop = 0;
updatePanTabActive(typeId);
renderPanResults();
if ($("detailSheet")) $("detailSheet").scrollTop = detailTop;
requestAnimationFrame(() => { if ($("detailSheet")) $("detailSheet").scrollTop = detailTop; });
lockPanFocus(findByDataset($("panTabs"), "panType", typeId) || $("panTabs").querySelector(".chip.active"), "tabs", { keepBlockPosition: true });
scheduleUiSnapshotSave();
}
function updatePanTabActive(typeId) {
const tabs = $("panTabs");
if (!tabs) return;
Array.from(tabs.querySelectorAll("[data-pan-type]")).forEach((button) => {
button.classList.toggle("active", getPanTypeFromElement(button) === typeId);
});
}
function focusFirstPanTab() {
const target = $("panTabs") && ($("panTabs").querySelector(".chip.active") || $("panTabs").querySelector(".chip"));
if (target) {
centerPanSearchBlock();
lockPanFocus(target, "tabs");
}
}
function isPanSearchActive() {
const block = $("panSearchBlock");
return !!(block && block.classList.contains("active") && block.style.display !== "none");
}
function updatePostPanFocusState() {
const panActive = isPanSearchActive();
["seasonBlock", "castBlock", "personWorkBlock", "recommendBlock"].forEach((id) => {
const block = $(id);
if (!block) return;
block.setAttribute("aria-hidden", panActive ? "true" : "false");
block.querySelectorAll(".focusable,button,input,textarea").forEach((el) => {
if (panActive) {
if (!el.dataset.panFocusLocked) {
el.dataset.panFocusLocked = "1";
el.dataset.panPrevTabindex = el.hasAttribute("tabindex") ? el.getAttribute("tabindex") || "" : "__none__";
}
el.setAttribute("tabindex", "-1");
} else if (el.dataset.panFocusLocked) {
const previous = el.dataset.panPrevTabindex;
if (previous && previous !== "__none__") el.setAttribute("tabindex", previous);
else el.removeAttribute("tabindex");
delete el.dataset.panFocusLocked;
delete el.dataset.panPrevTabindex;
}
});
});
}
function renderPanResults() {
const list = $("panResultList");
if (!list) return;
renderPanTabs();
const active = ensureActivePanType();
const items = rankedPanResults(active);
const total = state.pan.results.length;
const activeName = active ? panDiskName(active) : "";
$("panSearchHint").textContent = state.pan.keyword ? `${state.pan.keyword} · 共 ${total}${activeName ? " · " + activeName + " " + items.length : ""}` : "";
if (!total) {
state.pan.renderKeys = "";
list.replaceChildren(emptyNode("资源搜索中…"));
return;
}
if (!items.length) {
state.pan.renderKeys = "";
list.replaceChildren(emptyNode("当前网盘暂无资源"));
return;
}
const keys = `${active}\n` + items.map((item) => `${item.key}:${getPanHealth(item).state}:${item.normalizedUrl || ""}`).join("\n");
const activeEl = document.activeElement;
const shouldRestore = !!(activeEl && list.contains(activeEl)) || state.pan.focusMode === "results";
const restoreKey = shouldRestore && activeEl.dataset.panKey || state.pan.focusKey || "";
list.setAttribute("tabindex", "-1");
list.setAttribute("role", "listbox");
if (patchPanResultList(list, items, active, shouldRestore, restoreKey)) return;
if (keys === state.pan.renderKeys && list.children.length === items.length) {
restorePanResultFocus(list, restoreKey, shouldRestore);
return;
}
state.pan.renderKeys = keys;
list.replaceChildren(...items.map(panResultNode));
observePanVisibleItems();
restorePanResultFocus(list, restoreKey, shouldRestore);
}
function patchPanResultList(list, items, active, shouldRestore, restoreKey) {
const existing = Array.from(list.querySelectorAll(".pan-result-item"));
if (!existing.length || existing.some((node) => !items.some((item) => item.key === node.dataset.panKey))) return false;
const nodes = new Map(existing.map((node) => [node.dataset.panKey, node]));
const ordered = items.map((item) => {
const node = nodes.get(item.key);
if (node) {
updatePanResultNode(node, item);
return node;
}
return panResultNode(item);
});
let reference = list.firstChild;
ordered.forEach((node) => {
if (node === reference) {
reference = reference.nextSibling;
return;
}
list.insertBefore(node, reference);
});
state.pan.renderKeys = `${active}\n` + items.map((item) => `${item.key}:${getPanHealth(item).state}:${item.normalizedUrl || ""}`).join("\n");
observePanVisibleItems();
restorePanResultFocus(list, restoreKey, shouldRestore);
return true;
}
function updatePanResultNode(button, item) {
const health = getPanHealth(item);
button.className = "pan-result-item focusable" + (health.state === "bad" ? " is-bad" : "");
button.dataset.panKey = item.key;
button.innerHTML = panResultHtml(item, health);
}
function restorePanResultFocus(list, key, shouldRestore) {
if (!shouldRestore || !list) return;
const target = key ? findByDataset(list, "panKey", key) : null;
const fallback = list.querySelector(".pan-result-item");
lockPanFocus(target || fallback, "results", { keepBlockPosition: true, once: true });
}
function lockPanFocus(target, mode, options) {
if (!target) return;
const opts = options || {};
const apply = () => {
if (!$("panSearchBlock") || $("panSearchBlock").style.display === "none") return;
if (mode && state.pan.focusMode && state.pan.focusMode !== mode) return;
const active = document.activeElement;
const inPan = active && ($("panTabs").contains(active) || $("panResultList").contains(active));
if (inPan && mode && state.pan.focusMode !== mode) return;
if (inPan && active === target) return;
if (!isVisibleFocusable(target)) return;
state.pan.focusMode = mode || state.pan.focusMode;
focusPanTarget(target, opts);
};
requestAnimationFrame(apply);
if (opts.once) return;
setTimeout(apply, 80);
setTimeout(apply, 220);
}
function focusPanTarget(target, options) {
if (!target) return;
const opts = options || {};
const list = $("panResultList");
const tabs = $("panTabs");
const keepBlockPosition = opts.keepBlockPosition || Date.now() < Number(state.pan.keepBlockPositionUntil || 0);
if (tabs && tabs.contains(target) && !keepBlockPosition) centerPanSearchBlock();
try {
target.focus({ preventScroll: true });
} catch (e) {
target.focus();
}
if (list && list.contains(target)) {
keepPanResultItemVisible(target, list);
if (!opts.keepBlockPosition) ensurePanListViewport();
return;
}
if (tabs && tabs.contains(target)) return;
const rect = target.getBoundingClientRect();
const height = window.innerHeight || document.documentElement.clientHeight || 0;
if (height && (rect.top < 48 || rect.bottom > height - 48)) {
try { target.scrollIntoView({ block: "nearest", inline: "nearest" }); } catch (e) { target.scrollIntoView(false); }
}
}
function centerPanSearchBlock() {
const block = $("panSearchBlock");
const detail = $("detailSheet");
if (!block || !detail || !detail.classList.contains("active")) return;
try {
block.scrollIntoView({ block: "center", inline: "nearest" });
} catch (e) {
const blockRect = block.getBoundingClientRect();
const detailRect = detail.getBoundingClientRect();
const height = detail.clientHeight || window.innerHeight || 0;
if (!height) return;
detail.scrollTop += blockRect.top - detailRect.top - Math.round((height - Math.min(blockRect.height, height * .72)) / 2);
}
}
function ensurePanListViewport() {
const list = $("panResultList");
const detail = $("detailSheet");
if (!list || !detail || !detail.classList.contains("active")) return;
const listRect = list.getBoundingClientRect();
const detailRect = detail.getBoundingClientRect();
const height = detail.clientHeight || window.innerHeight || 0;
if (!height) return;
const topLimit = detailRect.top + Math.max(34, height * .1);
const bottomLimit = detailRect.top + height - Math.max(50, height * .1);
if (listRect.top < topLimit) detail.scrollTop += listRect.top - topLimit;
else if (listRect.bottom > bottomLimit && listRect.top > topLimit) detail.scrollTop += Math.min(listRect.top - topLimit, listRect.bottom - bottomLimit);
}
function keepPanResultItemVisible(target, list) {
const itemRect = target.getBoundingClientRect();
const listRect = list.getBoundingClientRect();
const topDelta = itemRect.top - listRect.top;
const bottomDelta = itemRect.bottom - listRect.bottom;
if (topDelta < 0) list.scrollTop += topDelta;
else if (bottomDelta > 0) list.scrollTop += bottomDelta;
}
function findByDataset(root, name, value) {
if (!root) return null;
return Array.from(root.querySelectorAll("[data-" + name.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase()) + "]"))
.find((el) => String(el.dataset[name] || "") === String(value || "")) || null;
}
function panResultNode(item) {
const health = getPanHealth(item);
const button = document.createElement("button");
button.className = "pan-result-item focusable" + (health.state === "bad" ? " is-bad" : "");
button.type = "button";
button.dataset.panKey = item.key;
button.innerHTML = panResultHtml(item, health);
button.addEventListener("focus", () => { state.pan.focusKey = item.key; state.pan.focusMode = "results"; });
button.addEventListener("click", () => playPanResult(item));
return button;
}
function panResultHtml(item, health) {
const quality = panQualityInfo(item);
// 合并规则:
// 1. DolbyVision + 杜比全景声同时命中 → 合并为「双杜比」
// 2. 4K + 1080P 同时命中 → 只保留 4K,去掉 1080P
const _hasDV = quality.tags.some(t => t.cls === "dv");
const _hasAtmos = quality.tags.some(t => t.label === "杜比");
const _has4K = quality.tags.some(t => t.label === "4K");
let _tags = quality.tags;
if (_hasDV && _hasAtmos)
_tags = [{ label: "双杜比", cls: "dv" }, ..._tags.filter(t => t.cls !== "dv" && t.label !== "杜比")];
if (_has4K)
_tags = _tags.filter(t => t.label !== "1080P");
const qualityTags = _tags.map((t) => `<span class="pan-tag quality${t.cls ? " " + t.cls : ""}">${escapeHtml(t.label)}</span>`).join("");
return `
<div class="pan-result-title">
<span>${escapeHtml(item.title || state.pan.keyword || "盘搜资源")}</span>
${panHealthIndicatorHtml(item, health)}
</div>
${qualityTags ? `<div class="pan-result-meta">${qualityTags}</div>` : ""}
`;
}
async function rememberPanPlaybackReturn(item) {
const detail = $("detailSheet");
const list = $("panResultList");
const key = item && item.key || state.pan.focusKey || "";
state.pan.playbackReturn = {
key,
activeType: state.pan.activeType || "",
detailScrollTop: detail ? Math.round(detail.scrollTop || 0) : 0,
listScrollTop: list ? Math.round(list.scrollTop || 0) : 0,
at: Date.now()
};
state.pan.focusKey = key;
state.pan.focusMode = "results";
await saveUiSnapshotNow();
}
function restorePanPlaybackReturn(options) {
const opts = options || {};
const saved = state.pan.playbackReturn;
if (!saved) return false;
if (Date.now() - Number(saved.at || 0) > PAN_PLAYBACK_RETURN_TTL_MS) {
state.pan.playbackReturn = null;
return false;
}
const detail = $("detailSheet");
const block = $("panSearchBlock");
if (!detail || !detail.classList.contains("active") || !block || !block.classList.contains("active")) return false;
if (saved.activeType) state.pan.activeType = saved.activeType;
state.pan.focusKey = saved.key || state.pan.focusKey || "";
state.pan.focusMode = "results";
if (!opts.skipRender) renderPanResults();
const apply = () => {
const currentDetail = $("detailSheet");
const currentList = $("panResultList");
if (currentDetail) currentDetail.scrollTop = Number(saved.detailScrollTop || 0);
if (currentList) currentList.scrollTop = Number(saved.listScrollTop || 0);
const target = saved.key ? findByDataset(currentList, "panKey", saved.key) : null;
const fallback = currentList && currentList.querySelector(".pan-result-item");
if (target || fallback) lockPanFocus(target || fallback, "results");
};
requestAnimationFrame(apply);
setTimeout(apply, 80);
setTimeout(apply, 240);
state.pan.playbackReturn = null;
scheduleUiSnapshotSave();
return true;
}
function panHealthLabel(state) {
return ({ ok: "链接有效", bad: "链接失效", locked: "需要提取码", unsupported: "暂不支持检测", uncertain: "检测结果不确定", pending: "检测中", idle: "未检测" })[state] || "未检测";
}
function panHealthIndicatorHtml(item, health) {
if (!canCheckPanLinks() || !isPanCheckSupported(item)) return "";
const stateValue = health && health.state || "idle";
const title = health && health.summary ? `${panHealthLabel(stateValue)}${health.summary}` : panHealthLabel(stateValue);
return `<span class="pan-health ${escapeAttr(stateValue)}" title="${escapeAttr(title)}" aria-label="${escapeAttr(title)}"></span>`;
}
function observePanVisibleItems() {
const list = $("panResultList");
if (state.pan.observer) state.pan.observer.disconnect();
if (!canCheckPanLinks()) return;
if (!list || !rankedPanResults(state.pan.activeType).some(isPanCheckSupported) || !("IntersectionObserver" in window)) {
queueAllVisiblePanFallback();
return;
}
state.pan.observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting || entry.intersectionRatio < 0.25) return;
const key = entry.target.dataset.panKey;
const item = state.pan.results.find((value) => value.key === key);
queuePanCheck(item);
});
}, { root: list, threshold: [0.25, 0.6] });
list.querySelectorAll(".pan-result-item").forEach((node) => state.pan.observer.observe(node));
scheduleVisiblePanCheck();
}
function scheduleVisiblePanCheck() {
clearTimeout(scheduleVisiblePanCheck.timer);
scheduleVisiblePanCheck.timer = setTimeout(queueVisiblePanChecks, 90);
}
function queueVisiblePanChecks() {
if (!canCheckPanLinks()) return;
const list = $("panResultList");
if (!list) return;
const listRect = list.getBoundingClientRect();
const bottomLimit = listRect.bottom + Math.max(80, listRect.height * .35);
Array.from(list.querySelectorAll(".pan-result-item")).some((node) => {
const rect = node.getBoundingClientRect();
if (rect.bottom < listRect.top || rect.top > bottomLimit) return false;
const item = state.pan.results.find((value) => value.key === node.dataset.panKey);
queuePanCheck(item);
return state.pan.queued.size + state.pan.inFlight.size >= 10;
});
}
function queueAllVisiblePanFallback() {
if (!canCheckPanLinks()) return;
queueVisiblePanChecks();
}
function queuePanCheck(item) {
if (!item || !canCheckPanLinks() || !isPanCheckSupported(item)) return;
const key = panHealthKey(item);
if (!key || state.pan.health[key] || state.pan.pending[key] || state.pan.queued.has(key) || state.pan.inFlight.has(key)) return;
state.pan.queued.set(key, item);
schedulePanCheckFlush();
}
function schedulePanCheckFlush() {
if (state.pan.flushTimer) return;
state.pan.flushTimer = setTimeout(() => {
state.pan.flushTimer = 0;
flushPanCheckQueue();
}, 180);
}
async function flushPanCheckQueue() {
if (!canCheckPanLinks()) {
state.pan.queued.clear();
return;
}
if (!state.pan.queued.size) return;
const entries = Array.from(state.pan.queued.entries()).slice(0, 10);
entries.forEach(([key]) => state.pan.queued.delete(key));
entries.forEach(([key]) => {
state.pan.pending[key] = true;
state.pan.inFlight.add(key);
});
renderPanResults();
try {
const response = await sdk().pan.check(entries.map(([, item]) => ({ type: item.diskType, url: item.url, password: item.password })));
const results = response && Array.isArray(response.results) ? response.results : [];
entries.forEach(([originalKey, fallback], index) => {
const result = results[index] || {};
const normalizedUrl = String(result.normalized_url || result.normalizedUrl || "").trim();
if (normalizedUrl) fallback.normalizedUrl = normalizedUrl;
const health = {
state: result.state || "uncertain",
summary: result.summary || "",
checkedAt: result.checked_at || Date.now(),
expiresAt: result.expires_at || Date.now() + 300000
};
state.pan.health[originalKey] = health;
const resultKey = panHealthKey({ diskType: normalizePanDiskType(result.type || fallback.diskType), url: result.url || fallback.url });
if (resultKey && resultKey !== originalKey) state.pan.health[resultKey] = health;
});
setPanStatus(`已检测 ${checkedPanCount()}/${checkablePanCount()}`);
} catch (e) {
entries.forEach(([, item]) => {
state.pan.health[panHealthKey(item)] = { state: "uncertain", summary: e.message || "检测失败", checkedAt: Date.now(), expiresAt: Date.now() + 300000 };
});
setPanStatus("检测不可用");
} finally {
entries.forEach(([key]) => {
delete state.pan.pending[key];
state.pan.inFlight.delete(key);
});
renderPanResults();
scheduleVisiblePanCheck();
if (state.pan.queued.size) schedulePanCheckFlush();
}
}
function checkedPanCount() {
return state.pan.results.filter((item) => isPanCheckSupported(item) && !!state.pan.health[panHealthKey(item)]).length;
}
function checkablePanCount() {
return state.pan.results.filter(isPanCheckSupported).length;
}
function panPasswordParam(item, url) {
const type = normalizePanDiskType(item && item.diskType);
const target = String(url || "").toLowerCase();
if (type === "115" || /(^|\/\/)([^/]*\.)?(115|115cdn|anxia)\./i.test(target)) return "password";
return "pwd";
}
function hasPanPassword(url) {
return /(?:[?&#])(pwd|password|passcode|code)=/i.test(String(url || ""));
}
function addPanPassword(url, password, item) {
let target = String(url || "").trim();
const code = String(password || "").trim();
if (!target || !code || hasPanPassword(target)) return target;
if (/^(magnet:|ed2k:\/\/)/i.test(target)) return target;
const param = panPasswordParam(item, target);
try {
const parsed = new URL(target);
parsed.searchParams.set(param, code);
return parsed.toString();
} catch (e) {
const hashIndex = target.indexOf("#");
const base = hashIndex >= 0 ? target.slice(0, hashIndex) : target;
const hash = hashIndex >= 0 ? target.slice(hashIndex) : "";
const sep = base.includes("?") ? (base.endsWith("?") || base.endsWith("&") ? "" : "&") : "?";
return base + sep + param + "=" + encodeURIComponent(code) + hash;
}
}
function buildPanPlayPayload(item) {
let url = String(item && (item.normalizedUrl || item.normalized_url || item.url) || "").trim();
if (!url) return null;
if (/^push:\/\//i.test(url)) url = url.replace(/^push:\/\//i, "");
return {
type: normalizePanDiskType(item && item.diskType),
url,
password: item && item.password || "",
title: item && (item.title || state.pan.keyword || item.url) || ""
};
}
async function playPanResult(item) {
const payload = buildPanPlayPayload(item);
if (!payload) return;
await rememberPanPlaybackReturn(item);
if (state.selected) {
rememberWatchIntent(state.selected, "search");
startWatchTracking(state.selected);
}
try {
const pan = sdk().pan || {};
if (!pan.play) throw new Error("当前 App 不支持 pan.play");
// 用 localStorage 持久化标记,WebView 被挂起后 JS 内存会丢失
// 但 localStorage 会保留,visibilitychange 恢复时可以读到
localStorage.setItem("fm_pan_playing", "1");
localStorage.setItem("fm_pan_play_at", String(Date.now()));
if (state.selected) {
try { localStorage.setItem("fm_pan_play_item", JSON.stringify(normalizeSnapshot(state.selected))); } catch (e) {}
}
state.pan.isPlaying = true;
pan.play(payload); // 不 await,原生接管后 Promise 永不 resolve
} catch (e) {
localStorage.removeItem("fm_pan_playing");
localStorage.removeItem("fm_pan_play_at");
localStorage.removeItem("fm_pan_play_item");
state.pan.isPlaying = false;
toast("播放失败:" + (e.message || "unknown"));
}
}
function emptyNode(text) {
const node = document.createElement("div");
node.className = "empty";
node.textContent = text;
return node;
}
function closeDetail(fromPopState) {
const sheet = $("detailSheet");
if (!sheet) return;
// 防止动画期间重复触发
if (sheet.classList.contains("sheet-closing")) return;
// 停止轮播和盘搜
stopDetailCoverCarousel(true);
resetPanSearch();
setNativeToolbarVisible(true);
// 清零模糊 / 隐藏徽标
setHeroBlur(0, 0);
sheet.style.setProperty("--cover-blur-pct", "0");
delete sheet.dataset.blurLocked;
const logoWrap = $("detailLogoWrap");
const logoImg = $("detailLogoImg");
if (logoWrap) logoWrap.style.display = "none";
if (logoImg) logoImg.classList.remove("loaded");
// 动画结束后执行真正的状态清除
const FADE = 240;
// 若盘搜打开时 hash 是 #pan,先收敛到 #detail,再走统一的 needBack 路径
if (!fromPopState && location.hash === "#pan") {
history.replaceState({ sheet: "detail" }, "", "#detail");
}
// 关闭详情时需要回退 history:从 #detail pop 回主页哨兵
const needBack = !fromPopState && location.hash === "#detail";
// 触发整体淡出动画(sheet 整体 opacity 022ms
sheet.classList.add("sheet-closing");
state.detailClosing = true;
document.body.classList.add("detail-closing");
// fromPopState 路径:history.back() 已发生,立即补哨兵防止动画期间再按返回退出 App
if (!needBack) {
ensureHomeHistoryEntry();
}
setTimeout(() => {
state.detailClosing = false;
document.body.classList.remove("detail-closing");
sheet.classList.remove("active");
sheet.classList.remove("sheet-closing");
sheet.classList.remove("detail-large");
sheet.style.display = "";
sheet.setAttribute("aria-hidden", "true");
// 此时 sheet 已不可见,再移除 detail-active 并恢复滚动
document.body.classList.remove("detail-active");
document.body.classList.remove("episode-active");
state.detailReturn = null;
scheduleUiSnapshotSave();
if (needBack) {
// 保存当前主页滚动位置,history.back() 后 popstate 会重置它
_pendingHomeScrollY = Math.round(window.scrollY || document.documentElement.scrollTop || 0);
history.back();
} else {
// 系统返回键路径:popstate 里保存的位置,动画结束后还原
if (_pendingHomeScrollY > 0) {
const _sy = _pendingHomeScrollY;
_pendingHomeScrollY = 0;
window.scrollTo(0, _sy);
document.documentElement.scrollTop = _sy;
document.body.scrollTop = _sy;
}
}
// 延迟清除背景图(避免闪一帧)
setTimeout(() => {
if (!sheet.classList.contains("active")) setHeroBg("");
}, 80);
}, FADE);
}
function openSync(options) {
const opts = options || {};
ensureSheetViewport($("syncSheet"));
$("syncSheet").classList.add("active");
$("syncSheet").setAttribute("aria-hidden", "false");
$("nsecInput").value = state.identity ? state.identity.nsec : "";
if (!opts.skipHistory && location.hash !== "#sync") history.pushState({ sheet: "sync" }, "", "#sync");
scheduleUiSnapshotSave();
if (!opts.restore) setTimeout(() => focusRemoteTarget($("saveNsecBtn")), 40);
}
function closeSync(fromPopState) {
const sheet = $("syncSheet");
sheet.classList.remove("active");
sheet.style.display = "";
sheet.setAttribute("aria-hidden", "true");
scheduleUiSnapshotSave();
if (!fromPopState && location.hash === "#sync") history.back();
}
function openImage(url, options) {
const opts = options || {};
const episode = opts.episode || null;
if (!url && !episode) return;
if (!opts.restore) rememberFocusReturn();
const image = $("viewerImage");
const episodeBox = $("episodeViewer");
if (episode) {
document.body.classList.add("episode-active");
$("imageViewer").classList.add("episode-mode");
image.style.display = "none";
image.removeAttribute("src");
const still = episode.still || url || "";
episodeBox.style.display = "grid";
episodeBox.innerHTML = `
${still ? `<img alt="" ${imageAttrs(still)}>` : ""}
<div>
<h3>${escapeHtml(episode.title || "分集剧情")}</h3>
${episode.meta ? `<span>${escapeHtml(episode.meta)}</span>` : ""}
</div>
<p>${escapeHtml(episode.overview || "暂无剧情概要")}</p>
`;
} else {
document.body.classList.remove("episode-active");
$("imageViewer").classList.remove("episode-mode");
episodeBox.style.display = "none";
episodeBox.replaceChildren();
image.style.display = "";
image.src = url;
}
ensureSheetViewport($("imageViewer"), "grid");
$("imageViewer").classList.add("active");
$("imageViewer").setAttribute("aria-hidden", "false");
if (!opts.skipHistory) history.pushState({ sheet: "image" }, "", "#image");
scheduleUiSnapshotSave();
if (!opts.restore) setTimeout(() => focusRemoteTarget(episode ? $("imageContent") : $("closeImageBtn")), 40);
}
function closeImage(fromPopState) {
const viewer = $("imageViewer");
document.body.classList.remove("episode-active");
viewer.classList.remove("active");
viewer.classList.remove("episode-mode");
viewer.style.display = "";
viewer.setAttribute("aria-hidden", "true");
$("viewerImage").removeAttribute("src");
$("viewerImage").style.display = "";
$("episodeViewer").style.display = "none";
$("episodeViewer").replaceChildren();
if (!fromPopState && location.hash === "#image") history.back();
scheduleUiSnapshotSave();
restoreFocusReturn($("detailSheet") && $("detailSheet").classList.contains("active") ? $("closeDetailBtn") : null);
}
async function nativeSearch(title) {
if (!title) return;
rememberDetailReturn($("detailSearchBtn"));
await sdk().search(title, { direct: true });
toast("已发起原生搜索");
}
async function openLiveHome() {
try {
const api = sdk();
if (window.fm && api.openLive) {
await api.openLive();
toast("已打开直播");
} else if (api.openLive) {
await api.openLive();
} else if (window.fongmi && window.fongmi.app && window.fongmi.app.openLive) {
await window.fongmi.app.openLive();
toast("已打开直播");
} else {
toast("当前环境不支持打开直播");
}
} catch (e) {
toast("打开直播失败:" + (e.message || "unknown"));
}
}
async function openDetailContinueHistory() {
const button = $("detailContinueBtn");
const history = button && button.__historyItem || findDetailContinueHistory(state.selected);
if (!state.selected || !history || !history.siteKey || !history.vodId) {
toast("没有可继续观看的记录");
updateDetailContinueButton();
return;
}
try {
rememberDetailReturn(button);
rememberWatchIntent(state.selected, "view");
startWatchTracking(state.selected);
await sdk().vod(history.siteKey, history.vodId, history.title || state.selected.title, history.pic || state.selected.pic);
} catch (e) {
stopWatchTracking(false);
toast("继续观看失败:" + (e.message || "unknown"));
}
}
function rememberDetailReturn(target) {
const detail = $("detailSheet");
if (!detail || !detail.classList.contains("active")) return;
state.detailReturn = {
scrollTop: Math.round(detail.scrollTop || 0),
targetId: target && target.id || document.activeElement && document.activeElement.id || "detailSearchBtn",
at: Date.now()
};
saveUiSnapshotNow();
}
function restoreDetailReturn() {
const saved = state.detailReturn;
if (!saved) return false;
if (Date.now() - Number(saved.at || 0) > 10 * 60 * 1000) {
state.detailReturn = null;
return false;
}
const detail = $("detailSheet");
if (!detail || !detail.classList.contains("active")) return false;
const apply = () => {
if ($("detailSheet")) $("detailSheet").scrollTop = Number(saved.scrollTop || 0);
const target = saved.targetId && $(saved.targetId) || $("detailSearchBtn") || $("closeDetailBtn");
if (target) focusRemoteTarget(target);
};
requestAnimationFrame(apply);
setTimeout(apply, 80);
setTimeout(apply, 220);
state.detailReturn = null;
scheduleUiSnapshotSave();
return true;
}
function rememberWatchIntent(item, action) {
if (!item) return;
state.watch = {
item: normalizeSnapshot(item),
timer: state.watch.timer,
bestMs: state.watch.bestMs || 0,
durationMs: state.watch.durationMs || 0,
lastAt: Date.now(),
published: false,
intentAction: action || "view"
};
savePendingWatch();
}
function startWatchTracking(item) {
if (!item || !window.fm) return;
state.watch = {
item: normalizeSnapshot(item),
timer: state.watch.timer,
bestMs: 0,
durationMs: 0,
lastAt: Date.now(),
published: false,
intentAction: state.watch.intentAction || ""
};
savePendingWatch();
if (!state.watch.timer) state.watch.timer = setInterval(sampleWatchStatus, 12000);
sampleWatchStatus();
}
async function savePendingWatch() {
const watch = state.watch;
if (!watch || !watch.item) return;
try {
await sdk().cache.set(cacheKey("pendingWatch"), JSON.stringify({
item: watch.item,
intentAction: watch.intentAction,
startedAt: watch.lastAt || Date.now(),
published: !!watch.published
}));
} catch (e) {}
}
async function loadPendingWatch() {
if (state.watch && state.watch.item) return state.watch;
try {
const text = await sdk().cache.get(cacheKey("pendingWatch"));
const data = text ? JSON.parse(text) : null;
if (!data || !data.item) return null;
state.watch = {
item: normalizeSnapshot(data.item),
timer: state.watch.timer,
bestMs: 0,
durationMs: 0,
lastAt: data.startedAt || Date.now(),
published: !!data.published,
intentAction: data.intentAction || "search"
};
return state.watch;
} catch (e) {
return null;
}
}
async function clearPendingWatch() {
try { await sdk().cache.del(cacheKey("pendingWatch")); } catch (e) {}
}
async function sampleWatchStatus() {
const watch = state.watch;
if (!watch || !watch.item || !window.fm) return;
try {
const status = await fm.stat();
const position = Math.max(0, Number(status && status.position || 0));
const duration = Math.max(0, Number(status && status.duration || 0));
if (duration > 0) watch.durationMs = duration;
if (position > watch.bestMs) watch.bestMs = position;
const completion = watch.durationMs > 0 ? watch.bestMs / watch.durationMs : 0;
if (watch.bestMs >= WATCH_HEAT_MS && hasHeatIntent(watch) && !watch.published) {
if (await hasPublishedHeat(watch.item)) {
watch.published = true;
await savePendingWatch();
await clearPendingWatch();
return;
}
watch.published = true;
savePendingWatch();
await recordPreference(watch.item, "watch", {
watchMs: Math.round(watch.bestMs),
durationMs: Math.round(watch.durationMs),
completion: Number(Math.min(1, completion).toFixed(3)),
intentAction: watch.intentAction,
playState: status && status.state,
playTitle: status && status.title || ""
});
await clearPendingWatch();
}
if (completion >= .92 && watch.bestMs > 0) stopWatchTracking(false);
} catch (e) {}
}
async function settleWatchFromHistory() {
const watch = await loadPendingWatch();
if (!watch || !watch.item || watch.published) return;
if (await hasPublishedHeat(watch.item)) {
watch.published = true;
await savePendingWatch();
await clearPendingWatch();
return;
}
try {
const list = await sdk().history();
const history = findWatchHistory(watch.item, Array.isArray(list) ? list : []);
if (!history) return;
const position = Math.max(0, Number(history.position || 0));
const duration = Math.max(0, Number(history.duration || 0));
if (position < WATCH_HEAT_MS || !hasHeatIntent(watch)) return;
if (await hasPublishedHeat(watch.item)) {
await clearPendingWatch();
return;
}
watch.bestMs = position;
watch.durationMs = duration;
watch.published = true;
await savePendingWatch();
await recordPreference(watch.item, "watch", {
watchMs: Math.round(position),
durationMs: Math.round(duration),
completion: duration > 0 ? Number(Math.min(1, position / duration).toFixed(3)) : 0,
intentAction: watch.intentAction,
historyKey: history.key || "",
siteKey: history.siteKey || "",
vodId: history.vodId || "",
playTitle: history.vodName || ""
});
await clearPendingWatch();
toast("已同步观看偏好");
} catch (e) {}
}
function scheduleHistorySettlement() {
settleWatchFromHistory();
setTimeout(settleWatchFromHistory, 1200);
setTimeout(settleWatchFromHistory, 4000);
}
function findWatchHistory(item, list) {
const title = normalizeTitle(item.title || "");
const tmdbId = item.tmdbId ? String(item.tmdbId) : "";
return list
.filter((history) => history && Number(history.position || 0) > 0)
.filter((history) => !state.watch.lastAt || Number(history.createTime || 0) >= state.watch.lastAt - 30000)
.sort((a, b) => Number(b.createTime || 0) - Number(a.createTime || 0))
.find((history) => {
const name = normalizeTitle(history.vodName || "");
if (title && name && (title === name || title.includes(name) || name.includes(title))) return true;
if (tmdbId && String(history.vodId || "").includes(tmdbId)) return true;
return false;
});
}
async function hasPublishedHeat(item) {
const identity = state.identity && state.identity.pubkey;
if (!identity || !item) return false;
const mediaKeyValue = mediaHeatKey(item);
if (!mediaKeyValue) return false;
const vector = await hotGetMyVector();
return hotVectorHasMedia(vector, mediaKeyValue);
}
function stopWatchTracking(flush) {
const watch = state.watch;
if (flush && watch && watch.item && watch.bestMs >= WATCH_HEAT_MS && hasHeatIntent(watch) && !watch.published) {
const completion = watch.durationMs > 0 ? watch.bestMs / watch.durationMs : 0;
trackPreference(watch.item, "watch", {
watchMs: Math.round(watch.bestMs),
durationMs: Math.round(watch.durationMs),
completion: Number(Math.min(1, completion).toFixed(3)),
intentAction: watch.intentAction
});
}
if (watch && watch.timer) clearInterval(watch.timer);
state.watch = { item: null, timer: 0, bestMs: 0, durationMs: 0, lastAt: 0, published: false, intentAction: "" };
}
// 盘搜播放返回后,按离开时长估算是否记录观看偏好
// 阈值:离开 >= PAN_WATCH_MIN_MS 才算「看过」
const PAN_WATCH_MIN_MS = 5 * 60 * 1000; // 5 分钟
async function settlePanWatch() {
const playAt = Number(localStorage.getItem("fm_pan_play_at") || 0);
const itemJson = localStorage.getItem("fm_pan_play_item");
// 无论是否满足条件,用完即清,避免重复触发
localStorage.removeItem("fm_pan_play_at");
localStorage.removeItem("fm_pan_play_item");
if (!playAt || !itemJson) return;
let item;
try { item = JSON.parse(itemJson); } catch (e) { return; }
if (!item) return;
const elapsed = Date.now() - playAt;
if (elapsed < PAN_WATCH_MIN_MS) return; // 停留不足 5 分钟,不记录
// 已通过内置播放器记过就不重复
if (await hasPublishedHeat(item)) return;
try {
await recordPreference(item, "watch", {
watchMs: Math.round(elapsed),
durationMs: 0, // 外部播放器无法获取总时长
completion: 0,
intentAction: "search",
source: "pan_elapsed" // 标记来源,便于日后区分
});
} catch (e) {}
}
function trackPreference(item, action, extra) {
recordPreference(item, action, extra).catch(() => {});
}
async function recordPreference(item, action, extra) {
if (action !== "watch") return;
if (await isPreferencePublishBlocked()) {
setStatus("publish", "删除保护中,暂停发布");
return;
}
if (action === "watch" && await hasPublishedHeat(item)) return;
const event = await createPreferenceEvent(item, action, extra || {});
if (!event) return;
event.local = true;
await hotIngestEvent(event);
scheduleRender();
publishEvent(event);
}
async function createPreferenceEvent(item, action, extra) {
const identity = await ensureIdentity();
if (!identity) return null;
if (action === "watch" && Math.max(0, Number(extra.watchMs || extra.position || 0)) < WATCH_HEAT_MS) return null;
const snapshot = normalizeSnapshot(item);
const currentItem = hotVectorItemFromSnapshot(snapshot, hotToday());
if (!currentItem) return null;
const oldVector = await hotGetMyVector();
const createdAt = Math.max(hotNow(), Number(oldVector && oldVector.ts || 0) + 1);
const expiresAt = hotExpiresAt(createdAt);
const items = hotWireItemsForPublish(oldVector, currentItem, createdAt);
const content = { v: HOT_VECTOR_VERSION, i: items };
const tags = [
["d", HOT_VECTOR_D],
["t", window.WEBHOME_CONFIG.nostr.tag],
["app", "fongmi-webhome"],
["expiration", String(expiresAt)]
];
const draft = { kind: window.WEBHOME_CONFIG.nostr.kind, created_at: createdAt, tags, content: JSON.stringify(content) };
if (identity && identity.secret && window.NostrTools) return window.NostrTools.finalizeEvent(draft, identity.secret);
if (window.nostr && window.nostr.signEvent) return await window.nostr.signEvent(draft);
return Object.assign({ id: HOT_VECTOR_D, pubkey: "" }, draft);
}
function hotVectorItemFromSnapshot(snapshot, day) {
if (!snapshot || (snapshot.mediaType !== "movie" && snapshot.mediaType !== "tv") || !snapshot.tmdbId) return null;
return hotNormalizeWireItem([
hotMediaTypeCode(snapshot.mediaType),
Number(snapshot.tmdbId) || String(snapshot.tmdbId),
day,
snapshot.title || "",
snapshot.pic || snapshot.image || ""
], day, false);
}
function hotWireItemsForPublish(oldVector, currentItem, createdAt) {
const map = new Map();
(oldVector && oldVector.i || []).forEach((item) => {
const wire = hotWireItemFromStored(item);
if (wire) map.set(hotVectorItemMediaKey(wire), wire);
});
map.set(hotVectorItemMediaKey(currentItem), currentItem);
return normalizeWireVectorItems(Array.from(map.values()), createdAt);
}
function hotWireItemFromStored(item) {
const mediaKey = hotVectorItemMediaKey(item);
const media = mediaKey ? state.hot.media.get(mediaKey) : null;
if (!media || !media.t || !media.p) return null;
return hotNormalizeWireItem([
hotMediaTypeCode(media.mt),
Number(media.tid) || media.tid,
hotVectorItemDay(item),
media.t,
media.p
], hotToday(), false);
}
async function ensureIdentity() {
if (state.identity) return state.identity;
await waitForNostrTools();
if (!window.NostrTools) return null;
let nsec = await sdk().cache.get(cacheKey("nsec"));
if (!nsec) {
nsec = window.NostrTools.nip19.nsecEncode(window.NostrTools.generateSecretKey());
await sdk().cache.set(cacheKey("nsec"), nsec);
}
return setIdentity(nsec);
}
async function setIdentity(nsec) {
await waitForNostrTools();
if (!window.NostrTools) throw new Error("nostr-tools 加载失败");
const decoded = window.NostrTools.nip19.decode(nsec.trim());
if (decoded.type !== "nsec") throw new Error("请输入 nsec 私钥");
const secret = decoded.data;
const pubkey = window.NostrTools.getPublicKey(secret);
const npub = window.NostrTools.nip19.npubEncode(pubkey);
state.identity = { nsec: nsec.trim(), secret, pubkey, npub };
await sdk().cache.set(cacheKey("nsec"), state.identity.nsec);
await hotLoadMyVector().catch(() => null);
setStatus("identity", shortKey(npub));
renderMetrics();
return state.identity;
}
function waitForNostrTools() {
if (window.NostrTools) return Promise.resolve();
return new Promise((resolve) => {
let tries = 0;
const timer = setInterval(() => {
if (window.NostrTools || tries++ > 40) {
clearInterval(timer);
if (!window.NostrTools) setStatus("identity", "nostr-tools 加载失败");
resolve();
}
}, 100);
});
}
function subscribeNostr() {
const relays = window.WEBHOME_CONFIG.nostr.relays;
state.relay.subscribeStarted = true;
state.relay.subscribeDone = 0;
state.relay.subscribeFinished = {};
setStatus("nostr", "连接中");
relays.forEach((relay, index) => {
try {
setRelayStatus(relay, "连接中");
const ws = new WebSocket(relay);
const subId = "fongmi_pref_" + index + "_" + Date.now();
let backfillStarted = false;
let seenEvents = 0;
let eventQueue = [];
let flushTimer = 0;
const flushQueue = () => {
clearTimeout(flushTimer);
flushTimer = 0;
const batch = eventQueue;
eventQueue = [];
if (!batch.length) return;
hotIngestEvents(batch).then((changed) => {
if (!changed) return;
scheduleHotRefresh(HOT_REFRESH_IDLE_MS);
});
};
const startBackfill = () => {
if (backfillStarted) return;
backfillStarted = true;
state.relay.backfillCandidates[relay] = Math.max(Number(state.relay.backfillCandidates[relay] || 0), seenEvents);
choosePrimaryBackfillRelay(false);
syncRelayBackfill(relay).catch(() => {});
};
const timer = setTimeout(() => {
if (state.relay.statuses[relay] === "已连接") startBackfill();
finishRelaySubscribe(relay, state.relay.statuses[relay] === "已连接" ? "" : "失败");
try { ws.close(); } catch (e) {}
}, 8000);
ws.onopen = () => {
state.relay.connected += 1;
setRelayStatus(relay, "已连接");
setStatus("nostr", "已连接");
if (isNostrRefreshActive()) updateNostrRefreshProgress({ phase: "接收订阅", connected: state.relay.connected, relay: shortRelay(relay) });
renderMetrics();
ws.send(JSON.stringify(["REQ", subId, {
kinds: [window.WEBHOME_CONFIG.nostr.kind],
"#t": [window.WEBHOME_CONFIG.nostr.tag],
"#d": [HOT_VECTOR_D],
since: hotBackupWindowStart(),
limit: HOT_SUBSCRIBE_LIMIT
}]));
};
ws.onmessage = async (message) => {
const data = safeJson(message.data, []);
if (data[0] === "EOSE") {
clearTimeout(timer);
flushQueue();
finishRelaySubscribe(relay, "");
startBackfill();
try {
ws.send(JSON.stringify(["CLOSE", subId]));
ws.close();
} catch (e) {}
return;
}
if (data[0] !== "EVENT" || !data[2]) return;
seenEvents += 1;
if (isNostrRefreshActive()) updateNostrRefreshProgress({ phase: "接收订阅", subEvents: Number(state.relay.refresh && state.relay.refresh.subEvents || 0) + 1, relay: shortRelay(relay) });
eventQueue.push(data[2]);
if (!flushTimer) flushTimer = setTimeout(flushQueue, 100);
};
ws.onclose = () => {
clearTimeout(timer);
flushQueue();
if (state.relay.statuses[relay] !== "已连接") finishRelaySubscribe(relay, "断开");
};
ws.onerror = () => {
clearTimeout(timer);
flushQueue();
finishRelaySubscribe(relay, "失败");
if (!Object.values(state.relay.statuses).includes("已连接")) setStatus("nostr", "连接失败");
};
} catch (e) {
finishRelaySubscribe(relay, "失败");
}
});
}
function queryRelayHotPage(relay, filter, timeout) {
return new Promise((resolve) => {
const events = [];
let settled = false;
const done = (complete) => {
if (settled) return;
settled = true;
events.complete = !!complete;
resolve(events);
};
try {
const ws = new WebSocket(relay);
const subId = "fongmi_hot_" + Date.now() + "_" + Math.random().toString(16).slice(2);
const timer = setTimeout(() => {
try { ws.close(); } catch (e) {}
done(false);
}, timeout || 9000);
ws.onopen = () => ws.send(JSON.stringify(["REQ", subId, filter]));
ws.onmessage = (message) => {
const data = safeJson(message.data, []);
if (data[0] === "EVENT" && data[2]) events.push(data[2]);
if (data[0] === "EOSE") {
clearTimeout(timer);
try {
ws.send(JSON.stringify(["CLOSE", subId]));
ws.close();
} catch (e) {}
done(true);
}
};
ws.onclose = () => {
clearTimeout(timer);
done(false);
};
ws.onerror = () => {
clearTimeout(timer);
done(false);
};
} catch (e) {
done(false);
}
});
}
async function syncRelayBackfill(relay) {
if (state.relay.backfillBusy[relay]) return;
state.relay.backfillBusy[relay] = true;
let cursor = null;
try {
const db = await openHotDb();
if (!db) return;
const primary = selectPrimaryBackfillRelay(relay);
const isPrimary = primary === relay;
const since = isPrimary ? hotWindowStart() : hotBackupWindowStart();
const now = hotNow();
cursor = normalizeRelayCursor(await hotStoreGet("relayCursor", relay), relay, since, now);
if (cursor.nextRetryAt && cursor.nextRetryAt > now) return;
let changed = false;
changed = await syncRelayRecent(relay, cursor, since, now) || changed;
if (isPrimary && !cursor.recentUntil) changed = await syncRelayHistory(relay, cursor, since, now) || changed;
if (!isPrimary) {
cursor.historyDone = true;
cursor.historyUntil = since - 1;
}
cursor.updatedAt = hotNow();
cursor.primary = isPrimary;
const tx = db.transaction("relayCursor", "readwrite");
tx.objectStore("relayCursor").put(cursor);
await idbDone(tx).catch(() => {});
if (changed) {
scheduleHotRefresh(HOT_REFRESH_BACKFILL_MS);
setStatus("nostr", `已同步 ${shortRelay(relay)}${isPrimary ? " 主" : " 近7天"}`);
}
} finally {
delete state.relay.backfillBusy[relay];
if (cursor && !relayBackfillDone(cursor)) scheduleRelayBackfill(relay, relayBackfillDelay(cursor));
maybeFinishNostrRefresh();
}
}
function selectPrimaryBackfillRelay(relay) {
const primary = choosePrimaryBackfillRelay(state.relay.subscribeDone >= window.WEBHOME_CONFIG.nostr.relays.length);
if (primary && primary !== relay && !state.relay.backfillBusy[primary]) scheduleRelayBackfill(primary);
return primary;
}
function choosePrimaryBackfillRelay(force) {
if (state.relay.primaryBackfillRelay) return state.relay.primaryBackfillRelay;
if (!force) return "";
const relays = window.WEBHOME_CONFIG.nostr.relays;
let best = "";
let bestScore = -1;
relays.forEach((relay) => {
const connected = state.relay.statuses[relay] === "已连接";
const score = Number(state.relay.backfillCandidates[relay] || 0);
if (!connected && !score) return;
if (score > bestScore) {
best = relay;
bestScore = score;
}
});
if (best) state.relay.primaryBackfillRelay = best;
return state.relay.primaryBackfillRelay;
}
function relayBackfillDone(cursor) {
return !cursor.recentUntil && cursor.historyDone;
}
function relayBackfillDelay(cursor) {
const nextRetryAt = Number(cursor && cursor.nextRetryAt || 0);
if (nextRetryAt > hotNow()) return Math.max(HOT_BACKFILL_IDLE_MS, (nextRetryAt - hotNow()) * 1000);
return HOT_BACKFILL_IDLE_MS;
}
function markRelayBackfillRetry(cursor) {
cursor.failures = Math.min(12, Number(cursor.failures || 0) + 1);
const delay = Math.min(HOT_BACKFILL_RETRY_MAX_MS, HOT_BACKFILL_RETRY_MS * Math.pow(2, cursor.failures - 1));
cursor.nextRetryAt = hotNow() + Math.ceil(delay / 1000);
}
function clearRelayBackfillRetry(cursor) {
cursor.failures = 0;
cursor.nextRetryAt = 0;
}
function scheduleRelayBackfill(relay, delay) {
clearTimeout(state.relay.backfillTimers[relay]);
state.relay.backfillTimers[relay] = setTimeout(() => {
syncRelayBackfill(relay).catch(() => {});
}, Number.isFinite(delay) ? delay : HOT_BACKFILL_IDLE_MS);
}
function normalizeRelayCursor(row, relay, since, now) {
if (!row) {
return {
relay,
since,
newest: now,
recentHigh: 0,
recentUntil: 0,
recentTarget: 0,
historyUntil: now,
historyDone: false,
updatedAt: 0,
failures: 0,
nextRetryAt: 0
};
}
const cursor = row || {};
const previousSince = Number(cursor.since || 0);
const expandedWindow = previousSince > since;
const newest = Math.max(Number(cursor.newest || 0), since - 1);
let historyUntil = Number(cursor.historyUntil || cursor.until || 0);
if (expandedWindow) historyUntil = Math.max(since, previousSince - 1);
if (!historyUntil || historyUntil < since) historyUntil = Math.max(newest, now);
const historyDone = expandedWindow ? false : cursor.historyDone === true || cursor.done === true && historyUntil <= since;
return {
relay,
since,
newest,
recentHigh: Math.max(Number(cursor.recentHigh || 0), 0),
recentUntil: Math.max(Number(cursor.recentUntil || 0), 0),
recentTarget: Math.max(Number(cursor.recentTarget || 0), 0),
historyUntil,
historyDone,
updatedAt: Number(cursor.updatedAt || 0),
failures: Number(cursor.failures || 0),
nextRetryAt: Number(cursor.nextRetryAt || 0)
};
}
async function syncRelayRecent(relay, cursor, since, now) {
if (!cursor.recentUntil && now > cursor.newest) {
cursor.recentHigh = now;
cursor.recentUntil = now;
cursor.recentTarget = Math.max(since, Number(cursor.newest || 0) + 1);
}
if (!cursor.recentUntil) return false;
let changed = false;
for (let page = 0; page < HOT_RECENT_PAGES_PER_RELAY; page++) {
if (cursor.recentUntil < cursor.recentTarget) break;
const events = await queryRelayHotPage(relay, {
kinds: [window.WEBHOME_CONFIG.nostr.kind],
"#t": [window.WEBHOME_CONFIG.nostr.tag],
"#d": [HOT_VECTOR_D],
since: cursor.recentTarget,
until: cursor.recentUntil,
limit: HOT_PAGE_LIMIT
}, 9000);
if (!events.length) {
if (!events.complete) {
markRelayBackfillRetry(cursor);
break;
}
clearRelayBackfillRetry(cursor);
finishRelayRecent(cursor);
break;
}
clearRelayBackfillRetry(cursor);
let oldest = cursor.recentUntil;
for (const event of events) oldest = Math.min(oldest, Number(event.created_at || oldest));
if (await hotIngestEvents(events)) changed = true;
if (isNostrRefreshActive()) updateNostrRefreshProgress({ phase: "近7天回填", recentPages: Number(state.relay.refresh && state.relay.refresh.recentPages || 0) + 1, recentEvents: Number(state.relay.refresh && state.relay.refresh.recentEvents || 0) + events.length, relay: shortRelay(relay) });
cursor.recentUntil = oldest - 1;
if (!events.complete) break;
if (events.length < HOT_PAGE_LIMIT || cursor.recentUntil < cursor.recentTarget) {
finishRelayRecent(cursor);
break;
}
}
return changed;
}
function finishRelayRecent(cursor) {
const historyCeiling = Number(cursor.recentTarget || 0) - 1;
cursor.newest = Math.max(Number(cursor.newest || 0), Number(cursor.recentHigh || 0));
if (historyCeiling <= Number(cursor.since || 0)) {
cursor.historyDone = true;
cursor.historyUntil = Number(cursor.since || 0) - 1;
} else if (!cursor.historyDone && (!cursor.historyUntil || cursor.historyUntil > historyCeiling)) {
cursor.historyUntil = historyCeiling;
}
cursor.recentHigh = 0;
cursor.recentUntil = 0;
cursor.recentTarget = 0;
}
async function syncRelayHistory(relay, cursor, since, now) {
if (cursor.historyDone) return false;
if (!cursor.historyUntil || cursor.historyUntil < since) cursor.historyUntil = Math.max(cursor.newest || 0, now);
let changed = false;
for (let page = 0; page < HOT_HISTORY_PAGES_PER_RELAY; page++) {
if (cursor.historyUntil <= since) {
cursor.historyDone = true;
break;
}
const events = await queryRelayHotPage(relay, {
kinds: [window.WEBHOME_CONFIG.nostr.kind],
"#t": [window.WEBHOME_CONFIG.nostr.tag],
"#d": [HOT_VECTOR_D],
since,
until: cursor.historyUntil,
limit: HOT_PAGE_LIMIT
}, 9000);
if (!events.length) {
if (!events.complete) {
markRelayBackfillRetry(cursor);
break;
}
clearRelayBackfillRetry(cursor);
cursor.historyDone = true;
break;
}
clearRelayBackfillRetry(cursor);
let oldest = cursor.historyUntil;
for (const event of events) oldest = Math.min(oldest, Number(event.created_at || oldest));
if (await hotIngestEvents(events)) changed = true;
if (isNostrRefreshActive()) updateNostrRefreshProgress({ phase: "历史回填", historyPages: Number(state.relay.refresh && state.relay.refresh.historyPages || 0) + 1, historyEvents: Number(state.relay.refresh && state.relay.refresh.historyEvents || 0) + events.length, relay: shortRelay(relay) });
cursor.historyUntil = oldest - 1;
if (!events.complete) break;
cursor.historyDone = events.length < HOT_PAGE_LIMIT || cursor.historyUntil <= since;
if (cursor.historyDone) break;
}
return changed;
}
function publishEvent(event) {
if (!event || !event.sig) {
setStatus("publish", "未签名,未发布");
return;
}
const relays = window.WEBHOME_CONFIG.nostr.relays;
state.relay.total = relays.length;
let done = 0;
let ok = 0;
state.relay.lastOk = 0;
state.relay.lastDone = 0;
setStatus("publish", `发布中 0/${relays.length}`);
relays.forEach((relay) => {
try {
const ws = new WebSocket(relay);
const finish = (success, text) => {
done += 1;
if (success) ok += 1;
state.relay.lastOk = ok;
state.relay.lastDone = done;
setStatus("publish", `发布 ${ok}/${done}/${relays.length}${text ? " · " + text : ""}`);
renderMetrics();
};
const timer = setTimeout(() => {
finish(false, shortRelay(relay) + " 超时");
try { ws.close(); } catch (e) {}
}, 6000);
ws.onopen = () => ws.send(JSON.stringify(["EVENT", stripLocal(event)]));
ws.onmessage = (message) => {
const data = safeJson(message.data, []);
if (data[0] === "OK") {
clearTimeout(timer);
if (data[2]) state.relay.published += 1;
finish(!!data[2], `${shortRelay(relay)} ${data[2] ? "OK" : data[3] || "拒绝"}`);
try { ws.close(); } catch (e) {}
}
};
ws.onerror = () => {
clearTimeout(timer);
finish(false, shortRelay(relay) + " 失败");
};
} catch (e) {
done += 1;
state.relay.lastOk = ok;
state.relay.lastDone = done;
setStatus("publish", `发布 ${ok}/${done}/${relays.length} · ${shortRelay(relay)} 失败`);
}
});
}
function eventAddress(event) {
const d = getD(event);
if (!event || !event.kind || !event.pubkey || !d) return "";
return `${event.kind}:${event.pubkey}:${d}`;
}
function createDeleteEvent(events, identity) {
const ids = Array.from(new Set(events.map((event) => event && event.id).filter(Boolean)));
const addresses = Array.from(new Set(events.map(eventAddress).filter(Boolean)));
const tags = ids.map((id) => ["e", id]).concat(addresses.map((addr) => ["a", addr]));
tags.push(["t", window.WEBHOME_CONFIG.nostr.tag], ["app", "fongmi-webhome"]);
const draft = {
kind: 5,
created_at: Math.floor(Date.now() / 1000),
tags,
content: "delete fongmi webhome preference events"
};
return window.NostrTools.finalizeEvent(draft, identity.secret);
}
function publishToRelay(relay, event, timeout) {
return new Promise((resolve) => {
let settled = false;
const done = (ok, text) => {
if (settled) return;
settled = true;
resolve({ ok, text });
};
try {
const ws = new WebSocket(relay);
const timer = setTimeout(() => {
try { ws.close(); } catch (e) {}
done(false, "超时");
}, timeout || 7000);
ws.onopen = () => ws.send(JSON.stringify(["EVENT", event]));
ws.onmessage = (message) => {
const data = safeJson(message.data, []);
if (data[0] !== "OK") return;
clearTimeout(timer);
try { ws.close(); } catch (e) {}
done(!!data[2], data[2] ? "OK" : data[3] || "拒绝");
};
ws.onerror = () => {
clearTimeout(timer);
done(false, "失败");
};
} catch (e) {
done(false, e.message || "失败");
}
});
}
async function queryRelayEventsPaged(relay, pubkey, since) {
const events = [];
let until = Math.floor(Date.now() / 1000);
for (let page = 0; page < 20; page++) {
const batch = await queryRelayHotPage(relay, {
kinds: [window.WEBHOME_CONFIG.nostr.kind],
authors: [pubkey],
"#t": [window.WEBHOME_CONFIG.nostr.tag],
"#d": [HOT_VECTOR_D],
since,
until,
limit: HOT_PAGE_LIMIT
}, 9000);
if (!batch.length) break;
events.push(...batch);
until = Math.min(...batch.map((event) => Number(event.created_at || until))) - 1;
if (batch.length < HOT_PAGE_LIMIT) break;
}
return events;
}
async function clearLocalPreferenceData() {
await clearLocalEvents(false);
setStatus("publish", "已清理本机缓存");
toast("本机偏好缓存已清理");
}
async function clearMyNostrData(identity) {
identity = identity || await ensureIdentity();
if (!identity || !identity.pubkey || !identity.secret) return toast("请先同步或生成身份");
setStatus("publish", "查询我的事件");
const since = hotWindowStart();
const relays = window.WEBHOME_CONFIG.nostr.relays;
const found = [];
for (const relay of relays) found.push(...await queryRelayEventsPaged(relay, identity.pubkey, since));
const events = Array.from(new Map(found.filter((event) => event && event.id).map((event) => [event.id, event])).values());
if (!events.length) {
await clearLocalEvents(true);
setStatus("publish", "没有找到我的远端事件");
toast("没有找到需要删除的数据");
return;
}
const deleteEvent = createDeleteEvent(events, identity);
let ok = 0;
let done = 0;
setStatus("publish", `删除中 0/${relays.length}`);
for (const relay of relays) {
const result = await publishToRelay(relay, deleteEvent, 7000);
done += 1;
if (result.ok) ok += 1;
setStatus("publish", `删除 ${ok}/${done}/${relays.length} · ${shortRelay(relay)} ${result.text || ""}`);
}
await clearLocalEvents(true);
toast(`删除请求已发送 ${ok}/${relays.length}`);
}
async function deleteAllPreferenceData() {
const identity = await ensureIdentity();
await markIdentityDeletedLocally(identity);
stopWatchTracking(false);
await clearPendingWatch();
await clearLocalPreferenceData();
await clearMyNostrData(identity);
}
function stripLocal(event) {
const copy = Object.assign({}, event);
delete copy.local;
return copy;
}
function getD(event) {
const tag = (event && event.tags || []).find((item) => item && item[0] === "d");
return tag && tag[1];
}
// ── 镜像榜单到其它 relay ────────────────────────────────────────
function parseRelayList(value) {
return Array.from(new Set(String(value || "")
.split(/[\s,]+/)
.map((item) => {
let url = String(item || "").trim();
if (!url) return "";
if (!/^wss?:\/\//i.test(url)) url = "wss://" + url;
return url.replace(/^WSS?:\/\//i, (m) => m.toLowerCase()).replace(/\/+$/, "");
})
.filter(Boolean)));
}
function setupRelayMirrorDefaults() {
const source = $("mirrorSourceInput");
const target = $("mirrorTargetInput");
if (source && !source.value.trim()) source.value = window.WEBHOME_CONFIG.nostr.relays.join("\n");
if (target) target.value = target.value || "";
disablePanelTextEditing(source);
disablePanelTextEditing(target);
}
function setMirrorProgress(lines) {
const el = $("mirrorProgress");
if (!el) return;
el.textContent = Array.isArray(lines) ? lines.filter(Boolean).join("\n") : String(lines || "镜像未执行");
}
function isMirrorableHotEvent(event) {
return !!(event && event.id && event.pubkey && event.sig && event.kind === window.WEBHOME_CONFIG.nostr.kind && getD(event) === HOT_VECTOR_D && !eventExpired(event));
}
function mirrorEventKey(event) {
return eventAddress(event) || String(event && event.id || "");
}
function mirrorEventIsNewer(next, old) {
if (!old) return true;
const nextCreated = Number(next && next.created_at || 0);
const oldCreated = Number(old && old.created_at || 0);
if (nextCreated !== oldCreated) return nextCreated > oldCreated;
return String(next && next.id || "") > String(old && old.id || "");
}
async function queryMirrorEventsFromRelay(relay, since, progress) {
const byId = new Map();
let until = hotNow();
let pages = 0;
while (until > since && pages < RELAY_MIRROR_MAX_PAGES_PER_SOURCE) {
pages += 1;
setMirrorProgress([
`拉取中:${shortRelay(relay)}`,
`第 ${pages} 页,已收到 ${byId.size} 条`,
progress
]);
const events = await queryRelayHotPage(relay, {
kinds: [window.WEBHOME_CONFIG.nostr.kind],
"#t": [window.WEBHOME_CONFIG.nostr.tag],
"#d": [HOT_VECTOR_D],
since,
until,
limit: RELAY_MIRROR_PAGE_LIMIT
}, 12000);
events.filter(isMirrorableHotEvent).forEach((event) => byId.set(event.id, event));
if (!events.length || !events.complete) break;
let oldest = until;
for (const event of events) oldest = Math.min(oldest, Number(event.created_at || oldest));
if (events.length < RELAY_MIRROR_PAGE_LIMIT || oldest >= until) break;
until = oldest - 1;
}
return Array.from(byId.values());
}
async function publishMirrorEventToRelay(relay, event, timeout) {
return new Promise((resolve) => {
let settled = false;
const done = (ok, text) => {
if (settled) return;
settled = true;
resolve({ ok, text: text || "" });
};
try {
const ws = new WebSocket(relay);
const timer = setTimeout(() => {
try { ws.close(); } catch (e) {}
done(false, "超时");
}, timeout || 9000);
ws.onopen = () => ws.send(JSON.stringify(["EVENT", stripLocal(event)]));
ws.onmessage = (message) => {
const data = safeJson(message.data, []);
if (data[0] !== "OK") return;
clearTimeout(timer);
try { ws.close(); } catch (e) {}
done(!!data[2], data[2] ? "OK" : data[3] || "拒绝");
};
ws.onerror = () => {
clearTimeout(timer);
done(false, "失败");
};
ws.onclose = () => done(false, "断开");
} catch (e) {
done(false, e.message || "失败");
}
});
}
async function mirrorHotEventsToRelays() {
if (mirrorHotEventsToRelays.busy) return;
const sources = parseRelayList($("mirrorSourceInput") && $("mirrorSourceInput").value || window.WEBHOME_CONFIG.nostr.relays.join("\n"));
const targets = parseRelayList($("mirrorTargetInput") && $("mirrorTargetInput").value || "");
if (!sources.length) return toast("请填写源 relay");
if (!targets.length) return toast("请填写目标 relay");
mirrorHotEventsToRelays.busy = true;
const button = $("mirrorRelayBtn");
if (button) button.textContent = "镜像中";
try {
const since = hotWindowStart();
const eventsByAddress = new Map();
for (const relay of sources) {
const events = await queryMirrorEventsFromRelay(relay, since, `源 ${sources.indexOf(relay) + 1}/${sources.length} · 用户 ${eventsByAddress.size} 个`);
events.forEach((event) => {
const key = mirrorEventKey(event);
if (key && mirrorEventIsNewer(event, eventsByAddress.get(key))) eventsByAddress.set(key, event);
});
setMirrorProgress([
`源完成:${shortRelay(relay)}`,
`本源有效 ${events.length} 条`,
`按用户去重后 ${eventsByAddress.size} 条`
]);
}
const events = Array.from(eventsByAddress.values()).sort((a, b) => Number(b.created_at || 0) - Number(a.created_at || 0));
if (!events.length) {
setMirrorProgress(["镜像完成", "没有从源 relay 拉到可镜像的榜单事件"]);
toast("没有可镜像事件");
return;
}
let ok = 0;
let rejected = 0;
let failed = 0;
let done = 0;
for (const target of targets) {
let targetOk = 0;
let targetRejected = 0;
let targetFailed = 0;
for (const event of events) {
done += 1;
setMirrorProgress([
`发布中:${shortRelay(target)}`,
`事件 ${done}/${events.length * targets.length}`,
`总计 OK ${ok} · 拒绝 ${rejected} · 失败 ${failed}`
]);
const result = await publishMirrorEventToRelay(target, event, 9000);
if (result.ok) {
ok += 1;
targetOk += 1;
} else if (/duplicate|exists|already|newer|older|replace/i.test(result.text || "")) {
ok += 1;
targetOk += 1;
} else if ((result.text || "").includes("拒绝")) {
rejected += 1;
targetRejected += 1;
} else {
failed += 1;
targetFailed += 1;
}
}
setMirrorProgress([
`目标完成:${shortRelay(target)}`,
`OK ${targetOk} · 拒绝 ${targetRejected} · 失败 ${targetFailed}`,
`总计 OK ${ok} · 拒绝 ${rejected} · 失败 ${failed}`
]);
}
setMirrorProgress([
"镜像完成",
`源 relay ${sources.length} 个,目标 relay ${targets.length} 个`,
`最新用户事件 ${events.length} 条`,
`发布 OK ${ok} · 拒绝 ${rejected} · 失败 ${failed}`
]);
toast(`镜像完成:${events.length} 条事件`);
} finally {
mirrorHotEventsToRelays.busy = false;
if (button) button.textContent = "镜像榜单";
}
}
function eventContent(event) {
const content = event && event.content;
if (content && typeof content === "object") return content;
return safeJson(content, null);
}
function bindActions() {
$("syncBtn").addEventListener("click", openSync);
// 影视简介:点击展开/收起(默认3行)
$("detailText").addEventListener("click", () => {
$("detailText").classList.toggle("expanded");
});
$("closeSyncBtn").addEventListener("click", () => closeSync(false));
$("saveNsecBtn").addEventListener("click", async () => {
try {
await setIdentity($("nsecInput").value);
toast("同步身份已导入");
} catch (e) {
toast(e.message || "导入失败");
}
});
$("newNsecBtn").addEventListener("click", async () => {
await waitForNostrTools();
if (!window.NostrTools) return toast("Nostr 工具未加载");
const nsec = window.NostrTools.nip19.nsecEncode(window.NostrTools.generateSecretKey());
await setIdentity(nsec);
$("nsecInput").value = nsec;
toast("已生成新身份");
});
$("connectionToggle").addEventListener("click", toggleConnectionPanel);
$("connectionToggle").addEventListener("keydown", (event) => {
const key = normalizeRemoteKey(event);
if (key !== "Enter" && event.key !== " ") return;
toggleConnectionPanel(event);
});
$("deleteDataBtn").addEventListener("click", () => {
closeConnectionPanel();
setStatus("publish", "后台删除数据中");
toast("已开始后台删除数据");
deleteAllPreferenceData().catch((e) => toast(e.message || "删除失败"));
});
$("savePanConfigBtn").addEventListener("click", () => {
savePanConfig().catch((e) => toast(e.message || "保存失败"));
});
if ($("refreshNostrBtn")) $("refreshNostrBtn").addEventListener("click", () => {
forceRefreshNostrRanking().catch((e) => toast(e.message || "刷新失败"));
});
if ($("mirrorRelayBtn")) $("mirrorRelayBtn").addEventListener("click", () => {
mirrorHotEventsToRelays().catch((e) => {
setMirrorProgress(["镜像失败", e.message || String(e || "")]);
toast(e.message || "镜像失败");
});
});
const homeFullscreenSeg = $("homeFullscreenSegmented");
if (homeFullscreenSeg) homeFullscreenSeg.addEventListener("click", (event) => {
const button = event.target && event.target.closest ? event.target.closest("[data-home-fullscreen]") : null;
if (!button) return;
setHomeFullscreenEnabled(button.dataset.homeFullscreen === "on").catch(() => {});
});
const panConfig = document.querySelector(".pan-config");
if (panConfig) {
panConfig.addEventListener("input", markPanConfigDirty);
panConfig.addEventListener("change", markPanConfigDirty);
}
$("closeImageBtn").addEventListener("click", () => closeImage(false));
$("imageViewer").addEventListener("click", (event) => {
if (event.target === $("imageViewer")) closeImage(false);
});
bindDetailCoverSwipe();
bindDetailPullToClose();
$("panTabs").addEventListener("click", handlePanTabEvent);
$("panTabs").addEventListener("touchend", handlePanTabEvent);
$("panTabs").addEventListener("keydown", (event) => {
const key = normalizeRemoteKey(event);
if (key !== "Enter" && event.key !== " ") return;
handlePanTabEvent(event);
});
$("panTabs").addEventListener("keyup", (event) => {
const key = normalizeRemoteKey(event);
if (key !== "Enter" && event.key !== " ") return;
handlePanTabEvent(event);
});
$("searchInput").addEventListener("input", scheduleSearchSuggest);
if ($("searchFocusMask")) {
$("searchFocusMask").addEventListener("touchmove", (event) => event.preventDefault(), { passive: false });
$("searchFocusMask").addEventListener("wheel", (event) => event.preventDefault(), { passive: false });
}
if ($("connectionFocusMask")) {
$("connectionFocusMask").addEventListener("touchmove", (event) => event.preventDefault(), { passive: false });
$("connectionFocusMask").addEventListener("wheel", (event) => event.preventDefault(), { passive: false });
}
$("searchInput").addEventListener("pointerdown", enableSearchEditing);
$("searchInput").addEventListener("mousedown", enableSearchEditing);
$("searchInput").addEventListener("touchstart", enableSearchEditing, { passive: true });
$("searchInput").addEventListener("focus", () => {
if ($("searchInput").readOnly) { hideSearchSuggest(); return; }
document.documentElement.classList.add("search-focused");
// 聚焦即压入统一的 #search 历史层;结果出现时复用同一层(searchTmdb 不再额外压栈)。
// 这样无论处于「聚焦态」还是「结果页」,一次返回都能整体关闭搜索、回到主页且保持全屏,
// 不会因为多压了一层而出现「返回先消费聚焦层、结果仍留在屏幕上」的错位。
if (location.hash !== "#search" && location.hash !== "#detail") {
history.pushState({ sheet: "search" }, "", "#search");
_searchHistoryPushed = true;
}
// 输入框为空时展示热搜词
const val = ($("searchInput").value || "").trim();
if (!val) showHotSuggest();
});
$("searchInput").addEventListener("blur", () => {
disableSearchEditing();
// 延迟移除,避免点联想项时 blur 先触发导致联想面板消失
setTimeout(() => {
if (document.activeElement && $("suggestPanel") && $("suggestPanel").contains(document.activeElement)) return;
if (document.activeElement === $("searchInput")) return;
document.documentElement.classList.remove("search-focused");
}, 120);
});
document.querySelectorAll("#connectionBody input, #connectionBody textarea").forEach((el) => {
el.addEventListener("pointerdown", () => enablePanelTextEditing(el));
el.addEventListener("mousedown", () => enablePanelTextEditing(el));
el.addEventListener("touchstart", () => enablePanelTextEditing(el), { passive: true });
el.addEventListener("blur", () => disablePanelTextEditing(el));
});
$("searchInput").addEventListener("keydown", (event) => {
if (document.activeElement !== $("searchInput")) return;
const key = normalizeRemoteKey(event);
if (key === "ArrowDown" && isSearchSuggestOpen()) {
const first = firstSearchSuggestItem();
event.preventDefault();
event.stopPropagation();
if (first) focusSearchSuggestTarget(first);
return;
}
if (key === "ArrowDown" && $("searchInput").readOnly) {
event.preventDefault();
event.stopPropagation();
focusInitialHomeNow();
return;
}
if (key === "Enter" && $("searchInput").readOnly) {
event.preventDefault();
event.stopPropagation();
enableSearchEditing();
$("searchInput").focus();
return;
}
});
document.addEventListener("click", (event) => {
if (!$("searchForm").contains(event.target)) {
const input = $("searchInput");
const hasValue = input && input.value.trim().length > 0;
// 点击搜索区域外:退出聚焦态
document.documentElement.classList.remove("search-focused");
if (isSearchSuggestOpen() || hasValue) {
clearSearchResults();
} else {
hideSearchSuggest();
}
}
});
// 状态面板:点击面板外部时关闭,并在捕获阶段吞掉这次点击,
// 避免穿透到背景卡片(替代原来的全屏遮挡蒙层,页面不再被冻结)。
document.addEventListener("click", (event) => {
const dock = $("connectionDock");
if (!dock || !dock.classList.contains("open")) return;
const body = $("connectionBody");
const toggle = $("connectionToggle");
const clickedInside = (body && body.contains(event.target)) || (toggle && toggle.contains(event.target));
if (clickedInside) return;
event.preventDefault();
event.stopPropagation();
closeConnectionPanel();
}, true);
$("searchForm").addEventListener("submit", (event) => {
event.preventDefault();
submitSearchInput();
});
$("searchForm").querySelector("button[type='submit']").addEventListener("click", (event) => {
event.preventDefault();
submitSearchInput();
});
// 长按搜索图标 → 直接用搜索框关键词执行"搜索播放"nativeSearch
(function bindSearchBtnLongPress() {
const searchBtn = $("searchForm").querySelector("button[type='submit']");
if (!searchBtn) return;
let _lpTimer = 0;
let _lpFired = false;
function _lpStart(e) {
_lpFired = false;
clearTimeout(_lpTimer);
_lpTimer = setTimeout(function() {
_lpFired = true;
const kw = ($("searchInput") && $("searchInput").value.trim()) || (state.selected && state.selected.title) || "";
if (!kw) { toast("请先输入搜索词"); return; }
haptic("medium");
if (state.selected) {
rememberWatchIntent(state.selected, "search");
startWatchTracking(state.selected);
}
nativeSearch(kw);
}, 500);
}
function _lpCancel() {
clearTimeout(_lpTimer);
}
function _lpClickGuard(e) {
if (_lpFired) {
e.preventDefault();
e.stopImmediatePropagation();
_lpFired = false;
}
}
searchBtn.addEventListener("pointerdown", _lpStart);
searchBtn.addEventListener("touchstart", _lpStart, { passive: true });
searchBtn.addEventListener("pointerup", _lpCancel);
searchBtn.addEventListener("touchend", _lpCancel);
searchBtn.addEventListener("pointerleave", _lpCancel);
searchBtn.addEventListener("pointercancel", _lpCancel);
searchBtn.addEventListener("touchcancel", _lpCancel);
searchBtn.addEventListener("click", _lpClickGuard, true);
})();
$("closeDetailBtn").addEventListener("click", () => {
const sheet = $("detailSheet");
if (sheet && sheet.classList.contains("sheet-closing")) return;
closeDetail(false);
});
// ── 详情页滚动 → 轮播图渐进高斯模糊 ──
(function bindDetailScrollBlur() {
const sheet = $("detailSheet");
if (!sheet) return;
// blurStart/End 在首次滚动时根据 detail-spacer 高度动态计算
// 需求:一开始滑动背景海报就执行模糊+压暗,滚回顶部恢复正常
// - blurStartspacer 的 10%,滑动一小段后渐入模糊/压暗
// - blurEnd spacer 的 75%,文字基本覆盖背景时达到最暗最模糊
let blurStart = -1;
let blurEnd = -1;
function measureBlurRange() {
const spacer = sheet.querySelector(".detail-spacer");
const spacerH = spacer ? spacer.offsetHeight : window.innerHeight * 0.42;
blurStart = Math.round(spacerH * 0.20); // 滚过 20% 开始模糊压暗
blurEnd = Math.round(spacerH * 0.75); // 完全模糊压暗
}
// MutationObserver:每次打开详情重置状态
const mo = new MutationObserver(() => {
if (sheet.classList.contains("active")) {
sheet.scrollTop = 0;
setHeroBlur(0, 0);
sheet.style.setProperty("--cover-blur-pct", "0");
delete sheet.dataset.blurLocked;
blurStart = blurEnd = -1; // 标记需要重新测量
} else if (!sheet.classList.contains("sheet-closing")) {
setHeroBlur(0, 0);
sheet.style.setProperty("--cover-blur-pct", "0");
delete sheet.dataset.blurLocked;
blurStart = blurEnd = -1;
}
});
mo.observe(sheet, { attributes: true, attributeFilter: ["class"] });
let rafId = 0;
let lastScrollTop = 0;
sheet.addEventListener("scroll", () => {
if (rafId) return;
// TV 端:完全跳过滚动时的 filter/blur 更新(GPU 合成层代价高)
if (isTvMode()) return;
rafId = requestAnimationFrame(() => {
rafId = 0;
// 首次滚动时测量(布局已稳定)
if (blurStart < 0) measureBlurRange();
const scrollTop = sheet.scrollTop || 0;
// panSearch 锁定:保持当前模糊值,直到用户滚回 blurStart 以上才解锁
if (sheet.dataset.blurLocked === "1") {
if (scrollTop < lastScrollTop && scrollTop <= blurStart) {
delete sheet.dataset.blurLocked;
setHeroBlur(0, 0);
sheet.style.setProperty("--cover-blur-pct", "0");
} else {
const lockedPx = parseFloat(sheet.dataset.blurLockedPx || "0");
const lockedPct = parseFloat(sheet.dataset.blurLockedPct || "1");
setHeroBlur(lockedPx, lockedPct);
sheet.style.setProperty("--cover-blur-pct", String(lockedPct));
}
lastScrollTop = scrollTop;
return;
}
lastScrollTop = scrollTop;
// 线性插值:blurStart→blurEnd 之间 0→1,双向对称
const range = Math.max(1, blurEnd - blurStart);
const pct = Math.min(1, Math.max(0, (scrollTop - blurStart) / range));
// 用 easeInOut 曲线让过渡更自然(在端点附近更平滑)
const ease = pct < 0.5 ? 2 * pct * pct : 1 - Math.pow(-2 * pct + 2, 2) / 2;
const blurPx = parseFloat((ease * 13).toFixed(1)); // 最大 13px 模糊
setHeroBlur(blurPx, ease);
sheet.style.setProperty("--cover-blur-pct", ease.toFixed(3));
});
}, { passive: true });
})();
$("detailContinueBtn").addEventListener("click", () => {
openDetailContinueHistory();
});
(function setupDetailSearchBtn() {
const btn = $("detailSearchBtn");
if (!btn) return;
const LONG_MS = 500;
let timer = 0, fired = false, suppressClickUntil = 0;
// 原来的"搜索"功能(宿主原生搜索)——长按触发
function doSearch() {
if (!state.selected) return;
haptic("light");
rememberWatchIntent(state.selected, "search");
startWatchTracking(state.selected);
nativeSearch(state.selected.title);
}
// 短按:电影/剧集 → 直接播放最优画质夸克(剧集播整季包,不影响点单集);其它 → 原搜索
function doShort() {
if (!state.selected) return;
const mt = state.selected.mediaType;
if (mt === "movie" || mt === "tv") { haptic("light"); playBestQuark(state.selected); }
else doSearch();
}
function startHold() {
fired = false;
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
timer = 0; fired = true; suppressClickUntil = Date.now() + 700;
doSearch();
}, LONG_MS);
}
function cancelHold() { if (timer) { clearTimeout(timer); timer = 0; } }
btn.addEventListener("pointerdown", startHold);
btn.addEventListener("pointerup", cancelHold);
btn.addEventListener("pointerleave", cancelHold);
btn.addEventListener("pointercancel", cancelHold);
btn.addEventListener("click", (event) => {
if (fired || Date.now() < suppressClickUntil) { event.preventDefault(); event.stopPropagation(); fired = false; return; }
doShort();
});
btn.addEventListener("keydown", (event) => {
const key = normalizeRemoteKey(event);
if (key !== "Enter" && event.key !== " ") return;
event.preventDefault();
if (event.repeat) return;
startHold();
});
btn.addEventListener("keyup", (event) => {
const key = normalizeRemoteKey(event);
if (key !== "Enter" && event.key !== " ") return;
event.preventDefault();
cancelHold();
if (fired) { fired = false; return; }
doShort();
});
})();
$("panSearchBtn").addEventListener("click", () => {
if (!state.selected) return;
const sheet = $("detailSheet");
if (sheet) {
// 立即压暗背景(不等滚动),锁定在最暗
sheet.dataset.blurLocked = "1";
sheet.dataset.blurLockedPx = "20";
sheet.dataset.blurLockedPct = "1";
setHeroBlur(20, 1);
sheet.style.setProperty("--cover-blur-pct", "1");
}
searchPanResources(state.selected);
});
$("backTopBtn").addEventListener("click", () => {
scrollHomeToTop();
});
}
// ── 详情页下拉关闭手势(已禁用)──
function bindDetailPullToClose() {
// 下拉关闭手势已禁用,避免误触
}
function bindDetailCoverSwipe() {
const cover = $("detailImage") && $("detailImage").parentElement;
if (!cover) return;
cover.addEventListener("touchstart", (event) => {
if (document.documentElement.classList.contains("tv-mode") || state.detailCover.images.length <= 1) return;
const touch = event.touches && event.touches[0];
if (!touch) return;
state.detailCover.swipe = { x: touch.clientX, y: touch.clientY, at: Date.now(), moved: false };
}, { passive: true });
cover.addEventListener("touchmove", (event) => {
const swipe = state.detailCover.swipe;
const touch = event.touches && event.touches[0];
if (!swipe || !touch) return;
const dx = touch.clientX - swipe.x;
const dy = touch.clientY - swipe.y;
if (Math.abs(dx) > 12 && Math.abs(dx) > Math.abs(dy) * 1.25) {
swipe.moved = true;
event.preventDefault();
}
}, { passive: false });
cover.addEventListener("touchend", (event) => {
const swipe = state.detailCover.swipe;
state.detailCover.swipe = null;
if (!swipe || !swipe.moved || state.detailCover.images.length <= 1) return;
const touch = event.changedTouches && event.changedTouches[0];
if (!touch) return;
const dx = touch.clientX - swipe.x;
const dy = touch.clientY - swipe.y;
if (Math.abs(dx) < 42 || Math.abs(dx) < Math.abs(dy) * 1.25 || Date.now() - swipe.at > 900) return;
event.preventDefault();
event.stopPropagation();
shiftDetailCover(dx < 0 ? 1 : -1);
}, { passive: false });
cover.addEventListener("touchcancel", () => {
state.detailCover.swipe = null;
}, { passive: true });
}
function scrollHomeToTop() {
try {
window.scrollTo({ top: 0, behavior: "smooth" });
} catch (e) {
window.scrollTo(0, 0);
document.documentElement.scrollTop = 0;
document.body.scrollTop = 0;
}
setTimeout(updateBackTopButton, 260);
}
// 遥控方向键节流:TV 端长按/连发时,过快的同向重复直接吞掉,
// 让焦点一格一格走、不冲过头(同向最小间隔 56ms,换向 38ms)。
function shouldThrottleRemoteNav(key, event, active) {
if (!isTvMode() || !["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(key)) return false;
if (focusScopeRoot() !== document || isConnectionPanelOpen() || isSearchSuggestOpen()) return false;
if ($("searchForm") && active && $("searchForm").contains(active)) return false;
const now = window.performance && performance.now ? performance.now() : Date.now();
const gate = state.remoteKeyGate || { key: "", at: 0 };
const minGap = gate.key === key ? 56 : 38;
if (gate.at && now - gate.at < minGap) return true;
state.remoteKeyGate = { key, at: now };
return false;
}
function installRemoteKeys() {
document.addEventListener("keydown", (event) => {
const key = normalizeRemoteKey(event);
if (!key) return;
const el = document.activeElement;
const editing = isTextEditingElement(el);
// 节流过快的方向键重复(替代原来的「长按加速」,避免焦点冲过头)
if (!editing && shouldThrottleRemoteNav(key, event, el)) {
event.preventDefault();
return;
}
if (key !== "Enter" && ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(key) && ensureRemoteActiveFocus()) {
event.preventDefault();
return;
}
if (key === "Enter") {
if (handleConnectionPanelEnterKey(event)) return;
if (el === $("searchInput") && el.readOnly) {
event.preventDefault();
enableSearchEditing();
el.focus();
return;
}
if (activateFocusedElement(el, event)) return;
return;
}
if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(key)) {
if (handleSearchSuggestDirectionalKey(key, event)) return;
if (handleSearchFormDirectionalKey(key, event)) return;
if (handleConnectionPanelDirectionalKey(key, event)) return;
}
if ((key === "Escape" || key === "Backspace") && handleConnectionPanelBackKey(event)) return;
if (editing) {
if (el === $("searchInput") && el.readOnly && key === "ArrowDown") {
event.preventDefault();
el.blur();
focusInitialHomeNow();
return;
}
if (key === "Escape") {
event.preventDefault();
el.blur();
}
if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(key)) {
const target = nearestFocusable(key, el);
if (!target) return;
event.preventDefault();
el.blur();
focusRemoteTarget(target);
}
return;
}
if (key === "Escape" || key === "Backspace") {
if (handleSearchSuggestBackKey(event)) return;
if (handleConnectionPanelBackKey(event)) return;
if (handlePanBackKey(event)) return;
// Backspace 时,若 searchInput 正在编辑中,放行让浏览器正常删字
const _searchEl = $("searchInput");
if (key === "Backspace" && _searchEl && document.activeElement === _searchEl && !_searchEl.readOnly) return;
if (handleSearchBackKey(event)) return;
event.preventDefault();
// 二级页面:只关闭当前层,不退出 App
if ($("imageViewer").classList.contains("active")) return history.back();
if ($("detailSheet").classList.contains("active")) return history.back();
if ($("syncSheet").classList.contains("active")) return history.back();
// 主页面:才允许调用 App 原生返回(可能退出)
if (sdk().back) return sdk().back();
return;
}
if (!["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(key)) return;
if (handleScrollableOverlayKey(key, event)) return;
if (handlePanDirectionalKey(key, event)) return;
if (handleDetailDirectionalKey(key, event)) return;
// TV 快速路径:焦点在 .media-grid 内的 card 时,用 data-card-index 直接计算目标,跳过全局几何搜索
if (isTvMode() && tryGridCardFastNav(key, event)) return;
const target = nearestFocusable(key);
if (target) {
event.preventDefault();
if (!isPanFocusTarget(target)) state.pan.focusMode = "";
focusRemoteTarget(target);
}
});
document.addEventListener("keydown", (event) => {
const key = normalizeRemoteKey(event);
if (key !== "Escape" && key !== "Backspace") return;
if (handleSearchSuggestBackKey(event)) return;
if (handleConnectionPanelBackKey(event)) return;
if (handlePanBackKey(event)) return;
// Backspace 时,若 searchInput 正在编辑中,放行让浏览器正常删字
const searchEl = $("searchInput");
if (key === "Backspace" && searchEl && document.activeElement === searchEl && !searchEl.readOnly) return;
handleSearchBackKey(event);
}, true);
// ── 手机端主页面左右滑动切换 chip(类别)──
(function installHomeSwipe() {
var _t = null;
var HORIZ_THRESHOLD = 52;
var RATIO_THRESHOLD = 1.6;
var TIME_LIMIT = 480;
function isLayerOpen() {
return (
($("detailSheet") && $("detailSheet").classList.contains("active")) ||
($("imageViewer") && $("imageViewer").classList.contains("active")) ||
($("syncSheet") && $("syncSheet").classList.contains("active")) ||
document.documentElement.classList.contains("search-active")
);
}
function isInHorizScrollable(el) {
while (el && el !== document.body) {
var style = window.getComputedStyle(el);
var ox = style.overflowX;
if ((ox === "auto" || ox === "scroll") && el.scrollWidth > el.clientWidth + 4) return true;
el = el.parentElement;
}
return false;
}
function getChipIds() {
return Array.from($("chips").querySelectorAll("[data-chip-id]")).map(function(b) { return b.dataset.chipId; });
}
function switchChipByDelta(delta) {
var ids = getChipIds();
if (!ids.length) return;
var cur = ids.indexOf(state.activeList);
var next = cur + delta;
if (next < 0 || next >= ids.length) return;
// 先 blur 消除触摸残留的 :focus 背景
if (document.activeElement && document.activeElement.blur) document.activeElement.blur();
selectChip(ids[next]);
requestAnimationFrame(function() {
var btn = $("chips").querySelector("[data-chip-id='" + ids[next] + "']");
if (btn) btn.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "center" });
});
}
document.addEventListener("touchstart", function(e) {
if (isTvMode()) return;
if (isLayerOpen()) return;
var touch = e.touches[0];
if (!touch) return;
if (isInHorizScrollable(e.target)) return;
_t = { x: touch.clientX, y: touch.clientY, at: Date.now() };
}, { passive: true });
document.addEventListener("touchend", function(e) {
if (!_t || isTvMode()) { _t = null; return; }
if (isLayerOpen()) { _t = null; return; }
var touch = e.changedTouches[0];
if (!touch) { _t = null; return; }
var dx = touch.clientX - _t.x;
var dy = touch.clientY - _t.y;
var dt = Date.now() - _t.at;
_t = null;
if (dt > TIME_LIMIT) return;
if (Math.abs(dx) < HORIZ_THRESHOLD) return;
if (Math.abs(dx) < Math.abs(dy) * RATIO_THRESHOLD) return;
switchChipByDelta(dx < 0 ? 1 : -1);
}, { passive: true });
})();
}
function activateFocusedElement(el, event) {
if (!isVisibleFocusable(el)) return false;
if (isTextEditingElement(el)) return false;
// 搜索播放按钮有自己的 keydown/keyup(短按播放、长按搜索),这里不再 click,避免双触发。
if (el === $("detailSearchBtn")) return false;
event.preventDefault();
event.stopPropagation();
const panType = getPanTypeFromElement(el);
if (panType) {
selectPanType(panType);
return true;
}
if (typeof el.click === "function") {
el.click();
} else {
const click = document.createEvent("MouseEvents");
click.initMouseEvent("click", true, true, window, 1, 0, 0, 0, 0, false, false, false, false, 0, null);
el.dispatchEvent(click);
}
return true;
}
function isTextEditingElement(el) {
if (!el) return false;
if (el.tagName === "TEXTAREA" || el.isContentEditable) return true;
if (el.tagName !== "INPUT") return false;
const type = String(el.getAttribute("type") || "text").toLowerCase();
return !["button", "checkbox", "radio", "submit", "reset", "range", "color"].includes(type);
}
function isPanelTextField(el) {
return !!(el && $("connectionBody") && $("connectionBody").contains(el) && (el.tagName === "TEXTAREA" || el.tagName === "INPUT" && !["button", "checkbox", "radio", "submit", "reset", "range", "color"].includes(String(el.getAttribute("type") || "text").toLowerCase())));
}
function enablePanelTextEditing(el) {
if (!isPanelTextField(el)) return false;
el.readOnly = false;
el.classList.add("panel-editing");
try {
el.focus({ preventScroll: true });
} catch (e) {
el.focus();
}
return true;
}
function disablePanelTextEditing(el) {
if (!isPanelTextField(el)) return false;
el.readOnly = true;
el.classList.remove("panel-editing");
return true;
}
function disableConnectionTextEditing() {
const body = $("connectionBody");
if (!body) return;
body.querySelectorAll("input,textarea").forEach((el) => {
if (isPanelTextField(el)) disablePanelTextEditing(el);
});
}
function handleConnectionPanelEnterKey(event) {
if (!isConnectionPanelOpen()) return false;
const active = document.activeElement;
if (!active || !$("connectionDock") || !$("connectionDock").contains(active)) return false;
if (active === $("connectionToggle")) return false;
if (active.matches && active.matches("input[type='checkbox']")) {
event.preventDefault();
event.stopPropagation();
if (event.stopImmediatePropagation) event.stopImmediatePropagation();
active.click();
markPanConfigDirty();
return true;
}
if (isPanelTextField(active)) {
if (active.readOnly) {
event.preventDefault();
event.stopPropagation();
if (event.stopImmediatePropagation) event.stopImmediatePropagation();
enablePanelTextEditing(active);
return true;
}
return false;
}
return false;
}
function handleScrollableOverlayKey(key, event) {
if (!$("imageViewer") || !$("imageViewer").classList.contains("active")) return false;
const content = $("imageContent");
if (!content || key !== "ArrowUp" && key !== "ArrowDown") return false;
const max = content.scrollHeight - content.clientHeight;
if (max <= 4) return false;
const delta = Math.max(80, Math.round(content.clientHeight * 0.72)) * (key === "ArrowDown" ? 1 : -1);
if (key === "ArrowUp" && content.scrollTop <= 0) {
event.preventDefault();
focusRemoteTarget($("closeImageBtn"));
return true;
}
if (key === "ArrowDown" && content.scrollTop >= max - 2) return false;
event.preventDefault();
content.scrollTop = Math.max(0, Math.min(max, content.scrollTop + delta));
if (!content.contains(document.activeElement)) focusRemoteTarget(content);
return true;
}
function handlePanBackKey(event) {
const tabs = $("panTabs");
const list = $("panResultList");
const active = document.activeElement;
const inResults = !!(tabs && list && active && list.contains(active));
if (!tabs || !list || !inResults && state.pan.focusMode !== "results") return false;
event.preventDefault();
event.stopPropagation();
if (event.stopImmediatePropagation) event.stopImmediatePropagation();
focusPanTabFromResults();
scheduleUiSnapshotSave();
return true;
}
function handleSearchBackKey(event) {
if (!state.searchItems.length || !$("searchSection") || $("searchSection").style.display === "none") return false;
const active = document.activeElement;
const inSearch = active && ($("searchSection").contains(active) || $("searchForm").contains(active));
if (!inSearch) return false;
event.preventDefault();
event.stopPropagation();
if (event.stopImmediatePropagation) event.stopImmediatePropagation();
clearSearchResults();
requestAnimationFrame(() => focusInitialHomeNow());
return true;
}
function handleSearchSuggestBackKey(event) {
if (!isSearchSuggestOpen()) return false;
const active = document.activeElement;
const input = $("searchInput");
const panel = $("suggestPanel");
const form = $("searchForm");
const inSuggest = active && (active === input || panel && panel.contains(active) || form && form.contains(active));
if (!inSuggest) return false;
// 如果焦点在输入框且正在编辑(非 readOnly),Backspace 应删字而不是关闭 suggest
if (active === input && !input.readOnly && normalizeRemoteKey(event) === "Backspace") return false;
event.preventDefault();
event.stopPropagation();
if (event.stopImmediatePropagation) event.stopImmediatePropagation();
hideSearchSuggest();
disableSearchEditing();
if (input) requestAnimationFrame(() => focusRemoteTarget(input));
return true;
}
function handleSearchSuggestDirectionalKey(key, event) {
if (!isSearchSuggestOpen() || !["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(key)) return false;
const input = $("searchInput");
const panel = $("suggestPanel");
const form = $("searchForm");
const active = document.activeElement;
if (!active || active !== input && !(panel && panel.contains(active)) && !(form && form.contains(active))) return false;
const items = searchSuggestItems();
let target = null;
if (active === input || form && form.contains(active) && !(panel && panel.contains(active))) {
if (key === "ArrowDown") target = items[0] || input;
else target = input;
} else if (panel && panel.contains(active)) {
const index = items.indexOf(active);
if (key === "ArrowDown") target = items[Math.min(items.length - 1, index + 1)] || active;
else if (key === "ArrowUp") target = index <= 0 ? input : items[index - 1];
else target = active;
}
event.preventDefault();
event.stopPropagation();
if (target) focusSearchSuggestTarget(target);
return true;
}
function handleSearchFormDirectionalKey(key, event) {
if (!$("searchForm")) return false;
const input = $("searchInput");
const submit = $("searchForm").querySelector("button[type='submit']");
const active = document.activeElement;
let target = null;
if (active === submit && key === "ArrowLeft") target = input;
else if (active === input && key === "ArrowRight") target = submit;
else if (active === input && key === "ArrowDown") target = currentHomeFocus();
else if (active === submit && key === "ArrowDown") target = currentHomeFocus();
if (!target) return false;
event.preventDefault();
event.stopPropagation();
if (target === input && input.readOnly) hideSearchSuggest();
focusRemoteTarget(target);
return true;
}
function isSearchSuggestOpen() {
const panel = $("suggestPanel");
return !!(panel && panel.classList.contains("open") && panel.querySelector(".suggest-item"));
}
function searchSuggestItems() {
const panel = $("suggestPanel");
return panel ? Array.from(panel.querySelectorAll(".suggest-item")).filter(isVisibleFocusable) : [];
}
function firstSearchSuggestItem() {
return searchSuggestItems()[0] || null;
}
function focusSearchSuggestTarget(target) {
if (!target) return;
try {
target.focus({ preventScroll: true });
} catch (e) {
target.focus();
}
keepSearchSuggestItemVisible(target);
}
function keepSearchSuggestItemVisible(target) {
const panel = $("suggestPanel");
if (!panel || !target || !panel.contains(target)) return;
const itemRect = target.getBoundingClientRect();
const panelRect = panel.getBoundingClientRect();
const topDelta = itemRect.top - panelRect.top;
const bottomDelta = itemRect.bottom - panelRect.bottom;
if (topDelta < 0) panel.scrollTop += topDelta;
else if (bottomDelta > 0) panel.scrollTop += bottomDelta;
}
function handleConnectionPanelBackKey(event) {
if (!isConnectionPanelOpen()) return false;
const active = document.activeElement;
if (isPanelTextField(active) && !active.readOnly) {
if (normalizeRemoteKey(event) === "Backspace") return false;
event.preventDefault();
event.stopPropagation();
if (event.stopImmediatePropagation) event.stopImmediatePropagation();
disablePanelTextEditing(active);
focusRemoteTarget(active);
return true;
}
event.preventDefault();
event.stopPropagation();
if (event.stopImmediatePropagation) event.stopImmediatePropagation();
closeConnectionPanel();
const toggle = $("connectionToggle");
if (toggle) requestAnimationFrame(() => focusRemoteTarget(toggle));
return true;
}
function handleConnectionPanelDirectionalKey(key, event) {
if (!isConnectionPanelOpen() || !["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(key)) return false;
const dock = $("connectionDock");
const body = $("connectionBody");
const toggle = $("connectionToggle");
if (!dock || !body) return false;
const active = document.activeElement;
const bodyItems = connectionPanelItems();
const inDock = active && dock.contains(active);
let target = null;
if (!inDock || active === toggle) {
target = key === "ArrowDown" ? bodyItems[0] || toggle : toggle || bodyItems[0];
} else if (body.contains(active)) {
target = nearestFocusableFromList(key, active, bodyItems);
if (!target && key === "ArrowUp") target = toggle || bodyItems[0];
if (!target) target = active;
} else {
target = bodyItems[0] || toggle;
}
event.preventDefault();
event.stopPropagation();
if (target) focusRemoteTarget(target);
return true;
}
function restoreConnectionPanelFocus() {
if (!isConnectionPanelOpen()) return false;
const dock = $("connectionDock");
const active = document.activeElement;
if (active && dock && dock.contains(active) && isVisibleFocusable(active)) return false;
const target = $("connectionToggle") || connectionPanelItems()[0];
if (!target) return false;
focusRemoteTarget(target);
return true;
}
function isConnectionPanelOpen() {
const dock = $("connectionDock");
const body = $("connectionBody");
return !!(dock && body && dock.classList.contains("open") && body.classList.contains("open"));
}
function connectionPanelItems() {
const body = $("connectionBody");
return body ? Array.from(body.querySelectorAll(".focusable,button,input,textarea")).filter(isVisibleFocusable) : [];
}
function nearestFocusableFromList(key, current, list) {
const items = (list || []).filter((item) => item && item !== current && isVisibleFocusable(item));
if (!current || !items.length) return null;
const from = center(current.getBoundingClientRect());
const vertical = key === "ArrowUp" || key === "ArrowDown";
const forward = key === "ArrowRight" || key === "ArrowDown";
let best = null;
let bestScore = Infinity;
for (const el of items) {
const to = center(el.getBoundingClientRect());
const main = vertical ? to.y - from.y : to.x - from.x;
const cross = vertical ? Math.abs(to.x - from.x) : Math.abs(to.y - from.y);
if (forward ? main <= 4 : main >= -4) continue;
const score = Math.abs(main) * 1.25 + cross * 1.9;
if (score < bestScore) {
best = el;
bestScore = score;
}
}
return best;
}
function focusPanTabFromResults() {
const tabs = $("panTabs");
if (!tabs) return false;
const target = findByDataset(tabs, "panType", state.pan.activeType) || tabs.querySelector(".chip.active") || tabs.querySelector(".chip");
if (!target) return false;
state.pan.focusMode = "tabs";
focusPanTarget(target);
return true;
}
function handlePanDirectionalKey(key, event) {
const tabs = $("panTabs");
const list = $("panResultList");
const active = document.activeElement;
if (!tabs || !list || !active) return false;
const inTabs = tabs.contains(active);
const inResults = list.contains(active);
if (!inTabs && !inResults) {
if ((active === $("detailContinueBtn") || active === $("panSearchBtn") || active === $("detailSearchBtn")) && key === "ArrowDown" && isPanSearchActive()) {
const target = tabs.querySelector(".chip.active") || tabs.querySelector(".chip") || list.querySelector(".pan-result-item");
if (target) {
event.preventDefault();
state.pan.focusMode = tabs.contains(target) ? "tabs" : "results";
focusPanTarget(target, { keepBlockPosition: true });
return true;
}
}
if (isPanSearchActive() && $("detailSheet") && $("detailSheet").contains(active)) {
const panRect = $("panSearchBlock").getBoundingClientRect();
const activeRect = active.getBoundingClientRect ? active.getBoundingClientRect() : null;
if (key === "ArrowDown" && activeRect && activeRect.bottom <= panRect.bottom + 8) {
event.preventDefault();
const target = tabs.querySelector(".chip.active") || tabs.querySelector(".chip") || list.querySelector(".pan-result-item");
if (target) {
state.pan.focusMode = tabs.contains(target) ? "tabs" : "results";
focusPanTarget(target, { keepBlockPosition: true });
} else {
centerPanSearchBlock();
}
return true;
}
}
state.pan.focusMode = "";
return false;
}
let target = null;
if (inTabs && key === "ArrowDown") target = panResultByColumn(active) || list.querySelector(".pan-result-item");
else if (inResults && key === "ArrowUp" && isFirstVisiblePanResult(active)) target = findByDataset(tabs, "panType", state.pan.activeType) || tabs.querySelector(".chip.active") || tabs.querySelector(".chip");
else if (inTabs && (key === "ArrowLeft" || key === "ArrowRight")) target = siblingFocusable(tabs, active, key === "ArrowRight" ? 1 : -1);
else if (inResults && (key === "ArrowUp" || key === "ArrowDown")) target = siblingFocusable(list, active, key === "ArrowDown" ? 1 : -1);
if (!target) {
if (inTabs && key === "ArrowDown" || inResults && key === "ArrowDown") {
event.preventDefault();
centerPanSearchBlock();
return true;
}
return false;
}
event.preventDefault();
if (list.contains(target)) {
state.pan.focusKey = target.dataset.panKey || state.pan.focusKey;
state.pan.focusMode = "results";
focusPanTarget(target, { keepBlockPosition: true });
} else {
state.pan.focusMode = "tabs";
const panType = getPanTypeFromElement(target);
if (panType && panType !== state.pan.activeType) selectPanType(panType);
focusPanTarget(target, { keepBlockPosition: true });
}
return true;
}
function isPanFocusTarget(target) {
return !!(target && (($("panTabs") && $("panTabs").contains(target)) || ($("panResultList") && $("panResultList").contains(target))));
}
function panResultByColumn(tab) {
const list = $("panResultList");
const items = list ? Array.from(list.querySelectorAll(".pan-result-item")).filter(isVisibleFocusable) : [];
if (!items.length) return null;
const from = center(tab.getBoundingClientRect());
let best = null;
let bestScore = Infinity;
for (const item of items.slice(0, 5)) {
const rect = item.getBoundingClientRect();
const to = center(rect);
if (to.y < from.y - 4) continue;
const score = Math.abs(to.x - from.x) * 1.15 + Math.max(0, to.y - from.y) * .35;
if (score < bestScore) {
best = item;
bestScore = score;
}
}
return best || items[0];
}
function handleDetailDirectionalKey(key, event) {
const sheet = $("detailSheet");
const active = document.activeElement;
if (!sheet || !sheet.classList.contains("active") || !active || !sheet.contains(active)) return false;
if (isPanSearchActive() && isPanFocusTarget(active)) return false;
const currentBlock = detailFocusBlock(active);
if (!currentBlock) return false;
let target = null;
if (key === "ArrowLeft" || key === "ArrowRight") target = detailHorizontalTarget(currentBlock, active, key === "ArrowRight" ? 1 : -1);
else target = detailVerticalTarget(currentBlock, active, key === "ArrowDown" ? 1 : -1);
if (!target) return false;
event.preventDefault();
event.stopPropagation();
state.pan.focusMode = "";
focusRemoteTarget(target);
return true;
}
function detailFocusBlock(el) {
if (el === $("closeDetailBtn")) return { id: "closeDetailBtn", root: $("closeDetailBtn") };
if (el === $("detailContinueBtn") || el === $("detailSearchBtn") || el === $("panSearchBtn")) return { id: "detailActions", root: el.closest(".actions") };
const ids = ["panSearchBlock", "seasonBlock", "castBlock", "personWorkBlock", "recommendBlock"];
for (const id of ids) {
const root = $(id);
if (root && root.contains(el)) return { id, root };
}
return null;
}
function detailBlockOrder() {
const order = [];
if (isVisibleFocusable($("closeDetailBtn"))) order.push({ id: "closeDetailBtn", root: $("closeDetailBtn") });
const info = $("detailText") && $("detailText").closest(".detail-info");
if (info && detailBlockFocusables(info).length) order.push({ id: "detailInfo", root: info });
const actions = $("detailSearchBtn") && $("detailSearchBtn").closest(".actions");
if (actions && detailBlockFocusables(actions).length) order.push({ id: "detailActions", root: actions });
["panSearchBlock", "seasonBlock", "castBlock", "personWorkBlock", "recommendBlock"].forEach((id) => {
const root = $(id);
if (root && root.style.display !== "none" && root.getAttribute("aria-hidden") !== "true" && detailBlockFocusables(root).length) order.push({ id, root });
});
return order;
}
function detailVerticalTarget(currentBlock, active, delta) {
const seasonTarget = currentBlock.id === "seasonBlock" ? detailSeasonVerticalTarget(active, delta) : null;
if (seasonTarget) return seasonTarget;
const order = detailBlockOrder();
const index = order.findIndex((block) => block.id === currentBlock.id);
if (index < 0) return null;
const blocks = delta > 0 ? order.slice(index + 1) : order.slice(0, index).reverse();
for (const block of blocks) {
const target = detailClosestInBlock(block.root, active, delta);
if (target) return target;
}
return null;
}
function detailHorizontalTarget(currentBlock, active, delta) {
const row = active && active.closest && active.closest("#seasonTabs, #episodeRail, #castRail, #personWorkRail, #recommendWorkRail, #panTabs, .actions");
const items = detailBlockFocusables(row || currentBlock.root);
const index = items.indexOf(active);
if (index < 0) return null;
return items[index + delta] || null;
}
function detailSeasonVerticalTarget(active, delta) {
const tabs = $("seasonTabs");
const rail = $("episodeRail");
if (tabs && tabs.contains(active)) {
if (delta < 0) return null;
return detailClosestInBlock(rail, active, delta);
}
if (rail && rail.contains(active)) {
if (delta > 0) return null;
return detailClosestInBlock(tabs, active, delta);
}
return null;
}
function detailClosestInBlock(root, active, delta) {
const items = detailBlockFocusables(root);
if (!items.length) return null;
if (!active || !active.getBoundingClientRect) return delta > 0 ? items[0] : items[items.length - 1];
const from = center(active.getBoundingClientRect());
let best = null;
let bestScore = Infinity;
for (const item of items) {
const rect = item.getBoundingClientRect();
const to = center(rect);
const vertical = Math.max(0, delta > 0 ? rect.top - from.y : from.y - rect.bottom);
const cross = Math.abs(to.x - from.x);
const score = vertical * 1.1 + cross * 1.5;
if (score < bestScore) {
best = item;
bestScore = score;
}
}
return best || (delta > 0 ? items[0] : items[items.length - 1]);
}
function detailBlockFocusables(root) {
if (!root) return [];
if (root.matches && root.matches(".focusable,button,input,textarea")) return isVisibleFocusable(root) ? [root] : [];
return Array.from(root.querySelectorAll(".focusable,button,input,textarea")).filter(isVisibleFocusable);
}
function isFirstVisiblePanResult(el) {
const list = $("panResultList");
if (!list || !list.contains(el)) return false;
return Array.from(list.querySelectorAll(".pan-result-item")).filter(isVisibleFocusable)[0] === el;
}
function siblingFocusable(root, current, delta) {
if (!root || !current) return null;
if (root.id === "panResultList" && current.classList && current.classList.contains("pan-result-item")) {
let target = delta > 0 ? current.nextElementSibling : current.previousElementSibling;
while (target) {
if (target.classList && target.classList.contains("pan-result-item") && isVisibleFocusable(target)) return target;
target = delta > 0 ? target.nextElementSibling : target.previousElementSibling;
}
return null;
}
const items = Array.from(root.querySelectorAll(".focusable,button,input,textarea")).filter(isVisibleFocusable);
const index = items.indexOf(current);
if (index < 0) return null;
return items[index + delta] || null;
}
function normalizeRemoteKey(event) {
if (["Enter", "Escape", "Backspace", "BrowserBack", "GoBack", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(event.key)) return event.key === "BrowserBack" || event.key === "GoBack" ? "Escape" : event.key;
const map = { 13: "Enter", 23: "Enter", 66: "Enter", 4: "Escape", 8: "Backspace", 27: "Escape", 10009: "Escape", 461: "Escape", 19: "ArrowUp", 20: "ArrowDown", 21: "ArrowLeft", 22: "ArrowRight" };
return map[event.keyCode || event.which] || "";
}
var _keepFocusRaf = 0;
function focusRemoteTarget(target) {
if (!target) return;
try {
target.focus({ preventScroll: true });
} catch (e) {
target.focus();
}
maybeAppendGridForFocus(target);
// TV 上 keepFocusInView 用 rAF 延迟执行,避免每次 keydown 同步强制回流
if (isTvMode() || isNativeLeanbackClient()) {
if (_keepFocusRaf) return; // 已有排队的 rAF,跳过叠加
_keepFocusRaf = requestAnimationFrame(function() {
_keepFocusRaf = 0;
keepFocusInView(document.activeElement);
});
} else {
keepFocusInView(target);
}
}
function keepFocusInView(target) {
if (!target) return;
// 详情页关闭动画中 / 详情激活时不干扰主页滚动位置
if (document.body.classList.contains("detail-active") || state.detailClosing) return;
const rect = target.getBoundingClientRect();
const height = window.innerHeight || document.documentElement.clientHeight || 0;
const width = window.innerWidth || document.documentElement.clientWidth || 0;
if (!height || !width) return;
// TV 端用更大边距(确保焦点不贴边),手机端用小边距
const margin = isTvMode() ? 80 : 54;
const hMargin = isTvMode() ? 60 : 8;
if (rect.top >= margin && rect.bottom <= height - margin &&
rect.left >= hMargin && rect.right <= width - hMargin) return;
// chips / sticky 元素在页面顶部,但用户已滚动下去时不强制跳回顶部
const scrollY = window.scrollY || document.documentElement.scrollTop || 0;
if (scrollY > 0 && rect.top < height * 0.3 && rect.bottom < height * 0.5) return;
try {
// TV 即时滚动更响应,手机平滑更舒适
const behavior = isTvMode() ? "instant" : "smooth";
target.scrollIntoView({ block: "nearest", inline: "nearest", behavior });
} catch (e) {
target.scrollIntoView(false);
}
}
function isVisibleFocusable(el) {
if (!el || !el.matches || !el.matches(".focusable,button,input,textarea")) return false;
if (el.disabled || el.getAttribute("tabindex") === "-1" || el.closest('[aria-hidden="true"]')) return false;
// Android 9 TVgetComputedStyle 极慢,改用 offsetParent + offsetWidth 快速判断
if (el.offsetWidth === 0 && el.offsetHeight === 0) return false;
if (el.offsetParent === null) {
// fixed/sticky 元素 offsetParent 为 null 但可能可见,补检 getClientRects
try { return el.getClientRects().length > 0; } catch(e) { return false; }
}
return true;
}
function rememberFocusReturn() {
const active = document.activeElement;
if (isVisibleFocusable(active)) state.focusReturnEl = active;
}
function restoreFocusReturn(fallback) {
const target = isVisibleFocusable(state.focusReturnEl) ? state.focusReturnEl : fallback || firstContentFocus();
state.focusReturnEl = null;
if (target) requestAnimationFrame(() => focusRemoteTarget(target));
}
// TV 性能优化:每帧只计算一次可聚焦列表,避免重复 querySelectorAll + getBoundingClientRect
var _focusListCache = null;
var _focusListFrame = -1;
var _focusListScopeId = "";
var _rafFrameCount = 0;
// 每帧递增帧计数,作为缓存key
(function tickFocusCache() {
_rafFrameCount++;
// 帧变化时清除缓存(而非主动清空列表,避免浪费)
_focusListFrame = -1; // 标记需要重新计算
requestAnimationFrame(tickFocusCache);
})();
function visibleFocusable() {
var root = focusScopeRoot();
var scopeId = root === document ? "doc" : (root.id || "root");
// 同一帧、同一scope直接复用缓存
var currentFrame = _rafFrameCount;
if (_focusListCache && _focusListFrame === currentFrame && _focusListScopeId === scopeId) {
return _focusListCache;
}
_focusListCache = Array.from(root.querySelectorAll(".focusable,button,input,textarea"))
.filter(isVisibleFocusable);
_focusListFrame = currentFrame;
_focusListScopeId = scopeId;
return _focusListCache;
}
// 布局变化时主动清除缓存(强制下次重算)
function invalidateFocusListCache() { _focusListFrame = -1; _focusListCache = null; }
function focusScopeRoot() {
if ($("imageViewer") && $("imageViewer").classList.contains("active")) return $("imageViewer");
if ($("syncSheet") && $("syncSheet").classList.contains("active")) return $("syncSheet");
if ($("detailSheet") && $("detailSheet").classList.contains("active")) return $("detailSheet");
return document;
}
// ── TV grid 快速导航:焦点在 .media-grid 内 card 时,通过 data-card-index 直接计算目标
// 避免每次方向键全局 querySelectorAll + getBoundingClientRect
function tryGridCardFastNav(key, event) {
const active = document.activeElement;
if (!active || !active.dataset || active.dataset.cardIndex === undefined) return false;
const grid = active.closest && active.closest(".media-grid");
if (!grid) return false;
const currentIndex = parseInt(active.dataset.cardIndex, 10);
if (isNaN(currentIndex)) return false;
const cols = gridColumns(grid);
let targetIndex = -1;
if (key === "ArrowRight") targetIndex = currentIndex + 1;
else if (key === "ArrowLeft") targetIndex = currentIndex - 1;
else if (key === "ArrowDown") targetIndex = currentIndex + cols;
else if (key === "ArrowUp") {
targetIndex = currentIndex - cols;
// 第一行向上 → 跳出到 chips
if (targetIndex < 0) {
const chip = document.querySelector("#chips .chip.active") || document.querySelector("#chips .chip");
if (chip && isVisibleFocusable(chip)) {
event.preventDefault();
focusRemoteTarget(chip);
return true;
}
return false;
}
}
if (targetIndex < 0) return false;
// 右边界:不能越过同行末尾
if (key === "ArrowRight" && Math.floor(targetIndex / cols) !== Math.floor(currentIndex / cols)) return false;
if (key === "ArrowLeft" && Math.floor(targetIndex / cols) !== Math.floor(currentIndex / cols)) return false;
const targetCard = grid.querySelector(`[data-card-index="${targetIndex}"]`);
if (!targetCard || !isVisibleFocusable(targetCard)) {
// 目标不存在可能需要追加 → 让 appendGridBatch 先补充再重试
if (key === "ArrowDown" && targetIndex >= parseInt(grid.children.length, 10)) {
maybeAppendGridForFocus(active);
}
return false;
}
event.preventDefault();
maybeAppendGridForFocus(targetCard);
focusRemoteTarget(targetCard);
return true;
}
function nearestFocusable(key, fromEl) {
const active = document.activeElement;
const current = fromEl || (isVisibleFocusable(active) ? active : null);
const list = visibleFocusable();
if (!list.length) return null;
if (!current) return firstContentFocus() || list[0];
const from = center(current.getBoundingClientRect());
const vertical = key === "ArrowUp" || key === "ArrowDown";
const forward = key === "ArrowRight" || key === "ArrowDown";
let best = null;
let bestScore = Infinity;
for (const el of list) {
if (el === current) continue;
const to = center(el.getBoundingClientRect());
const main = vertical ? to.y - from.y : to.x - from.x;
const cross = vertical ? Math.abs(to.x - from.x) : Math.abs(to.y - from.y);
if (forward ? main <= 4 : main >= -4) continue;
const score = Math.abs(main) * 1.25 + cross * 1.9;
if (score < bestScore) {
best = el;
bestScore = score;
}
}
return best;
}
function firstContentFocus() {
const root = focusScopeRoot();
const selectors = ["#chips .chip.active", "#chips .chip", "#recommendRail .card", "#listStack .card", "#searchRail .card", ".focusable:not(#searchInput)", "#searchInput"];
for (const selector of selectors) {
const target = Array.from(root.querySelectorAll(selector)).find(isVisibleFocusable);
if (target) return target;
}
return null;
}
function center(rect) {
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
}
function uiSnapshotRoute() {
if ($("imageViewer") && $("imageViewer").classList.contains("active")) return "image";
if ($("syncSheet") && $("syncSheet").classList.contains("active")) return "sync";
if ($("detailSheet") && $("detailSheet").classList.contains("active")) return "detail";
return "home";
}
function compactPanResults(items) {
return (items || []).slice(0, 400).map((item) => ({
key: item.key,
diskType: item.diskType,
url: item.url,
normalizedUrl: item.normalizedUrl || "",
password: item.password || "",
title: item.title || "",
source: item.source || "",
datetime: item.datetime || "",
index: item.index || 0
}));
}
function buildUiSnapshot() {
const panList = $("panResultList");
const detail = $("detailSheet");
return {
version: 1,
savedAt: Date.now(),
route: uiSnapshotRoute(),
hash: location.hash || "",
activeList: state.activeList,
scrollY: Math.round(window.scrollY || document.documentElement.scrollTop || document.body.scrollTop || 0),
connectionOpen: !!($("connectionDock") && $("connectionDock").classList.contains("open")),
selected: state.selected ? normalizeSnapshot(state.selected) : null,
detailScrollTop: detail ? Math.round(detail.scrollTop || 0) : 0,
imageUrl: $("viewerImage") && $("viewerImage").style.display !== "none" ? $("viewerImage").getAttribute("src") || "" : "",
detailReturn: state.detailReturn || null,
pan: {
visible: !!($("panSearchBlock") && $("panSearchBlock").classList.contains("active")),
keyword: state.pan.keyword || "",
activeType: state.pan.activeType || "",
results: compactPanResults(state.pan.results),
health: state.pan.health || {},
listScrollTop: panList ? Math.round(panList.scrollTop || 0) : 0,
focusKey: state.pan.focusKey || "",
focusMode: state.pan.focusMode || "",
playbackReturn: state.pan.playbackReturn || null
}
};
}
function scheduleUiSnapshotSave() {
clearTimeout(scheduleUiSnapshotSave.timer);
scheduleUiSnapshotSave.timer = setTimeout(saveUiSnapshotNow, 260);
}
async function saveUiSnapshotNow() {
try {
await sdk().cache.set(cacheKey("ui"), JSON.stringify(buildUiSnapshot()));
} catch (e) {}
}
async function readUiSnapshot() {
try {
const snapshot = safeJson(await sdk().cache.get(cacheKey("ui")), null);
if (!snapshot || snapshot.version !== 1) return null;
if (Date.now() - Number(snapshot.savedAt || 0) > UI_SNAPSHOT_TTL_MS) return null;
return snapshot;
} catch (e) {
return null;
}
}
function shouldRestoreUiSnapshot() {
try {
return new URLSearchParams(location.search || "").get("_fm_restore") === "1";
} catch (e) {
return /(?:^|[?&])_fm_restore=1(?:&|$)/.test(location.search || "");
}
}
function restorePanSnapshot(pan) {
if (!pan || !pan.keyword && !(pan.results || []).length) return;
state.pan.loading = false;
state.pan.keyword = pan.keyword || "";
state.pan.activeType = pan.activeType || "";
state.pan.results = Array.isArray(pan.results) ? pan.results.map((item, index) => Object.assign({ index }, item)) : [];
state.pan.health = pan.health && typeof pan.health === "object" ? pan.health : {};
state.pan.focusKey = pan.focusKey || "";
state.pan.focusMode = pan.focusMode || "";
state.pan.playbackReturn = pan.playbackReturn || null;
state.pan.pending = {};
state.pan.queued.clear();
state.pan.inFlight.clear();
state.pan.renderKeys = "";
state.pan.viewToken = `restore-${Date.now()}`;
if ($("panSearchBlock")) {
$("panSearchBlock").classList.add("active");
$("panSearchBlock").style.display = "";
}
renderPanResults();
updatePostPanFocusState();
requestAnimationFrame(() => {
if ($("panResultList")) $("panResultList").scrollTop = Number(pan.listScrollTop || 0);
});
}
function restoreSearchSnapshot() {
if ($("searchInput")) $("searchInput").value = "";
state.searchItems = [];
hideSearchSuggest();
renderSearch();
}
async function restoreUiSnapshot(snapshot) {
if (!snapshot) return false;
state.detailReturn = snapshot.detailReturn || null;
restoreSearchSnapshot(snapshot.search);
if (snapshot.activeList) state.activeList = snapshot.activeList;
normalizeActiveListForViewport();
renderAll({ deferContent: snapshot.route === "home" });
if (snapshot.connectionOpen && $("connectionDock") && $("connectionBody")) {
$("connectionDock").classList.add("open");
$("connectionBody").classList.add("open");
fitConnectionPanel();
}
if ((snapshot.route === "detail" || snapshot.route === "image") && snapshot.selected) {
if (location.hash !== "#detail" && snapshot.route === "detail") history.replaceState({ sheet: "detail" }, "", "#detail");
openDetail(snapshot.selected, { restore: true, skipHistory: true });
restorePanSnapshot(snapshot.pan);
// 若快照包含盘搜且盘搜 block 已激活,补推 #pan 条目保持返回栈完整
if (snapshot.pan && snapshot.pan.visible && $("panSearchBlock") && $("panSearchBlock").classList.contains("active")) {
if (location.hash !== "#pan") history.pushState({ sheet: "pan" }, "", "#pan");
}
requestAnimationFrame(() => {
if ($("detailSheet")) $("detailSheet").scrollTop = Number(snapshot.detailScrollTop || 0);
});
if (snapshot.route === "image" && snapshot.imageUrl) {
history.replaceState({ sheet: "image" }, "", "#image");
openImage(snapshot.imageUrl, { restore: true, skipHistory: true });
}
} else if (snapshot.route === "sync") {
history.replaceState({ sheet: "sync" }, "", "#sync");
openSync({ restore: true, skipHistory: true });
} else {
// 主页路由:不强制恢复滚动,让浏览器自行管理
}
return true;
}
function scheduleWebHomeResume(options) {
const opts = options || {};
clearTimeout(state.resume.timer);
state.resume.timer = setTimeout(() => handleWebHomeResume(opts), opts.delay == null ? 80 : opts.delay);
}
function handleWebHomeResume(options) {
const opts = options || {};
const now = Date.now();
if (!opts.force && now - Number(state.resume.lastAt || 0) < 650) return;
state.resume.lastAt = now;
const isPanPlaybackReturn = !!(state.pan.playbackReturn && $("detailSheet") && $("detailSheet").classList.contains("active") && $("panSearchBlock") && $("panSearchBlock").classList.contains("active"));
lockViewportWidth();
fitConnectionPanel();
renderConnection();
if (!isPanPlaybackReturn) {
if ($("detailSheet") && $("detailSheet").classList.contains("active") && state.selected) refreshActiveDetailView();
if ($("panSearchBlock") && $("panSearchBlock").classList.contains("active")) renderPanResults();
renderAll({ deferContent: uiSnapshotRoute() === "home" });
normalizeRails();
}
if (!restorePanPlaybackReturn({ skipRender: isPanPlaybackReturn })) restoreDetailReturn();
updateBackTopButton();
scheduleHistorySettlement();
loadRecentList({ refresh: true, silent: true }).catch(() => {});
sampleWatchStatus();
if (!isPanPlaybackReturn) {
const home = $("home");
if (home) {
home.style.transform = "translateZ(0)";
requestAnimationFrame(() => home.style.transform = "");
}
}
scheduleUiSnapshotSave();
}
function refreshActiveDetailView() {
if (state.detail && state.selected) {
renderDetailExtras(state.selected, state.detail);
return;
}
if (state.detailCover.images && state.detailCover.images.length) {
updateDetailCoverControls();
syncDetailCoverFrame($("detailImage"));
return;
}
renderDetailBase(state.selected);
}
function safeJson(text, fallback) {
try { return JSON.parse(text || ""); } catch (e) { return fallback; }
}
function escapeHtml(value) {
return String(value || "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
}
function escapeAttr(value) {
return escapeHtml(value).replace(/`/g, "&#96;");
}
function shortKey(value) {
if (!value) return "";
return value.length > 14 ? value.slice(0, 8) + "..." + value.slice(-4) : value;
}
function toast(message) {
const el = $("toast");
el.textContent = message || "";
el.classList.add("show");
clearTimeout(toast.timer);
toast.timer = setTimeout(() => el.classList.remove("show"), 2200);
}
let _backTopBtnEl = null;
let _backTopShown = null;
function updateBackTopButton() {
if (!_backTopBtnEl) _backTopBtnEl = $("backTopBtn");
if (!_backTopBtnEl) return;
const top = window.scrollY || document.documentElement.scrollTop || document.body.scrollTop || 0;
const show = top > window.innerHeight;
if (show === _backTopShown) return; // 状态没变就不写 DOM
_backTopShown = show;
_backTopBtnEl.classList.toggle("show", show);
}
// ── 主页哨兵:确保 history stack 始终有一个"主页"条目
// 这样从任何二级页面 history.back() 都只是关闭 sheet,不会退到 App 外
function ensureHomeHistoryEntry() {
if (!location.hash) {
const _sy = Math.round(window.scrollY || document.documentElement.scrollTop || 0);
history.pushState({ sheet: "home" }, "", location.pathname + location.search);
if (_sy > 0) {
window.scrollTo(0, _sy);
document.documentElement.scrollTop = _sy;
document.body.scrollTop = _sy;
}
}
}
// ── 搜索板块状态判断与整体关闭(对齐 Eclipse:以「可见状态」为准,一次返回关全部)──
// 搜索 UI 是否处于活动状态:聚焦态 / 联想框 / 结果列表,任一即算开启。
function _isSearchUiOpen() {
return document.documentElement.classList.contains("search-focused")
|| document.documentElement.classList.contains("search-active")
|| isSearchSuggestOpen()
|| !!(state.searchItems && state.searchItems.length);
}
// 一次性关闭所有搜索相关 UI(聚焦态 + 联想框 + 结果列表),复原主页布局。
// 不在此处操作 history —— 历史栈由 popstate 处理器统一管理(_afterCloseStay 补哨兵)。
function _closeAllSearchUi() {
document.documentElement.classList.remove("search-focused");
document.documentElement.classList.remove("search-active");
hideSearchSuggest();
disableSearchEditing();
const input = $("searchInput");
if (input) { input.value = ""; input.blur(); }
state.searchItems = [];
renderSearch(); // 依据空结果复原 recommendSection/listStack/chips 被折叠的高度
scheduleUiSnapshotSave();
}
// 关闭某一层后:若仍有更底层 sheet 打开则保持现状;否则回到主页补哨兵、保持全屏。
// 对齐 Eclipse 的 _afterCloseStay —— 「其它任何返回只关闭当前层、保持全屏」。
function _afterCloseStay() {
if (_anySheetActive()) return;
if (!location.hash) ensureHomeHistoryEntry();
}
window.addEventListener("popstate", (event) => {
// 正在关闭动画中,忽略重复触发
const isClosing = detailSheet && detailSheet.classList.contains("sheet-closing");
if (isClosing) return;
// 盘搜层:用户从 #pan back 到 #detail → 只关闭盘搜 block,留在详情页
// 用 _panHistoryPushed 标记确认 #pan 条目确实推入过(避免误判)
if (location.hash === "#detail" && detailSheet && detailSheet.classList.contains("active") && isPanSearchActive() && _panHistoryPushed) {
_panHistoryPushed = false;
resetPanSearchState(true);
requestAnimationFrame(() => focusRemoteTarget($("panSearchBtn") || $("detailSearchBtn") || $("closeDetailBtn")));
return;
}
// 二级页面关闭逻辑:依次检查,有 active 就关闭
if (location.hash !== "#image" && $("imageViewer").classList.contains("active")) {
closeImage(true);
return;
}
if (location.hash !== "#detail" && detailSheet && detailSheet.classList.contains("active")) {
// 系统返回键路径:history.back() 已发生,scrollY 可能已被重置为 0
// 先暂存(可能是 0),closeDetail 动画结束后再还原
_pendingHomeScrollY = _pendingHomeScrollY || Math.round(window.scrollY || document.documentElement.scrollTop || 0);
closeDetail(true);
return;
}
if (location.hash !== "#sync" && $("syncSheet").classList.contains("active")) {
closeSync(true);
return;
}
// 搜索板块(聚焦态 / 联想框 / 结果列表 共用同一 #search 历史层):
// 一次返回整体关闭,回到主页推荐,并保持全屏、留在 App(对齐 Eclipse)。
if (location.hash !== "#search" && _isSearchUiOpen()) {
_searchHistoryPushed = false;
_closeAllSearchUi();
_afterCloseStay();
requestAnimationFrame(() => focusInitialHomeNow());
return;
}
// 主页面:所有 sheet 都已关闭。
// hash 为空说明用户 back 到了主页根路由,推入新哨兵,防止再 back 退出 App
if (!location.hash && !_anySheetActive()) {
ensureHomeHistoryEntry();
}
});
window.addEventListener("fmviewport", () => {
syncClientModeClasses();
document.body.style.minHeight = getComputedStyle(document.documentElement).getPropertyValue("--fm-web-height");
lockViewportWidth();
fitConnectionPanel();
const detailLayoutChanged = syncDetailLayout();
if (detailLayoutChanged && $("detailSheet") && $("detailSheet").classList.contains("active") && state.selected) refreshActiveDetailView();
renderAll({ deferContent: uiSnapshotRoute() === "home" });
scheduleDetailTextClamp();
scheduleHistorySettlement();
});
// ── 移动端键盘适配:弹出键盘时让 sheet 底部预留空间,
// 并把弹层(.sheet)内聚焦的输入框滚动到键盘上方,避免被遮挡。
// 注意:只在 focusin 时触发一次定位,不监听 visualViewport 的
// scroll 事件,避免普通滑动页面时反复触发 scrollIntoView 导致
// 页面乱跳、滑动不跟手。 ──
(function setupKeyboardAvoidance() {
const vv = window.visualViewport;
if (!vv) return;
function isTextField(el) {
if (!el) return false;
if (el.tagName === "TEXTAREA") return true;
if (el.tagName === "INPUT") {
const type = String(el.getAttribute("type") || "text").toLowerCase();
return !["button", "checkbox", "radio", "submit", "reset", "range", "color"].includes(type);
}
return false;
}
function updateKeyboardHeight() {
const kb = Math.max(0, window.innerHeight - vv.height);
document.documentElement.style.setProperty("--keyboard-height", (kb > 60 ? kb : 0) + "px");
return kb;
}
vv.addEventListener("resize", updateKeyboardHeight);
document.addEventListener("focusin", (event) => {
const target = event.target;
// 弹层(.sheet) 与状态面板(#connectionBody) 内的输入框都需要避让键盘
if (!isTextField(target) || !target.closest(".sheet, #connectionBody")) return;
// 延迟等待键盘动画完成后再定位,避免和滚动/点击手势打架
setTimeout(() => {
if (document.activeElement !== target) return;
const kb = updateKeyboardHeight();
if (kb > 60) target.scrollIntoView({ block: "center", behavior: "smooth" });
}, 320);
});
})();
let _resizeTimer = 0;
window.addEventListener("resize", () => {
// lockViewportWidth 立即执行避免布局抖动,其他操作防抖
lockViewportWidth();
clearTimeout(_resizeTimer);
_resizeTimer = setTimeout(() => {
fitConnectionPanel();
const detailLayoutChanged = syncDetailLayout();
if (detailLayoutChanged && $("detailSheet") && $("detailSheet").classList.contains("active") && state.selected) refreshActiveDetailView();
renderAll({ deferContent: uiSnapshotRoute() === "home" });
scheduleDetailTextClamp();
}, 120);
});
let _scrollRafPending = false;
window.addEventListener("scroll", () => {
state.scrollingUntil = Date.now() + 900;
// 无 IntersectionObserver 时的兜底无限滚动:保持原逻辑、同步执行,不进 rAF
if (!("IntersectionObserver" in window)) {
const doc = document.documentElement;
if ((window.scrollY || doc.scrollTop || 0) + window.innerHeight >= doc.scrollHeight - 900) {
clearTimeout(state.scrollLoadTimer);
state.scrollLoadTimer = setTimeout(loadMoreVisible, 80);
}
}
// 其余轻量工作合并到一帧里最多执行一次
if (_scrollRafPending) return;
_scrollRafPending = true;
requestAnimationFrame(() => {
_scrollRafPending = false;
updateBackTopButton();
LongPressMenu.destroy();
scheduleUiSnapshotSave();
});
}, { passive: true });
window.addEventListener("pagehide", () => {
saveUiSnapshotNow();
clearRelayBackfillTimers();
pauseDetailCoverCarousel();
stopWatchTracking(true);
});
window.addEventListener("pageshow", () => {
const panWasPlaying = localStorage.getItem("fm_pan_playing") === "1";
if (panWasPlaying) {
localStorage.removeItem("fm_pan_playing");
state.pan.isPlaying = false;
settlePanWatch();
// 从播放器返回:保持详情页打开
return;
}
scheduleWebHomeResume();
});
window.addEventListener("fmsdk", () => {
initPanConfig({ preserveDirty: true, timeout: 0 })
.then(loadInfo)
.then(() => scheduleWebHomeResume({ force: true }))
.catch(() => {});
});
window.addEventListener("fmresume", () => {
const panWasPlaying = localStorage.getItem("fm_pan_playing") === "1";
if (panWasPlaying) {
localStorage.removeItem("fm_pan_playing");
state.pan.isPlaying = false;
settlePanWatch();
// 从播放器返回:保持详情页打开
return;
}
loadInfo().finally(() => scheduleWebHomeResume());
});
window.addEventListener("fmpause", saveUiSnapshotNow);
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") {
saveUiSnapshotNow();
state.detailCover.swipe = null;
pauseDetailCoverCarousel();
}
if (document.visibilityState === "visible") {
restartDetailCoverCarousel();
updateDetailCoverControls();
syncDetailCoverFrame($("detailImage"));
// 从盘搜播放器返回检测:用 localStorage 而非内存(WebView 挂起后内存丢失)
const panWasPlaying = localStorage.getItem("fm_pan_playing") === "1";
if (panWasPlaying) {
localStorage.removeItem("fm_pan_playing");
state.pan.isPlaying = false;
settlePanWatch();
// 从播放器返回:保持详情页打开
return;
}
scheduleWebHomeResume();
}
});
function lockViewportWidth() {
const width = Math.floor(window.innerWidth || document.documentElement.clientWidth || 0);
if (!width) return;
document.documentElement.style.width = width + "px";
document.body.style.width = width + "px";
document.body.style.maxWidth = width + "px";
}
// TV 端:页面初始化后强制加载所有可见图片(含 rail 内的 poster
function tvForceLoadImages() {
if (!isTvMode() && !isNativeLeanbackClient()) return;
document.querySelectorAll("img[src]").forEach(function(img) {
if (!img.complete || img.naturalWidth === 0) {
var src = img.getAttribute("src");
if (src) {
img.removeAttribute("loading");
img.decoding = "async"; // sync 会阻塞主线程导致卡顿,用 async
img.src = src;
}
}
});
}
async function boot() {
lockViewportWidth();
bindActions();
installRemoteKeys();
// 确保主页始终有一个 history 哨兵条目,防止返回键直接退出 App
if (history.scrollRestoration) history.scrollRestoration = "manual";
if (!location.hash) ensureHomeHistoryEntry();
// 涟漪效果已移除
// ── 手机端:滚动时 chip 栏自动居中选中项 ──
(function setupChipScroll() {
const chips = $("chips");
if (!chips) return;
chips.addEventListener("click", function(e) {
const chip = e.target.closest(".chip");
if (!chip) return;
chip.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "center" });
});
})();
// 长按加速已移除:改用 installRemoteKeys 里的 shouldThrottleRemoteNav 节流,
// 焦点逐格移动、不再冲过头(TV 端交互对齐官方 demo)。
await initTmdbConfig();
await initUiPrefs({ timeout: 0 });
await initPanConfig();
setupRelayMirrorDefaults();
renderConnection();
hotLoadIndex().catch(() => {});
await loadInfo();
await loadCatalog();
const canRestoreUi = shouldRestoreUiSnapshot();
const restored = canRestoreUi ? await restoreUiSnapshot(await readUiSnapshot()) : false;
await ensureIdentity();
await loadEvents();
scheduleHistorySettlement();
setTimeout(subscribeNostr, 600);
renderMetrics();
if (restored) scheduleWebHomeResume({ force: true });
if (!canRestoreUi) scheduleUiSnapshotSave();
updateBackTopButton();
setTimeout(uiSnapshotRoute() === "home" ? focusInitialHomeNow : ensureRemoteInitialFocus, 80);
if (uiSnapshotRoute() === "home") setTimeout(focusInitialHomeNow, 260);
// TV 端:启动完成后分两批强制加载图片,应对 WebView 不触发 lazy load 的情况
if (isTvMode() || isNativeLeanbackClient()) {
setTimeout(tvForceLoadImages, 400);
setTimeout(tvForceLoadImages, 1800);
}
}
// 根据 chromeMode 调整顶部安全区:有原生 Toolbar 时归零,全屏时用状态栏高度
function _applySafeTop(mode) {
const hasToolbar = mode === "normal" || mode === "tv-normal" || mode === "tv-overlay";
document.documentElement.style.setProperty(
"--safe-t",
hasToolbar ? "0px" : "max(var(--fm-safe-top,0px),env(safe-area-inset-top,0px))"
);
}
window.addEventListener("fmviewport", function(e) {
_applySafeTop(((e.detail) || {}).chromeMode || "");
});
// 初始化时主动读一次,避免首帧 fmviewport 尚未触发时顶部间距偏大
(function() {
if (!window.fm) return;
try {
fm.ui.getViewport().then(function(vp) { _applySafeTop((vp || {}).chromeMode || ""); }).catch(function() {});
} catch(e) {}
})();
boot();
// 禁止双指缩放(pinch-zoom)及双击缩放
document.addEventListener("touchstart", function(e) {
if (e.touches.length > 1) e.preventDefault();
}, { passive: false });
document.addEventListener("touchmove", function(e) {
if (e.touches.length > 1) e.preventDefault();
}, { passive: false });
let _lastTouchEnd = 0;
document.addEventListener("touchend", function(e) {
const now = Date.now();
if (now - _lastTouchEnd <= 300) e.preventDefault();
_lastTouchEnd = now;
}, { passive: false });
document.addEventListener("gesturestart", function(e) {
e.preventDefault();
});
</script></body></html>