13736 lines
528 KiB
HTML
13736 lines
528 KiB
HTML
<!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-cover,hero-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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||
}
|
||
|
||
function escapeAttr(value) {
|
||
return escapeHtml(value).replace(/`/g, "`");
|
||
}
|
||
|
||
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> |