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

13736 lines
528 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
<meta name="theme-color" content="#101419">
<title>Nostr</title>
<script>
window.WEBHOME_PAGE_OPENED_AT = Date.now();
if (window.fongmiBridge) document.documentElement.classList.add("fm-native");
(function () {
// 移动端禁止放大缩小:拦截 iOS 手势缩放与双击缩放(meta 标签在部分系统会被忽略)
var prevent = function (e) { if (e.cancelable) e.preventDefault(); };
document.addEventListener("gesturestart", prevent, { passive: false });
document.addEventListener("gesturechange", prevent, { passive: false });
document.addEventListener("gestureend", prevent, { passive: false });
document.addEventListener("touchmove", function (e) {
if (e.touches && e.touches.length > 1 && e.cancelable) e.preventDefault();
}, { passive: false });
var lastTouchEnd = 0;
document.addEventListener("touchend", function (e) {
var now = Date.now();
if (now - lastTouchEnd <= 300 && e.cancelable) e.preventDefault();
lastTouchEnd = now;
}, { passive: false });
})();
(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; });
});
};
}
var detectLayoutGap = function () {
if (!document || !document.body || !document.documentElement) return;
var root = document.documentElement;
var flex = document.createElement("div");
var grid = document.createElement("div");
var makeChild = function () {
var child = document.createElement("div");
child.style.height = "1px";
child.style.width = "1px";
return child;
};
try {
flex.style.cssText = "position:absolute;left:-9999px;top:-9999px;display:flex;flex-direction:column;gap:1px;visibility:hidden;";
flex.appendChild(makeChild());
flex.appendChild(makeChild());
grid.style.cssText = "position:absolute;left:-9999px;top:-9999px;display:grid;grid-template-columns:1px;gap:1px;visibility:hidden;";
grid.appendChild(makeChild());
grid.appendChild(makeChild());
document.body.appendChild(flex);
document.body.appendChild(grid);
if (flex.scrollHeight < 3 || grid.scrollHeight < 3) root.classList.add("no-layout-gap");
} catch (e) {
root.classList.add("no-layout-gap");
}
try {
if (flex.parentNode) flex.parentNode.removeChild(flex);
if (grid.parentNode) grid.parentNode.removeChild(grid);
} catch (e) {}
};
var detectCssFunctions = function () {
if (!document || !document.documentElement || !window.CSS || !CSS.supports) {
document.documentElement.classList.add("no-css-functions");
return;
}
try {
if (!CSS.supports("width", "min(10px, 20px)") || !CSS.supports("width", "clamp(10px, 2vw, 20px)")) {
document.documentElement.classList.add("no-css-functions");
}
} catch (e) {
document.documentElement.classList.add("no-css-functions");
}
};
var detectAspectRatio = function () {
if (!document || !document.documentElement) return;
var root = document.documentElement;
if (!window.CSS || !CSS.supports) {
root.classList.add("no-aspect-ratio");
return;
}
try {
if (!CSS.supports("aspect-ratio", "2 / 3")) {
root.classList.add("no-aspect-ratio");
return;
}
} catch (e) {
root.classList.add("no-aspect-ratio");
return;
}
var measure = function () {
if (!document.body) return;
var box = document.createElement("div");
try {
box.style.cssText = "position:absolute;left:-9999px;top:-9999px;width:20px;aspect-ratio:2/1;visibility:hidden;";
document.body.appendChild(box);
var height = box.getBoundingClientRect ? box.getBoundingClientRect().height : box.offsetHeight;
if (height < 8 || height > 12) root.classList.add("no-aspect-ratio");
} catch (e) {
root.classList.add("no-aspect-ratio");
}
try { if (box.parentNode) box.parentNode.removeChild(box); } catch (e) {}
};
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", measure);
else measure();
};
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", detectLayoutGap);
else detectLayoutGap();
detectCssFunctions();
detectAspectRatio();
})();
</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;
touch-action: pan-x pan-y;
}
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;
}
.mobile-detail-back {
display: none;
position: fixed;
top: max(12px, env(safe-area-inset-top));
left: 12px;
z-index: 90;
width: 42px;
height: 42px;
place-items: center;
border-radius: 999px;
color: rgba(255, 255, 255, .92);
background: rgba(12, 18, 24, .18);
border: 1px solid rgba(255, 255, 255, .16);
box-shadow: 0 8px 22px rgba(0, 0, 0, .18);
backdrop-filter: blur(8px) saturate(1.05);
transition: opacity .16s ease, visibility .16s ease, transform .16s ease;
}
.mobile-detail-back .icon {
width: 21px;
height: 21px;
}
.mobile-detail-back:focus {
z-index: 95;
background: rgba(16, 24, 32, .34);
}
.mobile-detail-back.is-hidden {
opacity: 0;
visibility: hidden;
pointer-events: none;
transform: translateY(-8px);
}
html.native-mobile-app body.detail-active .mobile-detail-back,
html.native-mobile-app body.episode-active .image-viewer .mobile-detail-back {
display: grid;
}
.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: 11px;
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: 12px;
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 auto;
gap: 10px;
margin: 14px 0 8px;
position: relative;
z-index: 20;
}
.search .field {
flex: 1 1 auto;
min-width: 0;
}
.search .btn,
.search .icon-btn {
flex: 0 0 auto;
margin-left: 10px;
}
.search .icon-btn {
width: 44px;
height: auto;
min-height: 44px;
align-self: stretch;
}
@supports (display: grid) {
.search .btn,
.search .icon-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: 13px;
line-height: 1.25;
}
.suggest-item span {
color: var(--muted);
font-size: 11px;
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: 12px;
text-shadow: 0 1px 2px rgba(0, 0, 0, .38);
backdrop-filter: blur(12px) saturate(1.05);
display: inline-flex;
align-items: center;
gap: 5px;
white-space: nowrap;
}
/* 分类图标(参考美化最终.html)*/
.chip-icon {
width: 15px;
height: 15px;
flex-shrink: 0;
opacity: .75;
}
.chip.active .chip-icon { opacity: 1; }
.chip.active {
background: var(--control-active);
color: var(--text);
border-color: var(--line-strong);
}
/* 导航栏选中态呼吸光晕(参考美化最终.html):仅手机/浏览器,排除 TV 端 */
html:not(.tv-mode) .chip.active {
position: relative;
overflow: hidden;
}
html:not(.tv-mode) .chip.active::before {
content: "";
display: block;
position: absolute;
inset: 0;
border-radius: inherit;
background: radial-gradient(ellipse at 50% 120%,
rgba(140, 200, 255, .70) 0%,
rgba(160, 110, 255, .45) 35%,
transparent 68%);
pointer-events: none;
z-index: 0;
animation: chip-inner-breathe 2s ease-in-out infinite;
animation-delay: -1s;
}
@keyframes chip-inner-breathe {
0% { opacity: .30; transform: scaleY(.85); }
50% { opacity: 1; transform: scaleY(1); }
100% { opacity: .30; transform: scaleY(.85); }
}
/* chip 文字浮在光晕之上 */
html:not(.tv-mode) .chip.active > * {
position: relative;
z-index: 1;
}
.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: 17px;
line-height: 1.2;
}
.section small {
color: var(--muted);
font-size: 11px;
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: 14px;
line-height: 1.2;
}
.subsection-head span {
color: var(--muted);
font-size: 11px;
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: rgba(var(--panel-rgb), .29);
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);
text-align: left;
}
.rating-badge {
position: absolute;
right: 7px;
top: 7px;
min-width: 24px;
height: 18px;
padding: 0 5px;
display: grid;
place-items: center;
border-radius: 999px;
background: rgba(255, 235, 120, .28);
border: 1px solid rgba(255, 230, 100, .35);
color: rgba(255, 248, 200, .95);
font-size: 10px;
font-weight: 800;
line-height: 1;
backdrop-filter: blur(8px);
}
.card-meta {
margin-top: 6px;
color: var(--muted);
font-size: 11px;
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: 12px;
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: 10px;
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: 11px;
line-height: 1.2;
}
.recent-card .card-meta {
max-width: 100%;
margin-top: 4px;
color: rgba(238, 244, 250, .74);
font-size: 10px;
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: 10px;
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: 10px;
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: 11px;
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: 15px;
line-height: 1.2;
}
.live-copy p {
margin: 5px 0 0;
color: var(--muted);
font-size: 11px;
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: 10px;
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: 11px;
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: 12px;
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: 19px;
line-height: 1.1;
}
.metric span {
display: block;
margin-top: 8px;
color: var(--muted);
font-size: 11px;
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; }
/* ── 沉浸式全屏背景(替代 ::before CSS变量方案)── */
.sheet::before { display: none; }
.sheet::after { display: none; }
.detail-hero-bg {
position: fixed;
inset: 0;
z-index: 59;
background-size: cover;
background-position: center 10%;
filter: brightness(0.78) saturate(1.05);
opacity: 0;
transition: opacity 0.45s cubic-bezier(.25,.8,.30,1), filter 0.18s ease;
pointer-events: none;
will-change: opacity, filter;
display: none;
}
.detail-hero-bg.active {
display: block;
opacity: 1;
}
.detail-hero-bg.closing {
opacity: 0;
transition: opacity 0.22s ease;
}
/* 毛玻璃模糊层(下半部分) */
.sheet-blur-layer {
display: none;
position: fixed;
inset: 0;
z-index: 59;
pointer-events: none;
backdrop-filter: blur(18px) saturate(1.05);
-webkit-backdrop-filter: blur(18px) saturate(1.05);
-webkit-mask-image: linear-gradient(180deg, transparent 0%, transparent 68%, black 80%);
mask-image: linear-gradient(180deg, transparent 0%, transparent 68%, black 80%);
}
.sheet-blur-layer.active {
display: block;
}
/* 透明占位:撑开上半屏,让内容沉到下方 */
.detail-spacer {
flex: 1 0 42vh;
min-height: 42vh;
pointer-events: none;
position: relative;
}
html.detail-immersive .detail-spacer {
flex: 1 0 38vh;
min-height: 38vh;
}
/* 沉浸模式 sheet 从最顶端开始 */
html.detail-immersive #detailSheet.sheet {
top: 0 !important;
padding-top: 0 !important;
padding-bottom: calc(32px + var(--fm-safe-bottom, 20px) + env(safe-area-inset-bottom, 0px)) !important;
}
html.detail-immersive .detail-hero-bg {
bottom: 0 !important;
}
/* 移动端:sheet 改为 flex 列布局让 spacer 生效 */
@media (max-width: 719px) {
html:not(.tv-mode) #detailSheet.sheet {
display: none;
flex-direction: column;
justify-content: flex-start;
padding-top: 0 !important;
}
html:not(.tv-mode) #detailSheet.sheet.active {
display: flex;
}
/* 移动端隐藏 detail-cover 图片框,用全屏背景代替 */
html:not(.tv-mode) #detailSheet:not(.detail-large) .detail-cover {
display: none !important;
}
}
.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-logo {
display: block;
max-height: 64px;
max-width: min(280px, 55vw);
width: auto;
object-fit: contain;
object-position: left center;
filter: drop-shadow(0 2px 8px rgba(0,0,0,.55));
}
#detailSheet.detail-large .detail-title-logo {
max-height: 80px;
max-width: min(380px, 38vw);
}
html.native-mobile-app #detailSheet:not(.detail-large) .detail-title-logo {
max-height: 72px;
max-width: min(260px, 60vw);
}
.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: 12px;
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: 9px;
}
.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: 11px;
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: 16px;
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-wrap {
width: calc(100% + 2px);
max-width: calc(100% + 2px);
display: block;
margin: -1px -1px 0;
aspect-ratio: 16 / 9;
overflow: hidden;
line-height: 0;
background: transparent;
}
.episode-still {
width: 100%;
max-width: none;
height: 100%;
margin: 0;
aspect-ratio: 16 / 9;
object-fit: cover;
object-position: center top;
}
.episode-card b,
.person-card b {
display: block;
font-size: 13px;
line-height: 1.25;
}
.episode-card span,
.person-card span {
display: block;
margin-top: 6px;
color: var(--muted);
font-size: 11px;
line-height: 1.35;
}
#detailSheet .episode-card .episode-badge {
display: none;
}
.episode-card p {
margin: 3px 0 0;
color: rgba(244, 247, 251, .78);
font-size: 11px;
line-height: 1.26;
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* 圆形胶囊式演职员卡片 */
.person-card {
flex: 0 0 auto;
width: auto;
max-width: none;
min-width: 0;
height: auto;
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
border-radius: 999px;
background: rgba(18, 24, 38, .15);
border: 1px solid var(--line);
color: var(--text);
overflow: hidden;
padding: 6px 14px 6px 6px;
text-align: left;
scroll-snap-align: start;
white-space: nowrap;
}
.person-card img {
width: 44px;
height: 44px;
min-width: 44px;
max-width: 44px;
flex: 0 0 44px;
aspect-ratio: 1 / 1;
object-fit: cover;
object-position: center top;
border-radius: 50%;
margin: 0;
background: rgba(255, 255, 255, .07);
}
.person-card div {
padding: 0;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.person-card b {
display: block;
font-size: 12px;
font-weight: 760;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 130px;
}
.person-card span {
display: block;
margin-top: 0;
color: var(--muted);
font-size: 10px;
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 130px;
}
.person-card .person-en-name {
font-size: 9px;
color: rgba(244, 247, 251, .38);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 130px;
}
#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: 16px;
font-weight: 620;
backdrop-filter: none;
}
#detailSheet.detail-large .detail-info p {
max-width: min(620px, 42vw);
color: rgba(232, 239, 247, .82);
font-size: clamp(11px, 1.05vw, 14px);
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: 14px;
box-shadow: none;
}
#detailSheet.detail-large.episode-preview-active .detail-info {
max-width: calc(100vw - var(--detail-side-pad) - var(--detail-side-pad));
height: var(--episode-preview-info-height, auto);
overflow: visible;
}
#detailSheet.detail-large.episode-preview-active .detail-title-row {
flex-wrap: nowrap;
max-width: calc(100vw - var(--detail-side-pad) - var(--detail-side-pad));
height: 64px;
height: clamp(50px, 5.8vw, 74px);
overflow: hidden;
text-shadow: none !important;
box-shadow: none !important;
filter: none !important;
backdrop-filter: none !important;
}
#detailSheet.detail-large.episode-preview-active .detail-info h2 {
flex: 0 1 auto;
min-width: 0;
max-width: min(760px, 54vw);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-shadow: none !important;
box-shadow: none !important;
filter: none !important;
backdrop-filter: none !important;
}
#detailSheet.detail-large.episode-preview-active .detail-title-meta {
display: none;
flex: 0 0 auto;
white-space: nowrap;
text-shadow: none !important;
box-shadow: none !important;
filter: none !important;
backdrop-filter: none !important;
}
#detailSheet.detail-large.episode-preview-active > .actions {
visibility: hidden;
pointer-events: none;
}
#detailSheet.detail-large.episode-preview-active .detail-info p#detailText {
max-width: min(620px, 42vw);
outline: none !important;
border-color: transparent !important;
box-shadow: none !important;
pointer-events: none;
user-select: none;
-webkit-user-select: none;
-webkit-tap-highlight-color: transparent;
}
#detailSheet.detail-large.episode-preview-active .detail-info p#detailText.clamped[data-more-label] {
position: relative;
padding-right: 64px;
}
#detailSheet.detail-large.episode-preview-active .detail-info p#detailText.clamped[data-more-label]::after {
content: attr(data-more-label);
position: absolute;
right: 0;
bottom: 0;
min-width: 0;
color: rgba(129, 190, 255, .96);
font-weight: 780;
text-align: right;
background: transparent !important;
background-color: transparent !important;
background-image: none !important;
box-shadow: none !important;
text-shadow: none !important;
filter: none !important;
backdrop-filter: none !important;
}
#detailSheet.detail-large.episode-preview-active #detailMoreBtn {
background: transparent !important;
background-color: transparent !important;
background-image: none !important;
box-shadow: none !important;
text-shadow: none !important;
filter: none !important;
backdrop-filter: none !important;
display: none !important;
}
#detailSheet.detail-large.episode-preview-active .detail-info p#detailText.clamped {
max-height: var(--episode-preview-text-max, 7.6em);
overflow: hidden;
}
#detailSheet.detail-large.episode-preview-expanded .detail-info p#detailText {
visibility: visible;
max-width: none;
width: calc(100vw - var(--detail-side-pad) - var(--detail-side-pad));
max-height: none;
overflow: visible;
}
#detailSheet.detail-large.episode-preview-expanded .detail-info h2 {
max-width: calc(100vw - var(--detail-side-pad) - var(--detail-side-pad));
}
#detailSheet.detail-large.episode-preview-expanded #seasonBlock > h3,
#detailSheet.detail-large.episode-preview-expanded #seasonTabs {
visibility: hidden;
pointer-events: none;
}
html.no-css-functions #detailSheet.detail-large.episode-preview-active .detail-info,
html.no-css-functions #detailSheet.detail-large.episode-preview-active .detail-title-row,
html.legacy-detail-layout #detailSheet.detail-large.episode-preview-active .detail-info,
html.legacy-detail-layout #detailSheet.detail-large.episode-preview-active .detail-title-row {
max-width: 1152px;
}
html.no-css-functions #detailSheet.detail-large .detail-info p,
html.legacy-detail-layout #detailSheet.detail-large .detail-info p,
html.no-css-functions #detailSheet.detail-large.episode-preview-active .detail-info p#detailText,
html.legacy-detail-layout #detailSheet.detail-large.episode-preview-active .detail-info p#detailText {
max-width: 560px;
}
html.no-css-functions #detailSheet.detail-large.episode-preview-active .detail-info,
html.legacy-detail-layout #detailSheet.detail-large.episode-preview-active .detail-info {
height: 220px;
max-height: 220px;
}
html.no-css-functions #detailSheet.detail-large.episode-preview-active .detail-title-row,
html.legacy-detail-layout #detailSheet.detail-large.episode-preview-active .detail-title-row {
height: 64px;
}
html.no-css-functions #detailSheet.detail-large.episode-preview-active .detail-info p#detailText.clamped,
html.legacy-detail-layout #detailSheet.detail-large.episode-preview-active .detail-info p#detailText.clamped {
max-height: 116px;
}
html.no-css-functions #detailSheet.detail-large.episode-preview-expanded .detail-info p#detailText,
html.legacy-detail-layout #detailSheet.detail-large.episode-preview-expanded .detail-info p#detailText {
width: 1152px;
}
#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;
}
#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: 13px;
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 auto;
max-width: none;
height: auto;
flex-direction: row;
align-items: center;
border-radius: 999px;
background: rgba(255, 255, 255, .06);
border-color: rgba(255, 255, 255, .1);
box-shadow: none;
padding: 8px 18px 8px 8px;
gap: 14px;
}
#detailSheet.detail-large .person-card img {
width: 56px;
min-width: 56px;
max-width: 56px;
height: 56px;
flex: 0 0 56px;
aspect-ratio: 1 / 1;
border-radius: 50%;
object-fit: cover;
object-position: center 18%;
margin: 0;
background: rgba(255, 255, 255, .08);
}
#detailSheet.detail-large .person-card div {
padding: 0;
}
#detailSheet.detail-large .person-card b {
font-size: clamp(14px, 1.2vw, 18px);
font-weight: 820;
line-height: 1.2;
text-shadow: 0 2px 10px rgba(0, 0, 0, .5);
max-width: 180px;
}
#detailSheet.detail-large .person-card span {
margin-top: 3px;
color: rgba(232, 239, 247, .58);
font-size: clamp(11px, 1vw, 14px);
line-height: 1.2;
max-width: 180px;
}
#detailSheet.detail-large .person-card .person-en-name {
max-width: 180px;
}
#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: 15px;
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;
}
}
html.native-mobile-app #detailSheet:not(.detail-large) {
--mobile-detail-x: 20px;
--mobile-hero-min: min(72vh, 680px);
background: #081016;
padding: 0 var(--mobile-detail-x) calc(32px + var(--fm-safe-bottom) + env(safe-area-inset-bottom));
overflow-y: auto;
overflow-x: hidden;
overscroll-behavior: contain;
}
html.native-mobile-app #detailSheet:not(.detail-large).active {
display: flex;
flex-direction: column;
}
html.native-mobile-app #detailSheet:not(.detail-large)::before,
html.native-mobile-app #detailSheet:not(.detail-large)::after {
content: "";
position: fixed;
top: 0;
right: 0;
left: 0;
pointer-events: none;
z-index: 0;
}
html.native-mobile-app #detailSheet:not(.detail-large)::before {
bottom: 0;
background:
linear-gradient(180deg, rgba(4, 8, 12, .02) 0%, rgba(4, 8, 12, .06) 34%, rgba(4, 8, 12, .58) 67%, #081016 100%),
var(--detail-hero-bg, none);
background-size: cover;
background-position: center top;
filter: saturate(.96) contrast(.98);
opacity: .98;
}
html.native-mobile-app #detailSheet:not(.detail-large)::after {
bottom: 0;
background:
radial-gradient(circle at 50% 18%, rgba(255, 255, 255, .08), transparent 30%),
linear-gradient(180deg, rgba(2, 5, 8, .08) 0%, rgba(3, 7, 11, .08) 38%, rgba(5, 10, 15, .72) 70%, #081016 100%),
linear-gradient(90deg, rgba(3, 7, 11, .28), rgba(3, 7, 11, .02) 42%, rgba(3, 7, 11, .28));
}
html.native-mobile-app #detailSheet:not(.detail-large) > * {
position: relative;
z-index: 2;
}
html.native-mobile-app #detailSheet:not(.detail-large) .mobile-detail-back {
position: fixed;
top: max(14px, env(safe-area-inset-top));
left: 16px;
width: 54px;
height: 54px;
color: rgba(255, 255, 255, .96);
background: rgba(25, 35, 44, .28);
border-color: rgba(255, 255, 255, .22);
box-shadow: 0 12px 30px rgba(0, 0, 0, .24), inset 0 1px 0 rgba(255, 255, 255, .12);
backdrop-filter: blur(14px) saturate(1.08);
}
html.native-mobile-app #detailSheet:not(.detail-large) .mobile-detail-back .icon {
width: 25px;
height: 25px;
stroke-width: 2.4;
}
html.native-mobile-app #detailSheet:not(.detail-large) .detail-cover {
position: fixed;
top: 0;
right: 0;
left: 0;
z-index: 1;
width: 100vw;
max-width: none;
height: var(--mobile-hero-min);
min-height: 520px;
max-height: none;
margin: 0;
border-radius: 0;
aspect-ratio: auto;
pointer-events: none;
overflow: hidden;
background: transparent;
}
html.native-mobile-app #detailSheet:not(.detail-large) .detail-cover::after {
content: "";
position: absolute;
inset: 0;
z-index: 2;
background:
linear-gradient(180deg, rgba(0, 0, 0, .06) 0%, rgba(0, 0, 0, .05) 46%, rgba(5, 9, 13, .62) 78%, #081016 100%),
linear-gradient(90deg, rgba(4, 8, 12, .18), transparent 38%, rgba(4, 8, 12, .18));
}
html.native-mobile-app #detailSheet:not(.detail-large) .detail-cover.poster-mode::after {
display: block !important;
}
html.native-mobile-app #detailSheet:not(.detail-large) .detail-cover.loading::before {
z-index: 3;
}
html.native-mobile-app #detailSheet:not(.detail-large) .detail-cover img {
width: 100%;
height: 100%;
object-fit: cover !important;
object-position: center top;
filter: saturate(.94) contrast(.98) brightness(.82);
}
html.native-mobile-app #detailSheet:not(.detail-large) .detail-info {
display: contents;
margin-top: max(410px, calc(var(--mobile-hero-min) - 220px));
padding: 0;
text-shadow: 0 2px 14px rgba(0, 0, 0, .56);
}
html.native-mobile-app #detailSheet:not(.detail-large) .detail-title-row {
order: 1;
}
html.native-mobile-app #detailSheet:not(.detail-large) .detail-meta {
order: 2;
}
html.native-mobile-app #detailSheet:not(.detail-large) .detail-info p {
order: 3;
}
html.native-mobile-app #detailSheet:not(.detail-large) > .actions {
order: 4;
}
html.native-mobile-app #detailSheet:not(.detail-large) #detailMoreBtn {
order: 5;
}
html.native-mobile-app #detailSheet:not(.detail-large) .detail-block {
order: 6;
}
html.native-mobile-app #detailSheet:not(.detail-large) .detail-title-row,
html.native-mobile-app #detailSheet:not(.detail-large) .detail-meta,
html.native-mobile-app #detailSheet:not(.detail-large) .detail-info p,
html.native-mobile-app #detailSheet:not(.detail-large) #detailMoreBtn {
position: relative;
z-index: 2;
}
html.native-mobile-app #detailSheet:not(.detail-large) .detail-title-row {
display: block;
margin: max(410px, calc(var(--mobile-hero-min) - 220px)) 0 12px;
}
html.native-mobile-app #detailSheet:not(.detail-large) .detail-info h2 {
max-width: calc(100vw - var(--mobile-detail-x) * 2 - 92px);
margin: 0 0 10px;
color: rgba(255, 255, 255, .98);
font-size: clamp(38px, 11vw, 54px);
line-height: 1.02;
font-weight: 900;
letter-spacing: 0;
text-shadow: 0 4px 20px rgba(0, 0, 0, .62);
}
html.native-mobile-app #detailSheet:not(.detail-large) .detail-title-meta {
display: flex;
flex-wrap: wrap;
gap: 7px;
color: rgba(245, 248, 251, .86);
font-size: 19px;
font-weight: 780;
line-height: 1.2;
}
html.native-mobile-app #detailSheet:not(.detail-large) .detail-title-meta .score {
color: rgba(255, 213, 96, .98);
}
html.native-mobile-app #detailSheet:not(.detail-large) .detail-title-meta .sep {
color: rgba(245, 248, 251, .48);
}
html.native-mobile-app #detailSheet:not(.detail-large) .detail-meta {
gap: 8px;
margin: 10px 0 20px;
}
html.native-mobile-app #detailSheet:not(.detail-large) .meta-pill {
min-height: 34px;
display: inline-flex;
align-items: center;
padding: 0 13px;
border: 0;
border-radius: 999px;
color: rgba(245, 248, 251, .78);
background: rgba(255, 255, 255, .13);
font-size: 14px;
font-weight: 620;
backdrop-filter: blur(10px) saturate(1.04);
}
html.native-mobile-app #detailSheet:not(.detail-large) .meta-pill.strong {
color: rgba(255, 225, 132, .98);
background: rgba(255, 255, 255, .16);
font-weight: 820;
}
html.native-mobile-app #detailSheet:not(.detail-large) .detail-info p {
color: rgba(246, 249, 252, .82);
font-size: 12px;
line-height: 1.72;
font-weight: 430;
text-shadow: 0 2px 12px rgba(0, 0, 0, .64);
}
html.native-mobile-app #detailSheet:not(.detail-large) .detail-info p.clamped {
max-height: var(--detail-text-max, 6.9em);
padding-right: 54px;
overflow: hidden;
cursor: pointer;
}
html.native-mobile-app #detailSheet:not(.detail-large) #detailMoreBtn {
min-height: 0;
margin: -1.72em 0 0 auto;
padding: 0;
border: 0;
background: transparent;
color: rgba(190, 220, 255, .92);
box-shadow: none;
font-size: 14px;
line-height: 1.72;
text-shadow: none;
backdrop-filter: none;
}
html.native-mobile-app #detailSheet:not(.detail-large) > .actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin: 20px 0 0;
}
html.native-mobile-app #detailSheet:not(.detail-large) > .actions.has-continue {
grid-template-columns: minmax(132px, 1fr) minmax(82px, .58fr) minmax(82px, .58fr);
gap: 8px;
}
html.native-mobile-app #detailSheet:not(.detail-large) > .actions .btn {
min-width: 0;
min-height: 50px;
justify-content: center;
border-radius: 999px;
border-color: rgba(255, 255, 255, .18);
background: rgba(255, 255, 255, .15);
color: rgba(255, 255, 255, .95);
box-shadow: 0 10px 26px rgba(0, 0, 0, .18), inset 0 1px 0 rgba(255, 255, 255, .1);
font-size: 15px;
font-weight: 800;
backdrop-filter: blur(16px) saturate(1.08);
}
html.native-mobile-app #detailSheet:not(.detail-large) > .actions.has-continue .btn {
min-height: 50px;
gap: 6px;
padding: 0 8px;
border-radius: 14px;
font-size: 13px;
line-height: 1.05;
white-space: nowrap;
backdrop-filter: none;
-webkit-font-smoothing: antialiased;
}
html.native-mobile-app #detailSheet:not(.detail-large) > .actions.has-continue .icon {
width: 18px;
height: 18px;
}
html.native-mobile-app #detailSheet:not(.detail-large) > .actions.has-continue #detailContinueBtn {
color: #fff;
background:
radial-gradient(circle at 24% 20%, rgba(255, 255, 255, .34), rgba(255, 255, 255, 0) 34%),
linear-gradient(135deg, #12d7ff 0%, #0b91ff 58%, #0867f7 100%);
border-color: rgba(255, 255, 255, .16);
box-shadow: 0 16px 30px rgba(0, 128, 255, .28), inset 0 1px 0 rgba(255, 255, 255, .26);
font-size: 14px;
font-weight: 800;
text-shadow: none;
}
html.native-mobile-app #detailSheet:not(.detail-large) > .actions.has-continue #detailSearchBtn,
html.native-mobile-app #detailSheet:not(.detail-large) > .actions.has-continue #panSearchBtn {
color: #111827;
background: rgba(255, 255, 255, .98);
border-color: rgba(255, 255, 255, .72);
box-shadow: 0 14px 28px rgba(0, 0, 0, .18), inset 0 1px 0 rgba(255, 255, 255, .72);
font-weight: 800;
text-shadow: none;
}
html.native-mobile-app #detailSheet:not(.detail-large) > .actions .btn.primary {
background: rgba(255, 255, 255, .19);
}
html.native-mobile-app #detailSheet:not(.detail-large) > .actions .icon {
width: 21px;
height: 21px;
}
html.native-mobile-app #detailSheet:not(.detail-large) .detail-block {
margin-top: 28px;
}
html.native-mobile-app #detailSheet:not(.detail-large) .detail-block h3 {
margin: 0 0 14px;
color: rgba(255, 255, 255, .96);
font-size: 20px;
line-height: 1.18;
font-weight: 880;
text-shadow: 0 2px 12px rgba(0, 0, 0, .52);
}
html.native-mobile-app #detailSheet:not(.detail-large) .rail {
width: calc(100% + var(--mobile-detail-x) * 2);
max-width: none;
gap: 12px;
margin-right: calc(var(--mobile-detail-x) * -1);
margin-left: calc(var(--mobile-detail-x) * -1);
padding: 0 var(--mobile-detail-x) 14px;
scroll-padding-left: var(--mobile-detail-x);
}
html.native-mobile-app #detailSheet:not(.detail-large) #seasonTabs {
width: calc(100% + var(--mobile-detail-x) * 2);
max-width: none;
margin-right: calc(var(--mobile-detail-x) * -1);
margin-left: calc(var(--mobile-detail-x) * -1);
padding-right: var(--mobile-detail-x);
padding-left: var(--mobile-detail-x);
scroll-padding-left: var(--mobile-detail-x);
}
html.native-mobile-app #detailSheet:not(.detail-large) .episode-card {
flex-basis: 168px;
max-width: 178px;
min-height: 112px;
display: flex;
flex-direction: column;
border-radius: 12px;
background: rgba(255, 255, 255, .1);
border-color: rgba(255, 255, 255, .13);
overflow: hidden;
box-shadow: 0 10px 24px rgba(0, 0, 0, .2);
}
html.native-mobile-app #detailSheet:not(.detail-large) .episode-card.has-still {
padding: 0;
}
html.native-mobile-app #detailSheet:not(.detail-large) .episode-still-wrap {
width: calc(100% + 2px);
max-width: calc(100% + 2px);
height: 94px;
min-height: 94px;
display: block;
order: 0;
margin: -1px -1px 0;
padding: 0;
border: 0;
aspect-ratio: auto;
overflow: hidden;
line-height: 0;
flex: 0 0 94px;
background: transparent;
}
html.native-mobile-app #detailSheet:not(.detail-large) .episode-still {
width: 100%;
max-width: none;
height: 100%;
min-height: 100%;
margin: 0;
display: block;
aspect-ratio: auto;
object-fit: cover;
object-position: center top;
}
html.native-mobile-app #detailSheet:not(.detail-large) .episode-body {
order: 1;
flex: 1 1 auto;
}
html.native-mobile-app #detailSheet:not(.detail-large) .episode-card b {
font-size: 12px;
line-height: 1.22;
}
html.native-mobile-app #detailSheet:not(.detail-large) .person-card {
flex: 0 0 auto;
max-width: none;
height: auto;
flex-direction: row;
align-items: center;
text-align: left;
background: rgba(255, 255, 255, .07);
border-color: rgba(255, 255, 255, .12);
box-shadow: none;
padding: 6px 14px 6px 6px;
gap: 10px;
border-radius: 999px;
}
html.native-mobile-app #detailSheet:not(.detail-large) .person-card img {
width: 48px;
min-width: 48px;
max-width: 48px;
height: 48px;
flex: 0 0 48px;
margin: 0;
border-radius: 50%;
object-position: center 18%;
box-shadow: 0 4px 12px rgba(0, 0, 0, .3);
}
html.native-mobile-app #detailSheet:not(.detail-large) .person-card div {
width: auto;
min-width: 0;
padding: 0;
text-align: left;
}
html.native-mobile-app #detailSheet:not(.detail-large) .person-card b {
width: auto;
color: rgba(255, 255, 255, .94);
font-size: 12px;
font-weight: 820;
text-align: left;
max-width: 140px;
}
html.native-mobile-app #detailSheet:not(.detail-large) .person-card span {
width: auto;
margin-top: 2px;
color: rgba(232, 239, 247, .58);
font-size: 10px;
text-align: left;
max-width: 140px;
-webkit-line-clamp: 1;
}
@media (max-width: 380px) {
html.native-mobile-app #detailSheet:not(.detail-large) {
--mobile-detail-x: 16px;
}
html.native-mobile-app #detailSheet:not(.detail-large) .detail-info {
margin-top: max(380px, calc(var(--mobile-hero-min) - 240px));
}
html.native-mobile-app #detailSheet:not(.detail-large) .detail-title-row {
margin-top: max(380px, calc(var(--mobile-hero-min) - 240px));
}
html.native-mobile-app #detailSheet:not(.detail-large) .detail-info h2 {
font-size: 33px;
}
html.native-mobile-app #detailSheet:not(.detail-large) .detail-title-meta {
font-size: 17px;
}
html.native-mobile-app #detailSheet:not(.detail-large) > .actions.has-continue {
grid-template-columns: minmax(122px, 1fr) minmax(72px, .58fr) minmax(72px, .58fr);
gap: 6px;
}
html.native-mobile-app #detailSheet:not(.detail-large) > .actions.has-continue .btn {
min-height: 48px;
padding: 0 6px;
font-size: 12px;
}
html.native-mobile-app #detailSheet:not(.detail-large) > .actions.has-continue .icon {
width: 17px;
height: 17px;
}
html.native-mobile-app #detailSheet:not(.detail-large) > .actions.has-continue #detailContinueBtn {
font-size: 12px;
}
}
html.native-mobile-app.mobile-detail-translucent #detailSheet:not(.detail-large) {
--mobile-detail-x: 14px;
--sheet-x: 14px;
background: transparent;
padding: max(16px, env(safe-area-inset-top)) var(--mobile-detail-x) calc(22px + var(--fm-safe-bottom) + env(safe-area-inset-bottom));
backdrop-filter: none;
}
html.native-mobile-app.mobile-detail-translucent #detailSheet:not(.detail-large)::before,
html.native-mobile-app.mobile-detail-translucent #detailSheet:not(.detail-large)::after {
display: none;
}
html.native-mobile-app.mobile-detail-translucent #detailSheet:not(.detail-large) > * {
position: relative;
z-index: auto;
}
html.native-mobile-app.mobile-detail-translucent #detailSheet:not(.detail-large) .mobile-detail-back {
top: max(12px, env(safe-area-inset-top));
left: 12px;
width: 42px;
height: 42px;
color: rgba(255, 255, 255, .92);
background: rgba(12, 18, 24, .18);
border-color: rgba(255, 255, 255, .16);
box-shadow: 0 8px 22px rgba(0, 0, 0, .18);
backdrop-filter: blur(8px) saturate(1.05);
}
html.native-mobile-app.mobile-detail-translucent #detailSheet:not(.detail-large) .mobile-detail-back .icon {
width: 21px;
height: 21px;
stroke-width: 2.2;
}
html.native-mobile-app.mobile-detail-translucent #detailSheet:not(.detail-large) .detail-cover {
position: relative;
top: auto;
right: auto;
left: auto;
z-index: auto;
width: 100%;
max-width: 100%;
height: 56.25vw;
min-height: 180px;
max-height: 42vh;
margin: 14px 0;
border-radius: 14px;
aspect-ratio: 16 / 9;
pointer-events: auto;
overflow: hidden;
background: transparent;
isolation: isolate;
}
html.native-mobile-app.mobile-detail-translucent #detailSheet:not(.detail-large) .detail-cover::after {
display: none;
}
html.native-mobile-app.mobile-detail-translucent #detailSheet:not(.detail-large) .detail-cover img {
object-fit: contain !important;
object-position: left center;
filter: none;
border-radius: inherit;
}
html.native-mobile-app.mobile-detail-translucent #detailSheet:not(.detail-large) .detail-cover.poster-mode img {
object-fit: cover !important;
object-position: center 24%;
}
html.native-mobile-app.mobile-detail-translucent #detailSheet:not(.detail-large) .detail-info {
margin-top: 0;
padding: 0 2px 12px;
text-shadow: none;
}
html.native-mobile-app.mobile-detail-translucent #detailSheet:not(.detail-large) .detail-title-row {
display: flex;
align-items: baseline;
gap: 8px;
flex-wrap: wrap;
margin: 0 0 8px;
}
html.native-mobile-app.mobile-detail-translucent #detailSheet:not(.detail-large) .detail-info h2 {
max-width: none;
margin: 0;
color: var(--text);
font-size: clamp(22px, 6vw, 32px);
line-height: 1.12;
font-weight: 800;
text-shadow: none;
}
html.native-mobile-app.mobile-detail-translucent #detailSheet:not(.detail-large) .detail-title-meta {
display: inline-flex;
align-items: baseline;
flex-wrap: wrap;
gap: 6px;
color: rgba(244, 247, 251, .9);
font-size: 12px;
font-weight: 720;
line-height: 1.2;
text-shadow: 0 1px 2px rgba(0, 0, 0, .38);
}
html.native-mobile-app.mobile-detail-translucent #detailSheet:not(.detail-large) .detail-title-meta .score {
color: rgba(255, 218, 128, .95);
}
html.native-mobile-app.mobile-detail-translucent #detailSheet:not(.detail-large) .detail-title-meta .sep {
display: inline;
color: rgba(244, 247, 251, .42);
}
html.native-mobile-app.mobile-detail-translucent #detailSheet:not(.detail-large) .detail-meta {
gap: 8px;
margin: 8px 0 14px;
}
html.native-mobile-app.mobile-detail-translucent #detailSheet:not(.detail-large) .meta-pill {
min-height: 0;
padding: 5px 8px;
border: 1px solid var(--line);
border-radius: 999px;
color: var(--muted);
background: var(--panel);
font-size: 11px;
font-weight: 400;
backdrop-filter: none;
}
html.native-mobile-app.mobile-detail-translucent #detailSheet:not(.detail-large) .meta-pill.strong {
color: var(--text);
border-color: var(--line-strong);
background: var(--control-active);
font-weight: 760;
}
html.native-mobile-app.mobile-detail-translucent #detailSheet:not(.detail-large) .detail-info p {
color: rgba(244, 247, 251, .82);
font-size: 13px;
line-height: 1.62;
font-weight: 400;
text-shadow: none;
}
html.native-mobile-app.mobile-detail-translucent #detailSheet:not(.detail-large) .detail-info p.clamped {
cursor: pointer;
}
html.native-mobile-app.mobile-detail-translucent #detailSheet:not(.detail-large) #detailMoreBtn {
min-height: 0;
margin: -1.62em 0 0 auto;
padding: 0;
border: 0;
background: transparent;
color: rgba(184, 226, 255, .92);
box-shadow: none;
font-size: 12px;
line-height: 1.62;
}
html.native-mobile-app.mobile-detail-translucent #detailSheet:not(.detail-large) > .actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin: 18px 0 0;
}
html.native-mobile-app.mobile-detail-translucent #detailSheet:not(.detail-large) > .actions.has-continue {
display: grid;
grid-template-columns: minmax(122px, 1fr) minmax(72px, .58fr) minmax(72px, .58fr);
gap: 8px;
}
html.native-mobile-app.mobile-detail-translucent #detailSheet:not(.detail-large) > .actions .btn {
min-width: 0;
min-height: 42px;
justify-content: center;
border-radius: var(--radius);
border-color: var(--line);
background: var(--control);
color: var(--text);
box-shadow: none;
font-size: 13px;
font-weight: 760;
text-shadow: 0 1px 2px rgba(0, 0, 0, .38);
backdrop-filter: blur(12px) saturate(1.05);
}
html.native-mobile-app.mobile-detail-translucent #detailSheet:not(.detail-large) > .actions.has-continue .btn {
min-height: 44px;
gap: 5px;
padding: 0 7px;
border-radius: 13px;
font-size: 12px;
line-height: 1.05;
white-space: nowrap;
}
html.native-mobile-app.mobile-detail-translucent #detailSheet:not(.detail-large) > .actions.has-continue .icon {
width: 18px;
height: 18px;
}
html.native-mobile-app.mobile-detail-translucent #detailSheet:not(.detail-large) > .actions.has-continue #detailContinueBtn {
color: #fff;
background:
radial-gradient(circle at 24% 20%, rgba(255, 255, 255, .34), rgba(255, 255, 255, 0) 34%),
linear-gradient(135deg, #12d7ff 0%, #0b91ff 58%, #0867f7 100%);
border-color: rgba(255, 255, 255, .16);
box-shadow: 0 14px 26px rgba(0, 128, 255, .24), inset 0 1px 0 rgba(255, 255, 255, .24);
font-weight: 800;
text-shadow: none;
}
html.native-mobile-app.mobile-detail-translucent #detailSheet:not(.detail-large) > .actions.has-continue #detailSearchBtn,
html.native-mobile-app.mobile-detail-translucent #detailSheet:not(.detail-large) > .actions.has-continue #panSearchBtn {
color: #111827;
background: rgba(255, 255, 255, .97);
border-color: rgba(255, 255, 255, .72);
box-shadow: 0 12px 24px rgba(0, 0, 0, .14), inset 0 1px 0 rgba(255, 255, 255, .72);
font-weight: 800;
text-shadow: none;
}
html.native-mobile-app.mobile-detail-translucent #detailSheet:not(.detail-large) > .actions .btn.primary {
background: var(--control-active);
border-color: var(--line-strong);
}
html.native-mobile-app.mobile-detail-translucent #detailSheet:not(.detail-large) > .actions .icon {
width: 19px;
height: 19px;
}
html.native-mobile-app.mobile-detail-translucent #detailSheet:not(.detail-large) .detail-block {
margin-top: 18px;
}
html.native-mobile-app.mobile-detail-translucent #detailSheet:not(.detail-large) .detail-block h3 {
margin: 0 0 10px;
color: var(--text);
font-size: 16px;
line-height: 1.2;
font-weight: 760;
text-shadow: none;
}
html.native-mobile-app.mobile-detail-translucent #detailSheet:not(.detail-large) .rail {
width: 100%;
max-width: 100%;
gap: 12px;
margin: 0;
padding: 2px 0 5px;
scroll-padding-left: 0;
}
html.native-mobile-app.mobile-detail-translucent #detailSheet:not(.detail-large) #seasonTabs {
width: 100%;
max-width: 100%;
margin: 0;
padding-right: 0;
padding-left: 0;
scroll-padding-left: 0;
}
html.native-mobile-app.mobile-detail-translucent #detailSheet:not(.detail-large) .episode-card {
flex: 0 0 230px;
max-width: 260px;
min-height: 138px;
display: flex;
flex-direction: column;
border-radius: var(--radius);
background: var(--panel);
border-color: var(--line);
box-shadow: none;
}
html.native-mobile-app.mobile-detail-translucent #detailSheet:not(.detail-large) .episode-still-wrap {
height: 130px;
min-height: 130px;
flex-basis: 130px;
}
html.native-mobile-app.mobile-detail-translucent #detailSheet:not(.detail-large) .episode-card b {
font-size: 13px;
line-height: 1.18;
}
html.native-mobile-app.mobile-detail-translucent #detailSheet:not(.detail-large) .person-card {
flex: 0 0 auto;
max-width: none;
height: auto;
align-items: center;
background: rgba(255, 255, 255, .07);
border-color: rgba(255, 255, 255, .12);
box-shadow: none;
text-align: left;
padding: 6px 14px 6px 6px;
border-radius: 999px;
flex-direction: row;
gap: 10px;
}
html.native-mobile-app.mobile-detail-translucent #detailSheet:not(.detail-large) .person-card img {
width: 48px;
min-width: 48px;
max-width: 48px;
height: 48px;
flex: 0 0 48px;
margin: 0;
border-radius: 50%;
object-position: center top;
box-shadow: none;
}
html.native-mobile-app.mobile-detail-translucent #detailSheet:not(.detail-large) .person-card div {
width: auto;
min-width: 0;
padding: 0;
text-align: left;
}
html.native-mobile-app.mobile-detail-translucent #detailSheet:not(.detail-large) .person-card b {
width: auto;
color: rgba(255, 255, 255, .94);
font-size: 12px;
font-weight: 760;
text-align: left;
max-width: 140px;
}
html.native-mobile-app.mobile-detail-translucent #detailSheet:not(.detail-large) .person-card span {
width: auto;
margin-top: 2px;
color: rgba(232, 239, 247, .58);
font-size: 10px;
text-align: left;
-webkit-line-clamp: 1;
max-width: 140px;
}
html.native-mobile-app .image-viewer.episode-mode {
display: none;
grid-template-rows: minmax(0, 1fr);
gap: 0;
background: #081016;
padding: 0 0 calc(28px + var(--fm-safe-bottom) + env(safe-area-inset-bottom));
overflow: hidden;
}
html.native-mobile-app .image-viewer.episode-mode.active {
display: grid;
}
html.native-mobile-app .image-viewer.episode-mode #closeImageBtn {
display: none;
}
html.native-mobile-app .image-viewer.episode-mode .mobile-detail-back {
position: fixed;
top: max(14px, env(safe-area-inset-top));
left: 16px;
width: 54px;
height: 54px;
z-index: 95;
color: rgba(255, 255, 255, .96);
background: rgba(25, 35, 44, .28);
border-color: rgba(255, 255, 255, .22);
box-shadow: 0 12px 30px rgba(0, 0, 0, .24), inset 0 1px 0 rgba(255, 255, 255, .12);
backdrop-filter: blur(14px) saturate(1.08);
}
html.native-mobile-app .image-viewer.episode-mode .mobile-detail-back .icon {
width: 25px;
height: 25px;
stroke-width: 2.4;
}
html.native-mobile-app .image-viewer.episode-mode .image-content {
width: 100%;
height: 100%;
display: block;
overflow-y: auto;
overscroll-behavior: contain;
background: #081016;
}
html.native-mobile-app .image-viewer.episode-mode .episode-view {
width: 100%;
min-height: var(--fm-web-height);
display: block;
padding: 0 22px calc(34px + var(--fm-safe-bottom) + env(safe-area-inset-bottom));
border: 0;
border-radius: 0;
background: #081016;
box-shadow: none;
}
html.native-mobile-app .image-viewer.episode-mode .episode-view img {
width: calc(100% + 44px);
max-width: none;
height: min(64vh, 620px);
max-height: none;
margin: 0 -22px;
aspect-ratio: auto;
object-fit: cover;
object-position: center top;
border: 0;
border-radius: 0;
filter: saturate(.94) contrast(.98) brightness(.84);
}
html.native-mobile-app .image-viewer.episode-mode .episode-view::before {
content: "";
position: fixed;
top: 0;
right: 0;
left: 0;
height: min(68vh, 660px);
z-index: 1;
pointer-events: none;
background:
linear-gradient(180deg, rgba(2, 5, 8, .02) 0%, rgba(2, 5, 8, .1) 42%, rgba(5, 10, 15, .72) 76%, #081016 100%),
linear-gradient(90deg, rgba(3, 7, 11, .28), transparent 42%, rgba(3, 7, 11, .22));
}
html.native-mobile-app .image-viewer.episode-mode .episode-view > div,
html.native-mobile-app .image-viewer.episode-mode .episode-view > p {
position: relative;
z-index: 2;
text-shadow: 0 2px 12px rgba(0, 0, 0, .58);
}
html.native-mobile-app .image-viewer.episode-mode .episode-view > div {
margin-top: -142px;
padding-bottom: 18px;
}
html.native-mobile-app .image-viewer.episode-mode .episode-view.no-still {
padding-top: max(96px, env(safe-area-inset-top));
background:
linear-gradient(180deg, rgba(12, 20, 28, .96), #081016 42%),
var(--detail-hero-bg, none);
background-size: cover;
background-position: center top;
}
html.native-mobile-app .image-viewer.episode-mode .episode-view.no-still > div {
margin-top: 0;
}
html.native-mobile-app .image-viewer.episode-mode .episode-view h3 {
margin: 0 0 10px;
color: rgba(255, 255, 255, .98);
font-size: 29px;
line-height: 1.14;
font-weight: 900;
letter-spacing: 0;
}
html.native-mobile-app .image-viewer.episode-mode .episode-view span {
display: inline-flex;
min-height: 30px;
align-items: center;
padding: 0 11px;
border-radius: 999px;
color: rgba(245, 248, 251, .76);
background: rgba(255, 255, 255, .12);
font-size: 13px;
line-height: 1;
backdrop-filter: blur(10px) saturate(1.04);
}
html.native-mobile-app .image-viewer.episode-mode .episode-view p {
margin: 0;
color: rgba(246, 249, 252, .84);
font-size: 16px;
line-height: 1.78;
white-space: pre-wrap;
}
.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: 12px;
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: 12px;
}
.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: 12px;
font-weight: 800;
white-space: nowrap;
}
.connection-arrow {
color: var(--muted);
font-size: 12px;
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: 11px;
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;
}
.detail-style-segmented {
min-width: 0;
display: inline-grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 2px;
padding: 2px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, .14);
background: rgba(255, 255, 255, .06);
}
.detail-style-option {
min-width: 0;
min-height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 8px;
border-radius: 6px;
border: 1px solid transparent;
color: rgba(244, 247, 251, .7);
font-size: 11px;
font-weight: 760;
line-height: 1;
white-space: nowrap;
}
.detail-style-option.active {
color: var(--text);
background: rgba(184, 226, 255, .2);
border-color: rgba(184, 226, 255, .34);
}
.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: 11px;
font-weight: 700;
}
.relay-mirror-config .field {
width: 100%;
min-height: 36px;
padding: 8px 10px;
font-size: 11px;
}
.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: 11px;
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: 11px;
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: 11px;
line-height: 1.35;
}
.pan-config .field {
min-height: 38px;
font-size: 11px;
}
.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: 11px;
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: 11px;
}
.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: 12px;
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: 11px;
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: 18px;
line-height: 1.3;
}
.episode-view span {
color: var(--muted);
font-size: 12px;
line-height: 1.4;
}
.episode-view p {
margin: 0;
color: rgba(244, 247, 251, .9);
font-size: 14px;
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,
.detail-style-option: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;
}
html.no-layout-gap .search .btn {
margin-left: 10px !important;
}
html.no-layout-gap .search .icon-btn {
margin-left: 10px !important;
}
html.no-layout-gap .btn .icon {
margin-right: 7px;
}
html.no-layout-gap .btn .icon:only-child {
margin-right: 0;
}
html.no-layout-gap .chips > .chip {
margin-right: 8px;
}
html.no-layout-gap .chips > .chip:last-child {
margin-right: 0;
}
html.no-layout-gap .rail > .card,
html.no-layout-gap .rail > .episode-card,
html.no-layout-gap .rail > .person-card {
margin-right: 12px;
}
html.no-layout-gap .rail > .card:last-child,
html.no-layout-gap .rail > .episode-card:last-child,
html.no-layout-gap .rail > .person-card:last-child {
margin-right: 0;
}
html.no-layout-gap .media-grid {
margin: -5px;
}
html.no-layout-gap .media-grid > .card {
width: auto;
margin: 5px;
}
html.no-layout-gap .actions > .btn {
margin: 0 10px 10px 0;
}
html.no-layout-gap .detail-meta > .meta-pill {
margin: 0 8px 8px 0;
}
html.no-layout-gap .detail-title-row > * {
margin-right: 8px;
}
html.no-layout-gap .detail-title-row > *:last-child {
margin-right: 0;
}
html.no-layout-gap .connection-grid > .connection-item,
html.no-layout-gap .pan-config > *,
html.no-layout-gap .relay-mirror-config > * {
margin-top: 8px;
}
html.no-layout-gap .connection-body .actions > .btn {
margin: 4px;
}
html.no-layout-gap .connection-grid > .connection-item:first-child,
html.no-layout-gap .pan-config > *:first-child,
html.no-layout-gap .relay-mirror-config > *:first-child {
margin-top: 0;
}
html.no-layout-gap .connection-item > b {
margin-right: 8px;
}
html.no-layout-gap .pan-disk-grid {
margin: -3px;
}
html.no-layout-gap .pan-disk-grid > label {
margin: 3px;
}
html.no-layout-gap #detailSheet.detail-large .rail > .episode-card,
html.no-layout-gap #detailSheet.detail-large .rail > .person-card,
html.no-layout-gap #detailSheet.detail-large .rail > .card {
margin-right: 18px;
}
html.no-layout-gap #detailSheet.detail-large .rail > .episode-card:last-child,
html.no-layout-gap #detailSheet.detail-large .rail > .person-card:last-child,
html.no-layout-gap #detailSheet.detail-large .rail > .card:last-child {
margin-right: 0;
}
html.no-layout-gap #detailSheet.detail-large > .actions > .btn {
margin-right: 16px;
margin-bottom: 16px;
}
html.no-layout-gap #detailSheet.detail-large .detail-meta > .meta-pill {
margin-right: 10px;
margin-bottom: 10px;
}
html.no-aspect-ratio .poster-wrap {
position: relative;
display: block;
height: 0;
padding-top: 150%;
overflow: hidden;
border-radius: var(--radius) var(--radius) 0 0;
}
html.no-aspect-ratio .poster-wrap .poster {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
width: 100%;
max-width: 100%;
height: 100%;
margin: 0;
object-fit: cover;
border-radius: inherit;
}
html.no-aspect-ratio .card.landscape-card .poster-wrap {
padding-top: 56.25%;
}
html.no-aspect-ratio .card.recent-card {
height: 128px;
}
html.no-aspect-ratio .card.recent-card > .poster {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
width: 100%;
max-width: 100%;
height: 100%;
margin: 0;
object-fit: cover;
border-radius: inherit;
}
html.no-aspect-ratio .media-grid > .card,
html.no-aspect-ratio .rail > .card {
overflow: hidden;
border-radius: var(--radius);
}
html.no-aspect-ratio #detailSheet.detail-large .episode-card {
height: 141px;
}
html.no-aspect-ratio #detailSheet.detail-large .episode-still,
html.no-aspect-ratio #detailSheet.detail-large .landscape-card .poster {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
width: 100%;
max-width: 100%;
height: 100%;
margin: 0;
object-fit: cover;
}
html.no-aspect-ratio #detailSheet.detail-large .card.landscape-card {
height: 146px;
}
html.no-css-functions #detailSheet.detail-large,
html.legacy-detail-layout #detailSheet.detail-large {
--detail-side-pad: 64px;
--detail-top-pad: 56px;
padding: 56px 64px 54px;
}
html.no-css-functions #detailSheet.detail-large .detail-cover,
html.legacy-detail-layout #detailSheet.detail-large .detail-cover {
width: 870px;
height: 560px;
}
html.no-css-functions #detailSheet.detail-large .detail-info,
html.no-css-functions #detailSheet.detail-large > .actions,
html.no-css-functions #detailSheet.detail-large .detail-block,
html.legacy-detail-layout #detailSheet.detail-large .detail-info,
html.legacy-detail-layout #detailSheet.detail-large > .actions,
html.legacy-detail-layout #detailSheet.detail-large .detail-block {
max-width: 720px;
}
html.no-css-functions #detailSheet.detail-large .detail-info h2,
html.legacy-detail-layout #detailSheet.detail-large .detail-info h2 {
font-size: 51px;
}
html.no-css-functions #detailSheet.detail-large .detail-title-meta,
html.legacy-detail-layout #detailSheet.detail-large .detail-title-meta {
font-size: 23px;
}
html.no-css-functions #detailSheet.detail-large .detail-info p,
html.legacy-detail-layout #detailSheet.detail-large .detail-info p {
font-size: 16px;
}
html.no-css-functions #detailSheet.detail-large > .actions .btn,
html.legacy-detail-layout #detailSheet.detail-large > .actions .btn {
font-size: 19px;
}
html.no-css-functions #detailSheet.detail-large #seasonBlock,
html.legacy-detail-layout #detailSheet.detail-large #seasonBlock {
margin-top: 46px;
}
html.no-css-functions #detailSheet.detail-large .detail-block h3,
html.legacy-detail-layout #detailSheet.detail-large .detail-block h3 {
font-size: 24px;
}
html.no-css-functions #detailSheet.detail-large #seasonTabs,
html.legacy-detail-layout #detailSheet.detail-large #seasonTabs {
max-width: 720px;
}
html.no-css-functions #detailSheet.detail-large .episode-card,
html.legacy-detail-layout #detailSheet.detail-large .episode-card {
flex: 0 0 250px;
max-width: 250px;
height: 141px;
}
html.no-css-functions #detailSheet.detail-large .episode-card b,
html.legacy-detail-layout #detailSheet.detail-large .episode-card b {
font-size: 16px;
}
html.no-css-functions #detailSheet.detail-large .person-card,
html.legacy-detail-layout #detailSheet.detail-large .person-card {
flex: 0 0 280px;
max-width: 280px;
}
html.no-css-functions #detailSheet.detail-large .person-card b,
html.legacy-detail-layout #detailSheet.detail-large .person-card b {
font-size: 21px;
}
html.no-css-functions #detailSheet.detail-large .person-card span,
html.legacy-detail-layout #detailSheet.detail-large .person-card span {
font-size: 15px;
}
html.no-css-functions #detailSheet.detail-large .card.landscape-card,
html.legacy-detail-layout #detailSheet.detail-large .card.landscape-card {
flex: 0 0 260px;
max-width: 260px;
height: 146px;
}
/* ============================================================
iOS 玻璃风格全局美化层
仅作用于手机 / 浏览器模式(html:not(.tv-mode))。
TV 模式(html.tv-mode)完全保持原有渲染与布局不变。
仅调整“材质”:毛玻璃模糊、半透明、描边高光、柔和投影、圆角;
不改动任何元素的尺寸、内外边距、定位、栅格与排版。
全屏透明浮层(.sheet、.image-viewer.episode-mode)按文档要求保持透明,不玻璃化。
============================================================ */
html:not(.tv-mode) {
--radius: 12px;
--glass-blur: blur(26px) saturate(180%) brightness(1.04);
--glass-blur-soft: blur(20px) saturate(162%) brightness(1.03);
--glass-blur-light: blur(15px) saturate(150%);
--glass-edge: inset 0 1px 0 rgba(255, 255, 255, .24), inset 0 0 0 1px rgba(255, 255, 255, .05);
--glass-shadow: 0 16px 40px rgba(0, 0, 0, .30), 0 3px 10px rgba(0, 0, 0, .16);
--glass-shadow-soft: 0 10px 26px rgba(0, 0, 0, .22);
--glass-shadow-card: 0 6px 18px rgba(0, 0, 0, .18);
}
/* 一级浮层:搜索建议、连接面板、下拉坞 —— 厚毛玻璃 + 分层投影 + 顶部高光 */
html:not(.tv-mode) .suggest-panel,
html:not(.tv-mode) .connection-dock,
html:not(.tv-mode) .connection-body {
backdrop-filter: var(--glass-blur);
-webkit-backdrop-filter: var(--glass-blur);
box-shadow: var(--glass-edge), var(--glass-shadow);
}
/* 内容承载面:人物信息、直播入口卡、网盘结果项、建议项 —— 中度毛玻璃 + 轻浮起 */
html:not(.tv-mode) .person-info,
html:not(.tv-mode) .live-entry-card,
html:not(.tv-mode) .pan-result-item,
html:not(.tv-mode) .suggest-item {
backdrop-filter: var(--glass-blur-soft);
-webkit-backdrop-filter: var(--glass-blur-soft);
box-shadow: var(--glass-edge), var(--glass-shadow-soft);
}
/* 搜索联想结果:移动端去掉每条的卡片背景/描边/毛玻璃,列表更清爽不再密集
(聚焦高亮 .suggest-item:focus 保留;TV 端不变)*/
html:not(.tv-mode) .suggest-item:not(:focus) {
background: transparent;
border-color: transparent;
backdrop-filter: none;
-webkit-backdrop-filter: none;
box-shadow: none;
}
/* 海报 / 分集 / 人物卡片 —— 轻毛玻璃 + 顶部高光 + 极柔投影(栅格内不显杂乱) */
html:not(.tv-mode) .card,
html:not(.tv-mode) .episode-card,
html:not(.tv-mode) .person-card {
backdrop-filter: var(--glass-blur-light);
-webkit-backdrop-filter: var(--glass-blur-light);
box-shadow: var(--glass-edge), var(--glass-shadow-card);
}
/* 交互控件静置态:按钮、图标钮、胶囊、Tab、连接开关 —— 柔毛玻璃 + 描边高光
排除 :focus/:active 与文字型 .btn.link,完整保留原有焦点 / 按压表现 */
html:not(.tv-mode) .btn:not(.link):not(:focus):not(:active),
html:not(.tv-mode) .icon-btn:not(:focus):not(:active),
html:not(.tv-mode) .mini-icon-btn:not(:focus):not(:active),
html:not(.tv-mode) .chip:not(:focus):not(:active),
html:not(.tv-mode) .pill,
html:not(.tv-mode) .connection-toggle:not(:focus):not(:active) {
backdrop-filter: var(--glass-blur-soft);
-webkit-backdrop-filter: var(--glass-blur-soft);
box-shadow: var(--glass-edge), 0 4px 14px rgba(0, 0, 0, .14);
}
/* 输入框静置态:柔毛玻璃 + 顶部细高光(焦点态交给原有规则) */
html:not(.tv-mode) .field:not(:focus) {
backdrop-filter: var(--glass-blur-soft);
-webkit-backdrop-filter: var(--glass-blur-soft);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .14);
}
/* 悬浮操作钮 / 角标:回顶、移动端返回、评分角标 —— 加深玻璃质感 */
html:not(.tv-mode) .back-top {
backdrop-filter: var(--glass-blur-soft);
-webkit-backdrop-filter: var(--glass-blur-soft);
box-shadow: var(--glass-edge), 0 12px 32px rgba(0, 0, 0, .26);
}
html:not(.tv-mode) .mobile-detail-back {
backdrop-filter: var(--glass-blur-light);
-webkit-backdrop-filter: var(--glass-blur-light);
box-shadow: var(--glass-edge), 0 8px 22px rgba(0, 0, 0, .2);
}
html:not(.tv-mode) .rating-badge,
html:not(.tv-mode) .recent-badge {
backdrop-filter: blur(12px) saturate(150%);
-webkit-backdrop-filter: blur(12px) saturate(150%);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .2);
}
/* 沉浸式图片预览:加深磨砂(episode-mode 透明态保持不变,见文档 22.3) */
html:not(.tv-mode) .image-viewer:not(.episode-mode) {
backdrop-filter: blur(20px) saturate(150%);
-webkit-backdrop-filter: blur(20px) saturate(150%);
}
/* ------------------------------------------------------------
全站按钮统一:偏亮玻璃质感(对齐「搜索」按钮)
覆盖 .btn / .icon-btn / .mini-icon-btn 的静置态;
排除 :focus/:active(保留原焦点/按压表现)、.btn.link(文字按钮);
.btn.danger 单独保留红色语义但同步提亮;
分段控件 .detail-style-option、分类 .chip 属于「切换态」控件,保持不变。
仅作用于手机/浏览器模式;详情页等带 ID 的专属按钮样式不被覆盖。
------------------------------------------------------------ */
html:not(.tv-mode) .btn:not(.link):not(.danger):not(:focus):not(:active),
html:not(.tv-mode) .icon-btn:not(:focus):not(:active),
html:not(.tv-mode) .mini-icon-btn:not(:focus):not(:active) {
background:
linear-gradient(180deg, rgba(255, 255, 255, .16), rgba(255, 255, 255, .04)),
rgba(78, 92, 105, .52);
border-color: rgba(255, 255, 255, .32);
color: var(--text);
backdrop-filter: var(--glass-blur-soft);
-webkit-backdrop-filter: var(--glass-blur-soft);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, .34),
inset 0 0 0 1px rgba(255, 255, 255, .05),
0 6px 16px rgba(0, 0, 0, .16);
}
/* 危险按钮:同样的玻璃亮度,保留红色识别 */
html:not(.tv-mode) .btn.danger:not(:focus):not(:active) {
background:
linear-gradient(180deg, rgba(255, 255, 255, .12), rgba(255, 255, 255, .02)),
rgba(196, 102, 102, .42);
border-color: rgba(255, 214, 214, .42);
color: #fff1f1;
backdrop-filter: var(--glass-blur-soft);
-webkit-backdrop-filter: var(--glass-blur-soft);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, .28),
0 6px 16px rgba(0, 0, 0, .16);
}
/* ============================================================
Apple TV / Infuse 风格 —— 高级毛玻璃提级(仅 html:not(.tv-mode)
做法:重定义玻璃令牌 + 更通透的冷调面板 + 镜面顶光 + 分层悬浮投影
+ 斜向光膜,所有引用这些令牌的玻璃面一次性升级。
TV 模式(html.tv-mode)仍读取 :root 原值,渲染与布局完全不变。
仅材质,不动任何尺寸 / 间距 / 定位 / 栅格 / 排版。
============================================================ */
html:not(.tv-mode) {
/* 更通透的冷调中性玻璃面板(TV 仍用 :root 原值,不受影响) */
--panel-rgb: 90, 102, 114;
--panel: rgba(var(--panel-rgb), .50);
--panel-soft: rgba(var(--panel-rgb), .38);
--panel-strong: rgba(var(--panel-rgb), .74);
--panel-2: rgba(var(--panel-rgb), .54);
--control: rgba(88, 100, 113, .40);
--control-active: rgba(98, 112, 125, .56);
--line: rgba(255, 255, 255, .16);
--line-strong: rgba(255, 255, 255, .30);
--radius: 14px;
/* 厚磨砂 + 高饱和(Apple 材质质感的核心) */
--glass-blur: blur(34px) saturate(185%) brightness(1.05);
--glass-blur-soft: blur(24px) saturate(170%) brightness(1.04);
--glass-blur-light: blur(18px) saturate(155%) brightness(1.02);
/* 镜面顶光 + 细描边 + 极轻底部内阴影(玻璃边缘“接光”的感觉) */
--glass-edge:
inset 0 1px 0 rgba(255, 255, 255, .32),
inset 0 0 0 1px rgba(255, 255, 255, .06),
inset 0 -10px 22px rgba(0, 0, 0, .07);
/* 分层悬浮投影:贴边 + 中景 + 远景,营造漂浮层级 */
--glass-shadow:
0 1px 1px rgba(0, 0, 0, .22),
0 12px 30px rgba(0, 0, 0, .30),
0 30px 70px rgba(0, 0, 0, .26);
--glass-shadow-soft:
0 1px 1px rgba(0, 0, 0, .18),
0 10px 26px rgba(0, 0, 0, .24);
--glass-shadow-card:
0 2px 6px rgba(0, 0, 0, .16),
0 12px 26px rgba(0, 0, 0, .22);
/* 斜向高光薄膜,叠在面板底色之上 */
--glass-sheen: linear-gradient(152deg, rgba(255, 255, 255, .10), rgba(255, 255, 255, 0) 46%);
}
/* 一级面板叠加斜向光膜(仅补 background-image,保留各自底色) */
html:not(.tv-mode) .connection-dock,
html:not(.tv-mode) .connection-body,
html:not(.tv-mode) .person-info,
html:not(.tv-mode) .pan-result-item {
background-image: var(--glass-sheen);
}
/* 搜索建议下拉:更通透一点 + 光膜(仍保证文字可读) */
html:not(.tv-mode) .suggest-panel {
background-color: rgba(20, 26, 32, .80);
background-image: var(--glass-sheen);
}
/* 按钮:均匀实色玻璃底。去掉实时背景模糊(blur 在重绘时会出现 GPU 拼接竖缝),
全状态统一,无渐变、无模糊缝。 */
html:not(.tv-mode) .btn,
html:not(.tv-mode) .icon-btn,
html:not(.tv-mode) .mini-icon-btn {
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
}
html:not(.tv-mode) .btn:not(.link):not(.danger):not(:focus):not(:active),
html:not(.tv-mode) .icon-btn:not(:focus):not(:active),
html:not(.tv-mode) .mini-icon-btn:not(:focus):not(:active) {
background: rgba(88, 102, 116, .82);
border-color: rgba(255, 255, 255, .26);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, .22),
0 1px 1px rgba(0, 0, 0, .16),
0 8px 18px rgba(0, 0, 0, .18);
}
html:not(.tv-mode) .btn.danger:not(:focus):not(:active) {
background: rgba(190, 92, 92, .80);
border-color: rgba(255, 214, 214, .40);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, .20),
0 1px 1px rgba(0, 0, 0, .14),
0 8px 18px rgba(0, 0, 0, .18);
}
/* 指针设备(鼠标)上的轻量悬浮反馈:只提亮 / 加深投影,不位移、不影响触屏与 TV */
@media (hover: hover) and (pointer: fine) {
html:not(.tv-mode) .btn:not(.link):not(:active):hover,
html:not(.tv-mode) .icon-btn:not(:active):hover,
html:not(.tv-mode) .mini-icon-btn:not(:active):hover {
border-color: rgba(255, 255, 255, .44);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, .52),
0 1px 1px rgba(0, 0, 0, .18),
0 10px 26px rgba(0, 0, 0, .24);
filter: brightness(1.05);
}
html:not(.tv-mode) .card:hover,
html:not(.tv-mode) .episode-card:hover,
html:not(.tv-mode) .person-card:hover {
border-color: rgba(255, 255, 255, .30);
box-shadow:
var(--glass-edge),
0 6px 14px rgba(0, 0, 0, .20),
0 18px 38px rgba(0, 0, 0, .26);
}
}
/* 浏览器预览底(仅非 App 环境):换成可透出玻璃的深色环境光,便于评估质感。
App 内(html.fm-native)仍保持页面透明,让真实壁纸透出;不影响 TV 布局。 */
html:not(.fm-native) {
background: #0d1014;
}
html:not(.fm-native) body {
background:
radial-gradient(125% 80% at 50% -12%, rgba(64, 82, 104, .55), rgba(13, 16, 20, 0) 58%),
radial-gradient(110% 100% at 88% 116%, rgba(38, 58, 78, .42), rgba(13, 16, 20, 0) 52%),
linear-gradient(180deg, #161b22 0%, #0d1014 100%);
background-attachment: fixed;
}
/* ------------------------------------------------------------
状态按钮迁移:移入搜索栏最左侧,做成圆形图标钮,仅保留呼吸灯。
DOM 中 #connectionDock 已移动进搜索表单,功能面板随之带过去)
------------------------------------------------------------ */
.search {
grid-template-columns: auto minmax(0, 1fr) auto auto;
}
#connectionDock {
position: relative;
justify-self: start;
align-self: center;
width: 38px;
min-width: 38px;
height: 38px;
margin: 0;
padding: 0;
border-radius: 999px;
overflow: visible;
}
#connectionToggle {
width: 100%;
height: 100%;
min-height: 38px;
display: grid;
grid-template-columns: 1fr;
place-items: center;
gap: 0;
padding: 0;
border-radius: 999px;
outline: none;
-webkit-tap-highlight-color: transparent;
}
/* 去掉「状态」文字与箭头,只留呼吸灯 */
#connectionDock .connection-title,
#connectionDock .connection-arrow {
display: none;
}
/* 呼吸灯:脉冲缩放 + 随状态变色的柔光晕 */
#connectionDot {
width: 11px;
height: 11px;
border-radius: 999px;
animation: pulse-dot 1.8s ease-in-out infinite;
}
#connectionDot.ok { box-shadow: 0 0 8px 1px rgba(159, 216, 173, .85); }
#connectionDot.warn { box-shadow: 0 0 8px 1px rgba(255, 204, 77, .85); }
#connectionDot.bad { box-shadow: 0 0 9px 1px rgba(240, 157, 157, .90); }
/* 下拉状态面板改为从按钮左侧对齐展开(仍由 fitConnectionPanel 夹在视口内) */
#connectionBody {
right: auto;
left: 0;
}
/* ------------------------------------------------------------
搜索图标移入搜索框内右侧(纯图标);隐藏独立设置按钮
(设置功能改为长按状态圆钮触发,见脚本)
------------------------------------------------------------ */
.search {
grid-template-columns: auto minmax(0, 1fr);
}
#homeSettingBtn {
display: none !important;
}
/* 给输入框右侧留出图标空间 */
#searchInput {
padding-right: 46px;
}
/* 提交按钮变成框内右侧的纯图标(保留其提交/长按逻辑) */
#searchSubmitBtn {
position: absolute;
top: 50%;
right: 6px;
transform: translateY(-50%);
width: 36px;
height: 36px;
min-height: 0;
margin: 0;
padding: 0;
gap: 0;
font-size: 0; /* 隐藏“搜索”文字,仅留放大镜图标 */
border-radius: 999px;
color: var(--muted);
background: transparent !important;
border: 0 !important;
box-shadow: none !important;
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
z-index: 10;
pointer-events: auto;
}
#searchSubmitBtn .icon {
width: 20px;
height: 20px;
}
#searchSubmitBtn:focus {
transform: translateY(-50%);
color: var(--text);
background: rgba(255, 255, 255, .12) !important;
}
#searchSubmitBtn:active {
transform: translateY(-50%) scale(.94);
}
</style>
<style>
/* ══ 封面图显示补丁 ══════════════════════════════════════════
手机端(≤719px):隐藏 .detail-cover,显示 .detail-hero-bg
TV端(html.tv-mode):覆盖恢复 .detail-cover 显示
desktop / detail-large.detail-hero-bg 作为背景显示
═══════════════════════════════════════════════════════════ */
/* 1. 手机端:隐藏 detail-coverhero-bg 托底 */
@media (max-width: 719px) {
.detail-cover {
display: none;
}
.detail-hero-bg {
display: block;
}
}
/* 手机横屏:宽度超过 719px 但高度很低,同样隐藏封面 */
@media (orientation: landscape) and (max-height: 500px) {
html:not(.tv-mode) .detail-cover {
display: none;
}
html:not(.tv-mode) .detail-hero-bg {
display: block;
}
}
/* 2. TV端:无论屏宽,强制保持 detail-cover 可见 */
html.tv-mode .detail-cover {
display: block !important;
}
/* 3. TV端:关闭 hero-bg,避免全屏背景干扰 Netflix 双栏布局 */
html.tv-mode .detail-hero-bg {
display: none !important;
opacity: 0 !important;
}
html.tv-mode .sheet-blur-layer {
display: none !important;
}
</style>
<style>
/* ══ 胶囊形改造:只改形状,不动尺寸/布局(含 TV 模式) ══
目标:搜索框 / 盘搜按钮 / 搜索播放按钮 / 继续播放按钮
用 ID 选择器提高优先级以覆盖 .field / .btn 的 var(--radius) */
#searchInput,
#panSearchBtn,
#detailSearchBtn,
#detailContinueBtn {
border-radius: 999px !important;
}
</style>
</head>
<body>
<main class="app" id="home">
<form class="search" id="searchForm" autocomplete="off">
<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 class="connection-item"><b>手机详情</b><div class="detail-style-segmented" id="mobileDetailStyleSegmented" role="group" aria-label="手机详情样式">
<button class="detail-style-option focusable" id="mobileDetailImmersiveBtn" type="button" data-mobile-detail-style="immersive">沉浸式</button>
<button class="detail-style-option focusable" id="mobileDetailTranslucentBtn" type="button" data-mobile-detail-style="translucent">半透明</button>
</div></div>
</div>
<div class="pan-config">
<label for="tmdbKeyInput">TMDB Key
<input class="field focusable mono" id="tmdbKeyInput" type="password" inputmode="text" autocomplete="off" placeholder="TMDB API Key" readonly>
</label>
<label for="panBaseInput">盘搜地址
<input class="field focusable" id="panBaseInput" type="url" inputmode="url" placeholder="https://so.252035.xyz" readonly>
</label>
<label for="panChannelsInput">TG频道
<textarea class="field focusable" id="panChannelsInput" rows="2" placeholder="可选,多个频道用逗号或换行分隔" readonly></textarea>
</label>
<label for="panUserInput">账号
<input class="field focusable" id="panUserInput" type="text" autocomplete="username" placeholder="未启用认证可留空" readonly>
</label>
<label for="panPassInput">密码
<input class="field focusable" id="panPassInput" type="password" autocomplete="current-password" placeholder="未启用认证可留空" readonly>
</label>
<div class="pan-disk-grid" id="panDiskGrid"></div>
<button class="btn focusable" id="savePanConfigBtn" type="button">保存配置</button>
</div>
<div class="relay-mirror-config">
<label for="mirrorSourceInput">镜像源 Relays
<textarea class="field focusable mono" id="mirrorSourceInput" rows="3" placeholder="每行一个源 relay" readonly></textarea>
</label>
<label for="mirrorTargetInput">镜像目标 Relays
<textarea class="field focusable mono" id="mirrorTargetInput" rows="2" placeholder="每行一个目标 relay,例如 wss://your-relay.example" readonly></textarea>
</label>
<div class="relay-mirror-progress mono" id="mirrorProgress">镜像未执行</div>
<button class="btn focusable" id="mirrorRelayBtn" type="button">镜像榜单</button>
</div>
<div class="actions" style="margin-top:10px">
<button class="btn focusable" id="refreshNostrBtn" type="button">刷新榜单</button>
<button class="btn focusable" id="syncBtn" type="button">同步身份</button>
<button class="btn danger focusable" id="deleteDataBtn" type="button">删除数据</button>
</div>
</div>
</aside>
<input class="field focusable" id="searchInput" type="search" inputmode="search" enterkeyhint="search" placeholder="搜索关键词" aria-autocomplete="list" aria-controls="suggestPanel">
<button class="btn blue focusable" id="searchSubmitBtn" type="submit" aria-label="搜索" title="搜索">
<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="icon-btn focusable" id="homeSettingBtn" type="button" aria-label="设置" title="设置">
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></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>
</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>
<div class="detail-hero-bg" id="detailHeroBg"></div>
<div class="sheet-blur-layer" id="detailBlurLayer"></div>
<section class="sheet" id="detailSheet" aria-hidden="true">
<div class="detail-spacer"></div>
<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" tabindex="-1"></p>
<button class="btn link focusable" id="detailMoreBtn" type="button">更多</button>
</div>
<div class="actions">
<button class="btn primary focusable" id="detailContinueBtn" type="button" style="display:none" aria-hidden="true">
<svg class="icon" viewBox="0 0 24 24"><path d="M8 5v14l11-7Z"/></svg>
<span id="detailContinueText">继续观看</span>
</button>
<button class="btn primary focusable" id="detailSearchBtn" type="button">
<svg class="icon" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
<span id="detailSearchText">搜索播放</span>
</button>
<button class="btn focusable" id="panSearchBtn" type="button">
<svg class="icon" viewBox="0 0 24 24"><path d="M6.2 18.3h11.6a4.1 4.1 0 0 0 .42-8.18 6.2 6.2 0 0 0-11.9 1.68A3.28 3.28 0 0 0 6.2 18.3Z"/></svg>
盘搜
</button>
</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="mobile-detail-back focusable" id="mobileImageBackBtn" type="button" aria-label="返回">
<svg class="icon" viewBox="0 0 24 24"><path d="M19 12H5"/><path d="M12 19l-7-7 7-7"/></svg>
</button>
<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: "d7040155454e7fdf547c4d889ebbcca7",
apiBase: "https://api.tmdb.org/3",
language: "zh-CN",
imageBase: "https://images.tmdb.org/t/p/w342",
backdropBase: "https://images.tmdb.org/t/p/w1920_and_h800_multi_faces",
lists: [
{
id: "now-playing",
title: "热映",
hint: "影院热映中",
mediaType: "movie",
sources: [
{ endpoint: "movie/now_playing", mediaType: "movie", params: { language: "zh-CN" } },
{ endpoint: "discover/movie", mediaType: "movie", params: { with_original_language: "zh", sort_by: "popularity.desc", "primary_release_date_gte": "today-60", "primary_release_date_lte": "today+3", vote_count_gte: "5", language: "zh-CN" } }
]
},
{
id: "all",
title: "推荐",
hint: "近期国内外最新上线",
mediaType: "all",
sources: [
{ endpoint: "discover/tv", mediaType: "tv", params: { with_original_language: "zh", with_origin_country: "CN", without_genres: "10764,10766,10767", sort_by: "popularity.desc", include_null_first_air_dates: "false", "first_air_date_gte": "today-90", "first_air_date_lte": "today+7", vote_count_gte: "2" } },
{ endpoint: "discover/movie", mediaType: "movie", params: { with_original_language: "zh", sort_by: "popularity.desc", "primary_release_date_gte": "today-90", "primary_release_date_lte": "today+7", vote_count_gte: "2" } },
{ endpoint: "movie/now_playing", mediaType: "movie", params: { language: "zh-CN" } },
{ endpoint: "discover/tv", mediaType: "tv", params: { with_original_language: "en", with_origin_country: "US|GB", without_genres: "10764,10767,10766,16", with_watch_providers: "8|337|350|384|9|15|386|531", watch_region: "US", sort_by: "popularity.desc", include_null_first_air_dates: "false", "first_air_date_gte": "today-60", "first_air_date_lte": "today+7" } },
{ endpoint: "discover/movie", mediaType: "movie", params: { with_original_language: "en", with_watch_providers: "8|337|350|384|9|15|386|531", watch_region: "US", sort_by: "popularity.desc", "primary_release_date_gte": "today-90", "primary_release_date_lte": "today+7", vote_count_gte: "5" } },
{ endpoint: "discover/tv", mediaType: "tv", params: { with_original_language: "ko", with_origin_country: "KR", without_genres: "10764,10767,10766,16", sort_by: "popularity.desc", include_null_first_air_dates: "false", "first_air_date_gte": "today-60", "first_air_date_lte": "today+7" } },
{ endpoint: "discover/tv", mediaType: "tv", params: { with_original_language: "ja", with_origin_country: "JP", without_genres: "10764,10767,10766,16", sort_by: "popularity.desc", include_null_first_air_dates: "false", "first_air_date_gte": "today-60", "first_air_date_lte": "today+7" } }
]
},
{
id: "cn-tv",
title: "华语剧",
hint: "大陆长篇电视剧 · 最新热播",
mediaType: "tv",
sources: [
{ endpoint: "discover/tv", mediaType: "tv", params: { with_original_language: "zh", with_origin_country: "CN", without_genres: "10764,10766,10767", with_type: "4", sort_by: "popularity.desc", include_null_first_air_dates: "false", "first_air_date_gte": "today-180", "first_air_date_lte": "today+7" } },
{ endpoint: "discover/tv", mediaType: "tv", params: { with_original_language: "zh", with_origin_country: "CN", without_genres: "10764,10766,10767", with_type: "4", sort_by: "first_air_date.desc", include_null_first_air_dates: "false", "first_air_date_gte": "today-180", "first_air_date_lte": "today+7" } }
]
},
{
id: "movie",
title: "电影",
hint: "最新上映·即将上映·流媒体",
mediaType: "movie",
sources: [
{ endpoint: "movie/now_playing", mediaType: "movie", params: { language: "zh-CN" } },
{ endpoint: "movie/upcoming", mediaType: "movie", params: { language: "zh-CN" } },
{ endpoint: "discover/movie", mediaType: "movie", params: { with_original_language: "zh", sort_by: "popularity.desc", "primary_release_date_gte": "today-120", "primary_release_date_lte": "today+60" } },
{ endpoint: "discover/movie", mediaType: "movie", params: { with_watch_providers: "8|337|350|384|9|15|386|531", watch_region: "US", sort_by: "popularity.desc", "primary_release_date_gte": "today-90", "primary_release_date_lte": "today+14", vote_count_gte: "10" } }
]
},
{
id: "jp-kr-tv",
title: "日韩剧",
hint: "韩国·日本最新剧集",
mediaType: "tv",
sources: [
{ endpoint: "discover/tv", mediaType: "tv", params: { with_original_language: "ko", sort_by: "first_air_date.desc", "first_air_date_gte": "today-120", "first_air_date_lte": "today+60", include_null_first_air_dates: "false", without_genres: "16,99" } },
{ endpoint: "discover/tv", mediaType: "tv", params: { with_original_language: "ja", sort_by: "first_air_date.desc", "first_air_date_gte": "today-120", "first_air_date_lte": "today+60", include_null_first_air_dates: "false", without_genres: "16,99" } }
]
},
{
id: "hk-tw-tv",
title: "港台剧",
hint: "香港·台湾最新剧集",
mediaType: "tv",
sources: [
{ endpoint: "discover/tv", mediaType: "tv", params: { with_original_language: "zh", with_origin_country: "HK", without_genres: "10764,10766,10767,16", sort_by: "first_air_date.desc", include_null_first_air_dates: "false", "first_air_date_gte": "today-180", "first_air_date_lte": "today+14" } },
{ endpoint: "discover/tv", mediaType: "tv", params: { with_original_language: "zh", with_origin_country: "TW", without_genres: "10764,10766,10767,16", sort_by: "first_air_date.desc", include_null_first_air_dates: "false", "first_air_date_gte": "today-180", "first_air_date_lte": "today+14" } },
{ endpoint: "discover/tv", mediaType: "tv", params: { with_original_language: "zh", with_origin_country: "HK", without_genres: "10764,10766,10767,16", sort_by: "popularity.desc", include_null_first_air_dates: "false", "first_air_date_gte": "today-365", "first_air_date_lte": "today+14" } }
]
},
{
id: "us-tv",
title: "欧美剧",
hint: "Netflix·HBO·Apple TV+·Disney+ 最新",
mediaType: "tv",
sources: [
{ endpoint: "discover/tv", mediaType: "tv", params: { with_original_language: "en", with_origin_country: "US", without_genres: "16", with_watch_providers: "8|337|350|384|9|386", watch_region: "US", sort_by: "popularity.desc", include_null_first_air_dates: "false", "first_air_date_gte": "today-90", "first_air_date_lte": "today+14" } },
{ endpoint: "discover/tv", mediaType: "tv", params: { with_original_language: "en", with_origin_country: "GB", without_genres: "16", with_watch_providers: "8|337|350|384|9|386", watch_region: "US", sort_by: "popularity.desc", include_null_first_air_dates: "false", "first_air_date_gte": "today-90", "first_air_date_lte": "today+14" } },
{ endpoint: "discover/tv", mediaType: "tv", params: { with_original_language: "en", with_origin_country: "US", without_genres: "16", sort_by: "popularity.desc", include_null_first_air_dates: "false", "first_air_date_gte": "today-60", "first_air_date_lte": "today+14", vote_count_gte: "10" } }
]
},
{
id: "variety-cn",
title: "国内综艺",
hint: "内地热门综艺节目",
mediaType: "tv",
sources: [
{ endpoint: "discover/tv", mediaType: "tv", params: { with_genres: "10764", with_original_language: "zh", with_origin_country: "CN", sort_by: "first_air_date.desc", include_null_first_air_dates: "false", "first_air_date_gte": "today-180", "first_air_date_lte": "today+60" } },
{ endpoint: "discover/tv", mediaType: "tv", params: { with_genres: "10767", with_original_language: "zh", with_origin_country: "CN", sort_by: "first_air_date.desc", include_null_first_air_dates: "false", "first_air_date_gte": "today-180", "first_air_date_lte": "today+60" } },
{ endpoint: "discover/tv", mediaType: "tv", params: { with_genres: "10764,10767", with_original_language: "zh", with_origin_country: "HK", sort_by: "first_air_date.desc", include_null_first_air_dates: "false", "first_air_date_gte": "today-180", "first_air_date_lte": "today+60" } },
{ endpoint: "discover/tv", mediaType: "tv", params: { with_genres: "10764,10767", with_original_language: "zh", with_origin_country: "TW", sort_by: "first_air_date.desc", include_null_first_air_dates: "false", "first_air_date_gte": "today-180", "first_air_date_lte": "today+60" } }
]
},
{
id: "variety-global",
title: "国外综艺",
hint: "海外综艺·真人秀",
mediaType: "tv",
sources: [
{ endpoint: "discover/tv", mediaType: "tv", params: { with_genres: "10764", with_origin_country: "US", sort_by: "first_air_date.desc", include_null_first_air_dates: "false", "first_air_date_gte": "today-180", "first_air_date_lte": "today+60" } },
{ endpoint: "discover/tv", mediaType: "tv", params: { with_genres: "10764", with_origin_country: "KR", sort_by: "first_air_date.desc", include_null_first_air_dates: "false", "first_air_date_gte": "today-180", "first_air_date_lte": "today+60" } },
{ endpoint: "discover/tv", mediaType: "tv", params: { with_genres: "10767", with_origin_country: "US", sort_by: "first_air_date.desc", include_null_first_air_dates: "false", "first_air_date_gte": "today-180", "first_air_date_lte": "today+60" } },
{ endpoint: "discover/tv", mediaType: "tv", params: { with_genres: "10767", with_origin_country: "KR", sort_by: "first_air_date.desc", include_null_first_air_dates: "false", "first_air_date_gte": "today-180", "first_air_date_lte": "today+60" } }
]
},
{
id: "anime",
title: "动画",
hint: "国内外最新动画",
mediaType: "tv",
sources: [
{ endpoint: "discover/tv", mediaType: "tv", params: { with_genres: "16", with_original_language: "ja", with_origin_country: "JP", sort_by: "first_air_date.desc", include_null_first_air_dates: "false", "first_air_date_gte": "today-120", "first_air_date_lte": "today+30" } },
{ endpoint: "discover/tv", mediaType: "tv", params: { with_genres: "16", with_original_language: "zh", sort_by: "first_air_date.desc", include_null_first_air_dates: "false", "first_air_date_gte": "today-120", "first_air_date_lte": "today+30" } },
{ endpoint: "discover/movie", mediaType: "movie", params: { with_genres: "16", sort_by: "primary_release_date.desc", "primary_release_date_gte": "today-120", "primary_release_date_lte": "today+30" } }
]
},
{
id: "documentary",
title: "纪录片",
hint: "全球最热门纪录片",
mediaType: "all",
sources: [
{ endpoint: "discover/movie", mediaType: "movie", params: { with_genres: "99", sort_by: "popularity.desc", vote_count_gte: "50", language: "zh-CN" } },
{ endpoint: "discover/tv", mediaType: "tv", params: { with_genres: "99", sort_by: "popularity.desc", vote_count_gte: "20", language: "zh-CN" } },
{ endpoint: "discover/movie", mediaType: "movie", params: { with_genres: "99", with_original_language: "zh", sort_by: "vote_average.desc", vote_count_gte: "20", language: "zh-CN" } }
]
}
]
},
nostr: {
kind: 30078,
tag: "fish2018-home-v1",
eventsKey: "fish2018_home_v1_events",
nsecKey: "fish2018_home_v1_nsec",
relays: [
"wss://relay-sgp.signedbyme.com",
"wss://relay.lovelana.org",
"wss://relay.nostr.moe",
"wss://nostr.spacecitynode.com",
]
},
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: "now-playing",
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 },
uiPrefs: { loaded: false, mobileDetailStyle: "immersive" },
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 },
episodeViewer: { episodes: [], effectiveIndexes: [], index: -1, episodeNumber: "", swipe: null },
episodePreview: { active: false, expanded: false, index: -1, episodeNumber: "", lastEpisodeNumber: "", infoHeight: 0 },
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 中打开收藏"),
openSetting: 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 syncClientModeClasses() {
document.documentElement.classList.toggle("native-mobile-app", isNativeMobileClient());
}
function setTvMode(tv) {
document.documentElement.classList.toggle("tv-mode", !!tv);
syncClientModeClasses();
syncNativeToolbarForRoute();
}
function setNativeToolbarVisible(visible, force) {
if (!force && !isTvMode() && !isNativeMobileClient()) return;
try {
const ui = sdk().ui || {};
if (ui.setToolbar) ui.setToolbar(visible);
} catch (e) {}
// 同步沉浸模式 class(toolbar 隐藏时内容延伸到顶部)
if (!visible) {
document.documentElement.classList.add("detail-immersive");
} else {
document.documentElement.classList.remove("detail-immersive");
}
}
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");
syncClientModeClasses();
return false;
}
const device = await api.device();
const mode = String(device && device.mode || "").toLowerCase();
const tv = Number(device && device.type) === 0 || mode === "leanback";
const nativeClient = !!(window.fm || window.fongmiBridge || window.fongmiClient);
state.deviceMode = tv ? "leanback" : nativeClient && (Number(device && device.type) === 1 || mode === "mobile") ? "mobile" : "browser";
setTvMode(tv);
return tv;
} catch (e) {
setNativeToolbarVisible(true, true);
document.documentElement.classList.remove("tv-mode");
state.deviceMode = isNativeMobileClient() ? "mobile" : "browser";
syncClientModeClasses();
return false;
}
}
function cacheKey(type) {
if (type === "events") return window.WEBHOME_CONFIG.nostr.eventsKey || "fish2018_home_v1_events";
if (type === "nsec") return window.WEBHOME_CONFIG.nostr.nsecKey || "fish2018_home_v1_nsec";
if (type === "pan") return window.WEBHOME_CONFIG.pan.cacheKey || "fish2018_home_v1_pan_config";
if (type === "ui") return "fish2018_home_v1_ui_snapshot";
if (type === "uiPrefs") return "fish2018_home_v1_ui_prefs";
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 sanitizeUiPrefs(value) {
const raw = value && typeof value === "object" ? value : {};
const style = raw.mobileDetailStyle === "translucent" ? "translucent" : "immersive";
return { mobileDetailStyle: style };
}
function applyUiPrefs() {
const prefs = sanitizeUiPrefs(state.uiPrefs || {});
state.uiPrefs = Object.assign({ loaded: state.uiPrefs && state.uiPrefs.loaded }, prefs);
document.documentElement.classList.toggle("mobile-detail-translucent", prefs.mobileDetailStyle === "translucent");
renderUiPrefsControls();
if ($("detailSheet") && $("detailSheet").classList.contains("active")) {
resetDetailCoverFrame($("detailImage") && $("detailImage").parentElement);
scheduleDetailTextClamp();
updateMobileDetailBackButton();
}
}
async function initUiPrefs(options) {
options = options || {};
if (window.fongmiBridge && !window.fm) await waitForNativeSdk(options.timeout == null ? 1500 : options.timeout);
let saved = null;
try { saved = safeJson(await sdk().cache.get(cacheKey("uiPrefs")), null); } catch (e) { saved = null; }
state.uiPrefs = Object.assign({ loaded: true }, sanitizeUiPrefs(saved));
applyUiPrefs();
}
function renderUiPrefsControls() {
const style = state.uiPrefs && state.uiPrefs.mobileDetailStyle === "translucent" ? "translucent" : "immersive";
document.querySelectorAll("[data-mobile-detail-style]").forEach((button) => {
const active = button.dataset.mobileDetailStyle === style;
button.classList.toggle("active", active);
button.setAttribute("aria-pressed", active ? "true" : "false");
});
}
async function setMobileDetailStyle(style) {
state.uiPrefs = Object.assign({}, state.uiPrefs || {}, sanitizeUiPrefs({ mobileDetailStyle: style }), { loaded: true });
applyUiPrefs();
if (window.fongmiBridge && !window.fm) await waitForNativeSdk(1200);
try { await sdk().cache.set(cacheKey("uiPrefs"), JSON.stringify(sanitizeUiPrefs(state.uiPrefs))); } catch (e) {}
toast(state.uiPrefs.mobileDetailStyle === "translucent" ? "手机详情:半透明背景" : "手机详情:全屏沉浸式");
scheduleUiSnapshotSave();
}
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 === "images.tmdb.org") return parsed.pathname.replace(/^\/t\/p\/[^/]+/, "") || parsed.pathname;
} catch (e) {}
return value;
}
function hotPosterUrl(path) {
const value = String(path || "").trim();
if (!value) return "";
if (/^https?:\/\//i.test(value)) return value;
return value.startsWith("/") ? imageUrl(value, false) : value;
}
function hotMinItemDay() {
return hotToday() - HOT_WINDOW_DAYS + 1;
}
function hotVectorNextPruneDay(items, expiresDay) {
let day = Number(expiresDay || hotToday() + HOT_WINDOW_DAYS);
(items || []).forEach((item) => {
day = Math.min(day, hotVectorItemDay(item) + HOT_WINDOW_DAYS);
});
return Math.max(1, Math.floor(day || hotToday() + HOT_WINDOW_DAYS));
}
function hotVectorItemMediaKey(item) {
const mediaType = hotMediaTypeFromCode(item && item[0] || "");
const tmdbId = item && item[1] !== undefined && item[1] !== null ? String(item[1]) : "";
return mediaType && tmdbId ? `tmdb:${mediaType}:${tmdbId}` : "";
}
function hotVectorItemDay(item) {
return Math.floor(Number(item && item[2] || 0));
}
function hotVectorItemLatest(item) {
return hotVectorItemDay(item) * HOT_DAY_SECONDS;
}
function hotVectorItemMedia(item) {
const mediaType = hotMediaTypeFromCode(item && item[0] || "");
const tmdbId = item && item[1] !== undefined && item[1] !== null ? String(item[1]) : "";
return {
m: hotVectorItemMediaKey(item),
t: item && item[3] || "",
mt: mediaType,
tid: tmdbId,
p: hotCompactPoster(item && item[4] || "")
};
}
function hotVectorItemMap(items) {
const map = new Map();
(Array.isArray(items) ? items : []).forEach((item) => {
const key = hotVectorItemMediaKey(item);
if (!key) return;
const old = map.get(key);
if (!old || hotVectorItemDay(item) >= hotVectorItemDay(old)) map.set(key, item);
});
return map;
}
function hotActiveVectorItems(vector) {
return normalizeStoredVectorItems(vector && vector.i || [], vector && vector.ts || hotNow());
}
function hotVectorHasMedia(vector, mediaKey) {
if (!vector || !mediaKey) return false;
return hotVectorItemMap(hotActiveVectorItems(vector)).has(mediaKey);
}
function hotIsNewerVector(next, old) {
if (!old) return true;
const nextTs = Number(next && next.ts || 0);
const oldTs = Number(old && old.ts || 0);
if (nextTs !== oldTs) return nextTs > oldTs;
return String(next && next.id || "") > String(old && old.id || "");
}
function hotIsLocalUserKey(userKey) {
return !!(state.identity && userKey && hotUserKey(state.identity.pubkey) === userKey);
}
function hotCacheVector(vector, db) {
if (!vector || !vector.u) return;
if (!db || hotIsLocalUserKey(vector.u)) state.hot.users.set(vector.u, vector);
else state.hot.users.delete(vector.u);
}
function hotApplyVectorDiffToState(oldVector, newVector) {
const oldMap = hotVectorItemMap(oldVector && oldVector.i || []);
const newMap = hotVectorItemMap(newVector && newVector.i || []);
const metaMap = hotVectorItemMap(newVector && newVector.meta || []);
const changedMedia = new Set();
oldMap.forEach((item, mediaKey) => {
if (newMap.has(mediaKey)) return;
const media = state.hot.media.get(mediaKey);
if (!media) return;
media.c = Math.max(0, Number(media.c || 0) - 1);
if (media.c > 0) state.hot.media.set(mediaKey, media);
else state.hot.media.delete(mediaKey);
changedMedia.add(mediaKey);
});
newMap.forEach((item, mediaKey) => {
const oldMedia = state.hot.media.get(mediaKey);
const delta = oldMap.has(mediaKey) && oldMedia ? 0 : 1;
const meta = metaMap.get(mediaKey) || item;
const media = mergeHotMedia(oldMedia, hotVectorItemMedia(meta), hotVectorItemLatest(item), delta);
if (media.c > 0 && media.t && media.p) state.hot.media.set(mediaKey, media);
changedMedia.add(mediaKey);
});
return changedMedia;
}
function hotGetVectors(userKeys, db) {
const uniqueKeys = Array.from(new Set((userKeys || []).filter(Boolean)));
const map = new Map();
if (!uniqueKeys.length) return Promise.resolve(map);
if (!db) {
uniqueKeys.forEach((key) => {
const vector = state.hot.users.get(key);
if (vector) map.set(key, vector);
});
return Promise.resolve(map);
}
return new Promise((resolve) => {
try {
const tx = db.transaction("userVector", "readonly");
const store = tx.objectStore("userVector");
uniqueKeys.forEach((key) => {
const req = store.get(key);
req.onsuccess = () => {
if (req.result) map.set(key, req.result);
};
});
tx.oncomplete = () => resolve(map);
tx.onerror = () => resolve(map);
tx.onabort = () => resolve(map);
} catch (e) {
resolve(map);
}
});
}
async function hotGetUserVector(userKey, db) {
if (!userKey) return null;
const map = await hotGetVectors([userKey], db || await openHotDb());
return map.get(userKey) || state.hot.users.get(userKey) || null;
}
async function hotGetMyVector() {
const identity = state.identity && state.identity.pubkey;
if (!identity) return null;
const userKey = hotUserKey(identity);
const vector = await hotGetUserVector(userKey);
if (vector) state.hot.users.set(userKey, vector);
return vector;
}
async function hotLoadMyVector() {
const vector = await hotGetMyVector();
renderMetrics();
return vector;
}
async function hotPruneExpired() {
const db = await openHotDb();
if (!db) return;
let changed = false;
for (;;) {
const vectors = await hotGetPrunableVectors(db, hotToday(), HOT_PRUNE_BATCH);
if (!vectors.length) break;
const changedMedia = new Set();
const vectorWrites = [];
const vectorDeletes = [];
for (const old of vectors) {
const expiresDay = Number(old && old.e || 0);
const nextItems = expiresDay > hotToday() ? normalizeStoredVectorItems(old.i || [], old.ts || hotNow()) : [];
const next = expiresDay > hotToday()
? Object.assign({}, old, { i: nextItems, x: hotVectorNextPruneDay(nextItems, expiresDay) })
: null;
if (next && hotSameVectorItems(old.i || [], next.i || []) && Number(old.x || 0) === Number(next.x || 0)) continue;
hotApplyVectorDiffToState(old, next).forEach((key) => changedMedia.add(key));
if (next) {
vectorWrites.push(next);
hotCacheVector(hotPersistVector(next), db);
} else {
vectorDeletes.push(old.u);
state.hot.users.delete(old.u);
}
changed = true;
}
if (changedMedia.size || vectorWrites.length || vectorDeletes.length) {
const tx = db.transaction(["media", "userVector"], "readwrite");
const mediaStore = tx.objectStore("media");
const vectorStore = tx.objectStore("userVector");
changedMedia.forEach((mediaKey) => {
const media = state.hot.media.get(mediaKey);
if (media && Number(media.c || 0) > 0) mediaStore.put(media);
else mediaStore.delete(mediaKey);
});
vectorWrites.forEach((vector) => vectorStore.put(hotPersistVector(vector)));
vectorDeletes.forEach((userKey) => vectorStore.delete(userKey));
await idbDone(tx).catch(() => {});
}
if (vectors.length < HOT_PRUNE_BATCH) break;
}
if (changed) scheduleHotRefresh();
}
function hotSameVectorItems(a, b) {
return JSON.stringify(a || []) === JSON.stringify(b || []);
}
function hotGetPrunableVectors(db, expiresDay, limit) {
return new Promise((resolve) => {
const vectors = [];
try {
const tx = db.transaction("userVector", "readonly");
const index = tx.objectStore("userVector").index("x");
const req = index.openCursor(IDBKeyRange.upperBound(expiresDay));
req.onsuccess = () => {
const cursor = req.result;
if (!cursor) return;
vectors.push(cursor.value);
if (limit && vectors.length >= limit) return;
cursor.continue();
};
tx.oncomplete = () => resolve(vectors);
tx.onerror = () => resolve(vectors);
tx.onabort = () => resolve(vectors);
} catch (e) {
resolve(vectors);
}
});
}
function hotRefreshItems() {
clearTimeout(state.hot.refreshTimer);
state.hot.refreshTimer = 0;
state.hot.items = buildHotItemsFromIndex();
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 ? "刷新中" : "刷新榜单";
renderUiPrefsControls();
$("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 resolveParamDate(value) {
if (typeof value !== "string") return value;
if (value === "today") return today();
const match = value.match(/^today([+-])(\d+)$/);
if (match) {
const offset = parseInt(match[2], 10) * (match[1] === "-" ? -1 : 1);
const date = new Date();
date.setDate(date.getDate() + offset);
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
}
return value;
}
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]) => {
// 支持 _gte/_lte/_date 后缀转为 TMDB 的 .gte/.lte 参数
const param = key.replace(/_gte$/, ".gte").replace(/_lte$/, ".lte");
url.searchParams.set(param, resolveParamDate(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://images.tmdb.org/t/p/${size || "w342"}${imagePath}`;
}
function tmdbImagePath(url) {
const value = String(url || "").trim();
if (!value) return "";
if (value.startsWith("/")) return value;
try {
const parsed = new URL(value);
if (parsed.hostname !== "images.tmdb.org") return "";
return parsed.pathname.replace(/^\/t\/p\/[^/]+/, "") || "";
} catch (e) {
return "";
}
}
function isTmdbImage(url) {
return !!tmdbImagePath(url);
}
function displayImage(url, options) {
if (!url) return "";
const opts = options || {};
const tmdbPath = tmdbImagePath(url);
if (tmdbPath) 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 detailHistoryTitleCandidates(item) {
return [
item && item.title,
item && item.name,
item && item.query,
item && item.originalTitle,
item && item.original_name,
item && item.original_title
].map(normalizeTitle).filter(Boolean);
}
function detailHistoryVodIdMatchesTmdb(tmdbId, vodId) {
const id = String(tmdbId || "").trim();
const value = String(vodId || "").trim();
if (!id || !value) return false;
if (value === id) return true;
if (/tmdb/i.test(value) && value.split(/[^a-z0-9]+/i).includes(id)) return true;
return false;
}
function detailHistoryMatches(item, history) {
if (!item || !history || history.source !== "history" || !history.siteKey || !history.vodId) return false;
const tmdbId = item.tmdbId ? String(item.tmdbId) : item.id && /^tmdb:/i.test(String(item.id)) ? String(item.id).split(":").pop() : "";
if (detailHistoryVodIdMatchesTmdb(tmdbId, history.vodId)) return true;
const title = normalizeTitle(history.title || "");
if (!title) return false;
return detailHistoryTitleCandidates(item).some((candidate) => {
if (!candidate) return false;
if (candidate === title) return true;
return candidate.length >= 4 && title.length >= 4 && (candidate.includes(title) || title.includes(candidate));
});
}
function findDetailContinueHistory(item) {
return (state.recent.items || []).find((history) => detailHistoryMatches(item, history)) || null;
}
function updateDetailContinueButton() {
const button = $("detailContinueBtn");
const searchText = $("detailSearchText");
if (!button) return;
const match = findDetailContinueHistory(state.selected);
const actions = button.closest(".actions");
button.__historyItem = match || null;
button.style.display = match ? "" : "none";
button.setAttribute("aria-hidden", match ? "false" : "true");
if (actions) actions.classList.toggle("has-continue", !!match);
if (searchText) searchText.textContent = match ? "搜索" : "搜索播放";
}
function shouldRefreshRecentList() {
return !state.recent.loaded || Date.now() - Number(state.recent.refreshedAt || 0) > RECENT_UI_TTL_MS;
}
async function loadRecentList(options) {
const opts = options || {};
if (state.recent.loading) return;
if (state.recent.loaded && !opts.refresh) return;
state.recent.loading = true;
state.recent.error = "";
if (!opts.silent) setStatus("tmdb", "读取最近观看");
if (state.activeList === "recent") renderActiveGrid();
try {
const list = await sdk().history();
state.recent.items = normalizeHistoryList(list);
state.recent.loaded = true;
state.recent.refreshedAt = Date.now();
if (!opts.silent) setStatus("tmdb", "最近观看 " + state.recent.items.length + " 条");
} catch (e) {
state.recent.items = [];
state.recent.loaded = true;
state.recent.error = e.message || "unknown";
if (!opts.silent) setStatus("tmdb", "最近观看读取失败");
} finally {
state.recent.loading = false;
if (state.activeList === "recent") renderActiveGrid();
if ($("detailSheet") && $("detailSheet").classList.contains("active")) updateDetailContinueButton();
}
}
async function loadInfo() {
try {
await detectDeviceMode();
state.site = await sdk().site();
state.config = await sdk().config();
state.pan.checkEnabled = !!(state.config && state.config.driveCheck);
setStatus("sdk", window.fm ? "App SDK 已连接" : "浏览器预览模式");
setPanStatus(state.pan.checkEnabled ? "检测已开启" : "检测关闭");
state.pan.renderKeys = "";
renderPanResults();
} catch (e) {
state.site = { name: "推荐首页" };
state.pan.checkEnabled = false;
setStatus("sdk", "SDK 获取失败:" + (e.message || "unknown"));
}
}
async function loadCatalog() {
setStatus("tmdb", "等待按需请求");
loadRecentList({ silent: true }).catch(() => {});
renderAll({ deferContent: true });
}
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();
// 多数据源混合加载
if (list.sources && list.sources.length) {
try {
const fetches = list.sources.map((src) => {
const srcList = Object.assign({}, list, { endpoint: src.endpoint, params: src.params, mediaType: src.mediaType || list.mediaType });
return requestJson(tmdbUrl(srcList, 1), 18)
.then((body) => ({ items: (body.results || []).map((item, i) => normalizeTmdb(item, srcList, i)).filter(hasPoster), total: body.total_pages || 1 }))
.catch(() => ({ items: [], total: 1 }));
});
const results = await Promise.all(fetches);
// 交叉混排:依次从每个来源取一条,循环直到取完
const mixed = [];
const iters = results.map((r) => r.items[Symbol.iterator]());
let anyLeft = true;
while (anyLeft) {
anyLeft = false;
for (const iter of iters) {
const { value, done } = iter.next();
if (!done) { mixed.push(value); anyLeft = true; }
}
}
state.catalog[id] = uniqueMedia(mixed);
const maxTotal = Math.max(...results.map((r) => r.total));
state.catalogPage[id] = { page: 1, total: maxTotal, loading: false, loaded: true, multiSrc: true };
setStatus("tmdb", `${list.title} 已加载 ${state.catalog[id].length} 条`);
} catch (e) {
state.catalog[id] = [];
state.catalogPage[id] = { page: 1, total: 1, loading: false, loaded: true, error: e.message || "unknown" };
setStatus("tmdb", `${list.title} 加载失败`);
}
if (state.activeList === id) renderActiveGrid();
return;
}
try {
const body = await requestJson(tmdbUrl(list, 1), 18);
const results = body.results || [];
state.catalog[id] = uniqueMedia(results.map((item, index) => normalizeTmdb(item, list, index)).filter(hasPoster));
state.catalogPage[id] = { page: 1, total: body.total_pages || 1, loading: false, loaded: true };
setStatus("tmdb", `${list.title} 已加载 ${state.catalog[id].length} 条`);
} catch (e) {
state.catalog[id] = [];
state.catalogPage[id] = { page: 1, total: 1, loading: false, loaded: true, error: e.message || "unknown" };
setStatus("tmdb", `${list.title} 加载失败`);
}
if (state.activeList === id) renderActiveGrid();
}
async function 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;
// 多源列表:并发请求所有来源的下一页,交叉混排追加
if (page.multiSrc && list.sources && list.sources.length) {
const fetches = list.sources.map((src) => {
const srcList = Object.assign({}, list, { endpoint: src.endpoint, params: src.params, mediaType: src.mediaType || list.mediaType });
return requestJson(tmdbUrl(srcList, next), 18)
.then((body) => ({ items: (body.results || []).map((item, i) => normalizeTmdb(item, srcList, (next - 1) * 20 + i)).filter(hasPoster), total: body.total_pages || page.total }))
.catch(() => ({ items: [], total: page.total }));
});
const results = await Promise.all(fetches);
const mixed = [];
const iters = results.map((r) => r.items[Symbol.iterator]());
let anyLeft = true;
while (anyLeft) {
anyLeft = false;
for (const iter of iters) {
const { value, done } = iter.next();
if (!done) { mixed.push(value); anyLeft = true; }
}
}
const maxTotal = Math.max(...results.map((r) => r.total));
state.catalog[id] = uniqueMedia((state.catalog[id] || []).concat(mixed));
state.catalogPage[id] = { page: next, total: maxTotal, loading: false, loaded: true, multiSrc: true };
renderActiveGrid();
return;
}
const body = await requestJson(tmdbUrl(list, next), 18);
const items = (body.results || []).map((item, index) => normalizeTmdb(item, list, (next - 1) * 20 + index)).filter(hasPoster);
state.catalog[id] = uniqueMedia((state.catalog[id] || []).concat(items));
state.catalogPage[id] = { page: next, total: body.total_pages || page.total || next, loading: false, loaded: true };
renderActiveGrid();
} catch (e) {
page.loading = false;
}
}
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 syncLegacyDetailLayout(sheet, large) {
const root = document.documentElement;
if (!root || !sheet) return;
if (!large) {
root.classList.remove("legacy-detail-layout");
return;
}
requestAnimationFrame(() => {
try {
const style = window.getComputedStyle ? getComputedStyle(sheet) : null;
const left = style ? parseFloat(style.paddingLeft) || 0 : 0;
const top = style ? parseFloat(style.paddingTop) || 0 : 0;
root.classList.toggle("legacy-detail-layout", left < 32 || top < 32);
} catch (e) {
root.classList.add("legacy-detail-layout");
}
});
}
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);
syncDetailBackFocusability(large);
syncLegacyDetailLayout(sheet, large);
return before !== large;
}
function syncDetailBackFocusability(large) {
const close = $("closeDetailBtn");
if (close) close.tabIndex = large ? -1 : 0;
}
function isKnownList(id) {
return id === "all" || id === "recent" || id === "live" || !!getList(id);
}
function normalizeActiveListForViewport() {
if (!isKnownList(state.activeList)) state.activeList = "now-playing";
}
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);
}
// 分类图标映射(参考美化最终.html)
const CHIP_ICONS = {
recent: `<svg class="chip-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>`,
all: `<svg class="chip-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>`,
movie: `<svg class="chip-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="20" rx="2.18" ry="2.18"/><line x1="7" y1="2" x2="7" y2="22"/><line x1="17" y1="2" x2="17" y2="22"/><line x1="2" y1="12" x2="22" y2="12"/><line x1="2" y1="7" x2="7" y2="7"/><line x1="17" y1="7" x2="22" y2="7"/><line x1="17" y1="17" x2="22" y2="17"/><line x1="2" y1="17" x2="7" y2="17"/></svg>`,
tv: `<svg class="chip-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="8" width="20" height="13" rx="2" ry="2"/><polyline points="9 2 12 5 15 2"/><line x1="8" y1="2" x2="12" y2="5"/><line x1="16" y1="2" x2="12" y2="5"/></svg>`,
music: `<svg class="chip-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>`,
photo: `<svg class="chip-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>`,
live: `<svg class="chip-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="2" y1="10" x2="2" y2="14"/><line x1="6" y1="6" x2="6" y2="18"/><line x1="10" y1="3" x2="10" y2="21"/><line x1="14" y1="8" x2="14" y2="16"/><line x1="18" y1="11" x2="18" y2="13"/><line x1="22" y1="10" x2="22" y2="14"/></svg>`,
variety: `<svg class="chip-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>`,
anime: `<svg class="chip-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><line x1="9" y1="9" x2="9.01" y2="9"/><line x1="15" y1="9" x2="15.01" y2="9"/></svg>`,
documentary: `<svg class="chip-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>`,
hot: `<svg class="chip-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2c0 0-4 4-4 8a4 4 0 0 0 8 0c0-1.5-.5-3-1.5-4C14 8 13 10 12 10c0 0-2-2-2-4 0 0 1 1 2 1z"/><path d="M12 22c-3.3 0-6-2.7-6-6 0-2 1-4 2.5-5.5C9 12 10.5 13 12 13s3-1 3.5-2.5C17 12 18 14 18 16c0 3.3-2.7 6-6 6z"/></svg>`,
};
function getChipIcon(id) {
id = String(id || "");
if (id === "recent") return CHIP_ICONS.recent;
if (id === "all") return CHIP_ICONS.all;
if (id === "now-playing") return CHIP_ICONS.hot;
if (id.includes("variety")) return CHIP_ICONS.variety;
if (id === "anime") return CHIP_ICONS.anime;
if (id === "documentary") return CHIP_ICONS.documentary;
const list = getList(id);
const mt = (list && list.mediaType) || "";
if (id.includes("movie") || mt === "movie") return CHIP_ICONS.movie;
if (id.includes("tv") || id.includes("series") || mt === "tv") return CHIP_ICONS.tv;
if (id.includes("music")) return CHIP_ICONS.music;
if (id.includes("photo")) return CHIP_ICONS.photo;
if (id.includes("live")) return CHIP_ICONS.live;
return CHIP_ICONS.all;
}
function renderChips() {
const chips = [{ id: "live", title: "直播" }, { id: "recent", title: "最近" }]
.concat(visibleTmdbLists().filter((item) => item.id !== "all").map((item) => ({ id: item.id, title: item.title })));
const root = $("chips");
const keys = chips.map((chip) => chip.id + ":" + chip.title).join("\n");
if (root.dataset.renderKeys !== keys) {
root.dataset.renderKeys = keys;
root.replaceChildren(...chips.map((chip) => {
const button = document.createElement("button");
button.className = "chip focusable";
button.type = "button";
button.dataset.chipId = chip.id;
button.innerHTML = getChipIcon(chip.id) + `<span>${escapeHtml(chip.title)}</span>`;
button.addEventListener("click", () => selectChip(chip.id));
button.addEventListener("focus", () => {
scheduleChipFocusSelect(chip.id);
});
return button;
}));
}
Array.from(root.children).forEach((button) => button.classList.toggle("active", button.dataset.chipId === state.activeList));
}
function scheduleChipFocusSelect(id) {
clearTimeout(state.chipFocusTimer);
if (!id || state.activeList === id) return;
state.chipFocusTimer = setTimeout(() => selectChip(id, { keepFocus: true, fromFocus: true }), 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", "now-playing") || $("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) => {
const detail = $("detailSheet");
const mobileDetailRail = document.documentElement.classList.contains("native-mobile-app") && detail && detail.contains(rail) && !detail.classList.contains("detail-large");
if (mobileDetailRail) {
rail.style.removeProperty("width");
rail.style.removeProperty("max-width");
rail.style.minWidth = "0";
return;
}
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"));
}
}
async function openDetailContinueHistory() {
const button = $("detailContinueBtn");
const history = button && button.__historyItem || findDetailContinueHistory(state.selected);
if (!state.selected || !history || !history.siteKey || !history.vodId) {
toast("没有可继续观看的记录");
updateDetailContinueButton();
return;
}
try {
rememberDetailReturn(button);
rememberWatchIntent(state.selected, "view");
startWatchTracking(state.selected);
await sdk().vod(history.siteKey, history.vodId, history.title || state.selected.title, history.pic || state.selected.pic);
} catch (e) {
stopWatchTracking(false);
toast("继续观看失败:" + (e.message || "unknown"));
}
}
function 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();
nativeSearch(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");
// 浏览器预览模式:加沉浸 class 让背景延伸到顶部
if (!window.fongmiBridge) document.documentElement.classList.add("detail-immersive");
$("detailSheet").setAttribute("aria-hidden", "false");
if (isNativeLeanbackClient()) setTvMode(true);
try {
renderDetailBase(item);
clearDetailExtras();
} catch (e) {
$("detailTitle").textContent = item && item.title || "详情";
setDetailTextContent("详情渲染失败:" + (e.message || "unknown"));
}
if (!opts.skipHistory && location.hash !== "#detail") history.pushState({ sheet: "detail" }, "", "#detail");
if (!opts.restore) rememberWatchIntent(item, "view");
setNativeToolbarVisible(false, isNativeLeanbackClient() || isNativeMobileClient());
if (shouldRefreshRecentList()) loadRecentList({ silent: true }).catch(() => {});
loadDetail(item);
scheduleUiSnapshotSave();
if (!opts.restore) setTimeout(() => focusRemoteTarget(detailPrimaryActionButton() || $("closeDetailBtn")), 40);
}
function ensureSheetViewport(sheet, display) {
if (!sheet) return;
sheet.style.position = "fixed";
sheet.style.top = "0";
sheet.style.right = "0";
sheet.style.bottom = "0";
sheet.style.left = "0";
sheet.style.width = "100%";
sheet.style.height = "100vh";
sheet.style.display = display || "block";
sheet.style.zIndex = sheet.id === "imageViewer" ? "100" : "60";
}
function renderDetailBase(item) {
clearInlineEpisodePreview({ restoreDetail: false });
resetDetailTextClamp();
// 清除上一个影视的 logo,显示文字标题
const existingLogo = $("detailTitle") && $("detailTitle").parentElement && $("detailTitle").parentElement.querySelector(".detail-title-logo");
if (existingLogo) existingLogo.remove();
if ($("detailTitle")) $("detailTitle").style.display = "";
$("detailTitle").textContent = item.title;
const fallbackText = item.desc || item.remark || "";
setDetailTextContent(fallbackText || "");
renderDetailMeta(item, state.detail);
renderDetailTitleMeta(item, state.detail);
setDetailCoverCarousel(item.landscape ? [item.landscape] : [], "", { allowPoster: false });
updateDetailContinueButton();
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");
// 同步更新全屏背景元素(仅移动端/桌面沉浸海报效果,TV端不触发)
const heroBg = $("detailHeroBg");
if (heroBg && !isTvMode()) {
heroBg.style.backgroundImage = bgSrc ? `url("${cssUrl(bgSrc)}")` : "none";
heroBg.classList.toggle("active", !!bgSrc);
if (bgSrc) {
const dsheet = $("detailSheet");
if (!dsheet || (dsheet.scrollTop || 0) < 8) setDetailHeroBlur(0, 0); // 新打开从清晰开始
}
} else if (heroBg && isTvMode()) {
heroBg.style.backgroundImage = "none";
heroBg.classList.remove("active");
}
const blurLayer = $("detailBlurLayer");
if (blurLayer) blurLayer.classList.toggle("active", !!bgSrc && !isTvMode());
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");
const heroBg2 = $("detailHeroBg");
if (heroBg2 && !isTvMode()) { heroBg2.style.backgroundImage = "none"; heroBg2.classList.remove("active"); }
const blurLayer2 = $("detailBlurLayer");
if (blurLayer2 && !isTvMode()) blurLayer2.classList.remove("active");
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) {
setDetailTextContent(`详情加载失败:${e.message || "unknown"}`);
scheduleDetailTextClamp();
toast("详情加载失败");
}
}
function renderDetailExtras(item, detail) {
const overview = detail.overview || item.desc || item.remark;
if (overview) setDetailTextContent(overview);
renderDetailMeta(item, detail);
renderDetailTitleMeta(item, detail);
renderDetailTitleLogo(item, detail);
scheduleDetailTextClamp();
renderDetailCoverFromDetail(item, detail);
renderCast(detail.credits && detail.credits.cast || []);
if (item.mediaType === "tv") renderSeasons(item, detail);
loadRecommendations(item);
normalizeRails();
updateDetailContinueButton();
updatePostPanFocusState();
}
function bestDetailLogo(detail) {
const logos = (detail && detail.images && detail.images.logos) || [];
// 优先中文,其次英文,再其次 null(语言无标注)
const ranked = logos.slice().sort((a, b) => {
const langScore = (l) => l === "zh" ? 2 : l === "en" ? 1 : 0;
const aScore = langScore(a.iso_639_1) * 1000 + (Number(a.vote_average) || 0) * 10 + Math.min(Number(a.vote_count) || 0, 100);
const bScore = langScore(b.iso_639_1) * 1000 + (Number(b.vote_average) || 0) * 10 + Math.min(Number(b.vote_count) || 0, 100);
return bScore - aScore;
});
return ranked[0] || null;
}
function renderDetailTitleLogo(item, detail) {
const title = $("detailTitle");
if (!title) return;
const logo = bestDetailLogo(detail);
if (!logo || !logo.file_path) {
// 没有 logo,恢复文字标题
title.textContent = item.title || "";
title.style.display = "";
const existing = title.parentElement && title.parentElement.querySelector(".detail-title-logo");
if (existing) existing.remove();
return;
}
const logoUrl = `https://images.tmdb.org/t/p/w500${logo.file_path}`;
// 检查是否已渲染同一张图
const existing = title.parentElement && title.parentElement.querySelector(".detail-title-logo");
if (existing && existing.src === logoUrl) return;
if (existing) existing.remove();
const img = document.createElement("img");
img.className = "detail-title-logo";
img.alt = item.title || "";
img.src = logoUrl;
img.onerror = () => {
// 加载失败则回退到文字
img.remove();
title.style.display = "";
};
img.onload = () => {
title.style.display = "none";
};
title.parentElement.insertBefore(img, title);
}
function detailPrimaryActionButton() {
return isVisibleFocusable($("detailContinueBtn")) ? $("detailContinueBtn") : $("detailSearchBtn");
}
function scheduleDetailTextClamp() {
requestAnimationFrame(() => requestAnimationFrame(updateDetailTextClamp));
}
function isMobileDetailSheetActive(sheet) {
const target = sheet || $("detailSheet");
return !!(document.documentElement.classList.contains("native-mobile-app") && target && target.classList.contains("active") && !target.classList.contains("detail-large"));
}
function detailTextPlain(text) {
return String(text && text.textContent || "").replace(/\s+/g, "").trim();
}
function setDetailTextContent(value) {
const text = $("detailText");
if (!text) return;
text.dataset.fullText = String(value || "");
text.textContent = text.dataset.fullText;
text.removeAttribute("data-detail-toggle");
text.removeAttribute("data-more-label");
}
function renderMobileDetailText(expanded, options) {
const text = $("detailText");
const more = $("detailMoreBtn");
if (!text || !more) return false;
const raw = String(text.dataset.fullText || text.textContent || "");
const plain = raw.replace(/\s+/g, "").trim();
const opts = options || {};
if (text.textContent !== raw) text.textContent = raw;
text.removeAttribute("data-more-label");
const lineHeight = parseFloat(getComputedStyle(text).lineHeight || "21") || 21;
const maxLines = 4;
const targetHeight = Math.round(lineHeight * maxLines + Math.max(4, lineHeight * .16));
if (!plain) {
text.textContent = raw;
text.removeAttribute("data-detail-toggle");
text.classList.remove("clamped");
more.style.display = "none";
return false;
}
text.style.setProperty("--detail-text-max", `${targetHeight}px`);
const needsToggle = opts.forceToggle || text.scrollHeight > targetHeight + 1;
if (!needsToggle) {
text.removeAttribute("data-detail-toggle");
text.classList.remove("clamped");
more.style.display = "none";
return false;
}
text.dataset.detailToggle = expanded ? "collapse" : "expand";
text.classList.toggle("clamped", !expanded);
more.dataset.expanded = expanded ? "1" : "";
more.textContent = expanded ? "收起" : "更多";
more.style.display = "inline-flex";
more.style.visibility = "";
return true;
}
function resetDetailTextClamp() {
const text = $("detailText");
const more = $("detailMoreBtn");
if (text) {
text.classList.remove("clamped");
text.removeAttribute("data-detail-toggle");
text.removeAttribute("data-more-label");
text.style.removeProperty("--detail-text-max");
text.style.removeProperty("--episode-preview-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 = detailTextPlain(text);
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 (state.episodePreview && state.episodePreview.active) {
updateInlineEpisodePreviewClamp();
return;
}
const mobileDetail = isMobileDetailSheetActive(sheet);
if (more.dataset.expanded === "1") {
if (mobileDetail) {
renderMobileDetailText(true, { forceToggle: true });
return;
}
text.classList.remove("clamped");
text.removeAttribute("data-more-label");
text.style.removeProperty("--detail-text-max");
more.style.display = mobileDetail ? "none" : "inline-flex";
more.style.visibility = "";
more.textContent = "收起";
return;
}
text.classList.remove("clamped");
text.removeAttribute("data-more-label");
text.style.removeProperty("--detail-text-max");
more.style.display = "none";
more.style.visibility = "";
more.textContent = "更多";
if (mobileDetail) {
renderMobileDetailText(false);
return;
}
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;
let 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");
text.setAttribute("data-more-label", "更多");
more.style.display = mobileDetail ? "none" : "inline-flex";
more.style.visibility = "";
}
function toggleDetailTextMore() {
if (state.episodePreview && state.episodePreview.active) {
toggleInlineEpisodePreviewExpanded();
scheduleUiSnapshotSave();
return;
}
const more = $("detailMoreBtn");
if (!more) return;
more.dataset.expanded = more.dataset.expanded === "1" ? "" : "1";
updateDetailTextClamp();
scheduleUiSnapshotSave();
}
function handleDetailTextClick() {
const sheet = $("detailSheet");
const text = $("detailText");
const more = $("detailMoreBtn");
if (!sheet || !text || !more) return;
const mobileDetail = isMobileDetailSheetActive(sheet);
if (!mobileDetail || !text.getAttribute("data-detail-toggle")) return;
more.dataset.expanded = more.dataset.expanded === "1" ? "" : "1";
updateDetailTextClamp();
scheduleUiSnapshotSave();
}
function useInlineEpisodePreview() {
const sheet = $("detailSheet");
return !!(sheet && sheet.classList.contains("active") && sheet.classList.contains("detail-large") && useLargeDetailLayout());
}
function episodePreviewTitle(ep) {
const number = ep && ep.episode_number ? `第${ep.episode_number}集` : "分集剧情";
const name = String(ep && ep.name || "").trim();
return name ? `${number} ${name}` : number;
}
function episodePreviewMeta(ep) {
return [
ep && ep.air_date ? formatDateCn(ep.air_date) : "",
state.selected && state.selected.title || ""
].filter(Boolean).join(" · ");
}
function renderEpisodePreviewTitleMeta(metaText) {
const root = $("detailTitleMeta");
if (!root) return;
root.replaceChildren();
if (!metaText) return;
const span = document.createElement("span");
span.textContent = metaText;
root.appendChild(span);
}
function applyInlineEpisodePreview(index, options) {
if (!useInlineEpisodePreview()) return false;
const episodes = state.episodeViewer.episodes || [];
const ep = episodes[index];
if (!ep) return false;
const opts = options || {};
const sheet = $("detailSheet");
const text = $("detailText");
const info = text && text.closest(".detail-info");
const anchor = currentEpisodePreviewAnchor();
const anchorTop = anchor ? anchor.getBoundingClientRect().top : 0;
const infoHeight = state.episodePreview.infoHeight || measureEpisodePreviewInfoHeight(info);
state.episodePreview.active = true;
state.episodePreview.expanded = !!opts.expanded;
state.episodePreview.index = index;
state.episodePreview.episodeNumber = String(ep.episode_number || "");
state.episodePreview.lastEpisodeNumber = String(ep.episode_number || "") || state.episodePreview.lastEpisodeNumber || "";
state.episodePreview.infoHeight = infoHeight;
state.episodeViewer.index = index;
state.episodeViewer.episodeNumber = String(ep.episode_number || "");
sheet.classList.add("episode-preview-active");
sheet.classList.toggle("episode-preview-expanded", state.episodePreview.expanded);
if (info && infoHeight) setEpisodePreviewInfoHeight(info, infoHeight);
$("detailTitle").textContent = episodePreviewTitle(ep);
renderEpisodePreviewTitleMeta(episodePreviewMeta(ep));
$("detailMeta").replaceChildren();
const overview = ep.overview || "暂无剧情概要";
text.textContent = overview;
pauseDetailCoverCarousel();
const still = episodeFullStill(ep);
const fallback = state.detailCover.images && state.detailCover.images[state.detailCover.index] || state.selected && state.selected.landscape || "";
setDetailCoverImage(still || fallback, "", { allowPoster: false, keepLoading: false });
preserveEpisodePreviewAnchor(anchor, anchorTop);
syncEpisodePreviewLayout(info);
return true;
}
function measureEpisodePreviewInfoHeight(info) {
if (!info) return 0;
const rect = info.getBoundingClientRect ? info.getBoundingClientRect() : null;
const height = rect && rect.height || info.offsetHeight || 0;
return Math.max(0, Math.round(height));
}
function useLegacyEpisodePreviewLayout() {
const root = document.documentElement;
return !!(root && (root.classList.contains("no-css-functions") || root.classList.contains("legacy-detail-layout")));
}
function setEpisodePreviewInfoHeight(info, height) {
if (!info) return;
if (useLegacyEpisodePreviewLayout()) {
const fixedHeight = Math.max(180, Math.min(240, Number(height || 0) || 220));
info.style.height = `${fixedHeight}px`;
info.style.maxHeight = `${fixedHeight}px`;
return;
}
info.style.setProperty("--episode-preview-info-height", `${height}px`);
}
function updateEpisodePreviewPosition(info) {
if (!info || !info.getBoundingClientRect) return;
const sheet = $("detailSheet");
if (!sheet || !sheet.getBoundingClientRect) {
info.style.transform = "";
return;
}
info.style.transform = "";
const infoRect = info.getBoundingClientRect();
const sheetRect = sheet.getBoundingClientRect ? sheet.getBoundingClientRect() : { top: 0 };
const styles = window.getComputedStyle ? getComputedStyle(sheet) : null;
const topPad = styles ? parseFloat(styles.paddingTop || "0") || 0 : 0;
const defaultTop = sheetRect.top + topPad;
const offset = Math.round(defaultTop - infoRect.top);
info.style.transform = Math.abs(offset) > 1 ? `translateY(${offset}px)` : "";
}
function syncEpisodePreviewLayout(info) {
if (!state.episodePreview || !state.episodePreview.active) return;
updateEpisodePreviewPosition(info);
updateInlineEpisodePreviewClamp();
if (!info) return;
requestAnimationFrame(() => {
if (!state.episodePreview || !state.episodePreview.active || !info.isConnected) return;
updateEpisodePreviewPosition(info);
updateInlineEpisodePreviewClamp();
requestAnimationFrame(() => {
if (!state.episodePreview || !state.episodePreview.active || !info.isConnected) return;
updateEpisodePreviewPosition(info);
updateInlineEpisodePreviewClamp();
});
});
}
function currentEpisodePreviewAnchor() {
const active = document.activeElement;
const rail = $("episodeRail");
return active && rail && rail.contains(active) && active.getBoundingClientRect ? active : null;
}
function lastEpisodeFocusTarget() {
const rail = $("episodeRail");
if (!rail) return null;
const episodeNumber = state.episodePreview && state.episodePreview.lastEpisodeNumber || state.episodeViewer && state.episodeViewer.episodeNumber || "";
const target = episodeNumber ? findByDataset(rail, "episodeNumber", episodeNumber) : null;
return isVisibleFocusable(target) ? target : rail.querySelector(".episode-card.focusable");
}
function preserveEpisodePreviewAnchor(anchor, top) {
const sheet = $("detailSheet");
if (!sheet || !anchor || !anchor.isConnected || !sheet.contains(anchor) || !Number.isFinite(top)) return;
const nextTop = anchor.getBoundingClientRect().top;
const delta = nextTop - top;
if (Math.abs(delta) > .5) sheet.scrollTop += delta;
}
function updateInlineEpisodePreviewClamp() {
const sheet = $("detailSheet");
const text = $("detailText");
const more = $("detailMoreBtn");
if (!sheet || !text || !more) return;
more.dataset.expanded = "";
more.style.display = "inline-flex";
more.style.visibility = "";
if (!state.episodePreview || !state.episodePreview.active) return;
const lineHeight = parseFloat(getComputedStyle(text).lineHeight || "28") || 28;
const textRect = text.getBoundingClientRect ? text.getBoundingClientRect() : null;
const info = text.closest(".detail-info");
const infoRect = info && info.getBoundingClientRect ? info.getBoundingClientRect() : null;
const actions = info && info.nextElementSibling;
const actionsRect = actions && actions.getBoundingClientRect ? actions.getBoundingClientRect() : null;
const seasonBlock = $("seasonBlock");
const seasonRect = seasonBlock && seasonBlock.style.display !== "none" && seasonBlock.getBoundingClientRect ? seasonBlock.getBoundingClientRect() : null;
const lowerBound = seasonRect && seasonRect.top || actionsRect && actionsRect.bottom || infoRect && infoRect.bottom || 0;
const available = textRect && infoRect
? lowerBound - textRect.top - (state.episodePreview && state.episodePreview.expanded ? 18 : 22)
: lineHeight * 5.25;
const fullLines = useLegacyEpisodePreviewLayout() ? 4 : Math.max(3, Math.floor(Math.max(lineHeight * 3, available) / lineHeight));
const maxHeight = Math.round(fullLines * lineHeight);
if (useLegacyEpisodePreviewLayout()) text.style.maxHeight = `${maxHeight}px`;
else text.style.setProperty("--episode-preview-text-max", `${maxHeight}px`);
if (state.episodePreview.expanded) {
text.classList.remove("clamped");
text.removeAttribute("data-more-label");
text.style.removeProperty("--episode-preview-text-max");
text.style.maxHeight = "";
more.textContent = "收起";
return;
}
const shouldClamp = text.scrollHeight > maxHeight + 2 || detailTextShouldClampByLength(text);
text.classList.toggle("clamped", shouldClamp);
if (shouldClamp) text.setAttribute("data-more-label", "更多");
else text.removeAttribute("data-more-label");
more.textContent = shouldClamp ? "更多" : "";
more.style.display = shouldClamp ? "inline-flex" : "none";
}
function toggleInlineEpisodePreviewExpanded() {
if (!state.episodePreview || !state.episodePreview.active) return false;
const index = Number(state.episodePreview.index);
if (!Number.isFinite(index) || index < 0) return false;
return applyInlineEpisodePreview(index, { expanded: !state.episodePreview.expanded });
}
function collapseInlineEpisodePreview(event) {
if (!state.episodePreview || !state.episodePreview.active || !state.episodePreview.expanded) return false;
if (event) {
event.preventDefault();
event.stopPropagation();
if (event.stopImmediatePropagation) event.stopImmediatePropagation();
}
applyInlineEpisodePreview(Number(state.episodePreview.index), { expanded: false });
return true;
}
function clearInlineEpisodePreview(options) {
const opts = options || {};
const sheet = $("detailSheet");
if (sheet) sheet.classList.remove("episode-preview-active", "episode-preview-expanded");
state.episodePreview.active = false;
state.episodePreview.expanded = false;
state.episodePreview.index = -1;
state.episodePreview.episodeNumber = "";
state.episodePreview.infoHeight = 0;
const text = $("detailText");
if (text) {
text.style.removeProperty("--episode-preview-text-max");
text.style.maxHeight = "";
text.classList.remove("clamped");
text.removeAttribute("data-more-label");
const info = text.closest(".detail-info");
if (info) {
info.style.removeProperty("--episode-preview-info-height");
info.style.height = "";
info.style.maxHeight = "";
info.style.transform = "";
}
}
if (!opts.restoreDetail) return;
restoreBaseDetailView();
}
function restoreBaseDetailView() {
const item = state.selected;
if (!item) return;
resetDetailTextClamp();
$("detailTitle").textContent = item.title || "详情";
const detail = state.detail;
setDetailTextContent(detail && detail.overview || item.desc || item.remark || "");
renderDetailMeta(item, detail);
renderDetailTitleMeta(item, detail);
if (detail) renderDetailCoverFromDetail(item, detail);
else setDetailCoverCarousel(item.landscape ? [item.landscape] : [], "", { allowPoster: false });
scheduleDetailTextClamp();
}
function renderDetailCoverFromDetail(item, detail) {
const detailLandscapes = bestDetailBackdrops(detail, 8);
const detailLandscape = detailLandscapes[0] || "";
const detailPoster = imageUrl(detail.poster_path, false) || item.pic;
const posterFallback = detailPoster || item.pic || "";
if (detailLandscapes.length) {
item.landscape = detailLandscape;
item.pic = item.pic || posterFallback;
setDetailCoverCarousel(detailLandscapes, posterFallback, { allowPoster: false });
} else if (posterFallback) {
item.landscape = "";
item.pic = item.pic || posterFallback;
setDetailCoverCarousel([], posterFallback, { allowPoster: true, keepLoading: false });
}
}
function bestDetailBackdrops(detail, limit) {
const candidates = [];
if (detail && detail.backdrop_path) candidates.push({ file_path: detail.backdrop_path, vote_average: 11, vote_count: Number.MAX_SAFE_INTEGER, width: 1280, height: 720 });
(detail && detail.images && detail.images.backdrops || []).forEach((img) => {
if (img && img.file_path) candidates.push(img);
});
return candidates
.filter((img) => img && img.file_path)
.sort((a, b) => backdropScore(b) - backdropScore(a))
.slice(0, limit || 8)
.map((img) => imageUrl(img.file_path, true));
}
function backdropScore(img) {
const vote = Number(img && img.vote_average || 0);
const count = Number(img && img.vote_count || 0);
const width = Number(img && img.width || 0);
const height = Number(img && img.height || 0);
const ratio = width > 0 && height > 0 ? width / height : 16 / 9;
const ratioPenalty = Math.abs(ratio - 16 / 9) * 1.4;
return vote * 1000 + Math.min(count, 1000) * 2 + Math.min(width, 3840) / 20 - ratioPenalty * 100;
}
function renderCast(cast) {
const people = cast.filter((person) => person.profile_path).slice(0, 20);
if (!people.length) return;
$("castBlock").style.display = "";
$("castRail").replaceChildren(...people.map((person) => {
const button = document.createElement("button");
button.className = "person-card focusable";
button.type = "button";
const profile = displayImage(imageUrl(person.profile_path, false), { size: "w185" });
const zhName = escapeHtml(person.name || "");
const enName = person.original_name && person.original_name !== person.name
? escapeHtml(person.original_name) : "";
const role = escapeHtml(person.character || person.known_for_department || "");
button.innerHTML = `
<img alt="${zhName}" ${imageAttrs(profile)}>
<div>
<b>${zhName}</b>
${enName ? `<span class="person-en-name">${enName}</span>` : ""}
${role ? `<span>${role}</span>` : ""}
</div>
`;
button.addEventListener("click", () => loadPersonWorks(person));
return button;
}));
}
function renderSeasons(item, detail) {
const seasons = (detail.seasons || []).filter((season) => season.season_number > 0);
if (!seasons.length) return;
$("seasonBlock").style.display = "";
const activeEl = document.activeElement;
const restoreSeason = activeEl && $("seasonTabs").contains(activeEl) && activeEl.dataset.seasonNumber || "";
const singleSeason = seasons.length === 1;
$("seasonTabs").replaceChildren(...seasons.map((season, index) => {
const button = document.createElement("button");
button.className = "chip" + (singleSeason ? "" : " focusable") + (index === 0 ? " active" : "");
button.type = "button";
button.dataset.seasonNumber = String(season.season_number);
button.textContent = season.name || "第 " + season.season_number + " 季";
if (singleSeason) {
button.tabIndex = -1;
button.setAttribute("aria-hidden", "true");
return button;
}
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 && !singleSeason) 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) {
if (state.episodePreview && state.episodePreview.active) clearInlineEpisodePreview({ restoreDetail: true });
const activeEl = document.activeElement;
const restoreEpisode = activeEl && $("episodeRail").contains(activeEl) && activeEl.dataset.episodeNumber || "";
const keepEpisode = $("imageViewer") && $("imageViewer").classList.contains("active") && $("imageViewer").classList.contains("episode-mode")
? String(state.episodeViewer.episodeNumber || "")
: "";
state.episodeViewer.episodes = Array.isArray(episodes) ? episodes.slice() : [];
state.episodeViewer.effectiveIndexes = episodeEffectiveIndexes(state.episodeViewer.episodes);
if (keepEpisode) {
const index = state.episodeViewer.episodes.findIndex((ep) => String(ep && ep.episode_number || "") === keepEpisode);
state.episodeViewer.index = index;
state.episodeViewer.episodeNumber = index >= 0 ? keepEpisode : "";
} else {
const lastEpisode = state.episodePreview && state.episodePreview.lastEpisodeNumber || "";
const index = lastEpisode ? state.episodeViewer.episodes.findIndex((ep) => String(ep && ep.episode_number || "") === lastEpisode) : -1;
state.episodeViewer.index = index;
state.episodeViewer.episodeNumber = index >= 0 ? lastEpisode : "";
}
if (!episodes.length) {
$("episodeRail").replaceChildren(emptyNode("暂无分集信息"));
return;
}
$("episodeRail").replaceChildren(...episodes.map((ep, index) => {
const stillSource = ep.still_path ? imageUrl(ep.still_path, true) : "";
const still = stillSource ? displayImage(stillSource, { size: "w300" }) : "";
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 ? `<div class="episode-still-wrap"><img class="episode-still" alt="" ${imageAttrs(still)}></div>` : ""}
<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("focus", () => {
applyInlineEpisodePreview(index, { expanded: false });
});
button.addEventListener("blur", () => {
setTimeout(() => {
const rail = $("episodeRail");
if (state.episodePreview && state.episodePreview.active && rail && !rail.contains(document.activeElement)) {
clearInlineEpisodePreview({ restoreDetail: true });
}
}, 0);
});
button.addEventListener("click", (event) => {
if (useInlineEpisodePreview()) {
event.preventDefault();
event.stopPropagation();
if (!state.episodePreview.active || Number(state.episodePreview.index) !== index) {
applyInlineEpisodePreview(index, { expanded: false });
} else {
toggleInlineEpisodePreviewExpanded();
}
return;
}
openEpisodeViewer(index);
});
return button;
}));
if (restoreEpisode) requestAnimationFrame(() => focusRemoteTarget(findByDataset($("episodeRail"), "episodeNumber", restoreEpisode) || $("episodeRail").querySelector(".episode-card")));
updatePostPanFocusState();
}
function isEffectiveEpisode(ep, maxAiredEpisode) {
if (!ep) return false;
const airDate = String(ep.air_date || "").trim();
if (airDate) {
const airMs = dateOnlyMs(airDate);
const todayMs = dateOnlyMs(today());
if (airMs) return airMs <= todayMs;
}
if (maxAiredEpisode > 0) return Number(ep.episode_number || 0) > 0 && Number(ep.episode_number || 0) <= maxAiredEpisode;
return !!(ep.still_path || String(ep.overview || "").trim());
}
function episodeEffectiveIndexes(episodes) {
const todayMs = dateOnlyMs(today());
const maxAiredEpisode = (Array.isArray(episodes) ? episodes : []).reduce((max, ep) => {
const airMs = dateOnlyMs(ep && ep.air_date);
if (!airMs || airMs > todayMs) return max;
return Math.max(max, Number(ep && ep.episode_number || 0));
}, 0);
const indexes = [];
(Array.isArray(episodes) ? episodes : []).forEach((ep, index) => {
if (isEffectiveEpisode(ep, maxAiredEpisode)) indexes.push(index);
});
return indexes.length ? indexes : (episodes || []).map((_, index) => index);
}
function episodeFullStill(ep) {
const stillSource = ep && ep.still_path ? imageUrl(ep.still_path, true) : "";
return stillSource ? displayImage(stillSource, { size: "w780" }) : "";
}
function episodeViewerPayload(ep) {
const still = episodeFullStill(ep);
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 || "暂无剧情概要";
return { title, meta, overview, still };
}
function openEpisodeViewer(index) {
const episodes = state.episodeViewer.episodes || [];
const ep = episodes[index];
if (!ep) return;
state.episodeViewer.index = index;
state.episodeViewer.episodeNumber = String(ep.episode_number || "");
const episode = episodeViewerPayload(ep);
openImage(episode.still || "", { episode });
}
function currentEpisodeViewerIndex() {
const episodes = state.episodeViewer.episodes || [];
const episodeNumber = String(state.episodeViewer.episodeNumber || "");
if (episodeNumber) {
const index = episodes.findIndex((ep) => String(ep && ep.episode_number || "") === episodeNumber);
if (index >= 0) return index;
}
const index = Number(state.episodeViewer.index);
return Number.isFinite(index) && index >= 0 ? index : 0;
}
function renderEpisodeViewerContent(episode) {
const episodeBox = $("episodeViewer");
if (!episodeBox) return;
const still = episode && episode.still || "";
episodeBox.style.display = "grid";
episodeBox.classList.toggle("no-still", !still);
episodeBox.innerHTML = `
${still ? `<img alt="" ${imageAttrs(still)}>` : ""}
<div>
<h3>${escapeHtml(episode && episode.title || "分集剧情")}</h3>
${episode && episode.meta ? `<span>${escapeHtml(episode.meta)}</span>` : ""}
</div>
<p>${escapeHtml(episode && episode.overview || "暂无剧情概要")}</p>
`;
}
function switchEpisodeViewer(delta) {
const viewer = $("imageViewer");
if (!viewer || !viewer.classList.contains("active") || !viewer.classList.contains("episode-mode")) return false;
const episodes = state.episodeViewer.episodes || [];
if (episodes.length <= 1) return false;
const current = currentEpisodeViewerIndex();
const effective = (state.episodeViewer.effectiveIndexes || []).filter((index) => index >= 0 && index < episodes.length);
const indexes = effective.length ? effective : episodes.map((_, index) => index);
if (indexes.length <= 1) return false;
let currentPos = indexes.indexOf(current);
if (currentPos < 0) {
currentPos = delta > 0
? indexes.findIndex((index) => index > current) - 1
: indexes.findIndex((index) => index >= current);
if (currentPos < 0) currentPos = delta > 0 ? indexes.length - 1 : 0;
}
const next = indexes[(currentPos + delta + indexes.length) % indexes.length];
state.episodeViewer.index = next;
state.episodeViewer.episodeNumber = String(episodes[next].episode_number || "");
renderEpisodeViewerContent(episodeViewerPayload(episodes[next]));
const content = $("imageContent");
if (content) content.scrollTop = 0;
const currentCard = findByDataset($("episodeRail"), "episodeNumber", String(episodes[next].episode_number || ""));
if (currentCard) state.focusReturnEl = currentCard;
scheduleUiSnapshotSave();
return true;
}
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");
clearInlineEpisodePreview({ restoreDetail: false });
document.body.classList.remove("detail-active");
document.body.classList.remove("episode-active");
document.documentElement.classList.remove("detail-immersive");
const heroBgEl = $("detailHeroBg");
const blurEl = $("detailBlurLayer");
if (heroBgEl) { heroBgEl.classList.remove("active", "closing"); }
if (blurEl) blurEl.classList.remove("active");
sheet.classList.remove("active");
sheet.classList.remove("detail-large");
sheet.style.display = "";
sheet.setAttribute("aria-hidden", "true");
setTimeout(() => { if (heroBgEl) heroBgEl.style.backgroundImage = "none"; }, 100);
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");
renderEpisodeViewerContent(Object.assign({}, episode, { still: episode.still || url || "" }));
} else {
document.body.classList.remove("episode-active");
$("imageViewer").classList.remove("episode-mode");
episodeBox.style.display = "none";
episodeBox.classList.remove("no-still");
episodeBox.replaceChildren();
image.style.display = "";
image.src = url;
}
ensureSheetViewport($("imageViewer"), "grid");
$("imageViewer").classList.add("active");
$("imageViewer").setAttribute("aria-hidden", "false");
if (episode) $("mobileImageBackBtn").classList.remove("is-hidden");
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").classList.remove("no-still");
$("episodeViewer").replaceChildren();
state.episodeViewer.swipe = null;
$("mobileImageBackBtn").classList.remove("is-hidden");
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"));
}
}
async function openSettingHome() {
try {
const api = sdk();
if (window.fm && api.openSetting) {
await api.openSetting();
toast("已打开设置");
} else if (api.openSetting) {
await api.openSetting();
} else if (window.fongmi && window.fongmi.app && window.fongmi.app.openSetting) {
await window.fongmi.app.openSetting();
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 savedTarget = saved.targetId && $(saved.targetId);
const target = isVisibleFocusable(savedTarget) ? savedTarget : detailPrimaryActionButton() || $("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);
}
// 详情页背景:随滚动渐变高斯模糊 + 压暗(参考美化最终.html,排除 TV 端)
// ease 0→1:0=顶部正常,1=完全模糊压暗
function setDetailHeroBlur(blurPx, ease) {
const heroBg = $("detailHeroBg");
if (!heroBg) return;
if (isTvMode()) { heroBg.style.filter = ""; return; } // TV 端交还给 CSS,不做模糊
const e = (ease !== undefined) ? Math.min(1, Math.max(0, ease)) : Math.min(1, (blurPx || 0) / 20);
const brightness = (0.78 - e * 0.46).toFixed(3); // 顶部 0.78 → 压暗到 0.32
heroBg.style.filter = `brightness(${brightness}) saturate(1.05) blur(${(blurPx || 0).toFixed(1)}px)`;
}
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("已生成新身份");
});
(function setupStatusToggle() {
const toggle = $("connectionToggle");
if (!toggle) return;
const LONG_MS = 500;
let timer = 0;
let fired = false;
let suppressClickUntil = 0;
function startHold() {
fired = false;
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
timer = 0;
fired = true;
suppressClickUntil = Date.now() + 700;
try { closeConnectionPanel(); } catch (e) {}
openSettingHome();
}, LONG_MS);
}
function cancelHold() {
if (timer) { clearTimeout(timer); timer = 0; }
}
toggle.addEventListener("pointerdown", startHold);
toggle.addEventListener("pointerup", cancelHold);
toggle.addEventListener("pointerleave", cancelHold);
toggle.addEventListener("pointercancel", cancelHold);
toggle.addEventListener("click", (event) => {
if (fired || Date.now() < suppressClickUntil) {
event.preventDefault();
event.stopPropagation();
fired = false;
return;
}
toggleConnectionPanel(event);
});
toggle.addEventListener("keydown", (event) => {
const key = normalizeRemoteKey(event);
if (key !== "Enter" && event.key !== " ") return;
event.preventDefault();
if (event.repeat) return;
startHold();
});
toggle.addEventListener("keyup", (event) => {
const key = normalizeRemoteKey(event);
if (key !== "Enter" && event.key !== " ") return;
event.preventDefault();
cancelHold();
if (fired) { fired = false; 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 || "保存失败"));
});
document.querySelectorAll("[data-mobile-detail-style]").forEach((button) => {
button.addEventListener("click", () => {
setMobileDetailStyle(button.dataset.mobileDetailStyle).catch(() => {});
});
button.addEventListener("keydown", (event) => {
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
event.preventDefault();
const next = event.key === "ArrowRight" ? "translucent" : "immersive";
setMobileDetailStyle(next).then(() => {
const target = document.querySelector(`[data-mobile-detail-style="${next}"]`);
if (target) focusRemoteTarget(target);
}).catch(() => {});
});
});
const panConfig = document.querySelector(".pan-config");
if (panConfig) {
panConfig.addEventListener("input", markPanConfigDirty);
panConfig.addEventListener("change", markPanConfigDirty);
}
$("closeImageBtn").addEventListener("click", () => closeImage(false));
$("mobileImageBackBtn").addEventListener("click", () => closeImage(false));
$("imageViewer").addEventListener("click", (event) => {
if (event.target === $("imageViewer")) closeImage(false);
});
(function () {
const sheet = $("detailSheet");
if (sheet) {
let rafId = 0;
let blurStart = -1;
let blurEnd = -1;
const measureBlurRange = () => {
const spacer = sheet.querySelector(".detail-spacer");
const spacerH = spacer ? spacer.offsetHeight : window.innerHeight * 0.42;
blurStart = Math.round(spacerH * 0.55); // 开始渐入模糊
blurEnd = Math.round(spacerH * 0.90); // 完全模糊
};
sheet.addEventListener("scroll", () => {
updateMobileDetailBackButton();
scheduleUiSnapshotSave();
if (isTvMode()) return; // 排除 TV 端
if (rafId) return;
rafId = requestAnimationFrame(() => {
rafId = 0;
if (blurStart < 0) measureBlurRange();
const scrollTop = sheet.scrollTop || 0;
const range = Math.max(1, blurEnd - blurStart);
const pct = Math.min(1, Math.max(0, (scrollTop - blurStart) / range));
// easeInOut 让两端过渡更柔和
const ease = pct < 0.5 ? 2 * pct * pct : 1 - Math.pow(-2 * pct + 2, 2) / 2;
const blurPx = parseFloat((ease * 20).toFixed(1)); // 最大 20px
setDetailHeroBlur(blurPx, ease);
});
}, { passive: true });
}
})();
bindDetailCoverSwipe();
bindEpisodeViewerSwipe();
$("detailMoreBtn").addEventListener("click", toggleDetailTextMore);
$("detailText").addEventListener("click", handleDetailTextClick);
$("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", () => {
// readOnly 已移除,直接确保可编辑
if ($("searchInput").readOnly) {
$("searchInput").readOnly = false;
}
});
// 移除 blur 时设为 readonly,避免手机第二次点击无法输入
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);
$("homeSettingBtn").addEventListener("click", openSettingHome);
(function () {
const clearBtn = $("clearSearchBtn");
if (clearBtn) {
// 点清空时阻止按钮抢走输入框焦点:键盘全程不动,避免收起后又回弹
const keepSearchFocus = (event) => {
if (document.activeElement === $("searchInput")) event.preventDefault();
};
clearBtn.addEventListener("pointerdown", keepSearchFocus);
clearBtn.addEventListener("mousedown", keepSearchFocus);
clearBtn.addEventListener("click", clearSearchResults);
}
})();
if ($("closeDetailBtn")) $("closeDetailBtn").addEventListener("click", () => closeDetail(false));
// mobileDetailBackBtn 已移除
$("detailContinueBtn").addEventListener("click", () => {
openDetailContinueHistory();
});
$("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 bindEpisodeViewerSwipe() {
const content = $("imageContent");
if (!content) return;
content.addEventListener("touchstart", (event) => {
const viewer = $("imageViewer");
if (!viewer || !viewer.classList.contains("episode-mode") || !viewer.classList.contains("active")) return;
if ((state.episodeViewer.episodes || []).length <= 1) return;
const touch = event.touches && event.touches[0];
if (!touch) return;
state.episodeViewer.swipe = { x: touch.clientX, y: touch.clientY, at: Date.now(), moved: false };
}, { passive: true });
content.addEventListener("touchmove", (event) => {
const swipe = state.episodeViewer.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) > 16 && Math.abs(dx) > Math.abs(dy) * 1.35) {
swipe.moved = true;
event.preventDefault();
}
}, { passive: false });
content.addEventListener("touchend", (event) => {
const swipe = state.episodeViewer.swipe;
state.episodeViewer.swipe = null;
if (!swipe || !swipe.moved) 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) < 46 || Math.abs(dx) < Math.abs(dy) * 1.35 || Date.now() - swipe.at > 1000) return;
event.preventDefault();
event.stopPropagation();
switchEpisodeViewer(dx < 0 ? 1 : -1);
}, { passive: false });
content.addEventListener("touchcancel", () => {
state.episodeViewer.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 (collapseInlineEpisodePreview(event)) 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 (handleEpisodeViewerDirectionalKey(key, event)) 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;
// 软键盘 Backspace 是删字:仅移动端/浏览器,正在编辑可输入文本框时直接放行,
// 不当作“返回/清空”,避免清空搜索框、收起键盘后又回弹。TV 端保持原有逻辑不变。
if (key === "Backspace" && !isTvMode()) {
const el = document.activeElement;
if (el && isTextEditingElement(el) && !el.readOnly) return;
}
if (state.blocked.selecting) return;
if (collapseInlineEpisodePreview(event)) 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();
if (!active.dataset || !active.dataset.mobileDetailStyle) 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 handleEpisodeViewerDirectionalKey(key, event) {
if (key !== "ArrowLeft" && key !== "ArrowRight") return false;
const viewer = $("imageViewer");
if (!viewer || !viewer.classList.contains("active") || !viewer.classList.contains("episode-mode")) return false;
event.preventDefault();
event.stopPropagation();
if (event.stopImmediatePropagation) event.stopImmediatePropagation();
switchEpisodeViewer(key === "ArrowRight" ? 1 : -1);
focusRemoteTarget($("imageContent"));
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 dock = $("connectionToggle");
const active = document.activeElement;
let target = null;
if (active === dock && key === "ArrowRight") target = input;
else if (active === input && key === "ArrowLeft") target = dock;
else if (active === input && key === "ArrowRight") target = submit;
else if (active === submit && key === "ArrowLeft") target = input;
else if ((active === input || active === submit || active === dock) && key === "ArrowDown") target = currentHomeFocus();
if (!target) return false;
event.preventDefault();
event.stopPropagation();
if (target === input && input.readOnly) hideSearchSuggest();
focusRemoteTarget(target);
return true;
}
function isSearchSuggestOpen() {
const panel = $("suggestPanel");
return !!(panel && panel.classList.contains("open") && panel.querySelector(".suggest-item"));
}
function searchSuggestItems() {
const panel = $("suggestPanel");
return panel ? Array.from(panel.querySelectorAll(".suggest-item")).filter(isVisibleFocusable) : [];
}
function firstSearchSuggestItem() {
return searchSuggestItems()[0] || null;
}
function focusSearchSuggestTarget(target) {
if (!target) return;
try {
target.focus({ preventScroll: true });
} catch (e) {
target.focus();
}
keepSearchSuggestItemVisible(target);
}
function keepSearchSuggestItemVisible(target) {
const panel = $("suggestPanel");
if (!panel || !target || !panel.contains(target)) return;
const itemRect = target.getBoundingClientRect();
const panelRect = panel.getBoundingClientRect();
const topDelta = itemRect.top - panelRect.top;
const bottomDelta = itemRect.bottom - panelRect.bottom;
if (topDelta < 0) panel.scrollTop += topDelta;
else if (bottomDelta > 0) panel.scrollTop += bottomDelta;
}
function handleConnectionPanelBackKey(event) {
if (!isConnectionPanelOpen()) return false;
const active = document.activeElement;
if (isPanelTextField(active) && !active.readOnly) {
if (normalizeRemoteKey(event) === "Backspace") return false;
event.preventDefault();
event.stopPropagation();
if (event.stopImmediatePropagation) event.stopImmediatePropagation();
disablePanelTextEditing(active);
focusRemoteTarget(active);
return true;
}
event.preventDefault();
event.stopPropagation();
if (event.stopImmediatePropagation) event.stopImmediatePropagation();
closeConnectionPanel();
const toggle = $("connectionToggle");
if (toggle) requestAnimationFrame(() => focusRemoteTarget(toggle));
return true;
}
function handleConnectionPanelDirectionalKey(key, event) {
if (!isConnectionPanelOpen() || !["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(key)) return false;
const dock = $("connectionDock");
const body = $("connectionBody");
const toggle = $("connectionToggle");
if (!dock || !body) return false;
const active = document.activeElement;
const bodyItems = connectionPanelItems();
const inDock = active && dock.contains(active);
let target = null;
if (!inDock || active === toggle) {
target = key === "ArrowDown" ? bodyItems[0] || toggle : toggle || bodyItems[0];
} else if (body.contains(active)) {
target = nearestFocusableFromList(key, active, bodyItems);
if (!target && key === "ArrowUp") target = toggle || bodyItems[0];
if (!target) target = active;
} else {
target = bodyItems[0] || toggle;
}
event.preventDefault();
event.stopPropagation();
if (target) focusRemoteTarget(target);
return true;
}
function restoreConnectionPanelFocus() {
if (!isConnectionPanelOpen()) return false;
const dock = $("connectionDock");
const active = document.activeElement;
if (active && dock && dock.contains(active) && isVisibleFocusable(active)) return false;
const target = $("connectionToggle") || connectionPanelItems()[0];
if (!target) return false;
focusRemoteTarget(target);
return true;
}
function isConnectionPanelOpen() {
const dock = $("connectionDock");
const body = $("connectionBody");
return !!(dock && body && dock.classList.contains("open") && body.classList.contains("open"));
}
function connectionPanelItems() {
const body = $("connectionBody");
return body ? Array.from(body.querySelectorAll(".focusable,button,input,textarea")).filter(isVisibleFocusable) : [];
}
function nearestFocusableFromList(key, current, list) {
const items = (list || []).filter((item) => item && item !== current && isVisibleFocusable(item));
if (!current || !items.length) return null;
const from = center(current.getBoundingClientRect());
const vertical = key === "ArrowUp" || key === "ArrowDown";
const forward = key === "ArrowRight" || key === "ArrowDown";
let best = null;
let bestScore = Infinity;
for (const el of items) {
const to = center(el.getBoundingClientRect());
const main = vertical ? to.y - from.y : to.x - from.x;
const cross = vertical ? Math.abs(to.x - from.x) : Math.abs(to.y - from.y);
if (forward ? main <= 4 : main >= -4) continue;
const score = Math.abs(main) * 1.25 + cross * 1.9;
if (score < bestScore) {
best = el;
bestScore = score;
}
}
return best;
}
function focusPanTabFromResults() {
const tabs = $("panTabs");
if (!tabs) return false;
const target = findByDataset(tabs, "panType", state.pan.activeType) || tabs.querySelector(".chip.active") || tabs.querySelector(".chip");
if (!target) return false;
state.pan.focusMode = "tabs";
focusPanTarget(target);
return true;
}
function handlePanDirectionalKey(key, event) {
const tabs = $("panTabs");
const list = $("panResultList");
const active = document.activeElement;
if (!tabs || !list || !active) return false;
const inTabs = tabs.contains(active);
const inResults = list.contains(active);
if (!inTabs && !inResults) {
if ((active === $("detailContinueBtn") || active === $("panSearchBtn") || active === $("detailSearchBtn")) && key === "ArrowDown" && isPanSearchActive()) {
const target = tabs.querySelector(".chip.active") || tabs.querySelector(".chip") || list.querySelector(".pan-result-item");
if (target) {
event.preventDefault();
state.pan.focusMode = tabs.contains(target) ? "tabs" : "results";
focusPanTarget(target, { keepBlockPosition: true });
return true;
}
}
if (isPanSearchActive() && $("detailSheet") && $("detailSheet").contains(active)) {
const panRect = $("panSearchBlock").getBoundingClientRect();
const activeRect = active.getBoundingClientRect ? active.getBoundingClientRect() : null;
if (key === "ArrowDown" && activeRect && activeRect.bottom <= panRect.bottom + 8) {
event.preventDefault();
const target = tabs.querySelector(".chip.active") || tabs.querySelector(".chip") || list.querySelector(".pan-result-item");
if (target) {
state.pan.focusMode = tabs.contains(target) ? "tabs" : "results";
focusPanTarget(target, { keepBlockPosition: true });
} else {
centerPanSearchBlock();
}
return true;
}
}
state.pan.focusMode = "";
return false;
}
let target = null;
if (inTabs && key === "ArrowDown") target = panResultByColumn(active) || list.querySelector(".pan-result-item");
else if (inResults && key === "ArrowUp" && isFirstVisiblePanResult(active)) target = findByDataset(tabs, "panType", state.pan.activeType) || tabs.querySelector(".chip.active") || tabs.querySelector(".chip");
else if (inTabs && (key === "ArrowLeft" || key === "ArrowRight")) target = siblingFocusable(tabs, active, key === "ArrowRight" ? 1 : -1);
else if (inResults && (key === "ArrowUp" || key === "ArrowDown")) target = siblingFocusable(list, active, key === "ArrowDown" ? 1 : -1);
if (!target) {
if (inTabs && key === "ArrowDown" || inResults && key === "ArrowDown") {
event.preventDefault();
centerPanSearchBlock();
return true;
}
return false;
}
event.preventDefault();
if (list.contains(target)) {
state.pan.focusKey = target.dataset.panKey || state.pan.focusKey;
state.pan.focusMode = "results";
focusPanTarget(target, { keepBlockPosition: true });
} else {
state.pan.focusMode = "tabs";
const panType = getPanTypeFromElement(target);
if (panType && panType !== state.pan.activeType) selectPanType(panType);
focusPanTarget(target, { keepBlockPosition: true });
}
return true;
}
function isPanFocusTarget(target) {
return !!(target && (($("panTabs") && $("panTabs").contains(target)) || ($("panResultList") && $("panResultList").contains(target))));
}
function panResultByColumn(tab) {
const list = $("panResultList");
const items = list ? Array.from(list.querySelectorAll(".pan-result-item")).filter(isVisibleFocusable) : [];
if (!items.length) return null;
const from = center(tab.getBoundingClientRect());
let best = null;
let bestScore = Infinity;
for (const item of items.slice(0, 5)) {
const rect = item.getBoundingClientRect();
const to = center(rect);
if (to.y < from.y - 4) continue;
const score = Math.abs(to.x - from.x) * 1.15 + Math.max(0, to.y - from.y) * .35;
if (score < bestScore) {
best = item;
bestScore = score;
}
}
return best || items[0];
}
function handleDetailDirectionalKey(key, event) {
const sheet = $("detailSheet");
const active = document.activeElement;
if (!sheet || !sheet.classList.contains("active") || !active || !sheet.contains(active)) return false;
if (isPanSearchActive() && isPanFocusTarget(active)) return false;
if (active === $("closeDetailBtn") && useLargeDetailLayout()) {
event.preventDefault();
event.stopPropagation();
focusRemoteTarget(detailPrimaryActionButton() || $("panSearchBtn"));
return true;
}
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 = "";
if (prepareEpisodeFocusFromBelow(currentBlock, target)) {
requestAnimationFrame(() => focusEpisodeReturnTarget(target));
return true;
}
focusRemoteTarget(target);
return true;
}
function prepareEpisodeFocusFromBelow(currentBlock, target) {
const sheet = $("detailSheet");
const rail = $("episodeRail");
if (!sheet || !rail || !target || !rail.contains(target)) return false;
if (!currentBlock || ["seasonBlock", "detailActions", "detailInfo"].includes(currentBlock.id)) return false;
positionEpisodeRailAtViewportBottom(target);
return true;
}
function positionEpisodeRailAtViewportBottom(target) {
const sheet = $("detailSheet");
if (!sheet || !target || !target.getBoundingClientRect) return;
const sheetRect = sheet.getBoundingClientRect ? sheet.getBoundingClientRect() : { top: 0, bottom: window.innerHeight || 0 };
const targetRect = target.getBoundingClientRect();
const height = sheet.clientHeight || window.innerHeight || 0;
const bottomGap = Math.max(34, Math.round(height * .08));
const desiredBottom = sheetRect.top + height - bottomGap;
const delta = targetRect.bottom - desiredBottom;
if (Math.abs(delta) < 2) return;
sheet.scrollTop += delta;
}
function focusEpisodeReturnTarget(target) {
if (!target) return;
try {
target.focus({ preventScroll: true });
} catch (e) {
target.focus();
}
keepEpisodeCardHorizontallyVisible(target);
}
function keepEpisodeCardHorizontallyVisible(target) {
const rail = $("episodeRail");
if (!rail || !target || !rail.contains(target) || !target.getBoundingClientRect) return;
const itemRect = target.getBoundingClientRect();
const railRect = rail.getBoundingClientRect();
const leftDelta = itemRect.left - railRect.left - 8;
const rightDelta = itemRect.right - railRect.right + 8;
if (leftDelta < 0) rail.scrollLeft += leftDelta;
else if (rightDelta > 0) rail.scrollLeft += rightDelta;
}
function detailFocusBlock(el) {
if (el === $("closeDetailBtn")) return useLargeDetailLayout() ? null : { id: "closeDetailBtn", root: $("closeDetailBtn") };
if (el === $("detailMoreBtn")) return { id: "detailInfo", root: el.closest(".detail-info") };
if (el === $("detailContinueBtn") || el === $("detailSearchBtn") || el === $("panSearchBtn")) return { id: "detailActions", root: el.closest(".actions") };
const ids = ["panSearchBlock", "seasonBlock", "castBlock", "personWorkBlock", "recommendBlock"];
for (const id of ids) {
const root = $(id);
if (root && root.contains(el)) return { id, root };
}
return null;
}
function detailBlockOrder() {
const order = [];
if (!useLargeDetailLayout() && 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 = block.id === "seasonBlock" && delta > 0
? lastEpisodeFocusTarget() || detailClosestInBlock(block.root, active, delta)
: detailClosestInBlock(block.root, active, delta);
if (target) return target;
}
return null;
}
function detailHorizontalTarget(currentBlock, active, delta) {
const episodeTarget = detailEpisodeHorizontalTarget(active, delta);
if (episodeTarget) return episodeTarget;
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 detailEpisodeHorizontalTarget(active, delta) {
const rail = $("episodeRail");
if (!rail || !active || !rail.contains(active)) return null;
const episodes = state.episodeViewer.episodes || [];
if (episodes.length <= 1) return null;
const episodeNumber = active.dataset && active.dataset.episodeNumber || state.episodeViewer.episodeNumber || "";
const current = episodeNumber ? episodes.findIndex((ep) => String(ep && ep.episode_number || "") === episodeNumber) : Number(state.episodeViewer.index || 0);
const effective = (state.episodeViewer.effectiveIndexes || []).filter((index) => index >= 0 && index < episodes.length);
const indexes = effective.length ? effective : episodes.map((_, index) => index);
if (indexes.length <= 1) return null;
let currentPos = indexes.indexOf(current);
if (currentPos < 0) {
currentPos = delta > 0 ? -1 : 0;
}
const nextPos = (currentPos + (delta > 0 ? 1 : -1) + indexes.length) % indexes.length;
const next = episodes[indexes[nextPos]];
const target = next ? findByDataset(rail, "episodeNumber", String(next.episode_number || "")) : null;
return isVisibleFocusable(target) ? target : null;
}
function detailSeasonVerticalTarget(active, delta) {
const tabs = $("seasonTabs");
const rail = $("episodeRail");
if (tabs && tabs.contains(active)) {
if (delta < 0) return null;
return lastEpisodeFocusTarget() || detailClosestInBlock(rail, active, delta);
}
if (rail && rail.contains(active)) {
if (delta > 0) return null;
if (state.episodePreview && state.episodePreview.active) {
clearInlineEpisodePreview({ restoreDetail: true });
ensureDefaultDetailHeaderVisible();
}
const tabTarget = detailClosestInBlock(tabs, active, delta);
if (tabTarget) return tabTarget;
return detailPrimaryActionButton() || $("panSearchBtn");
}
return null;
}
function ensureDefaultDetailHeaderVisible() {
const sheet = $("detailSheet");
const info = $("detailTitle") && $("detailTitle").closest(".detail-info");
const text = $("detailText");
if (!sheet || !info || !text || !sheet.classList.contains("active")) return;
const lineHeight = parseFloat(getComputedStyle(text).lineHeight || "28") || 28;
const maxHeight = Math.round(lineHeight * 3.2);
text.style.setProperty("--detail-text-max", `${maxHeight}px`);
text.classList.add("clamped");
if ($("detailMoreBtn")) {
$("detailMoreBtn").dataset.expanded = "";
$("detailMoreBtn").style.display = "inline-flex";
$("detailMoreBtn").textContent = "更多";
}
requestAnimationFrame(() => {
const sheetRect = sheet.getBoundingClientRect ? sheet.getBoundingClientRect() : { top: 0 };
const infoRect = info.getBoundingClientRect ? info.getBoundingClientRect() : null;
if (!infoRect) return;
const desiredTop = sheetRect.top + 42;
if (infoRect.top < desiredTop || infoRect.top > desiredTop + 24) {
sheet.scrollTop += infoRect.top - desiredTop;
}
});
}
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.episodePreview && state.episodePreview.active) clearInlineEpisodePreview({ restoreDetail: false });
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);
}
function updateMobileDetailBackButton() {
const sheet = $("detailSheet");
const button = $("mobileDetailBackBtn");
if (!sheet || !button) return;
const hide = document.documentElement.classList.contains("native-mobile-app") && sheet.classList.contains("active") && !sheet.classList.contains("detail-large") && sheet.scrollTop > 120;
button.classList.toggle("is-hidden", hide);
}
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", () => {
syncClientModeClasses();
Promise.all([
initUiPrefs({ timeout: 0 }),
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();
await initUiPrefs({ timeout: 0 });
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>