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

10287 lines
386 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: 100vh;
--fm-safe-bottom: 20px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "PingFang SC", "Microsoft YaHei", sans-serif;
}
@supports (height: 100dvh) {
:root { --fm-web-height: 100dvh; }
}
* { 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;
}
body.detail-active .app {
visibility: hidden;
pointer-events: none;
}
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);
}
.btn.link {
min-height: 30px;
padding: 0;
border: 0;
background: transparent;
color: rgba(184, 226, 255, .92);
font-size: 13px;
font-weight: 760;
text-shadow: 0 1px 2px rgba(0, 0, 0, .38);
backdrop-filter: none;
}
.search {
width: 100%;
min-width: 0;
display: flex;
align-items: stretch;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
margin: 14px 0 8px;
position: relative;
z-index: 20;
}
.search .field {
flex: 1 1 auto;
min-width: 0;
}
.search .btn {
flex: 0 0 auto;
margin-left: 10px;
}
@supports (display: grid) {
.search .btn { margin-left: 0; }
}
.search.suggesting {
z-index: 95;
}
.suggest-panel {
position: absolute;
left: 0;
right: 0;
top: calc(100% + 8px);
z-index: 1;
display: none;
max-height: 460px;
max-height: 54vh;
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: block; display: grid; gap: 6px; }
.suggest-panel::-webkit-scrollbar { display: none; }
.suggest-item {
min-width: 0;
display: flex;
justify-content: space-between;
gap: 8px;
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 {
width: 100%;
max-width: 100%;
min-width: 0;
display: flex;
gap: 8px;
overflow-x: auto;
padding: 3px 0 2px;
overscroll-behavior-x: contain;
}
.chips::-webkit-scrollbar,
.rail::-webkit-scrollbar { display: none; }
.chip {
flex: 0 0 auto;
min-height: 36px;
padding: 0 12px;
border-radius: 999px;
background: var(--control);
border: 1px solid var(--line);
color: var(--text);
font-weight: 700;
font-size: 13px;
text-shadow: 0 1px 2px rgba(0, 0, 0, .38);
backdrop-filter: blur(12px) saturate(1.05);
}
.chip.active {
background: var(--control-active);
color: var(--text);
border-color: var(--line-strong);
}
.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 {
margin-top: 0;
}
html.tv-mode .list-block {
overflow: visible;
}
.subsection-head {
min-width: 0;
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin-bottom: 9px;
}
.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: none;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
touch-action: pan-x pan-y;
padding: 2px 0 5px;
}
html.tv-mode .rail {
overflow-y: visible;
padding: 10px 5px 14px;
}
.media-grid {
width: 100%;
max-width: 100%;
min-width: 0;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
html.tv-mode .media-grid {
overflow: visible;
padding: 10px 5px 14px;
}
.list-panel[hidden] {
display: none !important;
}
.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;
backdrop-filter: blur(10px) saturate(1.05);
}
.media-grid .card {
width: 100%;
max-width: none;
flex: none;
contain: layout style;
}
.poster {
aspect-ratio: 2 / 3;
width: 100%;
object-fit: cover;
}
.poster-wrap {
position: relative;
width: calc(100% + 2px);
max-width: calc(100% + 2px);
margin: -1px -1px 0;
overflow: hidden;
}
.poster-wrap .poster {
width: 100%;
max-width: 100%;
}
.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-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);
}
.block-select-hint {
display: none;
margin: 6px 0 10px;
color: rgba(244, 247, 251, .78);
font-size: 13px;
line-height: 1.35;
}
body.block-select-active .block-select-hint {
display: block;
}
body.block-select-active #recommendRail .card {
outline: 1px solid rgba(255, 255, 255, .18);
outline-offset: -1px;
}
body.block-select-active #recommendRail .card::after {
content: "按 OK 屏蔽";
position: absolute;
top: 7px;
left: 7px;
z-index: 5;
min-height: 24px;
display: inline-flex;
align-items: center;
padding: 0 8px;
border-radius: 999px;
background: rgba(6, 9, 12, .68);
border: 1px solid rgba(255, 255, 255, .18);
color: rgba(255, 255, 255, .9);
font-size: 11px;
font-weight: 800;
line-height: 1;
text-shadow: 0 1px 2px rgba(0, 0, 0, .5);
}
body.block-select-active #recommendRail .card.blocked {
opacity: .62;
border-color: rgba(240, 157, 157, .55);
}
body.block-select-active #recommendRail .card.blocked::after {
content: "已屏蔽";
background: rgba(132, 38, 38, .78);
border-color: rgba(255, 210, 210, .24);
color: rgba(255, 240, 240, .95);
}
body.block-select-active #recommendRail .card.blocked img {
filter: grayscale(.3) brightness(.7);
}
.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-people {
position: absolute;
right: 7px;
bottom: 7px;
z-index: 2;
min-height: 22px;
display: inline-flex;
align-items: center;
padding: 0 7px;
border-radius: 999px;
background: rgba(6, 9, 12, .48);
border: 1px solid rgba(255, 255, 255, .16);
color: rgba(255, 255, 255, .88);
font-size: 11px;
font-weight: 760;
line-height: 1;
text-shadow: 0 1px 2px rgba(0, 0, 0, .46);
pointer-events: none;
}
.person-info {
display: block;
padding: 12px;
border-radius: var(--radius);
background: var(--panel-soft);
border: 1px solid var(--line);
backdrop-filter: blur(10px) saturate(1.05);
}
.person-info-body {
min-width: 0;
}
.person-info-body p {
margin: 8px 0 0;
color: var(--muted);
font-size: 12px;
line-height: 1.45;
}
.live-entry-card {
width: 100%;
min-height: 96px;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
padding: 12px;
border-radius: var(--radius);
background: linear-gradient(135deg, rgba(var(--panel-rgb), .72), rgba(34, 43, 51, .36));
border: 1px solid var(--line);
color: var(--text);
text-align: left;
box-shadow: 0 12px 30px rgba(0, 0, 0, .14);
backdrop-filter: blur(12px) saturate(1.05);
}
.live-icon {
width: 42px;
height: 42px;
display: grid;
place-items: center;
border-radius: var(--radius);
background: rgba(255, 255, 255, .1);
border: 1px solid rgba(255, 255, 255, .18);
}
.live-icon .icon {
width: 21px;
height: 21px;
}
.live-copy {
min-width: 0;
}
.live-copy h5 {
margin: 0;
font-size: 16px;
line-height: 1.2;
}
.live-copy p {
margin: 5px 0 0;
color: var(--muted);
font-size: 12px;
line-height: 1.35;
}
.live-tags {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin-top: 8px;
}
.live-tag {
min-height: 21px;
display: inline-flex;
align-items: center;
padding: 0 7px;
border-radius: 999px;
background: rgba(255, 255, 255, .08);
border: 1px solid rgba(255, 255, 255, .14);
color: rgba(238, 244, 250, .86);
font-size: 11px;
font-weight: 720;
line-height: 1;
}
.live-action {
min-height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 10px;
border-radius: var(--radius);
background: rgba(255, 255, 255, .12);
border: 1px solid rgba(255, 255, 255, .2);
font-size: 12px;
font-weight: 800;
white-space: nowrap;
}
.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 {
--sheet-x: 14px;
display: none;
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
inset: 0;
z-index: 60;
width: 100%;
max-width: 100%;
min-width: 0;
height: 100vh;
min-height: var(--fm-web-height);
background: transparent;
backdrop-filter: none;
overflow-y: auto;
overflow-x: hidden;
padding: max(16px, env(safe-area-inset-top)) var(--sheet-x) calc(22px + var(--fm-safe-bottom) + env(safe-area-inset-bottom));
}
.sheet.active { display: block; }
.detail-cover {
position: relative;
width: 100%;
max-width: 100%;
height: 56.25vw;
min-height: 180px;
max-height: 42vh;
aspect-ratio: 16 / 9;
border-radius: var(--radius);
overflow: hidden;
background: transparent;
margin: 14px 0;
}
.detail-cover.loading::before {
content: "";
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
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;
top: 0;
right: 0;
bottom: 0;
left: 0;
inset: 0;
width: 100%;
height: 100%;
object-fit: contain;
object-position: left center;
opacity: 0;
transition: opacity .18s ease;
}
.detail-cover img.active {
opacity: 1;
}
.detail-cover.poster-mode {
background: transparent !important;
background-image: none !important;
}
.detail-cover.poster-mode::before {
display: none !important;
}
.detail-cover.poster-mode::after {
display: none !important;
}
.detail-cover.poster-mode img {
top: 0;
right: 0;
bottom: 0;
left: 0;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover !important;
object-position: center 24%;
z-index: 1;
}
@media (max-width: 719px) {
html:not(.tv-mode) #closeDetailBtn,
html:not(.tv-mode) #imageViewer.episode-mode #closeImageBtn {
display: none;
}
html:not(.tv-mode) #detailSheet:not(.detail-large) .detail-cover {
border-radius: 14px;
overflow: hidden;
isolation: isolate;
}
html:not(.tv-mode) #detailSheet:not(.detail-large) .detail-cover::before,
html:not(.tv-mode) #detailSheet:not(.detail-large) .detail-cover img {
border-radius: inherit;
}
html:not(.tv-mode) .detail-cover.has-multiple {
touch-action: pan-y;
}
}
html.tv-mode .detail-cover {
width: min(calc(100vw - 68px), calc(clamp(360px, 58vh, 680px) * 16 / 9));
max-width: none;
height: auto;
min-height: 180px;
max-height: none;
aspect-ratio: 16 / 9;
margin-top: 0;
}
html.tv-mode #closeDetailBtn,
#detailSheet.detail-large #closeDetailBtn {
display: none;
}
@media (max-height: 760px) {
html.tv-mode .detail-cover {
width: min(calc(100vw - 68px), calc(clamp(320px, 56vh, 430px) * 16 / 9));
}
}
.detail-info {
padding: 0 2px 12px;
}
.detail-title-row {
display: flex;
align-items: baseline;
gap: 8px;
flex-wrap: wrap;
margin: 0 0 8px;
}
.detail-info h2 {
margin: 0 0 8px;
font-size: clamp(22px, 6vw, 32px);
line-height: 1.12;
}
.detail-title-row h2 {
margin: 0;
}
.detail-title-meta {
display: inline-flex;
align-items: baseline;
gap: 6px;
color: rgba(244, 247, 251, .9);
font-size: 13px;
font-weight: 720;
line-height: 1.2;
text-shadow: 0 1px 2px rgba(0, 0, 0, .38);
}
.detail-title-meta .score {
color: rgba(255, 218, 128, .95);
font-weight: 820;
}
.detail-title-meta .sep {
color: rgba(244, 247, 251, .42);
font-weight: 600;
}
.detail-info p {
margin: 0;
color: rgba(244, 247, 251, .82);
line-height: 1.5;
font-size: 14px;
}
.detail-info p.clamped {
max-height: var(--detail-text-max, none);
overflow: hidden;
}
#detailMoreBtn {
margin-top: 2px;
display: none;
}
.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;
line-height: 1.2;
}
.meta-pill.strong {
color: var(--text);
border-color: var(--line-strong);
background: var(--control-active);
font-weight: 760;
}
.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;
contain: layout style;
}
.episode-card.has-still {
padding: 0;
overflow: hidden;
}
.episode-card.has-still .episode-body {
padding: 8px 9px 9px;
}
.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.18;
}
.person-card b {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.episode-card span,
.person-card span {
display: block;
margin-top: 3px;
color: var(--muted);
font-size: 12px;
line-height: 1.22;
}
#detailSheet .episode-card .episode-badge {
display: none;
}
.person-card span {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
word-break: break-word;
}
.episode-card p {
margin: 3px 0 0;
color: rgba(244, 247, 251, .78);
font-size: 12px;
line-height: 1.26;
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
}
.person-card {
flex: 0 0 116px;
max-width: 128px;
height: 244px;
display: flex;
flex-direction: column;
border-radius: var(--radius);
background: var(--panel);
border: 1px solid var(--line);
color: var(--text);
overflow: hidden;
padding: 0;
text-align: left;
scroll-snap-align: start;
contain: layout style;
}
.person-card img {
width: calc(100% + 2px);
max-width: calc(100% + 2px);
height: 176px;
flex: 0 0 176px;
margin: -1px -1px 0;
object-fit: cover;
object-position: center top;
background: rgba(255, 255, 255, .04);
}
.person-card div {
flex: 1 1 auto;
min-height: 0;
min-width: 0;
padding: 8px 9px 9px;
overflow: hidden;
}
#detailSheet.detail-large {
--detail-side-pad: clamp(42px, 5.4vw, 80px);
--detail-top-pad: clamp(46px, 7.2vh, 82px);
background: #06111b;
padding: var(--detail-top-pad) var(--detail-side-pad) calc(34px + var(--fm-safe-bottom) + env(safe-area-inset-bottom));
overflow-y: auto;
overflow-x: hidden;
}
#detailSheet.detail-large::before,
#detailSheet.detail-large::after {
content: "";
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
inset: 0;
pointer-events: none;
z-index: 0;
}
#detailSheet.detail-large::before {
background-image:
linear-gradient(90deg, rgba(4, 12, 19, .98) 0%, rgba(4, 12, 19, .88) 30%, rgba(4, 12, 19, .42) 58%, rgba(4, 12, 19, .54) 100%),
linear-gradient(180deg, rgba(4, 12, 19, .08) 0%, rgba(4, 12, 19, .66) 72%, #06111b 100%),
var(--detail-hero-bg, none);
background-size: cover;
background-position: center, center, right top;
opacity: .98;
}
#detailSheet.detail-large::after {
background:
linear-gradient(90deg, rgba(6, 17, 27, .18) 0%, rgba(6, 17, 27, 0) 46%, rgba(6, 17, 27, .3) 100%),
linear-gradient(180deg, transparent 0%, rgba(6, 17, 27, .78) 56%, #06111b 100%);
}
#detailSheet.detail-large > * {
position: relative;
z-index: 2;
}
#detailSheet.detail-large .detail-cover {
position: fixed;
top: 0;
right: 0;
z-index: 1;
width: min(68vw, 940px);
max-width: none;
height: min(66vh, 620px);
min-height: 360px;
max-height: none;
margin: 0;
aspect-ratio: auto;
border-radius: 0;
overflow: hidden;
pointer-events: none;
background: transparent;
}
#detailSheet.detail-large .detail-cover[data-cover-mode="landscape"] {
-webkit-mask-image: linear-gradient(90deg, transparent 0%, rgba(0, 0, 0, .28) 3%, rgba(0, 0, 0, .76) 7%, #000 12%, #000 100%);
mask-image: linear-gradient(90deg, transparent 0%, rgba(0, 0, 0, .28) 3%, rgba(0, 0, 0, .76) 7%, #000 12%, #000 100%);
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
-webkit-mask-size: 100% 100%;
mask-size: 100% 100%;
}
#detailSheet.detail-large .detail-cover[data-cover-mode="landscape"]::before {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 0;
z-index: 1;
width: 16%;
pointer-events: none;
background-image: var(--detail-cover-bg, none);
background-size: cover;
background-position: center top;
opacity: .16;
filter: blur(8px) saturate(.92);
transform: scale(1.04);
transform-origin: left center;
-webkit-mask-image: linear-gradient(90deg, transparent 0%, #000 36%, rgba(0, 0, 0, .66) 68%, transparent 100%);
mask-image: linear-gradient(90deg, transparent 0%, #000 36%, rgba(0, 0, 0, .66) 68%, transparent 100%);
}
#detailSheet.detail-large .detail-cover::after {
content: "";
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
inset: 0;
z-index: 2;
background:
linear-gradient(90deg, #06111b 0%, rgba(6, 17, 27, .78) 5%, rgba(6, 17, 27, .34) 13%, rgba(6, 17, 27, .1) 24%, rgba(6, 17, 27, .1) 100%),
linear-gradient(180deg, rgba(6, 17, 27, .1) 0%, rgba(6, 17, 27, .06) 56%, #06111b 100%);
}
#detailSheet.detail-large .detail-cover.poster-mode::after {
display: block !important;
}
#detailSheet.detail-large .detail-cover img {
object-fit: cover !important;
object-position: center top;
filter: saturate(.92) contrast(.98) brightness(.76);
}
#detailSheet.detail-large .detail-cover[data-cover-mode="landscape"] img {
transform: translateZ(0) scale(1.012);
transform-origin: center top;
}
#detailSheet.detail-large .detail-cover.poster-mode img {
object-fit: cover !important;
object-position: center 18%;
}
#detailSheet.detail-large .detail-info,
#detailSheet.detail-large > .actions,
#detailSheet.detail-large .detail-block {
max-width: min(760px, 54vw);
}
#detailSheet.detail-large .detail-info {
min-height: 0;
padding: 0;
}
#detailSheet.detail-large .detail-title-row {
gap: 20px;
align-items: baseline;
margin: 0 0 22px;
}
#detailSheet.detail-large .detail-info h2 {
font-size: clamp(38px, 5.2vw, 64px);
line-height: 1.04;
font-weight: 850;
letter-spacing: 0;
text-shadow: 0 3px 16px rgba(0, 0, 0, .48);
}
#detailSheet.detail-large .detail-title-meta {
gap: 0;
font-size: clamp(20px, 2.3vw, 28px);
line-height: 1;
font-weight: 820;
color: rgba(244, 188, 129, .96);
}
#detailSheet.detail-large .detail-title-meta .score {
color: rgba(244, 188, 129, .96);
}
#detailSheet.detail-large .detail-title-meta .sep {
display: none;
}
#detailSheet.detail-large .detail-meta {
gap: 10px;
margin: 0 0 18px;
}
#detailSheet.detail-large .meta-pill {
min-height: 34px;
display: inline-flex;
align-items: center;
border-radius: 6px;
padding: 0 12px;
color: rgba(245, 249, 252, .92);
background: rgba(65, 82, 96, .52);
border-color: rgba(255, 255, 255, .14);
font-size: 17px;
font-weight: 620;
backdrop-filter: none;
}
#detailSheet.detail-large .detail-info p {
max-width: 690px;
color: rgba(232, 239, 247, .82);
font-size: clamp(15px, 1.45vw, 18px);
line-height: 1.72;
text-shadow: 0 2px 10px rgba(0, 0, 0, .42);
}
#detailSheet.detail-large #detailMoreBtn {
min-height: 30px;
margin: 4px 0 0;
padding: 0;
border: 0;
background: transparent;
color: rgba(129, 190, 255, .92);
font-size: 15px;
box-shadow: none;
}
#detailSheet.detail-large > .actions {
gap: 16px;
margin-top: 24px;
}
#detailSheet.detail-large > .actions .btn {
min-width: 144px;
min-height: 58px;
border-radius: 8px;
padding: 0 26px;
color: rgba(246, 250, 253, .92);
background: rgba(54, 68, 80, .56);
border-color: rgba(255, 255, 255, .12);
box-shadow: none;
font-size: clamp(18px, 1.8vw, 22px);
font-weight: 820;
backdrop-filter: none;
text-shadow: none;
}
#detailSheet.detail-large > .actions .btn:focus {
color: #06111b;
background: rgba(249, 251, 253, .96);
border-color: rgba(255, 255, 255, .9);
box-shadow: 0 16px 36px rgba(0, 0, 0, .28), 0 0 0 1px rgba(255, 255, 255, .1) inset;
}
#detailSheet.detail-large > .actions .btn:focus .icon {
color: #06111b;
}
#detailSheet.detail-large .detail-block {
margin-top: 34px;
}
#detailSheet.detail-large #seasonBlock,
#detailSheet.detail-large #castBlock,
#detailSheet.detail-large #recommendBlock,
#detailSheet.detail-large #personWorkBlock {
max-width: none;
}
#detailSheet.detail-large #seasonBlock {
margin-top: clamp(34px, 5.2vh, 58px);
}
#detailSheet.detail-large .detail-block h3 {
margin: 0 0 14px;
font-size: clamp(21px, 2vw, 28px);
font-weight: 820;
text-shadow: 0 2px 12px rgba(0, 0, 0, .45);
}
#detailSheet.detail-large #seasonTabs {
max-width: min(760px, 54vw);
padding-bottom: 8px;
}
#detailSheet.detail-large #seasonTabs:empty {
display: none;
}
#detailSheet.detail-large .rail {
gap: 18px;
padding: 6px 6px 16px;
overflow-y: visible;
}
#detailSheet.detail-large .episode-card {
flex: 0 0 clamp(210px, 18.4vw, 286px);
max-width: clamp(210px, 18.4vw, 286px);
min-height: 0;
aspect-ratio: 16 / 9;
padding: 0;
overflow: hidden;
position: relative;
border-radius: 7px;
background: rgba(32, 45, 58, .74);
border-color: rgba(255, 255, 255, .15);
box-shadow: 0 12px 28px rgba(0, 0, 0, .24);
}
#detailSheet.detail-large .episode-card::before {
content: "";
position: absolute;
right: 0;
bottom: 0;
left: 0;
z-index: 1;
height: 62%;
background: linear-gradient(180deg, rgba(0, 0, 0, 0), rgba(4, 10, 16, .72) 55%, rgba(4, 10, 16, .92));
pointer-events: none;
}
#detailSheet.detail-large .episode-still {
width: 100%;
max-width: 100%;
height: 100%;
margin: 0;
aspect-ratio: auto;
object-fit: cover;
opacity: .92;
}
html.tv-mode #detailSheet.detail-large .episode-badge {
display: inline-flex;
position: absolute;
top: 8px;
left: 8px;
z-index: 2;
min-height: 26px;
align-items: center;
padding: 0 8px;
border-radius: 5px;
background: rgba(54, 119, 199, .84);
border: 1px solid rgba(255, 255, 255, .18);
color: rgba(255, 255, 255, .94);
font-size: 14px;
font-weight: 780;
line-height: 1;
}
.episode-badge {
display: none;
}
#detailSheet.detail-large .episode-body {
position: absolute;
right: 0;
bottom: 0;
left: 0;
z-index: 2;
padding: 0 12px 12px;
}
#detailSheet.detail-large .episode-card b {
color: #fff;
font-size: clamp(15px, 1.35vw, 18px);
font-weight: 800;
line-height: 1.18;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-shadow: 0 2px 8px rgba(0, 0, 0, .72);
}
#detailSheet.detail-large .episode-card span:not(.episode-badge),
#detailSheet.detail-large .episode-card p {
display: none;
}
#detailSheet.detail-large .person-card {
flex: 0 0 clamp(240px, 20vw, 310px);
max-width: clamp(240px, 20vw, 310px);
height: 96px;
display: grid;
grid-template-columns: 96px minmax(0, 1fr);
align-items: center;
border-radius: 8px;
background: transparent;
border-color: transparent;
box-shadow: none;
}
#detailSheet.detail-large .person-card img {
width: 96px;
max-width: 96px;
height: 96px;
flex: none;
margin: 0;
aspect-ratio: auto;
border-radius: 999px;
object-fit: cover;
object-position: center 18%;
background: rgba(255, 255, 255, .08);
}
#detailSheet.detail-large .person-card div {
padding: 0 0 0 18px;
}
#detailSheet.detail-large .person-card b {
font-size: clamp(18px, 1.55vw, 24px);
font-weight: 820;
line-height: 1.08;
text-shadow: 0 2px 10px rgba(0, 0, 0, .5);
}
#detailSheet.detail-large .person-card span {
margin-top: 7px;
color: rgba(232, 239, 247, .58);
font-size: clamp(14px, 1.25vw, 18px);
line-height: 1.08;
-webkit-line-clamp: 1;
}
#detailSheet.detail-large .card.landscape-card {
flex: 0 0 clamp(222px, 18.6vw, 300px);
max-width: clamp(222px, 18.6vw, 300px);
aspect-ratio: 16 / 9;
border-radius: 8px;
background: rgba(34, 47, 60, .7);
border-color: rgba(255, 255, 255, .13);
box-shadow: 0 12px 28px rgba(0, 0, 0, .22);
}
#detailSheet.detail-large .landscape-card .poster {
width: 100%;
max-width: 100%;
height: 100%;
margin: 0;
aspect-ratio: auto;
object-fit: cover;
opacity: .88;
}
#detailSheet.detail-large .landscape-card .card-body {
position: absolute;
right: 0;
bottom: 0;
left: 0;
z-index: 2;
padding: 38px 12px 11px;
border-top: 0;
background: linear-gradient(180deg, rgba(0, 0, 0, 0), rgba(3, 8, 14, .76) 48%, rgba(3, 8, 14, .92));
}
#detailSheet.detail-large .landscape-card .card-title {
font-size: 16px;
line-height: 1.18;
min-height: 0;
}
#detailSheet.detail-large .landscape-card .card-meta,
#detailSheet.detail-large .landscape-card .rating-badge {
display: none;
}
#detailSheet.detail-large .pan-search-block.active {
max-width: min(980px, calc(100vw - var(--detail-side-pad) * 2));
}
@media (max-width: 960px) {
#detailSheet.detail-large {
--detail-side-pad: 38px;
}
#detailSheet.detail-large .detail-info,
#detailSheet.detail-large > .actions,
#detailSheet.detail-large .detail-block,
#detailSheet.detail-large #seasonTabs {
max-width: min(720px, 68vw);
}
#detailSheet.detail-large .detail-cover {
width: 72vw;
}
}
.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.show {
opacity: 1;
visibility: visible;
pointer-events: auto;
transform: translateY(0);
}
.connection-dock {
position: relative;
z-index: 50;
width: fit-content;
max-width: 100%;
justify-self: end;
margin-left: auto;
margin: 0;
border-radius: var(--radius);
border: 1px solid var(--line);
background: var(--control);
box-shadow: 0 12px 32px rgba(0, 0, 0, .2);
backdrop-filter: blur(12px) saturate(1.05);
overflow: visible;
}
.connection-toggle {
width: auto;
min-height: 34px;
display: grid;
grid-template-columns: auto auto auto;
align-items: center;
gap: 7px;
padding: 0 9px;
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: 80;
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-item.refresh-progress {
align-items: start;
}
.connection-item.refresh-progress span {
white-space: pre-line;
}
.relay-mirror-config {
display: grid;
gap: 8px;
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid rgba(255, 255, 255, .12);
}
.relay-mirror-config label {
display: grid;
gap: 5px;
color: var(--muted);
font-size: 12px;
font-weight: 700;
}
.relay-mirror-config .field {
width: 100%;
min-height: 36px;
padding: 8px 10px;
font-size: 12px;
}
.relay-mirror-config textarea.field {
min-height: 68px;
}
.relay-mirror-progress {
min-height: 58px;
padding: 8px 10px;
border: 1px solid rgba(255, 255, 255, .12);
border-radius: var(--radius);
color: var(--text);
background: rgba(0, 0, 0, .16);
font-size: 12px;
line-height: 1.45;
white-space: pre-line;
word-break: break-word;
}
.connection-body .actions {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(96px, 1fr));
gap: 8px;
}
.connection-body .btn {
min-width: 0;
padding: 0 10px;
font-size: 12px;
white-space: nowrap;
}
.pan-config {
display: grid;
gap: 8px;
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid rgba(255, 255, 255, .12);
}
.pan-config label {
display: grid;
gap: 5px;
color: var(--muted);
font-size: 12px;
line-height: 1.35;
}
.pan-config .field {
min-height: 38px;
font-size: 12px;
}
.pan-disk-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(70px, 1fr));
gap: 6px;
}
.pan-disk-grid label {
min-width: 0;
min-height: 32px;
display: flex;
align-items: center;
gap: 6px;
padding: 0 8px;
border-radius: var(--radius);
border: 1px solid rgba(255, 255, 255, .14);
background: rgba(255, 255, 255, .04);
color: var(--text);
font-size: 12px;
font-weight: 720;
}
.pan-disk-grid input {
width: 14px;
height: 14px;
accent-color: rgba(244, 247, 251, .82);
}
.pan-disk-grid label:focus-within {
background: rgba(70, 85, 96, .72);
border-color: var(--focus-line);
color: var(--text);
}
.panel-editing {
background: rgba(24, 36, 46, .88) !important;
border-color: rgba(184, 226, 255, .9) !important;
}
.pan-search-block {
display: none;
margin-top: 18px;
}
.pan-search-block.active {
display: block;
}
.pan-result-list {
display: grid;
gap: 9px;
align-content: flex-start;
align-items: start;
grid-auto-rows: max-content;
height: 52vh;
max-height: 560px;
min-height: 260px;
overflow-y: auto;
overscroll-behavior-y: contain;
padding-right: 2px;
scrollbar-width: none;
}
.pan-result-list::-webkit-scrollbar { display: none; }
.pan-tabs {
margin: 0 0 9px;
}
.pan-tabs:empty { display: none; }
.pan-tabs .chip {
min-height: 30px;
padding: 0 10px;
font-size: 12px;
}
.pan-tabs .chip.active {
background: rgba(82, 98, 108, .82);
border-color: rgba(184, 226, 255, .74);
}
.pan-result-item {
width: 100%;
min-width: 0;
display: grid;
gap: 7px;
padding: 11px;
border-radius: var(--radius);
border: 1px solid var(--line);
background: var(--panel);
color: var(--text);
text-align: left;
backdrop-filter: blur(10px) saturate(1.05);
contain: layout style;
}
.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;
top: 0;
right: 0;
bottom: 0;
left: 0;
inset: 0;
z-index: 100;
height: 100vh;
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: none;
}
.focusable:not(.back-top):focus,
button:not(.back-top):focus,
input:focus,
textarea:focus {
position: relative;
}
.back-top:focus {
position: fixed;
z-index: 55;
}
.card:focus,
.episode-card:focus,
.person-card:focus {
transform: translateY(-1px) scale(1.035);
border-color: var(--focus-line);
background: rgba(var(--panel-rgb), .66);
box-shadow: none;
}
.card:focus img,
.episode-card:focus img,
.person-card:focus img {
filter: brightness(1.06) saturate(1.04);
}
html.tv-mode .card:focus,
html.tv-mode .episode-card:focus,
html.tv-mode .person-card:focus {
transform: translateY(-1px) scale(1.025);
outline: 2px solid rgba(255, 255, 255, .9);
outline-offset: -2px;
border-color: rgba(255, 255, 255, .96);
background: rgba(var(--panel-rgb), .72);
box-shadow: inset 0 0 0 1px rgba(168, 218, 255, .72);
transition: none;
z-index: 12;
}
html.tv-mode .episode-card:focus {
transform: translateY(-1px) scale(1.03);
}
html.tv-mode .card:focus {
border-color: rgba(255, 255, 255, .98);
box-shadow: inset 0 0 0 1px rgba(168, 218, 255, .74);
}
html.tv-mode .card,
html.tv-mode .chip,
html.tv-mode .btn,
html.tv-mode .mini-icon-btn,
html.tv-mode .connection-toggle,
html.tv-mode .field,
html.tv-mode .pan-result-item,
html.tv-mode .person-info,
html.tv-mode .rating-badge,
html.tv-mode .recent-badge,
html.tv-mode .live-entry-card {
backdrop-filter: none;
}
html.tv-mode .card:focus img,
html.tv-mode .episode-card:focus img,
html.tv-mode .person-card:focus img {
filter: none;
}
.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);
box-shadow: none;
}
.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 {
--sheet-x: 34px;
padding-left: 34px;
padding-right: 34px;
}
.detail-cover {
width: min(100%, 78vw, 960px);
max-width: calc(100vw - 68px);
height: auto;
aspect-ratio: 16 / 9;
min-height: 180px;
max-height: none;
margin: 14px 0;
border-radius: var(--radius);
}
.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 {
--sheet-x: 56px;
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));
}
}
/* TV performance merge: rendering hints and lightweight containment. */
html.tv-mode #recommendRail,
html.tv-mode #searchRail,
html.tv-mode #listStack .media-grid {
transform: translateZ(0);
backface-visibility: hidden;
}
html.tv-mode .media-grid .card,
html.tv-mode #searchRail .card,
html.tv-mode .episode-card,
html.tv-mode .person-card,
html.tv-mode .pan-result-item,
html.tv-mode .live-entry-card {
will-change: transform;
transform: translateZ(0);
backface-visibility: hidden;
}
html.tv-mode .card:focus {
transform: translateY(-1px) scale(1.025) translateZ(0);
}
html.tv-mode .episode-card:focus,
html.tv-mode .person-card:focus {
transform: translateY(-1px) scale(1.03) translateZ(0);
}
html.tv-mode .media-grid,
html.tv-mode .rail {
contain: layout style;
}
</style>
</head>
<body>
<main class="app" id="home">
<form class="search" id="searchForm" autocomplete="off">
<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" id="searchSubmitBtn" 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>
<nav class="chips" id="chips"></nav>
<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">
<h3>Nostr推荐</h3>
<aside class="connection-dock" id="connectionDock">
<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 refresh-progress"><b>刷新</b><span id="statusRefresh">未执行</span></div>
<div class="connection-item"><b>Relays</b><span id="statusRelays">未连接</span></div>
</div>
<div class="pan-config">
<label for="tmdbKeyInput">TMDB Key
<input class="field focusable mono" id="tmdbKeyInput" type="password" inputmode="text" autocomplete="off" placeholder="TMDB API Key" readonly>
</label>
<label for="panBaseInput">盘搜地址
<input class="field focusable" id="panBaseInput" type="url" inputmode="url" placeholder="https://so.252035.xyz" readonly>
</label>
<label for="panChannelsInput">TG频道
<textarea class="field focusable" id="panChannelsInput" rows="2" placeholder="可选,多个频道用逗号或换行分隔" readonly></textarea>
</label>
<label for="panUserInput">账号
<input class="field focusable" id="panUserInput" type="text" autocomplete="username" placeholder="未启用认证可留空" readonly>
</label>
<label for="panPassInput">密码
<input class="field focusable" id="panPassInput" type="password" autocomplete="current-password" placeholder="未启用认证可留空" readonly>
</label>
<div class="pan-disk-grid" id="panDiskGrid"></div>
<button class="btn focusable" id="savePanConfigBtn" type="button">保存配置</button>
</div>
<div class="relay-mirror-config">
<label for="mirrorSourceInput">镜像源 Relays
<textarea class="field focusable mono" id="mirrorSourceInput" rows="3" placeholder="每行一个源 relay" readonly></textarea>
</label>
<label for="mirrorTargetInput">镜像目标 Relays
<textarea class="field focusable mono" id="mirrorTargetInput" rows="2" placeholder="每行一个目标 relay,例如 wss://your-relay.example" readonly></textarea>
</label>
<div class="relay-mirror-progress mono" id="mirrorProgress">镜像未执行</div>
<button class="btn focusable" id="mirrorRelayBtn" type="button">镜像榜单</button>
</div>
<div class="actions" style="margin-top:10px">
<button class="btn focusable" id="refreshNostrBtn" type="button">刷新榜单</button>
<button class="btn focusable" id="syncBtn" type="button">同步身份</button>
<button class="btn danger focusable" id="deleteDataBtn" type="button">删除数据</button>
</div>
</div>
</aside>
</div>
<div class="block-select-hint" id="blockSelectHint">屏蔽选择模式:按 OK 切换屏蔽,按返回退出</div>
<div class="media-grid" id="recommendRail"></div>
</section>
<section class="section" id="listSection">
<div class="stack" id="listStack">
<div class="empty">正在加载片单...</div>
</div>
</section>
<div class="infinite-sentinel" id="infiniteSentinel" aria-hidden="true"></div>
</main>
<section class="sheet" id="detailSheet" aria-hidden="true">
<button class="btn focusable" id="closeDetailBtn" 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="detail-cover">
<img id="detailImage" alt="">
</div>
<div class="detail-info">
<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="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>
<div class="detail-block" id="recommendBlock" style="display:none">
<h3>相关推荐</h3>
<div class="rail" id="recommendWorkRail"></div>
</div>
</section>
<section class="sheet" id="syncSheet" aria-hidden="true">
<button class="btn focusable" id="closeSyncBtn" type="button">
<svg class="icon" viewBox="0 0 24 24"><path d="M19 12H5"/><path d="M12 19l-7-7 7-7"/></svg>
返回
</button>
<div class="sync-panel" style="margin-top:14px">
<h3 style="margin:0">Nostr 同步身份</h3>
<p class="hint">页面会自动生成本机身份并把有效观看偏好签名发布到 relay。手机和电脑如果想同步同一个用户的推荐,请导入同一个 nsec。只看全网热度时不需要导入。</p>
<div>
<label class="hint" for="nsecInput">nsec 私钥</label>
<textarea class="field focusable mono" id="nsecInput" placeholder="nsec1..."></textarea>
</div>
<div class="actions">
<button class="btn primary focusable" id="saveNsecBtn" type="button">导入身份</button>
<button class="btn focusable" id="newNsecBtn" type="button">生成新身份</button>
</div>
<p class="hint mono" id="identityText">身份未就绪</p>
<p class="hint" id="relayText">relay 未连接</p>
</div>
</section>
<section class="image-viewer" id="imageViewer" aria-hidden="true">
<button class="btn focusable" id="closeImageBtn" type="button">
<svg class="icon" viewBox="0 0 24 24"><path d="M19 12H5"/><path d="M12 19l-7-7 7-7"/></svg>
返回
</button>
<div class="image-content focusable" id="imageContent" tabindex="0">
<img id="viewerImage" alt="">
<div class="episode-view" id="episodeViewer" style="display:none"></div>
</div>
</section>
<div class="toast" id="toast"></div>
<button class="back-top focusable" id="backTopBtn" type="button" aria-label="返回顶部">
<svg class="icon" viewBox="0 0 24 24"><path d="M12 19V5"/><path d="M5 12l7-7 7 7"/></svg>
</button>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/lib/nostr.bundle.js"></script>
<script>
window.WEBHOME_CONFIG = {
siteKey: "",
tmdb: {
apiKey: "304ca56b1b7b57ca7a47d9b59946be94",
apiBase: "https://api.tmdb.org/3",
language: "zh-CN",
imageBase: "https://image.tmdb.org/t/p/w342",
backdropBase: "https://image.tmdb.org/t/p/w780",
lists: [
{
id: "cn-movie",
title: "电影",
hint: "大陆电影优先",
mediaType: "movie",
endpoint: "discover/movie",
params: {
with_original_language: "zh",
with_origin_country: "CN",
region: "CN",
sort_by: "primary_release_date.desc",
primary_release_date_lte: "today",
include_adult: "false",
include_video: "false",
page: "1"
}
},
{
id: "cn-tv",
title: "剧集",
hint: "大陆剧集优先",
mediaType: "tv",
endpoint: "discover/tv",
params: {
with_original_language: "zh",
with_origin_country: "CN",
sort_by: "first_air_date.desc",
first_air_date_lte: "today",
include_null_first_air_dates: "false",
page: "1"
}
},
{
id: "western-animation",
title: "美漫",
hint: "英文动画剧集",
mobileHidden: true,
mediaType: "tv",
endpoint: "discover/tv",
params: {
with_genres: "16",
with_original_language: "en",
sort_by: "first_air_date.desc",
first_air_date_lte: "today",
include_null_first_air_dates: "false",
page: "1"
}
}
]
},
nostr: {
kind: 30078,
tag: "fish2018-home-v1",
eventsKey: "fish2018_home_v1_events",
nsecKey: "fish2018_home_v1_nsec",
relays: [
"wss://relay-sgp.signedbyme.com",
"wss://kotukonostr.onrender.com",
"wss://nostr.spicyz.io",
"wss://x.kojira.io",
"wss://relay.wellorder.net",
// "wss://relay.nostr.info",
// "wss://bitcoiner.social",
// "wss://relay.398ja.xyz",
// "wss://us-east.nostr.pikachat.org",
// "wss://relay.damus.io",
// "wss://relay.chorus.community",
// "wss://relay.gulugulu.moe",
// "wss://nostr.hifish.org",
// "wss://nos.lol",
// "wss://relay.vertexlab.io",
// "wss://nostr.wecsats.io",
// "wss://relay.nostr.net",
// "wss://nostr.wine",
// "wss://relay.nostr.moe",
// "wss://relay.peer.ooo",
// 翻
// "wss://relay.snort.social",
// "wss://nostr.mom",
// "wss://relay.primal.net",
// "wss://nostr-01.yakihonne.com",
// "wss://nostr-02.yakihonne.com",
// "wss://nostr-pub.wellorder.net",
]
},
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 DEFAULT_TMDB_API_KEY = String(window.WEBHOME_CONFIG.tmdb.apiKey || "").trim();
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 = 3;
const GRID_APPEND_ROWS = 2;
const RECENT_UI_TTL_MS = 30000;
const DETAIL_TEXT_CLAMP_LIMIT = 150;
const DETAIL_TEXT_CLAMP_LIMIT_LARGE = 210;
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: {},
gridColumnCache: {},
fallback: [],
fallbackPage: { sourceIndex: 0, page: 0, loading: false, loaded: false, done: false },
loadingMore: false,
chipFocusTimer: 0,
railScroll: {},
searchItems: [],
searchHold: { timer: 0, fired: false, suppressClickUntil: 0, pointer: null, target: null },
suggestions: { keyword: "", items: [], loading: false, timer: 0, seq: 0, controller: null },
hot: { db: null, dbPromise: null, idb: false, ready: false, items: [], media: new Map(), users: new Map(), ingestQueue: Promise.resolve(), refreshTimer: 0 },
relay: { connected: 0, published: 0, total: 0, lastOk: 0, lastDone: 0, statuses: {}, subscribeStarted: false, subscribeDone: 0, subscribeFinished: {}, subscribeToken: 0, refresh: null, backfillBusy: {}, backfillTimers: {}, backfillCandidates: {}, primaryBackfillRelay: "", fallbackReadyAt: 0, fallbackTimer: 0, fallbackPrefetchTimer: 0 },
recommendationSource: "pending",
status: {
sdk: "检测中",
tmdb: "等待请求",
nostr: "等待连接",
pan: "未搜索",
publish: "暂无发布",
identity: "未就绪",
refresh: "未执行"
},
deleteState: { loaded: false, users: {} },
tmdb: { config: null, configDirty: false },
blocked: { loaded: false, items: {}, selecting: false, holdTimer: 0, holdTarget: null, longPressFired: false, pointer: null, suppressClickUntil: 0 },
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,
homeReturn: null,
focusReturnEl: null,
remoteInitialFocused: false,
renderTimer: 0,
homeContentTimer: 0,
homeContentSeq: 0,
activeGridAppendTimer: 0,
activeGridAppendId: "",
focusScrollTimer: 0,
focusScrollTarget: null,
remoteKeyGate: { key: "", at: 0 },
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";
if (type === "blockedRecommend") return "fish2018_home_v1_blocked_recommend";
if (type === "tmdb") return "fish2018_home_v1_tmdb_config";
return type;
}
function sanitizeTmdbConfig(value) {
const raw = value && typeof value === "object" ? value : {};
return { apiKey: String(raw.apiKey || "").trim() || DEFAULT_TMDB_API_KEY };
}
function tmdbApiKey() {
return String(window.WEBHOME_CONFIG.tmdb.apiKey || "").trim() || DEFAULT_TMDB_API_KEY;
}
async function initTmdbConfig(options) {
options = options || {};
if (window.fongmiBridge && !window.fm) await waitForNativeSdk(options.timeout == null ? 1500 : options.timeout);
if (options.preserveDirty && state.tmdb.configDirty) return;
let saved = null;
try { saved = safeJson(await sdk().cache.get(cacheKey("tmdb")), null); } catch (e) { saved = null; }
state.tmdb.config = sanitizeTmdbConfig(saved);
window.WEBHOME_CONFIG.tmdb.apiKey = state.tmdb.config.apiKey;
state.tmdb.configDirty = false;
renderTmdbConfigControls();
}
function readTmdbConfigControls() {
return { apiKey: $("tmdbKeyInput") ? $("tmdbKeyInput").value : "" };
}
function renderTmdbConfigControls() {
const config = state.tmdb.config || sanitizeTmdbConfig(null);
if ($("tmdbKeyInput")) {
$("tmdbKeyInput").value = config.apiKey || DEFAULT_TMDB_API_KEY;
disablePanelTextEditing($("tmdbKeyInput"));
}
}
function resetTmdbRuntimeAfterConfigChange() {
state.catalog = {};
state.catalogPage = {};
state.fallback = [];
state.fallbackPage = { sourceIndex: 0, page: 0, loading: false, loaded: false, done: false };
state.searchItems = [];
state.gridRender = {};
state.recommendationSource = "pending";
renderSearch();
renderAll({ deferContent: true });
loadCatalog();
}
function defaultPanConfig() {
return {
apiBase: window.WEBHOME_CONFIG.pan.apiBase || "https://so.252035.xyz",
diskTypes: (window.WEBHOME_CONFIG.pan.diskTypes || []).slice(),
username: "",
password: "",
token: "",
tokenExpiresAt: 0,
channels: []
};
}
function normalizePanBase(value) {
let base = String(value || "").trim() || defaultPanConfig().apiBase;
if (!/^https?:\/\//i.test(base)) base = "https://" + base;
return base.replace(/\/+$/, "");
}
function normalizePanDiskType(type) {
const value = String(type || "").trim().toLowerCase();
if (value === "ali" || value === "alipan") return "aliyun";
if (value === "123pan") return "123";
if (value === "139" || value === "caiyun") return "mobile";
if (value === "pikpakdrive") return "pikpak";
if (value === "guangyapan") return "guangya";
if (value === "magnetlink" || value === "magnet:?" || value === "bt") return "magnet";
if (value === "ed2k:") return "ed2k";
if (value === "other") return "others";
return value;
}
function normalizePanChannels(value) {
const list = Array.isArray(value) ? value : String(value || "").split(/[\n,\s]+/);
return Array.from(new Set(list.map((item) => String(item || "").trim()).filter(Boolean)));
}
function isPanCheckSupported(item) {
return !!(item && PAN_CHECKABLE_DISK_TYPES.has(normalizePanDiskType(item.diskType)));
}
function canCheckPanLinks() {
const pan = sdk().pan || {};
return !!(state.pan.checkEnabled && pan.check);
}
function sanitizePanConfig(value) {
const fallback = defaultPanConfig();
const raw = value && typeof value === "object" ? value : {};
const allowed = new Set(PAN_DISK_TYPES.map((item) => item.id));
const disks = Array.isArray(raw.diskTypes) ? raw.diskTypes.map(normalizePanDiskType).filter((item) => allowed.has(item)) : fallback.diskTypes;
return {
apiBase: normalizePanBase(raw.apiBase || fallback.apiBase),
diskTypes: Array.from(new Set(disks.length ? disks : fallback.diskTypes)),
username: String(raw.username || "").trim(),
password: String(raw.password || ""),
token: String(raw.token || ""),
tokenExpiresAt: Number(raw.tokenExpiresAt || 0),
channels: normalizePanChannels(raw.channels || raw.tgChannels || raw.channel)
};
}
async function initPanConfig(options) {
options = options || {};
if (window.fongmiBridge && !window.fm) await waitForNativeSdk(options.timeout == null ? 1500 : options.timeout);
if (options.preserveDirty && state.pan.configDirty) return;
let saved = null;
try { saved = safeJson(await sdk().cache.get(cacheKey("pan")), null); } catch (e) { saved = null; }
state.pan.config = sanitizePanConfig(saved);
state.pan.configDirty = false;
renderPanConfigControls();
}
async function savePanConfig() {
if (window.fongmiBridge && !window.fm) await waitForNativeSdk(1500);
if (window.fongmiBridge && !window.fm) throw new Error("App SDK 未就绪,请稍后再保存");
const previousTmdbKey = tmdbApiKey();
state.tmdb.config = sanitizeTmdbConfig(readTmdbConfigControls());
window.WEBHOME_CONFIG.tmdb.apiKey = state.tmdb.config.apiKey;
state.pan.config = sanitizePanConfig(readPanConfigControls());
await sdk().cache.set(cacheKey("tmdb"), JSON.stringify(state.tmdb.config));
await sdk().cache.set(cacheKey("pan"), JSON.stringify(state.pan.config));
renderTmdbConfigControls();
renderPanConfigControls();
state.tmdb.configDirty = false;
state.pan.configDirty = false;
setPanStatus("配置已保存");
closeConnectionPanel();
toast("配置已保存");
if (previousTmdbKey !== tmdbApiKey()) resetTmdbRuntimeAfterConfigChange();
}
function readPanConfigControls() {
const checked = Array.from(document.querySelectorAll("#panDiskGrid input:checked")).map((input) => input.value);
return {
apiBase: $("panBaseInput") ? $("panBaseInput").value : "",
username: $("panUserInput") ? $("panUserInput").value : "",
password: $("panPassInput") ? $("panPassInput").value : "",
token: state.pan.config && state.pan.config.token || "",
tokenExpiresAt: state.pan.config && state.pan.config.tokenExpiresAt || 0,
diskTypes: checked,
channels: normalizePanChannels($("panChannelsInput") ? $("panChannelsInput").value : "")
};
}
function renderPanConfigControls() {
const config = state.pan.config || defaultPanConfig();
if ($("panBaseInput")) {
$("panBaseInput").value = config.apiBase;
disablePanelTextEditing($("panBaseInput"));
}
if ($("panChannelsInput")) {
$("panChannelsInput").value = (config.channels || []).join("\n");
disablePanelTextEditing($("panChannelsInput"));
}
if ($("panUserInput")) {
$("panUserInput").value = config.username || "";
disablePanelTextEditing($("panUserInput"));
}
if ($("panPassInput")) {
$("panPassInput").value = config.password || "";
disablePanelTextEditing($("panPassInput"));
}
const grid = $("panDiskGrid");
if (!grid) return;
const selected = new Set(config.diskTypes || []);
grid.replaceChildren(...PAN_DISK_TYPES.map((disk) => {
const label = document.createElement("label");
label.innerHTML = `<input class="focusable" type="checkbox" value="${escapeAttr(disk.id)}" ${selected.has(disk.id) ? "checked" : ""}><span>${escapeHtml(disk.name)}</span>`;
return label;
}));
}
function markPanConfigDirty(event) {
const target = event && event.target;
if (target && target.id === "tmdbKeyInput") state.tmdb.configDirty = true;
else state.pan.configDirty = true;
}
function panDiskName(type) {
const normalized = normalizePanDiskType(type);
const item = PAN_DISK_TYPES.find((disk) => disk.id === normalized);
return item ? item.name : normalized || "网盘";
}
function panApi(path) {
const base = normalizePanBase((state.pan.config || defaultPanConfig()).apiBase);
if (base.endsWith("/api") && path.startsWith("/api/")) return base + path.slice(4);
return base + path;
}
function panSearchDiskTypes(config) {
const types = ((config && config.diskTypes) || []).map(normalizePanDiskType).filter(Boolean);
return Array.from(new Set(types));
}
function panSearchPayload(keyword, config) {
const payload = {
kw: keyword,
res: "merge",
src: "all",
cloud_types: panSearchDiskTypes(config)
};
const channels = normalizePanChannels(config && config.channels || []);
if (channels.length) payload.channels = channels;
return payload;
}
async function persistPanConfigQuietly() {
try { await sdk().cache.set(cacheKey("pan"), JSON.stringify(state.pan.config || defaultPanConfig())); } catch (e) {}
}
async function ensurePanAuthHeaders() {
const config = state.pan.config || defaultPanConfig();
if (!config.username || !config.password) return {};
if (config.token && Number(config.tokenExpiresAt || 0) * 1000 > Date.now() + 60000) return { Authorization: "Bearer " + config.token };
const response = await postJson(panApi("/api/auth/login"), { username: config.username, password: config.password }, 12, {});
const data = response && response.data ? response.data : response;
const token = data && data.token;
if (!token) throw new Error(response && (response.error || response.message) || "盘搜认证失败");
state.pan.config = sanitizePanConfig(Object.assign({}, config, { token, tokenExpiresAt: data.expires_at || 0 }));
await persistPanConfigQuietly();
return { Authorization: "Bearer " + token };
}
function openHotDb() {
if (!("indexedDB" in window)) return Promise.resolve(null);
if (state.hot.db) return Promise.resolve(state.hot.db);
if (state.hot.dbPromise) return state.hot.dbPromise;
state.hot.dbPromise = new Promise((resolve) => {
const req = indexedDB.open(HOT_DB_NAME, HOT_DB_VERSION);
req.onupgradeneeded = () => {
const db = req.result;
db.createObjectStore("media", { keyPath: "m" });
const store = db.createObjectStore("userVector", { keyPath: "u" });
store.createIndex("u", "u", { unique: false });
store.createIndex("e", "e", { unique: false });
store.createIndex("x", "x", { unique: false });
db.createObjectStore("relayCursor", { keyPath: "relay" });
};
req.onsuccess = () => {
state.hot.db = req.result;
state.hot.dbPromise = null;
state.hot.idb = true;
resolve(state.hot.db);
};
req.onerror = () => {
state.hot.dbPromise = null;
resolve(null);
};
req.onblocked = () => setStatus("nostr", "本地索引升级等待");
});
return state.hot.dbPromise;
}
function hotWindowStart() {
return Math.floor((Date.now() - HOT_WINDOW_MS) / 1000);
}
function hotBackupWindowStart() {
return Math.max(hotWindowStart(), hotNow() - HOT_BACKUP_WINDOW_SECONDS);
}
function hotNow() {
return Math.floor(Date.now() / 1000);
}
function hotToday() {
return Math.floor(hotNow() / HOT_DAY_SECONDS);
}
function hotExpiresAt(createdAt) {
return Number(createdAt || hotNow()) + HOT_WINDOW_SECONDS;
}
function hotExpiresDay(expiresAt) {
return Math.ceil(Number(expiresAt || hotNow()) / HOT_DAY_SECONDS);
}
function hotUserKey(pubkey) {
return String(pubkey || "").slice(0, 32);
}
function normalizeDeleteState(raw) {
const now = Date.now();
const users = {};
Object.entries(raw && raw.users || {}).forEach(([pubkey, marker]) => {
const key = String(pubkey || "").trim();
if (!key || !marker || typeof marker !== "object") return;
const expiresAt = Number(marker.expiresAt || 0);
if (expiresAt && expiresAt <= now) return;
users[key] = {
cutoff: Math.max(0, Number(marker.cutoff || 0)),
blockPublishUntil: Math.max(0, Number(marker.blockPublishUntil || 0)),
expiresAt: expiresAt || now + DELETE_TOMBSTONE_TTL_MS,
updatedAt: Math.max(0, Number(marker.updatedAt || 0))
};
});
return { loaded: true, users };
}
async function ensureDeleteStateLoaded() {
if (state.deleteState.loaded) return state.deleteState;
let saved = null;
try { saved = safeJson(await sdk().cache.get(cacheKey("deleteState")), null); } catch (e) { saved = null; }
state.deleteState = normalizeDeleteState(saved);
return state.deleteState;
}
async function persistDeleteState() {
await sdk().cache.set(cacheKey("deleteState"), JSON.stringify({ users: state.deleteState.users || {} }));
}
async function markIdentityDeletedLocally(identity) {
if (!identity || !identity.pubkey) return null;
await ensureDeleteStateLoaded();
const now = Date.now();
const marker = {
cutoff: hotNow() + 1,
blockPublishUntil: now + DELETE_REPUBLISH_BLOCK_MS,
expiresAt: now + DELETE_TOMBSTONE_TTL_MS,
updatedAt: now
};
state.deleteState.users[identity.pubkey] = marker;
await persistDeleteState().catch(() => {});
return marker;
}
function localDeleteMarker(pubkey) {
const marker = state.deleteState.users && state.deleteState.users[String(pubkey || "")];
if (!marker) return null;
if (Number(marker.expiresAt || 0) && Number(marker.expiresAt || 0) <= Date.now()) {
delete state.deleteState.users[String(pubkey || "")];
persistDeleteState().catch(() => {});
return null;
}
return marker;
}
function isLocallyDeletedPreferenceEvent(event) {
const pubkey = eventUserKey(event);
const marker = localDeleteMarker(pubkey);
if (!marker) return false;
return Number(event && event.created_at || 0) <= Number(marker.cutoff || 0);
}
async function isPreferencePublishBlocked() {
await ensureDeleteStateLoaded();
const pubkey = state.identity && state.identity.pubkey;
const marker = localDeleteMarker(pubkey);
return !!(marker && Number(marker.blockPublishUntil || 0) > Date.now());
}
function eventExpiration(event) {
const tag = (event && event.tags || []).find((item) => item && item[0] === "expiration");
const value = tag ? Number(tag[1]) : 0;
return Number.isFinite(value) ? value : 0;
}
function eventExpired(event) {
const expiration = eventExpiration(event);
return expiration > 0 && expiration <= hotNow();
}
function idbRequest(req) {
return new Promise((resolve, reject) => {
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
function idbDone(tx) {
return new Promise((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error);
});
}
async function hotStoreGet(storeName, key) {
const db = await openHotDb();
if (!db) return null;
return idbRequest(db.transaction(storeName, "readonly").objectStore(storeName).get(key)).catch(() => null);
}
async function hotLoadIndex() {
const db = await openHotDb();
if (!db) {
state.hot.ready = true;
return;
}
try {
const tx = db.transaction("media", "readonly");
const media = await idbRequest(tx.objectStore("media").getAll());
state.hot.media = new Map((media || []).filter((item) => item && item.m).map((item) => [item.m, item]));
state.hot.items = buildHotItemsFromIndex();
hotPruneExpired().catch(() => {});
state.hot.ready = true;
} catch (e) {
state.hot.ready = true;
}
useNostrRecommendationsIfReady();
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();
useNostrRecommendationsIfReady();
if (isNostrRefreshActive()) {
updateNostrRefreshProgress({ indexed: state.hot.items.length });
maybeFinishNostrRefresh();
}
}
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 hotClearRankingIndexForRefresh() {
const db = await openHotDb();
const localKey = state.identity ? hotUserKey(state.identity.pubkey) : "";
const localVector = localKey ? await hotGetUserVector(localKey, db) : null;
const localMedia = [];
hotActiveVectorItems(localVector).forEach((item) => {
const mediaKey = hotVectorItemMediaKey(item);
const media = mediaKey ? state.hot.media.get(mediaKey) : null;
if (media && media.m) localMedia.push(Object.assign({}, media, { c: 1 }));
});
await hotClearIndex(false);
if (localVector && localVector.u) state.hot.users.set(localVector.u, localVector);
localMedia.forEach((media) => state.hot.media.set(media.m, media));
if (db && (localVector && localVector.u || localMedia.length)) {
const tx = db.transaction(["media", "userVector"], "readwrite");
const mediaStore = tx.objectStore("media");
const vectorStore = tx.objectStore("userVector");
if (localVector && localVector.u) vectorStore.put(localVector);
localMedia.forEach((media) => mediaStore.put(media));
await idbDone(tx).catch(() => {});
}
state.hot.items = buildHotItemsFromIndex();
}
async function hotRemoveVectors(vectors, options) {
const rows = (vectors || []).filter((vector) => vector && vector.u);
if (!rows.length) return;
const db = await openHotDb();
const changedMedia = new Set();
rows.forEach((vector) => {
hotApplyVectorDiffToState(vector, null).forEach((key) => changedMedia.add(key));
state.hot.users.delete(vector.u);
});
if (db) {
const tx = db.transaction(["media", "userVector"], "readwrite");
const mediaStore = tx.objectStore("media");
const vectorStore = tx.objectStore("userVector");
changedMedia.forEach((mediaKey) => {
const media = state.hot.media.get(mediaKey);
if (media && Number(media.c || 0) > 0) mediaStore.put(media);
else mediaStore.delete(mediaKey);
});
rows.forEach((vector) => vectorStore.delete(vector.u));
await idbDone(tx).catch(() => {});
}
if (!options || options.refresh !== false) scheduleHotRefresh();
}
function setStatus(key, value) {
state.status[key] = value;
renderConnection();
}
function setPanStatus(value) {
state.status.pan = value;
renderConnection();
}
function setRefreshStatus(value) {
state.status.refresh = value || "未执行";
renderConnection();
}
function updateNostrRefreshProgress(patch) {
const current = state.relay.refresh || {};
state.relay.refresh = Object.assign({
active: false,
phase: "未执行",
connected: 0,
subscribeDone: 0,
subEvents: 0,
recentPages: 0,
recentEvents: 0,
historyPages: 0,
historyEvents: 0,
indexed: 0,
relay: "",
startedAt: 0,
finishedAt: 0
}, current, patch || {});
const info = state.relay.refresh;
const relays = window.WEBHOME_CONFIG.nostr.relays;
const phase = info.phase || "刷新中";
const connected = Number(info.connected || state.relay.connected || 0);
const subscribeDone = Number(info.subscribeDone || state.relay.subscribeDone || 0);
const subEvents = Number(info.subEvents || 0);
const recentPages = Number(info.recentPages || 0);
const recentEvents = Number(info.recentEvents || 0);
const historyPages = Number(info.historyPages || 0);
const historyEvents = Number(info.historyEvents || 0);
const backfillBusy = Object.keys(state.relay.backfillBusy || {}).length;
const backfillTimers = Object.values(state.relay.backfillTimers || {}).filter(Boolean).length;
const parts = [];
if (phase === "完成") parts.push("已完成");
else if (info.active) parts.push(`进行中:${phase}`);
else parts.push(phase);
if (phase !== "未执行") {
parts.push(`连接 ${connected}/${relays.length},订阅完成 ${subscribeDone}/${relays.length}`);
parts.push(`订阅事件 ${subEvents} 条`);
parts.push(`近7天回填 ${recentPages} 页 / ${recentEvents} 条`);
parts.push(`历史回填 ${historyPages} 页 / ${historyEvents} 条`);
if (info.active && (backfillBusy || backfillTimers)) parts.push(`回填队列 ${backfillBusy} 个执行中 / ${backfillTimers} 个等待`);
if (info.active && state.hot.refreshTimer) parts.push("榜单列表待更新");
if (info.relay) parts.push(`当前 relay${info.relay}`);
}
parts.push(`当前榜单 ${state.hot.items.length} 条`);
setRefreshStatus(parts.join("\n"));
}
function isNostrRefreshActive(token) {
const info = state.relay.refresh;
return !!(info && info.active && (token == null || token === state.relay.subscribeToken));
}
function maybeFinishNostrRefresh(token) {
if (!isNostrRefreshActive(token)) return;
const relays = window.WEBHOME_CONFIG.nostr.relays;
const busy = Object.keys(state.relay.backfillBusy || {}).length;
const timers = Object.values(state.relay.backfillTimers || {}).filter(Boolean).length;
if (state.relay.subscribeDone < relays.length || busy || timers || state.hot.refreshTimer) return;
updateNostrRefreshProgress({ active: false, phase: "完成", finishedAt: Date.now(), indexed: state.hot.items.length });
toast(`榜单刷新完成:${state.hot.items.length}条`);
}
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 nostrReadyForFallback() {
const relays = window.WEBHOME_CONFIG.nostr.relays;
return state.recommendationSource === "fallback"
|| relayFailedAll()
|| state.relay.subscribeDone >= relays.length
|| Date.now() >= PAGE_OPENED_AT + FALLBACK_SHOW_MS;
}
function useFallbackRecommendations() {
if (preferenceItems().length) {
useNostrRecommendationsIfReady();
return;
}
if (state.recommendationSource === "fallback") return;
state.recommendationSource = "fallback";
ensureRecommendationFallback();
if (state.activeList === "all") renderActiveGrid();
}
function useNostrRecommendationsIfReady() {
if (!preferenceItems().length) return;
state.recommendationSource = "nostr";
clearTimeout(state.relay.fallbackTimer);
clearTimeout(state.relay.fallbackPrefetchTimer);
if (state.activeList === "all") renderActiveGrid();
}
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 (preferenceItems().length === 0) {
setStatus("nostr", relayFailedAll() ? "连接失败" : "无推荐数据");
useFallbackRecommendations();
}
}
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;
if ($("statusRefresh")) $("statusRefresh").textContent = state.status.refresh || "未执行";
$("statusRelays").textContent = relayValues.join(" · ");
if ($("refreshNostrBtn")) $("refreshNostrBtn").textContent = state.relay.refresh && state.relay.refresh.active ? "刷新中" : "刷新榜单";
$("connectionDot").className = "dot " + (nostrOk ? "ok" : bad ? "bad" : "warn");
fitConnectionPanel();
}
function fitConnectionPanel() {
const dock = $("connectionDock");
const body = $("connectionBody");
if (!dock || !body) return;
body.style.setProperty("--connection-shift", "0px");
if (!dock.classList.contains("open")) return;
requestAnimationFrame(() => {
const rect = body.getBoundingClientRect();
const pad = 8;
let shift = 0;
if (rect.left < pad) shift = pad - rect.left;
if (rect.right + shift > window.innerWidth - pad) shift = window.innerWidth - pad - rect.right;
body.style.setProperty("--connection-shift", `${Math.round(shift)}px`);
});
}
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 resetRelaySubscribeState() {
Object.values(state.relay.backfillTimers || {}).forEach((timer) => clearTimeout(timer));
state.relay.connected = 0;
state.relay.subscribeDone = 0;
state.relay.subscribeFinished = {};
state.relay.statuses = {};
state.relay.backfillBusy = {};
state.relay.backfillTimers = {};
state.relay.backfillCandidates = {};
state.relay.primaryBackfillRelay = "";
return ++state.relay.subscribeToken;
}
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", tmdbApiKey());
url.searchParams.set("language", window.WEBHOME_CONFIG.tmdb.language);
Object.entries(list.params || {}).forEach(([key, value]) => {
const param = key.replace(/_lte$/, ".lte");
url.searchParams.set(param, value === "today" ? today() : value);
});
if (page) url.searchParams.set("page", String(page));
return url.toString();
}
function tmdbFallbackUrl(type, page) {
const endpoint = type === "airing" ? "tv/airing_today" : "trending/all/day";
const url = new URL(`${window.WEBHOME_CONFIG.tmdb.apiBase}/${endpoint}`);
url.searchParams.set("api_key", tmdbApiKey());
url.searchParams.set("language", window.WEBHOME_CONFIG.tmdb.language);
url.searchParams.set("page", String(page || 1));
return url.toString();
}
function tmdbSearchUrl(keyword) {
const url = new URL(`${window.WEBHOME_CONFIG.tmdb.apiBase}/search/multi`);
url.searchParams.set("api_key", tmdbApiKey());
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", tmdbApiKey());
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", tmdbApiKey());
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", tmdbApiKey());
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", tmdbApiKey());
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", tmdbApiKey());
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 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) return tmdbImageUrl(tmdbPath, opts.size || "w342");
return nativeImage(url);
}
function nativeImage(url) {
if (!url) return "";
if (isTmdbImage(url)) return url;
try { return sdk().res(url, { credentials: "include" }); } catch (e) { return url; }
}
function imageAttrs(src, options) {
const opts = options || {};
const loading = opts.loading || "lazy";
const fetchPriority = opts.fetchPriority ? ` fetchpriority="${escapeAttr(opts.fetchPriority)}"` : "";
return `src="${escapeAttr(src)}" loading="${escapeAttr(loading)}" decoding="async"${fetchPriority}`;
}
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 || item.media_type || "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 });
}
function prefetchRecommendationFallback() {
if (preferenceItems().length) {
useNostrRecommendationsIfReady();
return;
}
if (state.fallbackPage.loading || state.fallbackPage.loaded || state.fallbackPage.done) return;
loadRecommendationFallback().catch((e) => {
state.fallbackPage.loading = false;
state.fallbackPage.loaded = true;
state.fallbackPage.done = true;
setStatus("tmdb", "推荐加载失败");
if (state.recommendationSource === "fallback") renderActiveGrid();
});
}
function ensureRecommendationFallback() {
if (!nostrReadyForFallback()) return;
prefetchRecommendationFallback();
}
function armFallbackTimers() {
clearTimeout(state.relay.fallbackTimer);
clearTimeout(state.relay.fallbackPrefetchTimer);
const elapsed = Date.now() - PAGE_OPENED_AT;
state.relay.fallbackPrefetchTimer = setTimeout(() => {
if (!preferenceItems().length) prefetchRecommendationFallback();
}, Math.max(0, FALLBACK_PREFETCH_MS - elapsed));
state.relay.fallbackTimer = setTimeout(() => {
if (!preferenceItems().length) useFallbackRecommendations();
}, Math.max(0, FALLBACK_SHOW_MS - elapsed));
}
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();
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 loadRecommendationFallback() {
const sources = [
{ type: "trending", id: "tmdb-trending", title: "今日趋势" },
{ type: "airing", id: "tv-airing-today", title: "今日播出", mediaType: "tv" }
];
if (preferenceItems().length) {
useNostrRecommendationsIfReady();
return;
}
state.fallback = [];
state.fallbackPage = { sourceIndex: 0, page: 0, loading: true, loaded: false, done: false };
setStatus("tmdb", "请求推荐兜底");
if (state.recommendationSource === "fallback") renderActiveGrid();
for (let index = 0; index < sources.length; index++) {
const source = sources[index];
try {
const body = await requestJson(tmdbFallbackUrl(source.type, 1), 18);
const items = (body.results || [])
.filter((item) => item.poster_path && (item.media_type === "movie" || item.media_type === "tv" || source.mediaType))
.map((item, index) => normalizeTmdb(item, source, index))
.filter(hasPoster);
if (preferenceItems().length) {
useNostrRecommendationsIfReady();
return;
}
if (items.length) {
state.fallback = uniqueMedia(items);
state.fallbackPage = { sourceIndex: index, page: 1, total: body.total_pages || 1, loading: false, loaded: true, done: false };
setStatus("tmdb", "推荐兜底已加载");
if (state.recommendationSource === "fallback") renderActiveGrid();
return;
}
} catch (e) {}
}
state.fallback = ranked(allItems());
state.fallbackPage.loaded = true;
state.fallbackPage.loading = false;
state.fallbackPage.done = true;
setStatus("tmdb", state.fallback.length ? "本地兜底已加载" : "推荐为空");
if (state.recommendationSource === "fallback") 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;
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;
}
}
async function loadMoreFallback() {
const sources = [
{ type: "trending", id: "tmdb-trending", title: "今日趋势" },
{ type: "airing", id: "tv-airing-today", title: "今日播出", mediaType: "tv" }
];
if (preferenceItems().length) {
useNostrRecommendationsIfReady();
return;
}
const page = state.fallbackPage;
if (page.loading || page.done) return;
const source = sources[page.sourceIndex] || sources[0];
if (page.total && page.page >= page.total) {
page.done = true;
return;
}
page.loading = true;
try {
const next = page.page + 1;
const body = await requestJson(tmdbFallbackUrl(source.type, next), 18);
const items = (body.results || [])
.filter((item) => item.poster_path && (item.media_type === "movie" || item.media_type === "tv" || source.mediaType))
.map((item, index) => normalizeTmdb(item, source, (next - 1) * 20 + index))
.filter(hasPoster);
state.fallback = uniqueMedia(state.fallback.concat(items));
state.fallbackPage = { sourceIndex: page.sourceIndex, page: next, total: body.total_pages || page.total || next, loading: false, loaded: true, done: !items.length };
renderActiveGrid();
} catch (e) {
page.loading = false;
}
}
function loadMoreVisible() {
if (appendActiveGridBatch()) return;
if (state.loadingMore || state.activeList === "recent" || state.activeList === "live") return;
if (state.activeList === "all") {
if (preferenceItems().length) {
useNostrRecommendationsIfReady();
return;
}
if (!nostrReadyForFallback()) return;
state.recommendationSource = "fallback";
}
if (state.infiniteObserver) state.infiniteObserver.unobserve($("infiniteSentinel"));
state.loadingMore = true;
Promise.resolve(state.activeList === "all" ? loadMoreFallback() : 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();
}
async function forceRefreshNostrRanking() {
if (forceRefreshNostrRanking.busy) return;
forceRefreshNostrRanking.busy = true;
const button = $("refreshNostrBtn");
const oldText = button && button.textContent || "";
if (button) button.textContent = "刷新中";
try {
setStatus("nostr", "刷新榜单中");
toast("开始刷新 Nostr 榜单");
updateNostrRefreshProgress({ active: true, phase: "清理索引", startedAt: Date.now(), finishedAt: 0, connected: 0, subscribeDone: 0, subEvents: 0, recentPages: 0, recentEvents: 0, historyPages: 0, historyEvents: 0, indexed: 0, relay: "" });
clearRelayBackfillTimers();
resetRelaySubscribeState();
await hotClearRankingIndexForRefresh();
state.recommendationSource = "pending";
renderMetrics();
if (state.activeList === "all") renderActiveGrid();
updateNostrRefreshProgress({ phase: "连接 relay", indexed: state.hot.items.length });
subscribeNostr();
} catch (e) {
setStatus("nostr", "刷新榜单失败");
updateNostrRefreshProgress({ active: false, phase: "刷新失败", finishedAt: Date.now(), indexed: state.hot.items.length });
toast(e.message || "刷新榜单失败");
} finally {
forceRefreshNostrRanking.busy = false;
renderConnection();
}
}
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());
}
async function loadBlockedRecommend() {
try {
const saved = safeJson(await sdk().cache.get(cacheKey("blockedRecommend")), null);
const items = saved && saved.items && typeof saved.items === "object" ? saved.items : {};
state.blocked.items = items;
} catch (e) {
state.blocked.items = {};
}
state.blocked.loaded = true;
}
async function saveBlockedRecommend() {
const items = state.blocked.items || {};
await sdk().cache.set(cacheKey("blockedRecommend"), JSON.stringify({ version: 1, items }));
}
function blockedKey(item) {
return mediaHeatKey(item) || mediaDomKey(item);
}
function isBlocked(item) {
const key = blockedKey(item);
return !!(key && state.blocked.items && state.blocked.items[key]);
}
function filterBlocked(items) {
if (state.blocked.selecting) return items || [];
return (items || []).filter((item) => !isBlocked(item));
}
function blockableRecommendCard(el) {
if (state.activeList !== "all" || uiSnapshotRoute() !== "home") return null;
const card = el && el.closest && el.closest("#recommendRail .card");
if (!card || !card.__mediaItem || card.__mediaItem.source === "history") return null;
return card;
}
function updateBlockSelectUi() {
document.body.classList.toggle("block-select-active", !!state.blocked.selecting);
document.querySelectorAll("#recommendRail .card").forEach((card) => {
card.classList.toggle("blocked", isBlocked(card.__mediaItem));
});
if ($("blockSelectHint")) {
const count = Object.keys(state.blocked.items || {}).length;
$("blockSelectHint").textContent = `屏蔽选择模式:按 OK 切换屏蔽,按返回退出${count ? ` · 已屏蔽 ${count} 个` : ""}`;
}
}
function enterBlockSelectMode(card) {
if (state.activeList !== "all" || uiSnapshotRoute() !== "home") return false;
state.blocked.selecting = true;
updateBlockSelectUi();
renderActiveGrid();
toast("屏蔽选择模式");
requestAnimationFrame(() => {
const key = card && (card.dataset.blockKey || card.dataset.mediaKey);
const target = key && findByDataset($("recommendRail"), "blockKey", key) || $("recommendRail").querySelector(".card");
if (target) focusRemoteTarget(target);
});
return true;
}
function exitBlockSelectMode() {
if (!state.blocked.selecting) return false;
state.blocked.selecting = false;
clearBlockLongPress();
updateBlockSelectUi();
renderActiveGrid();
toast("已退出屏蔽选择");
return true;
}
function clearBlockLongPress() {
if (state.blocked.holdTimer) clearTimeout(state.blocked.holdTimer);
state.blocked.holdTimer = 0;
state.blocked.holdTarget = null;
state.blocked.pointer = null;
}
function armBlockLongPress(card) {
if (!card || state.blocked.selecting || state.blocked.holdTimer) return false;
state.blocked.holdTarget = card;
state.blocked.longPressFired = false;
state.blocked.holdTimer = setTimeout(() => {
state.blocked.holdTimer = 0;
state.blocked.longPressFired = true;
enterBlockSelectMode(card);
}, 650);
return true;
}
function consumeBlockCardEnterDown(card, event) {
if (!card) return false;
event.preventDefault();
event.stopPropagation();
if (state.blocked.selecting) {
toggleBlockedCard(card);
return true;
}
armBlockLongPress(card);
return true;
}
function handleBlockCardEnterUp(event) {
const card = state.blocked.holdTarget;
if (!card) return false;
event.preventDefault();
event.stopPropagation();
const longPressed = state.blocked.longPressFired;
clearBlockLongPress();
if (longPressed) {
state.blocked.longPressFired = false;
return true;
}
if (!state.blocked.selecting) card.click();
return true;
}
function handleBlockPointerDown(card, event) {
if (!card || state.blocked.selecting) return false;
if (event.pointerType === "mouse" && event.button !== 0) return false;
state.blocked.pointer = { id: event.pointerId, x: event.clientX || 0, y: event.clientY || 0 };
armBlockLongPress(card);
return true;
}
function handleBlockPointerMove(event) {
const pointer = state.blocked.pointer;
if (!pointer || pointer.id !== event.pointerId) return;
const dx = Math.abs((event.clientX || 0) - pointer.x);
const dy = Math.abs((event.clientY || 0) - pointer.y);
if (dx > 12 || dy > 12) clearBlockLongPress();
}
function handleBlockPointerUp(event) {
const pointer = state.blocked.pointer;
if (!pointer || pointer.id !== event.pointerId) return false;
const longPressed = state.blocked.longPressFired;
clearBlockLongPress();
if (longPressed) {
state.blocked.longPressFired = false;
state.blocked.suppressClickUntil = Date.now() + 700;
event.preventDefault();
event.stopPropagation();
return true;
}
return false;
}
async function toggleBlockedCard(card) {
const item = card && card.__mediaItem;
const key = blockedKey(item);
if (!item || !key) return;
if (state.blocked.items[key]) {
delete state.blocked.items[key];
toast("已解除屏蔽");
} else {
state.blocked.items[key] = {
title: item.title || "",
mediaType: item.mediaType || "",
tmdbId: item.tmdbId || "",
pic: item.pic || "",
blockedAt: Date.now()
};
toast("已屏蔽");
}
updateBlockSelectUi();
try { await saveBlockedRecommend(); } catch (e) { toast("屏蔽保存失败"); }
scheduleUiSnapshotSave();
}
function getList(id) {
return visibleTmdbLists().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);
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() {
$("searchSection").style.display = state.searchItems.length ? "" : "none";
renderRail("searchRail", ranked(state.searchItems).slice(0, 18));
}
function clearSearchResults() {
state.searchItems = [];
if ($("searchInput")) $("searchInput").value = "";
hideSearchSuggest();
renderSearch();
scheduleUiSnapshotSave();
}
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() {
const recommend = state.activeList === "all";
$("recommendSection").style.display = recommend ? "" : "none";
$("listSection").style.display = recommend ? "none" : "";
if (recommend) renderRecommendations();
else 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 !== "all" && 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);
}
function renderChips() {
const chips = [{ id: "live", title: "直播" }, { id: "all", title: "Nostr推荐" }, { id: "recent", title: "最近" }]
.concat(visibleTmdbLists().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.textContent = chip.title;
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 }), 45);
}
function selectChip(id, options) {
const opts = options || {};
if (!opts.fromFocus) clearTimeout(state.chipFocusTimer);
if (!id || state.activeList === id) return;
if (id !== "all") exitBlockSelectMode();
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() {
const items = filterBlocked(preferenceItems());
if (items.length) {
state.recommendationSource = "nostr";
clearTimeout(state.relay.fallbackTimer);
fillGrid($("recommendRail"), items);
updateBlockSelectUi();
return;
}
if (state.recommendationSource === "nostr") state.recommendationSource = "pending";
if (state.recommendationSource === "pending" && !nostrReadyForFallback()) {
showGridStatus($("recommendRail"), "Nostr 推荐同步中...");
} else {
state.recommendationSource = "fallback";
ensureRecommendationFallback();
if (state.fallbackPage.loading && !state.fallback.length) {
showGridStatus($("recommendRail"), "推荐兜底加载中...");
return;
}
fillGrid($("recommendRail"), filterBlocked(state.fallback.length ? state.fallback : ranked(allItems())));
updateBlockSelectUi();
}
}
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">
<h4>${escapeHtml(title || "")}</h4>
<span>${escapeHtml(hint || "")}</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 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 width = window.innerWidth || 0;
const mode = isTvMode() ? "tv" : "web";
const gridId = gridRenderId(grid);
const value = grid ? getComputedStyle(grid).gridTemplateColumns : "";
const cacheKey = `${gridId}|${mode}|${width}|${value}`;
const cached = state.gridColumnCache[cacheKey];
if (cached) return cached;
const count = value && value !== "none" ? value.split(" ").filter(Boolean).length : 0;
const cols = count > 0 ? count : isTvMode() ? 5 : width >= 1180 ? 6 : width >= 720 ? 4 : 3;
state.gridColumnCache[cacheKey] = cols;
return cols;
}
function initialGridBatchSize(grid) {
const cols = gridColumns(grid);
return Math.max(cols * GRID_INITIAL_ROWS, isTvMode() ? 18 : 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");
updateBlockSelectUi();
}
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") {
const source = preferenceItems().length ? preferenceItems() : state.fallback.length ? state.fallback : ranked(allItems());
return uniqueMedia(filterBlocked(source)).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;
let index = Number(card.dataset.cardIndex || -1);
if (!Number.isFinite(index) || index < 0) {
const cards = Array.from(grid.querySelectorAll(".card"));
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() {
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() {
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: "900px 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") return !preferenceItems().length && state.recommendationSource === "fallback" && !state.fallbackPage.done;
const page = state.catalogPage[state.activeList];
return !!(page && !page.loading && page.page < page.total);
}
function ensureScrollablePage() {
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 blockKey = blockedKey(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";
button.dataset.cardIndex = String(index || 0);
if (key) button.dataset.mediaKey = key;
if (blockKey) button.dataset.blockKey = blockKey;
button.__mediaItem = item;
button.classList.toggle("blocked", isBlocked(item));
const rating = item.voteAverage ? Number(item.voteAverage).toFixed(1) : "";
const poster = displayImage(landscape ? item.landscape || item.image || item.pic : item.pic, { size: landscape ? "w780" : "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>` : ""}
` : `
<div class="poster-wrap">
<img class="poster" alt="" ${imageAttrs(poster, imageOpt)}>
${people > 0 ? `<span class="card-people">${escapeHtml(people)}人</span>` : ""}
</div>
${rating ? `<span class="rating-badge">${escapeHtml(rating)}</span>` : ""}
<div class="card-body">
<div class="card-title">${escapeHtml(item.title)}</div>
<div class="card-meta">${escapeHtml(item.remark)}</div>
</div>
`;
button.addEventListener("pointerdown", (event) => handleBlockPointerDown(blockableRecommendCard(button), event));
button.addEventListener("pointermove", handleBlockPointerMove);
button.addEventListener("pointerup", handleBlockPointerUp);
button.addEventListener("pointercancel", clearBlockLongPress);
button.addEventListener("contextmenu", (event) => {
if (Date.now() < Number(state.blocked.suppressClickUntil || 0)) event.preventDefault();
});
button.addEventListener("click", () => {
if (Date.now() < Number(state.blocked.suppressClickUntil || 0)) return;
if (state.blocked.selecting && blockableRecommendCard(button)) {
toggleBlockedCard(button);
return;
}
if (item.source === "history") openRecentItem(item);
else openDetail(item, { returnTarget: button });
});
return button;
}
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 preferenceItems() {
return state.hot.items;
}
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);
}
function currentSearchKeyword() {
const input = $("searchInput");
return String(input && input.value || "").trim();
}
function clearSearchHold() {
if (state.searchHold.timer) clearTimeout(state.searchHold.timer);
state.searchHold.timer = 0;
state.searchHold.pointer = null;
state.searchHold.target = null;
}
function handleSearchNativeHoldStart(event) {
const button = $("searchSubmitBtn");
if (!button) return;
if (event && event.pointerType === "mouse" && event.button !== 0) return;
clearSearchHold();
state.searchHold.fired = false;
state.searchHold.target = button;
state.searchHold.pointer = event && event.pointerId != null ? {
id: event.pointerId,
x: event.clientX || 0,
y: event.clientY || 0
} : null;
state.searchHold.timer = setTimeout(() => {
state.searchHold.timer = 0;
state.searchHold.fired = true;
state.searchHold.suppressClickUntil = Date.now() + 800;
const keyword = currentSearchKeyword();
hideSearchSuggest();
nativeHomeSearch(keyword);
}, 650);
}
function handleSearchNativeHoldMove(event) {
const pointer = state.searchHold.pointer;
if (!pointer || !event || pointer.id !== event.pointerId) return;
const dx = Math.abs((event.clientX || 0) - pointer.x);
const dy = Math.abs((event.clientY || 0) - pointer.y);
if (dx > 12 || dy > 12) clearSearchHold();
}
function handleSearchNativeHoldEnd() {
const fired = state.searchHold.fired;
clearSearchHold();
return fired;
}
function consumeSearchSubmitEnterDown(el, event) {
if (el !== $("searchSubmitBtn")) return false;
event.preventDefault();
event.stopPropagation();
if (state.searchHold.timer || state.searchHold.fired) return true;
handleSearchNativeHoldStart();
return true;
}
function handleSearchSubmitEnterUp(event) {
const key = normalizeRemoteKey(event);
if (key !== "Enter" || state.searchHold.target !== $("searchSubmitBtn")) return false;
event.preventDefault();
event.stopPropagation();
if (handleSearchNativeHoldEnd()) {
state.searchHold.fired = false;
return true;
}
if (Date.now() < Number(state.searchHold.suppressClickUntil || 0)) return true;
state.searchHold.suppressClickUntil = Date.now() + 180;
submitSearchInput();
return true;
}
function handleSearchSubmitClick(event) {
if (Date.now() < Number(state.searchHold.suppressClickUntil || 0) || state.searchHold.fired) {
event.preventDefault();
event.stopPropagation();
state.searchHold.fired = false;
return;
}
event.preventDefault();
submitSearchInput();
}
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();
scheduleUiSnapshotSave();
} catch (e) {
setStatus("tmdb", "搜索失败:" + (e.message || "unknown"));
toast("搜索失败");
}
}
function homeScrollTop() {
return Math.round(window.scrollY || document.documentElement.scrollTop || document.body.scrollTop || 0);
}
function homeFocusSnapshot(target, item) {
const el = target && target.closest ? target : null;
const card = el && el.closest(".card[data-media-key]");
if (card) {
const grid = card.closest(".media-grid,.rail");
return {
type: "media",
key: card.dataset.mediaKey || mediaDomKey(item),
cardIndex: Number(card.dataset.cardIndex || -1),
gridId: grid && (grid.dataset.listId || grid.id || "") || ""
};
}
const chip = el && el.closest("#chips .chip[data-chip-id]");
if (chip) return { type: "chip", key: chip.dataset.chipId || "" };
const itemKey = mediaDomKey(item);
if (itemKey) return { type: "media", key: itemKey, cardIndex: -1, gridId: "" };
if (el && el.id) return { type: "id", key: el.id };
return { type: "", key: "" };
}
function rememberHomeReturn(item, returnTarget) {
const target = returnTarget && returnTarget.isConnected ? returnTarget : document.activeElement;
state.homeReturn = {
scrollY: homeScrollTop(),
activeList: state.activeList || "all",
mediaKey: mediaDomKey(item),
focus: homeFocusSnapshot(target, item),
at: Date.now()
};
}
function findHomeReturnMediaTarget(saved, focus) {
const key = focus.key || saved.mediaKey || "";
const candidates = key ? Array.from(document.querySelectorAll(".app .card[data-media-key]"))
.filter((el) => String(el.dataset.mediaKey || "") === String(key) && isVisibleFocusable(el)) : [];
if (focus.gridId) {
const scoped = candidates.find((el) => {
const grid = el.closest(".media-grid,.rail");
return grid && String(grid.dataset.listId || grid.id || "") === String(focus.gridId);
});
if (scoped) return scoped;
}
if (candidates.length) return candidates[0];
const grid = focus.gridId === "searchRail" ? $("searchRail") : activeMediaGrid();
const index = Number(focus.cardIndex);
if (!grid || !Number.isFinite(index) || index < 0) return null;
let guard = 0;
while (grid.querySelectorAll(".card").length <= index && appendGridBatch(grid) && guard < 60) guard += 1;
const indexed = grid.querySelectorAll(".card")[index] || null;
return isVisibleFocusable(indexed) ? indexed : null;
}
function findHomeReturnTarget(saved) {
if (!saved) return null;
const focus = saved.focus || {};
if (focus.type === "media") return findHomeReturnMediaTarget(saved, focus);
if (focus.type === "chip") return findByDataset($("chips"), "chipId", focus.key);
if (focus.type === "id" && focus.key) return $(focus.key);
if (saved.activeList) return findByDataset($("chips"), "chipId", saved.activeList);
return firstContentFocus();
}
function applyHomeScrollTop(y) {
window.scrollTo(0, y);
document.documentElement.scrollTop = y;
document.body.scrollTop = y;
updateBackTopButton();
}
function focusHomeReturnTarget(target) {
if (!isVisibleFocusable(target)) return false;
state.remoteInitialFocused = true;
try {
target.focus({ preventScroll: true });
} catch (e) {
target.focus();
}
maybeAppendGridForFocus(target);
return true;
}
function restoreHomeReturn() {
const saved = state.homeReturn;
if (!saved) return false;
if (Date.now() - Number(saved.at || 0) > 10 * 60 * 1000) {
state.homeReturn = null;
return false;
}
state.homeReturn = null;
if (saved.activeList && isKnownList(saved.activeList) && state.activeList !== saved.activeList) {
state.activeList = saved.activeList;
normalizeActiveListForViewport();
renderAll({ deferContent: false });
}
const y = Math.max(0, Number(saved.scrollY || 0));
const apply = (withFocus) => {
if ($("detailSheet") && $("detailSheet").classList.contains("active")) return;
const target = withFocus ? findHomeReturnTarget(saved) : null;
if (target) focusHomeReturnTarget(target);
applyHomeScrollTop(y);
};
requestAnimationFrame(() => apply(true));
setTimeout(() => apply(true), 80);
setTimeout(() => apply(false), 240);
setTimeout(() => {
apply(false);
scheduleUiSnapshotSave();
}, 520);
return true;
}
function openDetail(item, options) {
const opts = options || {};
const detailWasActive = $("detailSheet") && $("detailSheet").classList.contains("active");
if (!opts.restore && !detailWasActive) rememberHomeReturn(item, opts.returnTarget);
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";
sheet.style.display = display || "block";
sheet.style.zIndex = sheet.id === "imageViewer" ? "100" : "60";
}
function renderDetailBase(item) {
resetDetailTextClamp();
$("detailTitle").textContent = item.title;
const fallbackText = item.desc || item.remark || "";
$("detailText").textContent = fallbackText || "";
renderDetailMeta(item, state.detail);
renderDetailTitleMeta(item, state.detail);
setDetailCoverCarousel(item.landscape ? [item.landscape] : [], "", { allowPoster: false });
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: "w1280" });
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: usePoster ? "w780" : "w1280" }) : "";
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");
if (sheet) sheet.style.setProperty("--detail-hero-bg", bgSrc ? `url("${cssUrl(bgSrc)}")` : "none");
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");
if ($("detailSheet")) $("detailSheet").style.setProperty("--detail-hero-bg", "none");
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", "recommendBlock"].forEach((id) => $(id).style.display = "none");
["seasonTabs", "episodeRail", "castRail", "personInfo", "personWorkRail", "recommendWorkRail"].forEach((id) => $(id).replaceChildren());
}
async function loadDetail(item) {
try {
const body = await requestJson(tmdbDetailUrl(item), 18);
if (!body || state.selected !== item) return;
state.detail = body;
renderDetailExtras(item, body);
} catch (e) {
$("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);
renderCast(detail.credits && detail.credits.cast || []);
if (item.mediaType === "tv") renderSeasons(item, detail);
loadRecommendations(item);
normalizeRails();
updatePostPanFocusState();
}
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 detailTextClampLimit() {
return useLargeDetailLayout() ? DETAIL_TEXT_CLAMP_LIMIT_LARGE : DETAIL_TEXT_CLAMP_LIMIT;
}
function detailTextShouldClampByLength(text) {
const plain = String(text && text.textContent || "").replace(/\s+/g, "").trim();
return plain.length > detailTextClampLimit();
}
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);
const lengthClamp = detailTextShouldClampByLength(text);
if (overflow <= 0 && !lengthClamp) return;
if (naturalHeight <= minHeight + 1) return;
const lengthMaxHeight = lineHeight * (useLargeDetailLayout() ? 4.2 : 3.15);
const overflowMaxHeight = overflow > 0 ? textRect.height - overflow - measureDetailMoreHeight(more) - 6 : naturalHeight;
const maxHeight = Math.max(minHeight, Math.min(lengthClamp ? lengthMaxHeight : naturalHeight, overflowMaxHeight));
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, 18);
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" });
button.innerHTML = `
<img alt="" ${imageAttrs(profile)}>
<div><b>${escapeHtml(person.name)}</b><span>${escapeHtml(person.character || person.known_for_department || "")}</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: "w300" }) : "";
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", "recommendBlock"].forEach((id) => {
const block = $(id);
if (!block) return;
block.setAttribute("aria-hidden", panActive ? "true" : "false");
block.querySelectorAll(".focusable,button,input,textarea").forEach((el) => {
if (panActive) {
if (!el.dataset.panFocusLocked) {
el.dataset.panFocusLocked = "1";
el.dataset.panPrevTabindex = el.hasAttribute("tabindex") ? el.getAttribute("tabindex") || "" : "__none__";
}
el.setAttribute("tabindex", "-1");
} else if (el.dataset.panFocusLocked) {
const previous = el.dataset.panPrevTabindex;
if (previous && previous !== "__none__") el.setAttribute("tabindex", previous);
else el.removeAttribute("tabindex");
delete el.dataset.panFocusLocked;
delete el.dataset.panPrevTabindex;
}
});
});
}
function renderPanResults() {
const list = $("panResultList");
if (!list) return;
renderPanTabs();
const active = ensureActivePanType();
const items = rankedPanResults(active);
const total = state.pan.results.length;
const activeName = active ? panDiskName(active) : "";
$("panSearchHint").textContent = state.pan.keyword ? `${state.pan.keyword} · 共 ${total}${activeName ? " · " + activeName + " " + items.length : ""}` : "";
if (!total) {
state.pan.renderKeys = "";
list.replaceChildren(emptyNode("暂无盘搜资源,后台仍会继续更新"));
return;
}
if (!items.length) {
state.pan.renderKeys = "";
list.replaceChildren(emptyNode("当前网盘暂无资源"));
return;
}
const keys = `${active}\n` + items.map((item) => `${item.key}:${getPanHealth(item).state}:${item.normalizedUrl || ""}`).join("\n");
const activeEl = document.activeElement;
const shouldRestore = !!(activeEl && list.contains(activeEl)) || state.pan.focusMode === "results";
const restoreKey = shouldRestore && activeEl.dataset.panKey || state.pan.focusKey || "";
list.setAttribute("tabindex", "-1");
list.setAttribute("role", "listbox");
if (patchPanResultList(list, items, active, shouldRestore, restoreKey)) return;
if (keys === state.pan.renderKeys && list.children.length === items.length) {
restorePanResultFocus(list, restoreKey, shouldRestore);
return;
}
state.pan.renderKeys = keys;
list.replaceChildren(...items.map(panResultNode));
observePanVisibleItems();
restorePanResultFocus(list, restoreKey, shouldRestore);
}
function patchPanResultList(list, items, active, shouldRestore, restoreKey) {
const existing = Array.from(list.querySelectorAll(".pan-result-item"));
if (!existing.length || existing.some((node) => !items.some((item) => item.key === node.dataset.panKey))) return false;
const nodes = new Map(existing.map((node) => [node.dataset.panKey, node]));
const ordered = items.map((item) => {
const node = nodes.get(item.key);
if (node) {
updatePanResultNode(node, item);
return node;
}
return panResultNode(item);
});
let reference = list.firstChild;
ordered.forEach((node) => {
if (node === reference) {
reference = reference.nextSibling;
return;
}
list.insertBefore(node, reference);
});
state.pan.renderKeys = `${active}\n` + items.map((item) => `${item.key}:${getPanHealth(item).state}:${item.normalizedUrl || ""}`).join("\n");
observePanVisibleItems();
restorePanResultFocus(list, restoreKey, shouldRestore);
return true;
}
function updatePanResultNode(button, item) {
const health = getPanHealth(item);
button.className = "pan-result-item focusable" + (health.state === "bad" ? " is-bad" : "");
button.dataset.panKey = item.key;
button.innerHTML = panResultHtml(item, health);
}
function restorePanResultFocus(list, key, shouldRestore) {
if (!shouldRestore || !list) return;
const target = key ? findByDataset(list, "panKey", key) : null;
const fallback = list.querySelector(".pan-result-item");
lockPanFocus(target || fallback, "results", { keepBlockPosition: true, once: true });
}
function lockPanFocus(target, mode, options) {
if (!target) return;
const opts = options || {};
const apply = () => {
if (!$("panSearchBlock") || $("panSearchBlock").style.display === "none") return;
if (mode && state.pan.focusMode && state.pan.focusMode !== mode) return;
const active = document.activeElement;
const inPan = active && ($("panTabs").contains(active) || $("panResultList").contains(active));
if (inPan && mode && state.pan.focusMode !== mode) return;
if (inPan && active === target) return;
if (!isVisibleFocusable(target)) return;
state.pan.focusMode = mode || state.pan.focusMode;
focusPanTarget(target, opts);
};
requestAnimationFrame(apply);
if (opts.once) return;
setTimeout(apply, 80);
setTimeout(apply, 220);
}
function focusPanTarget(target, options) {
if (!target) return;
const opts = options || {};
const list = $("panResultList");
const tabs = $("panTabs");
const keepBlockPosition = opts.keepBlockPosition || Date.now() < Number(state.pan.keepBlockPositionUntil || 0);
if (tabs && tabs.contains(target) && !keepBlockPosition) centerPanSearchBlock();
try {
target.focus({ preventScroll: true });
} catch (e) {
target.focus();
}
if (list && list.contains(target)) {
keepPanResultItemVisible(target, list);
if (!opts.keepBlockPosition) ensurePanListViewport();
return;
}
if (tabs && tabs.contains(target)) return;
const rect = target.getBoundingClientRect();
const height = window.innerHeight || document.documentElement.clientHeight || 0;
if (height && (rect.top < 48 || rect.bottom > height - 48)) {
try { target.scrollIntoView({ block: "nearest", inline: "nearest" }); } catch (e) { target.scrollIntoView(false); }
}
}
function centerPanSearchBlock() {
const block = $("panSearchBlock");
const detail = $("detailSheet");
if (!block || !detail || !detail.classList.contains("active")) return;
try {
block.scrollIntoView({ block: "center", inline: "nearest" });
} catch (e) {
const blockRect = block.getBoundingClientRect();
const detailRect = detail.getBoundingClientRect();
const height = detail.clientHeight || window.innerHeight || 0;
if (!height) return;
detail.scrollTop += blockRect.top - detailRect.top - Math.round((height - Math.min(blockRect.height, height * .72)) / 2);
}
}
function ensurePanListViewport() {
const list = $("panResultList");
const detail = $("detailSheet");
if (!list || !detail || !detail.classList.contains("active")) return;
const listRect = list.getBoundingClientRect();
const detailRect = detail.getBoundingClientRect();
const height = detail.clientHeight || window.innerHeight || 0;
if (!height) return;
const topLimit = detailRect.top + Math.max(34, height * .1);
const bottomLimit = detailRect.top + height - Math.max(50, height * .1);
if (listRect.top < topLimit) detail.scrollTop += listRect.top - topLimit;
else if (listRect.bottom > bottomLimit && listRect.top > topLimit) detail.scrollTop += Math.min(listRect.top - topLimit, listRect.bottom - bottomLimit);
}
function keepPanResultItemVisible(target, list) {
const itemRect = target.getBoundingClientRect();
const listRect = list.getBoundingClientRect();
const topDelta = itemRect.top - listRect.top;
const bottomDelta = itemRect.bottom - listRect.bottom;
if (topDelta < 0) list.scrollTop += topDelta;
else if (bottomDelta > 0) list.scrollTop += bottomDelta;
}
function findByDataset(root, name, value) {
if (!root) return null;
return Array.from(root.querySelectorAll("[data-" + name.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase()) + "]"))
.find((el) => String(el.dataset[name] || "") === String(value || "")) || null;
}
function panResultNode(item) {
const health = getPanHealth(item);
const button = document.createElement("button");
button.className = "pan-result-item focusable" + (health.state === "bad" ? " is-bad" : "");
button.type = "button";
button.dataset.panKey = item.key;
button.innerHTML = panResultHtml(item, health);
button.addEventListener("focus", () => { state.pan.focusKey = item.key; state.pan.focusMode = "results"; });
button.addEventListener("click", () => playPanResult(item));
return button;
}
function panResultHtml(item, health) {
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");
document.body.classList.remove("detail-active");
document.body.classList.remove("episode-active");
sheet.classList.remove("active");
sheet.classList.remove("detail-large");
sheet.style.display = "";
sheet.setAttribute("aria-hidden", "true");
state.detailReturn = null;
stopDetailCoverCarousel(true);
resetPanSearch();
setNativeToolbarVisible(true);
restoreHomeReturn();
scheduleUiSnapshotSave();
if (!fromPopState && location.hash === "#detail") history.back();
}
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 nativeHomeSearch(keyword) {
await sdk().search(String(keyword || "").trim(), { direct: false });
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;
const subscribeToken = resetRelaySubscribeState();
state.relay.subscribeStarted = true;
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 active = () => subscribeToken === state.relay.subscribeToken;
const flushQueue = () => {
clearTimeout(flushTimer);
flushTimer = 0;
if (!active()) {
eventQueue = [];
maybeFinishNostrRefresh(subscribeToken);
return;
}
const batch = eventQueue;
eventQueue = [];
if (!batch.length) {
maybeFinishNostrRefresh(subscribeToken);
return;
}
hotIngestEvents(batch).then((changed) => {
if (changed && active()) {
scheduleHotRefresh(HOT_REFRESH_IDLE_MS);
if (isNostrRefreshActive(subscribeToken)) updateNostrRefreshProgress({ phase: "写入索引", indexed: state.hot.items.length });
}
maybeFinishNostrRefresh(subscribeToken);
});
};
const startBackfill = () => {
if (!active() || backfillStarted) return;
backfillStarted = true;
state.relay.backfillCandidates[relay] = Math.max(Number(state.relay.backfillCandidates[relay] || 0), seenEvents);
choosePrimaryBackfillRelay(false);
syncRelayBackfill(relay, subscribeToken).catch(() => {});
};
const timer = setTimeout(() => {
if (!active()) return;
if (state.relay.statuses[relay] === "已连接") startBackfill();
finishRelaySubscribe(relay, state.relay.statuses[relay] === "已连接" ? "" : "失败");
maybeFinishNostrRefresh(subscribeToken);
try { ws.close(); } catch (e) {}
}, 8000);
ws.onopen = () => {
if (!active()) {
try { ws.close(); } catch (e) {}
return;
}
state.relay.connected += 1;
if (isNostrRefreshActive(subscribeToken)) updateNostrRefreshProgress({ phase: "订阅近7天", connected: state.relay.connected, relay: shortRelay(relay) });
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) => {
if (!active()) return;
const data = safeJson(message.data, []);
if (data[0] === "EOSE") {
clearTimeout(timer);
flushQueue();
finishRelaySubscribe(relay, "");
if (isNostrRefreshActive(subscribeToken)) updateNostrRefreshProgress({ subscribeDone: state.relay.subscribeDone, indexed: state.hot.items.length });
startBackfill();
maybeFinishNostrRefresh(subscribeToken);
try {
ws.send(JSON.stringify(["CLOSE", subId]));
ws.close();
} catch (e) {}
return;
}
if (data[0] !== "EVENT" || !data[2]) return;
seenEvents += 1;
if (isNostrRefreshActive(subscribeToken)) updateNostrRefreshProgress({ phase: "接收订阅", subEvents: Number(state.relay.refresh && state.relay.refresh.subEvents || 0) + 1, relay: shortRelay(relay) });
eventQueue.push(data[2]);
if (!flushTimer) flushTimer = setTimeout(flushQueue, 100);
};
ws.onclose = () => {
clearTimeout(timer);
flushQueue();
if (!active()) return;
if (state.relay.statuses[relay] !== "已连接") finishRelaySubscribe(relay, "断开");
maybeFinishNostrRefresh(subscribeToken);
};
ws.onerror = () => {
clearTimeout(timer);
flushQueue();
if (!active()) return;
finishRelaySubscribe(relay, "失败");
if (!Object.values(state.relay.statuses).includes("已连接")) setStatus("nostr", "连接失败");
maybeFinishNostrRefresh(subscribeToken);
};
} catch (e) {
if (subscribeToken !== state.relay.subscribeToken) return;
finishRelaySubscribe(relay, "失败");
maybeFinishNostrRefresh(subscribeToken);
}
});
}
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);
}
});
}
const RELAY_MIRROR_PAGE_LIMIT = 1000;
const RELAY_MIRROR_MAX_PAGES_PER_SOURCE = 120;
function normalizeRelayUrl(value) {
const text = String(value || "").trim();
if (!text) return "";
try {
const url = new URL(text);
if (url.protocol !== "wss:" && url.protocol !== "ws:") return "";
url.hash = "";
return url.toString().replace(/\/$/, "");
} catch (e) {
return "";
}
}
function parseRelayList(value) {
return Array.from(new Set(String(value || "")
.split(/[\s,]+/)
.map(normalizeRelayUrl)
.filter(Boolean)));
}
function setupRelayMirrorDefaults() {
const source = $("mirrorSourceInput");
const target = $("mirrorTargetInput");
if (source && !source.value.trim()) source.value = window.WEBHOME_CONFIG.nostr.relays.join("\n");
if (target) target.value = target.value || "";
disablePanelTextEditing(source);
disablePanelTextEditing(target);
}
function setMirrorProgress(lines) {
const el = $("mirrorProgress");
if (!el) return;
el.textContent = Array.isArray(lines) ? lines.filter(Boolean).join("\n") : String(lines || "镜像未执行");
}
function isMirrorableHotEvent(event) {
return !!(event && event.id && event.pubkey && event.sig && event.kind === window.WEBHOME_CONFIG.nostr.kind && getD(event) === HOT_VECTOR_D && !eventExpired(event));
}
function mirrorEventKey(event) {
return eventAddress(event) || String(event && event.id || "");
}
function mirrorEventIsNewer(next, old) {
if (!old) return true;
const nextCreated = Number(next && next.created_at || 0);
const oldCreated = Number(old && old.created_at || 0);
if (nextCreated !== oldCreated) return nextCreated > oldCreated;
return String(next && next.id || "") > String(old && old.id || "");
}
async function queryMirrorEventsFromRelay(relay, since, progress) {
const byId = new Map();
let until = hotNow();
let pages = 0;
while (until > since && pages < RELAY_MIRROR_MAX_PAGES_PER_SOURCE) {
pages += 1;
setMirrorProgress([
`拉取中:${shortRelay(relay)}`,
`第 ${pages} 页,已收到 ${byId.size} 条`,
progress
]);
const events = await queryRelayHotPage(relay, {
kinds: [window.WEBHOME_CONFIG.nostr.kind],
"#t": [window.WEBHOME_CONFIG.nostr.tag],
"#d": [HOT_VECTOR_D],
since,
until,
limit: RELAY_MIRROR_PAGE_LIMIT
}, 12000);
events.filter(isMirrorableHotEvent).forEach((event) => byId.set(event.id, event));
if (!events.length || !events.complete) break;
let oldest = until;
for (const event of events) oldest = Math.min(oldest, Number(event.created_at || oldest));
if (events.length < RELAY_MIRROR_PAGE_LIMIT || oldest >= until) break;
until = oldest - 1;
}
return Array.from(byId.values());
}
async function publishMirrorEventToRelay(relay, event, timeout) {
return new Promise((resolve) => {
let settled = false;
const done = (ok, text) => {
if (settled) return;
settled = true;
resolve({ ok, text: text || "" });
};
try {
const ws = new WebSocket(relay);
const timer = setTimeout(() => {
try { ws.close(); } catch (e) {}
done(false, "超时");
}, timeout || 9000);
ws.onopen = () => ws.send(JSON.stringify(["EVENT", stripLocal(event)]));
ws.onmessage = (message) => {
const data = safeJson(message.data, []);
if (data[0] !== "OK") return;
clearTimeout(timer);
try { ws.close(); } catch (e) {}
done(!!data[2], data[2] ? "OK" : data[3] || "拒绝");
};
ws.onerror = () => {
clearTimeout(timer);
done(false, "失败");
};
ws.onclose = () => done(false, "断开");
} catch (e) {
done(false, e.message || "失败");
}
});
}
async function mirrorHotEventsToRelays() {
if (mirrorHotEventsToRelays.busy) return;
const sources = parseRelayList($("mirrorSourceInput") && $("mirrorSourceInput").value || window.WEBHOME_CONFIG.nostr.relays.join("\n"));
const targets = parseRelayList($("mirrorTargetInput") && $("mirrorTargetInput").value || "");
if (!sources.length) return toast("请填写源 relay");
if (!targets.length) return toast("请填写目标 relay");
mirrorHotEventsToRelays.busy = true;
const button = $("mirrorRelayBtn");
if (button) button.textContent = "镜像中";
try {
const since = hotWindowStart();
const eventsByAddress = new Map();
for (const relay of sources) {
const events = await queryMirrorEventsFromRelay(relay, since, `源 ${sources.indexOf(relay) + 1}/${sources.length} · 用户 ${eventsByAddress.size} 个`);
events.forEach((event) => {
const key = mirrorEventKey(event);
if (key && mirrorEventIsNewer(event, eventsByAddress.get(key))) eventsByAddress.set(key, event);
});
setMirrorProgress([
`源完成:${shortRelay(relay)}`,
`本源有效 ${events.length} 条`,
`按用户去重后 ${eventsByAddress.size} 条`
]);
}
const events = Array.from(eventsByAddress.values()).sort((a, b) => Number(b.created_at || 0) - Number(a.created_at || 0));
if (!events.length) {
setMirrorProgress(["镜像完成", "没有从源 relay 拉到可镜像的榜单事件"]);
toast("没有可镜像事件");
return;
}
let ok = 0;
let rejected = 0;
let failed = 0;
let done = 0;
for (const target of targets) {
let targetOk = 0;
let targetRejected = 0;
let targetFailed = 0;
for (const event of events) {
done += 1;
setMirrorProgress([
`发布中:${shortRelay(target)}`,
`事件 ${done}/${events.length * targets.length}`,
`总计 OK ${ok} · 拒绝 ${rejected} · 失败 ${failed}`
]);
const result = await publishMirrorEventToRelay(target, event, 9000);
if (result.ok) {
ok += 1;
targetOk += 1;
} else if (/duplicate|exists|already|newer|older|replace/i.test(result.text || "")) {
ok += 1;
targetOk += 1;
} else if ((result.text || "").includes("拒绝")) {
rejected += 1;
targetRejected += 1;
} else {
failed += 1;
targetFailed += 1;
}
}
setMirrorProgress([
`目标完成:${shortRelay(target)}`,
`OK ${targetOk} · 拒绝 ${targetRejected} · 失败 ${targetFailed}`,
`总计 OK ${ok} · 拒绝 ${rejected} · 失败 ${failed}`
]);
}
setMirrorProgress([
"镜像完成",
`源 relay ${sources.length} 个,目标 relay ${targets.length} 个`,
`最新用户事件 ${events.length} 条`,
`发布 OK ${ok} · 拒绝 ${rejected} · 失败 ${failed}`
]);
toast(`镜像完成:${events.length} 条事件`);
} finally {
mirrorHotEventsToRelays.busy = false;
if (button) button.textContent = "镜像榜单";
}
}
async function syncRelayBackfill(relay, token) {
const runToken = token == null ? state.relay.subscribeToken : token;
if (runToken !== state.relay.subscribeToken) return;
if (state.relay.backfillBusy[relay]) return;
state.relay.backfillBusy[relay] = true;
let cursor = null;
try {
const db = await openHotDb();
if (runToken !== state.relay.subscribeToken) return;
if (!db) {
if (isNostrRefreshActive(runToken)) updateNostrRefreshProgress({ active: false, phase: "本地索引不可用", finishedAt: Date.now() });
return;
}
const primary = selectPrimaryBackfillRelay(relay, runToken);
const isPrimary = primary === relay;
if (isNostrRefreshActive(runToken)) updateNostrRefreshProgress({ phase: isPrimary ? "回填历史" : "回填近7天", relay: shortRelay(relay) });
const since = isPrimary ? hotWindowStart() : hotBackupWindowStart();
const now = hotNow();
cursor = normalizeRelayCursor(await hotStoreGet("relayCursor", relay), relay, since, now);
if (runToken !== state.relay.subscribeToken) return;
if (cursor.nextRetryAt && cursor.nextRetryAt > now) return;
let changed = false;
changed = await syncRelayRecent(relay, cursor, since, now, runToken) || changed;
if (isPrimary && !cursor.recentUntil) changed = await syncRelayHistory(relay, cursor, since, now, runToken) || changed;
if (runToken !== state.relay.subscribeToken) return;
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天"}`);
if (isNostrRefreshActive(runToken)) updateNostrRefreshProgress({ phase: "写入索引", indexed: state.hot.items.length, relay: shortRelay(relay) });
}
} finally {
if (runToken === state.relay.subscribeToken) {
delete state.relay.backfillBusy[relay];
if (cursor && !relayBackfillDone(cursor)) scheduleRelayBackfill(relay, relayBackfillDelay(cursor), runToken);
maybeFinishNostrRefresh(runToken);
}
}
}
function selectPrimaryBackfillRelay(relay, token) {
const primary = choosePrimaryBackfillRelay(state.relay.subscribeDone >= window.WEBHOME_CONFIG.nostr.relays.length);
if (primary && primary !== relay && !state.relay.backfillBusy[primary]) scheduleRelayBackfill(primary, undefined, token);
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, token) {
clearTimeout(state.relay.backfillTimers[relay]);
const runToken = token == null ? state.relay.subscribeToken : token;
state.relay.backfillTimers[relay] = setTimeout(() => {
delete state.relay.backfillTimers[relay];
if (isNostrRefreshActive(runToken)) updateNostrRefreshProgress({ phase: "回填排队完成", relay: shortRelay(relay) });
syncRelayBackfill(relay, runToken).catch(() => {});
}, Number.isFinite(delay) ? delay : HOT_BACKFILL_IDLE_MS);
if (isNostrRefreshActive(runToken)) updateNostrRefreshProgress({ phase: "等待回填", relay: shortRelay(relay) });
}
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, token) {
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 (token != null && token !== state.relay.subscribeToken) break;
if (isNostrRefreshActive(token)) {
updateNostrRefreshProgress({
phase: "回填近7天",
relay: shortRelay(relay),
recentPages: Number(state.relay.refresh && state.relay.refresh.recentPages || 0) + 1,
recentEvents: Number(state.relay.refresh && state.relay.refresh.recentEvents || 0) + events.length
});
}
if (!events.length) {
if (!events.complete) {
markRelayBackfillRetry(cursor);
break;
}
clearRelayBackfillRetry(cursor);
finishRelayRecent(cursor);
break;
}
clearRelayBackfillRetry(cursor);
let oldest = cursor.recentUntil;
for (const event of events) oldest = Math.min(oldest, Number(event.created_at || oldest));
if (await hotIngestEvents(events)) {
changed = true;
if (isNostrRefreshActive(token)) updateNostrRefreshProgress({ phase: "写入索引", indexed: state.hot.items.length, relay: shortRelay(relay) });
}
cursor.recentUntil = oldest - 1;
if (!events.complete) break;
if (events.length < HOT_PAGE_LIMIT || cursor.recentUntil < cursor.recentTarget) {
finishRelayRecent(cursor);
break;
}
if (token != null && token !== state.relay.subscribeToken) 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, token) {
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 (token != null && token !== state.relay.subscribeToken) break;
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 (token != null && token !== state.relay.subscribeToken) break;
if (isNostrRefreshActive(token)) {
updateNostrRefreshProgress({
phase: "回填历史",
relay: shortRelay(relay),
historyPages: Number(state.relay.refresh && state.relay.refresh.historyPages || 0) + 1,
historyEvents: Number(state.relay.refresh && state.relay.refresh.historyEvents || 0) + events.length
});
}
if (!events.length) {
if (!events.complete) {
markRelayBackfillRetry(cursor);
break;
}
clearRelayBackfillRetry(cursor);
cursor.historyDone = true;
break;
}
clearRelayBackfillRetry(cursor);
let oldest = cursor.historyUntil;
for (const event of events) oldest = Math.min(oldest, Number(event.created_at || oldest));
if (await hotIngestEvents(events)) {
changed = true;
if (isNostrRefreshActive(token)) updateNostrRefreshProgress({ phase: "写入索引", indexed: state.hot.items.length, relay: shortRelay(relay) });
}
cursor.historyUntil = oldest - 1;
if (!events.complete) break;
cursor.historyDone = events.length < HOT_PAGE_LIMIT || cursor.historyUntil <= since;
if (cursor.historyDone) break;
}
return changed;
}
function publishEvent(event) {
if (!event || !event.sig) {
setStatus("publish", "未签名,未发布");
return;
}
const relays = window.WEBHOME_CONFIG.nostr.relays;
state.relay.total = relays.length;
let done = 0;
let ok = 0;
state.relay.lastOk = 0;
state.relay.lastDone = 0;
setStatus("publish", `发布中 0/${relays.length}`);
relays.forEach((relay) => {
try {
const ws = new WebSocket(relay);
const finish = (success, text) => {
done += 1;
if (success) ok += 1;
state.relay.lastOk = ok;
state.relay.lastDone = done;
setStatus("publish", `发布 ${ok}/${done}/${relays.length}${text ? " · " + text : ""}`);
renderMetrics();
};
const timer = setTimeout(() => {
finish(false, shortRelay(relay) + " 超时");
try { ws.close(); } catch (e) {}
}, 6000);
ws.onopen = () => ws.send(JSON.stringify(["EVENT", stripLocal(event)]));
ws.onmessage = (message) => {
const data = safeJson(message.data, []);
if (data[0] === "OK") {
clearTimeout(timer);
if (data[2]) state.relay.published += 1;
finish(!!data[2], `${shortRelay(relay)} ${data[2] ? "OK" : data[3] || "拒绝"}`);
try { ws.close(); } catch (e) {}
}
};
ws.onerror = () => {
clearTimeout(timer);
finish(false, shortRelay(relay) + " 失败");
};
} catch (e) {
done += 1;
state.relay.lastOk = ok;
state.relay.lastDone = done;
setStatus("publish", `发布 ${ok}/${done}/${relays.length} · ${shortRelay(relay)} 失败`);
}
});
}
function eventAddress(event) {
const d = getD(event);
if (!event || !event.kind || !event.pubkey || !d) return "";
return `${event.kind}:${event.pubkey}:${d}`;
}
function createDeleteEvent(events, identity) {
const ids = Array.from(new Set(events.map((event) => event && event.id).filter(Boolean)));
const addresses = Array.from(new Set(events.map(eventAddress).filter(Boolean)));
const tags = ids.map((id) => ["e", id]).concat(addresses.map((addr) => ["a", addr]));
tags.push(["t", window.WEBHOME_CONFIG.nostr.tag], ["app", "fongmi-webhome"]);
const draft = {
kind: 5,
created_at: Math.floor(Date.now() / 1000),
tags,
content: "delete fongmi webhome preference events"
};
return window.NostrTools.finalizeEvent(draft, identity.secret);
}
function publishToRelay(relay, event, timeout) {
return new Promise((resolve) => {
let settled = false;
const done = (ok, text) => {
if (settled) return;
settled = true;
resolve({ ok, text });
};
try {
const ws = new WebSocket(relay);
const timer = setTimeout(() => {
try { ws.close(); } catch (e) {}
done(false, "超时");
}, timeout || 7000);
ws.onopen = () => ws.send(JSON.stringify(["EVENT", event]));
ws.onmessage = (message) => {
const data = safeJson(message.data, []);
if (data[0] !== "OK") return;
clearTimeout(timer);
try { ws.close(); } catch (e) {}
done(!!data[2], data[2] ? "OK" : data[3] || "拒绝");
};
ws.onerror = () => {
clearTimeout(timer);
done(false, "失败");
};
} catch (e) {
done(false, e.message || "失败");
}
});
}
async function queryRelayEventsPaged(relay, pubkey, since) {
const events = [];
let until = Math.floor(Date.now() / 1000);
for (let page = 0; page < 20; page++) {
const batch = await queryRelayHotPage(relay, {
kinds: [window.WEBHOME_CONFIG.nostr.kind],
authors: [pubkey],
"#t": [window.WEBHOME_CONFIG.nostr.tag],
"#d": [HOT_VECTOR_D],
since,
until,
limit: HOT_PAGE_LIMIT
}, 9000);
if (!batch.length) break;
events.push(...batch);
until = Math.min(...batch.map((event) => Number(event.created_at || until))) - 1;
if (batch.length < HOT_PAGE_LIMIT) break;
}
return events;
}
async function clearLocalPreferenceData() {
await clearLocalEvents(false);
setStatus("publish", "已清理本机缓存");
toast("本机偏好缓存已清理");
}
async function clearMyNostrData(identity) {
identity = identity || await ensureIdentity();
if (!identity || !identity.pubkey || !identity.secret) return toast("请先同步或生成身份");
setStatus("publish", "查询我的事件");
const since = hotWindowStart();
const relays = window.WEBHOME_CONFIG.nostr.relays;
const found = [];
for (const relay of relays) found.push(...await queryRelayEventsPaged(relay, identity.pubkey, since));
const events = Array.from(new Map(found.filter((event) => event && event.id).map((event) => [event.id, event])).values());
if (!events.length) {
await clearLocalEvents(true);
setStatus("publish", "没有找到我的远端事件");
toast("没有找到需要删除的数据");
return;
}
const deleteEvent = createDeleteEvent(events, identity);
let ok = 0;
let done = 0;
setStatus("publish", `删除中 0/${relays.length}`);
for (const relay of relays) {
const result = await publishToRelay(relay, deleteEvent, 7000);
done += 1;
if (result.ok) ok += 1;
setStatus("publish", `删除 ${ok}/${done}/${relays.length} · ${shortRelay(relay)} ${result.text || ""}`);
}
await clearLocalEvents(true);
toast(`删除请求已发送 ${ok}/${relays.length}`);
}
async function deleteAllPreferenceData() {
const identity = await ensureIdentity();
await markIdentityDeletedLocally(identity);
stopWatchTracking(false);
await clearPendingWatch();
await clearLocalPreferenceData();
await clearMyNostrData(identity);
}
function stripLocal(event) {
const copy = Object.assign({}, event);
delete copy.local;
return copy;
}
function getD(event) {
const tag = (event && event.tags || []).find((item) => item && item[0] === "d");
return tag && tag[1];
}
function eventContent(event) {
const content = event && event.content;
if (content && typeof content === "object") return content;
return safeJson(content, null);
}
function bindActions() {
$("refreshNostrBtn").addEventListener("click", () => {
forceRefreshNostrRanking().catch((e) => toast(e.message || "刷新榜单失败"));
});
$("mirrorRelayBtn").addEventListener("click", () => {
mirrorHotEventsToRelays().catch((e) => {
setMirrorProgress(["镜像失败", e.message || String(e || "")]);
toast(e.message || "镜像失败");
});
});
$("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();
$("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();
});
const searchSubmit = $("searchSubmitBtn") || $("searchForm").querySelector("button[type='submit']");
searchSubmit.addEventListener("pointerdown", handleSearchNativeHoldStart);
searchSubmit.addEventListener("pointermove", handleSearchNativeHoldMove);
searchSubmit.addEventListener("pointerup", handleSearchNativeHoldEnd);
searchSubmit.addEventListener("pointercancel", clearSearchHold);
searchSubmit.addEventListener("pointerleave", clearSearchHold);
searchSubmit.addEventListener("contextmenu", (event) => {
if (Date.now() < Number(state.searchHold.suppressClickUntil || 0)) event.preventDefault();
});
searchSubmit.addEventListener("blur", clearSearchHold);
searchSubmit.addEventListener("click", handleSearchSubmitClick);
$("clearSearchBtn").addEventListener("click", clearSearchResults);
$("closeDetailBtn").addEventListener("click", () => closeDetail(false));
$("detailSearchBtn").addEventListener("click", () => {
if (!state.selected) return;
rememberWatchIntent(state.selected, "search");
startWatchTracking(state.selected);
nativeSearch(state.selected.title);
});
$("panSearchBtn").addEventListener("click", () => {
if (!state.selected) return;
searchPanResources(state.selected);
});
$("backTopBtn").addEventListener("click", () => {
scrollHomeToTop();
});
}
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 (!editing && shouldThrottleRemoteNav(key, event, el)) {
event.preventDefault();
return;
}
if (key !== "Enter" && ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(key) && ensureRemoteActiveFocus()) {
event.preventDefault();
return;
}
if (key === "Enter") {
const blockCard = blockableRecommendCard(el);
if (consumeBlockCardEnterDown(blockCard, event)) return;
if (handleConnectionPanelEnterKey(event)) return;
if (consumeSearchSubmitEnterDown(el, 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 (exitBlockSelectMode()) {
event.preventDefault();
return;
}
if (handleSearchSuggestBackKey(event)) return;
if (handleConnectionPanelBackKey(event)) return;
if (handlePanBackKey(event)) return;
if (handleSearchBackKey(event)) return;
event.preventDefault();
if ($("imageViewer").classList.contains("active")) return history.back();
if ($("detailSheet").classList.contains("active")) return history.back();
if ($("syncSheet").classList.contains("active")) return history.back();
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 = fastHomeDirectionalTarget(key, el) || 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 (state.blocked.selecting) return;
if (handleSearchSuggestBackKey(event)) return;
if (handleConnectionPanelBackKey(event)) return;
if (handlePanBackKey(event)) return;
handleSearchBackKey(event);
}, true);
document.addEventListener("keyup", (event) => {
const key = normalizeRemoteKey(event);
if (key !== "Enter") return;
if (handleSearchSubmitEnterUp(event)) return;
if (handleBlockCardEnterUp(event)) return;
clearBlockLongPress();
}, true);
}
function activateFocusedElement(el, event) {
if (!isVisibleFocusable(el)) return false;
if (isTextEditingElement(el)) return false;
event.preventDefault();
event.stopPropagation();
if (state.blocked.longPressFired) {
state.blocked.longPressFired = false;
return true;
}
const blockCard = blockableRecommendCard(el);
if (state.blocked.selecting && blockCard) return true;
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;
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 (active === input && key === "ArrowRight") return false;
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", "recommendBlock"];
for (const id of ids) {
const root = $(id);
if (root && root.contains(el)) return { id, root };
}
return null;
}
function detailBlockOrder() {
const order = [];
if (isVisibleFocusable($("closeDetailBtn"))) order.push({ id: "closeDetailBtn", root: $("closeDetailBtn") });
const info = $("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", "recommendBlock"].forEach((id) => {
const root = $(id);
if (root && root.style.display !== "none" && root.getAttribute("aria-hidden") !== "true" && detailBlockFocusables(root).length) order.push({ id, root });
});
return order;
}
function detailVerticalTarget(currentBlock, active, delta) {
const seasonTarget = currentBlock.id === "seasonBlock" ? detailSeasonVerticalTarget(active, delta) : null;
if (seasonTarget) return seasonTarget;
const order = detailBlockOrder();
const index = order.findIndex((block) => block.id === currentBlock.id);
if (index < 0) return null;
const blocks = delta > 0 ? order.slice(index + 1) : order.slice(0, index).reverse();
for (const block of blocks) {
const target = detailClosestInBlock(block.root, active, delta);
if (target) return target;
}
return null;
}
function detailHorizontalTarget(currentBlock, active, delta) {
const row = active && active.closest && active.closest("#seasonTabs, #episodeRail, #castRail, #personWorkRail, #recommendWorkRail, #panTabs, .actions");
const items = detailBlockFocusables(row || currentBlock.root);
const index = items.indexOf(active);
if (index < 0) return null;
return items[index + delta] || null;
}
function detailSeasonVerticalTarget(active, delta) {
const tabs = $("seasonTabs");
const rail = $("episodeRail");
if (tabs && tabs.contains(active)) {
if (delta < 0) return null;
return detailClosestInBlock(rail, active, delta);
}
if (rail && rail.contains(active)) {
if (delta > 0) return null;
return detailClosestInBlock(tabs, active, delta);
}
return null;
}
function detailClosestInBlock(root, active, delta) {
const items = detailBlockFocusables(root);
if (!items.length) return null;
if (!active || !active.getBoundingClientRect) return delta > 0 ? items[0] : items[items.length - 1];
const from = center(active.getBoundingClientRect());
let best = null;
let bestScore = Infinity;
for (const item of items) {
const rect = item.getBoundingClientRect();
const to = center(rect);
const vertical = Math.max(0, delta > 0 ? rect.top - from.y : from.y - rect.bottom);
const cross = Math.abs(to.x - from.x);
const score = vertical * 1.1 + cross * 1.5;
if (score < bestScore) {
best = item;
bestScore = score;
}
}
return best || (delta > 0 ? items[0] : items[items.length - 1]);
}
function detailBlockFocusables(root) {
if (!root) return [];
if (root.matches && root.matches(".focusable,button,input,textarea")) return isVisibleFocusable(root) ? [root] : [];
return Array.from(root.querySelectorAll(".focusable,button,input,textarea")).filter(isVisibleFocusable);
}
function isFirstVisiblePanResult(el) {
const list = $("panResultList");
if (!list || !list.contains(el)) return false;
let item = list.firstElementChild;
while (item) {
if (item.classList && item.classList.contains("pan-result-item") && isVisibleFocusable(item)) return item === el;
item = item.nextElementSibling;
}
return false;
}
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] || "";
}
function shouldThrottleRemoteNav(key, event, active) {
if (!isTvMode() || !["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(key)) return false;
if (focusScopeRoot() !== document || isConnectionPanelOpen() || isSearchSuggestOpen()) return false;
if ($("searchForm") && active && $("searchForm").contains(active)) return false;
const now = window.performance && performance.now ? performance.now() : Date.now();
const gate = state.remoteKeyGate || { key: "", at: 0 };
const minGap = gate.key === key ? 56 : 38;
if (gate.at && now - gate.at < minGap) return true;
state.remoteKeyGate = { key, at: now };
return false;
}
function fastHomeDirectionalTarget(key, active) {
if (!isTvMode() || focusScopeRoot() !== document || isConnectionPanelOpen() || isSearchSuggestOpen()) return null;
if (!active || !["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(key)) return null;
if (isTextEditingElement(active)) return null;
if ($("connectionDock") && $("connectionDock").contains(active)) return null;
if ($("searchForm") && $("searchForm").contains(active)) return null;
return fastHomeChipTarget(key, active)
|| fastHomeGridTarget(key, active)
|| fastHomeSearchRailTarget(key, active);
}
function fastHomeChipTarget(key, active) {
const chips = $("chips");
if (!chips || !chips.contains(active)) return null;
if (key === "ArrowLeft" || key === "ArrowRight") return fastHomeSibling(chips, active, key === "ArrowRight" ? 1 : -1, ".chip");
return null;
}
function fastHomeGridTarget(key, active) {
const card = active && active.classList && active.classList.contains("card") ? active : null;
const grid = card && card.closest && card.closest(".media-grid");
if (!grid || grid.closest && grid.closest(".list-panel[hidden]")) return null;
if (grid !== activeMediaGrid()) return null;
let index = Number(card.dataset.cardIndex || -1);
if (!Number.isFinite(index) || index < 0) index = Array.prototype.indexOf.call(grid.children, card);
if (index < 0) return null;
const columns = Math.max(1, gridColumns(grid));
const count = grid.children.length;
let targetIndex = -1;
if (key === "ArrowLeft" && index % columns > 0) targetIndex = index - 1;
else if (key === "ArrowRight" && index % columns < columns - 1) targetIndex = index + 1;
else if (key === "ArrowUp") targetIndex = index - columns;
else if (key === "ArrowDown") targetIndex = index + columns;
if (targetIndex >= 0 && targetIndex < count) return homeGridCardAt(grid, targetIndex);
if (key === "ArrowDown" && targetIndex >= count && appendGridBatch(grid)) {
observeInfiniteScroll();
return homeGridCardAt(grid, targetIndex);
}
return null;
}
function fastHomeSearchRailTarget(key, active) {
const rail = active && active.closest && active.closest("#searchRail");
if (!rail || !rail.contains(active)) return null;
if (key === "ArrowLeft" || key === "ArrowRight") return fastHomeSibling(rail, active, key === "ArrowRight" ? 1 : -1, ".card");
return null;
}
function homeGridCardAt(grid, index) {
const target = grid && index >= 0 ? grid.children[index] : null;
return target && target.classList && target.classList.contains("card") && canFastHomeFocus(target) ? target : null;
}
function fastHomeSibling(root, current, delta, selector) {
let target = delta > 0 ? current.nextElementSibling : current.previousElementSibling;
while (target && root.contains(target)) {
if (target.matches && target.matches(selector) && canFastHomeFocus(target)) return target;
target = delta > 0 ? target.nextElementSibling : target.previousElementSibling;
}
return null;
}
function canFastHomeFocus(el) {
return !!(el && el.matches && el.matches(".focusable,button,input,textarea") && !el.disabled && el.getAttribute("tabindex") !== "-1" && !el.hidden && !el.closest("[hidden]") && !el.closest('[aria-hidden="true"]'));
}
function focusRemoteTarget(target) {
if (!target) return;
try {
target.focus({ preventScroll: true });
} catch (e) {
target.focus();
}
maybeAppendGridForFocus(target);
scheduleFocusInView(target);
}
function scheduleFocusInView(target) {
state.focusScrollTarget = target;
if (state.focusScrollTimer) return;
state.focusScrollTimer = requestAnimationFrame(() => {
const target = state.focusScrollTarget;
state.focusScrollTimer = 0;
state.focusScrollTarget = null;
if (target && target.isConnected) keepFocusInView(target);
});
}
function keepFocusInView(target) {
if (!target) 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;
if (rect.top >= 54 && rect.bottom <= height - 54 && rect.left >= 8 && rect.right <= width - 8) return;
try {
target.scrollIntoView({ block: "nearest", inline: "nearest" });
} 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;
const style = window.getComputedStyle ? window.getComputedStyle(el) : null;
if (style && (style.visibility === "hidden" || style.display === "none" || style.pointerEvents === "none")) return false;
return !!(el.offsetParent !== null || el.getClientRects().length);
}
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));
}
function visibleFocusable() {
const root = focusScopeRoot();
return Array.from(root.querySelectorAll(".focusable,button,input,textarea"))
.filter(isVisibleFocusable);
}
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)"];
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,
homeReturn: state.homeReturn || 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;
state.homeReturn = snapshot.homeReturn || 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 {
requestAnimationFrame(() => window.scrollTo(0, Number(snapshot.scrollY || 0)));
}
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);
}
window.addEventListener("popstate", () => {
if (location.hash !== "#detail" && $("detailSheet").classList.contains("active") && state.pan.focusMode === "results") {
history.pushState({ sheet: "detail" }, "", "#detail");
focusPanTabFromResults();
return;
}
if (location.hash !== "#image" && $("imageViewer").classList.contains("active")) closeImage(true);
if (location.hash !== "#detail" && $("detailSheet").classList.contains("active")) closeDetail(true);
if (location.hash !== "#sync" && $("syncSheet").classList.contains("active")) closeSync(true);
});
window.addEventListener("fmviewport", () => {
state.gridColumnCache = {};
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();
});
window.addEventListener("resize", () => {
state.gridColumnCache = {};
lockViewportWidth();
fitConnectionPanel();
const detailLayoutChanged = syncDetailLayout();
if (detailLayoutChanged && $("detailSheet") && $("detailSheet").classList.contains("active") && state.selected) refreshActiveDetailView();
renderAll({ deferContent: uiSnapshotRoute() === "home" });
scheduleDetailTextClamp();
});
window.addEventListener("scroll", () => {
state.scrollingUntil = Date.now() + 900;
updateBackTopButton();
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", () => {
Promise.all([
initTmdbConfig({ preserveDirty: true, timeout: 0 }),
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";
}
async function boot() {
lockViewportWidth();
bindActions();
installRemoteKeys();
await initTmdbConfig();
await initPanConfig();
setupRelayMirrorDefaults();
await loadBlockedRecommend();
renderConnection();
armFallbackTimers();
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);
}
boot();
</script>
</body>
</html>