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

11523 lines
441 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, 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;
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;
}
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;
}
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, env(safe-area-inset-top)) 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 把按钮拉伸成整行大方块。 */
.actions.has-continue .btn {
white-space: nowrap;
}
.actions.has-continue #detailContinueBtn {
color: #fff;
background:
radial-gradient(circle at 24% 20%, rgba(255, 255, 255, .34), rgba(255, 255, 255, 0) 34%),
linear-gradient(135deg, #12d7ff 0%, #0b91ff 58%, #0867f7 100%);
border-color: rgba(255, 255, 255, .16);
box-shadow: 0 16px 30px rgba(0, 128, 255, .28), inset 0 1px 0 rgba(255, 255, 255, .26);
font-weight: 800;
text-shadow: none;
}
.actions.has-continue #detailSearchBtn,
.actions.has-continue #panSearchBtn {
/* 实际背景被 Liquid-Glass 的 var(--lg-base)!important 接管成深色玻璃,
故文字用白色才可读(原 #111827 深色字会与深玻璃糊在一起看不清)。 */
color: #fff;
background: rgba(255, 255, 255, .98);
border-color: rgba(255, 255, 255, .72);
box-shadow: 0 14px 28px rgba(0, 0, 0, .18), inset 0 1px 0 rgba(255, 255, 255, .72);
font-weight: 800;
text-shadow: 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: var(--radius);
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 12px;
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));
}
/* 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.80) 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; }
/* Blur overlay on lower half — gives text area a frosted glass feel */
.sheet-blur-layer {
display: none;
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
backdrop-filter: blur(18px) saturate(1.05);
-webkit-backdrop-filter: blur(18px) saturate(1.05);
/* mask: clear at top, blurry from 45% down */
-webkit-mask-image: linear-gradient(180deg, transparent 0%, transparent 71%, black 81%);
mask-image: linear-gradient(180deg, transparent 0%, transparent 71%, black 81%);
}
.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;
}
.detail-meta {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin: 8px 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;
background: transparent;
color: var(--text);
text-align: center;
}
.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));
}
.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-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;
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 加呼吸灯动画 — 方案 A 同心光晕扩散 */
.connection-toggle .dot {
width: 8px;
height: 8px;
position: relative;
animation: none;
}
.connection-toggle .dot::before,
.connection-toggle .dot::after {
content: '';
position: absolute;
border-radius: 50%;
top: 50%; left: 50%;
transform: translate(-50%, -50%) scale(0);
opacity: 0;
pointer-events: none;
}
.connection-toggle .dot.ok {
background: #4ade80;
animation: dot-glow-core 2s ease-in-out infinite;
}
.connection-toggle .dot.ok::before {
width: 18px; height: 18px;
background: rgba(74, 222, 128, .22);
animation: dot-glow-ring1 2s ease-out infinite;
}
.connection-toggle .dot.ok::after {
width: 28px; height: 28px;
background: rgba(74, 222, 128, .10);
animation: dot-glow-ring2 2s ease-out .15s infinite;
}
.connection-toggle .dot.warn {
background: #fbbf24;
animation: dot-glow-core 2s ease-in-out infinite;
}
.connection-toggle .dot.warn::before {
width: 18px; height: 18px;
background: rgba(251, 191, 36, .22);
animation: dot-glow-ring1 2s ease-out infinite;
}
.connection-toggle .dot.warn::after {
width: 28px; height: 28px;
background: rgba(251, 191, 36, .10);
animation: dot-glow-ring2 2s ease-out .15s infinite;
}
.connection-toggle .dot.bad {
background: #fb7185;
animation: dot-glow-core 2s ease-in-out infinite;
}
.connection-toggle .dot.bad::before {
width: 18px; height: 18px;
background: rgba(251, 113, 133, .22);
animation: dot-glow-ring1 2s ease-out infinite;
}
.connection-toggle .dot.bad::after {
width: 28px; height: 28px;
background: rgba(251, 113, 133, .10);
animation: dot-glow-ring2 2s ease-out .15s infinite;
}
@keyframes dot-glow-core {
0%, 100% { opacity: .7; transform: scale(.88); }
50% { opacity: 1; transform: scale(1.12); }
}
@keyframes dot-glow-ring1 {
0% { opacity: 0; transform: translate(-50%, -50%) scale(.5); }
60% { opacity: 1; }
100% { opacity: 0; transform: translate(-50%, -50%) scale(1); }
}
@keyframes dot-glow-ring2 {
0% { opacity: 0; transform: translate(-50%, -50%) scale(.5); }
60% { opacity: .7; }
100% { opacity: 0; transform: translate(-50%, -50%) scale(1); }
}
@keyframes pulse-dot-glow {
0%, 100% { opacity: .55; transform: scale(.9); box-shadow: 0 0 3px 1px rgba(226,234,242,.25); }
50% { opacity: 1; transform: scale(1.1); box-shadow: 0 0 8px 3px rgba(226,234,242,.55), 0 0 16px 5px rgba(226,234,242,.18); }
}
.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, env(safe-area-inset-top)) 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.80) 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,
.connection-toggle:focus {
transform: translateY(-1px);
background: rgba(70, 85, 96, .72);
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{
--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, env(safe-area-inset-top)) 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;
}
/* 大屏模式:剧照框同样隐藏,只用全屏背景轮播 */
#detailSheet.detail-large .detail-cover {
display: none !important;
}
/* 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%; }
/* 返回按钮:与其他操作按钮统一样式,位于 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 {
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">
/* ── iOS 26 Liquid Glass — 核心变量 ── */
:root {
--lg-base: rgba(255,255,255,.08);
--lg-border: rgba(255,255,255,.14);
--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: 0 4px 24px rgba(0,0,0,.22), 0 1px 0 rgba(255,255,255,.12) inset;
--lg-shadow-raised: 0 8px 32px rgba(0,0,0,.30), 0 1.5px 0 rgba(255,255,255,.18) inset;
--lg-radius-btn: 20px;
--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;
}
/* 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 呼吸光晕(已合并到下方主规则)── */
/* 内高光条 — 顶部玻璃折射感 */
.btn::before,
.icon-btn::before,
.mini-icon-btn::before,
.connection-toggle::before,
.back-top::before {
content: "" !important;
display: block !important;
position: absolute !important;
inset: 0 !important;
border-radius: inherit !important;
background: linear-gradient(
160deg,
rgba(255,255,255,.18) 0%,
rgba(255,255,255,.06) 38%,
transparent 60%
) !important;
pointer-events: none !important;
z-index: 1 !important;
overflow: hidden !important; /* 高光层自身裁剪,不依赖父容器 overflow */
}
/* 确保内容在高光层之上 */
.btn > *,
.icon-btn > *,
.mini-icon-btn > *,
.connection-toggle > *,
.back-top > * {
position: relative;
z-index: 2;
}
/* Hover — 玻璃微微提亮 */
.btn:hover,
.icon-btn:hover,
.mini-icon-btn:hover,
.connection-toggle:hover,
.back-top:hover {
background: rgba(255,255,255,.13) !important;
border-color: rgba(255,255,255,.22) !important;
box-shadow: var(--lg-shadow-raised) !important;
transform: translateY(-1px) !important;
}
/* Focus / Active — 下压弹动 */
.btn:focus,
.icon-btn:focus,
.mini-icon-btn:focus,
.chip:focus,
.connection-toggle:focus,
.back-top:focus {
background: rgba(255,255,255,.16) !important;
border-color: rgba(140,200,255,.55) !important;
box-shadow: 0 0 0 2px rgba(100,180,255,.30), var(--lg-shadow-raised) !important;
transform: translateY(-1px) !important;
outline: none !important;
}
.btn:active,
.icon-btn:active,
.mini-icon-btn:active,
.chip:active,
.connection-toggle:active,
.back-top:active {
background: rgba(255,255,255,.06) !important;
box-shadow: var(--lg-shadow) !important;
transform: translateY(1px) scale(.98) !important;
transition: all .08s ease !important;
}
/* 主要/选中态 — 淡玻璃白,不再深蓝色 */
.btn.primary,
.btn.blue {
background: rgba(80,160,255,.16) !important;
border-color: rgba(120,190,255,.32) !important;
box-shadow: 0 4px 20px rgba(80,140,255,.20), 0 1px 0 rgba(255,255,255,.18) inset !important;
}
.btn.primary:hover,
.btn.blue:hover {
background: rgba(80,160,255,.24) !important;
border-color: rgba(140,200,255,.42) !important;
}
/* chip 选中态 — 与状态按钮同款毛玻璃,只加亮边框区分 */
.chip.active {
background: rgba(255,255,255,.14) !important;
border-color: rgba(255,255,255,.38) !important;
box-shadow: 0 4px 18px rgba(0,0,0,.22), 0 1.5px 0 rgba(255,255,255,.22) inset !important;
color: #fff !important;
position: relative !important;
overflow: hidden !important;
}
/* 呼吸内光晕 — 径向渐变从中心向外,opacity 呼吸 */
.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;
/* 静态基线可见:即便动画在 WebView 首帧尚未起步(合成层未提升),
光晕也已点亮,避免「首次加载是暗色块、点击后才正常」的问题。 */
opacity: .9 !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 文字在光晕层上方 */
.chip.active > * {
position: relative !important;
z-index: 1 !important;
}
/* TV 端统一在下方 tv-mode 块中禁用,此处不重复 */
.chip.active:hover {
background: rgba(255,255,255,.20) !important;
border-color: rgba(255,255,255,.50) !important;
}
/* danger 按钮 */
.btn.danger {
background: rgba(255,80,80,.12) !important;
border-color: rgba(255,160,160,.24) !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;
}
/* 搜索栏本身要保持在蒙层之上,可继续输入/操作 */
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 .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="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"><b>Relays</b><span id="statusRelays">未连接</span></div>
</div>
<div class="pan-config">
<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="actions" style="margin-top:10px">
<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"></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="detail-meta" id="detailMeta"></div>
<div class="collapsible-overview open" id="detailOverviewWrap">
<button class="overview-toggle" id="detailOverviewToggle" type="button" aria-expanded="true">
<svg class="overview-toggle-icon" viewBox="0 0 24 24"><path d="M6 9l6 6 6-6"/></svg>
<span id="detailOverviewLabel">简介</span>
</button>
<div class="overview-body">
<p id="detailText"></p>
</div>
</div>
</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-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">
<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: "variety-global",
title: "国外综艺",
hint: "海外综艺·真人秀",
mediaType: "tv",
sources: [
{ endpoint: "discover/tv", mediaType: "tv", params: { with_genres: "10764", with_origin_country: "US", 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", with_origin_country: "KR", 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_origin_country: "US", 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_origin_country: "KR", 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",
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: "zh", 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/movie", mediaType: "movie", params: { with_genres: "16", sort_by: "primary_release_date.desc", "primary_release_date_gte": "today-120", "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", sort_by: "popularity.desc", language: "zh-CN" } },
{ endpoint: "discover/movie", mediaType: "movie", params: { with_keywords: "156205", sort_by: "primary_release_date.desc", "primary_release_date_lte": "today+30", language: "zh-CN" } },
{ endpoint: "discover/movie", mediaType: "movie", params: { with_genres: "10402", with_keywords: "156205", sort_by: "vote_average.desc", vote_count_gte: "5", language: "zh-CN" } },
{ endpoint: "discover/tv", mediaType: "tv", params: { with_keywords: "156205", 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 > 高码率 > 杜比全景声 > 1080P > 720P > SDR
const PAN_QUALITY_KEYWORDS = [
{ re: /dv|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: /高码|高码率/i, score: 6, label: "高码" },
{ re: /杜比(?!视界)|atmos/i, score: 5, label: "杜比" },
{ re: /1080p|1080i|全高清|fhd/i, score: 4, label: "1080P", cls: "fhd" },
{ re: /720p/i, score: 3, label: "720P", cls: "hd" },
{ re: /480p|576p/i, score: 2, label: "SD", cls: "sd" },
{ re: /sdr/i, score: 1, 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: {}, backfillBusy: {}, backfillTimers: {}, backfillCandidates: {}, primaryBackfillRelay: "", fallbackReadyAt: 0, fallbackTimer: 0, fallbackPrefetchTimer: 0 },
status: {
sdk: "检测中",
tmdb: "等待请求",
nostr: "等待连接",
pan: "未搜索",
publish: "暂无发布",
identity: "未就绪"
},
deleteState: { loaded: false, users: {} },
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 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 () => {} },
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 setTvMode(tv) {
document.documentElement.classList.toggle("tv-mode", !!tv);
syncNativeToolbarForRoute();
}
function setNativeToolbarVisible(visible, force) {
// 手机端详情页:隐藏/恢复原生 Toolbar,实现沉浸全屏
if (!force && !isTvMode() && !isNativeMobileClient()) return;
try {
const ui = sdk().ui || {};
if (ui.setToolbar) ui.setToolbar(visible);
// 同步 CSS 变量:Toolbar 隐藏时给顶部腾出状态栏空间
if (!visible) {
document.documentElement.classList.add("detail-immersive");
} else {
document.documentElement.classList.remove("detail-immersive");
}
} catch (e) {
// 降级:即使 SDK 不支持也应用 CSS 沉浸效果
if (!visible) {
document.documentElement.classList.add("detail-immersive");
} else {
document.documentElement.classList.remove("detail-immersive");
}
}
}
function syncNativeToolbarForRoute() {
// 沉浸(全屏)状态是粘滞的,由 _fsOn 决定,不再随路由自动恢复 Toolbar
_fsSetToolbar(!_fsOn);
}
// ══════════════════════════════════════════════════════
// 全屏(沉浸)模型 — 参考 Eclipse
// 规则:
// · TV 端:首页加载不全屏;进入详情/子页面后全屏。
// · 移动端:加载即全屏(含首页)。
// · 退出全屏的唯一方式 = 已在主页时再按一次返回;
// 其它任何返回只关闭当前层、保持全屏。
// ══════════════════════════════════════════════════════
let _fsOn = false; // 当前是否处于全屏(Toolbar 隐藏)
function _fsSetToolbar(show) {
try { const ui = sdk().ui || {}; if (ui.setToolbar) ui.setToolbar(show); } catch (e) {}
}
function _applyFs(on) {
_fsOn = !!on;
_fsSetToolbar(!on); // 全屏 = 隐藏 Toolbar
}
function _enterFullscreen() { if (!_fsOn) _applyFs(true); } // 进入子页面时调用
function _exitFullscreen() { if (_fsOn) _applyFs(false); } // 仅主页返回时调用
// 启动时按设备类型决定首页初始全屏状态
function _initFsForHome() {
if (isNativeLeanbackClient() || isTvMode()) _applyFs(false); // TV:首页不全屏
else _applyFs(true); // 移动端:首页即全屏
}
// 是否有二级 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");
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";
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 === "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 未就绪,请稍后再保存");
state.pan.config = sanitizePanConfig(readPanConfigControls());
await sdk().cache.set(cacheKey("pan"), JSON.stringify(state.pan.config));
renderPanConfigControls();
state.pan.configDirty = false;
setPanStatus("配置已保存");
closeConnectionPanel();
toast("盘搜配置已保存");
}
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() {
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();
}, 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 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 (state.relay.subscribeDone >= window.WEBHOME_CONFIG.nostr.relays.length) {
const primary = choosePrimaryBackfillRelay(true);
if (primary) scheduleRelayBackfill(primary);
}
if (state.activeList === "all") renderActiveGrid();
}
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;
$("statusRelays").textContent = relayValues.join(" · ");
$("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();
_initFsForHome(); // 首页全屏初始化:TV 不全屏 / 移动端全屏
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));
}
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() {
if (state.deviceMode === "mobile" || isNativeMobileClient()) return false;
if (state.deviceMode === "leanback" || isNativeLeanbackClient()) return true;
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", ranked(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 = ranked(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 === "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) { 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", () => {
$("searchInput").value = item.title;
hideSearchSuggest();
searchTmdb(item.title);
});
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;
}
}
function applySearchSuggest(title) {
const value = String(title || "").trim();
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");
// 浏览器预览模式:直接加沉浸 class(无 Native Toolbar 干扰)
if (!window.fongmiBridge) document.documentElement.classList.add("detail-immersive");
$("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");
// 进入详情即全屏:TV 端在此首次进入全屏,移动端通常已是全屏
document.documentElement.classList.add("detail-immersive");
_enterFullscreen();
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 ow = $("detailOverviewWrap");
const ob = $("detailOverviewToggle");
if (ow) ow.classList.add("open");
if (ob) ob.setAttribute("aria-expanded", "true");
// 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";
cover.style.setProperty("--detail-cover-bg", bgSrc ? `url("${cssUrl(bgSrc)}")` : "none");
const sheet = $("detailSheet");
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");
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);
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 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; // 兜底:非失效的第一条(含灰灯)
}
// 定时解析:5 秒内优先「绿灯」单集;超 5 秒换任意网盘「绿灯」全集;都没有再超时用灰灯兜底。
async function resolveEpisodePlayableTimed(season, epNo, item, seq) {
const OK_SINGLE_WINDOW_MS = 5000;
const HARD_DEADLINE_MS = 12000;
const start = Date.now();
const active = () => _episodeLoadSeq === seq && state.selected === item;
while (active()) {
const { singles, packs } = episodePanCandidates(season, epNo);
const elapsed = Date.now() - start;
// 1) 绿灯单集(5s 窗口内优先)
const okSingle = await firstUsableCandidate(singles.slice(0, 6), true);
if (!active()) return null;
if (okSingle) return { item: okSingle, kind: "single" };
// 2) 5s 后:绿灯全集(任意网盘,自动跳过灰灯/失效的盘)
if (elapsed >= OK_SINGLE_WINDOW_MS) {
const okPack = await firstUsableCandidate(packs.slice(0, 12), true);
if (!active()) return null;
if (okPack) return { item: okPack, kind: "pack" };
}
// 3) 超时兜底:接受非失效(含灰灯)的单集/全集
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, 400));
}
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(() => {});
}
// 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();
}
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");
state.pan.isPlaying = true;
pan.play(payload); // 不 await,原生接管后 Promise 永不 resolve
} catch (e) {
localStorage.removeItem("fm_pan_playing");
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();
// 关闭详情保持全屏(不恢复 Toolbar);退出全屏只在「已在主页时再按返回」处理
document.documentElement.classList.remove("detail-immersive");
// 清零模糊 / 隐藏徽标
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: "" };
}
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", "已连接");
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;
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));
}
}
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;
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;
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];
}
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);
// 影视简介折叠
$("detailOverviewToggle").addEventListener("click", () => {
const wrap = $("detailOverviewWrap");
const btn = $("detailOverviewToggle");
const open = wrap.classList.toggle("open");
btn.setAttribute("aria-expanded", open ? "true" : "false");
});
$("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 || "保存失败"));
});
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 });
}
$("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();
}
}
// 点击状态面板外部空白处时自动关闭状态面板
const dock = $("connectionDock");
if (dock && dock.classList.contains("open")) {
const body = $("connectionBody");
const toggle = $("connectionToggle");
const clickedInside = (body && body.contains(event.target)) || (toggle && toggle.contains(event.target));
if (!clickedInside) closeConnectionPanel();
}
});
$("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 高度动态计算
// 原理:detail-spacer = 42vh,是背景图的视觉占位高度
// - blurStartspacer 的 55%,用户刚滑动,文字开始进入画面上半区
// - blurEnd spacer 的 90%,文字已基本覆盖背景,完全模糊
// 返回(scrollTop 减小)时沿同一曲线还原清晰,完全对称跟手
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.55); // 开始渐入模糊
blurEnd = Math.round(spacerH * 0.90); // 完全模糊
}
// 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 * 20).toFixed(1)); // 最大 20px 模糊
setHeroBlur(blurPx, ease);
sheet.style.setProperty("--cover-blur-pct", ease.toFixed(3));
});
}, { passive: true });
})();
$("detailContinueBtn").addEventListener("click", () => {
openDetailContinueHistory();
});
$("detailSearchBtn").addEventListener("click", () => {
if (!state.selected) return;
haptic("light");
rememberWatchIntent(state.selected, "search");
startWatchTracking(state.selected);
nativeSearch(state.selected.title);
});
$("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);
}
function installRemoteKeys() {
document.addEventListener("keydown", (event) => {
const key = normalizeRemoteKey(event);
if (!key) return;
const el = document.activeElement;
const editing = isTextEditingElement(el);
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;
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);
}
function updateBackTopButton() {
const top = window.scrollY || document.documentElement.scrollTop || document.body.scrollTop || 0;
$("backTopBtn").classList.toggle("show", top > window.innerHeight);
}
// ── 主页哨兵:确保 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
// 注意:只有在没有任何 sheet 的情况下才允许这么做
if (!location.hash) {
const anySheetActive = _anySheetActive();
if (!anySheetActive) {
if (_fsOn) {
// 第一次在主页返回:退出全屏(显示 Toolbar),消费这次返回,留在 App
// pushState / history.back() 都会把 window.scrollY 重置为 0manual 模式无自动恢复)
// 优先用 closeDetail 在 history.back() 前保存的值
_exitFullscreen();
const _sy = _pendingHomeScrollY || Math.round(window.scrollY || document.documentElement.scrollTop || 0);
_pendingHomeScrollY = 0;
history.pushState({ sheet: "home" }, "", location.pathname + location.search);
if (_sy > 0) {
window.scrollTo(0, _sy);
document.documentElement.scrollTop = _sy;
document.body.scrollTop = _sy;
}
}
// 已非全屏:不再补哨兵,放行让返回交由宿主(退出 App)
}
}
});
// 移动端默认沉浸式:主页退出全屏后,点击任意内容自动重新进入全屏。
// 仅在「移动端 + 当前非全屏 + 处于主页(无二级 sheet)」时触发;
// TV 端不处理,也不影响「主页按返回退出全屏、再按返回退出 App」的流程。
document.addEventListener("click", () => {
if (isTvMode() || isNativeLeanbackClient()) return; // 仅移动端
if (_fsOn) return; // 已是全屏
if (_anySheetActive()) return; // 只在主页
_enterFullscreen();
}, true);
window.addEventListener("fmviewport", () => {
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();
});
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);
});
window.addEventListener("scroll", () => {
state.scrollingUntil = Date.now() + 900;
updateBackTopButton();
LongPressMenu.destroy();
scheduleUiSnapshotSave();
if ("IntersectionObserver" in window) return;
const doc = document.documentElement;
if ((window.scrollY || doc.scrollTop || 0) + window.innerHeight < doc.scrollHeight - 900) return;
clearTimeout(state.scrollLoadTimer);
state.scrollLoadTimer = setTimeout(loadMoreVisible, 80);
}, { 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;
// 从播放器返回:保持详情页打开
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;
// 从播放器返回:保持详情页打开
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;
// 从播放器返回:保持详情页打开
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" });
});
})();
// ── 电视端:方向键焦点移动加速(长按加速,用 rAF 批处理防掉帧) ──
(function setupKeyAccel() {
let pressCount = 0;
let accelRafId = 0;
document.addEventListener("keydown", function(e) {
const key = normalizeRemoteKey(e);
if (!["ArrowUp","ArrowDown","ArrowLeft","ArrowRight"].includes(key)) { pressCount = 0; return; }
pressCount++;
// 长按加速:>8 次跳额外 1 格,>20 次跳额外 3 格
const extra = pressCount > 20 ? 3 : pressCount > 8 ? 1 : 0;
if (extra <= 0) return;
// 用 rAF 把额外跳格延到下一帧,避免同一帧多次 scrollIntoView 掉帧
if (accelRafId) return; // 上一帧还没处理完,跳过避免叠加
accelRafId = requestAnimationFrame(function() {
accelRafId = 0;
for (let i = 0; i < extra; i++) {
const t = nearestFocusable(key);
if (t) {
try { t.focus({ preventScroll: true }); } catch(_) { t.focus(); }
maybeAppendGridForFocus(t);
}
}
// 只在最后一次跳格后才执行 scrollIntoView,减少重绘
const finalTarget = document.activeElement;
if (finalTarget && isVisibleFocusable(finalTarget)) keepFocusInView(finalTarget);
});
});
document.addEventListener("keyup", function() {
pressCount = 0;
if (accelRafId) { cancelAnimationFrame(accelRafId); accelRafId = 0; }
});
})();
await initPanConfig();
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);
}
}
boot();
</script>
</body>
</html>