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

10544 lines
391 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), .58);
--panel-soft: rgba(var(--panel-rgb), .44);
--panel-strong: rgba(var(--panel-rgb), .82);
--panel-2: rgba(var(--panel-rgb), .62);
--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, 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-color: #303840;
background-image:
linear-gradient(45deg, rgba(255, 255, 255, .07) 25%, transparent 25%),
linear-gradient(-45deg, rgba(255, 255, 255, .07) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, rgba(255, 255, 255, .07) 75%),
linear-gradient(-45deg, transparent 75%, rgba(255, 255, 255, .07) 75%);
background-position: 0 0, 0 16px, 16px -16px, -16px 0;
background-size: 32px 32px;
}
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: blur(12px) saturate(1.05);
}
.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;
}
.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);
backdrop-filter: blur(12px) saturate(1.05);
}
.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);
z-index: 1;
display: none;
max-height: min(54vh, 460px);
overflow-y: auto;
padding: 6px;
border-radius: var(--radius);
border: 1px solid var(--line);
background: rgba(22, 28, 34, .94);
box-shadow: var(--shadow);
backdrop-filter: blur(14px) saturate(1.08);
scrollbar-width: none;
}
.suggest-panel.open { display: grid; gap: 6px; }
.suggest-panel::-webkit-scrollbar { display: none; }
.suggest-item {
min-width: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
align-items: center;
min-height: 42px;
padding: 8px 10px;
border-radius: var(--radius);
color: var(--text);
text-align: left;
background: rgba(42, 52, 60, .56);
border: 1px solid rgba(255, 255, 255, .12);
}
.suggest-item b {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
line-height: 1.25;
}
.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);
backdrop-filter: blur(12px) saturate(1.05);
}
textarea.field {
min-height: 74px;
padding: 10px 12px;
resize: vertical;
}
.field::placeholder { color: rgba(226, 234, 242, .5); }
/* ══════════════════════════════════════
导航栏:一体长条设计(参考图样式)
══════════════════════════════════════ */
/* .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; }
/* 主导航栏 #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: rgba(255,255,255,.07);
border: 1px solid rgba(255,255,255,.10);
border-radius: 999px;
padding: 3px 4px;
gap: 2px;
backdrop-filter: blur(18px) saturate(1.1);
-webkit-backdrop-filter: blur(18px) saturate(1.1);
box-shadow: 0 2px 16px rgba(0,0,0,.22), inset 0 1px 0 rgba(255,255,255,.08);
/* 允许子 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%;
animation: poster-shimmer 1.4s ease-in-out infinite;
}
.poster.loaded {
animation: none;
background: transparent;
}
@keyframes poster-shimmer {
0% { background-position: 150% 0; }
100% { background-position: -150% 0; }
}
.card > .poster {
width: calc(100% + 2px);
max-width: calc(100% + 2px);
margin: -1px -1px 0;
}
.card-body {
padding: 10px;
background: var(--panel);
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%;
/* 顶部保持正常亮度,brightness 由 JS 随滚动动态写入 */
filter: brightness(1.0) 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 42%, black 62%);
mask-image: linear-gradient(180deg, transparent 0%, transparent 42%, black 62%);
}
.sheet.active .sheet-blur-layer {
display: block;
}
.sheet.active { display: block; }
/* 关闭淡出:整个 sheet 整体透明度过渡,避免内容和背景不同步 */
.sheet.sheet-closing {
opacity: 0;
pointer-events: none;
transition: opacity 0.22s ease !important;
}
.detail-cover {
display: none !important;
}
/* 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: linear-gradient(110deg, rgba(255, 255, 255, .04), rgba(255, 255, 255, .11), rgba(255, 255, 255, .04));
background-size: 220% 100%;
animation: cover-loading 1.25s ease-in-out infinite;
}
@keyframes cover-loading {
0% { background-position: 120% 0; }
100% { background-position: -120% 0; }
}
.detail-cover img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0;
transition: none;
}
.detail-cover img.active {
opacity: 1;
}
.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: scale(1.06);
}
.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;
}
.detail-meta {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin: 8px 0 14px;
}
.meta-pill {
padding: 5px 8px;
border-radius: 999px;
color: var(--muted);
background: var(--panel);
border: 1px solid var(--line);
font-size: 12px;
}
.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: var(--panel);
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: var(--panel);
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: auto;
min-height: 42px;
display: grid;
grid-template-columns: auto auto auto;
align-items: center;
gap: 7px;
padding: 0 14px;
background: transparent;
color: var(--text);
text-align: left;
}
.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-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.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 .card:focus {
transform: translateY(-4px) scale(1.04);
box-shadow: 0 0 0 3px rgba(255,255,255,.95), 0 0 0 6px rgba(60,140,255,.85), 0 12px 36px rgba(0,0,0,.5) !important;
border-color: rgba(255, 255, 255, .98) !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,
html.tv-mode .hero-glow,
html.tv-mode .hero-orb-1,
html.tv-mode .hero-orb-2,
html.tv-mode .hero-orb-3,
html.tv-mode .hero-orb-4 {
display: none !important;
}
/* hero-bg TV 端 brightness 也由 JS 控制,初始正常亮度 */
html.tv-mode .detail-hero-bg {
filter: brightness(1.0) 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 {
transform: translateY(-2px) scale(1.028);
border-color: var(--focus-line);
background: rgba(var(--panel-rgb), .66);
box-shadow: 0 0 0 2px rgba(116, 184, 255, .35), 0 8px 28px rgba(0,0,0,.38);
outline: none;
}
.card:focus img,
.episode-card:focus img,
.person-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,
.btn:focus,
.mini-icon-btn:focus,
.connection-toggle:focus {
transform: translateY(-1px);
background: rgba(70, 85, 96, .72);
border-color: var(--focus-line);
/* 焦点环改用 outline,不在这里设置 box-shadow,由 ios26 样式统一控制 */
}
.chip.active:focus {
background: rgba(82, 98, 108, .78);
border-color: rgba(184, 226, 255, .8);
}
input.focusable:focus,
textarea.focusable:focus,
.field:focus {
transform: none;
background: rgba(28, 38, 46, .72);
border-color: var(--focus-line);
box-shadow: none;
}
.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:#1a1a1a;
--bg2:#101826;
--panel:rgba(18,24,38,.78);
--panel-soft:rgba(22,30,48,.58);
--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: #0d1117 !important;
}
/* Apple-style background orbs handle the glow — body pseudo-elements removed */
.app{
padding:28px 28px 120px !important;
max-width:1920px;
margin:auto;
}
.card > .poster {
aspect-ratio: 3/4;
border-radius: 10px;
}
.rail {
gap: 16px !important;
justify-content: flex-start;
}
.media-grid {
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)) !important;
gap: 16px !important;
}
@media screen and (max-width: 768px) {
.media-grid {
grid-template-columns: repeat(3, 1fr) !important;
gap: 8px !important;
}
.app {
padding-left: 8px !important;
padding-right: 8px !important;
}
}
.section-head h3 {
font-size: 15px !important;
font-weight: 700 !important;
line-height: 1.2 !important;
}
.button.selected, .tab.selected {
background-color: var(--accent) !important;
color: var(--text) !important;
}
.suggest-panel {
background: rgba(255,255,255,0.06) !important;
backdrop-filter: blur(24px) saturate(140%) !important;
-webkit-backdrop-filter: blur(24px) saturate(140%) !important;
border: 1px solid rgba(255,255,255,0.08) !important;
box-shadow: 0 8px 32px rgba(0,0,0,.18) !important;
}
.suggest-item {
background: rgba(255,255,255,0.04) !important;
border: 1px solid rgba(255,255,255,0.06) !important;
backdrop-filter: blur(18px) !important;
-webkit-backdrop-filter: blur(18px) !important;
}
.suggest-item:focus,
.suggest-item:hover {
background: rgba(255,255,255,0.08) !important;
}
.suggest-panel {
background: rgba(10,14,22,0.82) !important;
backdrop-filter: blur(60px) saturate(180%) brightness(0.7) !important;
-webkit-backdrop-filter: blur(60px) saturate(180%) brightness(0.7) !important;
border: 1px solid rgba(255,255,255,0.05) !important;
box-shadow: 0 12px 48px rgba(0,0,0,.45) !important;
}
.suggest-panel::before{
content:"";
position:absolute;
inset:0;
background: rgba(8,10,18,0.45);
border-radius: inherit;
pointer-events:none;
}
.suggest-item {
background: rgba(255,255,255,0.03) !important;
backdrop-filter: blur(40px) !important;
-webkit-backdrop-filter: blur(40px) !important;
}
.search-mask,
.search-overlay {
backdrop-filter: blur(80px) brightness(0.55) !important;
-webkit-backdrop-filter: blur(80px) brightness(0.55) !important;
background: rgba(5,8,15,0.55) !important;
}
.section-head h3{
/* 已在上方统一为 15px */
}
/* 统一热播标题与子列表标题大小/行高一致 */
#recommendSection {
margin-top: 18px !important;
}
/* iOS 毛玻璃搜索层 */
.suggest-panel,
.search-overlay,
.search-mask,
.search-result-wrap{
background: rgba(255,255,255,0.08) !important;
backdrop-filter: blur(38px) saturate(180%) brightness(1.08) !important;
-webkit-backdrop-filter: blur(38px) saturate(180%) brightness(1.08) !important;
border: 1px solid rgba(255,255,255,0.12) !important;
box-shadow:
0 8px 32px rgba(0,0,0,.18),
inset 0 1px 0 rgba(255,255,255,.12) !important;
}
/* 去掉黑底 */
.suggest-panel::before,
.search-overlay::before,
.search-mask::before{
display:none !important;
}
/* 搜索列表项玻璃感 */
.suggest-item,
.search-item{
background: rgba(255,255,255,0.05) !important;
border: 1px solid rgba(255,255,255,0.08) !important;
backdrop-filter: blur(24px) saturate(160%) !important;
-webkit-backdrop-filter: blur(24px) saturate(160%) !important;
}
/* hover/active */
.suggest-item:hover,
.search-item:hover,
.suggest-item.active,
.search-item.active{
background: rgba(255,255,255,0.12) !important;
}
/* 输入框也统一玻璃效果 */
.search-box,
.search-input-wrap{
background: rgba(255,255,255,0.06) !important;
backdrop-filter: blur(22px) saturate(180%) !important;
-webkit-backdrop-filter: blur(22px) saturate(180%) !important;
border: 1px solid rgba(255,255,255,0.14) !important;
}
/* 搜索总层 */
.suggest-panel,
.search-overlay,
.search-mask,
.search-result-wrap{
background: rgba(22,28,38,0.72) !important;
backdrop-filter: blur(28px) saturate(160%) !important;
-webkit-backdrop-filter: blur(28px) saturate(160%) !important;
border: 1px solid rgba(255,255,255,0.08) !important;
overflow: hidden !important;
}
/* 额外加一层遮罩,避免后面文字透出来 */
.suggest-panel::after,
.search-overlay::after,
.search-result-wrap::after{
content:"";
position:absolute;
inset:0;
background: rgba(18,22,30,0.28);
pointer-events:none;
}
/* 单个搜索项 */
.suggest-item,
.search-item{
position:relative;
background: rgba(255,255,255,0.04) !important;
border: 1px solid rgba(255,255,255,0.06) !important;
backdrop-filter: blur(12px) !important;
-webkit-backdrop-filter: blur(12px) !important;
}
/* hover */
.suggest-item:hover,
.search-item:hover,
.suggest-item.active,
.search-item.active{
background: rgba(255,255,255,0.08) !important;
}
/* 文本层级提高 */
.suggest-item *,
.search-item *,
.suggest-panel *,
.search-result-wrap *{
position:relative;
z-index:2;
}
/* 避免后面卡片文字穿透 */
.card,
.media-grid {
isolation: isolate;
}
/* ── 性能优化:减少冗余backdrop-filter ── */
/* 搜索面板统一使用单层模糊,去掉叠加 */
.suggest-panel,
.search-overlay,
.search-mask,
.search-result-wrap{
background: rgba(18,24,34,0.86) !important;
backdrop-filter: blur(22px) saturate(140%) !important;
-webkit-backdrop-filter: blur(22px) saturate(140%) !important;
border: 1px solid rgba(255,255,255,0.08) !important;
overflow: hidden !important;
}
.suggest-panel::before,.suggest-panel::after,
.search-overlay::before,.search-overlay::after,
.search-result-wrap::after{ display:none !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提升 */
.hero-glow {
transform: translateZ(0);
backface-visibility: hidden;
}
/* 解除头部的隔离限制,并提高整个头部的层级 */
.section-head {
position: relative;
z-index: 999 !important;
}
/* 减淡搜索总层透明度 */
.suggest-panel,
.search-overlay,
.search-mask,
.search-result-wrap{
background: rgba(22,28,38,0.58) !important; /* 从0.72减淡约20% */
}
.suggest-panel::after,
.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;
margin-bottom: 8px;
}
/* 剧照框隐藏,只用背景轮播 */
#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%; }
#closeDetailBtn {
position: relative;
top: auto;
align-self: flex-start;
z-index: 10;
flex-shrink: 0;
margin: max(10px, env(safe-area-inset-top)) 0 0;
min-height: 40px !important;
min-width: 40px !important;
width: 40px !important;
padding: 0 !important;
display: grid !important;
place-items: center !important;
border-radius: 50% !important;
color: rgba(255,255,255,0.92) !important;
opacity: 1;
transition: background .18s ease, transform .12s ease;
}
#closeDetailBtn:hover,
#closeDetailBtn:focus {
background: rgba(255,255,255,0.18) !important;
color: #fff !important;
}
#closeDetailBtn:active {
transform: scale(0.88);
background: rgba(255,255,255,0.26) !important;
}
#closeDetailBtn .icon {
width: 22px !important;
height: 22px !important;
stroke-width: 2.2 !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(28px) saturate(180%) brightness(1.06);
--lg-blur-heavy: blur(40px) saturate(200%) brightness(1.08);
--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);
}
html,body{
background:transparent !important;
}
/* body::before/after 用于 Apple 风格流光背景,不隐藏 */
.app,
.section,
.search,
.connection-dock,
.sync-panel,
.card,
.person-card,
.episode-card,
.metric,
.pan-result-item,
.sheet{
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;
}
/* 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 呼吸 */
/* 不用 overflow:visible 避免影响 flex 布局裁切 */
.chip.active::before {
content: "" !important;
display: block !important;
position: absolute !important;
inset: 0 !important;
border-radius: inherit !important;
background: radial-gradient(ellipse at 50% 120%,
rgba(140,200,255,.70) 0%,
rgba(160,110,255,.45) 35%,
transparent 68%
) !important;
pointer-events: none !important;
z-index: 0 !important;
animation: chip-inner-breathe 2s ease-in-out infinite !important;
/* 负延迟让动画从最亮处开始,切换瞬间立刻可见 */
animation-delay: -1s !important;
}
/* chip 文字在光晕层上方 */
.chip.active > * {
position: relative !important;
z-index: 1 !important;
}
@keyframes chip-inner-breathe {
0% { opacity: .30; transform: scaleY(.85); }
50% { opacity: 1; transform: scaleY(1.0); }
100% { opacity: .30; transform: scaleY(.85); }
}
html.tv-mode .chip.active::before {
animation: none !important;
opacity: 0 !important;
}
.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;
}
/* ── 呼吸 Glow 焦点效果 ── */
/* 原理:多层彩色 box-shadow 紧贴元素边缘向外散光,@keyframes 做强弱呼吸 */
@keyframes glow-breathe-chip {
0% { box-shadow: 0 0 6px 2px rgba(80,180,255,.55), 0 0 18px 6px rgba(140,80,255,.35), 0 0 32px 10px rgba(255,100,180,.18); }
50% { box-shadow: 0 0 10px 4px rgba(80,180,255,.90), 0 0 28px 10px rgba(140,80,255,.60), 0 0 50px 18px rgba(255,100,180,.32); }
100% { box-shadow: 0 0 6px 2px rgba(80,180,255,.55), 0 0 18px 6px rgba(140,80,255,.35), 0 0 32px 10px rgba(255,100,180,.18); }
}
@keyframes glow-breathe-card {
0% { box-shadow: 0 0 8px 2px rgba(60,160,255,.50), 0 0 22px 7px rgba(160,80,255,.30), 0 0 40px 14px rgba(255,140,60,.16), 0 14px 40px rgba(0,0,0,.55); }
50% { box-shadow: 0 0 14px 5px rgba(60,160,255,.88), 0 0 36px 12px rgba(160,80,255,.55), 0 0 60px 22px rgba(255,140,60,.28), 0 14px 40px rgba(0,0,0,.55); }
100% { box-shadow: 0 0 8px 2px rgba(60,160,255,.50), 0 0 22px 7px rgba(160,80,255,.30), 0 0 40px 14px rgba(255,140,60,.16), 0 14px 40px rgba(0,0,0,.55); }
}
@keyframes glow-breathe-btn {
0% { box-shadow: 0 0 6px 2px rgba(80,180,255,.50), 0 0 18px 6px rgba(140,80,255,.32), 0 0 30px 10px rgba(255,100,180,.16); }
50% { box-shadow: 0 0 10px 4px rgba(80,180,255,.85), 0 0 28px 10px rgba(140,80,255,.56), 0 0 46px 16px rgba(255,100,180,.28); }
100% { box-shadow: 0 0 6px 2px rgba(80,180,255,.50), 0 0 18px 6px rgba(140,80,255,.32), 0 0 30px 10px rgba(255,100,180,.16); }
}
/* chip 呼吸 glow — 手机 + TV 通用基础 */
.chip:focus,
.chip.active:focus {
outline: none !important;
border-color: transparent !important;
transform: none !important;
background: rgba(70,85,96,.72) !important;
animation: glow-breathe-chip 1.8s ease-in-out infinite !important;
}
.chip.active:focus {
background: rgba(82,98,108,.82) !important;
}
/* TV chip:更强的 glow */
html.tv-mode .chip:focus {
background: rgba(255,255,255,.18) !important;
color: #fff !important;
transform: none !important;
animation: glow-breathe-chip 1.5s ease-in-out infinite !important;
}
/* TV 普通按钮 glow */
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;
animation: glow-breathe-btn 1.8s ease-in-out infinite !important;
}
/* TV card 海报卡片 glow */
html.tv-mode .card:focus {
outline: none !important;
border-color: rgba(255,255,255,.2) !important;
transform: translateY(-4px) scale(1.04) !important;
animation: glow-breathe-card 2s ease-in-out infinite !important;
}
/* 手机端 coarse pointer 按钮 glow */
@media (hover:none),(pointer:coarse) {
button:focus:not(.chip),
.focusable:focus:not(.chip),
.btn:focus {
outline: none !important;
border-color: rgba(255,255,255,.3) !important;
animation: glow-breathe-btn 1.8s ease-in-out infinite !important;
}
}
/* pan-result-item glow */
.pan-result-item:focus {
outline: none !important;
border-color: transparent !important;
background: rgba(82,100,112,.86) !important;
animation: glow-breathe-btn 1.8s ease-in-out infinite !important;
}
/* 去掉之前残留的 ::after 伪元素环(确保不冲突) */
.chip:focus::after,
html.tv-mode .chip:focus::after,
html.tv-mode button:focus::after,
html.tv-mode .btn:focus::after,
html.tv-mode .focusable:focus::after,
html.tv-mode .card:focus::after,
.pan-result-item:focus::after {
display: none !important;
animation: none !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-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: 16px;
background: rgba(255,255,255,.06);
border: 1px solid rgba(255,255,255,.10);
backdrop-filter: blur(24px) saturate(160%);
-webkit-backdrop-filter: blur(24px) saturate(160%);
box-shadow: 0 2px 12px rgba(0,0,0,.18);
text-align: left;
color: var(--text);
cursor: pointer;
transition: background .16s ease, transform .14s ease;
box-sizing: border-box;
}
.search-list-item:hover,
.search-list-item:focus {
background: rgba(255,255,255,.11);
transform: scale(1.005);
outline: none;
}
.search-list-item:active {
transform: scale(.99);
background: rgba(255,255,255,.05);
}
.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: var(--muted);
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: var(--muted);
line-height: 1.6;
margin-top: 8px;
}
/* ── TV 模式:覆盖 liquid glass 重度 backdrop-filter,防止操作卡顿 ── */
html.tv-mode .btn,
html.tv-mode .icon-btn,
html.tv-mode .mini-icon-btn,
html.tv-mode .chip,
html.tv-mode .connection-toggle,
html.tv-mode .back-top,
html.tv-mode .field,
html.tv-mode .card {
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
will-change: auto !important;
}
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;
}
</style>
</head>
<body>
<main class="app" id="home">
<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>
<span class="connection-title">状态</span>
<span class="connection-arrow"></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="http://flix.dpdns.org:8888/" 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">
<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>
<div class="suggest-panel" id="suggestPanel" role="listbox"></div>
</form>
</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;backdrop-filter:blur(18px) saturate(1.1);-webkit-backdrop-filter:blur(18px) saturate(1.1);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 class="section-actions">
<button class="mini-icon-btn focusable" id="clearSearchBtn" type="button" aria-label="清空搜索结果" title="清空搜索结果">
<svg class="icon" viewBox="0 0 24 24"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
</button>
</div>
</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>
<button class="btn focusable" id="closeDetailBtn" type="button" aria-label="返回">
<svg class="icon" viewBox="0 0 24 24"><path d="M19 12H5"/><path d="M12 19l-7-7 7-7"/></svg>
</button>
<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>
<p id="detailText"></p>
<button class="btn link focusable" id="detailMoreBtn" type="button">更多</button>
</div>
<div class="actions">
<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>
搜索播放
</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>
</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="recommendBlock" style="display:none">
<h3>相关推荐</h3>
<div class="rail" id="recommendWorkRail"></div>
</div>
<div class="detail-block" id="personWorkBlock" style="display:none">
<h3 id="personWorkTitle">简介</h3>
<div id="personInfo"></div>
<h3 style="margin-top:16px">关联作品</h3>
<div class="rail" id="personWorkRail"></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://image.tmdb.org/t/p/w342",
backdropBase: "https://image.tmdb.org/t/p/original",
lists: [
{
id: "now-playing",
title: "正在上映",
hint: "影院热映中",
mediaType: "movie",
sources: [
{ 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" } }
]
},
{
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/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",
title: "电影",
hint: "最新上映·即将上映·流媒体",
mediaType: "movie",
sources: [
{ endpoint: "movie/now_playing", mediaType: "movie", params: { language: "zh-CN" } },
{ endpoint: "movie/upcoming", 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-120", "primary_release_date_lte": "today+60" } },
{ endpoint: "discover/movie", mediaType: "movie", params: { 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+14", vote_count_gte: "10" } }
]
},
{
id: "jp-kr-tv",
title: "日韩剧",
hint: "韩国·日本最新剧集",
mediaType: "tv",
sources: [
{ endpoint: "discover/tv", mediaType: "tv", params: { with_original_language: "ko", with_origin_country: "KR", without_genres: "16", 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_original_language: "ja", with_origin_country: "JP", without_genres: "16", sort_by: "first_air_date.desc", include_null_first_air_dates: "false", "first_air_date_gte": "today-120", "first_air_date_lte": "today+30" } }
]
},
{
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" } }
]
}
]
},
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 };
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 },
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) {
if (!force && !isTvMode()) return;
try {
const ui = sdk().ui || {};
if (ui.setToolbar) ui.setToolbar(visible);
} catch (e) {}
}
function syncNativeToolbarForRoute() {
setNativeToolbarVisible(uiSnapshotRoute() === "home");
}
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 === "image.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=1.0(正常),压暗到 0.42
const brightness = (1.0 - e * 0.58).toFixed(3);
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://image.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 !== "image.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="${tvMode ? "sync" : "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,
baseRank: index + 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 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();
}
}
async function loadInfo() {
try {
await detectDeviceMode();
state.site = await sdk().site();
state.config = await sdk().config();
state.pan.checkEnabled = !!(state.config && state.config.driveCheck);
setStatus("sdk", window.fm ? "App SDK 已连接" : "浏览器预览模式");
setPanStatus(state.pan.checkEnabled ? "检测已开启" : "检测关闭");
state.pan.renderKeys = "";
renderPanResults();
} catch (e) {
state.site = { name: "推荐首页" };
state.pan.checkEnabled = false;
setStatus("sdk", "SDK 获取失败:" + (e.message || "unknown"));
}
}
async function loadCatalog() {
setStatus("tmdb", "等待按需请求");
loadRecentList({ silent: true }).catch(() => {});
renderAll({ deferContent: true });
}
async function loadCatalogList(id) {
const list = getList(id);
const page = state.catalogPage[id];
if (!list || page && page.loading) return;
if (page && page.loaded) return;
state.catalogPage[id] = { page: 0, total: 1, loading: true, loaded: false };
setStatus("tmdb", `请求 ${list.title}`);
if (state.activeList === id) renderActiveGrid();
// 多数据源混合加载(推荐 tab 用)
if (list.sources && list.sources.length) {
try {
const fetches = list.sources.map((src) => {
const srcList = Object.assign({}, list, { endpoint: src.endpoint, params: src.params, mediaType: src.mediaType || list.mediaType });
return requestJson(tmdbUrl(srcList, 1), 18)
.then((body) => ({ items: (body.results || []).map((item, i) => normalizeTmdb(item, srcList, i)).filter(hasPoster), total: body.total_pages || 1 }))
.catch(() => ({ items: [], total: 1 }));
});
const results = await Promise.all(fetches);
// 交叉混排:依次从每个来源取一条,循环直到取完
const mixed = [];
const iters = results.map((r) => r.items[Symbol.iterator]());
let anyLeft = true;
while (anyLeft) {
anyLeft = false;
for (const iter of iters) {
const { value, done } = iter.next();
if (!done) { mixed.push(value); anyLeft = true; }
}
}
state.catalog[id] = uniqueMedia(mixed);
// 记录多源分页状态,以便继续加载第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));
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));
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);
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);
// 同步 inline display,防止 ensureSheetViewport 的 block 覆盖 flex 布局
if (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");
if ($("searchInput")) $("searchInput").value = "";
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="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></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>`,
};
function getChipIcon(id) {
if (id === "recent") return CHIP_ICONS.recent;
if (id === "all") return CHIP_ICONS.all;
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));
}
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 ? "…" : "");
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>` : ""}
</div>
`;
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";
}
function gridColumns(grid) {
const value = grid ? getComputedStyle(grid).gridTemplateColumns : "";
const count = value && value !== "none" ? value.split(" ").filter(Boolean).length : 0;
if (count > 0) return count;
if (isTvMode()) return 5;
if ((window.innerWidth || 0) >= 1180) return 6;
if ((window.innerWidth || 0) >= 720) return 4;
return 3;
}
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;
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('image.tmdb.org')<0&&p.indexOf('/t/p/')<0){var m=p.match(/\/(t\d|w\d+|original|h\d+)(\/.+)/);if(m)this.src='https://image.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) {
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 renderSearchSuggest() {
const panel = $("suggestPanel");
if (!panel) return;
const items = state.suggestions.items || [];
if (!items.length) return hideSearchSuggest();
closeConnectionPanel();
panel.replaceChildren(...items.map((item) => {
const button = document.createElement("button");
button.className = "suggest-item focusable";
button.type = "button";
button.setAttribute("role", "option");
button.innerHTML = `<b>${escapeHtml(item.title)}</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();
// 搜索结果出现时推入历史,让返回键能清除结果
if (state.searchItems.length && location.hash !== "#search" && location.hash !== "#detail") {
history.pushState({ sheet: "search" }, "", "#search");
}
scheduleUiSnapshotSave();
// 直接打开第一个搜索结果的详情页
openSearchFirstResult();
} catch (e) {
setStatus("tmdb", "搜索失败:" + (e.message || "unknown"));
toast("搜索失败");
}
}
// 滚动恢复由我们在 popstate 里手动处理(pushState 后立即还原)
if ("scrollRestoration" in history) {
history.scrollRestoration = "manual";
}
// 关闭详情时跨 history.back() 传递主页滚动位置
let _pendingHomeScrollY = 0;
// 主页滚动位置 —— 开详情前保存,关详情后同步恢复
function openDetail(item, options) {
const opts = options || {};
state.selected = item;
state.detail = null;
if (!opts.keepPan) resetPanSearch();
ensureSheetViewport($("detailSheet"));
syncDetailLayout();
document.body.classList.add("detail-active");
$("detailSheet").classList.add("active");
$("detailSheet").setAttribute("aria-hidden", "false");
if (isNativeLeanbackClient()) setTvMode(true);
try {
renderDetailBase(item);
clearDetailExtras();
} catch (e) {
$("detailTitle").textContent = item && item.title || "详情";
$("detailText").textContent = "详情渲染失败:" + (e.message || "unknown");
}
if (!opts.skipHistory && location.hash !== "#detail") history.pushState({ sheet: "detail" }, "", "#detail");
if (!opts.restore) rememberWatchIntent(item, "view");
setNativeToolbarVisible(false, isNativeLeanbackClient());
loadDetail(item);
scheduleUiSnapshotSave();
if (!opts.restore) setTimeout(() => focusRemoteTarget($("detailSearchBtn") || $("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();
// 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 } // 有海报就先用海报,比空白强
);
scheduleDetailTextClamp();
}
function renderDetailMeta(item, detail) {
if (isTvMode() && !useLargeDetailLayout()) {
$("detailMeta").replaceChildren();
return;
}
$("detailMeta").replaceChildren(...detailMeta(item, detail).map((meta) => {
const span = document.createElement("span");
span.className = "meta-pill" + (meta.strong ? " strong" : "");
span.textContent = meta.text || meta;
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);
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;
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).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"].forEach((id) => $(id).style.display = "none");
["seasonTabs", "episodeRail", "castRail", "personInfo", "personWorkRail"].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) {
$("detailText").textContent = `详情加载失败:${e.message || "unknown"}`;
scheduleDetailTextClamp();
toast("详情加载失败");
}
}
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();
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 scheduleDetailTextClamp() {
requestAnimationFrame(() => requestAnimationFrame(updateDetailTextClamp));
}
function resetDetailTextClamp() {
const text = $("detailText");
const more = $("detailMoreBtn");
if (text) {
text.classList.remove("clamped");
text.style.removeProperty("--detail-text-max");
}
if (more) {
more.dataset.expanded = "";
more.style.display = "none";
more.style.visibility = "";
more.textContent = "更多";
}
}
function measureDetailMoreHeight(more) {
const display = more.style.display;
const visibility = more.style.visibility;
more.style.display = "inline-flex";
more.style.visibility = "hidden";
const height = more.offsetHeight || 30;
more.style.display = display;
more.style.visibility = visibility;
return height;
}
function updateDetailTextClamp() {
const sheet = $("detailSheet");
const text = $("detailText");
const more = $("detailMoreBtn");
const actions = more && more.closest(".detail-info") && more.closest(".detail-info").nextElementSibling;
if (!sheet || !text || !more || !actions || !sheet.classList.contains("active")) return;
if (more.dataset.expanded === "1") {
text.classList.remove("clamped");
text.style.removeProperty("--detail-text-max");
more.style.display = "inline-flex";
more.style.visibility = "";
more.textContent = "收起";
return;
}
text.classList.remove("clamped");
text.style.removeProperty("--detail-text-max");
more.style.display = "none";
more.style.visibility = "";
more.textContent = "更多";
const sheetRect = sheet.getBoundingClientRect();
const actionsRect = actions.getBoundingClientRect();
const textRect = text.getBoundingClientRect();
const firstScreenBottom = Math.min(sheetRect.bottom, sheetRect.top + (sheet.clientHeight || window.innerHeight || 0));
const safeBottom = firstScreenBottom - Math.max(58, (window.innerHeight || 0) * .06);
const overflow = actionsRect.bottom - safeBottom;
const naturalHeight = text.scrollHeight;
const lineHeight = parseFloat(getComputedStyle(text).lineHeight || "21") || 21;
const minHeight = Math.max(44, lineHeight * 2.05);
if (overflow <= 0 || naturalHeight <= minHeight + 1) return;
const maxHeight = Math.max(minHeight, textRect.height - overflow - measureDetailMoreHeight(more) - 6);
if (maxHeight >= naturalHeight - 2) return;
text.style.setProperty("--detail-text-max", `${Math.round(maxHeight)}px`);
text.classList.add("clamped");
more.style.display = "inline-flex";
more.style.visibility = "";
}
function toggleDetailTextMore() {
const more = $("detailMoreBtn");
if (!more) return;
more.dataset.expanded = more.dataset.expanded === "1" ? "" : "1";
updateDetailTextClamp();
scheduleUiSnapshotSave();
}
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", () => openEpisodeViewer(ep, fullStill));
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 } });
}
async function loadPersonWorks(person) {
try {
$("personWorkTitle").textContent = "简介";
$("personWorkBlock").style.display = "";
renderPersonInfo(person, null);
$("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="person-info" aria-live="polite">
<div class="person-info-body">
<p>${escapeHtml(biography)}</p>
</div>
</div>
`;
}
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) {
if (!item || state.pan.loading) return;
const keyword = panKeyword(item);
if (!keyword) return toast("缺少搜索标题");
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;
if ($("panSearchBlock")) {
$("panSearchBlock").classList.add("active");
$("panSearchBlock").style.display = "";
}
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} 条` : "无资源");
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 = [];
$("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) => !activeType || normalizePanDiskType(item.diskType) === activeType);
if (!canCheckPanLinks() || !activeType || !PAN_CHECKABLE_DISK_TYPES.has(normalizePanDiskType(activeType))) return source;
return source
.map((item, index) => ({ item, index, priority: panHealthPriority(item) }))
.sort((a, b) => 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"].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) {
return `
<div class="pan-result-title">
<span>${escapeHtml(item.title || state.pan.keyword || "盘搜资源")}</span>
${panHealthIndicatorHtml(item, health)}
</div>
<div class="pan-result-meta">${escapeHtml(panDiskName(item.diskType))}${item.password ? " · 提取码 " + escapeHtml(item.password) : ""}${item.source ? " · " + escapeHtml(item.source) : ""}</div>
<div class="pan-result-url">${escapeHtml(item.normalizedUrl || item.url)}</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");
await pan.play(payload);
toast("已交给原生播放");
} catch (e) {
toast("播放失败:" + (e.message || "unknown"));
}
}
function emptyNode(text) {
const node = document.createElement("div");
node.className = "empty";
node.textContent = text;
return node;
}
function closeDetail(fromPopState) {
const sheet = $("detailSheet");
if (!sheet) return;
// 防止动画期间重复触发
if (sheet.classList.contains("sheet-closing")) return;
// 停止轮播和盘搜
stopDetailCoverCarousel(true);
resetPanSearch();
setNativeToolbarVisible(true);
// 清零模糊 / 隐藏徽标
setHeroBlur(0, 0);
sheet.style.setProperty("--cover-blur-pct", "0");
delete sheet.dataset.blurLocked;
const logoWrap = $("detailLogoWrap");
const logoImg = $("detailLogoImg");
if (logoWrap) logoWrap.style.display = "none";
if (logoImg) logoImg.classList.remove("loaded");
// 触发整体淡出动画(sheet 整体 opacity 022ms
sheet.classList.add("sheet-closing");
state.detailClosing = true;
document.body.classList.add("detail-closing");
// 动画结束后执行真正的状态清除
const FADE = 240;
const needBack = !fromPopState && location.hash === "#detail";
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 if (_pendingHomeScrollY > 0) {
// 系统返回键路径:popstate 里保存的位置,动画结束后还原
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"));
}
}
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);
$("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();
$("detailMoreBtn").addEventListener("click", toggleDetailTextMore);
$("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);
$("searchInput").addEventListener("pointerdown", enableSearchEditing);
$("searchInput").addEventListener("mousedown", enableSearchEditing);
$("searchInput").addEventListener("touchstart", enableSearchEditing, { passive: true });
$("searchInput").addEventListener("focus", () => {
if ($("searchInput").readOnly) hideSearchSuggest();
});
$("searchInput").addEventListener("blur", disableSearchEditing);
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)) hideSearchSuggest();
});
$("searchForm").addEventListener("submit", (event) => {
event.preventDefault();
submitSearchInput();
});
$("searchForm").querySelector("button[type='submit']").addEventListener("click", (event) => {
event.preventDefault();
submitSearchInput();
});
$("clearSearchBtn").addEventListener("click", clearSearchResults);
$("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;
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 });
})();
$("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;
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);
}
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 === $("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 === $("detailMoreBtn")) return { id: "detailInfo", root: el.closest(".detail-info") };
if (el === $("detailSearchBtn") || el === $("panSearchBtn")) return { id: "detailActions", root: el.closest(".actions") };
const ids = ["panSearchBlock", "seasonBlock", "castBlock", "personWorkBlock"];
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 = $("detailMoreBtn") && $("detailMoreBtn").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"].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, #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;
function visibleFocusable() {
// 用 rAF frame 计数作为缓存 key,每帧最多执行一次 querySelectorAll
var now = _focusListFrame;
if (_focusListCache && now === _focusListFrame) return _focusListCache;
var root = focusScopeRoot();
_focusListCache = Array.from(root.querySelectorAll(".focusable,button,input,textarea"))
.filter(isVisibleFocusable);
return _focusListCache;
}
// 每次布局变化时主动清除缓存
function invalidateFocusListCache() { _focusListCache = null; }
// 每帧开始时清除(通过 rAF tick 驱动)
(function tickFocusCache() {
_focusListCache = null;
requestAnimationFrame(tickFocusCache);
})();
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;
}
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);
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.replaceState({ sheet: "home", scrollY: _sy }, "", location.pathname + location.search);
history.pushState({ sheet: "home", scrollY: _sy }, "", location.pathname + location.search);
if (_sy > 0) {
window.scrollTo(0, _sy);
document.documentElement.scrollTop = _sy;
document.body.scrollTop = _sy;
}
}
}
window.addEventListener("popstate", (event) => {
const detailSheet = $("detailSheet");
// 正在关闭动画中,忽略重复触发
const isClosing = detailSheet && detailSheet.classList.contains("sheet-closing");
if (isClosing) return;
// If pan results are focused and user pressed back, re-anchor to #detail instead of leaving
if (location.hash !== "#detail" && detailSheet && detailSheet.classList.contains("active") && state.pan.focusMode === "results") {
history.pushState({ sheet: "detail" }, "", "#detail");
focusPanTabFromResults();
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;
}
// 搜索结果页:清空搜索结果,回到主页推荐
if (location.hash !== "#search" && state.searchItems && state.searchItems.length) {
clearSearchResults();
requestAnimationFrame(() => focusInitialHomeNow());
return;
}
// 主页面:所有 sheet 都已关闭。
// 如果 hash 是空(说明用户 back 到了主页根路由),推入新哨兵,防止再 back 退出 App
// 注意:只有在没有任何 sheet 的情况下才允许这么做
if (!location.hash) {
const anySheetActive = (
($("detailSheet") && $("detailSheet").classList.contains("active")) ||
($("syncSheet") && $("syncSheet").classList.contains("active")) ||
($("imageViewer") && $("imageViewer").classList.contains("active"))
);
if (!anySheetActive) {
// 重新推入主页哨兵,让下一次 back 有地方可退而不是退出 App
// pushState / history.back() 都会把 window.scrollY 重置为 0manual 模式下无自动恢复)
// 优先用 closeDetail 在 history.back() 前保存的值
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;
}
}
}
});
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", () => scheduleWebHomeResume());
window.addEventListener("fmsdk", () => {
initPanConfig({ preserveDirty: true, timeout: 0 })
.then(loadInfo)
.then(() => scheduleWebHomeResume({ force: true }))
.catch(() => {});
});
window.addEventListener("fmresume", () => { 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"));
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 = "sync";
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>
<div class="hero-glow" id="heroGlow">
<div class="hero-orb-1"></div>
<div class="hero-orb-2"></div>
<div class="hero-orb-3"></div>
<div class="hero-orb-4"></div>
<div class="hero-orb-5"></div>
<div class="hero-orb-6"></div>
<div class="hero-noise"></div>
</div>
<style>
/* ═══════════════════════════════════════════════════════
Full-screen Random Aurora — orbs roam the entire viewport
═══════════════════════════════════════════════════════ */
.hero-glow {
position: fixed;
inset: 0;
z-index: -3;
pointer-events: none;
overflow: hidden;
}
/* shared orb base */
.hero-orb-1,.hero-orb-2,.hero-orb-3,
.hero-orb-4,.hero-orb-5,.hero-orb-6 {
position: absolute;
border-radius: 50%;
will-change: transform;
contain: strict;
}
/* ── Orb 1: rose-pink — starts top-left, roams full screen ── */
.hero-orb-1 {
width: 80vw; height: 80vw;
left: 10vw; top: 5vh;
background: radial-gradient(circle at 45% 45%,
rgba(255, 80, 150, .28) 0%,
rgba(200, 40, 110, .12) 45%,
transparent 70%);
filter: blur(90px);
animation: orb1 23s ease-in-out infinite alternate;
}
@keyframes orb1 {
0% { transform: translate(0, 0); opacity:.72; }
15% { transform: translate(55vw, 22vh); }
30% { transform: translate(20vw, 65vh); opacity:.42; }
45% { transform: translate(70vw, 55vh); }
60% { transform: translate(-5vw, 80vh); opacity:.68; }
75% { transform: translate(45vw, 10vh); }
90% { transform: translate(10vw, 40vh); opacity:.38; }
100% { transform: translate(65vw, 75vh); opacity:.72; }
}
/* ── Orb 2: violet — starts bottom-right, sweeps diagonally ── */
.hero-orb-2 {
width: 75vw; height: 75vw;
left: 30vw; top: 30vh;
background: radial-gradient(circle at 55% 55%,
rgba(120, 60, 255, .30) 0%,
rgba(80, 20, 200, .15) 45%,
transparent 70%);
filter: blur(100px);
animation: orb2 29s ease-in-out infinite alternate;
animation-delay: -8s;
}
@keyframes orb2 {
0% { transform: translate(0, 0); opacity:.68; }
20% { transform: translate(-35vw, -25vh); }
35% { transform: translate(40vw, -40vh); opacity:.32; }
50% { transform: translate(-20vw, 50vh); }
65% { transform: translate(55vw, 30vh); opacity:.65; }
80% { transform: translate(-10vw, -10vh); }
100% { transform: translate(30vw, 60vh); opacity:.40; }
}
/* ── Orb 3: teal/emerald — center, wide wandering ── */
.hero-orb-3 {
width: 65vw; height: 65vw;
left: 20vw; top: 20vh;
background: radial-gradient(circle at 50% 50%,
rgba(20, 210, 160, .20) 0%,
rgba(10, 160, 120, .10) 50%,
transparent 72%);
filter: blur(110px);
animation: orb3 35s ease-in-out infinite alternate;
animation-delay: -14s;
}
@keyframes orb3 {
0% { transform: translate(0, 0); opacity:.60; }
12% { transform: translate(50vw, -30vh); }
28% { transform: translate(-30vw, 40vh); opacity:.28; }
44% { transform: translate(60vw, 70vh); }
58% { transform: translate(10vw, -20vh); opacity:.55; }
72% { transform: translate(-20vw, 80vh); }
88% { transform: translate(40vw, 20vh); opacity:.25; }
100% { transform: translate(-10vw, -35vh); opacity:.60; }
}
/* ── Orb 4: amber/orange — top-right to bottom-left ── */
.hero-orb-4 {
width: 60vw; height: 60vw;
left: 50vw; top: -10vh;
background: radial-gradient(circle at 50% 45%,
rgba(255, 140, 30, .22) 0%,
rgba(230, 80, 20, .11) 52%,
transparent 72%);
filter: blur(95px);
animation: orb4 27s ease-in-out infinite alternate;
animation-delay: -5s;
}
@keyframes orb4 {
0% { transform: translate(0, 0); opacity:.62; }
18% { transform: translate(-60vw, 30vh); }
36% { transform: translate(-20vw, 80vh); opacity:.30; }
52% { transform: translate(-50vw, -15vh); }
68% { transform: translate(10vw, 55vh); opacity:.58; }
84% { transform: translate(-40vw, 10vh); }
100% { transform: translate(-70vw, 70vh); opacity:.32; }
}
/* ── Orb 5: deep blue — bottom-left corner outward ── */
.hero-orb-5 {
width: 70vw; height: 70vw;
left: -10vw; top: 60vh;
background: radial-gradient(circle at 45% 50%,
rgba(40, 120, 255, .22) 0%,
rgba(20, 60, 200, .10) 48%,
transparent 70%);
filter: blur(105px);
animation: orb5 31s ease-in-out infinite alternate;
animation-delay: -19s;
}
@keyframes orb5 {
0% { transform: translate(0, 0); opacity:.58; }
22% { transform: translate(70vw, -55vh); }
40% { transform: translate(30vw, -80vh); opacity:.24; }
56% { transform: translate(80vw, 10vh); }
72% { transform: translate(20vw, -40vh); opacity:.55; }
88% { transform: translate(55vw, -70vh); }
100% { transform: translate(10vw, -20vh); opacity:.26; }
}
/* ── Orb 6: magenta accent — right edge, mid-screen ── */
.hero-orb-6 {
width: 55vw; height: 55vw;
left: 60vw; top: 35vh;
background: radial-gradient(circle at 50% 50%,
rgba(220, 60, 200, .18) 0%,
rgba(180, 30, 160, .08) 50%,
transparent 72%);
filter: blur(85px);
animation: orb6 25s ease-in-out infinite alternate;
animation-delay: -11s;
}
@keyframes orb6 {
0% { transform: translate(0, 0); opacity:.52; }
16% { transform: translate(-70vw, -30vh); }
32% { transform: translate(-40vw, 40vh); opacity:.22; }
50% { transform: translate(-80vw, -50vh); }
66% { transform: translate(-20vw, 60vh); opacity:.48; }
82% { transform: translate(-60vw, 10vh); }
100% { transform: translate(-30vw, -60vh); opacity:.24; }
}
/* ── Noise grain overlay ── */
.hero-noise {
position: absolute;
inset: 0;
opacity: .025;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
background-size: 256px 256px;
mix-blend-mode: overlay;
pointer-events: none;
}
/* ══════════ Card shine + liquid glass border ══════════ */
.card {
position: relative;
transform: translateZ(0);
}
/* TV 模式:卡片背景不透明,确保焦点环有足够对比度 */
html.tv-mode .card {
background: rgba(30, 38, 52, 0.88) !important;
}
.card::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
background: linear-gradient(
135deg,
rgba(255, 255, 255, .13),
transparent 44%,
rgba(255, 255, 255, .04)
);
opacity: 0;
transition: opacity .3s ease;
pointer-events: none;
}
.card:hover::before,
.card:focus::before {
opacity: 1;
}
/* Luminous border via mask */
.card::after {
content: "";
position: absolute;
inset: -1px;
border-radius: inherit;
padding: 1px;
background: linear-gradient(
135deg,
rgba(255, 255, 255, .22),
rgba(130, 180, 255, .18) 45%,
rgba(180, 120, 255, .22)
);
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
opacity: .5;
pointer-events: none;
transition: opacity .3s ease;
}
.card:hover::after {
opacity: .9;
}
</style>
</body>
</html>